PR

iOS アプリ開発始めました 〜 カメラで文字を読取るアプリ

前回の環境構築&お勉強編に続き、iOSアプリ開発ネタの話です。ある程度の環境と知識が整い実際にアプリ開発をスタートしました。今回のポイントは「各画面の作成」「画像とカメラの取り扱い」「文字認識」「ファイル入出力」になります。新規アプリ作成者のMittyが、どのように理解して実装していったのかの備忘録です。

各画面の作成

画面のデザインはMittyが最も苦手とする仕事です(汗。SwifUIを利用する事でそれなりの「見た目」になりますが、どうしても「センス」がないシーンが多いです。ツールやユーティリティなどは使いやすさや見やすさが重要なので、デザインを意識する必要はあまりない事が多いですが。UIの実装事態はサンプルやAPIを見ながらPreviewで確認できるのでXcodeは良いですよね。今回は初めてだったので何度もソース書いては消してを繰り返してかなりのスパゲッティーニになりましたが(汗。

SwiftUIでの再描画

View関連の実装をする時にネックというか、面倒なのが「再描画処理」です。毎回描画すると速度的に問題になったり、更新しているのにそのパーツが更新されなかったり。自前でフレームワークを作るともう最低です。

SwiftUIは天才が作ったフレームワークなので、そのあたりはかなり最適化されていて、必要な時に必要なところだけを描画するようにしか実装できなくなっています。無理やり再描画とか色々と試しましたが、言われたとおりに実装するのが一番という結論に至りました(汗。

ポイント1:描画するデータ構造はシンプルにする

なにかを描画する時は画像なり、図形、文字などのデータを取り扱います。SwiftUIは表示画面にあるパーツ達は必ず存在すると思って実装する必要があります。今回は取り込んだテキストデータをテーブルとして管理して表示させるだけの描画でしたが、構造体やクラスでデータを定義して、get/setで更新していたのですが、データ更新しても描画更新されないシーンがあり、何が原因かわからなかったので複雑なデータ構造を避けて描画するパーツはシンプル(単純な定義)にして、そのパーツ自体(インスタンス)を更新するようにしました。

ポイント2:データの更新はButtonのactionで行う

今回のアプリはテキストデータなどすべての要素を「ボタン」として描画することでイベントを取得しています。なので、なにかデータの処理をする時にView関連のコードエリアでデータ処理を実装していましたが、データ処理はユーザイベントベースにすることでシンプルなデータ渡しに変更しました。

スタート画面

まずはスタート画面から。読み込み後のテキストデータは「表」にするので、表の列を決める事にします。1行の単位は私のケースだと「1枚の基板」「1枚の発注書」「1枚の伝票」「1枚の受領書」などなどが該当します。この「表」を選んでスキャンモードに移行して読み込み、データを保存する流れです。

なので、起動後の画面は作成した表のフォームベースで記録したものをリスト表示するようにします。初回起動時はアプリ内部に保存してある「テンプレート」のみ表示すると。

テーブル表示画面

テーブルを表示して内容やタイトルなどを編集する画面。右上のボタンでCSVファイルをダウンロードします。各編集対象はすべてボタンとして実装していて、選択すると編集メニューが出てくるようになっています。

表示系はプログラムが膨らむ事以外は特に問題なくプレビュー画面を見ながら実装可能でした。

テキスト読み込み画面

テーブル表示画面で「追加スキャン」か「新規スキャン」でテキスト読み込みを開始します。追加はテーブルの最後の行に文字列を追加し、新規はテーブルの中身を消してフォームをコピーしてスキャンすることにしました。このあたりから次の章で説明しているカメラ操作や画像選択処理など複雑化していきます。

Swift
if let image {
    let temp  = recognizing_text.setImage(uiImage: image)
    HStack {
        Button(action: {
            if !temp.isEmpty {
                selected_content = modelData.setFocusedContent(format: format, focused_id: focused_id, content: selected_text)
            }
        }, label: {Image(systemName: "square.and.arrow.up")})
            .padding(.leading, 10)
        Spacer()
        Text((temp.isEmpty ? "No Text in the picture":selected_text))
        Spacer()
        Button(action: {
            if !temp.isEmpty {
                self.appendMode.toggle()
            }
        }, label: {
            if self.appendMode {
                Image(systemName: "plus.square.fill.on.square.fill")
            } else {
                Image(systemName: "plus.square.on.square")
            }
            
        })
            .padding(.trailing, 10)
    }
    
    Image(uiImage: image)
        .resizable()
        .scaledToFit()
        .overlay(content: {
            GeometryReader { geometry in
                let ratiox = geometry.size.width / image.size.width
                let ratioy = geometry.size.height / image.size.height
                
                ForEach(recognizing_text.scanData) { data in
                    Button(action: {
                        if self.appendMode {
                            selected_text.append(data.text)
                        } else {
                            selected_text = data.text
                        }
                    }, label: {
                        Rectangle()
                            .stroke(Color.red, lineWidth: 2)
                    })
                    .frame(width: data.cgRect.width * ratiox,
                           height: data.cgRect.height * ratioy)
                    .position(x: (data.cgRect.minX + (data.cgRect.width / 2)) * ratiox,
                              y: (data.cgRect.minY + (data.cgRect.height / 2)) * ratioy)
                }
            }
        })
}

画像が選択されたらそれを解析して認識した文字列を四角で囲み、それをボタンとして認識することで、選択可能にしています。左のボタンで列の最後に追加して、右のプラスボタンで複数の文字列を追加後に列に追加する事もできます。仕事で基板にあるシールが連続の英数字だけど改行されているのケースがあったので。例の写真はサンプルの請求書をシュミレーターで読み込ませています。

画像とカメラの取り扱い

文字認識させるために画像を取り扱う必要があります。画像は撮影したものを読み込ませるパターンとカメラを起動して撮影するパターンを用意します。カメラはシュミレータやプレビュー画面では確認できないので、先にカメラロールから取得して解析できるようにします。

画像を選択する

PhotosPicker | Apple Developer Documentation
A view that displays a Photos picker for choosing assets from the photo library.

これまたSwiftUIで用意されている「PhotosPicker」を利用します。ボタンのように動作してくれるので見つけたコードをコピペして必要な変数を用意してあげるだけ。

Swift
                PhotosPicker(selection: $selectedItem,
                             matching: .images) {
                    Image(systemName: "photo")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 30, height: 30)
                }.onChange(of: selectedItem) {
                    Task {
                        if let data = try? await selectedItem!.loadTransferable(type: Data.self) {
                            image = UIImage(data: data)
                        }
                    }
                }

これだけでカメラロールから画像取り出してメインで処理することが可能です。おまけにシュミレーターどころかPreviewでも動くという…なんて日だ!

カメラ操作はUIPickerControllerはやめた

UIImagePickerController | Apple Developer Documentation
A view controller that manages the system interfaces for taking pictures, recording movies, and choosing items from the ...

最初は色々とネットを調べながらUIPickerContorollerを利用してホゲホゲしようとしていたのですが、ある程度実装を進め、撮影からデータ渡しまで完了したあとに気がつきました…

なんの画面ですか?と思うでしょうがカメラ起動後のシュミレーターのスクショになります(汗。実機でも同じで、灰色の部分にカメラ映像が写ってます。下はPreview画面で縦画像です。映像エリアの範囲など気になる所はありますが、比較的カンタンにカメラを起動して画像を取り込む事が可能です。

あまりUIPickerContorollerの仕様を読まないで、実装(各種コピペ)しましたが、初心に帰って仕様を読んでると…「The UIImagePickerController class supports portrait mode only.」との記載が…英語が苦手なMittyでもわかります。縦画面のみ対応ってことなんですね…トリッキーな方法もありそうでしたが、トリッキーな実装するのが大好きなMittyも今回は見送って正規の方法でカメラを起動することにしました。

AVCaptureXXXXを使ったカメラ操作

UIPickerContorollerで時間を使ってしまったこともあり、どうやって実装するのが正解なのか色々と検索しまくって、途方にくれているとGoogle先生がおすすめの動画を教えてくれました。

ネットでは古いVersionのSwifを利用した情報が多く、コピペで実装して動作確認はできたけど、いまいち使い勝手が違うと思いながらこの動画見て…「これだ!」とほぼそのまんま実装してみました(汗。

左側が縦画面で右側が横画面です。画像をクリックすると全体が表示されます。これでカメラを利用した画像の取り込みまで完了です。参考にしている動画はかなりわかりやすく実装してくれているので、Swifでアプリを作る参考にもなると思います。

内蔵カメラの種類

とりあえずはカメラで撮影した画像を解析に回す事ができるようになりましたが、かなりの接写をするとピントが合わず文字が見えませんでした。UIPickerContorollerではズームなどの基本的な機能は利用可能だったので盲点でした。AVCaptureDeviceでは利用するデバイスを指定しない場合はメインカメラが利用されるようです。私の「iPhone 15 Pro Max」は以下のカメラが搭載されています。

  • メインカメラ:4800万画素 24mm / F1.78
  • 超広角カメラ:1200万画素 14mm / F2.2
  • 望遠カメラ:1200万画素 120mm / F2.8

で、対応しているDeviceTypeを調べたところ、以下になりました。最後の数字は[com.apple.avfoundation.avcapturedevice.built-in_video:x]のように何かのインデックスのようです。

  • AVCaptureFigVideoDevice: 0x1061a1800 [背面カメラ]:0
  • AVCaptureFigVideoDevice: 0x1061a1e00 [背面超広角カメラ]:5
  • AVCaptureFigVideoDevice: 0x1061a4800 [背面望遠カメラ]:2
  • AVCaptureFigVideoDevice: 0x1061a6e00 [背面デュアルカメラ]:3
  • AVCaptureFigVideoDevice: 0x1061a7400 [背面デュアル広角カメラ]:6
  • AVCaptureFigVideoDevice: 0x105929000 [背面トリプルカメラ]:7
  • <AVCaptureFigVideoDevice: 0x1061a6600 [背面LiDAR Depthカメラ]:9

デフォルトで背面カメラをしていするとIndexが0の背面カメラを取得するようです。スーパー接写(1cm位)が可能なカメラは「5, 6, 7」で、今回の用途でギリギリ行けるのが「0, 3, 9」だったので利用できるかの確認する順番は「7, 6, 5, 3, 9, 0」ですかね。0はどのiPhoneでも動くと思いたい。あとは9の「LiDAR Depthカメラ」で遊びたい衝動が爆発しましたが、我慢します。

Vision Frameworkといふもの

Vision | Apple Developer Documentation
Apply computer vision algorithms to perform a variety of tasks on input images and video.

テキストを認識する処理のためにドキュメントを模索していると「Vision Framework」なるものを見つけました。画像認識が可能なフレームワークらしいです。画像認識系はここから入るのが「正」なのですかね。最初に「Core ML」を見つけて「先は長いな…」と途方に暮れていたので助かりました。

テキスト認識のサンプルコード発見

ここで色々と調べているとWWDC2019で公開されているサンプルコードを発見しました。が、カメラ機能を使うApplicationはMac上のシュミレータでは動かないとの事。「シュミレータがあるなんて素晴らしい」と思っていたのもつかの間、昔のように実機にダウンロードして調査する必要が出てきました。

Xcodeを信じて自分のiPhoneをUSBで繋いでみると、なんとiPhoneを認識しているではないですか。色々と「認証が必要で〜」とか「これやると危険だぞー」とか言われたけど「全部許可」して進めるとUSB経由でアプリがインストールできました。これでサンプルコード触りながらカメラの機能を確認できます。早速サンプルコードを実行すると…普通に文字認識して文字列出力してるやんけ…すばらしい!

このサンプルコードを参考に画像解析に必要な処理を抜き出して、自分のアプリにコピペと言う名のプログラミングを開始します。

画像入力〜解析要求

VNImageRequestHandler | Apple Developer Documentation
An object that processes one or more image analysis requests pertaining to a single image.
VNRecognizeTextRequest | Apple Developer Documentation
An image analysis request that finds and recognizes text in an image.

流れとしては「VNImageRequestHandler」「VNRecognizeTextRequest」を利用して解析を実行後にハンドラーで結果を受け取る感じです。表示には「UIImage」、画像処理(解析など)は「CGImage」ということで解析に回す前に「CGImage」を取り出します。で、画像解析には「向き」情報が必要で名前は一緒だけど値が違うから変換してから「VNImageRequestHandler」に渡します。

Swift
class ScanTextFromImage {
    var cgImage: CGImage = (UIImage(systemName: "table")?.cgImage)!
    var imgOri: CGImagePropertyOrientation = .up
    var scanData: [ScanData] = [ScanData]()

    func setImage(uiImage: UIImage) -> [ScanData] {
        if ObjectIdentifier(self.cgImage) == ObjectIdentifier(uiImage.cgImage!) {
            return self.scanData
        }
        
        self.imgOri = {
            switch uiImage.imageOrientation {
            case .up: return CGImagePropertyOrientation.up
            case .down: return CGImagePropertyOrientation.down
            case .left: return CGImagePropertyOrientation.left
            case .right: return CGImagePropertyOrientation.right
            case .upMirrored: return CGImagePropertyOrientation.upMirrored
            case .downMirrored: return CGImagePropertyOrientation.downMirrored
            case .leftMirrored: return CGImagePropertyOrientation.leftMirrored
            case .rightMirrored: return CGImagePropertyOrientation.rightMirrored
            @unknown default: return CGImagePropertyOrientation.up
            }
            
        }()
        self.cgImage = uiImage.cgImage!

        let requestHandler = VNImageRequestHandler(cgImage: self.cgImage,
                                                   orientation: self.imgOri, options: [:])
        let request = VNRecognizeTextRequest(completionHandler: recognizeTextHandler)
        request.recognitionLevel = .accurate
        request.usesLanguageCorrection = true
        request.recognitionLanguages = ["ja"]
        do {
            try requestHandler.perform([request])
        } catch {
            print("Unable to perform the requests: \(error). ")
        }
        self.recognizeTextHandler(request: request, error: nil)
        
        return self.scanData
    }

request.recognitionLanguages = [“ja]

デフォルトは英語なようなので、日本語をISO言語コードで32行目で指定しています。サンプルアプリにもこれ追加するだけで日本語を認識するようになるという…なんて日だ!

request.recognitionLevel = .accurate

デフォルトは「.accurate」で「.fast」も設定可能です。2つの違いは「OCRっぽい.fast」と「Neural-network-basedな.accurate」という事らしいです。「OCR」は昔に痛い目見てるので「.accurate」を使います。

文字認識結果の取得

VNRecognizedTextObservation | Apple Developer Documentation
A request that detects and recognizes regions of text in an image.
VNImageRectForNormalizedRect(_:_:_:) | Apple Developer Documentation
Projects a rectangle from normalized coordinates into image coordinates.

解析要求後は「VNRecognizedTextObservation」を参照して結果が出たら内容を確認していきます。このときに注意が必要なのが要求時に渡した「向き」ベースで解析を行うので結果も同様に向きを考慮しなければ正しい矩形が「VNImageRectForNormalizedRect」を通して取得できません。縦横考慮して横幅と高さを入れ替えてるだけですが。

Swift
    func recognizeTextHandler(request: VNRequest, error: Error?) {
        guard let observations =
                request.results as? [VNRecognizedTextObservation] else {
            return
        }
        var index = 0
        var width = 0
        var height = 0
        self.scanData.removeAll()

        switch self.imgOri {
        case .right, .rightMirrored, .left, .leftMirrored:
            width = self.cgImage.height
            height = self.cgImage.width
        default:
            width = self.cgImage.width
            height = self.cgImage.height
        }
        index = 0
        index = 0
        self.scanData = observations.compactMap { observation in
            index += 1
            let tmpRect = VNImageRectForNormalizedRect(observation.boundingBox, Int(width), Int(height))
            return ScanData(id: index,
                            cgRect: CGRect(x: tmpRect.minX,
                                           y: CGFloat(height) - tmpRect.minY - tmpRect.height,
                                           width: tmpRect.width,
                                           height: tmpRect.height),
                            text: observation.topCandidates(1).first!.string)
        }
    }
}

表示系と操作系で画像に対する起点が異なるので、そこを調整して表示時に矩形を書いて終了です。

ファイル入出力

画像解析したテキストを各テーブルに追加する形でデータを管理します。作成されたテーブルはCSVファイルとして出力するので、その保存処理やテーブルのフォーマットを複数管理できるようにするためのデータも保存・読み出しが必要です。

最初に利用したTutorialがアプリ内に保存したjsonファイルを読み込む仕様だったので、その処理をそのまま流用しています。リソースとしてファイルをアプリで持っておいて初回はそのデータを読み込み、アプリ内部に新規テンプレートファイルとして保存して、その後はそのファイルを操作する仕様にします。

JSONファイル読み書き

アプリのリソース経由でJSONファイルを読み出します。JSONファイル利用時にデコードしてインスタンス化してくれるJSONDecoderなるものがあるらしいのでjsonをそのまま利用することにした次第です。Bundleでは書き込みができないらしいです。

リソースからのJSONファイル読み込み(サンプルコードそのまま)

Swift
func load<T: Decodable>(_ filename: String) -> T {
    let data: Data
    
    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }
    
    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }
    
    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}

    var formats: [FormatRow] = load("template.json")

他の場所からのJSONファイル読み込み

ユーザが作成したテーブルをユーザエリアに保存・読み込みする処理です。ユーザデータはLibraryに保存します。起動時はリソースエリアから読込した初期データを引数で渡しています。

Swift
let modelFileName: String = "modeldata.json"
var formats: [FormatRow] = load("template.json")
let modelUrl = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!

func readFormats(_ formats: [FormatRow]) -> [FormatRow] {
    let file = self.modelUrl.appending(component: self.modelFileName)
    var newFormats: [FormatRow] = formats
    
    do {
      let data = try Data(contentsOf: file)
      let decoder = JSONDecoder()
      newFormats = try decoder.decode([FormatRow].self, from: data)
    } catch {
      self.update()
    }
    return newFormats
}

保存処理は最新のデータを上書き保存しています。

Swift
func update() -> Void {
    do {
        let file = self.modelUrl.appending(component: self.modelFileName)

        let encoder = JSONEncoder()
        encoder.outputFormatting = . prettyPrinted
        guard let result = try? encoder.encode(formats.self) else {
            fatalError("JSON Encode Error\n")
        }
        try result.write(to: file)
    } catch let error {
        fatalError("Update Error: " + error.localizedDescription)
    }
}

CSVファイル書き出し

CSVファイルはユーザデータを元に作成して保存するだけで読み込みは行わない方向にします。戻り値は保存のダイアログように作成したけど、基本的に問題があったらアプリ落ちるので「true」のみリターンしているという…保存場所は共有を考えて「書類(Documents)フォルダ」にしています。

Swift
func saveCSVFile(format: FormatRow) -> Bool {
    let folderUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
    let file = folderUrl.first!.appending(component: format.title + ".csv")
    
    var fileStream = ""
    let CRLF = "\r\n"
    var maxRow = 0
    
    for column in format.columns {
        if maxRow < column.contents.count {
            maxRow = column.contents.count
        }
    }
    maxRow += 1
    var table: [String] = Array(repeating: "", count: maxRow)
    
    for i in 0 ..< format.columns.count {
        table[0].append(contentsOf: "\"" + format.columns[i].label + "\",")
        for j in 0 ..< (maxRow - 1) {
            if j < format.columns[i].contents.count {
                table[j + 1].append(contentsOf: "\"" + format.columns[i].contents[j].text + "\",")
            } else {
                table[j + 1].append(contentsOf: "\"\",")
            }
        }
    }
    for i in 0 ..< maxRow {
        if let del = table[i].lastIndex(of: ",") {
            table[i].remove(at: del)
        }
        table[i].append(contentsOf: CRLF)
        fileStream.append(contentsOf: table[i])
    }

    do {
        let data = fileStream
        try data.write(to: file, atomically: false, encoding: .utf8)
    } catch {
        fatalError("CSV file Write Error\n")
    }
    return true
}

ディレクトリー(Apple Developer Program登録前)

FileManager.default.urls(for:in:)で帰って来るDirectoryを調べました。in: は.userDomainMaskで取得しています。今回はPreview上でのprint()したので、自アプリの配下(file:///Users/xxx/LIbrary/Developer/Xcode/UserData/Previews/Simulator%20Devices/XXX/data/Containers/Data/Application/XXX/)にこれらのフォルダが構成されているそうです。in:を他のマスクにすると自アプリ配下ではなく「file:///Application」や「file:///Network」、「file:///System」などに変わります。コメントにEmptyとあるのは戻り値がなかったケースです。ADPに登録前の状態なので登録後は変わるかも。

Swift
 public enum SearchPathDirectory : UInt, @unchecked Sendable {
        
        case applicationDirectory = 1  //[0] [1] Applications/

        case demoApplicationDirectory = 2  //[0] [2] Applications/Demos/

        case developerApplicationDirectory = 3  //[0] [3] Developer/Applications/

        case adminApplicationDirectory = 4  //[0] [4] Applications/Utilities/

        case libraryDirectory = 5  //[0] [5] Library/

        case developerDirectory = 6  //[0] [6] Developer/

        case userDirectory = 7  //Empty

        case documentationDirectory = 8  //[0] [8] Library/Documentation/

        case documentDirectory = 9  //[0] [9] Documents/

        case coreServiceDirectory = 10  //Empty

        @available(iOS 4.0, *)
        case autosavedInformationDirectory = 11  //[0] [11] Library/Autosave%20Information/

        case desktopDirectory = 12  //[0] [12] Desktop/

        case cachesDirectory = 13  //[0] [13] Library/Caches/

        case applicationSupportDirectory = 14  //[0] [14] Library/Application%20Support/

        @available(iOS 2.0, *)
        case downloadsDirectory = 15  //[0] [15] Downloads/

        @available(iOS 4.0, *)
        case inputMethodsDirectory = 16  //[0] [16] Library/Input%20Methods/

        @available(iOS 4.0, *)
        case moviesDirectory = 17  //[0] [17] Movies/

        @available(iOS 4.0, *)
        case musicDirectory = 18  //[0] [18] Music/

        @available(iOS 4.0, *)
        case picturesDirectory = 19  //[0] [19] Pictures/

        @available(iOS 4.0, *)
        case printerDescriptionDirectory = 20  //Empty

        @available(iOS 4.0, *)
        case sharedPublicDirectory = 21  //[0] [21] Public/

        @available(iOS 4.0, *)
        case preferencePanesDirectory = 22  //[0] [22] Library/PreferencePanes/

        @available(iOS 4.0, *)
        case itemReplacementDirectory = 99  //Empty

        case allApplicationsDirectory = 100  //[0] [100] Applications/
                                             //[1] [100] Applications/Utilities/
                                             //[2] [100] Developer/Applications/
                                             //[3] [100] Applications/Demos

        case allLibrariesDirectory = 101  //[0] [101] Library/
                                          //[1] [101] Developer/

        @available(iOS 11.0, *)
        case trashDirectory = 102  //Empty
}

雑感

ここまで実装してから何をやってもDocumentsに保存したCSVが実機で参照できず、「iCloudにCSV保存してPCから参照」がシュミレーターで無理やり見に行くしかない…どこを探してもiCouldというかドキュメント共有させる設定が見つかりません(涙。調べると共有系の機能はApple Developer Program(有料)に登録しないと使えないという…なんて日だ!

仕方がないので登録開始してから、かれこれ10営業日ほど経過していますが、未だにサポートから連絡がない…共有機能が使えればデバッグモードで自分のiPad, iPhoneに入れて使う予定でしたが、ADPに登録するなら正式に公開してみようと思います。さて…ADPに登録できるのはいつの日になることやら。まさかADP登録がiOS開発の一番の鬼門だったとは(汗。

コメント

タイトルとURLをコピーしました