
简介这是一份面向 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 pkgcar_base typebase_driver_node namebase_driver outputscreen rosparam file$(find car_base)/config/base.yaml commandload/ /node !-- 雷达发布 /scanframe_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 namerobot_description textfile$(find car_description)/urdf/car.urdf/ node pkgrobot_state_publisher typerobot_state_publisher namerobot_state_publisher/ /launch这段 launch 的写法有一个关键点robot_state_publisher必须在 URDF 参数之后启动否则静态 TF 会缺一帧rviz 报 No transform from [laser_link] to [map]看起来像雷达坏了其实是发布顺序问题。另一种做法是把雷达和摄像头的安装位置写成static_transform_publisher简单但外参一改就要动 launch不推荐长期使用。至于 ROS 版本一套新工程建议直接上 ROS NoeticROS1 的最后一代 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 namecar !-- base_footprint 贴地是导航用的基准帧z 恒为 0 -- link namebase_footprint/ link namebase_link visual geometrybox size0.25 0.20 0.10//geometry origin xyz0 0 0.05/ /visual /link joint namebase_joint typefixed parent linkbase_footprint/ child linkbase_link/ origin xyz0 0 0.10 rpy0 0 0/ /joint !-- 雷达外参xyz 是相对 base_link 的实际安装位置量准别拍脑袋 -- link namelaser_link/ joint namelaser_joint typefixed parent linkbase_link/ child linklaser_link/ origin xyz0.10 0.0 0.12 rpy0 0 0/ /joint !-- 摄像头装在前端光轴朝前 -- link namecamera_link/ joint namecamera_joint typefixed parent linkbase_link/ child linkcamera_link/ origin xyz0.14 0.0 0.15 rpy0 0 0/ /joint /robotbase_footprint这个帧不要省。它是把车体投影到地面后的虚拟帧导航、代价地图都默认机器人中心在 z0直接用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 查这里用占位符表示 KERNELttyUSB*, ATTRS{idVendor}VID_A, ATTRS{idProduct}PID_A, SYMLINKcar_base KERNELttyUSB*, ATTRS{idVendor}VID_B, ATTRS{idProduct}PID_B, SYMLINKlidar KERNELvideo*, ATTRS{idVendor}VID_C, SYMLINKcamera写完后执行sudo udevadm control --reload sudo udevadm trigger再插拔一次设备用ls -l /dev/lidar确认软链接指向正确。这一步做完launch 里就永远写别名不再关心 USB 插在哪个口。!-- lidar_driver/launch/lidar.launch -- launch node pkglidar_driver typelidar_node namelidar_node outputscreen param nameport value/dev/lidar/ param namebaudrate value115200/ param nameframe_id valuelaser_link/ param nameangle_min value-3.14159/ param nameangle_max value3.14159/ param namerange_min value0.15/ param namerange_max value8.0/ param namescan_frequency value10.0/ /node /launchframe_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_bridgeUSB 摄像头用usb_cam这类驱动包就能出图配置重点是分辨率、像素格式和设备名。注意pixel_format要和摄像头实际输出一致写成yuyv但设备只支持mjpeg时会出现话题有数据但图像花屏。!-- camera_driver/launch/camera.launch -- launch node pkgusb_cam typeusb_cam_node nameusb_cam outputscreen param namevideo_device value/dev/camera/ param nameimage_width value640/ param nameimage_height value480/ param namepixel_format valueyuyv/ param namecamera_frame_id valuecamera_link/ param nameio_method valuemmap/ param nameframerate value30/ /node !-- 相机内参不做标定就先给一个近似矩阵做视觉测距时必须重新标 -- node pkgcamera_calibration typecameracalibrator.py namecalibrator args--size 8x6 --square 0.024 image:/usb_cam/image_raw camera:/usb_cam/ /launchcamera_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 pkggmapping typeslam_gmapping nameslam_gmapping outputscreen param namebase_frame valuebase_footprint/ param nameodom_frame valueodom/ param namemap_frame valuemap/ param namemap_update_interval value2.0/ param namemaxUrange value6.0/ param namemaxRange value8.0/ param namesigma value0.05/ param namedelta value0.05/ param nameparticles value30/ param namelinearUpdate value0.2/ param nameangularUpdate value0.2/ param nametemporalUpdate value1.0/ param nameresampleThreshold value0.5/ param namexmin value-20.0/param nameymin value-20.0/ param namexmax value20.0/ param nameymax value20.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: truemax_vel_x先给 0.2 跑通整条链路再逐步往上加加太快会在窄门口出现冲过头再倒车的摆动。path_distance_bias和goal_distance_bias是一对矛盾体前者大走线准但遇障不灵活后者大避障积极但容易抄近路蹭墙。occdist_scale不要一上来就调很大先让车能走再按撞墙频率微调。启动顺序也有讲究先起 bringup再起定位最后起 move_baseroslaunch car_bringup bringup.launch roslaunch car_navigation amcl.launch # 加载地图 AMCL roslaunch car_navigation move_base.launchAMCL 起完后要在 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_size1) # 目标面积按实际距离标定这里给的是 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, keycv2.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 再动手出问题能回放不至于只能靠记忆猜。希望这些能帮你少走几步弯路。本文还有配套的精品资源点击获取