把一块万用表的屏幕照片丢进通用OCR工具,识别率通常还能看;把同一块表绑到产线设备上,隔着1.5米还压着一层防爆玻璃,屏幕又是低对比度的蓝底白字,通用OCR基本原地退役。这是我去年做设备数据采集项目时最直观的体会。YOLOv3-SPP数字显示设备读数识别,实际干的事情是让目标检测模型先把图像里的每个待读字符框出来、分好类,再按坐标顺序拼成完整读数。听起来比OCR绕远路,但它对付光照反差、斜视角、半亮数码管这些真实工况时,稳定性明显高出一截。这篇博文就是整个项目的完整复盘:为什么没有直接上OCR,SPP模块到底补了什么,数据怎么标、模型怎么调、部署时踩了哪些坑。如果你正在做生产设备读数识别、仪表盘自动抄录,或者单纯想把手头的YOLO系模型调得更准一点,这篇都值得慢慢看。
1. 为什么选YOLOv3-SPP:一段被"数字读数"逼出来的选型思考
1.1 数字显示设备读数的真实难点,和"拍得清"是两回事
先说项目背景。现场要采集一类设备的工作电压和运行时长,设备自带LED显示屏,读数通常只有三到六位。刚开始我以为这就是个简单OCR问题,等到真正对着现场图像做测试才发现,数字显示设备读数的难点根本不在"文字识别"本身,而在"把字符从乱七八糟的背景里干净地找出来"这一步。
难点主要落在几个地方。第一是七段数码管的断笔现象。LED/LCD数字管通电后,某些笔画亮度不够,照片里经常出现"3"缺一笔变成像"7"、"8"缺两笔变成"0"之类的情况。传统OCR先做二值化再提取字符连通域,笔画一断,连通域直接裂开,后续流程全崩。第二是光照问题,现场有顶光和侧光,防爆玻璃反光会在屏幕上拉出一道白条,把中间两位数字整个盖住。第三是视角,安装位置决定相机只能斜着拍,字符本身是梯形变形,通用OCR对姿态校正依赖比较强,一旦校正不准,识别结果反而更差。
还有一个常被忽略的点:显示屏本身会刷新。不少LED屏在相机快门下手呈现半亮状态,某一帧笔画缺失严重,另一帧又恢复正常。"拍得清"不等于"每一帧都拍得清",所以后策略必须容忍单帧缺陷,而不是把宝押在单帧完美识别上。
1.2 从YOLOv3到SPP:多尺度特征给字符识别补了什么课
YOLOv3的骨干是Darknet53,用FPN结构输出三个尺度的特征图:13×13负责大目标,26×26负责中等目标,52×52负责小目标。数字显示屏上的字符属于典型小目标,单字符在1080P图像里通常只有30×60像素左右,主要靠高分辨率特征图去检测。问题在于,高分辨率特征图感受野偏小,能看清局部笔画,却把握不住"这是一个独立字符"的全局边界。表现在结果上就是:没有SPP时,模型容易把"1"和"7"搞混,有时把相邻两个数字并成一个框,或者漏掉连续"8"中间的某一笔。
SPP模块(空间金字塔池化)就是在最后一个特征层后面,接上三个不同尺寸的池化核并联分支,然后把结果拼接回主特征图。它的价值在于把局部信息和更大范围上下文做了融合。对于数字识别,上下文恰恰很有用:一个字符左右两侧是否留了空位、笔画有没有延伸到下一位的边界,这些信息只有更大感受野才能感知到。我用的配置是Darknet原版SPP,在backbone输出后加一组kernel size为5、9、13的最大池化,拼接后送入后续检测头。它只增加少量计算量,但误检和漏检改善非常直观,尤其对"1和7""3和8"这类容易彼此串扰的字符对。
我做过一个对比实验:同样的训练数据和超参,不加SPP的模型在验证集上误检数量比加了SPP的模型高出约35%,漏检数量高出约20%。这说明SPP不是锦上添花,而是在这种低对比度、断笔严重的小目标场景下实打实地补上了最重要的上下文信息。
1.3 和"端到端OCR管线"的正面对比
这里顺便回答一个高频问题:为什么放着现成OCR不用,非得自己训检测网络?我在方案评审时做过一张对比表,列一下核心差异:
| 对比维度 | 通用OCR(文本检测+序列识别) | 目标检测+字符分类(本文方案) |
|---|---|---|
| 抗数码管断笔能力 | 弱,依赖二值化和连通域 | 强,卷积特征本身容忍局部缺失 |
| 小数点/负号处理 | 有时并入字符序列,规则隐蔽 | 当成独立类别,位置可控 |
| 倾斜/透视矫正 | 依赖前置矫正模块 | 检测网自身对轻微倾斜鲁棒 |
| 调试可解释性 | 序列解码结果难定位错误来源 | 每个字符框可视化,错误直观 |
| 训练数据需求 | 需要整行文本样本 | 只需要单字符框标注 |
通用OCR当然很成熟,但它面向的是印刷文字、屏幕截图这类相对规整的输入。工业现场的数字屏属于极端场景,序列识别一旦在中间某一位出错,错误会往后面传递,很难定位到底哪一步出了问题。检测+分类的方案,如果读出来是"23.8V"但后面少了一位,直接把框画出来看一遍就知道是漏检还是分类错,这种可调试性在实际项目里比模型精度还值钱。
2. 数据集构建:读数识别精度的"隐形天花板"
2.1 采集方案决定模型上限,别拿网图凑数
模型能学到的上限由数据集决定,这个道理人人都懂,但做的时候很容易偷懒。我当时犯过第一个错误是想从网上收集同类屏幕照片来扩充数据,后来发现屏型、字体、环境光完全对不上,模型在测试集上反而更差。正确的做法是按真实部署场景搭一个临时采集台:相机高度和角度模拟最终安装位置,在不同时段的光照下拍摄,屏幕内容则通过调整设备参数人为制造出各种读数。
采集要覆盖几类变化:正常读数、负值、带小数点的值、最高位为零的情况、屏幕熄灭瞬间、屏幕内容正在更新时的拖影帧。尤其负号和小数点,出现概率不高,但在真实读数中绝不能错。按经验,这两类样本在训练集里的占比至少要达到5%到10%,否则模型会倾向于把它们全部忽略,因为"无字符"才是多数类,学起来阻力最小。
另外,拍摄时必须同步记录每一段视频对应的真实读数。我当时的做法是每录一段屏幕视频,马上人工把当前读数写进文件名,比如"01.5V_20240110.mp4"。这样后面切帧做标注时,标注员可以对照真实读数判断屏幕状态,有效避免把闪烁半亮帧当成错误数字标进去。
2.2 标注策略:一个字符一个框,还是整个读数一个框
标注方案直接决定模型输出形式,我强烈建议按"每个字符一个独立框"来做。类别就设这几类:数字0到9、小数点、负号、以及"blank"(空屏或熄灭屏)。不要尝试把整个读数框起来做多分类,原因很简单:读数的组合情况太多了,"23.8V"和"238V"是两个完全不同的类别,类别爆炸不说,模型对有遮挡的中间几位毫无招架之力。
每个字符独立框的好处是:单个字符类别只有14个,样本量容易凑均衡;某个字符被反光条盖住时,其他字符依然能正常识别;最后拼读数时还可以对每一位做合理性校验。标注框需要尽量贴合字符实际轮廓,不能贪心地把周围半格空隙也包进来。我在复核中发现,框扩得大的样本,训练出来的模型预测框也偏大,两个相邻数字的框容易交叠,给后面的排序和去重带来麻烦。
标注工作量看着大,实际还好,单张图通常只有三到六个框,一个熟练标注员一天能处理两千张。重点是设置复检环节:抽20%的样本让第二个人独立标一遍,对比差异,把漏标、错标的问题尽早杀在数据源头。这一步省下的时间,抵得上后面调模型的十倍功夫。
2.3 数据增强的边界:不是越猛越好
增强做得好能缓解样本不足,做过头会让模型学到假规律。我最终采用的是"保守基础增强+针对性模拟增强"的组合方案。基础增强包括:亮度扰动±35,对比度扰动±30%,饱和度扰动0.5以内,高斯模糊sigma取0到1.5,仿射变换只允许旋转±8度、平移10%、缩放0.8到1.2。这类增强让模型对光照波动和轻微对焦不准保持鲁棒,也不会扭曲字符的基本形状。
针对性增强则只处理真实场景的问题。最值得做的是"局部高光模拟":在图像随机位置叠加一条亮带,模拟玻璃反光遮挡,逼着模型学会利用可见部分推断字符;以及"数码管闪烁模拟":随机把某些字符的笔画擦掉一半,模拟刷新半亮。这两个增强对泛化性能的提升比通用增强明显得多。
但有几个增强能不用就不用。马赛克增强在通用目标检测里效果很好,对数字字符这种小目标却很危险,拼图之后字符缩得更小,模型容易丢失细节。旋转角度也不要超过15度,否则"6"和"9"的方向语义会被破坏,模型在推理时会对这两个类别迟疑。我的经验是,先把基础增强跑通,再逐项验证新增增强是否真的提升了验证集效果,不涨点就果断撤掉。
3. 训练优化落地:从模型配置到损失收敛的完整记录
3.1 迁移学习与冻结策略
数字字符属于非常具体的目标域,从头训练不现实,数据量远不够。我的做法是加载在通用目标数据集上预训练好的Darknet53权重,而不是只加载ImageNet分类权重。理由很简单:检测预训练权重已经学会了"找目标、定边界"的基础能力,微调到字符检测上收敛更快。
训练分成两个阶段。第一阶段冻结骨干网络,只训练特征融合层和检测头,学习率设得低一点,跑大约80轮。这个阶段让新加的SPP融合层和三个输出头先适应字符特征分布,避免随机初始化的检测头把骨干学到的通用特征一下冲坏。第二阶段把全部层解冻,整个网络一起训练约120轮。解冻后骨干为了适配数字字符会做一些微调,但不会剧烈漂移。如果数据量达到数万张,可以适当缩短第一阶段到40轮;如果只有两三千张,第二阶段要缩短并加强正则,防止过拟合。
3.2 anchors设计与配置文件修改
YOLO系模型非常依赖默认锚框和实际目标尺寸的匹配度。原始9组anchor是针对COCO数据集的大目标设计的,对数字字符来说普遍偏大。我统计了自己训练集中所有GT框的宽高分布,字符框宽度集中在20到60像素,高度集中在40到80像素,宽高比大多在0.5到1.5之间。基于这个分布重新做k-means聚类,得到9组新的锚框。
聚类结果大致长这样(示意,具体要按自己的数据重算): (6, 14), (10, 18), (9, 28), (15, 24), (13, 36), (24, 32), (18, 52), (34, 48), (30, 78)。把这些数值写进模型配置文件,同时调整检测头对应配置里的类别数。我用的框架配置片段大致如下:
[convolutional] filters=105 size=1 stride=1 pad=1 activation=linear [yolo] mask = 0,1,2 anchors = 6,14, 10,18, 9,28, 15,24, 13,36, 24,32, 18,52, 34,48, 30,78 classes=14注意filters的计算公式是(classes+5)×3,我这里类别数是14,算下来filters=(14+5)×3=57×?,实际是(14+5)×3=57,这里应该是57而不是105。上面的105对应classes=30的情况,是模板抄错留下的坑,训练前一定要按类别数重新算一遍,否则加载配置文件时维度不匹配,或者更隐蔽地,程序自动裁剪权重导致最后一层乱掉。
3.3 关键超参与收敛判断
我常用的超参设置如下表。不是什么金科玉律,但在这个场景下验证过,可以直接抄。
| 参数 | 数值 | 说明 |
|---|---|---|
| 输入尺寸 | 416×416 | 字符小目标多,建议至少416,有条件可以上544 |
| batch size | 16 | 显存不够就降,梯度累计兜底 |
| 初始学习率 | 0.001 | 迁移学习用低一点更稳 |
| 学习率策略 | warmup 1000步 + 余弦退火 | 避免前期剧烈震荡 |
| 权重衰减 | 0.0005 | 防止类别多时过拟合 |
| 标签平滑 | 0.1 | 对六位数字这种难界定的边界有帮助 |
训练观察不能只盯着train loss。我见过loss降得很漂亮,验证mAP却不涨反跌的过拟合典型症状。合理的做法是每个epoch记录验证集上的字符类别平均精确率、平均召回率、以及F1,顺便统计难样本,比如被错误分类的负数和小数点。我的测试集里最终字符mAP到0.982,读数完全正确率在单帧上约98.2%,多帧投票后到99.4%。如果训练后期发现验证集上某个字符类别(通常是0和8)召回率明显偏低,优先检查是不是标注时把这两个数字互相标错了,而不是急着调模型结构。
4. 推理部署与真实场景效果:模型跑起来才算数
4.1 导出、量化与边缘部署
训练产物是框架原生权重,部署时我不会直接用它推理。先把权重导出成ONNX中间表示,再转成推理优化引擎,并用FP16量化。这样做的原因有两个:一是ONNX让推理脱离训练框架,部署环境更干净;二是FP16在几乎不影响精度的前提下,把边缘GPU平台的推理延迟砍掉一半以上。
整个转换链路里最容易踩的坑是SPP层的最大池化在导出时padding行为不一致。某些转换器对形状不整除的特征图处理方式不同,导致数值偏差。我的排查办法是转换后用同一张测试图逐层对比中间tensor的最大误差,误差超过1e-2就说明转换有bug,不能继续用。还有就是导出时固定batch为1,动态维度在边缘推理盒子上经常带来额外的内存开销,固定成静态维度反而更快。
最终在边缘GPU设备上的数据大概是:FP16单帧推理约11ms,CPU推理约48ms,而产线视频流是10fps采集,GPU方案有将近九倍的性能余量,足够再做多帧投票和读数范围校验。如果换更低端的嵌入式平台,我建议优先把输入尺寸从544降到416,再考虑换轻量骨干,而不是直接对原模型做剪枝,因为剪枝对后期维护和重新训练都不友好。
4.2 从字符框到完整读数:排序、小数点位与多帧稳定
检测网络的输出只是十几个候选框和类别概率,要变成一行读数,还需要一个轻量后处理流程。我按下面这些步骤写了个服务函数:
- 先用置信度阈值0.45过滤掉弱检框,再用NMS去掉重叠框。
- 对保留下来的字符框,按中心点x坐标从小到大排序。这个场景是单行读数,所以不需要做行聚类;如果以后遇到双行显示的设备,得加一个按中心点y坐标分层聚类的步骤。
- 读取类别:数字类直接拼进字符串;负号类如果有且出现在最左,就给最终读数加负号;小数点类不占字符个数,只记录位置。
- 小数点落位规则:检测器输出的小数点框中心通常在相邻两位数字框中心线以下,且位于两者之间。如果一个小数点框被检测出来,先把它和左右最近的数字框关联,再决定插在结果字符串的哪个位置。
- 多帧投票:对同一台设备连续取5帧识别结果,每个位上的字符取众数。这个方法对屏幕刷新闪断特别有用,某两帧把"3"识别成"7",只要另外三帧都是"3",最终结果就是"3"。
关键代码段大概是这个思路:
def boxes_to_reading(boxes, conf_thresh=0.45): valid = [b for b in boxes if b.conf > conf_thresh] valid.sort(key=lambda b: b.cx) reading = "" dot_pos = None row = [b for b in valid if b.cls != "dot"] for idx, b in enumerate(row): if b.cls == "minus": reading = "-" + reading else: reading += b.cls if b.cls.isdigit(): pass if any( abs(d.cx - b.cx) < max(b.w, d.w) * 1.2 and d.cy > b.cy + b.h * 0.3 for d in valid if d.cls == "dot" ): dot_pos = len(reading) - 1 if dot_pos is not None: reading = reading[:dot_pos+1] + "." + reading[dot_pos+1:] return reading注意负号的处理,判断它属于哪个数字位需要小心。真实设备上负号通常站在读数最前面,但检测可能把它和"1"的横线混在一起,我建议输出前加一条规则:如果最左边的非dot类别是minus,才允许最终串首加负号,其他位置出现的负号一律视为误检。
4.3 实测指标与失败场景复盘
部署现场最关心的是"读数完全正确率"而不是单字符mAP。我在6500张现场图片组成的测试集上测出来的数字是单帧98.2%,加上5帧投票后到99.4%,单字符mAP是0.982。这个差距主要来自三类失败:
第一类是屏幕玻璃严重反光,中间两位数字完全消失在亮带里。这类图模型能框出边框,但类别可信度低,投票机制可以把它当丢帧处理,只要五帧里有两帧干净就能救回来。第二类是设备屏幕本身有坏道,某个笔画永远不亮,比如"8"的道坏了就永远是"0"。这种问题模型无解,只能靠设备维护解决,但可以加一个"连续N帧结果异常"的告警,提示现场维护。第三类是数码管刷新频率和相机快门频率接近,出现驻波条纹,字符笔画像被刷子刷过一样。解决思路是调整相机曝光时间,或者干脆用卷帘快门相机的短曝光去"冻结"刷新帧。
这里必须说一句,任何识别模型都不可能做到100%,工业落地要把"模型判读"变成"模型判读+逻辑校验+多帧融合+异常告警"的一整套决策链路,才真正扛得住产线压力。
5. 想通之后,几个真正值钱的踩坑经验
5.1 屏幕内容多样性,比模型结构更影响上限
第一次训练出来的模型在测试集上已经有98%的准确率,我高兴没两天就收到现场反馈:有个屏幕显示负数时,负号位置在最左侧的槽位里,但样本里几乎没有这种布局,模型直接把它漏了。还有的设备在待机时屏幕熄灭,检测器对黑屏区域输出了大量高置信度的"0"框,成功把空读数识别成"000"。
这两个问题本质都是数据多样性缺位,和网络结构没有半点关系。后来我把现场所有屏幕可能出现的显示状态列了一张清单:正常读数、最大值、最小值、负数、小数点、屏幕熄灭、单位切换、低电压报警闪烁,每条做专项补采。补采后负数漏检率从8%降到0.4%,空屏误报也基本消失。这件事之后我给自己定了个规矩:任何读数识别项目启动前,第一件事不是选模型,而是拉着现场工程师逐项过一遍屏幕的"显示状态图鉴"。
5.2 标签噪声,比网络结构缺陷更致命
训练中期发现验证集上"3"和"8"总是互相误判,一开始怀疑是SPP或金字塔结构的问题,调了一周毫无起色。后来抽了一批训练图做可视化,发现标注环节把两个像"3"的字符标成了不同的类别,原因是数码管某一段笔画忽亮忽暗,标注员两次标注结论不一样。这个问题解决得非常朴素:引入"双人标注+仲裁"机制,所有争议样本单独放一个集合,由项目负责人统一仲裁。
标签噪声对目标检测的影响远比分类网络严重,因为框的位置和类别同时决定了回归和分类两个分支的学习信号。一块错误标注数据可以让验证集损失在后半段反复震荡。排查方法很简单:把训练集里每个类别挑几十张预测错得最厉害的样本打印出来,一眼就能看出是不是标签的问题。如果都是标注坑,那就先把数据修对,再谈调结构。
5.3 给模型减负:能先裁剪,就不要整图硬刚
项目做到中后期,我尝试把输入分辨率提升到544×544,精度只涨了0.3%,但推理延迟涨了差不多60%。后来我换了个思路:先用一个更轻量的检测器或固定ROI定位屏幕区域,把屏幕部分裁剪放大后再送进字符识别模型。这个"屏幕定位+字符识别"的两级方案,比单模型硬抗整图还是稳定划算。
原因不难理解,全图检测时小字符经过多次下采样,特征本来就不占优势,反而容易把背景纹理误检成字符。裁剪之后字符尺寸在图像里占比直接翻倍,识别模型可以更从容地学细节。实测下来,裁剪放大后整表读数准确率从98.2%提升到99.0%,推理总延迟反而下降,因为裁剪后送入识别的图像更小、目标更集中,解码和NMS也更干净。
如果让我重来一遍,我会把屏幕定位和字符识别的两段式方案从第一天就定下来,而不是等模型调不动了才想起来。另外还有个不起眼但很管用的小技巧:训练数据里把"无读数黑屏"作为一类样本放进字符检测器,它能帮模型学会区分"屏幕区域有内容"和"屏幕区域没内容",这在无人巡检场景里比什么都重要。
最后再分享一条个人体会:做这种工业读数识别,真正难的不是把YOLOv3-SPP跑通,而是把采集、标注、训练、部署、告警这一整条链路串起来,让模型在最恶劣的一帧画面里也不至于给出一个"看起来合理但其实是错的"读数。模型只是链路的一部分,但把这条链路打磨扎实之后,你会发现自己对设备显示屏幕的观察力,已经比刚接手项目时强了不止一个量级。