Hinweise
Dieser Blogpost:
- Cover: Generiert mit Google Nano Banana 2, urheberrechtsfrei.
- Projekt-Quellcode: Open Source auf GitHub verfügbar unter PPOCRv5-Android.
Erklärung:
Der Autor (Fleey) ist kein professioneller KI-Experte, sondern betreibt dies aus reinem Interesse. Sollten sich Fehler oder Auslassungen im Text finden, bitte ich um Nachsicht und zeitnahe Korrekturhinweise!
Einleitung
Im Jahr 2024 benannte Google TensorFlow Lite in LiteRT um. Dies war nicht nur ein Rebranding, sondern markierte einen Paradigmenwechsel der On-Device-KI von „Mobile First“ hin zu „Edge First“1. In diesem Kontext erlebt OCR (optische Zeichenerkennung) als eine der praktischsten Anwendungen von On-Device-KI eine stille Revolution.
Das PaddleOCR-Team von Baidu veröffentlichte 2025 PP-OCRv5, ein einheitliches OCR-Modell, das mehrere Sprachen wie vereinfachtes Chinesisch, traditionelles Chinesisch, Englisch und Japanisch unterstützt2. Die mobile Version ist nur etwa 70 MB groß, kann aber in einem einzigen Modell 18.383 Zeichen erkennen. Hinter dieser Zahl steht das Zusammenspiel zweier tiefer neuronaler Netze: Detektion und Erkennung.
Das Problem ist jedoch: PP-OCRv5 wurde auf dem PaddlePaddle-Framework trainiert, während die ausgereifteste Inferenz-Engine auf Android-Geräten LiteRT ist. Wie überbrückt man diese Kluft?
Beginnen wir mit der Modellkonvertierung und lüften Schritt für Schritt den technischen Schleier von On-Device-OCR.
flowchart TB subgraph E2E["End-to-End OCR Prozess"] direction TB
subgraph Input["Eingabe"] IMG[Originalbild<br/>Beliebige Größe] end
subgraph Detection["Textdetektion - DBNet"] DET_PRE[Vorverarbeitung<br/>Resize 640x640<br/>ImageNet Normalize] DET_INF[DBNet Inferenz<br/>~45ms GPU] DET_POST[Nachverarbeitung<br/>Binarisierung - Konturen - Rotierte Rechtecke] end
subgraph Recognition["Texterkennung - SVTRv2"] REC_CROP[Perspektivischer Zuschnitt<br/>48xW Adaptive Breite] REC_INF[SVTRv2 Inferenz<br/>~15ms/Zeile GPU] REC_CTC[CTC-Dekodierung<br/>Duplikate zusammenführen + Blanks entfernen] end
subgraph Output["Ausgabe"] RES[OCR-Ergebnisse<br/>Text + Konfidenz + Position] end end
IMG --> DET_PRE --> DET_INF --> DET_POST DET_POST -->|N Textboxen| REC_CROP REC_CROP --> REC_INF --> REC_CTC --> RESModellkonvertierung: Die lange Reise von PaddlePaddle zu TFLite
Die Fragmentierung von Deep-Learning-Frameworks ist ein Schmerzpunkt der Branche. PyTorch, TensorFlow, PaddlePaddle, ONNX – jedes Framework hat sein eigenes Modellformat und seine eigene Operator-Implementierung. ONNX (Open Neural Network Exchange) versucht, eine universelle Zwischenrepräsentation zu sein, aber die Realität ist oft ernüchternder als das Ideal.
Der Pfad der Modellkonvertierung für PP-OCRv5 sieht wie folgt aus:
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/>Operator-Zerlegung] end
subgraph TFLite["LiteRT Format"] TM[model.tflite<br/>FP16 Quantized] end
PM -->|paddle2onnx| OM OM -->|HardSigmoid Zerlegung<br/>Resize-Modus Anpassung| GS GS -->|onnx2tf| TMDieser Pfad scheint einfach, birgt aber Tücken.
Die erste Hürde: Operator-Kompatibilität von paddle2onnx
paddle2onnx ist das offizielle Konvertierungstool von PaddlePaddle. Theoretisch kann es PaddlePaddle-Modelle in das ONNX-Format konvertieren. PP-OCRv5 verwendet jedoch einige spezielle Operatoren, deren Mapping in ONNX nicht eins-zu-eins erfolgt.
paddle2onnx --model_dir PP-OCRv5_mobile_det \ --model_filename inference.json \ --params_filename inference.pdiparams \ --save_file ocr_det_v5.onnx \ --opset_version 14Ein wichtiges Detail: Der Dateiname des PP-OCRv5-Modells ist inference.json anstelle des traditionellen inference.pdmodel. Dies ist eine Änderung im Modellformat neuerer PaddlePaddle-Versionen, über die viele Entwickler stolpern3.
Die zweite Hürde: HardSigmoid und GPU-Kompatibilität
Das konvertierte ONNX-Modell enthält den HardSigmoid-Operator. Dieser Operator ist mathematisch definiert als:
Wobei und .
Das Problem: Der GPU Delegate von LiteRT unterstützt HardSigmoid nicht. Wenn ein Modell nicht unterstützte Operatoren enthält, fällt der GPU Delegate für den gesamten Subgraphen auf die CPU zurück (Fallback), was zu massiven Leistungseinbußen führt.
Die Lösung besteht darin, HardSigmoid in Basis-Operatoren zu zerlegen. Mit der Bibliothek onnx-graphsurgeon können wir Operationen auf Ebene des Berechnungsgraphen durchführen:
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 graphDer Schlüssel dieser Zerlegung liegt darin, dass Mul, Add und Clip Operatoren sind, die vom LiteRT GPU Delegate vollständig unterstützt werden. Nach der Zerlegung kann der gesamte Subgraph kontinuierlich auf der GPU ausgeführt werden, wodurch der Overhead für den Datentransfer zwischen CPU und GPU vermieden wird.
TIP
Warum nicht direkt den Trainingscode des Modells ändern? Weil die Gradientenberechnung von HardSigmoid beim Training anders ist als bei Clip. Die Zerlegung sollte nur in der Inferenzphase erfolgen, um die numerische Stabilität während des Trainings zu wahren.
Die dritte Hürde: Koordinatentransformationsmodus des Resize-Operators
Der Resize-Operator in ONNX hat ein Attribut coordinate_transformation_mode, das bestimmt, wie Ausgabekoordinaten auf Eingabekoordinaten abgebildet werden. PP-OCRv5 verwendet den Modus half_pixel, aber die Unterstützung für diesen Modus im LiteRT GPU Delegate ist begrenzt.
Die Änderung auf den Modus asymmetric verbessert die GPU-Kompatibilität:
for node in graph.nodes: if node.op == "Resize": node.attrs["coordinate_transformation_mode"] = "asymmetric"WARNING
Diese Änderung kann zu minimalen numerischen Differenzen führen. In praktischen Tests war der Einfluss auf die OCR-Genauigkeit vernachlässigbar, aber bei anderen Aufgaben sollte dies sorgfältig evaluiert werden.
Der letzte Schritt: onnx2tf und FP16-Quantisierung
onnx2tf ist ein Tool zur Konvertierung von ONNX-Modellen in das TFLite-Format. FP16-Quantisierung (Floating Point mit halber Genauigkeit) ist eine gängige Wahl für On-Device-Deployments. Sie halbiert die Modellgröße bei akzeptablem Genauigkeitsverlust und nutzt die FP16-Recheneinheiten mobiler GPUs.
onnx2tf -i ocr_det_v5_fixed.onnx -o converted_det \ -b 1 -ois x:1,3,640,640 -nDer Parameter -ois legt die statische Form (Shape) der Eingabe fest. Statische Shapes sind entscheidend für die GPU-Beschleunigung; dynamische Shapes würden dazu führen, dass das GPU-Programm bei jeder Inferenz neu kompiliert werden muss, was die Performance massiv beeinträchtigt.
Textdetektion: Die differenzierbare Binarisierung von DBNet
Das Detektionsmodul von PP-OCRv5 basiert auf DBNet (Differentiable Binarization Network)4. Traditionelle Textdetektionsmethoden verwenden einen festen Schwellenwert für die Binarisierung. Die Innovation von DBNet besteht darin, dass das Netzwerk selbst lernt, den optimalen Schwellenwert für jedes Pixel zu bestimmen.
flowchart TB subgraph DBNet["DBNet Architektur"] direction TB IMG[Eingabebild<br/>H x W x 3] BB[Backbone<br/>MobileNetV3] FPN[FPN Feature Pyramid<br/>Multiskalen-Fusion]
subgraph Heads["Zwei-Zweig-Ausgabe"] PH[Wahrscheinlichkeitskarte<br/>P: H x W x 1] TH[Schwellenwertkarte<br/>T: H x W x 1] end
DB["Differenzierbare Binarisierung<br/>B = sigmoid k * P-T"] end
IMG --> BB --> FPN FPN --> PH FPN --> TH PH --> DB TH --> DBStandard-Binarisierung vs. Differenzierbare Binarisierung
Die Standard-Binarisierung ist eine Sprungfunktion:
Diese Funktion ist nicht differenzierbar und kann daher nicht mittels Backpropagation für ein End-to-End-Training genutzt werden. DBNet schlägt eine Approximationsfunktion vor:
Wobei die Wahrscheinlichkeitskarte ist, die Schwellenwertkarte (vom Netzwerk gelernt) und ein Verstärkungsfaktor (beim Training auf 50 gesetzt).
TIP
Diese Formel ist im Wesentlichen eine Sigmoid-Funktion, bei der die Eingabe zu wird. Wenn groß genug ist, nähert sich ihr Verhalten einer Sprungfunktion an, bleibt aber differenzierbar.
Technische Implementierung des Nachverarbeitungsprozesses
Im Projekt PPOCRv5-Android ist der Nachverarbeitungsprozess in postprocess.cpp implementiert. Der Kernprozess umfasst:
flowchart LR subgraph Input["Modellausgabe"] PM[Wahrscheinlichkeitskarte P<br/>640 x 640] end
subgraph Binary["Binarisierung"] BT[Schwellenwertfilter<br/>threshold=0.1] BM[Binärbild<br/>640 x 640] end
subgraph Contour["Konturerkennung"] DS[4x Downsampling<br/>160 x 160] CC[Zusammenhangskomponenten-Analyse<br/>BFS-Traversierung] BD[Extraktion von Grenzpunkten] end
subgraph Geometry["Geometrische Berechnung"] CH[Konvexe Hülle<br/>Graham Scan] RR[Rotating Calipers<br/>Kleinstes umschreibendes Rechteck] UC[Unclip-Erweiterung<br/>ratio=1.5] end
subgraph Output["Ausgabe"] TB[RotatedRect<br/>center, size, angle] end
PM --> BT --> BM BM --> DS --> CC --> BD BD --> CH --> RR --> UC --> TBIm tatsächlichen Code zeigt die Methode TextDetector::Impl::Detect den vollständigen Detektionsprozess:
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_; // ... }}Der Schlüssel dieses Prozesses liegt im „kleinsten umschreibenden rotierten Rechteck“. Im Gegensatz zu achsenparallelen Bounding Boxes können rotierte Rechtecke Text in jedem beliebigen Winkel eng umschließen, was für geneigten Text in natürlichen Szenen entscheidend ist.
Unclip: Der Expansionsalgorithmus für Textboxen
Die von DBNet ausgegebenen Textbereiche sind normalerweise etwas kleiner als der tatsächliche Text, da das Netzwerk den „Kernbereich“ des Textes lernt. Um die vollständigen Textgrenzen zu erhalten, muss eine Expansion (Unclip) auf die erkannten Polygone angewendet werden.
Das mathematische Prinzip von Unclip basiert auf der Umkehroperation des Vatti-Polygon-Clipping-Algorithmus. Für ein gegebenes Polygon und eine Expansionsdistanz gilt für das expandierte Polygon :
Wobei die Fläche des Polygons ist, der Umfang und das Expansionsverhältnis (normalerweise auf 1,5 gesetzt).
In postprocess.cpp implementiert die Funktion UnclipBox diese Logik:
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;}Diese vereinfachte Version geht davon aus, dass die Textbox ein Rechteck ist. Für komplexere Polygone müsste die vollständige Clipper-Bibliothek für Polygon-Offsets verwendet werden:
// 完整的多边形 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 hat sich für die vereinfachte rechteckige Expansion anstelle des vollständigen Polygon-Offsets entschieden. Die Gründe dafür sind:
- Die meisten Textboxen sind annähernd rechteckig.
- Die vollständige Clipper-Bibliothek würde die Binärgröße erheblich erhöhen.
- Die Performance der vereinfachten Version ist besser.
Texterkennung: SVTRv2 und CTC-Dekodierung
Wenn die Detektion bedeutet „zu finden, wo der Text ist“, dann bedeutet die Erkennung „zu lesen, was der Text ist“. Das Erkennungsmodul von PP-OCRv5 basiert auf SVTRv2 (Scene Text Recognition with Visual Transformer v2)5.
Architektur-Innovationen von SVTRv2
SVTRv2 weist im Vergleich zum Vorgänger SVTR drei wesentliche Verbesserungen auf:
flowchart TB subgraph SVTRv2["SVTRv2 Architektur"] direction TB
subgraph Encoder["Visueller Encoder"] PE[Patch Embedding<br/>4x4 Faltung]
subgraph Mixing["Mixing Attention Block x12"] LA[Local Attention<br/>7x7 Fenster] GA[Global Attention<br/>Globales Rezeptives Feld] FFN[Feed Forward<br/>MLP] end end
subgraph Decoder["CTC Decoder"] FC[Fully Connected Layer<br/>D -> 18384] SM[Softmax] CTC[CTC Decode] end end
PE --> LA --> GA --> FFN FFN --> FC --> SM --> CTC-
Hybrid-Attention-Mechanismus: Abwechselnde Nutzung von lokaler Attention (Erfassung von Strichdetails) und globaler Attention (Verständnis der Zeichenstruktur). Die lokale Attention nutzt ein gleitendes 7x7-Fenster, wodurch die Rechenkomplexität von auf sinkt.
-
Multiskalen-Feature-Fusion: Anders als die einheitliche Auflösung von ViT verwendet SVTRv2 unterschiedliche Feature-Map-Auflösungen in verschiedenen Tiefen, ähnlich der Pyramidenstruktur von CNNs.
-
Semantic Guidance Module: Am Ende des Encoders wurde ein leichtgewichtiger semantischer Zweig hinzugefügt, der dem Modell hilft, semantische Beziehungen zwischen Zeichen zu verstehen, anstatt nur visuelle Merkmale zu nutzen.
Diese Verbesserungen ermöglichen es SVTRv2, bei gleichbleibender Einfachheit der CTC-Dekodierung eine Genauigkeit zu erreichen, die mit Attention-basierten Methoden vergleichbar ist6.
Warum CTC statt Attention?
Für die Texterkennung gibt es zwei Hauptparadigmen:
- CTC (Connectionist Temporal Classification): Betrachtet die Erkennung als Sequenz-Labeling-Problem, wobei die Ausgabe an der Eingabe ausgerichtet ist.
- Attention-based Decoder: Nutzt einen Attention-Mechanismus, um die Ausgabe Zeichen für Zeichen zu generieren.
Attention-Methoden sind oft genauer, aber CTC-Methoden sind einfacher und schneller. Der Beitrag von SVTRv2 besteht darin, durch die Verbesserung des visuellen Encoders die Genauigkeit von CTC-Methoden auf das Niveau von Attention-Methoden zu heben oder diese sogar zu übertreffen6.
Der Kern der CTC-Dekodierung ist das „Zusammenführen von Duplikaten“ und das „Entfernen von Blanks“:
flowchart LR subgraph Input["Modellausgabe"] 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["Zusammenführen"] M["blank, H, blank, e, l, o"] end
subgraph Remove["Blanks entfernen"] R["H, e, l, o"] end
subgraph Output["Ausgabe"] O["Helo - Fehler"] end
L --> A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9 A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9 --> Merge --> Remove --> OutputMoment, hier gibt es ein Problem. Wenn der Originaltext „Hello“ lautet, wurden die beiden ‘l’ fälschlicherweise zusammengeführt. Die Lösung von CTC lautet: Einfügen eines Blank-Tokens zwischen identischen Zeichen.
Korrekte Kodierung: [blank, H, e, l, blank, l, o]Dekodierungsergebnis: "Hello"NEON-optimierte CTC-Dekodierung
Die CTC-Dekodierung in PPOCRv5-Android nutzt NEON-optimiertes Argmax. In 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); // ... 最终比较}Für ein Argmax über 18.384 Kategorien bringt die NEON-Optimierung eine etwa 3-fache Beschleunigung.
Mathematisches Prinzip der CTC-Loss-Funktion und Dekodierung
Die Kernidee von CTC ist: Gegeben eine Eingabesequenz und alle möglichen Ausrichtungspfade , berechne die Wahrscheinlichkeit der Zielsequenz :
Wobei eine „Many-to-One-Mapping-Funktion“ ist, die den Pfad auf die Ausgabesequenz abbildet (durch Zusammenführen von Duplikaten und Entfernen von Blanks).
Bei der Inferenz verwenden wir Greedy Decoding anstelle eines vollständigen 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;}Die Zeitkomplexität des Greedy Decoding beträgt , wobei die Anzahl der Zeitschritte und die Anzahl der Kategorien ist. Für PP-OCRv5 gilt und , was etwa 1,5 Millionen Vergleiche pro Dekodierung bedeutet. Deshalb ist die NEON-Optimierung so wichtig.
TIP
Beam Search kann die Dekodierungsgenauigkeit erhöhen, aber der Rechenaufwand ist um den Faktor höher (wobei die Beam-Breite ist). Auf mobilen Geräten ist Greedy Decoding meist die bessere Wahl.
Das Zeichenlexikon: Die Herausforderung von 18.383 Zeichen
PP-OCRv5 unterstützt 18.383 Zeichen, darunter:
- Häufige Zeichen in vereinfachtem Chinesisch
- Häufige Zeichen in traditionellem Chinesisch
- Englische Buchstaben und Zahlen
- Japanische Hiragana und Katakana
- Gängige Satzzeichen und Sonderzeichen
Dieses Lexikon ist in der Datei keys_v5.txt gespeichert, ein Zeichen pro Zeile. Bei der CTC-Dekodierung haben die vom Modell ausgegebenen Logits die Form [1, T, 18384], wobei T die Anzahl der Zeitschritte ist und 18384 = 18383 Zeichen + 1 Blank-Token.
LiteRT C++ API: Die moderne Schnittstelle nach dem Refactoring 2024
PPOCRv5-Android nutzt die nach dem Refactoring 2024 eingeführte C++ API von LiteRT, die ein moderneres Interface-Design bietet. Im Vergleich zur traditionellen TFLite C API bietet die neue API eine bessere Typsicherheit und Ressourcenverwaltung.
Vergleich: Alte vs. Neue API
Das LiteRT-Refactoring 2024 brachte signifikante Änderungen an der API:
| Feature | Alte API (TFLite) | Neue API (LiteRT) |
|---|---|---|
| Namespace | tflite:: | litert:: |
| Fehlerbehandlung | Rückgabe von TfLiteStatus Enum | Rückgabe vom Typ Expected<T> |
| Speicherverwaltung | Manuell | Automatisch via RAII |
| Delegate-Konfiguration | Verteilte APIs | Einheitliche Options-Klasse |
| Tensor-Zugriff | Pointer + manueller Cast | Typsicherer TensorBuffer |
Der Hauptvorteil der neuen API liegt in der Typsicherheit und der automatischen Ressourcenverwaltung. Beispiel Fehlerbehandlung:
// 旧 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); // 自动管理生命周期Umgebung und Modellinitialisierung
In text_detector.cpp sieht der Initialisierungsprozess wie folgt aus:
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: Der Schlüssel zur Zero-Copy-Inferenz
Der Managed Tensor Buffer von LiteRT ist entscheidend für eine performante Inferenz. Er erlaubt es dem GPU Delegate, direkt auf den Buffer zuzugreifen, ohne dass ein Datentransfer zwischen CPU und GPU nötig ist:
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;}Die Vorteile dieses Designs sind:
- Zero-Copy-Inferenz: Der GPU Delegate greift direkt auf den Buffer zu.
- Automatische Speicherverwaltung:
OwnHandle::kYesstellt sicher, dass der Buffer freigegeben wird, wenn das C++ Objekt zerstört wird. - Typsicherheit: Prüfung der Tensortypen zur Kompilierzeit.
GPU-Beschleunigung: Wahl und Abwägung von OpenCL
LiteRT bietet verschiedene Optionen zur Hardwarebeschleunigung:
flowchart TB subgraph Delegates["LiteRT Delegate Ökosystem"] 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 Optimiert] end
subgraph Hardware["Hardware-Mapping"] direction TB ADRENO[Adreno GPU<br/>Qualcomm] MALI[Mali GPU<br/>ARM] NPU[NPU/DSP<br/>Herstellerspezifisch] CPU[ARM CPU<br/>NEON] end
GPU_CL --> ADRENO GPU_CL --> MALI GPU_GL --> ADRENO GPU_GL --> MALI NNAPI --> NPU XNN --> CPU| Beschleuniger | Backend | Vorteile | Nachteile |
|---|---|---|---|
| GPU | OpenCL | Breite Unterstützung, gute Performance | Keine Standard-Android-Komponente |
| GPU | OpenGL ES | Standard-Android-Komponente | Performance schlechter als OpenCL |
| NPU | NNAPI | Höchste Performance | Schlechte Gerätekompatibilität |
| CPU | XNNPACK | Breiteste Kompatibilität | Niedrigste Performance |
PPOCRv5-Android hat OpenCL als primäres Beschleunigungs-Backend gewählt. Google veröffentlichte 2020 das OpenCL-Backend für TFLite, das auf Adreno-GPUs eine etwa 2-fache Beschleunigung gegenüber dem OpenGL ES-Backend erreicht7.
Die Vorteile von OpenCL ergeben sich aus mehreren Aspekten:
- Design-Fokus: OpenCL wurde von Grund auf für allgemeine Berechnungen (GPGPU) entwickelt, während OpenGL eine Grafik-API ist, die erst später Support für Compute Shader erhielt.
- Constant Memory: Der Constant Memory von OpenCL ist hocheffizient für den Zugriff auf Gewichte in neuronalen Netzen.
- FP16-Support: OpenCL unterstützt nativ Floating Point mit halber Genauigkeit, während der OpenGL-Support erst später kam.
OpenCL hat jedoch einen entscheidenden Nachteil: Es ist keine Standardkomponente von Android. Die Qualität der OpenCL-Implementierungen variiert je nach Hersteller, und manche Geräte unterstützen es gar nicht.
OpenCL vs. OpenGL ES: Tiefer Performance-Vergleich
Um die Vorteile von OpenCL zu verstehen, müssen wir die GPU-Architektur betrachten. Beispiel Qualcomm Adreno 640:
flowchart TB subgraph Adreno["Adreno 640 Architektur"] 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["Speicherhierarchie"] L1[L1 Cache<br/>16KB pro SP] L2[L2 Cache<br/>1MB Shared] GMEM[Global Memory<br/>LPDDR4X] end
subgraph Special["Spezialeinheiten"] TMU[Texture Unit<br/>Bilineare Interpolation] CONST[Constant Cache<br/>Gewichtsbeschleunigung] end end
ALU1 --> L1 ALU2 --> L1 L1 --> L2 --> GMEM TMU --> L1 CONST --> ALU1 & ALU2Die Performance-Vorteile von OpenCL resultieren aus:
| Feature | OpenCL | OpenGL ES Compute |
|---|---|---|
| Constant Memory | Native Unterstützung, Hardware-beschleunigt | Muss via UBO simuliert werden |
| Workgroup Size | Flexibel konfigurierbar | Limitiert durch Shader-Modell |
| Memory Barriers | Feingranulare Kontrolle | Grobgranular |
| FP16-Berechnung | cl_khr_fp16 Extension | Benötigt mediump Präzision |
| Debugging-Tools | Snapdragon Profiler | Eingeschränkter Support |
Bei Faltungsoperationen (Convolutions) sind Gewichte normalerweise Konstanten. OpenCL kann diese in den Constant Memory laden und von Broadcast-Optimierungen auf Hardware-Ebene profitieren. OpenGL ES muss Gewichte als Uniform Buffer Objects (UBO) übergeben, was den Overhead beim Speicherzugriff erhöht.
NOTE
Seit Android 7.0 schränkt Google das direkte Laden von OpenCL-Bibliotheken durch Apps ein. Der GPU Delegate von LiteRT umgeht dies jedoch, indem er die OpenCL-Implementierung des Systems dynamisch via dlopen lädt. Deshalb muss der GPU Delegate die Verfügbarkeit von OpenCL zur Laufzeit prüfen.
Graceful Fallback-Strategie
PPOCRv5-Android implementiert eine Strategie für einen eleganten Rückfall (Fallback):
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;}Diese Strategie stellt sicher, dass die App auf jedem Gerät läuft, wenn auch mit unterschiedlicher Performance.
Native Ebene: C++ und NEON-Optimierung
Warum C++ statt Kotlin?
Die Antwort ist einfach: Performance. Die Bildvorverarbeitung umfasst eine Vielzahl von Operationen auf Pixelebene, deren Overhead auf der JVM inakzeptabel wäre. Wichtiger noch: C++ kann direkt ARM NEON SIMD-Instruktionen nutzen, um vektorisierte Berechnungen durchzuführen.
NEON: Der SIMD-Befehlssatz von ARM
NEON ist die SIMD-Erweiterung (Single Instruction, Multiple Data) für ARM-Prozessoren. Sie erlaubt es, mit einem Befehl mehrere Datenelemente gleichzeitig zu verarbeiten.
flowchart LR subgraph NEON["128-bit NEON Register"] direction TB F4["4x float32"] I8["8x int16"] B16["16x int8"] end
subgraph Operations["Vektorisierte Operationen"] direction TB LD["vld1q_f32<br/>Lädt 4 floats"] SUB["vsubq_f32<br/>4-fache parallele Subtraktion"] MUL["vmulq_f32<br/>4-fache parallele Multiplikation"] ST["vst1q_f32<br/>Speichert 4 floats"] end
subgraph Speedup["Performance-Steigerung"] S1["Skalar: 4 Befehle"] S2["NEON: 1 Befehl"] S3["Theoretischer Speedup: 4x"] end
F4 --> LD LD --> SUB --> MUL --> ST ST --> S3PPOCRv5-Android nutzt NEON-Optimierungen an mehreren kritischen Stellen. Beispiel Binarisierung (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}Die entscheidenden Optimierungspunkte dieses Codes:
- Batch-Loading:
vld1q_f32lädt 4 Floats gleichzeitig und reduziert so die Anzahl der Speicherzugriffe. - Vektorisierter Vergleich:
vcgtq_f32vergleicht 4 Werte gleichzeitig und generiert eine Maske. - Typ-Narrowing:
vmovn_u32komprimiert 32-Bit-Ergebnisse auf 16-Bit und schließlich auf 8-Bit.
Im Vergleich zur skalaren Implementierung bringt die NEON-Optimierung eine 3- bis 4-fache Beschleunigung8.
NEON-Implementierung der ImageNet-Normalisierung
Die Bildnormalisierung ist ein entscheidender Schritt der Vorverarbeitung. Die ImageNet-Standardisierung nutzt folgende Formel:
Wobei und (RGB-Kanäle).
In image_utils.cpp sieht die NEON-optimierte Normalisierung so aus:
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}Wichtige Optimierungstechniken in diesem Code:
- Vorkalkulierte Konstanten: Umwandlung von
(x - mean) / stdinx * scale + bias, um Divisionen zur Laufzeit zu vermeiden. - Fused Multiply-Add:
vmlaq_f32führt Multiplikation und Addition in einem einzigen Befehl aus. - De-Interleaving Load:
vld4q_u8trennt RGBA automatisch in vier Kanäle auf. - Interleaving Store:
vst3q_f32schreibt die drei RGB-Kanäle verschachtelt in den Speicher zurück.
Null OpenCV-Abhängigkeit
Viele OCR-Projekte hängen von OpenCV für die Bildvorverarbeitung ab. OpenCV ist mächtig, bringt aber eine enorme Paketgröße mit sich; die OpenCV-Bibliothek für Android ist oft über 10 MB groß.
PPOCRv5-Android hat den Weg der „Null OpenCV-Abhängigkeit“ gewählt. Alle Bildvorverarbeitungsoperationen sind in image_utils.cpp in reinem C++ implementiert:
- Bilineare Interpolations-Skalierung: Manuell implementiert mit NEON-Unterstützung.
- Normalisierung: ImageNet-Standardisierung und Erkennungs-Normalisierung.
- Perspektivische Transformation: Zuschnitt von Textbereichen in beliebigen Winkeln aus dem Originalbild.
NEON-Implementierung der bilinearen Interpolation
Die bilineare Interpolation ist der Kernalgorithmus der Bildskalierung. Für eine Quellkoordinate berechnet die bilineare Interpolation den Zielpixelwert:
Wobei , und die Werte der vier benachbarten Pixel sind.
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
Die NEON-Optimierung der bilinearen Interpolation ist komplex, da die Adressen der vier benachbarten Pixel nicht kontinuierlich sind. Eine effizientere Methode ist die separable bilineare Interpolation: Zuerst wird in horizontaler Richtung interpoliert, dann in vertikaler. Dies nutzt die Cache-Lokalität besser aus.
Diese Entscheidung bedeutet mehr Entwicklungsaufwand, aber die Vorteile sind signifikant:
- APK-Größe um ca. 10 MB reduziert.
- Volle Kontrolle über die Vorverarbeitungslogik für Optimierungen.
- Vermeidung von Kompatibilitätsproblemen mit OpenCV-Versionen.
Perspektivische Transformation: Vom rotierten Rechteck zur Standard-Textzeile
Das Texterkennungsmodell erwartet als Eingabe horizontale Bilder von Textzeilen. Die erkannten Textboxen können jedoch rotierte Rechtecke in jedem beliebigen Winkel sein. Die perspektivische Transformation ist dafür verantwortlich, diese rotierten Bereiche „geradezuziehen“.
In text_recognizer.cpp implementiert die Methode CropAndRotate diese Funktion:
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); } }}Wichtige Optimierungen dieser Implementierung:
- Adaptive Breite: Dynamische Anpassung der Ausgabebreite basierend auf dem Seitenverhältnis der Textbox, um übermäßiges Strecken oder Stauchen zu vermeiden.
- Affine Transformations-Approximation: Für Textboxen, die annähernd Parallelogramme sind, wird eine affine Transformation anstelle einer perspektivischen verwendet, um Rechenaufwand zu sparen.
- NEON Bilineare Interpolation: Sampling und Normalisierung erfolgen in einem Durchgang, was Speicherzugriffe reduziert.
JNI: Die Brücke zwischen Kotlin und C++
JNI (Java Native Interface) ist die Brücke für die Kommunikation zwischen Kotlin/Java und C++. JNI-Aufrufe verursachen jedoch Overhead; häufige sprachübergreifende Aufrufe können die Performance erheblich beeinträchtigen.
Das Designprinzip von PPOCRv5-Android lautet: Minimierung der JNI-Aufrufe. Der gesamte OCR-Prozess benötigt nur einen einzigen JNI-Aufruf:
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-Pointer übergeben
Note over N,G: Native Ebene erledigt die gesamte Arbeit
N->>N: Bildvorverarbeitung NEON N->>G: Textdetektions-Inferenz G-->>N: Wahrscheinlichkeitskarte N->>N: Nachverarbeitung Konturerkennung
loop Jede Textbox N->>N: Perspektivischer Zuschnitt N->>G: Texterkennungs-Inferenz G-->>N: Logits N->>N: CTC-Dekodierung end
N-->>J: OCR-Ergebnisse J-->>K: List OcrResultIn ppocrv5_jni.cpp zeigt die zentrale Funktion nativeProcess dieses Design:
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 对象数组返回 // ...}Dieses Design vermeidet den Overhead für das Hin- und Herschieben von Daten zwischen Detektion und Erkennung.
Architektur-Design: Modularität und Testbarkeit
Die Architektur von PPOCRv5-Android folgt dem Prinzip der „Separation of Concerns“:
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/>Orchestrierung]
subgraph Detection["Textdetektion"] TD[TextDetector] DB[DBNet FP16] end
subgraph Recognition["Texterkennung"] TR[TextRecognizer] SVTR[SVTRv2 + CTC] end
subgraph Preprocessing["Bildverarbeitung"] IP[ImagePreprocessor<br/>NEON Optimiert] PP[PostProcessor<br/>Konturerkennung] 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| CPUDie Vorteile dieser Schichtenarchitektur sind:
- UI-Schicht: Reines Kotlin/Compose, fokussiert auf Benutzerinteraktion.
- ViewModel-Schicht: Verwaltung von Status und Geschäftslogik.
- Native-Schicht: Hochleistungsberechnungen, vollständig von der UI entkoppelt.
Jede Schicht kann unabhängig getestet werden. Die Native-Schicht kann mit Google Test für Unit-Tests genutzt werden, die ViewModel-Schicht mit JUnit + MockK.
Kapselung in der Kotlin-Schicht
In OcrEngine.kt bietet die Kotlin-Schicht eine prägnante 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 } }}Vorteile dieses Designs:
- Nutzung des
Result-Typs zur Behandlung von Initialisierungsfehlern. - Implementierung des
Closeable-Interfaces für die automatische Ressourcenfreigabe inuse-Blöcken. - Modell-Dateien werden automatisch von Assets in das Cache-Verzeichnis kopiert.
Kaltstart-Optimierung
Die erste Inferenz (Kaltstart) ist normalerweise viel langsamer als nachfolgende Aufrufe (Warmstart). Die Gründe dafür sind:
- Der GPU Delegate muss OpenCL-Programme kompilieren.
- Modellgewichte müssen vom CPU-Speicher in den GPU-Speicher übertragen werden.
- Diverse Caches müssen aufgewärmt werden.
PPOCRv5-Android nutzt einen Warm-up-Mechanismus, um Kaltstart-Probleme zu mildern:
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_));}Speicher-Alignment-Optimierung
In TextDetector::Impl werden alle vorallozierten Buffer mit 64-Byte-Alignment ausgerichtet:
// 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_;Ein 64-Byte-Alignment entspricht der Cache-Line-Größe moderner ARM-Prozessoren. Ausgerichtete Speicherzugriffe vermeiden Cache-Line-Splits und erhöhen die Effizienz des Speicherzugriffs.
Memory Pool und Objekt-Wiederverwendung
Häufige Speicherallokationen und -freigaben sind Performance-Killer. PPOCRv5-Android nutzt eine Vorallokationsstrategie, bei der bei der Initialisierung der gesamte benötigte Speicher auf einmal reserviert wird:
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; }};Vorteile dieses Designs:
- Vermeidung von Speicherfragmentierung: Alle großen Blöcke werden beim Start alloziert, zur Laufzeit entsteht keine Fragmentierung.
- Reduzierung von Systemaufrufen:
mallockann Systemaufrufe auslösen; die Vorallokation vermeidet diesen Overhead. - Cache-Freundlichkeit: Kontinuierlich alloziierter Speicher liegt mit höherer Wahrscheinlichkeit physisch beieinander, was die Cache-Hit-Rate erhöht.
Branch-Prediction-Optimierung
Moderne CPUs nutzen Branch Prediction (Sprungvorhersage), um die Pipeline-Effizienz zu steigern. Eine falsche Vorhersage führt zu einem Pipeline-Flush, was 10-20 Taktzyklen kostet.
Auf Hot-Paths nutzen wir __builtin_expect, um dem Compiler Hinweise zu geben:
// 大多数像素不会超过阈值if (__builtin_expect(prob_map[i] > kBinaryThreshold, 0)) { binary_map_[i] = 255;} else { binary_map_[i] = 0;}__builtin_expect(expr, val) teilt dem Compiler mit, dass der Wert von expr sehr wahrscheinlich val sein wird. Der Compiler passt das Code-Layout entsprechend an und platziert „unwahrscheinliche“ Zweige weiter weg vom Hauptpfad.
Loop Unrolling und Software-Pipelining
Bei rechenintensiven Schleifen kann manuelles Unrolling (Schleifenentrollung) den Overhead reduzieren und mehr Instruktions-Parallelität freilegen:
// 未展开版本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;}Nach dem Entrollen kann die CPU mehrere unabhängige Multiply-Add-Befehle gleichzeitig ausführen und die multiplen Ausführungseinheiten der superskalaren Architektur voll ausnutzen.
Prefetch-Optimierung
In der inneren Schleife der perspektivischen Transformation wird __builtin_prefetch genutzt, um Daten der nächsten Zeile vorab zu laden:
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); } } // ... 处理当前行}Diese Optimierung kann Speicherlatenzen verbergen; während die aktuelle Zeile verarbeitet wird, befinden sich die Daten der nächsten Zeile bereits im L1-Cache.
Technische Details der Nachverarbeitung
Zusammenhangskomponenten-Analyse und Konturerkennung
In postprocess.cpp implementiert die Funktion FindContours eine effiziente Zusammenhangskomponenten-Analyse:
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;}Wichtige Optimierungspunkte:
- 4x Downsampling: Reduzierung der 640x640 Binärkarte auf 160x160, was den Rechenaufwand um den Faktor 16 verringert.
- Grenzpunkterkennung: Nur Grenzpixel werden gespeichert, nicht die gesamte Zusammenhangskomponente.
- Limitierung der maximalen Konturenanzahl:
kMaxContours = 100, um Performance-Probleme in Extremsituationen zu vermeiden.
Konvexe Hülle und Rotating Calipers-Algorithmus
Die Berechnung des kleinsten umschreibenden rotierten Rechtecks erfolgt in zwei Schritten: Zuerst wird die konvexe Hülle berechnet, dann wird mittels des Rotating Calipers-Algorithmus das umschreibende Rechteck mit der minimalen Fläche gesucht.
Graham Scan-Algorithmus für die konvexe Hülle
Graham Scan ist ein klassischer Algorithmus zur Berechnung der konvexen Hülle mit einer Zeitkomplexität von :
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) { // 共线时,距离近s的排前面 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-Algorithmus
Der Rotating Calipers-Algorithmus traversiert jede Kante der konvexen Hülle und berechnet die Fläche des umschreibenden Rechtecks mit dieser Kante als Basis:
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;}Die entscheidende Erkenntnis bei Rotating Calipers ist, dass sich die drei „Messschieber“ (rechts, oben, links) beim Rotieren der Basis nur monoton vorwärts bewegen. Daher beträgt die Gesamtzeitkomplexität und nicht .
Kleinstes umschreibendes rotiertes Rechteck
Die Funktion MinAreaRect nutzt den Rotating Calipers-Algorithmus zur Berechnung des kleinsten umschreibenden rotierten Rechtecks:
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;}Die Zeitkomplexität dieses Algorithmus beträgt (konvexe Hülle) + (Rotating Calipers), wobei die Anzahl der Grenzpunkte ist. Durch Subsampling wird auf unter 200 begrenzt, was die Echtzeit-Performance sicherstellt.
Echtzeit-Kamera-OCR: CameraX und Frame-Analyse
Die Herausforderung bei Echtzeit-OCR besteht darin, jede Frame so schnell wie möglich zu verarbeiten und gleichzeitig eine flüssige Vorschau beizubehalten.
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["Frame-Analyse-Prozess"] direction TB IP[ImageProxy<br/>YUV_420_888] BM[Bitmap-Konvertierung<br/>RGBA_8888] JNI[JNI-Aufruf<br/>Einmalig sprachübergreifend] end
subgraph Native["Native OCR"] direction TB DET[TextDetector<br/>~45ms GPU] REC[TextRecognizer<br/>~15ms/Zeile] RES[OCR-Ergebnisse] end
subgraph UI["UI-Update"] direction TB VM[ViewModel<br/>StateFlow] OV[ResultOverlay<br/>Canvas-Zeichnung] end
CP --> PV CP --> IA IA --> IP --> BM --> JNI JNI --> DET --> REC --> RES RES --> VM --> OVCameraX ImageAnalysis
CameraX ist die Kamera-Bibliothek von Android Jetpack. Sie bietet den ImageAnalysis-UseCase, der uns erlaubt, Kamera-Frames in Echtzeit zu analysieren:
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()}Die wichtigste Konfiguration ist STRATEGY_KEEP_ONLY_LATEST: Wenn der Analyzer langsamer verarbeitet als die Kamera Frames liefert, werden alte Frames verworfen und nur die neueste Frame behalten. Dies stellt die Aktualität der OCR-Ergebnisse sicher.
Abwägung zwischen Framerate und Latenz
Auf Geräten mit GPU-Beschleunigung (mein aktuelles Snapdragon 870 scheint Probleme zu haben, den Großteil der Berechnungen an die GPU zu delegieren) kann PPOCRv5-Android theoretisch hohe Verarbeitungsgeschwindigkeiten erreichen. Das bedeutet jedoch nicht, dass wir jede Frame verarbeiten sollten.
Stellen Sie sich folgendes Szenario vor: Ein Benutzer richtet die Kamera auf einen Textabschnitt; der Textinhalt ändert sich in kurzer Zeit nicht. Wenn wir jede Frame eine vollständige OCR durchführen, verschwenden wir massiv Rechenressourcen.
Eine Optimierungsstrategie ist die „Änderungserkennung“: OCR wird nur ausgelöst, wenn sich das Bild signifikant verändert hat. Dies kann durch den Vergleich von Histogrammen oder Feature-Points aufeinanderfolgender Frames erreicht werden.
Zukunftsausblick: NPU und Quantisierung
Die Zukunft der On-Device-KI liegt in NPUs (Neural Processing Units). Im Vergleich zu GPUs sind NPUs speziell für die Inferenz neuronaler Netze konzipiert und bieten eine höhere Energieeffizienz.
Die Herausforderung bei NPUs ist jedoch die Fragmentierung. Jeder Chiphersteller hat seine eigene NPU-Architektur und sein eigenes SDK:
- Qualcomm: Hexagon DSP + AI Engine
- MediaTek: APU
- Samsung: Exynos NPU
- Google: Tensor TPU
Androids NNAPI (Neural Networks API) versucht, eine einheitliche Abstraktionsschicht zu bieten, aber die tatsächlichen Ergebnisse variieren stark. Viele NPU-Funktionen können nicht über NNAPI angesprochen werden, weshalb Entwickler oft auf herstellerspezifische SDKs zurückgreifen müssen.
INT8-Quantisierung: Eine unvollendete Schlacht
FP16-Quantisierung ist eine konservative Wahl, die fast keine Genauigkeit einbüßt. Wenn man jedoch nach extremer Performance strebt, ist INT8-Quantisierung der nächste Schritt.
Die INT8-Quantisierung komprimiert Gewichte und Aktivierungen von 32-Bit-Float auf 8-Bit-Integer, was theoretisch Folgendes ermöglicht:
- 4-fache Modellkompression.
- 2- bis 4-fache Inferenzbeschleunigung (je nach Hardware).
- Auf Qualcomm Hexagon DSPs ist eine mehr als 10-fache Beschleunigung möglich.
Diese Verlockung war zu groß. So begann ich eine lange Reise in die INT8-Quantisierung.
Erster Versuch: Kalibrierung mit synthetischen Daten
Die INT8-Quantisierung benötigt einen Kalibrierungsdatensatz, um die Quantisierungsparameter (Scale und Zero Point) zu bestimmen. Anfangs war ich nachlässig und verwendete zufällig generierte „textähnliche“ Bilder:
# 错误示范:使用随机噪声做校准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_valDas Ergebnis war katastrophal. Das Modell gab nur Nullen aus:
Raw FLOAT32 output range: min=0.0000, max=0.0000Prob map stats: min=0.0000, max=0.0000, mean=0.000000Das Quantisierungstool berechnete basierend auf dem Zufallsrauschen falsche Quantisierungsparameter, was dazu führte, dass die Aktivierungswerte realer Bilder abgeschnitten wurden.
Zweiter Versuch: Kalibrierung mit echten Bildern
Ich wechselte zu echten Bildern aus OCR-Datensätzen: ICDAR2015, TextOCR, offizielle PaddleOCR-Beispielbilder. Gleichzeitig implementierte ich eine Letterbox-Vorverarbeitung, um sicherzustellen, dass die Bildverteilung bei der Kalibrierung der bei der Inferenz entsprach:
def letterbox_image(image, target_size): """保持长宽比缩放,不足部分填充灰色""" ih, iw = image.shape[:2] h, w = target_size scale = min(w / iw, h / ih) # ... 居中粘贴Das Modell gab keine Nullen mehr aus, aber die Erkennungsergebnisse waren immer noch Zeichensalat.
Dritter Versuch: Korrektur der Typbehandlung auf C++-Ebene
Ich stellte fest, dass der C++-Code bei der Verarbeitung von INT8-Eingaben Probleme hatte. Das INT8-Modell erwartet rohe Pixelwerte (0-255), während ich noch die ImageNet-Normalisierung (Mittelwert abziehen, durch Standardabweichung teilen) durchführte.
if (input_is_int8_) { // INT8 模型:直接输入原始像素,归一化已融合到第一层 dst[i * 3 + 0] = static_cast<int8_t>(src[i * 4 + 0] ^ 0x80);} else { // FP32 模型:需要手动归一化 // (pixel - mean) / std}Gleichzeitig implementierte ich die Logik zum dynamischen Auslesen der Quantisierungsparameter, anstatt sie hart zu kodieren:
bool GetQuantizationParams(LiteRtTensor tensor, float* scale, int32_t* zero_point) { LiteRtQuantization quant; LiteRtGetTensorQuantization(tensor, &quant); // ...}Endergebnis: Ein Kompromiss
Nach tagelangem Debugging funktionierte das INT8-Modell immer noch nicht korrekt. Die Probleme könnten liegen bei:
- Der Quantisierungsimplementierung von onnx2tf: PP-OCRv5 nutzt spezielle Operator-Kombinationen, die onnx2tf bei der Quantisierung eventuell nicht korrekt verarbeitet hat.
- Den Ausgabeeigenschaften von DBNet: DBNet gibt eine Wahrscheinlichkeitskarte aus, deren Wertebereich zwischen 0 und 1 liegt. Die INT8-Quantisierung reagiert besonders empfindlich auf solch kleine Wertebereiche.
- Fehlerakkumulation in mehrstufigen Modellen: Detektion und Erkennung sind hintereinandergeschaltet; Quantisierungsfehler akkumulieren und verstärken sich.
Lassen Sie uns Punkt 2 genauer analysieren. Die Ausgabe von DBNet durchläuft eine Sigmoid-Aktivierung, wodurch der Wertebereich auf [0, 1] komprimiert wird. Die INT8-Quantisierung nutzt folgende Formel:
Für Werte im Bereich [0, 1] kann ein falsch gewählter Scale dazu führen, dass die quantisierten Werte nur einen winzigen Teil des INT8-Bereichs [-128, 127] einnehmen, was zu massiven Genauigkeitsverlusten führt.
# 假设 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 # 精度丢失!Der Schwellenwert von DBNet liegt normalerweise bei 0,1-0,3. Das bedeutet, dass eine Vielzahl aussagekräftiger Wahrscheinlichkeitswerte (0,1-0,3) nach der Quantisierung nur durch 25 Ganzzahlen (13-38) dargestellt werden können – die Auflösung ist völlig unzureichend.
WARNING
Die INT8-Quantisierung von PP-OCRv5 ist eine bekannte Herausforderung. Wenn Sie es ebenfalls versuchen, stellen Sie sicher, dass das FP32-Modell korrekt funktioniert, bevor Sie Quantisierungsprobleme untersuchen. Alternativ könnten Sie das offizielle Paddle Lite-Framework von PaddlePaddle in Betracht ziehen, das PaddleOCR besser unterstützt.
Quantization-Aware Training: Die richtige Lösung
Wenn INT8-Quantisierung zwingend erforderlich ist, ist der richtige Weg Quantization-Aware Training (QAT) anstelle von Post-Training Quantization (PTQ).
QAT simuliert Quantisierungsfehler während des Trainingsprozesses, sodass das Modell lernt, sich an Darstellungen mit niedriger Präzision anzupassen:
# 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)Leider bietet das PP-OCRv5-Team keine offiziellen QAT-trainierten Modelle an. Das bedeutet, um ein qualitativ hochwertiges INT8-Modell zu erhalten, müsste man ein QAT-Training von Grund auf durchführen, was den Rahmen dieses Projekts sprengen würde.
Letztendlich entschied ich mich für einen Kompromiss: Nutzung von FP16-Quantisierung + GPU-Beschleunigung anstelle von INT8 + DSP.
Der Preis dieser Entscheidung:
- Die Modellgröße ist doppelt so groß wie bei INT8.
- Der extrem niedrige Stromverbrauch des Hexagon DSP kann nicht genutzt werden.
- Die Inferenzgeschwindigkeit ist 2-3 mal langsamer als das theoretische Optimum.
Der Gewinn jedoch:
- Die Modellgenauigkeit ist fast identisch mit FP32.
- Die Entwicklungszeit wurde massiv verkürzt.
- Die Code-Komplexität wurde reduziert.
Das Wesen des Engineerings ist die Abwägung. Manchmal ist „gut genug“ wichtiger als „theoretisch optimal“.
Fazit
Von PaddlePaddle zu TFLite, von DBNet zu SVTRv2, von OpenCL zu NEON – die technische Praxis von On-Device-OCR umfasst Wissen aus den Bereichen Deep Learning, Compiler, GPU-Programmierung, mobile Entwicklung und mehr.
Die wichtigste Lehre aus diesem Projekt ist: On-Device-KI bedeutet nicht nur, „ein Modell auf das Handy zu schieben“. Es erfordert:
- Ein tiefes Verständnis der Modellarchitektur für eine korrekte Konvertierung.
- Kenntnis der Hardware-Eigenschaften zur optimalen Nutzung von Beschleunigern.
- Beherrschung der Systemprogrammierung für performanten nativen Code.
- Fokus auf die User Experience, um die Balance zwischen Performance und Stromverbrauch zu finden.
PPOCRv5-Android ist ein Open-Source-Projekt, das zeigt, wie moderne OCR-Modelle in realen mobilen Anwendungen eingesetzt werden können. Ich hoffe, dieser Artikel dient Entwicklern mit ähnlichen Anforderungen als nützliche Referenz.
Wie Google bei der Veröffentlichung von LiteRT sagte: „Maximum performance, simplified.“9 Das Ziel von On-Device-KI ist nicht Komplexität, sondern das Komplexe einfach zu machen.
Nachwort
Ehrlich gesagt, habe ich mich (beruflich und privat) seit mindestens zwei Jahren von Android distanziert. Dies ist das erste Mal, dass ich auf einem GitHub-Zweitaccount (meinen Hauptaccount habe ich Kollegen überlassen, um meine Entschlossenheit zum Abschied zu zeigen) eine ausgereiftere Bibliothek veröffentliche.
In den letzten Jahren lag mein Arbeitsschwerpunkt nicht im Android-Bereich. Die Details dazu kann ich hier nicht vertiefen, aber vielleicht ergibt sich später die Gelegenheit. Jedenfalls werde ich wohl kaum noch große Beiträge im Android-Ökosystem leisten.
Die Veröffentlichung dieses Projekts entsprang meinem persönlichen Interesse während der Entwicklung eines frühen On-Device-Tools für Android – wobei OCR nur ein kleiner Teil der Basistechnologie ist. Dieses Tool wird später (hoffentlich bald) ebenfalls vollständig Open Source gehen.
Wie dem auch sei, danke, dass Sie bis hierhin gelesen haben. Ich würde mich freuen, wenn Sie meinem Repository einen Star geben. Vielen Dank!
Referenzen
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 ↩