首页 / 资讯中心 / 文章详情

OpenMV多数字识别1~8:字符分割与模板匹配工程实践

OpenMV多数字识别1~8:字符分割与模板匹配工程实践 ★ FEATURED ARTICLE
简介这份压缩包面向OpenMV与MicroPython嵌入式视觉初学者以及需要快速验证数字识别的开发者聚焦1至8多数字的模板匹配识别可用于智能仪表读数、简单字符检测等低功耗视觉项目的前期实验。包内共162个文件主体为160个pgm数字模板图覆盖1到8的不同样本另含1个openmv_disk配置文件和1个py识别脚本整体约158KB体积轻便便于放入OpenMV存储卡调试。已有2404人学习下载在入门实践中具有一定参考度。读者可获得已训练好的数字模板、可运行的识别脚本框架以及围绕灰度化、二值化、模板比对、数字定位与结果修正的实验素材借助这些素材能观察模板质量对识别稳定性的影响并据此调整阈值与匹配参数减少从零采集、训练模板的时间适合用作课程设计、竞赛原型与嵌入式视觉练手项目的起点。1. 从一块 OpenMV 说起这套「1~8 多数字识别」到底在识别什么产线上一块工装板七个工位每个工位贴一个直径不到两厘米的圆形标签上面印着一到八之间的某一个数字字体还不统一。要用一块 OpenMV 摄像头在三十厘米外一次性把这串数字读出来回传给主控做工序校验——这就是「openmv数字识别之多数字识别 1~8」这份资源要解决的实际问题。它不属于单字符识别那种「对准一个数字、按一下、出结果」的演示工程而是要把一帧画面里散落的多个数字先切出来、再逐个分类、最后按阅读顺序拼成字符串。适合已经跑通过 OpenMV 基础例程、想把视觉真正接到自己项目里的开发者也适合拿它当字符分割与模板匹配的教学样本。数字范围限定在 1 到 8没有 0 和 9这个细节后面会讲到它直接决定了模板集的构造方式。2. 技术选型与工程结构模板匹配、字符分割、ROI 三级流水线2.1 为什么多数字不能直接套单数字模板匹配很多人第一步会去翻 OpenMV 官方例程里的find_template演示把一张数字 1 的图存成模板对着画面一搜能出框就以为大功告成。真放到多数字场景里立刻翻车find_template一次调用只返回当前搜索区域内相似度最高的那一个位置它天然是个「单目标」接口。画面里有 1、3、6 三个数字你调三次、改三次阈值得到的还是同一个最像的那个另外两个永远出不来。这是一个接口语义问题不是调参能解决的。多数字识别必须在模板匹配之前插一层「字符分割」把整幅图拆成若干个互不重叠的小矩形再对每个小矩形单独做一次匹配。整体链路就变成三级预处理灰度化、曝光锁定、必要时做形态学去噪把画面稳定在一个可控的分布上。分割在感兴趣区域里找出每个数字的连通域包围盒按横坐标排序。分类对每个包围盒内部的子图与 1~8 八个模板逐一比对取相似度最高且超过阈值的那一个。这三级的顺序不能反。有人图省事想先全局扫一遍所有模板把每次find_template的结果收集起来去重——理论上可行实际上因为模板会在大图上滑动搜索同一个数字会被重复命中很多次去重逻辑写起来比分割还麻烦而且帧率会被拖垮。分割优先是工程上更省事的路子。资源本身是围绕这个三级结构组织的一份主程序负责调度一份模板目录放 1 到 8 的灰度小图外加若干张用于验证的实拍样张。它没有引入额外的算法库全部跑在 OpenMV 自带的image模块上这一点很重要——意味着它能直接烧进板子独立运行不依赖上位机。2.2 OpenMV 上的三种可选路线与取舍在这块板子上做数字识别常见做法有三条路各自的适用边界差别很大选错了后面全是坑。第一条是纯模板匹配也就是这份资源走的路。优点是零训练成本、改一个数字只要换一张模板图、结果可解释能画出匹配框看是哪一步错了。缺点是对字体和尺度敏感模板和实际成像的字号差一圈相似度就掉得厉害。1~8 这个范围恰好规避了一部分问题这几个数字的字形区分度相对高1 和 7、3 和 8 是最容易混的两组靠调阈值和加预处理基本能压住。第二条是 LBP 或 HOG 特征加最近邻分类。OpenMV 的image模块提供了find_lbp、find_features这类接口能提取旋转不变的特征描述子。它的泛化比裸模板匹配好字体换一换不至于全崩。代价是要自己攒训练集每个数字至少几十个样本而且 OpenMV 上做特征距离计算的速度不如一次模板卷积来得直接帧率会掉。如果你的现场字体经常变、或者标签是手写的值得往这条路上走。第三条是内置的神经网络模块加载一个量化后的.network模型把分割出来的子图缩放到固定尺寸喂进去。这是泛化能力最强的一条路但门槛也最高训练、量化、转换三步都在上位机完成模型大小要压在板子的内存预算内输入分辨率通常只能做到 28×28 或 32×32 这种量级太小的图对细笔画数字本身就是一种损失。新手直接上这条路往往卡在模型转换环节一整天出不来结果。我的建议是先用模板匹配把整条链路跑通、把分割逻辑调稳等到确认瓶颈确实在分类精度上再考虑换 NN。因为分割这一层是三条路共用的先把共用部分做扎实后面换分类器只是替换一个函数的事。路线训练成本泛化能力帧率适合场景模板匹配无弱依赖字体与尺度高印刷体固定、光照可控LBP/HOG KNN中需样本集中中字体略有变化内置 NN 模型高需量化转换强中低手写体、复杂背景2.3 资源目录与文件组织拿到手先别急着烧代码把目录摊开看一眼能省掉后面一半的困惑。典型的组织方式是这样digit_multi/ ├── main.py # 主程序采集 分割 匹配 输出 ├── config.py # 阈值、ROI、相似度门限等可调参数集中放这里 ├── digits/ # 模板目录 │ ├── 1.pgm │ ├── 2.pgm │ ├── ... │ └── 8.pgm ├── samples/ # 实拍验证图用于离线调试 │ ├── s01.bmp │ └── s02.bmp └── tools/ └── make_template.py # 从实拍图裁模板的辅助脚本模板用.pgm而不是.bmp或.jpg原因是 PGM 是无压缩的纯灰度格式OpenMV 解码它几乎不耗时间也不会有 JPEG 压缩带来的块效应干扰相似度计算。.bmp也能用但文件头解析在部分固件版本上会慢一点JPEG 则绝对不要用在模板上一个 8×8 的 DCT 块就能把细笔画的数字糊掉。config.py单独拆出来是个好习惯。现场调试的时候你会反复改二值化阈值、相似度门限、最小字符宽度这几个数全写在主程序里改一次要往下翻两百行。集中到配置文件里主程序from config import *引进来改完直接跑心智负担小很多。模板文件放内部 Flash 根目录还是 SD 卡取决于板子有没有插卡。内部 Flash 的读写速度快但空间有限八张小图加起来通常不到 50KB完全塞得下。路径上要注意内部 Flash 挂载在/SD 卡挂载在/sd代码里写相对路径digits/1.pgm时实际是相对当前工作目录解析的切换存储介质后记得同步改前缀否则就是一路OSError: [Errno 2] ENOENT。3. 环境与固件准备把 OpenMV 相机和 IDE 跑起来3.1 硬件与固件确认先确认板子型号和固件版本。OpenMV 的 H7 系列主控主频在 400MHz 以上跑这套流程比较从容老一些的 M7 板子也能跑但帧率要打折扣必须把分辨率降下来。固件版本直接决定了find_template的返回值结构不同版本之间返回元组的长度有差异写代码时用len(r)做个保护是必要的防御。判断固件版本的方式很简单连上 IDE 之后右下角状态栏会显示或者跑一行import omv print(omv.version) print(omv.machine)omv.machine会打出板子型号字符串omv.version是固件版本号。把这两个值记下来后面遇到玄学问题的时候第一件事就是确认它跟你参考的例程是不是同一个固件环境。我见过太多「照着例程抄报错报得莫名其妙」的情况最后发现是固件差了好几个大版本API 早就改了。传感器型号也值得确认一下。全局快门和卷帘快门在拍运动物体时表现差别很大如果标签是贴在传送带上的卷帘快门会出现果冻效应数字被拉斜模板匹配的相似度会莫名其妙地掉。这种情况要么换全局快门模组要么让运动在曝光期间停下来——后者在产线上通常更容易实现。3.2 IDE 连接与第一个灰度帧OpenMV IDE 走的是 USB 虚拟串口加 DFU 的组合插上板子之后左下角会出现连接按钮点一下就连上了。连不上的排查顺序是换一根能传数据的线不是所有 USB 线都带数据线芯、确认驱动装好了、确认没有别的程序占着串口。这一步没什么技术含量但确实是新手卡得最久的地方。连上之后先跑一段最小可运行代码把画面调出来import sensor, image, time sensor.reset() # 复位传感器清掉上一次的配置 sensor.set_pixformat(sensor.GRAYSCALE) # 灰度模式数据量是 RGB565 的一半 sensor.set_framesize(sensor.QVGA) # 320x240够用且帧率高 sensor.skip_frames(time2000) # 丢掉前 2 秒的自动调整不稳定帧 sensor.set_auto_gain(False) # 关自动增益锁定亮度 sensor.set_auto_whitebal(False) # 灰度下其实无用但保持习惯 sensor.set_auto_exposure(False, exposure_us8000) # 锁定曝光单位微秒 clock time.clock() while True: clock.tick() img sensor.snapshot() print(clock.fps())几个参数值得展开说。GRAYSCALE是所有字符识别的默认起点颜色在这个任务里没有任何信息量反而让每个像素多占一倍内存、多花一倍带宽。QVGA是 320×240做数字识别足够了除非数字在画面里只占十几个像素那说明镜头选得不对应该换焦距而不是硬拉分辨率。set_auto_gain(False)和set_auto_exposure(False)这两行是整个项目稳定性的地基。自动增益会在画面整体变暗时拼命往上拉导致同一张标签在两帧之间的亮度分布完全不一样二值化阈值就失效了。锁定之后画面亮度只跟环境光有关配合固定的补光一致性才有保障。exposure_us这个值要给多少取决于你的补光强度和镜头光圈8000 微秒是一个常见起点画面太暗就往上加运动模糊就用往下减。跑起来之后 IDE 右边会显示实时画面下面的串口区会刷帧率。如果 QVGA 下帧率能到 25 以上说明板子和固件都没问题可以继续往下走。3.3 模板素材的制作与命名规范模板的质量决定了这套方案的上限。制作流程我一般是这样走的用板子在最终安装位置、最终光照条件下拍一张包含数字的实拍图存成 bmp通过 IDE 的保存功能导出到 SD 卡或电脑上。在 IDE 里打开这张图用裁剪工具单独抠出每一个数字尽量紧贴笔画外沿四周留一到两个像素的空白。把裁剪结果统一缩放到同一个尺寸比如 20×28八个模板尺寸必须完全一致否则匹配时的尺度基准就乱了。保存为 PGM 格式命名用1.pgm到8.pgm跟数字本身对应方便代码里用循环加载。命名这一条看着琐碎实际影响很大。代码里加载模板一般写成for i in range(1, 9): templates[i] image.Image(%d.pgm % i)如果文件名是one.pgm、two.pgm你就得手写八行或者维护一个字典映射改起来烦。统一用数字命名循环加载三行搞定。尺寸统一这件事还有个隐藏要求模板尺寸必须小于实际分割出来的字符 ROI 尺寸。find_template的语义是「在 ROI 里找模板」如果 ROI 比模板还小它会直接抛异常。所以在做模板的时候要预估实拍图中数字的像素高度模板做得比它小一圈留出搜索余量。举例来说实拍图上数字高 40 像素模板就做到 30×22 左右这样在 40 像素高的 ROI 里滑动匹配才有空间。还有个细节模板的灰度分布要和实际成像匹配。抠模板的那张图如果是在强光下拍的模板整体偏亮换到弱光场景下相似度会系统性偏低阈值就设不上去了。稳妥的做法是拍三组不同光照下的样本各抠一套取中间那组或者干脆在补光固定之后只拍一次。4. 核心链路拆解从一帧灰度图到 1234 的完整代码4.1 预处理与 ROI 锁定预处理的目标不是把图变好看而是把「数字可能出现的区域」和「背景」在像素值上分开并且让这个分开的界限在时间上稳定。import sensor, image, time from config import THRESHOLD, ROI, MIN_CHAR_W, MIN_CHAR_H sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time2000) sensor.set_auto_gain(False) sensor.set_auto_exposure(False, exposure_us8000) # 只处理标签所在的区域减少无谓运算 ROI (40, 60, 240, 100) def preprocess(img): # 复制一份避免二值化之后原图被破坏后续还要在原图上做匹配 bin_img img.copy() # THRESHOLD 形如 (0, 90)表示把灰度 0~90 的像素拉成白色 bin_img.binary([THRESHOLD]) # 轻度闭运算把笔画中间的断裂点补上 bin_img.erode(1, threshold2) bin_img.dilate(1, threshold2) return bin_img这里有三个关键决策。第一是img.copy()而不是直接对img做binary()。二值化之后原图的灰度信息就没了而find_template在灰度图上匹配的效果比在纯黑白图上更细腻因为它用的是归一化互相关能利用灰度的渐变。所以流程是在原图上做分割定位在原图上做模板匹配二值图只用来算包围盒。第二是binary([THRESHOLD])的参数形态。它接受一个列表每个元素是一个(low, high)元组落在区间内的像素被置为白色区间外的置为黑色。如果你想反过来黑底白字的标签就写成invertTrue。现场调试时最有效的方法是先print(img.get_histogram().get_percentile((10, 90)))看看灰度分布再定阈值不要凭感觉瞎填。第三是erodedilate这对组合也就是开运算的逆序用法。这里先腐蚀再膨胀目的是去掉背景上的孤立噪点、同时把笔画内部的细微断裂连上。结构元大小设为 1threshold2表示邻域内至少有 2 个像素满足条件才保留对 320×240 的图来说这个强度比较温和不会把细的笔画比如 1 的竖线吃掉。如果你的标签印刷质量差、笔画断得厉害把顺序反过来先膨胀后腐蚀会更好能保住笔画。ROI 锁定这一步在产线上是必须的。整幅 QVGA 图有七万多个像素真正有意义的可能只有中间一块两百乘一百的区域。把 ROI 传进后续的find_blobs和find_template能省掉大量无谓的遍历。ROI 的四个数怎么定在 IDE 里对着实时画面数像素坐标IDE 的帧缓冲区视图鼠标悬停会显示坐标直接读出来填进去就行。别用「画面中心」这种模糊描述写死具体数值。4.2 字符分割find_blobs 与垂直投影两条路分割是这套工程的核心。思路是在二值图上找连通域每个连通域就是一个候选字符。def split_chars(bin_img, roi): blobs bin_img.find_blobs( [(255, 255)], # 找白色连通域 roiroi, # 只在感兴趣区域内找 pixels_threshold30, # 像素数下限滤掉噪点 area_threshold30, # 包围盒面积下限 mergeFalse, # 关键不合并相邻连通域 margin2 # 允许 2 像素的断裂被连起来 ) boxes [] for b in blobs: x, y, w, h b.rect() # 尺寸过滤太窄太矮的肯定不是数字 if w MIN_CHAR_W or h MIN_CHAR_H: continue # 宽高比过滤数字一般是瘦高的宽高比超过 1.2 的多半是噪声块 if w / h 1.2: continue boxes.append((x, y, w, h)) # 按横坐标排序保证输出顺序是从左到右 boxes.sort(keylambda r: r[0]) return boxesmergeFalse这个参数是多数字识别的分水岭。默认值是True意思是把相邻的连通域合并成一个大块——对色块追踪来说这是好事对字符分割来说这是灾难三个挨得近的数字会被合成一个宽矩形后面不管怎么匹配都出不来三个结果。设成False之后每个数字独立成块。margin2是个补偿手段。印刷体偶尔会有笔画断裂比如 4 的横竖交接处、8 的两个圆环之间的连接点断裂处像素值偏高二值化之后会被切断成两个块。给 2 像素的容差让find_blobs在判定连通性时把距离很近的块视为一体。这个值不能给太大给到 5 以上相邻的两个数字又会被粘起来等于白设了mergeFalse。尺寸和宽高比这两个过滤条件解决的是噪声问题。背景上的反光点、标签边缘的毛刺、CCD 的热噪点都会形成小连通域。pixels_threshold和area_threshold先卡一道宽高比再卡一道基本能干净。宽高比用w/h数字字形普遍是高的1 可能达到 0.38 会有 0.7 左右1.2 是一个安全的边界。如果现场条件是数字严重粘连、find_blobs怎么调都分不开还有一条备用路线垂直投影。def vertical_projection(bin_img, roi, gap_threshold3): x0, y0, rw, rh roi col_sum [] for i in range(rw): s 0 for j in range(rh): if bin_img.get_pixel(x0 i, y0 j) 0: s 1 col_sum.append(s) boxes [] start -1 blank 0 for i, v in enumerate(col_sum): if v 0: if start 0: start i blank 0 else: if start 0: blank 1 # 连续空白超过阈值认为一个字符结束 if blank gap_threshold: boxes.append((x0 start, y0, i - blank - start, rh)) start -1 blank 0 if start 0: boxes.append((x0 start, y0, rw - start, rh)) return boxes投影法的原理是把 ROI 每一列的白像素数量统计出来数字所在的列会有大量白像素字符之间的间隙列几乎为零。找到连续的空白区间就切开。它对「数字高度不一致」不敏感但对倾斜很敏感——标签贴歪三度投影出来的间隙就模糊了。而且这段代码是双重循环逐像素读在 QVGA 下的 ROI 上跑一次要几十毫秒帧率会掉。所以它是备用方案不是主路线。顺带说一句 1~8 这个范围的好处。没有 0 和 9就不存在「0 被分割成内外两环」「9 的下半部分和 0 混淆」这类问题连通域的数量和实际字符数量基本一一对应分割的成功率高很多。如果哪天需求扩展到 0~9分割逻辑里要额外处理内环那是一个不小的改动。4.3 单字符模板匹配与置信度打分拿到每个字符的包围盒之后逐个与八个模板比对。import image # 启动时一次性加载避免每帧都读文件 TEMPLATES {} for i in range(1, 9): TEMPLATES[i] image.Image(digits/%d.pgm % i) def classify(img, box, threshold0.62): x, y, w, h box # 搜索区域要比模板大一圈否则 find_template 会抛异常 pad 4 roi (max(0, x - pad), max(0, y - pad), w pad * 2, h pad * 2) best_digit -1 best_score 0.0 for digit, tpl in TEMPLATES.items(): r img.find_template(tpl, threshold, roiroi, step2, searchimage.SEARCH_EX) if not r: continue # 不同固件版本返回值长度不同做一下保护 score r[8] if len(r) 8 else threshold if score best_score: best_score score best_digit digit return best_digit, best_scorethreshold传进find_template是「低于这个相似度直接返回 None」而返回的最高分保存在元组的第 9 个位置索引 8。不同固件版本这个位置可能有出入所以用len(r) 8做保护取不到就用阈值兜底。这个细节不写进代码里换个固件跑起来就会IndexError调试的时候一脸懵。roi的 pad 是必须的。分割出来的包围盒是紧贴笔画的模板的尺寸通常比包围盒略大如果直接把包围盒当作搜索区域传进去find_template会判「搜索区域小于模板」并抛异常。给 4 像素的余量既解决了尺寸问题也允许匹配位置有轻微偏移。step2是搜索步长。默认值是 1逐像素滑动设成 2 就是每两个像素试一次。对 QVGA 图来说设成 2 能把匹配耗时砍掉四分之三精度损失很小因为数字的笔画宽度普遍在 3 像素以上步长 2 不会跳过整个笔画。如果你把分辨率提到 VGAstep可以进一步给到 4。searchimage.SEARCH_EX是穷举搜索模式会遍历 ROI 内的所有位置。另一个选项是SEARCH_DS爬山式搜索快但容易陷入局部最优。数字识别这种模板和目标长得很像的场景用 EX 更稳。这个classify函数逐个模板调find_template一轮下来八次调用。在 QVGA、ROI 约 40×50 的情况下单次调用大概 2 到 4 毫秒八个模板加起二十多毫秒加上分割的开销整体帧率落在 15 到 20 之间。想再快可以把模板按宽高比先分组先用宽高比粗筛掉一半不符合的模板只对剩下的做匹配。这一招在很多项目里能再提 30% 的帧率。4.4 结果排序、拼接与去抖分割阶段已经按横坐标排过序拼接就是字符串拼一下def read_number(img, roi): bin_img preprocess(img) boxes split_chars(bin_img, roi) if not boxes: return , 0.0 result total_score 0.0 valid 0 for box in boxes: digit, score classify(img, box) if digit 0: continue # 这个字符没认出来跳过而不是用占位符 result str(digit) total_score score valid 1 avg total_score / valid if valid else 0.0 return result, avg注意这里对「没认出来」的处理是跳过而不是填入某个默认值。填充默认值会让结果字符串的长度和实际数字个数对不上主控那边做长度校验时就会误判。跳过的代价是可能漏掉一个字符但至少不会产生错误的数字。单帧结果不能直接用一定会抖。光照闪一下、标签轻微移动、传感器噪声都会让某一帧的相似度掉到阈值以下。常见的做法是滑动窗口投票维护最近 N 帧的结果取出现次数最多的那个。from collections import deque HISTORY deque(maxlen5) def stable_read(img, roi, min_votes3): text, score read_number(img, roi) HISTORY.append(text) if len(HISTORY) 3: return None # 统计最近若干帧里出现频次最高的结果 counts {} for t in HISTORY: if t : continue counts[t] counts.get(t, 0) 1 if not counts: return None best max(counts, keycounts.get) if counts[best] min_votes: return best return None窗口长度 5、最少票数 3是响应速度和稳定性之间的平衡点。窗口太长比如 10 帧会导致结果滞后产线上标签已经换了输出还停在旧值上窗口太短3 帧要求 2 票抗噪能力不够。这个值要根据你的实际帧率和标签切换频率调帧率 20 的情况下5 帧大约是 250 毫秒的延迟一般可以接受。还有个细节投票的键用的是完整结果字符串而不是单个字符。因为一个字符认错往往是整帧一起错光照突变按整串投票比按单字符投票更能反映「这一帧是否可信」。如果某个字符频繁出错那是模板本身的问题回去换模板不要指望投票能救。5. 常见问题排查多数字识别最容易翻车的五个地方5.1 ROI 比模板还小find_template 直接抛异常现象是程序跑起来没几帧就报OSError: ROI is smaller than template或者干脆进不去while循环。这个问题在模板换了一批、或者 ROI 调小之后突然出现很迷惑人。原因是find_template的语义要求搜索区域能完整容纳模板。分割出来的包围盒是紧贴笔画的而模板在制作时四周留了空白模板的宽高往往比包围盒大几个像素。直接拿包围盒当 ROI 传进去尺寸必然不够。解决方式是给 ROI 加 padding就像 4.3 里写的那样四周各扩 4 到 6 像素同时用max(0, ...)防止越界。更彻底的做法是在启动时做一次断言把模板尺寸和最小允许的 ROI 尺寸比一下不满足就直接在串口打印红字别等跑到一半才崩。这套检查我后来加进了所有视觉项目里省了太多在现场抓耳挠腮的时间。5.2 白天能认、晚上全错光照与曝光现象是调试时在工位上一切正常装到现场第二天早上开始识别率从 95% 掉到 40%画面上看数字还是清楚的。这是最典型的玄学问题本质是曝光和补光没有锁死。原因是set_auto_exposure和set_auto_gain如果保持默认开启板子会根据画面平均亮度自动调整参数。环境光从窗外来一点变化整幅图的灰度分布就平移了你在调试时定的二值化阈值(0, 90)就切在了不该切的位置笔画和背景混在一起分割直接失效。解决方式有三层。第一层是代码里强制锁死set_auto_gain(False)和set_auto_exposure(False, exposure_us...)这一步 3.2 已经做了。第二层是物理补光加一圈恒定亮度的环形光源让环境光的变化影响不到标签本身这是最有效的办法花几十块钱解决 80% 的问题。第三层是阈值自适应每次分割前用img.get_histogram().get_percentile((15, 85))动态算一个阈值区间传进binary()代价是每帧多几毫秒但能扛住缓慢的光衰。三层配合着上稳定性完全不一样。5.3 数字粘连被当成一个 blob现象是明明有三个数字只识别出一个而且这个结果的相似度特别低。用 IDE 把boxes画出来一看一个宽矩形横跨了三个字符。原因是find_blobs的merge参数默认是True相邻连通域会被合并。或者虽然设了False但margin给得太大把本该分开的块粘在了一起。还有一种情况是标签印刷时两个数字之间的空隙太小物理上就连着。解决方式分两步走。先确认代码里mergeFalsemargin不要超过 3。如果问题还在说明物理粘连这就得靠形态学分了在二值化之后、find_blobs之前做一次竖向的腐蚀把竖直方向的连接切断再膨胀回来。结构元要选竖向的OpenMV 的erode支持传尺寸参数竖向腐蚀用erode(1, 2)这类写法具体参数得在实拍图上试。极端情况下回到 4.2 的垂直投影法投影对粘连的容忍度比连通域高因为它找的是「空列」而不是「连通的块」。5.4 顺序错乱与漏识别现象是三个数字认出来了但顺序是「3 1 6」而不是「1 3 6」或者中间某个数字被跳过输出变成「16」。这个问题在标签倾斜或者数字间距不均的时候特别容易出现。顺序错乱的原因是find_blobs返回的顺序是按连通域面积或者发现顺序排的完全不保证从左到右。解决只有一条路拿到boxes之后显式按x坐标排序代码里那行boxes.sort(keylambda r: r[0])不能省。有人按y排那是处理竖排文本的做法横向排列的标签用不上。漏识别的原因通常是尺寸过滤太狠。MIN_CHAR_W设成了 12而实际成像上数字 1 的宽度只有 8 个像素直接被if w MIN_CHAR_W: continue干掉了。1 是所有数字里最窄的调参的时候一定要拿它当基准把MIN_CHAR_W卡在比 1 的实测宽度小一点的位置。宽高比过滤同理1 的宽高比能到 0.25如果你把下限设成 0.41 就永远出不来。5.5 帧率掉到个位数现象是调试时流畅加了几个后处理之后帧率从 18 掉到 5画面卡成幻灯片投票窗口也失效了因为帧间隔变长5 帧的延迟变成了一秒多。原因通常有三处。一是分辨率没降下来QVGA 是上限VGA 下所有算子耗时翻四倍。二是find_template的step1逐像素滑动在 ROI 稍大时非常慢。三是每个字符都对八个模板全量匹配没有做筛选。解决方式按性价比排序先把step改成 2 或 4这一条就能省掉一半时间再把分辨率降到 QVGA 以下的 QQVGA160×120如果数字在画面里够大这个降级完全无感最后加宽高比粗筛把明显不符合的模板先排除只对候选集做匹配。还有一个容易被忽略的点print本身很慢串口输出在高速循环里会明显拖帧调试信息要改成每 10 帧打一次。6. 二次开发改造加串口输出、投票去抖和 NN 兜底之后把主链路跑通之后剩下的都是改造。我按性价比从高到低排了几条都是实际项目里验证过有收益的。第一条是 UART 输出对接主控。OpenMV 的串口用法很直接接在板子上的 UART3 或者通过 USB 虚拟串口from pyb import UART uart UART(3, 115200, timeout_char1000) def send_result(text): if text is None: return # 加帧头帧尾主控侧才好做校验和 frame $%s#\r\n % text uart.write(frame)关键是加帧头帧尾。裸发数字串主控那边收到半截数据或者噪声没法判断边界。$...#这种成对的分隔符是最省事的做法主控解析时找$开始、#结束中间的内容做长度校验不可靠的帧直接丢。协议不要设计得太复杂产线环境下的串口误码率比实验室高得多。第二条是扩大识别范围。1~8 扩展到 0~9 不是加两张模板就完事。0 有内外两环find_blobs会把内环单独算成一个连通域分割结果里就会多出一个「洞里的小块」需要在过滤阶段把被其他 blob 完全包含的块剔除掉。9 的下半部分是个闭合环上半部分是一条竖线同样的包含关系问题。处理方式是遍历boxes如果某个 box 被另一个 box 的矩形完全包含就丢掉它。这段逻辑不长但漏了就会稳定输出多余的字符。第三条是 NN 兜底。当模板匹配对某个字符的置信度连续多帧低于 0.5说明这个字形确实超出模板的覆盖范围了。这时候可以切到内置的神经网络分类器做二次判定import nn net nn.load(/model/digit8_quant.network) def nn_classify(img, box): x, y, w, h box sub img.crop(roi(x, y, w, h)).resize(28, 28) out net.forward(sub) # out 是各类别的分数列表取最大值下标 scores list(out[0]) idx scores.index(max(scores)) return idx if max(scores) 0.55 else -1模型输入尺寸必须和训练时一致28×28 是常见选择crop之后resize一步不能少。模型文件放 SD 卡内部 Flash 空间通常不够放量化后的网络。NN 兜底只在置信度低的时候触发不参与主流程这样对帧率的影响可以控制住。验证方法上我习惯做两套测试。一套是离线回放把samples目录里的实拍图逐张喂进去统计每个数字的召回率和误识率出一张混淆矩阵看 1/7、3/8 这两组到底混到什么程度。另一套是在线压力测试标签连续切换五十次记录每次输出的延迟和正确率看投票窗口在快速切换时会不会滞后。这两套跑完才敢往现场装。从那以后我每次做这类视觉项目都会先把「分割对了没」和「分类对了没」拆成两个独立的调试开关分开放串口打印绝不把两者混在一起看。因为一旦结果错了你永远分不清是切歪了还是认错了而这个区分能省掉一晚上的时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站