OpenMV多数字1~8识别:分割排序与模板匹配实践 简介面向嵌入式视觉入门与物联网项目开发者的OpenMV多位数字识别实践包聚焦1至8这八个数字的模板匹配识别可用于智能仪表读数、车牌字符读取等低功耗场景。资源内含一个Python主程序与160个pgm格式数字模板另有OpenMV磁盘配置文件共162个文件、压缩包约158KB体量轻巧便于直接拷入开发板运行。程序围绕灰度转换、二值化、模板匹配与边界框定位展开模板已预先训练完成读者可在此基础上调整阈值、缩放比例与匹配相似度体会检测分割与后处理修正的思路。已有2404人学习下载。对初次接触OpenMV图像处理的读者而言这份素材省去从零采集样本、逐字训练模板的环节能快速跑通多数字识别流程并以此为骨架改写适配自己的仪表、编号识别任务。1. 从单数字到 1~8OpenMV 多数字识别工程先把“定位”讲清楚这套 openmv数字识别 工程把目标放在 1~8 的多数字场景画面里不止一个字符还要按从左到右的顺序把结果拼成串。它真正要解决的是一整条链路先找到候选数字再切分、排序、识别最后把结果通过串口送出去难点并不只落在“认出一个数字”上。适合已经能把单个字符拍清、却被多目标粘连、误检和顺序错乱拖住的人。拿到源码后别急着调阈值先把镜头距离、光源方向、字符高度这三件事固定否则后面每个参数都会变成玄学。2. 多数字识别为什么不能直接套单字符模板四段链路的选型账单字符例程的隐含前提很舒服画面里只有一个字符位置大致固定模板尺寸和现场字符差不多。多数字 1~8 把这个前提拆掉了。画面里出现多个候选字符之间可能挨得很近find_blobs 返回顺序还不一定是人眼看到的从左到右。直接套单字符模板常见结果是要么两个数字粘成一个框要么一个数字被拆成两个框最后串口输出的不是“1,2,3”而是“1,2,3,8”这类多一位或少一位的串。先讲清链路再决定用模板匹配还是轻量分类器后面的参数才有落点。2.1 单字符模板在多数字场景失效的三个现场第一个现场是粘连。字符间距小、光源偏暗、二值化阈值偏高时两个数字的外接框会连在一起。find_blobs 的 merge 和 margin 参数会把相邻块合并单字符模板只会在合并框里找一个最佳位置另一个数字直接丢掉。反过来阈值过低或字符笔画细一个数字的上下部分可能断成两个 blob排序后就变成两个“数字”。这类问题不是模板本身能修的得先在分割层把候选框做稳。第二个现场是尺度变化。模板匹配的归一化互相关对字符高度很敏感镜头稍微前后移动或者字符在画面边缘被透视压缩6、8、5 这类笔画复杂的数字最容易互相误判。单字符演示里你可以把模板做得和现场一样大多数字场景里每个字符的像素高度可能差几个像素模板匹配的分数就往下掉。常见做法是固定安装距离模板按现场字符高度裁切如果距离必须变就要么给每个数字准备多套不同高度的模板要么换轻量分类器。第三个现场是顺序。find_blobs 的返回顺序跟内存扫描和合并结果有关不保证按 x 坐标从小到大。人眼看到“1 2 3”代码可能先拿到 3 再拿到 1。多数字识别最终要拼串顺序错一次整串就错。排序不能省而且不能只按 blob 的 x 排还要考虑同一行内的 y 波动。单行 1~8 的场景按中心点 cx 排序足够多行场景要先按 y 聚类再按 x 排这份工程的主线是单行多数字排序逻辑可以写得直接一点。2.2 四段链路选型光学、分割、分类、通信我一般把多数字识别拆成四段光学固定、候选分割、逐字符分类、串口输出。光学段负责让字符和背景有稳定对比度分割段负责把每个数字的外接框找出来分类段只处理单个 ROI通信段负责把结果按帧发出去。四段里任何一段不稳后面调分类阈值都是白费。下面这张表是这份 1~8 工程里常用的选型账参数不是绝对值是现场调试时的起点。环节目标OpenMV 常见做法关键参数失败表现光学字符边缘稳定灰度模式、固定增益、固定曝光、侧向补光曝光时间、增益、光源角度同一字符时黑时白阈值每天都要改分割每个数字一个候选框find_blobs 面积/宽高比过滤 排序去重灰度阈值、pixels_threshold、area_threshold、merge、margin粘连、断裂、漏检、多框分类单 ROI 映射到 1~8模板匹配或轻量分类器模板阈值、step、ROI 扩边、模板尺寸6/8/5 互认1/7 互认通信主控拿到稳定串UART 固定波特率、按行分帧波特率、帧格式、发送频率重复、丢帧、粘包、主控解析错位模板匹配和轻量分类器的分界线主要看字体和成像是否稳定。字符字体固定、镜头距离固定、光照可控模板匹配足够快调试也直观字体变化大、距离会变、背景复杂模板匹配的阈值会越调越碎这时候才值得上轻量分类器。不要一上来就上模型多数字 1~8 的很多翻车点其实在分割和排序不在分类器。2.3 先写一个最小采集脚本把变量固定在改识别代码之前先让镜头输出稳定。下面这段脚本只做一件事固定灰度、固定增益、固定曝光连续保存若干帧方便在 PC 上看字符边缘和背景噪声。保存路径按手头硬件的文件系统改脚本里的/sample_00.pgm只是示例。import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time2000) # 关掉自动增益和自动白平衡曝光按现场亮度改 sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) sensor.set_auto_exposure(False, exposure_us10000) for i in range(20): img sensor.snapshot() img.save(/sample_%02d.pgm % i) time.sleep_ms(500)这段代码的逻辑是先把相机拉到手动模式再连续采 20 帧间隔 500 ms方便观察光照变化和字符边缘。参数上GRAYSCALE减少颜色通道干扰QVGA兼顾视野和帧率exposure_us10000是起点现场偏暗就加偏亮就减set_auto_gain(False)和set_auto_whitebal(False)必须关否则同一张卡在不同时间拍出来的灰度分布会漂。采集完先看保存的图字符边缘是否发虚背景是否有反光斑字符高度占多少像素。字符高度低于 12 像素时模板匹配会很不稳先把镜头拉近或换更高分辨率比后面调阈值有效。3. 在 OpenMV 上跑通 1~8采集、二值化、连通域与排序的代码骨架这一章把主链路跑通初始化相机、灰度二值化、find_blobs 找候选、按宽高比过滤、从左到右排序、给每个候选画框。识别函数放到下一章先把“每个数字一个框”这件事做出来。多数字 1~8 的工程分割稳了识别才有意义分割不稳模板匹配的阈值调到 0.9 也只是把错误藏起来。3.1 固定曝光与灰度化先把“玄学”压下去OpenMV 的自动增益和自动白平衡在单字符演示里很方便在多数字场景里却是黑匣子画面稍微变亮二值化阈值就得跟着改昨天调的参数今天翻车。比较稳的做法是手动曝光加固定增益先让背景灰度落在高位、字符灰度落在低位find_blobs 的阈值区间才有物理意义。下面这段初始化代码可以直接放在main.py开头。import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time2000) sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) sensor.set_auto_exposure(False, exposure_us10000) clock time.clock() uart UART(3, 115200)逻辑上先复位相机再设灰度模式和 QVGA跳过前 2 秒让自动算法收敛然后关掉自动增益、自动白平衡和自动曝光。exposure_us要根据现场亮度改常见范围可以从几千到两万微秒别死记一个数。UART(3, 115200)是串口输出用的引脚编号按手头板子改波特率要和主控一致。初始化完成后不要马上进入识别循环先打印帧率确认图像采集没有被串口或保存操作拖慢。while True: clock.tick() img sensor.snapshot() print(fps, clock.fps())这段循环只测帧率逻辑是每帧调用一次clock.tick()clock.fps()返回最近一帧的帧率。参数上QVGA 下如果帧率掉到 10 以下先看是不是打开了调试保存或串口打印太多多数字识别不需要很高帧率但低于 8 帧时串口输出会明显滞后主控那边会以为识别丢了。3.2 用 find_blobs 切出候选数字阈值、面积、合并策略灰度图里深色字符在浅色背景上会形成一个低灰度区域。find_blobs 的阈值区间选低灰度段比如(0, 75)具体上限看现场背景。面积过滤用来挡掉噪点宽高比过滤用来挡掉横条和竖条。merge 和 margin 要谨慎mergeTrue 可以把同一个数字的断裂块合并但 margin 太大也会把相邻数字粘在一起。下面这段是候选框生成的核心。THRESHOLDS [(0, 75)] MIN_PIXELS 120 MIN_AREA 120 MERGE True MARGIN 3 def keep_blob(b, img): x, y, w, h b.rect() if w 5 or h 12: return False if w img.width() // 2 or h img.height() // 2: return False ratio h / max(w, 1) if ratio 1.2 or ratio 6.0: return False return True逻辑说明find_blobs先按灰度阈值找连通区域pixels_threshold和area_threshold过滤太小噪点mergeTrue把邻近块合并margin3控制合并距离。keep_blob再做一次几何过滤宽度小于 5 像素、高度小于 12 像素的块直接丢宽或高超过画面一半的块多半是背景或大面积阴影高宽比小于 1.2 的偏横条大于 6.0 的偏细长噪点。参数上阈值上限75不是固定值背景很白时可以降到 60背景偏灰时可以升到 90MARGIN从 2 开始试字符间距很小就降到 1 或关闭 merge再用去重兜底。blobs img.find_blobs( THRESHOLDS, pixels_thresholdMIN_PIXELS, area_thresholdMIN_AREA, mergeMERGE, marginMARGIN ) candidates [] for b in blobs: if keep_blob(b, img): candidates.append(b.rect())这段代码把通过几何过滤的候选框收集到candidates。逻辑上先不排序也不识别只保留原始外接框。参数上pixels_threshold控制像素数下限area_threshold控制外接框面积下限两者同时用可以挡掉细碎反光如果现场数字很小两个值都要往下调但不要低于 50否则噪点会进来。3.3 从左到右排序、去重和 ROI 扩边候选框拿到后先按 x 坐标排序再做一次重叠去重。两个框重叠很多时保留面积更大的那个重叠很少但中心点很近时也按面积留一个。排序用中心点 cx 比左上角 x 更稳因为粘连合并后左上角会跳。ROI 扩边是为了让模板匹配留一点上下文模板匹配在紧贴字符边缘的框里容易丢分。def pad_rect(rect, pad, W, H): x, y, w, h rect x0 max(0, x - pad) y0 max(0, y - pad) x1 min(W, x w pad) y1 min(H, y h pad) return (x0, y0, x1 - x0, y1 - y0) def overlap(a, b): ax, ay, aw, ah a bx, by, bw, bh b x0 max(ax, bx) y0 max(ay, by) x1 min(ax aw, bx bw) y1 min(ay ah, by bh) if x1 x0 or y1 y0: return 0.0 inter (x1 - x0) * (y1 - y0) return inter / float(min(aw * ah, bw * bh)) def sort_dedup(rects, iou_th0.35): rects sorted(rects, keylambda r: r[0] r[2] / 2.0) out [] for r in rects: keep True for i, o in enumerate(out): if overlap(r, o) iou_th: if r[2] * r[3] o[2] * o[3]: out[i] r keep False break if keep: out.append(r) return sorted(out, keylambda r: r[0] r[2] / 2.0)逻辑说明pad_rect在四个方向扩边并裁到画面内返回(x, y, w, h)overlap计算两个框的交并比近似值分母取较小面积适合处理一个框套另一个框的情况sort_dedup先按中心 x 排序再逐个与已保留框比较重叠超过阈值就保留面积大的。参数上pad3是起点字符边缘有反光时加到 5iou_th0.35偏保守粘连严重时可以升到 0.5让去重更激进。rects [] for b in blobs: if keep_blob(b, img): rects.append(pad_rect(b.rect(), 3, img.width(), img.height())) rects sort_dedup(rects) for i, r in enumerate(rects): img.draw_rectangle(r, color255) img.draw_string(r[0], max(0, r[1] - 10), str(i), color255)这段代码把候选框扩边、排序、去重后画到图上draw_string标的是排序后的索引不是识别结果。逻辑上先扩边再排序可以让中心点更稳定。参数上color255在灰度图里是白色调试时看得清如果画面背景很白改画黑框加白底字别让调试框自己干扰判断。跑完这一步屏幕上应该每个数字一个框从左到右编号连续如果出现两个框套一个数字回到 3.2 调 merge 和面积过滤。4. 模板匹配还是轻量分类器1~8 数字识别的参数表与模板制作分割稳了之后逐字符识别只处理一个小 ROI。1~8 的字符类别少模板匹配的调试成本低是这份工程最自然的起点。模板匹配的核心不是“找一个模板”而是“让同一个数字在不同帧里稳定命中同时不让 6 和 8、1 和 7 互相抢”。这一章给出 find_template 的参数用法、模板制作流程以及什么时候该切轻量分类器。4.1 find_template 的四个参数和阈值阶梯OpenMV 的find_template在给定 ROI 里搜索模板返回匹配框或空。实际调参时阈值、step、ROI、模板尺寸四个量互相牵制。阈值太高字符稍微变形就漏阈值太低6/8/5 互相误命中。我一般用阈值阶梯先用高阈值找最确定的找不到再降一档避免一上来用低阈值把所有 ROI 都染成同一个数字。TEMPLATES {} for n in range(1, 9): TEMPLATES[n] image.Image(/digit_%d.pgm % n) THRESHOLD_LADDER (0.82, 0.72, 0.62) def classify_roi(img, roi): for th in THRESHOLD_LADDER: for n in range(1, 9): r img.find_template( TEMPLATES[n], th, roiroi, step1, searchimage.SEARCH_EX ) if r: return n, th return 0, 0.0逻辑说明先加载 1~8 八个模板classify_roi按阈值从高到低扫每一档里依次尝试八个模板命中就返回数字和当前阈值。参数上0.82是高置信档适合干净样本0.72是常用档0.62是兜底档低于 0.6 误检会明显增多。step1搜索更细但更慢QVGA 下 ROI 很小速度可以接受ROI 太大时把 step 提到 2。searchimage.SEARCH_EX是扩展搜索适合模板和现场有轻微尺度差的情况。不同固件返回字段可能有差异如果返回对象里带 score就按 score 选最高只返回匹配框时用阈值阶梯加模板顺序兜底。模板尺寸不要随手裁。模板高度最好和现场字符高度接近宽度按字符实际外接框裁四周留 1~2 像素背景。模板太大匹配对字符位置敏感模板太小背景噪声会进来。1~8 里 1 的宽度窄模板不能裁得太宽否则 1 会和 7 的竖笔混淆8 的模板要保留两个闭合区域裁掉上下任何一端都会掉分。4.2 1~8 模板怎么做采集、裁切、灰度化、命名模板制作分三步现场拍字符、按外接框裁切、统一成灰度 PGM 并按digit_1.pgm到digit_8.pgm命名。采集时用和第 3 章相同的曝光和增益不要用手机补拍也不要用不同焦距的图。下面这段脚本在 OpenMV 端抓一帧找最大候选框裁出 ROI 保存为模板每换一个数字重启脚本或按串口输入切换编号。import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time2000) sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) sensor.set_auto_exposure(False, exposure_us10000) digit_id 1 # 现场改成当前要采集的数字 img sensor.snapshot() blobs img.find_blobs([(0, 75)], pixels_threshold120, area_threshold120, mergeTrue, margin3) if blobs: b max(blobs, keylambda x: x.pixels()) x, y, w, h b.rect() pad 2 roi (max(0, x - pad), max(0, y - pad), w 2 * pad, h 2 * pad) digit img.crop(roi[0], roi[1], roi[2], roi[3]) digit.save(/digit_%d.pgm % digit_id) print(saved, digit_id, roi) else: print(no blob)逻辑说明脚本只抓一帧找像素数最大的 blob扩边 2 像素后裁切保存。参数上digit_id手动改成 1~8每采一个数字改一次pad2让模板边缘留一点背景匹配时更稳max(blobs, keylambda x: x.pixels())假设画面里只有一个待采数字如果同时有多个先遮挡其他字符。img.crop的参数顺序按手头固件的例程确认如果固件只支持 ROI 参数换成对应写法。模板保存后最好在 PC 上看一眼字符是否居中、有没有把相邻数字的边缘带进来、背景是否过亮。模板带进相邻数字的笔画后面 6/8 误判会很难查。模板命名必须和代码里的/digit_%d.pgm对上。1~8 八个文件放在 OpenMV 文件系统根目录或代码指定目录文件名不要带空格。模板统一用 PGM 灰度格式避免 JPEG 压缩在边缘产生振铃。模板数量不是越多越好先每个数字一套现场误判集中在 6/8 时再给 6 和 8 各补一套不同笔画粗细的模板代码里按列表遍历即可。4.3 模板匹配不够时切轻量分类器什么时候值得换出现下面三种情况模板匹配的维护成本会超过收益字符字体不固定今天印刷体明天手写体镜头距离会变字符像素高度在 10 到 40 之间跳背景复杂find_blobs 的分割框经常带进干扰笔画。这时候可以切轻量分类器输入仍然是分割后的单字符 ROI输出 1~8 的类别。OpenMV 端常见路径是加载轻量模型并对 ROI 做前向计算不同固件的 nn 接口名字不完全一样代码要以手头固件的例程为底改不要硬套别处的 API。训练侧的准备比模型本身更重要每个数字采集不同光照、不同位置、轻微旋转的样本统一裁成正方形并缩放标签只保留 1~8。样本里要专门放 6/8、1/7、3/5 这些易混对否则模型在训练集上很漂亮现场还是错。部署前先用第 6 章的回放集跑一遍确认模型在固定工位上的串准确率再决定是否替换模板匹配。模板匹配能跑通 90% 的固定字体场景轻量分类器不是必选项但字符变化大时继续在模板阈值上加补丁就是血泪经验。5. 多数字 1~8 的现场排查五类翻车与十分钟定位顺序本文还有配套的精品资源点击获取