iOS 中使用 和 Metal 的照片 Vision

192 阅读15分钟

入门

在本教程中,您将使用 Vision Frame 来学习如何:

  • 使用 . 地球地球的图像VNTranslationalImageRegistrationRequest
  • CIFilter使用 Metal 内核创建自定义。
  • 使用此过滤器组合多个图像以移除任何移动对象。

:由于您需要在使用服务器和Metal,因此必须在实际设备而不是在您的教程中显示您正在运行它。

01.jpg

你应该会看到一些看起来像摄像头应用程序的东西。有一个简单的按钮,周围有一个白色的环,它全屏显示摄像头输入。

你肯定已经发现了它并在下面找到了CameraViewController。那是因为它设置为在任务程序中寻找两行要查看的位置configureCaptureSession()

camera.activeFrameDuration = 时间(值:1,最大时间:5camera.activeVideoFrameDuration =  CMTime(值:1,时间亮:5

第一行强制最大帧速率为每秒五帧。第二行将最小帧速率定义为相同。两条线一起要求相机以所需的帧速率运行。

如果您点击录制按钮,您应该会看到外面的白色环顺时针填充。然而,当它完成时,什么也没有发生。

你现在必须为此做点什么。

将图像保存到文件应用程序

为了帮助您在进行过程中调试应用程序,最好将您正在使用的图像保存到“*文件”*应用程序中。幸运的是,这比听起来容易得多。

将以下两个键添加到您的Info.plist

  1. 应用程序支持 iTunes 文件共享。
  2. 支持就地打开文档。

将它们的值都设置为YES。完成后,文件应如下所示:

02.png

第一个键为Documents目录中的文件启用文件共享。第二个让您的应用程序从文件提供程序打开原始文档,而不是接收副本。当这两个选项都启用时,存储在应用程序的Documents目录中的所有文件都会出现在文件应用程序中。这也意味着其他应用程序可以访问这些文件。

现在您已授予Files应用程序访问Documents目录的权限,是时候在其中保存一些图像了。

与启动项目捆绑在一起的是一个struct名为ImageSaver. 实例化时,它会生成一个通用唯一标识符 (UUID) 并使用它在Documents目录下创建一个目录。这是为了确保您不会覆盖以前保存的图像。您将ImageSaver在您的应用程序中使用将图像写入文件。

CameraViewController.swift中,在类的顶部定义一个新变量,如下所示:

var saver: ImageSaver ?

然后,滚动到recordTapped(_:)并在方法末尾添加以下内容:

保护程序=  ImageSaver ()

每次点击录制按钮时,您都会在此处创建一个新ImageSaver的,以确保每个录制会话都将图像保存到新目录。

接下来,滚动到并在初始语句captureOutput(_:didOutput:from:)之后添加以下代码:if

// 1个
守卫 
  let imageBuffer =  CMSampleBufferGetImageBuffer (sampleBuffer),
   let cgImage =  CIImage (cvImageBuffer: imageBuffer).cgImage() 
   else {
     return
}
// 2 
let image =  CIImage (cgImage: cgImage)
 // 3 
saver ? .write(图像)

使用此代码,您可以:

  1. 从捕获的样本缓冲区中提取CVImageBuffer并将其转换为CGImage.
  2. 将 转换CGImageCIImage.
  3. 将图像写入Documents目录。

注意:为什么必须将样本缓冲区转换为 a CIImage,然后转换为 a CGImage,最后又转换回 a CIImage?这与谁拥有数据有关。当您将样本缓冲区转换为 aCIImage时,图像会存储对样本缓冲区的强引用。不幸的是,对于视频捕获,这意味着几秒钟后,它将开始丢帧,因为分配给样本缓冲区的内存不足。通过使用 a渲染CIImagea ,您可以复制图像数据,并且可以释放样本缓冲区以再次使用。CGImage``CIIContext

现在,构建并运行应用程序。点击录制按钮,完成后,切换到文件应用程序。在Evanesco文件夹下,您应该会看到一个以 UUID 命名的文件夹,其中包含 20 个项目。

03.png

UUID 命名文件夹

如果您查看此文件夹,您会发现在 4 秒的录制过程中捕获的 20 帧。

04.jpg

捕获的帧

*注意:如果您没有立即看到该文件夹,请使用“**文件”*应用顶部的搜索栏。

嗯不错。那么你能用 20 张几乎相同的图像做什么呢?

照片堆叠

在计算摄影中,照片堆叠是一种技术,其中捕获、对齐和组合多个图像以创建不同的所需效果。

例如,HDR 图像是通过在不同曝光水平下拍摄多张图像并将每张图像的最佳部分组合在一起而获得的。这就是您可以在 iOS 中同时查看阴影和明亮天空中的细节的方式。

天文摄影也大量使用照片堆叠。图像曝光时间越短,传感器拾取的噪点就越少。所以天文摄影师通常会拍摄一堆短曝光图像并将它们堆叠在一起以增加亮度。

在微距摄影中,很难同时对焦整个图像。使用照片堆叠,摄影师可以拍摄几张不同焦距的图像,然后将它们组合起来,生成非常清晰的非常小的物体图像。

要将图像组合在一起,您首先需要对齐它们。如何?iOS 提供了一些有趣的 API 来帮助你。

使用视觉对齐图像

Vision框架有两个不同的 API 用于对齐图像:VNTranslationalImageRegistrationRequestVNHomographicImageRegistrationRequest. 前者更容易使用,如果你假设应用程序的用户会相对静止地握住 iPhone,它应该足够好。

注意:如果您从未使用过 Vision 框架,请查看Face Detection Tutorial Using the Vision Framework for iOS,了解有关 Vision 请求如何工作的一些信息。

为了使您的代码更具可读性,您将创建一个新类来处理捕获的图像的对齐和最终组合。

创建一个新的空Swift 文件并将其命名为ImageProcessor.swift

删除任何提供的导入语句并添加以下代码:

导入CoreImage
导入Vision

类 ImageProcessor {
   var frameBuffer: [ CIImage ] = []
   var alignedFrameBuffer: [ CIImage ] = []
   var completion: (( CIImage ) -> Void ) ? 
  var isProcessingFrames =  false

  var frameCount: Int {
     return frameBuffer.count
  }
}

在这里,您导入Vision框架并定义ImageProcessor类以及一些必要的属性:

  • frameBuffer将存储原始捕获的图像。
  • alignedFrameBuffer将包含对齐后的图像。
  • 完成是一个处理程序,将在图像对齐和组合后调用。
  • isProcessingFrames将指示图像当前是否正在对齐和组合。
  • frameCount是捕获的图像数。

接下来,将以下方法添加到ImageProcessor类中:

func  add ( _frame  : CIImage ) {
   if isProcessingFrames {
     return
  }
  frameBuffer.append(帧)
}

此方法将捕获的帧添加到帧缓冲区,但前提是您当前未处理帧缓冲区中的帧。

还是在类里面,添加处理方法:

func  processFrames ( completion : (( CIImage ) -> Void ) ? ) {
   // 1 
  isProcessingFrames =  true   
  self .completion = completion
   // 2 
  let firstFrame = frameBuffer.removeFirst()
  对齐的FrameBuffer.append(firstFrame)
  // 3 
  for frame in frameBuffer {
     // 4 
    let request =  VNTranslationalImageRegistrationRequest (targetedCIImage: frame)

    do {
       // 5       
      let sequenceHandler =  VNSequenceRequestHandler ()
       // 6 
      try sequenceHandler.perform([request], on: firstFrame)
    }捕捉{
      打印(error.localizedDescription)
    }
    // 7
    alignImages(请求:请求,帧:帧)
  }
  // 8
  清理()
}

看起来步骤很多,但这种方法相对简单。添加所有捕获的帧,您将调用此方法。它将处理每一帧并使用Vision框架对齐它们。具体来说,在这段代码中,您:

  1. 设置isProcessingFrames布尔变量以防止添加更多帧。您还可以保存完成处理程序以供以后使用。
  2. 从帧缓冲区中删除第一帧并将其添加到对齐图像的帧缓冲区。所有其他帧将与这一帧对齐。
  3. 循环遍历帧缓冲区中的每一帧。
  4. 使用框架创建新的视觉请求以确定简单的平移对齐。
  5. 创建序列请求处理程序,它将处理您的对齐请求。
  6. 执行Vision请求以将帧与第一帧对齐并捕获任何错误。
  7. alignImages(request:frame:)使用请求和当前帧调用。此方法尚不存在,您将很快解决。
  8. 清理。这个方法还需要写。

准备好应对了alignImages(request:frame:)吗?

在下面添加以下代码processFrames(completion:)

func  alignImages ( request : VNRequest , frame : CIImage ) {
   // 1 
  guard  
    let results = request.results as? [ VNImageTranslationAlignmentObservation ],
    让result = results.first 
     else {
       return
  }
  // 2 
  let alignedFrame = frame.transformed(by: result.alignmentTransform)
   // 3
  alignedFrameBuffer.append(alignedFrame)
}

你在这里:

  1. 解开您forprocessFrames(completion:).
  2. 使用Vision框架计算的仿射变换矩阵变换帧。
  3. 将此翻译后的帧附加到对齐的帧缓冲区。

最后两种方法是您的应用所需的*Vision代码的核心。*您执行请求,然后使用结果来修改图像。现在剩下的就是清理自己。

将以下方法添加到ImageProcessor类的末尾:

功能 清理(){
  帧缓冲区= []
  对齐帧缓冲区= []
  isProcessingFrames = 错误
  完成=  nil
}

cleanup()中,您只需清除两个帧缓冲区,重置标志以指示您不再处理帧并将完成处理程序设置为nil

在您可以构建和运行您的应用程序之前,您需要ImageProcessor在您的CameraViewController.

打开CameraViewController.swift。在类的顶部,定义以下属性:

让图像处理器=图像 处理器()

接下来,找到captureOutput(_:didOutput:from:). 您将对这个方法进行两个小改动。

在该行下方添加以下let image = ...行:

imageProcessor.add(图像)

在对 的调用下方stopRecording(),仍在if语句中,添加:

imageProcessor.processFrames(完成:displayCombinedImage)

构建并运行您的应用程序,然后……什么也没有发生。不用担心,波特先生。您仍然需要将所有这些图像组合成一个杰作。要了解如何做到这一点,您必须继续阅读!

注意:如果您想查看对齐的图像与原始捕获的比较,您可以ImageSaverImageProcessor. 这将允许您将对齐的图像保存到 Documents 文件夹并在 Files 应用程序中查看它们。

照片堆叠的工作原理

有几种不同的方法可以将图像组合或堆叠在一起。到目前为止,最简单的方法是将图像中每个位置的像素平均在一起。

例如,如果您有 20 张图像要堆叠,您可以将所有 20 张图像的坐标 (13, 37) 处的像素平均在一起,以获得堆叠图像在 (13, 37) 处的平均像素值。

05.png

像素堆叠

如果您对每个像素坐标执行此操作,您的最终图像将是所有图像的平均值。您拥有的图像越多,平均值就越接近背景像素值。如果某物在相机前移动,它只会出现在几张图像中的同一位置,因此它对整体平均值的贡献不大。这就是移动物体消失的原因。

这就是您实现堆叠逻辑的方式。

堆叠图像

现在是真正有趣的部分!您将把所有这些图像组合成一个奇妙的图像。您将使用金属着色语言 (MSL)创建自己的**核心映像内核

您的简单内核将计算两个图像的像素值的加权平均值。当您将一堆图像平均在一起时,任何移动的物体都应该消失。背景像素会更频繁地出现并主导平均像素值。

创建核心映像内核

您将从使用 MSL 编写的实际内核开始。MSL 与 C++ 非常相似。

将一个新的金属文件添加到您的项目中,并将其命名为AverageStacking.metal。保留模板代码并将以下代码添加到文件末尾:

#包括 <CoreImage/CoreImage.h>

extern  "C" { namespace coreimage {
   // 1 
  float4 avgStacking ( sample_t currentStack, sample_t newImage, float stackCount)  {
     // 2 
    float4 avg = ((currentStack * stackCount) + newImage) / (stackCount + 1.0 );
    // 3 
    avg = float4 (avg.rgb, 1 );
    // 4
    返回平均值;
  }
}}

使用此代码,您可以:

  1. 定义一个名为 的新函数avgStacking,它将返回一个包含 4 个浮点值的数组,代表像素颜色红色、绿色和蓝色以及一个 alpha 通道。该功能将一次应用于两张图像,因此您需要跟踪所有看到的图像的当前平均值。该currentStack参数表示该平均值,stackCount而是一个数字,表示如何使用图像来创建currentStack.
  2. 计算两个图像的加权平均值。由于currentStack可能已经包含来自多个图像的信息,因此您将其乘以stackCount以赋予其适当的权重。
  3. 将 alpha 值添加到平均值以使其完全不透明。
  4. 返回平均像素值。

注意:理解这个函数对于两个图像之间的每一对对应像素将被调用一次是非常重要的。数据类型是来自图像的sample_t像素样本。

好的,现在你有了一个内核函数,你需要创建一个CIFilter来使用它!向项目中添加一个新的Swift 文件并将其命名为**AverageStackingFilter.swift。删除 import 语句并添加以下内容:

导入CoreImageAverageStackingFilter:CIFilter {
  让内核:CIBlendKernel 
  var inputCurrentStack:CIImage?
  var inputNewImage: CIImage ?
  var inputStackCount =  1.0
}

在这里,您正在定义您的新CIFilter类和一些您需要的属性。请注意三个输入变量如何对应于内核函数中的三个参数。巧合?;]

至此,Xcode 可能正在抱怨这个类缺少初始化程序。所以,是时候解决这个问题了。将以下内容添加到类中:

override  init () {
   // 1
  保护 let url =  Bundle .main.url(forResource: "default" ,
                                  withExtension: "metallib" ) else {
     fatalError ( "Check your build settings." )
  }
  do {
     // 2 
    let data =  try  Data (contentsOf: url)
     // 3 
    kernel =  try  CIBlendKernel (
      函数名称:“avgStacking”,
      来自MetalLibraryData:数据)
  } catch {
     print (error.localizedDescription)
     fatalError ( "确保函数名匹配" )
  }
  // 4
  超级. 初始化()
}

使用此初始化程序,您可以:

  1. 获取已编译和链接的 Metal 文件的 URL。
  2. 读取文件的内容。
  3. 尝试CIBlendKernelavgStackingMetal 文件中的函数创建一个,如果失败则恐慌。
  4. 打电话给超级init

等一下……你什么时候编译和链接你的 Metal 文件的?不幸的是,你还没有。不过,好消息是您可以让 Xcode 为您做这件事!

编译你的内核

要编译和链接您的 Metal 文件,您需要在Build Settings中添加两个标志。所以去那边吧。

搜索Other Metal Compiler Flags并将*-fcikernel*添加到其中:

06.png

金属编译器标志

接下来,单击*+按钮并选择Add User-Defined Setting*:

07.png

添加用户自定义设置

调用设置MTLLINKER_FLAGS并将其设置为*-cikernel*:

08.png

金属链接器标志

现在,下次您构建项目时,Xcode 将编译您的 Metal 文件并自动链接它们。

不过,在您执行此操作之前,您仍然需要在 Core Image 过滤器上做一些工作。

回到AverageStackingFilter.swift,添加以下方法:

函数输出 图像()-> CIImage?{
  守卫让inputCurrentStack = inputCurrentStack,
    让inputNewImage = inputNewImage
     else {
      返回零 
     
  }
  返回内核.apply(
    范围:inputCurrentStack.extent,
    参数:[inputCurrentStack,inputNewImage,inputStackCount])
}

这个方法非常重要。也就是说,它将您的内核函数应用于输入图像并返回输出图像!如果它不这样做,那将是一个无用的过滤器。

呃,Xcode 还在抱怨!美好的。将以下代码添加到类中以使其平静下来:

需要 初始化?( coder  aDecoder : NSCoder ) {
   fatalError ( "init(coder:) has not been implemented" )
}

你不需要能够从一个 unarchiver 初始化这个 Core Image 过滤器,所以你只需实现最低限度的让 Xcode 满意。

使用您的过滤器

打开ImageProcessor.swift并将以下方法添加到ImageProcessor

func  combineFrames () {
   // 1 
  var finalImage = alignedFrameBuffer.removeFirst()
   // 2 
  let filter =  AverageStackingFilter ()
   //3 
  for (i, image) in alignedFrameBuffer.enumerated() {
     // 4 
    filter.inputCurrentStack = finalImage
    filter.inputNewImage =图像
    filter.inputStackCount =  Double (i +  1 )
     // 5 
    finalImage = filter.outputImage() !
  }
  // 6
  清理(图片:finalImage)
}

你在这里:

  1. 使用对齐的成帧器缓冲区中的第一个图像初始化最终图像,并在此过程中将其删除。
  2. 初始化您的自定义核心图像过滤器。
  3. 循环遍历对齐的帧缓冲区中的每个剩余图像。
  4. 设置过滤器参数。注意最终图像设置为当前堆栈图像。重要的是不要交换输入图像!堆栈计数也设置为数组索引加一。这是因为您在方法开始时从对齐的帧缓冲区中删除了第一张图像。
  5. 用新的过滤器输出图像覆盖最终图像。
  6. cleanup(image:)合并所有图像后使用最终图像调用。

您可能已经注意到它cleanup()不带任何参数。通过替换cleanup()以下内容来解决此问题:

功能 清理(图片:CIImage){
  帧缓冲区= []
  对齐帧缓冲区= []
  isProcessingFrames =  false 
  if  let completion = completion {
     DispatchQueue .main.async {
      完成(图像)
    }
  }
  完成= 无
}

唯一的变化是新添加的参数和if在主线程上调用完成处理程序的语句。其余的保持原样。

在底部processFrames(completion:),将调用替换为cleanup()

组合帧()

这样,您的图像处理器将在对齐所有捕获的帧后将它们组合起来,然后将最终图像传递给完成函数。

呸!构建并运行这个应用程序,让那些人、汽车和任何在你的镜头中移动的东西消失!

09.gif

结论

但是,如果您想尝试改进您的应用程序,有几种方法可以做到这一点:

  1. 用于VNHomographicImageRegistrationRequest计算透视扭曲矩阵以对齐捕获的帧。这应该会在两个帧之间创建更好的匹配,只是使用起来有点复杂。
  2. 计算模式像素值而不是平均值。众数是最常出现的值。这样做会消除图像中移动对象的所有影响,因为它们不会被平均化。这应该会创建一个看起来更干净的输出图像。提示:将 RGB 转换为 HSL,并根据色调 (H) 值的小范围计算模式。

您可以使用教程中的所有代码部分下载最终项目。

这里也推荐一些面试相关的内容!