先声明一下,我不是什么视觉算法大佬,就是普通参赛队的调车主力,在OpenART Plus上折腾AprilTag折腾了一个多赛季。第二十届、第二十一届全国大学生智能汽车竞赛,赛题里“虚拟元素”“AR标签”出现的频率越来越高,很多队伍拿到带屏幕的OpenART Plus,第一反应还是当普通摄像头用——找赛道线、判十字、看路障。但AprilTag这套东西一旦玩起来,它能让你的车“看见”现实中不存在的元素,然后在屏幕上叠加出虚拟路障、虚拟门、虚拟指示,这就是我们常说的增强现实智能车玩法。这篇文章不堆理论,就把我在实战里验证过的参数、思路和坑一次性倒给你,想上车的新队伍可以少折腾好几周。
1. 为什么我在智能车竞赛里盯上了OpenART Plus和AprilTag
1.1 从“纯循迹”到“虚实结合”:赛题里越来越常见的AR元素
先说个我自己真实的心理变化。最早听到“增强现实+智能车”这个组合时,我觉得它离竞赛有点远,总觉得AR是手机App或者AR眼镜才玩得转的东西。直到我仔细翻了几届全国大学生智能汽车竞赛的规则,发现不少组别已经明确出现了“AR标定”“虚拟元素识别”这一类任务描述,我才意识到:竞赛赛道上的AR,从来不是让你在镜头里看到一头恐龙,而是让车模“看懂”一种现实中并不真实存在的任务要素。
举个例子,赛道旁边立一个纸箱,纸箱上贴一张打印出来的AprilTag。车模远远看到这个标签,就知道那里有一个“虚拟锥桶”,需要执行绕行;看到另一个ID的标签,可能代表“虚拟车库”,需要停车入库。纸箱本身没有任何特殊结构,它纯粹是一个载体,语义完全由标签本身决定。这其实就是增强现实最朴素的定义:在真实场景之上,通过算法叠加并解释虚拟信息,再驱动真实设备产生响应。
这种玩法对竞赛组办方来说也很友好,改赛道只需要换标签贴纸,不需要重新搭复杂的实物道具。对我们参赛队来说,只需要在代码里维护一张“标签ID到动作”的映射表,就能应对不同的赛题变化。所以它被越来越多组别采用,是技术演进的自然结果,不是凭空冒出来的噱头。也正因为如此,OpenART Plus加AprilTag的组合,在这几届比赛里几乎成了视觉处理方向的必备技能。
1.2 OpenART Plus是什么:一块能画屏、能跑Python的视觉模块
OpenART Plus是逐飞科技面向智能车竞赛做的视觉模块,核心是恩智浦的i.MX RT1062处理器,主频能到600MHz,运行环境兼容OpenMV IDE。这意味着什么?用过OpenMV的同学应该秒懂:打开IDE,连接USB,写Python脚本,烧录运行,整个开发链路非常短。但RT1062的算力比传统OpenMV平台强不少,在RGB565的QVGA分辨率下跑AprilTag检测,帧率可以稳到让人放心的程度。对于“实时AR”这种需求来说,算力就是底气,没有足够的帧率,后面所有虚拟叠加和位姿输出都谈不上。
真正让我觉得它适合做AR玩法的,是板上自带的LCD屏。很多摄像头模块是没有屏幕的,识别结果只能通过串口发出去,或者回传到电脑上看。而OpenART Plus的LCD可以直接实时显示当前图像,并且允许你在图像上画框、画线、写文字。这意味着什么?意味着你可以在屏幕上把识别结果“可视化”出来:给tag画一个红色框,在它旁边写上“虚拟门”,再画一条车模的预瞄线。调车的时候能直接看到算法在想什么,而不是对着串口数字去脑补画面。等到答辩演示时,这个屏幕又是一个极好的展示窗口,裁判一眼就能看懂你的车在“理解”什么。
除了LCD,模块上还有RGB LED、按键、SD卡槽、串口和一堆GPIO。SD卡槽这个细节常被忽略,但对调车来说意外地好用——你可以把某一帧图像直接存到SD卡里,赛后拉出来逐帧分析为什么那个tag没识别出来。这种能力在排错时能省下大量时间,比反复猜测光照、角度要高效得多。
1.3 AprilTag不是二维码,却比二维码更适合车模定位
AprilTag是密歇根大学开源的一套视觉基准系统,外形确实很像二维码,但它在设计上跟二维码走的是完全不同的路线。二维码是为了存信息,所以编码密度很高,解码需要大量的图像处理;AprilTag是为了让机器快速锁定位置和姿态,所以编码更稀疏,检测算法可以更快地找到它,并且恢复出标签相对于相机的完整6自由度位姿——x、y、z三个方向的位置,加上旋转角。这句话翻译成人话就是:你的车不仅知道“前方有一个标签”,还知道“标签在相机前方大概30厘米、偏右5厘米、带一点倾斜角度”。
这些数据对车模控制来说太重要了。比如我们要判断虚拟锥桶到底在赛道左侧还是右侧,不能只靠tag在画面里的像素坐标,因为同一个像素位置在不同距离对应的实际偏移完全不同。但有了x_translation和y_translation之后,主控可以直接拿到以厘米为单位的相对位置偏差,误差是明确的、可控的。这也是AprilTag在竞赛里胜过普通二维码的核心原因:它输出的是可以被控制算法直接使用的物理坐标,而不是一个需要二次换算的像素坐标。
2. AprilTag检测的工程化:家族选型、图像参数与坐标解算
2.1 TAG36H11为什么是竞赛默认,家族选型对比
OpenMV IDE里的find_apriltags()函数支持好几个家族:TAG16H5、TAG25H7、TAG25H9、TAG36H10、TAG36H11。家族后缀里的数字,前半部分代表网格规模,后半部分代表校验位数。网格规模直接决定了标签的物理形态,同样打印尺寸下,网格更多的家族,每个黑白格就更小,要求相机离得更近才能看清;校验位数则直接决定误检率,校验位越多,认错标签的概率越低。
| 家族 | 校验位数 | 同尺寸下识别距离 | 误检率 | 竞赛推荐度 |
|---|---|---|---|---|
| TAG16H5 | 5bit | 较远 | 较高 | 不推荐 |
| TAG25H7 | 7bit | 中等 | 中等 | 备用 |
| TAG25H9 | 9bit | 中等 | 中等 | 备用 |
| TAG36H10 | 10bit | 较近 | 较低 | 可用 |
| TAG36H11 | 11bit | 较近 | 最低 | 默认首选 |
我在竞赛场景里几乎只用TAG36H11,原因就一条:误检率最低。为什么这个指标如此关键?因为智能车竞赛里,漏检的代价是可以接受的。一帧没检测到tag,下一帧补上就行,车模速度再快也就几十毫秒的空白,控制上完全来得及。但误检的代价是完全不可接受的——车把墙上一个像tag的图案认成了“虚拟锥桶”,直接执行绕行动作,轻则偏离路线,重则冲出赛道。从我的经验来看,TAG36H11虽然要求tag在画面里占得更大一点,但换来的是极强的防误检能力,这个交换非常划算。
2.2 曝光、白平衡、增益:让tag“看清”的第一步
把AprilTag识别率低归咎于算法,是新手最容易犯的错。我调试时遇过很多次tag忽大忽小、时有时无的情况,反复调了一整天算法参数,最后发现根本不是算法问题,而是相机曝光参数在自动模式下乱跳。AprilTag是黑白编码图案,检测器依靠的是黑白网格的边界和相对面积,一旦曝光不稳,黑色块在强光下变成灰色,白色块在暗光下变成灰白,整个tag的编码信息都会被压实,检测器自然就认不出来了。
我的第一原则:所有图像参数全部锁死,不能全自动。自动曝光在车模转向时会剧烈波动,因为镜头对着的方向不同、画面平均亮度不同,自动曝光会不断调整,导致每一帧的tag像素表现都不一样。建议把曝光值固定,室内赛道环境可以从exposure_us = 3000左右起步,根据现场实际亮度往上下调;室外的话一般要降到1500甚至更低,不然白色区域会直接过曝变成一片白板。
增益也要固定,自动增益在暗光下会把噪点放大,而噪点会啃食tag网格的边缘,让方格的边界变得毛糙。白平衡在荧光灯下尤其容易偏色,荧光灯的光谱不是连续的,自动白平衡会在不同时间点漂移,把白色块调成偏蓝或偏黄。解决办法就是直接关掉自动白平衡,手动给一组平衡值,让tag的白色块在画面里看起来是接近纯白的即可。
import sensor sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.set_auto_exposure(False, exposure_us=3000) sensor.set_auto_gain(False, gain_db=18) sensor.set_auto_whitebal(False, rgb_gain_db=(40, 40, 40))这段配置看起来简单,但它是整个AprilTag稳定检测的地基。我强烈建议拿到新场地之后,第一件事就是打开IDE的预览窗口,一边调整这三个参数一边观察图像里tag边缘是否清晰锐利,确认没问题了再写业务逻辑。
2.3 tag_size与位姿输出单位:一处配错全盘皆输
find_apriltags()里面有个参数叫tag_size,很多新手不设或者乱设,导致x_translation、y_translation、z_translation的输出数值完全不可信。这里必须搞清楚:算法在解算位姿时,需要知道tag的真实物理尺寸作为参照。原理不复杂,本质上是针孔相机模型下的比例关系——tag在图像传感器上占了多少像素,相机焦距是多少,tag真实边长是多少,这几个量一旦确定,距离就能被反解出来。但如果你给的tag_size和实际打印尺寸对不上,整个比例就错了,输出的坐标会系统性地偏大或偏小。
我的做法是:打印标签时直接用固定边长,比如6厘米或者10厘米,不要随手缩放到一个不规整的尺寸。在代码里就写对应的毫米数,比如6厘米就写成tag_size=60。这样find_apriltags返回的x_translation、y_translation、z_translation单位就是厘米。
这里有个很简单的验证方法:把tag放在车模正前方1米处,看输出z_translation是不是接近100。如果明显偏大或者偏小,先别怀疑算法出bug,回头检查tag_size填没填对。另外要注意,不同PDF阅读器打印时如果设置了“缩放以适应纸张”,实际印出来的尺寸会和设计尺寸不一致,打印完之后用尺子量一下,别想当然地认为自己设了10厘米就是10厘米。
3. 把AR效果“画”出来:LCD叠加到串口联动的完整链路
3.1 屏幕上的虚拟门:LCD叠加绘制实操
OpenART Plus的LCD屏是AR玩法最佳的输出窗口。检测到tag之后,我们可以直接在图像上绘制各种虚拟元素,让屏幕上的画面呈现出一种虚实结合的视觉效果。这个功能最初我以为是用来炫技的,但实际调试后发现,它最大的价值其实是“让算法状态可见”。你不需要在脑海里想象tag检测得好不好,所有信息都在屏幕上。
下面这段代码是一个最简单的虚拟门叠加效果。当检测到ID为0的tag时,在屏幕上画出方框、十字和一行“VIRTUAL GATE”的文字,同时画一条虚拟门线。
import sensor, image, time, lcd lcd.init() sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time=2000) while True: img = sensor.snapshot() for tag in img.find_apriltags(families=image.TAG36H11, tag_size=60): img.draw_rectangle(tag.rect(), color=(255, 0, 0), thickness=2) img.draw_cross(tag.cx(), tag.cy(), color=(0, 255, 0)) if tag.id() == 0: img.draw_string(80, 20, "VIRTUAL GATE", color=(255, 255, 0), scale=1.5) img.draw_line((tag.cx() - 40, tag.cy(), tag.cx() + 40, tag.cy()), color=(0, 255, 255), thickness=2) lcd.display(img)第一次跑通这个效果时,画面里tag被红框锁住,中央顶着绿色十字,旁边浮着黄色文字,那种“车在透过镜头理解世界”的感觉确实很奇妙。但要注意一点:LCD的绘制是有开销的,字符串和画线如果每帧都做,会挤占宝贵的检测时间。所以正式的赛事代码里,我一般不会把LCD显示和核心检测放在同一个循环里跑,而是加一个开关,平时跑车关掉,展示调试时再打开。
3.2 把tag位姿交给主控:通信协议怎么定
屏幕上的虚拟元素只是给人看的,车模真正要执行动作,还得靠串口把位姿数据实时发给主控板。通信协议不需要复杂,但一定要有帧头和结束标记,否则主控端很容易在数据流里切分错位置。我常用的协议是固定长度6字节:帧头0xA5、tag ID、x偏移、y偏移、z距离、结束标记0x5A。为了节省带宽,坐标全部先乘10转成整数再发送,主控端收到后除以10还原成厘米。
from pyb import UART uart = UART(3, 115200, timeout_char=1000) while True: img = sensor.snapshot() detected = False for tag in img.find_apriltags(families=image.TAG36H11, tag_size=60): data = bytearray() data.append(0xA5) data.append(tag.id()) data.append(int(tag.x_translation() * 10)) data.append(int(tag.y_translation() * 10)) data.append(int(tag.z_translation() * 10)) data.append(0x5A) uart.write(data) detected = True if not detected: uart.write(bytearray([0xA5, 0xFF, 0x00, 0x00, 0x00, 0x5A]))这里有个细节容易被忽略:当一帧图像里检测不到tag时,一定要主动发一个“无目标”的帧,而不是什么都不发。为什么?因为主控端需要用这个空帧来清除上一帧的目标状态,否则车模已经过了tag,主控还拿着最后一帧的坐标继续执行动作,可能就会过度反应。ID=0xFF这个特定值就代表“当前没有看到任何标签”,主控收到后可以安全地把目标清空。这个空帧逻辑,是很多人调车时出现“车过站了还停一下”这类诡异问题的真正原因。
3.3 “检测-分类-输出”三段式框架:换赛题只改一张表
随着赛道里的tag数量增加,把识别逻辑和决策逻辑混在一起写会很痛苦。我的做法是把整个视觉模块的程序抽象成三段式:检测、分类、输出。检测部分只负责调用find_apriltags,拿到原始位姿数据;分类部分维护一张tag ID到动作的映射表;输出部分只负责按协议把分类结果发出去。决策代码不关心tag是怎么被识别出来的,只关心当前应该执行什么动作。这样每一层都可以单独调试,出问题也能快速定位。
TAG_ACTIONS = { 0: "SLOW_DOWN", 1: "AVOID", 2: "PARKING", 3: "BOOST" } def parse_tags(img): for tag in img.find_apriltags(families=image.TAG36H11, tag_size=60): action = TAG_ACTIONS.get(tag.id(), "UNKNOWN") send_to_mcu(action, tag.x_translation(), tag.y_translation(), tag.z_translation()) def send_to_mcu(action, x, y, z): # 按协议封装发送 pass这样设计的最大好处是换赛题时只需要改TAG_ACTIONS这张表。今年比赛要求把ID=1当成锥桶避障,明年改成停车入库,代码层面几乎不用动。我在备赛后期基本已经形成了一套自己的模板,新赛题下来第一天就能把视觉框架搭好,剩下的时间全花在调参和稳定性测试上,这比从头写代码高效太多。
4. 实战玩砸过的坑:反光、目标尺寸与帧率延迟
4.1 一场由反光引发的翻车:打印介质与多帧确认
我第一次把AprilTag贴到亚克力板上试跑时,识别率惨不忍睹。tag在画面里忽远忽近,刚才还在1米外,下一帧距离解算直接跳到3米,车模执行绕行时犹豫不决,最后直接冲出了赛道边界。我一度以为是算法参数没调好,反复加滤波、改阈值,折腾了一晚上没有效果。第二天到场地仔细一看,发现问题根源是亚克力板表面的反光:在灯光下,tag的黑色网格被反射成一片亮斑,检测器把这一块当成了白色,编码信息直接被破坏。
从那之后我定了一条死规矩:打印tag必须用哑光纸,最好再覆一层哑光膜。高光相纸、亚克力板、塑封袋这些东西虽然好看耐用,但在灯光下就是识别杀手。尤其是比赛现场经常有大型灯架和补光灯,角度稍微一偏,反光问题就会被放大。如果你的队伍已经踩了反光的坑,第一件事不是调算法,是换介质。
光照突变是另一个更隐蔽的坑。车模跑过窗口、灯箱下面或者顶棚阴影交界处时,画面平均亮度会在几帧内剧烈变化,如果曝光没有锁定,tag就会瞬间丢失。解决办法除了固定曝光外,我还在主控端加了一道多帧确认逻辑:连续3到5帧都检测到同一个tag,才判定为有效目标;中间一旦出现断裂,就重新计数。这个“有效帧数”设置量其实很小,但效果立竿见影,误动作率直接下降了一大截。
4.2 识别距离不够?先算tag该贴多大
很多队伍第一次试跑时发现车要到离tag很近的地方才能识别出来,比如三五厘米才开始有反应。这个问题说穿了就是tag在画面里的像素尺寸太小。AprilTag检测器对tag的分辨率有下限,标签在图像里的边长如果小于三四十个像素,网格结构就难以被还原,识别自然失败。所以射程不够的时候,与其折腾算法,不如先算算物理尺寸。
我这里有一个粗略的对应关系,可以拿来做赛前预算。不同物理边长的tag,稳定识别距离大概在这个范围:
| tag物理边长 | 稳定识别距离参考 |
|---|---|
| 5cm | 0.2 - 1.0m |
| 10cm | 0.5 - 2.0m |
| 15cm | 0.8 - 3.0m |
| 20cm | 1.0 - 4.0m |
当然这个数值会受到镜头焦距、分辨率、光照条件的综合影响,但大方向不会偏。如果你希望车模在8米外就开始执行减速动作,却贴了一个5厘米的小tag,那再怎么调参数都没用,必须换大码。这里还要提醒一点:放大打印tag时,别忽略tag周围的白边,AprilTag检测是依赖标签外边框的,打印时四周至少留出和一个小方格一样宽度的白边,检测器才能真正把tag和背景区分开。
4.3 帧率、延迟与算力开销:别让炫技拖垮控制
OpenART Plus的算力在嵌入式视觉模块里算相当能打的了,但也不是无限资源。如果把分辨率开到VGA,再在每帧里叠加大量绘线、文字、动画效果,帧率会明显下降,tag的定位精度和车模的响应速度都会跟着变差。我的经验是QVGA分辨率起步,优先保证检测帧率不低于25fps,图像分辨率再高,识别延迟大了也是白搭。tag的绘制反馈只在调试模式里开启,比赛模式里全部关掉。
更关键的是要理解帧率和延迟的区别。OpenART Plus检测完一帧,串口发出数据,主控还要解析、处理、控制电机,整个链路的延迟才是真正影响动作时机的。视觉那边已经做到很短了,如果主控每100毫秒才读一次串口,那再快的视觉也白搭。我自己在联调时习惯这样分工:视觉模块以尽可能高的频率往外发数据,主控固定10毫秒读一次串口,发现车模动作延迟明显改善。很多队伍只盯着视觉端的帧率,忽略了主控端读取频率,结果视觉优化了半天,问题其实出在接收端。
5. 竞赛场景下的进阶玩法与调优路线
5.1 把AprilTag当绝对定位信标:路径纠偏思路
前面讲的都是把tag当作“触发器”,看到就执行动作。更进阶的玩法是把tag当作绝对定位信标,用来修正车模的全局坐标。智能车如果用编码器做里程计,跑久了轮子打滑就会积累误差,位置越算越偏。但如果场地关键位置贴几个AprilTag,车模每次经过时都能通过视觉重新确认自己的位置和朝向,相当于做了一次全局坐标校正。
具体做法是把tag当作landmark,视觉模块检测到后,利用x_translation、y_translation和旋转角,反算出车模相对于这个信标的位姿。因为信标的全局坐标是事先标定好的,把两者做一次坐标变换,就能得到车模当前的全局位置。这个思路和SLAM里的landmark定位是同源的,只是我们不需要动态建图,只需要修正误差。配合简单的PID纠偏,车模在暂时看不清赛道线的时候,也能靠跨段的tag位置估算硬撑着往前走一段。当然这里对相机安装角度、tag的固定位置都有标定要求,跑通之后整个系统的鲁棒性会上一个台阶。
在OpenART Plus的IDE里,AprilTag检测函数还会返回rotation相关的角度信息,可以用来判断车模相对tag的偏航角。比赛场上每个tag都按同一朝向摆放,那么这个角度就直接等同于车模的航向偏差,比拿两组位置坐标去算角度要方便得多。
5.2 多tag共存的策略:最近优先与任务分级
赛道里不会只有一个tag,一般会同时出现好几个。多tag带来的一个直接问题是:画面里同时出现两个tag时,该听谁的?如果只是简单地在循环里处理最后一个,车模在不同tag之间游移时会频繁切换目标,行为会变得很神经质。我的处理策略是“最近优先”:检测到多个tag时,只取z_translation最小的那个作为当前主目标,因为最近的tag通常代表车模当前最需要响应的元素。
另外一个思路是给tag ID做分级,比如0到9作为导航类信标,只更新定位信息,不触发动作;10到19作为动作类元素,触发减速、绕行、停车;20以上作为状态类标签,表示赛道终点或者特殊区域。分级之后,逻辑上会清晰很多,代码里只要一条if就能区分“这个tag只是参考,不需要停车”和“这个tag必须立刻响应”。这个设计是我在调试一个多车道交互场景时想出来的,当时赛道上tag数量太多,不加分级根本没法调。
5.3 答辩演示模式:让AR效果被裁判一眼看懂
最后这点不是技术,但我认为它对竞赛队伍极其重要。很多队伍代码能力很强,但答辩时讲不清楚AR到底是什么,评委听得一头雾水。我的建议是做一个专门的展示模式:车模放在桌面静止,队员用手拿着不同ID的tag在镜头前缓慢移动,LCD屏幕上实时显示对应的虚拟元素和动作提示。比如拿到ID=0的tag,屏幕边框变黄,显示“虚拟门:允许通过”;换成ID=1的tag,屏幕显示“虚拟锥桶:右转绕行”。
这个展示模式实现起来并不复杂,就是前面第三节讲的检测分类输出框架,再加上一个展示开关而已。但它带来的效果非常直观:评委能看到传感器图像、看到识别框、看到虚拟元素随手中标签实时变化,整个“增强现实”的链路一目了然。比对着PPT解释几十页算法流程有效得多。我有一次在答辩现场演示这个功能时,一个评委还专门蹲下来凑近屏幕仔细看了几秒,那几秒,我觉得比讲十页PPT都值。
说实话,AprilTag这套东西入门不难,难在让它持续稳定地为你工作。我见过不少队伍第一周就能把demo跑得飞起,然后整个赛季都困在反光、光照、帧率这些问题里出不来。如果你也准备在车模上玩AR,我的建议很简单:先别急着做花活,找一张哑光纸,打印一个TAG36H11,贴到1米远,把识别距离、坐标数值稳定性、串口延迟一项一项测满意了,再谈虚拟门、再谈多车联动。稳这一个点,比铺开十个点都管用。这条路我替你验证过,值得走。