AV Foundation景深模式

2,725 阅读11分钟

本节主要内容:

  1. 深度和视差
  2. 深度数据
  3. 深度数据的捕获和保存

深度和视差

在2017年,也就是iOS 11的时候,苹果推出了相机上的一个大变革——景深,有兴趣的同学可以观看下WWDC17第507视频,本文绝大部分参考来源于视频中的内容和相关文档。

首先,我们理解下何为景深?

根据官方描述,景深是指在摄影机镜头或其他成像器前沿能够取得清晰图像的成像所测定的被摄物体前后距离范围,简单理解就是摄像头拍照时获取到图片中的物体在现实世界的远近数据,让本是2D的数据能描述成3D。这技术实现肯定离不开硬件的支持,但聪明的读者肯定会发现,为什么在iPhone X上,后置和前置摄像头都能实现景深效果,为什么后置需要装两个,前置只需要一个呢?

我们先说后置,为什么需要通过两个摄像头才能支持景深,其实栋爷的文章早已给了答案,但我这里还是要再说一下——视差。苹果的后置景深并不算是真正的景深,而是通过跳眼法,也就是使用两个摄像头的成图的视差,然后根据一定条件,计算出视差与物体景深存在的线性相关,可能有点啰嗦,我们看图:

截屏2021-01-10 下午9.21.20.png

摄像头的成像原理,我们可以抽象成小孔成像,由于光是直线传播的,当观察点经过Camera1和Camera2的透视后,最终会落在不同的位置,那么根据图中两个黄色圈起来的三角形可以发现,这两个三角形是一个相似三角形。那这就好办了,我们继续看下面的图:

截屏2021-01-10 下午10.14.55.png 其中,disparity(简称d)表示观察点在Camera1和Camera2上的成像视差,focal length(简称fl)表示焦距,Baseline(简称d)表示两个光学中心的距离,z表示观察点的在物理世界的位置,通过计算,能够证明视差能够一定程度上描述出物体的景深情况。

因此,后置摄像头景深的本质是通过计算视差得出来的。说完后置,那前置为什么就可以依赖一个摄像头捕获景深数据,答案是红外线,所以前置景深摄像头有个很有意思的名字:builtInTrueDepthCamera,可能就是真的景深数据吧。

深度数据

既然苹果摄像头带来了景深这一新特性,那么景深数据是长什么样的呢?

在iOS 11中,苹果新出了一个类用于描述景深数据:AVDepthData,AVDepthData内部提供了一系列的属性和方法来获取景深的CVPixelBuffer,景深数据类型等,AVDepthData在iOS、macOS等平台上都是用来描述景深数据,且属于AVFundation框架。

AVDepthData有总共有两种数据类型,每种数据类型分16位和32位,如下所示:

public var kCVPixelFormatType_DisparityFloat16: OSType { get } /* 'hdis' */
public var kCVPixelFormatType_DisparityFloat32: OSType { get } /* 'hdis' */
public var kCVPixelFormatType_DepthFloat16: OSType { get } /* 'hdep' */
public var kCVPixelFormatType_DepthFloat32: OSType { get } /* 'fdep' */
  • kCVPixelFormatType_DisparityX表示的是视差数据,表示的是1/米的形式
  • kCVPixelFormatType_DepthX表示的是深度数据,用米表示
  • 16位和32位的区别,根据官方描述说CPU上直接使用32位的数据,而GPU上直接使用16位的数据(本人才疏学浅,暂无法验证和反驳)
  • AVDepthData的所拥有的数据类型由摄像头决定,可以调整,也提供了数据转换的接口,代码示例如下:
depthData = depthData.converting(toDepthDataType: kCVPixelFormatType_DepthFloat16)

接着我们来看看AVDepthData的核心属性:

open class AVDetphData: NSObject {
    open var depthDataType: OSType { get }
    open var depthDataMap: CVPixelBuffer { get }
    open var isDepthDataFiltered: Bool { get }
    open var depthDataAccuracy: AVDepthDataAccuracy { get }
}

public enum Accuracy: Int {
    case relative
    case absolute
 }
  • depthDataType指的是数据类型,前面我们已经描述过了;

  • depthDataMap指的是景深的数据缓冲区,可以转成UIImage;

  • isDepthDataFiltered指的是是否启动插值,这个属性的目的有两个,一个是在实时景深(后续会聊到),需要做前一帧和后一帧的平滑过渡,第二个是由于遮挡或者弱光,导致取不到景深数据,对取不到的地方根据已有的数据做插值,如下所示:

  • depthDataAccuracy表示景深数据的准确度,有相对和绝对,区别在于绝对代表数据可以反应现实距离,之所以有相对是因为需要进行各种校准和误差,虽然仍具有深度数据,但其深度数据已经无法客观表示在现实世界的真实景深情况。

所以我们可以知道,能够调用了景深的API,也不一定保证能够获取到准确的景深数据,有以下几点原因:

  • 两个摄像头观察点a时,其中一个摄像头观察不到点a,导致无法生成视差数据,推断不出深度数据,如挡住其中一个摄像头。
  • 观察的点a变暗了,或者颜色嘈杂,无法识别特征,如拍一面白墙
  • 光学中心计算错误,是无法修正的。

深度数据的捕获和保存

前面我们描述了一堆篇理论的东西,终于来到了实践的环节。苹果在景深上提供了两种捕获方式,一个是实时景深,也就是跟着视频流一起将景深数据输出到外部;一个是拍照时的景深,在成像的同时顺便将景深数据输出到外部。我们来看下两者有什么不一样。

前提条件

景深捕获依赖于摄像头设备,这里总结一下支持景深的摄像头:

类型名称系统版本支持机型备注
后置builtInDualWideCameraiOS 13起iPhone 11、iPhone 11 Pro、iPhone 11 Pro Max、iPhone 12 mini、iPhone 12、iPhone 12 Pro、iPhone 12 Pro Max超广角+广角
builtInDualCameraiOS 10.2起iPhone X、iPhone 11 Pro、iPhone 11 Pro Max、iPhone 12 Pro、iPhone 12 Pro Max广角+长焦
前置builtInTrueDepthCameraiOS 11.1起iPhone X以上机型(包含iPhone X)广角+红外

当我们选择了设备之后,对于builtInTrueDepthCamera,默认是生成和后置一样的视差数据,但可以更改成景深的绝对数据,如下所示:

let availableFormats = captureDevice.activeFormat.supportedDepthDataFormats

let depthFormat = availableFormats.filter { format in
    let pixelFormatType =
        CMFormatDescriptionGetMediaSubType(format.formatDescription)
    
    return (pixelFormatType == kCVPixelFormatType_DepthFloat16 ||
            pixelFormatType == kCVPixelFormatType_DepthFloat32)
}.first

// Set the capture device to use that depth format.
captureSession.beginConfiguration()
captureDevice.activeDepthDataFormat = depthFormat
captureSession.commitConfiguration()

实时景深捕获

在iOS 11中,AVCaptureOutput迎来新一个儿子——AVCaptureDepthDataOutput,一听这名字,就知道和景深有关。聪明的你没猜错,它是实现实时景深的关键,我们简称它为DDO(苹果内部简称),其作用如下图所示:

截屏2021-01-11 上午11.18.18.png

我们可以看到DDO只有一个功能,就是输出AVDepthData,那么原先我们使用AVCaptureVideoDataOutput(简称VDO)来输出视频流,现在还得接着用,不然没法拿到视频流,这么一下来就有两个流需要我们处理,更为致命的是,两个流的FPS还不一样,如何协同两个流成了个问题。让我们先回到DDO本身,再去处理协同问题。

AVCaptureDepthDataOutput主要使用输出视频流中的景深数据,其数据格式取决于初始化AVCaptureDevice时activeDepthDataForamt有关,前面提到了如何更改格式,可以返回去看一看,这里有几个点需要注意一下:

  • 只能在带景深数据的摄像头参与下才能发挥效果;
  • 摄像头初始化后,其焦距本身就固定了,不能再调动;
  • 可以使用supportedDepthDataFormats查看支持的景深数据格式;
  • 使用activeDepthDataFormat设置景深数据格式。

接下去我们就将DDO给加入到cameraSession中,代码示例如下:

self.depthOutput = AVCaptureDepthDataOutput()
self.depthOutput.isFilteringEnabled = true
	if self.cameraSession.canAddOutput(self.depthOutput) {
       self.cameraSession.addOutput(self.depthOutput)
    }
if let depthConnection = self.depthOutput.connection(with: .depthData) {
   depthConnection.isEnabled = true
   depthConnection.videoOrientation = .portrait
   depthConnection.isVideoMirrored = location == .frontFacing
}

前面我们提到VDO和DDO的两个流帧率不一样,其情况是这样的,我们先看一张图:

截屏2021-01-11 上午11.32.56.png

从图中我们可以知道,DDO的景深数据分辨率和帧率会比VDO的低。众所周知,VDO是允许我们调整帧率的,但苹果不允许我们去调整DDO的帧率,目前可知最大是24fps,最小是15fps,却又会受到VDO的影响,比如VDO是30fps,那么DDO就会切换成15fps。当使用景深时遇到性能问题,调整VDO帧率是个有效的方法之一,那我们如何去修改帧率呢?示例代码如下:

let depthDataMaxFrameRate = sender.value
let newMinDuration = Double(1) / Double(depthDataMaxFrameRate)
let duration = CMTimeMaximum(videoInput.device.activeVideoMinFrameDuration, CMTimeMakeWithSeconds(newMinDuration, preferredTimescale: 1000))
do {
    try self.videoInput.device.lockForConfiguration()
    self.videoInput.device.activeDepthDataMinFrameDuration = duration
    self.videoInput.device.unlockForConfiguration()
} catch {
    print("Could not lock device for configuration: \(error)")
}

既然存在帧率不一致,那么如何去统一协调两个流的输出,答案是AVCaptureDataOutputSynchronizer,它也是在iOS 11推出的,带着同步所有流的特殊使命诞生的。至此,Camera的架构又有了新的调整,如图所示:

截屏2021-01-11 上午11.43.18.png

  • 对于不同的输出流,帧率不一样,方法回调频率也不一样,而一切都让AVCaptureDataOutputSynchronizer直接统一了接口的回调;
  • 所有数据直接通过dataOutputSynchronizer(_:didOutput:)回调到外层,并且封装到AVCaptureSynchronizedDataCollection中去,通过类似字典的方式取出各自的流数据。

前面我们为DDO配置了输入端,现在需要配备输出端,示例代码如下:

self.dataOutputSynchronizer = AVCaptureDataOutputSynchronizer(dataOutputs: [self.videoOutput, self.depthOutput])
self.dataOutputSynchronizer.setDelegate(self, queue: self.cameraProcessingQueue)

然后我们就可以在dataOutputSynchronizer(_:didOutput:)获得到AVCaptureSynchronizedDataCollection实例,示例如下:

func dataOutputSynchronizer(_ synchronizer: AVCaptureDataOutputSynchronizer, didOutput synchronizedDataCollection: AVCaptureSynchronizedDataCollection) {
	if let depthBufferData = synchronizedDataCollection.synchronizedData(for: self.depthOutput) as? AVCaptureSynchronizedDepthData {
    	let depthData = depthBufferData.depthData
        if depthBufferData.depthDataWasDropped || bufferData.sampleBufferWasDropped {
    	    return
        }
    }
}

到这里我们简单归纳下实时景深的步骤:

  1. 获取支持捕获景深数据流的摄像头设备;
  2. 构建VDO和DDO和AVCaptureDataOutputSynchronizer实例,将VDO和DDO扔到里面去;
  3. 在dataOutputSynchronizer(_:didOutput:)中获取各自的流数据。

静态景深捕获

既然相机在实时的时候能捕获景深数据,为何在拍照的时候还要再来一份?这个前面其实已经告诉我们答案了——分辨率,可以看到,实时捕获到的景深分辨率低的可怜,若用于4032x3024的图片上,简直锯齿到不能再锯齿。所以我们看下静态景深的分辨率情况:

截屏2021-01-11 下午2.13.32.png

静态景深图数据捕获其实也不是很复杂,首先是需要将AVCapturePhotoOutput的景深功能打开,示例代码如下:

self.photoOutput.isDepthDataDeliveryEnabled = self.photoOutput.isDepthDataDeliverySupported

接着在拍照的时候对AVCapturePhotoSettings的景深捕获作下设置:

captureSetting.isDepthDataDeliveryEnabled = self.photoOutput.isDepthDataDeliverySupported

最后就可以在photoOutput(_:didFinishProcessingPhoto:error:)中获取景深数据:

func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
	if let depthData = photo.depthData {
            
     }
}

景深数据的保存

苹果在景深数据的保存上提供了JPEG容器和HEVC容器,HEVC上能够存储更加多的信息,如是否使用了插值、精度、校准等,支持了CIImage和Image I/O等方式存储到相册中。我在这里展示两份示例代码,一份是使用CIImage将景深图存储到相册,另一份是使用Image I/O将数据写进原图片并让系统相册识别成人像图。

首先展示的是用CIImage将景深图保存到系统相册:

// 将景深数据转成CIImage对象
let ciImage = CIImage( cvImageBuffer: depthData.depthDataMap,
                             options: [.auxiliaryDepth: true,
                                       .colorSpace: perceptualColorSpace])
// 将CIImage转成Data
guard let imageData = context.heifRepresentation(of: ciImage,
                                                 format: .RGBA8,
                                                 colorSpace: perceptualColorSpace,
                                                 options: [.depthImage: ciImage]) else { return }
// 保存到系统相册
let creationRequest = PHAssetCreationRequest.forAsset()
creationRequest.addResource(with: .photo,
                            data: imageData,
                            options: nil)

以下是使用Image I/O将景深图写入原图,并可被系统识别和获取:

// 设置景深标志一
var exifInfo = metaDataDic[kCGImagePropertyExifDictionary as String]
exifInfo[kCGImagePropertyExifCustomRendered] = 7
// 设置景深标志二
var makerApple = metaDataDic[kCGImagePropertyMakerAppleDictionary as String]
let makerApple["25"] = 106
// 更新
metaDataDic[kCGImagePropertyExifDictionary as String] = exifInfo
metaDataDic[kCGImagePropertyMakerAppleDictionary as String] = makerApple

// 将CGImage转成CFData
guard let incrementData = CFDataCreateMutable(CFAllocatorGetDefault().takeUnretainedValue(), 0), let cgImage = image.cgImage else {
	return
}
guard let destination = CGImageDestinationCreateWithData(incrementData, "public.jpeg" as CFString, 1, nil) else { return }
// 将图片元数据写入图片,这里需要将
CGImageDestinationAddImage(destination, cgImage, metaDataDic as CFDictionary)
var depthAuxDataType :NSString?
let depthAuxData = depthData.dictionaryRepresentation(forAuxiliaryDataType: &depthAuxDataType)
// 写入景深数据
CGImageDestinationAddAuxiliaryDataInfo(destination, depthAuxDataType!, depthAuxData! as CFDictionary)
// 提交
if !CGImageDestinationFinalize(destination) {
	debugPrint("写入失败")
}

结语

关于Camera景深捕获就先暂时总结到这里,本章主要侧重描述景深的本质、景深的相关类、景深的捕获和保存,希望读者能从其中受益匪浅。笔者也相信,根据硬件的墨菲定律,后面关于景深这块技术肯定会有更多进步和突破,笔者也在后面继续对对关于景深的新特性做维护和更新。对于本章若有什么描述不准确和不恰当,欢迎指正。