在 2017 年的 WWDC 中,Apple 释出了许多新框架(frameworks),Vision Framework 便是其中一个。使用 Vision Framework ,你不需要高深的知识就可以很容易地在你的 App 中实作出电脑视觉技术(Vision Techniques)!Vision Framework 可以让你的 App 执行许多强大的功能,例如识别人脸范围及脸部特徵(微笑、皱眉、左眼眉毛等等)、条码检测、分类出图像中的场景、物件检测及追踪以及视距检测。
或许那些已经使用 Swift 开发程序一段时间的人会想知道既然已经有了Core Image 及 AVFoundation,为什么还要推出 Vision 呢?如果我们看一下这张在 WWDC 演讲中出现的表格,我们可以看到 Vision 的准确度(Accuracy)是最好的,同时也支持较多的平台。不过 Vision 需要较多的处理时间以及电源消耗。
图片来源: Apple’s WWDC video – Vision Framework: Building on Core ML
在本次的教学中,我们将会利用 Vision Framework 来作出文字检测的功能,并实作出一个能够检测出文字的 App ,不论字体、字型及颜色。如下图所示,Vision Framework 可以识别出印刷及手写两种文字。
编者按:根据测试结果,Vision Framework 对中文支持有限。
为了节省你建置 UI 所花的时间好专注在学习 Vision Framework 上,你可以下载 Starter Project 作为开始。
请注意你需要 Xcode 9 来完成本次教学,同时也需要一台 iOS 11 设备来测试。所有的代码皆是以 Swift 4 撰写。
建立即时影像
当你打开项目时,你可以看到视图已经为你设定好放在 Storyboard 上了。接着进入 ViewController.swift
,你会发现由一些 outlet 及 function 所构成的程序骨架。我们的第一步就是要建立一个即时影像来检测文字,在 imageView
底下宣告一个 AVCaptureSession
属性:
var session = AVCaptureSession()
这样就初始化了一个可以用来作即时(real-time)或非即时(offline)影音获取的AVCaptureSession
物件。而这个物件在你要对即时影像进行操作时就会用上。接着,我们需要把这个 session 连接到我们的设备上。首先把下面的函数放入 ViewController.swift
吧。
func startLiveVideo() {
//1
session.sessionPreset = AVCaptureSession.Preset.photo
let captureDevice = AVCaptureDevice.default(for: AVMediaType.video)
//2
let deviceInput = try! AVCaptureDeviceInput(device: captureDevice!)
let deviceOutput = AVCaptureVideoDataOutput()
deviceOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)]
deviceOutput.setSampleBufferDelegate(self, queue: DispatchQueue.global(qos: DispatchQoS.QoSClass.default))
session.addInput(deviceInput)
session.addOutput(deviceOutput)
//3
let imageLayer = AVCaptureVideoPreviewLayer(session: session)
imageLayer.frame = imageView.bounds
imageView.layer.addSublayer(imageLayer)
session.startRunning()
}
如果你曾经用过 AVFoundation
,你会发觉这个代码有点熟悉。如果你没用过,别担心。我们逐行的将代码说明一遍。
- 我们首先修改
AVCaptureSession
的设定。然后我们设定AVMediaType
为影片,因为我们希望是即时影像,此外它应该要一直持续地运作。 - 接着,我们要定义设备的输入及输出。输入是指相机所看到的,而输出则是指应该显示的影像。我们希望影像显示为
kCVPixelFormatType_32BGRA
格式。你可以从这里了解更多关于像素格式的类型。最后,我们把输入及输出加进到AVCaptureSession
。 - 最后,我们把含有影像预览的 sublayer 加进到
imageView
中,然后让 session 开始运作。
调用在 viewWillAppear
方法里的这个函数:
override func viewWillAppear(_ animated: Bool) {
startLiveVideo()
}
因为在 viewWillAppear()
中还没决定 imageView
的范围,所以覆写 viewDidLayoutSubviews()
方法来更新图层的范围。
override func viewDidLayoutSubviews() {
imageView.layer.sublayers?[0].frame = imageView.bounds
}
在执行之前,要在 Info.plist
加入一个条目来说明为何你需要使用到相机功能。这自 Apple 发佈 iOS 10 后,都是必须添加的步骤。
现在即时影像应该会如预期般的运作。然而,因为我们还没实作 Vision Framework,所以还没有文字检测功能。而这就是我们接下来要完成的部份。
实作文字检测
在我们实作文字检测(Text Detection)之前,我们需要了解 Vision Framework 是如何运作的。基本上,在你的 App 里实作 Vision 会有三个步骤,分别是:
- Requests – Requests 是指当你要求 Framework 为你检测一些东西时。
- Handlers – Handlers 是指当你想要 Framework 在 Request 产生后执行一些东西或处理这个 Request 时.
- Observations – Observations 是指你想要用你提供的资料做什么。
现在,让我们从 Request 开始吧。在初始化的变量 session 底下宣告另一个变量:
var requests = [VNRequest]()
我们建立了一个含有一个通用类别 VNRequest
的阵列。接着,让我们在 ViewController
类别里建立一个函数来进行文字检测吧。
func startTextDetection() {
let textRequest = VNDetectTextRectanglesRequest(completionHandler: self.detectTextHandler)
textRequest.reportCharacterBoxes = true
self.requests = [textRequest]
}
在这个函数里,我们建立一个 VNDetectTextRectanglesRequest
的常数 textRequest
。基本上它是 VNRequest
的一个特定型态,只能寻找文字中的矩形。当 Framework 完成了这个 Request,我们希望它调用 detectTextHandler
函数。同时我们也想要知道 Framework 辨识出了什么,这也是为什么我们设定 reportCharacterBoxes
属性为 true。最后,我们设定早先建立好的变量requests
为 textRequest
。
现在,你应该会得到一些错误讯息。这是因为我们还没定义应该用来处理 Request 的函数。为了解决这些错误,建立一个函数像:
func detectTextHandler(request: VNRequest, error: Error?) {
guard let observations = request.results else {
print("no result")
return
}
let result = observations.map({$0 as? VNTextObservation})
}
在上面的代码,我们首先定义一个含有所有 VNDetectTextRectanglesRequest
结果的常数 observations
。接着,我们定义另一个常数 result
,它将遍历所有 Request 的结果然后转换为 VNTextObservation
型态。
现在,更新 viewWillAppear()
方法:
override func viewWillAppear(_ animated: Bool) {
startLiveVideo()
startTextDetection()
}
如果你现在执行你的 App,你不会看到任何的不同。这是因为虽然我们告诉 VNDetectTextRectanglesRequest
要回报字母方框,但是没有告诉它该如何回报。这将是我们接下来要完成的部份。
绘制方框
在我们的 App 中,我们会让 Framework 绘制两个方框:一个所检测的每个字母,另一个则是整个单字。让我们就从制作绘制每个单字的方框开始吧!
func highlightWord(box: VNTextObservation) {
guard let boxes = box.characterBoxes else {
return
}
var maxX: CGFloat = 9999.0
var minX: CGFloat = 0.0
var maxY: CGFloat = 9999.0
var minY: CGFloat = 0.0
for char in boxes {
if char.bottomLeft.x < maxX {
maxX = char.bottomLeft.x
}
if char.bottomRight.x > minX {
minX = char.bottomRight.x
}
if char.bottomRight.y < maxY {
maxY = char.bottomRight.y
}
if char.topRight.y > minY {
minY = char.topRight.y
}
}
let xCord = maxX * imageView.frame.size.width
let yCord = (1 - minY) * imageView.frame.size.height
let width = (minX - maxX) * imageView.frame.size.width
let height = (minY - maxY) * imageView.frame.size.height
let outline = CALayer()
outline.frame = CGRect(x: xCord, y: yCord, width: width, height: height)
outline.borderWidth = 2.0
outline.borderColor = UIColor.red.cgColor
imageView.layer.addSublayer(outline)
}
我们一开始先在函数里定义一个常数 boxes
,他是由 Request 所找到的所有 characterBoxes
的组合。然后,我们定义一些在视图上的坐标点来帮助我们定位方框。最后,我们建立一个有给定范围约束的 CALayer
并将它应用在我们的 imageView
上。接下来,就让我们来为每个字母建立方框吧。
func highlightLetters(box: VNRectangleObservation) {
let xCord = box.topLeft.x * imageView.frame.size.width
let yCord = (1 - box.topLeft.y) * imageView.frame.size.height
let width = (box.topRight.x - box.bottomLeft.x) * imageView.frame.size.width
let height = (box.topLeft.y - box.bottomLeft.y) * imageView.frame.size.height
let outline = CALayer()
outline.frame = CGRect(x: xCord, y: yCord, width: width, height: height)
outline.borderWidth = 1.0
outline.borderColor = UIColor.blue.cgColor
imageView.layer.addSublayer(outline)
}
跟我们前面所撰写的代码相似,我们使用 VNRectangleObservation
来定义约束条件,让我们更容易地勾勒出方框。现在,我们已经设置好所有的函数了。最后一步便是要连接所有的东西。
连接程序
有两个主要的部分需要连接。第一个是处理 Request 的函数。我们先来完成个这个吧。像这样更新 detectTextHandler
方法:
func detectTextHandler(request: VNRequest, error: Error?) {
guard let observations = request.results else {
print("no result")
return
}
let result = observations.map({$0 as? VNTextObservation})
DispatchQueue.main.async() {
self.imageView.layer.sublayers?.removeSubrange(1...)
for region in result {
guard let rg = region else {
continue
}
self.highlightWord(box: rg)
if let boxes = region?.characterBoxes {
for characterBox in boxes {
self.highlightLetters(box: characterBox)
}
}
}
}
}
我们从让代码非同步执行开始。首先,我们移除 imageView
最底层的图层(如果你有注意到,我们先前添加了许多图层到 imageView
中。)接下来,我们从 VNTextObservation
的结果里确认是否有区域范围存在。现在,我们调用沿着范围(或者说单字)绘制方框的函数。然后我们确认是否有字符方框在这个范围里。如果有,我们调用方法来沿着字母绘上方框。
现在,连接所有东西的最后一个步骤就是以即时影像来执行我们的 Vision Framework 代码。我们需要做的是录制影像并将其转换为 CMSampleBuffer
。在 ViewController.swift
的扩展(Extension)中插入下面的代码:
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
var requestOptions:[VNImageOption : Any] = [:]
if let camData = CMGetAttachment(sampleBuffer, kCMSampleBufferAttachmentKey_CameraIntrinsicMatrix, nil) {
requestOptions = [.cameraIntrinsics:camData]
}
let imageRequestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: 6, options: requestOptions)
do {
try imageRequestHandler.perform(self.requests)
} catch {
print(error)
}
}
在那边打住一下。这是我们代码的最后部分了。这个扩展调用了 AVCaptureVideoDataOutputSampleBufferDelegate
协定。基本上这个函数所做的就是它确认 CMSampleBuffer
是否存在以及提供一个 AVCaptureOutput
。接着,我们建立一个 VNImageOption
型态的字典(Dictionary)变量 requestOptions
。VNImageOption
是一个结构(struct)类型,它可以从相机中保持着资料及属性。最后我们建立一个 VNImageRequestHandler
物件并执行我们早先建立的文字 Request。
Build 及 Run 你的 App,看看你得到什么!
小结
Well,接下来是个大工程呢!试着用不同字型、大小、字体、粗细等等来测试 App 吧。看看是否你可以扩展这个 App 。你可以在下面的回应中贴上你如何扩展这个项目。你也可以结合 Vision Framework 及 Core ML。想要更多关于 Core ML 的资讯,可以参阅先前撰写的 Core ML 介绍教学。
你可以参考放在 GitHub 上的 完整项目。
更多关于 Vision Framework 的细节可以参考 Vision Framework 官方文件。你也可以参考 WWDC 关于 Vision Framework 的演讲:
Vision Framework: Building on Core ML
Advances in Core Image: Filters, Metal, Vision, and More
原文:Using Vision Framework for Text Detection in iOS 11
简宝玉写作群日更打卡第 28 天