ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ROS四轮小车/scan与/odom链路调通及建图导航实战

ROS四轮小车/scan与/odom链路调通及建图导航实战

简介:这是一份面向 ROS 入门与机器人建模学习者的四轮小车模型描述资源,重点介绍如何搭建带摄像头与激光雷达传感器的移动平台。它通过 URDF 这一 XML 语言定义车身、车轮的连杆与关节,以及传感器节点、尺寸、质量与惯量等物理属性,帮助读者理解模型与 ROS 系统之间的衔接。压缩包共 6 个文件、约 22KB,以 urdf 模型文件为核心,配合 launch 启动脚本、package.xml 与 CMakeLists 等包配置、txt 说明及 gv、pdf 结构图,便于快速加载并核对整车结构关系。目前已有 2765 人学习下载。这份资源体积小但结构完整,可作为差速驱动小车的建模底稿;读者还可以替换传感器参数、调整连杆尺寸,并借助 tf 坐标变换、LaserScan 消息与图像传输话题开展避障、建图等实验,省去从零搭建描述包的重复工作。

1. 别急着上导航:这套ROS四轮车先跑通 /scan 和 /odom 才有意义

拿到一套带激光雷达和摄像头的ROS四轮小车,很多人的第一反应是直接照教程跑 move_base,结果车在 rviz 里原地画圈。原因不玄学,就是最底层的/scan、/odom、/tf三者没对齐。这套工程的价值在于,它把从底盘串口数据到地图上位姿的整条链路都摊开了:差速驱动、编码器里程计、激光雷达驱动、USB 摄像头图像采集,再到 gmapping 建图和 move_base 导航。适合两类人,一类是学完 ROS 基础想找一个整车工程练手,另一类是想在这台车上加视觉跟随、换定位方案做二次开发。硬件不贵,代码量也不大,难的是每个环节的参数都要对得上。

2. 整车架构:从电源到话题,链路怎么分工

一台小车看起来是一堆零件,落到软件上其实就几类节点:发指令的、读传感器的、算位姿的、画地图的。把节点和话题的关系理清楚,后面调参才有地方下手。这一章先把硬件组成和话题拓扑摆平,再把工作空间拆开,让你知道出问题时该翻哪个包。

2.1 硬件组成与话题拓扑

常见的四轮差速方案是两轮驱动加两轮随动,左右各一个带编码器的减速电机,中间一块电机驱动板负责把速度指令变成 PWM,同时把编码器计数打包成串口帧回传。主控一般是树莓派、工控机或者 Jetson 这类能跑 Ubuntu 的板子,接一个单线激光雷达和一个 USB 摄像头。部分方案会加一块 IMU,用来在轮速打滑时补角速度,但不是必须的。

整条数据流是这样:上位机发/cmd_vel速度指令,底盘驱动节点转成左右轮速下发给驱动板,驱动板回传编码器计数,驱动节点据此积分出/odom并广播odom -> base_footprint的 TF;激光雷达驱动发布/scan,摄像头驱动发布/image_raw和/camera_info;建图节点订阅/scan和 TF,输出/map;导航时 AMCL 订阅/scan和/map,发布map -> odom的 TF 修正。整条链路上任何一个环节的时间戳或坐标系写错,rviz 里就会直接表现出来。

话题消息类型发布者用途
/cmd_velgeometry_msgs/Twist遥控、导航线速度与角速度指令
/odomnav_msgs/Odometry底盘驱动轮式里程计位姿与速度
/scansensor_msgs/LaserScan雷达驱动二维激光扫描
/image_rawsensor_msgs/Image摄像头驱动原始图像
/mapnav_msgs/OccupancyGridgmapping / map_server栅格地图
/tftf2_msgs/TFMessage各节点坐标变换树

这张表最好打印出来贴在工位上。调试时先看某个话题有没有数据,再看它的header.frame_id对应不上 TF 树上的某个 link,问题基本就锁定在一两个包里面了。

2.2 工作空间的目录组织

推荐按职责拆包,不要把所有东西塞进一个功能包。整车工程的典型结构大概是这样:

catkin_ws/ └── src/ ├── car_bringup/ # 所有 launch 的汇总入口,一键起车 ├── car_base/ # 底盘驱动:串口、运动学、里程计 ├── car_description/ # URDF 模型、TF 外参、rviz 配置 ├── lidar_driver/ # 激光雷达驱动(也可用现成包) ├── camera_driver/ # 摄像头驱动与标定文件 ├── car_slam/ # 建图配置与地图保存脚本 └── car_navigation/ # move_base、AMCL、代价地图参数

car_bringup里放一个bringup.launch,把底盘、雷达、摄像头、URDF 的robot_state_publisher一起拉起来,这样每次实验只敲一条命令。car_description单独成包是因为 URDF 会被建图、导航、rviz 反复引用,它属于被依赖方,不该被塞进驱动包里。

<!-- car_bringup/launch/bringup.launch --> <launch> <!-- 底盘:串口 + 里程计 + odom->base_footprint 的 TF --> <node pkg="car_base" type="base_driver_node" name="base_driver" output="screen"> <rosparam file="$(find car_base)/config/base.yaml" command="load"/> </node> <!-- 雷达:发布 /scan,frame_id 必须是 laser_link --> <include file="$(find lidar_driver)/launch/lidar.launch"/> <!-- 摄像头:发布 /image_raw --> <include file="$(find camera_driver)/launch/camera.launch"/> <!-- 用 URDF 生成 base_link -> laser_link 等静态 TF --> <param name="robot_description" textfile="$(find car_description)/urdf/car.urdf"/> <node pkg="robot_state_publisher" type="robot_state_publisher" name="robot_state_publisher"/> </launch>

这段 launch 的写法有一个关键点:robot_state_publisher必须在 URDF 参数之后启动,否则静态 TF 会缺一帧,rviz 报 "No transform from [laser_link] to [map]",看起来像雷达坏了,其实是发布顺序问题。另一种做法是把雷达和摄像头的安装位置写成static_transform_publisher,简单但外参一改就要动 launch,不推荐长期使用。

至于 ROS 版本,一套新工程建议直接上 ROS Noetic(ROS1 的最后一代 LTS),社区包最全;如果目标平台是较新的 Ubuntu,就考虑 ROS2 加 nav2,驱动层写法差别不大,主要是话题命名和 launch 语法从 XML 变成 Python。下面章节的代码以 ROS1 为主,因为这类整车的现成资料大多集中在这一代。

3. 底盘驱动拆解:串口协议、里程计积分和一条不断裂的TF链

底盘驱动是整个工程的地基,它同时承担三件事:收速度指令、发轮速、读编码器算里程计。很多教程里这段代码被简化成"读一行字符串解析两个数",真车一跑就丢包。这一章把串口帧解析、差速运动学和 TF 广播拆开讲,每一段都是我在实际调试中会反复检查的地方。

3.1 串口帧格式与解析

驱动板和上位机之间最常见的是自定义二进制帧,典型结构是帧头加长度加类型加数据加校验。用文本协议虽然好调试,但高频回传时容易粘包。二进制帧的坑在于串口一次read未必读到完整一帧,解析器必须能处理半帧,所以一般会维护一个环形缓冲区。

// car_base/src/base_driver_node.cpp(节选) // 帧格式:0xAA 0x55 | len(1B) | type(1B) | payload(len B) | checksum(1B) bool BaseDriver::parseFrame(const uint8_t* buf, size_t n, uint16_t& left, uint16_t& right) { for (size_t i = 0; i + 3 < n; ++i) { if (buf[i] != 0xAA || buf[i + 1] != 0x55) continue; // 找帧头 uint8_t len = buf[i + 2]; if (i + 4 + len >= n) return false; // 半帧,等下一批 uint8_t type = buf[i + 3]; uint8_t sum = 0; for (int k = 0; k < len; ++k) sum ^= buf[i + 4 + k]; // 异或校验 if (sum != buf[i + 4 + len]) continue; // 校验失败,滑动继续找 if (type == 0x01) { // 0x01 表示编码器回传 left = buf[i + 4] | (buf[i + 5] << 8); right = buf[i + 6] | (buf[i + 7] << 8); return true; } } return false; }

这段解析的两个设计取舍值得说清楚。一是"找不到完整帧就返回 false 而不是报错",因为串口数据天然分片,报错会让日志被刷屏;二是校验失败时不是丢弃整个缓冲区,而是i继续往后滑,这样即使前面混入了噪声字节也能重新同步。left、right是编码器累计计数,注意要用无符号 16 位接收后再根据变化量转成有符号增量,直接强转会在计数回绕时出现一个巨大的跳变,表现为里程计偶尔"瞬移"一下。

3.2 差速运动学与里程计积分

速度指令到轮速的换算很简单:设轮距为L,线速度v、角速度w,则左右轮线速度分别是v - w*L/2和v + w*L/2,再除以轮半径得到角速度,乘以减速比和每转计数就得到驱动板需要的目标值。反过来,编码器增量到里程计的积分才是容易写错的地方。

// 收到一帧编码器数据后调用,dl/dr 为左右轮走过的弧长(米) void BaseDriver::updateOdometry(double dl, double dr, const ros::Time& stamp) { double ds = (dl + dr) * 0.5; // 车体中心位移 double dth = (dr - dl) / wheel_sep_; // 车体转角增量 // 中值积分:用半程角度,比直接累加 theta 在大角速度下漂得小 x_ += ds * cos(theta_ + dth * 0.5); y_ += ds * sin(theta_ + dth * 0.5); theta_ += dth; if (theta_ > M_PI) theta_ -= 2 * M_PI; // 归一到 [-pi, pi] if (theta_ < -M_PI) theta_ += 2 * M_PI; // 发布 /odom nav_msgs::Odometry odom; odom.header.stamp = stamp; odom.header.frame_id = "odom"; odom.child_frame_id = "base_footprint"; odom.pose.pose.position.x = x_; odom.pose.pose.position.y = y_; odom.pose.pose.orientation = tf::createQuaternionMsgFromYaw(theta_); odom_pub_.publish(odom); // 广播 odom -> base_footprint,这一帧不能断 tf::Transform tf_odom; tf_odom.setOrigin(tf::Vector3(x_, y_, 0.0)); tf_odom.setRotation(tf::createQuaternionFromYaw(theta_)); tf_broadcaster_.sendTransform( tf::StampedTransform(tf_odom, stamp, "odom", "base_footprint")); }

参数上有三个地方必须实测校准。wheel_sep_是左右轮接地点之间的距离,不是车宽,量错了走直线会画弧;wheel_dia_是轮胎实际滚动直径,充气轮胎要按负载下的直径算;ticks_是每转总计数,等于编码器线数乘四倍频再乘减速比。我一般会让车沿直线走三米,看/odom里x是不是接近 3.0,再原地转十圈看theta是否累计到 20π,偏差超过 3% 就回去改这两个参数。

tf_broadcaster_.sendTransform每帧都要发,频率跟随编码器回传频率(常见 20 到 50 Hz)。如果只是降频发布,rviz 里雷达点云会跟着车"跳",看起来像雷达延迟,其实缺的是 TF。

3.3 TF树与 URDF 外参

整车的 TF 树是map -> odom -> base_footprint -> base_link -> laser_link / camera_link。map -> odom由定位节点(建图时是 gmapping,导航时是 AMCL)发布,odom -> base_footprint由底盘驱动发布,剩下的是 URDF 里的固定外参。任何一条断了,rviz 都会报 "No transform"。

<!-- car_description/urdf/car.urdf(节选) --> <robot name="car"> <!-- base_footprint 贴地,是导航用的基准帧,z 恒为 0 --> <link name="base_footprint"/> <link name="base_link"> <visual> <geometry><box size="0.25 0.20 0.10"/></geometry> <origin xyz="0 0 0.05"/> </visual> </link> <joint name="base_joint" type="fixed"> <parent link="base_footprint"/> <child link="base_link"/> <origin xyz="0 0 0.10" rpy="0 0 0"/> </joint> <!-- 雷达外参:xyz 是相对 base_link 的实际安装位置,量准,别拍脑袋 --> <link name="laser_link"/> <joint name="laser_joint" type="fixed"> <parent link="base_link"/> <child link="laser_link"/> <origin xyz="0.10 0.0 0.12" rpy="0 0 0"/> </joint> <!-- 摄像头装在前端,光轴朝前 --> <link name="camera_link"/> <joint name="camera_joint" type="fixed"> <parent link="base_link"/> <child link="camera_link"/> <origin xyz="0.14 0.0 0.15" rpy="0 0 0"/> </joint> </robot>

base_footprint这个帧不要省。它是把车体投影到地面后的虚拟帧,导航、代价地图都默认机器人中心在 z=0,直接用base_link当地面基准会让障碍物膨胀层算在错误的高度上。外参里的xyz用卷尺量到厘米级即可,rpy里的横滚和俯仰如果能用水平仪测一下更好,雷达向前倾斜两度,建出来的墙就会呈扇形,这属于后面调参调不回来的硬件误差。

4. 传感器接入:激光雷达的 /scan 和摄像头的图像话题怎么落地

传感器这一层的目标只有一个:让/scan和/image_raw带着正确的时间戳和坐标系出现在话题列表里。听起来简单,但设备名、权限、波特率、frame_id 每一样都能卡住半天。这一章按设备接入的实际顺序写,先固定设备名,再验证数据,最后接图像。

4.1 激光雷达的接入与 /scan 自检

单线激光雷达有串口和网口两种,串口版最常见。驱动本身用现成的社区包即可,重点在设备名和参数。如果直接写/dev/ttyUSB0,插拔顺序一变,雷达和底盘驱动板的设备号就会互换,表现为底盘不动、雷达数据乱码。稳妥做法是写一条 udev 规则,按芯片的 VID/PID 或者序列号固定成别名。

# /etc/udev/rules.d/99-car.rules # 具体 VID/PID 用 lsusb 查,这里用占位符表示 KERNEL=="ttyUSB*", ATTRS{idVendor}=="<VID_A>", ATTRS{idProduct}=="<PID_A>", SYMLINK+="car_base" KERNEL=="ttyUSB*", ATTRS{idVendor}=="<VID_B>", ATTRS{idProduct}=="<PID_B>", SYMLINK+="lidar" KERNEL=="video*", ATTRS{idVendor}=="<VID_C>", SYMLINK+="camera"

写完后执行sudo udevadm control --reload && sudo udevadm trigger,再插拔一次设备,用ls -l /dev/lidar确认软链接指向正确。这一步做完,launch 里就永远写别名,不再关心 USB 插在哪个口。

<!-- lidar_driver/launch/lidar.launch --> <launch> <node pkg="lidar_driver" type="lidar_node" name="lidar_node" output="screen"> <param name="port" value="/dev/lidar"/> <param name="baudrate" value="115200"/> <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.15"/> <param name="range_max" value="8.0"/> <param name="scan_frequency" value="10.0"/> </node> </launch>

frame_id必须和 URDF 里的laser_link一字不差,大小写都算。range_min不要照抄驱动默认的 0.0,激光雷达近距离有盲区,设成 0.15 能挡掉车体自身反射;range_max建议比雷达标称值略小,超过可靠量程的点在代价地图里会变成随机障碍物。启动后按顺序验证:

rostopic hz /scan # 频率应稳定在 10Hz 左右 rostopic echo -n1 /scan/header # 看 frame_id 和 stamp 是否正常 rosrun tf tf_echo base_link laser_link # 看外参平移是否与实测一致

rostopic hz如果只有 3 到 5 Hz,多半是 USB 转串口带宽不足或者波特率配错;header.stamp如果是 0,说明驱动没有填时间戳,建图会直接失败。这三条命令是每次上车前的固定动作,比事后翻日志快得多。

4.2 摄像头图像链路与 cv_bridge

USB 摄像头用usb_cam这类驱动包就能出图,配置重点是分辨率、像素格式和设备名。注意pixel_format要和摄像头实际输出一致,写成yuyv但设备只支持mjpeg时,会出现"话题有数据但图像花屏"。

<!-- camera_driver/launch/camera.launch --> <launch> <node pkg="usb_cam" type="usb_cam_node" name="usb_cam" output="screen"> <param name="video_device" value="/dev/camera"/> <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_link"/> <param name="io_method" value="mmap"/> <param name="framerate" value="30"/> </node> <!-- 相机内参:不做标定就先给一个近似矩阵,做视觉测距时必须重新标 --> <node pkg="camera_calibration" type="cameracalibrator.py" name="calibrator" args="--size 8x6 --square 0.024 image:=/usb_cam/image_raw camera:=/usb_cam"/> </launch>

camera_frame_id同样要和 URDF 里对上。标定节点那行平时注释掉,只在需要精度时启用,用棋盘格标完会生成一个 yaml,把camera_matrix和distortion_coefficients写进camera_info的发布参数里。

图像进到 ROS 之后,处理节点一般用cv_bridge转成 OpenCV 的 Mat,这一步有编码格式和内存拷贝两个细节。转bgr8最省事,passthrough保留原编码但后续处理要自己判断;高分辨率高帧率下每帧深拷贝会吃满 CPU,能用toCvShare就别用toCvCopy。验证图像链路正常,最简单的是rqt_image_view选/usb_cam/image_raw,能出画面就说明驱动和内参链路通了,再谈检测和识别。

5. 建图与导航:gmapping 到 move_base 的可抄参数

前面两章把数据和坐标都对齐了,建图和导航才有基础。这一章给的是能直接跑起来的参数组合,不是最优解,但在一台轮距三十多厘米、雷达量程八米左右的小车上足够稳定。调参顺序很重要:先建一张干净的地图,再调定位,最后调局部规划,顺序反了会发现怎么调都不对。

5.1 建图:gmapping 参数与操作流程

建图阶段只依赖/scan和odom -> base_footprint的 TF,不需要地图。gmapping 的核心参数就那么几个,剩下的用默认值。maxUrange控制参与建图的最大距离,比雷达量程小一点能减少远处噪声;delta是地图分辨率,0.05 米够用,调到 0.02 会让地图文件翻几倍;linearUpdate和angularUpdate是更新阈值,调大能让建图更"懒",但太快走会漏掉角落。

<!-- car_slam/launch/gmapping.launch --> <launch> <node pkg="gmapping" type="slam_gmapping" name="slam_gmapping" output="screen"> <param name="base_frame" value="base_footprint"/> <param name="odom_frame" value="odom"/> <param name="map_frame" value="map"/> <param name="map_update_interval" value="2.0"/> <param name="maxUrange" value="6.0"/> <param name="maxRange" value="8.0"/> <param name="sigma" value="0.05"/> <param name="delta" value="0.05"/> <param name="particles" value="30"/> <param name="linearUpdate" value="0.2"/> <param name="angularUpdate" value="0.2"/> <param name="temporalUpdate" value="1.0"/> <param name="resampleThreshold" value="0.5"/> <param name="xmin" value="-20.0"/><param name="ymin" value="-20.0"/> <param name="xmax" value="20.0"/> <param name="ymax" value="20.0"/> </node> </launch>

操作流程是三步。第一步roslaunch car_bringup bringup.launch起车,再roslaunch car_slam gmapping.launch起建图,rviz 里把 Fixed Frame 设成map,加 LaserScan 和 Map 两个显示项。第二步用键盘遥控走一圈,速度控制在 0.2 m/s 以内,转弯慢一点,每个房间走两遍让粒子收敛。第三步保存地图:

rosrun map_server map_saver -f ~/maps/room

会得到room.pgm和room.yaml。拿到地图后第一件事是打开图片看墙是不是双层的,双层说明里程计标定有偏差或者建图时走太快,回第三章重新校轮距,不要指望导航阶段能补救。经验上,一张干净的图比一堆花哨的规划参数值钱。

5.2 导航:move_base 的三层配置

导航启动的是map_server加载地图、amcl做定位、move_base做规划。AMCL 负责发布map -> odom,它和底盘发布的odom -> base_footprint接起来,整棵 TF 树才完整。move_base的配置拆成三个 yaml:全局代价地图、局部代价地图、公共障碍参数。

# car_navigation/config/costmap_common_params.yaml # 用矩形 footprint 比 radius 更贴墙,按车体实际外廓加 1cm 余量 footprint: [[-0.16, -0.13], [-0.16, 0.13], [0.16, 0.13], [0.16, -0.13]] footprint_padding: 0.02 obstacle_range: 3.0 raytrace_range: 4.0 inflation_layer: cost_scaling_factor: 3.0 # 越小越"怕墙" inflation_radius: 0.35 # 至少大于车体半宽 observation_sources: scan scan: {data_type: LaserScan, topic: /scan, marking: true, clearing: true, expected_update_rate: 0.3}
# car_navigation/config/global_costmap_params.yaml global_costmap: global_frame: map robot_base_frame: base_footprint update_frequency: 1.0 publish_frequency: 0.5 static_map: true transform_tolerance: 1.0
# car_navigation/config/local_costmap_params.yaml local_costmap: global_frame: odom robot_base_frame: base_footprint update_frequency: 5.0 publish_frequency: 2.0 static_map: false rolling_window: true # 局部地图跟着车走,别用静态地图 width: 3.0 height: 3.0 resolution: 0.05 transform_tolerance: 1.0

这三个文件的职责要分清:公共参数管障碍物怎么进代价地图,全局地图管"从哪到哪",局部地图管"眼前三米怎么绕"。rolling_window忘了开是最常见的错误,症状是局部代价地图固定在原地不跟着车移动,规划器看不到新障碍。

5.3 DWA 参数与调参顺序

局部规划器用 DWA,它的参数决定车的"性格":速度上限、加速度、路径贴合度、避障敏感度。调参顺序建议先限速,再调贴合,最后调避障。

# car_navigation/config/dwa_params.yaml DWAPlannerROS: max_vel_x: 0.35 min_vel_x: -0.10 max_vel_theta: 1.2 min_in_place_vel_theta: 0.4 acc_lim_x: 0.8 acc_lim_theta: 1.5 sim_time: 2.0 # 预演时长,太短会犹豫,太长会绕远 vx_samples: 6 vth_samples: 20 path_distance_bias: 32.0 # 越大越贴全局路径 goal_distance_bias: 24.0 # 越大越直奔目标 occdist_scale: 0.02 # 越大越怕障碍 xy_goal_tolerance: 0.15 yaw_goal_tolerance: 0.15 prune_plan: true

max_vel_x先给 0.2 跑通整条链路,再逐步往上加,加太快会在窄门口出现"冲过头再倒车"的摆动。path_distance_bias和goal_distance_bias是一对矛盾体,前者大走线准但遇障不灵活,后者大避障积极但容易抄近路蹭墙。occdist_scale不要一上来就调很大,先让车能走,再按撞墙频率微调。

启动顺序也有讲究,先起 bringup,再起定位,最后起 move_base:

roslaunch car_bringup bringup.launch roslaunch car_navigation amcl.launch # 加载地图 + AMCL roslaunch car_navigation move_base.launch

AMCL 起完后要在 rviz 里用 "2D Pose Estimate" 给一个初始位姿,否则粒子撒满整张地图,规划出来的路径会乱飞。这一步新手动不动就忘,然后怀疑是导航算法的问题。

6. 避坑排查:五处最容易翻车的地方和它们的解法

调车的时间大部分花在排查上,真正写代码的时间很少。下面这几条是我自己反复遇到过、也在别人的车上见过的坑,每一条都按现象、原因、解决的顺序写。遇到问题时按这个清单过一遍,能省不少时间。

6.1 底盘与 TF 层的问题

第一个坑是方向反了。现象是发/cmd_vel让车前进,车却原地打转或者后退,rviz 里的车头朝向和实际相反。原因通常有两个:左右电机的接线接反了,或者编码器 A/B 相序反了导致里程计转速符号相反。解决方式别急着拆线,先在驱动节点里加两个符号参数left_sign、right_sign,单独给一侧发正速度看轮子转向,把符号调对再上位;如果动的是编码器,就再把odom_sign单独调一次。注意电机方向和编码器方向要分开调,一起改会互相掩盖。

第二个坑是 TF 时间戳断层。现象是 rviz 里激光点云整体偏移或者报 "Extrapolation into the future",车一加速就明显,停着没事。原因是底盘驱动的 TF 广播用的是串口帧到达时间,而/scan用的是雷达自己的时钟,两者在负载高时能差出几十毫秒,transform_tolerance又给得太小。解决办法是把底盘 TF 的stamp统一取ros::Time::now(),并在代价地图和 AMCL 里把transform_tolerance放到 0.5 到 1.0 秒,同时在 launch 里确认所有节点用同一台机器的时钟,不要在分布式部署时各跑各的。

第三个坑是外参歪了。现象是建出来的地图墙是斜的,或者机器人明明在走廊中间,代价地图却显示贴着一边的墙。原因是 URDF 里laser_link的rpy没量,凭感觉填了零。解决方式是拿水平仪贴在雷达支架上测横滚俯仰,或者用一堵已知笔直的墙做标定:把车平行停在墙边,看/scan里墙的距离在零度方向是否恒定,有偏差就调rpy里的 yaw 补偿。这个误差不修正,后面所有的定位精度都建立在歪的地图上。

6.2 传感器与时间同步的问题

第四个坑是串口权限和掉线。现象是启动时报 "Permission denied",或者跑几分钟后雷达数据停了、底盘不动,重启节点又能用一阵。原因一般是当前用户不在dialout组,或者 USB 供电不足导致转串口芯片反复重连。解决方式是sudo usermod -aG dialout $USER后重新登录,硬件上给雷达和主控分开供电,不要都从主控的 USB 口取电。跑长实验前用dmesg -w盯一下有没有 "device descriptor read error",有的话基本就是供电或者线材问题。

第五个坑是摄像头时间戳与激光不同步。现象是做视觉和雷达融合时,画面上看到的障碍物和/scan里的点对不上,车一动偏差更大。原因是usb_cam默认用接收时间做stamp,而实际曝光时刻更早。解决方式是尽量用支持硬件时间戳的相机,或者在低速场景下把车控制在 0.1 m/s 以内做近似;要做正经的融合,就得引入message_filters的ApproximateTime策略,并在标定阶段测一下固定延迟补偿掉。

7. 二次开发:在现成话题上加一个视觉跟随节点

这套车最舒服的地方在于话题边界清楚,加功能不用动底盘代码。以视觉跟随为例,目标就是订阅/usb_cam/image_raw,算出色块在画面里的横向偏移和面积,输出/cmd_vel。整个过程不碰驱动、不改 URDF,出问题直接杀掉节点车就停。

#!/usr/bin/env python # car_vision/scripts/color_follower.py import rospy, cv2, numpy as np from sensor_msgs.msg import Image from geometry_msgs.msg import Twist from cv_bridge import CvBridge class ColorFollower(object): def __init__(self): rospy.init_node('color_follower') self.bridge = CvBridge() self.pub = rospy.Publisher('/cmd_vel', Twist, queue_size=1) # 目标面积按实际距离标定,这里给的是 640x480 下 1 米左右的近似值 self.target_area = rospy.get_param('~target_area', 20000) self.kp_yaw = rospy.get_param('~kp_yaw', 0.004) self.kp_dist = rospy.get_param('~kp_dist', 0.00002) rospy.Subscriber('/usb_cam/image_raw', Image, self.on_image) def on_image(self, msg): frame = self.bridge.imgmsg_to_cv2(msg, 'bgr8') hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, np.array([0, 120, 70]), np.array([10, 255, 255])) cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cmd = Twist() if cnts: c = max(cnts, key=cv2.contourArea) area = cv2.contourArea(c) if area > 800: # 过滤噪点 cx = cv2.moments(c)['m10'] / cv2.moments(c)['m00'] err_x = cx - frame.shape[1] / 2.0 cmd.angular.z = -self.kp_yaw * err_x # 目标偏左就左转 cmd.linear.x = self.kp_dist * (area - self.target_area) self.pub.publish(cmd) if __name__ == '__main__': ColorFollower() rospy.spin()

两个比例系数必须先小后大。kp_yaw从 0.001 开始加,加到车能跟住但不来回摆;kp_dist的单位是"像素面积差到米每秒",量纲不直观,靠实测。安全上建议在节点里加一个"连续 N 帧没检测到目标就发零速"的看门狗,否则目标一消失,最后一条指令会一直生效,车会保持原速冲出去,这个坑我踩过一次,代价是撞了桌腿。更稳的做法是把跟随节点和 move_base 做成互斥,用一个/control_mode参数或话题切换,避免两个节点同时往/cmd_vel写指令。

还有一类改造是换定位方案,比如把 gmapping 换成 cartographer,或者上 ROS2 的 nav2。这类改造的验证方法很朴素:同一段走廊、同样路线跑三遍,把/odom和 AMCL 输出的位姿录成 rosbag,用evo之类的工具对比轨迹闭合误差,别看 rviz 里画得像不像。从那以后我每次改定位或底盘参数,都会强制先录一段固定路线的 rosbag 再动手,出问题能回放,不至于只能靠记忆猜。希望这些能帮你少走几步弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进