SwiftUIとOCRライブラリ「SwiftyTesseract」を使って文字数カウントアプリを作ってみた

SwiftUI Tutorialsをとりあえずやったものの、以来触る機会がなかったので世の中に取り残される危機感を払拭するために、復習を兼ねて簡単な文字数カウントアプリを作ってみました。
以前から興味のあったOCRライブラリも一緒に試すことで一石二鳥で学習できたこともあり、せっかくなのでQiitaに残しておこうと思います。

とてもシンプルなアプリなので、先に完成品のgifを貼っておきます。
ボタンには「撮影する」と表示されてますが、今回は省略してイメージライブラリの中から選択しています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89348/69fe7a4d-b67e-0333-3842-a906a7f93438.gif" width="300px">

## 対象読者、環境

- SwiftUI Tutorials 経験者
- Xcode 11 / Swift 5

## さっそく実装

SwiftUI Tutorialsを経験していることを前提に話を進めるため、プロジェクトやファイルの作成等の基本的操作は省略します。まだの方は先に進めるか交互に見ながら読み進めることをおすすめします。
[https://developer.apple.com/tutorials/swiftui](https://developer.apple.com/tutorials/swiftui)

### 最初のViewにButtonを追加

起動後の最初のViewに「撮影する」ボタンと「解析する」ボタンを追加します。
ファイル名は`HomeView`としてますが、もちろん`ContentView`でも構いません。

```HomeView.swift
import SwiftUI

struct HomeView: View {

    var body: some View {
        VStack(alignment: .center, spacing: 10) {

            Button(action: {
                //
            }) {
                Text("撮影する")
            }.padding()
                .background(Color.green)
                .foregroundColor(.white)
                .cornerRadius(10)
            
            Button(action: {
                // OCR実行
            }) {
                Text("解析する")
            }.padding()
                .background(Color.gray)
                .foregroundColor(.white)
                .cornerRadius(10)
            
        }
    }
}

struct HomeView_Previews: PreviewProvider {
    static var previews: some View {
        HomeView()
    }
}
```

### 画像を取得するためのImagePickerViewを追加

画像を取得するために`UIImagePickerViewController`を使います。Tutorialsの「Interfacing with UIKit」を参考に`UIViewControllerRepresentable`を準拠させたViewクラスと`UIImagePickerControllerDelegate`メソッドを利用するためのCoodinatorクラスを用意します。

まずはCoodinatorに必要な`NSObject`とUIImagePickerViewControllerんい必要な`UINavigationControllerDelegate`, `UIImagePickerControllerDelegate`を準拠させた`ImagePickerCoordinator`クラスを作ります。

```ImagePickerView.swift
class ImagePickerCoordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
    
    @Binding var isShown: Bool
    @Binding var uiImage: UIImage?
    
    init(isShown: Binding<Bool>, image: Binding<UIImage?>) {
        _isShown = isShown
        _uiImage = image
    }
    
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        uiImage = info[.originalImage] as? UIImage
        isShown = false
    }
    
    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        isShown = false
    }
}
```

`isShown`は画像選択/キャンセル時に画面を閉じるため、`uiImage`はイメージデータを参照するために`@Binding`を付けてプロパティを定義しています。この設計であればクラスの責務が極端に肥大化することなく、View側はFatにならずに済みますね。

```ImagePickerView.swift
struct ImagePickerView: UIViewControllerRepresentable {
    
    @Binding var isShown: Bool
    @Binding var uiImage: UIImage?
    
    func makeCoordinator() -> ImagePickerCoordinator {
        return ImagePickerCoordinator(isShown: $isShown, image: $uiImage)
    }
    
    func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
        let picker = UIImagePickerController()
        picker.delegate = context.coordinator
        return picker
    }
    
    func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
    }
}
```

こちらは先ほどの`ImagePickerCoordinator`を利用して間接的にデリゲートメソッドを呼べるようにしています。

### HomeViewからImagePickerを呼び出し

`sheet`メソッドを追加して「撮影する」ボタン押下時に`ImagePickerView`へ遷移させます。

画像選択後はボタンの上に画像を表示させます。
また、画像が無いときは「解析ボタン」を無効にしたいので、真偽値を渡して制御するようにしました。

```HomeView.swift
import SwiftUI

struct HomeView: View {
    
    @State private var showImagePicker: Bool = false
    @State private var uiImage: UIImage? = nil
    
    private var hasImage: Bool {
        return uiImage != nil
    }
    
    var body: some View {
        VStack(alignment: .center, spacing: 10) {
            
            if hasImage {
                Image(uiImage: uiImage!)
                    .resizable()
                    .scaledToFit()
            }
                        
            Button(action: {
                self.showImagePicker = true
            }) {
                Text("撮影する")
            }.padding()
                .background(Color.green)
                .foregroundColor(.white)
                .cornerRadius(10)
            
            Button(action: {
                // OCRを実行
            }) {
                Text("解析する")
            }.padding()
                .background(hasImage ? Color.orange : Color.gray)
                .foregroundColor(.white)
                .cornerRadius(10)
                .disabled(!hasImage)
    
        }.sheet(isPresented: $showImagePicker) {
            ImagePickerView(isShown: self.$showImagePicker, uiImage: self.$uiImage)
        }
    }
}

struct HomeView_Previews: PreviewProvider {
    static var previews: some View {
        HomeView()
    }
}
```

### 「[SwiftyTesseract](https://github.com/SwiftyTesseract/SwiftyTesseract)」をプロジェクトへ追加

今回はCocoaPodsでインストールしました。もちろんライブラリ管理ツールは、SwiftUIに関わらずこれまで通り使えます。
但し、ライブラリ側がSwift 5に対応している必要があるのでそこは注意が必要です。

「[SwiftyTesseract](https://github.com/SwiftyTesseract/SwiftyTesseract)」はリリースバージョン自体はまだSwift 4.2まででしたが、Swift 5に対応したブランチが存在したためそちらを利用しました。

```Podfile
pod 'SwiftyTesseract', git: 'https://github.com/SwiftyTesseract/SwiftyTesseract.git', branch: '3.0.0'
```

### 学習データをプロジェクトへ追加

SwiftyTesseractでは、[Tesseract](https://github.com/tesseract-ocr/tesseract)用にあらかじめ用意されている学習データをそのまま利用することができます。
今回は日本語を取り扱う文字数カウントをしたいので日本語学習データを利用します。

[tesseract-ocr/tessdata_fast](https://github.com/tesseract-ocr/tessdata_fast) から速度重視の学習データをダウンロードします。 👉[こちら](https://github.com/tesseract-ocr/tessdata_fast/blob/master/jpn.traineddata)
プロジェクトの以下のようにtessdataフォルダを作成し、ダウンロードしたファイルを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89348/2ee910be-3acd-039f-fec3-ce34233a0f37.png)

これだけで自動で学習データを読み込んでくれます👏

### 「撮影する」ボタン押下時にOCRの実行

SwiftyTesseractをプロジェクトに追加できたらimportしてコードを書いていきます。
取得した文字列は空行や空文字が入っており、純粋にカウントすると文字数以上の数値になってしまうのでトリミングしました。

```HomeView.swift
import SwiftUI
import SwiftyTesseract

struct HomeView: View {
    ...
    @State private var characterCount: Int? = nil

    var body: some View {
        VStack(alignment: .center, spacing: 10) {
            ...						

            Button(action: {
                let swiftyTesseract = SwiftyTesseract(language: .japanese)
                let result = swiftyTesseract.performOCR(on: self.uiImage!)
                switch result {
                case .success(let string):
                    print(string)
                    let trimString = string.components(separatedBy: .whitespacesAndNewlines).joined()
                    print(trimString)
                    self.characterCount = trimString.count
                case .failure: break // Handle the error
            }
        }) {
            Text("解析する")
        }.padding()
            .background(hasImage ? Color.orange : Color.gray)
            .foregroundColor(.white)
            .cornerRadius(10)
            .disabled(!hasImage)
...
```

### 実行結果をTextに反映

最後に「解析する」ボタンの下に以下のコードを追加して文字数を表示するようにして終わりです。

```swift
if characterCount != nil {
    Text("文字数: \(characterCount!)")
}
```

## あとがき

今回の内容もSwift Tutorialsと同様のレベルではあるものの、事前に用意されていないものを作ることでより実になったように感じます。また思っていたよりもコードが少なく、シンプルに書けたのでSwift UIにより魅了されました。また、Swift UIとWi-Fi 実機デバッグの開発体験は非常に良くて、動作確認すでも待ちやモヤモヤが随分解消されました。

SwiftyTesseractについては、最初の画像以外にもいろいろ試したところ思ったよりも精度が高かったのですが、やはり長文や手書きだと精度が落ちてしまうため、次の学習として[ML Kit for Firebase](https://firebase.google.com/products/ml-kit/?hl=ja)を試そうかと思っています。

**Swift UIを使うとクライアントの準備コストが下がるため、バックエンド側の試作に注力できるのが良いですね。**

## 参考

- [SwiftUI Tutorials \| Apple Developer Documentation](https://developer.apple.com/tutorials/swiftui)
- [【iOS】オープンソースのOCRライブラリ「SwiftyTesseract」を試してみる \- Qiita](https://qiita.com/shinjism/items/64e6f9f64ce8f2c1feb2)