OpenMV 1~8多数字识别:预处理、字符分割与模板匹配调参

发布时间:2026/10/30 3:11:16
OpenMV 1~8多数字识别:预处理、字符分割与模板匹配调参

简介:面向嵌入式视觉初学者与OpenMV开发者,这套“多数字识别1~8”资料围绕低功耗平台上的数字检测与模板匹配展开,可用于智能仪表读数、车牌识别、工业自动化等小型视觉项目练习。压缩包共162个文件,以160个pgm数字模板图像为主,另含openmv_disk镜像文件与1个py脚本,整体约158KB,结构紧凑,便于导入OpenMV设备进行比对实验。目前已有2404人学习。内容重点涉及数字1至8的模板训练与匹配思路,并配合灰度化、二值化、边缘检测、数字定位分割及相邻数字误识别修正等环节,帮助读者理解从图像预处理到结果后处理的小型识别流程,据此调整模板与参数,提升不同光照、角度和尺寸下的识别稳定性。

1. 一块摄像头读8个数字,卡住的往往不是识别算法

做过产线计数或仪表读数的人大概都有这个经历:单个数字识别跑得挺顺,一换成1~8连排的多数字,结果就开始乱——要么只切出第一个,要么把“8”拆成两个“3”,要么光线一变整行全废。openmv数字识别这件事,难点从来不在分类器,而在“怎么把一串数字稳稳地切开、再一个个喂进去”。这个项目围绕的就是1~8数字的多数字识别:用OpenMV这类嵌入式视觉模块,把画面里的多位数字分割出来,逐个匹配,最后输出完整数值。适合正在做仪表读数、工位计数、简易OCR原型的嵌入式开发者,也适合刚上手MicroPython视觉、想用一条完整链路练手的人。整套链路不长,但每一步都有能让你翻车的参数。

2. 从灰度到二值:预处理参数决定后面一半成败

数字识别的输入质量,在二值化那一刻基本就定死了。很多新手一上来就调模板匹配的阈值,发现怎么调都只有70%出头,回头一看是二值图里笔画断成几截,模板再准也白搭。这一章把预处理拆成三步:灰度化、二值化、形态学去噪,每一步都给出可抄的代码和参数判断方法。

2.1 为什么先做灰度而不是直接找色块

彩色图像在OpenMV里默认是RGB565,每个像素占2字节,一帧QVGA就是320×240×2≈150KB的缓冲区,处理起来内存和算力都吃紧。数字识别关心的是亮度对比,不是颜色,所以第一件事就是把像素格式切成灰度:

import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) # 灰度,单通道,内存减半 sensor.set_framesize(sensor.QVGA) # 320x240,先用小分辨率跑通 sensor.skip_frames(time=2000) # 等自动增益稳定,别省这一步 sensor.set_auto_gain(False) # 锁定增益和曝光,否则阈值会漂 sensor.set_auto_exposure(False)

set_auto_gain(False)和set_auto_exposure(False)这两行是血泪经验。自动增益在画面里数字少的时候会把整体亮度往上拉,数字一多又压下来,固定阈值直接失效。锁定之后,同一批物料在相同工位上的灰度分布才稳定。分辨率选QVGA而不是VGA,是因为多数仪表和标签上的数字高度在40~80像素就够模板匹配用了,QVGA下处理一帧的耗时大约是VGA的四分之一。

2.2 阈值怎么定:固定阈值与Otsu的取舍

二值化的本质是选一个灰度分界,把数字(前景)和背景分开。OpenMV的binary接受一个区间列表,白像素保留、黑像素置零:

# 方式一:固定阈值,适合光照稳定的封闭工位 def binarize_fixed(img, lo=110, hi=255): img.binary([(lo, hi)]) return img # 方式二:Otsu自适应,适合光照缓慢变化但背景较干净的场景 def binarize_otsu(img): hist = img.get_histogram() t = hist.get_threshold() # 返回一个可直接喂给binary的阈值区间 img.binary([t]) return img

固定阈值调参的方法不是盲试。把画面截一帧存成pgm,在OpenMV IDE里看直方图,数字笔画的灰度峰值和背景峰值之间会有一个谷底,阈值取谷底偏背景一侧10~20个灰度值。比如背景在60附近、笔画在180附近,阈值取100~120都能出干净的二值图。

Otsu的问题在于它假设图像是双峰的。仪表盘背景如果有反光、有刻度线、有边框,直方图会变成三峰甚至多峰,Otsu算出来的阈值经常把边框也切进来。我一般会先用固定阈值跑通,再用Otsu做兜底,并且把ROI卡死——只让数字区域进入统计,这样Otsu的双峰假设才成立。

2.3 形态学去噪与ROI裁剪的落地代码

二值化之后常见的脏东西有两类:椒盐噪点(小面积白点)和笔画断裂(数字中间断开)。前者用开运算(先腐蚀后膨胀)去掉,后者用闭运算(先膨胀后腐蚀)补上。OpenMV的erode和dilate都支持迭代次数:

def clean_binary(img): # 先腐蚀1次去掉孤立噪点,再膨胀1次恢复笔画粗细 img.erode(1, threshold=2) # threshold=2:邻域内至少2个白点才保留,抑制细噪 img.dilate(1, threshold=1) # 膨胀把断裂处接回 return img

threshold参数容易被忽略。腐蚀时threshold设成2表示“3×3邻域里至少2个白像素,中心才保留”,比默认的1更狠,适合噪点多的场景;如果数字本身笔画只有1~2像素宽,threshold设2会把整笔腐蚀没,这时候要么降到1,要么干脆跳过腐蚀只做膨胀。

ROI裁剪放在二值化之前做会更省算力,但前提是你知道数字大致在画面哪个区域。固定工位可以用硬编码矩形,移动场景用色块或矩形检测先框出标签区域:

# 固定工位:直接把ROI卡在数字带附近,后面所有处理只在这个矩形内 ROI = (60, 80, 200, 60) # (x, y, w, h),按实际画面标定 img = sensor.snapshot() roi_img = img.crop(roi=ROI) # crop返回子图,后续操作不再遍历全图

crop返回的是原图上的一个视图,后续binary、find_blobs都只在这块区域里跑,帧率提升非常明显。参数标定的笨办法是把img.draw_rectangle(ROI)画到预览上,对着实际物料调x、y、w、h,直到矩形刚好裹住数字带且留一点边距——边距留太少,边缘数字会被切掉半个。

3. 多数字分割:连通域和投影法怎么选

预处理做完,一张二值图上躺着1~8个白色字符。接下来的任务是把它们切成独立的单字符图像,这一步的准确率直接决定后面识别上限。工程上常用两条路:连通域标记和垂直投影。选哪条取决于你的字符是否粘连、间距是否均匀。

3.1 连通域标记法:字符间距均匀时的首选

OpenMV的find_blobs本质就是连通域标记,输入一个颜色阈值列表,返回每个连通块的外接矩形。二值图里白色是255,所以阈值给(200, 255):

def split_by_blobs(bin_img, min_area=80, max_area=4000): blobs = bin_img.find_blobs( [(200, 255)], pixels_threshold=min_area, # 白像素数下限,滤掉噪点 area_threshold=min_area, # 外接矩形面积下限 merge=False, # 关键:不合并相邻字符 margin=2 # 合并判定间距,merge=False时基本不起作用 ) # find_blobs返回顺序不保证,必须按x坐标排序才是从左到右的数字 blobs = sorted(blobs, key=lambda b: b.cx()) return blobs

merge=False是这条链路里最容易被写错的一个参数。默认merge是True,相邻的两个数字只要间距小于margin就会被合成一个blob,结果就是“12”变成一个宽条,后面识别直接报废。改成False之后,每个字符独立成一个blob,但代价是同一个数字如果笔画断开(比如“5”的上横和下半身没连上),会被切成两个blob——这就是为什么上一章的闭运算不能省。

过滤参数pixels_threshold和area_threshold要按实际字符尺寸给。QVGA下数字高度约50像素、宽度约30像素,单个数字的外接矩形面积在1000~2000之间,白像素数在400~900。下限设80能滤掉大部分噪点,上限设4000能挡掉整块背景被误判的情况。这两个值不要照抄,拿卡尺在预览图上量一下你的字符实际像素尺寸再定。

连通域法还有个隐藏问题:数字“1”的blob很窄,宽高比可能到0.2,如果后面有按宽高比过滤的逻辑,容易把“1”当噪点滤掉。解决办法是过滤条件里加一条“高度必须大于某个值”,用高度而不是宽高比做主判据。

3.2 垂直投影法:处理粘连字符的备选

当字符之间确实粘连(打印模糊、镜头失焦、标签纸起皱),连通域会把两个数字粘成一个blob,这时候投影法更稳。思路是把ROI按列统计白像素数量,字符所在列的投影值高,字符间隙的投影值接近0,找波谷就是切分点:

def split_by_projection(bin_img, roi, min_gap=4, white_th=128): x, y, w, h = roi col_sum = [0] * w # 逐列统计白像素;QVGA下w约200,遍历可接受,VGA要慎用 for i in range(w): cnt = 0 for j in range(h): if bin_img.get_pixel(x + i, y + j) > white_th: cnt += 1 col_sum[i] = cnt # 找连续的低投影区间作为切分间隙 segments, start = [], None for i, v in enumerate(col_sum): if v <= 1 and start is None: start = i elif v > 1 and start is not None: if i - start >= min_gap: segments.append((start, i)) start = None return segments

min_gap控制最小间隙宽度,设太小会把数字内部的笔画间隙也当切分点(“4”的中间就有低谷),设太大则漏切真正粘连的字符间隙。经验值是字符平均宽度的1/8~1/6,30像素宽的字符取4~5比较稳。

投影法的性能是硬伤。get_pixel在MicroPython里是逐像素Python调用,QVGA的ROI宽200、高60,一帧就是12000次调用,实测帧率会从25fps掉到8fps左右。优化手段有两个:一是把ROI再缩小,只框数字带本身;二是隔列采样,比如每2列统计一次,切分精度损失可接受但耗时减半。如果场景帧率要求高,还是优先用连通域。

3.3 分割结果的排序、过滤与尺寸归一

不管用哪种方法,切出来的字符块都要过一遍清洗:

def filter_and_sort(candidates, min_h=30, max_h=90, min_w=5, max_w=70): kept = [] for b in candidates: w, h = b.w(), b.h() # 高度是主判据:太矮是噪点,太高是背景块 if h < min_h or h > max_h: continue # 宽度兜底:太宽可能是两个字符粘连,太窄可能是竖线噪点 if w < min_w or w > max_w: continue kept.append(b) kept.sort(key=lambda b: b.cx()) # 从左到右 return kept

高度下限min_h的定法:量一下正常数字的高度,取其60%~70%。比如正常数字高50像素,min_h设30,能挡掉大部分刻度线和噪点,又不会误杀轻微缩放的字符。宽度上限max_w设成单字符宽度的1.6倍左右,超过这个值大概率是两个字符粘在一起,与其硬识别不如标记为“待重拍”。

尺寸归一化是为模板匹配服务的。OpenMV的find_template对尺度敏感,模板和待匹配区域的尺寸差超过20%,匹配得分会断崖式下跌。所以切出来的每个字符块,最好统一缩放到模板的尺寸:

def normalize_size(img, blob, target_w=32, target_h=48): sub = img.crop(roi=blob.rect()) # OpenMV的resize是最近邻,字符边缘会有锯齿,但对模板匹配影响不大 return sub.resize((target_w, target_h))

target_w和target_h的取值和模板保持一致即可。32×48是一个折中:再小会丢笔画细节(“3”和“8”的区分靠中间横笔),再大模板文件占内存、匹配耗时上升。这个尺寸下,8个数字模板加起来不到10KB,QVGA的RAM完全扛得住。

4. 识别1~8:模板匹配与特征法怎么配合

分割出单字符之后,识别本身反而是整条链路里最“标准”的一步。但1~8这8个数字有几个天然易混对:3和8、6和5、7和1,纯模板匹配在光照或字体稍有变化时就会在这几对上翻车。我的做法是模板匹配打底、几何特征做二次校验,两个得分加权。

4.1 模板匹配的实现与模板制作规范

OpenMV的模板匹配需要pgm格式的模板文件,制作流程是在IDE里截取一个干净的单字符区域,保存为灰度pgm。模板质量直接决定匹配上限,制作时有三条规矩:

项目要求原因
背景纯黑,字符纯白匹配是灰度相关,灰背景会拉低得分
边距字符四周留2~3像素贴边会导致匹配窗口偏移时得分骤降
尺寸与normalize后的尺寸一致尺度不一致时匹配得分不可比
数量每个数字3~5个字体变体单一模板对笔画粗细变化不鲁棒

匹配代码本身很短,参数才是重点:

def match_digit(img, blob, templates, threshold=0.65): sub = img.crop(roi=blob.rect()) sub = sub.resize((32, 48)) best_digit, best_score = -1, 0.0 for digit, tpl in templates.items(): # step=1全搜索保证精度;search参数用于限制搜索范围,这里模板与子图同尺寸可不设 r = sub.find_template(tpl, threshold, step=1) if r and r["score"] > best_score: best_score = r["score"] best_digit = digit return best_digit, best_score

threshold=0.65是起始值,实际要按现场调。设太高(0.85以上)会把稍微倾斜或粗细不同的字符判成“无匹配”,设太低(0.5以下)则3和8、6和5互相串。调参方法是拿一批已知标签跑一遍,统计正确匹配的最低得分和错误匹配的最高得分,阈值取两者中间偏错误侧一点。

step=1表示模板在子图上逐像素滑动,精度最高但耗时也最高。如果帧率吃紧,可以设step=2,匹配得分损失约5%,耗时减半。注意find_template的时间复杂度是子图面积乘模板面积,32×48的模板在32×48的子图上全搜索就是1500次相关运算,8个模板轮流匹配一帧8个数字,耗时在几十毫秒量级,QVGA下能跑到10fps以上。

4.2 几何特征做二次校验:专治3/8、6/5混淆

模板匹配分不清的,几何特征往往能补刀。1~8里可用的稳定特征有这么几个:

  • 孔洞数:8有两个封闭孔,6和0有一个,1、2、3、4、5、7没有。OpenMV没有现成的孔洞计数API,但可以用find_blobs在字符块内部反色找黑孔——把字符块再二值化一次、取反,找内部的黑色连通域,数量就是孔洞数。
  • 宽高比:1明显窄(宽高比0.2~0.35),8接近0.6~0.75,其余在0.45~0.65之间。
  • 上下半区像素比:6的上半区白像素明显少于下半区,5则上半区多。把字符块按水平中线切成两半,各算白像素总数,比值能区分6和5。
  • 中间横笔位置:3的中间横笔偏下,8的中间交叉偏中,用行投影的峰值位置判断。
def hole_count(img, rect): sub = img.crop(roi=rect).copy() sub.binary([(0, 100)]) # 反色:孔洞原本是黑,反成白 holes = sub.find_blobs([(200, 255)], pixels_threshold=15, area_threshold=15) # 孔洞必须完全在字符内部,排除贴边的大块背景 return sum(1 for h in holes if h.w() < rect[2] * 0.6 and h.h() < rect[3] * 0.6)

pixels_threshold=15是过滤小噪点,孔洞面积一般在50~300像素之间,15足够挡住二值化残留的针孔。rect传字符块的外接矩形,注意裁剪时不要带太多背景,否则背景反色后会连成一片被误当成孔。

4.3 双得分融合与识别结果输出

模板得分和特征得分怎么合?最直接的是加权求和,但权重不要拍脑袋,用一批标注样本回归出来:

def recognize(img, blob, templates): digit, score = match_digit(img, blob, templates) h = hole_count(img, blob.rect()) w_ratio = blob.w() / blob.h() # 特征规则作为否决项:孔洞数对不上直接压分 expected_holes = {8: 2, 6: 1, 0: 1} if digit in expected_holes and h != expected_holes[digit]: score *= 0.6 # 压到0.65阈值以下,触发重识别 if digit == 1 and w_ratio > 0.5: score *= 0.7 # 1不可能这么宽 return digit, score

这套规则朴素但有效。实测中3和8的混淆能压掉七八成——8的孔洞数不对时得分直接掉到阈值以下,系统会输出“低置信度”而不是硬给一个错数。工程上宁可漏识别也不要错识别,下游拿到一个置信度标记还能补救,拿到一个错数字往往就是批量报废。

5. 避坑与排查:数字识别翻车记录

这一章的五条都是实际调试里真撞过的,每条按“现象—原因—解决”写,对着查能省不少时间。

5.1 只识别出第一个数字,后面全丢

现象:画面里明明是“1234”,输出只有“1”,find_blobs返回的blob数量只有1个。原因:merge参数用了默认值True,或者margin设得太大,相邻字符被合并成一个宽blob;另一种可能是ROI宽度不够,只框住了第一个数字。解决:find_blobs显式写merge=False,并把ROI画到预览上确认覆盖整串数字。如果合并是因为字符本身粘连,改用第3章的投影法切分。

5.2 数字“8”被识别成“3”或“0”

现象:模板匹配得分不低(0.7以上),但孔洞数不对,输出错数。原因:二值化阈值偏高,8的中间横笔被切掉,变成一个开口的“3”形;或者阈值偏低,8的两个孔被填死,变成实心块,孔洞数算成0。解决:把阈值往中间调,用直方图确认8的中间横笔在二值图里连续;加一次dilate(1)把细笔画接回;孔洞计数时把pixels_threshold降到10,避免小孔被漏掉。

5.3 光照一变,整行识别全错

现象:上午跑得好好的,下午换了个工位或者窗外光线变了,识别率从90%掉到30%。原因:用了固定阈值二值化,且自动增益没锁。解决:set_auto_gain(False)和set_auto_exposure(False)锁定曝光;二值化改成Otsu自适应,但ROI要卡紧只含数字区域,否则Otsu会被背景干扰;实在不行加个环形补光,把光照变成可控变量。

5.4 帧率掉到5fps,实时性不达标

现象:单独跑预处理正常,加上投影法切分后帧率骤降。原因:get_pixel逐像素遍历在MicroPython里是解释执行,VGA尺寸下每帧几十万次调用。解决:分辨率降到QVGA;ROI裁到数字带;投影法改隔列采样;或者放弃投影法改用find_blobs,后者是C实现的,快一个数量级。如果必须用投影法又要高帧率,考虑把切分结果缓存,连续帧只在检测到ROI变化时才重新切。

5.5 数字顺序颠倒,读数变成反的

现象:画面从左到右是“123”,输出是“321”。原因:find_blobs的返回顺序按连通域标记的扫描顺序,不保证按x坐标排列;或者摄像头安装方向导致图像镜像。解决:拿到blobs后强制sorted(blobs, key=lambda b: b.cx());如果是摄像头镜像,在sensor.set_hmirror(True)和set_vflip(True)里核对方向,别在业务代码里用索引反转去补,那样换一批物料就会错。

6. 进阶:把识别率从70%拉到95%的几个技巧

模板匹配跑通之后,识别率通常卡在70%~85%之间,剩下的差距靠“识别前的几何校正”和“识别后的逻辑约束”来补。这一章给三个我实际用过、收益最明显的做法。

第一个是透视校正。标签纸贴歪、摄像头斜装,字符在画面里是梯形的,模板匹配对旋转和透视都敏感。OpenMV有find_rects,可以先找标签外框的四个角点,用img.rotation_corr做透视变换把标签拉正,再做分割和匹配:

def rectify_label(img): rects = img.find_rects(threshold=10000) if not rects: return img r = max(rects, key=lambda x: x.w() * x.h()) # 取最大矩形,排除小噪框 corners = r.corners() # 返回4个角点(x, y) # rotation_corr的四个参数依次是左上、右上、左下、右下 img.rotation_corr(corners=corners) return img

find_rects的threshold是边缘强度累加阈值,值越小越容易检出、也越容易误检。10000是QVGA下的起始值,标签对比度高可以往上加到20000。校正之后数字的旋转和透视都被拉平,模板匹配得分普遍能涨10个百分点以上。

第二个是位数约束。很多场景下数字的位数是固定的(比如工位码固定4位),识别完把结果串起来,如果位数不对直接判为“本帧无效”,等下一帧,而不是硬输出。这比调任何阈值都管用,能把偶发的漏切、多切挡在输出之前。

第三个是多帧投票。同一目标连续采5帧,每帧独立识别,取出现次数最多的结果作为最终输出。数字识别单帧错误率10%时,5帧投票能把错误率压到1%以下,代价是响应延迟增加。产线计数这种目标静止或慢速移动的场景完全值得。

优化手段典型收益代价
透视校正匹配得分+8%~15%每帧多一次find_rects,约5ms
位数约束过滤偶发错帧需要知道固定位数
5帧投票错误率降一个数量级延迟增加约200ms

有一个习惯我后来一直保持:每换一批物料或者换一个工位,先跑100帧统计正确率和最低匹配得分,把阈值重新标定一遍再上线。数字识别这种链路,参数是跟物料绑定的,不是跟代码绑定的。那年在一个计数工位上省了这次标定,结果换标签纸供应商后整线读错,返工排查了两天才发现只是阈值差了15个灰度值。从那以后我每次改硬件配置都强制走一遍标定流程,哪怕看起来“只是换了个颜色”。希望帮到你。

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