28  OCR 字符识别

提起 OCR(Optical Character Recognition,光学字符识别),多数人想到的是扫描仪与文档数字化:成千上万的字符类别、千变万化的字体与版式。工业 OCR 完全是另一回事。产线上要读的是喷码机打在瓶盖上的生产日期、激光刻在金属件上的序列号、点阵针打在铭牌上的批次号——字符集往往只有几十个(数字、少量大写字母),字体由喷码设备决定、完全固定,但成像条件却远比扫描文档恶劣:表面弯曲反光、墨迹深浅不一、喷头堵塞造成笔画残缺。在这种”小字符集、固定字体、恶劣表面”的组合下,经典的可训练字库(trainable font)路线至今仍是主力:用现场采集的真实字符训练一个小型分类器(classifier),它对这套字体的鲁棒性远胜任何通用文档 OCR。本章用一套自定义点阵字体完整走一遍这条路线——训练、识别、压力测试,直到亲手把它逼到失败。图 28.1 是我们的”字体”:0–9 与 A–F 共 16 个字符,每个 20×28 像素,墨色灰度 50、底色 200,刻意收入了 0/D、8/B 这样的易混对(confusable pairs)。

图 28.1: 自定义点阵字体的训练行:0–9 与 A–F 共 16 个字符,每字符 20×28 px,墨色 50、底色 200。字符集中刻意包含 0/D、8/B 等易混对,为后面的混淆实验埋下伏笔。

28.1 经典 OCR 流水线

经典 OCR 把”读字”拆成四个步骤,每一步都建立在前几章的基础之上。

  1. 二值化(binarization):把字符笔画从背景中分离出来,得到前景区域。这就是 章节 7 的阈值分割——字符与背景对比度通常较高,固定阈值或自动阈值都能胜任,关键是选对极性(深色字还是亮色字)。
  2. 字符分割(character segmentation):把前景区域切成一个个独立的字符单元。其内核是 章节 23 的连通域分析,再叠加文本的行列结构先验:字符排成行、行内有大致均匀的间距、字符的宽高与面积落在已知范围内。
  3. 特征提取 / 归一化:把每个字符小图缩放到统一尺寸,提取灰度或形状特征。
  4. 分类(classification):用一个分类器把特征映射为字符标签——这正是 章节 29 的内容,只不过类别从”良品/次品”换成了 16 个字符。

深度学习 OCR(SDK 中另有 DLOCR 模块)端到端地完成检测与识别,适合字体多变、背景复杂的场景;但在固定字体、小字符集的喷码识读中,经典路线训练样本少、推理快、失败模式可解释,仍是更轻更可控的选择。本章不展开 DLOCR。

注意一个结构性事实:这四步是单向流水线,每一步的输出就是下一步的输入单元。分类器再聪明,也只能对分割交给它的那块图像作答——这个看似平凡的观察,到 小节 28.5 会显示出全部分量。

28.2 训练字库

SciVision 的经典 OCR 工作流是”分割—标注—训练”三部曲。我们先渲染一行干净的 16 字符训练图(图 28.1),用 GetCharRegion 二值化得到字符前景区域,再用 SegCharacters 按行列结构切出 16 个字符小图。由于训练行的字符顺序已知(就是 “0123456789ABCDEF”),把切出的小图按外接框中心从左到右排序后,标签就自动对上了——AddSamples 一次喂入 16 张二值小图和 16 个标签字符串。

只用一行干净样本训练,字库会过于”娇气”:分类器没见过噪声与位置偏移,遇到真实喷码就可能翻车。因此我们做了轻度数据增广(data augmentation):在干净母版之外,再渲染 3 行退化版本——叠加标准差 \(\sigma=5\) 的高斯噪声、每个字符随机抖动 \(\pm 1\) 像素——同样分割、标注、AddSamples。四行合计 \(16\times 4=64\) 个样本、16 个类别,最后调用 Train(0) 训练默认分类器(输入为二值小图)。训练完成后用干净训练行做自检,16 个字符全部识别正确。

每类只有 4 个样本就能工作,听起来违背机器学习的直觉,其实前提非常苛刻:字体完全固定(类内变化只来自噪声与微小位移,不存在字形差异)且分割可靠(喂给分类器的永远是一个完整、居中的字符)。这两个前提正是工业 OCR 与文档 OCR 的分水岭——前者用控制现场来换取小样本,后者用海量样本去覆盖失控的多样性。一旦前提被破坏(喷码设备更换字模、字符触碰粘连),小样本字库会立刻失效,这也是后面两节实验的主题。

28.3 识别与置信度

字库训练好了,来读一串没见过的字符。测试串 “8A0F31” 以 \(\sigma=5\) 的噪声和每字符 \(\pm 1\) px 的随机抖动渲染(图 28.2 上方为标注后的结果图),走同一条二值化—分割流水线切出 6 个字符,交给 Recognize 识别。结果全部正确,但更值得看的是每个字符的得分(0–100):

位置 真值 识别 分数
0 8 8 100.00
1 A A 100.00
2 0 0 100.00
3 F F 94.95
4 3 3 100.00
5 1 1 87.16
(a) 测试串(\(\sigma=5\)\(\pm 1\) px 抖动)
(b) 识别结果标注
图 28.2: 测试串 “8A0F31” 的识别。(a) 输入图像;(b) 每个分割出的字符画双线框,框上方标注识别出的字形——6 个字符全部正确,但分数从 100 到 87.16 不等。

为什么 ‘1’ 只有 87.16?它是字符集中最窄的字形(仅 11 px 宽),笔画像素少,归一化缩放后噪声与 1 px 抖动对它的相对影响最大;‘F’ 的 94.95 同理来自噪声对其开放笔画末端的侵蚀。分数的价值不在”对了多少”,而在它是拒识(rejection)与复核的依据RecognizeminScore 参数划定接受门槛,低于门槛的字符不输出猜测值,而是输出 replaceChar 指定的占位符(我们传 “?”)。

这对参数的工程语义值得专门强调:宁可输出 “?” 触发人工复核或重扫,也绝不静默输出一个低置信度(confidence)的猜测。一个被误读的生产日期流入市场,代价远高于一次产线复扫——这与 章节 26 反复强调的”静默失败是最危险的失败”是同一条原则在 OCR 上的投影。87.16 与 100 的差距今天没有造成错误,但它告诉你:‘1’ 是这套字库里离悬崖最近的字符,minScore 应当设在哪里、留多少裕量,要从这种分数分布里读出来。

28.4 混淆与极限

字库在 \(\sigma=5\) 下游刃有余,那它的极限在哪里?我们设计了一个混淆压力实验:测试串 “0D8B” 同时包含两对字形高度相似的字符——0 与 D(都是封闭环,差别仅在转角方圆)、8 与 B(差别仅在左侧是否有中腰收束)。噪声从 \(\sigma=10\) 逐档加到 50,每档独立渲染识别 10 次。

结果出乎意料地”陡峭”:\(\sigma=10\)、20、30 三档全部 100% 正确——叠加 \(\sigma=30\) 的噪声时图像已经雪花密布,字库依然稳如泰山。首次失败出现在 \(\sigma=40\):单字符正确率降到 90%,而且失败的方式完全不按剧本走——不是预期中的 0/D 或 8/B 互换,而是 D 被认成了 C图 28.3 是这次首败的现场:\(\sigma=40\) 的噪声恰好”吃掉”了 D 右侧的竖笔画,封闭的环被打开,在分类器眼里它就成了开口向右的 C。\(\sigma=50\) 档的失败模式相同。整个实验 50 次试验没有一次分割失败——分割比分类先天更鲁棒,因为它只需要”这里有一团前景”,不需要分辨这团前景是什么。

图 28.3: \(\sigma=40\) 档的首次识别失败案例:“0D8B” 中的 D 被识别为 C——噪声恰好侵蚀了 D 右侧的竖笔画,把封闭环变成了开口字形。失败模式不是预设的 0/D 互换。

我们精心设计了 0/D、8/B 两对”理论易混对”,真实的首败却是 D→C。教训:失败模式不按直觉出牌。混淆矩阵必须实测,不能靠人眼对字形的相似性判断来推演——人觉得像的,分类器未必混;人觉得不像的,一笔噪声就能让它们相邻。

这个实验给出两条可操作的结论。其一,固定字体小字符集的经典 OCR 鲁棒性裕量很大(训练时只见过 \(\sigma=5\),扛到了 \(\sigma=30\)),不必为正常产线波动担忧。其二,评估字库必须做实测混淆分析,把噪声加到失败为止、记录真实的混淆对——它们往往不是你预设的那几对,而拒识阈值与字符集设计(例如序列号规则里避开易混字符)都应基于实测混淆,而非直觉。

28.5 分割:OCR 的阿喀琉斯之踵

混淆实验里分割零失败,是因为字符间距充足。现在抽掉这个前提:把 “8A0F31” 的字符间距压到 0,相邻字符的笔画直接贴合——这模拟喷码中常见的字距失控或墨水洇染。结果见 图 28.4:前 5 个字符 “8A0F3” 的笔画融成一个约 100 px 宽的大连通域,只有 ‘1’ 因字形右侧本就留空而幸免。分割器忠实地报告:切出 2 个”字符”。识别结果是 “?1”——那个 100 px 的庞然大物与字库中任何字形在 0.5–2 倍尺度范围内都对不上,被 SDK 拒识为 “?”。

图 28.4: 字符间距压到 0 的测试串:前 5 个字符 “8A0F3” 笔画贴合融成单个连通域,只有 ‘1’ 保持独立。分割器切出 2 个”字符”,识别结果为 “?1”。

请体会这次失败与上一节噪声失败的本质区别。噪声把 D 变成 C,是分类层的错误——错一个字符,其余照常,分数还会发出预警。而分割失败是结构层的错误:6 个字符变成了 2 个输入单元,下游分类器无论多强都无力回天——它的任务是回答”这个单元是什么字符”,而”这个单元根本不是一个字符”超出了它的问题空间。这就是经典 OCR 的阿喀琉斯之踵:分割决定成败,且分割错误不可恢复。文档 OCR 的研究史上,触碰字符切分(touching character segmentation)曾是最顽固的难题之一,深度学习端到端方法的兴起很大程度上正是为了绕开显式分割。

工业现场的对策不是去攻克这个难题,而是不让它发生:

  • 从源头规范字距:喷码设备的字符间距是可配置的,把它纳入工艺规范,给分割留出余量——这比任何算法补救都便宜;
  • 分割参数与字符尺寸联动SegCharacters 的宽、高、面积上下界应依据实际字模尺寸设定,让一个 100 px 宽的粘连块直接落在合法宽度区间之外;
  • 触碰检测告警:期望 6 个字符却切出 2 个,本身就是强烈的异常信号——字符数不符就报警复检,绝不把残缺结果当正常输出放行。

28.6 SciVision 实现

本章全部实验由 SCIMV::SciSvOCR 一个类完成,四步 API 与 小节 28.1 的流水线一一对应:

SCIMV::SciSvOCR ocr;
SciRegion region;
// 1) 二值化提取字符前景:手动阈值 [0,125] 选深色字符
//    注意 polarity 实测语义与文档相反——深色字符必须传 1
ocr.GetCharRegion(img, roi, /*thresholdMode*/0, /*polarity*/1,
                  /*minGray*/0, /*maxGray*/125, 0, 3, 3, 0, &region);

SciImageArray binImgs, greyImgs;  SciRegionArray regs;
SciROIArray lineRects, charRects; SciVarArray idx;
// 2) 字符分割:1 行文本、期望 6 字符、宽 [8,120] 高 [14,56] 等界限滤噪
ocr.SegCharacters(region, roi, 1, 6, 10, 2, -10, 10, /*ignoreEdge*/0,
                  10, /*mergeMode*/1, 6, 2, 10, 0, 9999,
                  8, 120, 14, 56, -30, 30, 30, 9000, 0.0f, 5.0f,
                  &binImgs, &greyImgs, &regs, &lineRects, &charRects, &idx);

// 3) 训练:每行分割结果配上标签喂入,4 行共 64 样本,再训练默认分类器
ocr.AddSamples(binImgs, labels);   // ×4 行
ocr.Train(0);                      // 0 = 默认分类器,输入二值小图

// 4) 识别:minScore 之下输出 replaceChar("?"),分数范围 0-100
SciVar replaceChar("?"), result;  SciVarArray scores;
Sci2DVarArray resultAll, scoresAll;
ocr.Recognize(binImgArray, /*minScore*/0, 0.5f, 2.0f, 0.5f, 2.0f,
              replaceChar, &result, &scores, &resultAll, &scoresAll);

关键参数中,GetCharRegionthresholdMode=0 为手动阈值,配合灰度区间 [0,125] 选取深色笔画;SegCharacters 的字符宽高与面积上下界既是滤噪手段,也是 小节 28.5 所说的触碰防线;RecognizeXscale/Yscale 区间允许识别时对字符做 0.5–2 倍的尺度搜索。

实测中踩到三个必须如实记录的坑:

  • GetCharRegion 手动阈值下的 polarity 语义与文档相反。按文档深色字符应传 0,实测传 0 拿到的是背景连通域——整行 16 个字符变成一个 538×78 的”巨字”,后续分割自然全军覆没。深色字符必须传 polarity=1 并配灰度区间 [0,125]
  • SegCharactersignoreEdge=1 会误报错误码 122701003,即使没有任何字符触边。传 0 规避。
  • AutoSegCharacters(自动分割变体)的自动宽度界会丢窄字符。它从样本估出的宽度区间为 [19,20],而 ‘1’ 实际只有 11 px 宽,直接被滤掉——16 个字符只剩 14 个。字符宽度差异大的字体应使用手动参数的 SegCharacters 并显式给出宽度界(本章用 [8,120])。

完整可运行工程见 code/ocr/,固定随机种子,所有数字均可复现。

工业案例:喷码日期的”幽灵 8”

某食品厂瓶盖喷码识读系统偶发把日期中的 “3” 读成 “8”。排查发现是喷头轻微堵塞:墨滴偏移让 “3” 左侧开口处出现一道淡墨桥接笔画,字形向 “8” 靠拢。关键证据在分数曲线——正常 “3” 的识别分数为 95 以上,故障期间掉到 60–70,但仍高于当时设置的 minScore=50,于是错误结果被系统当作合格识别放行,直到客诉才暴露。整改分三步:把 minScore 提到 85;低于 95 的识别触发自动复扫并留图;把喷头压力与喷嘴状态纳入设备点检表,从源头掐断字形漂移。教训:置信度阈值要从”误识的代价”反推,而不是越低越宽容——阈值 50 意味着系统默认”半信半疑也放行”,对一个印在食品上、面向消费者的日期来说,这个默认是不可接受的。

28.7 小结

  • 工业 OCR ≠ 文档 OCR:字符集小、字体固定、表面恶劣。用控制现场换取小样本——每类 4 个增广样本就能训练出可用字库,但前提是字体固定且分割可靠,前提一破字库即失效。
  • 经典流水线 = 二值化 → 字符分割 → 特征 → 分类,分别建立在阈值分割、连通域分析与经典分类器之上;它是单向的,每步的错误都由下游全额买单。
  • 分数是拒识与复核的依据,不是装饰:“8A0F31” 全对,但 87.16 与 100 的差距标出了离悬崖最近的字符。minScore 要从误识代价反推,宁可输出 “?” 也不静默猜测。
  • 混淆必须实测:σ 阶梯实验中字库扛过了 6 倍于训练噪声的 \(\sigma=30\),而 \(\sigma=40\) 的首败是意料之外的 D→C(噪声打开了封闭环),不是精心设计的 0/D、8/B——失败模式不按直觉出牌。
  • 分割是 OCR 的阿喀琉斯之踵:字距为 0 时 6 个字符融成 2 个输入单元,识别只剩 “?1”,再好的分类器也不可恢复。对策在工程侧:规范字距、分割参数与字模尺寸联动、字符数不符即告警。

关于 OCR 分类器的特征设计与可训练字库的系统论述,可进一步阅读 Steger 等人的著作 (Steger, Ulrich, 和 Wiedemann 2018)。OCR 研究的整体脉络可读 Mori、Suen 与 Yamamoto 的经典综述 (Mori, Suen, 和 Yamamoto 1992),它系统梳理了从模板匹配到结构分析的发展史;本章着力的特征提取环节,Trier、Jain 与 Taxt 对分割字符的各类特征方法做了至今仍具参考价值的调研 (Trier, Jain, 和 Taxt 1996);而把视野扩展到手写体的在线与离线识别,Plamondon 与 Srihari 的综述是公认的入门索引 (Plamondon 和 Srihari 2000)。与 OCR 同属”读取印刷信息”家族的一维码与二维码识别见 章节 27