每次少年创客营走到第九天,我都知道会议室里会乱成一锅粥。前八天,学员们的机器人小车基本还处于“被遥控”或者“傻跑”的状态:要么用键盘手柄控制前进后退,要么让轮子按写死的速度匀速转。而到了Day 9,我们要让这台小车真正睁开眼睛,学会自己追踪一个彩色目标——比如我放在地上的那枚橙色小球。对一群十来岁的孩子来说,这是他们第一次亲身体会到“机器人自己能感知世界”是什么感觉;对带营的我来说,这一天也是整期课程里最需要备课的一节。
这一天的技术主线其实很干净:摄像头把画面传成 ROS2 图像话题,Python 节点做颜色识别算出目标偏移量,再把偏移量换算成底盘速度发布到/cmd_vel。听起来不难,但里面藏着大量教学上需要精心安排的坑:图像数据到底长什么样、话题 QoS 为什么会让画面卡住、HSV 阈值为什么换个灯泡就不灵、控制增益太大为什么会导致小车原地转圈。这些问题如果不提前拆透,一旦在课堂上炸开,很容易消耗掉孩子们好不容易建立起来的信心。
这篇文章我写给三类人看:一是正在带着孩子做 ROS2 项目的老师或助教,二是打算在家里陪孩子复现活动的家长,三是那些已经在学 ROS2、想找一个小而完整的练手项目的少年。你会看到我从课程定位、硬件选型、代码结构,一直讲到课堂实测中的三个典型故障,以及怎么让孩子们从“瞎调参数”走向“有纪律地调试”。内容基于我们带营过程中的真实经验,方法和参数都可以直接搬走用。
1. 从“开环”到“闭环”:Day 9 到底要跨过哪一步
1.1 前八天我们搭好了什么
少年创客营的前期课程,主线是“先把 ROS2 这张网织起来”。第一天到第三天,大家装环境、建工作空间、跑通第一个发布订阅示例,理解了节点和话题这两个最基础的概念。第四天到第六天,我们用rqt_graph看节点通信,把键盘控制小车跑通,第一次听到电机随按键转起来的嗡嗡声,整个教室都很兴奋。第七天第八天,我们引入了launch文件,把底盘驱动、键盘遥操作节点、fjsp可视化工具一次性启动起来,小车终于能在场地里被人操控着游走。
但这时候的小车本质上还是一个“遥控玩具”。电脑键盘按下w,速度话题里就发出一个向前速度,仅此而已;机器人自己完全不知道前方有什么,也谈不上任何“自主”。所以我通常会在第八天结束前给孩子们看一段别人做的自动跟随小车视频,然后问一句:“为什么它的车不需要人控制就能一直追着人走?” 这是Day 9 的引子。
1.2 第九天目标:让小车形成第一个“感知—决策—执行”闭环
Day 9 的教学目标,我想了三个层次。第一层是所有人都要完成的:能订阅摄像头图像话题,并在屏幕上看到实时画面;第二层是大部分人能做到的:从画面里提炼出彩色目标的像素位置,输出一个偏移量数字;第三层是少数反应快的孩子可以冲击的:把偏移量变成小车速度,让小车慢慢转动,始终把目标保持在画面中央。
从课程设计角度看,这一天的本质是“开环”到“闭环”的跨越。之前小车的行为是单向的:人类看路、人类决策、人类给指令。现在摄像头成了一个数据源,程序自己看路、自己计算偏差、自己修正输出。孩子们可能还说不清“闭环控制”这个词,但他们能亲眼看到一件神奇的事:当目标物往左移,小车会自动往左转;当目标物停住,小车也会慢慢停下来。这种反馈过程非常直观,比往 PPT 里放一百张控制理论示意图都管用。
1.3 一帧图像到底是什么
这里有个关键点必须先讲清楚,否则后面代码根本没法看。图像话题里流动的sensor_msgs/Image,并不是一张我们常见的 JPG 图片,而是一堆按顺序排列的数字。以常见的 640 乘 480 分辨率为例,每一帧画面就是 307200 个数值,排成一个长数组,配合宽度、高度和编码格式,就能还原成一幅图。对孩子们,我喜欢用一个比喻:图片不是“一张照片”,而是“一张写满数字的大方格纸”,每个格子里存着那个像素的颜色。
在 ROS2 里,我们要用cv_bridge把这串数字变成 OpenCV 能处理的图像对象。很多大人第一次写这个转换也会愣住,更别说孩子。所以我会在课程开始前,单独准备一个小环节:订阅图像话题后,只把前 100 个数字打印在终端上,让他们亲眼看到那些[123 45 67]样子的数组。当屏幕上真的出现了摄像头画面时,再回头看那些数字,孩子们对“图像数据”的理解会一下子扎实很多。
2. 装备一页纸与话题网络的“先画图再写码”
2.1 用到的硬件清单与选型理由
带少年营最好别用太严肃的设备,太贵容易束手束脚,太玩具又撑不起 ROS2 依赖。我们最终固定下来的一套组合,成本可控,稳定度也不错。
| 部件 | 选型思路 | 注意事项 |
|---|---|---|
| 摄像头 | 入门级 USB 免驱摄像头,至少支持 640×480@30fps | 不建议选鱼眼广角,畸变会让调试变复杂 |
| 处理板 | 普通的 ARM 开发板或一台旧笔记本均可 | 只要能流畅跑 OpenCV 和 ROS2 就行 |
| 底盘 | 两轮差速小车,驱动节点能够订阅/cmd_vel | 带编码器最好,但没有也能完成本课 |
| 电池 | 容量够跑一节课即可 | 电压不足时摄像头会闪断,非常坑 |
| 目标物 | 橙色或亮红色纸球、积木块、卡纸圆片 | 颜色要和地面、桌子、衣服尽量区分开 |
| 调试显示 | 外接屏幕或远程桌面 | 关键是孩子能同时看到程序窗口和真车 |
关于摄像头位置我有一个比较实在的建议:宁可让画面略微倾斜,也尽量不要让摄像头正对着反光的地板。地板反光会把颜色信息搅乱,阈值怎么调都难受。我们的做法是在小车前面加一个小小的支架,摄像头略向下俯视,视野中心对准车身前方大约半米远的地面。这样追踪地面小球时,背景比较干净,颜色分割的成功率会高不少。
2.2 运行前把话题图画在纸上
我要求学员在写代码前,先不碰键盘,用纸笔把话题图画出来。Day 9 的话题网络并不复杂,但明确一下每个节点和话题,后面开发和排错会省很多时间。
| 话题 | 消息类型 | 谁发 | 谁收 | 干什么 |
|---|---|---|---|---|
/camera/image_raw | sensor_msgs/Image | 摄像头驱动节点 | 颜色感知节点 | 实时图像 |
/target_offset | std_msgs/Float32 | 颜色感知节点 | 速度控制节点 | 目标中心与画面中心的横向偏差 |
/cmd_vel | geometry_msgs/Twist | 速度控制节点 | 底盘驱动节点 | 线速度与角速度 |
我把这张表投影到屏幕上,然后让每个小组在纸上画一张同样的图,节点画成圆圈,话题画成连线,箭头标好方向。画完以后,一眼就能看出我们这一步要做两件事:要么往图里加一个节点,要么在已有节点之间加一条线。很多成年人在 ROS2 里绕弯路,就是因为脑子里没有这张图,一上来就到处复制粘贴代码。让孩子养成“先画图再写码”的习惯,是这一天额外的收获。
启动后先别急着跑算法,还有一组极简单的检查命令。让每个学员轮流在终端里执行,确认环境是健康的再往后走:
ros2 topic list ros2 topic type /camera/image_raw ros2 topic hz /camera/image_raw ros2 run rqt_image_view rqt_image_view正常情况/camera/image_raw的发布频率应该在 15 到 30 Hz 之间。如果低于 10 Hz,就意味着图像数据本身已经卡顿,后面识别和控制做得再漂亮也没意义。我们有一回就是摄像头插在 USB 延长线上导致帧率只有个位数,折腾了一整节课,最后还是换接口解决的。这种“越基础越致命”的经验,带队老师一定得提前知道。
3. 三行核心逻辑:从图像颜色到目标偏移量
3.1 为什么我坚持先用 HSV 而不是直接找 RGB 颜色
很多第一次接触图像处理的学员会问我:为什么不直接写“如果像素是红色,就把它标出来”?直观想法没错,但 RGB 表示颜色的方式有一个麻烦:当光照变化时,红色的 R 分量、G 分量、B 分量会一起变,你很难用一个简单范围把各种光线下的红色都框住。教室的灯一换、窗外太阳一晒,原本调好的红色范围就全部失效。
HSV 则把颜色拆成了三个更贴合人类直觉的维度:H 是色相,就是色环上站在哪个角度,红色大约是 0 度附近;S 是饱和度,颜色鲜艳不鲜艳;V 是明度,整体亮不亮。对孩子来说,HSV 里最难理解的是 H 是一个环形角度,红色既在 0 度附近,又在 360 度附近,所以调红色阈值时常常要设两段范围。一旦理解了这一点,他们对“颜色识别为什么要在 HSV 空间做”就彻底开窍了。
我通常会在白板上画一个色轮,然后拿一支红笔问:“如果光线变暗,这个红色是颜色变了,还是只是变暗了?”孩子们很快会回答“变暗了”。对,这就是 HSV 的分工:S 和 V 负责应对明暗变化,H 负责锁定真正的颜色。理解了原理再去调参数,就不再是碰运气了。
3.2 感知节点的核心代码骨架
下面这段是我们在营里使用的感知节点代码,我做了大量简化,只留核心逻辑。代码逻辑可以描述成四步:取帧、转 HSV、做阈值分割、计算目标中心。
import rclpy from rclpy.node import Node from rclpy.qos import qos_profile_sensor_data from sensor_msgs.msg import Image from std_msgs.msg import Float32 from cv_bridge import CvBridge import cv2 import numpy as np class ColorDetect(Node): def __init__(self): super().__init__('color_detect') self.sub = self.create_subscription( Image, '/camera/image_raw', self.image_callback, qos_profile_sensor_data # 图像话题必须用传感器 QoS ) self.pub = self.create_publisher(Float32, '/target_offset', 10) self.bridge = CvBridge() # HSV 阈值,示例为亮红色 self.hsv_low = np.array([0, 80, 80]) self.hsv_high = np.array([10, 255, 255]) def image_callback(self, msg): frame = self.bridge.imgmsg_to_cv2(msg, 'bgr8') hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, self.hsv_low, self.hsv_high) # 开运算:去掉杂点 kernel = np.ones((5, 5), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: largest = max(contours, key=cv2.contourArea) if cv2.contourArea(largest) > 800: M = cv2.moments(largest) if M['m00'] > 0: cx = int(M['m10'] / M['m00']) offset = cx - frame.shape[1] / 2 self.pub.publish(Float32(data=float(offset)))代码里最值得讲清楚的两个细节,一个是 QoS,一个是面积过滤。qos_profile_sensor_data这件事不做好,学员极其容易一脸懵:代码看着没问题,程序也编译通过,可订阅就是收不到图像。原因是 ROS2 的 QoS 讲究匹配,摄像头驱动通常使用传感器数据专用的 QoS,如果订阅方用默认 QoS,两边握手就失败了。我专门给学员写了一条口号:图像话题,必须用qos_profile_sensor_data。
面积过滤的含义是只保留最大的、面积足够的色块。没有这一步,画面上任何一个红色噪点、一根红色数据线,甚至孩子手指头的影子,都会让机器人误判目标位置。开运算“先腐蚀后膨胀”则可以去掉小碎点,我用了一个偏生活化的解释:这就像先把拼图里散落的碎片扔掉,再把大块图案用力擦亮一点,让轮廓更干净。
3.3 把感知结果变成孩子看得懂的画面
感知节点跑起来后,绝大多数孩子其实意识不到“程序到底看到了什么”。所以我要求每个小组的窗口里,除了原始画面之外,还要显示处理后的mask二值图,并在原始画面上画出目标中心十字和轮廓框。这样做效果立竿见影:孩子举起橙子左右移动,屏幕上那个十字就跟着左右移动,他们立刻明白“原来程序是这样看我的”。
可视化窗口我一般用两个窗口并列放。左边是mask,只有黑白两色,白色区域就是程序认为是目标颜色的地方;右边是原图叠加彩色框。课程里我鼓励每个孩子轮流举着目标物晃一晃,同时观察 offset 数值的变化。当 offset 从正的 200 变成负的 150,他们基本上就能凭直觉猜到下一步控制器要怎么转了。
那节课上,几乎每个小组都发出过类似的感叹:“它真的认识这个球了”。距离小车真正自己转起来,其实只差最后一小步。
4. Offset 到速度:一个 P 控制器就够了
4.1 让孩子先靠直觉估出转向速度
颜色感知节点已经把目标位置压缩成了一个数:offset,单位是像素。当目标在画面正中,offset 是 0;目标偏左,offset 是负的;目标偏右,offset 是正的。现在的问题就变成:拿到这个数,该让小车怎么转?
我先不教任何公式,而是让学员用手模拟机器人。我站在教室前面举一个红色纸板,左右移动,然后让一个学员充当“控制器”:纸板在哪边,他就要往哪边转“方向盘”。孩子们很快会发现,如果纸板偏得不多,就轻轻转一点;如果纸板都快跑出画面了,就得快速转过去。等他们把这种直觉说出来,我再告诉他们:这就是一个比例控制器,输出和误差成正比。
这个“先直觉、后公式”的顺序特别重要。直接扔一个P = Kp * error的公式,很多孩子只能记住符号,不明白这个窗口为什么会出现这些数字。但手舞足蹈地模拟一遍之后,他们自己就把控制逻辑推理出来了,写代码时也有代入感。
4.2 控制器节点的写法与参数直觉
控制器节点订阅/target_offset,发布/cmd_vel。我们先对 offset 做一次归一化:把像素偏差除以画面宽度的一半,让误差落到 -1 到 1 之间。比如 640 宽的图像,画面中心是 320,目标中心是 512,那么 offset 是 192,归一化后就是 0.6。这样做的好处是,换一个分辨率完全不同的摄像头,控制器代码和参数还能继续用。
控制逻辑大致如下:
def offset_callback(self, msg): raw_offset = msg.data norm = max(-1.0, min(1.0, raw_offset / 320.0)) # 向目标方向转动,偏移越大转得越快 angular = 0.8 * norm # 目标偏离较大时减速,避免边转边冲过头 if abs(norm) > 0.5: linear = 0.06 else: linear = 0.15 cmd = Twist() cmd.linear.x = linear cmd.angular.z = angular self.cmd_pub.publish(cmd)我建议在营里让每个孩子亲手算一次:offset 是 192,半宽是 320,归一化是 0.6,比例系数 0.8,角速度是 0.48 rad/s。这个数字到底意味着什么?可以让小车在 1 秒内转过大约 27 度。孩子们第一次把“像素偏差”和“真实转角”对应起来时,会有一种打通任督二脉的感觉。
这是标准的 P 控制器,我们刻意没有引入积分和微分。为什么?因为对于课堂演示,“越偏转得越快”这个直觉已经足够;PID 里 I 和 D 的作用,要等到他们面临稳态误差或来回震荡时,再作为新问题引入,才符合学习的节奏。如果一开始就把三个系数砸到他们脸上,大多数人只会胡乱调参,失去理解的乐趣。
4.3 目标丢失时的行为设计:一堂决策课
在营里最热闹的环节是“丢目标”。孩子们正玩得开心,目标物被故意藏起来,画面里没有红色了,小车会怎么办?不同团队的策略五花八门,也正好是一堂最简单的行为决策课。
我们通常准备三种默认策略供讨论:第一种是停车等待,优点安全,缺点是一旦目标重新出现,小车需要重新启动;第二种是原地慢速旋转搜索,优点是大概率能找到目标,缺点是如果目标在身后,搜索方向不对会越来越远;第三种是保持上一次的有效转向继续前进,很像人类凭记忆找东西,但很容易撞到障碍。我会让每个小组自己选择一种并写进代码,然后互相演示。孩子们很快会发现,没有哪个策略是万能的,这和真实机器人系统的行为设计其实是一样的。
丢目标判断也不复杂:如果在超过 0.5 秒内(大约 15 帧)没有拿到新的有效 offset,就进入丢失模式。实现上可以维护一个计数变量,在回调里更新。这里不写完整代码了,但强烈建议老师把这块留给学员自己实现。他们为了让自己小组的策略赢,会非常认真地调试。
5. 课堂实测:三个必须提前踩一遍的坑
5.1 灯光一变就“瞎眼”:阈值是在特定光线下调出来的
每期营都会有一个小组,上午调好的红色阈值跑得顺顺利利,下午换了一间教室或打开另一排顶灯之后,小车突然对自己的目标物视而不见。学员们的惯性思维是“程序坏了”,但真正的元凶往往是色温变化。下午的自然光偏暖黄,红色的 Hue 数值会偏移好几度,原先0-10的范围把目标排除在外了。
解决办法不是把阈值范围无限放宽,而是教会学员“换环境就要重新标定”这个意识。我们在代码里加入了动态调节键位:按h降低 H 下限,按H提高 H 上限,调试时通过滑动窗口实时观察 mask 的变化,直到目标色块再次被完整选中。这比发给他们一张写死的阈值表有用十倍,因为标定的过程本身就是教育。
顺带提一个特别容易忽视的细节:目标物表面材质会产生反光。光滑的橙色贴纸在强光下会形成一个白色高光区,HSV 分割时会把目标拆成好几块碎片。我们后来统一把目标物换成了哑光卡纸做的圆片,问题少了很多。如果你也带孩子做这课,买材料时一定要选哑光、不要选亮面。
5.2 到处乱认色块:面积过滤为什么比颜色过滤更重要
另一个高频翻车是“机器人不看目标,反而追着人跑”。比如某个学员穿了一件红色卫衣,或者教室墙上挂了一幅带红颜色的海报,小车就会误以为那是目标。孩子们的第一反应是调颜色范围,让红色更“纯正”,但很快会发现,衣服上的红和卡纸上的红差距并不大,在 HSV 空间里很难只用颜色把它们分开。
我们用的主要方法是最小面积过滤。目标物在正常距离下,在画面里占大约一千到三千像素;而远处海报的一角可能只有几十像素,靠近镜头的衣服则可能占好几万像素。这时我引入一个“目标大小窗口”的概念:小于最小面积的不认,大于最大面积的不认。代码里只需要在找轮廓以后,加两次面积比较,效果立竿见影。
为了加深印象,我让学员做一个实验:把目标物从离镜头半米远拿到十厘米前,观察轮廓面积的变化,然后思考为什么接近镜头时小车反而会“失去目标”。当他们发现面积过大也会被过滤掉时,就真正明白了“目标检测从来不是靠单一特征走天下”的道理。
5.3 追球变成转圈:比例增益太大带来的震荡
第三个坑是我每次带队都要留意的:小车追球追着追着,开始左右大幅度摆动,像在原地画正弦波。我们第一次遇到这个情况时,助教在旁边急得直说“转向太猛了”,但学员并不理解为什么“太猛”会导致摆动。其实这是经典的比例控制过冲问题,只是孩子们还不认识它。
原因分为两部分。第一,比例系数Kp=0.8看起来不大,但配合归一化 offset,一旦目标在画面边缘,角速度会直接逼近上限,让小车猛地甩头;第二,摄像头帧率大约 10 到 15 Hz,图像处理加控制发布之间有延迟,小车转过了头,画面里目标出现在反方向,于是又开始反向猛转,形成振荡。
处理方法我按顺序教。第一步是把角速度最大值限制到 0.5 rad/s 左右,先把剧烈的摆动物理上压住;第二步是降低比例系数到 0.3,让它转得温柔一些;第三步是给控制节点单独设定一个较高的发布频率,尽量不要和图像处理混在一个循环里。经过这三步,小车会从“疯了似的摇头”变成平滑地跟踪。等到孩子们体会过这个差别,他们对“反馈快了也会出问题”这件事就有了感性记忆。
6. 调试纪律与课后延伸
6.1 一次只改一个参数,给孩子一套实验记录表
Day 9 的课程进行到后半程,教室里会出现一种很常见的混乱:几个孩子围着代码七嘴八舌,这个说改 HSV 阈值,那个说调速度,另一个已经动手把比例系数改成了 99。我后来总结的经验是,调试课一定要在开始之前立规矩——一次只改一个参数,改完记录结果。没有这个纪律,所有问题都会混为一谈,故障原因根本没法定位。
我给每个小组发了一张实体表格,贴在工位旁,要求每做一次修改就填一行。表格长这样:
| 时间 | 现象描述 | 改了什么参数 | 改成多少 | 结果 |
|---|---|---|---|---|
| 14:20 | 小车追不上球 | 前进速度 | 0.15→0.10 | 更稳但偏慢 |
| 14:28 | 小车左右摆动 | 比例系数 | 0.8→0.4 | 摆动明显减小 |
最开始有学员觉得这很麻烦,但不到十分钟,当他们想回退某个糟糕的参数时,就体会到记录的好处了。我给这个表起名叫“实验账本”,并且用它来收起营结束时的复盘材料。让孩子从小养成记录实验的习惯,比多跑通一个功能重要得多。
6.2 如何判断问题出在感知还是在执行
在更复杂的调试场景里,孩子们经常卡在一个问题上:到底是谁的锅?是这个感知节点把 offset 算错了,还是控制节点把速度发错了,抑或是底盘驱动没响应?我教给他们一套二分过检法,简单说就是把话题链路切成两段,分别检查。
第一段检查感知:手动拿着目标物左右移动,在终端里打印 offset,如果偏移量的值和实际位置方向一致,说明感知侧基本没问题;第二段检查执行:用命令行直接发布一个固定角速度给/cmd_vel,观察小车是否真的转动。如果小车没动,问题就出现在底盘驱动或者电机供电,根本不要碰算法。这样排查下来,大多数问题都能十分钟内定位。
在营地里,我会打印出三组信息:当前帧率、offset 原始值、控制器计算后的速度值。这三个数同时显示在终端里,几乎可以解释所有“为什么车没往该走的方向走”的问题。往日志里加信息这件事,我会特意教孩子用 ROS2 的 logger,而不是随手print,因为print在正式的多节点工程里很难区分消息来自哪个节点。
6.3 延伸任务设计与给带队老师的一页纸建议
如果某个小组提前完成了追踪任务,后面还有几个阶梯式扩展方向。最省事的是“让目标物大小决定前进速度”:利用轮廓面积估算目标远近,面积越大说明越近,就越要减速,防止撞上去。这是给之后的深度感知做铺垫。
第二个方向是识别多个颜色,比如红球和蓝球,分别追踪并打印出颜色名称。这需要学员把 HSV 的低高阈值做成两组,再对两幅 mask 分别处理。对孩子来说,实现多个感知通路并不难,难点在于代码结构组织,正好顺势引导他们写一个简单的ColorDetector类。
第三个方向是引入简单的“避让”:当画面上方出现大面积的障碍物颜色时,小车停止前进。我们可以用另一块蓝色卡纸充当障碍,当蓝色面积超过阈值,控制器直接强制停车。这同样是“感知—决策—执行”闭环的延伸,但任务控制逻辑多了优先级,更适合学得快的学员。
给带队老师的话,我绞尽脑汁总结了三条。第一条,故障不过夜:当天出现的硬件问题,必须当天解决或直接替换,否则第二天课程无法推进。第二条,性能目标别定太高:最好先跑通 5 FPS 的慢速演示,再优化到 20 FPS,不要一上来就追求实时丝滑。第三条,先用录好的图像跑通代码,再上真车:把一段摄像头录制的图片序列离线跑一遍,能过滤掉大量低级代码错误,省下的时间远比录数据花掉的多。
Day 9 晚上,我收好设备准备离开教室时,看到有个平时话特别少的学员,还蹲在小车旁边不肯走。他让小车盯住手里的橙色圆片,从教室这头慢慢走到那头,小车一路稳稳跟着。他转过来说:“它看见我了。”那一刻我意识到,今天这堂课最宝贵的不是谁把算法跑得最顺,而是这些孩子亲眼看见了一个由自己拼起来的系统获得了“生命感”。后面我们还要讲建图、讲自动导航、讲路径规划,但 Day 9 这个“看—想—动”的闭环,会像一块基石一样,托着他们往更深的机器人世界里走。如果你也要带类似的营,别贪多,别赶进度,留够时间让每个孩子亲手感受一次数字和运动之间的因果,他们会还你一个无比投入的下午。