音视频基础能力之 iOS 视频篇(一):视频采集

涉及硬件的音视频能力,采集、渲染、硬件编码、硬件解码,通常是与客户端操作系统强相关的,就算是跨平台的多媒体框架也必须使用平台原生语言的模块来支持这些功能

本系列文章将详细讲述移动端音视频的采集、渲染、硬件编码、硬件解码这些涉及硬件的能力该如何实现

本文为该系列文章的第 1 篇,将详细讲述在 iOS 平台下如何实现摄像头的视频采集

前言

视频采集,从编程的角度来看,也就是拿到摄像头采集到的图像数据,至于拿到数据之后的用途,可以五花八门,想干嘛就干嘛,比如:存储为照片、写入本地文件、编码后进行传输、本地预览

CMSampleBuffer

在开始之前,必须先了解 CMSampleBuffer 的概念,它可以简单理解为媒体数据之外加了一层封装,在视频相关场景下,其可以包含未编码的视频数据(CVPixelBuffer),也可以包含编码过的视频数据(CMBlockBuffer)

CMSampleBuffer 组成部分

  • CMTime:图像的时间
  • CMVideoFormatDescription:图像格式的描述
  • CMBlockBuffer or CVPixelBuffer:编码后的图像数据 or 未编码的图像数据

整体流程

申请摄像头权限

真正开始视频采集之前,需要在应用层向用户申请摄像头权限

在 App 的 info.plist 中添加键值对,key 为 "Privacy - Camera Usage Description",value 为申请摄像头权限的原因

在启动视频采集之前,检查摄像头权限

  • 如果处于"权限未定"状态,需要调用系统 API 进行权限申请,有结果之后再根据权限确定后续流程
  • 如果处于"已授权"状态,走正常流程
  • 如果处于"未授权"状态,走异常流程,UI 上可能要引导用户自行到手机的设置中打开本应用的摄像头权限
objectivec 复制代码
AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
if (status == AVAuthorizationStatusNotDetermined) {
  // 权限未定,进行申请
  [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:completionHandler];
} else if (status == AVAuthorizationStatusAuthorized) {
  // 已授权,走正常流程
} else {
  // 未授权,走异常流程
}

初始化 + 参数设置

AVCaptureSession

针对视频采集,Apple 只给了一套 API,就是 AVCaptureSession,十分简单明了

AVCaptureSession 的运行需要有 input 和 output

input 通常与摄像头设备关联,也就是 AVCaptureDeviceInput

output 可以有多种类型,本文将着重介绍 AVCaptureVideoDataOutput,就是能直接拿到原始视频数据的 output 类型,其他类型比如 AVCaptureStillImageOutput、AVCaptureMovieFileOutput 都是在原始数据的基础上满足了个性化的需求,例如:拍照、视频存本地

AVCaptureSession 配置完成后,调用 startRunning 接口即可开始视频采集

因此要实现视频采集,AVCaptureSession 简单理解是这个样子

采集启动之后,图像数据的流向可以简单理解为这个样子

AVCaptureSession 常用的接口

  • startRunning:开始采集
  • stopRunning:停止采集
  • beginConfiguration:开始配置
  • commitConfiguration:结束配置

AVCaptureDeviceInput

要创建 input,首先要拿到 AVCaptureDevice,可以理解为摄像头设备在代码中的抽象体现

device 对象的获取:在 iOS 10 及以上,建议使用 AVCaptureDeviceDiscoverySession;iOS 10 以下,使用 devicesWithMediaType 方法即可

ini 复制代码
NSArray* device_list = nil;
if (@available(iOS 10.0, *)) {
  NSArray* device_type_list = @[AVCaptureDeviceTypeBuiltInWideAngleCamera];
  AVCaptureDeviceDiscoverySession* device_discovery_session = [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:device_type_list mediaType:AVMediaTypeVideo position:AVCaptureDevicePositionUnspecified];
  device_list = deviceDiscoverySession.devices;
} else {
  device_list = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];
}

接着选取需要的 device 对象,创建 AVCaptureDeviceInput,将 input 对象关联到 AVCaptureSession

go 复制代码
NSError* error = nil;
AVCaptureDeviceInput* device_input = [[AVCaptureDeviceInput alloc] initWithDevice:self.device error:&error];
if (error) {
  // error logic
}

将 input 对象关联到 AVCaptureSession

lua 复制代码
self.device_input = device_input;
if ([self.capture_session canAddInput:self.device_input]) {
  [self.capture_session addInput:self.device_input];
} else {
  // error logic
}

AVCaptureVideoDataOutput

创建 AVCaptureVideoDataOutput

ini 复制代码
self.data_output = [[AVCaptureVideoDataOutput alloc] init];

配置原始视频数据的格式,使用 NV12,也是 Apple 官方推荐的格式,该格式在 iOS 中效率最高

ini 复制代码
NSNumber* format_value = [NSNumber numberWithInt:kCVPixelFormatType_420YpCbCr8BiPlanarFullRange];
NSDictionary *settings = [NSDictionary dictionaryWithObjectsAndKeys:format_value, kCVPixelBufferPixelFormatTypeKey, nil];
[self.data_output setVideoSettings:settings];

配置视频数据的代理对象和输出线程,需要指定一个串行队列,也就是另开一个线程来做视频数据的接收。代理对象实现的方法参考 AVCaptureVideoDataOutputSampleBufferDelegate

ini 复制代码
dispatch_queue_t video_output_queue = dispatch_queue_create("com.xxx.video.output.queue", DISPATCH_QUEUE_SERIAL);
[self.data_output setSampleBufferDelegate:self queue:video_output_queue];

将 output 对象关联到 AVCaptureSession

lua 复制代码
if ([self.capture_session canAddOutput:self.data_output]) {
  [self.capture_session addOutput:self.data_output];
} else {
  // error logic
}

在其他的教程中,可能会出现使用 AVCaptureConnection 来继续配置输出参数,最常见的有视频的方向和镜像,但出于性能考虑,不建议使用 AVCaptureConnection 来做,因为视频采集在常见的音视频业务场景里只是一个巨大框架下的最开始环节,应该尽量避免一些额外的耗时操作

当然,针对一些简单的场景,可以通过 AVCaptureConnection 直接指定视频方向和是否镜像

objectivec 复制代码
self.data_output_connection = [self.data_output connectionWithMediaType:AVMediaTypeVideo];
if (self.dataOutputConnection.isVideoOrientationSupported) {
  self.data_output_connection.videoOrientation = AVCaptureVideoOrientationPortrait;
}

if (self.dataOutputConnection.isVideoMirroringSupported) {
  // iOS 和 macOS 平台,isVideoMirroringSupported 都会返回 YES
  // 但是设置 videoMirrored 之后,只有 iOS 上使用前置摄像头时才会生效
  self.data_output_connection.videoMirrored = YES;
}

以视频的方向为例,设备竖屏放置时,摄像头采集出来的画面其实是横屏的,需要顺时针旋转 90 度才是预期内竖屏的画面

通过 AVCaptureConnection 可以让系统帮忙把旋转 90 度的操作在采集阶段做掉,但会引入性能损耗,因此推荐放在后续环节。具体细节可参考 Apple 官方文档 developer.apple.com/documentati...

配置分辨率和帧率

最简单的方法,通过 AVCaptureSession setSessionPreset 方法设置系统预设的分辨率和帧率,系统提供的 preset 在字面意思中只会体现分辨率信息,不体现帧率,帧率通常是 30

常见的 preset

  • AVCaptureSessionPreset640x480
  • AVCaptureSessionPreset1280x720
  • AVCaptureSessionPreset1920x1080

进阶一点的方法,当我们想实现分辨率和帧率的自由组合,可以通过 device 对象的 formats 属性去寻找,找到合适的之后,再设置给 device

(注意:根据 AVCaptureVideoDataOutput 章节提到的采集画面默认为横屏的特点,分辨率宽高应该按照横屏进行设置)

ini 复制代码
uint32_t target_fps = 60;
uint32_t target_width = 1920;
uint32_t target_height = 1080;
for (AVCaptureDeviceFormat *format in self.device.formats) {
  NSUInteger max_frame_rate = format.videoSupportedFrameRateRanges.firstObject.maxFrameRate;
  if (max_frame_rate < target_fps) {
    continue;
  }
  
  NSArray<AVFrameRateRange *> *range_list = format.videoSupportedFrameRateRanges;
  for (AVFrameRateRange *range in range_list) {
    if (target_fps == (NSUInteger)range.maxFrameRate) {
      // 匹配到了想要的帧率
      CMFormatDescriptionRef description = format.formatDescription;
      CMVideoDimensions dimensions = CMVideoFormatDescriptionGetDimensions(description);
      if (dimensions.width == target_width &&
          dimensions.height == target_height) {
        // 匹配到了想要的分辨率
        [self.device setActiveFormat:format];
        [self.device setActiveVideoMinFrameDuration:range.minFrameDuration];
        [self.device setActiveVideoMaxFrameDuration:range.minFrameDuration];
        return;
      }
    }
  }
}

// logic:没匹配到

注意事项

配置参数的流程需要额外注意,AVCaptureDevice 和 AVCaptureSession 不是随时随地都能进行配置的

  • AVCaptureDevice 需要先锁定、再修改参数、最后解锁
  • AVCaptureSession 需要先开始配置、再修改参数、最后结束配置

因此配置阶段的流程大致如下

objectivec 复制代码
// 锁定 device,开始配置
[self.device lockForConfiguration:NULL];

// 开始配置 AVCaptureSession
[self.captureSession beginConfiguration];

// 配置 input

// 配置 output

// 结束配置 AVCaptureSession
[self.captureSession commitConfiguration];

// 针对 device 设置分辨率和帧率

// 解锁 device,结束配置
[self.device unlockForConfiguration];

处理数据回调

采集开始后数据会通过 AVCaptureVideoDataOutputSampleBufferDelegate 协议的 - (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection 方法给出,且该方法被调用的线程,就是之前我们创建的串行队列对应的线程

采集到的原始视频数据,存放在 CMSampleBuffer 中,前面的章节也提到,CMSampleBuffer 可以包含未编码的视频数据,存放在 CVPixelBuffer 中,获取 CVPixelBuffer 的代码如下

ini 复制代码
CVPixelBufferRef pixel_buffer = CMSampleBufferGetImageBuffer(sampleBuffer);

拿到 CVPixelBuffer 之后,视频采集的环节基本可以告一段落了,CVPixelBuffer 可以拿来做硬件编码、渲染,也可以直接把视频数据提取出来做其他的逻辑

从 CVPixelBuffer 中提取数据时需要额外注意 stride 和 width 可能不同,如果不同需要做逐行拷贝(stride 的概念可参考我们的公众号文章音视频处理必读:YUV格式详解及内存对齐技巧

ini 复制代码
// 提取数据之前需要锁定 CVPixelBuffer
CVPixelBufferLockBaseAddress(pixelBuffer, 0);

size_t pixelWidth = CVPixelBufferGetWidth(pixelBuffer);
size_t pixelHeight = CVPixelBufferGetHeight(pixelBuffer);

unsigned long dataLength = 0;
unsigned char* outputData = NULL;
// 提取 NV12 数据
dataLength = pixelWidth * pixelHeight / 2 * 3;
outputData = (unsigned char *)malloc(dataLength);
memset(outputData, 0, dataLength);
        
unsigned char *yData = (unsigned char *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0);
unsigned char *uvData = (unsigned char *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1);
size_t yDataSizePerRow = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0);
size_t uvDataSizePerRow = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1);
size_t yDataSize = pixelWidth * pixelHeight;
if (pixelWidth != yDataSizePerRow) {
  // 考虑到 pixelBuffer 中内存对齐
  // 当每行数据长度与视频宽不一致时,逐行进行数据拷贝
  for (int i = 0; i < pixelHeight; i++) {
    memcpy(outputData + pixelWidth * i, yData + yDataSizePerRow * i, pixelWidth);
  }
  for (int i = 0; i < (pixelHeight >> 1); i++) {
    memcpy(outputData + yDataSize + pixelWidth * i, uvData + uvDataSizePerRow * i, pixelWidth);
  }
} else {
  // 直接拷贝
  memcpy(outputData, yData, yDataSize);
  memcpy(outputData + yDataSize, uvData, yDataSize >> 1);
}

// 数据处理完之后需要解锁 CVPixelBuffer
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);

写在最后

以上就是本文的所有内容了,主要介绍了如何在 iOS 平台实现摄像头的视频采集。

本文为音视频基础能力系列文章的第 1 篇,后续精彩内容,敬请期待。

如果您觉得以上内容对您有所帮助的话,可以关注下我们运营的公众号"声知视界",会定期的推送 音视频技术、移动端技术 为主轴的 科普类、基础知识类、行业资讯类等相关文章。

相关推荐
关键帧Keyframe3 天前
音视频面试题集锦第 15 期 | 编辑 SDK 架构 | 直播回声 | 播放器架构
音视频开发·视频编码·客户端
关键帧Keyframe8 天前
iOS 不用 libyuv 也能高效实现 RGB/YUV 数据转换丨音视频工业实战
音视频开发·视频编码·客户端
关键帧Keyframe10 天前
音视频面试题集锦第 7 期
音视频开发·视频编码·客户端
关键帧Keyframe10 天前
音视频面试题集锦第 8 期
ios·音视频开发·客户端
蚝油菜花15 天前
MimicTalk:字节跳动和浙江大学联合推出 15 分钟生成 3D 说话人脸视频的生成模型
人工智能·开源·音视频开发
音视频牛哥17 天前
Android平台RTSP|RTMP播放器高效率如何回调YUV或RGB数据?
音视频开发·视频编码·直播
<Sunny>19 天前
MPP音视频总结
音视频开发·1024程序员节·海思mpp
GoFly开发者19 天前
GoFly快速开发框架已集成了RTSP流媒体服务器(直播、录播)代码插件-开发者集成流媒体服务器更加方便
go·音视频开发
音视频牛哥1 个月前
如何设计开发RTSP直播播放器?
音视频开发·视频编码·直播