利用 VisionKit 框架 在 SwiftUI 掃描圖片及辨識文字


歡迎來到新一篇教學,這次我們要來討論兩個有趣而且互相關聯的概念:如何掃描圖片並辨識當中的文字。這件事聽起來或許非常複雜,不過很快你就會發現完全不是如此。有了 Vision 框架,現在要執行文字掃描和辨識,已經是相當簡便的工作了。

讓我們來了解一下文字掃描和辨識的細節。我們會使用 VisionKit 框架,它有一個專用的類別 VNDocumentCameraViewController,來讓裝置可以掃描圖片。這是 UIKit 的視圖控制器 (view controller),讓使用者可以透過系統的介面和相機,來掃描單張或是多張頁面,掃描後我們會得到一些圖片(UIImage 物件),讓我們可以隨意做後續處理。

有了這些掃描取得的頁面,意味著我們得到了包含著文字的圖片,而 Vision 框架 這時候就可以大派用場了。它會以掃描所得的圖片為輸入 (input),然後執行辨識的操作來獲得文字。另外,它也可以為辨識的工作進行一些設定,以便提高辨識的準確度和速度。我們會在文章後面的部分再詳細討論這些細節。

我們會實作一個簡單的 SwiftUI App,來了解本篇文章要介紹的功能。我們會混合應用 UIKit 及 SwiftUI,因為 VNDocumentCameraViewController 是一個 UIKit 的視圖控制器,不過我們會一步一步慢慢地實作,將其他的類別組合起來。

編者備註:如果你是第一次接觸 SwiftUI,可以先閱讀我們《精通 SwiftUI》一書。

在下一個部分中,我們會概略看一下這篇文章的範例 App,並實作上述提到的所以東西,從掃描文件、到文字辨識的部分。在讀完這篇教學後,你將學會如何整合文字掃描和辨識的功能到你的 App 上。

範例 App 概覽

今天我們會透個一個 SwiftUI App,來探索文字掃描及辨識這個領域。為了節省時間,在開始之前請先先下載起始專案。App 的某些部分已經事先建立好了,但是其他部分還是需要由我們親手打造。

我們的範例是一個導航 App,建基於一個主視圖和一個詳細視圖。ContentView 是主視圖,當中有一個 NavigationView,包含了一個列表和一個在導航列、讓我們啟動掃描的按鈕。你會發現列表視圖預設是無法使用的,因為有一部分程式碼的實作還沒有存在。也就是說,在這個起始專案中我們還未能導航;我們必須加上那些尚未存在的部分,才能使用導航功能。

我們的範例 App 完成後會是這樣:

scanner-text-recognition-swiftui

點擊 Scan 按鈕之後,App 會展示一個系統提供的控制器,用來掃描圖片。掃描之後,所有圖片會被傳送到我們將會實作的文字辨識功能中,並且開始進行辨識。經過辨識之後,從圖片中辨識出來的文字會被顯示在列表之中,而我們點擊列表中的物件,就可以在第二個視圖 TextPreviewView 中預覽完整的文字。

在進行這個教學的下一步之前,我們首先需要建立幾個檔案來添加新的程式碼,並對原本已存在於起始專案裡的 SwiftUI 視圖來做一些修改。下載了範例 App 之後,就來看看如何顯示一個掃描視圖吧!

請註意:要使用相機來掃描文字,我們先要先取得使用者的權限。因此我們必須在 Info.plist 檔案內加上一個參數 NSCameraUsageDescription,或是更口語化一點的名稱 Privacy – Camera Usage Description。這個參數和使用相機的原因都已經包含在起始專案中。不過請謹記,如果沒有這個參數的話,在使用相機時 App 就會崩潰 (Crash)。

實作掃瞄器

讓我們開始實作掃描控制器吧!首先,我們要在專案中加入一個新的檔案。在鍵盤按下 Cmd+N,並在跳出的視窗中選擇 Swift File 模版。下一步,將這個檔案命名為 ScannerView 就可以了。

如果前文的簡介中提到,VNDocumentCameraViewController 是一個 UIKit 的視圖控制器。為了要讓這個檔案可以在 SwiftUI 中使用,我們需要實作一個客製化結構 (structure) 來遵從 UIViewControllerPresentable 協定。在這之前,我們需要利用以下兩行程式碼來取代預設的 Foundation 框架:

import SwiftUI
import VisionKit

我們需要 SwiftUI 框架來使用 UIViewControllerRepresentable 協定,還有 VisionKit 來使用 VNDocumentCameraViewController。

接著就可以定義新的客製化型別,在這裡會使用跟檔名一樣的名稱:ScannerView:

struct ScannerView: UIViewControllerRepresentable {

}

要使用 UIViewControllerRepresentable 這個協定的話,我們需要實作兩個方法。第一個方法是用來建立、設定、和回傳一個 UIKit 視圖控制器;而第二個方法是用來按 SwiftUI 環境的改變來更新視圖控制器。雖然第二個方法需要被定義出來,但是方法的 body 可以留白。

那就讓我們從第一個方法 makeUIViewController(context:) 開始吧。首先,建立一個新的 VNDocumentCameraViewController 實例,並回傳這個實例。

func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
    let scannerViewController = VNDocumentCameraViewController()

    // Set delegate here.

    return scannerViewController
}

我們可以看到 VNDocumentCameraViewController 類別的 initializer 並不需要帶入參數。不過,為了能夠從視圖控制器取得掃描結果的圖片,我們需要將一個物件指定為掃瞄器的委派 (delegate),並實作幾個特定的方法。這個物件不會是 ScannerView 的實例,因為它必須是一個類別型別,我們在下文會再加以解釋。

接下來,在這邊加上第二個需要的方法,不過我們暫時還用不到它:

func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) { }

Coordinator 類別

為了處理委派的方法,以及各種來自 UIKit 的訊息,我們需要在 UIViewControllerRepresentable 型別的 body 內部 建立一個 Coordinator 類別。

要建立這個類別,讓我們宣告一個儲存屬性 (stored property),並定義一個客製化的 initializer:

struct ScannerView: UIViewControllerRepresentable {
    ...

    class Coordinator {
        let scannerView: ScannerView

        init(with scannerView: ScannerView) {
            self.scannerView = scannerView
        }
    }
}

這個 scannerView 屬性會存放著 ScannerView 實例,然後在初始化 (initialization) 時傳遞給 Coordinator 類別。我們一定要擁有這個屬性,因為這樣我們才可以讓 SwiftUI 知道掃描的動作甚麼時候完成。

接下來,讓我們開始初始化 Coordinator 實例。要初始化 Coordinator 實例,我們需要先實作另外一個 UIViewControllerRepresentable 方法。在 ScannerView 結構裡面、Coordinator 類別外面,加上這段程式碼:

struct ScannerView: UIViewControllerRepresentable {
    ...

    func makeCoordinator() -> Coordinator {
        Coordinator(with: self)
    }
}

現在,我們可以將委派物件設置給 VNDocumentCameraViewController 實例。回到 makeUIViewController(context:) 方法,將 // Set delegate here. 這段註解替換成以下的程式碼:

scannerViewController.delegate = context.coordinator

你會注意到,在這個方法中的 context 參數可以直接存取 coordinator 物件。當你完成之後,makeUIViewController(context:) 方法看起來會是這樣:

func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
    let scannerViewController = VNDocumentCameraViewController()
    scannerViewController.delegate = context.coordinator
    return scannerViewController
}

進行到這一步,我們其實是在告訴 scannerViewController,Coordinator 實例是它的委派物件。不過,Xcode 似乎顯示了幾個錯誤,因為我們還沒有實作任何委派的方法,讓我們在下部分來實作吧!

委派方法

回到 Coordinator 類別,讓我們從更新標頭 (header) 開始:

class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
    ...
}

現在,Coordinator 類別遵從 VNDocumentCameraViewControllerDelegate 協定,協定包含了我們需要在這裡實作的委派方法定義。另外,Coordinator 也繼承了 NSObject 類別,以滿足 VNDocumentCameraViewControllerDelegate 協定的要求;因此它也遵從 NSObjectProtocol 協定。事實上,我們必須選擇實作一系列 NSObjectProtocol 所需要的方法,或是單純地繼承 NSObject 類別;很明顯後者是比較方便。

在這邊,我們有三個必要實作的委派方法。每個都會被 VNDocumentCameraViewController 實例所呼叫,來處理不同的事件:

  1. 當已經掃描好的圖片需要處理,就會呼叫第一個委派方法。
  2. 當使用者取消了掃描工作,就會呼叫第二個委派方法。
  3. 當有錯誤發生,而其實沒有掃描好的圖片需要處理,就會呼叫第三個委派方法。

讓我們先初步實作第二和第三個委派方法:

class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
    ...

    func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {

    }

    func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: Error) {

    }
}

兩個方法的 body 都只有一行程式碼,用來通知 SwiftUI 個別的結果。不過,我們待會再回到這個部分。我們還要實作第一個委派方法,而這個方法是最有趣而重要的一個:

class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
    ...

    func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) {

    }
}

在上列的方法裡,scan 參數以圖片形式存放了所有掃描好的文件。每個掃描的結果都被視為一個頁面 (page),這就是為什麼我們說掃描好的頁面會以圖片的形式回傳。

為了存放所有掃描好的頁面,我們需要先建構一個 UIImage 物件的 collection。接著,我們會使用一個簡單的迴圈 (loop) 來訪問每個頁面,並儲存圖像。Scan 中的 pageCount 參數定義了可用頁面的數量,也就是迴圈的上限。讓我們看看上述實作的程式碼:

func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) {
    var scannedPages = [UIImage]()

    for i in 0..<scan.pageCount {
        scannedPages.append(scan.imageOfPage(at: i))
    }
}

完成以上的方法之後,我們就能夠以圖片形式取得所有掃描好的頁面。現在,我們只需要通知 SwiftUI 視圖掃描的結果即可!

與 SWiftUI 的視圖溝通

看看上面三個委派方法,其實它們並沒有與 SwiftUI 視圖溝通,因此讓我們針對這部分來做些改變。

我們會使用行為處理器 (action handlers),又或是稱為閉包 (closures),來把掃描結果回傳給 SwiftUI。這些處理器會被 Coordinator 類別所呼叫,但是它們會在 ScannerView 結構的 initializer 當中被實作。不過,我們首先要宣告它們,才可以繼續實作,而宣告的位置就是在 ScannerView 結構裡面。

我們先從簡單的開始,在使用者取消掃描時,就會呼叫這個處理器:

struct ScannerView: UIViewControllerRepresentable {
    var didCancelScanning: () -> Void

    ...
}

這個行為處理器不需要任何的引數 (argument)。我們只為一件事情而呼叫它,而實作也只會針對這個需求而已。然而,第二個處理器就不是這麼簡單了。在這邊我們需要處理兩種情況:掃描成功或是失敗,然後按情況回傳掃描好的頁面,或是發生的錯誤。

最好的方式就是回傳一個 Result 參數。Result 是 Swift 一個特別的型別,用來表示成功或是失敗的結果,並允許我們以關聯值 (associated value) 來傳遞任意值或是錯誤物件。在這個例子當中,我們會在成功時傳遞一組掃描好的圖片,在失敗時傳遞出一個錯誤。

明白上述概念後,是時候宣告第二個處理器了,這次我們有一個 Result 型別的引數。這個語法看起來有點複雜,但是內容其實相當簡單:

struct ScannerView: UIViewControllerRepresentable {
    var didFinishScanning: ((_ result: Result<[UIImage], Error>) -> Void)

    ...
}

Result<[UIImage], Error> 中,第一個型別是成功時我們想要傳遞的資料,而第二個就是一個錯誤型別。

在宣告了前面兩個處理器之後,讓我們回到 Coordinator 類別來使用它們。現在,你會明白為什麼我們在 Coordinator 裡會宣告 scannerView 參數;因為我們將會透過這個參數,來存取剛剛實作的兩個行為處理器。

首先,從簡單的 documentCameraViewControllerDidCancel(_:) 委派方法開始。讓我們如下更新程式碼,讓它在 body 中呼叫 didCancelScanning

func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
    scannerView.didCancelScanning()
}

接下來,讓我們處理關於錯誤的委派方法。你會看到,我們呼叫 didFinishScanning 處理器時,使用的是 .failure Result 型別,並且傳入一個錯誤為引數。

func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: Error) {
    scannerView.didFinishScanning(.failure(error))
}

最後,讓我們到 documentCameraViewController(_:didFinishScanning:) 委派方法,向 Result 型別為 .success 的情況提供 scannedPages 陣列 (Array)。

func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) {
    ...

    scannerView.didFinishScanning(.success(scannedPages))
}

到這邊 ScannerView 終於完成了!現在,我們可以使用它取得掃描好的頁面,並繼續實作文字辨識的部分。

展示掃瞄器

打開 ContentView.swift 檔案,並且到 body 最後實作表單的部分:

.sheet(isPresented: $showScanner, content: {

})

在這個表單的內容裡面,我們要初始化一個 ScannerView 實例。我們提供的兩個參數都是閉包。

.sheet(isPresented: $showScanner, content: {
    ScannerView { result in

    } didCancelScanning: {

    }
})

如果使用者取消了掃描工作,我們只需要讓表單從螢幕上消失即可。我們可以將 showScanner @State 參數的值設定成 false

.sheet(isPresented: $showScanner, content: {
    ScannerView { result in

    } didCancelScanning: {
        // Dismiss the scanner controller and the sheet.
        showScanner = false
    }
})

不過,第一個閉包更加有趣,因為這邊我們處理掃描結果的地方。記得我們在 didFinishScanning 處理器中傳遞了 Result 值為引數,而上面的閉包中 result 值就代表這個引數。我們必須要檢查 result 實際的值,並按情況來執行適當的動作。

為了取得 Result 值以及其關聯值,我們需要在這裡使用 switch 陳述式:

switch result {
    case .success(let scannedImages):
    case .failure(let error):
}

在成功的情況下,scannedPages 值就會包含所有掃描好的圖片。而在失敗的情況下,error 值就會包含真正的 Error 物件。

在取得這些關聯值之後,處理錯誤的方式就因人而異,不同的 App 也會有不同的處理方式。因為我們只是在實作一個範例 App,因此不會針對錯誤做額外的處理,就這樣印出即可:

switch result {
    case .success(let scannedImages):
    case .failure(let error):
        print(error.localizedDescription)
}

在真正實作 App 的時候,當然不應該只將錯誤印出!我們應該好好處理錯誤,有需要的話,也可以顯示訊息告訴使用者掃描失敗。如果你沒有這樣做,使用者就會預計得到掃描頁面,也不明白為什麼沒有得到掃描結果;這是非常差的使用者體驗。

至於在成功的情況下,我們會加上 break 關鍵字,來讓 Xcode 停止顯示錯誤:

switch result {
    case .success(let scannedImages):
        break

    case .failure(let error):
        print(error.localizedDescription)
}

我們會在實作完辨識文字的部分之後,再回來這個方法。之後我們會將 break 指令替換掉,以提供 scannedPages 值來初始化辨識工作。

最後還有一件事情要處理,就是在 switch statement 後面,再次將 showScanner @State 參數設定為 false。如此一來,表單在執行完掃描之後就會關閉。加上這個部分之後,表單的內容會是這樣:

.sheet(isPresented: $showScanner, content: {
    ScannerView { result in
        switch result {
            case .success(let scannedPages):
                break

            case .failure(let error):
                print(error.localizedDescription)
        }

        showScanner = false
    } didCancelScanning: {
        showScanner = false
    }
})

現在我們可以試試這個範例 App,但是記得要在有相機的實機上來執行,否則 App 將會崩潰;這也是在真實 App 中需要注意的事情。當這個表單顯示之後,你會看到 scannner 視圖控制器。不過,可想而知即使你進行掃描,也不會發生任何事情。

一個基本的資料模型

在我們實作文字辨識之前,先來建立一個小資料模型,來存放辨識後的文字。按下 Cmd+N 來建立一個新的 Swift 檔案,取名為 Model.swift

檔案準備好之後,讓我們打開並進行編輯。首先加入以下類別:

class TextItem: Identifiable {

}

或許你會想:為甚麼 TextItem 是一個類別,而不是結構呢?這麼做是有目的的,因為在文字辨識時,我們需要它在一個地方宣告一些物件,然後在另一個地方修改那些物件,再以引數進行傳遞。如果使用結構,就會很難這樣實作。

你會看到我們定義 TextItem 類別遵從 Identifiable 協定。這個也是有目的的,是為了在 SwiftUI 列表視圖中使用 TextItem 實例時更容易。

Identifibale 型別有一個要求,就是必須宣告一個名為 id 的屬性。這個屬性可以是任意的型別,但是必須要讓物件在一組類似的物件中能夠被獨立辨認出來。

讓我們宣告這個屬性,並且定義為一個字串:

class TextItem: Identifiable {
    var id: String
}

在這之後,我們要加上另一個屬性,用來存放辨識出來的文字為字串:

class TextItem: Identifiable {
    ...

    var text: String = ""
}

接著,我們需要實作一個 initializer 方法。在 initializer 裡面,我們會賦予 id 屬性一個獨特的值。

這個獨特的值將會是一個 UUID 字串,也就是我們需要的全域獨立數值 (universaly unique value)。對於像這篇教學的小 App 來說,這個辦法沒甚麼問題,不過我不建議你在大型專案當中使用。

好,讓我們來實作初始化方法,在方法中我們會詢問系統,並取得一個 UUID 值來賦予 id 屬性:

init() {
    id = UUID().uuidString
}

以上就是我們的小小資料模型,整段的實作如下:

class TextItem: Identifiable {
    var id: String
    var text: String = ""

    init() {
        id = UUID().uuidString
    }
}

在上面的程式碼中,我們也建立了一個客製化類別。這個類別只會有一個參數,就是一組 TextItem 物件。這個類型也會遵從 ObservableObject 協定,讓我們可以使用 @Published 屬性包裝器(property wrapper) 來標記這個 TextItems 陣列。如此一來,我們就可以使用 Combine,來通知 SwiftUI 視圖有關陣列的改變。

以下是這個類型:

class RecognizedContent: ObservableObject {
    @Published var items = [TextItem]()
}

以上就是要在 Model.swift 所加上的內容。現在,我們可以開始使用這些客製化型別了。讓我們回到 ContentView.swift 檔案,在 ContentView 結構的最上方,宣告以下屬性:

struct ContentView: View {
    @ObservedObject var recognizedContent = RecognizedContent()

    ...
}

我們剛剛添加的這行程式碼,建立了 RecognizedContent 實例,並存放在 recognizedContent 屬性中。因為我們用 @ObservedObject 屬性包裝器標記了它,所以任何 items 陣列(以 @Published 參數包裝器進行標記)的變化,都會通知 SwiftUI 視圖。

實作文字辨識

為了在範例 App 加上文字辨識的功能,我們需要加上一個新的檔案。跟之前一樣,按下 Cmd+N 來建立新的 Swift 檔案,並且命名為 TextRecognition.swift

在這個全新的檔案中,我們需要匯入兩個框架,來取代預設的 Foundation 框架:

import SwiftUI
import Vision

Vision 框架將會為我們提供所有需要使用的 API。而我們只需要準備好辨識流程,然後就可以把真正的工作交給 Vision 框架。

不過,讓我們開始建立一個新的客製化型別:一個名為 TextRecognition 的結構:

struct TextRecognition {

}

在 TextRecognition 型別中,我們需要宣告三個屬性。第一個是一組 UIImage 物件,也就是存放著需要辨識的掃描頁面的陣列。很明顯地,這就是我們之前實作的 ScannerView 掃描所得的圖片。

第二個屬性是在 ContentView 裡初始化的 RecognizedContent 實例。像之前一樣,我們會利用 @ObservedObject 參數包裝器標記這個屬性,不過我們不需要初始化它,因為這個實例在 ContentView 中會以引數的形式提供。

第三個是一個行為處理器,在辨識處理結束的時候會被呼叫。要注意文字辨識是需要花時間的工作,所以會在背景佇列 (background queue) 中進行,而且完成時間是無法確定的。所以這邊處理的是一個非同步 (asynchronous) 的操作,我們會在處理器完成文字辨識的時候通知 ContentView 。

以下是剛剛所提到的三個屬性:

struct TextRecognition {
    var scannedImages: [UIImage]
    @ObservedObject var recognizedContent: RecognizedContent
    var didFinishRecognition: () -> Void
}

接下來,就讓我們專注在真正的辨識程序。一般來說,以下幾個步驟是必要的處理過程:

我們會為每個需要處理的掃描圖像建立一個特別的物件:一個 VNImageRequestHandler 實例。這個物件的用途是要取得圖片,並透過執行一個 Vision 請求 (request) 來執行辨識。

Vision 請求不只是一個模糊的名詞。對於開發者來說,我們有幾個動作需要執行,而這就是我們的第二個步驟。進一步來說,我們需要啟動一個 VNRecognizeTextRequest 物件,來請求 Vision 框架實際執行辨識動作。

上述的工作會以非同步的方式執行,而在工作結束的時候,我們就會得到請求的結果,或是如果辨識出現錯誤時,就會得到一個錯誤物件。這時候,我們就可以針對辨識出的文字,應用各種客製化的邏輯了。在這篇教學及範例 App 中,我們會在這裡把圖像中辨識出的文字存放在 TextItem 實例裡。

請注意,在執行識別請求之前,我們還可以配置一些屬性。在後面的部分,我們會配置幾個屬性。

我們將會在 TextRecognition 結構中實作兩個客製化方法,來管理以上所有內容。而我們會從最後介紹的部分開始,那就是文字請求物件。

實作一個文字辨識請求

首先,讓我們定義以下的私有方法 (private method):

private func getTextRecognitionRequest(with textItem: TextItem) -> VNRecognizeTextRequest {

}

我們可以看到這裡的參數為 TextItem 實例。因為 TextItem 是一個類別,如果在方法內它有任何變化,就會反映到呼叫者。另外,我們也標記了這個方法為私有,因為我們只想這個方法在客製化類別內可見,而不要被外部使用。

另外,這裡回傳的是一個 VNRecognizeTextRequest 物件,這個請求將會被之前所提到的 VNImageRequestHandler 物件所使用。在這裡,我們將會建立、設定、並且處理這個文字請求物件,不過我們暫時不會使用這個物件。我們在下一個部分實作好另一個方法之後,才會使用這個物件。

在這邊,先建立一個文字辨識請求物件:

let request = VNRecognizeTextRequest { request, error in

}

如前文所述,文字辨識是一個非同步的操作,所以在請求完成時,我們會在一個閉包中獲取執行的結果。這個閉包有兩個參數,第一個是 VNRequest 物件,其中包含著辨識結果,而第二個是可能會發生的錯誤物件。

我們先略過錯誤的情況,因為我們不會對它做太多的處理,畢竟這只是一個範例 App,我們只會印出錯誤訊息。不過記得,在真實的 App 當中,我們不應該只是印出錯誤訊息。

let request = VNRecognizeTextRequest { request, error in
    if let error = error {
        print(error.localizedDescription)
        return
    }
}

上面的 if-let 宣告中直接 return,可以停止執行閉包中的其他程式碼。

接著,讓我們處理辨識結果。我們可以透過 results 屬性來存取辨識結果,那是一個包含 VNRecognizedTextObservation 物件的陣列。這樣的物件包含了原始圖片的文字區域資訊,我們可以用以下的方式取得內容:

guard let observations = request.results as? [VNRecognizedTextObservation] else { return }

Results 的值可能為空值,所以我們需要像上方程式碼這樣解開這個選擇值 (option value)。解開了的文字觀察 (text observation) 物件會被儲存至 observations 常數。

接著,我們要遍歷每個觀察物件,並保存辨識到的文字。這邊大家需要了解這是透過機器執行的文字辨識,不是真人辨識,所以針對圖片中的文字部分,它可能會以不同的正確程度,回傳多於一個辨識結果。而我們想要求最準確的辨識結果,因此我們會如此編寫程式碼:

observations.forEach { observation in
    guard let recognizedText = observation.topCandidates(1).first else { return }
}

上面使用的 topCandidates(_:) 方法可以取得真實辨識出來的文字。而我們提供的引數,是指我們在這次觀察中需要取得多少筆辨識結果。請注意,這個數字不能超過 10,而且得到的結果可能會少於所請求的數量。

儘管如此,在上述程式碼中,guard 語句所定義的 recognizedText 常數包含了實際的辨識文字,因此,現在我們可以繼續執行閉包,並且將它儲存在 textItem 變數中:

observations.forEach { observation in
    ...

    textItem.text += recognizedText.string
    textItem.text += "\n"
}

如果沒有像上面這樣加入最後一行程式碼,散落在多行的文字會全部被儲存在 textItem 物件的 text 屬性中,而完全沒有斷行。當然你可以按照需要的方式來處理這些文字,畢竟這沒有一個特定的做法。

請注意,這裡的 recognizedText 常數是一個 VNRecognizedText 物件,我們可以透過它的 string 屬性來取得真實的文字。

總結以上內容,請求物件會像這樣:

let request = VNRecognizeTextRequest { request, error in
    if let error = error {
        print(error.localizedDescription)
        return
    }

    guard let observations = request.results as? [VNRecognizedTextObservation] else { return }

    observations.forEach { observation in
        guard let recognizedText = observation.topCandidates(1).first else { return }
        textItem.text += recognizedText.string
        textItem.text += "\n"
    }
}

在這個閉包的結尾之後,我們可以針對請求物件做一些設置:

request.recognitionLevel = .accurate
request.usesLanguageCorrection = true

第一個屬性是用來設置文字辨識的精準度。.accurate 明顯會回傳更好的辨識結果,但是也會花更多時間來執行。另一個選擇是 .fast,而相對要捨棄的就是精準度了。你可以按需要來選擇適合的設置,我建議你可以在教學結束之後試試不同的設置,看看結果會有甚麼不同。

第二個屬性是用來告訴 Vision 框架,應否在辨識的時候進行語言修正 (language correction)。當設置為 true 的時候,所得到的結果就會更準確,但是會花更多的時間來執行。設置為 false 的話,就不會進行語言修正,但結果的準確度就可能比較低。

最後,別忘記回傳剛剛建立且設置好的 request 物件:

return request

完整的 getTextRecognitionRequest(with:) 方法如下:

private func getTextRecognitionRequest(with textItem: TextItem, currentImageIndex: Int) -> VNRecognizeTextRequest {
    let request = VNRecognizeTextRequest { request, error in
        if let error = error {
            print(error.localizedDescription)
            return
        }

        guard let observations = request.results as? [VNRecognizedTextObservation] else { return }

        observations.forEach { observation in
            guard let recognizedText = observation.topCandidates(1).first else { return }
            textItem.text += recognizedText.string
            textItem.text += "\n"
        }
    }

    request.recognitionLevel = .accurate
    request.usesLanguageCorrection = true

    return request
}

執行文字辨識

相信進行到這邊,你也非常清楚文字辨識是一件非常花時間的事情,而且我們無法確定需要多少時間來完成。要進行這樣耗時的工作,我們自然不希望主線程 (main thread) 保持忙碌的狀態,反而希望 App 可以保持流暢,而且在文字辨識的過程中仍然可以被使用。為了能夠達到這個目的,我們需要利用背景分派佇列(background dispatch queue),在背景執行所有任務。這就是我們需要定義的第二個方法:

func recognizeText() {

}

跟之前的方法不同,這個方法不會被標記成私有。我們需要這個方法能夠被 TextRecognition 以外的類別存取,因為這個方法會啟動整個文字辨識的流程。

就像之前提到的,我們的第一步就是定義一個新的背景佇列,然後利用它來非同步地執行所有操作:

let queue = DispatchQueue(label: "textRecognitionQueue", qos: .userInitiated)
queue.async {

}

當這個佇列被初始化時,我們會提供兩個引數:可以是任意字串值的標籤、和服務品質 (quality of service) 的值。第二個引數定義了這個佇列在背景執行的優先權,.userInitiated 就是代表著最高優先權。

備註:如果你想了解關於分派佇列,可以參考這篇文章

在佇列的 async 閉包中,我們將建立一個迴圈 for-in,用來迭代處理所有掃描好的影像。

for image in scannedImages {

}

我在前文提過,我們會為每一個掃描好的圖片建立一個 VNImageRequestHandler 物件,這個物件會取得真實的圖片及前面所實作的文字辨識請求,並且啟動文字辨識的行為。要傳遞的圖片可以是各種型別,除了像我們在 scannedImages 陣列存放的 UIImage。

因此,在迴圈內的第一步,就是要從原本的 UIImage 取得 CGImage 物件,像是這樣:

guard let cgImage = image.cgImage else { return }

由於 cgImage 屬性有機會是空值,所以我們必須像上述一樣使用 guard 陳述式(或 if-let)。

現在,CGImage 代表原始圖片之後,我們就可以初始化一個 VNImageRequestHandler 物件。我們會傳入一個 cgImage 物件為引數。

let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:])

第二個引數是一組選項的數組,有需要的話我們就可以針對不同選項來做設定。不過這邊我們不需要,所以傳入空的數組。

因為我們即將呼叫的 perform 方法可能會拋出錯誤,所以要在這裡將剩下的程式碼包在 do-catch 陳述式裡。我們也可以使用選擇值 (Optional Value) 來處理這個情況,不過使用 do-catch 會比較適合。

所以在 for-in 迴圈裡,我們會加上以下的程式碼:

do {

} catch {
    print(error.localizedDescription)
}

同樣地,我們沒有打算處理錯誤,不過在真實的 App 中請你自行處理。

do 程式碼中,我們先建立一個 TextItem 物件,用來儲存辨識好的文字,我們已經在前面的方法中處理好這部分的邏輯。

let textItem = TextItem()

是時候使用 requestHandler 物件,來執行文字辨識的請求了:

try requestHandler.perform([getTextRecognitionRequest(with: textItem)])

我們會在這裡呼叫 getTextRecognitionRequest(with:) 方法,並且傳入 textItem 來當做引數。不過請注意,我們是在一個陣列裡面呼叫方法的,因為 perform(_:) 方法可以接受一個陣列的請求(雖然我們只有一個請求)。最後,以上的這行程式碼說明了需要 do-catch 陳述式的原因,因為我們在 perform(_:) 方法之前標記了 try 關鍵字。

當目前圖片的文字辨識完成之後,辨識出來的文字會被儲存在 textItem 物件裡。這時候,我們就可以把它加在 TextRecognition 型別中 recognizedContent 屬性的 items 裡。還記得我們用 @ObservedObject 屬性包裝器把這個屬性標記了嗎?也就是說,SwiftUI 視圖會知道屬性的所有變化。也因為如此,這個參數的任何修改行為都必須在主線程執行。

DispatchQueue.main.async {
    recognizedContent.items.append(textItem)
}

最後,我們還需要在關閉佇列的閉包之前,在 for-in 迴圈呼叫 didFinishRecognition 處理器,來通知文字辨識已經完成了。

DispatchQueue.main.async {
    didFinishRecognition()
}

以下是 recognizeText() 方法的完整實作:

func recognizeText() {
    let queue = DispatchQueue(label: "textRecognitionQueue", qos: .userInitiated)
    queue.async {
        for image in scannedImages {
            guard let cgImage = image.cgImage else { return }

            let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:])
            do {
                let textItem = TextItem()
                try requestHandler.perform([getTextRecognitionRequest(with: textItem)])

                    DispatchQueue.main.async {
                        recognizedContent.items.append(textItem)
                    }

            } catch {
                print(error.localizedDescription)
            }
        }

        DispatchQueue.main.async {
            didFinishRecognition()
        }
    }
}

現在文字辨識的部分已經完成了!接下來,讓我們應用剛剛實作好的部分。

啟動文字辨識

現在回到 ContentView.swift 檔案,我們會在這裡建立一個 TextRecognition 結構的實例,並且啟動文字辨識的流程。這部分將會在使用者掃描完包含文字的圖片後執行。

回到表單內容的實作,之前我們在這裡加上了 switch 陳述式。在 .success 的情況下,我們暫時使用了 break 來處理掃描結果。

case .success(let scannedImages):
    break

是時候改好這裡的程式碼了,將 break 移除並且加上這行程式碼:

isRecognizing = true

isRecognizing 是一個在 ContentView 的屬性,它被 @State 屬性包裝器標記。它的用處是標示文字辨識的工作是否正在進行。當這個值為 true 時,就會顯示一個圓形的 progress 視圖;而當 isRecognizing 數值回復為 false 時,視圖就會消失。Progress 視圖已經在初始專案中建立好了。

現在可以初始化一個 TextRecognition 實例:

TextRecognition(scannedImages: scannedImages,
                recognizedContent: recognizedContent) {

}

第一個引數是一組從掃描結果所得到的 scannedImages 陣列,第二個是在 ContentView 結構中被宣告和建構的 recognizedContent 屬性。最後一個是在文字辨識完成後會執行的 didFinishRecognition 閉包。而在文字辨識完成的時候,我們需要將 progress 視圖隱藏,所以必須將 isRecognizing 屬性設置為 false。

TextRecognition(scannedImages: scannedImages,
                recognizedContent: recognizedContent) {

    // Text recognition is finished, hide the progress indicator.
    isRecognizing = false
}

上述的程式碼還不足以啟動文字辨識的程序,我們還需要呼叫 recognizeText() 方法。我們會在 TextRecognition 方法後面進行呼叫:

TextRecognition(scannedImages: scannedImages,
                recognizedContent: recognizedContent) {
    ...
}
.recognizeText()

這時,掃描結果在 .success 的情況下看起來會像這樣:

case .success(let scannedImages):
    isRecognizing = true

    TextRecognition(scannedImages: scannedImages,
                    recognizedContent: recognizedContent) {

        // Text recognition is finished, hide the progress indicator.
        isRecognizing = false
    }
    .recognizeText()

不過,我們還沒有完成!回到 body 最開始的部分,你可以找到下列的註解:

// Uncomment the following lines:

下列三行程式碼的註解是用來定義一個列表和其內容,讓我們移除這些註解:

List(recognizedContent.items, id: \.id) { textItem in
    Text(String(textItem.text.prefix(50)).appending("..."))
}

這個列表就是我們要讓 TextItem 類別遵從 Identifiable 協定的原因。我們將 id 屬性設定為 keypath,代表在 recognizedContent 屬性內 items 陣列每個物件的獨特識別符 (identifier)。

在列表中每個物件都是 Text,每個只顯示前 50 個掃描字元。我們可以利用 prefix(_:) 方法,來取得實際的文字,而回傳的數值會是的子字串 (SubString)。因此我們會將它作為引數來提供給 String 初始化器,而它最終會與 Text 一起使用。

最後,是時候來看我們的 App 能不能實際執行了!在實機上執行這個 App,並掃描一個或多個圖片。完成後,Progress 視圖會出現,也就是說 App 正在進行文字辨識。之後,你就會在列表得到掃描好的文字。

預覽辨識到的文字

這個列表只顯示辨識文字的前 50 個字元,主要有兩個原因:

  1. 保持列表流暢,避免讀取大量資料(像是要辨識非常長的文字)。
  2. 只顯示部分文字,並且讓使用者可以開啟另一個視圖,來顯示完整文字。

這個額外的視圖就在初始專案裡;就是在 TextPreviewView 檔案中,而我們不需要做額外的處理,因為已經實作完成了。不過,我們可以打開來看一下。

在這個檔案中有一個 text 屬性,它沒有預設值,所以我們需要在建構時傳入一個數值。

在使用者點擊列表上的物件時,我們會使用 TextPreviewView 來顯示完整的文字。然而,我們需要先改變 ContentView 中列表的內容。

特別注意,我們會將列表中的文本視圖 (Text View) 替換成 NavigationLink。這樣一來,我們就可以將 TextPreviewView 的實例推送到導航畫面。主詳細導航是透過 ContentView 最外層的畫面 NavigationView 來實作的。

這個 NavigationLink 視圖會將我們想展示的 SwiftUI 視圖當做參數,在這個範例中,就是 TextPreviewView 的實例:

NavigationLink(destination: TextPreviewView(text: textItem.text)) {

}

這個閉包會包含一個可顯示在列表中每個物件的內容。這就是我們之前的 Text:

NavigationLink(destination: TextPreviewView(text: textItem.text)) {
    Text(String(textItem.text.prefix(50)).appending("..."))
}

改好程式碼之後,我們的列表會像這樣:

List(recognizedContent.items, id: \.id) { textItem in
    NavigationLink(destination: TextPreviewView(text: textItem.text)) {
        Text(String(textItem.text.prefix(50)).appending("..."))
    }
}

再一次執行掃描和文字辨識的流程,而這次我們將可以點擊列表中的文字物件,並且導航至另一個可以看到完整文字內容的視圖。點擊在導航列上的 Back 按鈕,就可以回到上一頁。

img

結論

這篇詳細的教學結束了。在這篇文章中,我們接觸了兩個非常有趣又實用的主題:在 SwiftUI 中實作並使用 VNDocumentCameraViewController 來掃描圖片、以及使用 Vision 框架來辨識文字。當然,你不一定要完全跟著我們的步驟來實作,特別是實作辨識文字的部分。

總之,我只是在這裡分享了一般的實作方法,你可以以這篇教學為基礎,按需要來改變實作方法和細節。不管如何,我希望你喜歡這篇文章,也會覺得這篇文章的技巧和概念有用。謝謝你的閱讀!

你可以在 GitHub 上參考完整專案。

譯者簡介:周竑翊 – 只待過新創公司的 iOS 開發者,本職是兩個兒子的爸。有空的時間喜歡看看新的技術跟科技時事。用 Playground 寫寫 Swift,但是 side project 仍然難產。其他興趣喜歡攝影、運動及看電影。歡迎寄信與我聯絡:[email protected]
原文How to Scan Images and Perform Text Recognition in SwiftUI Using VisionKit


資深軟體開發員,從事相關工作超過二十年,專門在不同的平台和各種程式語言去解決軟體開發問題。自2010年中,Gabriel專注在iOS程式的開發,利用教程與世界上每個角落的人分享知識。可以在Google+或推特關注 Gabriel。

blog comments powered by Disqus
Shares
Share This