$ メインコンテンツへスキップ
概要
0% · 残り ...
0%
オンデバイスOCRの実践:PP-OCRv5のAndroidネイティブデプロイ
$ cat mobile/ppocrv5-android.md

# オンデバイスOCRの実践:PP-OCRv5のAndroidネイティブデプロイ

著者:
日付: 2025年12月29日 20:17
読了時間: 読了時間 約 23 分
mobile/ppocrv5-android.md

このページはAIによって翻訳されています。不備がある場合は、元の記事を参照してください。

説明

本ブログ記事について:

  • カバー画像:Google Nano Banana 2 を使用して生成。著作権フリー。
  • プロジェクトのソースコード:GitHub にて公開中。PPOCRv5-Android をご覧ください。

免責事項:

筆者(Fleey)は AI 分野の専門家ではなく、あくまで趣味として取り組んでいます。内容に不備や誤りがある場合は、ご容赦いただくとともに、ぜひご指摘いただけますと幸いです。

はじめに

2024年、Google は TensorFlow Lite を LiteRT へと改称しました。これは単なるリブランディングではなく、エッジ AI が「モバイルファースト」から「エッジファースト」へとパラダイムシフトしたことを象徴しています1。このような背景の中、OCR(光学文字認識)は最も実用価値の高いエッジ AI アプリケーションの一つとして、静かな革命を遂げています。

Baidu の PaddleOCR チームは 2025 年に PP-OCRv5 をリリースしました。これは簡体字中国語、繁体字中国語、英語、日本語など多言語に対応した統一 OCR モデルです2。モバイル版のサイズは約 70MB と軽量ながら、単一のモデルで 18,383 文字の認識を実現しています。この数字の裏側には、検出と認識という 2 つの深いニューラルネットワークの協調動作があります。

しかし、問題があります。PP-OCRv5 は PaddlePaddle フレームワークに基づいてトレーニングされていますが、Android デバイスで最も成熟している推論エンジンは LiteRT です。この溝をどうやって埋めるべきでしょうか?

モデル変換から始めて、エッジ側 OCR のエンジニアリングの実態を段階的に解き明かしていきましょう。

flowchart TB
subgraph E2E["エンドツーエンド OCR フロー"]
direction TB
subgraph Input["入力"]
IMG[元画像<br/>任意のサイズ]
end
subgraph Detection["テキスト検出 - DBNet"]
DET_PRE[前処理<br/>Resize 640x640<br/>ImageNet Normalize]
DET_INF[DBNet 推論<br/>~45ms GPU]
DET_POST[後処理<br/>二値化 - 輪郭抽出 - 回転矩形]
end
subgraph Recognition["テキスト認識 - SVTRv2"]
REC_CROP[透視変換クロップ<br/>48xW 適応的幅]
REC_INF[SVTRv2 推論<br/>~15ms/行 GPU]
REC_CTC[CTC デコード<br/>重複統合 + 空白除去]
end
subgraph Output["出力"]
RES[OCR 結果<br/>テキスト + 信頼度 + 位置]
end
end
IMG --> DET_PRE --> DET_INF --> DET_POST
DET_POST -->|N 個のテキストボックス| REC_CROP
REC_CROP --> REC_INF --> REC_CTC --> RES

モデル変換:PaddlePaddle から TFLite への長い道のり

ディープラーニングフレームワークの断片化は、業界の大きな課題です。PyTorch、TensorFlow、PaddlePaddle、ONNX など、各フレームワークには独自のモデル形式と演算子(Operator)の実装があります。ONNX(Open Neural Network Exchange)は共通の中間表現を目指していますが、現実は理想ほど甘くありません。

PP-OCRv5 のモデル変換パスは以下の通りです:

flowchart LR
subgraph PaddlePaddle["PaddlePaddle Framework"]
PM[inference.json<br/>inference.pdiparams]
end
subgraph ONNX["ONNX Intermediate"]
OM[model.onnx<br/>opset 14]
end
subgraph Optimization["Graph Optimization"]
GS[onnx-graphsurgeon<br/>演算子の分解]
end
subgraph TFLite["LiteRT Format"]
TM[model.tflite<br/>FP16 量子化]
end
PM -->|paddle2onnx| OM
OM -->|HardSigmoid 分解<br/>Resize モード修正| GS
GS -->|onnx2tf| TM

このパスは一見シンプルですが、実際には多くの落とし穴が隠されています。

第一の壁:paddle2onnx の演算子互換性

paddle2onnx は PaddlePaddle 公式が提供するモデル変換ツールです。理論上は PaddlePaddle モデルを ONNX 形式に変換できますが、PP-OCRv5 はいくつかの特殊な演算子を使用しており、ONNX へのマッピングが 1 対 1 ではない場合があります。

Terminal window
paddle2onnx --model_dir PP-OCRv5_mobile_det \
--model_filename inference.json \
--params_filename inference.pdiparams \
--save_file ocr_det_v5.onnx \
--opset_version 14

ここで重要な詳細があります。PP-OCRv5 のモデルファイル名は従来の inference.pdmodel ではなく inference.json です。これは PaddlePaddle の新しいバージョンによるモデル形式の変更であり、多くの開発者がここで躓きます3

第二の壁:HardSigmoid と GPU の互換性

変換後の ONNX モデルには HardSigmoid 演算子が含まれています。この演算子は数学的に以下のように定義されます:

HardSigmoid(x)=max(0,min(1,αx+β))\text{HardSigmoid}(x) = \max(0, \min(1, \alpha x + \beta))

ここで α=0.2\alpha = 0.2β=0.5\beta = 0.5 です。

問題は、LiteRT の GPU Delegate が HardSigmoid をサポートしていないことです。モデルに未サポートの演算子が含まれている場合、GPU Delegate はサブグラフ全体を CPU 実行にフォールバックさせ、大幅なパフォーマンス低下を招きます。

解決策は、HardSigmoid を基本演算子に分解することです。onnx-graphsurgeon ライブラリを使用すると、計算グラフのレベルで「手術」を行うことができます。

import onnx_graphsurgeon as gs
import numpy as np
def decompose_hardsigmoid(graph: gs.Graph) -> gs.Graph:
"""
HardSigmoid を GPU フレンドリーな基本演算子に分解する
HardSigmoid(x) = max(0, min(1, alpha*x + beta))
分解後: Mul -> Add -> Clip
"""
for node in graph.nodes:
if node.op == "HardSigmoid":
# HardSigmoid のパラメータを取得
alpha = node.attrs.get("alpha", 0.2)
beta = node.attrs.get("beta", 0.5)
input_tensor = node.inputs[0]
output_tensor = node.outputs[0]
# 定数テンソルを作成
alpha_const = gs.Constant(
name=f"{node.name}_alpha",
values=np.array([alpha], dtype=np.float32)
)
beta_const = gs.Constant(
name=f"{node.name}_beta",
values=np.array([beta], dtype=np.float32)
)
# 中間変数を作成
mul_out = gs.Variable(name=f"{node.name}_mul_out")
add_out = gs.Variable(name=f"{node.name}_add_out")
# 分解後のサブグラフを構築: x -> Mul(alpha) -> Add(beta) -> Clip(0,1)
mul_node = gs.Node(
op="Mul",
inputs=[input_tensor, alpha_const],
outputs=[mul_out]
)
add_node = gs.Node(
op="Add",
inputs=[mul_out, beta_const],
outputs=[add_out]
)
clip_node = gs.Node(
op="Clip",
inputs=[add_out],
outputs=[output_tensor],
attrs={"min": 0.0, "max": 1.0}
)
# 元のノードを置換
graph.nodes.remove(node)
graph.nodes.extend([mul_node, add_node, clip_node])
graph.cleanup().toposort()
return graph

この分解の鍵は、MulAddClip がすべて LiteRT GPU Delegate で完全にサポートされている演算子である点です。分解により、サブグラフ全体が GPU 上で連続して実行可能になり、CPU-GPU 間のデータ転送オーバーヘッドを回避できます。

TIP

なぜモデルのトレーニングコードを直接修正しないのでしょうか? それは、トレーニング時の HardSigmoid の勾配計算が Clip とは異なるからです。分解は推論段階でのみ行い、トレーニング時の数値的安定性を維持すべきです。

第三の壁:Resize 演算子の座標変換モード

ONNX の Resize 演算子には coordinate_transformation_mode 属性があり、出力座標を入力座標にどのようにマッピングするかを決定します。PP-OCRv5 は half_pixel モードを使用していますが、LiteRT GPU Delegate のこのモードへのサポートは限定的です。

これを asymmetric モードに変更することで、より高い GPU 互換性を得ることができます:

for node in graph.nodes:
if node.op == "Resize":
node.attrs["coordinate_transformation_mode"] = "asymmetric"

WARNING

この修正により、わずかな数値的な差異が生じる可能性があります。実際のテストでは、OCR の精度への影響は無視できるレベルでしたが、他のタスクでは慎重な評価が必要になるかもしれません。

最後のステップ:onnx2tf と FP16 量子化

onnx2tf は ONNX モデルを TFLite 形式に変換するツールです。FP16(半精度浮動小数点)量子化はモバイル展開における一般的な選択肢であり、精度損失を許容範囲内に抑えつつ、モデルサイズを半分にし、モバイル GPU の FP16 演算ユニットを活用できます。

Terminal window
onnx2tf -i ocr_det_v5_fixed.onnx -o converted_det \
-b 1 -ois x:1,3,640,640 -n

ここで -ois パラメータは入力の静的な形状(Static Shape)を指定しています。静的な形状は GPU 加速において極めて重要です。動的な形状を使用すると、推論のたびに GPU プログラムの再コンパイルが必要になり、パフォーマンスに深刻な影響を及ぼします。

テキスト検出:DBNet の微分可能な二値化

PP-OCRv5 の検出モジュールは DBNet(Differentiable Binarization Network)に基づいています4。従来のテキスト検出手法は固定しきい値で二値化を行いますが、DBNet の革新的な点は、ネットワーク自体に各ピクセルの最適なしきい値を学習させることにあります。

flowchart TB
subgraph DBNet["DBNet アーキテクチャ"]
direction TB
IMG[入力画像<br/>H x W x 3]
BB[バックボーン<br/>MobileNetV3]
FPN[FPN 特徴ピラミッド<br/>マルチスケール融合]
subgraph Heads["デュアルブランチ出力"]
PH[確率マップブランチ<br/>P: H x W x 1]
TH[しきい値マップブランチ<br/>T: H x W x 1]
end
DB["微分可能な二値化<br/>B = sigmoid k * P-T"]
end
IMG --> BB --> FPN
FPN --> PH
FPN --> TH
PH --> DB
TH --> DB

標準的な二値化 vs 微分可能な二値化

標準的な二値化はステップ関数です:

Bi,j={1if Pi,jt0otherwiseB_{i,j} = \begin{cases} 1 & \text{if } P_{i,j} \geq t \\ 0 & \text{otherwise} \end{cases}

この関数は微分不可能であるため、バックプロパゲーションによるエンドツーエンドのトレーニングができません。DBNet は近似関数を提案しました:

B^i,j=11+ek(Pi,jTi,j)\hat{B}_{i,j} = \frac{1}{1 + e^{-k(P_{i,j} - T_{i,j})}}

ここで PP は確率マップ、TT は(ネットワークが学習した)しきい値マップ、kk は増幅係数(トレーニング時は 50 に設定)です。

TIP

この式は本質的に Sigmoid 関数であり、入力が PTP - T になったものです。kk が十分に大きい場合、その挙動はステップ関数に近づきますが、微分可能性は維持されます。

後処理プロセスのエンジニアリング実装

PPOCRv5-Android プロジェクトでは、後処理プロセスは postprocess.cpp で実装されています。コアフローは以下の通りです:

flowchart LR
subgraph Input["モデル出力"]
PM[確率マップ P<br/>640 x 640]
end
subgraph Binary["二値化"]
BT[しきい値フィルタリング<br/>threshold=0.1]
BM[二値画像<br/>640 x 640]
end
subgraph Contour["輪郭検出"]
DS[4x ダウンサンプリング<br/>160 x 160]
CC[連結成分分析<br/>BFS 探索]
BD[境界点抽出]
end
subgraph Geometry["幾何計算"]
CH[凸包計算<br/>Graham Scan]
RR[回転キャリパー法<br/>最小外接矩形]
UC[Unclip 拡張<br/>ratio=1.5]
end
subgraph Output["出力"]
TB[RotatedRect<br/>center, size, angle]
end
PM --> BT --> BM
BM --> DS --> CC --> BD
BD --> CH --> RR --> UC --> TB

実際のコードでは、TextDetector::Impl::Detect メソッドが完全な検出フローを示しています:

std::vector<RotatedRect> Detect(const uint8_t *image_data,
int width, int height, int stride,
float *detection_time_ms) {
// 1. スケール比率の計算
scale_x_ = static_cast<float>(width) / kDetInputSize;
scale_y_ = static_cast<float>(height) / kDetInputSize;
// 2. バイリニア補間による 640x640 へのリサイズ
image_utils::ResizeBilinear(image_data, width, height, stride,
resized_buffer_.data(), kDetInputSize, kDetInputSize);
// 3. ImageNet 標準化
PrepareFloatInput();
// 4. 推論
auto run_result = compiled_model_->Run(input_buffers_, output_buffers_);
// 5. 二値化
BinarizeOutput(prob_map, total_pixels);
// 6. 輪郭検出
auto contours = postprocess::FindContours(binary_map_.data(),
kDetInputSize, kDetInputSize);
// 7. 最小外接矩形 + Unclip
for (const auto &contour : contours) {
RotatedRect rect = postprocess::MinAreaRect(contour);
UnclipBox(rect, kUnclipRatio);
// 座標を元画像にマッピング
rect.center_x *= scale_x_;
rect.center_y *= scale_y_;
// ...
}
}

このフローの鍵は「最小外接回転矩形」です。軸に平行な境界ボックス(AABB)とは異なり、回転矩形は任意の角度のテキストに密着できるため、自然シーンにおける傾いたテキストに対して極めて重要です。

Unclip:テキストボックスの膨張アルゴリズム

DBNet が出力するテキスト領域は、ネットワークがテキストの「コア領域」を学習するため、通常は実際のテキストよりもわずかに小さくなります。完全なテキスト境界を得るには、検出された多角形に対して膨張(Unclip)操作を行う必要があります。

Unclip の数学的原理は、Vatti 多角形クリッピングアルゴリズムの逆操作に基づいています。多角形 PP と膨張距離 dd が与えられたとき、膨張後の多角形 PP' は以下を満たします:

d=A×rLd = \frac{A \times r}{L}

ここで AA は多角形の面積、LL は周囲の長さ、rr は膨張比率(通常は 1.5 に設定)です。

postprocess.cpp では、UnclipBox 関数がこのロジックを実装しています:

void UnclipBox(RotatedRect &box, float unclip_ratio) {
// 膨張距離の計算
float area = box.width * box.height;
float perimeter = 2.0f * (box.width + box.height);
if (perimeter < 1e-6f) return; // ゼロ除算防止
// d = A * r / L
float distance = area * unclip_ratio / perimeter;
// 外側に膨張:幅と高さをそれぞれ 2d 増加
box.width += 2.0f * distance;
box.height += 2.0f * distance;
}

この簡略化されたバージョンは、テキストボックスが矩形であることを前提としています。より複雑な多角形の場合、完全な Clipper ライブラリを使用して多角形のオフセットを実装する必要があります:

// 完全な多角形 Unclip(Clipper ライブラリを使用)
ClipperLib::Path polygon;
for (const auto& pt : contour) {
polygon.push_back(ClipperLib::IntPoint(
static_cast<int>(pt.x * 1000), // 精度維持のため拡大
static_cast<int>(pt.y * 1000)
));
}
ClipperLib::ClipperOffset offset;
offset.AddPath(polygon, ClipperLib::jtRound, ClipperLib::etClosedPolygon);
ClipperLib::Paths solution;
offset.Execute(solution, distance * 1000); // 膨張

NOTE

PPOCRv5-Android では、完全な多角形オフセットではなく、簡略化された矩形膨張を採用しました。その理由は以下の通りです:

  • ほとんどのテキストボックスは矩形に近い
  • 完全な Clipper ライブラリはバイナリサイズを増大させる
  • 簡略化バージョンのほうがパフォーマンスに優れる

テキスト認識:SVTRv2 と CTC デコード

検出が「文字がどこにあるかを見つける」ことなら、認識は「文字が何であるかを読み取る」ことです。PP-OCRv5 の認識モジュールは SVTRv2(Scene Text Recognition with Visual Transformer v2)に基づいています5

SVTRv2 のアーキテクチャの革新

SVTRv2 は前世代の SVTR と比較して、3 つの重要な改善点があります:

flowchart TB
subgraph SVTRv2["SVTRv2 アーキテクチャ"]
direction TB
subgraph Encoder["視覚エンコーダ"]
PE[Patch Embedding<br/>4x4 畳み込み]
subgraph Mixing["混合アテンションブロック x12"]
LA[Local Attention<br/>7x7 ウィンドウ]
GA[Global Attention<br/>グローバル受容野]
FFN[Feed Forward<br/>MLP]
end
end
subgraph Decoder["CTC デコーダ"]
FC[全結合層<br/>D -> 18384]
SM[Softmax]
CTC[CTC Decode]
end
end
PE --> LA --> GA --> FFN
FFN --> FC --> SM --> CTC
  1. 混合アテンション機構:局所アテンション(ストロークの詳細を捉える)とグローバルアテンション(文字構造を理解する)を交互に使用します。局所アテンションは 7x7 のスライディングウィンドウを使用し、計算複雑度を O(n2)O(n^2) から O(n×49)O(n \times 49) に低減しています。

  2. マルチスケール特徴融合:ViT の単一解像度とは異なり、SVTRv2 は CNN のピラミッド構造のように、深さに応じて異なる特徴マップ解像度を使用します。

  3. セマンティックガイダンスモジュール(Semantic Guidance Module):エンコーダの末端に軽量なセマンティックブランチを追加し、視覚的特徴だけでなく文字間の意味的関係の理解を助けます。

これらの改善により、SVTRv2 は CTC デコードのシンプルさを維持しつつ、Attention ベースの手法に匹敵する精度を達成しました6

なぜ Attention ではなく CTC なのか?

テキスト認識には 2 つの主要なパラダイムがあります:

  1. CTC(Connectionist Temporal Classification):認識をシーケンスラベリング問題と見なし、出力と入力をアライメントします。
  2. Attention-based Decoder:アテンション機構を使用して 1 文字ずつ出力を生成します。

Attention 手法は通常精度が高いですが、CTC 手法はよりシンプルで高速です。SVTRv2 の貢献は、視覚エンコーダを改善することで、CTC 手法でも Attention 手法の精度に到達、あるいは凌駕できることを示した点にあります6

CTC デコードの核心は「重複の統合」と「空白の除去」です:

flowchart LR
subgraph Input["モデル出力"]
L["Logits<br/>[T, 18384]"]
end
subgraph Argmax["Argmax NEON"]
A1["t=0: blank"]
A2["t=1: H"]
A3["t=2: H"]
A4["t=3: blank"]
A5["t=4: e"]
A6["t=5: l"]
A7["t=6: l"]
A8["t=7: l"]
A9["t=8: o"]
end
subgraph Merge["重複統合"]
M["blank, H, blank, e, l, o"]
end
subgraph Remove["空白除去"]
R["H, e, l, o"]
end
subgraph Output["出力"]
O["Helo - 誤り"]
end
L --> A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9
A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9 --> Merge --> Remove --> Output

おっと、ここで問題が発生しました。元のテキストが “Hello” の場合、2 つの ‘l’ が誤って統合されてしまいます。CTC の解決策は、重複する文字の間に blank トークンを挿入することです。

正しいエンコード: [blank, H, e, l, blank, l, o]
デコード結果: "Hello"

NEON 最適化された CTC デコード

PPOCRv5-Android の CTC デコードは、NEON で最適化された Argmax を使用しています。text_recognizer.cpp 内のコードです:

inline void ArgmaxNeon8(const float *__restrict__ data, int size,
int &max_idx, float &max_val) {
if (size < 16) {
// スカラーフォールバック
max_idx = 0;
max_val = data[0];
for (int i = 1; i < size; ++i) {
if (data[i] > max_val) {
max_val = data[i];
max_idx = i;
}
}
return;
}
// NEON ベクトル化:一度に 4 つの float を処理
float32x4_t v_max = vld1q_f32(data);
int32x4_t v_idx = {0, 1, 2, 3};
int32x4_t v_max_idx = v_idx;
const int32x4_t v_four = vdupq_n_s32(4);
int i = 4;
for (; i + 4 <= size; i += 4) {
float32x4_t v_curr = vld1q_f32(data + i);
v_idx = vaddq_s32(v_idx, v_four);
// ベクトル化された比較と条件選択
uint32x4_t cmp = vcgtq_f32(v_curr, v_max);
v_max = vbslq_f32(cmp, v_curr, v_max); // 大きい方の値を選択
v_max_idx = vbslq_s32(cmp, v_idx, v_max_idx); // 対応するインデックスを選択
}
// 水平削減:4 つの候補の中から最大値を見つける
float max_vals[4];
int32_t max_idxs[4];
vst1q_f32(max_vals, v_max);
vst1q_s32(max_idxs, v_max_idx);
// ... 最終的な比較
}

18,384 カテゴリの Argmax において、NEON 最適化は約 3 倍の高速化をもたらします。

CTC 損失関数とデコードの数学的原理

CTC の核心的な考え方は、入力シーケンス XX と考えられるすべての整列パス π\pi が与えられたとき、ターゲットシーケンス YY の確率を計算することです:

P(YX)=πB1(Y)P(πX)P(Y|X) = \sum_{\pi \in \mathcal{B}^{-1}(Y)} P(\pi|X)

ここで B\mathcal{B} は「多対 1 マッピング関数」であり、パス π\pi を(重複の統合と空白の除去を通じて)出力シーケンス YY にマッピングします。

推論時には、完全な Beam Search ではなく、貪欲デコーディング(Greedy Decoding)を使用します:

std::string CTCGreedyDecode(const float* logits, int time_steps, int num_classes,
const std::vector<std::string>& dictionary) {
std::string result;
int prev_idx = -1; // 重複統合用
for (int t = 0; t < time_steps; ++t) {
// 現在のタイムステップで最大確率のカテゴリを見つける
int max_idx = 0;
float max_val = logits[t * num_classes];
for (int c = 1; c < num_classes; ++c) {
if (logits[t * num_classes + c] > max_val) {
max_val = logits[t * num_classes + c];
max_idx = c;
}
}
// CTC デコード規則:
// 1. blank トークン (index 0) をスキップ
// 2. 連続して重複する文字を統合
if (max_idx != 0 && max_idx != prev_idx) {
result += dictionary[max_idx - 1]; // blank が index 0 を占有しているため -1
}
prev_idx = max_idx;
}
return result;
}

貪欲デコーディングの時間複雑度は O(T×C)O(T \times C) です。ここで TT はタイムステップ数、CC はカテゴリ数です。PP-OCRv5 の場合、T80T \approx 80C=18384C = 18384 であり、1 回のデコードに約 150 万回の比較が必要です。これが NEON 最適化が極めて重要である理由です。

TIP

Beam Search はデコード精度を向上させることができますが、計算量は貪欲デコーディングの kk 倍(kk はビーム幅)になります。モバイル環境では、通常は貪欲デコーディングがより良い選択肢となります。

文字辞書:18,383 文字の挑戦

PP-OCRv5 は以下の 18,383 文字をサポートしています:

  • 簡体字中国語の常用漢字
  • 繁体字中国語の常用漢字
  • 英数字
  • 日本語の平仮名、片仮名
  • 常用記号と特殊文字

この辞書は keys_v5.txt ファイルに 1 行 1 文字ずつ保存されています。CTC デコード時、モデルが出力する logits の形状は [1, T, 18384] となります。ここで T はタイムステップ数、18384 = 18383 文字 + 1 blank トークンです。

LiteRT C++ API:2024 年のリファクタリング後のモダンなインターフェース

PPOCRv5-Android は、2024 年にリファクタリングされた LiteRT の C++ API を使用しています。この API セットは、よりモダンなインターフェース設計を提供します。従来の TFLite C API と比較して、新しい API は型安全性とリソース管理能力が向上しています。

新旧 API の比較

LiteRT 2024 のリファクタリングにより、API は大きく変化しました:

特徴旧 API (TFLite)新 API (LiteRT)
名前空間tflite::litert::
エラーハンドリングTfLiteStatus 列挙型を返すExpected<T> 型を返す
メモリ管理手動管理RAII による自動管理
Delegate 設定分散した API統一された Options クラス
テンソルアクセスポインタ + 手動型変換型安全な TensorBuffer

新しい API の最大の利点は、型安全性とリソースの自動管理です。エラーハンドリングを例に挙げます:

// 旧 API:各戻り値を手動でチェックする必要がある
TfLiteStatus status = TfLiteInterpreterAllocateTensors(interpreter);
if (status != kTfLiteOk) {
// エラー処理
}
// 新 API:Expected 型を使用し、メソッドチェーンをサポート
auto model_result = litert::CompiledModel::Create(env, model_path, options);
if (!model_result) {
LOGE(TAG, "Error: %s", model_result.Error().Message().c_str());
return false;
}
auto model = std::move(*model_result); // ライフサイクルを自動管理

環境とモデルの初期化

text_detector.cpp における初期化フローは以下の通りです:

bool Initialize(const std::string &model_path, AcceleratorType accelerator_type) {
// 1. LiteRT 環境の作成
auto env_result = litert::Environment::Create({});
if (!env_result) {
LOGE(TAG, "Failed to create LiteRT environment: %s",
env_result.Error().Message().c_str());
return false;
}
env_ = std::move(*env_result);
// 2. ハードウェアアクセラレータの設定
auto options_result = litert::Options::Create();
auto hw_accelerator = ToLiteRtAccelerator(accelerator_type);
options.SetHardwareAccelerators(hw_accelerator);
// 3. モデルのコンパイル
auto model_result = litert::CompiledModel::Create(*env_, model_path, options);
if (!model_result) {
LOGW(TAG, "Failed to create CompiledModel with accelerator %d: %s",
static_cast<int>(accelerator_type),
model_result.Error().Message().c_str());
return false;
}
compiled_model_ = std::move(*model_result);
// 4. 入力テンソル形状の調整
std::vector<int> input_dims = {1, kDetInputSize, kDetInputSize, 3};
compiled_model_->ResizeInputTensor(0, absl::MakeConstSpan(input_dims));
// 5. マネージドバッファの作成
CreateBuffersWithCApi();
return true;
}

Managed Tensor Buffer:ゼロコピー推論の鍵

LiteRT の Managed Tensor Buffer は、高性能な推論を実現するための鍵です。これにより、GPU Delegate が CPU-GPU 間のデータ転送なしにバッファに直接アクセスできるようになります:

bool CreateBuffersWithCApi() {
LiteRtCompiledModel c_model = compiled_model_->Get();
LiteRtEnvironment c_env = env_->Get();
// 入力バッファの要件を取得
LiteRtTensorBufferRequirements input_requirements = nullptr;
LiteRtGetCompiledModelInputBufferRequirements(
c_model, /*signature_index=*/0, /*input_index=*/0,
&input_requirements);
// テンソルの型情報を取得
auto input_type = compiled_model_->GetInputTensorType(0, 0);
LiteRtRankedTensorType tensor_type =
static_cast<LiteRtRankedTensorType>(*input_type);
// マネージドバッファを作成
LiteRtTensorBuffer input_buffer = nullptr;
LiteRtCreateManagedTensorBufferFromRequirements(
c_env, &tensor_type, input_requirements, &input_buffer);
// C++ オブジェクトとしてラップし、ライフサイクルを自動管理
input_buffers_.push_back(
litert::TensorBuffer::WrapCObject(input_buffer,
litert::OwnHandle::kYes));
return true;
}

この設計の利点は以下の通りです:

  1. ゼロコピー推論:GPU Delegate がバッファに直接アクセスでき、CPU-GPU 間のデータ転送が不要
  2. 自動メモリ管理:OwnHandle::kYes により、C++ オブジェクトのデストラクタ呼び出し時にバッファが自動解放される
  3. 型安全性:コンパイル時にテンソル型の不一致をチェック可能

GPU 加速:OpenCL の選択とトレードオフ

LiteRT は、複数のハードウェア加速オプションを提供しています:

flowchart TB
subgraph Delegates["LiteRT Delegate エコシステム"]
direction TB
GPU_CL[GPU Delegate<br/>OpenCL Backend]
GPU_GL[GPU Delegate<br/>OpenGL ES Backend]
NNAPI[NNAPI Delegate<br/>Android HAL]
XNN[XNNPACK Delegate<br/>CPU Optimized]
end
subgraph Hardware["ハードウェアマッピング"]
direction TB
ADRENO[Adreno GPU<br/>Qualcomm]
MALI[Mali GPU<br/>ARM]
NPU[NPU/DSP<br/>ベンダー特定]
CPU[ARM CPU<br/>NEON]
end
GPU_CL --> ADRENO
GPU_CL --> MALI
GPU_GL --> ADRENO
GPU_GL --> MALI
NNAPI --> NPU
XNN --> CPU
アクセラレータバックエンド利点欠点
GPUOpenCL広くサポートされ、性能が良いAndroid の標準コンポーネントではない
GPUOpenGL ESAndroid の標準コンポーネントOpenCL ほど性能が出ない
NPUNNAPI最高のパフォーマンスデバイスの互換性が低い
CPUXNNPACK最も広範な互換性パフォーマンスが最も低い

PPOCRv5-Android では、主要な加速バックエンドとして OpenCL を選択しました。Google は 2020 年に TFLite の OpenCL バックエンドをリリースしましたが、OpenGL ES バックエンドと比較して、Adreno GPU 上で約 2 倍の高速化を実現しています7

OpenCL の優位性はいくつかの側面から来ています:

  1. 設計思想:OpenCL は最初から汎用計算(GPGPU)向けに設計されていますが、OpenGL はグラフィックスレンダリング API であり、後から計算シェーダーのサポートが追加されました。
  2. 定数メモリ:OpenCL の定数メモリは、ニューラルネットワークの重みアクセスに対して非常に効率的です。
  3. FP16 サポート:OpenCL はネイティブで半精度浮動小数点をサポートしていますが、OpenGL のサポートは後発でした。

しかし、OpenCL には致命的な欠点があります。それは Android の標準コンポーネントではないことです。ベンダーごとに OpenCL の実装品質にばらつきがあり、一部のデバイスでは全くサポートされていないこともあります。

OpenCL vs OpenGL ES:パフォーマンスの詳細比較

OpenCL の利点を理解するには、GPU アーキテクチャのレベルまで掘り下げる必要があります。Qualcomm Adreno 640 を例に見てみましょう:

flowchart TB
subgraph Adreno["Adreno 640 アーキテクチャ"]
direction TB
subgraph SP["Shader Processors x2"]
ALU1[ALU Array<br/>256 FP32 / 512 FP16]
ALU2[ALU Array<br/>256 FP32 / 512 FP16]
end
subgraph Memory["メモリ階層"]
L1[L1 キャッシュ<br/>16KB per SP]
L2[L2 キャッシュ<br/>1MB 共有]
GMEM[グローバルメモリ<br/>LPDDR4X]
end
subgraph Special["専用ユニット"]
TMU[テクスチャユニット<br/>バイリニア補間]
CONST[定数キャッシュ<br/>重み加速]
end
end
ALU1 --> L1
ALU2 --> L1
L1 --> L2 --> GMEM
TMU --> L1
CONST --> ALU1 & ALU2

OpenCL のパフォーマンス上の利点は以下に起因します:

特徴OpenCLOpenGL ES Compute
定数メモリネイティブサポート、ハードウェア加速UBO によるシミュレーションが必要
ワークグループサイズ柔軟な設定が可能シェーダーモデルによる制限
メモリバリアきめ細かな制御粗い制御
FP16 演算cl_khr_fp16 拡張mediump 精度が必要
デバッグツールSnapdragon Profiler限定的なサポート

畳み込み演算において、重みは通常定数です。OpenCL は重みを定数メモリに配置し、ハードウェアレベルのブロードキャスト最適化を享受できます。一方、OpenGL ES は重みを Uniform Buffer Object (UBO) として渡す必要があり、メモリアクセスのオーバーヘッドが増加します。

NOTE

Google は Android 7.0 以降、アプリが OpenCL ライブラリを直接ロードすることを制限しています。しかし、LiteRT の GPU Delegate は dlopen を使用してシステムの OpenCL 実装を動的にロードすることで、この制限を回避しています。これが、GPU Delegate が実行時に OpenCL の可用性を検出する必要がある理由です。

エレガントなフォールバック戦略

PPOCRv5-Android は、エレガントなフォールバック戦略を実装しています:

ocr_engine.cpp
constexpr AcceleratorType kFallbackChain[] = {
AcceleratorType::kGpu, // 第一選択:GPU
AcceleratorType::kCpu, // フォールバック:CPU
};
std::unique_ptr<OcrEngine> OcrEngine::Create(
const std::string &det_model_path,
const std::string &rec_model_path,
const std::string &keys_path,
AcceleratorType accelerator_type) {
auto engine = std::unique_ptr<OcrEngine>(new OcrEngine());
int start_index = GetFallbackStartIndex(accelerator_type);
for (int i = start_index; i < kFallbackChainSize; ++i) {
AcceleratorType current = kFallbackChain[i];
auto detector = TextDetector::Create(det_model_path, current);
if (!detector) continue;
auto recognizer = TextRecognizer::Create(rec_model_path, keys_path, current);
if (!recognizer) continue;
engine->detector_ = std::move(detector);
engine->recognizer_ = std::move(recognizer);
engine->active_accelerator_ = current;
engine->WarmUp();
return engine;
}
return nullptr;
}

この戦略により、パフォーマンスの差はあれど、アプリがどのデバイス上でも動作することが保証されます。

ネイティブレイヤー:C++ と NEON 最適化

なぜ Kotlin ではなく C++ を使うのでしょうか?

答えは単純です。パフォーマンスです。画像の前処理には大量のピクセルレベルの操作が含まれますが、これらの操作を JVM 上で行うオーバーヘッドは許容できません。さらに重要なのは、C++ では ARM NEON SIMD 命令を直接使用して、ベクトル化演算を実現できる点です。

NEON:ARM の SIMD 命令セット

NEON は ARM プロセッサの SIMD(Single Instruction, Multiple Data)拡張です。これにより、1 つの命令で複数のデータ要素を同時に処理できます。

flowchart LR
subgraph NEON["128-bit NEON レジスタ"]
direction TB
F4["4x float32"]
I8["8x int16"]
B16["16x int8"]
end
subgraph Operations["ベクトル化操作"]
direction TB
LD["vld1q_f32<br/>4 つの float をロード"]
SUB["vsubq_f32<br/>4 並列減算"]
MUL["vmulq_f32<br/>4 並列乗算"]
ST["vst1q_f32<br/>4 つの float をストア"]
end
subgraph Speedup["パフォーマンス向上"]
S1["スカラー: 4 命令"]
S2["NEON: 1 命令"]
S3["理論上の加速: 4x"]
end
F4 --> LD
LD --> SUB --> MUL --> ST
ST --> S3

PPOCRv5-Android は、複数のクリティカルパスで NEON 最適化を使用しています。二値化を例に見てみましょう(text_detector.cpp):

void BinarizeOutput(const float *prob_map, int total_pixels) {
#if defined(__ARM_NEON) || defined(__ARM_NEON__)
const float32x4_t v_threshold = vdupq_n_f32(kBinaryThreshold);
const uint8x16_t v_255 = vdupq_n_u8(255);
const uint8x16_t v_0 = vdupq_n_u8(0);
int i = 0;
for (; i + 16 <= total_pixels; i += 16) {
// 一度に 16 ピクセルを処理
float32x4_t f0 = vld1q_f32(prob_map + i);
float32x4_t f1 = vld1q_f32(prob_map + i + 4);
float32x4_t f2 = vld1q_f32(prob_map + i + 8);
float32x4_t f3 = vld1q_f32(prob_map + i + 12);
// ベクトル化された比較
uint32x4_t cmp0 = vcgtq_f32(f0, v_threshold);
uint32x4_t cmp1 = vcgtq_f32(f1, v_threshold);
uint32x4_t cmp2 = vcgtq_f32(f2, v_threshold);
uint32x4_t cmp3 = vcgtq_f32(f3, v_threshold);
// uint8 にナローイング(縮小)
uint16x4_t n0 = vmovn_u32(cmp0);
uint16x4_t n1 = vmovn_u32(cmp1);
uint16x8_t n01 = vcombine_u16(n0, n1);
// ... 統合してストア
}
// 残りのピクセルをスカラーで処理
for (; i < total_pixels; ++i) {
binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0;
}
#else
// 純粋なスカラー実装
for (int i = 0; i < total_pixels; ++i) {
binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0;
}
#endif
}

このコードの主な最適化ポイント:

  • バッチロード:vld1q_f32 で一度に 4 つの float をロードし、メモリアクセス回数を削減。
  • ベクトル化比較:vcgtq_f32 で 4 つの値を同時に比較し、マスクを生成。
  • 型のナローイング:vmovn_u32 で 32 ビットの結果を 16 ビット、最終的に 8 ビットに圧縮。

スカラー実装と比較して、NEON 最適化は 3〜4 倍の高速化をもたらします8

ImageNet 標準化の NEON 実装

画像の標準化は前処理の重要なステップです。ImageNet 標準化では以下の式を使用します:

xnormalized=xμσx_{normalized} = \frac{x - \mu}{\sigma}

ここで μ=[0.485,0.456,0.406]\mu = [0.485, 0.456, 0.406]σ=[0.229,0.224,0.225]\sigma = [0.229, 0.224, 0.225] です(RGB 3 チャンネル)。

image_utils.cpp における NEON 最適化された標準化の実装は以下の通りです:

void NormalizeImageNet(const uint8_t* src, int width, int height, int stride,
float* dst) {
// ImageNet 標準化パラメータ
constexpr float kMeanR = 0.485f, kMeanG = 0.456f, kMeanB = 0.406f;
constexpr float kStdR = 0.229f, kStdG = 0.224f, kStdB = 0.225f;
constexpr float kInvStdR = 1.0f / kStdR;
constexpr float kInvStdG = 1.0f / kStdG;
constexpr float kInvStdB = 1.0f / kStdB;
constexpr float kScale = 1.0f / 255.0f;
#if defined(__ARM_NEON) || defined(__ARM_NEON__)
// 事前計算: (1/255) / std = 1 / (255 * std)
const float32x4_t v_scale_r = vdupq_n_f32(kScale * kInvStdR);
const float32x4_t v_scale_g = vdupq_n_f32(kScale * kInvStdG);
const float32x4_t v_scale_b = vdupq_n_f32(kScale * kInvStdB);
// 事前計算: -mean / std
const float32x4_t v_bias_r = vdupq_n_f32(-kMeanR * kInvStdR);
const float32x4_t v_bias_g = vdupq_n_f32(-kMeanG * kInvStdG);
const float32x4_t v_bias_b = vdupq_n_f32(-kMeanB * kInvStdB);
for (int y = 0; y < height; ++y) {
const uint8_t* row = src + y * stride;
float* dst_row = dst + y * width * 3;
int x = 0;
for (; x + 4 <= width; x += 4) {
// 4 つの RGBA ピクセル (16 bytes) をロード
uint8x16_t rgba = vld1q_u8(row + x * 4);
// デインターリーブ(チャンネル分離): RGBARGBARGBARGBA -> RRRR, GGGG, BBBB, AAAA
uint8x16x4_t channels = vld4q_u8(row + x * 4);
// uint8 -> uint16 -> uint32 -> float32
uint16x8_t r16 = vmovl_u8(vget_low_u8(channels.val[0]));
uint16x8_t g16 = vmovl_u8(vget_low_u8(channels.val[1]));
uint16x8_t b16 = vmovl_u8(vget_low_u8(channels.val[2]));
float32x4_t r_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(r16)));
float32x4_t g_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(g16)));
float32x4_t b_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(b16)));
// 標準化: (x / 255 - mean) / std = x * (1/255/std) + (-mean/std)
r_f = vmlaq_f32(v_bias_r, r_f, v_scale_r); // fused multiply-add (積和演算)
g_f = vmlaq_f32(v_bias_g, g_f, v_scale_g);
b_f = vmlaq_f32(v_bias_b, b_f, v_scale_b);
// インターリーブストア: RRRR, GGGG, BBBB -> RGBRGBRGBRGB
float32x4x3_t rgb = {r_f, g_f, b_f};
vst3q_f32(dst_row + x * 3, rgb);
}
// 残りのピクセルをスカラーで処理
for (; x < width; ++x) {
const uint8_t* px = row + x * 4;
float* dst_px = dst_row + x * 3;
dst_px[0] = (px[0] * kScale - kMeanR) * kInvStdR;
dst_px[1] = (px[1] * kScale - kMeanG) * kInvStdG;
dst_px[2] = (px[2] * kScale - kMeanB) * kInvStdB;
}
}
#else
// スカラー実装(省略)
#endif
}

このコードの主な最適化テクニック:

  1. 定数の事前計算:(x - mean) / stdx * scale + bias に変換し、実行時の除算を削減。
  2. Fused Multiply-Add:vmlaq_f32 により、1 つの命令で乗算と加算を完了。
  3. デインターリーブロード:vld4q_u8 で RGBA を自動的に 4 つのチャンネルに分離。
  4. インターリーブストア:vst3q_f32 で RGB 3 チャンネルをインターリーブしてメモリに書き込み。

ゼロ OpenCV 依存

多くの OCR プロジェクトは画像の前処理に OpenCV を依存しています。OpenCV は強力ですが、バイナリサイズが非常に大きく、Android 版の OpenCV ライブラリは通常 10MB を超えます。

PPOCRv5-Android は「ゼロ OpenCV 依存」の路線を選択しました。すべての画像前処理操作は image_utils.cpp 内で純粋な C++ で実装されています:

  • バイリニア補間リサイズ:手書き実装、NEON 最適化をサポート。
  • 標準化:ImageNet 標準化および認識用標準化。
  • 透視変換:元画像から任意の角度のテキスト領域をクロップ。

バイリニア補間の NEON 実装

バイリニア補間は画像スケーリングのコアアルゴリズムです。ソース画像の座標 (x,y)(x, y) が与えられたとき、ターゲットピクセル値を以下のように計算します:

f(x,y)=(1α)(1β)f00+α(1β)f10+(1α)βf01+αβf11f(x, y) = (1-\alpha)(1-\beta)f_{00} + \alpha(1-\beta)f_{10} + (1-\alpha)\beta f_{01} + \alpha\beta f_{11}

ここで α=xx\alpha = x - \lfloor x \rfloorβ=yy\beta = y - \lfloor y \rfloor であり、fijf_{ij} は 4 つの近傍ピクセルの値です。

void ResizeBilinear(const uint8_t* src, int src_w, int src_h, int src_stride,
uint8_t* dst, int dst_w, int dst_h) {
const float scale_x = static_cast<float>(src_w) / dst_w;
const float scale_y = static_cast<float>(src_h) / dst_h;
for (int dy = 0; dy < dst_h; ++dy) {
const float sy = (dy + 0.5f) * scale_y - 0.5f;
const int y0 = std::max(0, static_cast<int>(std::floor(sy)));
const int y1 = std::min(src_h - 1, y0 + 1);
const float beta = sy - y0;
const float inv_beta = 1.0f - beta;
const uint8_t* row0 = src + y0 * src_stride;
const uint8_t* row1 = src + y1 * src_stride;
uint8_t* dst_row = dst + dy * dst_w * 4;
#if defined(__ARM_NEON) || defined(__ARM_NEON__)
// NEON: 一度に 4 つのターゲットピクセルを処理
const float32x4_t v_beta = vdupq_n_f32(beta);
const float32x4_t v_inv_beta = vdupq_n_f32(inv_beta);
int dx = 0;
for (; dx + 4 <= dst_w; dx += 4) {
// 4 つのソース座標を計算
float sx[4];
for (int i = 0; i < 4; ++i) {
sx[i] = ((dx + i) + 0.5f) * scale_x - 0.5f;
}
// alpha ウェイトをロード
float alpha[4], inv_alpha[4];
int x0[4], x1[4];
for (int i = 0; i < 4; ++i) {
x0[i] = std::max(0, static_cast<int>(std::floor(sx[i])));
x1[i] = std::min(src_w - 1, x0[i] + 1);
alpha[i] = sx[i] - x0[i];
inv_alpha[i] = 1.0f - alpha[i];
}
// 各チャンネルに対してバイリニア補間を実行
for (int c = 0; c < 4; ++c) { // RGBA
float32x4_t f00, f10, f01, f11;
// 4 つのピクセルの近傍値を収集
f00 = vsetq_lane_f32(row0[x0[0] * 4 + c], f00, 0);
f00 = vsetq_lane_f32(row0[x0[1] * 4 + c], f00, 1);
f00 = vsetq_lane_f32(row0[x0[2] * 4 + c], f00, 2);
f00 = vsetq_lane_f32(row0[x0[3] * 4 + c], f00, 3);
// ... f10, f01, f11 も同様
// バイリニア補間の公式
float32x4_t v_alpha = vld1q_f32(alpha);
float32x4_t v_inv_alpha = vld1q_f32(inv_alpha);
float32x4_t top = vmlaq_f32(
vmulq_f32(f00, v_inv_alpha),
f10, v_alpha
);
float32x4_t bottom = vmlaq_f32(
vmulq_f32(f01, v_inv_alpha),
f11, v_alpha
);
float32x4_t result = vmlaq_f32(
vmulq_f32(top, v_inv_beta),
bottom, v_beta
);
// uint8 に変換してストア
uint32x4_t result_u32 = vcvtq_u32_f32(result);
// ... ストア
}
}
#endif
// 残りのピクセルをスカラーで処理(省略)
}
}

TIP

バイリニア補間の NEON 最適化は、4 つの近傍ピクセルのアドレスが不連続であるため、比較的複雑です。より効率的な手法として、分離型バイリニア補間(まず水平方向に補間し、次に垂直方向に補間する)があります。これにより、キャッシュの局所性をより良く活用できます。

この選択には開発工数が増えるという代償がありますが、得られるメリットは顕著です:

  1. APK サイズを約 10MB 削減。
  2. 前処理ロジックを完全に制御でき、最適化が容易。
  3. OpenCV のバージョン互換性問題を回避。

透視変換:回転矩形から標準的なテキスト行へ

テキスト認識モデルは、水平なテキスト行画像を入力として期待します。しかし、検出されたテキストボックスは任意の角度の回転矩形である可能性があります。透視変換は、回転矩形領域を「まっすぐ」に伸ばす役割を担います。

text_recognizer.cppCropAndRotate メソッドがこの機能を実装しています:

void CropAndRotate(const uint8_t *__restrict__ image_data,
int width, int height, int stride,
const RotatedRect &box, int &target_width) {
// 回転矩形の 4 つの角の座標を計算
const float cos_angle = std::cos(box.angle * M_PI / 180.0f);
const float sin_angle = std::sin(box.angle * M_PI / 180.0f);
const float half_w = box.width / 2.0f;
const float half_h = box.height / 2.0f;
float corners[8]; // 4 つの角の (x, y) 座標
corners[0] = box.center_x + (-half_w * cos_angle - (-half_h) * sin_angle);
corners[1] = box.center_y + (-half_w * sin_angle + (-half_h) * cos_angle);
// ... 他の角を計算
// 適応的なターゲット幅:アスペクト比を維持
const float aspect_ratio = src_width / std::max(src_height, 1.0f);
target_width = static_cast<int>(kRecInputHeight * aspect_ratio);
target_width = std::clamp(target_width, 1, kRecInputWidth); // 48x[1, 320]
// アフィン変換行列
const float a00 = (x1 - x0) * inv_dst_w;
const float a01 = (x3 - x0) * inv_dst_h;
const float a10 = (y1 - y0) * inv_dst_w;
const float a11 = (y3 - y0) * inv_dst_h;
// バイリニアサンプリング + 標準化(NEON 最適化)
for (int dy = 0; dy < kRecInputHeight; ++dy) {
for (int dx = 0; dx < target_width; ++dx) {
float sx = base_sx + a00 * dx;
float sy = base_sy + a10 * dx;
BilinearSampleNeon(image_data, stride, sx, sy, dst_row + dx * 3);
}
}
}

この実装における主な最適化:

  1. 適応的な幅:テキストボックスのアスペクト比に応じて出力幅を動的に調整し、過度な引き伸ばしや圧縮を回避。
  2. アフィン変換による近似:平行四辺形に近いテキストボックスに対しては、透視変換の代わりにアフィン変換を使用して計算量を削減。
  3. NEON バイリニアサンプリング:サンプリングと標準化を 1 パスで行い、メモリアクセスを削減。

JNI:Kotlin と C++ の架け橋

JNI(Java Native Interface)は、Kotlin/Java と C++ が通信するための架け橋です。しかし、JNI 呼び出しにはオーバーヘッドがあり、頻繁な言語間呼び出しはパフォーマンスに深刻な影響を与えます。

PPOCRv5-Android の設計原則は、JNI 呼び出し回数を最小限に抑えることです。OCR フロー全体で必要な JNI 呼び出しは 1 回だけです:

sequenceDiagram
participant K as Kotlin Layer
participant J as JNI Bridge
participant N as Native Layer
participant G as GPU
K->>J: process(bitmap)
J->>N: RGBA ポインタを渡す
Note over N,G: Native レイヤーですべての処理を完結
N->>N: 画像前処理 NEON
N->>G: テキスト検出推論
G-->>N: 確率マップ
N->>N: 後処理 輪郭検出
loop 各テキストボックス
N->>N: 透視変換クロップ
N->>G: テキスト認識推論
G-->>N: logits
N->>N: CTC デコード
end
N-->>J: OCR 結果
J-->>K: List OcrResult

ppocrv5_jni.cpp におけるコア関数 nativeProcess は、この設計を示しています:

JNIEXPORT jobjectArray JNICALL
Java_me_fleey_ppocrv5_ocr_OcrEngine_nativeProcess(
JNIEnv *env, jobject thiz, jlong handle, jobject bitmap) {
auto *engine = reinterpret_cast<ppocrv5::OcrEngine *>(handle);
// Bitmap ピクセルをロック
void *pixels = nullptr;
AndroidBitmap_lockPixels(env, bitmap, &pixels);
// 1 回の JNI 呼び出しですべての OCR 処理を完了
auto results = engine->Process(
static_cast<const uint8_t *>(pixels),
static_cast<int>(bitmap_info.width),
static_cast<int>(bitmap_info.height),
static_cast<int>(bitmap_info.stride));
AndroidBitmap_unlockPixels(env, bitmap);
// Java オブジェクト配列を構築して返す
// ...
}

この設計により、検出と認識の間でデータをやり取りするオーバーヘッドを回避しています。

アーキテクチャ設計:モジュール化とテスト容易性

PPOCRv5-Android のアーキテクチャは「関心の分離(Separation of Concerns)」の原則に従っています:

flowchart TB
subgraph UI["Jetpack Compose UI レイヤー"]
direction LR
CP[CameraPreview]
GP[GalleryPicker]
RO[ResultOverlay]
end
subgraph VM["ViewModel レイヤー"]
OVM[OCRViewModel<br/>状態管理]
end
subgraph Native["Native レイヤー - C++"]
OE[OcrEngine<br/>オーケストレーション]
subgraph Detection["テキスト検出"]
TD[TextDetector]
DB[DBNet FP16]
end
subgraph Recognition["テキスト認識"]
TR[TextRecognizer]
SVTR[SVTRv2 + CTC]
end
subgraph Preprocessing["画像処理"]
IP[ImagePreprocessor<br/>NEON 最適化]
PP[PostProcessor<br/>輪郭検出]
end
subgraph Runtime["LiteRT ランタイム"]
GPU[GPU Delegate<br/>OpenCL]
CPU[CPU フォールバック<br/>XNNPACK]
end
end
CP --> OVM
GP --> OVM
OVM --> RO
OVM <-->|JNI| OE
OE --> TD
OE --> TR
TD --> DB
TR --> SVTR
TD --> IP
TR --> IP
DB --> PP
DB --> GPU
SVTR --> GPU
GPU -.->|Fallback| CPU

この階層化アーキテクチャの利点は以下の通りです:

  1. UI レイヤー:純粋な Kotlin/Compose であり、ユーザーインタラクションに集中。
  2. ViewModel レイヤー:状態とビジネスロジックを管理。
  3. Native レイヤー:高性能な計算を行い、UI と完全に分離。

各レイヤーは独立してテスト可能です。Native レイヤーは Google Test でユニットテストでき、ViewModel レイヤーは JUnit + MockK でテストできます。

Kotlin レイヤーのカプセル化

OcrEngine.kt では、Kotlin レイヤーが簡潔な API を提供しています:

class OcrEngine private constructor(
private var nativeHandle: Long,
) : Closeable {
companion object {
init {
System.loadLibrary("ppocrv5_jni")
}
fun create(
context: Context,
acceleratorType: AcceleratorType = AcceleratorType.GPU,
): Result<OcrEngine> = runCatching {
initializeCache(context)
val detModelPath = copyAssetToCache(context, "$MODELS_DIR/$DET_MODEL_FILE")
val recModelPath = copyAssetToCache(context, "$MODELS_DIR/$REC_MODEL_FILE")
val keysPath = copyAssetToCache(context, "$MODELS_DIR/$KEYS_FILE")
val handle = OcrEngine(0).nativeCreate(
detModelPath, recModelPath, keysPath,
acceleratorType.value,
)
if (handle == 0L) {
throw OcrException("Failed to create native OCR engine")
}
OcrEngine(handle)
}
}
fun process(bitmap: Bitmap): List<OcrResult> {
check(nativeHandle != 0L) { "OcrEngine has been closed" }
return nativeProcess(nativeHandle, bitmap)?.toList() ?: emptyList()
}
override fun close() {
if (nativeHandle != 0L) {
nativeDestroy(nativeHandle)
nativeHandle = 0
}
}
}

この設計の利点:

  1. Result 型を使用して初期化エラーを処理。
  2. Closeable インターフェースを実装し、use ブロックによるリソースの自動解放をサポート。
  3. モデルファイルを assets からキャッシュディレクトリへ自動的にコピー。

コールドスタートの最適化

初回推論(コールドスタート)は、通常、その後の推論(ホットスタート)よりも大幅に遅くなります。その理由は以下の通りです:

  1. GPU Delegate が OpenCL プログラムをコンパイルする必要がある。
  2. モデルの重みを CPU メモリから GPU メモリへ転送する必要がある。
  3. 各種キャッシュのウォームアップが必要。

PPOCRv5-Android は、ウォームアップ(Warm-up)メカニズムを通じてコールドスタート問題を緩和しています:

void OcrEngine::WarmUp() {
LOGD(TAG, "Starting warm-up (%d iterations)...", kWarmupIterations);
// 小さなテスト画像を作成
std::vector<uint8_t> dummy_image(kWarmupImageSize * kWarmupImageSize * 4, 128);
for (int i = 0; i < kWarmupImageSize * kWarmupImageSize; ++i) {
dummy_image[i * 4 + 0] = static_cast<uint8_t>((i * 7) % 256);
dummy_image[i * 4 + 1] = static_cast<uint8_t>((i * 11) % 256);
dummy_image[i * 4 + 2] = static_cast<uint8_t>((i * 13) % 256);
dummy_image[i * 4 + 3] = 255;
}
// 数回推論を実行してウォームアップ
for (int iter = 0; iter < kWarmupIterations; ++iter) {
float detection_time_ms = 0.0f;
detector_->Detect(dummy_image.data(), kWarmupImageSize, kWarmupImageSize,
kWarmupImageSize * 4, &detection_time_ms);
}
LOGD(TAG, "Warm-up completed (accelerator: %s)", AcceleratorName(active_accelerator_));
}

メモリアライメントの最適化

TextDetector::Impl では、すべての事前割り当てバッファが 64 バイト境界でアライメントされています:

// キャッシュラインアライメントされた事前割り当てバッファ
alignas(64) std::vector<uint8_t> resized_buffer_;
alignas(64) std::vector<float> normalized_buffer_;
alignas(64) std::vector<uint8_t> binary_map_;
alignas(64) std::vector<float> prob_map_;

64 バイトアライメントは、モダンな ARM プロセッサのキャッシュラインサイズです。アライメントされたメモリアクセスによりキャッシュラインの分割を回避し、メモリアクセス効率を向上させることができます。

メモリプールとオブジェクトの再利用

頻繁なメモリの割り当てと解放はパフォーマンスの天敵です。PPOCRv5-Android は事前割り当て戦略を採用し、初期化時に必要なすべてのメモリを一度に割り当てます:

class TextDetector::Impl {
// 事前割り当てバッファ。ライフサイクルは Impl と同じ
alignas(64) std::vector<uint8_t> resized_buffer_; // 640 * 640 * 4 = 1.6MB
alignas(64) std::vector<float> normalized_buffer_; // 640 * 640 * 3 * 4 = 4.9MB
alignas(64) std::vector<uint8_t> binary_map_; // 640 * 640 = 0.4MB
alignas(64) std::vector<float> prob_map_; // 640 * 640 * 4 = 1.6MB
bool Initialize(...) {
// 一括割り当て。実行時の malloc を回避
resized_buffer_.resize(kDetInputSize * kDetInputSize * 4);
normalized_buffer_.resize(kDetInputSize * kDetInputSize * 3);
binary_map_.resize(kDetInputSize * kDetInputSize);
prob_map_.resize(kDetInputSize * kDetInputSize);
return true;
}
};

この設計の利点:

  1. メモリ断片化の回避:すべての大きなメモリブロックを起動時に割り当てるため、実行時に断片化が発生しない。
  2. システムコールの削減:malloc はシステムコールを誘発する可能性がありますが、事前割り当てによりこのオーバーヘッドを回避。
  3. キャッシュフレンドリー:連続して割り当てられたメモリは物理的にも連続している可能性が高く、キャッシュヒット率が向上。

分岐予測の最適化

モダンな CPU は分岐予測を使用してパイプライン効率を向上させます。分岐予測が外れるとパイプラインがフラッシュされ、10〜20 クロックサイクルの損失が生じます。

ホットパス(頻繁に実行される箇所)では、__builtin_expect を使用してコンパイラにヒントを与えます:

// ほとんどのピクセルはしきい値を超えない
if (__builtin_expect(prob_map[i] > kBinaryThreshold, 0)) {
binary_map_[i] = 255;
} else {
binary_map_[i] = 0;
}

__builtin_expect(expr, val) は、expr の値が val である可能性が高いことをコンパイラに伝えます。コンパイラはこれに基づいてコードレイアウトを調整し、「可能性の低い」分岐をメインパスから遠ざけます。

ループ展開とソフトウェアパイプライン

計算集約型のループでは、手動で展開(Unrolling)することでループのオーバーヘッドを削減し、より多くの命令レベルの並列性を引き出すことができます:

// 展開前
for (int i = 0; i < n; ++i) {
dst[i] = src[i] * scale + bias;
}
// 4x 展開
int i = 0;
for (; i + 4 <= n; i += 4) {
dst[i + 0] = src[i + 0] * scale + bias;
dst[i + 1] = src[i + 1] * scale + bias;
dst[i + 2] = src[i + 2] * scale + bias;
dst[i + 3] = src[i + 3] * scale + bias;
}
for (; i < n; ++i) {
dst[i] = src[i] * scale + bias;
}

展開により、CPU は複数の独立した積和演算命令を同時に実行でき、スーパースカラーアーキテクチャの複数の実行ユニットを最大限に活用できます。

Prefetch(プリフェッチ)最適化

透視変換の内側ループでは、__builtin_prefetch を使用して次の行のデータを事前にロードします:

for (int dy = 0; dy < kRecInputHeight; ++dy) {
// 次の行のデータをプリフェッチ
if (dy + 1 < kRecInputHeight) {
const float next_sy = y0 + a11 * (dy + 1);
const int next_y = static_cast<int>(next_sy);
if (next_y >= 0 && next_y < height) {
__builtin_prefetch(image_data + next_y * stride, 0, 1);
}
}
// ... 現在の行を処理
}

この最適化によりメモリレイテンシを隠蔽でき、現在の行を処理している間に次の行のデータがすでに L1 キャッシュに読み込まれた状態になります。

後処理のエンジニアリングの詳細

連結成分分析と輪郭検出

postprocess.cppFindContours 関数は、効率的な連結成分分析を実装しています:

std::vector<std::vector<Point>> FindContours(const uint8_t *binary_map,
int width, int height) {
// 1. 4x ダウンサンプリングで計算量を削減
int ds_width = (width + kDownsampleFactor - 1) / kDownsampleFactor;
int ds_height = (height + kDownsampleFactor - 1) / kDownsampleFactor;
std::vector<uint8_t> ds_map(ds_width * ds_height);
downsample_binary_map(binary_map, width, height,
ds_map.data(), ds_width, ds_height, kDownsampleFactor);
// 2. BFS による連結成分の探索
std::vector<int> labels(ds_width * ds_width, 0);
int current_label = 0;
for (int y = 0; y < ds_height; ++y) {
for (int x = 0; x < ds_width; ++x) {
if (pixel_at(ds_map.data(), x, y, ds_width) > 0 &&
labels[y * ds_width + x] == 0) {
current_label++;
std::vector<Point> boundary;
std::queue<std::pair<int, int>> queue;
queue.push({x, y});
while (!queue.empty()) {
auto [cx, cy] = queue.front();
queue.pop();
// 境界ピクセルの検出
if (is_boundary_pixel(ds_map.data(), cx, cy, ds_width, ds_height)) {
boundary.push_back({
static_cast<float>(cx * kDownsampleFactor + kDownsampleFactor / 2),
static_cast<float>(cy * kDownsampleFactor + kDownsampleFactor / 2)
});
}
// 4 近傍拡張
for (int d = 0; d < 4; ++d) {
int nx = cx + kNeighborDx4[d];
int ny = cy + kNeighborDy4[d];
// ...
}
}
if (boundary.size() >= 4) {
contours.push_back(std::move(boundary));
}
}
}
}
return contours;
}

主な最適化ポイント:

  1. 4x ダウンサンプリング:640x640 の二値画像を 160x160 に縮小し、計算量を 16 分の 1 に削減。
  2. 境界検出:連結成分全体ではなく、境界ピクセルのみを保持。
  3. 最大輪郭数の制限:kMaxContours = 100 とし、極端な状況下でのパフォーマンス低下を防止。

凸包と回転キャリパー法

最小外接回転矩形の計算は 2 つのステップで行われます。まず凸包を計算し、次に回転キャリパー法(Rotating Calipers)を使用して最小面積の外接矩形を見つけます。

Graham Scan 凸包アルゴリズム

Graham Scan は凸包を計算するための古典的なアルゴリズムで、時間複雑度は O(nlogn)O(n \log n) です:

std::vector<Point> ConvexHull(std::vector<Point> points) {
if (points.size() < 3) return points;
// 1. 最も下の点(y が最小、次に x が最小)を見つける
auto pivot = std::min_element(points.begin(), points.end(),
[](const Point& a, const Point& b) {
return a.y < b.y || (a.y == b.y && a.x < b.x);
});
std::swap(points[0], *pivot);
Point p0 = points[0];
// 2. 極角でソート
std::sort(points.begin() + 1, points.end(),
[&p0](const Point& a, const Point& b) {
float cross = CrossProduct(p0, a, b);
if (std::abs(cross) < 1e-6f) {
// 共線の場合、距離が近い方を先に
return DistanceSquared(p0, a) < DistanceSquared(p0, b);
}
return cross > 0; // 反時計回り
});
// 3. 凸包を構築
std::vector<Point> hull;
for (const auto& p : points) {
// 時計回りになる点を削除
while (hull.size() > 1 &&
CrossProduct(hull[hull.size()-2], hull[hull.size()-1], p) <= 0) {
hull.pop_back();
}
hull.push_back(p);
}
return hull;
}
// 叉積:回転方向を判定
float CrossProduct(const Point& o, const Point& a, const Point& b) {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}

回転キャリパー法

回転キャリパー(Rotating Calipers)アルゴリズムは、凸包の各辺を巡回し、その辺を底辺とした際の外接矩形の面積を計算します:

RotatedRect MinAreaRect(const std::vector<Point>& hull) {
if (hull.size() < 3) return {};
float min_area = std::numeric_limits<float>::max();
RotatedRect best_rect;
int n = hull.size();
int right = 1, top = 1, left = 1; // 3 つの「キャリパー」位置
for (int i = 0; i < n; ++i) {
int j = (i + 1) % n;
// 現在の辺の方向ベクトル
float edge_x = hull[j].x - hull[i].x;
float edge_y = hull[j].y - hull[i].y;
float edge_len = std::sqrt(edge_x * edge_x + edge_y * edge_y);
// 単位ベクトル
float ux = edge_x / edge_len;
float uy = edge_y / edge_len;
// 垂直方向
float vx = -uy;
float vy = ux;
// 最も右の点を見つける(辺方向に沿った投影が最大)
while (Dot(hull[(right + 1) % n], ux, uy) > Dot(hull[right], ux, uy)) {
right = (right + 1) % n;
}
// 最も上の点を見つける(垂直方向に沿った投影が最大)
while (Dot(hull[(top + 1) % n], vx, vy) > Dot(hull[top], vx, vy)) {
top = (top + 1) % n;
}
// 最も左の点を見つける
while (Dot(hull[(left + 1) % n], ux, uy) < Dot(hull[left], ux, uy)) {
left = (left + 1) % n;
}
// 矩形のサイズを計算
float width = Dot(hull[right], ux, uy) - Dot(hull[left], ux, uy);
float height = Dot(hull[top], vx, vy) - Dot(hull[i], vx, vy);
float area = width * height;
if (area < min_area) {
min_area = area;
// 最適な矩形パラメータを更新
best_rect.width = width;
best_rect.height = height;
best_rect.angle = std::atan2(uy, ux) * 180.0f / M_PI;
// 中心点を計算...
}
}
return best_rect;
}

回転キャリパー法の重要な洞察は、底辺が回転するとき、3 つの「キャリパー」(右端、上端、左端の点)は単調に前進し、後退することはないという点です。そのため、全体の時間複雑度は O(n2)O(n^2) ではなく O(n)O(n) になります。

最小外接回転矩形

MinAreaRect 関数は、回転キャリパー法を使用して最小外接回転矩形を計算します:

RotatedRect MinAreaRect(const std::vector<Point> &contour) {
// 1. サブサンプリングで点数を削減
std::vector<Point> points = subsample_points(contour, kMaxBoundaryPoints);
// 2. ファストパス:アスペクト比の高いテキストボックスは AABB を直接使用
float aspect = std::max(aabb_width, aabb_height) /
std::max(1.0f, std::min(aabb_width, aabb_height));
if (aspect > 2.0f && points.size() > 50) {
// 軸に平行な境界ボックスを直接返す
RotatedRect rect;
rect.center_x = (min_x + max_x) / 2.0f;
rect.center_y = (min_y + max_y) / 2.0f;
rect.width = aabb_width;
rect.height = aabb_height;
rect.angle = 0.0f;
return rect;
}
// 3. 凸包計算
std::vector<Point> hull = convex_hull(std::vector<Point>(points));
// 4. 回転キャリパー:凸包の各辺を巡回
float min_area = std::numeric_limits<float>::max();
RotatedRect best_rect;
for (size_t i = 0; i < hull.size(); ++i) {
// 現在の辺を基準に外接矩形を計算
float edge_x = hull[j].x - hull[i].x;
float edge_y = hull[j].y - hull[i].y;
// すべての点を辺の方向と垂直方向に投影
project_points_onto_axis(hull, axis1_x, axis1_y, min1, max1);
project_points_onto_axis(hull, axis2_x, axis2_y, min2, max2);
float area = (max1 - min1) * (max2 - min2);
if (area < min_area) {
min_area = area;
// 最適な矩形を更新
}
}
return best_rect;
}

このアルゴリズムの時間複雑度は O(nlogn)O(n \log n)(凸包計算)+ O(n)O(n)(回転キャリパー)です。ここで nn は境界点の数です。サブサンプリングによって nn を 200 以内に制限することで、リアルタイム性能を確保しています。

リアルタイムカメラ OCR:CameraX とフレーム解析

リアルタイム OCR の課題は、スムーズなプレビューを維持しつつ、各フレームを可能な限り速く処理することにあります。

flowchart TB
subgraph Camera["CameraX パイプライン"]
direction TB
CP[CameraProvider]
PV[Preview UseCase<br/>30 FPS]
IA[ImageAnalysis UseCase<br/>STRATEGY_KEEP_ONLY_LATEST]
end
subgraph Analysis["フレーム解析フロー"]
direction TB
IP[ImageProxy<br/>YUV_420_888]
BM[Bitmap 変換<br/>RGBA_8888]
JNI[JNI 呼び出し<br/>単一の言語間呼び出し]
end
subgraph Native["Native OCR"]
direction TB
DET[TextDetector<br/>~45ms GPU]
REC[TextRecognizer<br/>~15ms/行]
RES[OCR 結果]
end
subgraph UI["UI 更新"]
direction TB
VM[ViewModel<br/>StateFlow]
OV[ResultOverlay<br/>Canvas 描画]
end
CP --> PV
CP --> IA
IA --> IP --> BM --> JNI
JNI --> DET --> REC --> RES
RES --> VM --> OV

CameraX の ImageAnalysis

CameraX は Android Jetpack のカメラライブラリであり、カメラフレームをリアルタイムで解析できる ImageAnalysis ユースケースを提供しています:

val imageAnalysis = ImageAnalysis.Builder()
.setTargetResolution(Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
imageAnalysis.setAnalyzer(executor) { imageProxy ->
val bitmap = imageProxy.toBitmap()
val result = ocrEngine.process(bitmap)
// UI を更新
imageProxy.close()
}

重要な設定は STRATEGY_KEEP_ONLY_LATEST です。解析器の処理速度がカメラのフレームレートに追いつかない場合、古いフレームを破棄して最新のフレームのみを保持します。これにより、OCR 結果の即時性が保証されます。

フレームレートと遅延のトレードオフ

GPU 加速が有効なデバイス(現在手元にある Snapdragon 870 では問題があるようで、計算の大部分を GPU に任せることができていません)では、PPOCRv5-Android は理論上、高い処理速度を達成できます。しかし、それはすべてのフレームを処理すべきであることを意味しません。

ユーザーがカメラをテキストに向けたとき、テキストの内容は短時間では変化しないというシナリオを考えてみましょう。すべてのフレームで完全な OCR を実行すると、計算リソースが無駄になります。

一つの最適化戦略は「変化検出」です。画面に顕著な変化があった場合にのみ OCR をトリガーします。これは、連続するフレームのヒストグラムや特徴点を比較することで実現できます。

今後の展望:NPU と量子化

エッジ AI の未来は NPU(Neural Processing Unit)にあります。GPU と比較して、NPU はニューラルネットワークの推論専用に設計されており、電力効率がより優れています。

しかし、NPU の課題は断片化にあります。チップベンダーごとに独自の NPU アーキテクチャと SDK を持っています:

  • Qualcomm:Hexagon DSP + AI Engine
  • MediaTek:APU
  • Samsung:Exynos NPU
  • Google:Tensor TPU

Android の NNAPI(Neural Networks API)は統一された抽象化レイヤーの提供を試みていますが、実際の効果はまちまちです。多くの NPU 機能は NNAPI を通じて公開されておらず、開発者はベンダー固有の SDK を使用せざるを得ない状況です。

INT8 量子化:終わりのない戦い

FP16 量子化は保守的な選択であり、精度をほとんど損ないません。しかし、究極のパフォーマンスを追求するなら、INT8 量子化が次のステップとなります。

INT8 量子化は、重みとアクティベーションを 32 ビット浮動小数点から 8 ビット整数に圧縮します。これにより、理論上は以下のメリットが得られます:

  • モデルサイズを 4 分の 1 に削減。
  • 推論を 2〜4 倍高速化(ハードウェアに依存)。
  • Qualcomm Hexagon DSP 上では 10 倍以上の高速化が可能。

この誘惑はあまりに大きく、私は INT8 量子化への長い旅を始めました。

最初の試み:合成データによるキャリブレーション

INT8 量子化には、量子化パラメータ(Scale と Zero Point)を決定するためのキャリブレーションデータセットが必要です。最初は、横着をしてランダムに生成した「テキスト風」画像を使用しました:

# 誤った例:ランダムノイズをキャリブレーションに使用
img = np.ones((h, w, 3), dtype=np.float32) * 0.9
for _ in range(num_lines):
gray_val = np.random.uniform(0.05, 0.3)
img[y:y+line_h, x:x+line_w] = gray_val

結果は悲惨でした。モデルの出力がすべて 0 になったのです:

Raw FLOAT32 output range: min=0.0000, max=0.0000
Prob map stats: min=0.0000, max=0.0000, mean=0.000000

量子化ツールがランダムノイズに基づいて誤った量子化パラメータを計算したため、実際の画像の有効なアクティベーション値が切り捨てられてしまったのです。

二度目の試み:実画像によるキャリブレーション

ICDAR2015、TextOCR、PaddleOCR 公式サンプル画像など、実際の OCR データセットの画像に切り替えました。同時に Letterbox 前処理を実装し、キャリブレーション時の画像分布が推論時と一致するようにしました:

def letterbox_image(image, target_size):
"""アスペクト比を維持してスケーリングし、不足部分をグレーでパディング"""
ih, iw = image.shape[:2]
h, w = target_size
scale = min(w / iw, h / ih)
# ... 中央に配置

モデルの出力はすべて 0 ではなくなりましたが、認識結果は依然として文字化けしたままでした。

三度目の試み:C++ 側の型処理の修正

C++ コードが INT8 入力を処理する際に問題があることに気づきました。INT8 モデルは生のピクセル値(0-255)を期待していますが、私はまだ ImageNet 標準化(平均を引いて標準偏差で割る)を行っていました。

if (input_is_int8_) {
// INT8 モデル:生のピクセルを直接入力。標準化は第一層に融合済み
dst[i * 3 + 0] = static_cast<int8_t>(src[i * 4 + 0] ^ 0x80);
} else {
// FP32 モデル:手動での標準化が必要
// (pixel - mean) / std
}

同時に、量子化パラメータをハードコーディングするのではなく、動的に読み取るロジックを実装しました:

bool GetQuantizationParams(LiteRtTensor tensor, float* scale, int32_t* zero_point) {
LiteRtQuantization quant;
LiteRtGetTensorQuantization(tensor, &quant);
// ...
}

最終結果:妥協

数日間にわたるデバッグの結果、INT8 モデルは依然として正常に動作しませんでした。原因として考えられるのは以下の通りです:

  1. onnx2tf の量子化実装:PP-OCRv5 はいくつかの特殊な演算子の組み合わせを使用しており、onnx2tf が量子化時にそれらを正しく処理できていない可能性がある。
  2. DBNet の出力特性:DBNet は確率マップを出力し、その値域は 0〜1 の間です。INT8 量子化はこのように範囲の狭い値に対して非常に敏感です。
  3. 多段階モデルの誤差蓄積:検出と認識の 2 つのモデルが直列に繋がっているため、量子化誤差が蓄積・増幅されてしまう。

2 番目の点について深く分析してみましょう。DBNet の出力は Sigmoid 活性化関数を通るため、値域は [0, 1] に圧縮されます。INT8 量子化は以下の公式を使用します:

xquantized=round(xfloatscale)+zero_pointx_{quantized} = \text{round}\left(\frac{x_{float}}{scale}\right) + zero\_point

[0, 1] の範囲の値に対して scale が不適切に設定されると、量子化後の値が INT8 の範囲 [-128, 127] のごく一部しか占有できず、深刻な精度損失を招きます。

# scale = 0.00784 (1/127), zero_point = 0 と仮定
# 入力 0.5 -> round(0.5 / 0.00784) + 0 = 64
# 入力 0.1 -> round(0.1 / 0.00784) + 0 = 13
# 入力 0.01 -> round(0.01 / 0.00784) + 0 = 1
# 入力 0.001 -> round(0.001 / 0.00784) + 0 = 0 # 精度損失!

DBNet のしきい値は通常 0.1〜0.3 に設定されます。これは、意味のある確率値(0.1〜0.3)の多くが、量子化後には 13〜38 というわずか 25 個の整数でしか表現されず、解像度が著しく不足することを意味します。

WARNING

PP-OCRv5 の INT8 量子化は既知の難題です。もしあなたが挑戦しているなら、まず FP32 モデルが正常に動作することを確認してから、段階的に量子化の問題を切り分けることをお勧めします。あるいは、PaddleOCR へのサポートがより手厚い PaddlePaddle 公式の Paddle Lite フレームワークの使用を検討してください。

量子化を考慮したトレーニング:正しい解決策

どうしても INT8 量子化を使用する必要がある場合、正しい手法はトレーニング後量子化(Post-Training Quantization, PTQ)ではなく、量子化を考慮したトレーニング(Quantization-Aware Training, QAT)です。

QAT はトレーニングプロセス中に量子化誤差をシミュレートし、モデルに低精度表現への適応を学習させます:

# PyTorch QAT の例
import torch.quantization as quant
model = DBNet()
model.qconfig = quant.get_default_qat_qconfig('fbgemm')
model_prepared = quant.prepare_qat(model)
# 通常のトレーニング。ただし順伝播中に擬似量子化ノードが挿入される
for epoch in range(num_epochs):
for images, labels in dataloader:
outputs = model_prepared(images) # 量子化シミュレーションを含む
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
# 本物の量子化モデルに変換
model_quantized = quant.convert(model_prepared)

残念ながら、PP-OCRv5 公式は QAT トレーニング済みのモデルを提供していません。これは、高品質な INT8 モデルを得るためにはゼロから QAT トレーニングを行う必要があることを意味し、本プロジェクトの範囲を超えています。

最終的に、私は妥協を選択しました。INT8 + DSP ではなく、FP16 量子化 + GPU 加速を使用することにしました。

この決定による代償は以下の通りです:

  • モデルサイズが INT8 の 2 倍になる。
  • Hexagon DSP の超低消費電力を活用できない。
  • 推論速度が理論上の最適値より 2〜3 倍遅くなる。

しかし、得られるメリットは以下の通りです:

  • モデル精度が FP32 とほぼ一致する。
  • 開発期間を大幅に短縮できる。
  • コードの複雑さを低減できる。

エンジニアリングの本質はトレードオフです。時には「理論上の最適」よりも「十分に良い」ことの方が重要です。

結び

PaddlePaddle から LiteRT へ、DBNet から SVTRv2 へ、OpenCL から NEON へ。エッジ側 OCR のエンジニアリング実践は、ディープラーニング、コンパイラ、GPU プログラミング、モバイル開発など、多岐にわたる分野の知識を必要とします。

このプロジェクトから得られた核心的な教訓は、エッジ AI は単に「モデルをスマホに載せる」だけではないということです。それには以下が必要です:

  1. モデルアーキテクチャを深く理解し、正しく変換すること。
  2. ハードウェア特性を熟知し、アクセラレータを最大限に活用すること。
  3. システムプログラミングをマスターし、高性能なネイティブコードを実装すること。
  4. ユーザー体験に注目し、パフォーマンスと消費電力のバランスを見つけること。

PPOCRv5-Android はオープンソースプロジェクトであり、現代的な OCR モデルを実際のモバイルアプリにどのようにデプロイするかを示しています。この記事が、同様のニーズを持つ開発者の方々にとって何らかの参考になれば幸いです。

Google が LiteRT のリリース時に述べたように、「Maximum performance, simplified.(最高のパフォーマンスを、よりシンプルに)」9。エッジ AI の目標は複雑にすることではなく、複雑なものをシンプルにすることにあります。

あとがき

正直なところ、私は(仕事と趣味の両分野において)Android から少なくとも 2 年は遠ざかっていました。そして、これが GitHub のサブアカウント(離れる決意を示すために、メインアカウントは同僚に譲りました)で公開する、初めての比較的成熟したライブラリとなります。

ここ数年、私の仕事の重点は Android 分野ではありませんでした。具体的な状況については明かせませんが、いつか詳しくお話しできればと思います。とにかく、私が Android でこれ以上の成果を上げることは、もう難しいかもしれません。

今回のプロジェクト公開は、あくまで趣味が高じてのことです。現在構築中の、Android エッジ側をベースとした初期のツールがあり、OCR はその低レイヤーのごく一部に過ぎません。後日(おそらく近いうちに)、その全ソースコードも公開する予定ですが、今はまだ詳細を伏せさせていただきます。

とにかく、ここまで読んでいただきありがとうございました。私のリポジトリに Star をいただけると励みになります。感謝いたします!


参考文献

Footnotes

  1. Google AI Edge. “LiteRT: Maximum performance, simplified.” 2024. https://developers.googleblog.com/litert-maximum-performance-simplified/

  2. PaddleOCR Team. “PaddleOCR 3.0 Technical Report.” arXiv:2507.05595, 2025. https://arxiv.org/abs/2507.05595

  3. GitHub Discussion. “Problem while deploying the newest official PP-OCRv5.” PaddleOCR #16100, 2025. https://github.com/PaddlePaddle/PaddleOCR/discussions/16100

  4. Liao, M., et al. “Real-time Scene Text Detection with Differentiable Binarization.” Proceedings of the AAAI Conference on Artificial Intelligence, 2020. https://arxiv.org/abs/1911.08947

  5. Du, Y., et al. “SVTR: Scene Text Recognition with a Single Visual Model.” IJCAI, 2022. https://arxiv.org/abs/2205.00159

  6. Du, Y., et al. “SVTRv2: CTC Beats Encoder-Decoder Models in Scene Text Recognition.” ICCV, 2025. https://arxiv.org/abs/2411.15858 2

  7. TensorFlow Blog. “Even Faster Mobile GPU Inference with OpenCL.” 2020. https://blog.tensorflow.org/2020/08/faster-mobile-gpu-inference-with-opencl.html

  8. ARM Developer. “Neon Intrinsics on Android.” ARM Documentation, 2024. https://developer.arm.com/documentation/101964/latest/

  9. Google AI Edge. “LiteRT Documentation.” 2024. https://ai.google.dev/edge/litert

~
~
mobile/ppocrv5-android.md
$ license --info

ライセンス

特記事項がない限り、本ブログのすべての記事および素材は以下のライセンスの下で提供されています: クリエイティブ・コモンズ 表示 - 非営利 - 継承 4.0 国際 ライセンス (CC BY-NC-SA 4.0)

✓ コピーしました!