AVFoundation 高级捕捉功能

2,417 阅读10分钟

1. 视频缩放

iOS 7.0 为 AVCaptureDevice 提供了一个 videoZoomFactor 属性用于对视频输出和捕捉提供缩放效果,这个属性的最小值为 1.0,最大值由下面的方法提供

self.cameraHelper.activeVideoDevice.activeFormat.videoMaxZoomFactor;

因而判断一个设备能否进行缩放也可以通过判断这一属性来获知

- (BOOL)cameraSupportsZoom
{
    return self.cameraHelper.activeVideoDevice.activeFormat.videoMaxZoomFactor > 1.0f;
}

设备执行缩放效果是通过居中裁剪由摄像头传感器捕捉到的图片实现的,也可以通过 videoZoomFactorUpscaleThreshold 来设置具体的放大中心。当 zoom factors 缩放因子比较小的时候,裁剪的图片刚好等于或者大于输出尺寸(考虑与抗边缘畸变有关),则无需放大就可以返回。但是当 zoom factors 比较大时,设备必须缩放裁剪图片以符合输出尺寸,从而导致图片质量上的丢失。具体的临界点由 videoZoomFactorUpscaleThreshold 值来确定。

// 在 iphone6s 和 iphone8plus 上测试得到此值为 2.0左右
self.cameraHelper.activeVideoDevice.activeFormat.videoZoomFactorUpscaleThreshold;

可以通过一个变化值从 0.0 到 1.0 的 UISlider 来实现对缩放值的控制。

{
    [self.slider addTarget:self action:@selector(sliderValueChange:) forControlEvents:UIControlEventValueChanged];
}

- (void)sliderValueChange:(id)sender
{
    UISlider *slider = (UISlider *)sender;
    [self setZoomValue:slider.value];
}

- (CGFloat)maxZoomFactor
{
    return MIN(self.cameraHelper.activeVideoDevice.activeFormat.videoMaxZoomFactor, 4.0f);
}

- (void)setZoomValue:(CGFloat)zoomValue
{
    if (!self.cameraHelper.activeVideoDevice.isRampingVideoZoom) {
        NSError *error;
        if ([self.cameraHelper.activeVideoDevice lockForConfiguration:&error]) {
            CGFloat zoomFactor = pow([self maxZoomFactor], zoomValue);
            self.cameraHelper.activeVideoDevice.videoZoomFactor = zoomFactor;
            [self.cameraHelper.activeVideoDevice unlockForConfiguration];
        }
    }
}    

首先注意在进行配置属性前需要进行设备的锁定,否则会引发异常。其次,插值缩放是一个指数形式的增长,传入的 slider 值是线性的,需要进行一次 pow 运算得到需要缩放的值。另外,videoMaxZoomFactor 的值可能会非常大,在 iphone8p 上这一个值是 16,缩放到这么大的图像是没有太大意义的,因此需要人为设置一个最大缩放值,这里选择 4.0。

当然这里进行的缩放是立即生效的,下面的方法可以以一个速度平滑缩放到一个缩放因子上

- (void)rampZoomToValue:(CGFloat)zoomValue {
    CGFloat zoomFactor = pow([self maxZoomFactor], zoomValue);
	NSError *error;
	if ([self.activeCamera lockForConfiguration:&error]) {
		[self.activeCamera rampToVideoZoomFactor:zoomFactor
                                        withRate:THZoomRate];
		[self.activeCamera unlockForConfiguration];
	} else {
	}
}

- (void)cancelZoom {
	NSError *error;
	if ([self.activeCamera lockForConfiguration:&error]) {
		[self.activeCamera cancelVideoZoomRamp];
		[self.activeCamera unlockForConfiguration];
	} else {
	}
}

监听设备的 videoZoomFactor 可以获知当前的缩放值

    [RACObserve(self, activeVideoDevice.videoZoomFactor) subscribeNext:^(id x) {
        NSLog(@"videoZoomFactor: %f", self.activeVideoDevice.videoZoomFactor);
    }];

监听设备的 rampingVideoZoom 可以获知设备是否正在平滑缩放

    [RACObserve(self, activeVideoDevice.rampingVideoZoom) subscribeNext:^(id x) {
        NSLog(@"rampingVideoZoom : %@", (self.activeVideoDevice.rampingVideoZoom)?@"true":@"false");
    }];

2. 人脸识别

人脸识别需要用到 AVCaptureMetadataOutput 作为输出,首先将其加入到捕捉会话中

    self.metaDataOutput = [[AVCaptureMetadataOutput alloc] init];
    if ([self.captureSession canAddOutput:self.metaDataOutput]) {
        [self.captureSession addOutput:self.metaDataOutput];
        NSArray *metaDataObjectType = @[AVMetadataObjectTypeFace];
        self.metaDataOutput.metadataObjectTypes = metaDataObjectType;
        [self.metaDataOutput setMetadataObjectsDelegate:self queue:dispatch_get_main_queue()];
    }

可以看到这里需要指定 AVCaptureMetadataOutput 的 metadataObjectTypes 属性,将其设置为 AVMetadataObjectTypeFace 的数组,它代表着人脸元数据对象。然后设置其遵循 AVCaptureMetadataOutputObjectsDelegate 协议的委托对象及回调线程,当检测到人脸时就会调用下面的方法

- (void)captureOutput:(AVCaptureOutput *)output didOutputMetadataObjects:(NSArray<__kindof AVMetadataObject *> *)metadataObjects fromConnection:(AVCaptureConnection *)connection
{
    if (self.detectFaces) {
        self.detectFaces(metadataObjects);
    }
}

其中 metadataObjects 是一个包含了许多 AVMetadataObject 对象的数组,这里则可以认为都是 AVMetadataObject 的子类 AVMetadataFaceObject。对于 AVMetadataFaceObject 对象,有四个重要的属性

  • faceID,用于标识检测到的每一个 face
  • rollAngle,用于标识人脸斜倾角,即人的头部向肩膀方便的侧倾角度
  • yawAngle,偏转角,即人脸绕 y 轴旋转的角度
  • bounds,标识检测到的人脸区域

这里我们将其回调给 ViewController,用于进行 UI 展示。

        @weakify(self)
        self.cameraHelper.detectFaces = ^(NSArray *faces) {
            @strongify(self)
            NSMutableArray *transformedFaces = [NSMutableArray array];
            for (AVMetadataFaceObject *face in faces) {
                AVMetadataObject *transformedFace = [self.previewLayer transformedMetadataObjectForMetadataObject:face];
                [transformedFaces addObject:transformedFace];
            }
            NSMutableArray *lostFaces = [self.faceLayers.allKeys mutableCopy];
            for (AVMetadataFaceObject *face in transformedFaces) {
                NSNumber *faceId = @(face.faceID);
                [lostFaces removeObject:faceId];
                
                CALayer *layer = self.faceLayers[faceId];
                if (!layer) {
                    layer = [CALayer layer];
                    layer.borderWidth = 5.0f;
                    layer.borderColor = [UIColor colorWithRed:0.188 green:0.517 blue:0.877 alpha:1.000].CGColor;
                    [self.previewLayer addSublayer:layer];
                    self.faceLayers[faceId] = layer;
                }
                layer.transform = CATransform3DIdentity;
                layer.frame = face.bounds;
                
                if (face.hasRollAngle) {
                    layer.transform = CATransform3DConcat(layer.transform, [self transformForRollAngle:face.rollAngle]);
                }
                
                if (face.hasYawAngle) {
                    NSLog(@"%f", face.yawAngle);
                    layer.transform = CATransform3DConcat(layer.transform, [self transformForYawAngle:face.yawAngle]);
                }
            }
            
            for (NSNumber *faceID in lostFaces) {
                CALayer *layer = self.faceLayers[faceID];
                [layer removeFromSuperlayer];
                [self.faceLayers removeObjectForKey:faceID];
            }
        };
        
// Rotate around Z-axis
- (CATransform3D)transformForRollAngle:(CGFloat)rollAngleInDegrees {        // 3
    CGFloat rollAngleInRadians = THDegreesToRadians(rollAngleInDegrees);
    return CATransform3DMakeRotation(rollAngleInRadians, 0.0f, 0.0f, 1.0f);
}

// Rotate around Y-axis
- (CATransform3D)transformForYawAngle:(CGFloat)yawAngleInDegrees {          // 5
    CGFloat yawAngleInRadians = THDegreesToRadians(yawAngleInDegrees);
    
    CATransform3D yawTransform = CATransform3DMakeRotation(yawAngleInRadians, 0.0f, -1.0f, 0.0f);
    
    return CATransform3DConcat(yawTransform, [self orientationTransform]);
}

- (CATransform3D)orientationTransform {                                     // 6
    CGFloat angle = 0.0;
    switch ([UIDevice currentDevice].orientation) {
        case UIDeviceOrientationPortraitUpsideDown:
            angle = M_PI;
            break;
        case UIDeviceOrientationLandscapeRight:
            angle = -M_PI / 2.0f;
            break;
        case UIDeviceOrientationLandscapeLeft:
            angle = M_PI / 2.0f;
            break;
        default: // as UIDeviceOrientationPortrait
            angle = 0.0;
            break;
    }
    return CATransform3DMakeRotation(angle, 0.0f, 0.0f, 1.0f);
}

static CGFloat THDegreesToRadians(CGFloat degrees) {
    return degrees * M_PI / 180;
}

我们用一个字典来管理每一个展示一个 face 对象的 layer,它的 key 值即 faceID,回调时更新当前已存在的 faceLayer,移除不需要的 faceLayer。其次对每一个 face,根据其 rollAngle 和 yawAngle 要通过 transfor 来变换展示的矩阵。

还要注意一点,transformedMetadataObjectForMetadataObject 方法可以将设备坐标系上的数据转换到视图坐标系上,设备坐标系的范围是 (0, 0) 到 (1,1)。

3. 机器可读代码识别

机器可读代码包括一维条码和二维码等,AVFoundation 支持多种一维码和三种二维码,其中最常见的是 QR 码,也即二维码。

扫码仍然需要用到 AVMetadataObject 对象,首先加入到捕捉会话中。

    self.metaDataOutput = [[AVCaptureMetadataOutput alloc] init];
    if ([self.captureSession canAddOutput:self.metaDataOutput]) {
        [self.captureSession addOutput:self.metaDataOutput];
        [self.metaDataOutput setMetadataObjectsDelegate:self queue:dispatch_get_main_queue()];
        NSArray *types = @[AVMetadataObjectTypeQRCode];
        self.metaDataOutput.metadataObjectTypes = types;
    }

然后实现委托方法

- (void)captureOutput:(AVCaptureOutput *)output didOutputMetadataObjects:(NSArray<__kindof AVMetadataObject *> *)metadataObjects fromConnection:(AVCaptureConnection *)connection
{
    [metadataObjects enumerateObjectsUsingBlock:^(__kindof AVMetadataObject * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if ([obj isKindOfClass:[AVMetadataMachineReadableCodeObject class]]) {
            NSLog(@"%@", ((AVMetadataMachineReadableCodeObject*)obj).stringValue);
        }
    }];
}

对于一个 AVMetadataMachineReadableCodeObject,有以下三个重要属性

  • stringValue,用于表示二维码编码信息
  • bounds,用于表示二维码的矩形边界
  • corners,一个角点字典表示的数组,比 bounds 表示的二维码区域更精确

所以可以通过以上属性,在 UI 界面上对二维码区域进行高亮展示

首先需要注意,一个从 captureSession 获得的 AVMetadataMachineReadableCodeObject,其坐标是设备坐标系下的坐标,需要进行坐标转换

- (NSArray *)transformedCodesFromCodes:(NSArray *)codes {
    NSMutableArray *transformedCodes = [NSMutableArray array];
    [codes enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        AVMetadataObject *transformedCode = [self.previewLayer transformedMetadataObjectForMetadataObject:obj];
        [transformedCodes addObject:transformedCode];
    }];
    return [transformedCodes copy];
}

其次,对于每一个 AVMetadataMachineReadableCodeObject 对象,其 bounds 属性由于是 CGRect,所以可以直接绘制出一个 UIBezierPath 对象

- (UIBezierPath *)bezierPathForBounds:(CGRect)bounds {
    return [UIBezierPath bezierPathWithRect:bounds];
}

而 corners 属性是一个字典,需要手动生成 CGPoint,然后进行连线操作,生成 UIBezierPath 对象

- (UIBezierPath *)bezierPathForCorners:(NSArray *)corners {
    UIBezierPath *path = [UIBezierPath bezierPath];
    for (int i = 0; i < corners.count; i++) {
        CGPoint point = [self pointForCorner:corners[i]];
        if (i == 0) {
            [path moveToPoint:point];
        } else {
            [path addLineToPoint:point];
        }
    }
    [path closePath];
    return path;
}

- (CGPoint)pointForCorner:(NSDictionary *)corner {
    CGPoint point;
    CGPointMakeWithDictionaryRepresentation((CFDictionaryRef)corner, &point);
    return point;
}

corners 字典的形式大致如下所示,可以调用 CGPointMakeWithDictionaryRepresentation 便捷函数将其转换为 CGPoint 形式。

{
    X = "336.9957633633747";
    Y = "265.7881843381643";
}

一般来说一个 corners 里会包含 4 个 corner 字典。

获取到每一个 code 对应的两个 UIBezierPath 对象后,就可以在视图上添加相应的 CALayer 来显示高亮区域了。

4. 使用高帧率捕捉

高帧率捕获视频是在 iOS 7 以后加入的,具有更逼真的效果和更好的清晰度,对于细节的加强和动作流畅度的提升非常明显,尤其是录制快速移动的内容时更为明显,也可以实现高质量的慢动作视频效果。

实现高帧率捕捉的基本思路是,通过设备的 formats 属性获取所有支持的格式,也就是 AVCaptureDeviceFormat 对象;然后根据对象的 videoSupportedFrameRateRanges 属性,可以获知其所支持的最小帧率、最大帧率及时长信息;然后手动设置设备的格式和帧时长。

首先写一个 AVCaptureDevice 的 category,获取支持格式的最大帧率的方法如下

    AVCaptureDeviceFormat *maxFormat = nil;
    AVFrameRateRange *maxFrameRateRange = nil;
    for (AVCaptureDeviceFormat *format in self.formats) {
        FourCharCode codecType = CMVideoFormatDescriptionGetCodecType(format.formatDescription);
        if (codecType == kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange) {
            NSArray *frameRateRanges = format.videoSupportedFrameRateRanges;
            for (AVFrameRateRange *range in frameRateRanges) {
                if (range.maxFrameRate > maxFrameRateRange.maxFrameRate) {
                    maxFormat = format;
                    maxFrameRateRange = range;
                }
            }
        } else {
        }
    }

codecType 是一个无符号32位的数据类型,但是是由四个字符对应的四个字节组成,一般可能值为 "420v" 或 "420f",这里选取 420v 格式来配置。

可以通过判断最大帧率是否大于 30,来判断设备是否支持高帧率

- (BOOL)isHighFrameRate {
    return self.frameRateRange.maxFrameRate > 30.0f;
}

然后就可以进行配置了

    if ([self hasMediaType:AVMediaTypeVideo] && [self lockForConfiguration:error] && [self.activeCamera supportsHighFrameRateCapture]) {
        CMTime minFrameDuration = self.frameRateRange.minFrameDuration;
        self.activeFormat = self.format;
        self.activeVideoMinFrameDuration = minFrameDuration;
        self.activeVideoMaxFrameDuration = minFrameDuration;
        [self unlockForConfiguration];
    }

这里首先锁定了设备,然后将最小帧时长和最大帧时长都设置成 minFrameDuration,帧时长与帧率是倒数关系,所以最大帧率对应最小帧时长。

播放时可以针对 AVPlayer 设置不同的 rate 实现变速播放,在 iphone8plus 上实测,如果 rate 在 0 到 0.5 之间, 则实际播放速率仍为 0.5。

另外要注意设置 AVPlayerItem 的 audioTimePitchAlgorithm 属性,这个属性允许你指定当视频正在各种帧率下播放的时候如何播放音频

  • AVAudioTimePitchAlgorithmLowQualityZeroLatency 质量低,适合快进,快退或低质量语音
  • AVAudioTimePitchAlgoruthmTimeDomain 质量适中,计算成本较低,适合语音
  • AVAudioTimePitchAlgorithmSpectral 最高质量,最昂贵的计算,保留了原来的项目间距
  • AVAudioTimePitchAlgorithmVarispeed 高品质的播放没有音高校正

通常选择 AVAudioTimePitchAlgorithmSpectral 或 AVAudioTimePitchAlgoruthmTimeDomain 即可。

5. 视频处理

AVCaptureMovieFileOutput 可以简单地捕捉视频,但是不能进行视频数据交互,因此需要使用 AVCaptureVideoDataOutput 类。AVCaptureVideoDataOutput 是一个 AVCaptureOutput 的子类,可以直接访问摄像头传感器捕捉到的视频帧。与之对应的是处理音频输入的 AVCaptureAudioDataOutput 类。

AVCaptureVideoDataOutput 有一个遵循 AVCaptureVideoDataOutputSampleBufferDelegate 协议的委托对象,它有下面两个主要方法

- (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection; // 有新的视频帧写入时调用,数据会基于 output 的 videoSetting 进行解码或重新编码
- (void)captureOutput:(AVCaptureOutput *)output didDropSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection; // 有迟到的视频帧被丢弃时调用,通常是因为在上面一个方法里进行了比较耗时的操作

5.1 CMSampleBufferRef

CMSampleBufferRef 是一个由 Core Media 框架提供的 Core Foundation 风格的对象,用于在媒体管道中传输数字样本。

5.1.1 样本数据

可以对 CMSampleBufferRef 的每一个 Core Video 视频帧进行处理

    int BYTES_PER_PIXEL = 4;
    CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); //CVPixelBufferRef 在主内存中保存像素数据
    CVPixelBufferLockBaseAddress(pixelBuffer, 0); // 获取相应内存块的锁
    size_t bufferWidth = CVPixelBufferGetWidth(pixelBuffer);
    size_t bufferHeight = CVPixelBufferGetHeight(pixelBuffer);// 获取像素宽高
    unsigned char *pixel = (unsigned char *)CVPixelBufferGetBaseAddress(pixelBuffer); // 获取像素 buffer 的起始位置
    unsigned char grayPixel;
    for (int row = 0; row < bufferHeight; row++) {
        for (int column = 0; column < bufferWidth; column ++) { // 遍历每一个像素点
            grayPixel = (pixel[0] + pixel[1] + pixel[2])/3.0;
            pixel[0] = pixel[1] = pixel[2] = grayPixel;
            pixel += BYTES_PER_PIXEL;
        }
    }
    CIImage *ciImage = [CIImage imageWithCVPixelBuffer:pixelBuffer]; // 通过 buffer 生成对应的 CIImage
    CVPixelBufferUnlockBaseAddress(pixelBuffer, 0); // 解除锁

5.1.2 格式描述

CMSampleBufferRef 还提供了每一帧数据的格式信息,CMFormatDescription.h 头文件定义了大量函数来获取各种信息。

    CMFormatDescriptionRef formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer);
    CMMediaType mediaType = CMFormatDescriptionGetMediaType(formatDescription);

5.1.3 时间信息

    CMTime presentation = CMSampleBufferGetPresentationTimeStamp(sampleBuffer); // 获取帧样本的原始时间戳
    CMTime decode = CMSampleBufferGetDecodeTimeStamp(sampleBuffer); // 获取帧样本的解码时间戳

5.1.4 附加元数据

    CFDictionaryRef exif = (CFDictionaryRef)CMGetAttachment(sampleBuffer, kCGImagePropertyExifDictionary, NULL);

CMAttachment.h 定义了 CMAttachment 形式的元数据协议,可以获取每一帧的底层元数据,如上述获取到图片 Exif 格式的元数据如下

{
    ApertureValue = "1.6959938131099";
    BrightnessValue = "-8.636618904801434";
    ColorSpace = 1;
    DateTimeDigitized = "2018:04:24 14:17:33";
    DateTimeOriginal = "2018:04:24 14:17:33";
    ExposureBiasValue = 0;
    ExposureTime = "0.05882352941176471";
    FNumber = "1.8";
    Flash = 0;
    FocalLenIn35mmFilm = 28;
    FocalLength = "3.99";
    ISOSpeedRatings =     (
        2000
    );
    LensMake = Apple;
    LensModel = "iPhone 8 Plus back camera 3.99mm f/1.8";
    LensSpecification =     (
        "3.99",
        "3.99",
        "1.8",
        "1.8"
    );
    MeteringMode = 5;
    PixelXDimension = 1440;
    PixelYDimension = 1080;
    SceneType = 1;
    SensingMethod = 2;
    ShutterSpeedValue = "4.0608667208218";
    SubsecTimeDigitized = 067;
    SubsecTimeOriginal = 067;
    WhiteBalance = 0;
}

5.2 AVCaptureVideoDataOutput

AVCaptureVideoDataOutput 的配置与 AVCaptureMovieFileOutput 大致相同,但要指明它的委托对象和回调队列。

    self.videoDataOutput = [[AVCaptureVideoDataOutput alloc] init];
    self.videoDataOutput.videoSettings = @{(id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA)}; // 摄像头的初始格式为双平面 420v,这是一个 YUV 格式,而 OpenGL ES 常用 BGRA 格式
    if ([self.captureSession canAddOutput:self.videoDataOutput]) {
        [self.captureSession addOutput:self.videoDataOutput];
        [self.videoDataOutput setSampleBufferDelegate:self queue:dispatch_get_main_queue()];
    }

为了确保视频帧按顺序传递,所以这里的队列要求必须是串行队列。