简介面向嵌入式视觉初学者与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(time2000) # 等自动增益稳定别省这一步 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, lo110, hi255): 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, threshold2) # threshold2邻域内至少2个白点才保留抑制细噪 img.dilate(1, threshold1) # 膨胀把断裂处接回 return imgthreshold参数容易被忽略。腐蚀时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(roiROI) # 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_area80, max_area4000): blobs bin_img.find_blobs( [(200, 255)], pixels_thresholdmin_area, # 白像素数下限滤掉噪点 area_thresholdmin_area, # 外接矩形面积下限 mergeFalse, # 关键不合并相邻字符 margin2 # 合并判定间距mergeFalse时基本不起作用 ) # find_blobs返回顺序不保证必须按x坐标排序才是从左到右的数字 blobs sorted(blobs, keylambda b: b.cx()) return blobsmergeFalse是这条链路里最容易被写错的一个参数。默认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_gap4, white_th128): 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 segmentsmin_gap控制最小间隙宽度设太小会把数字内部的笔画间隙也当切分点“4”的中间就有低谷设太大则漏切真正粘连的字符间隙。经验值是字符平均宽度的1/8~1/630像素宽的字符取4~5比较稳。投影法的性能是硬伤。get_pixel在MicroPython里是逐像素Python调用QVGA的ROI宽200、高60一帧就是12000次调用实测帧率会从25fps掉到8fps左右。优化手段有两个一是把ROI再缩小只框数字带本身二是隔列采样比如每2列统计一次切分精度损失可接受但耗时减半。如果场景帧率要求高还是优先用连通域。3.3 分割结果的排序、过滤与尺寸归一不管用哪种方法切出来的字符块都要过一遍清洗def filter_and_sort(candidates, min_h30, max_h90, min_w5, max_w70): 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(keylambda 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_w32, target_h48): sub img.crop(roiblob.rect()) # OpenMV的resize是最近邻字符边缘会有锯齿但对模板匹配影响不大 return sub.resize((target_w, target_h))target_w和target_h的取值和模板保持一致即可。32×48是一个折中再小会丢笔画细节“3”和“8”的区分靠中间横笔再大模板文件占内存、匹配耗时上升。这个尺寸下8个数字模板加起来不到10KBQVGA的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, threshold0.65): sub img.crop(roiblob.rect()) sub sub.resize((32, 48)) best_digit, best_score -1, 0.0 for digit, tpl in templates.items(): # step1全搜索保证精度search参数用于限制搜索范围这里模板与子图同尺寸可不设 r sub.find_template(tpl, threshold, step1) if r and r[score] best_score: best_score r[score] best_digit digit return best_digit, best_scorethreshold0.65是起始值实际要按现场调。设太高0.85以上会把稍微倾斜或粗细不同的字符判成“无匹配”设太低0.5以下则3和8、6和5互相串。调参方法是拿一批已知标签跑一遍统计正确匹配的最低得分和错误匹配的最高得分阈值取两者中间偏错误侧一点。step1表示模板在子图上逐像素滑动精度最高但耗时也最高。如果帧率吃紧可以设step2匹配得分损失约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.358接近0.6~0.75其余在0.45~0.65之间。上下半区像素比6的上半区白像素明显少于下半区5则上半区多。把字符块按水平中线切成两半各算白像素总数比值能区分6和5。中间横笔位置3的中间横笔偏下8的中间交叉偏中用行投影的峰值位置判断。def hole_count(img, rect): sub img.crop(roirect).copy() sub.binary([(0, 100)]) # 反色孔洞原本是黑反成白 holes sub.find_blobs([(200, 255)], pixels_threshold15, area_threshold15) # 孔洞必须完全在字符内部排除贴边的大块背景 return sum(1 for h in holes if h.w() rect[2] * 0.6 and h.h() rect[3] * 0.6)pixels_threshold15是过滤小噪点孔洞面积一般在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显式写mergeFalse并把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尺寸下每帧几十万次调用。解决分辨率降到QVGAROI裁到数字带投影法改隔列采样或者放弃投影法改用find_blobs后者是C实现的快一个数量级。如果必须用投影法又要高帧率考虑把切分结果缓存连续帧只在检测到ROI变化时才重新切。5.5 数字顺序颠倒读数变成反的现象画面从左到右是“123”输出是“321”。原因find_blobs的返回顺序按连通域标记的扫描顺序不保证按x坐标排列或者摄像头安装方向导致图像镜像。解决拿到blobs后强制sorted(blobs, keylambda 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(threshold10000) if not rects: return img r max(rects, keylambda x: x.w() * x.h()) # 取最大矩形排除小噪框 corners r.corners() # 返回4个角点(x, y) # rotation_corr的四个参数依次是左上、右上、左下、右下 img.rotation_corr(cornerscorners) return imgfind_rects的threshold是边缘强度累加阈值值越小越容易检出、也越容易误检。10000是QVGA下的起始值标签对比度高可以往上加到20000。校正之后数字的旋转和透视都被拉平模板匹配得分普遍能涨10个百分点以上。第二个是位数约束。很多场景下数字的位数是固定的比如工位码固定4位识别完把结果串起来如果位数不对直接判为“本帧无效”等下一帧而不是硬输出。这比调任何阈值都管用能把偶发的漏切、多切挡在输出之前。第三个是多帧投票。同一目标连续采5帧每帧独立识别取出现次数最多的结果作为最终输出。数字识别单帧错误率10%时5帧投票能把错误率压到1%以下代价是响应延迟增加。产线计数这种目标静止或慢速移动的场景完全值得。优化手段典型收益代价透视校正匹配得分8%~15%每帧多一次find_rects约5ms位数约束过滤偶发错帧需要知道固定位数5帧投票错误率降一个数量级延迟增加约200ms有一个习惯我后来一直保持每换一批物料或者换一个工位先跑100帧统计正确率和最低匹配得分把阈值重新标定一遍再上线。数字识别这种链路参数是跟物料绑定的不是跟代码绑定的。那年在一个计数工位上省了这次标定结果换标签纸供应商后整线读错返工排查了两天才发现只是阈值差了15个灰度值。从那以后我每次改硬件配置都强制走一遍标定流程哪怕看起来“只是换了个颜色”。希望帮到你。本文还有配套的精品资源点击获取