十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#离线OCR:基于ONNX Runtime的RapidOCR部署实践

C#离线OCR:基于ONNX Runtime的RapidOCR部署实践 简介一套基于C#与ONNX Runtime调用PaddleOCR模型的完整识别Demo面向需要在.NET环境中集成中文OCR功能的开发人员尤其适合希望绕过Python部署、直接使用C#完成端到端识别的项目场景。压缩包共包含2000个文件核心代码为C#工程文件与cs源文件模型部分为6个onnx文件另含大量dll依赖库、配置文件、XML文档及少量示例图片可满足离线推理与二次开发需求整体体积约338.43MB内部目录按模型、依赖、示例等模块划分定位清晰。资源发布以来已有513人学习下载属于中小型实用工具类资源。通过该Demo读者可以获得可直接运行的C# OCR解决方案了解Onnx模型加载、预处理、推理及结果解析的完整流程同时附带Paddle中文识别模型省去自行下载与转换模型的环节便于快速验证效果并迁移到自有业务中。1. RapidOCR不是又一个TesseractCSharp场景下的离线选择做.NET的这几年每隔一段时间就要处理一次能不能内网识别证件/票据的需求。真到了生产环境公有云API大多走不了Tesseract对中文的识别又常常让人不敢上。RapidOCR几乎是这个场景下性价比最高的选择模型来自PaddleOCR但推理完全交给ONNX Runtime所以C#程序里可以直接引用模型文件不需要安装Python也不需要拽一个庞大的深度学习框架。项目标题里的RapidOCRCSharp正好点出两个关键词RapidOCR模型和CSharp宿主语言。下面不聊花哨的UI只讲把RapidOCR接进C#服务时模型怎么放、推理怎么调、参数怎么定、部署有哪些边界。2. RapidOCR本地部署的模型与CSharp运行环境2.1 为什么选ONNX Runtime而不是Paddle原生推理RapidOCR的核心模型源自PaddleOCR最早演示脚本都是Python写的。一到C#就出现一个老问题让.NET进程去强依赖PaddlePaddle的C库版本冲突、动态库缺失、跨平台Release都很痛苦。而RapidOCR的另一条路线是模型导出为ONNX运行时只对接ONNX Runtime。这样做有三个很实际的好处第一模型文件自包含内网部署时只需要拷目录第二ONNX Runtime对.NET生态有官方包C#调用它跟调用普通库没有区别第三模型推理经过图优化后CPU上的延迟并不比Paddle原生差多少。我在实际项目里一般直接下载官方预导出的ONNX模型或者用PaddleOCR自带的PaddleX转换出ONNX。稳妥起见我会把三个模型单独放一个目录det负责文本定位cls负责方向分类rec负责字符识别。类别别只看名字倘若你拿到的模型只有det和rec没有cls也能跑只是遇到手机拍摄的横竖混合图片时识别结果会明显变差。所以在迁到C#侧前先确认你手上的模型是三个还是两个缺哪个补哪个。2.2 准备CSharp工程三个NuGet包就够新建一个.NET 8控制台项目或者直接在Web API里加引用都可以。最简依赖是三件事OnnxRuntime推理库、一个图像处理库、以及对应平台的运行时。OpenCvSharp在这类任务里最顺手因为它自带矩形的透视变换、轮廓查找这些CV函数如果你不愿意引入OpenCV用ImageSharp把像素手掏一遍也可以但后处理代码会多不少。dotnet add package Microsoft.ML.OnnxRuntime dotnet add package OpenCvSharp4 dotnet add package OpenCvSharp4.runtime.winMicrosoft.ML.OnnxRuntime是微软维护的推理引擎本体支持CPU/GPU版本号不用刻意追新选一个稳定的即可OpenCvSharp4是跨平台封装底层仍然是原生OpenCVruntime.win包负责在Windows下把运行时dll复制到输出目录。如果目标是Linux容器请改用OpenCvSharp4.runtime.ubuntu相关包或者移除runtime包后自己在镜像里装OpenCV。然后是加载模型的骨架。SessionOptions里最需要注意的是线程数不设置的话默认取满逻辑核在Web应用里可能把请求线程池饿死。using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class RapidOcrEngine { private readonly InferenceSession _detSession; private readonly InferenceSession _clsSession; private readonly InferenceSession _recSession; public RapidOcrEngine(string modelDir, int threads 4) { var options new SessionOptions(); options.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; options.AppendExecutionProvider_CPU(); options.OrtRunThreads threads; _detSession new InferenceSession(Path.Combine(modelDir, det.onnx), options); _clsSession new InferenceSession(Path.Combine(modelDir, cls.onnx), options); _recSession new InferenceSession(Path.Combine(modelDir, rec.onnx), options); } }代码逻辑说明每个模型对应一个InferenceSession可以共享SessionOptionsGraphOptimizationLevel设成ORT_ENABLE_ALL让ONNX Runtime做层融合AppendExecutionProvider_CPU显式指定CPU执行避免在带有CUDA的机器上自动选到GPU后又因为缺少dll报错。OrtRunThreads设为4是给并发服务留余量的通用做法实际可压在2到8之间调。模型模块在管线中的职责缺少时的后果det找出图片中所有文字行的坐标框无法定位文本cls把倾斜/倒置的文本图校正为0°或180°旋转图片识别率下降rec把裁剪出的文本图转成字符串无法输出文字确认模型放到了modelDir目录后调用三个会话的顺序就是det - cls - rec。先跑通这一个骨架再往后做预处理和后处理排错范围会小很多。这也是典型的RapidOCR本地部署第一步先把模型加载起来别急着跑识别。提示如果加载模型时报Contains a dimension with value 0或Invalid Model之类的错误先检查该ONNX文件能否被Netron打开能打开就是路径或目录权限问题打不开就是模型不完整。但这里有一个细节det/cls/rec三个模型要用各自的Session还是共用Session我的习惯是三个模型分别建Session。因为ONNX Runtime的Session是独立的内存空间一个Session同时跑两个模型没有性能收益反而让输入输出管理更乱。三个Session的好处是你可以单独把rec模型换成高精度版本而不影响det坏处是内存会各占一份后面第4章会聊怎么控制。2.3 自己从PaddleOCR导出ONNX时的注意点如果官方预编译模型与实际图片水印、字体差异大就需要自己用新版模型导出。PaddleOCR仓库自带导出脚本用paddle2onnx转一次模型命令大概是paddle2onnx --model_dir ch_PP-OCRv4_det_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file det.onnx \ --opset_version 11注意导出的是推理模型不是训练好的权重目录。如果用错了文件ONNX Runtime加载时经常报No Op found或Input dims mismatch。另一个容易忽略的地方是rec模型的输入节点名通常叫xdet的输入节点名通常叫x也可能叫image命名以导出日志为准。C#侧用NamedOnnxValue.CreateFromTensor创建输入时字符串必须和这个节点名完全一致不一致时Run方法直接返回运行时错误。这里给你一个从Session读取输入名的办法foreach (var name in _recSession.InputMetadata.Keys) { Console.WriteLine($rec input name: {name}); }这一步能帮你避免大量输入名不匹配的排查。顺带说一句导出时opset_version不要设得太新11到13在ONNX Runtime的兼容性最好设成17以上反而可能触发一些新算子不带CPU实现的坑。3. 从图像到文本CSharp里的OCR管线与参数设置3.1 检测阶段的后处理阈值、轮廓与unclip拿到一张图第一件事是放缩和归一化然后喂给det模型。RapidOCR的det输出不是直接坐标而是一张与输入等比例的概率图图上每个像素表示这里有多大的可能是文字中心。后处理要做的就是用阈值把概率图变成二值图再找轮廓把轮廓外接多边形还原为文本框。下面这段是C#里最常见的后处理写法。它先把模型输出转成OpenCV的Mat然后二值化、找轮廓、取旋转矩形。需要引入System.Runtime.InteropServices和OpenCvSharp。using System.Runtime.InteropServices; using OpenCvSharp; Mat BuildTextBoxes(float[] detectionOutput, int width, int height, float threshold, float unclipRatio) { // detectionOutput 是det模型输出的概率图按CHW排列 using var probMap new Mat(width, height, MatType.CV_32FC1); Marshal.Copy(detectionOutput, 0, probMap.Data, width * height); using var binary new Mat(); Cv2.Threshold(probMap, binary, threshold, 255.0, ThresholdTypes.Binary); Cv2.FindContours(binary, out var contours, out var hierarchy, RetrievalModes.List, ContourApproximationModes.ApproxSimple); var polyPoints new ListPoint2f(); foreach (var contour in contours) { var minAreaRect Cv2.MinAreaRect(contour); var points minAreaRect.Points(); // unclip让框向外扩展一点把文字边缘完全包住 if (unclipRatio 1.0f) { var expanded points.Select(p p * unclipRatio).ToArray(); polyPoints.AddRange(expanded); } else { polyPoints.AddRange(points); } } return new Mat(polyPoints.ToArray()); }参数说明threshold是二值化阈值值越低越容易把背景噪声当成文字产生大量空框值越高越容易漏掉浅色或小字。unclipRatio用于把检测框向外扩因为det模型给出的轮廓往往贴着字符外沿直接裁剪会影响后面识别官方训练时默认在1.5~1.7之间代码里取1.6基本能适配大多数截图和照片。要注意这里使用的透视变换用的是Point2f的浮点数组接后续affine变换时不要转成整数否则边界会被截掉一个像素。实际在处理长图和密集文档时我还会在det之前把原图按最长边缩放到width不超过1280超过就等比缩小。RapidOCR官方对输入尺寸没有硬性要求但太大时模型预处理的resize会让字符变形太小则小字直接消失。3.2 识别阶段的输入尺寸、归一化与置信度回调检测框拿到之后要把每个框里的子图旋转正再输入到rec模型。rec的输入张量是固定深度宽高基本固定为3x48x320不同版本可能是3x32x320但测试时最好以实际模型的输入shape为准。常见做法是让高固定宽等比例裁剪后补齐到模型宽度。public float[] RecognizeFromCrop(Mat crop, bool withScore false) { var resized new Mat(); Cv2.Resize(crop, resized, new Size(320, 48)); Cv2.CvtColor(resized, resized, ColorConversionCodes.BGR2RGB); var tensor new DenseTensorfloat(new[] { 1, 3, 48, 320 }); for (int y 0; y 48; y) { for (int x 0; x 320; x) { var bgr resized.AtVec3b(y, x); tensor[0, 0, y, x] (bgr.Item2 / 255f - 0.5f) / 0.5f; // R tensor[0, 1, y, x] (bgr.Item1 / 255f - 0.5f) / 0.5f; // G tensor[0, 2, y, x] (bgr.Item0 / 255f - 0.5f) / 0.5f; // B } } var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(x, tensor) }; using var result _recSession.Run(inputs); var output result.First().AsEnumerablefloat().ToArray(); return output; }代码逻辑说明OpenCvSharp的Mat.At 取的是BGR顺序而PaddleOCR训练时用的是RGB归一化所以转换时顺序不能错。归一化公式是像素/255 - 0.5/ 0.5等价于把像素映射到[-1,1]区间。rec输出通常是一个二维数组第一维是时间步第二维是字符表概率后处理还要接CTCLoss解码这里先返回原始概率数组。参数建议值调整方向det阈值 box_thresh0.3 ~ 0.6值越低召回越高、误检越多unclip_ratio1.5 ~ 1.8框太小就调大贴近文字时下调识别置信度 rec_score_thresh0.3 ~ 0.5最终过滤低质量识别结果输入最长边960 ~ 1280超过后先等比缩放调参时有一个容易踩的坑rec_score_thresh不要设得过高。很多C#开发者为了只要准确结果直接设成0.9结果把带水印、低对比度的样本全部丢弃。生产环境里rec_score低于阈值的记录应当单独落库而不是直接报识别失败这样后续还能用规则修正。3.3 解码输出把概率数组变成文本rec模型的输出一般是一个序列概率分布需要做两步后处理先取每个时间步概率最大的索引然后跳过blank和重复字符。用C#写一个简化解码器并不复杂public string DecodeOutput(float[] output, int channels, string[] charDict) { var sb new StringBuilder(); for (int t 0; t output.Length / channels; t) { var slice output.Skip(t * channels).Take(channels).ToArray(); int idx Array.IndexOf(slice, slice.Max()); // 假设0是blank最后一个也是padding字符 if (idx ! 0 idx ! charDict.Length - 1) { if (sb.Length 0 || charDict[idx] ! sb[^1]) sb.Append(charDict[idx]); } } return sb.ToString(); }这里最关键的是blank处理。CTC的解码规则是连续相同的字符只保留一个遇到blank就切分所以ab不会变成aa如果直接照搬Python代码里的循环很容易把好识别成好好。生产代码里还要考虑时间步很长时的性能可以用Array.IndexOf替代LINQ的SkipTake能减少不少GC压力。4. RapidOCR本地部署的CPU/GPU边界与内存控制4.1 线程、内存与进程模型的选择RapidOCR本地部署的常见误区是服务器有16核是不是并发设成16最快。答案是否定的。OCR管线里det/rec两个模型是串行的每个请求内部要经历预处理、两次推理、后处理推理线程设得过满会导致线程切换和内存带宽竞争整体吞吐反而下降。另一个问题是OnnxRuntime默认使用的arena内存分配器会预留大块内存并发高时进程内存看上去一路涨到几个GB这并不是泄漏而是它的缓存策略。我的建议是如果服务以并发为主每个InferenceSession的线程数压到2到4如果服务是任务队列式只有少量并行可以给到6或8。内存方面直接用SessionOptions开启内存池复用并且避免每个请求new一个Session。var options new SessionOptions(); options.OrtRunThreads 4; options.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; options.EnableMemoryPattern true; options.ExecutionMode ExecutionMode.ORT_SEQUENTIAL;参数说明EnableMemoryPattern会缓存推理时分配的Tensor内存在重复调用中复用ExecutionMode设为ORT_SEQUENTIAL保证同一session梯度执行避免并行session导致CPU占用翻倍。如果你把多个模型串在一个pipeline里用ORT_PARALLEL反而得不偿失因为OCR各阶段是强依赖关系。部署方式单图延迟内存占用适合场景CPU float32模型约300-600ms图宽1280350-500MB内网API并发10以下CPU int8量化模型约200-400ms250-400MB需要更快响应或弱CPUGPUCUDA50-150ms1GB以上高并发或大批量离线任务这张表的数字是大致量级实际会随模型版本、输入尺寸和硬件浮动。但有一件事是确定的CPU量化模型在多数场景里识别准确率掉不到1%部署麻烦却少很多。我一般先把int8量化模型接入跑通后再看是否需要GPU。4.2 用SemaphoreSlim限制并发避免内存雪崩在Web API里同一个RapidOcrEngine实例的所有推理调用必须串行或受限并发。ONNX Runtime的Session本身是线程安全的但并行调用时内存分配会剧烈波动而且CPU线程已经占用再多并发只会排队不会让单张图变快。常见稳妥做法是给整个识别动作加一个信号量。private readonly SemaphoreSlim _ocrLock new(2, 2); public async Taskstring RecognizeAsync(Mat image, CancellationToken ct) { await _ocrLock.WaitAsync(ct); try { return await Task.Run(() RecognizeInternal(image)); } finally { _ocrLock.Release(); } }这段代码把OCR送入独占的任务循环限制同时只有2个请求能进入推理其它请求在信号量上排队。好处是内存曲线稳定坏处是队列等待会被算进响应时间因此上层还要配一个超时和队列长度上限。不要试图用lock直接包住OnnxRuntime.Run因为lock阻塞的是调用线程还是在消耗线程池信号量配合async等待才能真正把并发让给别的IO请求。5. 一个验证技巧用一组标准样本集守住回归底线调阈值这种事最怕的是这儿好了那儿坏了。我会在项目里固定维护20到30张有代表性的图片比如清晰的票据、旋转过的拍照、低对比度截图、纯英文每张都预先写好了期望包含的字符串然后写一个单元测试每天改参数后跑一遍。代码用xUnit的Theory从外部文件读取样本识别结果和期望文本做编辑距离比较低于3个字符的差异才算通过。[Theory] [InlineData(samples/invoice_01.png, 发票号码12345678)] [InlineData(samples/cert_02.png, CERTIFICATE OF COMPLETION)] public void RapidOcr_回归样本_识别误差可控(string imagePath, string expected) { var engine new RapidOcrEngine(models); var actual engine.Recognize(new Mat(imagePath)); var distance EditDistance(actual, expected); Assert.True(distance 3, ${imagePath} 期望 [{expected}] 实际 [{actual}] (编辑距离{distance})); } static int EditDistance(string a, string b) { var dp new int[a.Length 1, b.Length 1]; for (int i 0; i a.Length; i) dp[i, 0] i; for (int j 0; j b.Length; j) dp[0, j] j; for (int i 1; i a.Length; i) for (int j 1; j b.Length; j) { var cost (a[i - 1] b[j - 1]) ? 0 : 1; dp[i, j] Math.Min(Math.Min( dp[i - 1, j] 1, dp[i, j - 1] 1), dp[i - 1, j - 1] cost); } return dp[a.Length, b.Length]; }这个测试看起来简单但它比人工看效果图要可靠得多。因为OCR的结果允许个别字符对不上用完全相等断言会让测试一直在报红用编辑距离又太宽松阈值3是经过几十次调整后确定的既捕捉到模型替换、漏字也不会因为地名缩写被误杀。这里还故意把dataset作为外部文件路径而不是内嵌资源这样从后台管理界面上传新样本时不需要重新编译测试项目。把这段测试挂到CI或者本地pre-commit钩子上每次改模型、改unclip、换Quantize方案时自动跑一遍。如果新增样本导致回归率下降就该回查本次改动而不是硬调阈值把分数补回来。这比刚才那两张图看着还行更能守住识别质量。本文还有配套的精品资源点击获取
返回列表