前言


目录(本文为上篇,覆盖第 1-5 章)


1 局部规划器

1-1 常用算法

请添加图片描述

  • 在讲 SCAN-Planner 之前,我们还是先花一小节,把「局部路径规划器」的生态捋一遍,看看它是站在谁的肩上
  • 上一期解读 PCT-Planner 时我们说过:
    • 全局规划器拿着完整先验地图,算一条从 A 到 B 的大致路线;
    • 局部规划器则边走边看,用传感器实时感知的信息,在毫秒级的时间内高频规划出可以执行的轨迹
  • 局部规划器的常用算法,主流可以分成「反应式」、「图/优化式」、「采样式」三大类:
类别 代表算法 核心思想 优点 缺点
反应式 DWA 直接对速度空间 (v, w) 采样,打分选最优 简单、快、鲁棒 短视,容易陷入局部最小值
图/优化式 TEB 把轨迹离散成位姿序列,用图优化求平滑 轨迹平滑、可加约束 参数多,高维时慢
优化式 EGO-Planner B-spline 控制点 + 梯度优化 平滑、安全、无 ESDF 依赖 依赖好初值,可能陷入局部极值
采样/MPC MPPI、MPC 随机采样候选轨迹或滚动优化 能处理非线性动力学 计算量大,需调协方差
  • 本期的主角 SCAN-Planner 属于 优化式,而且它的骨架几乎就是 EGO-Planner 本尊:B-spline 表示轨迹 + L-BFGS 优化 + A* 绕障反弹
1-2 之前的机器人膨胀

请添加图片描述

  • 机器人在栅格地图里做碰撞检测,不会真的拿它复杂的几何外形去逐点比对——那太慢了
  • 工程上的标准做法是:先把障碍物按机器人半径膨胀一圈,然后把机器人当成一个「点」来查碰撞。这一步叫膨胀(Inflation)
  • 我们用公式表达一下这个过程:假设机器人可以被一个半径为 r r r 的圆(二维)或球(三维)包住,那么对地图里的任意一个被占据的体素 g g g,我们要把它周围半径 r r r 以内的体素全部标记为「膨胀占用」:
    ∀   g o ∈ O : g  is inflated    ⟺    ∥ g − g o ∥ 2 < r \forall\, g_o \in \mathcal{O}:\quad g\ \text{is inflated} \iff \|g - g_o\|_2 < r goO:g is inflatedggo2<r
  • 这样碰撞检测就从「图形 vs 图形」降维成了「点 vs 查询表」,一次 getOccupancy 就完成了
  • EGO-Planner 就是这么做的,它的参数里有一个 robot_radius,把地图按这个半径膨胀,然后整个规划全程把无人机当成一个质点来处理
  • 我们在 SCAN-Planner 的源码里也能看到同样的思路,只是它把「一个半径」换成了「半径 + 双中心」,这个我们后面 3-43-5 节重点讲请添加图片描述

说人话:膨胀就是在障碍物周围画一圈半径等于机器人半径的安全区。安全区画大了,机器人离障碍远远的(安全但无法通过窄缝);画小了,机器人会擦到障碍(灵活但危险)。

1-3 之前算法的问题?
  • 单球/单圆膨胀对无人机非常合适,因为无人机就是近似轴对称的,前后左右都一样
  • 但四足机器人(比如宇树 Go2)的身体是长条形的,大概有 0.7 m 长、0.35 m 宽,而且它有明确的前后之分:
    • 单圆半径取小(按宽度):前脚后脚都会伸出圆外,转弯或贴边时直接蹭到墙
    • 单圆半径取大(按长度):中间的腰身被过度膨胀,一条 0.5 m 宽的走廊本来钻得过去,结果被「大圆」挡住死活过不去
  • 更麻烦的是,单圆膨胀是旋转不变的——它根本不知道机器人面朝哪里。而四足行走时,前身、后身扫过的空间是随 yaw 角变化的
  • 还有一点:四足要上楼梯、过窄门,头顶和脚下各有一段净空(obstacles_inflation_z_up/down),这在球形模型里也只能用一个固定高度带近似
  • 于是论文提出了核心问题:能不能让碰撞模型「认得机器人的朝向」?能不能用两个小圆柱去贴合四足的真实外形?

说人话:之前的算法把机器人当成一个固定半径的球——对无人机正合适,对长条形的四足却要么包不住头尾、要么把腰身撑得过宽,而且不随朝向变化。SCAN-Planner 改用两个贴合机身前后部的小圆柱,半径、间距都按四足身形配置,并随航向角一起旋转。


2 SCAN-Planner 总览

请添加图片描述

2-1 介绍
  • SCAN-Planner 的全称是 Spatial Collision-Aware Local Planning,空间碰撞感知局部规划,目标是解决四足机器人(quadruped)在长距离、多楼层环境里的局部导航问题
  • 它的承继关系非常清晰:底层沿用 EGO-Planner 那一套「B-spline 表示轨迹 + L-BFGS 优化 + A* 绕障」的优化式局部规划框架,只把最关键的碰撞模型换掉了——论文(arXiv 2606.19555)用双圆柱体取代 EGO 的单球,并用一条全局参考轨迹引导局部规划,使四足在长距离走廊、窄门、楼梯场景下安全高效地导航

说人话:SCAN-Planner 没去重造局部规划的轮子,而是把 EGO-Planner 的「球」换成了「会随航向转的两个圆柱」——机身是长条的,用两个小圆柱贴合前后,比一个圆准得多。

2-2 系统架构总览
  • 我们先从一张数据流图看整体,再逐个模块拆解:

激光雷达 / 深度相机

Elevator-LIO 定位

滑动窗口占据栅格地图

A* 绕障

B-spline 轨迹优化

全局参考轨迹 min-snap

局部目标提取 getLocalTarget

FSM 状态机

轨迹发布 / 足式步态执行

四足机器人

  • 拆开看,这个系统由四个层次组成:
层次 模块 文件 作用
感知 GridMap 滑动占据栅格 plan_env/grid_map.cpp 点云/深度图 → 计数式占据更新 → 双圆柱膨胀
全局引导 PolynomialTraj min-snap 全局轨迹 traj_utils/polynomial_traj.cpp 路点 → 一条平滑的全局参考轨迹(路由)
局部规划 SCANReplanFSM 状态机 plan_manage/scan_replan_fsm.cpp 沿路由取局部目标,调度绕障与优化
优化 BsplineOptimizer + AStar bspline_opt + path_searching B-spline 优化 + A* 反弹绕障
  • 论文的核心创新集中在感知层的地图膨胀优化层的碰撞代价这两处,其余骨架与 EGO-Planner 一脉相承
  • 所以我们后面的顺序是:先讲地图与膨胀(第 3 章),再讲全局路由(第 4 章);接着先讲怎么从全局路径取点、连出初始轨迹(第 5 章),再按局部规划的实际处理顺序依次讲 Projected A* 绕障搜索(第 6 章)、B-spline 轨迹优化(第 7 章)与 L-BFGS 优化(第 8 章),最后收束到调度这一切的局部重规划状态机 FSM(第 9 章)
2-3 三种导航模式
  • 论文里 route-guided(路由引导)有三种落地方式,对应 fsm/navi_mode 这个参数:
navi_mode 名称 目标来源 适用场景
1 MANUAL_TARGET RViz 手动点击 /move_base_simple/goal 单目标点
2 PRESET_TARGET tools/keypoint.yaml 预设路点序列 多楼层、多路点巡航
3 REFERENCE_PATH 订阅 /initial_path(全局路径) 上层规划器给出完整路由
  • 源码里对应 scan_replan_fsm.cpp 的初始化:
// 对应 2-3 节:三种导航模式
if (navi_mode_ == NAVI_MODE::MANUAL_TARGET)
  goal_sub_ = nh.subscribe("/move_base_simple/goal", 1, &SCANReplanFSM::rvizGoalCallback, this);   // 模式 1:RViz 点目标
else if (navi_mode_ == NAVI_MODE::PRESET_TARGET)
{
  ...
  planGlobalTrajbyGivenWps();               // 模式 2:预设路点 → 全局轨迹
}
else if (navi_mode_ == NAVI_MODE::REFERENCE_PATH)
  path_sub_ = nh.subscribe("/initial_path", 1, &SCANReplanFSM::pathCallback, this);                // 模式 3:订阅全局路径
  • 无论是哪种模式,最终都会统一变成一条全局参考轨迹 global_data_.global_traj_,局部规划器只负责沿它取目标、避障、规划
2-4 核心贡献:双圆柱体空间碰撞感知
  • 在进入代码之前,我们先把这篇论文的灵魂——双圆柱体模型——用一张图和几个公式讲清楚
  • 假设机器人在位置 p \mathbf{p} p,当前航向角为 θ \theta θ,那么它的前进方向(heading)是:
    h ( θ ) = [ cos ⁡ θ sin ⁡ θ 0 ] \mathbf{h}(\theta) = \begin{bmatrix}\cos\theta\\[2pt] \sin\theta\\[2pt] 0\end{bmatrix} h(θ)= cosθsinθ0
  • 我们不把整个机身当成一个圆柱,而是用两个小圆柱去包住机身的前部后部,两个圆柱的中心分别在:
    p front = p + d c   h ( θ ) , p rear = p − d c   h ( θ ) \mathbf{p}_{\text{front}} = \mathbf{p} + d_c\,\mathbf{h}(\theta), \qquad \mathbf{p}_{\text{rear}} = \mathbf{p} - d_c\,\mathbf{h}(\theta) pfront=p+dch(θ),prear=pdch(θ)
  • 其中 d c d_c dc 就是 double_cylinder_offset(默认 0.18 m),两个圆柱半径都是 R = double_cylinder_radius R=\text{double\_cylinder\_radius} R=double_cylinder_radius(默认 0.25 m)
  • 于是「位置 p \mathbf{p} p、航向 θ \theta θ 处是否碰撞」就等价于「前圆柱后圆柱的膨胀占用至少有一个为真」:
    c o l l i d e ( p , θ ) = O i n f l a t e ( p front )    ∨    O i n f l a t e ( p rear ) \mathrm{collide}(\mathbf{p}, \theta) = \mathcal{O}_{\mathrm{inflate}}(\mathbf{p}_{\text{front}}) \;\vee\; \mathcal{O}_{\mathrm{inflate}}(\mathbf{p}_{\text{rear}}) collide(p,θ)=Oinflate(pfront)Oinflate(prear)
  • 后面第 3 章我们会看到,这个公式在代码里就对应 GridMap::getInflateOccupancy(pos, yaw),短短几行,却彻底改变了碰撞模型对四足的适配性
  • 下图直观对比了「单圆」与「双圆柱」的覆盖范围:单圆要么包不住头尾、要么把腰身撑胖,双圆柱则严丝合缝地贴合四足的长条形机身(橙色矩形为机身示意):
    请添加图片描述

说人话:机器人在栅格地图上不是「一个圆」,而是「机身前端一个圆 + 机身后端一个圆」,两个圆都随航向角旋转。前进时前端的圆顶在最前;原地转弯时两个圆绕着机身中心转。这样腰身窄、头尾宽的真实机身就被精确地描述出来了。


3 滑动窗口占据栅格地图

  • 局部规划器最核心的输入,就是一张实时更新的占据栅格地图。SCAN 的地图直接继承了 ROG-Map 的设计:机器人坐标系 + 滑动窗口 + 计数式占据更新
  • 这一章我们按「地图怎么滑动 → 占据怎么更新 → 障碍怎么膨胀 → 碰撞怎么查询」的顺序,把 grid_map.cpp 从头到尾走一遍
3-1 机器人坐标系滑动窗口
  • 全局地图在长距离导航里会无限增长,局部规划器并不需要——它只需要机器人周围一小块
  • SCAN 的做法是:固定一块 10 m × 10 m × 5 m 的地图,分辨率 0.05 m,中心始终跟随机器人,机器人走远了就平移重定位(滑动窗口)
  • 地图原点 map_origin_ 记录的是窗口最小角点坐标(初始 ( − x / 2 ,   − y / 2 ,   ground ) (-x/2,\,-y/2,\,\text{ground}) (x/2,y/2,ground)),窗口中心map_origin_idx_ 表示(初始在世界原点),尺寸 map_size_ 决定体素总数:
参数 默认值 含义
sliding_map_size_x/y/z 10 / 10 / 5 滑动窗口尺寸(m)
resolution 0.05 分辨率(m)
map_sliding_thresh 0.2 机器人偏离窗口中心超过该距离就滑动
max_ray_length 5.0 射光线最大长度(m)
  • 初始化代码在 grid_map.cppinitMap
// 对应 3-1 节:地图尺寸与分辨率
mp_.map_origin_ = Eigen::Vector3d(-x_size / 2.0, -y_size / 2.0, mp_.ground_height_);
mp_.map_size_   = Eigen::Vector3d(x_size, y_size, z_size);
...
for (int i = 0; i < 3; ++i)
  mp_.map_voxel_num_(i) = ceil(mp_.map_size_(i) / mp_.resolution_);  // 各维体素个数
mp_.map_origin_idx_ = mp_.map_bound_min_idx_ + mp_.map_voxel_num_ / 2; // 窗口中心体素
  • 滑动逻辑在 updateSlidingMap(center):当机器人偏离中心超过阈值 map_sliding_thresh_vox_ 时,把窗口往新中心平移,只清理滑出窗口的那几层窄条(层数 = 位移量 shift,首次触发通常一次清 4 层),其余体素全部保留(这就是「滑动」而不是「重建」的关键):
// 对应 3-1 节:滑动窗口平移
const Eigen::Vector3i shift_num = new_origin_idx - mp_.map_origin_idx_;
if (shift_num.cwiseAbs().maxCoeff() < mp_.map_sliding_thresh_vox_)
  return;                                      // 没走远,不滑
...
// 按维度把滑出去的体素标记到 clear_mask,其余全部复用
for (int dim = 0; dim < 3; ++dim) {
  const int shift = shift_num(dim);
  ...
  add_clear_addr(id_l);                        // 记录需要清除的地址
}

说人话:地图是块跟随机器人平移的滑动窗口,机器人往前走 0.2 m,窗口就往前挪 0.2 m,只把身后移出窗口的那一窄条清掉,其余体素全部保留。比起「每次重建整张地图」,既省时间又不丢历史信息。

3-2 计数式占据概率更新
  • 我们曾经在【Navigation2进阶】(十三):手写概率地图!从log-odds推导到MI互信息探索与Nav2插件实现讲过概率地图的梳理,这里就不进行详细概念说明
  • 占据栅格地图的经典做法是贝叶斯占据概率更新:对每个体素维护一个概率 p p p,用 log-odds(对数几率)表示:
    L = l o g i t ( p ) = ln ⁡ p 1 − p L = \mathrm{logit}(p) = \ln\frac{p}{1-p} L=logit(p)=ln1pp
  • 每次观测到「命中」(ray end 落在该体素)就加一个正的增量,观测到「未命中」(射线穿过该体素)就加一个负的增量:
    L t + 1 = c l a m p ( L t + Δ L ,    L min ⁡ ,    L max ⁡ ) L_{t+1} = \mathrm{clamp}\big(L_t + \Delta L,\; L_{\min},\; L_{\max}\big) Lt+1=clamp(Lt+ΔL,Lmin,Lmax)
  • SCAN 沿用了 ROG-Map 的 计数式(counting)融合:它不逐体素立刻更新,而是先在 count_hit_ / count_hit_and_miss_计数,攒够一轮后批量融合
  • 一次观测里,某个体素既可能被多条射线穿过(miss),又可能被某些射线命中(hit),于是:
    Δ L = { L hit , n hit ≥ n miss L miss , n hit < n miss \Delta L = \begin{cases} L_{\text{hit}} , & n_{\text{hit}} \ge n_{\text{miss}} \\[4pt] L_{\text{miss}}, & n_{\text{hit}} < n_{\text{miss}} \end{cases} ΔL={Lhit,Lmiss,nhitnmissnhit<nmiss
  • 其中 n hit n_{\text{hit}} nhit 是命中计数, n miss = n hit_and_miss − n hit n_{\text{miss}}=n_{\text{hit\_and\_miss}} - n_{\text{hit}} nmiss=nhit_and_missnhit 是穿过计数。默认参数:
参数 logit 值 含义
p_hit 0.85 L hit ≈ + 1.73 L_{\text{hit}}\approx+1.73 Lhit+1.73 命中增量
p_miss 0.30 L miss ≈ − 0.85 L_{\text{miss}}\approx-0.85 Lmiss0.85 穿过增量
p_occ 0.80 L occ ≈ + 1.39 L_{\text{occ}}\approx+1.39 Locc+1.39 视为「占用」的阈值
p_min 0.12 L min ⁡ ≈ − 1.99 L_{\min}\approx-1.99 Lmin1.99 概率下限
p_max 0.98 L max ⁡ ≈ + 3.89 L_{\max}\approx+3.89 Lmax+3.89 概率上限
  • 代码里 initMap 直接把概率转成 log-odds 存下来,后续所有更新都在 log-odds 空间进行:
// 对应 3-2 节:概率 → log-odds 转换
mp_.prob_hit_log_  = logit(mp_.p_hit_);     // +1.73
mp_.prob_miss_log_ = logit(mp_.p_miss_);    // -0.85
mp_.clamp_min_log_ = logit(mp_.p_min_);     // -1.99
mp_.clamp_max_log_ = logit(mp_.p_max_);     // +3.89
mp_.min_occupancy_log_ = logit(mp_.p_occ_); // +1.39
  • 计数阶段在 setCacheOccupancy:命中 count_hit_+1,只要被观测过(无论命中还是穿过)count_hit_and_miss_+1,并把体素塞进 cache_voxel_ 队列:
// 对应 3-2 节:计数缓存
int GridMap::setCacheOccupancy(Eigen::Vector3d pos, int occ) {
  ...
  md_.count_hit_and_miss_[idx_ctns] += 1;    // 只要被观测到就 +1
  if (md_.count_hit_and_miss_[idx_ctns] == 1)
    md_.cache_voxel_.push(id);               // 第一次被观测,入队
  if (occ == 1)
    md_.count_hit_[idx_ctns] += 1;           // 命中才 +1
  return idx_ctns;
}
  • 融合阶段在 raycastProcess 末尾批量执行,先用计数比较决定增量正负,再 clamp 到上下限:
// 对应 3-2 节:批量融合(log-odds 累加 + clamp)
double log_odds_update =
    md_.count_hit_[idx_ctns] >= md_.count_hit_and_miss_[idx_ctns] - md_.count_hit_[idx_ctns]
        ? mp_.prob_hit_log_ : mp_.prob_miss_log_;      // 命中多 → 加 hit,否则加 miss
...
const double new_log_odds =
    std::min(std::max(md_.occupancy_buffer_[idx_ctns] + log_odds_update,
                      mp_.clamp_min_log_), mp_.clamp_max_log_);  // clamp 到 [Lmin, Lmax]
applyOccupancyUpdate(idx, new_log_odds);

说人话:地图更新不是「每来一帧点云就把所有体素重新算一遍」,而是先累计观测(哪些体素被射线穿过、哪些被激光打中),攒一批再统一算。命中多就向「占用」方向累加,穿过多就向「空闲」方向累加,累加结果还限制在上下限里,防止一次误观测让占据概率突变。

  • 下面这张图模拟了一个体素的 log-odds 随观测次数变化的曲线:前 4 次观测命中,log-odds 快速爬升越过 L_occ 判定为占用;后 6 次观测穿过,逐步回落,并始终被 clamp 在 L_minL_max 之间:
    请添加图片描述
3-3 射光线融合
  • 光有端点还不够——激光打在障碍物上,激光和障碍物之间的一切都应该是空闲的,所以必须对每条射线做raycasting,把中间穿过的体素全部记成 miss
  • SCAN 用的是 RayCaster(3D-DDA 体素遍历),射线起点是雷达位置 ray_pos_,终点是反投影出来的世界点 pt_w
// 对应 3-3 节:射光线融合
raycaster.setInput(pt_w / mp_.resolution_, md_.ray_pos_ / mp_.resolution_);
while (raycaster.step(ray_pt))
{
  Eigen::Vector3d tmp = (ray_pt + half) * mp_.resolution_;
  length = (tmp - md_.ray_pos_).norm();
  vox_idx = setCacheOccupancy(tmp, 0);     // 射线穿过的体素 → miss 计数
  ...
}
  • 注意两个标志的分工:flag_rayend_ 保证同一终点体素在一帧内只做一次 DDA 遍历——多条射线打到同一个端点体素时,只有第一条执行中间体素的 miss 标记,后续直接 continue(端点体素的命中计数不受影响,照样累加);flag_traverse_ 保证每条射线穿过体素时一旦遇到已处理体素就提前 break。至于「入队去重」,由 setCacheOccupancy 里的 count_hit_and_miss_ == 1 决定——第一次命中该体素才 cache_voxel_.push(id)
  • 这就是缓存计数能保持高效的原因:一帧点云几百上千条射线,但最终只有有限的一小撮体素会被推入更新队列
3-4 双圆柱体膨胀
  • 现在到了全文的正题——地图膨胀。上一章提到按障碍物尺寸对障碍膨胀(在障碍周围留出安全间距),在 SCAN 里膨胀的半径不是一圈均匀的圆,而是双圆柱
  • 先看膨胀偏移量的生成 rebuildInflationOffsets:XY 平面内,偏移 ( x , y ) (x,y) (x,y) 只要落在半径 R R R 的圆内 R = double_cylinder_radius = 0.25 R=\text{double\_cylinder\_radius}=0.25 R=double_cylinder_radius=0.25 m)就保留,Z 方向则在 [ − z down , z up ] [-z_{\text{down}}, z_{\text{up}}] [zdown,zup] 内取:

x 2 + y 2 < R 2 ⏟ XY 平面圆内 , z ∈ [ − z down ,    z up ] \underbrace{x^2 + y^2 < R^2}_{\text{XY 平面圆内}}, \qquad z \in [-z_{\text{down}},\; z_{\text{up}}] XY 平面圆内 x2+y2<R2,z[zdown,zup]

参数 默认值 含义
double_cylinder_radius 0.25 单个圆柱的 XY 半径(m)
double_cylinder_offset 0.18 前/后圆柱中心离机身中心的距离(m)
obstacles_inflation_z_up 0.1 膨胀上界(m)
obstacles_inflation_z_down 0.1 膨胀下界(m)
body_height 0.4 机身离地高度(m)
  • 下面用一张立体图把「双圆柱模型」和「圆柱膨胀」的关系画出来:机器人被建模成前后两个圆柱(中心偏移 ± d c = 0.18 \pm d_c=0.18 ±dc=0.18 m、半径 R = 0.25 R=0.25 R=0.25 m、高度带 [ h b o d y − z d o w n ,    h b o d y + z u p ] [h_{body}-z_{down},\; h_{body}+z_{up}] [hbodyzdown,hbody+zup]),而地图里每个障碍体素也会膨胀成同半径、同高度带的圆柱区域。因为膨胀半径等于圆柱半径,所以碰撞检测只需检查「前圆柱中心」和「后圆柱中心」这两个点有没有落进膨胀区即可:![[Pasted image 20260816112152.png]]

  • 代码:

// 对应 3-4 节:生成膨胀偏移集合
void GridMap::rebuildInflationOffsets() {
  const double double_radius = std::max(0.0, mp_.double_cylinder_radius_);   // R = 0.25
  const int inf_step_xy   = ceil(double_radius / mp_.resolution_);           // XY 步数
  const int inf_step_z_up = ceil(mp_.obstacles_inflation_z_up  / mp_.resolution_);
  const int inf_step_z_down = ceil(mp_.obstacles_inflation_z_down / mp_.resolution_);
  ...
  for (int x = -inf_step_xy; x <= inf_step_xy; ++x)
    for (int y = -inf_step_xy; y <= inf_step_xy; ++y) {
      Eigen::Vector2d offset_xy(x * mp_.resolution_, y * mp_.resolution_);
      if (offset_xy.norm() >= double_radius)
        continue;                                    // 只保留圆内的偏移
      for (int z = -inf_step_z_down; z <= inf_step_z_up; ++z)
        md_.inflate_offsets_.push_back(Eigen::Vector3i(x, y, z));
    }
}
  • 有了偏移集合,当一个体素从空闲变成占用(或相反)时,applyOccupancyUpdate 会把「占用/空闲」增量 ±1 广播到它周围的全部偏移体素,用计数 occupancy_buffer_inflate_cnt_ 记录:
// 对应 3-4 节:状态变化 → 更新膨胀层
void GridMap::applyOccupancyUpdate(const Eigen::Vector3i& id, double new_log_odds) {
  ...
  const bool was_occ = md_.occupancy_buffer_[addr] > mp_.min_occupancy_log_;
  const bool now_occ = new_log_odds > mp_.min_occupancy_log_;
  md_.occupancy_buffer_[addr] = new_log_odds;
  if (was_occ != now_occ)
    updateInflation(id, now_occ ? 1 : -1);    // 变为占用 +1,变为空闲 -1
}
  • updateInflationinflate_offsets_ 逐个叠加到邻居上:膨胀计数为正的体素,膨胀标志 occupancy_buffer_inflate_ 记为 1(即「膨胀占用」)

说人话:每个真正的障碍体素,会把自己的「占用」状态沿着一整套预先算好的偏移向量,洒到周围半径 0.25 m 的圆柱范围里。这样碰撞查询时只需查「点的膨胀占用」这一个标志,而不用每次算距离。

  • 这个「洒」的过程,用一张俯视图看最直观:(a) rebuildInflationOffsets 先把以体素为中心、半径 R R R 圆内的所有格点坐标预存成偏移集合;(b) 当某个体素从空闲变成占用时,updateInflation 沿着这些偏移向量把计数 + 1 +1 +1 广播出去,计数大于 0 的体素就被打上「膨胀占用」标记:
    请添加图片描述
3-5 空间碰撞检测 getInflateOccupancy
  • 现在把 2-4 节的公式落到代码——这是 SCAN-Planner 全仓库最核心的函数,在 grid_map.h 里以内联函数形式给出
  • 常规的单球/单圆模型只查一个点就够,而 SCAN-Planner(双圆柱)要查两个点——front 和 rear,因为膨胀半径 = 圆柱半径 R R R,「圆柱中心落在膨胀区」等价于「圆柱体碰到障碍」
  • 先用一张俯视图理解「yaw 是关键输入」:机器人停在同一个位置,前方 ( 0.25 ,   0 ) (0.25,\ 0) (0.25, 0) 处有一个障碍体素(已画出它的膨胀区,半径 R = 0.25 R=0.25 R=0.25 m)。左图机器人航向 0 ∘ 0^\circ 0front 点恰好落进膨胀区 → 判定碰撞;右图原地转到 90 ∘ 90^\circ 90front/rear 两个查询点都转开了 → 判定安全。同一个位置、同一片障碍,只是换个朝向,碰撞结论就完全不同——这就是「空间碰撞感知」的名字由来:
    请添加图片描述
// 对应 3-5 节 / 2-4 节公式:双圆柱体空间碰撞检测(官方源码,已加中文注释)
inline int GridMap::getInflateOccupancy(Eigen::Vector3d pos, double yaw) {
  // 由航向角得到前进方向单位向量 h(theta) = [cos, sin, 0]
  Eigen::Vector3d heading(std::cos(yaw), std::sin(yaw), 0.0);
  // 前圆柱中心:pos + offset * heading
  Eigen::Vector3d front = pos + mp_.double_cylinder_offset_ * heading;
  // 后圆柱中心:pos - offset * heading
  Eigen::Vector3d rear  = pos - mp_.double_cylinder_offset_ * heading;

  // 先查前圆柱的膨胀占用
  int front_occ = getInflateOccupancyFromBuffer(front, md_.occupancy_buffer_inflate_);
  if (front_occ != 0) return front_occ;   // 前圆柱被占 → 立即判定碰撞

  // 前圆柱空闲,再查后圆柱
  return getInflateOccupancyFromBuffer(rear, md_.occupancy_buffer_inflate_);
}
  • 对应的 getInflateOccupancyFromBuffer 只是一个「位置 → 体素 → 查表」的过程:
// 对应 3-5 节:单点膨胀占用查询
inline int GridMap::getInflateOccupancyFromBuffer(Eigen::Vector3d pos,
                                                  const std::vector<char>& buffer) {
  if (!isInMap(pos)) return -1;          // 出界 → 视为未知
  Eigen::Vector3i id;
  posToIndex(pos, id);                   // 连续坐标 → 体素索引
  return int(buffer[toAddress(id)]);     // 查膨胀标志
}
  • 从这个函数我们能提炼出三个要点:
    • yaw 是关键输入:同样的位置 p \mathbf{p} p,航向不同,前/后圆柱扫到的体素就不同,碰撞结果就不同。这就是「空间碰撞感知」的名字由来
    • 前后独立:前圆柱空闲不代表后圆柱空闲,必须两个都查(front_occ == 0 才继续查 rear)
    • 处处复用:地图膨胀、A* 搜索、B-spline 优化、FSM 安全检测,全仓库所有碰撞查询都走这一个函数

说人话:别的规划器问地图「这个点能不能站」,SCAN 问的是「这个点、这个朝向,机身前部和后部会不会碰到墙」。多问了「朝向」这一项,四足的窄门和走廊问题就解决了。


4 路由引导:全局参考轨迹

4-1 从「目标点」到「路由」
  • 传统局部规划器(比如 EGO-Planner)拿到一个目标点后,从当前位置直接往目标点规划。这在开阔地没问题,但在长走廊、多楼层的场景里,局部规划器「只见树木不见森林」,容易钻进死胡同

论文的思路是:上面先给一条全局参考轨迹(route),局部规划器只要沿着这条路由往前走一小段就行

  • 那问题来了:上层给的不就是一条全局路径(一串路点/折线)吗?为什么还要用 minimum-snap 拆成一条多项式轨迹,直接拿折线当路由不行吗?——不行。折线只是「一串离散点」,而局部规划需要的是一条随时可求值、运动学连续的曲线 p ( t ) p(t) p(t)
    • 折线没有「时间维度」getLocalTarget 先在轨迹上按时间投影找最近点,再沿轨迹走到 planning_horizon_ 取局部目标点——折线只有几何位置,算不出「几秒后机器人该在哪」
    • 折线运动学不连续:速度、加速度在路点处发生阶跃,跟随行进时存在明显抖动;四足的 yaw 参考取轨迹切线方向,折线拐角处切线方向突变,双圆柱的 yaw 碰撞检测(3-5 节)也随之波动
  • 所以上层三种导航模式(2-3 节)给的输入,最终都整理成「一串路点」,统一走 4-3 节 min-snap 变成一条平滑多项式轨迹(这条轨迹如何作为局部优化的初始解,见 5-3 / 5-4 节):
navi_mode 目标来源 生成入口 轨迹形态
1 MANUAL_TARGET RViz 点 /move_base_simple/goal planGlobalTraj 起点 → 单目标点,一整段
2 PRESET_TARGET tools/keypoint.yaml 路点序列 planGlobalTraj(逐段) 当前位置 → 下一个路点,到点再规划下一段
3 REFERENCE_PATH 订阅 /initial_path planGlobalTrajWaypoints 整条路径一次性 min-snap
  • 落到 global_data_.global_traj_ 后都是同一条「随时可求值、运动学连续」的多项式轨迹,局部规划器只认它,从不关心它从哪来
4-2 时间分配
  • 先说为什么需要这一步:每段轨迹 p k ( t ) p_k(t) pk(t) 定义在 [ 0 , T k ] [0, T_k] [0,Tk] 上,是时间函数。min-snap 的目标是对时间积分,QP 里「段末位置/速度/加速度等于指定值」的等式约束,也要靠 T k T_k Tk 才能把「系数」映射成「段末状态」——4-3 节 A 矩阵里就有 T k   j − i T_k^{\,j-i} Tkji 这一项,没有 T k T_k Tk,整个 QP 都写不出来
  • 更重要的是:min-snap 只保证几何平滑,不约束速度。同一段路,时间给短了机器人就得高速超调,给长了又走走停停。所以时间分配就是给每段轨迹定一个符合物理极限的「节奏」——距离除以最大速度:
    T k = ∥ p k + 1 − p k ∥ v max ⁡ T_k = \frac{\big\|\mathbf{p}_{k+1} - \mathbf{p}_k\big\|}{v_{\max}} Tk=vmax pk+1pk
  • 在标准 min-snap 闭式解法里, T k T_k Tk先于求解给定的固定参数,只有多项式系数是优化变量,所以时间分配必须发生在求解之前(4-4 节的代码正是先 time(i) = dist / vmax,再调 minSnapTraj
  • 首段和末段的时间再翻倍——起、止阶段的加减速需要额外余量,否则一出发就要立刻冲到 v max ⁡ v_{\max} vmax
    T 0 ← 2 T 0 , T m − 1 ← 2 T m − 1 T_0 \leftarrow 2T_0,\qquad T_{m-1} \leftarrow 2T_{m-1} T02T0,Tm12Tm1
4-3 min-snap 全局轨迹
  • 生成全局轨迹用的是经典的 minimum-snap 方法:把轨迹写成逐段多项式,然后最小化三阶导数(jerk)的能量——注意:官方函数名沿用 minSnapTraj,但代码里 Q 矩阵实际最小化的是 jerk(三阶导),是历史遗留误称,公式上我们以代码为准(下面马上证明)
  • 先用一张散点图看全局轨迹生成的全过程:输入是一串路点(红色大圆),超过阈值的远段会被先等间距插密成灰色小圆(对应 4-4 节代码),min-snap 再生成一条平滑穿过所有点的多项式轨迹(蓝色曲线,拐角处自然圆滑);图中还标出了 4-2 节的首末段时间翻倍
    ![[scanplanner_global_traj.png]]
  • 假设有 m m m 段、每段轨迹是 5 阶多项式(6 个系数),位置、速度、加速度在段与段之间连续,路点位置作为等式约束:
    p k ( t ) = ∑ i = 0 5 c k , i   t i , k = 0 , … , m − 1 p_k(t) = \sum_{i=0}^{5} c_{k,i}\,t^i,\qquad k=0,\dots,m-1 pk(t)=i=05ck,iti,k=0,,m1
  • 优化目标是最小化 jerk(三阶导)的积分:
    min ⁡ p ( t )    ∑ k ∫ 0 T k ∥ p k ( 3 ) ( t ) ∥ 2 d t \min_{p(t)}\; \sum_{k}\int_{0}^{T_k} \big\|p_k^{(3)}(t)\big\|^2 dt p(t)mink0Tk pk(3)(t) 2dt
  • 为什么是 jerk 而不是 snap?看 polynomial_traj.cpp 里 Q 矩阵的元素 i ( i − 1 ) ( i − 2 )   j ( j − 1 ) ( j − 2 )   T i + j − 5 i + j − 5 i(i-1)(i-2)\,j(j-1)(j-2)\,\frac{T^{i+j-5}}{i+j-5} i(i1)(i2)j(j1)(j2)i+j5Ti+j5——系数 i ( i − 1 ) ( i − 2 ) i(i-1)(i-2) i(i1)(i2) 正是三阶导的系数(四阶导应是 i ( i − 1 ) ( i − 2 ) ( i − 3 ) i(i-1)(i-2)(i-3) i(i1)(i2)(i3)),且 i i i 从 3 起才非零。5 阶多项式 + 端点位置/速度/加速度六个约束,恰好是 min-jerk 的最小可解阶数;若真最小化 snap 得用 6 阶多项式
  • 整理成二次规划(QP)形式: A A A 是「系数 → 各阶导数」的映射矩阵, C C C 是 Mellinger 式「固定/自由变量选择矩阵」, Q Q Q 是 jerk(三阶导)内积的 Hessian:

min ⁡ p    p T Q   p , s.t.      A   p = d \min_{\mathbf{p}}\; \mathbf{p}^T Q\,\mathbf{p},\qquad \text{s.t.}\;\; A\,\mathbf{p}=\mathbf{d} pminpTQp,s.t.Ap=d

  • polynomial_traj.cpp 里正是这么搭的:
// 对应 4-3 节:min-snap QP 的映射矩阵 A(每个段 6 系数,两端位置/速度/加速度)
for (int k = 0; k < seg_num; k++) {
  Ab = Eigen::MatrixXd::Zero(6, 6);
  for (int i = 0; i < 3; i++) {
    Ab(2 * i, i) = Factorial(i);                                  // 段首:p^(i)(0)
    for (int j = i; j < 6; j++)
      Ab(2 * i + 1, j) = Factorial(j) / Factorial(j - i) * pow(Time(k), j - i); // 段尾:p^(i)(T)
  }
  A.block(k * 6, k * 6, 6, 6) = Ab;
}
...
/* ---------- minimum snap matrix ---------- */
Eigen::MatrixXd Q = Eigen::MatrixXd::Zero(seg_num * 6, seg_num * 6);
for (int k = 0; k < seg_num; k++)
  ...   // Q = 系数空间的 jerk 内积矩阵(对应上面 ∫ p^(3) p^(3) dt)——官方注释误写为 snap,看元素可知是三阶导
4-4 代码实现
  • 路点中间还会做一步插点:任意两路点之间距离超过阈值(max(total_len/8, 4.0) m)就等间距插入中间点,防止长段轨迹让 min-snap 产生过大的中间波动:
// 对应 4-4 节:路点过远时插入中间点
double dist_thresh = max(total_len / 8, 4.0);
for (size_t i = 0; i < points.size() - 1; ++i) {
  inter_points.push_back(points.at(i));
  double dist = (points.at(i + 1) - points.at(i)).norm();
  if (dist > dist_thresh) {
    int id_num = floor(dist / dist_thresh) + 1;
    for (int j = 1; j < id_num; ++j)
      inter_points.push_back(points.at(i) * (1.0 - double(j)/id_num)
                           + points.at(i + 1) * double(j)/id_num);   // 线性插点
  }
}
inter_points.push_back(points.back());   // 官方原文:补上最后一个路点
  • 然后写位置矩阵、按 4-2 节分配时间、调 minSnapTraj
// 对应 4-2 / 4-4 节:时间分配 + 生成全局轨迹
Eigen::VectorXd time(pt_num - 1);
for (int i = 0; i < pt_num - 1; ++i)
  time(i) = (pos.col(i + 1) - pos.col(i)).norm() / (pp_.max_vel_);   // T_k = 距离 / vmax
time(0) *= 2.0;                    // 首段时间翻倍
time(time.rows() - 1) *= 2.0;      // 末段时间翻倍

PolynomialTraj gl_traj;
if (pos.cols() >= 3)
  gl_traj = PolynomialTraj::minSnapTraj(pos, start_vel, end_vel, start_acc, end_acc, time);
else if (pos.cols() == 2)
  gl_traj = PolynomialTraj::one_segment_traj_gen(start_pos, start_vel, start_acc,
                                                 pos.col(1), end_vel, end_acc, time(0));
else
  return false;                          // 点数异常 → 放弃本次全局规划
auto time_now = ros::Time::now();        // 官方原文:取当前时刻(供 setGlobalTraj 记录时间戳)
global_data_.setGlobalTraj(gl_traj, time_now);   // 存入全局参考轨迹
  • 至此,「路由」就绪。接下来局部规划器就进入「沿着这条路由不断取目标、规划」的循环——先讲第 5 章怎么从全局路径取局部目标点、连出初始轨迹,再讲第 6 章的 Projected A* 绕障搜索,调度这一切的状态机 FSM 放在第 9 章

说人话:全局路由就像手机地图给你规划的一条「路线」,局部规划器就是司机——司机不需要一次背完整条路线,只需要知道「再往前开 3.5 米到哪」,到了就再往前看 3.5 米。这段「3.5 米」就是 planning_horizon


5 从全局路径取点:局部目标点与初始轨迹

5-1 初始轨迹:一次局部优化的初始种子
  • 局部规划器的输入不是整条全局轨迹,而是当前这一小段路——从机器人当前位置 s 0 s_0 s0 连到「沿全局轨迹往前 planning_horizon(默认 3.5 m)处的局部目标点」(取点方法见 5-2 节)。这条两点之间的连线,就是初始轨迹
  • 为什么叫「初始」:它只是本次局部优化的种子。后面的 A* 绕障(第 6 章)、反弹方向(6-3 节)、碰撞代价(第 7 章)都作用在这条线上,最后被 B-spline + L-BFGS 优化成真正可执行的局部轨迹(第 8 章)
  • 这里最容易踩的坑,是把这条初始轨迹和第 4 章的全局轨迹混为一谈——两条轨迹都是 min-snap 生成的,但不是同一条
4-3 节全局轨迹 5-3 / 5-4 节初始轨迹
生成函数 planGlobalTrajWaypoints(第 4-4 节) reboundReplan 的 STEP 1(min-snap 采样见 5-3 节、重采样见 5-4 节)
输入 路点(起点 + 途经点) 只有两个点:当前起点 s 0 s_0 s0 + 局部目标点
穿过 所有路点(平滑穿过全局路由的每一个途经点) 只从 s 0 s_0 s0 连到局部目标点那一个点
一次生成 / 反复生成 收到导航指令时生成一次 约 10 ms 一次调度、按需重新生成(FSM 按 replan_thresh 等条件触发,非每个 10 ms 都重规划)
作用 提供「往哪走」的大方向 + 供取局部目标点 作为本次局部优化的初始种子,等待 B-spline + L-BFGS 优化
  • 把上面这张表画成图,关系就一目了然——全局路径(灰)穿所有路点、一次生成;机器人当前点 s 0 s_0 s0 并不在路径上,先把当前位置投影到全局轨迹(5-2 节的第一步),再沿路径往前 3.5 m 取局部目标点;然后从当前点另画一条初始轨迹(红)连到那个点,全局路径只贡献终点那一个坐标,其余形状就此丢掉:

请添加图片描述

  • 两者的关系:全局轨迹只贡献一个坐标——局部目标点。局部规划器在全局轨迹上取「再往前 planning_horizon」处的点,然后丢掉全局轨迹的其余形状,另起炉灶生成一条只连两个点的初始轨迹(或上一段轨迹截断),再交给优化器

说人话:全局轨迹是「长途路线图」,从起点标到终点、穿过沿途所有路点;局部规划器每次重规划都从路线图上取「前方 3.5 米那一个路标」当临时终点,然后自己画一条「当前点 → 那个路标」的短线作为初始猜测,再交给优化器打磨。短线画得好不好没关系,反正最后要交给 B-spline + L-BFGS 优化。

5-2 取点:getLocalTarget 从全局路径取局部目标点
  • 全局路由(一串路点/折线)本身不直接进优化器——它对局部规划只做一件事:提供局部目标点。局部规划器沿路由取「再往前 planning_horizon(部署配置 advanced_param.xml 默认 3.5 m;代码层兜底是 5.0)」处的点作为这次规划的终点(getLocalTarget())。它分三步:
  • 第一步:投影。把机器人当前位置投影到全局轨迹上(找轨迹上离当前最近的点 t proj t_{\text{proj}} tproj):
// 对应 5-2 节:把当前位置投影到全局轨迹
for (double t = 0.0; t < duration; t += t_step) {
  Eigen::Vector3d pos_t = planner_manager_->global_data_.getPosition(t);
  double dist_to_start = (pos_t - start_pt_).norm();
  if (dist_to_start < min_dist_to_start) { min_dist_to_start = dist_to_start; t_proj = t; }
}
  • 第二步:向前取 horizon。从 t proj t_{\text{proj}} tproj 开始沿轨迹累加弧长,走到总长达到 planning_horizon_(默认 3.5 m)的位置,就是局部目标:
// 对应 5-2 节:向前取 planning_horizon 距离的点
for (double t = t_proj; t < duration; t += t_step) {
  Eigen::Vector3d pos_t = planner_manager_->global_data_.getPosition(t);
  total_dist += (pos_t - prev_pos).norm();
  if (total_dist >= planning_horizon_) { local_target_pt_ = pos_t; target_t = t; target_found = true; break; }   // 官方原文:置 target_found
  prev_pos = pos_t;
}
  • 第三步:目标碰撞避难。如果取到的目标点本身落在障碍里,就在它前后各搜一段,找一个空闲点替代:
// 对应 5-2 节:目标点在障碍中则前后搜索空闲点
auto targetOccupancy = [&](const Eigen::Vector3d &pt) {
  return planner_manager_->grid_map_->getInflateOccupancy(pt, estimateYawFromSegment(odom_pos_, pt));
};
if (targetOccupancy(local_target_pt_) != 0) {
  bool found_free_target = false;
  double adjusted_t = target_t;
  for (double dt = 0.0; dt <= planner_manager_->global_data_.global_duration_; dt += t_step) {
    double t_forward = target_t + dt;                       // 先往前找
    if (t_forward <= planner_manager_->global_data_.global_duration_) {
      Eigen::Vector3d pt = planner_manager_->global_data_.getPosition(t_forward);
      if (targetOccupancy(pt) == 0) { local_target_pt_ = pt; adjusted_t = t_forward; found_free_target = true; break; }
    }
    double t_backward = target_t - dt;                      // 再往后找
    if (t_backward >= std::max(0.0, t_proj)) {
      Eigen::Vector3d pt = planner_manager_->global_data_.getPosition(t_backward);
      if (targetOccupancy(pt) == 0) { local_target_pt_ = pt; adjusted_t = t_backward; found_free_target = true; break; }
    }
  }
  if (found_free_target) target_t = adjusted_t;   // 目标被调整 → 更新 target_t(后续按它取速度)
}
  • 局部目标速度 local_target_vel_global_data_.getVelocity(target_t)——注意 getVelocity 有三个分支,目标时刻若落在本地轨迹时间窗内会返回本地轨迹的速度,正常在 horizon 之外走全局分支;接近终点时( ∥ e n d _ p t − l o c a l _ t a r g e t _ p t ∥ < v max ⁡ 2 / ( 2 a max ⁡ ) \|\mathrm{end}\_\mathrm{pt}-\mathrm{local\_target\_pt}\| < v_{\max}^2/(2a_{\max}) end_ptlocal_target_pt<vmax2/(2amax))清零,保证到点能刹住
5-3 连线:生成初始轨迹(STEP 1)
  • 有了「当前起点 s 0 s_0 s0 + 局部目标点」,reboundReplanSTEP 1连一条初始轨迹:首次从 min-snap 多项式采样路径点(本节代码块),后续从上一段已执行轨迹按伪弧长重采样(第 5-4 节)
  • 于是 reboundReplan 的输入是「当前起点 s 0 s_0 s0 + 局部目标点」——要绕障的那条「初始轨迹」,本质就是这两点之间连出来的一条线。后面的 A* 绕障(6-1、6-2)、反弹方向(6-3)、第 7 章碰撞代价里的控制点偏移,全都发生在这条线上——全局路径只贡献终点那一个坐标,线形是局部另画的
  • 采出的路径点还要反算成 B-spline 控制点(parameterizeToBspline),供后续优化使用:
// 对应 5-3 节:连线(STEP 1 INIT)——min-snap 多项式采样路径点(官方源码节选,已加中文注释)
double dist = (start_pt - local_target_pt).norm();
// 时间按梯形速度曲线定:距离够则加速到 v_max 再匀速;不够就三角(匀加匀减),时间 = 2*sqrt(dist/a_max)
double time = pow(pp_.max_vel_, 2) / pp_.max_acc_ > dist ? sqrt(dist / pp_.max_acc_)
                                                       : (dist - pow(pp_.max_vel_, 2) / pp_.max_acc_) / pp_.max_vel_ + 2 * pp_.max_vel_ / pp_.max_acc_;
gl_traj = PolynomialTraj::one_segment_traj_gen(start_pt, start_vel, start_acc,
                                               local_target_pt, local_target_vel,
                                               Eigen::Vector3d::Zero(), time);   // 起点→目标 一条多项式
// (官方此处外层有 flag_first_call||flag_polyInit||flag_force_polynomial 分支,内层还有 !flag_randomPolyTraj 的 else 随机插点分支,均省略)
double t;
bool flag_too_far;
ts *= 1.5;  // 官方原文:先 ×1.5,下面 do 循环首轮 ÷1.5 抵消,保证首次采样用原始 ts
do {                                   // 自适应细化:ts 逐次 ÷1.5,直到相邻点距足够近、点数足够多
  ts /= 1.5;  point_set.clear();  flag_too_far = false;
  Eigen::Vector3d last_pt = gl_traj.evaluate(0);
  for (t = 0; t < time; t += ts) {
    Eigen::Vector3d pt = gl_traj.evaluate(t);                     // 采样成路径点
    if ((last_pt - pt).norm() > pp_.ctrl_pt_dist * 1.5) { flag_too_far = true; break; }
    last_pt = pt;  point_set.push_back(pt);
  }
} while (flag_too_far || point_set.size() < 7);   // 保证点数足够、控制点间距合理
t -= ts;                                        // 官方原文:退回末次采样时刻
start_end_derivatives.push_back(gl_traj.evaluateVel(0));          // 首端速度
start_end_derivatives.push_back(local_target_vel);                // 末端速度
start_end_derivatives.push_back(gl_traj.evaluateAcc(0));          // 首端加速度
start_end_derivatives.push_back(gl_traj.evaluateAcc(t));          // 末端加速度:取末次采样点处
UniformBspline::parameterizeToBspline(ts, point_set, start_end_derivatives, ctrl_pts); // 反算控制点
  • 连线完成后,紧接着 initControlPoints(ctrl_pts, true) 会沿这条初始轨迹做碰撞分段(第 6 章细讲)——撞上了就用第 6 章的 Projected A* 绕障,没撞就直接进优化
5-4 重采样:上一段轨迹 → 新初始轨迹
  • 第 5-3 节讲的初始轨迹生成,是首次规划的路径:从零用 min-snap 连一条「当前起点 → 局部目标点」的多项式,再采样成路径点——起点状态是多项式首端「算」出来的

  • 但进入重规划后,这条初始轨迹的生成逻辑会不一样:机器人正在执行一段轨迹,随时可能因前方碰撞、轨迹失效而要换一条新轨迹(触发时机见第 9 章),但它不会停下来等——新轨迹的起点必须是当前正在执行的状态(位置、速度、加速度)。若还照搬首次的 min-snap 从零生成,起点状态是算出来的,与正在走的状态对不上,接缝处速度、加速度阶跃,运动就会突变。所以初始轨迹不能重新生成,只能「接着没走完的路」续:从上一段已执行轨迹、当前时刻之后截取
    请添加图片描述

  • reboundReplan 的 STEP 1 按 FSM 状态走两条采样分支:

    • 首次 / 换目标GEN_NEW_TRAJ):由「当前起点 s 0 s_0 s0 → 局部目标点」生成 min-snap 多项式,按时间均匀采样(5-3 节)
    • 中途重规划REPLAN_TRAJ):把「当前时刻 t c u r t_{\mathrm{cur}} tcur 之后的剩余部分」按伪弧长均匀重采样,得到新的路径点序列(planner_manager.cpp reboundReplan 的 STEP 1 else 分支)

请添加图片描述

  • 伪弧长指的是:把「走过的距离」当作标定轨迹位置的尺子——从 t c u r t_{\mathrm{cur}} tcur 起沿剩余轨迹逐点累计相邻采样点间的距离,得到弧长表 pseudo_arc_length;之后以固定弧长步长在这张表上等间隔取点,控制点间距就均匀了

说人话:原本的路径起点改变了,剩下的点如果还是用以前的,可能会出现数量不够的情况,这时候我们要借助 B-spline 的特性,直接取伪弧长,保证剩下的控制点数量是足够的,且等弧长取出的点间距是均匀的

// 对应 5-4 节:伪弧长重采样(planner_manager.cpp,reboundReplan STEP 1 else 分支,官方源码节选)
double t_cur = (ros::Time::now() - local_data_.start_time_).toSec();  // 当前执行时刻(相对本段起点)

vector<double> pseudo_arc_length;   // 累计弧长表:segment_point[i] 处的累计弧长
vector<Eigen::Vector3d> segment_point;   // 沿剩余轨迹逐点采出的原始路径点
pseudo_arc_length.push_back(0.0);   // 官方在后续尾巴采样处还包了 !pseudo_arc_length.empty() 空值守卫(此处因首元素已 push 0.0 恒非空,省略)
for (t = t_cur; t < local_data_.duration_ + 1e-3; t += ts)   // 从 t_cur 起,按 ts 扫完剩余轨迹
{
  segment_point.push_back(local_data_.position_traj_.evaluateDeBoorT(t));  // 取轨迹上的点
  if (t > t_cur)
    pseudo_arc_length.push_back((segment_point.back() - segment_point[segment_point.size() - 2]).norm()
                                + pseudo_arc_length.back());  // 弧长逐段累计
}
t -= ts;

double poly_time = (local_data_.position_traj_.evaluateDeBoorT(t) - local_target_pt).norm() / pp_.max_vel_ * 2;
if (poly_time > ts)   // 剩余轨迹终点离局部目标点还远:再接一段 min-snap 尾巴(5-3 节)平滑过去
{
  PolynomialTraj gl_traj = PolynomialTraj::one_segment_traj_gen(local_data_.position_traj_.evaluateDeBoorT(t),
                                                                local_data_.velocity_traj_.evaluateDeBoorT(t),
                                                                local_data_.acceleration_traj_.evaluateDeBoorT(t),
                                                                local_target_pt, local_target_vel,
                                                                Eigen::Vector3d::Zero(), poly_time);
  for (t = ts; t < poly_time; t += ts)
  {
    segment_point.push_back(gl_traj.evaluate(t));            // 尾巴上的点也追加进采样池
    pseudo_arc_length.push_back((segment_point.back() - segment_point[segment_point.size() - 2]).norm()
                                + pseudo_arc_length.back());
  }
}

double sample_length = 0;
double cps_dist = pp_.ctrl_pt_dist * 1.5;   // 目标间距;随后 ÷1.5 自适应细化
size_t id = 0;
do
{
  cps_dist /= 1.5;  point_set.clear();  sample_length = 0;  id = 0;
  while ((id <= pseudo_arc_length.size() - 2) && sample_length <= pseudo_arc_length.back())
  {
    if (sample_length >= pseudo_arc_length[id] && sample_length < pseudo_arc_length[id + 1])
    {
      // 在弧长表相邻两行之间线性插值,取「等弧长间隔」的点
      point_set.push_back((sample_length - pseudo_arc_length[id]) / (pseudo_arc_length[id + 1] - pseudo_arc_length[id]) * segment_point[id + 1]
                        + (pseudo_arc_length[id + 1] - sample_length) / (pseudo_arc_length[id + 1] - pseudo_arc_length[id]) * segment_point[id]);
      sample_length += cps_dist;
    }
    else
      id++;
  }
  point_set.push_back(local_target_pt);   // 末尾补上局部目标点
} while (point_set.size() < 7);           // 点数不足则继续细化(cps_dist ÷1.5)

start_end_derivatives.push_back(local_data_.velocity_traj_.evaluateDeBoorT(t_cur));      // 首端速度:直接继承执行轨迹
start_end_derivatives.push_back(local_target_vel);                                        // 末端速度
start_end_derivatives.push_back(local_data_.acceleration_traj_.evaluateDeBoorT(t_cur));  // 首端加速度:直接继承执行轨迹
start_end_derivatives.push_back(Eigen::Vector3d::Zero());

说人话:重规划要「接着没走完的路」重新算。直接从旧轨迹上截取一段下来,控制点疏密不匀,均匀 B-spline 不满足等间距前提;所以先量出每段的弧长,再按「每走多远取一个点」的固定步长重新取样,点距均匀、数量可控,起点还能和正在执行的轨迹平滑衔接。


附录

附录一:双圆柱体碰撞模型示意图生成代码
  • 生成 2-4 节「双圆柱 vs 单圆」对比图(已嵌入 2-4 节),直观看出单圆与双圆柱覆盖范围的差异

请添加图片描述

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np

fig, axes = plt.subplots(1, 2, figsize=(11, 4.6))

# 左图:单圆(按机身体长取大圆)——腰身被过度膨胀
ax = axes[0]
circle = plt.Circle((0, 0), 0.43, color='skyblue', alpha=0.45, ec='#3b6fa0')
ax.add_patch(circle)
# 机身示意(长条形)
ax.add_patch(plt.Rectangle((-0.43, -0.175), 0.86, 0.35, fc='#e8a33d', alpha=0.85, ec='k'))
ax.set_xlim(-0.6, 0.6); ax.set_ylim(-0.6, 0.6)
ax.set_aspect('equal'); ax.set_title('single circle R=0.43 (over-conservative)')

# 右图:双圆柱(前后各一个半径 0.25、中心 ±0.18)
ax = axes[1]
R, dc = 0.25, 0.18
for s in (+1, -1):
    ax.add_patch(plt.Circle((s * dc, 0), R, color='#7ec98e', alpha=0.55, ec='#2f7d3a'))
ax.add_patch(plt.Rectangle((-0.43, -0.175), 0.86, 0.35, fc='#e8a33d', alpha=0.85, ec='k'))
# 前圆柱中心
ax.plot([dc], [0], marker='o', color='k')
ax.annotate('front', (dc, 0), textcoords='offset points', xytext=(4, 8))
ax.plot([-dc], [0], marker='o', color='k')
ax.annotate('rear', (-dc, 0), textcoords='offset points', xytext=(4, 8))
ax.annotate(r'$d_c=0.18$', (0, 0), textcoords='offset points', xytext=(0, -20))
ax.set_xlim(-0.6, 0.6); ax.set_ylim(-0.6, 0.6)
ax.set_aspect('equal'); ax.set_title('double cylinder R=0.25, dc=0.18')

plt.tight_layout()
plt.savefig('scanplanner_double_cylinder.png', dpi=160)
print('saved: scanplanner_double_cylinder.png')
附录二:占据概率 logit 更新曲线生成代码
  • 生成 3-2 节占据概率随观测次数累积的曲线(已嵌入 3-2 节):命中加 hit 增量、穿过加 miss 增量,并展示 clamp 效果

请添加图片描述

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np

def logit(p): return np.log(p / (1 - p))

p_hit, p_miss = 0.85, 0.30
p_min, p_max, p_occ = 0.12, 0.98, 0.80
L_hit, L_miss = logit(p_hit), logit(p_miss)
L_min, L_max = logit(p_min), logit(p_max)
L_occ = logit(p_occ)

# 模拟一个体素:前 4 次观测命中,后 6 次穿过
L = L_min - 0.01          # 初始未知
hits = [L_min]
seq = [1, 1, 1, 1, 0, 0, 0, 0, 0, 0]
for m in seq:
    dL = L_hit if m == 1 else L_miss
    L = np.clip(L + dL, L_min, L_max)
    hits.append(L)

steps = np.arange(len(hits))
plt.figure(figsize=(6.8, 4.2))
plt.plot(steps, hits, 'o-', color='#3b6fa0', lw=1.8, label='log-odds L')
plt.axhline(L_occ, color='#b03a2e', ls='--', alpha=0.8, label='L_occ (occupied)')
plt.axhline(L_min, color='gray', ls=':', alpha=0.8, label='L_min')
plt.axhline(L_max, color='gray', ls=':', alpha=0.8, label='L_max')
plt.axvline(4.5, color='green', ls='--', alpha=0.5)
plt.text(4.6, 1.2, 'hit -> miss', fontsize=9)
plt.xlabel('observation count'); plt.ylabel('log-odds')
plt.title('Counting-based occupancy update (p_hit=0.85, p_miss=0.30)')
plt.legend(); plt.grid(alpha=0.3); plt.tight_layout()
plt.savefig('scanplanner_logit_update.png', dpi=160)
print('saved: scanplanner_logit_update.png')
附录三:双圆柱体膨胀广播俯视图生成代码
  • 生成 3-4 节「偏移集合 + 广播膨胀」的俯视图(已嵌入 3-4 节):左图 rebuildInflationOffsets 画出半径 R R R 圆内的全部偏移向量,右图 updateInflation 展示障碍体素把 + 1 +1 +1 广播到圆内所有体素、计数大于 0 即标记为「膨胀占用」

请添加图片描述

# -*- coding: utf-8 -*-
# 生成 3-4 节双圆柱体膨胀广播步骤俯视图(已嵌入 3-4 节)
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np

R, res = 0.25, 0.05        # double_cylinder_radius、resolution(格数 n = 5)
n = int(np.ceil(R / res))

fig, axes = plt.subplots(1, 2, figsize=(11, 5.2))

# ---------- 左图:偏移集合 rebuildInflationOffsets ----------
ax = axes[0]
for ix in range(-6, 7):
    for iy in range(-6, 7):
        ax.add_patch(plt.Rectangle((ix*res-0.5*res, iy*res-0.5*res), res, res,
                     fc='white', ec='#dddddd', lw=0.5))
off = [(ix, iy) for ix in range(-n, n+1) for iy in range(-n, n+1)
       if (ix*res)**2 + (iy*res)**2 < R**2]        # 圆内偏移(x^2+y^2 < R^2)
for (ix, iy) in off:
    ax.add_patch(plt.Rectangle((ix*res-0.5*res, iy*res-0.5*res), res, res,
                 fc='#7ec98e', ec='#2f7d3a', lw=0.6, alpha=0.7))
    ax.annotate('', xy=(ix*res, iy*res), xytext=(0, 0),
                arrowprops=dict(arrowstyle='->', color='#2f7d3a', lw=0.7, alpha=0.7))
ax.add_patch(plt.Rectangle((-0.5*res, -0.5*res), res, res, fc='#b03a2e', ec='k'))
theta = np.linspace(0, 2*np.pi, 100)
ax.plot(R*np.cos(theta), R*np.sin(theta), color='#b03a2e', lw=1.6, ls='--')
ax.annotate(r'$R = 0.25$ m', xy=(R, 0), xytext=(R+0.06, 0.04),
            fontsize=10, color='#b03a2e')
ax.text(0, -R-0.16, 'offset set (green cells)', ha='center', fontsize=9, color='#2f7d3a')
ax.set_title('(a) rebuildInflationOffsets: offsets with $x^2+y^2<R^2$', fontsize=10)
ax.set_xlim(-0.32, 0.32); ax.set_ylim(-0.34, 0.34)
ax.set_aspect('equal'); ax.set_xticks([]); ax.set_yticks([])

# ---------- 右图:广播 +1 → 膨胀占用 updateInflation ----------
ax = axes[1]
for ix in range(-6, 7):
    for iy in range(-6, 7):
        ax.add_patch(plt.Rectangle((ix*res-0.5*res, iy*res-0.5*res), res, res,
                     fc='white', ec='#dddddd', lw=0.5))
for (ix, iy) in off:
    ax.add_patch(plt.Rectangle((ix*res-0.5*res, iy*res-0.5*res), res, res,
                 fc='#f4a261', ec='#d9802c', lw=0.7))   # 收到 +1 → 膨胀占用
ax.add_patch(plt.Rectangle((-0.5*res, -0.5*res), res, res, fc='#b03a2e', ec='k'))
ax.text(0, 0, '+1', color='white', ha='center', va='center', fontsize=8, weight='bold')
ax.plot(R*np.cos(theta), R*np.sin(theta), color='#b03a2e', lw=1.6, ls='--')
ax.annotate(r'$R = 0.25$ m', xy=(R, 0), xytext=(R+0.06, 0.04),
            fontsize=10, color='#b03a2e')
ax.text(0, -R-0.16, 'inflated (cnt>0 -> flag=1)', ha='center', fontsize=9, color='#d9802c')
ax.set_title('(b) updateInflation: broadcast +1 to all offsets', fontsize=10)
ax.set_xlim(-0.32, 0.32); ax.set_ylim(-0.34, 0.34)
ax.set_aspect('equal'); ax.set_xticks([]); ax.set_yticks([])

plt.tight_layout()
plt.savefig('scanplanner_inflation_broadcast.png', dpi=160)
print('saved: scanplanner_inflation_broadcast.png')
附录四:航向 yaw 影响碰撞检测俯视图生成代码
  • 生成 3-5 节「航向不同 → 碰撞结论不同」的俯视图(已嵌入 3-5 节):机器人停在同一位置,前方一个障碍体素,左图 yaw = 0 ∘ =0^\circ =0front 点落进膨胀区判定碰撞,右图 yaw = 90 ∘ =90^\circ =90 时两个查询点都转开判定安全

请添加图片描述

# -*- coding: utf-8 -*-
# 生成 3-5 节:航向 yaw 如何影响双圆柱碰撞检测俯视图(已嵌入 3-5 节)
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np

R, dc = 0.25, 0.18        # double_cylinder_radius / double_cylinder_offset
res = 0.05
ox, oy = 0.25, 0.0        # 障碍体素中心(膨胀半径 R)

fig, axes = plt.subplots(1, 2, figsize=(11, 5.0))

def draw_scene(ax, yaw):
    """公共场景:网格 + 障碍体素 + 膨胀区 + 随 yaw 旋转的机身。"""
    for ix in range(-10, 11):
        for iy in range(-8, 9):
            ax.add_patch(plt.Rectangle((ix*res-0.5*res, iy*res-0.5*res), res, res,
                         fc='white', ec='#e8e8e8', lw=0.4))
    theta = np.linspace(0, 2*np.pi, 100)
    ax.fill(ox + R*np.cos(theta), oy + R*np.sin(theta),
            color='#f4a261', alpha=0.40, zorder=1)          # 障碍的膨胀区
    ax.plot(ox + R*np.cos(theta), oy + R*np.sin(theta),
            color='#d9802c', lw=1.4, ls='--', zorder=2)
    ax.annotate(r'inflated radius $R=0.25$', xy=(ox+R, oy), xytext=(ox+R+0.05, 0.04),
                fontsize=9, color='#d9802c', zorder=5)
    ax.add_patch(plt.Rectangle((ox-0.5*res, oy-0.5*res), res, res,
                 fc='#555555', ec='k', zorder=3))           # 障碍体素
    ax.text(ox, oy-0.10, 'obstacle', ha='center', fontsize=9,
            color='#333333', zorder=5)
    # 机身绕机器人中心 (0,0) 旋转 yaw 度
    body = plt.Rectangle((-0.35, -0.175), 0.70, 0.35,
                         angle=np.degrees(yaw), rotation_point=(0, 0),
                         fc='#e8a33d', alpha=0.95, ec='k', lw=0.8, zorder=4)
    ax.add_patch(body)

def draw_query(ax, yaw):
    """front/rear 两个查询点:落在膨胀区内→红(碰撞),区外→绿(安全)。"""
    heading = np.array([np.cos(yaw), np.sin(yaw)])
    front = dc * heading                       # pos + dc * heading
    rear  = -dc * heading                      # pos - dc * heading
    for name, pt in (('front', front), ('rear', rear)):
        d = np.hypot(pt[0]-ox, pt[1]-oy)
        if d < R:
            color, verdict = '#b03a2e', 'inside'      # 碰撞
        else:
            color, verdict = '#2f7d3a', 'outside'     # 安全
        ax.plot([pt[0]], [pt[1]], marker='o', ms=11, color=color,
                mec='k', mew=0.6, zorder=6)
        ax.annotate(f'{name} ({verdict})', (pt[0], pt[1]),
                    textcoords='offset points', xytext=(7, 7),
                    fontsize=9, color=color, zorder=7)
    # 航向箭头
    ax.annotate('', xy=(0.30*heading[0], 0.30*heading[1]), xytext=(0, 0),
                arrowprops=dict(arrowstyle='->', color='#3b6fa0', lw=2.2), zorder=5)
    ax.text(0.30*heading[0]+0.04, 0.30*heading[1]+0.04, 'heading',
            color='#3b6fa0', fontsize=9, zorder=5)

# 左图:yaw = 0° → front 点在膨胀区内,碰撞
ax = axes[0]
draw_scene(ax, 0.0)
draw_query(ax, 0.0)
ax.set_title(r'(a) yaw $= 0^\circ$: front inside inflated $\rightarrow$ collision',
             fontsize=10, color='#b03a2e')

# 右图:yaw = 90° → 两个查询点都在膨胀区外,安全
ax = axes[1]
draw_scene(ax, np.pi/2)
draw_query(ax, np.pi/2)
ax.set_title(r'(b) yaw $= 90^\circ$: both outside $\rightarrow$ safe',
             fontsize=10, color='#2f7d3a')

for ax in axes:
    ax.set_xlim(-0.5, 0.75); ax.set_ylim(-0.45, 0.45)
    ax.set_aspect('equal'); ax.set_xticks([]); ax.set_yticks([])

plt.tight_layout()
plt.savefig('scanplanner_yaw_collision.png', dpi=160)
print('saved: scanplanner_yaw_collision.png')
附录五:全局轨迹生成散点图生成代码
  • 生成 4-3 节「路点 → 插点 → min-snap 平滑轨迹」的散点图(已嵌入 4-3 节):红色大圆是输入路点,灰色小圆是长段插密点,蓝色曲线是 min-snap 多项式轨迹;min-snap 求解器直接复刻仓库 polynomial_traj.cpp 的 Q 矩阵,轨迹验证穿过所有点

请添加图片描述

# -*- coding: utf-8 -*-
# 生成第 4 章:全局轨迹生成散点图(路点 -> 插点 -> min-snap 平滑轨迹)
# min-snap 求解器与仓库 polynomial_traj.cpp 的 Q 矩阵一致
import math
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt


def min_snap_1d(P, T, v0=0.0, a0=0.0, vf=0.0, af=0.0):
    """一维 min-snap(Q 与仓库代码一致:最小化三阶导能量)。P: m+1 个路点,T: m 段时间"""
    m = len(T)
    nd = 6 * m

    # ---- Q:逐段分块,∫(p''')² dt ----
    Q = np.zeros((nd, nd))
    for k in range(m):
        t = T[k]
        for i in range(3, 6):
            for j in range(3, 6):
                Q[6 * k + i, 6 * k + j] = i * (i - 1) * (i - 2) * j * (j - 1) * (j - 2) / (i + j - 5) * t ** (i + j - 5)

    # ---- 约束行:phi(k, deriv, t) 取 c 的第 6k+i 项系数 = i!/(i-deriv)! t^(i-deriv) ----
    def phi(k, deriv, t):
        row = np.zeros(nd)
        for i in range(deriv, 6):
            row[6 * k + i] = math.factorial(i) / math.factorial(i - deriv) * t ** (i - deriv)
        return row

    rows, d = [], []
    rows.append(phi(0, 0, 0.0));   d.append(P[0])          # 起点位置
    rows.append(phi(0, 1, 0.0));   d.append(v0)            # 起点速度
    rows.append(phi(0, 2, 0.0));   d.append(a0)            # 起点加速度
    for k in range(m - 1):                                  # 每个连接点
        rows.append(phi(k, 0, T[k]));       d.append(P[k + 1])   # 段尾位置 = 路点
        rows.append(phi(k + 1, 0, 0.0));    d.append(P[k + 1])   # 段首位置 = 路点
        rows.append(phi(k, 1, T[k]) - phi(k + 1, 1, 0.0)); d.append(0.0)  # 速度连续
        rows.append(phi(k, 2, T[k]) - phi(k + 1, 2, 0.0)); d.append(0.0)  # 加速度连续
    rows.append(phi(m - 1, 0, T[-1])); d.append(P[-1])      # 终点位置
    rows.append(phi(m - 1, 1, T[-1])); d.append(vf)         # 终点速度
    rows.append(phi(m - 1, 2, T[-1])); d.append(af)         # 终点加速度
    A = np.array(rows)
    d = np.array(d)

    # ---- KKT 求解 ----
    K = np.block([[Q, A.T], [A, np.zeros((A.shape[0], A.shape[0]))]])
    rhs = np.concatenate([np.zeros(nd), d])
    sol = np.linalg.solve(K, rhs)
    return sol[:nd]


def eval_traj(c, T, n_per_seg=200):
    """把系数向量求值成密集点(含时间参数 t 和弧长)"""
    m = len(T)
    segs = []
    ts = []
    for k in range(m):
        ck = c[6 * k:6 * k + 6]
        tt = np.linspace(0.0, T[k], n_per_seg)
        # p(t) = Σ c_i t^i
        p = sum(ck[i] * tt ** i for i in range(6))
        segs.append(p)
        ts.append(tt)
    return segs, ts
# ---- 路点(米):一段先爬升、拐弯、再进 9 米长走廊、最后拐出 ----
waypoints = [(0.0, 0.0), (1.0, 2.0), (3.0, 1.5), (12.0, 1.5), (14.0, 0.0)]
vmax = 2.0

# ---- 插点(对应 4-4 代码:距离 > max(total_len/8, 4.0) 则等间距插入)----
pts = [np.array(waypoints[0])]
total_len = sum(np.linalg.norm(np.array(waypoints[i + 1]) - np.array(waypoints[i]))
                for i in range(len(waypoints) - 1))
dist_thresh = max(total_len / 8.0, 4.0)
for i in range(len(waypoints) - 1):
    a, b = np.array(waypoints[i]), np.array(waypoints[i + 1])
    dist = np.linalg.norm(b - a)
    if dist > dist_thresh:
        id_num = math.floor(dist / dist_thresh) + 1
        for j in range(1, id_num):
            pts.append(a * (1.0 - j / id_num) + b * (j / id_num))
    pts.append(b)
print(f"dist_thresh={dist_thresh:.2f} m, 插点后点数: {len(pts)}")

# ---- 时间分配(对应 4-2/4-4 代码:T_k=dist/vmax,首末段翻倍)----
T = []
for i in range(len(pts) - 1):
    T.append(np.linalg.norm(pts[i + 1] - pts[i]) / vmax)
T[0] *= 2.0
T[-1] *= 2.0
print("各段时间:", [round(t, 2) for t in T])

# ---- 分别对 X、Y 求 min-snap ----
Px = [p[0] for p in pts]
Py = [p[1] for p in pts]
cx = min_snap_1d(Px, T)
cy = min_snap_1d(Py, T)

# ---- 验证轨迹确实穿过每个点 ----
ok = True
for i, (px, py) in enumerate(zip(Px, Py)):
    # 该点位于段 k=i-1 的末尾 / 段 i 的开头
    if i == 0:
        k = 0; tt = 0.0
    elif i == len(pts) - 1:
        k = len(T) - 1; tt = T[k]
    else:
        k = i - 1; tt = T[k]
    ck_x = cx[6 * k:6 * k + 6]
    ck_y = cy[6 * k:6 * k + 6]
    ex = sum(ck_x[j] * tt ** j for j in range(6))
    ey = sum(ck_y[j] * tt ** j for j in range(6))
    err = np.linalg.norm([ex - px, ey - py])
    if err > 1e-8:
        ok = False
    print(f"点{i} 轨迹({ex:.3f},{ey:.3f}) vs 路点({px:.3f},{py:.3f}) 误差={err:.2e}")
print("穿过所有点:", "OK" if ok else "FAIL")

# ---- 求值 + 绘图 ----
segx, tss = eval_traj(cx, T)
segy, _ = eval_traj(cy, T)
X = np.concatenate(segx)
Y = np.concatenate(segy)

fig, ax = plt.subplots(figsize=(9.5, 4.4))
# min-snap 平滑轨迹
ax.plot(X, Y, '-', color='#2f7ed8', lw=2.2, zorder=3, label='min-snap trajectory')
# 插点(灰色小圆)
inserted = [p for p in pts[1:-1]]
ins_arr = np.array(inserted) if inserted else np.empty((0, 2))
ax.scatter(ins_arr[:, 0], ins_arr[:, 1], s=42, c='#9aa0a6', marker='o',
           edgecolors='#5f6368', linewidths=0.8, zorder=4, label='inserted points')
# 路点(红色大圆)
wp = np.array(waypoints)
ax.scatter(wp[:, 0], wp[:, 1], s=130, c='#e0453a', marker='o',
           edgecolors='#7c1008', linewidths=1.2, zorder=5, label='waypoints')

ax.annotate('waypoint $P_0$', (0, 0), xytext=(0.15, 0.35), fontsize=10, color='#7c1008')
ax.annotate('waypoint $P_4$', (14, 0), xytext=(10.7, -0.55), fontsize=10, color='#7c1008')
ax.annotate('inserted points\n(split long segment)', (6, 1.5), xytext=(5.0, 2.35),
            fontsize=9, color='#5f6368', ha='center',
            arrowprops=dict(arrowstyle='->', color='#5f6368', lw=0.8))
ax.annotate('min-snap trajectory\n(passes every point, smooth at corners)',
            (8.5, 1.15), xytext=(6.6, -1.15), fontsize=9, color='#1b5ba6', ha='center',
            arrowprops=dict(arrowstyle='->', color='#1b5ba6', lw=0.8))
ax.text(0.4, 2.6, '$T_0 \\leftarrow 2T_0$ (accel.)', fontsize=10, color='#b3541e')
ax.text(13.35, 0.7, '$T_{m-1} \\leftarrow 2T_{m-1}$', fontsize=10, color='#b3541e')

ax.set_xlim(-0.8, 15.2)
ax.set_ylim(-1.7, 3.3)
ax.set_aspect('equal')
ax.set_xlabel('x (m)')
ax.set_ylabel('y (m)')
ax.set_title('Global trajectory generation: waypoints → inserted points → min-snap')
ax.grid(True, ls='--', alpha=0.35)
ax.legend(loc='lower left', fontsize=9, framealpha=0.9)

plt.tight_layout()
plt.savefig('scanplanner_global_traj.png', dpi=170)
print('saved: scanplanner_global_traj.png')
附录六:全局路由驱动局部规划示意图生成代码
  • 生成第 6 章「全局路由 → 局部目标点 → 初始轨迹 → 碰撞区间 → A* 绕障」的链路示意图(已嵌入 6 章开头):灰色虚线路点是全局路由(提供方向的粗路由),路由上 3.5 m 处取局部目标点;红色虚线是 min-snap 初始轨迹(沿路由方向、穿过障碍), P 1 P_1 P1 P 3 P_3 P3 为碰撞控制点、in_id/out_id 为区间两端自由控制点;蓝色折线是 A* 只对碰撞段 in→out 搜索的绕障路径,终点是碰撞段出口 out_id 而不是局部目标点;图中各环节用分散标签直接标注请添加图片描述
# -*- coding: utf-8 -*-
# 第 6 章:A* 绕障搜索 —— 局部目标点 vs A* 搜索区间
# 链路:全局路由(灰色路点) → 取局部目标点(planning_horizon=3.5m) → 初始轨迹(min-snap, 红虚线)
#       → 碰撞检测标出 [in_id, out_id] → A* 只搜 in→out(蓝折线,终点=out_id,不是局部目标点)
import sys
import matplotlib
if 'mpl_toolkits' in sys.modules:
    del sys.modules['mpl_toolkits']
for k in list(sys.modules):
    if k.startswith('mpl_toolkits.mplot3d'):
        del sys.modules[k]
import numpy as np
import matplotlib.font_manager as fm
fm.fontManager.addfont('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc')
matplotlib.rcParams['font.sans-serif'] = ['Noto Sans CJK JP', 'DejaVu Sans']
matplotlib.rcParams['axes.unicode_minus'] = False
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse, Polygon

C_ROUTE = '#5f6368'
C_TRAJ = '#e0453a'
C_ASTAR = '#1b5ba6'
C_OBST = '#f4a261'
C_OBST_EDGE = '#d9802c'
C_INK = '#111111'
C_WARN = '#7c2f00'
C_TARGET = '#ff8c00'
C_ROBOT = '#b3541e'

y = 1.0
cx, cy, rx, ry = 4.4, y, 0.9, 0.6            # 膨胀障碍
waypts = [(1.2, y), (3.0, y), (4.4, y), (6.2, y), (8.0, y)]  # 全局路由路点
robot = (2.0, y)
target = (6.2, y)                             # 局部目标点(路由上 3.5m 处)
ctrl_x = [3.0, 3.7, 4.4, 5.1, 5.8]            # P_0..P_4(初始轨迹上的控制点)
IN_ID, OUT_ID = 0, 4                          # in_id=P_0(最后一个自由点) out_id=P_4(第一个自由点)
A_PATH = [(3.0, 1.0), (3.2, 1.9), (4.4, 2.3), (5.6, 1.9), (5.8, 1.0)]

fig, ax = plt.subplots(figsize=(10.2, 4.4))

# 全局路由(灰色虚线路点)——先画,让障碍盖住中间一段(示意「路由不知道这里有障碍」)
R = np.array(waypts)
ax.plot(R[:, 0], R[:, 1], '--', color=C_ROUTE, lw=1.6, alpha=0.9, zorder=1)
ax.scatter(R[:, 0], R[:, 1], s=34, marker='s', c=C_ROUTE, zorder=2)
ax.text(8.15, y + 0.09, '全局路由', fontsize=9.5, color=C_ROUTE, ha='left', va='bottom')

# 膨胀障碍
ax.add_patch(Ellipse((cx, cy), 2 * rx, 2 * ry, fc=C_OBST, alpha=0.45,
                     ec=C_OBST_EDGE, lw=1.4, ls='--', zorder=4))
ax.text(cx, cy, '膨胀障碍', ha='center', va='center', fontsize=9, color=C_OBST_EDGE, zorder=6)

# 初始轨迹(红虚线:min-snap 起点→局部目标点),覆盖在路由上方
ax.plot([robot[0], target[0]], [y, y], '--', color=C_TRAJ, lw=2.2, zorder=3)
ax.text(1.9, y - 0.30, '初始轨迹(min-snap:起点 → 局部目标点)', fontsize=9.5,
        color=C_TRAJ, ha='left', va='top', zorder=6)

# 控制点:碰撞的 P_1..P_3 红实心,两端 in/out 空心
for k, x in enumerate(ctrl_x):
    if k == IN_ID or k == OUT_ID:
        ax.scatter([x], [y], s=150, facecolors='none', edgecolors=C_WARN,
                   linewidths=1.8, zorder=7)
    else:
        ax.scatter([x], [y], s=120, c=C_TRAJ, marker='o',
                   edgecolors='#7c1008', linewidths=1.1, zorder=7)
    ax.text(x, y - 0.13, f'$P_{k}$', ha='center', fontsize=8.5, color=C_INK, zorder=7)
ax.text(ctrl_x[IN_ID], y + 0.13, 'in_id', ha='center', fontsize=9, color=C_WARN)
ax.text(ctrl_x[OUT_ID], y + 0.13, 'out_id', ha='center', fontsize=9, color=C_WARN)

# 机器人当前位姿(三角)
tri = Polygon([(robot[0] - 0.16, y - 0.14), (robot[0] - 0.16, y + 0.14), (robot[0] + 0.20, y)],
              closed=True, fc=C_ROBOT, ec=C_INK, lw=0.8, zorder=8)
ax.add_patch(tri)
ax.text(robot[0] - 0.14, y - 0.42, '机器人\n(当前起点)', fontsize=9, color=C_ROBOT,
        ha='center', va='top', zorder=8)

# 局部目标点(路由上 planning_horizon 处)
ax.scatter(*target, s=260, facecolors='none', edgecolors=C_TARGET, linewidths=2.2, zorder=8)
ax.text(target[0], y + 0.40, '局部目标点\n(路由上 3.5 m 处)', fontsize=9.5,
        color=C_TARGET, ha='center', va='bottom', zorder=8)
# 起点→局部目标点 的间距标注
ax.annotate('', xy=target, xytext=robot,
            arrowprops=dict(arrowstyle='<->', color=C_TARGET, lw=1.4, alpha=0.8,
                            shrinkA=24, shrinkB=32), zorder=5)
ax.text((robot[0] + target[0]) / 2, y - 0.30, 'planning_horizon = 3.5 m',
        fontsize=9, color=C_TARGET, ha='center', va='top', zorder=6)

# 碰撞区间括号 [in_id, out_id]
ax.plot([ctrl_x[IN_ID], ctrl_x[OUT_ID]], [0.74, 0.74], '-', color=C_WARN, lw=1.5)
ax.plot([ctrl_x[IN_ID], ctrl_x[IN_ID]], [0.70, 0.78], '-', color=C_WARN, lw=1.5)
ax.plot([ctrl_x[OUT_ID], ctrl_x[OUT_ID]], [0.70, 0.78], '-', color=C_WARN, lw=1.5)
ax.text((ctrl_x[IN_ID] + ctrl_x[OUT_ID]) / 2, 0.62, '碰撞区间 $[\\mathrm{in\\_id}, \\mathrm{out\\_id}]$',
        ha='center', fontsize=9.5, color=C_WARN, zorder=6)

# A* 绕障路径:in → out(蓝折线,只搜这一小段,终点不到局部目标点)
A = np.array(A_PATH)
ax.plot(A[:, 0], A[:, 1], '-', color=C_ASTAR, lw=2.6, marker='s', ms=6,
        mfc=C_ASTAR, mec='white', mew=0.7, zorder=6)
ax.text(5.95, 2.42, 'A* 绕障路径(in → out)', fontsize=9.5, color=C_ASTAR,
        ha='left', va='bottom', zorder=6)


ax.set_xlim(1.0, 8.7)
ax.set_ylim(0.1, 3.15)
ax.set_aspect('equal')
ax.set_xlabel('x (m)')
ax.set_ylabel('y (m)')
ax.set_title('第 6 章:全局路由 → 局部目标点 → 初始轨迹 → 碰撞区间 → A* 只搜 in→out',
             fontsize=11)

plt.tight_layout()
plt.savefig('scanplanner_global_to_local.png', dpi=160)
print('saved: scanplanner_global_to_local.png')
附录七:全局路径与初始轨迹关系示意图生成代码
  • 生成 5-1 节「全局轨迹 vs 初始轨迹」示意图(已嵌入 5-1 节):灰色实线为全局轨迹(第 4-3 节生成、一次穿过所有路点);机器人当前点 s 0 s_0 s0 不在路径上,先把当前位置投影到全局轨迹( t p r o j t_{proj} tproj),再沿路径往前 planning_horizon=3.5 m 取局部目标点(橙色圈);红色曲线为初始轨迹(第 5-3 节),从当前点另画、连到局部目标点;目标点之后全局轨迹画成浅灰虚线,示意「其余形状丢掉」

请添加图片描述

# -*- coding: utf-8 -*-
# 第 5-1 节:全局轨迹 vs 初始轨迹(同一张俯视图里看关系)
# 全局路径(灰)穿过所有路点、一次生成;机器人当前点 s0 不在路径上,先把当前位置投影到
# 全局轨迹(t_proj),再沿路径往前 planning_horizon=3.5 m 取局部目标点;初始轨迹(红)从当前点
# 另画、连到局部目标点——全局路径只贡献终点那一个坐标,目标点之后其余形状丢掉
import sys
import matplotlib
if 'mpl_toolkits' in sys.modules:
    del sys.modules['mpl_toolkits']
for k in list(sys.modules):
    if k.startswith('mpl_toolkits.mplot3d'):
        del sys.modules[k]
import numpy as np
import matplotlib.font_manager as fm
fm.fontManager.addfont('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc')
matplotlib.rcParams['font.sans-serif'] = ['Noto Sans CJK JP', 'DejaVu Sans']
matplotlib.rcParams['axes.unicode_minus'] = False
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon

C_ROUTE = '#5f6368'
C_ROUTE_DIM = '#9aa0a6'
C_TRAJ = '#e0453a'
C_TARGET = '#ff8c00'
C_PROJ = '#6a737d'
C_ROBOT = '#b3541e'
C_INK = '#111111'

YG = 1.6                      # 全局路径所在高度
waypts = [(1.2, YG), (3.0, YG), (4.7, YG), (6.5, YG), (8.5, YG)]  # 全局路径路点
s0 = (3.2, 0.85)              # 当前点(机器人实际位置,不在全局路径上)
tproj = (3.0, YG)             # 当前位置投影到全局轨迹的点 t_proj
target = (6.5, YG)            # 局部目标点(从 t_proj 沿路径往前 planning_horizon=3.5 m)

# 初始轨迹:从当前点 s0 出发、连到局部目标点(微弯曲线,区别于全局路径)
B0, B1, B2 = np.array(s0), np.array((4.9, 0.72)), np.array(target)
tt = np.linspace(0, 1, 100)
bezi = (1 - tt) ** 2 * B0[:, None] + 2 * (1 - tt) * tt * B1[:, None] + tt ** 2 * B2[:, None]

fig, ax = plt.subplots(figsize=(10.2, 4.8))

# 全局路径:目标点之前实线;目标点之后浅灰虚线 = 丢掉
R = np.array(waypts)
ax.plot(R[:, 0], R[:, 1], '-', color=C_ROUTE, lw=2.0, zorder=3)
ax.plot([6.5, 8.5], [YG, YG], '--', color=C_ROUTE_DIM, lw=1.6, zorder=2)
ax.scatter([p[0] for p in waypts], [YG] * 5, s=46, marker='s',
           c=[C_ROUTE] * 4 + [C_ROUTE_DIM], edgecolors='white', linewidths=0.8, zorder=4)
ax.text(0.95, YG + 0.33, '全局路径(第 4-3 节)', fontsize=10, color=C_ROUTE,
        ha='left', va='center', zorder=5)
ax.text(7.5, YG - 0.16, '丢掉', fontsize=8.5, color=C_ROUTE_DIM,
        ha='center', va='top', zorder=5)

# 投影:当前点 → 全局轨迹上的 t_proj
ax.plot([s0[0], tproj[0]], [s0[1], tproj[1]], ':', color=C_PROJ, lw=1.4, zorder=4)
ax.text(2.2, 1.18, '投影到全局轨迹', fontsize=8.5, color=C_PROJ,
        ha='right', va='center', zorder=5)

# 当前点 s0:机器人三角(实际位置)
tri = Polygon([(s0[0] - 0.18, s0[1] - 0.16), (s0[0] - 0.18, s0[1] + 0.16), (s0[0] + 0.22, s0[1])],
              closed=True, fc=C_ROBOT, ec=C_INK, lw=0.8, zorder=7)
ax.add_patch(tri)
ax.text(s0[0], s0[1] - 0.40, '当前点 $s_0$', fontsize=10, color=C_ROBOT,
        ha='center', va='top', zorder=7)

# 局部目标点
ax.scatter(*target, s=260, facecolors='none', edgecolors=C_TARGET, linewidths=2.2, zorder=7)
ax.text(6.6, YG + 0.26, '局部目标点', fontsize=10, color=C_TARGET,
        ha='left', va='center', zorder=7)

# planning_horizon 括号标注(全局路径上方,避让下方初始轨迹)
ax.plot([tproj[0], target[0]], [1.72, 1.72], '-', color=C_TARGET, lw=1.5, zorder=6)
ax.plot([tproj[0], tproj[0]], [YG, 1.72], '-', color=C_TARGET, lw=1.5, zorder=6)
ax.plot([target[0], target[0]], [YG, 1.72], '-', color=C_TARGET, lw=1.5, zorder=6)
ax.text(4.75, 1.86, 'planning_horizon = 3.5 m', fontsize=9, color=C_TARGET,
        ha='center', va='bottom', zorder=6)

# 初始轨迹(红粗线:当前点 s0 → 局部目标点)
ax.plot(bezi[0], bezi[1], '-', color=C_TRAJ, lw=3.2, zorder=5)
ax.text(4.9, 1.38, '初始轨迹(第 5-3 节)', fontsize=10, color=C_TRAJ,
        ha='left', va='center', zorder=6)

ax.set_xlim(0.75, 9.3)
ax.set_ylim(0.30, 2.35)
ax.set_aspect('equal')
ax.set_xlabel('x (m)')
ax.set_ylabel('y (m)')
ax.set_title('5-1:全局轨迹 vs 初始轨迹 —— 全局轨迹只贡献局部目标点那一个坐标', fontsize=11)

plt.tight_layout()
plt.savefig('scanplanner_global_local_init.png', dpi=160)
print('saved: scanplanner_global_local_init.png')
附录八:伪弧长重采样示意图生成代码
  • 生成 5-4 节「伪弧长重采样」示意图(已嵌入 5-4 节):上、下两格画同一段变速的「上一段已执行轨迹」——按时间戳等间隔采样(红点)点距随速度疏密不匀,破坏均匀 B-spline 的等间距前提;按伪弧长等间隔重采样(绿点)点距完全一致,顶部弧长轴上均匀刻度竖线示意「在弧长参数域均匀取点」

请添加图片描述

# -*- coding: utf-8 -*-
# 第 5-4 节:伪弧长重采样 —— 为什么不能按时间戳直接截断
# 上一段已执行轨迹(灰)是一条变速曲线:等时间戳采样 → 空间点距随速度变化、疏密不匀,
# 均匀 B-spline 的等间距前提被破坏;伪弧长重采样 → 在弧长参数域均匀取点,曲线上点距完全一致。
# 上:按时间戳等间隔采样(红点);下:按伪弧长等间隔重采样(绿点) + 弧长轴示意「在弧长域均匀取点」。
import sys
import matplotlib
if 'mpl_toolkits' in sys.modules:
    del sys.modules['mpl_toolkits']
for k in list(sys.modules):
    if k.startswith('mpl_toolkits.mplot3d'):
        del sys.modules[k]
import numpy as np
import matplotlib.font_manager as fm
fm.fontManager.addfont('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc')
matplotlib.rcParams['font.sans-serif'] = ['Noto Sans CJK JP', 'DejaVu Sans']
matplotlib.rcParams['axes.unicode_minus'] = False
matplotlib.use('Agg')
import matplotlib.pyplot as plt

C_TRAJ   = '#e0453a'   # 红:等时间戳采样点(疏密不匀)
C_RES    = '#1e8449'   # 绿:等弧长重采样点(均匀)
C_ROUTE  = '#5f6368'   # 灰:上一段已执行轨迹
C_ROUTE_DIM = '#9aa0a6'
C_INK    = '#111111'
C_ASTAR  = '#1a56a0'   # 蓝:弧长轴刻度线

# ---------- 构造一条「变速」的已执行轨迹,并做弧长参数化 ----------
t0 = np.linspace(0, 1, 2000)
x  = 0.6 + 8.8 * t0                                 # x 单调推进
y  = 1.05 + 0.85 * np.sin(1.65 * t0 * np.pi) + 0.12 * t0   # 波形:速度在弯道处快慢不一
dx, dy = np.diff(x), np.diff(y)
arc = np.concatenate([[0], np.cumsum(np.hypot(dx, dy))])   # 累计弧长表(同官方 pseudo_arc_length)
L   = arc[-1]

# 弧长均匀重采样成 1200 个高密度点,作为曲线的「弧长等距表示」
N_HI = 1200
s_hi = np.linspace(0, L, N_HI)
xh   = np.interp(s_hi, arc, x)
yh   = np.interp(s_hi, arc, y)

# ---- 采样方式 1:等时间戳(红)。时间均匀,弧长增量 ∝ 速度 → 点距不匀 ----
K = 9
tau = np.linspace(0, 1, K)
g   = tau ** 1.55                                   # 时间→弧长比例:下凸 → 前段点疏、后段点密
s_time = L * g
i_time = np.round(s_time / L * (N_HI - 1)).astype(int)
xt_, yt_ = xh[i_time], yh[i_time]

# ---- 采样方式 2:等弧长(绿)。弧长均匀 → 曲线上点距完全一致 ----
s_uni = np.linspace(0, L, K)
i_uni = np.round(s_uni / L * (N_HI - 1)).astype(int)
xu_, yu_ = xh[i_uni], yh[i_uni]

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10.4, 7.0))

# ================= 上:按时间戳采样 =================
ax1.plot(xh, yh, '-', color=C_ROUTE, lw=2.2, alpha=0.9, zorder=2)
ax1.plot(xt_, yt_, 'o', color=C_TRAJ, ms=9, mec='#7c1008', mew=1.0, zorder=4)
for i in range(K - 1):                             # 相邻点间连线,凸显间距差异
    ax1.plot([xt_[i], xt_[i + 1]], [yt_[i], yt_[i + 1]], '-', color=C_TRAJ,
             lw=1.2, alpha=0.45, zorder=3)
ax1.text(0.7, 2.28, '按时间戳等间隔采样:$t_k = t_{\\mathrm{cur}} + k\\,\\Delta t$', fontsize=10,
         color=C_TRAJ, ha='left', va='bottom')
ax1.text(0.7, 0.45, '点距随速度变化(前疏后密),不满足均匀 B-spline 等间距前提',
         fontsize=9.5, color=C_INK, ha='left', va='bottom')
ax1.text(9.0, 2.28, '点距 $\\propto$ 速度', fontsize=9, color=C_TRAJ,
         ha='right', va='bottom')
ax1.set_ylim(0.15, 2.5)

# ================= 下:按弧长重采样 =================
# 弧长轴(顶部):把弧长比例映射到曲线 x 区间,刻度均匀 = 弧长均匀
sx0, sx1 = 0.6, 9.4
ax2.plot([sx0, sx1], [2.62, 2.62], '-', color=C_ROUTE_DIM, lw=1.6, zorder=1)
ax2.text(sx1 + 0.06, 2.62, '弧长 $s$', fontsize=9.5, color=C_ROUTE_DIM,
         ha='left', va='center')
# 红点(时间采样)在弧长轴上的投影位置:不匀
sx_r = sx0 + s_time / L * (sx1 - sx0)
ax2.scatter(sx_r, [2.62] * K, marker='|', s=90, color=C_TRAJ, zorder=3)
# 绿点(弧长采样)在弧长轴上的投影位置:均匀
sx_g = sx0 + s_uni / L * (sx1 - sx0)
ax2.scatter(sx_g, [2.62] * K, marker='|', s=120, color=C_RES, zorder=3)
ax2.text((sx0 + sx1) / 2, 2.78, '弧长轴上均匀取点(绿)↔ 时间均匀投影(红)',
         fontsize=9.5, color=C_INK, ha='center', va='bottom')

ax2.plot(xh, yh, '-', color=C_ROUTE, lw=2.2, alpha=0.55, zorder=2)   # 原轨迹作淡参照
ax2.plot(xu_, yu_, 'o', color=C_RES, ms=9, mec='#0f4d24', mew=1.0, zorder=4)
# 均匀刻度竖线:弧长轴 → 曲线上绿点
for k in range(K):
    ax2.plot([sx_g[k], xu_[k]], [2.62, yu_[k]], ':', color=C_ASTAR, lw=1.0, alpha=0.7, zorder=1)
# 等距双箭头:任意两相邻绿点间距都是 Δs
ax2.annotate('', xy=(xu_[1], yu_[1]), xytext=(xu_[0], yu_[0]),
             arrowprops=dict(arrowstyle='<->', color=C_RES, lw=1.6, shrinkA=4, shrinkB=4), zorder=5)
ax2.text((xu_[0] + xu_[1]) / 2, (yu_[0] + yu_[1]) / 2 - 0.22, '$\\Delta s$ = 目标间距',
         fontsize=9.5, color=C_RES, ha='center', va='top', zorder=6)
ax2.text(9.0, 2.28, '等弧长点距', fontsize=9.5, color=C_RES, ha='right', va='bottom')
ax2.set_ylim(0.15, 2.95)

for ax, tag in [(ax1, '① 按时间戳截断(不推荐)'), (ax2, '② 按伪弧长重采样(官方做法)')]:
    ax.set_xlim(0.4, 10.2)
    ax.set_aspect('equal')
    ax.set_xticks([])
    ax.set_yticks([])
    ax.text(0.012, 0.03, tag, transform=ax.transAxes, fontsize=10.5, color=C_INK,
            ha='left', va='bottom',
            bbox=dict(boxstyle='round,pad=0.3', fc='white', ec=C_ROUTE_DIM, lw=0.8))

fig.suptitle('5-4:伪弧长重采样 —— 在弧长参数域均匀取点,控制点间距一致',
             fontsize=12, y=0.98)
plt.tight_layout(rect=[0, 0, 1, 0.96])
plt.savefig('scanplanner_resample.png', dpi=160)
print('saved: scanplanner_resample.png')
附录九:首次规划 vs 重规划初始轨迹生成示意图生成代码
  • 生成 5-4 节「首次规划 vs 重规划」示意图(已嵌入 5-4 节):上、下两格对照初始轨迹的起点状态来源不同——① 首次规划(第 5-3 节 GEN_NEW_TRAJ)从零用 min-snap 连一条「当前起点 → 局部目标点」的多项式,没有旧轨迹可续;② 重规划(第 5-4 节 REPLAN_TRAJ)机器人正在执行上一段轨迹(灰色,已走完部分实线、剩余部分虚线),新轨迹起点必须是当前正在执行的状态(位置/速度/加速度,蓝箭头 = 继承的起点速度),只能「接着没走完的路」续、绕开障碍连到局部目标点;若照搬首次从零 min-snap(棕红点线),起点方向与执行轨迹对不上,接缝处速度、加速度阶跃

请添加图片描述

# -*- coding: utf-8 -*-
# 第 5-4 节:首次规划 vs 重规划 —— 初始轨迹的起点状态来源不同
# 上:首次规划(第 5-3 节 GEN_NEW_TRAJ)—— 从零用 min-snap 连一条「当前起点 → 局部目标点」
#     的多项式,起点状态是多项式首端「算」出来的,没有旧轨迹可续。
# 下:重规划(第 5-4 节 REPLAN_TRAJ)—— 机器人正在执行上一段轨迹,随时可能因碰撞要换新轨迹
#     但它不停下等:新轨迹起点必须是当前正在执行的状态(位置/速度/加速度),只能「接着没走完
#     的路」续。画:原来的路径(灰,已走完实线 + 剩余虚线)、现在的点(三角)、已重规划的路径
#     (红,从当前状态出发、起点切线 = 旧轨迹切线,绕开障碍连到局部目标点)。若照搬首次从零
#     min-snap,起点方向与执行轨迹对不上 → 接缝处速度、加速度阶跃(棕红虚线 + 阶跃标注)。
import sys
import matplotlib
if 'mpl_toolkits' in sys.modules:
    del sys.modules['mpl_toolkits']
for k in list(sys.modules):
    if k.startswith('mpl_toolkits.mplot3d'):
        del sys.modules[k]
import numpy as np
import matplotlib.font_manager as fm
fm.fontManager.addfont('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc')
matplotlib.rcParams['font.sans-serif'] = ['Noto Sans CJK JP', 'DejaVu Sans']
matplotlib.rcParams['axes.unicode_minus'] = False
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon, FancyBboxPatch

C_ROUTE   = '#5f6368'   # 灰:上一段已执行轨迹(已走完)
C_ROUTE_DIM = '#9aa0a6' # 浅灰:剩余部分(将截断丢弃)
C_TRAJ    = '#e0453a'   # 红:首次 / 重规划初始轨迹
C_TARGET  = '#ff8c00'   # 橙:局部目标点
C_ROBOT   = '#b3541e'   # 机器人三角
C_INK     = '#111111'
C_OBST    = '#cfcfcf'   # 障碍(浅色,让剩余虚线可见)
C_TAN     = '#1a56a0'   # 蓝:接缝处继承的切线方向
C_WRONG   = '#7a5230'   # 棕红:错误做法(从零 min-snap 的起点方向)

# ---------- 三次贝塞尔工具 ----------
def cubic(B, N=600):
    B = np.asarray(B, float)
    tt = np.linspace(0, 1, N)
    return ((1-tt)**3 * B[0][:,None] + 3*(1-tt)**2*tt * B[1][:,None]
            + 3*(1-tt)*tt**2 * B[2][:,None] + tt**3 * B[3][:,None])

def cubic_deriv(B, t):
    B = np.asarray(B, float)
    return (3*(1-t)**2 * (B[1]-B[0]) + 6*(1-t)*t * (B[2]-B[1]) + 3*t**2 * (B[3]-B[2]))

def at_x(B, x0, N=800):
    p = cubic(B, N)
    i = int(np.argmin(np.abs(p[0] - x0)))
    return p[:, i], i

def norm(v):
    v = np.asarray(v, float)
    return v / np.hypot(v[0], v[1])

# ---------- 面板 ① 首次规划 ----------
S0    = np.array([1.2, 0.95])                       # 当前起点(多项式首端)
FIRST = np.array([S0, [4.1, 1.30], [6.5, 0.55], [8.8, 0.95]])   # 初始轨迹(微弯)
TGT   = np.array([8.8, 0.95])                       # 局部目标点

# ---------- 面板 ② 重规划 ----------
PREV  = np.array([[0.9, 0.75], [3.4, 1.50], [6.2, 0.40], [8.8, 0.95]])  # 上一段已执行轨迹
X_CUR = 5.0
P_CUR, _ = at_x(PREV, X_CUR)
t_cur = X_CUR / (PREV[3,0] - PREV[0,0]) * 0 + (np.clip((X_CUR - PREV[0,0]) / (PREV[3,0] - PREV[0,0]), 0, 1))
# 用数值法求切线更稳:找曲线上最近 t
tts = np.linspace(0, 1, 800)
pxs = cubic(PREV, 800)[0]
i_t = int(np.argmin(np.abs(pxs - X_CUR)))
tan = norm(cubic_deriv(PREV, tts[i_t]))
# 重规划路径:起点=当前点,初始方向=旧轨迹切线(继承速度),绕开障碍连到局部目标点
REPLAN = np.array([P_CUR,
                   P_CUR + tan * 0.95,
                   [6.35, 0.05],
                   TGT])
# 错误做法:若照搬首次从零 min-snap,起点方向 = 从当前点直连目标(与执行轨迹方向对不上)
WRONG  = np.array([P_CUR,
                   P_CUR + 0.55 * (TGT - P_CUR),
                   P_CUR + 0.9 * (TGT - P_CUR),
                   TGT])
OBST = (6.2, 0.6, 1.2, 0.9)   # (x0, y0, w, h):挡在剩余路径上,逼着重规划绕行

prev_all = cubic(PREV)
i_split  = int(np.argmin(np.abs(prev_all[0] - X_CUR)))

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10.6, 8.2))

# ================= ① 首次规划 =================
b1 = cubic(FIRST)
ax1.plot(b1[0], b1[1], '-', color=C_TRAJ, lw=3.2, zorder=5)
ax1.scatter(*TGT, s=260, facecolors='none', edgecolors=C_TARGET, linewidths=2.2, zorder=7)
tri1 = Polygon([(S0[0]-0.18, S0[1]-0.16), (S0[0]-0.18, S0[1]+0.16), (S0[0]+0.22, S0[1])],
               closed=True, fc=C_ROBOT, ec=C_INK, lw=0.8, zorder=7)
ax1.add_patch(tri1)
ax1.text(1.15, 0.28, '当前起点 $s_0$', fontsize=10, color=C_ROBOT, ha='center', va='top', zorder=7)
ax1.text(8.82, 1.38, '局部目标点', fontsize=10, color=C_TARGET, ha='right', va='center', zorder=7)
ax1.text(4.95, 1.74, '初始轨迹:min-snap 多项式(第 5-3 节)', fontsize=10, color=C_TRAJ,
         ha='center', va='bottom', zorder=6)
ax1.text(5.35, 0.28, '从零连一条「当前起点 → 局部目标点」的多项式,起点状态是多项式首端「算」出来的',
         fontsize=9.5, color=C_INK, ha='center', va='top', zorder=6)
ax1.set_xlim(0.7, 9.6); ax1.set_ylim(0.10, 2.05)

# ================= ② 重规划 =================
# 上一段已执行轨迹:已走完部分(实线灰)
ax2.plot(prev_all[0][:i_split+1], prev_all[1][:i_split+1], '-', color=C_ROUTE, lw=2.2, zorder=3)
# 剩余部分(浅灰虚线:继续走会撞障碍,将被截断丢弃)
ax2.plot(prev_all[0][i_split:], prev_all[1][i_split:], '--', color=C_ROUTE_DIM, lw=1.8, zorder=3)
# 障碍
ax2.add_patch(FancyBboxPatch((OBST[0], OBST[1]), OBST[2], OBST[3],
                             boxstyle='round,pad=0.03,rounding_size=0.12',
                             fc=C_OBST, ec='#8a8a8a', lw=1.0, zorder=2))
ax2.text(OBST[0]+OBST[2]/2, OBST[1]+OBST[3]/2, '障碍', fontsize=10, color=C_INK,
         ha='center', va='center', zorder=4)

# 当前点:机器人三角 + 继承的切线方向(蓝箭头)
tri2 = Polygon([(P_CUR[0]-0.18, P_CUR[1]-0.16), (P_CUR[0]-0.18, P_CUR[1]+0.16), (P_CUR[0]+0.22, P_CUR[1])],
               closed=True, fc=C_ROBOT, ec=C_INK, lw=0.8, zorder=8)
ax2.add_patch(tri2)
ax2.annotate('', xy=P_CUR + tan*1.0, xytext=P_CUR,
             arrowprops=dict(arrowstyle='-|>', color=C_TAN, lw=2.4, shrinkA=2, shrinkB=2), zorder=8)

# 错误做法(棕红点线:照搬首次从零 min-snap,起点方向与执行轨迹不一致)
bw = cubic(WRONG)
ax2.plot(bw[0], bw[1], ':', color=C_WRONG, lw=2.2, zorder=4)

# 重规划路径(红粗线:从当前状态接着续,绕开障碍)
b2 = cubic(REPLAN)
ax2.plot(b2[0], b2[1], '-', color=C_TRAJ, lw=3.2, zorder=6)
ax2.scatter(*TGT, s=260, facecolors='none', edgecolors=C_TARGET, linewidths=2.2, zorder=8)

# 标注
ax2.text(0.95, 1.88, '上一段已执行轨迹', fontsize=10, color=C_ROUTE, ha='left', va='center', zorder=7)
ax2.text(1.05, 1.30, '已走完', fontsize=9, color=C_ROUTE, ha='left', va='center', zorder=7)
ax2.text(7.75, 1.70, '剩余部分(将截断丢弃)', fontsize=8.5, color=C_ROUTE_DIM,
         ha='center', va='bottom', zorder=7)
ax2.text(5.0, 0.32, '当前点 $t_{\\mathrm{cur}}$', fontsize=9.5,
         color=C_ROBOT, ha='center', va='top', zorder=7)
ax2.text(7.62, 0.12, '重规划路径(第 5-4 节)',
         fontsize=9.5, color=C_TRAJ, ha='left', va='bottom', zorder=7)
ax2.text(2.35, 0.55, '若照搬首次从零 min-snap:\n起点方向与执行轨迹对不上 → 阶跃',
         fontsize=8.5, color=C_WRONG, ha='right', va='center', zorder=7)
ax2.text(3.5, 1.90, '新轨迹起点 = 当前执行状态\n(位置/速度/加速度,蓝箭头 = 继承的起点速度)',
         fontsize=9.5, color=C_INK, ha='center', va='center', zorder=7,
         bbox=dict(boxstyle='round,pad=0.35', fc='white', ec=C_ROUTE_DIM, lw=0.8))
ax2.set_xlim(0.7, 9.6); ax2.set_ylim(-0.05, 2.2)

for ax, tag in [(ax1, '① 首次规划(第 5-3 节):从零 min-snap'),
                (ax2, '② 重规划(第 5-4 节):接着上一段轨迹续')]:
    ax.set_aspect('equal')
    ax.set_xticks([])
    ax.set_yticks([])
    ax.text(0.985, 0.97, tag, transform=ax.transAxes, fontsize=10.5, color=C_INK,
            ha='right', va='top',
            bbox=dict(boxstyle='round,pad=0.3', fc='white', ec=C_ROUTE_DIM, lw=0.8))

fig.suptitle('5-4:首次规划从零连、重规划接着续 —— 新轨迹起点必须是当前正在执行的状态',
             fontsize=12, y=0.985)
plt.tight_layout(rect=[0, 0, 1, 0.97])
plt.savefig('scanplanner_first_vs_replan.png', dpi=160)
print('saved: scanplanner_first_vs_replan.png')

下篇

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐