数字体温计这玩意儿,看着简单,真做自动读数才发现处处是坑。去年我们团队接了个视觉需求:在固定机位上拍一张体温计的屏幕,系统自动识别出当前温度并写入记录,全程不需要人去看。第一反应肯定是"目标检测加OCR",结果真正把方案跑通、把误读率压到能用的水平,足足折腾了几周。这篇是这个系列的第二篇,重点讲清楚检测和读数这条完整链路里,哪些环节容易翻车、又是怎么修回来的,适合正在做仪表读数、屏幕OCR、设备状态自动识别这类项目的朋友参考。1. 数字体温计读取任务里,检测环节的真正难点1.1 目标小、特征杂:为什么通用检测模型直接套用会崩体温计检测乍一听是个标准的目标检测任务:画面里有体温计,画个框,输出类别,完事。但实际跑一遍就会发现两个特别要命的问题。第一是目标尺寸太小。体温计的屏幕区域在整张1080P画面里通常只占几十乘一百多个像素,数字区域更是只有几十像素宽。很多人习惯直接拿预训练权重去推理,结果体温计本身倒是能框出来,屏幕区域却经常漏检或者框偏。原因不复杂——预训练模型的分辨率基准是224或者640,特征金字塔的浅层特征主要服务中等尺寸目标,对这种几十像素的小区域,默认配置下召回率根本不够看。第二是特征不够"干净"。体温计外壳是白色或浅色塑料,和背景、手掌、衣物经常混在一起;屏幕本身又是个小黑块,稍微有点反光就变成亮块,甚至把外壳的渐变当作屏幕边缘。再加上不同品牌的体温计,屏幕区域比例、按键位置、字体粗细全都不一样,同一个模型要把这些"变体"都扛下来,光靠通用检测的默认策略是不够的。我们在项目里花了很大力气去调数据分布和标签策略,后面会讲到。这里想强调的是:不要一上来就追求"模型多高级",先把任务定义和标签划分想清楚,收益大得多。1.2 类别的粒度划分:检测体温计整体还是直接检测屏幕这是早期方案里争论最多的一个问题。团队里两种思路都有:思路A:只检测"体温计"这一个大类,拿到整体框之后再用固定ROI(感兴趣区域)规则去切屏幕区域;思路B:直接检测"屏幕区域"这个类别,体温计的整机位置通过屏幕框外扩推导出来。思路A的问题是,固定ROI的假设太脆弱。摄像头机位、体温计握持角度、不同品牌的外形比例,都会让屏幕在整机框内的相对位置漂移。一旦漂移超过设定阈值,切出来的区域就不是屏幕,后面的OCR全废。我们第一批测试集上,固定ROI方法的屏幕定位成功率只有七成左右,这个数字没法上线。思路B看起来更符合"端到端"的思路,但它把小目标检测的难度直接拉满——屏幕区域既小又容易被反光干扰。后来我们还是采用了折中方案:一个模型输出两个类别,"thermometer"和"display",训练时两者独立标注,推理时用display框作为OCR输入,用thermometer框做区域校验和跟踪的去抖依据。如果display框的置信度太低,就回退到thermometer框加显示区域回归分支。这个"双类别加回退"的策略,让屏幕定位成功率从七成提到了九成五以上,后面所有环节的稳定性都建立在这个基础上。2. YOLOv26带来的结构增益:为什么这个项目需要新版模型2.1 小目标召回率提升背后的机制变化项目启动时正好赶上YOLOv26的官方仓库更新,我们对比之后直接选了它,而不是继续用上代版本。很多人觉得"新版就是参数多点、精度高一点",对这类小目标强相关的任务,v26的收益其实体现在三个比较具体的点上。第一是主干网络里注意力模块的位置变了。老版本的注意力基本压在深层,对小目标的浅层特征关注很弱;v26在浅层也挂了轻量的通道注意力,屏幕区域这种"小但纹理明确"的目标,浅层语义增强之后,召回率提升非常明显。第二是颈部特征融合的梯度通路做了简化,多尺度特征在融合时信息损耗变小,对边框回归精度有帮助,我们测试集上屏幕框的IoU中位数比上代模型高了大概4个点。第三是训练策略里的动态分配机制,它对"正样本数量稀少"的情况更友好——小目标在每张图里往往只有一两个正样本候选,分配公式变化之后,这些小目标更容易被学习到。上面这几点不是空谈,我们做了一组对照实验来验证,下面这个表格就是当时记录的一部分。2.2 实际对比:老版本与v26在同一批数据上的表现我们在同一批标注数据(大约8000张,后面会细说)上分别训练了上代nano模型和v26的s模型,测试集固定为500张不同光照、角度、品牌的体温计画面,指标如下:模型输入分辨率display类mAP@0.5display类mAP@0.5:0.95单帧推理耗时(CPU)上代nano64086.254.728msv26 nano64089.158.927msv26 small64092.864.346msv26 small96094.668.278ms单看mAP提升幅度好像也就几个点,但在下游OCR任务里,这几个点直接决定了"屏幕框切出来的画面到底干不干净"。框稍微偏一点,数字区域就带上了外壳边缘,OCR错误率会成倍上升。所以最终线上我们用的是v26 small加960输入,虽然推理耗时多了些,但整条链路的稳定性才是这个项目的命门。这里也要提醒一句:如果你的体温计在画面里占的比例本来就大,用nano加640就够,不必为了追精度牺牲速度。项目里有个兄弟场景是近距离拍摄,我们试过四种组合,最后用的反而是nano,因为特征足够大,模型容量没那么关键。3. 读数识别链路:检测框之后的裁剪、增强与七段码解析3.1 屏幕区域的预处理三板斧检测模型输出display框之后,不能直接把框里的图丢给OCR,原始像素几乎必然有问题:角度倾斜、曝光不均、对比度低。我们做了三步预处理,每一步都是实测踩出来的。第一步是透视矫正。体温计屏幕本身是个矩形,但握持姿势千奇百怪,拍出来经常是平行四边形。我们拿到display框之后,先用边缘检测找屏幕的四条边,再做透视变换把屏幕拉正。如果你用的是固定机位、角度变化很小,这步可以简化成固定仿射变换,但我们的场景是人手拿着体温计,角度随机性大,透视矫正必不可少。第二步是自适应阈值二值化。LCD屏幕的背光不均匀是常态,半边亮半边暗,直接固定阈值分割,数字笔画很容易断。我们用自适应阈值,窗口大小设在21到31之间,效果比全局阈值稳定得多。代码大概长这样:def crop_display(frame, bbox): x1, y1, x2, y2 = bbox # 外扩10%避免边缘上的数字被截断 h_pad = int((x2 - x1) * 0.1) v_pad = int((y2 - y1) * 0.1) region = frame[max(0, y1-v_pad): y2+v_pad, max(0, x1-h_pad): x2+h_pad] return region def preprocess_display(region): gray = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY) binary = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 21, 5 ) return binary第三步是形态学修补。二值化之后,LCD数字常见的断笔、毛刺都还在。我们按先膨胀后腐蚀的顺序做了一次闭运算,核选3x3,能把细小的断点连上,又不至于把相邻笔画粘在一起。这三步做完,识别输入的质量基本就稳了。3.2 数字识别的两条路线:通用OCR与七段码专用分类器预处理做完,就该识别数字了。这里我们实际试过两条路线,结论很明确:体温计数字是七段码,不是普通印刷体,通用OCR并不占优势。第一条路线是通用OCR。我们试过几个开源框架,在标准印刷体上确实猛,但放到LCD七段码上就拉胯。原因在于七段码的笔画形态和印刷体差异太大,尤其是数字"1"往往只有最右边两段,通用OCR很容易把它和"7"混在一起。另外体温计的字体还有独特的小倾角和圆角,普通训练数据里根本覆盖不到。第二条路线是七段码专用分类器。先利用七段码的结构特点,把数字区域均匀切成七段(上横、中横、下横、左上竖、右上竖、左下竖、右下竖),对每一段判断亮还是不亮,得到的7位0/1向量直接映射到0到9的数字。比如7段全亮是8,只有上横加右上竖加右下竖是7。这种方法的鲁棒性极好,只要你有办法把数字区域切准、把段亮不亮判断对,识别准确率可以做到99%以上。段切分的难点在于数字宽度不一、间距不一。我们用一个轻量的语义分割网络先给每个数字做连通域定位,再按固定模板切段。实际效果如下:方案数字级准确率温度级准确率(含小数点位置)单次识别耗时通用OCR方案94.8%88.2%12ms七段码专用方案99.1%97.3%6ms七段码方案在速度和准确率上双赢。如果你读的不是体温计,而是电表、水表、血糖仪这一类七段码设备,思路完全可以复用。3.3 小数点和单位符号的处理细节数字读出来了,小数点是个容易被忽视的大坑。体温计显示"36.6"和"366",差一个小数点,含义完全不同。我们的做法是:在二值化图上单独检测小数点区域。小数点通常位于个位和十分位之间的底部,形状也是一个小方块,用模板匹配加周边连通域分析就能定位。一旦检测到,就在解析后的整数序列里从右往左插一位小数。注意有些体温计的温度显示在屏幕右侧还会带一个小一点的"C"字样,要么它的位置固定且大小明显小于数字,要么直接用区域规则排除掉,不要让它混进数字检测里。4. 数据从哪里来:真实样本采集与LCD数字仿真生成4.1 真实数据采集的注意事项训练数据是这类项目的地基。我们总共收集了大约8000张体温计图片,其中真实拍摄样本接近3000张,剩下的都是仿真生成。真实采集环节有几个心得:尽量覆盖光照变化的极端。LCD显示在强光下会看不清,在侧光下会反光变成一片亮白。我们专门挑了几类场景:室内日光灯直射、窗边逆光、夜晚屏幕自发光、手电筒补光,每种都拍一批。宁可多拍看似"重复"的极端光照,也不要只拍光线均匀的"理想图",否则模型一到现场就瞎。握持姿势要多样。体温计被横握、竖握、斜45度握,拍出来的屏幕形状完全不同。我们拍摄时故意让不同的人用不同姿势,确保透视矫正的训练数据里有充分的形态差异。背景别太干净。实验室里白桌子上一放就拍,模型学到的都是"白色背景上的体温计",真到了手里握着、背景是衣服纹理的时候就会懵。我们特意加了一批手持拍摄、桌面杂物背景、甚至隔着塑料袋拍的样本。4.2 仿真数据生成:用渲染器批量造样本真实样本永远不够用,尤其是不同品牌、不同数字组合的覆盖率。仿真生成是这类任务里性价比最高的做法。我们的思路很简单:写一个脚本,动态渲染LCD屏幕效果。七段码字体用开源的LCD字体库,随机生成不同温度值、不同字宽、不同倾斜角,再叠加噪声、模糊、亮度不均、塑料外壳高光的模拟。把渲染出来的屏幕随机拼到真实背景上,贴图时加上仿射变换模拟角度,最后再做一次整体光照扰动。这样一组下来,几分钟就能生成上万张带精确标签的样本。仿真数据有个特别大的优势:标注是免费的,而且不会错。真实数据里人眼标注几十个像素宽的屏幕框容易偏,仿真数据的框是渲染器直接输出的,精度天生拉满。我们把仿真数据和真实数据按7比3混合训练,最终测试集上的表现比纯真实数据训练高了约6个点mAP。4.3 标注时最容易犯的错误真实样本标注阶段,最容易犯的错就是屏幕框不贴边。标注人员习惯把整个"看起来像屏幕的区域"都框进去,包括周围一圈塑料边框。这会导致训练出来的display类别里混入大量非屏幕像素,推理时框偏大,OCR输入里全是干扰项。我们最后强行规定:屏幕框必须贴着数字显示区域的可视边界,宁可框小了也不要框大。重新标注一遍之后,下游OCR的准确率肉眼可见地涨了一截。另外标签里最好单独区分"被手指遮挡超过一半"和"清晰可见"这两种情况。被遮挡严重的样本量不要太大,但要留一批,让模型学会在遮挡时输出低置信度,而不是硬着头皮出一个错框。5. 帧级结果不可信:连续帧融合与温度合理性校验5.1 为什么单帧识别结果不能直接用就算检测和OCR都做得不错,单帧结果的稳定性依然不够看。我们之前跑过一个统计:即便数字级准确率到了99%,当画面出现轻微运动模糊或者一次反光闪过,那一帧的读数可能就是错的。单帧直接上报,偶尔冒出一个离谱温度,在医疗相关场景里是不可接受的。解决方案是给系统加上"时间维度的容错"。思路很简单:单帧结果只当作一次投票,连续多帧的多数一致结果才被当作有效读数。这个思路在几乎所有仪表读数项目里都通用。5.2 滑窗投票与跟踪关联的具体实现我们用的是滑窗投票加简单的IoU跟踪。每一帧先跑检测和OCR,得到一个候选温度值和置信度。把这个值与最近几帧的有效结果放在一个长度为5的窗口里,取众数,只有当众数的票数大于等于3时,才更新最终输出。核心代码很简短:from collections import Counter window = [] # 最近5帧的有效结果 def update_output(candidate): if candidate["confidence"] < 0.7: return None window.append(candidate["temperature"]) if len(window) > 5: window.pop(0) if len(window) == 5: counter = Counter(window) temp, count = counter.most_common(1)[0] if count >= 3: return temp return None这里要处理一个细节:如果画面里的体温计位置在移动,连续帧的框没有重叠,窗口里的结果就不能直接混在一起投票。我们按屏幕框的IoU判断是不是同一个目标,IoU小于阈值就清空窗口重新开始。否则一个镜头里先出现旧体温计、再出现新体温计,两者温度不一样,投票会一直失败。5.3 温度范围的硬约束与异常剔除模型的输出还要过一层"领域常识"约束。体温计的正常量程一般是32到43摄氏度,当然不同设备会有差异。我们加了一个硬过滤器:超过42度或者低于34度的结果直接标记异常,不参与投票,并触发"重新识别"状态。这层硬约束看起来简单,实际价值极大。有一次现场测试,屏幕反光让模型把"37.0"读成了"37.9",单帧结果连续三帧都是错的,但由于浮动位置不同,滑窗投票仍然输出了正确值。如果没有范围约束,偶尔一次全窗口都被带偏的情况还是会发生。加上之后,系统在数千次模拟测温中的异常读数基本被压到了零。6. 实测翻车场景与针对性修复6.1 反光和塑料外壳的镜面效应现场跑起来之后,第一个大规模翻车的场景是反光。体温计外壳是圆弧形塑料,稍微偏一个角度就会把顶灯的光斑映在屏幕上。光斑覆盖数字时,二值化之后那一整块都是白色,段判定的结果全错。我们针对这个问题的修复分三层:数据层面,仿真生成时加入了圆形高光斑和线性光斑的叠加,让模型见过各种反光形态;图像层面,预处理时对高光区域做局部的直方图均衡,把过曝压回来;策略层面,如果反光区域覆盖了超过三分之一的数字区域,直接给这一帧打低置信度标签,不参与投票。三层叠下来,反光场景下的误读率从最初的两成降到了百分之一以下。6.2 运动中模糊与低帧率的影响另一个高频翻车是运动模糊。人手拿着体温计,不可能纹丝不动,微小的振动手机会让屏幕区域出现几像素的模糊。对检测来说几像素无所谓,但对七段码的段判定来说,模糊会导致笔画边缘粘连,本来分得开的两段变成一段。我们试过在预处理里加去模糊,效果不稳定,最终选择了两个更务实的办法。一是推理时对连续几帧做小范围的对齐融合,把多帧的屏幕区域按特征点配准后取平均,等效于提高曝光时间但不会引入单帧的运动模糊;二是调整投票窗口的容错,让系统允许一两帧模糊结果存在,只要最终窗口里的多数是清晰帧就行。整体效果是系统在轻微手抖条件下基本不受影响,剧烈甩动时则果断进入低置信度状态。6.3 不同品牌字体的泛化问题最后是泛化问题。我们最初拿到的测试设备主要是两个品牌,数字的字形接近,模型在它们身上表现很好。重新买了另外三个品牌的体温计之后,发现其中一家的字体笔画特别粗,段间距明显小于训练分布,段判定经常把相邻的段误连,温度读数连续错。解决办法还是靠数据:我们用仿真渲染器专门模拟了这家品牌的粗笔画字形,并加入笔画粗细的正态随机扰动,重新训练了一版模型。最终五个品牌都留一批验证样本,模型在全部品牌上的数字级准确率都达到了98.5%以上。这个经历也说明,做这类项目别偷懒,多买几台不同品牌的设备回来拍,比事后补数据省钱省时间。最后再分享一个实际用下来的小技巧:上线之后一定要保留一个"模型自检"日志,把每一帧的检测置信度、OCR置信度、投票结果都记录下来。这个日志平时没人看,但一旦现场出现偶发的误读,它就是定位问题最快的入口。我们后期有一次现场投诉"读数偶尔跳一下",就是靠翻日志定位到是特定角度的屏幕反光触发的,针对性补了一组反光样本就解决了。这类系统的价值不在某个单点的模型精度,而在整条链路的稳定性,日志和兜底策略,跟模型本身一样重要。
数字体温计这玩意儿,看着简单,真做自动读数才发现处处是坑。去年我们团队接了个视觉需求:在固定机位上拍一张体温计的屏幕,系统自动识别出当前温度并写入记录,全程不需要人去看。第一反应肯定是"目标检测加OCR",结果真正把方案跑通、把误读率压到能用的水平,足足折腾了几周。这篇是这个系列的第二篇,重点讲清楚检测和读数这条完整链路里,哪些环节容易翻车、又是怎么修回来的,适合正在做仪表读数、屏幕OCR、设备状态自动识别这类项目的朋友参考。1. 数字体温计读取任务里,检测环节的真正难点1.1 目标小、特征杂:为什么通用检测模型直接套用会崩体温计检测乍一听是个标准的目标检测任务:画面里有体温计,画个框,输出类别,完事。但实际跑一遍就会发现两个特别要命的问题。第一是目标尺寸太小。体温计的屏幕区域在整张1080P画面里通常只占几十乘一百多个像素,数字区域更是只有几十像素宽。很多人习惯直接拿预训练权重去推理,结果体温计本身倒是能框出来,屏幕区域却经常漏检或者框偏。原因不复杂——预训练模型的分辨率基准是224或者640,特征金字塔的浅层特征主要服务中等尺寸目标,对这种几十像素的小区域,默认配置下召回率根本不够看。第二是特征不够"干净"。体温计外壳是白色或浅色塑料,和背景、手掌、衣物经常混在一起;屏幕本身又是个小黑块,稍微有点反光就变成亮块,甚至把外壳的渐变当作屏幕边缘。再加上不同品牌的体温计,屏幕区域比例、按键位置、字体粗细全都不一样,同一个模型要把这些"变体"都扛下来,光靠通用检测的默认策略是不够的。我们在项目里花了很大力气去调数据分布和标签策略,后面会讲到。这里想强调的是:不要一上来就追求"模型多高级",先把任务定义和标签划分想清楚,收益大得多。1.2 类别的粒度划分:检测体温计整体还是直接检测屏幕这是早期方案里争论最多的一个问题。团队里两种思路都有:思路A:只检测"体温计"这一个大类,拿到整体框之后再用固定ROI(感兴趣区域)规则去切屏幕区域;思路B:直接检测"屏幕区域"这个类别,体温计的整机位置通过屏幕框外扩推导出来。思路A的问题是,固定ROI的假设太脆弱。摄像头机位、体温计握持角度、不同品牌的外形比例,都会让屏幕在整机框内的相对位置漂移。一旦漂移超过设定阈值,切出来的区域就不是屏幕,后面的OCR全废。我们第一批测试集上,固定ROI方法的屏幕定位成功率只有七成左右,这个数字没法上线。思路B看起来更符合"端到端"的思路,但它把小目标检测的难度直接拉满——屏幕区域既小又容易被反光干扰。后来我们还是采用了折中方案:一个模型输出两个类别,"thermometer"和"display",训练时两者独立标注,推理时用display框作为OCR输入,用thermometer框做区域校验和跟踪的去抖依据。如果display框的置信度太低,就回退到thermometer框加显示区域回归分支。这个"双类别加回退"的策略,让屏幕定位成功率从七成提到了九成五以上,后面所有环节的稳定性都建立在这个基础上。2. YOLOv26带来的结构增益:为什么这个项目需要新版模型2.1 小目标召回率提升背后的机制变化项目启动时正好赶上YOLOv26的官方仓库更新,我们对比之后直接选了它,而不是继续用上代版本。很多人觉得"新版就是参数多点、精度高一点",对这类小目标强相关的任务,v26的收益其实体现在三个比较具体的点上。第一是主干网络里注意力模块的位置变了。老版本的注意力基本压在深层,对小目标的浅层特征关注很弱;v26在浅层也挂了轻量的通道注意力,屏幕区域这种"小但纹理明确"的目标,浅层语义增强之后,召回率提升非常明显。第二是颈部特征融合的梯度通路做了简化,多尺度特征在融合时信息损耗变小,对边框回归精度有帮助,我们测试集上屏幕框的IoU中位数比上代模型高了大概4个点。第三是训练策略里的动态分配机制,它对"正样本数量稀少"的情况更友好——小目标在每张图里往往只有一两个正样本候选,分配公式变化之后,这些小目标更容易被学习到。上面这几点不是空谈,我们做了一组对照实验来验证,下面这个表格就是当时记录的一部分。2.2 实际对比:老版本与v26在同一批数据上的表现我们在同一批标注数据(大约8000张,后面会细说)上分别训练了上代nano模型和v26的s模型,测试集固定为500张不同光照、角度、品牌的体温计画面,指标如下:模型输入分辨率display类mAP@0.5display类mAP@0.5:0.95单帧推理耗时(CPU)上代nano64086.254.728msv26 nano64089.158.927msv26 small64092.864.346msv26 small96094.668.278ms单看mAP提升幅度好像也就几个点,但在下游OCR任务里,这几个点直接决定了"屏幕框切出来的画面到底干不干净"。框稍微偏一点,数字区域就带上了外壳边缘,OCR错误率会成倍上升。所以最终线上我们用的是v26 small加960输入,虽然推理耗时多了些,但整条链路的稳定性才是这个项目的命门。这里也要提醒一句:如果你的体温计在画面里占的比例本来就大,用nano加640就够,不必为了追精度牺牲速度。项目里有个兄弟场景是近距离拍摄,我们试过四种组合,最后用的反而是nano,因为特征足够大,模型容量没那么关键。3. 读数识别链路:检测框之后的裁剪、增强与七段码解析3.1 屏幕区域的预处理三板斧检测模型输出display框之后,不能直接把框里的图丢给OCR,原始像素几乎必然有问题:角度倾斜、曝光不均、对比度低。我们做了三步预处理,每一步都是实测踩出来的。第一步是透视矫正。体温计屏幕本身是个矩形,但握持姿势千奇百怪,拍出来经常是平行四边形。我们拿到display框之后,先用边缘检测找屏幕的四条边,再做透视变换把屏幕拉正。如果你用的是固定机位、角度变化很小,这步可以简化成固定仿射变换,但我们的场景是人手拿着体温计,角度随机性大,透视矫正必不可少。第二步是自适应阈值二值化。LCD屏幕的背光不均匀是常态,半边亮半边暗,直接固定阈值分割,数字笔画很容易断。我们用自适应阈值,窗口大小设在21到31之间,效果比全局阈值稳定得多。代码大概长这样:def crop_display(frame, bbox): x1, y1, x2, y2 = bbox # 外扩10%避免边缘上的数字被截断 h_pad = int((x2 - x1) * 0.1) v_pad = int((y2 - y1) * 0.1) region = frame[max(0, y1-v_pad): y2+v_pad, max(0, x1-h_pad): x2+h_pad] return region def preprocess_display(region): gray = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY) binary = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 21, 5 ) return binary第三步是形态学修补。二值化之后,LCD数字常见的断笔、毛刺都还在。我们按先膨胀后腐蚀的顺序做了一次闭运算,核选3x3,能把细小的断点连上,又不至于把相邻笔画粘在一起。这三步做完,识别输入的质量基本就稳了。3.2 数字识别的两条路线:通用OCR与七段码专用分类器预处理做完,就该识别数字了。这里我们实际试过两条路线,结论很明确:体温计数字是七段码,不是普通印刷体,通用OCR并不占优势。第一条路线是通用OCR。我们试过几个开源框架,在标准印刷体上确实猛,但放到LCD七段码上就拉胯。原因在于七段码的笔画形态和印刷体差异太大,尤其是数字"1"往往只有最右边两段,通用OCR很容易把它和"7"混在一起。另外体温计的字体还有独特的小倾角和圆角,普通训练数据里根本覆盖不到。第二条路线是七段码专用分类器。先利用七段码的结构特点,把数字区域均匀切成七段(上横、中横、下横、左上竖、右上竖、左下竖、右下竖),对每一段判断亮还是不亮,得到的7位0/1向量直接映射到0到9的数字。比如7段全亮是8,只有上横加右上竖加右下竖是7。这种方法的鲁棒性极好,只要你有办法把数字区域切准、把段亮不亮判断对,识别准确率可以做到99%以上。段切分的难点在于数字宽度不一、间距不一。我们用一个轻量的语义分割网络先给每个数字做连通域定位,再按固定模板切段。实际效果如下:方案数字级准确率温度级准确率(含小数点位置)单次识别耗时通用OCR方案94.8%88.2%12ms七段码专用方案99.1%97.3%6ms七段码方案在速度和准确率上双赢。如果你读的不是体温计,而是电表、水表、血糖仪这一类七段码设备,思路完全可以复用。3.3 小数点和单位符号的处理细节数字读出来了,小数点是个容易被忽视的大坑。体温计显示"36.6"和"366",差一个小数点,含义完全不同。我们的做法是:在二值化图上单独检测小数点区域。小数点通常位于个位和十分位之间的底部,形状也是一个小方块,用模板匹配加周边连通域分析就能定位。一旦检测到,就在解析后的整数序列里从右往左插一位小数。注意有些体温计的温度显示在屏幕右侧还会带一个小一点的"C"字样,要么它的位置固定且大小明显小于数字,要么直接用区域规则排除掉,不要让它混进数字检测里。4. 数据从哪里来:真实样本采集与LCD数字仿真生成4.1 真实数据采集的注意事项训练数据是这类项目的地基。我们总共收集了大约8000张体温计图片,其中真实拍摄样本接近3000张,剩下的都是仿真生成。真实采集环节有几个心得:尽量覆盖光照变化的极端。LCD显示在强光下会看不清,在侧光下会反光变成一片亮白。我们专门挑了几类场景:室内日光灯直射、窗边逆光、夜晚屏幕自发光、手电筒补光,每种都拍一批。宁可多拍看似"重复"的极端光照,也不要只拍光线均匀的"理想图",否则模型一到现场就瞎。握持姿势要多样。体温计被横握、竖握、斜45度握,拍出来的屏幕形状完全不同。我们拍摄时故意让不同的人用不同姿势,确保透视矫正的训练数据里有充分的形态差异。背景别太干净。实验室里白桌子上一放就拍,模型学到的都是"白色背景上的体温计",真到了手里握着、背景是衣服纹理的时候就会懵。我们特意加了一批手持拍摄、桌面杂物背景、甚至隔着塑料袋拍的样本。4.2 仿真数据生成:用渲染器批量造样本真实样本永远不够用,尤其是不同品牌、不同数字组合的覆盖率。仿真生成是这类任务里性价比最高的做法。我们的思路很简单:写一个脚本,动态渲染LCD屏幕效果。七段码字体用开源的LCD字体库,随机生成不同温度值、不同字宽、不同倾斜角,再叠加噪声、模糊、亮度不均、塑料外壳高光的模拟。把渲染出来的屏幕随机拼到真实背景上,贴图时加上仿射变换模拟角度,最后再做一次整体光照扰动。这样一组下来,几分钟就能生成上万张带精确标签的样本。仿真数据有个特别大的优势:标注是免费的,而且不会错。真实数据里人眼标注几十个像素宽的屏幕框容易偏,仿真数据的框是渲染器直接输出的,精度天生拉满。我们把仿真数据和真实数据按7比3混合训练,最终测试集上的表现比纯真实数据训练高了约6个点mAP。4.3 标注时最容易犯的错误真实样本标注阶段,最容易犯的错就是屏幕框不贴边。标注人员习惯把整个"看起来像屏幕的区域"都框进去,包括周围一圈塑料边框。这会导致训练出来的display类别里混入大量非屏幕像素,推理时框偏大,OCR输入里全是干扰项。我们最后强行规定:屏幕框必须贴着数字显示区域的可视边界,宁可框小了也不要框大。重新标注一遍之后,下游OCR的准确率肉眼可见地涨了一截。另外标签里最好单独区分"被手指遮挡超过一半"和"清晰可见"这两种情况。被遮挡严重的样本量不要太大,但要留一批,让模型学会在遮挡时输出低置信度,而不是硬着头皮出一个错框。5. 帧级结果不可信:连续帧融合与温度合理性校验5.1 为什么单帧识别结果不能直接用就算检测和OCR都做得不错,单帧结果的稳定性依然不够看。我们之前跑过一个统计:即便数字级准确率到了99%,当画面出现轻微运动模糊或者一次反光闪过,那一帧的读数可能就是错的。单帧直接上报,偶尔冒出一个离谱温度,在医疗相关场景里是不可接受的。解决方案是给系统加上"时间维度的容错"。思路很简单:单帧结果只当作一次投票,连续多帧的多数一致结果才被当作有效读数。这个思路在几乎所有仪表读数项目里都通用。5.2 滑窗投票与跟踪关联的具体实现我们用的是滑窗投票加简单的IoU跟踪。每一帧先跑检测和OCR,得到一个候选温度值和置信度。把这个值与最近几帧的有效结果放在一个长度为5的窗口里,取众数,只有当众数的票数大于等于3时,才更新最终输出。核心代码很简短:from collections import Counter window = [] # 最近5帧的有效结果 def update_output(candidate): if candidate["confidence"] < 0.7: return None window.append(candidate["temperature"]) if len(window) > 5: window.pop(0) if len(window) == 5: counter = Counter(window) temp, count = counter.most_common(1)[0] if count >= 3: return temp return None这里要处理一个细节:如果画面里的体温计位置在移动,连续帧的框没有重叠,窗口里的结果就不能直接混在一起投票。我们按屏幕框的IoU判断是不是同一个目标,IoU小于阈值就清空窗口重新开始。否则一个镜头里先出现旧体温计、再出现新体温计,两者温度不一样,投票会一直失败。5.3 温度范围的硬约束与异常剔除模型的输出还要过一层"领域常识"约束。体温计的正常量程一般是32到43摄氏度,当然不同设备会有差异。我们加了一个硬过滤器:超过42度或者低于34度的结果直接标记异常,不参与投票,并触发"重新识别"状态。这层硬约束看起来简单,实际价值极大。有一次现场测试,屏幕反光让模型把"37.0"读成了"37.9",单帧结果连续三帧都是错的,但由于浮动位置不同,滑窗投票仍然输出了正确值。如果没有范围约束,偶尔一次全窗口都被带偏的情况还是会发生。加上之后,系统在数千次模拟测温中的异常读数基本被压到了零。6. 实测翻车场景与针对性修复6.1 反光和塑料外壳的镜面效应现场跑起来之后,第一个大规模翻车的场景是反光。体温计外壳是圆弧形塑料,稍微偏一个角度就会把顶灯的光斑映在屏幕上。光斑覆盖数字时,二值化之后那一整块都是白色,段判定的结果全错。我们针对这个问题的修复分三层:数据层面,仿真生成时加入了圆形高光斑和线性光斑的叠加,让模型见过各种反光形态;图像层面,预处理时对高光区域做局部的直方图均衡,把过曝压回来;策略层面,如果反光区域覆盖了超过三分之一的数字区域,直接给这一帧打低置信度标签,不参与投票。三层叠下来,反光场景下的误读率从最初的两成降到了百分之一以下。6.2 运动中模糊与低帧率的影响另一个高频翻车是运动模糊。人手拿着体温计,不可能纹丝不动,微小的振动手机会让屏幕区域出现几像素的模糊。对检测来说几像素无所谓,但对七段码的段判定来说,模糊会导致笔画边缘粘连,本来分得开的两段变成一段。我们试过在预处理里加去模糊,效果不稳定,最终选择了两个更务实的办法。一是推理时对连续几帧做小范围的对齐融合,把多帧的屏幕区域按特征点配准后取平均,等效于提高曝光时间但不会引入单帧的运动模糊;二是调整投票窗口的容错,让系统允许一两帧模糊结果存在,只要最终窗口里的多数是清晰帧就行。整体效果是系统在轻微手抖条件下基本不受影响,剧烈甩动时则果断进入低置信度状态。6.3 不同品牌字体的泛化问题最后是泛化问题。我们最初拿到的测试设备主要是两个品牌,数字的字形接近,模型在它们身上表现很好。重新买了另外三个品牌的体温计之后,发现其中一家的字体笔画特别粗,段间距明显小于训练分布,段判定经常把相邻的段误连,温度读数连续错。解决办法还是靠数据:我们用仿真渲染器专门模拟了这家品牌的粗笔画字形,并加入笔画粗细的正态随机扰动,重新训练了一版模型。最终五个品牌都留一批验证样本,模型在全部品牌上的数字级准确率都达到了98.5%以上。这个经历也说明,做这类项目别偷懒,多买几台不同品牌的设备回来拍,比事后补数据省钱省时间。最后再分享一个实际用下来的小技巧:上线之后一定要保留一个"模型自检"日志,把每一帧的检测置信度、OCR置信度、投票结果都记录下来。这个日志平时没人看,但一旦现场出现偶发的误读,它就是定位问题最快的入口。我们后期有一次现场投诉"读数偶尔跳一下",就是靠翻日志定位到是特定角度的屏幕反光触发的,针对性补了一组反光样本就解决了。这类系统的价值不在某个单点的模型精度,而在整条链路的稳定性,日志和兜底策略,跟模型本身一样重要。