0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NDLOCR-Liteを試しAndroidに載せてみた:リアルタイムは諦めてワンショットOCRに落ち着いた話

0
Posted at

この記事で書くこと

  • 国立国会図書館の 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段階に分けました。

  1. PC(CPU)で重み比較:速度・可読性・ONNX Runtime互換性(量子化含む)
  2. 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ページの画像を用いて、自分の環境でも動作確認を行いました。
同一入力に対して、FP32Int8(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の見え方は変わった可能性があります。([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. 通常はライブプレビューのみ
  2. ボタン押下で1回キャプチャ
  3. 推論して結果をオーバーレイ表示
  4. 画面タップでライブに戻る(処理中の多重実行は抑止)

この形にすると、

  • 計算資源を「必要時だけ」使える
  • 期待値が「常時翻訳」から「必要時OCR」へ揃う
  • 状態遷移が明確で、待ち時間も許容されやすい

というメリットが出ました。

6. ここまでの時点での結論(Android実装側)

  • CPUのみで「常時リアルタイムOCR」を成立させるのは、体験面で厳しい
  • ただし ワンショットOCR に寄せると、現実的に運用可能な形に落とせる
  • 成否を分けたのはモデル精度以前に、
    • 観測性(どこで止まっているか)
    • 入力経路(画像変換が重い/壊れる)
    • 座標整合(見えている場所を読む)
    • 運用設計(常時から必要時へ)
      だった

あわせて、認識側は **「3つの認識モデルのカスケード」**になっているため、短い行は軽いモデルで止めつつ、長い行だけ重いモデルへ回す設計です。
この構造上、長文が混ざるページを“ページ一括”で読ませるより、行ごとに切り分けて認識した方が結果が安定しやすい(=実運用に寄せやすい)と感じました。


実機サンプル(手書き)

参考画像(手書き) OCR
ref ocr

感想(この2枚から分かること)

  • スマホ(Android)でも実行自体は可能で、検出枠→文字のオーバーレイまで一通り動かせた
  • 一方で、1回のワンショット推論に5〜6秒程度かかり、常時リアルタイム用途としては待ちが大きい
  • 認識品質は「短文は比較的よさそう」だが、長文は厳しい
    → 実運用を考えるなら、ページ全体を一気に読むより 行ごとに切り出してOCRする方針が良さそう
  • PCでは短文の精度がそこまで悪くなかった一方で、スマホ撮影画像の結果が崩れるのは、
    • 入力画像のリサイズ/正規化
    • 撮影時のブレ/影/解像感
      などの影響もありそう(ここは深掘りしきれていない)

最後に(結び)

今回の成果は「実用的なOCRアプリができた」というより、“CPUだけで動くOCRをAndroidに組み込むと、どこが本当に詰まるか”が分かったことでした。
そして、その詰まりはモデル精度以前に、ログの出し方・画像の作り方・座標の合わせ方・UXの設計に強く依存していました。

正直、Androidアプリ開発は初めてで、実装はかなり **Codex頼り(ログとエラーを投げ続けるバイブコーディング)**でした。
そのせいで「行ごとに切り分けて認識する」といった当たり前の設計を初手で外したのも事実で、ここは Codexが悪いです。

とはいえ、未経験領域でも「実機で動くもの」を最後まで形にできた体験は大きく、次に改善するときの“叩き台”が手元に残りました。
(次は、行分割ベースの認識・入力正規化の再検証・軽量化の再挑戦、あたりをやる)

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?