本文记录一次完整的 PP-OCRv6 接入手机的过程,介绍了如何在 .NET MAUI Android 应用中通过无障碍截图和 ONNX Runtime 完成离线 OCR 识别。
最近在做 FlowBot 的流程自动化能力时,遇到一个很实际的问题:有些界面上的文字可以通过 Android 无障碍节点读取,有些却不行。比如小程序广告页、WebView、游戏化页面、图片按钮,系统节点里可能没有文本,或者文本和屏幕显示并不一致。
这时就需要一个真正基于图像的文字识别方案。云端 OCR 能用,但对于一个想要离线,即开即用的自动化工具来说并不理想:网络不稳定、延迟不可控,还会牵涉隐私和成本。更合适的方向是把 OCR 模型放进 App,本地截图、本地推理、本地判断。
这篇文章以 PP-OCRv6 的识别模型为例,记录如何把它接入 .NET MAUI Android 项目。
PP-OCRv6 是 PaddlePaddle 推出的轻量级 OCR 系统,核心目标是在保持较高识别精度的同时,覆盖从服务端到边缘设备的部署场景。它围绕统一的 MetaFormer 风格基础模块重构了 backbone、检测 neck 和识别 neck,并结合结构重参数化与数据优化来提升效果。
它提供了 medium、small、tiny 三个级别:
medium 更偏向精度和服务端场景。small 在精度和体积之间比较均衡。tiny 体积更小、速度更快,更适合边缘设备,但识别能力会有明显取舍。官方介绍里提到,PP-OCRv6 支持 50 种语言,也覆盖数字屏、点阵字符、工业场景、卡证等比较复杂的识别场景。对 App 内的自动化判断来说,这类轻量模型比大语言视觉模型更现实:包体可控、推理链路简单、部署成本低。
模型可以从魔搭社区的 PP-OCRv6 合集PP-OCRv6合集[1]或官方 GitHub 仓库 PaddleOCR[2] 下载。

PP-OCRv6模型合集
这里我主要测试了两个识别模型:
script/pp_ocrv6_small_rec.onnx
script/PP-OCRv6_small_rec_inference.yml
script/pp_ocrv6_tiny_rec.onnx
script/PP-OCRv6_tiny_rec_inference.yml
真正放进 MAUI App 的 Raw 资源里,只需要保留当前要使用的一组模型和配置:
src/FlowBot.App/Resources/Raw/inference.onnx
src/FlowBot.App/Resources/Raw/inference.yml
这里有个容易忽略的点:不能只放 onnx,PP-OCR 的识别模型还需要 inference.yml 里的 character_dict 来做 CTC 解码。模型输出的是类别索引,不是直接输出文字;没有对应字典,就算推理成功也没办法使用。
MAUI 项目里 Raw 资源的配置如下:
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
这样打包后,Android 侧可以用 Assets.Open("inference.onnx") 和 Assets.Open("inference.yml") 读取资源。
拿到模型以后,第一步不是直接写 App 代码,而是需要先确认模型输入输出形状。这个步骤很重要,因为预处理、tensor 排列、解码逻辑都依赖它。
我临时写了一个 Python 脚本读取 ONNX 元信息:
from pathlib import Path
import onnx
model_path = Path("inference.onnx")
model = onnx.load(str(model_path))
print(f"ir_version: {model.ir_version}")
opsets = [f"{op.domain or 'ai.onnx'}:{op.version}"for op in model.opset_import]
print(f"opset: {', '.join(opsets)}")
for direction, values in (("input", model.graph.input), ("output", model.graph.output)):
print(f"{direction}s:")
for value in values:
tensor_type = value.type.tensor_type
dims = []
for dim in tensor_type.shape.dim:
if dim.dim_param:
dims.append(dim.dim_param)
elif dim.HasField("dim_value"):
dims.append(str(dim.dim_value))
else:
dims.append("?")
print(f" - {value.name}: elem_type={tensor_type.elem_type}, shape=[{', '.join(dims)}]")
small 模型读出来的信息大致是:
ir_version: 6
opset:ai.onnx:11
inputs:
-x:elem_type=1,shape=[DynamicDimension.0,3,48,DynamicDimension.1]
outputs:
-fetch_name_0:elem_type=1,shape=[DynamicDimension.0,Reshape_471_o0__d2,18710]
这里可以看出几个关键信息:
float32,形状为 [N, 3, 48, W]。48,宽度是动态的。[N, T, C],其中 C=18710,对应 CTC 类别数。18710 不是字典字符数本身,还包含 CTC blank,以及可能的空格类。这一步确定后,后面就可以按 PP-OCR 的方式做 resize、normalize、CHW 排列和 CTC greedy decode。
在 App 里接模型之前,建议一定先写一个 Python 测试脚本。这样可以把问题拆开:如果 Python 都识别不对,就没必要急着怀疑 MAUI 或 Android 代码。
测试脚本做了几件事:
inference.yml 中的 character_dict。48。(pixel / 255 - 0.5) / 0.5。onnxruntime 推理。核心推理代码如下:
import numpy as np
import onnxruntime as ort
from PIL import Image
session = ort.InferenceSession(str(model_path), providers=["CPUExecutionProvider"])
input_meta = session.get_inputs()[0]
output_meta = session.get_outputs()[0]
tensor = preprocess(image_path, input_height=48, max_width=960)
logits = session.run([output_meta.name], {input_meta.name: tensor})[0]
text, confidence = ctc_greedy_decode(logits, characters)
CTC 解码的逻辑也不复杂:每个时间步取最大概率类别,跳过 blank,合并连续重复字符。
def ctc_greedy_decode(logits: np.ndarray, characters: list[str]) -> tuple[str, float]:
sequence = logits[0]
indices = sequence.argmax(axis=1)
scores = sequence.max(axis=1)
blank_index = 0
last_index = blank_index
result = []
result_scores = []
for index, score inzip(indices, scores, strict=False):
index = int(index)
if index != blank_index and index != last_index:
char_index = index - 1
if0 <= char_index < len(characters):
result.append(characters[char_index])
result_scores.append(float(score))
last_index = index
confidence = sum(result_scores) / len(result_scores) if result_scores else0.0
return"".join(result), confidence
我用一张广告任务截图测试,small 模型输出为:
text: 广告 浏览或查看商品30秒得奖励
confidence: 0.9742
这说明模型本身、字典和预处理链路是对的。
tiny 的优势很明显:小、快。
在同一张测试图上,我做了 30 次推理平均耗时对比:
模型 | 文件大小 | 识别结果 | 置信度 | 平均推理耗时 |
|---|---|---|---|---|
PP-OCRv6 small rec | 21.16 MB | 广告 浏览或查看商品30秒得奖励 | 0.9742 | 3.86 ms |
PP-OCRv6 tiny rec | 4.46 MB | 广告 浏歧助查有商品50沙得奖励 | 0.6184 | 1.03 ms |
从速度看,tiny 在 PC CPU 上大约快了 3.7 倍,包体也只有 small 的 21% 左右。但这张图里,tiny 把“浏览或查看商品30秒得奖励”识别错了好几个字,关键数字也错成了 50。
对 FlowBot 这种自动化工具来说,文字判断一旦误识别,后续点击、循环、等待逻辑都会跟着偏。相比多花几毫秒,我更在意判断可靠性。所以最终选择了 small 作为默认模型。
当然,tiny 并不是不能用。它更适合做“极速模式”,前提是你要拿自己的目标场景样本跑一批测试,确认误识率是否可以接受。
MAUI 项目里直接添加 NuGet 包:
dotnet add package Microsoft.ML.OnnxRuntime
项目文件会多出:
<ItemGroup>
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.27.0" />
</ItemGroup>
在 Android 端使用时,需要注意一个细节:ONNX Runtime 的 InferenceSession 更适合从文件路径加载模型,而不是直接从 MAUI asset stream 加载。所以我的做法是第一次使用时把 Raw 里的 inference.onnx 复制到 cache 目录,再创建 session。
private staticstringCopyAssetToCache(string assetName)
{
var context = Android.App.Application.Context;
var targetPath = System.IO.Path.Combine(
context.CacheDir?.AbsolutePath ?? FileSystem.CacheDirectory,
assetName);
usingvar input = context.Assets?.Open(assetName)
?? thrownew InvalidOperationException($"未找到 PP-OCRv6 资源:{assetName}。");
usingvar output = File.Create(targetPath);
input.CopyTo(output);
return targetPath;
}
FlowBot 本身已经使用 Android 无障碍服务来做点击、滑动和节点文本读取。接入 OCR 后,图像来源也放在无障碍服务里:通过 Android 11+ 的 TakeScreenshot API 获取屏幕截图。
首先在无障碍服务配置里声明截图能力:
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows|flagIncludeNotImportantViews"
android:canPerformGestures="true"
android:canTakeScreenshot="true"
android:canRetrieveWindowContent="true"
android:description="@string/app_name"
android:notificationTimeout="50" />
然后在服务里封装截图方法:
public Task<Bitmap> TakeScreenshotBitmapAsync(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsAndroidVersionAtLeast(30))
{
thrownew NotSupportedException("PP-OCR 截图识别需要 Android 11 或更高版本。");
}
var completion = new TaskCompletionSource<Bitmap>(TaskCreationOptions.RunContinuationsAsynchronously);
var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
completion.Task.ContinueWith(
_ => registration.Dispose(),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
TakeScreenshot((int)Display.DefaultDisplay, DirectExecutor.Instance, new ScreenshotCallback(completion));
return completion.Task;
}
截图回调里,系统给到的是 HardwareBuffer,需要转成 Bitmap:
[SupportedOSPlatform("android30.0")]
private static Bitmap CopyScreenshotBitmap(ScreenshotResult screenshot)
{
using var hardwareBuffer = screenshot.HardwareBuffer;
var bitmap = Bitmap.WrapHardwareBuffer(hardwareBuffer, screenshot.ColorSpace)
?? throw new InvalidOperationException("无法从系统截图创建位图。");
return bitmap.Copy(Bitmap.Config.Argb8888!, false)
?? throw new InvalidOperationException("无法复制系统截图位图。");
}
这里有一个运行时注意事项:如果你更新了 android:canTakeScreenshot,安装新版 App 后最好到系统设置里重新关闭/开启一次无障碍服务,让系统刷新服务能力。
不过对于我测试的 Android 设备来说,只要不是后台常驻的 APP 每次打开就需要重新开启无障碍服务。FlowBot 里我在启动时会检查服务状态,如果没开启就提示用户去设置里打开。
在业务层,我给文字判断增加了一个识别方式:
public enum TextRecognitionMode
{
AccessibilityText,
PpOcrV6Small
}
执行时根据识别方式分派:
public Task<string> GetTextInRegionAsync(
ScreenRegion region,
TextRecognitionMode recognitionMode,
CancellationToken cancellationToken)
{
return recognitionMode switch
{
TextRecognitionMode.PpOcrV6Small => GetTextWithPpOcrV6Async(region, cancellationToken),
_ => Task.FromResult(RequireService().GetTextInRegion(region))
};
}

FlowBot界面
PP-OCR 的完整流程如下:
private static async Task<string> GetTextWithPpOcrV6Async(
ScreenRegion region,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var service = RequireService();
using var screenshot = await service.TakeScreenshotBitmapAsync(cancellationToken);
using var cropped = CropBitmap(screenshot, region);
return PpOcrRecognizerInstance.Value.Recognize(cropped);
}
这个方法里只做流程组织,具体模型推理放到 PpOcrRecognizer:
inference.yml 读取字符字典。48。[1, 3, 48, W] 的 float tensor。InferenceSession.Run。预处理代码的核心是像素归一化和 CHW 排列:
private static DenseTensor<float> CreateInputTensor(Bitmap bitmap)
{
var width = bitmap.Width;
var height = bitmap.Height;
var pixels = newint[width * height];
bitmap.GetPixels(pixels, 0, width, 0, 0, width, height);
var tensor = new DenseTensor<float>([1, 3, height, width]);
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var pixel = pixels[(y * width) + x];
tensor[0, 0, y, x] = NormalizeChannel(Android.Graphics.Color.GetRedComponent(pixel));
tensor[0, 1, y, x] = NormalizeChannel(Android.Graphics.Color.GetGreenComponent(pixel));
tensor[0, 2, y, x] = NormalizeChannel(Android.Graphics.Color.GetBlueComponent(pixel));
}
}
return tensor;
}
privatestaticfloatNormalizeChannel(intvalue) => ((value / 255f) - 0.5f) / 0.5f;
CTC 解码和 Python 版本基本保持一致:
private stringDecode(Tensor<float> output)
{
var dimensions = output.Dimensions.ToArray();
var timeSteps = dimensions[1];
var classCount = dimensions[2];
var characters = _characters;
if (characters.Count + 2 == classCount)
{
characters = characters.Concat([" "]).ToList();
}
var result = new List<string>();
var lastIndex = 0;
for (var step = 0; step < timeSteps; step++)
{
var bestIndex = 0;
var bestScore = float.NegativeInfinity;
for (var classIndex = 0; classIndex < classCount; classIndex++)
{
var score = output[0, step, classIndex];
if (score > bestScore)
{
bestScore = score;
bestIndex = classIndex;
}
}
if (bestIndex != 0 && bestIndex != lastIndex)
{
var characterIndex = bestIndex - 1;
if (characterIndex >= 0 && characterIndex < characters.Count)
{
result.Add(characters[characterIndex]);
}
}
lastIndex = bestIndex;
}
returnstring.Concat(result);
}
搞完以上这些,手机端的离线 OCR 就算接入完成了,具体部署测试了一下,效果还可以,就是稍微可以感到一点卡顿。毕竟是每次截图、缩放、推理、解码,整个链路比直接读取无障碍文本要慢一些。
总体感觉还挺顺利,Microsoft.ML.OnnxRuntime 包真是好用,MAUI Android 端的推理和 Python 端几乎一致。
最终 FlowBot 的文字判断有了两种识别方式:
如果你也想在 MAUI 里接入离线 OCR,PP-OCRv6 small 是一个不错的选择。
[1] PP-OCRv6合集: https://www.modelscope.cn/collections/PaddlePaddle/PP-OCRv6 [2] PaddleOCR: https://github.com/PaddlePaddle/PaddleOCR?wt.mc_id=DT-MVP-5005195