首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >在 .NET MAUI 里接入 PP-OCRv6 离线文字识别

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

作者头像
桑榆肖物
发布2026-07-08 17:12:09
发布2026-07-08 17:12:09
40
举报

本文记录一次完整的 PP-OCRv6 接入手机的过程,介绍了如何在 .NET MAUI Android 应用中通过无障碍截图和 ONNX Runtime 完成离线 OCR 识别。

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.onnx
script/PP-OCRv6_small_rec_inference.yml
script/pp_ocrv6_tiny_rec.onnx
script/PP-OCRv6_tiny_rec_inference.yml

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

代码语言:javascript
复制
src/FlowBot.App/Resources/Raw/inference.onnx
src/FlowBot.App/Resources/Raw/inference.yml

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

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

代码语言:javascript
复制
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />

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

4. 读取 ONNX 输入输出信息

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

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

代码语言:javascript
复制
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 模型读出来的信息大致是:

代码语言:javascript
复制
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。

5. 先用 Python 跑通识别

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

测试脚本做了几件事:

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

核心推理代码如下:

代码语言:javascript
复制
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,合并连续重复字符。

代码语言: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 = 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 模型输出为:

代码语言: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

  1. 复制并加载 ONNX 模型。
  2. inference.yml 读取字符字典。
  3. 把裁剪后的 bitmap 缩放到高度 48
  4. 转成 [1, 3, 48, W] 的 float tensor。
  5. 执行 InferenceSession.Run
  6. 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,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 桑榆肖物 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 为什么要在 MAUI 里接入离线 OCR
  • 2. PP-OCRv6 简要介绍
  • 3. 准备模型文件
  • 4. 读取 ONNX 输入输出信息
  • 5. 先用 Python 跑通识别
  • 6. 为什么最终选 small,而不是 tiny
  • 7. 在 .NET MAUI 中加入 ONNX Runtime
  • 8. Android 无障碍截图
  • 9. OCR 识别链路
  • 10. 小结
    • References
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档