做智能视觉小车这件事,说难不难,说容易也真容易让人崩溃。我第一次把OpenART摄像头装到小车底盘上时,以为只要接好线、烧个巡线程序就能跑,结果一开机屏幕全是花的,电机一转画面就开始抖动,标签识别时远时近——那一个星期我几乎把能踩的坑都踩了一遍。今天这篇就把我从零搭OpenART图像处理小车到实现增强现实标签追踪的完整经验写出来,覆盖硬件选型、图像处理算法、AR定位逻辑,以及官方文档里大概率不会告诉你的避坑细节。适合正准备入门视觉小车的爱好者,也适合已经拼好车但程序总跑不通的初学者。
1. 选型前先想清楚:你的视觉小车到底要干什么
1.1 先定功能,再定硬件
很多朋友一上来就问“用哪块板子好”,这个顺序其实是反的。智能视觉小车的核心不是板子,而是你希望它具备什么能力。同样是视觉小车,巡线、颜色分拣、AprilTag定位、数字识别,这几件事对硬件的需求差异很大。
先说巡线,这个需求最简单,只需要灰度摄像头加基本的二值化处理,几十帧的帧率就够用。如果要做颜色识别,就需要彩色图像和稳定的颜色空间转换,这时候对镜头色彩还原、白平衡设置就有要求。再往上走,做增强现实标签追踪,比如识别AprilTag后在小车屏幕上绘制虚拟路径或提示框,那就不光要识别图案,还得解算出标签相对摄像头的位置和角度,对图像分辨率、处理器算力、镜头畸变控制都会更敏感。
我的建议是,先在纸上把功能列表写清楚,再倒推硬件需求。我当时的目标很明确:完成巡线、颜色识别、AprilTag增强现实追踪这三件事,并且希望全程跑在嵌入式平台上,不依赖电脑。基于这个目标,选了以STM32H7为核心的OpenART平台,配套MicroPython开发环境,后续也不需要为换板子推翻重写。
1.2 OpenART和树莓派、OpenMV比,赢在哪
如果只做一两个固定场景的视觉识别,树莓派加USB摄像头也能实现,网上这类教程非常多。但真放到小车上,树莓派的启动速度、供电稳定性、图像采集延迟都是问题。小车是移动设备,电池供电,空间有限,树莓派4B峰值功耗动不动就七八瓦,还得配散热,对底盘压力很大。
OpenART这类嵌入式视觉平台的优势在于,它把摄像头、处理器、外设接口集成在同一块板子上,图像采集、处理、控制输出可以在几十毫秒内完成闭环。相比树莓派,它的功耗只有几瓦甚至更低,上电即可运行,不需要等待操作系统启动。相比OpenMV,OpenART在引脚资源、内存、扩展性上又更充裕,适合带电机驱动、舵机、显示屏、串口模块等外设的场景。
另外还有一个很实际的因素:开发体验。OpenART支持在OpenMV IDE里用MicroPython写代码,改一行、点一下运行、立刻看到效果,这对调参和debug来说太重要了。C语言做嵌入式视觉不是不行,但每改一个阈值都要重新编译烧录,效率低到让人怀疑人生。
| 对比项 | OpenART嵌入式视觉板 | 树莓派方案 | OpenMV方案 |
|---|---|---|---|
| 启动时间 | 秒级,上电即跑 | 十几秒到几十秒 | 秒级 |
| 功耗 | 低,适合电池供电 | 较高,需散热 | 低 |
| 开发语言 | MicroPython | Python为主 | MicroPython |
| 图像处理延迟 | 低,硬件直连 | 较高,链路长 | 低 |
| 外设扩展 | 引脚丰富 | 依赖扩展板 | 相对较少 |
| 适合场景 | 移动视觉、控制闭环 | 服务机器人、复杂AI | 教学、轻量视觉 |
2. 硬件搭建里的细节:底盘、摄像头和电源
2.1 差速底盘与电机驱动的连接逻辑
常见的小车底盘分为差速驱动和舵机转向两种。视觉小车我强烈建议选差速驱动,也就是左右各一个电机,靠两个轮子的速度差实现转弯。差速驱动的好处是转向半径小,可以实现原地旋转,调巡线PID时也更灵活,不会出现舵机转向那种“先减速再打方向”的顿挫感。
电机驱动板的选择上,L298N是很经典的方案,但它体积大、压降也大,电池电压低的时候容易让逻辑电路不稳定。我更推荐用DRV8833或TB6612这类MOS管驱动芯片,体积小,效率高,还能通过PWM直接控制转速。接线时注意,电机供电和逻辑供电最好分开,驱动板的逻辑电源可以从OpenART的5V引脚取,电机电源单独从电池取,这样能有效避免电机启动时拉低主控电压。
如果你用的是带编码器的电机,建议把编码器信号接上。编码器能让你知道轮子实际转速和目标转速的差距,是后面做PID闭环的基础。没有编码器也能跑,但遇到地面摩擦不均匀或者电池电压下降时,小车会莫名其妙地跑偏,排查起来非常头疼。
2.2 摄像头安装高度和角度,比你想象的更重要
摄像头装多高、朝什么角度,是决定图像处理稳定性的关键因素。装得太低,画面里全是近处地面,前方信息太少,小车转弯时根本来不及反应;装得太高,视角虽然远了,但画面里会出现大量背景干扰,二值化时很容易把远方的物体误判成目标。
我的经验是,摄像头离地10到15厘米,俯角15到30度,让画面里近处地面的线条占屏幕下方三分之一左右。这个角度下,巡线时能同时看到近处的线、中距离的线,以及远处的转折点,小车可以提前判断方向变化。
还有镜头选型。OpenART默认配的是标准广角镜头,在室内小车场景下够用,但广角镜头边缘畸变比较明显,做AprilTag位姿解算时,标签靠近画面边缘会出现距离测量不准的问题。如果条件允许,可以换一个畸变更小的镜头,或者在代码里做简单的去畸变处理。之前我懒得处理畸变,结果小车在标签偏左时明明该左转却直行,查了半天才发现是边缘像素坐标偏差太大。
2.3 供电问题:为什么小车总是无故重启
如果只挑一个最影响智能视觉小车稳定性的因素,我会选供电。电机启动瞬间的电流冲击非常大,如果电池、驱动板、主控板共用一条电源路径,主控很容易电压跌落然后复位。这个问题的典型表现是:小车静止的时候一切正常,一加油门屏幕闪一下,程序从头跑。
解决思路是分开供电:电机用动力电池直接供,主控和传感器通过稳压模块单独供,并把两者的地线共地。共地这个操作很多新手会忽略,不共地的话,PWM信号在电机启停时会产生剧烈电平波动,轻则图像抖动,重则直接烧引脚。
另外要注意电池的放电能力。我用过一组性能较差的18650电池组,标称容量不小,但内阻高,大电流放电时电压掉得厉害。后来换成了放电倍率更高的锂电池,问题迎刃而解。如果你的小车出现“电压显示3.7V但一动就掉到2.8V”的情况,别急着调程序,先检查电池是不是带不动。
3. 图像处理流程:从一帧画面到可执行的指令
3.1 图像预处理:为什么要灰度化和二值化
摄像头采集到的原始图像数据量非常大,QVGA分辨率的一帧RGB565图像就有约15万字节。如果在彩色图上直接做复杂运算,嵌入式处理器的负载会很高。所以实际项目中,预处理的第一步通常是灰度化,把三通道的彩色图压缩成单通道灰度图,数据量直接降到三分之一。
灰度化之后是二值化,也就是根据灰度值把像素分成两类:目标和非目标。以巡线为例,如果赛道是黑线白底,设定一个阈值,灰度值低于阈值的像素置为白色(1),其他置为黑色(0),这样图像就变成了只有黑白两个值的矩阵。二值化的意义在于,它把问题从“判断一个像素像不像线”简化成了“这个像素是不是线”,后续的寻找色块、计算偏移都基于这个纯净的输入。
不过,“先灰度再二值化”只是最基础的流程。当你做颜色识别时,灰度化会把不同颜色的信息丢掉,这时候更需要保留色彩的LAB颜色空间来处理,后面会单独讲。
3.2 巡线算法:中心偏移量是怎么算出来的
巡线的核心不是“看到线”,而是“算出车身相对线的偏移量”。有了这个偏移量,再把它映射成两个电机的速度差,就完成了从视觉到控制的闭环。
我在OpenART上实现巡线的思路分四步。第一步,定义感兴趣区域ROI,只处理画面下方三分之一区域,减少远处干扰;第二步,在ROI内做二值化,找出亮色线条;第三步,用find_blobs找出最大面积的色块,色块中心点作为当前线的位置;第四步,计算色块中心横坐标与画面中心横坐标的差,这个差值就是偏移量error。
import sensor, image sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time=2000) sensor.set_auto_exposure(False) sensor.set_auto_whitebal(False) GRAY_THRESHOLD = (128, 255) # 白线黑底,按实际场景调整 def get_error(img): # 只取画面下方1/3作为ROI roi = (0, img.height() * 2 // 3, img.width(), img.height() // 3) blobs = img.find_blobs([GRAY_THRESHOLD], roi=roi) if not blobs: return None largest = max(blobs, key=lambda b: b.pixels()) center_x = largest.cx() error = center_x - img.width() // 2 return error while True: img = sensor.snapshot() err = get_error(img) if err is not None: # err为正说明线偏右,为负说明线偏左 print("error:", err)这里有个容易被忽略的点:ROI的坐标单位是像素,而且坐标系原点是图像左上角。如果你把ROI定义错了,二值化出来的线和画面里的线对不上,调半天阈值也没用。建议在开发环境里先把ROI区域用矩形画出来,确认框住的是想处理的区域再往下写。
3.3 形态学处理:膨胀与腐蚀的妙用
二值化之后的图像往往带着很多噪声点,尤其是地面有纹理、有反光时,白色区域里会掺杂不少孤立的小点,黑色背景里也会冒出一些碎块。这时候就要用到形态学操作,最基础的就是膨胀和腐蚀。
膨胀的效果是把白色区域向外扩张,把附近细小的间隙填上;腐蚀的效果是向内收缩,把孤立的噪点吞掉。实际操作中,我通常先做一次腐蚀,去掉地面纹理产生的杂点,再做一次膨胀,把线条本身残缺的部分补回来。顺序不能乱,如果先膨胀后腐蚀,会把噪声也一起放大,处理完反而更脏。
OpenMV系列固件提供了一个好用的combined方法,可以在二值化后直接调用img.erode(1)和img.dilate(1),其中参数表示执行次数。次数不要贪多,一两遍就够了,做多了线条会明显变粗,偏移量计算会引入额外误差。如果你做的是赛道巡线,尤其要注意这点——线变粗1个像素可能感知不出来,但弯道里的误差累积起来,车速一快就会冲出赛道。
3.4 颜色识别:LAB空间为什么更好用
做颜色识别时,RGB空间并不合适,因为它对光照变化太敏感。同一个红色物体,在亮光和阴影下RGB值能差出好几倍。LAB颜色空间把亮度信息单独放在L通道,把颜色信息放在A和B通道,这样即使亮度变化,A和B的值也相对稳定,识别率会高很多。
在OpenART上用LAB做颜色识别,思路和二值化很类似,只不过阈值变成了六个数:L的最小最大、A的最小最大、B的最小最大。这个六个值怎么确定?不用靠猜,开发环境里有阈值编辑器,实时显示图像的同时,可以直接框选目标区域自动计算阈值范围,非常方便。
颜色识别做完之后,通常还会加两个过滤条件:面积过滤和矩形度过滤。面积过滤可以排除小噪声,矩形度过滤可以排除那些形状明显不对的区域。比如识别红色小球,我会要求色块面积大于某个值,而且外接矩形宽度和高度比例接近1比1。这两个条件可以挡掉大量误识别。
4. 增强现实落地:让小车认识标签并“看见”虚拟标线
4.1 AprilTag为什么比普通色块更适合定位
增强现实在智能小车上的应用,最常见的形式就是让小车识别特定标签,并根据标签的位置、方向决定下一步动作。这里我用的是AprilTag,它不是简单的颜色块,而是一套设计好的黑白方格编码图案,图案内部带有校验信息,可以通过解码得到标签的ID、位置、旋转角度,甚至标签平面相对于摄像头的三维位姿。
相比普通色块,AprilTag最大的优势是抗干扰和唯一性。普通色块只要颜色接近就会被误判,而AprilTag即使被部分遮挡、旋转、倾斜,只要还在可识别范围内,就能稳定解算。这让我想到自动驾驶里的视觉定位——本质上都是通过图像中的已知标记推算自身位置,区别只是AprilTag是人为放置的标记,而自动驾驶用的是车道线、路牌等自然标记。
在OpenART上识别AprilTag,不需要自己写复杂的特征提取算法,固件里已经内置了find_apriltags接口。它的实现原理是基于四边形检测和编码解码,先在图像里找候选的四边形轮廓,再通过单应性矩阵把图案投影到标准坐标系,最后比对编码库确认标签ID。
4.2 在OpenART上跑通AR标签识别的完整步骤
我的AR标签识别程序分为三步:初始化摄像头参数、循环采集图像并查找标签、把标签信息可视化并输出。初始化时要特别注意两点,一是关闭自动曝光和自动白平衡,让图像亮度保持稳定;二是把分辨率设为VGA或更高,因为AprilTag的解码对细节要求比较高,QVGA下远距离小标签可能完全识别不出来。
识别主循环的核心代码如下:
import sensor, image sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.VGA) sensor.skip_frames(time=2000) sensor.set_auto_exposure(False) sensor.set_auto_whitebal(False) while True: img = sensor.snapshot() tags = img.find_apriltags(families=image.TAG36H11) for tag in tags: img.draw_rectangle(tag.rect(), color=(255, 0, 0)) img.draw_cross(tag.cx(), tag.cy(), color=(0, 255, 0)) img.draw_string(tag.x(), tag.y(), "ID:%d" % tag.id(), color=(255, 255, 0)) print("ID:", tag.id(), "距离估算:", tag.z_translation())这段代码运行后,屏幕上每个被识别到的AprilTag都会被红色矩形框住,中心画一个绿色十字,旁边再标出ID。所谓的增强现实效果,在这一步就已经初步出现了——你在屏幕里看到的标签周围多出来的框和文字,就是视觉系统叠加出来的虚拟信息。
4.3 把标签位姿变成小车的行为指令
识别标签只是第一步,真正让小车“听懂”标签,需要把位姿信息映射成行为指令。比如我布置了一个简单场景:小车看到ID为1的标签就左转,看到ID为2的标签就右转,看到ID为3的标签就停车并亮灯。
这里有个关键点:tag.z_translation()返回的是标签到摄像机的距离估计,单位是毫米,但这个数值会受镜头畸变和标签实际尺寸影响,默认是按固定尺寸计算的。如果标签打印尺寸和默认值不一致,一定要通过tag_size参数重新指定,否则距离信息完全不可信。我就是被这个坑过,打印的标签比默认尺寸小一半,识别到的距离比实际远了将近一倍,小车该减速的地方没减速,直接撞上了障碍物。
把位姿转行为指令时,还可以结合增强现实的思想,在图像上画出小车的预期行驶路径。我试过在识别到标签后,根据标签的中心坐标和旋转角度,在画面里画一条从画面底部延伸到标签位置的虚拟引导线。视觉效果很直观,调试时也能一眼看出算法判断的方向是不是合理。
5. 从零调试遇到的问题与排查技巧
5.1 图像层:反光、过曝、流动光线
视觉小车在室内调试最大的敌人就是光线。同样的代码,上午跑得好好的,下午换个角度就失灵,十有八九是光照变了。二值化阈值是固定的,但地面反射、灯光阴影都会让画面灰度发生偏移。
解决思路有几个,按优先级排列。第一,关闭摄像头的自动曝光和自动白平衡,让画面参数固定下来,避免摄像头自己在不同亮度间跳来跳去;第二,调整镜头角度,尽量避开正上方灯光直射造成的反光区域;第三,选择在固定时间、固定灯光下调试,把环境变量控制住,先跑通再考虑复杂场景。
如果阈值漂移问题很严重,还可以用自适应阈值的方法,比如把ROI内的灰度直方图取波谷作为分割点,而不是用固定阈值。这个方法在OpenMV里有现成的接口可以用,但要注意它对噪声更敏感,需要配合形态学处理。
5.2 控制层:转向震荡与PID参数整定
小车巡线最常见的失控表现是左右摇摆,越摆越大,最后冲出赛道,这就是典型的转向过度。原因是偏移量到电机速度差的映射系数太大,或者只做了比例控制没有阻尼。
我的调参经验是,先用P参数让小车能“大致跟着线走”,此时允许小幅摇摆,然后加一点D参数消除振荡,最后加I参数消除稳态误差。调D参数时尤其要小心,它是对误差变化率的响应,设置过大会导致转向抖动,电机咔咔作响,听起来就像齿轮在打架。
另一个容易踩的坑是,偏速度差之后要限制电机的最大转速变化率,也就是加速度限制。否则小车当前速度很快时,检测到急弯,突然给一个很大的反向速度差,轻则车轮打滑,重则驱动板过流保护。这个限制可以在代码里用简单的限幅实现:每次PWM变化量不超过某个上限。
5.3 系统层:串口日志与模块化验证
从零搭建系统,最怕的是所有模块一起联调。我坚持一个原则:每次只调一个变量。硬件通电后,先用串口打印系统状态,确认主控、摄像头、电机驱动都正常;再烧一个最简单的亮灯程序,确认GPIO映射正确;然后单独跑图像采集,确认画面不花不抖;最后才组合成完整逻辑。
串口打印是调试过程中最能救命的工具。不要在代码里到处写print却不带标签,建议统一格式,比如[LINE] error: 12,[TAG] id:2 dist:340。这样出现问题时,翻日志一眼就能定位是哪部分异常。如果嫌串口麻烦,也可以直接把调试信息画到图像上,我经常这样用,因为图像上看到的数据比一串串数字直观得多。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 上电没反应 | 电源接反、稳压模块损坏 | 先量电压,再查接线 |
| 电机一动主控重启 | 电源路径共用、电池供电不足 | 分开供电,共地,换高放电倍率电池 |
| 画面花屏闪烁 | 镜头排线接触不良、供电电流不够 | 重新插紧排线,给摄像头独立稳压 |
| 二值化后线条断断续续 | 阈值不合理、反光干扰 | 关闭自动曝光,调整阈值,加形态学处理 |
| 巡线时车身剧烈摇摆 | P值过大或D值缺失 | 降低P,适当增加D,限制PWM变化率 |
| AprilTag识别距离偏短 | 分辨率太低、标签尺寸参数错误 | 提高分辨率,在find_apriltags中指定tag_size |
| 颜色识别时好时坏 | 白平衡未关、环境光变化 | 关闭自动白平衡,改用LAB颜色空间,固定光照 |
6. 让小车更聪明的三个扩展方向
6.1 用状态机管理多任务
当小车同时具备巡线、颜色识别、AR标签识别能力后,代码会变得很复杂。如果全靠一堆if else互相嵌套,迟早会把自己绕晕。我推荐引入状态机,把一次任务拆分成多个状态,比如“寻找标签”“朝标签行驶”“识别颜色”“执行动作”,每个状态里只做一件事,状态之间通过条件触发跳转。
状态机的价值不只是让代码更整洁,它能让你逐步测试整个任务流程。比如我可以先单独测试“寻找标签”状态,确认没问题后再让它跳转到“朝标签行驶”。每次只验证一个转换,出问题能立刻定位是为了哪个环节。
6.2 视觉与上位机的通信协议设计
如果下一步想做更复杂的逻辑,比如把小车采集到的图像实时传回电脑做处理,或者用手机APP控制小车,就需要设计通信协议了。我用的方式是串口加JSON格式,OpenART把识别结果打包成JSON字符串发给上位机,上位机解析后再下发控制指令。
设计协议时要注意一点:传输频率不需要太高。图像处理帧率可能到了30帧每秒,但真正需要发给上位机的,可能只是每5帧里的一个结果,或者只有状态变化时发一次。我的习惯是只在关键状态变化时上报数据,否则串口会被刷爆,反而影响控制指令下发。
6.3 从视觉到控制:PID参数整定的整体思路
扩展方向说再多,最终都绕不开视觉和控制两条腿走路。视觉负责“看”,控制负责“动”。很多小车跑不好,不是算法不行,而是视觉输出到电机控制的这个桥梁没搭好。
我最后的建议是,把视觉部分和控制部分彻底解耦。视觉模块只负责输出目标位置、距离、方向这些语义化信息,控制模块只负责接收这些信息转换成电机指令。调试时,先给控制模块喂假数据,比如固定说“目标在左侧”,看它转不转、转多久;再给视觉模块配一个实时显示画面,确认它的输出值是否符合直觉。两边都正常了,再合并调试。这样分开验证,整个系统的可靠性会高很多,也方便后续把视觉部分升级成更复杂的模型——比如如果你以后想用CNN做目标分类,会发现图像处理为什么用CNN而不用普通前馈网络这个问题,在嵌入式小车上尤其直观:图像本身是局部相关的,普通前馈网络把每个像素独立对待,参数量爆炸不说,还学不到空间特征;CNN通过卷积核的局部感受野和共享权重,既减少了计算量,又天然适合处理图像,对算力有限的嵌入式平台来说,这种差异几乎是决定性的。
我在整个项目里最大的体会是,智能视觉小车的难点从来不是某一个具体功能,而是把图像处理、增强现实定位、控制决策串成一条稳定链路的系统能力。每一步单独看都不算复杂,但连起来之后,任何一环的不稳定都会被放大。所以别急着追求一步到位,先把一个功能调到足够稳,再往后走。最后再分享一个小技巧:所有阈值参数、PID参数,都建议用一个配置文件或常量区集中管理,并写上注释说明在什么环境下整定的。这样过了两周你回头调试时,不用靠回忆去猜当时为什么设成这个值。