Vision框架详细解析(四) —— 在iOS中使用Vision
版本记录
版本号 | 时间 |
---|---|
V1.0 | 2019.07.23 星期二 |
前言
iOS 11+
和macOS 10.13+
新出了Vision
框架,提供了人脸识别、物体检测、物体跟踪等技术,它是基于Core ML的。可以说是人工智能的一部分,接下来几篇我们就详细的解析一下Vision框架。感兴趣的看下面几篇文章。
1. Vision框架详细解析(一) —— 基本概览(一)
2. Vision框架详细解析(二) —— 基于Vision的人脸识别(一)
3. Vision框架详细解析(三) —— 基于Vision的人脸识别(二)
开始
首先看一下主要内容
主要内容:在本教程中,您将使用
Metal
和Vision
框架从iOS中的图片中删除移动对象。 您将学习如何堆叠,对齐和处理多个图像,以便任何移动对象消失。
然后看一下写作环境
Swift 5, iOS 12, Xcode 10
下面我们就正式开始了。
什么是照片堆叠(Photo Stacking)
?好吧,想象一下。你在度假,某个神奇的地方。你正在英国各地游览所有哈利波特拍摄地点!
现在是时候看到景点并拍摄最精彩的照片了。只有一个问题:人太多了。
啊!你拍的每一张照片都充满了它们。只要你能像哈利一样施放一个简单的咒语,并让所有这些人消失。 Evanesco!
噗!他们走了。那太棒了。
也许你可以做些什么。照片堆叠是所有酷孩子都在谈论的新兴计算摄影(computational photography)
趋势。你想知道如何使用它吗?
在本教程中,您将使用Vision
框架来学习如何:
- 使用
VNTranslationalImageRegistrationRequest
对齐采集的图像。 - 使用Metal内核
(Metal kernel)
创建自定义CIFilter
。 - 使用此过滤器组合多个图像以删除任何移动的对象。
令人兴奋,对吗?那么,你还等什么呢?继续阅读!
打开入门项目并在您的设备上运行它。
Evanesco startup screenshot注意:由于您需要在本教程中使用相机和
Metal
,因此您必须在实际设备而不是模拟器上运行它。
你应该看到一些看起来像一个简单的相机应用程序。 有一个红色的记录按钮,周围有一个白色的环,它显示了全屏的摄像头输入。
当然你已经注意到相机看起来有点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
目录的权限,现在可以在那里保存一些图像了。
与起始项目捆绑在一起的是一个名为ImageSaver
的helper 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
时,图像会存储对样本缓冲区的强引用。 不幸的是,对于视频采集,这意味着在几秒钟之后,它将开始丢弃帧,因为它耗尽了分配给样本缓冲区的内存。 通过使用CIIContext
将CIImage
渲染为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:VNTranslationalImageRegistrationRequest
和VNHomographicImageRegistrationRequest
。 前者更容易使用,如果你认为应用程序的用户将相对静止地保持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)
处的堆叠图像的平均像素值。
如果对每个像素坐标执行此操作,则最终图像将是所有图像的平均值。您拥有的图像越多,平均值与背景像素值越接近。如果某些东西在相机前移动,它只会出现在几张图像中的相同位置,因此它对整体平均值的贡献不大。这就是移动物体消失的原因。
这就是您实现堆叠逻辑(stacking logic)
的方法。
Stacking Images
现在来了真正有趣的部分!您将把所有这些图像组合成一个梦幻般的图像。您将使用Metal Shading Language(MSL)
创建自己的Core Image kernel
。
您的简单内核将计算两个图像的像素值的加权平均值。当你将一堆图像平均在一起时,任何移动的物体都应该消失。背景像素将更频繁地出现并且支配平均像素值。
1. Creating a Core Image Kernel
您将从实际内核开始,该内核是用MSL
编写的。 MSL
与C ++
非常相似。
将新的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
:
接下来,单击+
按钮并选择Add User-Defined Setting
:
调用设置MTLLINKER_FLAGS
并将其设置为-cikernel
:
现在,下次构建项目时,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进行照片堆叠,感兴趣的给个赞或者关注~~~