說明
本篇博文
- 封面:基於 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 中的映射並非一一對應。
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 算子。這個算子在數學上定義為:
其中 ,。
問題在於:LiteRT 的 GPU Delegate 不支援 HardSigmoid。當模型包含不支援的算子時,GPU Delegate 會將整個子圖回退到 CPU 執行,這會導致嚴重的效能損失。
解決方案是將 HardSigmoid 分解為基本算子。使用 onnx-graphsurgeon 庫,我們可以在計算圖級別進行手術:
import onnx_graphsurgeon as gsimport 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這種分解的關鍵在於:Mul、Add、Clip 都是 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 計算單元。
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 可微分二值化
標準二值化是一個階躍函數:
這個函數不可微,無法通過反向傳播進行端到端訓練。DBNet 提出了一個近似函數:
其中 是概率圖, 是閾值圖(由網路學習), 是放大因子(訓練時設為 50)。
TIP
這個公式本質上是一個 Sigmoid 函數,只是輸入變成了 。當 足夠大時,它的行為接近階躍函數,但保持了可微性。
後處理流程的工程實作
在 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 多邊形裁剪演算法的逆操作。給定一個多邊形 和膨脹距離 ,膨脹後的多邊形 滿足:
其中 是多邊形面積, 是周長, 是膨脹比例(通常設為 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-
混合注意力機制:交替使用局部注意力(捕捉筆畫細節)和全局注意力(理解字元結構)。局部注意力使用 7x7 的滑動窗口,計算複雜度從 降到 。
-
多尺度特徵融合:不同於 ViT 的單一解析度,SVTRv2 在不同深度使用不同的特徵圖解析度,類似於 CNN 的金字塔結構。
-
語義引導模組(Semantic Guidance Module):在編碼器末端添加了一個輕量級的語義分支,幫助模型理解字元的語義關係,而不僅僅是視覺特徵。
這些改進使得 SVTRv2 在保持 CTC 解碼簡單性的同時,達到了與 Attention-based 方法相當的精度6。
為什麼是 CTC 而不是 Attention?
文本辨識有兩種主流範式:
- CTC(Connectionist Temporal Classification):將辨識視為序列標註問題,輸出與輸入對齊
- 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 的核心思想是:給定輸入序列 和所有可能的對齊路徑 ,計算目標序列 的概率:
其中 是「多對一映射函數」,它將路徑 映射到輸出序列 (通過合併重複和移除空白)。
在推理時,我們使用貪心解碼(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;}貪心解碼的時間複雜度是 ,其中 是時間步數, 是類別數。對於 PP-OCRv5,,,每次解碼需要約 150 萬次比較。這就是為什麼 NEON 優化如此重要。
TIP
Beam Search 可以提高解碼精度,但計算量是貪心解碼的 倍( 是 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;}這種設計的優勢在於:
- 零拷貝推理:GPU Delegate 可以直接訪問 Buffer,無需 CPU-GPU 資料傳輸
- 自動記憶體管理:
OwnHandle::kYes確保 Buffer 在 C++ 對象析構時自動釋放 - 型別安全:編譯時檢查張量型別匹配
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| 加速器 | 後端 | 優點 | 缺點 |
|---|---|---|---|
| GPU | OpenCL | 廣泛支援,效能好 | 不是 Android 標準組件 |
| GPU | OpenGL ES | Android 標準組件 | 效能不如 OpenCL |
| NPU | NNAPI | 最高效能 | 裝置相容性差 |
| CPU | XNNPACK | 最廣泛相容 | 效能最低 |
PPOCRv5-Android 選擇了 OpenCL 作為主要加速後端。Google 在 2020 年發佈了 TFLite 的 OpenCL 後端,相比 OpenGL ES 後端,它在 Adreno GPU 上實現了約 2 倍的加速7。
OpenCL 的優勢來自幾個方面:
- 設計初衷:OpenCL 從一開始就為通用計算設計,而 OpenGL 是圖形渲染 API,後來才添加了計算著色器支援
- 常量記憶體:OpenCL 的常量記憶體對神經網路的權重訪問非常高效
- 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 & ALU2OpenCL 的效能優勢來自:
| 特性 | OpenCL | OpenGL 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 實作了一個優雅降級策略:
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 --> S3PPOCRv5-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 標準化使用以下公式:
其中 ,(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}這段代碼的關鍵優化技巧:
- 預計算常量:將
(x - mean) / std變換為x * scale + bias,減少運行時除法 - Fused Multiply-Add:
vmlaq_f32在單條指令中完成乘法和加法 - 解交織加載:
vld4q_u8自動將 RGBA 分離為四個通道 - 交織存儲:
vst3q_f32將 RGB 三通道交織寫入記憶體
零 OpenCV 依賴
許多 OCR 項目依賴 OpenCV 進行圖像預處理。OpenCV 功能強大,但它也帶來了巨大的包體積,Android 上的 OpenCV 庫通常超過 10MB。
PPOCRv5-Android 選擇了「零 OpenCV 依賴」的路線。所有圖像預處理操作都在 image_utils.cpp 中用純 C++ 實作:
- 雙線性插值縮放:手寫實作,支援 NEON 優化
- 歸一化:ImageNet 標準化和辨識標準化
- 透視變換:從原圖裁剪任意角度的文本區域
雙線性插值的 NEON 實作
雙線性插值是圖像縮放的核心演算法。給定源圖像座標 ,雙線性插值計算目標像素值:
其中 ,, 是四個鄰近像素的值。
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 優化比較複雜,因為四個鄰近像素的地址是不連續的。一個更高效的方法是使用分離式雙線性插值:先在水平方向插值,再在垂直方向插值。這樣可以更好地利用快取局部性。
這種選擇的代價是更多的開發工作,但收益是顯著的:
- APK 體積減少約 10MB
- 完全控制預處理邏輯,便於優化
- 避免 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); } }}這個實作的關鍵優化:
- 自適應寬度:根據文本框寬高比動態調整輸出寬度,避免過度拉伸或壓縮
- 仿射變換近似:對於近似平行四邊形的文本框,使用仿射變換代替透視變換,減少計算量
- 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 JNICALLJava_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這種分層架構的好處是:
- UI 層:純 Kotlin/Compose,專注於用戶交互
- ViewModel 層:管理狀態和業務邏輯
- 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 } }}這種設計的優點:
- 使用
Result型別處理初始化錯誤 - 實作
Closeable介面,支援use塊自動釋放資源 - 模型文件自動從 assets 複製到快取目錄
冷啟動優化
首次推理(冷啟動)通常比後續推理(熱啟動)慢很多。這是因為:
- GPU Delegate 需要編譯 OpenCL 程式
- 模型權重需要從 CPU 記憶體傳輸到 GPU 記憶體
- 各種快取需要預熱
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 alignmentalignas(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; }};這種設計的好處:
- 避免記憶體碎片:所有大塊記憶體在啟動時分配,運行時不會產生碎片
- 減少系統調用:
malloc可能觸發系統調用,預分配避免了這一開銷 - 快取友好:連續分配的記憶體更可能在物理上連續,提高快取命中率
分支預測優化
現代 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;}關鍵優化點:
- 4x 降採樣:將 640x640 的二值圖降採樣到 160x160,減少 16 倍的計算量
- 邊界檢測:只保留邊界像素,而不是整個連通域
- 最大輪廓數限制:
kMaxContours = 100,防止極端情況下的效能問題
凸包與旋轉卡殼演算法
最小外接旋轉矩形的計算分為兩步:首先計算凸包,然後使用旋轉卡殼演算法找到最小面積的外接矩形。
Graham Scan 凸包演算法
Graham Scan 是計算凸包的經典演算法,時間複雜度 :
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;}旋轉卡殼的關鍵洞察是:當底邊旋轉時,三個「卡殼」(最右、最上、最左點)只會單調前進,不會後退。因此總時間複雜度是 ,而非 。
最小外接旋轉矩形
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;}這個演算法的時間複雜度是 (凸包計算)+ (旋轉卡殼),其中 是邊界點數量。通過子採樣將 限制在 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 --> OVCameraX 的 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.9for _ 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.0000Prob 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 模型仍然無法正常工作。問題可能出在:
- onnx2tf 的量化實作:PP-OCRv5 使用了一些特殊的算子組合,onnx2tf 在量化時可能沒有正確處理
- DBNet 的輸出特性:DBNet 輸出的是概率圖,值域在 0-1 之間,INT8 量化對這種小範圍值特別敏感
- 多階段模型的誤差累計:檢測和辨識兩個模型串聯,量化誤差會累計放大
讓我們深入分析第二點。DBNet 的輸出經過 Sigmoid 激活,值域被壓縮到 [0, 1]。INT8 量化使用以下公式:
對於 [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 不僅僅是「把模型放到手機上」。它需要:
- 深入理解模型架構,才能正確轉換
- 熟悉硬體特性,才能充分利用加速器
- 掌握系統編程,才能實現高性能原生代碼
- 關注用戶體驗,才能在效能和功耗之間找到平衡
PPOCRv5-Android 是一個開源項目,它展示了如何將現代的 OCR 模型部署到實際的移動應用中。希望這篇文章能為有類似需求的開發者提供一些參考。
正如 Google 在 LiteRT 發佈時所說:「Maximum performance, simplified.」9 端側 AI 的目標不是複雜,而是讓複雜變得簡單。
後話
老實講,我(在工作與興趣領域)其實已經淡出 Android 有至少兩年了,而這是我首次在 GitHub 小號上(我已經把大號交給了同事,以表我離開的決心)公開一個較為成熟的庫。
這些年來我的工作重點其實並不是 Android 領域,具體情況不便透露,但日後有機會會展開講講。總之,我或許很難再在 Android 上再多建樹了。
這次發佈該項目源於我興趣使然,正構建的一個早期基於 Android 端側的工具——而 OCR 只是其中底層的一小部分,後期(應該很快了)也將完整開放原始碼,暫時也不便透露。
總之,感謝你看到這裡,也期待你能給我的倉庫點上 Star,感謝!
參考文獻
Footnotes
-
Google AI Edge. “LiteRT: Maximum performance, simplified.” 2024. https://developers.googleblog.com/litert-maximum-performance-simplified/ ↩
-
PaddleOCR Team. “PaddleOCR 3.0 Technical Report.” arXiv:2507.05595, 2025. https://arxiv.org/abs/2507.05595 ↩
-
GitHub Discussion. “Problem while deploying the newest official PP-OCRv5.” PaddleOCR #16100, 2025. https://github.com/PaddlePaddle/PaddleOCR/discussions/16100 ↩
-
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 ↩
-
Du, Y., et al. “SVTR: Scene Text Recognition with a Single Visual Model.” IJCAI, 2022. https://arxiv.org/abs/2205.00159 ↩
-
Du, Y., et al. “SVTRv2: CTC Beats Encoder-Decoder Models in Scene Text Recognition.” ICCV, 2025. https://arxiv.org/abs/2411.15858 ↩ ↩2
-
TensorFlow Blog. “Even Faster Mobile GPU Inference with OpenCL.” 2020. https://blog.tensorflow.org/2020/08/faster-mobile-gpu-inference-with-opencl.html ↩
-
ARM Developer. “Neon Intrinsics on Android.” ARM Documentation, 2024. https://developer.arm.com/documentation/101964/latest/ ↩
-
Google AI Edge. “LiteRT Documentation.” 2024. https://ai.google.dev/edge/litert ↩