$ Zum Hauptinhalt springen
Übersicht
0% · Noch ...
0%
On-Device OCR in der Praxis: Native Bereitstellung von PP-OCRv5 auf Android
$ cat mobile/ppocrv5-android.md

# On-Device OCR in der Praxis: Native Bereitstellung von PP-OCRv5 auf Android

Autor:
Datum: 29. Dezember 2025 um 20:17
Lesezeit: Voraussichtlich 23 Min. Lesezeit
mobile/ppocrv5-android.md

Diese Seite wurde von einer KI übersetzt. Im Falle von Unstimmigkeiten beziehen Sie sich bitte auf den Originalbeitrag.

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 --> RES

Modellkonvertierung: 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| TM

Dieser 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.

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

Ein 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:

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

Wobei α=0.2\alpha = 0.2 und β=0.5\beta = 0.5.

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 gs
import numpy as np
def decompose_hardsigmoid(graph: gs.Graph) -> gs.Graph:
"""
将 HardSigmoid 分解为 GPU 友好的基本算子
HardSigmoid(x) = max(0, min(1, alpha*x + beta))
分解为: Mul -> Add -> Clip
"""
for node in graph.nodes:
if node.op == "HardSigmoid":
# 获取 HardSigmoid 的参数
alpha = node.attrs.get("alpha", 0.2)
beta = node.attrs.get("beta", 0.5)
input_tensor = node.inputs[0]
output_tensor = node.outputs[0]
# 创建常量张量
alpha_const = gs.Constant(
name=f"{node.name}_alpha",
values=np.array([alpha], dtype=np.float32)
)
beta_const = gs.Constant(
name=f"{node.name}_beta",
values=np.array([beta], dtype=np.float32)
)
# 创建中间变量
mul_out = gs.Variable(name=f"{node.name}_mul_out")
add_out = gs.Variable(name=f"{node.name}_add_out")
# 构建分解后的子图: x -> Mul(alpha) -> Add(beta) -> Clip(0,1)
mul_node = gs.Node(
op="Mul",
inputs=[input_tensor, alpha_const],
outputs=[mul_out]
)
add_node = gs.Node(
op="Add",
inputs=[mul_out, beta_const],
outputs=[add_out]
)
clip_node = gs.Node(
op="Clip",
inputs=[add_out],
outputs=[output_tensor],
attrs={"min": 0.0, "max": 1.0}
)
# 替换原节点
graph.nodes.remove(node)
graph.nodes.extend([mul_node, add_node, clip_node])
graph.cleanup().toposort()
return graph

Der 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.

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

Der 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 --> DB

Standard-Binarisierung vs. Differenzierbare Binarisierung

Die Standard-Binarisierung ist eine Sprungfunktion:

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

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:

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

Wobei PP die Wahrscheinlichkeitskarte ist, TT die Schwellenwertkarte (vom Netzwerk gelernt) und kk ein Verstärkungsfaktor (beim Training auf 50 gesetzt).

TIP

Diese Formel ist im Wesentlichen eine Sigmoid-Funktion, bei der die Eingabe zu PTP - T wird. Wenn kk 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 --> TB

Im 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 PP und eine Expansionsdistanz dd gilt für das expandierte Polygon PP':

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

Wobei AA die Fläche des Polygons ist, LL der Umfang und rr 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
  1. 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 O(n2)O(n^2) auf O(n×49)O(n \times 49) sinkt.

  2. 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.

  3. 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:

  1. CTC (Connectionist Temporal Classification): Betrachtet die Erkennung als Sequenz-Labeling-Problem, wobei die Ausgabe an der Eingabe ausgerichtet ist.
  2. 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 --> Output

Moment, 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 XX und alle möglichen Ausrichtungspfade π\pi, berechne die Wahrscheinlichkeit der Zielsequenz YY:

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

Wobei B\mathcal{B} eine „Many-to-One-Mapping-Funktion“ ist, die den Pfad π\pi auf die Ausgabesequenz YY 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 O(T×C)O(T \times C), wobei TT die Anzahl der Zeitschritte und CC die Anzahl der Kategorien ist. Für PP-OCRv5 gilt T80T \approx 80 und C=18384C = 18384, 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 kk höher (wobei kk 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:

FeatureAlte API (TFLite)Neue API (LiteRT)
Namespacetflite::litert::
FehlerbehandlungRückgabe von TfLiteStatus EnumRückgabe vom Typ Expected<T>
SpeicherverwaltungManuellAutomatisch via RAII
Delegate-KonfigurationVerteilte APIsEinheitliche Options-Klasse
Tensor-ZugriffPointer + manueller CastTypsicherer 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:

  1. Zero-Copy-Inferenz: Der GPU Delegate greift direkt auf den Buffer zu.
  2. Automatische Speicherverwaltung: OwnHandle::kYes stellt sicher, dass der Buffer freigegeben wird, wenn das C++ Objekt zerstört wird.
  3. 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
BeschleunigerBackendVorteileNachteile
GPUOpenCLBreite Unterstützung, gute PerformanceKeine Standard-Android-Komponente
GPUOpenGL ESStandard-Android-KomponentePerformance schlechter als OpenCL
NPUNNAPIHöchste PerformanceSchlechte Gerätekompatibilität
CPUXNNPACKBreiteste KompatibilitätNiedrigste 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:

  1. 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.
  2. Constant Memory: Der Constant Memory von OpenCL ist hocheffizient für den Zugriff auf Gewichte in neuronalen Netzen.
  3. 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 & ALU2

Die Performance-Vorteile von OpenCL resultieren aus:

FeatureOpenCLOpenGL ES Compute
Constant MemoryNative Unterstützung, Hardware-beschleunigtMuss via UBO simuliert werden
Workgroup SizeFlexibel konfigurierbarLimitiert durch Shader-Modell
Memory BarriersFeingranulare KontrolleGrobgranular
FP16-Berechnungcl_khr_fp16 ExtensionBenötigt mediump Präzision
Debugging-ToolsSnapdragon ProfilerEingeschrä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):

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

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 --> S3

PPOCRv5-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_f32 lädt 4 Floats gleichzeitig und reduziert so die Anzahl der Speicherzugriffe.
  • Vektorisierter Vergleich: vcgtq_f32 vergleicht 4 Werte gleichzeitig und generiert eine Maske.
  • Typ-Narrowing: vmovn_u32 komprimiert 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:

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

Wobei μ=[0.485,0.456,0.406]\mu = [0.485, 0.456, 0.406] und σ=[0.229,0.224,0.225]\sigma = [0.229, 0.224, 0.225] (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:

  1. Vorkalkulierte Konstanten: Umwandlung von (x - mean) / std in x * scale + bias, um Divisionen zur Laufzeit zu vermeiden.
  2. Fused Multiply-Add: vmlaq_f32 führt Multiplikation und Addition in einem einzigen Befehl aus.
  3. De-Interleaving Load: vld4q_u8 trennt RGBA automatisch in vier Kanäle auf.
  4. Interleaving Store: vst3q_f32 schreibt 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 (x,y)(x, y) berechnet die bilineare Interpolation den Zielpixelwert:

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

Wobei α=xx\alpha = x - \lfloor x \rfloor, β=yy\beta = y - \lfloor y \rfloor und fijf_{ij} 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:

  1. APK-Größe um ca. 10 MB reduziert.
  2. Volle Kontrolle über die Vorverarbeitungslogik für Optimierungen.
  3. 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:

  1. Adaptive Breite: Dynamische Anpassung der Ausgabebreite basierend auf dem Seitenverhältnis der Textbox, um übermäßiges Strecken oder Stauchen zu vermeiden.
  2. Affine Transformations-Approximation: Für Textboxen, die annähernd Parallelogramme sind, wird eine affine Transformation anstelle einer perspektivischen verwendet, um Rechenaufwand zu sparen.
  3. 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 OcrResult

In ppocrv5_jni.cpp zeigt die zentrale Funktion nativeProcess dieses Design:

JNIEXPORT jobjectArray JNICALL
Java_me_fleey_ppocrv5_ocr_OcrEngine_nativeProcess(
JNIEnv *env, jobject thiz, jlong handle, jobject bitmap) {
auto *engine = reinterpret_cast<ppocrv5::OcrEngine *>(handle);
// 锁定 Bitmap 像素
void *pixels = nullptr;
AndroidBitmap_lockPixels(env, bitmap, &pixels);
// 一次 JNI 调用完成所有 OCR 工作
auto results = engine->Process(
static_cast<const uint8_t *>(pixels),
static_cast<int>(bitmap_info.width),
static_cast<int>(bitmap_info.height),
static_cast<int>(bitmap_info.stride));
AndroidBitmap_unlockPixels(env, bitmap);
// 构造 Java 对象数组返回
// ...
}

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| CPU

Die Vorteile dieser Schichtenarchitektur sind:

  1. UI-Schicht: Reines Kotlin/Compose, fokussiert auf Benutzerinteraktion.
  2. ViewModel-Schicht: Verwaltung von Status und Geschäftslogik.
  3. 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:

  1. Nutzung des Result-Typs zur Behandlung von Initialisierungsfehlern.
  2. Implementierung des Closeable-Interfaces für die automatische Ressourcenfreigabe in use-Blöcken.
  3. 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:

  1. Der GPU Delegate muss OpenCL-Programme kompilieren.
  2. Modellgewichte müssen vom CPU-Speicher in den GPU-Speicher übertragen werden.
  3. 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 alignment
alignas(64) std::vector<uint8_t> resized_buffer_;
alignas(64) std::vector<float> normalized_buffer_;
alignas(64) std::vector<uint8_t> binary_map_;
alignas(64) std::vector<float> prob_map_;

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:

  1. Vermeidung von Speicherfragmentierung: Alle großen Blöcke werden beim Start alloziert, zur Laufzeit entsteht keine Fragmentierung.
  2. Reduzierung von Systemaufrufen: malloc kann Systemaufrufe auslösen; die Vorallokation vermeidet diesen Overhead.
  3. 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:

  1. 4x Downsampling: Reduzierung der 640x640 Binärkarte auf 160x160, was den Rechenaufwand um den Faktor 16 verringert.
  2. Grenzpunkterkennung: Nur Grenzpixel werden gespeichert, nicht die gesamte Zusammenhangskomponente.
  3. 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 O(nlogn)O(n \log n):

std::vector<Point> ConvexHull(std::vector<Point> points) {
if (points.size() < 3) return points;
// 1. 找到最下方的点(y 最小,x 最小)
auto pivot = std::min_element(points.begin(), points.end(),
[](const Point& a, const Point& b) {
return a.y < b.y || (a.y == b.y && a.x < b.x);
});
std::swap(points[0], *pivot);
Point p0 = points[0];
// 2. 按极角排序
std::sort(points.begin() + 1, points.end(),
[&p0](const Point& a, const Point& b) {
float cross = CrossProduct(p0, a, b);
if (std::abs(cross) < 1e-6f) {
// 共线时,距离近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 O(n)O(n) und nicht O(n2)O(n^2).

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 O(nlogn)O(n \log n) (konvexe Hülle) + O(n)O(n) (Rotating Calipers), wobei nn die Anzahl der Grenzpunkte ist. Durch Subsampling wird nn 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 --> OV

CameraX 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.9
for _ in range(num_lines):
gray_val = np.random.uniform(0.05, 0.3)
img[y:y+line_h, x:x+line_w] = gray_val

Das Ergebnis war katastrophal. Das Modell gab nur Nullen aus:

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

Das 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:

  1. Der Quantisierungsimplementierung von onnx2tf: PP-OCRv5 nutzt spezielle Operator-Kombinationen, die onnx2tf bei der Quantisierung eventuell nicht korrekt verarbeitet hat.
  2. 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.
  3. 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:

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

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:

  1. Ein tiefes Verständnis der Modellarchitektur für eine korrekte Konvertierung.
  2. Kenntnis der Hardware-Eigenschaften zur optimalen Nutzung von Beschleunigern.
  3. Beherrschung der Systemprogrammierung für performanten nativen Code.
  4. 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

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

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

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

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

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

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

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

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

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

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

Lizenz

Sofern nicht anders angegeben, stehen alle Beiträge und Inhalte dieses Blogs unter der Creative Commons Namensnennung - Nicht kommerziell - Weitergabe unter gleichen Bedingungen 4.0 International Lizenz (CC BY-NC-SA 4.0)

✓ Kopiert!