ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# + PaddleOCR 打造双层OFD:扫描件文字层1:1精准匹配技术详解

C# + PaddleOCR 打造双层OFD:扫描件文字层1:1精准匹配技术详解 做档案数字化和电子单据处理的朋友应该都有过这种经历手里只有一张扫描图片客户却要求能检索、能复制、能盖章最好还能按证件号直接查档。直接交图片肯定不行简单压一层文字又怕错位一放大就露馅。这个项目解决的就是这件事——用 C# 在本地完成 OCR 识别把识别结果转成双层 OFD并保证字符坐标和扫描原图做到1:1 精准匹配。最终交付的文件从外表看还是那张干净的扫描图但内层已经带上了完全透明的文字层可以选中、可以搜索、可以复制坐标对得上每一个笔画。这个需求在政务档案、电子发票、银行单据、法院卷宗数字化里非常常见。适合正在做文档归档、电子证照相关功能的朋友参考也适合想在 .NET 生态里实现 OFD 组版和 OCR 后处理的人。文章会从方案选型、底层原理、关键代码、坐标换算、踩坑实录几个方面完整展开你跟着做一遍基本就能落地。1. 项目背景与方案选型1.1 为什么是双层 OFD而不是双层 PDF先把话说清楚双层 PDF 已经是很成熟的技术了开源有 ocrmypdf商业库也很多拿 C# 调一下就能出活。那为什么还要做双层 OFD因为交付场景决定技术路线。国内政务、金融、档案馆这些单位很多明确要求使用 OFD 格式作为电子文件归档格式。OFD 是国标版式文档GB/T 33190电子发票、电子证照、法院电子卷宗早就在往 OFD 迁移。客户验收文档时格式不对直接打回这时候你交双层 PDF 是过不了质检的。更关键的是OFD 和 PDF 虽然都是版式文档但内部结构完全不同。PDF 有现成的双层制作工具链而 OFD 生态相对年轻成熟的“图片加隐藏文字层”方案非常少特别是要逐字精准定位的更是凤毛麟角。所以这个项目在很多时候不是“选不选 OFD”的问题而是客户指定了 OFD你必须自己有这个能力。我自己的体会是做 OFD 双层最顺手的技术栈其实是 C# .NET。原因有三点一是 OFD 本身就是 ZIP XML 字体资源的打包结构.NET 对压缩包和 XML 的处理非常成熟二是 OFD 的规范文档在国内可以直接拿到不用翻各种资料三是很多做档案系统的公司内部本来就有一套 C# 业务框架在这个框架里加一个 OFD 转换模块集成成本最低。1.2 OCR 引擎选型PaddleOCR 本地部署转双层 OFD 的前提是先把图片里的文字识别出来这一步叫 OCR。OCR 引擎选型直接决定后续坐标数据的质量我选了 PaddleOCR 的本地部署方案。为什么不用在线 OCR一个很实际的问题是档案数据和单据照片往往涉及敏感信息客户不会允许你把图片传到外部 API 去识别。另一个问题是量大的时候按次计费成本很高。本地部署虽然要自己搞定环境但识别结果全部留在内网性能也可控跑批处理的时候吞吐量反而更大。PaddleOCR 输出的结果是一个 JSON 或列表结构里面包含每个文字框的四点坐标、识别文本、置信度。举个例子一张扫描发票经过识别后大概会得到几十条记录每条记录长这样text: 发票号码 points: [[ 286, 382 ], [ 433, 382 ], [ 433, 401 ], [ 286, 401 ]] confidence: 0.9987这里points是四个顶点坐标顺序是左上、右上、右下、左下单位是像素。注意PaddleOCR 识别出来的是不规则四边形框不是简单的 XYWH 矩形这对后续坐标转换有直接影响。C# 这一侧我用的集成方式是直接调 PaddleOCR 的服务接口也就是 OCR 服务单独起一个进程C# 通过 HTTP 接口把图片发过去拿回 JSON。这样做的原因是 PaddleOCR 原生是 Python 生态硬塞进 .NET 进程里反而麻烦拆成独立服务后两边各干各的C# 负责业务编排和 OFD 生成Python 只负责识别出问题也好排查。2. 双层 OFD 的核心技术原理解读2.1 OFD 文件结构剖析OFD 文件说到底就是一个 ZIP 压缩包但内部有明确规范。上手前先把结构弄清楚后面写代码才有方向。我用一个最小可用的 OFD 包举例Invoice_123.ofd ├── META │ └── manifest.xml # 包清单列出所有文件 ├── Doc_0 │ ├── Document.xml # 页面尺寸、公共资源引用 │ ├── Pages │ │ └── Page_0 │ │ ├── Content.xml # 页面内容图片和文字都在这 │ │ └── Page.xml # 页面元数据 │ └── Res │ ├── Fonts # 嵌入的字体文件目录 │ └── Images # 底图文件目录 └── OFD.xml # 文档入口指向 DocBody这里我要强调一点OFD 的版本有好几个2016 版是基础版也是目前兼容性最好的版本。写 XML 的时候命名空间必须用对常见的基础命名空间是http://www.ofdspec.org/2016写错一个字符阅读器直接打不开。项目的核心思路是“底层放图片上层放透明文字”对应到 OFD 机制里就是用两个 Layer 实现。Content.xml里可以有多个ofd:Layer节点ZOrder 小的在底层大的在上层。ZOrder 0 放底图ZOrder 1 放文字层这样用户看到的依然是原图但选中文字时能命中上层的透明字符。2.2 坐标系统像素坐标到 OFD 毫米坐标的关键换算这是整个项目最容易出错、也最核心的一环。OCR 引擎给出的坐标是像素坐标原点在图片左上角Y 轴向下而 OFD 的坐标系统是毫米原点在页面左下角Y 轴向上。坐标系不同直接搬过来肯定错位。换算公式也不复杂核心是两个步骤第一步把像素坐标转成毫米。这个取决于扫描 DPI。假设图片是 300 DPI 扫描的那么 1 英寸里有 300 个像素1 英寸等于 25.4 毫米所以每个像素对应的物理尺寸是mmPerPixel 25.4 / dpi第二步翻转 Y 轴。图片坐标系的左上角对应 OFD 坐标系的左上角但 OFD 的 Y 轴向上所以需要把 Y 坐标从“离顶部的距离”转成“离底部的距离”。假设页面高度是pageHeightMm某一点图片像素坐标是(px, py)那么 OFD 坐标就是x_mm px * mmPerPixel y_mm pageHeightMm - py * mmPerPixel这一行代码背后藏着很多细节。比如 DPI 从哪来是从图片元数据里读还是由扫描设备固定提供这就直接关系到整个文件坐标准不准。再比如页面高度其实也由像素高度和 DPI 决定pageWidthMm imageWidthPx * mmPerPixel pageHeightMm imageHeightPx * mmPerPixel到这里你应该能感受到只要 DPI 错了或者图片被 Resize 过坐标就全完了。我做了个判断OCR 阶段和 OFD 生成阶段必须使用同一张原始图片严禁在中间做缩放或压缩否则前面识别的坐标全部作废。2.3 字体嵌入与字符显示原理坐标对上了文字还必须以正确的字体和大小渲染出来。OFD 不像 PDF 那样可以直接引用系统字体为了保证任何阅读器打开都不缺字最好把用到的字体文件嵌入到 OFD 包里。为什么非要嵌入字体遇到过这样一个坑我在开发机上生成的双层 OFD 打开完全正常但客户电脑上打开文字层全是方块。原因就是目标机器没有安装对应的字体阅读器只能拿一个近似字体替换坐标虽然没错但字形完全对不上搜索和复制功能也受影响。所以我在 OFD 包的Res/Fonts目录里直接放入 TTF 或 OTF 字体文件在Document.xml里声明字体资源然后文字对象引用这个字体 ID。这样文件体积会大一点一个中文字体通常几 MB但对于可靠性和一致性来说这点代价完全可以接受。文字大小也需要换算。OCR 结果里每个文本框都有高度这个高度可以算成字符的物理尺寸。公式还是 DPI 换算fontSizePt boxHeightPx * mmPerPixel * 72 / 25.4因为 OFD 里的字号通常用毫米或磅值来描述。如果不想搞得太复杂也可以直接用毫米作为字体尺寸单位保证字符显示高度和原图上的字高一致。3. 完整实现流程与核心代码3.1 数据准备解析 OCR 结果先定义一个类来承载 OCR 结果把 PaddleOCR 返回的 JSON 映射进来。关键字段是文本内容、置信度和四点坐标。public class OcrResult { public ListWordItem Words { get; set; } } public class WordItem { public string Text { get; set; } public float Confidence { get; set; } public ListPointF Points { get; set; } // 四点左上、右上、右下、左下 }然后用System.Text.Json反序列化即可。这里有一点经验PaddleOCR 返回的坐标是浮点数但图片经过某些预处理后坐标可能出现亚像素偏移我一般统一取整到小数点后 2 位减少 XML 的冗余长度同时不影响精准度。拿到四点框之后需要把它整理成适合 OFD 定位的数据。OFD 的TextObject需要知道文字的位置、大小、旋转角度。对于绝大多数扫描件文字都是水平的所以取左上角点作为文字的起点即可。但对于旋转了 90 度的单据四点框的坐标就能派上用场——可以算出旋转角。var topLeft points[0]; // 左上 var topRight points[1]; // 右上 var width Distance(topLeft, topRight); var height Distance(points[0], points[3]); // 把像素转成 OFD 毫米 double mmPerPixel 25.4 / dpi; double startX topLeft.X * mmPerPixel; double startY pageHeightMm - topLeft.Y * mmPerPixel; double fontSizeMm height * mmPerPixel;如果你只想做到“词级定位”到这里已经足够。如果想做到“字符级精准”还得把每个词框内的文字按字符宽度切分这里我放在后面详细讲。3.2 构建 OFD 包结构与生成 XMLOFD 是一个 ZIP 包.NET 里最方便的是用System.IO.Compression.ZipArchive。严谨一点的话包内应该有一个mimetype文件且放在 ZIP 第一项并且不压缩类似 EPUB 的做法某些阅读器会校验这个。不过在实际项目里我发现完全按规范生成 OFD 包并不难难的是把 XML 的命名空间和结构写对。我推荐用XmlWriter来写避免手拼字符串出现转义问题和编码问题。下面是生成核心页面内容Content.xml的示意代码using var writer XmlWriter.Create(stream, settings); writer.WriteStartElement(ofd, Page, http://www.ofdspec.org/2016); writer.WriteStartElement(ofd, Content, ns); // 底层图片层 writer.WriteStartElement(ofd, Layer, ns); writer.WriteAttributeString(ID, 0); writer.WriteAttributeString(ZOrder, 0); // 写入 ImageObjectCTM 把图片铺满页面 writer.WriteStartElement(ofd, ImageObject, ns); writer.WriteAttributeString(ID, 1); writer.WriteAttributeString(ResourceID, 10); writer.WriteAttributeString(CTM, $1 0 0 1 0 0); writer.WriteEndElement(); writer.WriteEndElement(); // 上层文字层 writer.WriteStartElement(ofd, Layer, ns); writer.WriteAttributeString(ID, 1); writer.WriteAttributeString(ZOrder, 1); // 逐个写入 TextObject foreach (var word in words) { writer.WriteStartElement(ofd, TextObject, ns); writer.WriteAttributeString(ID, nextId); writer.WriteAttributeString(Font, F1); writer.WriteAttributeString(Size, word.FontSizeMm.ToString(0.##)); writer.WriteAttributeString(CTM, $1 0 0 1 {word.X.ToString(0.##)} {word.Y.ToString(0.##)}); writer.WriteElementString(ofd, TextCode, ns, word.Text); writer.WriteEndElement(); } writer.WriteEndElement(); writer.WriteEndElement(); writer.WriteEndElement();这里CTM是三个参数一组前四个数字是旋转缩放矩阵最后两个是平移量。对于不旋转的文字直接用1 0 0 1 x y即可x 和 y 就是文字左下角在页面上的毫米坐标。3.3 词级 vs 字符级坐标匹配什么时候用哪种这是我在实际项目里反复权衡过的问题。词级定位比较容易实现OCR 返回什么文本块就放什么文本块适合字段检索这种需求比如“在所有发票里搜‘发票号码’这个词组”。但如果你希望用户用鼠标选中文字时光标高亮区域能和底图的每个字严丝合缝地贴合那就需要字符级定位。字符级定位的核心做法是拿到一个词框后把文本字符串里的字符逐个拆开按字符中心位置计算每个字的起始 X 坐标。中文字符基本都是全角等宽但数字、字母、标点不是等宽的这就不能简单地“总宽度除以字符数”去平均分配。PaddleOCR 给的是词框的总宽度没有给每个字符的边界。这种情况下常见做法有两个一是用 PaddleOCR 的 “按字符输出”模式让每个字单独返回坐标二是用字体引擎测量每个字符的宽度比例然后按照比例切分词框宽度。我在项目里的方案比较务实需要精确选中的关键字身份证号、发票号、金额用字符级定位普通正文用词级定位。这样既保证了关键信息的交互体验又控制了处理耗时。具体切分时我会调用Graphics.MeasureString测每个字符的宽度然后按占比把词框总宽度分配给每个字符这样英文和数字也能对得比较准。// 按比例切分词框估算每个字符的起始位置 float totalPxWidth word.Right - word.Left; float[] charWidths MeasureChars(word.Text, fontSizePx); float used 0f; for (int i 0; i word.Text.Length; i) { float ratio charWidths[i] / charWidths.Sum(); float charWidthPx totalPxWidth * ratio; float centerXpx word.Left used charWidthPx / 2; used charWidthPx; // 转 OFD 坐标并写入 TextObject }3.4 图片底图与资源打包底图不能直接塞在原位OFD 要求图片作为资源存在包内然后由ImageObject引用。资源清单写在Document.xml或Resources.xml里图片本体放在Res/Images目录。放图片的时候有一点容易踩坑图片的显示尺寸必须和页面尺寸完全一致。如果你把页面尺寸定义成了 A4210×297mm但底图是一张 200 DPI 扫描的图片不做缩放直接铺两者之间的比例就会错位。所以更稳妥的做法是页面尺寸完全由图片尺寸和 DPI 计算得出而不是预设一个固定纸张大小。double pageWidthMm (image.Width / (double)dpi) * 25.4; double pageHeightMm (image.Height / (double)dpi) * 25.4;这样页面尺寸天然就是图片的物理尺寸底图用CTM 1 0 0 1 0 0铺上去就正好文字坐标也不会出现整体偏移。这也是做到 1:1 匹配的前提。4. 1:1 匹配的验证方法与常见坑4.1 如何验证“精准匹配”做完一个文件怎么证明它真的 1:1 准确不能光说“我觉得对得齐”要有一套可量化的验证方法。我的做法是分三步。第一步在支持 OFD 的阅读器里打开生成的文件用文字选择工具全选故意选中一个词观察高亮背景和底图字形是否重叠。如果高亮框明显偏离字形说明坐标换算有系统性误差。第二步把底图透明度调高或者直接把文字层导出为图片和原图做像素级叠加用图像处理工具看边缘是否重合。我一般用 Python 写个脚本把原图和 OFD 渲染出的文字层各取 50% 透明度叠加然后再放大到 200% 检查几个关键点标题、金额、日期。第三步最关键是量化验证选 5 到 10 个抽样词框记录原图上人工标注的坐标用画图工具就能取像素再解析 OFD 里对应词的坐标计算误差。我给自己定的验收标准是误差在 0.5 毫米以内也就是 300 DPI 下大约 6 个像素以内的偏差才能算达标。我遇到过一种隐蔽情况有些阅读器在渲染 OFD 时会做“适应窗口宽度”的缩放导致屏幕上看稍微错位但这是显示缩放导致的不是文件本身的问题。真正打包出错的典型表现是无论如何缩放错位量成比例变化。这种情况基本可以断定是页面尺寸或 DPI 换算出了问题。4.2 坐标匹配常见问题速查按我实际排查的经验坐标对不齐的原因不外乎下面几种整理成表方便你对照现象可能原因解决办法整体偏移越靠近右下角偏移越大页面尺寸和图片实际尺寸不一致页面尺寸严格按图片宽度像素和 DPI 计算上下颠倒或左右镜像坐标系转换时 Y 轴没有翻转检查原点OFD 原点是左下角图片原点是左上角只有文字层错位底图正常文字坐标在缩放时重复乘了 DPI确认像素转毫米只做一次不要二次换算中文正常英文和数字错位词级等宽切分导致的误差按字符宽度比例切分或者用逐字 OCR 输出所有字都挤在页面左下角坐标单位写错把像素当毫米用了检查mmPerPixel计算是否遗漏客户机器打开缺字字体未嵌入 OFD 包把 TTF/OTF 放进 Res/Fonts 并在资源列表里声明阅读器报包损坏ZIP 打包结构不符合规范检查 mimetype 位置、文件路径大小写、XML 命名空间4.3 项目实测数据与性能参考最后分享一组我自己的实测数据。拿 500 张 300 DPI 的 A4 扫描件做批处理单张图片平均识别时间在 1.5 秒到 3 秒之间取决于文字密度OFD 封装时间基本在 0.2 秒以内整体吞吐量大约每分钟 20 到 30 张。这个速度对档案批量入库来说完全够用。有过一次比较特殊的场景客户提供了一批 600 DPI 的高清档案扫描件每张图片 30 多 MB。这种图片直接做 OCR 内存占用非常高而且坐标数值非常大XML 里的数字串长度翻倍。我的处理方式是先复制原始图片用于 OFD 底图同时生成一个等比例缩小的副本给 OCR 识别识别完成后把坐标按缩放比例映射回原图坐标系。这样既保证了底图清晰度又不会让 OCR 进程内存爆炸。这里要注意缩放系数必须是浮点数除法并且回映射时不能取整误差太大不然坐标又对不齐了。关于多线程建议按文件维度并行而不是在一张文件内部并行。每个 OFD 包生成过程涉及 ZIP 写入本身不是线程安全的按文件并行最简单可靠。我试过在代码里用 Parallel.For 一次并发处理 4 个文件CPU 利用率能跑满也没有出现文件损坏的问题。但要注意字体文件在每个包之间会被重复打包4 个并发进程同时读同一个字体文件没问题写入的是不同目录互不干扰。再提一个容易被忽略的点生成 OFD 之前一定要校验 OCR 置信度。低于 0.95 的文本块坐标可能是对的但文本内容本身有可能是错的。如果强行写入文字层将来用户搜索一个错字会直接质疑整套系统的准确性。我的做法是先把低置信度的文本块单独保存到一个 CSV 文件里人工复核后再决定是否合并进 OFD这就避免了“坐标很准但文字是错的”这种尴尬局面。做这种坐标级匹配的工作我的经验是“不要相信印象要相信换算公式”。每一次坐标变换都在代码里保留日志记录原始像素坐标、中间换算值和最终 OFD 坐标一旦有问题能立刻定位是哪个环节出了问题。这个习惯帮我少加了很多次班。如果你后续还要做更高级的功能比如在双层 OFD 上叠加电子签章或者做全文检索引擎这套 1:1 坐标体系可以直接复用。文字层本来就是透明的盖章会盖在文字层上检索则直接查文字层的内容架构上不需要再推倒重来。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进