$ 跳轉到主要內容
概覽
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 應用之一,正在經歷一場靜默的革命。

百度的 PaddleOCR 團隊在 2025 年發佈了 PP-OCRv5,這是一個支援簡體中文、繁體中文、英文、日文等多語言的統一 OCR 模型2。它的移動端版本僅有約 70MB,卻能在單一模型中實現 18,383 個字元的辨識。這個數字背後,是檢測與辨識兩個深度神經網路的協同工作。

但問題在於: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 Results<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,每個框架都有自己的模型格式和算子實作。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 Quantized]
end
PM -->|paddle2onnx| OM
OM -->|HardSigmoid 分解<br/>Resize 模式修改| GS
GS -->|onnx2tf| TM

這條路徑看似簡單,實則暗藏玄機。

第一道坎:paddle2onnx 的算子相容性

paddle2onnx 是 PaddlePaddle 官方提供的模型轉換工具。理論上,它能將 PaddlePaddle 模型轉換為 ONNX 格式。但 PP-OCRv5 使用了一些特殊的算子,這些算子在 ONNX 中的映射並非一一對應。

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.json 而非傳統的 inference.pdmodel。這是 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 參數指定了輸入的靜態形狀。靜態形狀對於 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[Backbone<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_;
// ...
}
}

這個流程的關鍵在於「最小外接旋轉矩形」。與軸對齊的邊界框不同,旋轉矩形可以緊密貼合任意角度的文本,這對於自然場景中的傾斜文本至關重要。

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 有三個關鍵改進:

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-based 方法相當的精度6

為什麼是 CTC 而不是 Attention?

文本辨識有兩種主流範式:

  1. CTC(Connectionist Temporal Classification):將辨識視為序列標註問題,輸出與輸入對齊
  2. Attention-based Decoder:使用注意力機制逐字元生成輸出

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”,兩個 ‘l’ 被錯誤地合併了。CTC 的解決方案是:在重複字元之間插入 blank token。

正確編碼: [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} 是「多對一映射函數」,它將路徑 π\pi 映射到輸出序列 YY(通過合併重複和移除空白)。

在推理時,我們使用貪心解碼(Greedy Decoding)而非完整的 Beam Search:

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 token (index 0)
// 2. 合併連續重複的字元
if (max_idx != 0 && max_idx != prev_idx) {
result += dictionary[max_idx - 1]; // -1 因為 blank 佔用了 index 0
}
prev_idx = max_idx;
}
return result;
}

貪心解碼的時間複雜度是 O(T×C)O(T \times C),其中 TT 是時間步數,CC 是類別數。對於 PP-OCRv5,T80T \approx 80C=18384C = 18384,每次解碼需要約 150 萬次比較。這就是為什麼 NEON 優化如此重要。

TIP

Beam Search 可以提高解碼精度,但計算量是貪心解碼的 kk 倍(kk 是 beam width)。在移動端,貪心解碼通常是更好的選擇。

字元字典:18,383 個字元的挑戰

PP-OCRv5 支援 18,383 個字元,包括:

  • 簡體中文常用字
  • 繁體中文常用字
  • 英文字母和數字
  • 日文平假名、片假名
  • 常用標點符號和特殊字元

這個字典存儲在 keys_v5.txt 文件中,每行一個字元。CTC 解碼時,模型輸出的 logits 形狀為 [1, T, 18384],其中 T 是時間步數,18384 = 18383 個字元 + 1 個 blank token。

LiteRT C++ API:2024 年重構後的現代介面

PPOCRv5-Android 使用 LiteRT 2024 年重構後的 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. 創建託管 Buffer
CreateBuffersWithCApi();
return true;
}

Managed Tensor Buffer:零拷貝推理的關鍵

LiteRT 的 Managed Tensor Buffer 是實現高性能推理的關鍵。它允許 GPU Delegate 直接訪問 Buffer,無需 CPU-GPU 資料傳輸:

bool CreateBuffersWithCApi() {
LiteRtCompiledModel c_model = compiled_model_->Get();
LiteRtEnvironment c_env = env_->Get();
// 獲取輸入 Buffer 需求
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);
// 創建託管 Buffer
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 可以直接訪問 Buffer,無需 CPU-GPU 資料傳輸
  2. 自動記憶體管理:OwnHandle::kYes 確保 Buffer 在 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 從一開始就為通用計算設計,而 OpenGL 是圖形渲染 API,後來才添加了計算著色器支援
  2. 常量記憶體:OpenCL 的常量記憶體對神經網路的權重訪問非常高效
  3. FP16 支援:OpenCL 原生支援半精度浮點,而 OpenGL 的支援較晚

但 OpenCL 有一個致命缺陷:它不是 Android 的標準組件。不同廠商的 OpenCL 實作質量參差不齊,有些裝置甚至完全不支援。

OpenCL vs OpenGL ES:效能深度對比

為了理解 OpenCL 的優勢,我們需要深入到 GPU 架構層面。以高通 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 Cache<br/>16KB per SP]
L2[L2 Cache<br/>1MB Shared]
GMEM[Global Memory<br/>LPDDR4X]
end
subgraph Special["專用單元"]
TMU[Texture Unit<br/>雙線性插值]
CONST[Constant Cache<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 優化

為什麼要用 C++ 而不是 Kotlin?

答案很簡單:效能。圖像預處理涉及大量的像素級操作,這些操作在 JVM 上的開銷是不可接受的。更重要的是,C++ 可以直接使用 ARM NEON SIMD 指令,實現向量化計算。

NEON:ARM 的 SIMD 指令集

NEON 是 ARM 處理器的 SIMD(Single Instruction, Multiple Data)擴展。它允許一條指令同時處理多個資料元素。

flowchart LR
subgraph NEON["128-bit NEON Register"]
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 三通道)。

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) / std 變換為 x * scale + bias,減少運行時除法
  2. Fused Multiply-Add:vmlaq_f32 在單條指令中完成乘法和加法
  3. 解交織加載:vld4q_u8 自動將 RGBA 分離為四個通道
  4. 交織存儲:vst3q_f32 將 RGB 三通道交織寫入記憶體

零 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 \rfloorfijf_{ij} 是四個鄰近像素的值。

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 優化比較複雜,因為四個鄰近像素的地址是不連續的。一個更高效的方法是使用分離式雙線性插值:先在水平方向插值,再在垂直方向插值。這樣可以更好地利用快取局部性。

這種選擇的代價是更多的開發工作,但收益是顯著的:

  1. APK 體積減少約 10MB
  2. 完全控制預處理邏輯,便於優化
  3. 避免 OpenCV 版本相容性問題

透視變換:從旋轉矩形到標準文本行

文本辨識模型期望輸入是水平的文本行圖像。但檢測到的文本框可能是任意角度的旋轉矩形。透視變換負責將旋轉矩形區域「拉直」。

text_recognizer.cpp 中,CropAndRotate 方法實作了這一功能:

void CropAndRotate(const uint8_t *__restrict__ image_data,
int width, int height, int stride,
const RotatedRect &box, int &target_width) {
// 計算旋轉矩形的四個角點
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 雙線性插值:採樣和歸一化在一個 pass 中完成,減少記憶體訪問

JNI:Kotlin 與 C++ 的橋樑

JNI(Java Native Interface)是 Kotlin/Java 與 C++ 通信的橋樑。但 JNI 調用有開銷,頻繁的跨語言調用會嚴重影響效能。

PPOCRv5-Android 的設計原則是:最小化 JNI 調用次數。整個 OCR 流程只需要一次 JNI 調用:

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 Results
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);
// 一次 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 的架構遵循「關注點分離」原則:

flowchart TB
subgraph UI["Jetpack Compose UI Layer"]
direction LR
CP[CameraPreview]
GP[GalleryPicker]
RO[ResultOverlay]
end
subgraph VM["ViewModel Layer"]
OVM[OCRViewModel<br/>State Management]
end
subgraph Native["Native Layer - C++"]
OE[OcrEngine<br/>Orchestration]
subgraph Detection["Text Detection"]
TD[TextDetector]
DB[DBNet FP16]
end
subgraph Recognition["Text Recognition"]
TR[TextRecognizer]
SVTR[SVTRv2 + CTC]
end
subgraph Preprocessing["Image Processing"]
IP[ImagePreprocessor<br/>NEON Optimized]
PP[PostProcessor<br/>Contour Detection]
end
subgraph Runtime["LiteRT Runtime"]
GPU[GPU Delegate<br/>OpenCL]
CPU[CPU Fallback<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 中,所有預分配的 buffer 都使用 64 字節對齊:

// Pre-allocated buffers with cache-line alignment
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 {
// 預分配的 buffer,生命週期與 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。編譯器會據此調整代碼佈局,將「不太可能」的分支放到遠離主路徑的位置。

迴圈展開與軟體流水線

對於計算密集型迴圈,手動展開可以減少迴圈開銷並暴露更多的指令級並行:

// 未展開版本
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.cpp 中,FindContours 函數實作了高效的連通域分析:

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_height, 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 倍的計算量
  2. 邊界檢測:只保留邊界像素,而不是整個連通域
  3. 最大輪廓數限制:kMaxContours = 100,防止極端情況下的效能問題

凸包與旋轉卡殼演算法

最小外接旋轉矩形的計算分為兩步:首先計算凸包,然後使用旋轉卡殼演算法找到最小面積的外接矩形。

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; // 三個「卡殼」的位置
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;
}

旋轉卡殼的關鍵洞察是:當底邊旋轉時,三個「卡殼」(最右、最上、最左點)只會單調前進,不會後退。因此總時間複雜度是 O(n)O(n),而非 O(n2)O(n^2)

最小外接旋轉矩形

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 Pipeline"]
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 Results]
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 加速的裝置上(我目前手上的驍龍 870 似乎有問題,始終無法做到把大部分計算交給 GPU 加速),PPOCRv5-Android 理論可以達到較高的處理速度。但這並不意味著我們應該處理每一幀。

考慮這樣一個場景:用戶將相機對準一段文字,文字內容在短時間內不會變化。如果我們每幀都進行完整的 OCR,會浪費大量計算資源。

一個優化策略是「變化檢測」:只有當畫面發生顯著變化時,才觸發 OCR。這可以通過比較連續幀的直方圖或特徵點來實現。

未來展望:NPU 與量化

端側 AI 的未來在於 NPU(Neural Processing Unit)。與 GPU 相比,NPU 專為神經網路推理設計,能效比更高。

但 NPU 的挑戰在於碎片化。每個晶片廠商都有自己的 NPU 架構和 SDK:

  • 高通:Hexagon DSP + AI Engine
  • 聯發科:APU
  • 三星:Exynos NPU
  • Google:Tensor TPU

Android 的 NNAPI(Neural Networks API)試圖提供統一的抽象層,但實際效果參差不齊。許多 NPU 功能無法通過 NNAPI 暴露,開發者不得不使用廠商特定的 SDK。

INT8 量化:一場未竟的戰役

FP16 量化是一個保守的選擇,它幾乎不損失精度。但如果追求極致效能,INT8 量化是下一步。

INT8 量化將權重和激活從 32 位浮點壓縮到 8 位整數,理論上可以帶來:

  • 4 倍的模型壓縮
  • 2-4 倍的推理加速(取決於硬體)
  • 在高通 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

量化工具根據隨機噪聲計算出了錯誤的量化參數,導致真實圖像的激活值被截斷。

第二次嘗試:真實圖片校準

我改用真實的 OCR 資料集圖片:ICDAR2015、TextOCR、PaddleOCR 官方示例圖。同時實作了 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. 多階段模型的誤差累計:檢測和辨識兩個模型串聯,量化誤差會累計放大

讓我們深入分析第二點。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 模型能正常工作,再逐步排查量化問題。或者,考慮使用 PaddlePaddle 官方的 Paddle Lite 框架,它對 PaddleOCR 的支援更好。

量化感知訓練:正確的解決方案

如果必須使用 INT8 量化,正確的方法是量化感知訓練(Quantization-Aware Training, QAT),而非訓練後量化(Post-Training Quantization, PTQ)。

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 訓練,這超出了本項目的範圍。

最終,我選擇了妥協:使用 FP16 量化 + GPU 加速,而不是 INT8 + DSP。

這個決定的代價是:

  • 模型體積是 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 有至少兩年了,而這是我首次在 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

許可協議

除特別聲明以外,本部落格所有文章與素材內容均採用 創用CC 姓名標示 - 非商業性 - 相同方式分享 4.0 國際授權條款 (CC BY-NC-SA 4.0)

✓ 已複製!