ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

OpenMV多数字识别1~8:字符分割与模板匹配工程实践

OpenMV多数字识别1~8:字符分割与模板匹配工程实践

简介:这份压缩包面向OpenMV与MicroPython嵌入式视觉初学者,以及需要快速验证数字识别的开发者,聚焦1至8多数字的模板匹配识别,可用于智能仪表读数、简单字符检测等低功耗视觉项目的前期实验。包内共162个文件,主体为160个pgm数字模板图,覆盖1到8的不同样本,另含1个openmv_disk配置文件和1个py识别脚本,整体约158KB,体积轻便,便于放入OpenMV存储卡调试。已有2404人学习下载,在入门实践中具有一定参考度。读者可获得已训练好的数字模板、可运行的识别脚本框架,以及围绕灰度化、二值化、模板比对、数字定位与结果修正的实验素材;借助这些素材能观察模板质量对识别稳定性的影响,并据此调整阈值与匹配参数,减少从零采集、训练模板的时间,适合用作课程设计、竞赛原型与嵌入式视觉练手项目的起点。

1. 从一块 OpenMV 说起:这套「1~8 多数字识别」到底在识别什么

产线上一块工装板,七个工位,每个工位贴一个直径不到两厘米的圆形标签,上面印着一到八之间的某一个数字,字体还不统一。要用一块 OpenMV 摄像头在三十厘米外一次性把这串数字读出来,回传给主控做工序校验——这就是「openmv数字识别之多数字识别 1~8」这份资源要解决的实际问题。它不属于单字符识别那种「对准一个数字、按一下、出结果」的演示工程,而是要把一帧画面里散落的多个数字先切出来、再逐个分类、最后按阅读顺序拼成字符串。适合已经跑通过 OpenMV 基础例程、想把视觉真正接到自己项目里的开发者,也适合拿它当字符分割与模板匹配的教学样本。数字范围限定在 1 到 8,没有 0 和 9,这个细节后面会讲到,它直接决定了模板集的构造方式。

2. 技术选型与工程结构:模板匹配、字符分割、ROI 三级流水线

2.1 为什么多数字不能直接套单数字模板匹配

很多人第一步会去翻 OpenMV 官方例程里的find_template演示,把一张数字 1 的图存成模板,对着画面一搜,能出框,就以为大功告成。真放到多数字场景里立刻翻车:find_template一次调用只返回当前搜索区域内相似度最高的那一个位置,它天然是个「单目标」接口。画面里有 1、3、6 三个数字,你调三次、改三次阈值,得到的还是同一个最像的那个,另外两个永远出不来。

这是一个接口语义问题,不是调参能解决的。多数字识别必须在模板匹配之前插一层「字符分割」,把整幅图拆成若干个互不重叠的小矩形,再对每个小矩形单独做一次匹配。整体链路就变成三级:

  1. 预处理:灰度化、曝光锁定、必要时做形态学去噪,把画面稳定在一个可控的分布上。
  2. 分割:在感兴趣区域里找出每个数字的连通域包围盒,按横坐标排序。
  3. 分类:对每个包围盒内部的子图,与 1~8 八个模板逐一比对,取相似度最高且超过阈值的那一个。

这三级的顺序不能反。有人图省事,想先全局扫一遍所有模板,把每次find_template的结果收集起来去重——理论上可行,实际上因为模板会在大图上滑动搜索,同一个数字会被重复命中很多次,去重逻辑写起来比分割还麻烦,而且帧率会被拖垮。分割优先是工程上更省事的路子。

资源本身是围绕这个三级结构组织的:一份主程序负责调度,一份模板目录放 1 到 8 的灰度小图,外加若干张用于验证的实拍样张。它没有引入额外的算法库,全部跑在 OpenMV 自带的image模块上,这一点很重要——意味着它能直接烧进板子独立运行,不依赖上位机。

2.2 OpenMV 上的三种可选路线与取舍

在这块板子上做数字识别,常见做法有三条路,各自的适用边界差别很大,选错了后面全是坑。

第一条是纯模板匹配,也就是这份资源走的路。优点是零训练成本、改一个数字只要换一张模板图、结果可解释(能画出匹配框看是哪一步错了)。缺点是对字体和尺度敏感,模板和实际成像的字号差一圈,相似度就掉得厉害。1~8 这个范围恰好规避了一部分问题:这几个数字的字形区分度相对高,1 和 7、3 和 8 是最容易混的两组,靠调阈值和加预处理基本能压住。

第二条是 LBP 或 HOG 特征加最近邻分类。OpenMV 的image模块提供了find_lbp、find_features这类接口,能提取旋转不变的特征描述子。它的泛化比裸模板匹配好,字体换一换不至于全崩。代价是要自己攒训练集,每个数字至少几十个样本,而且 OpenMV 上做特征距离计算的速度不如一次模板卷积来得直接,帧率会掉。如果你的现场字体经常变、或者标签是手写的,值得往这条路上走。

第三条是内置的神经网络模块,加载一个量化后的.network模型,把分割出来的子图缩放到固定尺寸喂进去。这是泛化能力最强的一条路,但门槛也最高:训练、量化、转换三步都在上位机完成,模型大小要压在板子的内存预算内,输入分辨率通常只能做到 28×28 或 32×32 这种量级,太小的图对细笔画数字本身就是一种损失。新手直接上这条路,往往卡在模型转换环节一整天出不来结果。

我的建议是先用模板匹配把整条链路跑通、把分割逻辑调稳,等到确认瓶颈确实在分类精度上,再考虑换 NN。因为分割这一层是三条路共用的,先把共用部分做扎实,后面换分类器只是替换一个函数的事。

路线训练成本泛化能力帧率适合场景
模板匹配无弱,依赖字体与尺度高印刷体固定、光照可控
LBP/HOG + KNN中,需样本集中中字体略有变化
内置 NN 模型高,需量化转换强中低手写体、复杂背景

2.3 资源目录与文件组织

拿到手先别急着烧代码,把目录摊开看一眼,能省掉后面一半的困惑。典型的组织方式是这样:

digit_multi/ ├── main.py # 主程序,采集 + 分割 + 匹配 + 输出 ├── config.py # 阈值、ROI、相似度门限等可调参数集中放这里 ├── digits/ # 模板目录 │ ├── 1.pgm │ ├── 2.pgm │ ├── ... │ └── 8.pgm ├── samples/ # 实拍验证图,用于离线调试 │ ├── s01.bmp │ └── s02.bmp └── tools/ └── make_template.py # 从实拍图裁模板的辅助脚本

模板用.pgm而不是.bmp或.jpg,原因是 PGM 是无压缩的纯灰度格式,OpenMV 解码它几乎不耗时间,也不会有 JPEG 压缩带来的块效应干扰相似度计算。.bmp也能用,但文件头解析在部分固件版本上会慢一点;JPEG 则绝对不要用在模板上,一个 8×8 的 DCT 块就能把细笔画的数字糊掉。

config.py单独拆出来是个好习惯。现场调试的时候你会反复改二值化阈值、相似度门限、最小字符宽度这几个数,全写在主程序里,改一次要往下翻两百行。集中到配置文件里,主程序from config import *引进来,改完直接跑,心智负担小很多。

模板文件放内部 Flash 根目录还是 SD 卡,取决于板子有没有插卡。内部 Flash 的读写速度快,但空间有限,八张小图加起来通常不到 50KB,完全塞得下。路径上要注意:内部 Flash 挂载在/,SD 卡挂载在/sd,代码里写相对路径"digits/1.pgm"时,实际是相对当前工作目录解析的,切换存储介质后记得同步改前缀,否则就是一路OSError: [Errno 2] ENOENT。

3. 环境与固件准备:把 OpenMV 相机和 IDE 跑起来

3.1 硬件与固件确认

先确认板子型号和固件版本。OpenMV 的 H7 系列主控主频在 400MHz 以上,跑这套流程比较从容;老一些的 M7 板子也能跑,但帧率要打折扣,必须把分辨率降下来。固件版本直接决定了find_template的返回值结构,不同版本之间返回元组的长度有差异,写代码时用len(r)做个保护是必要的防御。

判断固件版本的方式很简单,连上 IDE 之后右下角状态栏会显示,或者跑一行:

import omv print(omv.version) print(omv.machine)

omv.machine会打出板子型号字符串,omv.version是固件版本号。把这两个值记下来,后面遇到玄学问题的时候,第一件事就是确认它跟你参考的例程是不是同一个固件环境。我见过太多「照着例程抄,报错报得莫名其妙」的情况,最后发现是固件差了好几个大版本,API 早就改了。

传感器型号也值得确认一下。全局快门和卷帘快门在拍运动物体时表现差别很大,如果标签是贴在传送带上的,卷帘快门会出现果冻效应,数字被拉斜,模板匹配的相似度会莫名其妙地掉。这种情况要么换全局快门模组,要么让运动在曝光期间停下来——后者在产线上通常更容易实现。

3.2 IDE 连接与第一个灰度帧

OpenMV IDE 走的是 USB 虚拟串口加 DFU 的组合,插上板子之后,左下角会出现连接按钮,点一下就连上了。连不上的排查顺序是:换一根能传数据的线(不是所有 USB 线都带数据线芯)、确认驱动装好了、确认没有别的程序占着串口。这一步没什么技术含量,但确实是新手卡得最久的地方。

连上之后先跑一段最小可运行代码,把画面调出来:

import sensor, image, time sensor.reset() # 复位传感器,清掉上一次的配置 sensor.set_pixformat(sensor.GRAYSCALE) # 灰度模式,数据量是 RGB565 的一半 sensor.set_framesize(sensor.QVGA) # 320x240,够用且帧率高 sensor.skip_frames(time=2000) # 丢掉前 2 秒的自动调整不稳定帧 sensor.set_auto_gain(False) # 关自动增益,锁定亮度 sensor.set_auto_whitebal(False) # 灰度下其实无用,但保持习惯 sensor.set_auto_exposure(False, exposure_us=8000) # 锁定曝光,单位微秒 clock = time.clock() while True: clock.tick() img = sensor.snapshot() print(clock.fps())

几个参数值得展开说。GRAYSCALE是所有字符识别的默认起点,颜色在这个任务里没有任何信息量,反而让每个像素多占一倍内存、多花一倍带宽。QVGA是 320×240,做数字识别足够了,除非数字在画面里只占十几个像素,那说明镜头选得不对,应该换焦距而不是硬拉分辨率。

set_auto_gain(False)和set_auto_exposure(False)这两行是整个项目稳定性的地基。自动增益会在画面整体变暗时拼命往上拉,导致同一张标签在两帧之间的亮度分布完全不一样,二值化阈值就失效了。锁定之后,画面亮度只跟环境光有关,配合固定的补光,一致性才有保障。exposure_us这个值要给多少,取决于你的补光强度和镜头光圈,8000 微秒是一个常见起点,画面太暗就往上加,运动模糊就用往下减。

跑起来之后 IDE 右边会显示实时画面,下面的串口区会刷帧率。如果 QVGA 下帧率能到 25 以上,说明板子和固件都没问题,可以继续往下走。

3.3 模板素材的制作与命名规范

模板的质量决定了这套方案的上限。制作流程我一般是这样走的:

  1. 用板子在最终安装位置、最终光照条件下拍一张包含数字的实拍图,存成 bmp,通过 IDE 的保存功能导出到 SD 卡或电脑上。
  2. 在 IDE 里打开这张图,用裁剪工具单独抠出每一个数字,尽量紧贴笔画外沿,四周留一到两个像素的空白。
  3. 把裁剪结果统一缩放到同一个尺寸,比如 20×28,八个模板尺寸必须完全一致,否则匹配时的尺度基准就乱了。
  4. 保存为 PGM 格式,命名用1.pgm到8.pgm,跟数字本身对应,方便代码里用循环加载。

命名这一条看着琐碎,实际影响很大。代码里加载模板一般写成for i in range(1, 9): templates[i] = image.Image("%d.pgm" % i),如果文件名是one.pgm、two.pgm,你就得手写八行或者维护一个字典映射,改起来烦。统一用数字命名,循环加载三行搞定。

尺寸统一这件事还有个隐藏要求:模板尺寸必须小于实际分割出来的字符 ROI 尺寸。find_template的语义是「在 ROI 里找模板」,如果 ROI 比模板还小,它会直接抛异常。所以在做模板的时候要预估实拍图中数字的像素高度,模板做得比它小一圈,留出搜索余量。举例来说,实拍图上数字高 40 像素,模板就做到 30×22 左右,这样在 40 像素高的 ROI 里滑动匹配才有空间。

还有个细节:模板的灰度分布要和实际成像匹配。抠模板的那张图如果是在强光下拍的,模板整体偏亮,换到弱光场景下相似度会系统性偏低,阈值就设不上去了。稳妥的做法是拍三组不同光照下的样本各抠一套,取中间那组,或者干脆在补光固定之后只拍一次。

4. 核心链路拆解:从一帧灰度图到 "1234" 的完整代码

4.1 预处理与 ROI 锁定

预处理的目标不是把图变好看,而是把「数字可能出现的区域」和「背景」在像素值上分开,并且让这个分开的界限在时间上稳定。

import sensor, image, time from config import THRESHOLD, ROI, MIN_CHAR_W, MIN_CHAR_H sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time=2000) sensor.set_auto_gain(False) sensor.set_auto_exposure(False, exposure_us=8000) # 只处理标签所在的区域,减少无谓运算 ROI = (40, 60, 240, 100) def preprocess(img): # 复制一份,避免二值化之后原图被破坏,后续还要在原图上做匹配 bin_img = img.copy() # THRESHOLD 形如 (0, 90),表示把灰度 0~90 的像素拉成白色 bin_img.binary([THRESHOLD]) # 轻度闭运算,把笔画中间的断裂点补上 bin_img.erode(1, threshold=2) bin_img.dilate(1, threshold=2) return bin_img

这里有三个关键决策。第一是img.copy()而不是直接对img做binary()。二值化之后原图的灰度信息就没了,而find_template在灰度图上匹配的效果比在纯黑白图上更细腻,因为它用的是归一化互相关,能利用灰度的渐变。所以流程是:在原图上做分割定位,在原图上做模板匹配,二值图只用来算包围盒。

第二是binary([THRESHOLD])的参数形态。它接受一个列表,每个元素是一个(low, high)元组,落在区间内的像素被置为白色,区间外的置为黑色。如果你想反过来(黑底白字的标签),就写成invert=True。现场调试时最有效的方法是先print(img.get_histogram().get_percentile((10, 90)))看看灰度分布,再定阈值,不要凭感觉瞎填。

第三是erode+dilate这对组合,也就是开运算的逆序用法。这里先腐蚀再膨胀,目的是去掉背景上的孤立噪点、同时把笔画内部的细微断裂连上。结构元大小设为 1,threshold=2表示邻域内至少有 2 个像素满足条件才保留,对 320×240 的图来说这个强度比较温和,不会把细的笔画(比如 1 的竖线)吃掉。如果你的标签印刷质量差、笔画断得厉害,把顺序反过来(先膨胀后腐蚀)会更好,能保住笔画。

ROI 锁定这一步在产线上是必须的。整幅 QVGA 图有七万多个像素,真正有意义的可能只有中间一块两百乘一百的区域。把 ROI 传进后续的find_blobs和find_template,能省掉大量无谓的遍历。ROI 的四个数怎么定:在 IDE 里对着实时画面数像素坐标,IDE 的帧缓冲区视图鼠标悬停会显示坐标,直接读出来填进去就行。别用「画面中心」这种模糊描述,写死具体数值。

4.2 字符分割:find_blobs 与垂直投影两条路

分割是这套工程的核心。思路是在二值图上找连通域,每个连通域就是一个候选字符。

def split_chars(bin_img, roi): blobs = bin_img.find_blobs( [(255, 255)], # 找白色连通域 roi=roi, # 只在感兴趣区域内找 pixels_threshold=30, # 像素数下限,滤掉噪点 area_threshold=30, # 包围盒面积下限 merge=False, # 关键:不合并相邻连通域 margin=2 # 允许 2 像素的断裂被连起来 ) boxes = [] for b in blobs: x, y, w, h = b.rect() # 尺寸过滤:太窄太矮的肯定不是数字 if w < MIN_CHAR_W or h < MIN_CHAR_H: continue # 宽高比过滤:数字一般是瘦高的,宽高比超过 1.2 的多半是噪声块 if w / h > 1.2: continue boxes.append((x, y, w, h)) # 按横坐标排序,保证输出顺序是从左到右 boxes.sort(key=lambda r: r[0]) return boxes

merge=False这个参数是多数字识别的分水岭。默认值是True,意思是把相邻的连通域合并成一个大块——对色块追踪来说这是好事,对字符分割来说这是灾难:三个挨得近的数字会被合成一个宽矩形,后面不管怎么匹配都出不来三个结果。设成False之后,每个数字独立成块。

margin=2是个补偿手段。印刷体偶尔会有笔画断裂,比如 4 的横竖交接处、8 的两个圆环之间的连接点,断裂处像素值偏高,二值化之后会被切断成两个块。给 2 像素的容差,让find_blobs在判定连通性时把距离很近的块视为一体。这个值不能给太大,给到 5 以上,相邻的两个数字又会被粘起来,等于白设了merge=False。

尺寸和宽高比这两个过滤条件解决的是噪声问题。背景上的反光点、标签边缘的毛刺、CCD 的热噪点,都会形成小连通域。pixels_threshold和area_threshold先卡一道,宽高比再卡一道,基本能干净。宽高比用w/h,数字字形普遍是高的,1 可能达到 0.3,8 会有 0.7 左右,1.2 是一个安全的边界。

如果现场条件是数字严重粘连、find_blobs怎么调都分不开,还有一条备用路线:垂直投影。

def vertical_projection(bin_img, roi, gap_threshold=3): x0, y0, rw, rh = roi col_sum = [] for i in range(rw): s = 0 for j in range(rh): if bin_img.get_pixel(x0 + i, y0 + j) > 0: s += 1 col_sum.append(s) boxes = [] start = -1 blank = 0 for i, v in enumerate(col_sum): if v > 0: if start < 0: start = i blank = 0 else: if start >= 0: blank += 1 # 连续空白超过阈值,认为一个字符结束 if blank >= gap_threshold: boxes.append((x0 + start, y0, i - blank - start, rh)) start = -1 blank = 0 if start >= 0: boxes.append((x0 + start, y0, rw - start, rh)) return boxes

投影法的原理是:把 ROI 每一列的白像素数量统计出来,数字所在的列会有大量白像素,字符之间的间隙列几乎为零。找到连续的空白区间就切开。它对「数字高度不一致」不敏感,但对倾斜很敏感——标签贴歪三度,投影出来的间隙就模糊了。而且这段代码是双重循环逐像素读,在 QVGA 下的 ROI 上跑一次要几十毫秒,帧率会掉。所以它是备用方案,不是主路线。

顺带说一句 1~8 这个范围的好处。没有 0 和 9,就不存在「0 被分割成内外两环」「9 的下半部分和 0 混淆」这类问题,连通域的数量和实际字符数量基本一一对应,分割的成功率高很多。如果哪天需求扩展到 0~9,分割逻辑里要额外处理内环,那是一个不小的改动。

4.3 单字符模板匹配与置信度打分

拿到每个字符的包围盒之后,逐个与八个模板比对。

import image # 启动时一次性加载,避免每帧都读文件 TEMPLATES = {} for i in range(1, 9): TEMPLATES[i] = image.Image("digits/%d.pgm" % i) def classify(img, box, threshold=0.62): x, y, w, h = box # 搜索区域要比模板大一圈,否则 find_template 会抛异常 pad = 4 roi = (max(0, x - pad), max(0, y - pad), w + pad * 2, h + pad * 2) best_digit = -1 best_score = 0.0 for digit, tpl in TEMPLATES.items(): r = img.find_template(tpl, threshold, roi=roi, step=2, search=image.SEARCH_EX) if not r: continue # 不同固件版本返回值长度不同,做一下保护 score = r[8] if len(r) > 8 else threshold if score > best_score: best_score = score best_digit = digit return best_digit, best_score

threshold传进find_template是「低于这个相似度直接返回 None」,而返回的最高分保存在元组的第 9 个位置(索引 8)。不同固件版本这个位置可能有出入,所以用len(r) > 8做保护,取不到就用阈值兜底。这个细节不写进代码里,换个固件跑起来就会IndexError,调试的时候一脸懵。

roi的 pad 是必须的。分割出来的包围盒是紧贴笔画的,模板的尺寸通常比包围盒略大,如果直接把包围盒当作搜索区域传进去,find_template会判���「搜索区域小于模板」并抛异常。给 4 像素的余量,既解决了尺寸问题,也允许匹配位置有轻微偏移。

step=2是搜索步长。默认值是 1,逐像素滑动;设成 2 就是每两个像素试一次。对 QVGA 图来说,设成 2 能把匹配耗时砍掉四分之三,精度损失很小,因为数字的笔画宽度普遍在 3 像素以上,步长 2 不会跳过整个笔画。如果你把分辨率提到 VGA,step可以进一步给到 4。

search=image.SEARCH_EX是穷举搜索模式,会遍历 ROI 内的所有位置。另一个选项是SEARCH_DS,爬山式搜索,快但容易陷入局部最优。数字识别这种模板和目标长得很像的场景,用 EX 更稳。

这个classify函数逐个模板调find_template,一轮下来八次调用。在 QVGA、ROI 约 40×50 的情况下,单次调用大概 2 到 4 毫秒,八个模板加起二十多毫秒,加上分割的开销,整体帧率落在 15 到 20 之间。想再快,可以把模板按宽高比先分组,先用宽高比粗筛掉一半不符合的模板,只对剩下的做匹配。这一招在很多项目里能再提 30% 的帧率。

4.4 结果排序、拼接与去抖

分割阶段已经按横坐标排过序,拼接就是字符串拼一下:

def read_number(img, roi): bin_img = preprocess(img) boxes = split_chars(bin_img, roi) if not boxes: return "", 0.0 result = "" total_score = 0.0 valid = 0 for box in boxes: digit, score = classify(img, box) if digit < 0: continue # 这个字符没认出来,跳过而不是用占位符 result += str(digit) total_score += score valid += 1 avg = total_score / valid if valid else 0.0 return result, avg

注意这里对「没认出来」的处理是跳过,而不是填入某个默认值。填充默认值会让结果字符串的长度和实际数字个数对不上,主控那边做长度校验时就会误判。跳过的代价是可能漏掉一个字符,但至少不会产生错误的数字。

单帧结果不能直接用,一定会抖。光照闪一下、标签轻微移动、传感器噪声,都会让某一帧的相似度掉到阈值以下。常见的做法是滑动窗口投票:维护最近 N 帧的结果,取出现次数最多的那个。

from collections import deque HISTORY = deque(maxlen=5) def stable_read(img, roi, min_votes=3): text, score = read_number(img, roi) HISTORY.append(text) if len(HISTORY) < 3: return None # 统计最近若干帧里出现频次最高的结果 counts = {} for t in HISTORY: if t == "": continue counts[t] = counts.get(t, 0) + 1 if not counts: return None best = max(counts, key=counts.get) if counts[best] >= min_votes: return best return None

窗口长度 5、最少票数 3,是响应速度和稳定性之间的平衡点。窗口太长(比如 10 帧)会导致结果滞后,产线上标签已经换了,输出还停在旧值上;窗口太短(3 帧要求 2 票)抗噪能力不够。这个值要根据你的实际帧率和标签切换频率调,帧率 20 的情况下,5 帧大约是 250 毫秒的延迟,一般可以接受。

还有个细节:投票的键用的是完整结果字符串,而不是单个字符。因为一个字符认错往往是整帧一起错(光照突变),按整串投票比按单字符投票更能反映「这一帧是否可信」。如果某个字符频繁出错,那是模板本身的问题,回去换模板,不要指望投票能救。

5. 常见问题排查:多数字识别最容易翻车的五个地方

5.1 ROI 比模板还小,find_template 直接抛异常

现象是程序跑起来没几帧就报OSError: ROI is smaller than template,或者干脆进不去while循环。这个问题在模板换了一批、或者 ROI 调小之后突然出现,很迷惑人。

原因是find_template的语义要求搜索区域能完整容纳模板。分割出来的包围盒是紧贴笔画的,而模板在制作时四周留了空白,模板的宽高往往比包围盒大几个像素。直接拿包围盒当 ROI 传进去,尺寸必然不够。

解决方式是给 ROI 加 padding,就像 4.3 里写的那样,四周各扩 4 到 6 像素,同时用max(0, ...)防止越界。更彻底的做法是在启动时做一次断言,把模板尺寸和最小允许的 ROI 尺寸比一下,不满足就直接在串口打印红字,别等跑到一半才崩。这套检查我后来加进了所有视觉项目里,省了太多在现场抓耳挠腮的时间。

5.2 白天能认、晚上全错:光照与曝光

现象是调试时在工位上一切正常,装到现场第二天早上开始,识别率从 95% 掉到 40%,画面上看数字还是清楚的。这是最典型的玄学问题,本质是曝光和补光没有锁死。

原因是set_auto_exposure和set_auto_gain如果保持默认开启,板子会根据画面平均亮度自动调整参数。环境光从窗外来一点变化,整幅图的灰度分布就平移了,你在调试时定的二值化阈值(0, 90)就切在了不该切的位置,笔画和背景混在一起,分割直接失效。

解决方式有三层。第一层是代码里强制锁死set_auto_gain(False)和set_auto_exposure(False, exposure_us=...),这一步 3.2 已经做了。第二层是物理补光,加一圈恒定亮度的环形光源,让环境光的变化影响不到标签本身,这是最有效的办法,花几十块钱解决 80% 的问题。第三层是阈值自适应,每次分割前用img.get_histogram().get_percentile((15, 85))动态算一个阈值区间传进binary(),代价是每帧多几毫秒,但能扛住缓慢的光衰。三层配合着上,稳定性完全不一样。

5.3 数字粘连被当成一个 blob

现象是明明有三个数字,只识别出一个,而且这个结果的相似度特别低。用 IDE 把boxes画出来一看,一个宽矩形横跨了三个字符。

原因是find_blobs的merge参数默认是True,相邻连通域会被合并。或者虽然设了False,但margin给得太大,把本该分开的块粘在了一起。还有一种情况是标签印刷时两个数字之间的空隙太小,物理上就连着。

解决方式分两步走。先确认代码里merge=False,margin不要超过 3。如果问题还在,说明物理粘连,这就得靠形态学分了:在二值化之后、find_blobs之前,做一次竖向的腐蚀,把竖直方向的连接切断,再膨胀回来。结构元要选竖向的,OpenMV 的erode支持传尺寸参数,竖向腐蚀用erode(1, 2)这类写法,具体参数得在实拍图上试。极端情况下回到 4.2 的垂直投影法,投影对粘连的容忍度比连通域高,因为它找的是「空列」而不是「连通的块」。

5.4 顺序错乱与漏识别

现象是三个数字认出来了,但顺序是「3 1 6」而不是「1 3 6」,或者中间某个数字被跳过,输出变成「16」。这个问题在标签倾斜或者数字间距不均的时候特别容易出现。

顺序错乱的原因是find_blobs返回的顺序是按连通域面积或者发现顺序排的,完全不保证从左到右。解决只有一条路:拿到boxes之后显式按x坐标排序,代码里那行boxes.sort(key=lambda r: r[0])不能省。有人按y排,那是处理竖排文本的做法,横向排列的标签用不上。

漏识别的原因通常是尺寸过滤太狠。MIN_CHAR_W设成了 12,而实际成像上数字 1 的宽度只有 8 个像素,直接被if w < MIN_CHAR_W: continue干掉了。1 是所有数字里最窄的,调参的时候一定要拿它当基准,把MIN_CHAR_W卡在比 1 的实测宽度小一点的位置。宽高比过滤同理,1 的宽高比能到 0.25,如果你把下限设成 0.4,1 就永远出不来。

5.5 帧率掉到个位数

现象是调试时流畅,加了几个后处理之后帧率从 18 掉到 5,画面卡成幻灯片,投票窗口也失效了(因为帧间隔变长,5 帧的延迟变成了一秒多)。

原因通常有三处。一是分辨率没降下来,QVGA 是上限,VGA 下所有算子耗时翻四倍。二是find_template的step=1,逐像素滑动在 ROI 稍大时非常慢。三是每个字符都对八个模板全量匹配,没有做筛选。

解决方式按性价比排序:先把step改成 2 或 4,这一条就能省掉一半时间;再把分辨率降到 QVGA 以下的 QQVGA(160×120),如果数字在画面里够大,这个降级完全无感;最后加宽高比粗筛,把明显不符合的模板先排除,只对候选集做匹配。还有一个容易被忽略的点:print本身很慢,串口输出在高速循环里会明显拖帧,调试信息要改成每 10 帧打一次。

6. 二次开发改造:加串口输出、投票去抖和 NN 兜底之后

把主链路跑通之后,剩下的都是改造。我按性价比从高到低排了几条,都是实际项目里验证过有收益的。

第一条是 UART 输出对接主控。OpenMV 的串口用法很直接,接在板子上的 UART3 或者通过 USB 虚拟串口:

from pyb import UART uart = UART(3, 115200, timeout_char=1000) def send_result(text): if text is None: return # 加帧头帧尾,主控侧才好做校验和 frame = "$%s#\r\n" % text uart.write(frame)

关键是加帧头帧尾。裸发数字串,主控那边收到半截数据或者噪声,没法判断边界。$...#这种成对的分隔符是最省事的做法,主控解析时找$开始、#结束,中间的内容做长度校验,不可靠的帧直接丢。协议不要设计得太复杂,产线环境下的串口误码率比实验室高得多。

第二条是扩大识别范围。1~8 扩展到 0~9 不是加两张模板就完事。0 有内外两环,find_blobs会把内环单独算成一个连通域,分割结果里就会多出一个「洞里的小块」,需要在过滤阶段把被其他 blob 完全包含的块剔除掉。9 的下半部分是个闭合环,上半部分是一条竖线,同样的包含关系问题。处理方式是遍历boxes,如果某个 box 被另一个 box 的矩形完全包含,就丢掉它。这段逻辑不长,但漏了就会稳定输出多余的字符。

第三条是 NN 兜底。当模板匹配对某个字符的置信度连续多帧低于 0.5,说明这个字形确实超出模板的覆盖范围了。这时候可以切到内置的神经网络分类器做二次判定:

import nn net = nn.load("/model/digit8_quant.network") def nn_classify(img, box): x, y, w, h = box sub = img.crop(roi=(x, y, w, h)).resize(28, 28) out = net.forward(sub) # out 是各类别的分数列表,取最大值下标 scores = list(out[0]) idx = scores.index(max(scores)) return idx if max(scores) > 0.55 else -1

模型输入尺寸必须和训练时一致,28×28 是常见选择,crop之后resize一步不能少。模型文件放 SD 卡,内部 Flash 空间通常不够放量化后的网络。NN 兜底只在置信度低的时候触发,不参与主流程,这样对帧率的影响可以控制住。

验证方法上,我习惯做两套测试。一套是离线回放:把samples目录里的实拍图逐张喂进去,统计每个数字的召回率和误识率,出一张混淆矩阵,看 1/7、3/8 这两组到底混到什么程度。另一套是在线压力测试:标签连续切换五十次,记录每次输出的延迟和正确率,看投票窗口在快速切换时会不会滞后。这两套跑完,才敢往现场装。

从那以后我每次做这类视觉项目,都会先把「分割对了没」和「分类对了没」拆成两个独立的调试开关,分开放串口打印,绝不把两者混在一起看。因为一旦结果错了,你永远分不清是切歪了还是认错了,而这个区分能省掉一晚上的时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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