一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

在 .NET MAUI 中接入 PP-OCRv6 离线文字识别

时间:2026-07-09 11:03:01 编辑:袖梨 来源:一聚教程网

1. 为什么要在 MAUI 里接入离线 OCR

最近在做 FlowBot 的流程自动化能力时,遇到一个很实际的问题:有些界面上的文字可以通过 Android 无障碍节点读取,有些却不行。比如小程序广告页、WebView、游戏化页面、图片按钮,系统节点里可能没有文本,或者文本和屏幕显示并不一致。

这时就需要一个真正基于图像的文字识别方案。云端 OCR 能用,但对于一个想要离线,即开即用的自动化工具来说并不理想:网络不稳定、延迟不可控,还会牵涉隐私和成本。更合适的方向是把 OCR 模型放进 App,本地截图、本地推理、本地判断。

这篇文章以 PP-OCRv6 的识别模型为例,记录如何把它接入 .NET MAUI Android 项目。

2. PP-OCRv6 简要介绍

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模型合集

3. 准备模型文件

这里我主要测试了两个识别模型:

代码语言:javascript

复制

script/pp_ocrv6_small_rec.onnxscript/PP-OCRv6_small_rec_inference.ymlscript/pp_ocrv6_tiny_rec.onnxscript/PP-OCRv6_tiny_rec_inference.yml

真正放进 MAUI App 的 Raw 资源里,只需要保留当前要使用的一组模型和配置:

代码语言:javascript

复制

src/FlowBot.App/Resources/Raw/inference.onnxsrc/FlowBot.App/Resources/Raw/inference.yml

这里有个容易忽略的点:不能只放 onnx,PP-OCR 的识别模型还需要 inference.yml 里的 character_dict 来做 CTC 解码。模型输出的是类别索引,不是直接输出文字;没有对应字典,就算推理成功也没办法使用。

MAUI 项目里 Raw 资源的配置如下:

代码语言:javascript

复制

<MauiAsset Include="ResourcesRaw**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />

这样打包后,Android 侧可以用 Assets.Open("inference.onnx")Assets.Open("inference.yml") 读取资源。

4. 读取 ONNX 输入输出信息

拿到模型以后,第一步不是直接写 App 代码,而是需要先确认模型输入输出形状。这个步骤很重要,因为预处理、tensor 排列、解码逻辑都依赖它。

我临时写了一个 Python 脚本读取 ONNX 元信息:

代码语言:javascript

复制

from pathlib import Pathimport onnxmodel_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_typedims = []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 模型读出来的信息大致是:

代码语言:javascript

复制

ir_version: 6opset:ai.onnx:11inputs:-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。

5. 先用 Python 跑通识别

在 App 里接模型之前,建议一定先写一个 Python 测试脚本。这样可以把问题拆开:如果 Python 都识别不对,就没必要急着怀疑 MAUI 或 Android 代码。

测试脚本做了几件事:

读取 inference.yml 中的 character_dict。用 Pillow 打开图片并缩放到高度 48。做归一化:(pixel / 255 - 0.5) / 0.5。从 HWC 转成 CHW,再扩展 batch 维度。用 onnxruntime 推理。对输出做 CTC greedy decode。

核心推理代码如下:

代码语言:javascript

复制

import numpy as npimport onnxruntime as ortfrom PIL import Imagesession = 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,合并连续重复字符。

代码语言:javascript

复制

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 = 0last_index = blank_indexresult = []result_scores = []for index, score inzip(indices, scores, strict=False):index = int(index)if index != blank_index and index != last_index:char_index = index - 1if0 <= char_index < len(characters):result.append(characters[char_index])result_scores.append(float(score))last_index = indexconfidence = sum(result_scores) / len(result_scores) if result_scores else0.0return"".join(result), confidence

我用一张广告任务截图测试,small 模型输出为:

代码语言:javascript

复制

text: 广告 浏览或查看商品30秒得奖励confidence: 0.9742

这说明模型本身、字典和预处理链路是对的。

6. 为什么最终选 small,而不是 tiny

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 并不是不能用。它更适合做“极速模式”,前提是你要拿自己的目标场景样本跑一批测试,确认误识率是否可以接受。

7. 在 .NET MAUI 中加入 ONNX Runtime

MAUI 项目里直接添加 NuGet 包:

代码语言:javascript

复制

dotnet add package Microsoft.ML.OnnxRuntime

项目文件会多出:

代码语言:javascript

复制

<ItemGroup><PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.27.0" /></ItemGroup>

在 Android 端使用时,需要注意一个细节:ONNX Runtime 的 InferenceSession 更适合从文件路径加载模型,而不是直接从 MAUI asset stream 加载。所以我的做法是第一次使用时把 Raw 里的 inference.onnx 复制到 cache 目录,再创建 session。

代码语言:javascript

复制

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;}

8. Android 无障碍截图

FlowBot 本身已经使用 Android 无障碍服务来做点击、滑动和节点文本读取。接入 OCR 后,图像来源也放在无障碍服务里:通过 Android 11 的 TakeScreenshot API 获取屏幕截图。

首先在无障碍服务配置里声明截图能力:

代码语言:javascript

复制

<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" />

然后在服务里封装截图方法:

代码语言:javascript

复制

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

代码语言:javascript

复制

[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 里我在启动时会检查服务状态,如果没开启就提示用户去设置里打开。

9. OCR 识别链路

在业务层,我给文字判断增加了一个识别方式:

代码语言:javascript

复制

public enum TextRecognitionMode{AccessibilityText,PpOcrV6Small}

执行时根据识别方式分派:

代码语言:javascript

复制

public Task<string> GetTextInRegionAsync(ScreenRegion region,TextRecognitionMode recognitionMode,CancellationToken cancellationToken){return recognitionMode switch{TextRecognitionMode.PpOcrV6Small => GetTextWithPpOcrV6Async(region, cancellationToken),_ => Task.FromResult(RequireService().GetTextInRegion(region))};}

FlowBot界面FlowBot界面

FlowBot界面

PP-OCR 的完整流程如下:

代码语言:javascript

复制

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

复制并加载 ONNX 模型。从 inference.yml 读取字符字典。把裁剪后的 bitmap 缩放到高度 48。转成 [1, 3, 48, W] 的 float tensor。执行 InferenceSession.Run。CTC 解码得到字符串。

预处理代码的核心是像素归一化和 CHW 排列:

代码语言:javascript

复制

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 版本基本保持一致:

代码语言:javascript

复制

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);}

10. 小结

搞完以上这些,手机端的离线 OCR 就算接入完成了,具体部署测试了一下,效果还可以,就是稍微可以感到一点卡顿。毕竟是每次截图、缩放、推理、解码,整个链路比直接读取无障碍文本要慢一些。

总体感觉还挺顺利,Microsoft.ML.OnnxRuntime 包真是好用,MAUI Android 端的推理和 Python 端几乎一致。

最终 FlowBot 的文字判断有了两种识别方式:

无障碍文本:快,适合普通原生控件。PP-OCRv6 small:基于截图识别,适合 WebView、图片文字、广告页等无障碍节点不可读的场景。

如果你也想在 MAUI 里接入离线 OCR,PP-OCRv6 small 是一个不错的选择。

References

[1] PP-OCRv6合集: https://www.modelscope.cn/collections/PaddlePaddle/PP-OCRv6[2] PaddleOCR: https://github.com/PaddlePaddle/PaddleOCR?wt.mc_id=DT-MVP-5005195

本文参与腾讯云自媒体同步曝光计划,分享自微信公众号。原始发表:2026-07-07,如有侵权请联系[email protected] 删除

热门栏目