简介围绕 OpenMV 平台的多数字识别学习包面向嵌入式机器视觉初学者、电子设计竞赛参与者及智能仪表读数等场景的开发者重点解决在低功耗硬件上识别 18 数字并定位多个数字区域的问题。压缩包共 162 个文件约 158KB主体是 160 个覆盖数字 1 至 8 不同样本的 pgm 模板图像另有 1 个 py 运行脚本和 1 个 openmv_disk 标识文件可直接配合 OpenMV 调用已训练模板。内容覆盖模板匹配相似度比较、灰度化与二值化预处理、数字边界检测与分割以及相邻数字误判修正等关键环节有助于理解从单数字到多数字的完整处理链路。已有 2404 人学习适合用来对照模板组织方式、调试匹配阈值和梳理项目实现思路也可作为智能仪表、车牌区域等视觉任务的入门参考。1. 从一块档位盘说起OpenMV 多数字识别要解决的是“连读”而不是“认字”把写着 18 的档位盘推到镜头前你要的其实不是一个“这是几”而是一串“1-3-5-8”而且顺序不能乱。单数字的 OpenMV 数字识别在网上能翻到不少例程可画面里一旦同时挤进两三个数字问题立马变成另一回事粘连的怎么划开、零散的怎么按从左到右排好、1 和 7 怎么不打架。这份“多数字识别 18”工程就是冲着这件事去的它把八个字形的分割、排序、分类、串口输出串成一条能在板子上跑完的链路。适合两类人手里有 OpenMV 想先跑通再改的新手和要做编号读数、档位判断这类小项目、缺一副现成骨架的开发者。2. 技术选型与识别链路为什么最后落在“分割 单字分类”上2.1 三条可选路线各自适用的场景在 OpenMV 上做数字识别能走的路线其实不止一条先想清楚后续迭代的空间比一上来就写 find_blobs 更省事。路线核心 API优点主要代价适合谁阈值分割 模板匹配find_blobs/find_template不用训练、可解释、改一个模板立刻见效对光照和字体敏感尺度变了要重做模板固定字体、固定工位、18 这种小字符集阈值分割 TFLite 分类tf.classify抗光照漂移、可扩充到 09 与字母要离线训练、模型文件占 Flash、单帧耗时升到几十毫秒字符集会变、现场光不稳AprilTag / 二维码类find_apriltags定位稳、误识率极低必须改造被识别物贴标签能控制标签、不要求识别真实字形这份工程选的是第一条灰度阈值切出字符块再对每个 ROI 做模板匹配。原因很直接——18 只有八个类别字形统一模板匹配的匹配分在 0.75 以上就很稳而且调试全程可解释分数低了你去换一张模板切歪了你去调pixels_threshold。用神经网络当然也能做但为了八个字形去训一个模型、再压到板子能跑的尺寸投入产出不太划算。常见做法是先用模板匹配把流程和机械结构跑顺等字符集扩到十几个、或者现场换了一批不同字体的物料再考虑切到tf路线这时候前面那套分割和排序代码一行都不用改只换最后一步分类器。真要走 NN 路线OpenMV 固件里是这么调的接口和模板匹配很对称import tf # 模型要提前转成 .tflite 并拷进 SD 卡或 Flash net_path /digit_1to8.tflite labels [1, 2, 3, 4, 5, 6, 7, 8] def classify_by_nn(img, box): # roi 决定模型看到的是哪一块尺寸必须与训练时一致 res tf.classify(net_path, img, roibox) if not res: return ? scores res[0].output() # 每个类别的置信度顺序同 labels idx scores.index(max(scores)) return labels[idx] if scores[idx] 0.6 else ?roi传进去的是(x, y, w, h)元组模型内部会把它归一化到训练分辨率所以切出来的框长宽比要尽量和训练样本一致否则置信度会整体往下掉一截。res[0].output()返回的是浮点数组max()拿到的是最高分0.6这个门限是经验值光好的时候可以压到 0.5光差的时候抬到 0.7 更保险。2.2 一条完整链路从一帧图到一串字符串流程压成一句话就是采图 → 阈值分割 → 过滤与合并 → 从左到右排序 → 间隙分组 → 单字分类 → 拼串 → 串口发出。每一步都有它存在的理由少一步现场就出问题。采图这一步看着简单坑最多。sensor.set_framesize()决定了后面所有参数的尺度——QQVGA160×120跑得快但字符高度只有十几个像素模板匹配的分数上不去QVGA320×240字符能到三四十像素匹配稳但单帧处理时间翻倍。18 这种识别距离固定的场景我一般直接上 QVGA把余量留给匹配精度帧率掉到 1520 fps 对读数类应用完全够用。分割用的是find_blobs灰度图上找暗色字符阈值区间大概落在(0, 90)。这里有个容易忽略的点merge参数一旦设为 True相邻的两个数字会被合并成一个大 blob多数字识别当场退化成单数字识别。所以多字符场景下merge必须关掉靠后面的间隙分组来处理“哪些字符属于同一串”。排序和分组是这份工程区别于单数字例程的核心。find_blobs返回的顺序是按面积或扫描顺序来的跟空间位置没关系不排序直接拼出来的串可能是“5-1-7”。按x()升序排一遍再按相邻框之间的水平间隙切组一串字符就成型了。分类是最后一步。每个字符框单独裁出来做模板匹配返回一个相似度和一个标签相似度低于门限的就记成?宁可报错也别硬猜——读数类应用里猜错比读不到危险得多。2.3 硬件与运行环境跑起来之前先对齐这几项板子方面OpenMV 家族里带 H7 的型号处理 QVGA 灰度图明显更从容M7 也能跑只是帧率会低一些。镜头用标准 2.8mm 就够识别距离在 1030cm 之间调整如果被识别物很小换 3.6mm 或加近摄环把字符在画面里放大到 30 像素以上这是匹配分数能否稳定在 0.8 的分水岭。固件版本建议统一IDE 里的“工具 → 固件升级”把板子刷到较新的稳定版避免find_template的返回值在旧固件和新固件之间出现差异——老版本返回元组新版本返回带.similarity()的对象代码里最好做兼容。供电上别用电脑 USB 口边调试边驱动负载USB 供电电流一波动图像亮度会跟着抖阈值就飘了。串口输出端接主控时注意共地TX/RX 交叉接。提示调试阶段先把sensor.set_auto_gain(False)和set_auto_whitebal(False)关掉让曝光和白平衡固定下来。自动增益在灰度图上的表现就是“物体一移动整体灰度跟着变”阈值区间会像玄学一样时灵时不灵。3. 目录结构与核心模块拆解分割、排序、分类各自怎么落地3.1 一份能直接改的目录长什么样工程按职责拆成几个文件主循环只负责调度算法细节各自待在模块里改一处不会牵动全身openmv-digit/ ├── main.py # 主循环采图 - 识别 - 输出 ├── config.py # 阈值、门限、串口波特率等全部可调参数 ├── digit_seg.py # 分割find_blobs 过滤 按 x 排序 间隙分组 ├── digit_cls.py # 分类模板匹配 / NN 二选一 ├── uart_out.py # 串口封帧与发送 ├── templates/ # t1.pgm ~ t8.pgm └── tools/ ├── make_template.py # 在板子上采集模板 └── calib_thresh.py # 阈值标定辅助脚本config.py单独拎出来是有血泪教训的阈值、面积门限、匹配分数这些参数一旦散落在各个文件里现场调一次就要翻五个 .py改完还容易漏。集中到一个文件之后标定流程就是“打开 config.py → 改两个数 → 保存 → 复位运行”。3.2 数字分割find_blobs 的参数该怎么定分割的质量直接决定后面所有环节的上限切歪了再好的分类器也救不回来。# digit_seg.py import image def segment(img, th(0, 90)): 在灰度图上找出所有可能是字符的块返回按 x 升序排好的框 blobs img.find_blobs( [th], area_threshold100, # 整个框的面积下限滤掉噪点 pixels_threshold100, # 实际像素数下限比 area 更严格 mergeFalse, # 多数字识别必须关掉否则相邻字符会粘成一个 margin8 # mergeFalse 时 margin 基本不起作用留着方便切回单字场景 ) boxes [] for b in blobs: w, h b.w(), b.h() if not (14 h 90): # 字符高度区间按实际画面调 continue ratio w / h if not (0.12 ratio 1.1): # 1 很窄8 接近方形超出这个范围多半是噪点或整行 continue if b.pixels() 0.25 * w * h: # 填充率太低说明是个空心框或边框 continue boxes.append((b.x(), b.y(), w, h)) boxes.sort(keylambda t: t[0]) # 从左到右排序是拼串正确的前提 return boxesarea_threshold和pixels_threshold的区别值得说清楚前者看的是外接矩形的面积后者数的是真正被判定为前景的像素个数。数字“1”的像素数很少但外接矩形不窄只卡area_threshold容易把 1 和 7 漏掉两个一起卡更稳。高度区间14 h 90是按 QVGA 下字符高度大约 3060 像素估的实际部署时把镜头怼近一点、字符占满视野高度的一半这个区间可以收窄到30 h 70过滤能力立刻上一个台阶。ratio的上限设 1.1 是为了挡掉横向的长条噪点下限 0.12 是给“1”留的余量。如果你发现 1 还是被滤掉别急着改这个值先看pixels_threshold是不是太高了——细笔画的 1 像素数本来就少。3.3 分组与拼串把零散的框还原成一串数字排好序的框还不能直接拼因为画面里可能同时存在两串数字比如左边一个“12”、右边一个“34”。用相邻框之间的水平间隙来切组是最朴素也最有效的办法# digit_seg.py续 def split_groups(boxes, gap14): 按水平间隙把排好序的框切成若干组每组代表一串连续数字 groups, cur [], [] for b in boxes: if cur: prev_right cur[-1][0] cur[-1][2] if b[0] - prev_right gap: # 间隙超过阈值认为换了一串 groups.append(cur) cur [] cur.append(b) if cur: groups.append(cur) return groupsgap这个参数要按字符宽度来定经验值是单字平均宽度的 0.60.8 倍。字符间距本来就小gap设大了会把“12”切成两个单字设小了又会把“12 34”粘成一串。标定方法很土但有效把实际物料摆到镜头前打印每个相邻框的间隙值取“字符内间隙最大值”和“字符间间隙最小值”的中位数。分组之后的拼串就是一次遍历遇到分类失败的字符填?让上位机自己决定怎么处理# main.py节选 from digit_seg import segment, split_groups from digit_cls import classify import uart_out while True: img sensor.snapshot() boxes segment(img, THRESH) groups split_groups(boxes, GAP) results [] for g in groups: s .join(classify(img, b) for b in g) results.append(s) for b in g: # 调试期把框画出来肉眼确认切得对不对 img.draw_rectangle(b, color255) uart_out.send(results)调试期那两行draw_rectangle千万别省。多数字识别出的问题八成能在图上直接看出来——框切多了、切少了、两个数字共用一个框肉眼一扫就知道该调哪个参数比盯着串口输出猜快得多。3.4 单字分类模板匹配的实现与门限设定分类这一步的输入是单个字符框输出是一个字符标签。# digit_cls.py import image TEMPLATES {} for i in range(1, 9): TEMPLATES[str(i)] image.Image(/t%d.pgm % i, copy_to_fbFalse) def _score(r): 兼容不同固件老版本返回元组新版本返回对象 try: return r[4] except (IndexError, TypeError): return r.similarity() def classify(img, box, thr0.72): x, y, w, h box roi (x, y, w, h) best_label, best_score ?, thr for label, tpl in TEMPLATES.items(): r img.find_template(tpl, thr, roiroi, step2, searchimage.SEARCH_EX) if r: s _score(r) if s best_score: best_label, best_score label, s return best_labelcopy_to_fbFalse是为了不让模板占用帧缓冲区八张模板全塞进 FB 会直接把可用内存吃光采图时报内存错误。step2表示搜索时每 2 个像素采样一次速度约提升一倍精度损失很小要极致精度可以设 1代价是单帧耗时明显上升。searchimage.SEARCH_EX是穷举搜索比默认的菱形搜索更不容易陷入局部最优八个模板挨个匹配本来就快没必要在这省。thr0.72是初始门限实际调到多少要看你的字体和光照。我的习惯是先设 0.6 跑一遍把串口打出来的分数都记下来取“正确匹配的最低分”和“错误匹配的最高分”的中点作为门限。如果这两个分数区间重叠了说明模板质量不行得回去重做模板而不是继续调门限。4. 上机标定让 1 到 8 在真实光照下稳定出结果4.1 阈值标定别靠眼睛猜灰度区间阈值区间拍脑袋定是这类项目最常见的翻车点。同一个(0, 90)在实验室日光灯下能切出干净的字符搬到窗边就只剩一半。标定的正确姿势是先把灰度分布打出来看# tools/calib_thresh.py import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time1500) sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) while True: img sensor.snapshot() hist img.get_histogram() # 打印 10% / 50% / 90% 分位点的灰度值看清亮暗分布 print(p10%d p50%d p90%d % (hist.get_percentile(0.1), hist.get_percentile(0.5), hist.get_percentile(0.9))) # 用当前阈值画框直观确认切得干不干净 for b in img.find_blobs([(0, 90)], pixels_threshold100, mergeFalse): img.draw_rectangle(b.rect(), color255) time.sleep_ms(200)看 p10 和 p50 之间的差距如果背景是浅色、字符是深色p10 附近就是字符的灰度p50 是背景。阈值上限设在两者中间偏背景一侧比如 p1030、p50150上限就取 8595。字符和背景灰度差不到 40 的时候这个方案本身就不可靠了得从光源下手而不是硬调阈值。光源这块投入产出比最高加一圈漫反射白光或者用一侧打光制造阴影对比比换算法便宜得多。我一般会在镜头周围贴一圈遮光棉把环境光的干扰挡掉大半。4.2 模板库怎么造尺寸、命名、样本量模板的质量决定了分类的上限做模板这一步值得多花半小时。# tools/make_template.py import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QVGA) # 必须和 main.py 里的帧尺寸一致 sensor.skip_frames(time1500) for i in range(1, 9): print(请把数字 %d 摆到画面中央然后在终端按回车 % i) input() img sensor.snapshot() # 手动指定裁剪区域保证每张模板尺寸严格一致 roi (110, 80, 60, 80) img.crop(roiroi).save(/t%d.pgm % i) print(已保存 t%d.pgm % i)几个硬性要求模板的帧尺寸必须和运行时的帧尺寸一致step和search的默认行为才对得上模板分辨率要尽量贴近实际 ROI 尺寸差太多匹配分数会系统性偏低裁剪框里要留一点边距模板贴得太紧字符稍有偏移就匹配不上。样本量上每个数字采 1 张能跑但现场光照有两三种状态时最好每个数字采 3 张取平均或者干脆给每个数字建多个模板取最高分。代价是内存和耗时八个数各三张就是二十四个模板QVGA 灰度图每张 76KB加起来 1.8MBH7 扛得住M7 就要算一下了。命名规范上别用1.pgm这种改成t1.pgm加前缀避免和工具生成的临时文件重名。4.3 串口输出与上位机验证识别结果最终要交给主控或者上位机串口封帧这个环节看着简单实际最容易出玄学问题上位机收到的字符串时对时错查半天发现是粘包。# uart_out.py from machine import UART uart UART(3, 115200, timeout_char100) def send(results): results 是字符串列表用 | 分隔多串数字整体加 # $ 包头包尾和一个校验字节 body |.join(results) chk 0 for c in body: chk ^ ord(c) # 简单异或校验够用 frame #%s*%02X$ % (body, chk) uart.write(frame.encode())timeout_char100是防止发送时阻塞太久串口速率低的时候这个值要往上调。#和$这对包头包尾让上位机可以做状态机解析中间夹一个异或校验粘包和错帧都能被挡下来。上位机侧按“找到#开始收收到$结束校验通过才采用”的逻辑写几十行就够。波特率先用 115200 验证稳定之后如果数据量上去了比如多组数字同时输出再降到 57600 换稳定性。别一上来就上 921600线长一点就开始丢字节。5. 常见问题排查多数字场景下最容易踩的五个坑5.1 分割类问题粘连、漏切、断笔现象一两个挨得近的数字被识别成一个字符输出里少了一位。原因多半出在find_blobs的merge参数没关或者margin设得太大把两个 blob 强行合并了。解决是mergeFalsemargin归零如果关了 merge 还是粘那就是真的物理粘连得引入垂直投影切分——统计每一列的前景像素数在低谷处把框劈开。现象二数字“1”频繁漏识别其他数字都正常。这是窄字符的典型症状。pixels_threshold按宽字符设的细笔画的 1 像素数达不到门限就被滤掉了。解决是把pixels_threshold降到 6080同时用宽高比w/h的下限来挡噪点两个条件配合比单纯调面积门限稳得多。5.2 识别与通信类问题分数偏低、光照漂移、串口错帧现象三八个模板的匹配分普遍掉到 0.5 以下但肉眼看着切得挺好。先查帧尺寸。模板是在 QVGA 下采的运行时main.py里写成了 QQVGA尺度对不上分数必然崩。对不上的时候不必重做模板把运行时的set_framesize改回来就行。其次查step设成 4 以上时精度损失明显。现象四上午调好的阈值下午同一套物料识别率掉一半。自动增益没关。sensor.set_auto_gain(False)和set_auto_whitebal(False)这两行必须在skip_frames之后、进入主循环之前执行。还有一种是外部光源变化比如下午阳光斜射进来这种只能靠加遮光罩解决软件层面补不回来。现象五上位机偶尔收到半截字符串或者两帧粘在一起。没有包头包尾或者校验缺失。按 4.3 的#...*XX$格式封帧上位机做状态机解析粘包立刻能定位。另外检查一下波特率115200 长距离传输时容易出现位错降到 57600 会好很多。注意调试时养成“先看图再改参数”的习惯。把find_blobs找到的框画在图上同时串口打分数哪个环节出的问题一目了然。只盯着最终输出猜原因效率差十倍。6. 二次开发从 18 扩到全字符集以及接到主控上这套骨架跑通之后扩展方向基本有三条按投入从小到大排改造方向要动的地方工作量风险点扩到 09 或加字母templates/加图range(1,9)改范围半小时0 和 O、6 和 9 容易混得加宽高比辅助判断模板匹配换 TFLite只换digit_cls.py分割代码不动半天到一天模型尺寸和帧尺寸要对齐Flash 空间要够接主控做闭环uart_out.py加协议主控侧写解析半天通信超时和重发机制要设计好扩字符集的时候有个坑得提前说classify里是遍历所有模板取最高分模板从 8 张涨到 36 张之后单帧耗时会线性涨上去。这时候要么给每个字符建索引、先用宽高比粗筛一遍要么老老实实切到 TFLite——模型推理的耗时基本固定不随类别数线性增长。真正做产品的话我建议再加一层多帧投票连续采 5 帧同一串数字出现 3 次以上才输出。代价是响应延迟增加 200ms 左右换来的稳定性提升非常明显尤其是被识别物轻微晃动的时候。投票逻辑不复杂用一个长度 5 的列表当滑动窗口统计众数就行。还有一个容易被忽略的改造点把config.py里的参数做成可以串口下发的。现场调试时不用带着电脑改代码主控发一条SET TH 0 95就改完了。这套东西加上去之后现场调参从“改代码 → 重新下载 → 复位”变成“发条指令”效率完全不是一个量级。从那以后我每次动帧尺寸或者换镜头都会先把tools/make_template.py重跑一遍重新采模板再开始调阈值——跳过这一步直接调参数最后发现是尺度不匹配前面的功夫全白费。希望这些能帮你少绕两圈。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?