この記事で書くこと
- 国立国会図書館の NDLラボが公開した NDLOCR-Lite を、CPUのみでAndroidに組み込んで動かした記録
- 量子化(Int8/Int4/Int16)を試して分かった、「変換できる」と「実行できる」は別問題 という話
- 成否を分けたのはモデル精度というより、観測性・座標整合・運用設計 だった、という結論
検証コードとメモは GitHub 上のリポジトリに置いています(後述)。
NDLOCR-Liteとは
NDLOCR-Liteは、NDLラボが公開しているOCRで、GPUを前提にせずCPU環境で動かすことを重視した“軽量志向”の実装です。デスクトップアプリとCLIが提供されており、Windows / macOS / Ubuntu での動作確認が明記されています。([1][4])
内部は大きく次の 3モジュール構成 です。([1])
- レイアウト認識:DEIMv2
- 文字列認識:PARSeq
- 読み順整序:既存NDLOCRと同系モジュール
また、学習後にONNXへ変換して推論する前提で設計されています。([1])
※一次情報は脚注リンク参照(公式README/実装コード)
背景
ここ1〜2年で、生成AIの主戦場はテキストだけではなくなり、画像理解やOCRを含むマルチモーダルの能力が実用レベルまで一気に上がりました。実際、スクリーンショットや写真を投げて「要約して」「文字起こしして」と頼む使い方は、すでに珍しくありません。
一方で業務利用では、クラウドに投げれば何でも解決、とはいかない場面が残ります。たとえば次のような事情です。
- 機密情報・個人情報の持ち出し制約(セキュリティ/コンプライアンス)
- ネットワーク前提にできない環境(閉域・現場端末)
- コストやレイテンシ(常時送信を前提にしづらい)
- 監査・再現性(入力と処理を手元で固定したい)
加えて、現場の入力は依然として PDF・スキャン・紙・手書き に強く依存しています。RAGやAIエージェントに繋ぐ以前に、まず「雑にテキスト化する」工程がボトルネックになりやすく、ここで詰まると後段のAI活用も止まります。
ローカルAI(小型モデル)自体も進化が速く、「用途を絞れば手元で回す」選択が現実的になってきました。私は普段、ローカルAI(とくにローカルLLM)を中心に調査・技術検証する立場なので、同じ文脈で OCRもローカルで成立させられるか を確かめたくなりました。
そこで今回、NDLラボが公開した NDLOCR-Lite(GPU不要・軽量志向) を題材に、まずはPCのCPUで現実的な選択肢を絞り込み、次に 「Android × CPUのみ」 でどこまで実用になるかを試しました。
検証の全体像
検証したい論点は次の4つです。
- CPUだけでどれくらいの速度が出るか
- モデルサイズ的にスマホに載るか
- 量子化でさらに軽くできるか(できるとして実行できるか)
- スマホで“それっぽいリアルタイム体験”は成立するか(※毎フレームではなく、フレームレートを落としての意味)
実施は次の2段階に分けました。
- PC(CPU)で重み比較:速度・可読性・ONNX Runtime互換性(量子化含む)
- Android(Kotlin + ONNX Runtime)アプリの実装
※最初にPCで比較したのは、Androidへ持ち込んでから「遅すぎる/動かない」で手戻りする前に、性能差と互換性の地雷(量子化まわり)を先に踏んでおきたかったためです。
1. PC(CPU)でモデルの速度と量子化を試してみた
なぜ最初に量子化したか
結論から言うと、Android(CPUのみ)へ持ち込む場合、FP32をそのまま載せると体感が厳しくなる可能性が高いです。
そのため、実装に入ってから手戻りしないように、PC(CPU)で次を先に確認しました。
- Int8量子化でどれだけ速くなるか(モデル単体・全体の両面)
- 量子化してもOCR結果が実用に耐えるか
- そもそも量子化モデルがONNX Runtimeで安定して動くか(互換性)
前提:NDLOCR-Liteは「検出1 + 認識3」の4モデル構成
NDLOCR-Liteは、1つのモデルで全部読むのではなく 4つのモデルを組み合わせて処理します。
- 検出(DEIMv2):ページ画像から行領域(ROI)を検出して切り出す
- 認識(PARSeq ×3):行画像を読み取る
追記:認識モジュールは3モデルのカスケードです。短い/易しい行は軽いモデルで止め、長い/難しい行だけ重いモデルに回す設計になっています。([1])
つまり、全体時間は単純に「1モデルの推論時間」では決まらず、
検出の時間 +(行数 × 認識)+ 前後処理(画像変換/切り出し/整序/描画など)
の合計で決まります。
この構造上、全体の速度改善を議論する前に 各モデルがどれくらい速くなっているか を見る方が判断しやすいです。
計測方法(スクリプト上の定義)
-
モデル単体ベンチ:
model_eval/evaluate_models.py
session.run()の呼び出し区間を計測(warmup後にruns回、mean/p95を出力)。([5]) -
ページ処理(検出+認識):
model_eval/run_weight_compare_samples.py
detector.detect()/ 認識器ロード / 行ごとの認識カスケード、を分解して秒で記録。([6])
「何を計測している数字か」を読者に誤解されにくくするため、ここは明示しておきます。
結果1:4モデルの単体推論(PC/CPU)
表1:4モデル構成の単体推論時間(PC/CPU, threads=1)
| モデル | 役割 | FP32 平均 (ms) | Int8 平均 (ms) | 速度比 (FP32/Int8) |
|---|---|---|---|---|
deim-s-1024x1024 |
検出(ROI抽出) | 1448.92 | 1262.79 | 1.15x |
parseq-16x256-30 |
認識(軽) | 20.96 | 10.81 | 1.94x |
parseq-16x384-50 |
認識(中) | 33.53 | 20.07 | 1.67x |
parseq-16x768-100 |
認識(重) | 80.94 | 49.70 | 1.63x |
単体で見るとInt8は確かに速くなります。ただし、入口で必ず走る 検出(DEIMv2)が重いため、全体の体感は「認識が速い=全体が大きく速い」とは限りません。
結果2:ページ処理(検出+認識)の合計時間(参考)
表2:3画像でのページ処理合計(検出+認識, PC/CPU)
| 入力画像 | FP32 合計 (sec) | Int8 合計 (sec) |
|---|---|---|
digidepo_2531162_0024.jpg |
3.9538 | 3.2290 |
digidepo_3048008_0025.jpg |
3.9877 | 3.1869 |
digidepo_11048278_po_geppo1803_00021.jpg |
3.8280 | 3.1524 |
平均
| 重み | 平均合計 (sec) | 速度比 (FP32=1.0) |
|---|---|---|
| FP32 | 3.9232 | 1.00 |
| Int8 | 3.1894 | 1.23 |
Int8で短縮はしますが、単体推論ほど劇的には効きません(検出・前後処理の比率が効くため)。
可視化:FP32(32bit) vs Int8(8bit)のOCR出力比較
本モデルのGitHubページの画像を用いて、自分の環境でも動作確認を行いました。
同一入力に対して、FP32 と Int8(QInt8) の出力を並べて比較します。
(左右の並びは 左=FP32 / 右=Int8)
サンプル1:digidepo_11048278_po_geppo1803_00021
| FP32(32bit) | Int8(8bit) |
|---|---|
![]() |
![]() |
サンプル2:digidepo_2531162_0024
| FP32(32bit) | Int8(8bit) |
|---|---|
![]() |
![]() |
サンプル3:digidepo_3048008_0025
| FP32(32bit) | Int8(8bit) |
|---|---|
![]() |
![]() |
認識精度について(この比較から言える範囲)
-
レイアウト検出(緑枠)は比較的安定
段組・見出し・本文ブロックの切り出し自体は概ねできており、量子化(Int8)による悪化もこの例では目立ちませんでした。
→ 少なくとも「ROIを切り出す」段階では、量子化のデメリットが出にくい印象です。 -
文字認識(青帯/テキスト)
-
FP32でも「読める」が、長文は厳しい
短い文字列(見出し/ラベル/短文)は拾えている一方で、本文のような長い行・長文は欠落が目立つ。
→ 実運用では 行ごとにOCR(行画像へ正しく切り出して認識) を前提に設計・調整した方がよいです。 -
Int8は完全に破綻
文字化け/同一フレーズの繰り返し/000000...のような不自然な連続出力が出ており、
「多少誤字が増える」ではなく 文章として成立しない崩れ方 になっています。
-
FP32でも「読める」が、長文は厳しい
補足:公式の可視化例では「検出 → 行へ分割 → 行認識」の流れが明確で、これに寄せた実装・前処理・閾値調整をしていれば、FP32の見え方は変わった可能性があります。([1])
……が、当初の実装ではこのあたりの設計理解が甘かった。ここはCodexが悪い(ログを渡して直させる中で、最初の方針が外れて手戻りした)。
結論(この比較の判断)
-
FP32は「読める」が、長文を欠落なくテキスト化するのは難しい(少なくとも私の条件では)
→ 実運用は行単位OCR前提で、入力経路と前処理・カスケード条件を調整した方がよい - Int8は完全に破綻で、採用は厳しい
量子化で詰まった点(Int4/Int16)
Int8以外(Int4/Int16)も試しましたが、次のエラーに代表される互換性問題で、実行まで到達しない/再現性が取りにくい状態になりました。
-
must be 8-bit before packing as 4-bit values(4bit化の前提制約) -
INVALID_GRAPH ... MatMulInteger ... tensor(int16) invalid(演算グラフ互換性)
ここで分かったのは、「変換できる」ことと「実行できる」ことは別という点です。
特にInt4/Int16は、理屈として軽くても ONNX Runtime側の演算子対応やグラフ互換性で詰まりやすく、検証コストが跳ねました。
この段階の判断(Androidへ持ち込む前に決めたこと)
以上を踏まえて、Android側へ持ち込む方針は次に落ち着きました。
- Int8:速度は出るが 完全に破綻(読めない)
- Int4 / Int16:互換性の壁が大きく、運用再現性が低い
- FP32:長文は厳しいが 相対的に「読める」(=壊れない)
結局、Android実装では FP32を採用しました。
「速いけど読めない」より「遅いけど読める」を優先した、という整理です。
2. Android実装(Kotlin + ONNX Runtime):未経験なので、ほぼ“バイブコーディング”で進めた
自分は普段ローカルAI寄りの開発が中心で、Androidアプリ開発はほぼ未経験でした。
そのため今回は、自分がAndroidの細部を理解して積み上げたというより、Codexにログとエラーを渡し続けて短いサイクルで直していった、いわゆる“バイブコーディング”寄りの進め方です。
- 症状 → ログ貼る → 仮説候補を出させる → 直す → もう一回ログを見る
このループを、ひたすら短く回しました。
なお、初期方針の見立てが外れて手戻りした箇所(特に入力経路と座標系)は、Codexが悪いです。
2-1. 目標(最初の見立て)
- Androidでカメラプレビューを表示しながらOCRを実行する
- まずは 1〜3FPS 程度でも良い(リアルタイム翻訳のような毎フレーム処理は狙わない)
- 言語はKotlin
- 端末側の推論は ONNX Runtime(Android) を使い、
.onnxを端末内で実行する(CPU)
またPC検証の時点で 検出が重いのは見えていたので、最初から「毎フレーム検出」は捨て、フレームレートを落として間引きながら回す設計から始めました。
2-2. 採用モデル
Android側は最終的に FP32 ONNX を採用しました(量子化は品質面・互換性面で厳しかったため)。
- 検出:
deim-s-1024x1024.onnx - 認識:
parseq-ndl-16x256-30-tiny-192epoch-tegaki3.onnx(まずは軽い認識モデルから)
2-3. 最小構成(まず動く土台)
アプリの骨格は定番の組み合わせです。
- CameraXでプレビュー表示
- ImageAnalysisでフレーム取得
- 最新フレームだけ保持(古いフレームは捨てる)
- ONNX Runtimeで検出→認識
- 結果をOverlayで描画
後のデバッグが破綻しないよう、責務はざっくり分離しました。
-
LatestFrameStore:最新フレームだけを保持(取り出し時はスナップショット化) -
OcrOrchestrator:検出・認識を周期で回す(間引き/排他/タイムアウト) -
DetectorEngine/RecognizerEngine:推論の実体(NoopとORT実装を差し替え可能) -
OverlayRenderer:BBoxと文字の描画、スケーリング
2-4. 当初の運用(“リアルタイム風”に見せる)設計
最初は「それっぽく動いている」状態を最短で作るため、周期を分けて間引きました。
- Detector:5秒に1回(0.2Hz)
- Recognizer:2.5秒に1回(0.4Hz)
- latest-only(常に最新フレームだけ処理)
この設計は「理屈としては」成立しますが、実機では別のボトルネックが出てきます(次章)。
3. 実機で詰まったポイントと、効いた対策
ここからは 「動かない/遅い/ズレる」 を潰していく話です。
結論としては、モデル以前に 観測性(ログ)と入力経路(画像変換)と座標整合 が支配的でした。
3-1. 「OCR結果が出ない」:まず観測性を上げる
最初の症状は「ビルドは通るのにOCRが出ない」でした。
この時点で重要だったのは、バグを当てに行くより先に “どこまで処理が進んでいるか”を確定させることでした。
入れたのは次のようなログと状態メトリクスです。
- detector/recognizer の開始・終了ログ(start / done)
- スキップ理由(frameなし / 実行中 / cooldown中)
- 推論タイムアウト(一定時間で諦めて復帰)
- ROI数、行数、処理ms、例外内容(lastError)
これで「動いてないのか、遅いだけなのか」を切り分けられるようになりました。
3-2. SkJpegCodec::onGetPixels 連発:画像変換が重すぎた
ログを見ると、推論以前に 画像変換 が支配的になっていました。
具体的には SkJpegCodec::onGetPixels が大量に出ていて、「JPEGデコード経由の変換」がボトルネックっぽい、という当たりがつきました。
ここで効いたのが ImageProxy → Bitmap の経路改善です。
-
RGBA_8888の1-plane入力はそのまま直変換(JPEGを経由しない) -
YUV_420_888 → NV21変換を rowStride / pixelStride 対応で実装し直す - 初回数フレームだけ format/planes/rotation/size をログ出しして端末差を把握
「推論が遅い」以前に「入力を作るのが遅い」状態だったので、ここを潰したことで推論ループが安定して回るようになりました。
3-3. latest-onlyの徹底(フレーム間引き + 排他 + タイムアウト)
CPU運用では“詰まり”がUXを壊すので、次をセットで入れました。
- analyzer側で一定間隔(例:250ms)でフレーム採用を間引く
- detector/recognizer は排他で多重実行しない(実行中ならスキップ)
- detector/recognizer に timeout(例:15s)を設定して復帰可能にする
- 起動直後の「フレーム未準備」時は短周期でリトライ(無駄に5秒待たない)
ここまでやると「止まっているのか」「遅いのか」がログで判別でき、デバッグが前に進みます。
3-4. 「見えている位置」と「OCR対象」がズレる:入力ソースを“表示基準”に寄せる
次に出たのが、プレビューで見えている場所とOCR対象がズレる問題です。
これ、精度の良し悪し以前に体験を壊します。
最終的に、OCR入力を解析フレーム基準ではなく、**PreviewView.bitmap(ユーザーが見ている描画)**から取る方式に変更しました。
- “見えている画面”をそのまま推論入力にする
- overlayは推論入力サイズからスケーリングして整合を取る
- 認識対象のズレ感を最小化
この変更は精度以上に「狙った場所を読んでいる感」を改善します。
4. 入力正規化:端末差を減らすため「長辺1000px」に統一
端末ごとに縦横比・解像度・回転が違うと、前処理と座標変換が安定しません。
そこで推論へ渡す入力は次で統一しました。
- 長辺 = 1000px
- 短辺 = 縦横比維持で自動計算
これで機種差による入力サイズばらつきを抑えつつ、処理量も一定に寄せられます。
5. 運用設計の変更:常時実行を捨ててワンショットへ
最終的に、リアルタイムOCRの常時実行は捨てました。
理由はシンプルで、CPUのみだと 速度だけでなく操作体験のコストが高い ためです。
最終UXはこうしました。
- 通常はライブプレビューのみ
- ボタン押下で1回キャプチャ
- 推論して結果をオーバーレイ表示
- 画面タップでライブに戻る(処理中の多重実行は抑止)
この形にすると、
- 計算資源を「必要時だけ」使える
- 期待値が「常時翻訳」から「必要時OCR」へ揃う
- 状態遷移が明確で、待ち時間も許容されやすい
というメリットが出ました。
6. ここまでの時点での結論(Android実装側)
- CPUのみで「常時リアルタイムOCR」を成立させるのは、体験面で厳しい
- ただし ワンショットOCR に寄せると、現実的に運用可能な形に落とせる
- 成否を分けたのはモデル精度以前に、
- 観測性(どこで止まっているか)
- 入力経路(画像変換が重い/壊れる)
- 座標整合(見えている場所を読む)
-
運用設計(常時から必要時へ)
だった
あわせて、認識側は **「3つの認識モデルのカスケード」**になっているため、短い行は軽いモデルで止めつつ、長い行だけ重いモデルへ回す設計です。
この構造上、長文が混ざるページを“ページ一括”で読ませるより、行ごとに切り分けて認識した方が結果が安定しやすい(=実運用に寄せやすい)と感じました。
実機サンプル(手書き)
| 参考画像(手書き) | OCR |
|---|---|
![]() |
![]() |
感想(この2枚から分かること)
- スマホ(Android)でも実行自体は可能で、検出枠→文字のオーバーレイまで一通り動かせた
- 一方で、1回のワンショット推論に5〜6秒程度かかり、常時リアルタイム用途としては待ちが大きい
- 認識品質は「短文は比較的よさそう」だが、長文は厳しい
→ 実運用を考えるなら、ページ全体を一気に読むより 行ごとに切り出してOCRする方針が良さそう - PCでは短文の精度がそこまで悪くなかった一方で、スマホ撮影画像の結果が崩れるのは、
- 入力画像のリサイズ/正規化
- 撮影時のブレ/影/解像感
などの影響もありそう(ここは深掘りしきれていない)
最後に(結び)
今回の成果は「実用的なOCRアプリができた」というより、“CPUだけで動くOCRをAndroidに組み込むと、どこが本当に詰まるか”が分かったことでした。
そして、その詰まりはモデル精度以前に、ログの出し方・画像の作り方・座標の合わせ方・UXの設計に強く依存していました。
正直、Androidアプリ開発は初めてで、実装はかなり **Codex頼り(ログとエラーを投げ続けるバイブコーディング)**でした。
そのせいで「行ごとに切り分けて認識する」といった当たり前の設計を初手で外したのも事実で、ここは Codexが悪いです。
とはいえ、未経験領域でも「実機で動くもの」を最後まで形にできた体験は大きく、次に改善するときの“叩き台”が手元に残りました。
(次は、行分割ベースの認識・入力正規化の再検証・軽量化の再挑戦、あたりをやる)
参考
- [1] NDLOCR-Lite公式リポジトリ(技術構成・カスケード・ONNX前提): https://github.com/ndl-lab/ndlocr-lite
- [2] 検証コード(本記事のリポジトリ): https://github.com/CHIPMUNK-T0T/NDLOCR-Lite-Android-APP/tree/main
- [3] NDLOCR-Liteの使い方(NDLラボ): https://lab.ndl.go.jp/data_set/ndlocrlite-usage/
- [4] 公開告知(NDLラボ): https://lab.ndl.go.jp/news/2025/2026-02-24/







