1. 先把问题拆开:混排识别的难点在哪里
手写体混合印刷体识别这件事,我从几年前做票据和试卷数字化项目时就开始反复折腾。那时候的常规做法是"两套模型各管一段"——先用版式分析把区域切成印刷块和手写块,再分别送进两个识别器,最后按坐标拼回去。这套链路能跑,但维护成本高得离谱:切错的区域永远救不回来,手写和印刷贴在一起的地方基本无解。所以当端到端的多模态 OCR 方案开始成熟,我第一反应就是把 HunyuanOCR 拉过来做一轮多字体适应性实测,看看它在这种"最难缠的混排场景"里到底能顶到什么程度。
先把结论往前放一点:它不是万能的,但在手写体混合印刷体识别这个细分任务上,端到端路线带来的收益是实实在在的,尤其是省掉了"先切块再识别"这一层容易出错的中转。这篇文章我会把自己搭测试集、调推理参数、跑对比、复盘错例的全过程摊开讲,包括我踩过的坑和最后沉淀下来的几条经验。适合两类人看:一类是正在选型 OCR 方案、纠结自建还是调 API 的工程同学;另一类是手上有大量手写批注、试卷、合同、表单需要结构化的业务方,想搞清楚这条路现在能走到哪一步。
1.1 三类难点:字形、版式、上下文
混排识别难,很多人第一反应是"手写字丑"。这只是表层。实际拆下来至少三层。
字形层是大家最容易想到的:同一个字,印刷体有宋黑楷仿圆几十种字形,手写体更是千人千面,连笔、简写、异体字、书写习惯带来的笔画粘连,都会让基于字形模板匹配的老方案直接崩掉。更麻烦的是"手写风格的字库字体"——有些排版里会故意用手写体字库做装饰性文字,肉眼一看是手写,实际上它是规整的矢量字形,这类样本特别容易让模型判断混乱。
版式层才是真正拉开差距的地方。印刷体文档通常是横平竖直、行距均匀的,检测框好切;一旦手写批注插进来,行基线立刻歪掉,批注可能压着正文、压在页边、写在两行之间的缝隙里,甚至绕着表格画圈。传统方案靠投影法或连通域做行切分,在这种场景下错得离谱。
上下文层是最容易被低估的。手写内容往往信息密度极高——签名、日期、金额、勾选项、修改痕迹,一个字错了整条数据的业务含义就变了。比如金额栏"1000"被识别成"10000",或者日期"1/5"被读成"7/5",模型在字符层面可能只错了一个 token,但在业务层面就是事故。
注意:评估混排识别时,千万别只看整体字符准确率。手写区域往往只占整页面积的 10%,但它承载的关键信息可能占 80%。分区统计错误率比整体统计有用得多。
1.2 HunyuanOCR 的端到端路线与我的三个关注点
公开资料里,HunyuanOCR 是一个十亿参数量级的端到端多模态 OCR 模型,把文本检测、识别、部分结构化理解放在同一个模型里完成,而不是传统的"检测网络 + 识别网络"级联。这个设计选择对混排场景的意义在于:模型在输出文字的同时,能利用整页的视觉上下文做联合判断,而不是被切块边界人为割裂。
我这次实测主要盯三件事:
第一,字体泛化边界。印刷体它肯定没问题,我想知道的是在楷体、仿宋、圆体、手写风格字库这些"非主流但真实存在"的字体上,性能衰减曲线长什么样。
第二,手写与印刷交界处的处理。同一行里前半段印刷、后半段手写,或者手写直接把印刷字覆盖掉一半,这类样本是级联方案的重灾区,端到端能不能吃掉。
第三,推理成本与参数敏感性。端到端模型通常对输入分辨率敏感,我想把"分辨率—精度—耗时"这条曲线跑出来,找到工程上真正划算的甜点位置。
2. 自建测试集:把"多字体适应性"变成可量化的数字
网上现成的 OCR 评测集大多是纯印刷体扫描件,手写样本要么量少,要么场景单一。想测多字体适应性,必须自己造数据。这一步花的時間比调模型多得多,但我觉得值——没有一套自己完全掌握的测试集,后面所有结论都是道听途说。
2.1 语料设计:让每个字都有考察目的
语料我没有随便找文章灌进去,而是按"考察目的"分层设计。具体做法是准备一份覆盖常用字和易混字的语料池,然后针对不同子集做定向填充。
| 子集 | 内容构成 | 样本量 | 主要考察点 |
|---|---|---|---|
| S1 印刷单字体 | 通知、说明类文本,分别用宋、黑、楷、仿宋、圆体排版 | 300(5 字体 × 60) | 基础识别能力、字体间差异 |
| S2 真实手写 | 8 位不同书写者,工整到潦草梯度分布 | 150 | 手写泛化、连笔鲁棒性 |
| S3 混排 | 印刷正文 + 手写批注/修改/签名 | 120 | 交界处理、阅读顺序 |
| S4 低质实拍 | 手机拍摄,含倾斜、阴影、折痕、摩尔纹 | 80 | 退化鲁棒性 |
| S5 表格单据 | 带边框线的表单、票据类 | 60 | 结构还原 |
合计 710 张。这个量级不算大,结论只能看趋势,不能当权威基准,但足够支撑工程选型判断了。
语料内容上我特意塞了几类"地雷":形近字串(如"己已巳"、"未末"、"戌戍")、数字串(金额、编号、身份证样式,全部虚构)、中英混排、全角半角混杂的标点、少量繁体异体字、竖排小段落。这些在真实业务里出现频率不高,但一旦出错代价极大。
2.2 手写样本的采集与"劣化"处理
手写样本最难搞。我的做法是找 8 位书写习惯差异明显的人,每人写同样的 20 段短文本,覆盖签名、日期、数字、短句、勾选。然后按"工整/一般/潦草"三档人工打标,方便后面分档看结果。
采集完之后我没有直接拿去测,而是做了一轮可控劣化,模拟真实拍摄条件:
- 随机旋转 ±8 度,模拟手持拍摄歪斜
- 叠加高斯模糊,核大小 3 到 7,模拟对焦不准
- 加侧向阴影梯度,模拟台灯打光不均
- 加 JPEG 压缩伪影,质量因子 60 到 85
- 局部遮挡,模拟手指压页或印章覆盖
这一步很关键。很多方案在实验室干净扫描件上表现漂亮,一到手机实拍就原形毕露。劣化样本能提前把这类问题暴露出来。
2.3 标注规范与评测口径
标注我用的是行级文本框 + 文本内容,加上"印刷/手写"的属性标签。属性标签不是给模型看的,纯粹为了评测时能分区统计。
评测口径定死三条:
- 字符错误率 CER:基于编辑距离,
CER = (替换 + 删除 + 插入) / 参考文本总字符数,按行计算后加权平均。 - 行级完全正确率 EM:一行识别结果与标注完全一致才算对,这个指标对业务最直观。
- 分区 CER:分别统计印刷区、手写区、交界区,交界区定义为手写与印刷字符间距小于 1 个字宽的行。
计算 CER 的代码很简单,但要处理好空白字符归一化,否则全角空格和半角空格会白送一堆错误:
def normalize(s): # 统一空白、全角半角差异,避免评测口径污染 s = s.replace("\u3000", " ") s = " ".join(s.split()) return s def edit_distance(ref, hyp): m, n = len(ref), len(hyp) dp = list(range(n + 1)) for i in range(1, m + 1): prev, dp[0] = dp[0], i for j in range(1, n + 1): cur = dp[j] cost = 0 if ref[i - 1] == hyp[j - 1] else 1 dp[j] = min(dp[j] + 1, dp[j - 1] + 1, prev + cost) prev = cur return dp[n] def cer(ref, hyp): ref, hyp = normalize(ref), normalize(hyp) if not ref: return 0.0 if not hyp else 1.0 return edit_distance(ref, hyp) / len(ref)提示:CER 一定要在归一化之后算。我第一版忘了处理全角标点,结果楷体子集的错误率虚高两个百分点,排查了半天才发现是评测脚本的问题,不是模型的锅。
3. 环境搭建与推理链路调优
测试集准备好之后,重点就转到推理链路上。这一部分其实比模型本身更影响最终结果,我见过太多人模型没换、只调了输入分辨率和后处理,准确率就涨了三五个点。
3.1 部署与最小可跑通链路
我是在一台单卡机器上跑的本地推理,模型权重走官方发布的版本。这里不贴具体路径,避免环境差异导致你直接复制报错。整体链路抽象成三步:图像预处理、模型推理、结果后处理。
import time from pathlib import Path class OCRPipeline: def __init__(self, model, processor, max_side=1280): self.model = model self.processor = processor self.max_side = max_side def preprocess(self, image): # 等比缩放到短边不超过 max_side,保留原始尺寸用于坐标还原 w, h = image.size scale = min(self.max_side / max(w, h), 1.0) if scale < 1.0: image = image.resize((int(w * scale), int(h * scale))) return image, scale def infer(self, image): img, scale = self.preprocess(image) inputs = self.processor(images=img, return_tensors="pt") t0 = time.time() outputs = self.model.generate(**inputs, max_new_tokens=2048) cost = time.time() - t0 text = self.processor.batch_decode(outputs, skip_special_tokens=True)[0] return {"text": text, "scale": scale, "cost": cost}这个骨架故意做得很朴素,因为我想先测"裸模型"的能力上限,后面再加花活。实际工程里你还得补上批处理、并发控制、超长图切片、结果缓存这些,但那些属于工程优化,不影响模型能力的判断。
3.2 分辨率与切图策略:最影响结果的一档参数
端到端 OCR 对输入分辨率极其敏感,这是我在多轮实测里感受最深的一点。给太小,笔画糊成一团,手写体直接崩;给太大,显存吃紧、耗时暴涨,而且收益迅速递减。
我在 S1 和 S2 上跑了一组分辨率对比,固定其他条件,只改输入短边:
| 输入短边 | S1 印刷 CER | S2 手写 CER | 单页均耗时 | 显存峰值 |
|---|---|---|---|---|
| 960 | 0.71% | 12.4% | 1.9s | 约 6.2G |
| 1280 | 0.45% | 8.6% | 3.2s | 约 9.1G |
| 1600 | 0.44% | 7.9% | 5.1s | 约 13.4G |
| 2048 | 0.43% | 7.6% | 8.4s | 约 20G+ |
这张表信息量很大。印刷体在 960 就已经很稳了,1280 之后基本没有增益;手写体从 960 到 1280 掉了将近 4 个百分点,1280 到 1600 只再降 0.7,1600 之后基本走平。1280 是这条曲线上的性价比拐点,除非你的场景手写占比极高且字特别小,否则没必要往 1600 以上堆。
切图策略上我试了两种:整页直接送,和按 2×2 或横向长条切分后拼接。结论是——在版式复杂(多栏、表格嵌套)时切图反而更差,因为切分边界会切断行,模型在边界处容易漏字或多字。整页送虽然显存吃得多,但上下文完整,阅读顺序也更靠谱。只有在超长图(比如扫描仪出来的 A3 长条)时才考虑切,切的时候必须留 5% 到 10% 的重叠区。
3.3 后处理:把原始输出修成能用的结构化数据
模型的原始输出离"能直接入库"还有距离。我加了三层后处理,每一层都是被错例逼出来的。
第一层是字符级归一化。处理全角半角、易混字符、大小写。这里要小心,不能无脑替换——比如 "O" 和 "0" 在编号里可能是字母 O,在金额里一定是数字 0,得靠上下文判断。
第二层是结构化解析。把连续的文本行按位置聚类成段落,识别键值对("姓名:XXX")、日期、金额。这一步我用规则 + 正则,便宜且可控。
第三层是置信度过滤。对低于阈值的行做标记,进入人工复核队列。实测下来,把后 5% 的低置信行挑出来复核,能把整体业务准确率拉高一大截,复核工作量也可控。
import re CONFUSABLE = {"l": "1", "I": "1", "O": "0"} def normalize_field(text, field_type): text = text.strip().replace("\u3000", "") if field_type == "amount": text = re.sub(r"[^\d.]", "", text) for k, v in CONFUSABLE.items(): text = text.replace(k, v) elif field_type == "date": text = re.sub(r"[年月]", "-", text).rstrip("日") return text注意:后处理规则一定要有版本管理和回归测试。我吃过亏——为了修一个特殊格式临时加的正则,三个月后把另一批数据搞乱了,排查成本极高。
4. 实测结果:多字体适应性到底几斤几两
好了,进入正题。下面所有数据都来自我那套 710 张的自建集,样本量有限,看趋势为主。
4.1 印刷体单字体基准:这里不是瓶颈
S1 子集的结果比我预期的好一些。整体 CER 0.44%,行级完全正确率 97.2%。分字体看:
| 字体 | CER | 行级 EM | 主要错误类型 |
|---|---|---|---|
| 宋体 | 0.38% | 98.1% | 标点位置 |
| 黑体 | 0.45% | 97.6% | 形近字 |
| 楷体 | 0.61% | 96.4% | 笔画细处粘连 |
| 仿宋 | 0.72% | 95.8% | 长横与下划线混淆 |
| 圆体 | 0.83% | 94.9% | 数字 6/8/9 混淆 |
| 手写风格字库 | 1.9% | 88.3% | 字库字与真实手写混淆 |
这里有个很有意思的发现:手写风格字库的 CER 是真实手写(工整档)的两倍。为什么?我分析下来是模型对"这种规整但又不是标准印刷"的字形产生了判断摇摆,偶尔会把它当真实手写去走另一套解码路径,导致输出不稳定。这提示我:如果你的文档里大量使用装饰性手写字体,反而要重点测这一类。
另外注意仿宋那 0.72%,错误集中在下划线、填空横线被压进文本的情况。这类错误靠模型本身解决不了,得靠版式规则在后处理里剔除。
4.2 手写与混排:真正的分水岭
S2 手写按三档分开统计,S3 混排按区域分开统计,结果如下:
| 子集 / 分档 | CER | 行级 EM | 备注 |
|---|---|---|---|
| S2 工整手写 | 4.8% | 71.5% | 错误多为标点与连笔 |
| S2 一般手写 | 8.2% | 54.3% | 连笔粘连、简写 |
| S2 潦草手写 | 11.6% | 38.7% | 大面积漏字 |
| S3 印刷区 | 0.9% | 95.1% | 受批注干扰轻微 |
| S3 手写区 | 9.4% | 49.8% | 与 S2 一般档接近 |
| S3 交界区 | 13.1% | 36.2% | 覆盖、压字、方向混乱 |
交界区的数据最值得说。我在测试集里专门做了一批"手写压在印刷字上"的样本,比如把"同意"两个手写字直接写在正文的某一行中间。传统级联方案在这种样本上基本是废的,因为检测框会打架。HunyuanOCR 在这批样本上能做到把两层文字都输出出来,虽然顺序和分组经常错,但至少没丢信息。这个能力对合同批改、试卷判分这类场景价值很高——信息不丢,后处理就有得救。
反过来,交接区里我最不满意的是阅读顺序。当手写批注写在行间缝隙时,模型有时会把它插到上一行末尾,有时插到下一行开头,非常随机。这个问题的本质是纯视觉模型对"哪个字属于哪一行"的判断依赖排版线索,而手写批注恰恰破坏了这些线索。
4.3 低质图像与极端字体:边界在哪里
S4 低质实拍整体 CER 9.5%,行级 EM 52.8%。分退化类型拆开看:
| 退化类型 | 相对基线 CER 增幅 | 说明 |
|---|---|---|
| 轻度倾斜(±3°) | +18% | 影响很小 |
| 中度倾斜(±8°) | +65% | 行切分开始混乱 |
| 高斯模糊(核 5) | +90% | 小字基本糊掉 |
| 侧向阴影 | +120% | 暗部字符漏检 |
| 折痕穿过文本 | +180% | 折痕被误判为字符 |
| 手指遮挡 | 视面积而定 | 遮挡区直接丢失 |
折痕那一项是最糟的,因为折痕在图像上表现为一条细长高对比度的线,模型有时会把它当成"一"或"-"输出,污染结果。这个问题我在别的方案上也见过,属于共性问题,只能靠预处理做折痕检测,或者后处理里过滤掉孤立的横线字符。
S5 表格子集我没用严格的结构指标,只看"单元格内容还原率"和"行列数是否正确"。结果是内容还原率 88%,行列数正确率 91%,合并单元格错得比较多。表格这块目前还是得搭配专门的表格结构识别做兜底,端到端 OCR 直接出 Markdown 表格的能力,实用但不够稳。
5. 典型故障复盘与排查手册
跑完这一轮,我把所有错例按失败模式归了类,整理成下面这张速查表。这份东西我觉得比结果数据更有价值,因为它是"下次遇到问题先看哪里"的清单。
5.1 失败模式速查表
| 现象 | 可能原因 | 排查动作 | 处理建议 |
|---|---|---|---|
| 整行漏识别 | 分辨率过低 / 行太长被截断 | 检查切图与短边设置 | 提到 1280 以上,超长行切片留重叠 |
| 手写批注消失 | 批注与正文重叠,被合并 | 对比原图逐区核对 | 分区裁剪后二次推理 |
| 折痕被识别成"一" | 高对比度细线干扰 | 看输出里孤立横线 | 预处理去线 / 后处理过滤单字符行 |
| 阅读顺序错乱 | 版式线索被批注破坏 | 检查行间插批注样本 | 用坐标重排,别信模型给的顺序 |
| 数字 0 与字母 O 混淆 | 缺少上下文约束 | 看字段类型 | 按字段类型做定向替换 |
| 全角标点全错 | 归一化缺失 | 检查评测脚本 | 输出侧统一转半角 |
| 表格行列错位 | 合并单元格 | 看结构输出 | 引入专门表格识别兜底 |
| 竖排文本乱序 | 阅读方向判断 | 单独测竖排样本 | 先做方向分类再送模型 |
| 印章区文字丢失 | 遮挡 | 检查遮挡面积 | 接受丢失,或做印章分离 |
| 长图后半段丢失 | 输入被压缩 | 看原始尺寸与缩放比 | 长图必须切片处理 |
这张表是我从大约 200 条错例里归纳的,覆盖了 85% 以上的失败情况。建议你也给自己的业务场景建一张类似的表,比记一堆"总体准确率"有用得多。
5.2 我的几条避坑心得
第一条:别一上来就怀疑模型。我刚开始测的时候,发现某个子集错误率异常高,第一反应是模型不行。结果查下来是自己切图时把图像宽度压到了 800,笔画全糊了。后来我养成了一个习惯——任何异常结果,先看送进模型的图长什么样,肉眼看不清楚的字,模型也看不清。
第二条:把原始坐标留住。端到端模型输出文本很方便,但如果你只拿文本,后面想按区域统计、想重排阅读顺序、想做字段对齐,全都没法做。我在第一版里只存了纯文本,后来要加字段抽取,硬是重跑了一遍全量数据,浪费了一整天。
第三条:建立错例池,定期回看。我专门建了一个目录,每次发现典型错例就存进去,附带当时的参数配置。这个池子后来成了我调参和写规则的主要依据。人是会忘的,尤其是"上次那个报错是怎么解决的",没有池子基本想不起来。
第四条:手写体想提升,方向在数据不在参数。我试过各种提示词引导、各种后处理规则,效果都有限。真正有明显提升的,是往测试集里补充同类型书写者的样本做少量适配。手写这种高度个性化的问题,参数调优的边际收益很低。
第五条:别迷信端到端能吃掉一切。端到端在混排上的优势很明显,但在复杂表格、多栏期刊、竖排古籍这些场景,版式分析仍然有不可替代的价值。我的建议是"端到端为主,版式分析兜底",而不是非此即彼。
第六条:耗时和精度要分开测。我一开始把精度测试和性能测试混在一起跑,结果因为显存回收不及时,耗时数据全飘了。后来改成两轮独立跑,精度看结果,耗时看单次冷启动,数据才稳。
提示:如果你的业务里手写占比超过 30%,我建议把评测重点从"整体 CER"切换到"关键字段完全正确率"。整体指标会被大量简单印刷文本稀释,看起来很漂亮,实际业务体验却很差。
最后分享一个我自己在用的评估小技巧。我会把错例按"业务影响"打个标:A 类是完全不可接受(金额、日期、证件号错),B 类是可人工纠正(普通文本错字),C 类是无所谓(标点、格式)。实测下来,HunyuanOCR 在我这套集子上 A 类错误率大约 2.1%,其中大部分集中在潦草手写的数字串。这个数字比整体 CER 更能反映它能不能直接上生产。我个人在实际操作中的体会是,只要 A 类错误能压到 2% 以下,配合低置信度复核,这条链路就已经具备落地价值了;而如果想再往下压,靠的不是换模型,是把你的业务语料喂进去做针对性适配。