# SZOCR **Repository Path**: WinterWoods/szocr ## Basic Information - **Project Name**: SZOCR - **Description**: 纯 C# 实现的离线中文 OCR 引擎(PaddleOCR PP-OCRv5 模型)。零第三方 NuGet 依赖;推理调用 Paddle 官方 paddle_inference_c 原生库(Apache 2.0,可免费商用)。支持 Windows / Linux,支持 .NET 依赖注入(配合 SzOcr.DependencyInjection 包)。 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-01 - **Last Updated**: 2026-10-10 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # SzOcr — 纯 C# 离线 OCR(PaddleOCR PP-OCRv5) 零第三方 NuGet 依赖的离线 OCR 引擎。除 Paddle 官方原生推理库(Apache 2.0,可免费商用)外, 全部 C# 自行实现,Windows / Linux 跨平台,支持 .NET 依赖注入与 NuGet 分发。 ## 依赖注入用法 ```csharp // Program.cs / Startup services.AddSzOcr(cfg => { cfg.DetModelDir = "models/PP-OCRv5_server_det_infer"; cfg.ClsModelDir = "models/ch_ppocr_mobile_v5.0_cls_infer"; cfg.RecModelDir = "models/PP-OCRv5_server_rec_infer"; cfg.RecDictPath = "models/ppocrv5_dict.txt"; }); // 业务类构造函数注入 public class MyService(IOcrEngine ocr) { public OcrResult Parse(string img) => ocr.Detect(img); } ``` 生命周期: - `AddSzOcr(...)`:Singleton,单线程串行调用(最简单,吞吐要求不高时够用)。 - `AddSzOcrPooled(..., poolSize: N)`:**多线程并发识别的正确姿势**。为每个引擎绑定一条专属常驻线程(actor 模式)——Paddle 3.x CPU 的 predictor 绑定创建线程,跨线程调用 Run 会崩溃,且推理中克隆/创建其他 predictor 也会崩溃;本池让模型在专属线程上加载和推理,天然线程安全且可真并行。第 1 个实例完整加载模型,其余通过 `PD_PredictorClone` 共享权重(毫秒级)。Web 场景建议注入 `SzOcrEnginePool` 后调用 `DetectAsync`(异步不阻塞线程池)。 - `AddSzOcrTransient(...)`:每次请求新建引擎(重新加载模型),仅调试用。 实测吞吐(12 核 / mobile 全套 / test.jpg):串行 1078ms/张;池化 3 实例并发 872ms/张(加速 1.24x 后达 CPU 饱和——CPU 密集任务无法超线性加速,需要线性扩展吞吐时用多进程部署)。 ## 多核机制与调优(单次识别是多核,池化并发也是多核) SzOcr 的并行分两个层面,二者**共享同一批 CPU 核心**: 1. **单次识别内部(数据并行)**:Paddle 推理经 oneDNN 使用多线程,线程数由 `OcrConfig.CpuThreads`(默认 4,官方部署默认值)控制。 即使只有 1 个引擎实例,一次 `Detect` 也会占满多核。 2. **引擎池并发(请求并行)**:`SzOcrEnginePool` 每个 worker 是独立 predictor + 独立线程,多个请求可真正同时推理。 关键结论:单次推理默认已用多核,再加池实例只会排队等核,吞吐几乎不涨。 **要提升并发吞吐,应调小单实例线程数、增加池实例数**: | 场景 | CpuThreads | PoolSize | 说明 | |---|---|---|---| | 桌面/单用户,单请求最快 | 4 | 1 | 默认配置即可(实测 mobile 模型 4 线程最快) | | Web 服务,多请求吞吐最大 | 2 | ≈ 物理核数/2 | 各请求真并行,总吞吐最高,单请求略慢 | | 折中 | 4 | 2~3 | 一般服务端 | ```csharp // 8 核 Web 服务器示例:4 个请求各占 2 核真正并行 services.AddSzOcrPooled(cfg => { cfg.DetModelDir = "models/PP-OCRv5_server_det_infer"; cfg.ClsModelDir = "models/ch_ppocr_mobile_v5.0_cls_infer"; cfg.RecModelDir = "models/PP-OCRv5_server_rec_infer"; cfg.RecDictPath = "models/ppocrv5_dict.txt"; cfg.CpuThreads = 2; // 关键:调小单实例线程数 }, poolSize: 4); ``` ## 性能参考(12 核,稳态) 付款通知书 2217×3211 截图(PNG): | 配置 | 置信度 | 耗时 | |---|---|---| | 全 mobile(Det 173ms / Cls 85ms / Rec 547ms) | 0.900 | ~1.0s | | **mobile-det + server-rec(推荐性价比)** | 0.902 | ~1.4s | | 全 server | 0.919 | ~2.9s | 16MP 手机照片(3456×4608,证件小目标,4 线程稳态,与 PaddleOCRSharp 原生引擎同图对照): | 图像 | SzOcr 全 mobile | PaddleOCRSharp 原生 | SzOcr mixed(更准) | |---|---|---|---| | 1.jpg | 810ms / 0.979 | 916ms | 1403ms / 0.990 | | 1.bmp | 780ms / 0.948 | 835ms | — | | 1.png | 1100ms / 0.948 | 961ms | — | - JPEG 解码为纯 C# 实现(libjpeg islow 整数 IDCT 同源算法 + 霍夫曼 8-bit 快速查表 + 按行并行 YCbCr 转换),16MP 约 0.15s,解码结果与 libjpeg-turbo 逐像素一致。 - PNG 解码同样全自写(位缓冲 DEFLATE + 9-bit 霍夫曼查表 + 指针化 Unfilter), 16MP 约 0.3s(原生 zlib+libpng 约 0.05s——PNG 是纯 C# 实现的主要差距来源)。 - 正立图片自动跳过方向分类(`SkipClsWhenUpright`,默认开):所有行框宽高比 ≥ 1 时 必为横向文本,cls 无意义;整图旋转 180° 场景请设 `UseCls = true`。 > 注意:进程首次运行有模型加载 + oneDNN JIT 冷启动(server 约 +5s),生产环境进程常驻无此开销。 > 之前测得的 7.7s 即冷启动数字,稳态全 server 仅 ~2.9s。 ## 能力清单(全部自写,无 OpenCV / 无 EmguCV / 无 System.Drawing) | 模块 | 说明 | |---|---| | `Paddle/` | Paddle Inference **C API** P/Invoke 绑定(依据 pd_*.h 逐条手写),原生库动态加载器 | | `Imaging/Inflate.cs` | **DEFLATE 解压**(RFC1951,stored/fixed/dynamic Huffman) | | `Imaging/PngDecoder.cs` | **PNG 解码**(8/16 位,灰度/RGB/调色板/α,Adam7 隔行) | | `Imaging/JpegDecoder.cs` | **JPEG 解码**(baseline 顺序式,4:4:4/4:2:2/4:2:0,重启间隔) | | `Imaging/BmpDecoder.cs` | **BMP 解码**(24/32 位、8 位调色板) | | `Imaging/ImageOps.cs` | 双线性缩放、四点**透视裁剪**(等价 cv::getPerspectiveTransform+warpPerspective) | | `Ocr/DbPostProcess.cs` | DB 后处理:二值化→连通域→凸包→**最小面积旋转矩形**→unclip 扩框→阈值过滤 | | `Ocr/ClsEngine.cs` | 文本行方向分类(0°/180°) | | `Ocr/RecEngine.cs` | 识别预处理(批内统一宽度右填充)+ **CTC 贪心解码** | | `Ocr/OcrEngine.cs` | 主链路:检测(960 限边/32 对齐) → 排序(按行分组) → 裁剪 → 方向 → 识别 | ## 目录结构 ``` src/SzOcr/ 核心类库(netstandard 兼容的 net8.0,零 NuGet 引用) demo/SzOcrDemo/ 控制台演示 models/ PP-OCRv5 mobile det/rec + v5.0 cls 模型 + ppocrv5_dict.txt (18383 字) native/ paddle_inference_c 3.0.0 官方原生库(Win/Linux CPU avx-mkl) testdata/test.jpg PaddleOCRSharp 官方测试图 ``` ## 运行 ```bash dotnet build demo/SzOcrDemo/SzOcrDemo.csproj -c Release dotnet demo/SzOcrDemo/bin/Release/net8.0/SzOcrDemo.dll # 默认识别 testdata/test.jpg dotnet demo/SzOcrDemo/bin/Release/net8.0/SzOcrDemo.dll 你的图.jpg # 指定图片(PNG/JPEG/BMP) dotnet demo/SzOcrDemo/bin/Release/net8.0/SzOcrDemo.dll --test # 完整测试套件(36 项断言) dotnet demo/SzOcrDemo/bin/Release/net8.0/SzOcrDemo.dll --web # Web OCR 宿主(Kestrel,Ctrl+C 退出) ``` `--test` 覆盖六个层面:解码器功能(BMP/PNG/JPEG 逐字节断言)、OCR 功能与参数覆盖、 引擎池并发正确性、依赖注入、性能(解码/热识别/池化吞吐 + 宽松上限)、Web 注入请求 (进程内 Kestrel + DI + 真实 HTTP 往返,multipart 与原始字节两种上传、min_score 过滤、400 错误路径)。 全部通过退出码 0,否则为失败数,可直接接入 CI。 demo 默认即最佳性价比档:**mixed(mobile-det + server-rec)+ DetDbBoxThresh 0.6(官方默认)**。 可用 `--profile mobile`(最快)/ `--profile server`(最准最慢)切换档位。 ## 代码调用示例 ```csharp var config = new OcrConfig { DetModelDir = "models/PP-OCRv5_mobile_det_infer", ClsModelDir = "models/ch_ppocr_mobile_v5.0_cls_infer", RecModelDir = "models/PP-OCRv5_mobile_rec_infer", RecDictPath = "models/ppocrv5_dict.txt", }; using var engine = new OcrEngine(config); OcrResult result = engine.Detect("test.jpg"); foreach (var b in result.TextBlocks) Console.WriteLine($"[{b.Score:F3}] {b.Text} 框={b.Box.TL}..{b.Box.BR}"); ``` ### 单次识别参数(Detect 调用点覆盖,可选) 初始化时无法确定的参数(不同证件、不同拍摄条件需要不同阈值)可在调用点按次传入, 对应 PaddleOCRSharp 的 `Detect(image, parameter)` 用法。`OcrParameter` 字段全部可空, `null` = 沿用 `OcrConfig` 初始化值;优先级:环境变量 > `OcrParameter` > `OcrConfig`。 ```csharp // 例:本次调用覆盖 DB box 阈值,仅影响这一次 var result = engine.Detect("idcard.jpg", new OcrParameter { DetDbBoxThresh = 0.5 }); // 引擎池同样支持(并发安全:parameter 只读使用,不要识别中途修改) var pool = new SzOcrEnginePool(() => config, poolSize: 2); var r2 = await pool.DetectAsync(bytes, new OcrParameter { DetDbBoxThresh = 0.5 }); ``` 可覆盖项:`DetLimitSideLen`、`DetDbThresh`、`DetDbBoxThresh`、`DetDbUnclipRatio`、 `DropScore`、`RecBatchNum`、`UseCls`(需初始化时已加载 cls 模型)。 > 注:对证件类"小目标 + 高清背景"照片,正确的粒度与识别率来自**正确的图像解码**—— > JPEG 全链路必须与标准解码器一致,任何亮度/对比度损伤都会让 DB 检测粘连字段。 > 不要用 ROI 裁剪/二次识别之类的方式绕过解码质量问题。 ## Linux 部署 `native/paddle_c_linux/paddle_inference_c/` 已包含 `libpaddle_inference_c.so` 及 third_party(libiomp5/libmklml/libdnnl)。加载器会自动按仓库根目录向上搜索, 也可通过 `NativeLibLoader.AdditionalSearchDirs` 指定;或设置 `LD_LIBRARY_PATH`。 ## 可调参数(环境变量,运行期覆盖) | 变量 | 默认 | 说明 | |---|---|---| | `MYOCR_DB_THRESH` | 0.3 | DB 二值化阈值 | | `MYOCR_DB_BOX_THRESH` | 0.6 | mini-box 平均分阈值(与官方一致) | | `MYOCR_DB_UNCLIP` | 1.6 | 扩框比例 | | `MYOCR_LIMIT_SIDE` | 960 | 检测限边长(调大利于小字,更慢) | | `MYOCR_MKLDNN` | 开 | 置 0 关闭 oneDNN 加速 | | `MYOCR_DROP_SCORE` | 0.65 | 低分块丢弃阈值;置 0 保留全部(官方 0.5) | ## 识别不准时的排查顺序(以 test.jpg 实测为例) | 措施 | 置信度 | 效果 | |---|---|---| | 默认全 mobile | 0.853 | 错字多:公厅/每進/代加T/鲸些/黄表/郡油/即国/干换,且漏最后一行 | | `--profile mixed`(mobile-det + server-rec,推荐) | 0.912 | 修复绝大部分错字:每公斤/代加工/鲸蜡/椰油酰胺丙基甜菜碱/即时持久 | | `--profile server`(全 server) | 0.915 | 同上,但 det 会漏【净含量】行 | | mixed + `MYOCR_DB_BOX_THRESH=0.4` | 0.912 | 15 行全部检出,无噪块(历史值:JPEG 解码修复后官方 0.6 即可) | 剩余错字(每池→每瓶、纯续→纯臻、碳层→鳞层)为 server rec 模型在小字号艺术字上的极限, 进一步手段:`MYOCR_LIMIT_SIDE=1280` 提升小字分辨率,或对原图超分后识别。 ## 精度说明(为什么之前平均置信度只有 79%) 以"付款通知书"截图实测:mobile 模型 0.797 → server 模型 0.884 → server + DropScore 0.65 = **0.919**。 低分来源主要是红章/红字污染的碎块(如"[a联ble:]"),本身不可用;DropScore 过滤后平均分反映真实可用文本。 换用 server 模型同时补齐了 mobile 漏检的整行(如开户帐号全串数字)。代价:耗时 ~2.3s → ~6s(10 线程 CPU)。 ## 已知限制 / 后续路线 - JPEG 仅 baseline;渐进式(SOF2)、算术编码暂不支持(会抛明确异常) - 仅 CPU 推理(C API GPU 需换 GPU 版原生库,绑定层已预留 EnableUseGpu) - 提精度可换 PP-OCRv5_server_det/rec 模型(目录结构相同,直接换路径) - 调试日志:设 `MYOCR_DEBUG=1` 输出 DB 后处理中间结果 ## 解码器测试套件 `testdata/pnggen.py` 生成覆盖全部 PNG 变体的测试图(灰度/RGB/RGBA/调色板/16-bit/Adam7 隔行/多 IDAT/大图, filter 0-4 循环)+ Python zlib 真值,并导出真实截图的 IDAT/raw 对照: ```bash python testdata/pnggen.py dotnet demo/SzOcrDemo/bin/Release/net8.0/SzOcrDemo.dll --pngtest # 逐字节比对,全部通过为准 ``` 输出 `== 全部通过 ==` 即解码链路(Inflate → Unfilter → 颜色转换)与参考 zlib 实现完全一致。