Vision框架详细解析(四) —— 在iOS中使用Vision和Metal进行照片堆叠(一)

版本记录

版本号 时间
V1.0 2019.07.23 星期二

前言

iOS 11+macOS 10.13+ 新出了Vision框架,提供了人脸识别、物体检测、物体跟踪等技术,它是基于Core ML的。可以说是人工智能的一部分,接下来几篇我们就详细的解析一下Vision框架。感兴趣的看下面几篇文章。
1. Vision框架详细解析(一) —— 基本概览(一)
2. Vision框架详细解析(二) —— 基于Vision的人脸识别(一)
3. Vision框架详细解析(三) —— 基于Vision的人脸识别(二)

开始

首先看一下主要内容

主要内容:在本教程中,您将使用MetalVision框架从iOS中的图片中删除移动对象。 您将学习如何堆叠,对齐和处理多个图像,以便任何移动对象消失。

然后看一下写作环境

Swift 5, iOS 12, Xcode 10

下面我们就正式开始了。

什么是照片堆叠(Photo Stacking)?好吧,想象一下。你在度假,某个神奇的地方。你正在英国各地游览所有哈利波特拍摄地点!

现在是时候看到景点并拍摄最精彩的照片了。只有一个问题:人太多了。

啊!你拍的每一张照片都充满了它们。只要你能像哈利一样施放一个简单的咒语,并让所有这些人消失。 Evanesco!噗!他们走了。那太棒了。

也许你可以做些什么。照片堆叠是所有酷孩子都在谈论的新兴计算摄影(computational photography)趋势。你想知道如何使用它吗?

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

  • 使用VNTranslationalImageRegistrationRequest对齐采集的图像。
  • 使用Metal内核(Metal kernel)创建自定义CIFilter
  • 使用此过滤器组合多个图像以删除任何移动的对象。

令人兴奋,对吗?那么,你还等什么呢?继续阅读!

打开入门项目并在您的设备上运行它。

注意:由于您需要在本教程中使用相机和Metal,因此您必须在实际设备而不是模拟器上运行它。

Evanesco startup screenshot

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

当然你已经注意到相机看起来有点jittery。 那是因为它设置为每秒五帧捕获。 要查看代码中定义的位置,请打开CameraViewController.swift并在configureCaptureSession()中找到以下两行:

camera.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 5)
camera.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 5)

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

如果点击录制按钮,您应该看到外部白色环顺时针填满。 但是,当它完成时,没有任何反应。

你现在必须要做些什么。


Saving Images to the Files App

为了帮助您在进行中调试应用程序,将您正在使用的图像保存到Files应用程序会很不错。 幸运的是,这比听起来容易得多。

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

  • 1) Application supports iTunes file sharing
  • 2) Supports opening documents in place

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

第一个密钥为Documents目录中的文件启用文件共享。 第二个让您的应用程序从文件提供程序file provider打开原始文档,而不是接收副本。 启用这两个选项后,存储在应用程序Documents目录中的所有文件都将显示在Files应用程序中。 这也意味着其他应用可以访问这些文件。

现在您已经获得了Files应用程序访问Documents目录的权限,现在可以在那里保存一些图像了。

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

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

var saver: ImageSaver?

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

saver = ImageSaver()

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

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

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

使用此代码,您:

  • 1) 从采集的样本缓冲区中提取CVImageBuffer并将其转换为CGImage
  • 2) 将CGImage转换为CIImage
  • 3) 将图像写入Documents目录。

注意:为什么你必须将样本缓冲区转换为CIImage,然后转换为CGImage,最后再转换回CIImage? 这与谁拥有数据有关。 将样本缓冲区转换为CIImage时,图像会存储对样本缓冲区的强引用。 不幸的是,对于视频采集,这意味着在几秒钟之后,它将开始丢弃帧,因为它耗尽了分配给样本缓冲区的内存。 通过使用CIIContextCIImage渲染为CGImage,可以复制图像数据,并且可以释放样本缓冲区以便再次使用。

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

如果您查看此文件夹,您将找到在录制的4秒内采集的20帧。

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

好的。那么你可以用20张几乎相同的图像做什么呢?


Photo Stacking

在计算摄影(computational photography)中,照片堆叠是一种采集,对齐和组合多个图像以产生不同所需效果的技术。

例如,通过拍摄不同曝光等级的若干图像并将每个图像的最佳部分组合在一起来获得HDR图像。这就是你如何在iOS中同时在阴影和明亮的天空中看到细节。

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

在微距摄影(macro photography)中,难以立即获得整个图像。使用照片堆叠,摄影师可以拍摄不同焦距的一些图像并将它们组合在一起,以产生非常小的物体的极其清晰的图像。

要将图像组合在一起,首先需要对齐它们。怎么样? iOS提供了一些有趣的API,可以帮助您。


Using Vision to Align Images

Vision框架有两个用于对齐图像的不同API:VNTranslationalImageRegistrationRequestVNHomographicImageRegistrationRequest。 前者更容易使用,如果你认为应用程序的用户将相对静止地保持iPhone,它应该足够好。

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

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

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

import CoreImage
import Vision

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

  var frameCount: Int {
    return frameBuffer.count
  }
}

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

  • frameBuffer将存储原始采集的图像。
  • alignedFrameBuffer将包含在对齐后图像。
  • completion是在对齐和组合图像后调用的处理程序。
  • isProcessingFrames将指示图像当前是否正在对齐和组合。
  • frameCount是采集的图像数。

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

func add(_ frame: CIImage) {
  if isProcessingFrames {
    return
  }
  frameBuffer.append(frame)
}

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

仍然在类上,添加处理方法:

func processFrames(completion: ((CIImage) -> Void)?) {
  // 1
  isProcessingFrames = true  
  self.completion = completion
  // 2
  let firstFrame = frameBuffer.removeFirst()
  alignedFrameBuffer.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)
    } catch {
      print(error.localizedDescription)
    }
    // 7
    alignImages(request: request, frame: frame)
  }
  // 8
  cleanup()
}

这看着是很多步骤,但这种方法相对简单。添加完所有采集的帧后,您将调用此方法。它将处理每个帧并使用Vision框架对齐它们。具体来说,在此代码中,您:

  • 1) 设置isProcessingFrames布尔变量以防止添加更多帧。您还要保存完成处理程序(completion handler)以供日后使用。
  • 2) 从帧缓冲区中删除第一帧,并将其添加到帧缓冲区以获得对齐的图像。所有其他帧将与此对齐。
  • 3) 循环遍历帧缓冲区中的每个帧。
  • 4) 使用框架创建新的Vision请求以确定简单的平移对齐。
  • 5) 创建序列请求处理程序(sequence request handler),它将处理您的对齐请求。
  • 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],
    let result = results.first 
    else {
      return
  }
  // 2
  let alignedFrame = frame.transformed(by: result.alignmentTransform)
  // 3
  alignedFrameBuffer.append(alignedFrame)
}

在这里你:

  • 1) 从processFrames(completion :)for循环中的对齐请求(alignment request)中解包第一个结果。
  • 2) 使用Vision框架计算的仿射变换矩阵变换帧。
  • 3) 将此已转换的帧附加到对齐的帧缓冲区。

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

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

func cleanup() {
  frameBuffer = []
  alignedFrameBuffer = []
  isProcessingFrames = false
  completion = nil
}

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

在构建和运行应用程序之前,需要在CameraViewController中使用ImageProcessor

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

let imageProcessor = ImageProcessor()

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

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

imageProcessor.add(image)

在调用stopRecording()之后,仍然在if语句中添加:

imageProcessor.processFrames(completion: displayCombinedImage)

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

注意:如果要查看对齐图像与原始采集的比较,可以在ImageProcessor中实例化ImageSaver。 这将允许您将对齐的图像保存到Documents文件夹,并在Files应用程序中查看它们。


How Photo Stacking works

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

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

Pixel stacking

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

这就是您实现堆叠逻辑(stacking logic)的方法。


Stacking Images

现在来了真正有趣的部分!您将把所有这些图像组合成一个梦幻般的图像。您将使用Metal Shading Language(MSL)创建自己的Core Image kernel

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

1. Creating a Core Image Kernel

您将从实际内核开始,该内核是用MSL编写的。 MSLC ++非常相似。

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

#include <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
    return avg;
  }
}}

使用此代码,您:

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

注意:理解为两个图像之间的每对相应像素调用此函数非常重要。 sample_t数据类型是来自图像的像素样本。

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

import CoreImage

class AverageStackingFilter: CIFilter {
  let kernel: CIBlendKernel
  var inputCurrentStack: CIImage?
  var inputNewImage: CIImage?
  var inputStackCount = 1.0
}

在这里,您将定义新的CIFilter类以及它所需的一些属性。 注意三个输入变量如何对应于内核函数中的三个参数。 巧合?

到目前为止,Xcode可能会警告此类缺少初始化程序。 所以,是时候解决这个问题。 将以下内容添加到类中:

override init() {
  // 1
  guard 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(
      functionName: "avgStacking", 
      fromMetalLibraryData: data)
  } catch {
    print(error.localizedDescription)
    fatalError("Make sure the function names match")
  }
  // 4
  super.init()
}

使用此初始化程序,您:

  • 1) 获取已编译和链接的Metal文件的URL
  • 2) 阅读文件的内容。
  • 3) 尝试从Metal文件中的avgStacking函数创建一个CIBlendKernel,如果失败则会发生混乱。
  • 4) 调用super init

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

2. Compiling Your Kernel

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

搜索Other Metal Compiler Flags并添加-fcikernel

Metal compiler flag

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

Add user-defined setting

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

Metal linker flag

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

但是,在您可以执行此操作之前,您仍需要对Core Image过滤器进行一些工作。

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

func outputImage() -> CIImage? {
  guard 
    let inputCurrentStack = inputCurrentStack,
    let inputNewImage = inputNewImage
    else {
      return nil
  }
  return kernel.apply(
    extent: inputCurrentStack.extent,
    arguments: [inputCurrentStack, inputNewImage, inputStackCount])
}

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

呃,Xcode还在警告! 很好,将以下代码添加到类中以使其消失:

required init?(coder aDecoder: NSCoder) {
  fatalError("init(coder:) has not been implemented")
}

您无需从unarchiver初始化此Core Image过滤器,因此您只需实现最低限度即可使Xcode满意。

3. Using Your Filter

打开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 = image
    filter.inputStackCount = Double(i + 1)
    // 5
    finalImage = filter.outputImage()!
  }
  // 6
  cleanup(image: finalImage)
}

在这里你:

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

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

func cleanup(image: CIImage) {
  frameBuffer = []
  alignedFrameBuffer = []
  isProcessingFrames = false
  if let completion = completion {
    DispatchQueue.main.async {
      completion(image)
    }
  }
  completion = nil
}

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

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

combineFrames()

这样,图像处理器(image processor)将在对齐后将所有捕获的帧组合在一起,然后将最终图像传递给completion函数。

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

为了更多的乐趣,当你使用该应用程序。其他人绝对不会认为你很奇怪。

恭喜!您已经完成了本教程中的许多概念。你现在已准备好在现实世界中发挥你的魔力!

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

  • 1) 使用VNHomographicImageRegistrationRequest计算透视扭曲矩阵(perspective warp matrix )以对齐采集的帧。这应该在两个帧之间创建更好的匹配,使用起来有点复杂。
  • 2) 计算模式像素值而不是平均值。该模式是最常出现的值。这样做会消除图像中移动物体的所有影响,因为它们不会被平均。这应该创建一个更清晰的输出图像。提示:将RGB转换为HSL,并根据hue(H)值的小范围计算模式。

后记

本篇主要讲述了在iOS中使用Vision和Metal进行照片堆叠,感兴趣的给个赞或者关注~~~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容