简介:面向C#开发者的PaddleOCR部署示例工程,基于OpenVINO推理引擎在.NET环境下调用PaddleOCR模型,完成图片文字检测与识别,解决C#项目难以直接集成Python版OCR的痛点。工程完整展示了由C#调用C++原生接口,经OpenVINO加载模型,覆盖图像预处理、检测框解码、方向分类、文本识别与结果可视化的完整调用链。资源共36个文件,压缩包仅3.48MB,其中包含5个C#核心源码、C++封装工程(含动态库、导入库及工程配置文件)、14张测试样张与推理效果图、3份说明文档、模型字典及下载说明,并附带Visual Studio解决方案和PDF教程,目录划分清楚,便于按模块对照学习,C#源码负责上层调用,C++工程提供底层接口。读者可根据文档步骤编译还原,快速跑通识别流程,也可以将C#封装方法提取到自有项目中,调整输入输出或替换模型文件,集成离线OCR能力。目前已有410人学习下载,适合具备一定C#基础、想在Windows桌面应用中实现中文识别的开发者参考。
1. 为什么桌面应用选择C#加OpenVINO跑PaddleOCR
做上位机或者桌面工具的人,迟早会遇到一个需求:在本地识别一张图片里的文字,不把图片传出去,也不想让用户装Python环境。PaddleOCR的精度在中文场景下表现稳定,但它的原生接口是Python,C#集成起来一直绕路。常见做法有几种:起一个本地HTTP服务、用PaddleOCR的C API做P/Invoke、导出ONNX用ONNX Runtime推理。如果你优先考虑CPU上的推理效率和部署体积,OpenVINO是更顺手的一条路径——它能把PaddleOCR的推理模型转成IR格式,在Intel CPU上跑出比原始Paddle Inference更稳定的性能,而且C#有官方绑定,不需要自己写一层不靠谱的C++封装。本文按“模型转换、C#推理骨架、预处理后处理、整链编排、性能排错”这条线,把一套可复现的部署方案讲完。适合手里有PaddleOCR模型、想在Visual Studio里用C#做本地OCR的开发者。
2. 先把PaddleOCR模型转成OpenVINO能加载的格式
2.1 用paddle2onnx完成PaddleOCR到ONNX的导出
PaddleOCR发布的inference模型包含inference.pdmodel和inference.pdiparams两个文件。OpenVINO不能直接读这种格式,需要先经过ONNX。Paddle官方提供了paddle2onnx工具,一行命令就可以完成转换。转换前先确认Python环境里装了paddlepaddle、paddle2onnx,同时把paddleocr的paddleocr命令装好,因为后面要拿它下载或验证模型文件。
paddle2onnx \ --model_dir ./inference/ch_PP-OCRv4_det_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx/ch_PP-OCRv4_det.onnx \ --opset_version 11 \ --enable_onnx_checker Truerec模型用同一套命令,把路径换成ch_PP-OCRv4_rec_infer,输出文件名改成ch_PP-OCRv4_rec.onnx。转完用onnx.checker.check_model或者直接让OpenVINO加载一次来验证模型结构没有损坏。--opset_version建议固定在11,OpenVINO对11的支持最稳,某些新opset算子可能导致转换阶段报“Unsupported operation”。
2.2 用ovc把ONNX转成IR或直接加载ONNX
OpenVINO新版本推荐用ovc命令替代老的mo,把ONNX转成IR格式,生成det.xml和det.bin两个文件。注意OpenVINO 2023.3之后ovc已经作为独立命令提供,不需要再管mo_onnx.py那套旧调用了。
ovc ch_PP-OCRv4_det.onnx --output_model ./ir/ch_PP-OCRv4_det.xml ovc ch_PP-OCRv4_rec.onnx --output_model ./ir/ch_PP-OCRv4_rec.xml转换完成后的IR文件才是推荐的生产格式。相比直接加载ONNX,IR形态的模型图结构经过优化,加载速度更快,内存占用更稳定。直接加载ONNX也完全可以,OpenVINO的C# API会隐式做一次转换,但每次启动都要多花时间。
| 加载方式 | 启动耗时 | 推理性能 | 部署物体积 | 适用场景 |
|---|---|---|---|---|
| 直接加载ONNX | 较慢 | 与IR基本一致 | 只带onnx文件 | 快速验证、跨框架调试 |
| 加载IR | 快 | 一致 | xml + bin 两个文件 | 生产交付、上位机集成 |
我一般会把IR和ONNX同时保留:调试时用ONNX,发布时打IR包。OpenVINO是允许直接读ONNX的,所以即使漏了转换步骤,程序也不会立刻报错,容易被忽视。
2.3 转换失败时先看这三个参数
转换报错场景里九成是三个原因。第一,--opset_version太高,部分Paddle算子在ONNX里没有对应映射,降到11多半能绕开。第二,动态shape导致ovc在推理时才知道输入尺寸、优化时无法确定张量形状,给--input显式传一个[1,3,-1,-1]并加--dynamic_shapes配合。第三,rec模型里的LSTM算子转换失败,这是老版本paddle2onnx的已知问题,升级到最新版后重导一次即可。
3. 在C#工程里搭出OpenVINO推理的最小骨架
3.1 NuGet包选择和工程结构
Visual Studio里新建一个.NET 6或.NET 8的控制台项目或者WPF项目,NuGet里装OpenVINO.CSharp和OpenVINO.runtime两个包。OpenVINO.CSharp提供C#风格封装,OpenVINO.runtime承载底层原生库。记住x64是OpenVINO C#绑定的默认目标平台,把解决方案平台切到x64再编译,否则会撞DllNotFoundException。
dotnet add package OpenVINO.CSharp dotnet add package OpenVINO.runtime工程里建议的目录结构是:Models/放xml和bin、Inference/放封装好的Detector和Recognizer类、Utils/放图像预处理和坐标映射工具。PaddleOCR有三个模型——det、rec、cls,后面会分别封装,不要写成一个类里人肉切换。
3.2 用Core加载模型并完成一次推理
OpenVINO的C# API整体流程是:创建Core、读模型、编译模型、创建推理请求、塞输入、执行、取输出。下面这段是det模型的最小推理代码,rec模型结构完全相同,区别只在张量名称和shape。
using OpenVinoSharp; using OpenVinoSharp.Extensions; var core = new Core(); var model = core.ReadModel("./Models/ch_PP-OCRv4_det.xml"); var compiled = core.CompileModel(model, "CPU"); using (var request = compiled.CreateInferRequest()) { // 读取图片并转为浮点张量,张量形状 [1,3,H,W] float[] inputData = LoadImageAsTensor("test.jpg", 640, 640); var inputShape = new Shape(1, 3, 640, 640); var inputTensor = new Tensor(OpenVinoSharp.ElementType.F32, inputShape, inputData); request.SetInputTensor(inputTensor); request.Infer(); var outputTensor = request.GetOutputTensor(); var outputData = outputTensor.GetData<float>(); Console.WriteLine($"输出张量形状: {outputTensor.Shape}, 数据长度: {outputData.Length}"); }Core.ReadModel负责从磁盘加载IR或ONNX文件。CompileModel的第二个参数是设备名,支持CPU、GPU、AUTO,桌面端建议先用CPU跑通再考虑核显。SetInputTensor要求张量形状与模型输入完全一致,PaddleOCR的det模型输入是[1,3,H,W],H和W需要是32的倍数,否则推理阶段会报形状不匹配。GetOutputTensor返回det的输出,形状是[1,1,H,W],对应的是每个像素的文本框概率图,后处理里要对它做二值化和轮廓查找。
3.3 设备字符串、CPU线程数这些参数在哪里改
CompileModel之前可以通过OVCoreProperties或配置字典控制推理后端的行为。OpenVINO C#绑定提供了SetProperty这类接口,设备名称、线程数和性能模式都能在编译时定好。
var properties = new Dictionary<string, string> { { "NUM_STREAMS", "1" }, { "INFERENCE_NUM_THREADS", "4" }, { "PERFORMANCE_HINT", "LATENCY" } }; var compiled = core.CompileModel(model, "CPU", properties);NUM_STREAMS:并行推理流的数量。设成1时延迟最低,适合逐张图片识别的上位机场景;设成4时吞吐量高,但单张延迟会变大。INFERENCE_NUM_THREADS:CPU推理线程数。8核机器设4到6比较稳妥,设成0让OpenVINO自己决定也行。PERFORMANCE_HINT:LATENCY偏延迟优先,THROUGHPUT偏吞吐优先。如果只是识别单张图片,LATENCY;如果是无界面批量处理,换THROUGHPUT。
这些参数调整后不需要重新编译模型,运行时就能生效,适合做配置界面的可选项。
4. C#端图像预处理与输出后处理,避开常见的坑
4.1 图像缩放、归一化和HWC转CHW的完整实现
PaddleOCR的输入要求是:BGR格式、除以255归一化、按[0.485, 0.456, 0.406]做均值、按[0.229, 0.224, 0.225]做方差,最后排成CHW。OpenCV的C#封装OpenCvSharp在这里是标配,直接用Cv2读图、缩放、填充。
public static float[] Preprocess(Mat src, int targetH, int targetW) { // 保持长宽比的缩放 + 填充,避免文字被拉伸 float scale = Math.Min((float)targetH / src.Rows, (float)targetW / src.Cols); int resizedH = (int)(src.Rows * scale); int resizedW = (int)(src.Cols * scale); Mat resized = new Mat(); Cv2.Resize(src, resized, new Size(resizedW, resizedH)); Mat padded = new Mat(new Size(targetW, targetH), MatType.CV_8UC3, new Scalar(0, 0, 0)); resized.CopyTo(padded[new OpenCvSharp.Rect(0, 0, resizedW, resizedH)]); // BGR -> CHW + 归一化 float[] result = new float[3 * targetH * targetW]; int index = 0; for (int c = 0; c < 3; c++) { for (int h = 0; h < targetH; h++) { for (int w = 0; w < targetW; w++) { Vec3b pixel = padded.At<Vec3b>(h, w); float value = pixel[c] / 255.0f; value = (value - new float[] { 0.485f, 0.456f, 0.406f }[c]) / new float[] { 0.229f, 0.224f, 0.225f }[c]; result[index++] = value; } } } return result; }pixel[c]在OpenCvSharp里是按BGR顺序取的,c=0是B通道、c=1是G通道、c=2是R通道,正好对应PaddleOCR的输入约定。很多人在这个地方踩坑:把OpenCV默认的BGR当成RGB送进去,导致识别率骤降。填充颜色用Scalar(0,0,0),黑色填充不会给归一化带来额外偏置,但要注意记录缩放比例和填充偏移量,后处理还原坐标时要用。
4.2 det输出后处理:概率图、二值化和连通域找框
det模型输出一张单通道概率图,每个像素值表示该点属于文本框的概率。拿到输出数组后,先做Sigmoid压缩到0到1之间,再用阈值0.3做二值化,最后用连通域分析找出每个文字框的轮廓。
public static List<Rect> PostprocessDet(float[] output, int mapH, int mapW, float threshold = 0.3f) { Mat probMap = new Mat(mapH, mapW, MatType.CV_32FC1, output); Mat binary = new Mat(); Cv2.Threshold(probMap, binary, threshold, 1.0, ThresholdTypes.Binary); // 连通域分析,过滤掉面积过小的噪声块 Mat labels = new Mat(); Mat stats = new Mat(); Mat centroids = new Mat(); int numLabels = Cv2.ConnectedComponentsWithStats(binary, labels, stats, centroids); List<Rect> boxes = new List<Rect>(); for (int i = 1; i < numLabels; i++) { int area = stats.At<int>(i, (int)ConnectedComponentsTypes.Area); if (area < 10) continue; int x = stats.At<int>(i, (int)ConnectedComponentsTypes.Left); int y = stats.At<int>(i, (int)ConnectedComponentsTypes.Top); int w = stats.At<int>(i, (int)ConnectedComponentsTypes.Width); int h = stats.At<int>(i, (int)ConnectedComponentsTypes.Height); boxes.Add(new Rect(x, y, w, h)); } return boxes; }ConnectedComponentsWithStats是OpenCvSharp里现成的函数,一次调用同时拿到轮廓属性、质心和像素面积。area < 10的过滤阈值需要根据实际图片调整:小字密排的截图可以降到3,大字海报可以升到50。这个方法比FindContours更稳,因为FindContours需要额外做多边形逼近,而检测模型输出的边缘往往带毛刺,ConnectedComponentsWithStats直接给出外接矩形,够用了。
4.3 rec输出后处理:CTC解码去掉重复字符
rec模型的输出形状是[1, 25, 6625],25是序列长度,6625是字符表大小加上blank。C#端的解码逻辑和PaddleOCR的CTCLabelDecode保持一致:每个时间步取最大概率的索引,去掉blank和相邻重复字符。
public static string DecodeRecOutput(float[] output, int seqLen, int numClasses, string[] charList) { List<int> indices = new List<int>(); for (int t = 0; t < seqLen; t++) { int bestIdx = 0; float bestScore = float.MinValue; for (int c = 0; c < numClasses; c++) { float score = output[t * numClasses + c]; if (score > bestScore) { bestScore = score; bestIdx = c; } } indices.Add(bestIdx); } // 合并重复字符,跳过blank(索引0表示blank) System.Text.StringBuilder sb = new System.Text.StringBuilder(); int prev = -1; foreach (int idx in indices) { if (idx == 0 || idx == prev) continue; sb.Append(charList[idx]); prev = idx; } return sb.ToString(); }这段逻辑对应PaddleOCR的CTC解码规则。prev用来记录上一个非blank索引,遇到连续重复字符时只保留一个。charList是字符表,来自PaddleOCR发布包里的ppocr_keys_v1.txt,C#端可以用File.ReadAllLines按索引读入,注意该文件里第一行是blank占位符,所以charList[0]不该被输出。
5. 检测加识别串起来,一个可用的OCR全流程
5.1 坐标映射、裁剪和识别顺序
det给出的是缩放后图片上的坐标,要还原到原图才能裁剪出文字区域。缩放比例scale和填充偏移量在预处理时已经记录,逆向映射就是做一次坐标变换。
public static void RunOcr(Mat src, Detector det, Recognizer rec) { int targetH = 640; int targetW = 640; float scale = Math.Min((float)targetH / src.Rows, (float)targetW / src.Cols); int offsetX = 0, offsetY = 0; // 检测阶段,拿到缩放图上的文本框 float[] detOutput = det.Run(src); // 内部包含预处理 + 推理 var boxes = PostprocessDet(detOutput, targetH, targetW); // 把缩放图坐标映射回原图 var originalBoxes = boxes.Select(box => new Rect { X = (int)((box.X - offsetX) / scale), Y = (int)((box.Y - offsetY) / scale), Width = (int)(box.Width / scale), Height = (int)(box.Height / scale) }).ToList(); // 对每个文本框裁剪、缩放后送rec识别 foreach (var box in originalBoxes) { using (Mat crop = new Mat(src, box)) { string text = rec.Recognize(crop); Console.WriteLine($"识别结果: {text}, 位置: {box}"); } } }src是原始图片,box是原始图片上的裁剪区域,crop直接通过new Mat(src, box)截取,不需要额外拷贝内存。识别结果的顺序在这个版本里是乱的,因为连通域分析不保证从上到下输出,需要按坐标排序。常见做法是先按Y坐标聚类,聚成行,再对每一行按X坐标从左到右排序。
5.2 方向分类器cls要不要加
PaddleOCR完整流程里还有一步方向分类器,处理旋转180度的图片。cls模型的输入是det裁剪后的小图,输出一个二分类概率,0表示正常、1表示旋转了180度。桌面端场景里,手机拍照上传的图大概率带旋转,cls加上能明显提升rec的识别率;截图类的图片基本没问题,可以跳过。
我一般会先跑通det加rec,拿一批真实样本看错误分布,如果旋转问题突出再加cls。原因有两点:一是多一次推理,单张图片的整体延迟会增加几十毫秒;二是cls模型也有自己的输入尺寸——它通常要求32乘100的固定大小,这和三阶段流程里其他模型的动态shape策略不一样,需要额外维护一套逻辑。
5.3 折叠在异步和批量里的注意点
WPF上位机里,OCR推理会被放到Task.Run里执行,避免卡住UI线程。OpenVINO的InferRequest不是线程安全的,同一个request不能同时跑两个推理,但不同request之间可以并行。多线程场景下要么每个线程创建自己的InferRequest,要么用CompiledModel创建一个InferRequest池。
我的做法是每个OCR任务独立创建InferRequest,用完释放,因为Core是线程安全的,CompiledModel也可以被多个线程共享,只有InferRequest需要独占。这样做的好处是任务间完全隔离,一个任务的异常不会影响其他任务;缺点是每次创建request有一点开销,但对于单张几毫秒的推理来说,几十微秒的请求创建成本可以忽略。
6. 用性能分析定位瓶颈,再决定具体优化手段
6.1 用Stopwatch逐段计时而不是凭感觉优化
OpenVINO本身提供了性能分析接口,但C#绑定的稳定性在不同版本里不统一。最可靠的是自己用Stopwatch给全流程分段计时:图像解码、det预处理、det推理、det后处理、坐标映射、rec推理、rec后处理。计时日志打出来后,瓶颈一眼就能看出来。
var sw = Stopwatch.StartNew(); sw.Restart(); var detOutput = det.Run(src); Console.WriteLine($"det推理+前后处理: {sw.ElapsedMilliseconds} ms"); sw.Restart(); var text = rec.Recognize(crop); Console.WriteLine($"rec识别单行: {sw.ElapsedMilliseconds} ms");大多数情况下,时间大头不是推理,而是rec需要逐行执行、每行都要做一次预处理和推理。如果一张图里识别出了30行文字,rec的时间就是30倍的单行推理时间,这时候优化目标不是让单次rec推理更快,而是减少rec的调用次数或者批量推理。OpenVINO支持把多张图片拼成一个batch送进模型,但rec模型的输入宽度是动态的,batch内宽度不同就不能直接合并,所以更实用的优化是拆行时过滤掉明显识别不出内容的框。
6.2 实测中常踩的推理错误和排查点
最常见的报错是Shape mismatch,原因是det和rec的输入shape与模型定义不一致。det可以接受动态尺寸,但一定要在预处理时把宽高凑成32的倍数;rec的输入高度是固定的32,只有宽度是动态的,如果裁剪出来的文字区域高度差太多,需要先等比缩放到高度32再补宽度。其次是输出张量的名称问题,OpenVINO C#绑定的GetOutputTensor()不传名称时默认取第一个输出,det没问题,rec如果有多个输出分支,必须用GetOutputTensor("softmax_0.tmp_0")的方式显式指定。排查这类问题最直接的办法是下载Netron打开onnx模型文件,看一眼输入输出节点名,再回代码里对齐。
6.3 一个值得保留的稳定技巧
在模型加载完成后把模型的输入输出信息打印出来,记录到一个静态配置类里,后续所有预处理和后处理都从配置类读取,而不是在代码里到处硬编码尺寸。这样更换模型版本时只需要改一处配置。OpenVINO的model.Input("x").Shape能拿到动态shape的维度信息,运行时推断出实际尺寸后再传给Preprocess方法,可以避免写死640这类魔法数字。
本文还有配套的精品资源,点击获取