ホーム 記事一覧 ブック DH週間トピックス 検索 このサイトについて
English

iOS OCRアプリのメモリ起因クラッシュの調査と対策

KotenOCRは古典籍のOCR処理にONNX Runtimeを使用しているiOSアプリです。6つのONNXモデル(合計約230MB)を搭載しています。ダウンロード数が300に達した時点で、クラッシュ率が6.7%(20件)に上昇していることが確認されました。Xcode Organizerにクラッシュログが表示されなかったため、原因の特定には別のアプローチが必要でした。 調査体制 4つの調査方針を並行して進めました。 メモリ・モデルサイズの調査 画像処理パイプラインの調査 ONNX Runtimeのスレッドセーフティの調査 カメラ・UIライフサイクルの調査 原因の特定 調査の結果、以下の4つの問題が確認されました。影響度の大きい順に記載します。 1. 起動時の全モデル一括ロード 6つのONNXモデルをアプリ起動時にすべてメモリに展開していました。ディスク上では約230MBですが、ONNX Runtimeがモデルをメモリに展開すると1.5〜2.5倍程度に膨張するため、RAM上では推定350〜550MB程度を占有していたと考えられます。 iOSにはjetsam(カーネルレベルのメモリ監視機構)があり、アプリ単位のメモリ上限を超えるとプロセスが強制終了されます。デバイスごとのRAMとアプリあたりのメモリ上限の目安は以下のとおりです。 デバイス RAM リスク iPhone 8 2GB CRITICAL — iOS 16対応機種で最もメモリが少ない iPhone X / XR / XS 3GB HIGH iPhone 11 4GB MEDIUM iPhone 12以降 4〜6GB LOW iPhone 8やiPhone Xでは、アプリあたりのメモリ上限が200〜300MB程度とされており、全モデル一括ロードだけで上限を超過する状況でした。 2. DEIMDetectorの画像前処理 DEIMDetectorが入力画像をリサイズせずにフルサイズのまま正方形パディング用のCGContextを生成していました。12MPの写真(4032×3024)の場合、4032×4032ピクセルのCGContextが作成され、これだけで約63MBのメモリを消費します。 3. 無制限の並列認識タスク withThrowingTaskGroupで最大100件の認識タスクを同時に実行可能な状態でした。各タスクがモデル推論時にメモリを確保するため、並列数に比例してメモリ使用量が増加します。 4. メモリ警告のハンドリング不在 UIApplication.didReceiveMemoryWarningNotificationを購読しておらず、iOSからのメモリ警告を受け取っても何も対処していませんでした。jetsam発動前の最後の解放機会を逃していたことになります。 実施した修正 Lazy Model Loading 起動時にすべてのモデルをロードする方式から、選択中のモードに必要なモデルのみをロードする方式に変更しました。初期メモリ使用量が約350MBから約150MBに減少しました。 func loadModelsForMode(_ mode: OCRMode) { // 選択中のモードに必要なモデルのみロード let requiredModels = mode.requiredModelKeys for key in requiredModels { if sessionCache[key] == nil { sessionCache[key] = try? createSession(for: key) } } } 画像前処理の改善 DEIMDetectorの前処理で、パディング前にリサイズを行うように変更しました。メモリ使用量が63MBから約2.4MBに削減されました。 ...

KotenOCR: 近代OCRの検出重複を解消する(NMS追加とクラスフィルタリング)

はじめに KotenOCRは、国立国会図書館(NDL)が公開したOCRモデルをiOS上で動作させ、くずし字や近代活字をオフラインで認識するアプリです。 近代OCRモード(NDLモード)では、NDLのDEIMv2-Sモデルでレイアウト検出を行い、PARSeQで文字認識を行います。しかし、iOS実装の検出結果が本家のndlocr-liteと比べて明らかに多く、テキストが重複して認識されるという問題がありました。 本記事では、原因の調査から修正までの過程を記録します。 症状:検出数が多すぎる テスト画像として、NDLデジタルコレクションから取得した「校異源氏物語」の序文ページを使いました。 本家ndlocr-liteでは17件の行検出(line_main、line_captionなど)が得られるのに対し、iOS実装では28件の検出が返り、OCR結果にも重複テキストや文字化けが混入していました。 検出数が多い原因は大きく3つありました。 原因1:NMS(Non-Maximum Suppression)が未実装だった DEIMv2モデルのONNX出力は、内部でNMSを完了した結果を返すと想定していました。しかし本家ndlocr-liteのコードを読むと、モデル推論後に追加のNMS(IoU閾値=0.2)を適用していることがわかりました。 つまり、モデル出力にはまだ重複する矩形が含まれており、後処理でそれを除去する必要があったのです。 修正:NMSの追加 DEIMDetector.swiftにNMS処理を追加しました。 // DEIMDetector.swift — postprocess() の末尾 // Sort by score descending let sorted = detections.sorted { $0.score > $1.score } // Apply NMS to remove overlapping detections let nmsResult = applyNMS(sorted, iouThreshold: iouThreshold) return Array(nmsResult.prefix(maxDetections)) NMS本体の実装は以下のとおりです。スコア降順にソートされた検出結果を走査し、既に採用した矩形とのIoUが閾値を超える場合は除外します。 // DEIMDetector.swift /// Apply Non-Maximum Suppression to remove overlapping detections. private func applyNMS(_ detections: [Detection], iouThreshold: Float) -> [Detection] { var kept: [Detection] = [] for det in detections { var shouldKeep = true for existing in kept { if computeIoU(det.box, existing.box) > iouThreshold { shouldKeep = false break } } if shouldKeep { kept.append(det) } } return kept } /// Compute Intersection over Union between two boxes [x1, y1, x2, y2]. private func computeIoU(_ a: [Int], _ b: [Int]) -> Float { let x1 = max(a[0], b[0]) let y1 = max(a[1], b[1]) let x2 = min(a[2], b[2]) let y2 = min(a[3], b[3]) let interW = max(0, x2 - x1) let interH = max(0, y2 - y1) let interArea = Float(interW * interH) let areaA = Float((a[2] - a[0]) * (a[3] - a[1])) let areaB = Float((b[2] - b[0]) * (b[3] - b[1])) let unionArea = areaA + areaB - interArea guard unionArea > 0 else { return 0 } return interArea / unionArea } 原因2:全クラスをOCRに送っていた DEIMv2モデルは17クラスのレイアウト要素を検出します。 ...

Swift ConcurrencyでOCR認識処理を並列化し最大6.7倍高速化する

Swift ConcurrencyでOCR認識処理を並列化し最大6.7倍高速化する

OCRパイプラインの構造 iOSでONNX Runtimeを使ったOCRパイプラインは、大まかに以下の流れで動作します。 画像全体に対する文字領域検出(Detection) 検出された各領域に対する文字認識(Recognition) 読み順の推定と結合 この中で、検出は画像全体に対して1回だけ実行されます。一方、認識は検出された領域の数だけ繰り返されます。領域数が多い画像では、認識処理が全体の処理時間の大部分を占めることになります。 逐次処理の問題 認識処理を単純なforループで逐次実行すると、処理時間は領域数にほぼ比例します。 for det in detections { let rect = CGRect(x: max(0, det.box[0]), y: max(0, det.box[1]), width: max(1, det.box[2] - det.box[0]), height: max(1, det.box[3] - det.box[1])) if let cropped = image.cropping(to: rect) { _ = try recognizer.recognize(image: cropped) } } たとえば98領域が検出された画像の場合、1領域あたり約80msとすると合計で約8秒かかります。 withThrowingTaskGroupによる並列化 Swift ConcurrencyのwithThrowingTaskGroupを使うと、各領域の認識処理を並列に実行できます。 let results = try await withThrowingTaskGroup(of: (Int, String).self) { group in for i in 0..<detections.count { let box = detections[i].box let cropRect = CGRect( x: max(0, box[0]), y: max(0, box[1]), width: max(1, box[2] - box[0]), height: max(1, box[3] - box[1]) ) guard let cropped = image.cropping(to: cropRect) else { continue } group.addTask { let text = try recognizer.recognize(image: cropped) return (i, text) } } var texts: [(Int, String)] = [] for try await result in group { texts.append(result) } return texts } for (i, text) in results { recognized[i].text = text } 各タスクはインデックスとともに結果を返すため、完了順序に関係なく元の順序を保持できます。 ベンチマーク結果 iOSシミュレータ上で、逐次処理と並列処理の実行時間を比較しました。 古典籍(源氏物語、6642x4990px、21領域) 出典: 東京大学デジタルアーカイブ 源氏物語 方式 処理時間 逐次 4.55s 並列 3.24s 高速化率 1.4x 近代活字(NDLデジタルコレクション、6890x4706px、98領域) 出典: 国立国会図書館デジタルコレクション 方式 処理時間 逐次 7.59s 並列 1.13s 高速化率 6.7x 高速化率が異なる理由 21領域の場合と98領域の場合で高速化率に差が出ています。 ...

KotenOCR:くずし字をオフラインで認識するiOSアプリの開発と公開

KotenOCR:くずし字をオフラインで認識するiOSアプリの開発と公開

はじめに 古典籍に書かれたくずし字(変体仮名・草書体の漢字)を読むのは、専門家でも容易ではありません。近年はAI-OCRによって機械的な認識が可能になってきましたが、調査した限り、スマートフォンでオフライン利用できるツールは見当たりませんでした。 KotenOCRは、国立国会図書館(NDL)が公開した軽量くずし字OCRモデル「NDL古典籍OCR-Lite」をiOS上で動作させ、写真を撮るだけでくずし字を認識できるアプリです。 App Store(無料): https://apps.apple.com/jp/app/kotenocr/id6760045646 背景:既存ツールの状況 NDLが「NDL古典籍OCR-Lite」を公開したことで、くずし字OCRの敷居は下がりました。既存ツールを見渡すと以下のような状況でした。 ツール 形態 インターネット接続 NDL古典籍OCR-Lite デスクトップ / Web / CLI 不要(デスクトップ版) miwo(CODH) モバイルアプリ 必要 古文書カメラ(TOPPAN) モバイルアプリ 必要 モバイルアプリは存在するものの、いずれもクラウド通信が必要です。一方、NDL古典籍OCR-LiteはPC環境でしか動作しません。 そこで、NDL古典籍OCR-Liteのモデルをスマートフォンに載せて、オフラインで動くiOSアプリを作ることにしました。 KotenOCRの特徴 完全オフライン — すべての処理がデバイス上で完結。通信不要 iPhone / iPad対応 — iOS 16.0以上 無料 — App Storeから無料でダウンロード可能 スキャン履歴 — 認識結果を保存・管理 TXT / PDFエクスポート — 認識テキストをファイルとして出力 範囲トリミング — 認識する領域を指定可能 使い方 古典籍の写真を撮影する(またはライブラリから選択) AIがくずし字を自動認識 認識されたテキストをコピー・エクスポート OCRパイプライン 写真からテキストが認識されるまでの処理フローは以下の通りです。 写真 → トリミング → テキスト領域検出 → 文字認識 → 読み順決定 → 表示 テキスト領域検出: RTMDetモデルにより、画像内の文字領域を検出 文字認識: PARSeqモデルにより、検出領域内の文字を認識(7,141文字、NDLmojiの文字集合に対応) 読み順: 日本語の縦書き・右から左への読み順を考慮して並べ替え ...