ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

OpenMV多数字1~8识别:分割排序与模板匹配实践

OpenMV多数字1~8识别:分割排序与模板匹配实践

简介:面向嵌入式视觉入门与物联网项目开发者的OpenMV多位数字识别实践包,聚焦1至8这八个数字的模板匹配识别,可用于智能仪表读数、车牌字符读取等低功耗场景。资源内含一个Python主程序与160个pgm格式数字模板,另有OpenMV磁盘配置文件,共162个文件、压缩包约158KB,体量轻巧便于直接拷入开发板运行。程序围绕灰度转换、二值化、模板匹配与边界框定位展开,模板已预先训练完成,读者可在此基础上调整阈值、缩放比例与匹配相似度,体会检测分割与后处理修正的思路。已有2404人学习下载。对初次接触OpenMV图像处理的读者而言,这份素材省去从零采集样本、逐字训练模板的环节,能快速跑通多数字识别流程,并以此为骨架改写适配自己的仪表、编号识别任务。

1. 从单数字到 1~8:OpenMV 多数字识别工程先把“定位”讲清楚

这套 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(time=2000) # 关掉自动增益和自动白平衡,曝光按现场亮度改 sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) sensor.set_auto_exposure(False, exposure_us=10000) for i in range(20): img = sensor.snapshot() img.save("/sample_%02d.pgm" % i) time.sleep_ms(500)

这段代码的逻辑是先把相机拉到手动模式,再连续采 20 帧,间隔 500 ms,方便观察光照变化和字符边缘。参数上,GRAYSCALE减少颜色通道干扰,QVGA兼顾视野和帧率,exposure_us=10000是起点,现场偏暗就加,偏亮就减;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(time=2000) sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) sensor.set_auto_exposure(False, exposure_us=10000) 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 要谨慎:merge=True 可以把同一个数字的断裂块合并,但 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过滤太小噪点,merge=True把邻近块合并,margin=3控制合并距离。keep_blob再做一次几何过滤:宽度小于 5 像素、高度小于 12 像素的块直接丢;宽或高超过画面一半的块多半是背景或大面积阴影;高宽比小于 1.2 的偏横条,大于 6.0 的偏细长噪点。参数上,阈值上限75不是固定值,背景很白时可以降到 60,背景偏灰时可以升到 90;MARGIN从 2 开始试,字符间距很小就降到 1 或关闭 merge,再用去重兜底。

blobs = img.find_blobs( THRESHOLDS, pixels_threshold=MIN_PIXELS, area_threshold=MIN_AREA, merge=MERGE, margin=MARGIN ) 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_th=0.35): rects = sorted(rects, key=lambda 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, key=lambda r: r[0] + r[2] / 2.0)

逻辑说明:pad_rect在四个方向扩边并裁到画面内,返回(x, y, w, h);overlap计算两个框的交并比近似值,分母取较小面积,适合处理一个框套另一个框的情况;sort_dedup先按中心 x 排序,再逐个与已保留框比较,重叠超过阈值就保留面积大的。参数上,pad=3是起点,字符边缘有反光时加到 5;iou_th=0.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, color=255) img.draw_string(r[0], max(0, r[1] - 10), str(i), color=255)

这段代码把候选框扩边、排序、去重后画到图上,draw_string标的是排序后的索引,不是识别结果。逻辑上先扩边再排序,可以让中心点更稳定。参数上,color=255在灰度图里是白色,调试时看得清;如果画面背景很白,改画黑框加白底字,别让调试框自己干扰判断。跑完这一步,屏幕上应该每个数字一个框,从左到右编号连续;如果出现两个框套一个数字,回到 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, roi=roi, step=1, search=image.SEARCH_EX ) if r: return n, th return 0, 0.0

逻辑说明:先加载 1~8 八个模板,classify_roi按阈值从高到低扫,每一档里依次尝试八个模板,命中就返回数字和当前阈值。参数上,0.82是高置信档,适合干净样本;0.72是常用档;0.62是兜底档,低于 0.6 误检会明显增多。step=1搜索更细但更慢,QVGA 下 ROI 很小,速度可以接受;ROI 太大时把 step 提到 2。search=image.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(time=2000) sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) sensor.set_auto_exposure(False, exposure_us=10000) digit_id = 1 # 现场改成当前要采集的数字 img = sensor.snapshot() blobs = img.find_blobs([(0, 75)], pixels_threshold=120, area_threshold=120, merge=True, margin=3) if blobs: b = max(blobs, key=lambda 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,每采一个数字改一次;pad=2让模板边缘留一点背景,匹配时更稳;max(blobs, key=lambda 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 的现场排查:五类翻车与十分钟定位顺序

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

返回列表