[iOS] 图文讲解原生二维码有效扫描区域 rectOfInterest

在使用原生的 AVFoundation 框架实现二维码扫描的时候, 需要注意一下两个方面:

    1. 启动相机的卡顿问题;
    1. 有效扫描区域的问题; 本文主要针对这两个问题进行讲解.

1. 启动扫描卡顿

Push到二维码扫描页时, 一般在初始化扫描视图的时候就开始启动session:

[self.session startRunning]

但是这样会有一个问题, 就是点击扫描按钮的时候, 按钮会1秒多的卡顿, 然后才会Push到扫描页;
针对这个问题, 有的人是在Push到扫描页的时候, 才去启动 session, 但是这样会有1 -- 2秒的等待时间, 才会出现正常的扫描画面;

针对此问题, 解决的方式很简单, 依然在初始化的时候启动 session, 但是, 需要异步去启动:

[self.activity startAnimating];
    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    dispatch_async(queue, ^{
        
        if (self.session.isRunning == NO) {
            NSLog(@"startRunning");
            [self.session startRunning];
        }
        
        dispatch_async(dispatch_get_main_queue(), ^{
            
            [self.activity stopAnimating];
        });
    });

这样, 在push 的过程中就在准备session, 可以很大程度上缩短等待时间, 甚至, 在push 到扫描页的时候, 几乎看不到等待时间.

需要注意

如果使用了 属性观察者模式, 其方法是异步执行的, 需要返回到主线程, 例如这里我监听了属性 running:

[self.session addObserver:self forKeyPath:@"running" options:NSKeyValueObservingOptionNew context:nil];

根据这个来开始/停止扫描线的动画, 这时候, 就需要回到主线程执行:

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context{
    
    dispatch_async(dispatch_get_main_queue(), ^{
        
        if ([object isKindOfClass:[AVCaptureSession class]]) {
            
            if ([keyPath isEqualToString:@"running"]) {
                BOOL isRunning = ((AVCaptureSession *)object).isRunning;
                if (isRunning) {
                    
                    [self startAnimate];
                }else{
                    [self stopAnimate];
                }
            }
            
        }
    });
}

2. AVCaptureMetadataOutput 有效扫描区域 rectOfInterest

首先, 需要知道 rectOfInterest 所在的坐标系, 其坐标原点是图像的左上角, 注意, 这里是图像的左上角, 不是设备的左上角, 其值介于 0 -- 1, 如果设置为 CGRectMake(0, 0, 1, 1) 将是全屏幕扫描; 所以需要将我们的扫描框的坐标(相对于设备), 转换为 0--1 之间的值(相对于图像).

2.1. 影响因素

要想准确计算其区域, 就需要知道影响因素, rectOfInterest 的影响因素有以下两个:

    1. AVCaptureSessionsessionPreset 属性
    1. AVCaptureVideoPreviewLayervideoGravity 属性

前者为图像的分辨率, 不同的值生成不同分辨率的图像;
后者为预览时的图像填充模式, 类似于 UIViewcontentMode 属性;

sessionPreset

// 完整的图像分辨率输出,不支持音频
NSString *const AVCaptureSessionPresetPhoto; 
// 最高分辨率,根据设备系统自动选择最高分辨率
NSString *const AVCaptureSessionPresetHigh;
// 中等分辨率,根据设备系统自动选择中等分辨率
NSString *const AVCaptureSessionPresetMedium;
// 最低分辨率,根据设备系统自动选择最低分辨率
NSString *const AVCaptureSessionPresetLow;
// 以352x288分辨率输出
NSString *const AVCaptureSessionPreset352x288;
// 以640x480分辨率输出
NSString *const AVCaptureSessionPreset640x480;
// 以1280x720分辨率输出
NSString *const AVCaptureSessionPreset1280x720;
// 以1920x1080分辨率输出
NSString *const AVCaptureSessionPreset1920x1080;
// 以960x540分辨率输出
NSString *const AVCaptureSessionPresetiFrame960x540;
// 以1280x720分辨率输出
NSString *const AVCaptureSessionPresetiFrame1280x720;
// 不去控制音频与视频输出设置,而是通过已连接的捕获设备的 activeFormat 来反过来控制 capture session 的输出质量等级
NSString *const AVCaptureSessionPresetInputPriority;

videoGravity

// 保持原始比例,自适应最小的bounds,不足的会有留白;类似于UIView的contentMode属性的UIViewContentModeScaleAspectFit.
AVLayerVideoGravityResizeAspect;

// 保持原始比例,填充整个bounds,多余的会被剪掉,类似于UIView的contentMode属性的UIViewContentModeScaleAspectFill.
AVLayerVideoGravityResizeAspectFill;

// 拉伸直到填充整个bounds,类似于UIView的contentMode属性的UIViewContentModeScaleToFill.
AVLayerVideoGravityResize

2.2. 转换关系

其实, 我们主要需要做的就是, 将我们屏幕中的扫描区域, 转换到图像中对应的位置上;
首先需要知道图像的方向, AVFoundation 捕获的图像是横着的, 且方向朝右;
其次, 需要知道我们在屏幕中看到的图像, 和实际的图像是镜像关系;

这里以图像完全填充屏幕(即 videoGravity 值为 AVLayerVideoGravityResize), 假设, 我们设置的扫描区域为(30, 80, 100, 80);

另外, 转换的关系与设备方向也有关系, 下面就是图像完全填充屏幕时的不同设备方向的转换关系

2.2.1. 竖屏, Home 键在下 (UIInterfaceOrientationPortrait)

此时设备方向为朝上(竖屏, Home键在下), 扫描区域在设备屏幕中的位置为:

首先, 将图像进行左右镜像, 中间图所示
接着顺时针旋转 90 度, 使设备方向和图像方向一致,右边图所示


此时, 显示的位置, 大致就是其在图像中的位置; 扫描区域的位置有了, 但是其坐标原点呢? 上面说了是在图像的左上角, 是上面图中的哪个呢? o1 ? 还是 o2?

答案是 o2, 图像的左上角, 当然是当图像是正着看的时候的左上角, 也就是我们看到的图像的右上角; 这时的坐标系如下所示:

坐标系有了, 接下来就是坐标的转化了; 和最初的扫描区域相比, 可以看到 x/y/width/height 都进行的镜像翻转, 即最终的结果为:

CGRect r = CGRectMake(0, 0, 1., 1.);
r.origin.x = (cropRect.origin.y)/size.height;
r.origin.y = (size.width - CGRectGetMaxX(cropRect))/size.width;
r.size.width = cropRect.size.height/size.height;
r.size.height = cropRect.size.width/size.width;

这里的size为预览图层的Size, 一般也是屏幕的size;
上面是竖屏情况下, 图片完全填充时的计算方式; 其他情况转换方式是一样的, 只不过需要计算图片的实际宽高;

2.2.2. 横屏, Home 键在右 (UIInterfaceOrientationLandscapeRight)

扫描区域在设备屏幕中位置为:

由上面知道, 捕获到的图像是横着的, 方向朝右, 此时设备也是横着的, 但是设备的方向和图像相反, 所以, 将扫描区域进行镜像翻转之后, 再顺时针旋转 180 度:


其坐标原点同上, 也是在我们看到的图像的右上角; 所以, 其转换公式为:

        CGRect r = CGRectMake(0, 0, 1., 1.);
        r.origin.x = (CGRectGetMinX(cropRect))/size.width;
        r.origin.y = (CGRectGetMinY(cropRect))/size.height;
        r.size.width = cropRect.size.width/size.width ;
        r.size.height = cropRect.size.height/size.height;

2.2.3. 横屏, Home 键在左 (UIInterfaceOrientationLandscapeLeft)

扫描区域在设备屏幕中位置为:

此时设备和图像方向一致, 所以, 只需要进行上下镜像即可:

所以, 其转换公式为:

        CGRect r = CGRectMake(0, 0, 1., 1.);
        r.origin.x = (CGRectGetMinX(cropRect))/size.width;
        r.origin.y = (CGRectGetMinY(cropRect))/size.height;
        r.size.width = cropRect.size.width/size.width ;
        r.size.height = cropRect.size.height/size.height;

2.2.4. 竖屏, Home 键在上(UIInterfaceOrientationPortraitUpsideDown)

先进行左右镜像, 然后, 逆时针旋转 90 度:

所以, 其转换公式为:

        CGRect r = CGRectMake(0, 0, 1., 1.);
        r.origin.x = (size.height - CGRectGetMaxY(cropRect))/size.height;
        r.origin.y = (CGRectGetMinX(cropRect))/size.width;
        r.size.width = cropRect.size.height/size.height;
        r.size.height = cropRect.size.width/size.width;

2.3. 其他图像填充模式转换

上面讲的是, 图像完全填充满整个屏幕的情况, 有可能会导致图片被挤压变形, 实际使用时很少采用; 而 AVLayerVideoGravityResizeAspect 模式, 屏幕中可能会左右留有黑边或者上下留有黑边, 也很少采用; 一般我们在使用的时候会使用 AVLayerVideoGravityResizeAspectFill 方式进行图片填充, 铺满屏幕, 多余的会被裁掉; 不同的填充模式, 在参考上面的转换方式进行转换时, 需要考虑被裁掉的部分图像, 或者出现的黑边;

以下示意图的说明

左图为扫描区域在屏幕中的位置, 右图为扫描区域转换为图像中的位置;
灰色背景为设备屏幕大小;
红色线框为图像大小
红色区域为扫描框

2.3.1. 竖屏, Home 键在下
  • AVLayerVideoGravityResizeAspectFill
  1. 设备竖屏状态
  2. 图像填充模式为 AVLayerVideoGravityResizeAspectFill
  3. 图像上下或左右部分会被裁去

扫描区域在设备中的位置如下图左所示:


等宽情况下, 图像比屏幕高
等高情况下, 图像比屏幕宽

可以看到, 实际我们在屏幕中看到的图像只是原图像的中间部分, 上下或左右 padding 大小的区域被裁去;

此时, 坐标的转换应该是:

// p1 为设备屏幕(即previewLayer)的height/width
// p2 为图像的分辨率 height/width
        if (p1 < p2) {
            CGFloat height = size.width * p2;
            CGFloat padding = (height - size.height)/2.;
            
            r.origin.x = (CGRectGetMinY(cropRect) + padding)/height ;
            r.origin.y = (size.width - CGRectGetMaxX(cropRect))/size.width ;
            r.size.width = cropRect.size.height/height ;
            r.size.height = cropRect.size.width/size.width ;
        } else {
            CGFloat width = size.height / p2;
            CGFloat padding = (width - size.width)/2.;
            
            r.origin.x = CGRectGetMinY(cropRect)/size.height ;
            r.origin.y = (size.width - CGRectGetMaxX(cropRect) + padding)/width;
            r.size.width = cropRect.size.height/size.height ;
            r.size.height = cropRect.size.width/width ;
        }
  • AVLayerVideoGravityResizeAspect
  1. 设备竖屏状态
  2. 图像填充模式为 AVLayerVideoGravityResizeAspect
  3. 图像上下或左右部分会有黑边
等高情况下, 图像比屏幕宽小
等宽情况下, 图像比屏幕高小

可以看到, 实际我们在屏幕中看到的图像是原图像的等比缩小, 上下 或左右会有 padding 区域的黑边;

此时, 坐标的转换应该是:

// p1 为设备屏幕(即previewLayer)的height/width
// p2 为图像的分辨率 height/width
        if (p1 > p2) {
            CGFloat height = size.width * p2;
            CGFloat padding = (size.height - height)/2.0;
            
            r.origin.x = (CGRectGetMinY(cropRect) - padding)/height ;
            r.origin.y = (size.width - CGRectGetMaxX(cropRect))/size.width ;
            r.size.width = cropRect.size.height/height ;
            r.size.height = cropRect.size.width/size.width ;
        } else {
            CGFloat width = size.height * (1./p2);
            CGFloat padding = (size.width - width)/2;
            
            r.origin.x = CGRectGetMinY(cropRect)/size.height ;
            r.origin.y = (size.width - CGRectGetMaxX(cropRect) - padding)/width ;
            r.size.width = cropRect.size.height/size.height ;
            r.size.height = cropRect.size.width/width ;
        }

2.3.2. 横屏, Home 键在右
  • AVLayerVideoGravityResizeAspectFill

图像填充模式为 AVLayerVideoGravityResizeAspectFill
图像上下或左右部分会被裁去

扫描区域在设备中的位置如下图左所示:

等宽情况下, 图像比屏幕高大
等高情况下, 图像比屏幕宽大

可以看到, 实际我们在屏幕中看到的图像只是原图像的中间部分, 上下或左右 padding 大小的区域被裁去;

此时, 坐标的转换应该是:

// p1 为设备屏幕(即previewLayer)的height/width
// p2 为图像的分辨率 height/width
        if (p1 > p2) {
            CGFloat width = size.height / p2;
            CGFloat padding = (width - size.width) / 2.0;
            
            r.origin.x = (CGRectGetMinX(cropRect) + padding)/width ;
            r.origin.y = (CGRectGetMinY(cropRect))/size.height ;
            r.size.width = cropRect.size.width/width ;
            r.size.height = cropRect.size.height/size.height ;
        } else {
            CGFloat height = size .width * p2;
            CGFloat padding = (height  - size.height) / 2.0;
            
            r.origin.x = CGRectGetMinX(cropRect) / size.width;
            r.origin.y = (CGRectGetMinY(cropRect) + padding) / height;
            r.size.width = CGRectGetWidth(cropRect) / size.width;
            r.size.height = CGRectGetHeight(cropRect) / height;
        }
  • AVLayerVideoGravityResizeAspect

图像填充模式为 AVLayerVideoGravityResizeAspect
图像上下或左右部分会有黑边

等宽情况下, 图像比设备的高小
等高情况下, 图像比设备的宽小

可以看到, 实际我们在屏幕中看到的图像是原图像的等比缩小, 上下 或左右会有 padding 区域的黑边;

此时, 坐标的转换应该是:

// p1 为设备屏幕(即previewLayer)的height/width
// p2 为图像的分辨率 height/width
        if (p1 > p2) {
            CGFloat height = size.width * p2;
            CGFloat padding = (size.height - height)/2.0;
            
            r.origin.x = CGRectGetMinX(cropRect)/size.width ;
            r.origin.y = (CGRectGetMinY(cropRect) - padding)/ height ;
            r.size.width = cropRect.size.width/size.width ;
            r.size.height =  cropRect.size.height/height ;
        } else {
            CGFloat width = size.height / p2;
            CGFloat padding = (size.width - width)/2.;
            
            r.origin.x = (CGRectGetMinX(cropRect) - padding)/width ;
            r.origin.y = (CGRectGetMinY(cropRect))/size.height ;
            r.size.width = cropRect.size.width/width ;
            r.size.height = cropRect.size.height/size.height ;
        }

2.3.3. 横屏, Home 键在左
  • AVLayerVideoGravityResizeAspectFill

图像填充模式为 AVLayerVideoGravityResizeAspectFill
图像上下或左右部分会被裁去

扫描区域在设备中的位置如下图左所示:

等高情况下, 图像比屏幕宽大
等宽情况下, 图像比屏幕高

可以看到, 实际我们在屏幕中看到的图像只是原图像的中间部分, 上下或左右 padding 大小的区域被裁去;

此时, 坐标的转换应该是:

// p1 为设备屏幕(即previewLayer)的height/width
// p2 为图像的分辨率 height/width
        if (p1 > p2) {
            CGFloat width = size.height / p2;
            CGFloat padding = (width - size.width) / 2.0;
            
            r.origin.x = (CGRectGetMinX(cropRect) + padding)/width ;
            r.origin.y = (CGRectGetMinY(cropRect))/size.height ;
            r.size.width = cropRect.size.width/width ;
            r.size.height = cropRect.size.height/size.height ;
        } else {
            CGFloat height = size .width * p2;
            CGFloat padding = (height  - size.height) / 2.0;
            
            r.origin.x = CGRectGetMinX(cropRect) / size.width;
            r.origin.y = (CGRectGetMinY(cropRect) + padding) / height;
            r.size.width = CGRectGetWidth(cropRect) / size.width;
            r.size.height = CGRectGetHeight(cropRect) / height;
        }

  • AVLayerVideoGravityResizeAspect

图像填充模式为 AVLayerVideoGravityResizeAspect
图像上下或左右部分会有黑边

等宽情况下, 图像比屏幕高小
等高情况下, 图像比屏幕宽小

可以看到, 实际我们在屏幕中看到的图像是原图像的等比缩小, 上下 或左右会有 padding 区域的黑边;

此时, 坐标的转换应该是:

// p1 为设备屏幕(即previewLayer)的height/width
// p2 为图像的分辨率 height/width
        if (p1 > p2) {
            CGFloat height = size.width * p2;
            CGFloat padding = (size.height - height)/2.0;
            
            r.origin.x = CGRectGetMinX(cropRect)/size.width ;
            r.origin.y = (CGRectGetMinY(cropRect) - padding)/ height ;
            r.size.width = cropRect.size.width/size.width ;
            r.size.height =  cropRect.size.height/height ;
        } else {
            CGFloat width = size.height / p2;
            CGFloat padding = (size.width - width)/2.;
            
            r.origin.x = (CGRectGetMinX(cropRect) - padding)/width ;
            r.origin.y = (CGRectGetMinY(cropRect))/size.height ;
            r.size.width = cropRect.size.width/width ;
            r.size.height = cropRect.size.height/size.height ;
        }

2.3.4. 竖屏, Home 键在上
  • AVLayerVideoGravityResizeAspectFill

图像填充模式为 AVLayerVideoGravityResizeAspectFill
图像上下或左右部分会被裁去

扫描区域在设备中的位置如下图左所示:

等宽情况下, 图像比屏幕高大
等高情况下, 图像比屏幕宽大

可以看到, 实际我们在屏幕中看到的图像只是原图像的中间部分, 上下或左右 padding 大小的区域被裁去;

此时, 坐标的转换应该是:

// p1 为设备屏幕(即previewLayer)的height/width
// p2 为图像的分辨率 height/width
        if (p1 < p2) {
            CGFloat height = size.width * p2;
            CGFloat padding = (height - size.height)/2.;
            
            r.origin.x = (size.height - CGRectGetMaxY(cropRect) + padding)/height;
            r.origin.y = (CGRectGetMinX(cropRect))/size.width;
            r.size.width = cropRect.size.height/height;
            r.size.height = cropRect.size.width/size.width;
        } else {
            CGFloat width = size.height / p2;
            CGFloat padding = (width - size.width)/2.;
            
            r.origin.x = (size.height - CGRectGetMaxY(cropRect))/size.height;
            r.origin.y = (CGRectGetMinX(cropRect) + padding)/width;
            r.size.width = cropRect.size.height/size.height;
            r.size.height = cropRect.size.width/width;
        }

  • AVLayerVideoGravityResizeAspect

图像填充模式为 AVLayerVideoGravityResizeAspect
图像上下或左右部分会有黑边

等宽情况下, 图像比屏幕高小
等高情况下, 图像比屏幕宽小

可以看到, 实际我们在屏幕中看到的图像是原图像的等比缩小, 上下 或左右会有 padding 区域的黑边;

此时, 坐标的转换应该是:

// p1 为设备屏幕(即previewLayer)的height/width
// p2 为图像的分辨率 height/width
        if (p1 > p2) {
            CGFloat height = size.width * p2;
            CGFloat padding = (size.height - height)/2.0;
            
            r.origin.x = (size.height - CGRectGetMaxY(cropRect) - padding)/height;
            r.origin.y = (CGRectGetMinX(cropRect))/size.width;
            r.size.width = cropRect.size.height/height;
            r.size.height = cropRect.size.width/size.width;
        } else {
            CGFloat width = size.height * (1./p2);
            CGFloat padding = (size.width - width)/2;
            
            r.origin.x = CGRectGetMinY(cropRect)/size.height ;
            r.origin.y = (CGRectGetMinX(cropRect) - padding)/width ;
            r.size.width = cropRect.size.height/size.height ;
            r.size.height = cropRect.size.width/width ;
        }

参考文章 iOS 二维码有效区域rectOfInterest详解

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,099评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,828评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,540评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,848评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,971评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,132评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,193评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,934评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,376评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,687评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,846评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,537评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,175评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,887评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,134评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,674评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,741评论 2 351

推荐阅读更多精彩内容