iOS 二维码有效区域rectOfInterest详解

demo

前言

  • 关于二维码的有效区域,在开发中遇到的人可能并不是很多,大多数情况都是直接用第三方,但是当你真正自己去尝试写的时候,你会发现二维码的有效区域是一个很令人捉摸不定的问题,其实很多基于系统的第三方并没有解决这个问题,它们都是全屏扫描。
  • 网上有一些关于rectOfInterest属性的解释,但是经过我自己的核对,发现他们说的并不是很精确,甚至可以说是错误的。我觉得我还是有必要跟大家分享一下,在网上你真的再也找不到这么详细的有关rectOfInterest的解释。

影响rectOfInterest的因素

rectOfInterest跟2个属性息息相关:一个是AVCaptureSession(会话对象)的sessionPreset属性,另一个是AVCaptureVideoPreviewLayer(预览图层)的videoGravity属性。在这里,我简单讲一下这2个属性的意义:

sessionPreset属性

该属性是设置图像音频等输出分辨率,大约一共有11个:

// 完整的图像分辨率输出,不支持音频
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属性

该属性共有3个值:如果你不了解,我建议你先去熟悉一下UIView的contentMode属性,光了解没有用,必须知道它的原理以及计算方式

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

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

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

正题

一般的,扫描区域就是预览视图previewLayer的frame对应的矩形框,一般是设置全屏。如果我们想要设置一个有效区域怎么办,如同支付宝、微信等将扫码区域限制在一个小正方形内。这就要用到输出流AVCaptureMetadataOutput的一个rectOfInterest属性。

rectOfInterest默认为(0,0,1,1);

大家应该提出质疑:为什么宽高才为1?这也太小了吧,然而这个区域却是全屏。这是肿么肥四呢? 聪明的你应该猜到了,rectOfInterest肯定是经过某种转化而来,而且x,y, w, h的范围均在0~1之间。究竟是如何转化的,且听我慢慢说给你听:
假如在手机屏幕中,我想限制有效扫描区域在矩形框(10,10,100,100)内,是不是这样设置:

metadataOutput.rectOfInterest = CGRectMake(10, 10, 100, 100);

这样对吗?肯定不对咯,因为还没有转化为0~1的范围内呢。
好的,我们一起来转化一下,由于图像都是显示在预览视图previewLayer中,所以自然是通过previewLayer的frame来转化.

假设previewLayer的frame为全屏,记为:
preViewRect = CGRectMake(0,0,kScreenW,kScreenH);
有效扫描区域为
validRect =  CGRectMake(x, y, w, h);
转化后:
rectOfInterest = (x / kScreenW, y / kScreenH, w / kScreenW, h / kScreenH)

到此,转化结束!就这样完了吗?还早着呢!

我问大家一个问题:矩形框rectOfInterest=(0,0,1,1)应该在屏幕的哪个位置?
大家应该会回答在屏幕的左上方,没错,不仅是你,就连官方文档的解释都是这样说的,官方文档说:

rectOfInterest中的origin如果为(0,0),表示在图像的左上方;如果为(1,1),表示 在未经过旋转的图像的右下方。这很符合我们的想象。

好。如果按照我们的想象或者官方文档所说,我们设置的有效区域:(10 ,10 , 100 ,100)应该会偏左上方。然而,结果并非如此,显示结果是这样的:

红色矩形框代表扫码区域
17298B562720FB2280DC530FD12022EA.jpg

有没有发现,显示结果和我们想的完全相反,偏右上角,也就是说:

核心句子:

实际显示在我们肉眼看到的屏幕中的坐标原点,应该是在右上角,这就好比是小明在照镜子,假如小明真人的左脸颊有一颗痣,那么在镜子中,痣应该是在右脸颊。我们所想的rectOfInterest,都是镜中的rectOfInterest。

既然我们已经知道了坐标原点,那么我们想让扫描区域(10 ,10 , 100 ,100)显示在左上方,就不是难事了。


17298B562720FB2280DC530FD12022EA.jpg

如上图:左边红色矩形框就是我们实际要的扫描区域所在位置,最关键是要求出图中蓝点相对原点(右上角)的坐标。

蓝点的坐标(相对右上角)为:
x = (kScreenW-(100+10)) / kScreenW;
y = 10 / kScreenH; 

// 除以kScreenW和kScreenH是转化比例

// 由此,我们可以推导出一个转化公式:

设 有效区域为
validRect = CGRectMake(x, y, w, h);
预览图层的frame为
preViewRect = CGRectMake(0, 0, kScreenW, kScreenH);
那么
rectOfInterest = CGRectMake((kScreenW-(w+x)) / kScreenW, y / kScreenH, w / kScreenW, h / kScreenH);

到了这里, 离成功似乎很近了,但是很遗憾, 漫长的路才刚起步!用此公式代入计算,发现扫码区域完全不对,好桑心,为什么会这样?于是猜想: AVCapture输出的图片大小都是横着的,而iPhone的屏幕是竖着的,那么我把它旋转90°呢:
旋转90°也就意味着x与y互换,w和h互换,即:rectOfInterest的x, y, w , h 应该对应y, x , h, w;转换如下:

有一定正确性的转化公式:

设 有效区域为
validRect = CGRectMake(x, y, w, h);
预览图层的frame为
preViewRect = CGRectMake(0, 0, kScreenW, kScreenH);
那么
rectOfInterest = CGRectMake(y / kScreenH, (kScreenW-(w+x)) / kScreenW, h / kScreenH, w / kScreenW);

// 这个公式上升了一个级别,有了一定的正确性,但是它太“死”了,不够灵活,也就是说,假如我随意更换设备,随意修改sessionPreset和videoGravity属性的话,此公式计算出来的扫描区域是不准确的。这下该怎么办,我差点就要放弃了,到这里就结束算了,但是心里总感觉有点希望,于是彻夜都在想这个问题。
大家还记得我开篇讲的sessionPreset和videoGravity属性吧,在这里,这俩属性就要闪亮登场了。

核心句子:

rectOfInterest是相对图像大小的比例,而不是相对设备或者预览图层AVCaptureVideoPreviewLayer的比例

既然是相对图像,由于图像的输出有多种模式,这些模式通过AVCaptureVideoPreviewLayer的videoGravity属性设置,如AVLayerVideoGravityResizeAspectFill;由于这些模式的设置,导致图像会被裁减、留白或者拉伸,所以我们计算出来的结果是相对图像而言的,我们需要将其转化到预览图层AVCaptureVideoPreviewLayer上来。所以我开始要求大家去熟悉一下UIView的contentMode模式。
我不废话了,我直接上转化过程,我将其封装成了一个方法.

最终的万能转化公式:(本文核心)

// 该方法中,_preViewLayer指的是AVCaptureVideoPreviewLayer的实例对象,_session是会话对象,_metadataOutput是扫码输出流
- (void)coverToMetadataOutputRectOfInterestForRect:(CGRect)cropRect {
    CGSize size = _previewLayer.bounds.size;
    CGFloat p1 = size.height/size.width;
    CGFloat p2 = 0.0;

    if ([_session.sessionPreset isEqualToString:AVCaptureSessionPreset1920x1080]) {
        p2 = 1920./1080.;
    }
    else if ([_session.sessionPreset isEqualToString:AVCaptureSessionPreset352x288]) {
        p2 = 352./288.;
    }
    else if ([_session.sessionPreset isEqualToString:AVCaptureSessionPreset1280x720]) {
        p2 = 1280./720.;
    }
    else if ([_session.sessionPreset isEqualToString:AVCaptureSessionPresetiFrame960x540]) {
        p2 = 960./540.;
    }
    else if ([_session.sessionPreset isEqualToString:AVCaptureSessionPresetiFrame1280x720]) {
        p2 = 1280./720.;
    }
    else if ([_session.sessionPreset isEqualToString:AVCaptureSessionPresetHigh]) {
        p2 = 1920./1080.;
    }
    else if ([_session.sessionPreset isEqualToString:AVCaptureSessionPresetMedium]) {
        p2 = 480./360.;
    }
    else if ([_session.sessionPreset isEqualToString:AVCaptureSessionPresetLow]) {
        p2 = 192./144.;
    }
    else if ([_session.sessionPreset isEqualToString:AVCaptureSessionPresetPhoto]) { // 暂时未查到具体分辨率,但是可以推导出分辨率的比例为4/3
         p2 = 4./3.;
    }
    else if ([_session.sessionPreset isEqualToString:AVCaptureSessionPresetInputPriority]) {
        p2 = 1920./1080.;
    }
    else if (@available(iOS 9.0, *)) {
        if ([_session.sessionPreset isEqualToString:AVCaptureSessionPreset3840x2160]) {
            p2 = 3840./2160.;
        }
    } else {
        
    }
    if ([_previewLayer.videoGravity isEqualToString:AVLayerVideoGravityResize]) {
        _metadataOutput.rectOfInterest = CGRectMake((cropRect.origin.y)/size.height,(size.width-(cropRect.size.width+cropRect.origin.x))/size.width, cropRect.size.height/size.height,cropRect.size.width/size.width);
    } else if ([_previewLayer.videoGravity isEqualToString:AVLayerVideoGravityResizeAspectFill]) {
        if (p1 < p2) {
            CGFloat fixHeight = size.width * p2;
            CGFloat fixPadding = (fixHeight - size.height)/2;
            _metadataOutput.rectOfInterest = CGRectMake((cropRect.origin.y + fixPadding)/fixHeight,
                                                        (size.width-(cropRect.size.width+cropRect.origin.x))/size.width,
                                                        cropRect.size.height/fixHeight,
                                                        cropRect.size.width/size.width);
        } else {
            CGFloat fixWidth = size.height * (1/p2);
            CGFloat fixPadding = (fixWidth - size.width)/2;
            _metadataOutput.rectOfInterest = CGRectMake(cropRect.origin.y/size.height,
                                                        (size.width-(cropRect.size.width+cropRect.origin.x)+fixPadding)/fixWidth,
                                                        cropRect.size.height/size.height,
                                                        cropRect.size.width/fixWidth);
        }
    } else if ([_previewLayer.videoGravity isEqualToString:AVLayerVideoGravityResizeAspect]) {
        if (p1 > p2) {
            CGFloat fixHeight = size.width * p2;
            CGFloat fixPadding = (fixHeight - size.height)/2;
            _metadataOutput.rectOfInterest = CGRectMake((cropRect.origin.y + fixPadding)/fixHeight,
                                                        (size.width-(cropRect.size.width+cropRect.origin.x))/size.width,
                                                        cropRect.size.height/fixHeight,
                                                        cropRect.size.width/size.width);
        } else {
            CGFloat fixWidth = size.height * (1/p2);
            CGFloat fixPadding = (fixWidth - size.width)/2;
            _metadataOutput.rectOfInterest = CGRectMake(cropRect.origin.y/size.height,
                                                        (size.width-(cropRect.size.width+cropRect.origin.x)+fixPadding)/fixWidth,
                                                        cropRect.size.height/size.height,
                                                        cropRect.size.width/fixWidth);
        }
    }
}

上面那个公式就是最终的转化公式,有一点要声明一下,当开发者设置输出分辨率为AVCaptureSessionPresetHigh、AVCaptureSessionPresetMedium、AVCaptureSessionPresetLow、AVCaptureSessionPresetPhoto等不确定性分辨率时,我都是默认给了一个对应的明确的分辨率,例如AVCaptureSessionPresetHigh我计算时采用的是1920x1080,因为我测试时是采用的iPhone6s,其他机型未必是这个分辨率,所以当分辨率取决于设备时,你自己需要根据设备的不同去修改一下。本人没有那么多真机,所以我无法给出通用的答案。

metadataOutputRectOfInterestForRect方法

我想有人肯定一直在怀疑,为什么不用系统自带的metadataOutputRectOfInterestForRect方法,这个方法就是我上面那个公式的功能啊,甚至更权威。但是,试了就知道,metadataOutputRectOfInterestForRect在输入流格式发生变化之前设置是无效的,你需要监听一个通知:AVCaptureInputPortFormatDescriptionDidChangeNotification,在通知方法中调用metadataOutputRectOfInterestForRect才起作用,或者你开启扫码startRunning之后再设置也行,这些做法确实也能计算出扫码有效区域,但是会卡顿,开启扫描之后,总是会卡一下,才开始扫描,这非常影响用户体验,所以不建议使用。

作者寄语

你可以用我的公式和采用系统的metadataOutputRectOfInterestForRect方法的转化结果对比一下,你会发现结果的差距非常微妙,只有零点零零几的误差

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

推荐阅读更多精彩内容