本文记录一次完整的 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合集或官方 GitHub 仓库 PaddleOCR 下载。3. 准备模型文件这里我主要测试了两个识别模型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这里有个容易忽略的点不能只放onnxPP-OCR 的识别模型还需要inference.yml里的character_dict来做 CTC 解码。模型输出的是类别索引不是直接输出文字没有对应字典就算推理成功也没办法使用。MAUI 项目里 Raw 资源的配置如下MauiAssetIncludeResources\Raw\**LogicalName%(RecursiveDir)%(Filename)%(Extension)/这样打包后Android 侧可以用Assets.Open(inference.onnx)和Assets.Open(inference.yml)读取资源。4. 读取 ONNX 输入输出信息拿到模型以后第一步不是直接写 App 代码而是需要先确认模型输入输出形状。这个步骤很重要因为预处理、tensor 排列、解码逻辑都依赖它。我临时写了一个 Python 脚本读取 ONNX 元信息frompathlibimportPathimportonnx model_pathPath(inference.onnx)modelonnx.load(str(model_path))print(fir_version:{model.ir_version})opsets[f{op.domainorai.onnx}:{op.version}foropinmodel.opset_import]print(fopset:{, .join(opsets)})fordirection,valuesin((input,model.graph.input),(output,model.graph.output)):print(f{direction}s:)forvalueinvalues:tensor_typevalue.type.tensor_type dims[]fordimintensor_type.shape.dim:ifdim.dim_param:dims.append(dim.dim_param)elifdim.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_type1, shape[DynamicDimension.0, 3, 48, DynamicDimension.1] outputs: - fetch_name_0: elem_type1, shape[DynamicDimension.0, Reshape_471_o0__d2, 18710]这里可以看出几个关键信息输入是float32形状为[N, 3, 48, W]。高度固定为48宽度是动态的。输出是[N, T, C]其中C18710对应 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。核心推理代码如下importnumpyasnpimportonnxruntimeasortfromPILimportImage sessionort.InferenceSession(str(model_path),providers[CPUExecutionProvider])input_metasession.get_inputs()[0]output_metasession.get_outputs()[0]tensorpreprocess(image_path,input_height48,max_width960)logitssession.run([output_meta.name],{input_meta.name:tensor})[0]text,confidencectc_greedy_decode(logits,characters)CTC 解码的逻辑也不复杂每个时间步取最大概率类别跳过 blank合并连续重复字符。defctc_greedy_decode(logits:np.ndarray,characters:list[str])-tuple[str,float]:sequencelogits[0]indicessequence.argmax(axis1)scoressequence.max(axis1)blank_index0last_indexblank_index result[]result_scores[]forindex,scoreinzip(indices,scores,strictFalse):indexint(index)ifindex!blank_indexandindex!last_index:char_indexindex-1if0char_indexlen(characters):result.append(characters[char_index])result_scores.append(float(score))last_indexindex confidencesum(result_scores)/len(result_scores)ifresult_scoreselse0.0return.join(result),confidence我用一张广告任务截图测试small 模型输出为text: 广告 浏览或查看商品30秒得奖励 confidence: 0.9742这说明模型本身、字典和预处理链路是对的。6. 为什么最终选 small而不是 tinytiny 的优势很明显小、快。在同一张测试图上我做了 30 次推理平均耗时对比模型文件大小识别结果置信度平均推理耗时PP-OCRv6 small rec21.16 MB广告 浏览或查看商品30秒得奖励0.97423.86 msPP-OCRv6 tiny rec4.46 MB广告 浏歧助查有商品50沙得奖励0.61841.03 ms从速度看tiny 在 PC CPU 上大约快了 3.7 倍包体也只有 small 的 21% 左右。但这张图里tiny 把“浏览或查看商品30秒得奖励”识别错了好几个字关键数字也错成了50。对 FlowBot 这种自动化工具来说文字判断一旦误识别后续点击、循环、等待逻辑都会跟着偏。相比多花几毫秒我更在意判断可靠性。所以最终选择了 small 作为默认模型。当然tiny 并不是不能用。它更适合做“极速模式”前提是你要拿自己的目标场景样本跑一批测试确认误识率是否可以接受。7. 在 .NET MAUI 中加入 ONNX RuntimeMAUI 项目里直接添加 NuGet 包dotnetaddpackage Microsoft.ML.OnnxRuntime项目文件会多出ItemGroupPackageReferenceIncludeMicrosoft.ML.OnnxRuntimeVersion1.27.0//ItemGroup在 Android 端使用时需要注意一个细节ONNX Runtime 的InferenceSession更适合从文件路径加载模型而不是直接从 MAUI asset stream 加载。所以我的做法是第一次使用时把 Raw 里的inference.onnx复制到 cache 目录再创建 session。privatestaticstringCopyAssetToCache(stringassetName){varcontextAndroid.App.Application.Context;vartargetPathSystem.IO.Path.Combine(context.CacheDir?.AbsolutePath??FileSystem.CacheDirectory,assetName);usingvarinputcontext.Assets?.Open(assetName)??thrownewInvalidOperationException($未找到 PP-OCRv6 资源{assetName}。);usingvaroutputFile.Create(targetPath);input.CopyTo(output);returntargetPath;}8. Android 无障碍截图FlowBot 本身已经使用 Android 无障碍服务来做点击、滑动和节点文本读取。接入 OCR 后图像来源也放在无障碍服务里通过 Android 11 的TakeScreenshotAPI 获取屏幕截图。首先在无障碍服务配置里声明截图能力accessibility-servicexmlns:androidhttp://schemas.android.com/apk/res/androidandroid:accessibilityEventTypestypeAllMaskandroid:accessibilityFeedbackTypefeedbackGenericandroid:accessibilityFlagsflagReportViewIds|flagRetrieveInteractiveWindows|flagIncludeNotImportantViewsandroid:canPerformGesturestrueandroid:canTakeScreenshottrueandroid:canRetrieveWindowContenttrueandroid:descriptionstring/app_nameandroid:notificationTimeout50/然后在服务里封装截图方法publicTaskBitmapTakeScreenshotBitmapAsync(CancellationTokencancellationToken){if(!OperatingSystem.IsAndroidVersionAtLeast(30)){thrownewNotSupportedException(PP-OCR 截图识别需要 Android 11 或更高版本。);}varcompletionnewTaskCompletionSourceBitmap(TaskCreationOptions.RunContinuationsAsynchronously);varregistrationcancellationToken.Register(()completion.TrySetCanceled(cancellationToken));completion.Task.ContinueWith(_registration.Dispose(),CancellationToken.None,TaskContinuationOptions.ExecuteSynchronously,TaskScheduler.Default);TakeScreenshot((int)Display.DefaultDisplay,DirectExecutor.Instance,newScreenshotCallback(completion));returncompletion.Task;}截图回调里系统给到的是HardwareBuffer需要转成Bitmap[SupportedOSPlatform(android30.0)]privatestaticBitmapCopyScreenshotBitmap(ScreenshotResultscreenshot){usingvarhardwareBufferscreenshot.HardwareBuffer;varbitmapBitmap.WrapHardwareBuffer(hardwareBuffer,screenshot.ColorSpace)??thrownewInvalidOperationException(无法从系统截图创建位图。);returnbitmap.Copy(Bitmap.Config.Argb8888!,false)??thrownewInvalidOperationException(无法复制系统截图位图。);}这里有一个运行时注意事项如果你更新了android:canTakeScreenshot安装新版 App 后最好到系统设置里重新关闭/开启一次无障碍服务让系统刷新服务能力。不过对于我测试的 Android 设备来说只要不是后台常驻的 APP 每次打开就需要重新开启无障碍服务。FlowBot 里我在启动时会检查服务状态如果没开启就提示用户去设置里打开。9. OCR 识别链路在业务层我给文字判断增加了一个识别方式publicenumTextRecognitionMode{AccessibilityText,PpOcrV6Small}执行时根据识别方式分派publicTaskstringGetTextInRegionAsync(ScreenRegionregion,TextRecognitionModerecognitionMode,CancellationTokencancellationToken){returnrecognitionModeswitch{TextRecognitionMode.PpOcrV6SmallGetTextWithPpOcrV6Async(region,cancellationToken),_Task.FromResult(RequireService().GetTextInRegion(region))};}PP-OCR 的完整流程如下privatestaticasyncTaskstringGetTextWithPpOcrV6Async(ScreenRegionregion,CancellationTokencancellationToken){cancellationToken.ThrowIfCancellationRequested();varserviceRequireService();usingvarscreenshotawaitservice.TakeScreenshotBitmapAsync(cancellationToken);usingvarcroppedCropBitmap(screenshot,region);returnPpOcrRecognizerInstance.Value.Recognize(cropped);}这个方法里只做流程组织具体模型推理放到PpOcrRecognizer复制并加载 ONNX 模型。从inference.yml读取字符字典。把裁剪后的 bitmap 缩放到高度48。转成[1, 3, 48, W]的 float tensor。执行InferenceSession.Run。CTC 解码得到字符串。预处理代码的核心是像素归一化和 CHW 排列privatestaticDenseTensorfloatCreateInputTensor(Bitmapbitmap){varwidthbitmap.Width;varheightbitmap.Height;varpixelsnewint[width*height];bitmap.GetPixels(pixels,0,width,0,0,width,height);vartensornewDenseTensorfloat([1,3,height,width]);for(vary0;yheight;y){for(varx0;xwidth;x){varpixelpixels[(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));}}returntensor;}privatestaticfloatNormalizeChannel(intvalue)((value/255f)-0.5f)/0.5f;CTC 解码和 Python 版本基本保持一致privatestringDecode(Tensorfloatoutput){vardimensionsoutput.Dimensions.ToArray();vartimeStepsdimensions[1];varclassCountdimensions[2];varcharacters_characters;if(characters.Count2classCount){characterscharacters.Concat([ ]).ToList();}varresultnewListstring();varlastIndex0;for(varstep0;steptimeSteps;step){varbestIndex0;varbestScorefloat.NegativeInfinity;for(varclassIndex0;classIndexclassCount;classIndex){varscoreoutput[0,step,classIndex];if(scorebestScore){bestScorescore;bestIndexclassIndex;}}if(bestIndex!0bestIndex!lastIndex){varcharacterIndexbestIndex-1;if(characterIndex0characterIndexcharacters.Count){result.Add(characters[characterIndex]);}}lastIndexbestIndex;}returnstring.Concat(result);}10. 小结搞完以上这些手机端的离线 OCR 就算接入完成了具体部署测试了一下效果还可以就是稍微可以感到一点卡顿。毕竟是每次截图、缩放、推理、解码整个链路比直接读取无障碍文本要慢一些。总体感觉还挺顺利Microsoft.ML.OnnxRuntime包真是好用MAUI Android 端的推理和 Python 端几乎一致。最终 FlowBot 的文字判断有了两种识别方式无障碍文本快适合普通原生控件。PP-OCRv6 small基于截图识别适合 WebView、图片文字、广告页等无障碍节点不可读的场景。如果你也想在 MAUI 里接入离线 OCRPP-OCRv6 small 是一个不错的选择。