ホーム 記事一覧 ブック 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に削減されました。 ...

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領域の場合で高速化率に差が出ています。 ...

Cantaloupe IIIFサーバーのキャッシュ最適化で画像配信を最大7.6倍高速化した

Cantaloupe IIIFサーバーのキャッシュ最適化で画像配信を最大7.6倍高速化した

はじめに IIIFに対応した画像サーバーであるCantaloupeを、S3をソースとしたDocker環境で運用しています。IIIFビューア(Mirador, OpenSeadragonなど)では、ズームやパンの操作のたびに数十〜数百のタイルリクエストが同時に発生します。 今回、キャッシュ設定の見直しとパラメータチューニングにより、タイル配信速度を最大7.6倍高速化できたので、その手法と効果を共有します。 環境 サーバー: AWS EC2(2 vCPU, 7.6GB RAM) Cantaloupe: islandora/cantaloupe:2.0.10(Cantaloupe 5.0.7ベース) 画像ソース: Amazon S3(S3Source) テスト画像: 25167×12483px のTIFF画像(512×512タイル) リバースプロキシ: Traefik v3.2 構成: Docker Compose 問題:デフォルト設定ではキャッシュが無効 islandora/cantaloupe イメージのデフォルト設定を調査したところ、以下の状態でした。 キャッシュ種別 デフォルト 説明 Derivative Cache(加工済み画像) 無効 同じリクエストでも毎回画像変換が発生 Source Cache(元画像のローカルコピー) 有効(FilesystemCache) S3から取得した元画像をローカルに保持 Info Cache(画像メタデータ) 有効(メモリ内) 画像の寸法・タイル情報を保持 Client Cache(HTTPヘッダ) 有効(max-age 30日) ブラウザ側のキャッシュ制御 最大の問題は Derivative Cache が無効であることです。IIIFビューアが同じタイルを再度リクエストした場合でも、毎回 S3 → ダウンロード → 画像変換 → レスポンス という処理が走ります。 ベンチマーク方法 単純なタイル一括テスト まず基本的な性能測定として、以下の条件で一括タイルベンチマークを行いました。 タイル数: 91タイル(zoom level 4、scaleFactor=4の全タイル) 同時接続数: 10(ブラウザの一般的な同時接続数) ツール: curl + xargs -Pによる並列リクエスト # タイルURLを生成し、10並列で同時リクエスト xargs -a tile_urls.txt -P 10 -I {} \ curl -s -o /dev/null -w "%{time_total}\n" "{}" Miradorシミュレーション 単純なタイル一括テストに加え、IIIFビューア(Mirador)の実際の操作フローを再現したベンチマークも行いました。Miradorでは、ユーザーが画像を開くと以下のリクエストが短時間に発生します。 ...

MapLibre GL JS でカスタムマーカーがズーム時にずれる問題と GeoJSON レイヤーによる解決

MapLibre GL JS でカスタムマーカーがズーム時にずれる問題と GeoJSON レイヤーによる解決

はじめに MapLibre GL JS で地図上にカスタムマーカーを配置する際、maplibregl.Marker に独自の DOM 要素を渡す方法がよく使われる。件数バッジ付きの丸いマーカーなど、CSS で自由にスタイリングできる利点がある。 しかし、この方法にはズーム・パン操作時にマーカーが地図の動きに遅れて追従するという問題がある。特にマーカー数が多い場合やモバイルデバイスでは顕著になり、ズームアウトすると本来陸上にあるべきマーカーが海上に表示されるような視覚的な不整合も起きる。 本記事では、この問題の原因と、GeoJSON ソース + レイヤーを使った根本的な解決方法を解説する。 問題の再現 DOM マーカーによる実装(問題あり) for (const point of dataPoints) { const el = document.createElement('div') Object.assign(el.style, { width: '32px', height: '32px', borderRadius: '50%', backgroundColor: point.color, border: '3px solid white', boxShadow: '0 2px 6px rgba(0,0,0,0.3)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', }) // 件数バッジ if (point.count > 1) { const badge = document.createElement('span') badge.style.color = 'white' badge.style.fontSize = '11px' badge.style.fontWeight = 'bold' badge.textContent = String(point.count) el.appendChild(badge) } new maplibregl.Marker({ element: el }) .setLngLat([point.lng, point.lat]) .addTo(map) } この実装では以下の問題が発生する。 症状 ズーム・パン時の遅延: マーカーが地図の動きに 1〜2 フレーム遅れて追従する ズームアウト時の位置ずれ: 沿岸都市のマーカーが海上に表示される パフォーマンス低下: マーカー数が増えると DOM の再配置コストが増大する 原因 maplibregl.Marker は DOM 要素を CSS transform で配置する仕組みになっている。地図がズーム・パンされるたびに、各マーカーの CSS transform を再計算して更新する必要があり、これが WebGL キャンバスの描画と非同期で行われるため、視覚的なずれが生じる。 ...

XSLT処理を5倍高速化:Saxon-JSからSaxon-HEへの移行

まとめ TEI XML → HTML変換において、npx xslt3(Saxon-JS)からJava Saxon-HEへ切り替えたところ、ビルド時間が1分48秒から23秒に短縮された(約5倍の高速化)。 背景 校異源氏物語テキストDBは、源氏物語のデジタルエディションで、54巻分のTEI XMLファイルを持つ。ビルドスクリプト(Python)が各XMLをHTMLに変換するため、npx xslt3を54回呼び出していた。 python3 scripts/prebuild.py xsl # 全54巻のXSLT処理 この処理がビルドパイプライン全体で最も時間のかかるステップだった。 ベンチマーク ファイルごとの比較 巻 文字数 npx xslt3 (JS) saxon (Java) 高速化率 01 桐壺 11,240 1.8秒 1.1秒 1.6倍 34 若菜上 46,230 4.9秒 0.4秒 12倍 ファイルが大きいほど改善幅が大きい。JVM起動コスト(約1秒)を差し引くと、実際の変換処理は桁違いに速い。 合計(全54巻) npx xslt3 (Saxon-JS): 1分48秒 saxon (Saxon-HE): 23秒 移行手順 ローカル環境(macOS) brew install saxon ビルドスクリプト SAXON_JAR環境変数 → saxonコマンド → npx xslt3の順にフォールバックするヘルパーを追加した。 def xslt_cmd(xsl, src, dst): """Return XSLT command, preferring Saxon-HE over npx xslt3.""" saxon_jar = os.environ.get('SAXON_JAR') if saxon_jar: return ['java', '-jar', saxon_jar, f'-xsl:{xsl}', f'-s:{src}', f'-o:{dst}'] if shutil.which('saxon'): return ['saxon', f'-xsl:{xsl}', f'-s:{src}', f'-o:{dst}'] return ['npx', 'xslt3', f'-xsl:{xsl}', f'-s:{src}', f'-o:{dst}'] GitHub Actions Node.js + xslt3をJava + Saxon-HE jarに置き換えた。 ...