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)应该会偏左上方。然而,结果并非如此,显示结果是这样的:
红色矩形框代表扫码区域
有没有发现,显示结果和我们想的完全相反,偏右上角,也就是说:
核心句子:
实际显示在我们肉眼看到的屏幕中的坐标原点,应该是在右上角,这就好比是小明在照镜子,假如小明真人的左脸颊有一颗痣,那么在镜子中,痣应该是在右脸颊。我们所想的rectOfInterest,都是镜中的rectOfInterest。
既然我们已经知道了坐标原点,那么我们想让扫描区域(10 ,10 , 100 ,100)显示在左上方,就不是难事了。
如上图:左边红色矩形框就是我们实际要的扫描区域所在位置,最关键是要求出图中蓝点相对原点(右上角)的坐标。
蓝点的坐标(相对右上角)为:
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方法的转化结果对比一下,你会发现结果的差距非常微妙,只有零点零零几的误差