基于ROS的轨迹跟踪仿真:纯跟踪、PID与Stanley联合控制
2026/9/15 4:06:46 网站建设 项目流程

简介:面向ROS智能车轨迹跟踪算法学习与毕业设计场景的资源包,基于C++实现了纯跟踪、PID与Stanley控制算法,并通过ROS话题通信驱动仿真车在Gazebo环境中沿期望轨迹行驶。资源共61个文件,包含7个C++源文件用于核心算法实现,7个launch启动文件负责节点运行,16个yaml参数文件配置车辆与控制器参数,另有world仿真地图、rviz可视化配置、urdf/xacro模型以及PDF说明文档,整个压缩包仅136KB,轻量精简却结构完整。已有197人学习。项目中提供完整工作空间gazebo_test_ws,目录划分清晰:gazebo_map用于环境地图,my_navigation与gazebo_pkg承载导航和仿真逻辑,说明文档.zip内含运行步骤,便于快速复现。对于正在做课设、毕设或想深入理解轨迹跟踪算法的学生,可在Gazebo中快速验证PID纯跟踪与Stanley算法效果,并结合launch与参数文件按需调整控制参数,降低入门门槛,适合二次开发。

1. 基于ROS的轨迹跟踪仿真:纯跟踪、PID和Stanley联合控制思路

很多人在Gazebo里跑第一辆智能车时,最困惑的不是怎么发cmd_vel,而是“为什么PID明明把速度稳住了,车还是在绕圈时往外飘”。标题里的“PID法轨迹跟踪”和“纯跟踪结合PID和Stanley”并不是让你在三套算法里挑一个,而是指一套典型的工程分工:纵向速度闭环交给PID,横向转向在纯跟踪和Stanley之间切换。纯跟踪靠前视距离构造圆弧,适合中高速平滑跟随;Stanley直接让横向误差反馈到前轮转角,低速收敛快;PID再把速度环的波动压平。这篇文章按做这类仿真最常见可靠的做法,把算法原理、Gazebo环境搭建、C++节点实现和调试参数一起串起来,不逐行解读某个zip包,但同样能让你照这个思路复现并跑通一套完整仿真。

2. 先理清算法关系:横向偏差、前视距离与Stanley增益

三种算法名容易让人误以为它们是三选一的并列关系,实际它们作用在不同控制层。先分清控制量,后面写C++时才不会写成一堆互相打架的if分支。

2.1 用自行车模型定义两个核心误差量

低速智能车仿真里,车辆一般被简化为自行车模型:前轮转角δ直接决定转弯半径R = L / tan(δ),其中L是轴距。轨迹跟踪算法要控制的是两个量:车辆当前位置到最近参考路径点的垂直距离,称为横向误差e;车辆当前航向与路径切线方向的夹角,称为航向误差θe。纯跟踪主要用前视距离和航向角构造圆弧,Stanley同时用横向误差和航向误差,PID横向控制器则把这些误差直接作为输入量。

仿真里最容易踩的第一个坑是角度差没有归一化。C++里的atan2返回范围是[-π, π],而车辆航向累计值可能超过这个范围,两者直接相减会出现±2π的跳变,导致转向角突然反向。

// 把任意角度差限制在 [-π, π] 内,避免误差跳变 double normalizeAngle(double a) { while (a > M_PI) a -= 2.0 * M_PI; while (a < -M_PI) a += 2.0 * M_PI; return a; }

变量a是两角之差,可能是任意实数。这个函数在每轮控制循环里都会被调用,纯跟踪里的alpha和Stanley里的psi都依赖它。漏掉这一步的现象是:车在直线路径上跑着跑着突然打一个大角度的反向方向盘。

2.2 纯跟踪:前视距离决定圆弧几何

纯跟踪的出发点是:从车辆后轴中心出发,沿前进方向找一个距离为Ld的参考路径点,假设车辆从当前位置到该目标点走一段圆弧,那么这个圆弧的半径就确定了前轮转角。目标点相对车头方向的夹角alpha为:

// 找前视目标点 double dx = target.x - point.x; double dy = target.y - point.y; double lookahead = hypot(dx, dy); double alpha = normalizeAngle(atan2(dy, dx) - yaw); // 计算前轮转角 double delta = atan2(2.0 * wheelbase * sin(alpha), lookahead);

wheelbase是轴距,lookahead是前视距离。这个公式得细看:alpha越接近0,说明目标点在车正前方,delta趋近0;alpha越大,转弯越急;lookahead越大,转向越平缓。实际调试中若车在弯道内切,常见原因就是Ld相对弯道半径过大。

纯跟踪对小车模型有一个实际问题:差速小车没有前轮,angular.z和线速度v之间是ω = 2·v·sin(alpha)/Ld。很多C++实现里为了统一接口,先算出delta,再除以一个虚拟轴距换算成角速度,这个虚拟轴距写得不准,转弯就会整体偏慢或偏快。

2.3 Stanley:横向误差直接掰前轮

Stanley控制器是斯坦福大学在DARPA挑战赛里用过的方案,它的核心是把航向误差和横向误差一起放到前轮转角里:

// 最近路径点的切线方向 double path_yaw = atan2(path[nearest].y - path[nearest - 1].y, path[nearest].x - path[nearest - 1].x); double psi = normalizeAngle(yaw - path_yaw); double lateral = signedLateralError(car_x, car_y, path[nearest]); // Stanley 控制律 double delta = psi + atan2(stanley_k * lateral, v);

psi让车头先对齐路径方向,atan2项则根据横向偏差修正。注意v在分母上:速度越低,同一横向误差产生的转角越大。这种特性让Stanley在低速时收敛极快,但也容易原地猛打方向,所以实现时常把分母改成fmax(v, 0.1),防止v接近0时输出异常。

前进方向上的横向误差符号需要单独约定。常见约定是路径点在车身左侧为正,右侧为负:

// 用向量叉积方向判断符号 double signedLateralError(double x, double y, double yaw, Point p) { double dx = p.x - x; double dy = p.y - y; return cos(yaw) * dy - sin(yaw) * dx; }

这个公式把路径点投影到车身坐标系下,直接取y分量。符号约定错误时,车会在路径一侧反复震荡,且调大增益也不收敛。

2.4 PID在纵向和横向上怎么分工

PID在这里有两个完全不同的作用对象。纵向速度环:输入目标车速和当前车速,输出cmd_vel.linear.x。横向环直接用PID输出前轮转角,理论也能用,但实际效果往往不如前面两种几何法,原因是积分项在连续转弯时容易积累过量,形成“过弯前带着积分、过弯后继续顶方向”的滞后现象。

一种变体是级联PID控制:外环用横向误差生成目标前轮转角,内环用前轮转角误差输出角速度。这在有前轮角度反馈的实车上可行,但Gazebo仿真里程计里一般不直接提供前轮转角,需要额外估算,复杂度上涨不少。我在仿真项目里更倾向于:横向用纯跟踪和Stanley二选一,纵向用位置式PID,PID的输出只参与速度环,不参与转向环。

位置式PID的离散化差分方程是u(k) = Kp·e(k) + Ki·Σe(k)·dt + Kd·(e(k) - e(k-1))/dt,每一项的物理意义很直观。至于增量式PID,输出的是u(k) - u(k-1),适合集成在电机驱动控制器里的场景,用于整车速度控制时反而需要额外保存上一次输出,仿真里不必要。

2.5 三种算法在一辆车上的选择策略

综合来看,纯跟踪的优点是目标点永远在前方,车辆路径更平滑;Stanley的优点是能快速消除大横向偏差。两者结合时的常见策略是:横向偏差较大时先用Stanley快速拉回,偏差小且速度较高时切到纯跟踪维持平滑。PID则只负责速度环。下一章先搭建仿真环境,把参考路径发出来,再在C++节点里实现这组策略。

3. 搭建ROS仿真环境:从Gazebo底盘到参考路径

环境准备在整个项目里最容易被低估。代码没跑起来之前,先确认话题和坐标系是通的,否则后面所有调参都在对着一个错位路径做。

3.1 一键安装ROS基础环境与Gazebo

新装ROS机器最耗时间的是软件源和依赖。国内很多用户会直接使用鱼香ROS一键安装脚本,它可以自动完成换源、安装ROS基础工具和Gazebo。我的习惯是用这类脚本只做基础安装,后续功能包全部自己编译,这样能避免脚本锁死版本导致的环境兼容问题。安装完成后确认两个命令存在:

roscore gazebo --version

如果希望省去自己写URDF的步骤,可以直接使用turtlebot3_gazebo作为仿真底盘,打开一个带平坦地面的世界,并设置环境变量:

export TURTLEBOT3_MODEL=burger roslaunch turtlebot3_gazebo turtlebot3_world.launch

这个命令会启动Gazebo、机器人模型以及odom和cmd_vel话题。作为轨迹跟踪实验,burger模型足够用,它最大线速度虽然不高,但可以覆盖大多数低速调参场景。若要测试更高速度,也可以用waffle模型,差速模型和话题结构不变。启动成功后,用一个终端发布几秒速度指令验证底盘响应:

rostopic pub -r 10 /cmd_vel geometry_msgs/Twist \ '{linear: {x: 0.2, y: 0.0, z: 0.0}, angular: {z: 0.0}}'

里程计话题/odom开始更新,且Gazebo里小车向前移动,说明底盘部分已经可用。

3.2 发布一条闭合圆环参考路径

纯跟踪对路径点的连续性要求较高,路径点太稀会直接导致前视点跳变。常见做法是先用nav_msgs/Path发布闭合圆环路径,后续再替换成csv文件或全局规划器输出。下面的Python节点生成一个半径2m、圆心位于(2,0)的圆环:

#!/usr/bin/env python3 import math import rospy from nav_msgs.msg import Path from geometry_msgs.msg import PoseStamped rospy.init_node('circle_path_pub') pub = rospy.Publisher('/ref_path', Path, queue_size=1) path = Path() path.header.frame_id = 'odom' # 必须和车体里程计同一坐标系 for i in range(361): # 多取一个点,让首尾闭合 th = math.radians(i) p = PoseStamped() p.header.frame_id = 'odom' p.pose.position.x = 2.0 * math.cos(th) + 2.0 p.pose.position.y = 2.0 * math.sin(th) path.poses.append(p) rate = rospy.Rate(1) while not rospy.is_shutdown(): pub.publish(path) rate.sleep()

range(361)的目的是让路径首尾坐标重合,避免车跟踪到终点后突然横穿场区。发布频率设置为1Hz,是为了方便使用rostopic echo调试;实际跟踪节点订阅时会缓存最新Path,不需要高频重发。frame_id写成odom而不是map,原因是这里要跟踪的是一条相对起始位置的固定路径,不涉及全局定位漂移。

3.3 用Rviz确认坐标系对齐

打开Rviz,把Fixed Frame设为odom,添加Path display并选择/ref_path话题,可以看到蓝色圆环。再添加RobotModel和Odometry display,确认小车原点落在odom系原点附近。一个常见错位是:Path frame设置成map,而小车里程计发布在odom下,此时路径会显示在地图原点,两者相差一个坐标变换。仿真最开始建议先固定所有话题都在odom或map其中一个,跑通后再引入TF补全。

3.4 用launch文件整理启动顺序

上面命令分散在多个终端,调试时容易漏节点。一个最小launch文件可以把路径发布节点、后续的跟踪节点、Rviz一起拉起来:

<launch> <!-- 发布圆环参考路径 --> <node name="circle_path_pub" pkg="robot_track" type="circle_path_pub.py" output="screen"/> <!-- 轨迹跟踪控制节点 --> <node name="tracker_node" pkg="robot_track" type="tracker_node" output="screen"/> <!-- 可视化 --> <node name="rviz" pkg="rviz" type="rviz" args="-d $(find robot_track)/rviz/track.rviz"/> </launch>

pkg名和type名需要跟实际包结构一致。如果你没有预存track.rviz配置,可以把rviz这行先注释掉,手动打开Rviz配置。tracker_node的C++实现,下一章详细写。

4. C++源码实现:一个节点里同时跑纯跟踪、PID和Stanley

这一章给出可以直接抄进ROS节点的核心代码结构。重点是路径预处理、速度PID、转向控制切换和保护逻辑,四个部分缺一不可。

4.1 路径预处理:找最近点和前视点

Path消息里的点可能是任意密度,如果直接用最近点作为Stanley的参考点,路径点间隔不均匀会导致横向误差抖动。处理办法是:先对路径按累计弦长重采样到固定间隔,再遍历查找最近点和前视点。

// 在路径上找距当前位置最近点的索引 size_t findNearestIndex(const geometry_msgs::Pose& car, const std::vector<geometry_msgs::Pose>& path) { size_t best = 0; double best_d = std::numeric_limits<double>::max(); for (size_t i = 0; i < path.size(); ++i) { double dx = path[i].position.x - car.position.x; double dy = path[i].position.y - car.position.y; double d = std::hypot(dx, dy); if (d < best_d) { best_d = d; best = i; } } return best; }

最近点是Stanley横向误差的参考点,也是纯跟踪搜索前视点的起点。随后从最近点向前累加弧长,直到累计距离不小于前视距离Ld,返回该点作为纯跟踪的目标点:

// 从最近点往前找前视点,避免直接用欧氏距离跳过弯道 size_t findLookAheadIndex(size_t nearest, const std::vector<geometry_msgs::Pose>& path, double lookahead) { double dist = 0.0; size_t idx = nearest; while (dist < lookahead && idx < path.size() - 1) { ++idx; double dx = path[idx].position.x - path[idx-1].position.x; double dy = path[idx].position.y - path[idx-1].position.y; dist += std::hypot(dx, dy); } return idx; }

lookahead参数单位为米,典型值在0.3到1.5之间。用弧长累计而不是欧氏距离直接判断,能避免路径弯曲段因为两点之间直线距离变短而提前选中目标点。如果路径存在噪声,可以先对Path做前后三点平滑,再送入这两个函数。

4.2 速度PID:位置式差分方程与积分限幅

这里实现一个可复用的SpeedPid类,输入目标速度和当前速度,输出位置式PID结果。控制周期dt需要与主循环定时器保持一致,不能直接用rostopic回调时间。

class SpeedPid { public: void setParam(double kp, double ki, double kd, double dt) { kp_ = kp; ki_ = ki; kd_ = kd; dt_ = dt; integral_ = 0.0; prevErr_ = 0.0; } double update(double target, double actual) { double err = target - actual; integral_ += err * dt_; // 积分限幅是位置式PID的必备保护 integral_ = std::clamp(integral_, -10.0, 10.0); double derivative = (err - prevErr_) / dt_; prevErr_ = err; return kp_ * err + ki_ * integral_ + kd_ * derivative; } private: double kp_, ki_, kd_, dt_; double integral_; double prevErr_; };

参数kp、ki、kd分别对应比例、积分、微分系数,dt是控制周期,这里按20Hz取值0.05s。积分限幅正负10,防止直线启动时积分累积过大导致超调。增量式PID在这个场景中没有优势,因为它输出的是控制量的增量,还需要额外维护上次输出值;位置式直接输出目标线速度,语义更清晰。

4.3 转向控制:纯跟踪和Stanley切换

转向控制器的输入是当前车体姿态、最近路径点、前视点和当前线速度,输出是前轮转角。用一个枚举区分当前选择的横向控制模式:

enum class LateralMode { PurePursuit, Stanley }; double computeSteering(LateralMode mode, const geometry_msgs::Pose& car, const geometry_msgs::Pose& nearest, const geometry_msgs::Pose& spaceTarget, double v) { double yaw = tf::getYaw(car.orientation); if (mode == LateralMode::Stanley) { double path_yaw = atan2( nearest.position.y - pathAhead(nearest).position.y, nearest.position.x - pathAhead(nearest).position.x); // 实际项目中用nearest点的前一个相邻点计算切线更稳定 double psi = normalizeAngle(yaw - path_yaw); double latErr = signedLateralError(car.position.x, car.position.y, yaw, nearest); return psi + atan2(stanleyK_, lap_log * lateral_error ...); } else { // PurePursuit double alpha = normalizeAngle( atan2(spaceTarget.position.y - car.position.y, spaceTarget.position.x - car.position.x) - yaw); double lookahead = std::hypot( spaceTarget.position.x - car.position.x, spaceTarget.position.y - car.position.y); return atan2(2.0 * wheelbase_ * sin(alpha), lookahead); } }

Stanley的stanleyK_是横向误差增益,代表横向误差转成角度的强度。v作为速度传入,低速时需要保护。纯跟踪的目标点是第四章开头findLookAheadIndex返回的spaceTarget,计算前视距离时直接在函数内用hypot重新算,避免外部传参不一致。

4.4 输出限幅与停车保护

控制周期20Hz下,话题消息可能因为调度延迟而抖动。直接限幅是最后一层保险。最大前轮转角限制在±0.6 rad,角速度限制在±1.0 rad/s。同时增加里程计超时保护:如果0.5s内没有收到odom,立即发布零速指令,防止仿真暂停或话题断开后小车继续冲出去。

void timerCallback(const ros::TimerEvent&) { if ((ros::Time::now() - lastOdom_).toSec() > 0.5) { geometry_msgs::Twist stop; cmdPub_.publish(stop); return; } double v = speedPid_.update(targetSpeed_, currentSpeed_); double steer = computeSteering(mode_, car_, nearest_, target_, v); steer = std::clamp(steer, -0.6, 0.6); geometry_msgs::Twist cmd; cmd.linear.x = std::clamp(v, 0.0, 0.5); cmd.angular.z = std::clamp(2.0 * v * sin(alpha_) / lookahead_, -1.0, 1.0); cmdPub_.publish(cmd); }

角速度部分直接使用差速车的几何关系,而没有用delta换算,这是为了让小车在Gazebo里实际转角与指令严格对应。前轮转角delta用于日志记录和观察,cmd_vel里的angular.z用纯跟踪公式的角速度形式。参数表中,最大线速度0.5m/s对仿真小车偏保守,圆环试验可以从0.3m/s起步。

4.5 把控制器组织成一个Tracker类

一个干净的结构是把路径预处理、PID、转向器都用类封装,主节点只负责订阅和定时器回调。msg订阅关系如下:

/odom -> 更新当前位姿 /ref_path -> 更新参考路径 定时器 -> 计算并发布/cmd_vel

订阅/ref_path时要做一次路径拷贝,并重采样到等间隔。这样findNearestIndex和findLookAheadIndex的时间复杂度都是O(n),圆环路径301个点完全够用。

5. 调参与排错:速度PID参数、前视距离与Stanley增益

代码能跑起来只是第一步,真正的分水岭在参数。下面的调试顺序基于仿真场景,按速度环、横向环、异常排查三步走。

5.1 速度PID参数的经验整定顺序

速度环的目标是让车快速而无超调地达到目标速度。调整顺序固定为P、I、D:

  1. 先把Ki和Kd置0,只留Kp,从0.5开始逐步增加,观察rostopic echo /odom里的速度是否逼近目标值。
  2. 速度存在稳态误差时,再加Ki,每次增加0.02,观察是否出现震荡。
  3. Kd只在启动有明显超调时使用,仿真底盘本身阻尼不小,通常Kd为0也能稳定。

一个低负载仿真小车的参考参数范围如下表。注意这个范围只在turtlebot3和类似差速模型上有效,实车需要重新标定。

参数参考范围初始建议值说明
Kp0.5 ~ 2.51.0过小启动慢,过大会抖动
Ki0.05 ~ 0.20.1消除稳态误差,过大会超调
Kd0 ~ 0.050.01抑制速度波动,对噪声敏感
积分限幅8 ~ 1510防止积分饱和

速度环调好后,可以线速度恒定在0.3m/s,再用rqt_plot观察/cmd_vel/odom速度曲线的跟随情况。如果速度曲线呈锯齿状,常见原因是控制周期dt与实际定时器频率不一致,需要检查定时器创建时的Duration。

5.2 前视距离Ld怎么跟着车速变

纯跟踪的前视距离不是常量。速度越高,Ld应越大,否则车会在弯道上频繁修正方向。常见公式:

double lookahead = std::clamp(0.3 + v * 0.8, 0.3, 1.5);

v是当前线速度。对圆环半径2m的路径,Ld取1.2m左右比较合适。Ld太大,车在弯道内切严重,走的是比参考路径更短的弧线;Ld太小,车在直线段也会横向摆动。调试时可固定速度,松开Ld下限,观察圆环跟踪的横向误差大小。

还有一个经常被忽略的关联点:前视距离和路径点密度有关系。如果路径相邻点间隔50cm,而Ld只有0.3m,纯跟踪的前视点会在最近点附近原地跳动。前面重采样步骤把路径点间隔控制在10cm,就是为Ld留出充足调节空间。

5.3 Stanley增益的收敛与振荡

Stanley的横向增益默认从0.5起步,增大k会让车更快消除横向偏差。但k过大时,横向误差稍微增加一点,转角就大幅摆动。低速度下尤其明显,因为v在分母放大了e的影响。

// 速度接近0时仍保留一个最小分母,防止原地打轮 double delta = psi + atan2(stanleyK_ * lateralError, std::fmax(v, 0.1));

std::fmax(v, 0.1)用来保护低速场景。如果车以0.05m/s速度前进,原始公式会输出接近π/2的转角,车在原地反复调头。加了0.1的下限后,低速时Stanley退化为一个高增益P控制器,不会出现极端转角。

弯道场景中,Stanley对路径切线方向估计非常敏感。路径点稀疏或带噪声时,path_yaw会突变,表现为车在过弯时突然抽搐。解决办法是在路径预处理阶段用相邻三点的平均切线方向。

5.4 仿真异常排查速查表

下面几个现象是仿真中最常见的,按“现象—原因—调整项”顺序检查,基本能覆盖调试初期的全部问题。

现象可能原因调整项
车在直线上左右扭前视距离过小或Stanley增益过大增大Ld或减小k
转弯时明显内切Ld相对弯道半径过大降低Ld,或让Ld随速度再平滑下降
速度忽快忽慢,呈周期波动速度PID的积分系数过大减小Ki,检查控制周期dt
车在起点附近原地打转路径frame_id与odom不一致确认Path的header.frame_id
圆环跑完一圈后横穿路径没有闭合,首尾点不重合生成路径时多取一个闭合点
Stanley切入瞬间猛打方向横向误差符号突变检查signedLateralError符号约定

排查时多利用可视化。rqt_plot分别绘制横向误差和转向角,比盯着Gazebo画面判断更可靠。

6. 用离线脚本验证三套控制器的组合效果

最后要回答的问题是:凭什么说这套组合比单独用PID好。与其肉眼观察,不如录一段rosbag,离线算最大横向误差和平均横向误差。这个数据也能用于后续调参对比。

先启动跟踪节点和路径发布节点,记录所有跟控制有关的话题:

rosbag record -O track.bag /odom /cmd_vel /ref_path

让小车跑完至少一圈后,Ctrl+C结束录制。此时bag里有完整的odom轨迹和参考路径。用下面的Python脚本计算每个odom点到路径最近点的距离,作为横向误差近似:

import math import rosbag bag = rosbag.Bag('track.bag') ref = [] for topic, msg, t in bag.read_messages(topics=['/ref_path']): for pose in msg.poses: ref.append((pose.pose.position.x, pose.pose.position.y)) max_err = 0.0 total_err = 0.0 count = 0 for topic, msg, t in bag.read_messages(topics=['/odom']): x = msg.pose.pose.position.x y = msg.pose.pose.position.y # 路径点数不多,直接遍历找最近距离 d = min(math.hypot(x - px, y - py) for px, py in ref) max_err = max(max_err, d) total_err += d count += 1 print('max lateral error: %.4f m' % max_err) print('mean lateral error: %.4f m' % (total_err / count))

这个脚本的横向误差是欧氏距离近似,没有区分路径左侧还是右侧,但用来对比算法参数已经足够。注意脚本假设bag里只有一条/ref_path消息,如果记录了多次路径,需要改成只取最近一次。

跑完脚本后,你可能会发现Stanley模式的最大误差明显小于纯跟踪,但误差曲线高频振摆更大。此时一个实用技巧是在同一个节点里保留两种模式,通过简单状态机切换:

if (fabs(lateralErr) > 0.3) { mode = LateralMode::Stanley; // 大偏差时先快速拉回来 } else if (currentSpeed_ > 0.45) { mode = LateralMode::PurePursuit; // 高速时隔断高频抖动 } else { mode = LateralMode::Stanley; // 低速精修路径 }

把切换方向定为“大误差用Stanley,高速用纯跟踪”,比固定单一模式更容易同时满足启动阶段和稳定跟踪阶段的需求。把离线脚本跑通后,以max_lateral_error压缩到0.05m以下为基准,再回过来微调前视距离和PID积分系数,会比凭感觉改参数省得多。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询