Vision框架详细解析(十六) —— 基于Vision的轮廓检
版本记录
版本号 | 时间 |
---|---|
V1.0 | 2022.06.01 星期三 |
前言
iOS 11+
和macOS 10.13+
新出了Vision
框架,提供了人脸识别、物体检测、物体跟踪等技术,它是基于Core ML的。可以说是人工智能的一部分,接下来几篇我们就详细的解析一下Vision框架。感兴趣的看下面几篇文章。
1. Vision框架详细解析(一) —— 基本概览(一)
2. Vision框架详细解析(二) —— 基于Vision的人脸识别(一)
3. Vision框架详细解析(三) —— 基于Vision的人脸识别(二)
4. Vision框架详细解析(四) —— 在iOS中使用Vision和Metal进行照片堆叠(一)
5. Vision框架详细解析(五) —— 在iOS中使用Vision和Metal进行照片堆叠(二)
6. Vision框架详细解析(六) —— 基于Vision的显著性分析(一)
7. Vision框架详细解析(七) —— 基于Vision的显著性分析(二)
8. Vision框架详细解析(八) —— 基于Vision的QR扫描(一)
9. Vision框架详细解析(九) —— 基于Vision的QR扫描(二)
10. Vision框架详细解析(十) —— 基于Vision的Body Detect和Hand Pose(一)
11. Vision框架详细解析(十一) —— 基于Vision的Body Detect和Hand Pose(二)
12. Vision框架详细解析(十二) —— 基于Vision的Face Detection新特性(一)
13. Vision框架详细解析(十三) —— 基于Vision的Face Detection新特性(二)
14. Vision框架详细解析(十四) —— 基于Vision的人员分割(一)
15. Vision框架详细解析(十五) —— 基于Vision的人员分割(二)
开始
首先看下主要内容:
了解如何使用
Vision
框架以有趣且艺术的方式检测和修改SwiftUI iOS
应用程序中的图像轮廓。内容来自翻译。
接着看下写作环境
Swift 5.5, iOS 15, Xcode 13
接着就是正文了。
艺术是一个非常主观的东西。但是,编码不是。当然,开发人员有时可能会固执己见,但是计算机如何解释您的代码在很大程度上不是见仁见智的问题。
那么,作为开发人员,您如何使用代码来创作艺术呢?也许这不是你如何编码,而是你选择用它做什么。使用可用的工具获得创意可以显着影响输出。
想想苹果是如何推动计算摄影的极限的。大多数数码摄影都是关于对来自传感器的像素进行后处理。不同的算法集会改变最终输出的外观和感觉。那是艺术!
您甚至可以使用计算机视觉算法为图像和照片创建令人兴奋的滤镜和效果。例如,如果您检测到图像中的所有轮廓,您可能有一些很酷的材料来制作具有艺术感的图像绘图。这就是本教程的全部内容!
在本教程中,您将学习如何使用 Vision 框架:
- 创建请求以执行轮廓检测。
- 调整设置以获得不同的轮廓。
- 简化轮廓以创造艺术效果。
听起来很有趣,对吧?没错……艺术应该很有趣!
打开起始项目,启动项目包括一些扩展、模型文件和 UI。
如果您现在构建并运行,您将看到点击屏幕和功能设置图标的说明。
您可能会注意到现在点击屏幕不会做任何事情,但在您进入本教程的Vision
部分之前,您需要在屏幕上显示图像。
Displaying an Image
在浏览启动项目时,您可能已经注意到一个 ImageView
连接到 ContentViewModel
的 image
属性。 如果您打开 ContentViewModel.swift
,您会看到该image
是一个已发布的属性,但没有为其分配(assigned)
任何内容。
你需要做的第一件事就是改变它!
首先在 ContentViewModel.swift
中定义的三个已发布属性之后直接添加以下代码:
init() {
let uiImage = UIImage(named: "sample")
let cgImage = uiImage?.cgImage
self.image = cgImage
}
此代码从资产目录加载名为 sample.png
的图像并为其获取 CGImage
,然后将其分配给image
发布属性。
通过这个小改动,继续构建并重新运行应用程序,您将在屏幕上看到下面的图像:
现在,当您点击屏幕时,它应该在上面的图像和您最初看到的空白屏幕之间切换。
空白屏幕最终将包含您使用 Vision
框架检测到的轮廓。
Vision API Pipeline
在开始编写一些代码来检测轮廓之前,了解 Vision API
管道会很有帮助。 一旦你知道它是如何工作的,你就可以很容易地在你未来的项目中包含任何视觉算法; 这很漂亮。
Vision API
管道由三部分组成:
- 第一个是
request
,它是VNRequest
的子类——所有分析请求的基类。 然后将此请求传递给handler
。 - 处理程序
handler
可以是两种类型之一,VNImageRequestHandler
或VNSequenceRequestHandler
。 - 最后,结果
result
是VNObservation
的子类,作为原始request
对象的属性返回。
通常,很容易判断哪种结果类型与哪种请求类型对应,因为它们的名称相似。例如,如果您的请求是 VNDetectFaceRectanglesRequest
,则返回的结果将是 VNFaceObservation
。
对于这个项目,请求将是一个 VNDetectContoursRequest
,它将以 VNContoursObservation
的形式返回结果。
无论何时处理单个图像,而不是图像序列中的帧,您都将使用 VNImageRequestHandler
。 VNSequenceRequestHandler
在处理图像序列时使用,您希望将请求应用于一系列相关图像,例如来自视频流的帧。在这个项目中,您将使用前者来处理单个图像请求。
现在您已经掌握了背景理论,是时候将其付诸实践了!
Contour Detection
为了使项目井井有条,在项目导航器中右键单击 Contour Art
组并选择 New Group
。将新组命名为 Vision
。
右键单击新的 Vision
组并选择 New File…
。选择
Swift File
并将其命名为 ContourDetector
。
用以下代码替换文件的内容:
import Vision
class ContourDetector {
static let shared = ContourDetector()
private init() {}
}
这段代码所做的只是将一个新的 ContourDetector
类设置为 Singleton
。 单例模式并不是绝对必要的,但它可以确保您只有一个 ContourDetector
实例在应用程序周围运行。
1. Performing Vision Requests
现在是时候让检测器类做点什么了。
将以下属性添加到 ContourDetector
类:
private lazy var request: VNDetectContoursRequest = {
let req = VNDetectContoursRequest()
return req
}()
这将在您第一次需要时懒惰地创建一个 VNDetectContoursRequest
。 Singleton
结构还确保只有一个 Vision
请求,可以在应用程序的整个生命周期中重复使用。
现在添加以下方法:
private func perform(request: VNRequest,
on image: CGImage) throws -> VNRequest {
// 1
let requestHandler = VNImageRequestHandler(cgImage: image, options: [:])
// 2
try requestHandler.perform([request])
// 3
return request
}
这种方法简单但功能强大。 你在这里:
- 1) 创建
request handler
并将提供的CGImage
传递给它。 - 2) 使用
handler
执行request
。 - 3) 返回请求,现在已附加结果。
为了使用请求的结果,您需要进行一些处理。 在上一个方法下面,添加以下方法来处理返回的请求:
private func postProcess(request: VNRequest) -> [Contour] {
// 1
guard let results = request.results as? [VNContoursObservation] else {
return []
}
// 2
let vnContours = results.flatMap { contour in
(0..<contour.contourCount).compactMap { try? contour.contour(at: $0) }
}
// 3
return vnContours.map { Contour(vnContour: $0) }
}
在这种方法中,您:
- 1) 检查
results
是否为VNContoursObservation
对象数组。 - 2) 将每个结果转换为
VNContours
数组。-
flatMap
将结果转换为单个扁平数组。 - 使用
compactMap
遍历contour
中的contours
,以确保仅保留非nil
值。 - 使用
contour(at:)
检索指定索引处的轮廓对象。
-
- 3) 将
VNContours
数组映射到自定义Contour
模型数组中。
注意:从
VNContour
转换为Contour
的原因是为了简化一些SwiftUI
代码。Contour
遵循Identifiable
,因此很容易遍历它们的数组。 查看ContoursView.swift
以查看实际情况。
2. Processing Images in the Detector
现在您只需要将这两个私有方法绑定到某个可从类外部调用的地方。 仍然在 ContourDetector.swift
中,添加以下方法:
func process(image: CGImage?) throws -> [Contour] {
guard let image = image else {
return []
}
let contourRequest = try perform(request: request, on: image)
return postProcess(request: contourRequest)
}
在这里,您正在检查是否有图像,然后使用 perform(request:on:)
创建请求,最后使用 postProcess(request:)
返回结果。 这将是您的view model
将调用以检测图像轮廓的方法,这正是您接下来要做的。
打开 ContentViewModel.swift
并将以下方法添加到类的末尾:
func asyncUpdateContours() async -> [Contour] {
let detector = ContourDetector.shared
return (try? detector.process(image: self.image)) ?? []
}
在此代码中,您将创建一个异步方法来检测轮廓。 为什么是异步的? 虽然检测轮廓通常相对较快,但您仍然不想在等待 API 调用结果时占用 UI。 如果检测器未找到任何轮廓,则异步方法返回一个空数组。 此外,spoiler alert
,稍后您将在此处添加更多逻辑,这将对您的设备处理器造成负担。
但是,您仍然需要从某个地方调用此方法。 找到 updateContours
的方法存根,并用以下代码填充它:
func updateContours() {
// 1
guard !calculating else { return }
calculating = true
// 2
Task {
// 3
let contours = await asyncUpdateContours()
// 4
DispatchQueue.main.async {
self.contours = contours
self.calculating = false
}
}
}
使用此代码,您可以:
- 1) 如果我们已经在计算轮廓,什么也不做。 否则设置一个标志以指示您正在计算轮廓。 然后,用户界面将能够通知用户,因此他们保持耐心。
- 2) 创建一个异步上下文,从中运行轮廓检测器。 这对于异步工作是必要的。
- 3) 启动轮廓检测方法并等待其结果。
- 4) 将结果设置回主线程并清除
calculating
标志位。 由于contours
和calculating
都是published properties
,因此只能在主线程上assigned
它们。
这个更新方法需要从某个地方调用,init
的底部比任何地方都好! 找到 init
并将以下行添加到底部:
updateContours()
现在是构建和运行您的应用程序的时候了。 应用程序加载并看到图像后,点击屏幕以使用默认设置显示其检测到的轮廓。
很棒!
VNContoursObservation and VNContour
在撰写本文时,VNDetectContoursObservation
似乎永远不会在结果数组中返回多个 VNContoursObservation
。相反,您看到的所有contours
(在上一个屏幕截图中共有 43
个)都由单个 VNContoursObservation
引用。
注意:您编写的代码处理多个
VNContoursObservation
结果,以防Apple
决定更改其工作方式。
每个单独的contour
由 VNContour
描述并按层次组织。 VNContour
可以有子contour
。要访问它们,您有两种选择:
- 1) 索引
childContours
属性,它是一个VNContours
数组。 - 2) 结合使用
childContourCount
整数属性和childContour(at: Int)
方法来循环访问每个子contour
。
由于任何 VNContour
都可以有一个子 VNContour
,因此如果您需要保留层次结构信息,则必须递归访问它们。
如果您不关心层次结构,VNContoursObservation
为您提供了一种以简单方式访问所有轮廓的简单方法。 VNContoursObservation
有一个 contourCount
整数属性和一个 contour(at: Int)
方法来访问所有轮廓,就好像它们是一个平面数据结构一样。
但是,如果层次结构对您很重要,则需要访问 topLevelContours
属性,它是 VNContours
数组。从那里,您可以访问每个轮廓的子轮廓(contours)
。
如果您要编写一些简单的代码来计算顶级轮廓和子轮廓,您会发现示例图像在默认设置下具有 4
个顶级轮廓和 39
个子轮廓,总共 43
个。
VNDetectContoursRequest Settings
到目前为止,您已经创建了一个 VNDetectContoursRequest
,而没有尝试各种可用的设置。目前,您可以更改四个属性以实现不同的结果-
- 1) contrastAdjustment:该算法具有在执行轮廓检测之前调整图像对比度的内置方法。调整对比度会尝试使图像的暗部变暗并减轻亮部以增大它们的差异。此浮点属性的范围从
0.0
到3.0
,默认值为2.0
。该值越高,对图像应用的对比度就越高,从而更容易检测到一些轮廓。 - 2) contrastPivot:算法如何知道图像的哪个部分应该被视为暗与亮?这就是对比度枢轴
(contrast pivot)
的用武之地。它是一个可选的NSNumber
属性,范围从0.0
到1.0
,默认值为0.5
。低于此值的任何像素都将变暗,而高于此值的任何像素都将变亮。您还可以将此属性设置为nil
以使Vision
框架自动检测该值“应该”是什么。 - 3) detectDarkOnLight:此布尔属性是对轮廓检测算法的提示。默认设置为
true
,这意味着它应该在浅色背景上寻找深色物体。 - 4) maximumImageDimension:由于您可以将任何尺寸的图像传递给请求处理程序
(request handler)
,因此此整数属性允许您设置要使用的最大图像尺寸。如果您的图像尺寸大于此值,API
会缩放图像,以使两个尺寸中较大的一个等于maximumImageDimension
。此属性的默认值为512
。为什么要更改它?轮廓检测(Contour detection)
需要相当多的处理能力——图像越大,需要的越多。但是,图像越大,它就越准确。此属性允许您根据需要微调。
Changing the Contrast
现在您了解了可用的设置,您将编写一些代码来更改两个对比度设置的值。在本教程中,您将不理会detectDarkOnLight
和maximumImageDimension
属性,只使用它们的默认值。
打开 ContourDetector.swift
并将以下方法添加到 ContourDetector
的底部:
func set(contrastPivot: CGFloat?) {
request.contrastPivot = contrastPivot.map {
NSNumber(value: $0)
}
}
func set(contrastAdjustment: CGFloat) {
request.contrastAdjustment = Float(contrastAdjustment)
}
这些方法分别更改 VNDetectContoursRequest
上的 contrastPivot
和 contrastAdjustment
,并使用一些额外的逻辑来允许您将 contrastPivot
设置为 nil
。
您会记得 request
是一个lazy var
,这意味着如果在您调用这些方法之一时它还没有被实例化,它将现在实例化。
接下来,打开 ContentViewModel.swift
并找到 asyncUpdateContours
。 更新方法,使其看起来像这样:
func asyncUpdateContours() async -> [Contour] {
let detector = ContourDetector.shared
// New logic
detector.set(contrastPivot: 0.5)
detector.set(contrastAdjustment: 2.0)
return (try? detector.process(image: self.image)) ?? []
}
这两行新代码为 contrastPivot
和 contrastAdjustment
赋值。
构建并运行应用程序并为这些设置尝试不同的值(您需要更改这些值,然后再次构建并运行)。 以下是不同值的一些截图:
好的,现在你得到了一些有趣的结果。但是,有点烦人的是,没有神奇的设置可以从图像中获取所有轮廓并将它们组合成一个结果。
但是……有一个解决方案。
在探索入门项目时,您可能已经点击了右下角的设置图标。如果您点击它,您会看到用于最小和最大对比度枢轴和调整(contrast pivot and adjustment)
的滑块。
您将使用这些滑块为这些设置创建范围并循环访问它们。然后,您将组合每个设置对中的所有轮廓,为图像创建更完整的轮廓集。
注意:每个设置的范围越大,您运行的
Vision
请求就越多。这可能是一个缓慢的过程,除非您非常有耐心,否则不建议在旧设备上使用。它在较新的 iPhone、iPad 和基于 M1 的 Mac 上运行良好。
如果您还没有打开 ContentViewModel.swift
,请继续打开它。删除 asyncUpdateContours
的全部内容,并用以下代码替换:
// 1
var contours: [Contour] = []
// 2
let pivotStride = stride(
from: UserDefaults.standard.minPivot,
to: UserDefaults.standard.maxPivot,
by: 0.1)
let adjustStride = stride(
from: UserDefaults.standard.minAdjust,
to: UserDefaults.standard.maxAdjust,
by: 0.2)
// 3
let detector = ContourDetector.shared
// 4
for pivot in pivotStride {
for adjustment in adjustStride {
// 5
detector.set(contrastPivot: pivot)
detector.set(contrastAdjustment: adjustment)
// 6
let newContours = (try? detector.process(image: self.image)) ?? []
// 7
contours.append(contentsOf: newContours)
}
}
// 8
return contours
在这个新版本的 asyncUpdateContours
中,您:
- 1) 创建一个空的轮廓
Contours
数组来存储所有轮廓。 - 2) 设置要循环遍历的
contourPivot
和contourAdjustment
值的步幅。 - 3) 获取对
ContourDetector
单例的引用。 - 4) 循环通过两个步骤。 请注意,这是一个嵌套循环,因此每个
contourPivot
值都将与每个contourAdjustment
值配对。 - 5) 使用您创建的访问器方法更改
VNDetectContoursRequest
的设置。 - 6) 通过
Vision
轮廓检测器API
运行图像。 - 7) 将结果附加到轮廓
Contours
列表并... - 8) 返回此轮廓列表。
已经太多了,但它会是值得的。 继续构建并运行应用程序并更改设置菜单中的滑块。 通过向下滑动或在其外部点击关闭设置菜单后,它将开始重新计算轮廓。
以下屏幕截图中使用的范围是:
Contrast Pivot: 0.2 - 0.7
Contrast Adjustment: 0.5 - 3.0
真的很酷!
Thinning the Contours
这是一个很酷的效果,但你可以做得更好!
您可能会注意到一些轮廓现在看起来很厚,而另一些则很薄。 “厚”轮廓实际上是同一区域的多个轮廓,但由于对比度的调整方式而彼此略有偏移。
如果您可以检测到重复的轮廓,您就可以删除它们,这应该会使线条看起来更细。
确定两个轮廓是否相同的一种简单方法是查看它们有多少重叠。 它并不完全是 100% 准确的,但它是一个相对较快的近似值。 要确定重叠,您可以计算它们的边界框的交集。
Intersection over union
或 IoU
是两个边界框的交集面积除以它们的并集面积。
当 IoU
为 1.0
时,边界框完全相同。 如果 IoU
为 0.0
,则两个边界框之间没有重叠。
您可以将其用作阈值来过滤掉看起来“足够接近”的边界框。
回到 ContentViewModel.swift
中的 asyncUpdateContours
,在 return
语句之前添加以下代码:
// 1
if contours.count < 9000 {
// 2
let iouThreshold = UserDefaults.standard.iouThresh
// 3
var pos = 0
while pos < contours.count {
// 4
let contour = contours[pos]
// 5
contours = contours[0...pos] + contours[(pos+1)...].filter {
contour.intersectionOverUnion(with: $0) < iouThreshold
}
// 6
pos += 1
}
}
使用此代码,您可以:
- 1) 仅当轮廓数少于
9,000
时运行。 这可能是整个函数中最慢的部分,所以尽量限制它何时可以使用。 - 2) 抓取
IoU
阈值设置,可以在设置屏幕中更改。 - 3) 循环遍历每个轮廓。 您在此处使用
while
循环,因为您将动态更改轮廓数组。 您不希望意外地在数组大小之外进行索引! - 4) 索引轮廓数组以获取当前轮廓。
- 5) 只保留当前轮廓之后的轮廓,其
IoU
小于阈值。 请记住,如果IoU
大于或等于阈值,则您已确定它与当前轮廓相似,应将其删除。 - 6) 增加索引位置。
注意:可能有一种更有效的方法来完成此操作,但这是解释该概念的最简单方法。
继续构建并运行应用程序。
注意有多少厚轮廓现在明显变薄了!
Simplifying the Contours
您可以使用另一种技巧为您的轮廓艺术添加艺术感。 您可以简化轮廓。
VNContour
有一个名为 polygonApproximation(epsilon:)
的成员方法,它就是这样做的。 该方法的目的是返回具有较少点的相似轮廓。 这是轮廓的近似值。
epsilon
的选择将决定返回轮廓的简化程度。 较大的 epsilon
将导致具有较少点的更简单的轮廓,而较小的 epsilon
将返回更接近原始轮廓的轮廓。
打开 ContourDetector.swift
。 在 ContourDetector
的顶部,添加以下属性:
private var epsilon: Float = 0.001
接下来,在 ContourDetector
的底部,添加以下方法:
func set(epsilon: CGFloat) {
self.epsilon = Float(epsilon)
}
仍然在同一个类中,找到 postProcess(request:)
并将方法底部的 return
语句替换为以下代码:
let simplifiedContours = vnContours.compactMap {
try? $0.polygonApproximation(epsilon: self.epsilon)
}
return simplifiedContours.map { Contour(vnContour: $0) }
此代码在返回之前根据 epsilon
的当前值简化每个检测到的轮廓。
在尝试这个新功能之前,您需要将 epsilon
设置连接到 ContourDetector
。 您只需从设置屏幕写入的 UserDefaults
中读取它。
打开 ContentViewModel.swift
并再次找到 asyncUpdateContours
。 然后,在定义detector
常数的行下方,添加以下行:
detector.set(epsilon: UserDefaults.standard.epsilon)
这将确保检测器在每次需要更新显示的轮廓时获得最新的 epsilon
值。
最后一次,继续构建并运行!
此示例将值 0.01
用于多边形近似 Epsilon (Polygon Approximation Epsilon)
设置。
现在,这是具有风格的轮廓艺术。
通过了解 Vision API
管道的工作原理,您现在可以在 Vision
框架中使用 Apple
提供的任何其他算法。想想可能性!
如果您对有关 Vision API 的更多教程感兴趣,我们会提供这些东西;查看:
- Face Detection Tutorial Using the Vision Framework for iOS
- Photo Stacking in iOS with Vision and Metal
- Saliency Analysis in iOS using Vision
- Core ML and Vision Tutorial: On-device training on iOS
- Person Segmentation in the Vision Framework
- Vision Tutorial for iOS: Detect Body and Hand Pose
后记
本篇主要讲述了基于
Vision
的轮廓检测,感兴趣的给个赞或者关注~~~