$ 跳转到主要内容
概览
0% · 剩余 ...
0%
端侧 OCR 的实践:PP-OCRv5 在 Android 上的原生部署
$ cat mobile/ppocrv5-android.md

# 端侧 OCR 的实践:PP-OCRv5 在 Android 上的原生部署

作者:
日期: 2025年12月29日 20:17
阅读时间: 预计 23 分钟
mobile/ppocrv5-android.md

说明

本篇博文

  • 封面:基于 Google Nano Banana 2 生成,无版权所有。
  • 项目源代码:已开源至 GitHub,请访问 PPOCRv5-Android 获取。

声明:

笔者(Fleey)非 AI 领域从业者,纯属兴趣使然。文中如有疏漏与错误,望读者谅解与及时指正!

开篇

2024 年,Google 将 TensorFlow Lite 更名为 LiteRT,这不仅是一次品牌重塑,更标志着端侧 AI 从「移动优先」向「边缘优先」的范式转变1。在这个背景下,OCR(光学字符识别)作为最具实用价值的端侧 AI 应用之一,正在经历一场静默的革命。

百度的 PaddleOCR 团队在 2025 年发布了 PP-OCRv5,这是一个支持简体中文、繁体中文、英文、日文等多语言的统一 OCR 模型2。它的移动端版本仅有约 70MB,却能在单一模型中实现 18,383 个字符的识别。这个数字背后,是检测与识别两个深度神经网络的协同工作。

但问题在于:PP-OCRv5 基于 PaddlePaddle 框架训练,而 Android 设备上最成熟的推理引擎是 LiteRT。如何跨越这道鸿沟?

让我们从模型转换开始,逐步揭开端侧 OCR 的工程面纱。

flowchart TB
subgraph E2E["端到端 OCR 流程"]
direction TB
subgraph Input["输入"]
IMG[原始图像<br/>任意尺寸]
end
subgraph Detection["文本检测 - DBNet"]
DET_PRE[预处理<br/>Resize 640x640<br/>ImageNet Normalize]
DET_INF[DBNet 推理<br/>~45ms GPU]
DET_POST[后处理<br/>二值化 - 轮廓 - 旋转矩形]
end
subgraph Recognition["文本识别 - SVTRv2"]
REC_CROP[透视变换裁剪<br/>48xW 自适应宽度]
REC_INF[SVTRv2 推理<br/>~15ms/行 GPU]
REC_CTC[CTC 解码<br/>合并重复 + 移除空白]
end
subgraph Output["输出"]
RES[OCR Results<br/>文本 + 置信度 + 位置]
end
end
IMG --> DET_PRE --> DET_INF --> DET_POST
DET_POST -->|N 个文本框| REC_CROP
REC_CROP --> REC_INF --> REC_CTC --> RES

模型转换:从 PaddlePaddle 到 TFLite 的漫长旅途

深度学习框架的碎片化是行业的痛点。PyTorch、TensorFlow、PaddlePaddle、ONNX,每个框架都有自己的模型格式和算子实现。ONNX(Open Neural Network Exchange)试图成为通用的中间表示,但现实往往比理想骨感。

PP-OCRv5 的模型转换路径如下:

flowchart LR
subgraph PaddlePaddle["PaddlePaddle Framework"]
PM[inference.json<br/>inference.pdiparams]
end
subgraph ONNX["ONNX Intermediate"]
OM[model.onnx<br/>opset 14]
end
subgraph Optimization["Graph Optimization"]
GS[onnx-graphsurgeon<br/>算子分解]
end
subgraph TFLite["LiteRT Format"]
TM[model.tflite<br/>FP16 Quantized]
end
PM -->|paddle2onnx| OM
OM -->|HardSigmoid 分解<br/>Resize 模式修改| GS
GS -->|onnx2tf| TM

这条路径看似简单,实则暗藏玄机。

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

paddle2onnx 是 PaddlePaddle 官方提供的模型转换工具。理论上,它能将 PaddlePaddle 模型转换为 ONNX 格式。但 PP-OCRv5 使用了一些特殊的算子,这些算子在 ONNX 中的映射并非一一对应。

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

这里有一个关键细节:PP-OCRv5 的模型文件名是 inference.json 而非传统的 inference.pdmodel。这是 PaddlePaddle 新版本的模型格式变化,许多开发者在这里踩坑3

第二道坎:HardSigmoid 与 GPU 兼容性

转换后的 ONNX 模型包含 HardSigmoid 算子。这个算子在数学上定义为:

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

其中 α=0.2\alpha = 0.2β=0.5\beta = 0.5

问题在于:LiteRT 的 GPU Delegate 不支持 HardSigmoid。当模型包含不支持的算子时,GPU Delegate 会将整个子图回退到 CPU 执行,这会导致严重的性能损失。

解决方案是将 HardSigmoid 分解为基本算子。使用 onnx-graphsurgeon 库,我们可以在计算图级别进行手术:

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

这种分解的关键在于:MulAddClip 都是 LiteRT GPU Delegate 完全支持的算子。分解后,整个子图可以在 GPU 上连续执行,避免了 CPU-GPU 数据传输的开销。

TIP

为什么不直接修改模型训练代码?因为 HardSigmoid 在训练时的梯度计算与 Clip 不同。分解只应在推理阶段进行,保持训练时的数值稳定性。

第三道坎:Resize 算子的坐标变换模式

ONNX 的 Resize 算子有一个 coordinate_transformation_mode 属性,它决定了如何将输出坐标映射到输入坐标。PP-OCRv5 使用的是 half_pixel 模式,但 LiteRT GPU Delegate 对这种模式的支持有限。

将其改为 asymmetric 模式可以获得更好的 GPU 兼容性:

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

WARNING

这种修改可能会导致微小的数值差异。在实际测试中,这种差异对 OCR 精度的影响可以忽略不计,但在其他任务中可能需要仔细评估。

最后一步:onnx2tf 与 FP16 量化

onnx2tf 是将 ONNX 模型转换为 TFLite 格式的工具。FP16(半精度浮点)量化是移动端部署的常见选择,它在精度损失可接受的前提下,将模型大小减半,并能利用移动 GPU 的 FP16 计算单元。

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

这里的 -ois 参数指定了输入的静态形状。静态形状对于 GPU 加速至关重要,动态形状会导致每次推理都需要重新编译 GPU 程序,严重影响性能。

文本检测:DBNet 的可微分二值化

PP-OCRv5 的检测模块基于 DBNet(Differentiable Binarization Network)4。传统的文本检测方法使用固定阈值进行二值化,而 DBNet 的创新在于:让网络自己学习每个像素的最优阈值。

flowchart TB
subgraph DBNet["DBNet 架构"]
direction TB
IMG[输入图像<br/>H x W x 3]
BB[Backbone<br/>MobileNetV3]
FPN[FPN 特征金字塔<br/>多尺度融合]
subgraph Heads["双分支输出"]
PH[概率图分支<br/>P: H x W x 1]
TH[阈值图分支<br/>T: H x W x 1]
end
DB["可微分二值化<br/>B = sigmoid k * P-T"]
end
IMG --> BB --> FPN
FPN --> PH
FPN --> TH
PH --> DB
TH --> DB

标准二值化 vs 可微分二值化

标准二值化是一个阶跃函数:

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

这个函数不可微,无法通过反向传播进行端到端训练。DBNet 提出了一个近似函数:

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

其中 PP 是概率图,TT 是阈值图(由网络学习),kk 是放大因子(训练时设为 50)。

TIP

这个公式本质上是一个 Sigmoid 函数,只是输入变成了 PTP - T。当 kk 足够大时,它的行为接近阶跃函数,但保持了可微性。

后处理流程的工程实现

在 PPOCRv5-Android 项目中,后处理流程在 postprocess.cpp 中实现。核心流程包括:

flowchart LR
subgraph Input["模型输出"]
PM[概率图 P<br/>640 x 640]
end
subgraph Binary["二值化"]
BT[阈值过滤<br/>threshold=0.1]
BM[二值图<br/>640 x 640]
end
subgraph Contour["轮廓检测"]
DS[4x 降采样<br/>160 x 160]
CC[连通域分析<br/>BFS 遍历]
BD[边界点提取]
end
subgraph Geometry["几何计算"]
CH[凸包计算<br/>Graham Scan]
RR[旋转卡壳<br/>最小外接矩形]
UC[Unclip 扩展<br/>ratio=1.5]
end
subgraph Output["输出"]
TB[RotatedRect<br/>center, size, angle]
end
PM --> BT --> BM
BM --> DS --> CC --> BD
BD --> CH --> RR --> UC --> TB

实际代码中,TextDetector::Impl::Detect 方法展示了完整的检测流程:

std::vector<RotatedRect> Detect(const uint8_t *image_data,
int width, int height, int stride,
float *detection_time_ms) {
// 1. 计算缩放比例
scale_x_ = static_cast<float>(width) / kDetInputSize;
scale_y_ = static_cast<float>(height) / kDetInputSize;
// 2. 双线性插值缩放到 640x640
image_utils::ResizeBilinear(image_data, width, height, stride,
resized_buffer_.data(), kDetInputSize, kDetInputSize);
// 3. ImageNet 标准化
PrepareFloatInput();
// 4. 推理
auto run_result = compiled_model_->Run(input_buffers_, output_buffers_);
// 5. 二值化
BinarizeOutput(prob_map, total_pixels);
// 6. 轮廓检测
auto contours = postprocess::FindContours(binary_map_.data(),
kDetInputSize, kDetInputSize);
// 7. 最小外接矩形 + Unclip
for (const auto &contour : contours) {
RotatedRect rect = postprocess::MinAreaRect(contour);
UnclipBox(rect, kUnclipRatio);
// 坐标映射回原图
rect.center_x *= scale_x_;
rect.center_y *= scale_y_;
// ...
}
}

这个流程的关键在于「最小外接旋转矩形」。与轴对齐的边界框不同,旋转矩形可以紧密贴合任意角度的文本,这对于自然场景中的倾斜文本至关重要。

Unclip:文本框的膨胀算法

DBNet 输出的文本区域通常比实际文本略小,因为网络学习的是文本的「核心区域」。为了获得完整的文本边界,需要对检测到的多边形进行膨胀(Unclip)操作。

Unclip 的数学原理基于 Vatti 多边形裁剪算法的逆操作。给定一个多边形 PP 和膨胀距离 dd,膨胀后的多边形 PP' 满足:

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

其中 AA 是多边形面积,LL 是周长,rr 是膨胀比例(通常设为 1.5)。

postprocess.cpp 中,UnclipBox 函数实现了这一逻辑:

void UnclipBox(RotatedRect &box, float unclip_ratio) {
// 计算膨胀距离
float area = box.width * box.height;
float perimeter = 2.0f * (box.width + box.height);
if (perimeter < 1e-6f) return; // 防止除零
// d = A * r / L
float distance = area * unclip_ratio / perimeter;
// 向外膨胀:宽高各增加 2d
box.width += 2.0f * distance;
box.height += 2.0f * distance;
}

这个简化版本假设文本框是矩形。对于更复杂的多边形,需要使用完整的 Clipper 库实现多边形偏移:

// 完整的多边形 Unclip(使用 Clipper 库)
ClipperLib::Path polygon;
for (const auto& pt : contour) {
polygon.push_back(ClipperLib::IntPoint(
static_cast<int>(pt.x * 1000), // 放大以保持精度
static_cast<int>(pt.y * 1000)
));
}
ClipperLib::ClipperOffset offset;
offset.AddPath(polygon, ClipperLib::jtRound, ClipperLib::etClosedPolygon);
ClipperLib::Paths solution;
offset.Execute(solution, distance * 1000); // 膨胀

NOTE

PPOCRv5-Android 选择了简化的矩形膨胀,而非完整的多边形偏移。这是因为:

  • 大多数文本框接近矩形
  • 完整的 Clipper 库会增加较多二进制体积
  • 简化版本的性能更好。

文本识别:SVTRv2 与 CTC 解码

如果说检测是「找到文字在哪里」,那么识别就是「读出文字是什么」。PP-OCRv5 的识别模块基于 SVTRv2(Scene Text Recognition with Visual Transformer v2)5

SVTRv2 的架构创新

SVTRv2 相比前代 SVTR 有三个关键改进:

flowchart TB
subgraph SVTRv2["SVTRv2 架构"]
direction TB
subgraph Encoder["视觉编码器"]
PE[Patch Embedding<br/>4x4 卷积]
subgraph Mixing["混合注意力块 x12"]
LA[Local Attention<br/>7x7 窗口]
GA[Global Attention<br/>全局感受野]
FFN[Feed Forward<br/>MLP]
end
end
subgraph Decoder["CTC 解码器"]
FC[全连接层<br/>D -> 18384]
SM[Softmax]
CTC[CTC Decode]
end
end
PE --> LA --> GA --> FFN
FFN --> FC --> SM --> CTC
  1. 混合注意力机制:交替使用局部注意力(捕捉笔画细节)和全局注意力(理解字符结构)。局部注意力使用 7x7 的滑动窗口,计算复杂度从 O(n2)O(n^2) 降到 O(n×49)O(n \times 49)

  2. 多尺度特征融合:不同于 ViT 的单一分辨率,SVTRv2 在不同深度使用不同的特征图分辨率,类似于 CNN 的金字塔结构。

  3. 语义引导模块(Semantic Guidance Module):在编码器末端添加了一个轻量级的语义分支,帮助模型理解字符的语义关系,而不仅仅是视觉特征。

这些改进使得 SVTRv2 在保持 CTC 解码简单性的同时,达到了与 Attention-based 方法相当的精度6

为什么是 CTC 而不是 Attention?

文本识别有两种主流范式:

  1. CTC(Connectionist Temporal Classification):将识别视为序列标注问题,输出与输入对齐
  2. Attention-based Decoder:使用注意力机制逐字符生成输出

Attention 方法通常精度更高,但 CTC 方法更简单、更快。SVTRv2 的贡献在于:通过改进视觉编码器,让 CTC 方法达到甚至超越 Attention 方法的精度6

CTC 解码的核心是「合并重复」和「移除空白」:

flowchart LR
subgraph Input["模型输出"]
L["Logits<br/>[T, 18384]"]
end
subgraph Argmax["Argmax NEON"]
A1["t=0: blank"]
A2["t=1: H"]
A3["t=2: H"]
A4["t=3: blank"]
A5["t=4: e"]
A6["t=5: l"]
A7["t=6: l"]
A8["t=7: l"]
A9["t=8: o"]
end
subgraph Merge["合并重复"]
M["blank, H, blank, e, l, o"]
end
subgraph Remove["移除空白"]
R["H, e, l, o"]
end
subgraph Output["输出"]
O["Helo - 错误"]
end
L --> A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9
A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9 --> Merge --> Remove --> Output

等等,这里有问题。如果原始文本是 “Hello”,两个 ‘l’ 被错误地合并了。CTC 的解决方案是:在重复字符之间插入 blank token。

正确编码: [blank, H, e, l, blank, l, o]
解码结果: "Hello"

NEON 优化的 CTC 解码

PPOCRv5-Android 的 CTC 解码使用 NEON 优化的 Argmax。在 text_recognizer.cpp 中:

inline void ArgmaxNeon8(const float *__restrict__ data, int size,
int &max_idx, float &max_val) {
if (size < 16) {
// 标量回退
max_idx = 0;
max_val = data[0];
for (int i = 1; i < size; ++i) {
if (data[i] > max_val) {
max_val = data[i];
max_idx = i;
}
}
return;
}
// NEON 向量化:一次处理 4 个 float
float32x4_t v_max = vld1q_f32(data);
int32x4_t v_idx = {0, 1, 2, 3};
int32x4_t v_max_idx = v_idx;
const int32x4_t v_four = vdupq_n_s32(4);
int i = 4;
for (; i + 4 <= size; i += 4) {
float32x4_t v_curr = vld1q_f32(data + i);
v_idx = vaddq_s32(v_idx, v_four);
// 向量化比较和条件选择
uint32x4_t cmp = vcgtq_f32(v_curr, v_max);
v_max = vbslq_f32(cmp, v_curr, v_max); // 选择较大值
v_max_idx = vbslq_s32(cmp, v_idx, v_max_idx); // 选择对应索引
}
// 水平归约:找到 4 个候选中的最大值
float max_vals[4];
int32_t max_idxs[4];
vst1q_f32(max_vals, v_max);
vst1q_s32(max_idxs, v_max_idx);
// ... 最终比较
}

对于 18,384 个类别的 Argmax,NEON 优化可以带来约 3 倍的加速。

CTC 损失函数与解码的数学原理

CTC 的核心思想是:给定输入序列 XX 和所有可能的对齐路径 π\pi,计算目标序列 YY 的概率:

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

其中 B\mathcal{B} 是「多对一映射函数」,它将路径 π\pi 映射到输出序列 YY(通过合并重复和移除空白)。

在推理时,我们使用贪心解码(Greedy Decoding)而非完整的 Beam Search:

std::string CTCGreedyDecode(const float* logits, int time_steps, int num_classes,
const std::vector<std::string>& dictionary) {
std::string result;
int prev_idx = -1; // 用于合并重复
for (int t = 0; t < time_steps; ++t) {
// 找到当前时间步的最大概率类别
int max_idx = 0;
float max_val = logits[t * num_classes];
for (int c = 1; c < num_classes; ++c) {
if (logits[t * num_classes + c] > max_val) {
max_val = logits[t * num_classes + c];
max_idx = c;
}
}
// CTC 解码规则:
// 1. 跳过 blank token (index 0)
// 2. 合并连续重复的字符
if (max_idx != 0 && max_idx != prev_idx) {
result += dictionary[max_idx - 1]; // -1 因为 blank 占用了 index 0
}
prev_idx = max_idx;
}
return result;
}

贪心解码的时间复杂度是 O(T×C)O(T \times C),其中 TT 是时间步数,CC 是类别数。对于 PP-OCRv5,T80T \approx 80C=18384C = 18384,每次解码需要约 150 万次比较。这就是为什么 NEON 优化如此重要。

TIP

Beam Search 可以提高解码精度,但计算量是贪心解码的 kk 倍(kk 是 beam width)。在移动端,贪心解码通常是更好的选择。

字符字典:18,383 个字符的挑战

PP-OCRv5 支持 18,383 个字符,包括:

  • 简体中文常用字
  • 繁体中文常用字
  • 英文字母和数字
  • 日文平假名、片假名
  • 常用标点符号和特殊字符

这个字典存储在 keys_v5.txt 文件中,每行一个字符。CTC 解码时,模型输出的 logits 形状为 [1, T, 18384],其中 T 是时间步数,18384 = 18383 个字符 + 1 个 blank token。

LiteRT C++ API:2024 年重构后的现代接口

PPOCRv5-Android 使用 LiteRT 2024 年重构后的 C++ API,这套 API 提供了更现代的接口设计。与传统的 TFLite C API 相比,新 API 具有更好的类型安全性和资源管理能力。

新旧 API 对比

LiteRT 2024 重构带来了显著的 API 变化:

特性旧 API (TFLite)新 API (LiteRT)
命名空间tflite::litert::
错误处理返回 TfLiteStatus 枚举返回 Expected<T> 类型
内存管理手动管理RAII 自动管理
Delegate 配置分散的 API统一的 Options
张量访问指针 + 手动类型转换类型安全的 TensorBuffer

新 API 的核心优势是类型安全和资源自动管理。以错误处理为例:

// 旧 API:需要手动检查每个返回值
TfLiteStatus status = TfLiteInterpreterAllocateTensors(interpreter);
if (status != kTfLiteOk) {
// 错误处理
}
// 新 API:使用 Expected 类型,支持链式调用
auto model_result = litert::CompiledModel::Create(env, model_path, options);
if (!model_result) {
LOGE(TAG, "Error: %s", model_result.Error().Message().c_str());
return false;
}
auto model = std::move(*model_result); // 自动管理生命周期

环境与模型初始化

text_detector.cpp 中,初始化流程如下:

bool Initialize(const std::string &model_path, AcceleratorType accelerator_type) {
// 1. 创建 LiteRT 环境
auto env_result = litert::Environment::Create({});
if (!env_result) {
LOGE(TAG, "Failed to create LiteRT environment: %s",
env_result.Error().Message().c_str());
return false;
}
env_ = std::move(*env_result);
// 2. 配置硬件加速器
auto options_result = litert::Options::Create();
auto hw_accelerator = ToLiteRtAccelerator(accelerator_type);
options.SetHardwareAccelerators(hw_accelerator);
// 3. 编译模型
auto model_result = litert::CompiledModel::Create(*env_, model_path, options);
if (!model_result) {
LOGW(TAG, "Failed to create CompiledModel with accelerator %d: %s",
static_cast<int>(accelerator_type),
model_result.Error().Message().c_str());
return false;
}
compiled_model_ = std::move(*model_result);
// 4. 调整输入张量形状
std::vector<int> input_dims = {1, kDetInputSize, kDetInputSize, 3};
compiled_model_->ResizeInputTensor(0, absl::MakeConstSpan(input_dims));
// 5. 创建托管 Buffer
CreateBuffersWithCApi();
return true;
}

Managed Tensor Buffer:零拷贝推理的关键

LiteRT 的 Managed Tensor Buffer 是实现高性能推理的关键。它允许 GPU Delegate 直接访问 Buffer,无需 CPU-GPU 数据传输:

bool CreateBuffersWithCApi() {
LiteRtCompiledModel c_model = compiled_model_->Get();
LiteRtEnvironment c_env = env_->Get();
// 获取输入 Buffer 需求
LiteRtTensorBufferRequirements input_requirements = nullptr;
LiteRtGetCompiledModelInputBufferRequirements(
c_model, /*signature_index=*/0, /*input_index=*/0,
&input_requirements);
// 获取张量类型信息
auto input_type = compiled_model_->GetInputTensorType(0, 0);
LiteRtRankedTensorType tensor_type =
static_cast<LiteRtRankedTensorType>(*input_type);
// 创建托管 Buffer
LiteRtTensorBuffer input_buffer = nullptr;
LiteRtCreateManagedTensorBufferFromRequirements(
c_env, &tensor_type, input_requirements, &input_buffer);
// 包装为 C++ 对象,自动管理生命周期
input_buffers_.push_back(
litert::TensorBuffer::WrapCObject(input_buffer,
litert::OwnHandle::kYes));
return true;
}

这种设计的优势在于:

  1. 零拷贝推理:GPU Delegate 可以直接访问 Buffer,无需 CPU-GPU 数据传输
  2. 自动内存管理:OwnHandle::kYes 确保 Buffer 在 C++ 对象析构时自动释放
  3. 类型安全:编译时检查张量类型匹配

GPU 加速:OpenCL 的选择与权衡

LiteRT 提供了多种硬件加速选项:

flowchart TB
subgraph Delegates["LiteRT Delegate 生态"]
direction TB
GPU_CL[GPU Delegate<br/>OpenCL Backend]
GPU_GL[GPU Delegate<br/>OpenGL ES Backend]
NNAPI[NNAPI Delegate<br/>Android HAL]
XNN[XNNPACK Delegate<br/>CPU Optimized]
end
subgraph Hardware["硬件映射"]
direction TB
ADRENO[Adreno GPU<br/>Qualcomm]
MALI[Mali GPU<br/>ARM]
NPU[NPU/DSP<br/>厂商特定]
CPU[ARM CPU<br/>NEON]
end
GPU_CL --> ADRENO
GPU_CL --> MALI
GPU_GL --> ADRENO
GPU_GL --> MALI
NNAPI --> NPU
XNN --> CPU
加速器后端优点缺点
GPUOpenCL广泛支持,性能好不是 Android 标准组件
GPUOpenGL ESAndroid 标准组件性能不如 OpenCL
NPUNNAPI最高性能设备兼容性差
CPUXNNPACK最广泛兼容性能最低

PPOCRv5-Android 选择了 OpenCL 作为主要加速后端。Google 在 2020 年发布了 TFLite 的 OpenCL 后端,相比 OpenGL ES 后端,它在 Adreno GPU 上实现了约 2 倍的加速7

OpenCL 的优势来自几个方面:

  1. 设计初衷:OpenCL 从一开始就为通用计算设计,而 OpenGL 是图形渲染 API,后来才添加了计算着色器支持
  2. 常量内存:OpenCL 的常量内存对神经网络的权重访问非常高效
  3. FP16 支持:OpenCL 原生支持半精度浮点,而 OpenGL 的支持较晚

但 OpenCL 有一个致命缺陷:它不是 Android 的标准组件。不同厂商的 OpenCL 实现质量参差不齐,有些设备甚至完全不支持。

OpenCL vs OpenGL ES:性能深度对比

为了理解 OpenCL 的优势,我们需要深入到 GPU 架构层面。以高通 Adreno 640 为例:

flowchart TB
subgraph Adreno["Adreno 640 架构"]
direction TB
subgraph SP["Shader Processors x2"]
ALU1[ALU Array<br/>256 FP32 / 512 FP16]
ALU2[ALU Array<br/>256 FP32 / 512 FP16]
end
subgraph Memory["内存层次"]
L1[L1 Cache<br/>16KB per SP]
L2[L2 Cache<br/>1MB Shared]
GMEM[Global Memory<br/>LPDDR4X]
end
subgraph Special["专用单元"]
TMU[Texture Unit<br/>双线性插值]
CONST[Constant Cache<br/>权重加速]
end
end
ALU1 --> L1
ALU2 --> L1
L1 --> L2 --> GMEM
TMU --> L1
CONST --> ALU1 & ALU2

OpenCL 的性能优势来自:

特性OpenCLOpenGL ES Compute
常量内存原生支持,硬件加速需要模拟,通过 UBO
工作组大小灵活配置受限于着色器模型
内存屏障细粒度控制粗粒度
FP16 计算cl_khr_fp16 扩展需要 mediump 精度
调试工具Snapdragon Profiler有限支持

在卷积运算中,权重通常是常量。OpenCL 可以将权重放入常量内存,享受硬件级别的广播优化。而 OpenGL ES 需要将权重作为 Uniform Buffer Object (UBO) 传递,增加了内存访问开销。

NOTE

Google 在 Android 7.0 后限制了应用直接加载 OpenCL 库。但 LiteRT 的 GPU Delegate 通过 dlopen 动态加载系统的 OpenCL 实现,绕过了这一限制。这也是为什么 GPU Delegate 需要在运行时检测 OpenCL 可用性。

优雅降级策略

PPOCRv5-Android 实现了一个优雅降级策略:

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

这种策略确保了应用在任何设备上都能运行,只是性能有所不同。

原生层:C++ 与 NEON 优化

为什么要用 C++ 而不是 Kotlin?

答案很简单:性能。图像预处理涉及大量的像素级操作,这些操作在 JVM 上的开销是不可接受的。更重要的是,C++ 可以直接使用 ARM NEON SIMD 指令,实现向量化计算。

NEON:ARM 的 SIMD 指令集

NEON 是 ARM 处理器的 SIMD(Single Instruction, Multiple Data)扩展。它允许一条指令同时处理多个数据元素。

flowchart LR
subgraph NEON["128-bit NEON Register"]
direction TB
F4["4x float32"]
I8["8x int16"]
B16["16x int8"]
end
subgraph Operations["向量化操作"]
direction TB
LD["vld1q_f32<br/>加载 4 个 float"]
SUB["vsubq_f32<br/>4 路并行减法"]
MUL["vmulq_f32<br/>4 路并行乘法"]
ST["vst1q_f32<br/>存储 4 个 float"]
end
subgraph Speedup["性能提升"]
S1["标量: 4 条指令"]
S2["NEON: 1 条指令"]
S3["理论加速: 4x"]
end
F4 --> LD
LD --> SUB --> MUL --> ST
ST --> S3

PPOCRv5-Android 在多个关键路径使用 NEON 优化。以二值化为例(text_detector.cpp):

void BinarizeOutput(const float *prob_map, int total_pixels) {
#if defined(__ARM_NEON) || defined(__ARM_NEON__)
const float32x4_t v_threshold = vdupq_n_f32(kBinaryThreshold);
const uint8x16_t v_255 = vdupq_n_u8(255);
const uint8x16_t v_0 = vdupq_n_u8(0);
int i = 0;
for (; i + 16 <= total_pixels; i += 16) {
// 一次处理 16 个像素
float32x4_t f0 = vld1q_f32(prob_map + i);
float32x4_t f1 = vld1q_f32(prob_map + i + 4);
float32x4_t f2 = vld1q_f32(prob_map + i + 8);
float32x4_t f3 = vld1q_f32(prob_map + i + 12);
// 向量化比较
uint32x4_t cmp0 = vcgtq_f32(f0, v_threshold);
uint32x4_t cmp1 = vcgtq_f32(f1, v_threshold);
uint32x4_t cmp2 = vcgtq_f32(f2, v_threshold);
uint32x4_t cmp3 = vcgtq_f32(f3, v_threshold);
// 窄化到 uint8
uint16x4_t n0 = vmovn_u32(cmp0);
uint16x4_t n1 = vmovn_u32(cmp1);
uint16x8_t n01 = vcombine_u16(n0, n1);
// ... 合并并存储
}
// 标量回退处理剩余像素
for (; i < total_pixels; ++i) {
binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0;
}
#else
// 纯标量实现
for (int i = 0; i < total_pixels; ++i) {
binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0;
}
#endif
}

这段代码的关键优化点:

  • 批量加载:vld1q_f32 一次加载 4 个 float,减少内存访问次数
  • 向量化比较:vcgtq_f32 同时比较 4 个值,生成掩码
  • 类型窄化:vmovn_u32 将 32 位结果压缩为 16 位,最终到 8 位

相比标量实现,NEON 优化可以带来 3-4 倍的加速8

ImageNet 归一化的 NEON 实现

图像归一化是预处理的关键步骤。ImageNet 标准化使用以下公式:

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

其中 μ=[0.485,0.456,0.406]\mu = [0.485, 0.456, 0.406]σ=[0.229,0.224,0.225]\sigma = [0.229, 0.224, 0.225](RGB 三通道)。

image_utils.cpp 中,NEON 优化的归一化实现如下:

void NormalizeImageNet(const uint8_t* src, int width, int height, int stride,
float* dst) {
// ImageNet 归一化参数
constexpr float kMeanR = 0.485f, kMeanG = 0.456f, kMeanB = 0.406f;
constexpr float kStdR = 0.229f, kStdG = 0.224f, kStdB = 0.225f;
constexpr float kInvStdR = 1.0f / kStdR;
constexpr float kInvStdG = 1.0f / kStdG;
constexpr float kInvStdB = 1.0f / kStdB;
constexpr float kScale = 1.0f / 255.0f;
#if defined(__ARM_NEON) || defined(__ARM_NEON__)
// 预计算: (1/255) / std = 1 / (255 * std)
const float32x4_t v_scale_r = vdupq_n_f32(kScale * kInvStdR);
const float32x4_t v_scale_g = vdupq_n_f32(kScale * kInvStdG);
const float32x4_t v_scale_b = vdupq_n_f32(kScale * kInvStdB);
// 预计算: -mean / std
const float32x4_t v_bias_r = vdupq_n_f32(-kMeanR * kInvStdR);
const float32x4_t v_bias_g = vdupq_n_f32(-kMeanG * kInvStdG);
const float32x4_t v_bias_b = vdupq_n_f32(-kMeanB * kInvStdB);
for (int y = 0; y < height; ++y) {
const uint8_t* row = src + y * stride;
float* dst_row = dst + y * width * 3;
int x = 0;
for (; x + 4 <= width; x += 4) {
// 加载 4 个 RGBA 像素 (16 bytes)
uint8x16_t rgba = vld1q_u8(row + x * 4);
// 解交织: RGBARGBARGBARGBA -> RRRR, GGGG, BBBB, AAAA
uint8x16x4_t channels = vld4q_u8(row + x * 4);
// uint8 -> uint16 -> uint32 -> float32
uint16x8_t r16 = vmovl_u8(vget_low_u8(channels.val[0]));
uint16x8_t g16 = vmovl_u8(vget_low_u8(channels.val[1]));
uint16x8_t b16 = vmovl_u8(vget_low_u8(channels.val[2]));
float32x4_t r_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(r16)));
float32x4_t g_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(g16)));
float32x4_t b_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(b16)));
// 归一化: (x / 255 - mean) / std = x * (1/255/std) + (-mean/std)
r_f = vmlaq_f32(v_bias_r, r_f, v_scale_r); // fused multiply-add
g_f = vmlaq_f32(v_bias_g, g_f, v_scale_g);
b_f = vmlaq_f32(v_bias_b, b_f, v_scale_b);
// 交织存储: RRRR, GGGG, BBBB -> RGBRGBRGBRGB
float32x4x3_t rgb = {r_f, g_f, b_f};
vst3q_f32(dst_row + x * 3, rgb);
}
// 标量处理剩余像素
for (; x < width; ++x) {
const uint8_t* px = row + x * 4;
float* dst_px = dst_row + x * 3;
dst_px[0] = (px[0] * kScale - kMeanR) * kInvStdR;
dst_px[1] = (px[1] * kScale - kMeanG) * kInvStdG;
dst_px[2] = (px[2] * kScale - kMeanB) * kInvStdB;
}
}
#else
// 标量实现(略)
#endif
}

这段代码的关键优化技巧:

  1. 预计算常量:将 (x - mean) / std 变换为 x * scale + bias,减少运行时除法
  2. Fused Multiply-Add:vmlaq_f32 在单条指令中完成乘法和加法
  3. 解交织加载:vld4q_u8 自动将 RGBA 分离为四个通道
  4. 交织存储:vst3q_f32 将 RGB 三通道交织写入内存

零 OpenCV 依赖

许多 OCR 项目依赖 OpenCV 进行图像预处理。OpenCV 功能强大,但它也带来了巨大的包体积,Android 上的 OpenCV 库通常超过 10MB。

PPOCRv5-Android 选择了「零 OpenCV 依赖」的路线。所有图像预处理操作都在 image_utils.cpp 中用纯 C++ 实现:

  • 双线性插值缩放:手写实现,支持 NEON 优化
  • 归一化:ImageNet 标准化和识别标准化
  • 透视变换:从原图裁剪任意角度的文本区域

双线性插值的 NEON 实现

双线性插值是图像缩放的核心算法。给定源图像坐标 (x,y)(x, y),双线性插值计算目标像素值:

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

其中 α=xx\alpha = x - \lfloor x \rfloorβ=yy\beta = y - \lfloor y \rfloorfijf_{ij} 是四个邻近像素的值。

void ResizeBilinear(const uint8_t* src, int src_w, int src_h, int src_stride,
uint8_t* dst, int dst_w, int dst_h) {
const float scale_x = static_cast<float>(src_w) / dst_w;
const float scale_y = static_cast<float>(src_h) / dst_h;
for (int dy = 0; dy < dst_h; ++dy) {
const float sy = (dy + 0.5f) * scale_y - 0.5f;
const int y0 = std::max(0, static_cast<int>(std::floor(sy)));
const int y1 = std::min(src_h - 1, y0 + 1);
const float beta = sy - y0;
const float inv_beta = 1.0f - beta;
const uint8_t* row0 = src + y0 * src_stride;
const uint8_t* row1 = src + y1 * src_stride;
uint8_t* dst_row = dst + dy * dst_w * 4;
#if defined(__ARM_NEON) || defined(__ARM_NEON__)
// NEON: 一次处理 4 个目标像素
const float32x4_t v_beta = vdupq_n_f32(beta);
const float32x4_t v_inv_beta = vdupq_n_f32(inv_beta);
int dx = 0;
for (; dx + 4 <= dst_w; dx += 4) {
// 计算 4 个源坐标
float sx[4];
for (int i = 0; i < 4; ++i) {
sx[i] = ((dx + i) + 0.5f) * scale_x - 0.5f;
}
// 加载 alpha 权重
float alpha[4], inv_alpha[4];
int x0[4], x1[4];
for (int i = 0; i < 4; ++i) {
x0[i] = std::max(0, static_cast<int>(std::floor(sx[i])));
x1[i] = std::min(src_w - 1, x0[i] + 1);
alpha[i] = sx[i] - x0[i];
inv_alpha[i] = 1.0f - alpha[i];
}
// 对每个通道进行双线性插值
for (int c = 0; c < 4; ++c) { // RGBA
float32x4_t f00, f10, f01, f11;
// 收集 4 个像素的邻近值
f00 = vsetq_lane_f32(row0[x0[0] * 4 + c], f00, 0);
f00 = vsetq_lane_f32(row0[x0[1] * 4 + c], f00, 1);
f00 = vsetq_lane_f32(row0[x0[2] * 4 + c], f00, 2);
f00 = vsetq_lane_f32(row0[x0[3] * 4 + c], f00, 3);
// ... f10, f01, f11 类似
// 双线性插值公式
float32x4_t v_alpha = vld1q_f32(alpha);
float32x4_t v_inv_alpha = vld1q_f32(inv_alpha);
float32x4_t top = vmlaq_f32(
vmulq_f32(f00, v_inv_alpha),
f10, v_alpha
);
float32x4_t bottom = vmlaq_f32(
vmulq_f32(f01, v_inv_alpha),
f11, v_alpha
);
float32x4_t result = vmlaq_f32(
vmulq_f32(top, v_inv_beta),
bottom, v_beta
);
// 转换回 uint8 并存储
uint32x4_t result_u32 = vcvtq_u32_f32(result);
// ... 存储
}
}
#endif
// 标量处理剩余像素(略)
}
}

TIP

双线性插值的 NEON 优化比较复杂,因为四个邻近像素的地址是不连续的。一个更高效的方法是使用分离式双线性插值:先在水平方向插值,再在垂直方向插值。这样可以更好地利用缓存局部性。

这种选择的代价是更多的开发工作,但收益是显著的:

  1. APK 体积减少约 10MB
  2. 完全控制预处理逻辑,便于优化
  3. 避免 OpenCV 版本兼容性问题

透视变换:从旋转矩形到标准文本行

文本识别模型期望输入是水平的文本行图像。但检测到的文本框可能是任意角度的旋转矩形。透视变换负责将旋转矩形区域「拉直」。

text_recognizer.cpp 中,CropAndRotate 方法实现了这一功能:

void CropAndRotate(const uint8_t *__restrict__ image_data,
int width, int height, int stride,
const RotatedRect &box, int &target_width) {
// 计算旋转矩形的四个角点
const float cos_angle = std::cos(box.angle * M_PI / 180.0f);
const float sin_angle = std::sin(box.angle * M_PI / 180.0f);
const float half_w = box.width / 2.0f;
const float half_h = box.height / 2.0f;
float corners[8]; // 4 个角点的 (x, y) 坐标
corners[0] = box.center_x + (-half_w * cos_angle - (-half_h) * sin_angle);
corners[1] = box.center_y + (-half_w * sin_angle + (-half_h) * cos_angle);
// ... 计算其他角点
// 自适应目标宽度:保持宽高比
const float aspect_ratio = src_width / std::max(src_height, 1.0f);
target_width = static_cast<int>(kRecInputHeight * aspect_ratio);
target_width = std::clamp(target_width, 1, kRecInputWidth); // 48x[1, 320]
// 仿射变换矩阵
const float a00 = (x1 - x0) * inv_dst_w;
const float a01 = (x3 - x0) * inv_dst_h;
const float a10 = (y1 - y0) * inv_dst_w;
const float a11 = (y3 - y0) * inv_dst_h;
// 双线性插值采样 + 归一化(NEON 优化)
for (int dy = 0; dy < kRecInputHeight; ++dy) {
for (int dx = 0; dx < target_width; ++dx) {
float sx = base_sx + a00 * dx;
float sy = base_sy + a10 * dx;
BilinearSampleNeon(image_data, stride, sx, sy, dst_row + dx * 3);
}
}
}

这个实现的关键优化:

  1. 自适应宽度:根据文本框宽高比动态调整输出宽度,避免过度拉伸或压缩
  2. 仿射变换近似:对于近似平行四边形的文本框,使用仿射变换代替透视变换,减少计算量
  3. NEON 双线性插值:采样和归一化在一个 pass 中完成,减少内存访问

JNI:Kotlin 与 C++ 的桥梁

JNI(Java Native Interface)是 Kotlin/Java 与 C++ 通信的桥梁。但 JNI 调用有开销,频繁的跨语言调用会严重影响性能。

PPOCRv5-Android 的设计原则是:最小化 JNI 调用次数。整个 OCR 流程只需要一次 JNI 调用:

sequenceDiagram
participant K as Kotlin Layer
participant J as JNI Bridge
participant N as Native Layer
participant G as GPU
K->>J: process(bitmap)
J->>N: 传递 RGBA 指针
Note over N,G: Native 层完成所有工作
N->>N: 图像预处理 NEON
N->>G: 文本检测推理
G-->>N: 概率图
N->>N: 后处理 轮廓检测
loop 每个文本框
N->>N: 透视变换裁剪
N->>G: 文本识别推理
G-->>N: logits
N->>N: CTC 解码
end
N-->>J: OCR Results
J-->>K: List OcrResult

ppocrv5_jni.cpp 中,核心的 nativeProcess 函数展示了这种设计:

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

这种设计避免了在检测和识别之间来回传递数据的开销。

架构设计:模块化与可测试性

PPOCRv5-Android 的架构遵循「关注点分离」原则:

flowchart TB
subgraph UI["Jetpack Compose UI Layer"]
direction LR
CP[CameraPreview]
GP[GalleryPicker]
RO[ResultOverlay]
end
subgraph VM["ViewModel Layer"]
OVM[OCRViewModel<br/>State Management]
end
subgraph Native["Native Layer - C++"]
OE[OcrEngine<br/>Orchestration]
subgraph Detection["Text Detection"]
TD[TextDetector]
DB[DBNet FP16]
end
subgraph Recognition["Text Recognition"]
TR[TextRecognizer]
SVTR[SVTRv2 + CTC]
end
subgraph Preprocessing["Image Processing"]
IP[ImagePreprocessor<br/>NEON Optimized]
PP[PostProcessor<br/>Contour Detection]
end
subgraph Runtime["LiteRT Runtime"]
GPU[GPU Delegate<br/>OpenCL]
CPU[CPU Fallback<br/>XNNPACK]
end
end
CP --> OVM
GP --> OVM
OVM --> RO
OVM <-->|JNI| OE
OE --> TD
OE --> TR
TD --> DB
TR --> SVTR
TD --> IP
TR --> IP
DB --> PP
DB --> GPU
SVTR --> GPU
GPU -.->|Fallback| CPU

这种分层架构的好处是:

  1. UI 层:纯 Kotlin/Compose,专注于用户交互
  2. ViewModel 层:管理状态和业务逻辑
  3. Native 层:高性能计算,与 UI 完全解耦

每一层都可以独立测试。Native 层可以用 Google Test 进行单元测试,ViewModel 层可以用 JUnit + MockK 进行测试。

Kotlin 层的封装

OcrEngine.kt 中,Kotlin 层提供了简洁的 API:

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

这种设计的优点:

  1. 使用 Result 类型处理初始化错误
  2. 实现 Closeable 接口,支持 use 块自动释放资源
  3. 模型文件自动从 assets 复制到缓存目录

冷启动优化

首次推理(冷启动)通常比后续推理(热启动)慢很多。这是因为:

  1. GPU Delegate 需要编译 OpenCL 程序
  2. 模型权重需要从 CPU 内存传输到 GPU 内存
  3. 各种缓存需要预热

PPOCRv5-Android 通过 Warm-up 机制来缓解冷启动问题:

void OcrEngine::WarmUp() {
LOGD(TAG, "Starting warm-up (%d iterations)...", kWarmupIterations);
// 创建一个小的测试图像
std::vector<uint8_t> dummy_image(kWarmupImageSize * kWarmupImageSize * 4, 128);
for (int i = 0; i < kWarmupImageSize * kWarmupImageSize; ++i) {
dummy_image[i * 4 + 0] = static_cast<uint8_t>((i * 7) % 256);
dummy_image[i * 4 + 1] = static_cast<uint8_t>((i * 11) % 256);
dummy_image[i * 4 + 2] = static_cast<uint8_t>((i * 13) % 256);
dummy_image[i * 4 + 3] = 255;
}
// 执行几次推理来预热
for (int iter = 0; iter < kWarmupIterations; ++iter) {
float detection_time_ms = 0.0f;
detector_->Detect(dummy_image.data(), kWarmupImageSize, kWarmupImageSize,
kWarmupImageSize * 4, &detection_time_ms);
}
LOGD(TAG, "Warm-up completed (accelerator: %s)", AcceleratorName(active_accelerator_));
}

内存对齐优化

TextDetector::Impl 中,所有预分配的 buffer 都使用 64 字节对齐:

// Pre-allocated buffers with cache-line alignment
alignas(64) std::vector<uint8_t> resized_buffer_;
alignas(64) std::vector<float> normalized_buffer_;
alignas(64) std::vector<uint8_t> binary_map_;
alignas(64) std::vector<float> prob_map_;

64 字节对齐是现代 ARM 处理器的缓存行大小。对齐的内存访问可以避免缓存行分裂,提高内存访问效率。

内存池与对象复用

频繁的内存分配和释放是性能杀手。PPOCRv5-Android 使用预分配策略,在初始化时一次性分配所有需要的内存:

class TextDetector::Impl {
// 预分配的 buffer,生命周期与 Impl 相同
alignas(64) std::vector<uint8_t> resized_buffer_; // 640 * 640 * 4 = 1.6MB
alignas(64) std::vector<float> normalized_buffer_; // 640 * 640 * 3 * 4 = 4.9MB
alignas(64) std::vector<uint8_t> binary_map_; // 640 * 640 = 0.4MB
alignas(64) std::vector<float> prob_map_; // 640 * 640 * 4 = 1.6MB
bool Initialize(...) {
// 一次性分配,避免运行时 malloc
resized_buffer_.resize(kDetInputSize * kDetInputSize * 4);
normalized_buffer_.resize(kDetInputSize * kDetInputSize * 3);
binary_map_.resize(kDetInputSize * kDetInputSize);
prob_map_.resize(kDetInputSize * kDetInputSize);
return true;
}
};

这种设计的好处:

  1. 避免内存碎片:所有大块内存在启动时分配,运行时不会产生碎片
  2. 减少系统调用:malloc 可能触发系统调用,预分配避免了这一开销
  3. 缓存友好:连续分配的内存更可能在物理上连续,提高缓存命中率

分支预测优化

现代 CPU 使用分支预测来提高流水线效率。错误的分支预测会导致流水线刷新,损失 10-20 个时钟周期。

在热路径上,我们使用 __builtin_expect 提示编译器:

// 大多数像素不会超过阈值
if (__builtin_expect(prob_map[i] > kBinaryThreshold, 0)) {
binary_map_[i] = 255;
} else {
binary_map_[i] = 0;
}

__builtin_expect(expr, val) 告诉编译器 expr 的值很可能是 val。编译器会据此调整代码布局,将「不太可能」的分支放到远离主路径的位置。

循环展开与软件流水线

对于计算密集型循环,手动展开可以减少循环开销并暴露更多的指令级并行:

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

展开后,CPU 可以同时执行多条独立的乘加指令,充分利用超标量架构的多个执行单元。

Prefetch 优化

在透视变换的内层循环中,使用 __builtin_prefetch 提前加载下一行的数据:

for (int dy = 0; dy < kRecInputHeight; ++dy) {
// 预取下一行数据
if (dy + 1 < kRecInputHeight) {
const float next_sy = y0 + a11 * (dy + 1);
const int next_y = static_cast<int>(next_sy);
if (next_y >= 0 && next_y < height) {
__builtin_prefetch(image_data + next_y * stride, 0, 1);
}
}
// ... 处理当前行
}

这种优化可以隐藏内存延迟,在处理当前行时,下一行的数据已经在 L1 缓存中了。

后处理的工程细节

连通域分析与轮廓检测

postprocess.cpp 中,FindContours 函数实现了高效的连通域分析:

std::vector<std::vector<Point>> FindContours(const uint8_t *binary_map,
int width, int height) {
// 1. 4x 降采样减少计算量
int ds_width = (width + kDownsampleFactor - 1) / kDownsampleFactor;
int ds_height = (height + kDownsampleFactor - 1) / kDownsampleFactor;
std::vector<uint8_t> ds_map(ds_width * ds_height);
downsample_binary_map(binary_map, width, height,
ds_map.data(), ds_width, ds_height, kDownsampleFactor);
// 2. BFS 遍历连通域
std::vector<int> labels(ds_width * ds_height, 0);
int current_label = 0;
for (int y = 0; y < ds_height; ++y) {
for (int x = 0; x < ds_width; ++x) {
if (pixel_at(ds_map.data(), x, y, ds_width) > 0 &&
labels[y * ds_width + x] == 0) {
current_label++;
std::vector<Point> boundary;
std::queue<std::pair<int, int>> queue;
queue.push({x, y});
while (!queue.empty()) {
auto [cx, cy] = queue.front();
queue.pop();
// 检测边界像素
if (is_boundary_pixel(ds_map.data(), cx, cy, ds_width, ds_height)) {
boundary.push_back({
static_cast<float>(cx * kDownsampleFactor + kDownsampleFactor / 2),
static_cast<float>(cy * kDownsampleFactor + kDownsampleFactor / 2)
});
}
// 4-邻域扩展
for (int d = 0; d < 4; ++d) {
int nx = cx + kNeighborDx4[d];
int ny = cy + kNeighborDy4[d];
// ...
}
}
if (boundary.size() >= 4) {
contours.push_back(std::move(boundary));
}
}
}
}
return contours;
}

关键优化点:

  1. 4x 降采样:将 640x640 的二值图降采样到 160x160,减少 16 倍的计算量
  2. 边界检测:只保留边界像素,而不是整个连通域
  3. 最大轮廓数限制:kMaxContours = 100,防止极端情况下的性能问题

凸包与旋转卡壳算法

最小外接旋转矩形的计算分为两步:首先计算凸包,然后使用旋转卡壳算法找到最小面积的外接矩形。

Graham Scan 凸包算法

Graham Scan 是计算凸包的经典算法,时间复杂度 O(nlogn)O(n \log n)

std::vector<Point> ConvexHull(std::vector<Point> points) {
if (points.size() < 3) return points;
// 1. 找到最下方的点(y 最小,x 最小)
auto pivot = std::min_element(points.begin(), points.end(),
[](const Point& a, const Point& b) {
return a.y < b.y || (a.y == b.y && a.x < b.x);
});
std::swap(points[0], *pivot);
Point p0 = points[0];
// 2. 按极角排序
std::sort(points.begin() + 1, points.end(),
[&p0](const Point& a, const Point& b) {
float cross = CrossProduct(p0, a, b);
if (std::abs(cross) < 1e-6f) {
// 共线时,距离近的排前面
return DistanceSquared(p0, a) < DistanceSquared(p0, b);
}
return cross > 0; // 逆时针方向
});
// 3. 构建凸包
std::vector<Point> hull;
for (const auto& p : points) {
// 移除导致顺时针转向的点
while (hull.size() > 1 &&
CrossProduct(hull[hull.size()-2], hull[hull.size()-1], p) <= 0) {
hull.pop_back();
}
hull.push_back(p);
}
return hull;
}
// 叉积:判断转向方向
float CrossProduct(const Point& o, const Point& a, const Point& b) {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}

旋转卡壳算法

旋转卡壳(Rotating Calipers)算法遍历凸包的每条边,计算以该边为底的外接矩形面积:

RotatedRect MinAreaRect(const std::vector<Point>& hull) {
if (hull.size() < 3) return {};
float min_area = std::numeric_limits<float>::max();
RotatedRect best_rect;
int n = hull.size();
int right = 1, top = 1, left = 1; // 三个「卡壳」的位置
for (int i = 0; i < n; ++i) {
int j = (i + 1) % n;
// 当前边的方向向量
float edge_x = hull[j].x - hull[i].x;
float edge_y = hull[j].y - hull[i].y;
float edge_len = std::sqrt(edge_x * edge_x + edge_y * edge_y);
// 单位向量
float ux = edge_x / edge_len;
float uy = edge_y / edge_len;
// 垂直方向
float vx = -uy;
float vy = ux;
// 找到最右点(沿边方向投影最大)
while (Dot(hull[(right + 1) % n], ux, uy) > Dot(hull[right], ux, uy)) {
right = (right + 1) % n;
}
// 找到最上点(沿垂直方向投影最大)
while (Dot(hull[(top + 1) % n], vx, vy) > Dot(hull[top], vx, vy)) {
top = (top + 1) % n;
}
// 找到最左点
while (Dot(hull[(left + 1) % n], ux, uy) < Dot(hull[left], ux, uy)) {
left = (left + 1) % n;
}
// 计算矩形尺寸
float width = Dot(hull[right], ux, uy) - Dot(hull[left], ux, uy);
float height = Dot(hull[top], vx, vy) - Dot(hull[i], vx, vy);
float area = width * height;
if (area < min_area) {
min_area = area;
// 更新最优矩形参数
best_rect.width = width;
best_rect.height = height;
best_rect.angle = std::atan2(uy, ux) * 180.0f / M_PI;
// 计算中心点...
}
}
return best_rect;
}

旋转卡壳的关键洞察是:当底边旋转时,三个「卡壳」(最右、最上、最左点)只会单调前进,不会后退。因此总时间复杂度是 O(n)O(n),而非 O(n2)O(n^2)

最小外接旋转矩形

MinAreaRect 函数使用旋转卡壳算法计算最小外接旋转矩形:

RotatedRect MinAreaRect(const std::vector<Point> &contour) {
// 1. 子采样减少点数
std::vector<Point> points = subsample_points(contour, kMaxBoundaryPoints);
// 2. 快速路径:高宽高比的文本框直接使用 AABB
float aspect = std::max(aabb_width, aabb_height) /
std::max(1.0f, std::min(aabb_width, aabb_height));
if (aspect > 2.0f && points.size() > 50) {
// 直接返回轴对齐边界框
RotatedRect rect;
rect.center_x = (min_x + max_x) / 2.0f;
rect.center_y = (min_y + max_y) / 2.0f;
rect.width = aabb_width;
rect.height = aabb_height;
rect.angle = 0.0f;
return rect;
}
// 3. 凸包计算
std::vector<Point> hull = convex_hull(std::vector<Point>(points));
// 4. 旋转卡壳:遍历凸包的每条边
float min_area = std::numeric_limits<float>::max();
RotatedRect best_rect;
for (size_t i = 0; i < hull.size(); ++i) {
// 以当前边为基准,计算外接矩形
float edge_x = hull[j].x - hull[i].x;
float edge_y = hull[j].y - hull[i].y;
// 投影所有点到边的方向和垂直方向
project_points_onto_axis(hull, axis1_x, axis1_y, min1, max1);
project_points_onto_axis(hull, axis2_x, axis2_y, min2, max2);
float area = (max1 - min1) * (max2 - min2);
if (area < min_area) {
min_area = area;
// 更新最优矩形
}
}
return best_rect;
}

这个算法的时间复杂度是 O(nlogn)O(n log n)(凸包计算)+ O(n)O(n)(旋转卡壳),其中 nn 是边界点数量。通过子采样将 nn 限制在 200 以内,确保了实时性能。

实时相机 OCR:CameraX 与帧分析

实时 OCR 的挑战在于:如何在保持流畅预览的同时,尽可能快地处理每一帧?

flowchart TB
subgraph Camera["CameraX Pipeline"]
direction TB
CP[CameraProvider]
PV[Preview UseCase<br/>30 FPS]
IA[ImageAnalysis UseCase<br/>STRATEGY_KEEP_ONLY_LATEST]
end
subgraph Analysis["帧分析流程"]
direction TB
IP[ImageProxy<br/>YUV_420_888]
BM[Bitmap 转换<br/>RGBA_8888]
JNI[JNI 调用<br/>单次跨语言]
end
subgraph Native["Native OCR"]
direction TB
DET[TextDetector<br/>~45ms GPU]
REC[TextRecognizer<br/>~15ms/行]
RES[OCR Results]
end
subgraph UI["UI 更新"]
direction TB
VM[ViewModel<br/>StateFlow]
OV[ResultOverlay<br/>Canvas 绘制]
end
CP --> PV
CP --> IA
IA --> IP --> BM --> JNI
JNI --> DET --> REC --> RES
RES --> VM --> OV

CameraX 的 ImageAnalysis

CameraX 是 Android Jetpack 的相机库,它提供了 ImageAnalysis 用例,允许我们对相机帧进行实时分析:

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

关键配置是 STRATEGY_KEEP_ONLY_LATEST:当分析器处理速度跟不上相机帧率时,丢弃旧帧,只保留最新帧。这确保了 OCR 结果的时效性。

帧率与延迟的权衡

在 GPU 加速的设备上(我目前手上的骁龙 870 似乎有问题,始终无法做到把大部分计算交给 GPU 加速),PPOCRv5-Android 理论可以达到较高的处理速度。但这并不意味着我们应该处理每一帧。

考虑这样一个场景:用户将相机对准一段文字,文字内容在短时间内不会变化。如果我们每帧都进行完整的 OCR,会浪费大量计算资源。

一个优化策略是「变化检测」:只有当画面发生显著变化时,才触发 OCR。这可以通过比较连续帧的直方图或特征点来实现。

未来展望:NPU 与量化

端侧 AI 的未来在于 NPU(Neural Processing Unit)。与 GPU 相比,NPU 专为神经网络推理设计,能效比更高。

但 NPU 的挑战在于碎片化。每个芯片厂商都有自己的 NPU 架构和 SDK:

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

Android 的 NNAPI(Neural Networks API)试图提供统一的抽象层,但实际效果参差不齐。许多 NPU 功能无法通过 NNAPI 暴露,开发者不得不使用厂商特定的 SDK。

INT8 量化:一场未竟的战役

FP16 量化是一个保守的选择,它几乎不损失精度。但如果追求极致性能,INT8 量化是下一步。

INT8 量化将权重和激活从 32 位浮点压缩到 8 位整数,理论上可以带来:

  • 4 倍的模型压缩
  • 2-4 倍的推理加速(取决于硬件)
  • 在高通 Hexagon DSP 上可实现 10 倍以上的加速

这个诱惑太大了。于是我开始了一段漫长的 INT8 量化之旅。

第一次尝试:合成数据校准

INT8 量化需要校准数据集来确定量化参数(Scale 和 Zero Point)。最初,我偷懒使用了随机生成的「类文字」图像:

# 错误示范:使用随机噪声做校准
img = np.ones((h, w, 3), dtype=np.float32) * 0.9
for _ in range(num_lines):
gray_val = np.random.uniform(0.05, 0.3)
img[y:y+line_h, x:x+line_w] = gray_val

结果是灾难性的。模型输出全为 0:

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

量化工具根据随机噪声计算出了错误的量化参数,导致真实图像的激活值被截断。

第二次尝试:真实图片校准

我改用真实的 OCR 数据集图片:ICDAR2015、TextOCR、PaddleOCR 官方示例图。同时实现了 Letterbox 预处理,确保校准时的图像分布与推理时一致:

def letterbox_image(image, target_size):
"""保持长宽比缩放,不足部分填充灰色"""
ih, iw = image.shape[:2]
h, w = target_size
scale = min(w / iw, h / ih)
# ... 居中粘贴

模型不再输出全 0,但识别结果仍然是乱码。

第三次尝试:修复 C++ 端的类型处理

我发现 C++ 代码在处理 INT8 输入时存在问题。INT8 模型期望的是原始像素值(0-255),而我还在做 ImageNet 归一化(减均值除方差)。

if (input_is_int8_) {
// INT8 模型:直接输入原始像素,归一化已融合到第一层
dst[i * 3 + 0] = static_cast<int8_t>(src[i * 4 + 0] ^ 0x80);
} else {
// FP32 模型:需要手动归一化
// (pixel - mean) / std
}

同时,我实现了动态读取量化参数的逻辑,而不是硬编码:

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

最终结果:妥协

经过数天的调试,INT8 模型仍然无法正常工作。问题可能出在:

  1. onnx2tf 的量化实现:PP-OCRv5 使用了一些特殊的算子组合,onnx2tf 在量化时可能没有正确处理
  2. DBNet 的输出特性:DBNet 输出的是概率图,值域在 0-1 之间,INT8 量化对这种小范围值特别敏感
  3. 多阶段模型的误差累积:检测和识别两个模型串联,量化误差会累积放大

让我们深入分析第二点。DBNet 的输出经过 Sigmoid 激活,值域被压缩到 [0, 1]。INT8 量化使用以下公式:

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

对于 [0, 1] 范围的值,如果 scale 设置不当,量化后的值可能只占用 INT8 范围 [-128, 127] 的一小部分,导致严重的精度损失。

# 假设 scale = 0.00784 (1/127), zero_point = 0
# 输入 0.5 -> round(0.5 / 0.00784) + 0 = 64
# 输入 0.1 -> round(0.1 / 0.00784) + 0 = 13
# 输入 0.01 -> round(0.01 / 0.00784) + 0 = 1
# 输入 0.001 -> round(0.001 / 0.00784) + 0 = 0 # 精度丢失!

DBNet 的阈值通常设为 0.1-0.3,这意味着大量有意义的概率值(0.1-0.3)在量化后只能用 13-38 这 25 个整数表示,分辨率严重不足。

WARNING

PP-OCRv5 的 INT8 量化是一个已知的难题。如果你也在尝试,建议先确认 FP32 模型能正常工作,再逐步排查量化问题。或者,考虑使用 PaddlePaddle 官方的 Paddle Lite 框架,它对 PaddleOCR 的支持更好。

量化感知训练:正确的解决方案

如果必须使用 INT8 量化,正确的方法是量化感知训练(Quantization-Aware Training, QAT),而非训练后量化(Post-Training Quantization, PTQ)。

QAT 在训练过程中模拟量化误差,让模型学会适应低精度表示:

# PyTorch QAT 示例
import torch.quantization as quant
model = DBNet()
model.qconfig = quant.get_default_qat_qconfig('fbgemm')
model_prepared = quant.prepare_qat(model)
# 正常训练,但前向传播中插入了伪量化节点
for epoch in range(num_epochs):
for images, labels in dataloader:
outputs = model_prepared(images) # 包含量化模拟
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
# 转换为真正的量化模型
model_quantized = quant.convert(model_prepared)

遗憾的是,PP-OCRv5 官方没有提供 QAT 训练的模型。这意味着要获得高质量的 INT8 模型,需要从头进行 QAT 训练,这超出了本项目的范围。

最终,我选择了妥协:使用 FP16 量化 + GPU 加速,而不是 INT8 + DSP。

这个决定的代价是:

  • 模型体积是 INT8 的 2 倍
  • 无法利用 Hexagon DSP 的超低功耗
  • 推理速度比理论最优慢 2-3 倍

但收益是:

  • 模型精度与 FP32 几乎一致
  • 开发周期大幅缩短
  • 代码复杂度降低

工程的本质是权衡。有时候,「足够好」比「理论最优」更重要。

结语

从 PaddlePaddle 到 TFLite,从 DBNet 到 SVTRv2,从 OpenCL 到 NEON,端侧 OCR 的工程实践涉及深度学习、编译器、GPU 编程、移动开发等多个领域的知识。

这个项目的核心教训是:端侧 AI 不仅仅是「把模型放到手机上」。它需要:

  1. 深入理解模型架构,才能正确转换
  2. 熟悉硬件特性,才能充分利用加速器
  3. 掌握系统编程,才能实现高性能原生代码
  4. 关注用户体验,才能在性能和功耗之间找到平衡

PPOCRv5-Android 是一个开源项目,它展示了如何将现代的 OCR 模型部署到实际的移动应用中。希望这篇文章能为有类似需求的开发者提供一些参考。

正如 Google 在 LiteRT 发布时所说:「Maximum performance, simplified.」9 端侧 AI 的目标不是复杂,而是让复杂变得简单。

后话

老实讲,我(在工作与兴趣领域)其实已经淡出 Android 有至少两年了,而这是我首次在 GitHub 小号上(我已经把大号交给了同事,以表我离开的决心)公开一个较为成熟的库。

这些年来我的工作重点其实并不是 Android 领域,具体情况不便透露,但日后有机会会展开讲讲。总之,我或许很难再在 Android 上再多建树了。

这次发布该项目源于我兴趣使然,正构建的一个早期基于 Android 端侧的工具——而 OCR 只是其中底层的一小部分,后期(应该很快了)也将完整开放源代码,暂时也不便透露。

总之,感谢你看到这里,也期待你能给我的仓库点上 Star,感谢!


参考文献

Footnotes

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

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

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

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

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

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

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

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

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

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

许可协议

除特别声明以外,本博客所有文章与素材内容均采用 知识共享署名 - 非商业性使用 - 相同方式共享 4.0 国际许可协议 (CC BY-NC-SA 4.0)

✓ 已复制!