ROS四轮小车分层架构:从驱动标定到建图定位避障
简介:面向ROS初学者与机器人开发者的四轮小车模型包,围绕带摄像头与激光雷达的移动平台,提供URDF机器人描述、传感器声明及可视化启动配置,可用于理解车体、车轮、关节结构,以及ROS中图像与激光数据的接入方式,帮助处理传感器建模、坐标衔接和模型验证等基础问题。压缩包共6个文件,以urdf模型、launch启动脚本、xml包配置和txt说明为主,并附pdf与gv结构图,整体约22KB,体量轻便,便于导入工作空间后对照查看模型与坐标关系。已有2765人学习下载,适合作为移动机器人建模、TF坐标变换、差速驱动控制与避障建图实验的入门素材。读者可据此梳理四轮小车从物理结构到传感器数据流的组织方式,借助结构图核对链接与关节关系,并在此基础上扩展路径规划、目标追踪或环境建图等练习。
1. 装完驱动就卡住:ROS 四轮小车真正缺的是分层设计
买齐四轮小车底盘、单线激光雷达和 USB 摄像头,把 ROS 环境装好,驱动跑通,rviz 里能看见点云在转、图像在刷——绝大多数人卡在这一步之后。车能动、数据能看,但接下来代码怎么组织、激光和图像怎么互相印证、答辩时拿什么证明它真的能用,脑子里是空的。我见过太多工位上的小车,雷达转得很勤快,底盘却在原地不动。
这套方案要处理的就是这段「从能跑到能用」的空白阶段:以 ROS 为运行框架,把四轮差速底盘、二维激光雷达和摄像头组织成一套分层、可复现的移动机器人系统,最终落到建图、定位与避障这些能被量化验收的功能上。适合正在做相关毕设、课程设计,或者要给团队交一台原型车的同学——照着搭能跑起来,照着自己的硬件改也能落地。
2. 总体架构设计:三层节点与一棵 TF 树的职责划分
新手最容易犯的错是「先写功能再说结构」:遥控节点直接发速度,雷达节点直接把点云喂给建图,相机节点自己算完再发个自定义消息出去。等到要换一个雷达、或者建图开始飘的时候,你会发现所有节点都长在了一起,拆不动也测不了。
我在这个题目上一般会把软件切成三层,层与层之间只靠 ROS 话题和 TF 通信,任何一层换实现都不影响另外两层。这不是为了好看,是为了让你在答辩现场能一句话说清楚「哪一段出了问题,我从哪一层开始查」。
2.1 硬件清单与选型约束
先列部件。这套配置的定位是「室内、单人能搬动、总价可控」,所以每一项都有明确的取舍点,不是越贵越好。
| 部件 | 常见规格 | 选型时真正该看的参数 | 容易忽略的点 |
|---|---|---|---|
| 主控 | 树莓派 4B 4G / 小型 x86 工控机 | 是否有 USB 3.0、持续功耗与散热 | 树莓派跑建图时 CPU 容易打满,散热片和风扇别省 |
| 底盘 | 四轮差速(两驱动两万向)或四轮全驱 | 轮径、轮距、编码器线数、减速比 | 编码器分辨率决定低速时的里程计抖动 |
| 激光雷达 | 单线 2D 雷达,360° 扫描 | 量程、扫描频率、角分辨率、接口类型 | 量程 8 m 足够室内,别为了参数买单 |
| 摄像头 | USB 免驱,广角或标准视角 | 是否支持免驱 UVC、固定焦距、低照度表现 | 自动对焦的型号标定时会漂,能选定焦就选定焦 |
| 电源 | 锂电池组 + 降压模块 | 电机与主控分路供电、总电流余量 | 电机启动瞬间的压降会把主控拉重启 |
底盘这块重点说一句:差速四轮里,两个驱动轮带编码器,另外两个是万向轮。里程计只能从驱动轮算,万向轮的打滑不会被感知到,这是后面建图重影的主要来源之一。选底盘时问清楚编码器是「单相」还是「AB 双相」,只有单相的话你拿不到方向,倒车时会持续累加脉冲,得自己加方向判断。
主控与底盘的连接方式有两种常见做法。一种是底盘自带单片机做电机闭环,主控通过 USB 转串口发速度指令、读编码器;另一种是主控直接出 PWM。前者更省事,也更适合把 ROS 跑在上层的思路,下面按这种方式走。
2.2 三层架构:驱动层、状态估计层、应用层
三层的分工是这样:驱动层只负责「把硬件翻译成 ROS 消息」,不含任何决策逻辑;状态估计层把轮式里程计、激光扫描、图像揉成一致的位姿和地图;应用层才做导航、跟踪、避障这些任务。
| 层级 | 典型节点 | 订阅 | 发布 | 频率 |
|---|---|---|---|---|
| 驱动层 | 底盘串口节点 | /cmd_vel | /odom、odom→base_footprintTF | 20~50 Hz |
| 驱动层 | 雷达驱动节点 | — | /scan | 8~10 Hz |
| 驱动层 | 相机节点 | — | /camera/image_raw、/camera/camera_info | 30 Hz |
| 状态估计层 | 建图节点 | /scan、TF | /map、map→odomTF | 按更新阈值触发 |
| 状态估计层 | 定位节点 | /scan、/map | map→odomTF、/amcl_pose | 按更新阈值触发 |
| 应用层 | 路径规划与控制 | /map、/scan、目标点 | /cmd_vel | 10~20 Hz |
这张表的价值在于:任何一次故障,你都可以先用rostopic hz逐层确认频率对不对,再往下钻。比如车不走,先看/cmd_vel有没有输出,有就去看底盘节点有没有收到、串口有没有写出去,一条链路几分钟能定位完,比在代码里乱翻强得多。
有一点要提前定下来:整机启动用统一的一个 launch 文件拉起,节点都放进同一个命名空间,参数集中放在config/目录下。这样你在实验室换一台车,只要改参数文件,不用改代码。后面第 6 章验收时会用到这个结构——每个测试项对应一个只启动部分节点的 launch,测试才可重复。
2.3 TF 树与坐标系约定
TF 是这个项目里最容易被当成「玄学」的部分,其实规则很简单:一棵树、不能有环、每个坐标系的原点和朝向要能和实物对上。
约定如下:map是全局地图坐标系,由建图或定位节点发布;odom是里程计坐标系,由底盘节点发布,短期连续、长期会漂;base_footprint是车体在地面的投影点,base_link是车体几何中心;laser_link和camera_link挂在base_link下面,是固定变换,由 URDF 描述。相机再往下挂一个光学坐标系。
<robot name="four_wheel_car"> <!-- 贴地投影坐标系,导航栈用它判断机器人落在哪个栅格 --> <link name="base_footprint"/> <link name="base_link"/> <joint name="base_joint" type="fixed"> <parent link="base_footprint"/> <child link="base_link"/> <!-- 底盘几何中心离地高度,按实车用卡尺量,写错会让雷达数据整体抬高一截 --> <origin xyz="0 0 0.085" rpy="0 0 0"/> </joint> <!-- 雷达:x 前移量、z 高度、yaw 朝向三项必须跟安装位置一致 --> <link name="laser_link"/> <joint name="laser_joint" type="fixed"> <parent link="base_link"/> <child link="laser_link"/> <origin xyz="0.10 0 0.12" rpy="0 0 0"/> </joint> <!-- 相机本体坐标系,x 朝前、z 朝上 --> <link name="camera_link"/> <joint name="camera_joint" type="fixed"> <parent link="base_link"/> <child link="camera_link"/> <origin xyz="0.14 0 0.16" rpy="0 0 0"/> </joint> <!-- 光学坐标系:z 朝前、x 朝右,图像消息的 frame_id 指向它 --> <link name="camera_optical_link"/> <joint name="camera_optical_joint" type="fixed"> <parent link="camera_link"/> <child link="camera_optical_link"/> <origin xyz="0 0 0" rpy="-1.5708 0 -1.5708"/> </joint> </robot>这段 URDF 里三个数字值得单独强调。base_link离地 0.085 m,如果你直接写 0,整台车在 rviz 里会陷进地面,雷达扫描平面跟着下移,建出来的地图会把墙角切掉。laser_link的 x 前移 0.10 m,这个值的意义是让雷达数据和车体轮廓对齐;写反了,避障时车头会保持一个错误的安全距离。camera_optical_link那个rpy="-1.5708 0 -1.5708"是 ROS 的固定约定,漏掉它,你用图像做任何投影计算都会差 90°。
还有一条常被忘掉:laser_link和camera_link在 URDF 里是 fixed joint,但雷达驱动节点发布的/scan消息里也有一个frame_id,两处必须写成完全一样的字符串。差一个下划线,TF 就查不到,rviz 里报 "No transform from [laser] to [map]",然后你花两小时怀疑雷达坏了。
3. 驱动层落地:串口协议、雷达接入与相机标定三件事
驱动层是整个项目里最容易「看起来跑通了其实没跑对」的一层。rviz 里点云在动不代表数据可用,图像在刷不代表标定有效。这一章按底盘、雷达、相机的顺序把每一段的自检点写清楚。
3.1 底盘串口协议与里程计解算
底盘和主控之间的协议,我一般定成「帧头 + 长度 + 载荷 + 校验」的定长帧,比直接发 ASCII 字符串更抗干扰,也方便单片机端用状态机解析。
#!/usr/bin/env python # -*- coding: utf-8 -*- import math import struct import serial import rospy import tf2_ros from nav_msgs.msg import Odometry from geometry_msgs.msg import TransformStamped, Quaternion WHEEL_RADIUS = 0.0325 # 轮半径 m,实测轮径 65 mm,不要用标称值 WHEEL_BASE = 0.200 # 左右驱动轮中心距 m TICKS_PER_REV = 1320.0 # 编码器每圈脉冲:11 线 x 减速比 30 x 4 倍频 def ticks_to_dist(ticks): return ticks / TICKS_PER_REV * 2.0 * math.pi * WHEEL_RADIUS def yaw_to_quat(yaw): # 平面运动只有 yaw,四元数手算比调库更省依赖 return Quaternion(0.0, 0.0, math.sin(yaw / 2.0), math.cos(yaw / 2.0)) class BaseOdom(object): def __init__(self): rospy.init_node('base_odom') self.ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=0.05) self.pub = rospy.Publisher('/odom', Odometry, queue_size=20) self.br = tf2_ros.TransformBroadcaster() self.x = self.y = self.th = 0.0 self.last_ticks = None self.last_time = rospy.Time.now() def read_frame(self): # 帧格式: 0xAA 0x55 | len | payload(两个 int16) | checksum head = self.ser.read(2) if len(head) < 2 or head != b'\xaa\x55': return None n = ord(self.ser.read(1)) payload = self.ser.read(n) chk = ord(self.ser.read(1)) if len(payload) != n: return None if (sum(bytearray(payload)) & 0xFF) != chk: return None # 校验错直接丢帧,不要凑合用 return struct.unpack('<hh', payload) # 左轮、右轮累计脉冲,有符号 def publish(self, stamp, v, w): q = yaw_to_quat(self.th) odom = Odometry() odom.header.stamp = stamp odom.header.frame_id = 'odom' odom.child_frame_id = 'base_footprint' odom.pose.pose.position.x = self.x odom.pose.pose.position.y = self.y odom.pose.pose.orientation = q odom.twist.twist.linear.x = v odom.twist.twist.angular.z = w # 协方差给粗值即可,SLAM 前端会拿它做权重 odom.pose.covariance[0] = 0.02 odom.pose.covariance[7] = 0.02 odom.pose.covariance[35] = 0.05 self.pub.publish(odom) t = TransformStamped() t.header.stamp = stamp t.header.frame_id = 'odom' t.child_frame_id = 'base_footprint' t.transform.translation.x = self.x t.transform.translation.y = self.y t.transform.rotation = q self.br.sendTransform(t) def spin(self): while not rospy.is_shutdown(): frame = self.read_frame() if frame is None: continue left, right = frame now = rospy.Time.now() if self.last_ticks is None: self.last_ticks = (left, right) self.last_time = now continue dl = ticks_to_dist(left - self.last_ticks[0]) dr = ticks_to_dist(right - self.last_ticks[1]) self.last_ticks = (left, right) dt = (now - self.last_time).to_sec() self.last_time = now if dt <= 0.0 or dt > 0.5: continue # 时间跳变(比如主控卡了一下)时丢弃这一帧 v = (dr + dl) / 2.0 / dt w = (dr - dl) / WHEEL_BASE / dt # 中值积分:先更新航向再投影,小转弯时比欧拉积分收敛得好 self.th += w * dt self.x += v * math.cos(self.th) * dt self.y += v * math.sin(self.th) * dt self.publish(now, v, w) if __name__ == '__main__': BaseOdom().spin()几个参数决定成败。WHEEL_RADIUS一定要拿卡尺量实际轮径,标称 65 mm 的轮子装上橡胶胎皮可能是 67 mm,2 mm 的偏差在 10 m 直线距离上就是 30 cm 的累积误差。TICKS_PER_REV要和单片机端的上报逻辑对上,如果单片机发的是「每 10 ms 的增量」而不是「累计值」,那这里就要改成增量累加,否则里程计会瞬间发散。
dt > 0.5时丢帧这一行是经验值。串口偶尔会因为 USB 调度卡住几百毫秒,如果照常积分,这一帧会算出一个巨大的速度,位姿直接飞出去。丢一帧比错一帧划算。
速度指令的下行链路在同一个节点里补上就行,订阅/cmd_vel,限幅后按同样的帧格式发回单片机。限幅是必须的,导航栈偶尔会发出超过底盘能力的角速度,不裁剪的话轮子会失步,里程计和数据就对不上了。
3.2 激光雷达接入与扫描数据自检
雷达驱动用厂商提供的 ROS 包,或者用通用的串口雷达节点。启动之后不要只看 rviz,先跑三条命令。
# 1. 确认扫描频率,正常应在 8~10 Hz rostopic hz /scan # 2. 确认 frame_id 与 URDF 完全一致,输出应为 laser_link rostopic echo /scan/header -n 1 # 3. 确认角度范围与点数是整圈的,输出中 angle_increment 应约为 0.0087(2π/720) rostopic echo /scan/angle_increment -n 1/scan里有一个range_min,很多驱动默认给 0.0,实际上雷达近处有一圈盲区,通常 0.1~0.2 m 内的读数都是噪声。把这个值调成厂商给的盲区值,建图时就不会在车体周围糊出一圈假墙。
安装朝向要实测。把车放在一条笔直的走廊上,车头朝前,在 rviz 里看扫描点:如果走廊的墙在图上是一条斜线,说明雷达的 yaw 安装角有偏差,在 URDF 的laser_joint里补一个rpy修正;如果左右墙的远近跟实物相反,那就是雷达装反了 180°,改rpy的第三个值。
3.3 摄像头标定与图像管线
相机这一环最容易被跳过,因为「图像能看到东西」给人一种已经可用的错觉。只要你想用图像测距、做目标定位,内参就必须标。
# 棋盘格常见规格 8x6,方格边长按手头标定板实测,单位是米 rosrun camera_calibration cameracalibrator.py \ --size 8x6 \ --square 0.108 \ image:=/camera/image_raw \ camera:=/camera标定过程中要把标定板铺满画面的四个角和中心,并且做几组不同倾角的姿态,让 X、Y、Size、Skew 四个指示条都变绿再点 CALIBRATE。只在一个位置拍十几张,标出来的畸变系数基本没用。
标定结果保存成 yaml,在 launch 里通过camera_info_url指过去。这里有个坑:标定时的分辨率必须和实际运行的分辨率一致。用 640×480 标定,运行时改成 1280×720,内参矩阵里的焦距是以像素为单位的,换了分辨率就全错了,很多人以为标定没用,其实是分辨率对不上。
<launch> <!-- 底盘串口节点,端口号建议后面换成 udev 固定别名 --> <node pkg="rosserial_python" type="serial_node.py" name="base_driver" output="screen"> <param name="port" value="/dev/ttyUSB0"/> <param name="baud" value="115200"/> </node> <!-- 雷达驱动,frame_id 必须与 URDF 中的 laser_link 一字不差 --> <node pkg="laser_driver" type="laser_node" name="laser" output="screen"> <param name="frame_id" value="laser_link"/> <param name="angle_min" value="-3.14159"/> <param name="angle_max" value="3.14159"/> <param name="range_min" value="0.12"/> <param name="range_max" value="12.0"/> </node> <!-- 相机:分辨率与标定文件必须匹配,frame_id 指向光学坐标系 --> <node pkg="usb_cam" type="usb_cam_node" name="camera" output="screen"> <param name="video_device" value="/dev/video0"/> <param name="image_width" value="640"/> <param name="image_height" value="480"/> <param name="pixel_format" value="yuyv"/> <param name="camera_frame_id" value="camera_optical_link"/> <param name="camera_info_url" value="file://$(find car_bringup)/config/camera.yaml"/> </node> <!-- URDF 与关节状态,TF 树的静态部分从这里长出来 --> <node pkg="robot_state_publisher" type="robot_state_publisher" name="rsp"/> <node pkg="joint_state_publisher" type="joint_state_publisher" name="jsp"/> </launch>这份 launch 的结构值得照搬:所有硬件节点集中在一个文件,参数外置,output="screen"保留日志方便排查。真机调试时,把jsp换成读实车关节状态的节点即可,其余不动。
4. 感知与建图:把激光与图像拧成一张能用的地图
驱动层跑通之后,项目的核心工作才开始:让车自己走一圈,回来给你一张墙是墙、门是门的地图。这一步做不好,后面导航全是空中楼阁。
4.1 里程计标定:先修内功再谈建图
建图前必须先标里程计,顺序不能反。SLAM 前端用里程计做位姿初值,初值偏了,匹配算法再强也只能在错误的初值附近找局部最优,表现出来就是地图重影、回环拉不回来。
标定分两项。直线标定:在地面贴一条 2 m 的直线,遥控车沿直线走完,读/odom的 x 累计值,和实际距离比。如果实测 2 m、里程计报 1.90 m,说明轮径或脉冲当量偏小,把WHEEL_RADIUS乘以 2.0/1.90 = 1.053。角度标定:原地转 10 整圈,读最终的 yaw 累计值,与实际 20π 比较,偏大偏小就按同样比例修WHEEL_BASE。
两项各标两轮,一般就能把误差压到 2% 以内。这一步花二十分钟,能省掉后面几小时的怀疑人生。
4.2 激光 SLAM 参数与建图流程
建图节点可选的不止一个,我一般在两种里选:一是经典的粒子滤波方案,参数直观、资料多,课程设计够用;二是基于图优化的方案,回环处理更好,适合场地大、要跑多次的场景。两者的配置思路一致,下面以粒子滤波方案为例,参数放在 yaml 里。
# 建图关键参数,未列出的保持默认 base_frame: base_footprint # 必须与 TF 树中的底盘坐标系同名 odom_frame: odom map_frame: map scan_topic: scan maxUrange: 8.0 # 参与匹配的最大距离,比量程小一点更省算力 maxRange: 12.0 # 与雷达实际量程一致 delta: 0.05 # 栅格分辨率 5 cm,室内足够;场地大就调到 0.1 particles: 30 # 粒子数,主控算力弱降到 15~20,先跑起来再谈精度 linearUpdate: 0.15 # 走 15 cm 更新一次,太小会把 CPU 打满 angularUpdate: 0.15 # 转 0.15 rad 更新一次 temporalUpdate: -1.0 # 关闭定时更新,交给运动触发 resampleThreshold: 0.5 xmin: -20.0 ymin: -20.0 xmax: 20.0 ymax: 20.0particles和linearUpdate是一对:粒子多、更新密,地图质量好但 CPU 吃紧;树莓派上建议从 20 粒子、0.2 m 更新起步,先确认能出图,再往上加。反过来先调高参数,很可能建到一半节点被系统杀掉,还被误判成算法有问题。
建图的动作规范也有讲究。用键盘遥控节点以低速走,转弯时慢一点,同一面墙尽量只走一遍;走完回到起点附近,让扫描数据和地图起点重合,回环才有机会闭合。走完保存地图:
# 保存为 map.pgm 与 map.yaml 两个文件,后者记录分辨率与原点 rosrun map_server map_saver -f ~/maps/room保存出来的 yaml 里有个origin字段,是地图左下角在全局坐标里的位姿,后面定位时要用,别随手删。
4.3 摄像头在导航链路里的位置
摄像头在这个方案里不承担主定位,它做三件事:给操作者提供第一视角、给特定任务提供目标位姿、给答辩提供一个直观的演示点。分工清楚,系统才不会因为视觉算法崩了而整体瘫痪。
第一种用法是标记识别。在目标点贴一个方形标记,用识别包解出标记在相机坐标系下的位姿,再通过 TF 转换到map坐标系,就能给导航栈发一个精确的目标点。这条链路的价值是:激光雷达只能告诉你「前面有东西」,摄像头能告诉你「前面那个东西是什么、在哪」。
import rospy import tf2_ros from geometry_msgs.msg import PoseStamped class MarkerGoal(object): def __init__(self): self.br = tf2_ros.Buffer() self.listener = tf2_ros.TransformListener(self.br) self.goal_pub = rospy.Publisher('/move_base_simple/goal', PoseStamped, queue_size=1) def to_map(self, pose_in_camera, camera_frame='camera_optical_link'): # 把相机系下的目标位姿变换到地图系,5 cm 内精度足够做导航目标 try: trans = self.br.lookup_transform('map', camera_frame, rospy.Time(0), rospy.Duration(0.2)) except (tf2_ros.LookupException, tf2_ros.ExtrapolationException) as e: rospy.logwarn('TF 查询失败: %s', e) return None goal = PoseStamped() goal.header.frame_id = 'map' goal.header.stamp = rospy.Time.now() # 简化处理:只取平移叠加,旋转用 TF 结果覆盖 goal.pose.position.x = trans.transform.translation.x + pose_in_camera[0] goal.pose.position.y = trans.transform.translation.y + pose_in_camera[1] goal.pose.orientation = trans.transform.rotation return goal这段代码里rospy.Time(0)表示取最新可用的变换,配合 0.2 s 的超时,避免因为 TF 还没发布完就报错。实际用的时候,pose_in_camera应该来自标记识别节点输出的完整位姿,这里只保留平移叠加是为了说明链路,正式代码里要做完整的四元数复合。
第二种用法是轻量的颜色或轮廓跟踪,用来做「跟着某个物体走」的演示;第三种是跑一个轻量检测模型做语义标注,把检测框投到地图上。三种都不影响主链路,视觉节点挂了,激光建图和导航照常工作——这个隔离性本身就是设计的一部分,答辩时也值得说一句。
5. 避坑与排查:五类最容易翻车的问题及处理路径
这一章是我自己踩过的坑里挑出来的五条。它们的共同点是:现象看起来都像「算法不行」,实际上全是配置或约定没对上。
5.1 地图左右镜像或整圈反向
现象:建完图发现走廊在图上和实际左右相反,或者门开在错误的一侧。
原因通常有三个:雷达物理安装旋转了 180°,但 URDF 里没补 yaw;驱动的inverted参数被误开;或者建图节点拿到的angle_increment符号不对,点序反了。三者表现相似,但处理方式不同。
处理办法是用「单点验证法」:把车正对一面墙停下,只留这面墙在扫描范围内,看 rviz 里点云出现在车头方向还是车尾方向。如果出现在车尾,就是安装朝向问题,改 URDF 的rpy第三个值加 3.14159;如果方向对但左右反了,检查驱动的镜像参数;如果点云是一个完整的圆但顺序错乱,那就是点序问题,换驱动或改angle_min/angle_max的符号。
5.2 建图重影、回环拉不回来
现象:走一圈回到起点,同一面墙在图上出现两条平行的线,间隔十几厘米。
原因排序:第一位是里程计没标定,第二位是雷达和里程计的时间戳不同步,第三位是use_sim_time被打开但没喂时钟。第一条用第 4.1 节的方法解决。第二条要靠rosbag record录一段数据回放对比,看/scan和/odom的时间戳差是否超过 50 ms,超了就在驱动里对齐。第三条最常见也最冤:参数文件里use_sim_time忘了改回 false,所有节点都在等一个永远不会来的仿真时钟,话题频率看起来正常,但 TF 查询全都超时。
5.3 串口丢包、底盘一顿一顿
现象:遥控时车走走停停,/odom的位置跳变。
原因一是 USB 转串口的带宽或调度问题,尤其在树莓派上同时接了雷达和相机时更明显,可以把底盘和雷达分到不同的 USB 控制器上,或者给串口加 udev 固定别名,避免枚举顺序变化导致端口号漂移。原因二是读取方式阻塞:serial.read(2)在 timeout 内没读到数据时返回空,如果代码里没判断长度就直接struct.unpack,会抛异常或读到错位的数据。原因三是编码器计数溢出,单片机用 int16 存累计脉冲,转几圈就溢出了,表现是里程计突然跳一大段。解决是把上报字段改成 int32,或者让单片机每帧发增量、由上位机累加。
5.4 标定之后测距还是偏
现象:相机标定做完了,标记位姿解出来距离对不上,越远偏得越多。
第一条检查项是分辨率。标定用了 640×480,运行时用了 1280×720,内参矩阵必须按比例缩放,否则焦距全错。第二条是畸变模型,广角镜头只用五参数模型在边缘残余畸变会很明显,标定时要看重投影误差,超过 1 像素就重标。第三条最隐蔽:camera_info消息里的K和 yaml 里的内参被不同节点读了两份,一份是标定文件、一份是代码里硬编码的默认值,两边打架。统一从camera_info取,代码里不要留默认内参。
5.5 CPU 打满、控制延迟越来越大
现象:跑一段时间后节点响应变慢,/cmd_vel发出去半秒车才动。
原因一是图像分辨率和帧率过高,单路 1280×720@30fps 在树莓派上就能吃掉大半个 CPU,降到 640×480@15fps 往往不影响功能。原因二是雷达点数太多或建图更新太密,把linearUpdate从 0.1 调到 0.2 立刻见效。原因三是节点里写了rospy.loginfo打印高频数据,日志本身的开销被严重低估,正式跑的时候把调试日志降成logdebug或直接去掉。
6. 验收与答辩:用一张测试表把「能跑」变成「可证明」
功能做出来和能证明它做出来了,是两件事。答辩现场最尴尬的场面是:车在地上来回走,评委问「精度多少」,答不上来。所以我在项目收尾阶段会强制自己填一张测试表,每个测试项配一个单独的 launch,只启动相关节点,测完记录数据。
| 测试项 | 操作方法 | 通过判据 |
|---|---|---|
| 静态 TF 完整性 | rosrun tf view_frames生成 PDF | 树无环,laser_link/camera_optical_link均挂在base_link下 |
| 里程计直线精度 | 地面标 2 m,直行往返各 3 次 | 往返平均误差 < 2%,反向不累加 |
| 里程计角度精度 | 原地转 10 圈 | 角度误差 < 5% |
| 扫描数据有效性 | 车距墙 1.0 m,读/scan正前值 | 误差 < 3 cm,盲区内无有效读数 |
| 建图闭环 | 绕场地一圈回起点 | 同一面墙重影间隔 < 10 cm |
| 定点导航 | 地图上设 5 个目标点,各跑 3 次 | 到达率 100%,终点误差 < 15 cm |
| 动态避障 | 在路径上放置障碍物 | 能重新规划并绕行,无碰撞 |
表格里最值得下功夫的是「建图闭环」和「定点导航」两项。前者决定了地图质量的上限,后者是全部工作的最终体现。测导航时把/amcl_pose和/move_base/result录下来,答辩时可以直接放轨迹图,比口头描述有说服力得多。
现场演示要排顺序,我的习惯是:先放一段提前录好的完整跑图视频作为兜底,再上实车。实车演示从静态开始——打开 rviz 展示 TF 树和当前扫描与地图的重合度,让评委看到「初始定位是收敛的」;然后发一个近距离目标点,跑完再发一个需要绕障的远点。整个过程控制在三分钟内,不要试图现场建图,环境光、地面材质、人员走动都会让现场建图翻车,血泪经验。
答辩提问里高频出现的三个问题,提前准备好答案:里程计怎么标的、地图分辨率为什么取 5 cm、摄像头在系统里到底起什么作用。前两个能答出具体数字和取舍理由,第三个能说清「视觉不参与主定位,只做目标级补充」,基本就稳了。另外把rosbag里的一段关键数据留着,被问到「如果雷达被遮挡会怎样」这类假设性问题时,可以直接回放数据现场演示降级表现。
从那以后我每次交付这类小车,都会在上车前强制走一遍静态 TF 检查和里程计直线标定,哪怕昨天刚测过——硬件这东西,螺丝松半圈、轮胎磨一点,数据就变了。希望这套结构能帮到你,少走几段我走过的弯路。
本文还有配套的精品资源,点击获取