4. RRT算法实现:从伪代码到工程落地

RRT(Rapidly-exploring Random Tree)这个名字,说白了就是「快速探索随机树」。我最早接触它是在2015年做七轴协作臂避障项目时,当时被它的「随机」二字迷惑过——总觉得随机出来的路径不靠谱。后来才发现,正是这种随机性让它在高维空间里比传统方法快了一个数量级。

这一章,咱们手把手把RRT的核心模块拆开。我会给出Python风格的伪代码,然后逐个讲清楚:节点采样、最近邻搜索、Steer函数、碰撞检测。嗯,每个模块我都踩过坑,咱们一起避过去。

核心思想一句话: RRT通过在C空间中随机撒点,用树状结构快速「生长」出一条从起点到终点的无碰撞路径。它不是最优的,但它是「能找到一条路就行」场景下的首选。
RRT算法核心流程 初始化树T 随机采样 q_rand 最近邻搜索 q_near Steer 扩展 碰撞检测 通过 碰撞→丢弃 添加节点/边 循环直到到达目标 💡 关键点: 每次迭代只扩展一步(步长δ),碰撞检测只检查 q_near → q_new 这一段。树就这样一步步「长」向目标。

4.1 整体伪代码框架

先看骨架。RRT的伪代码其实很简洁,核心就是一个while循环:

def rrt_plan(q_start, q_goal, max_iter=1000, step_size=0.1):
    # 1. 初始化树
    tree = Tree()
    tree.add_node(q_start)
    
    for i in range(max_iter):
        # 2. 随机采样
        q_rand = sample_random()
        
        # 3. 最近邻搜索
        q_near = nearest_neighbor(tree, q_rand)
        
        # 4. Steer扩展
        q_new = steer(q_near, q_rand, step_size)
        
        # 5. 碰撞检测
        if not collision_free(q_near, q_new):
            continue  # 有碰撞,跳过
        
        # 6. 添加节点和边
        tree.add_node(q_new)
        tree.add_edge(q_near, q_new)
        
        # 7. 检查是否到达目标
        if distance(q_new, q_goal) < goal_threshold:
            return extract_path(tree, q_new)
    
    return None  # 规划失败

这个框架我用了好多年,基本没大改过。你想想看,每次迭代就做五件事:采样、找最近、扩展、检测、添加。简单粗暴,但有效。

我的习惯: max_iter不要设死。我一般设一个动态上限——比如连续100次采样都失败就提前退出,而不是傻等1000次。这在障碍物密集的场景下能省不少时间。

4.2 节点采样:随机与引导的平衡

采样是RRT的「眼睛」。最简单的做法就是均匀随机采样——在关节空间里每个关节角度范围内均匀抽一个值。但纯随机有个问题:树可能到处乱长,就是不长向目标。

我常用的改进是目标偏置采样:

def sample_random(goal_bias=0.1):
    if random.random() < goal_bias:
        return q_goal  # 以一定概率直接采样目标点
    else:
        # 均匀随机采样
        q = []
        for joint_range in joint_limits:
            q.append(random.uniform(joint_range[0], joint_range[1]))
        return q

goal_bias我一般取0.05~0.2。取大了树容易「急功近利」——总想一步跨到目标,结果被障碍物挡住,反复失败。取小了又太散漫。我在一个六轴码垛项目里试过,0.1是个不错的起点。

我曾经踩过的坑: 采样时忘了检查采样点本身是否在障碍物内。虽然碰撞检测在后面会过滤,但采样点本身就在障碍物里的话,最近邻搜索会浪费大量计算。建议采样后加一个快速自检:如果q_rand在障碍物内,直接重采样。

4.3 最近邻搜索:KD-Tree是标配

最近邻搜索就是找树里离q_rand最近的节点。最笨的办法是遍历所有节点算欧氏距离——树小的时候还行,树长到几千个节点就慢得让人抓狂。

我个人的建议:直接用KD-Tree。Python里scipy.spatial.cKDTree就很好用,查询复杂度O(log N)。

from scipy.spatial import cKDTree

class RRTTree:
    def __init__(self):
        self.nodes = []      # 节点列表
        self.parent = {}     # 父子关系
        self.kd_tree = None  # KD-Tree
    
    def add_node(self, q):
        idx = len(self.nodes)
        self.nodes.append(q)
        # 重建KD-Tree(增量式更新更高效,但这里简化)
        self.kd_tree = cKDTree(self.nodes)
        return idx
    
    def nearest_neighbor(self, q_rand):
        # 返回最近节点索引和距离
        dist, idx = self.kd_tree.query(q_rand, k=1)
        return self.nodes[idx], idx

注意:每次添加节点都重建KD-Tree其实有开销。如果节点数超过1万,我建议用增量式KD-Tree或者每加N个节点才重建一次。我在一个需要实时重规划的项目里,就是每加50个节点重建一次,性能提升很明显。

4.4 Steer函数:一步走多远

Steer函数决定了树「长」的方向和步长。说白了就是:从q_near出发,朝着q_rand方向走一步,但最多走step_size那么远。

def steer(q_near, q_rand, step_size):
    # 计算方向向量
    delta = [q_rand[i] - q_near[i] for i in range(len(q_near))]
    dist = math.sqrt(sum(d**2 for d in delta))
    
    if dist < step_size:
        # 目标就在一步之内,直接返回
        return q_rand[:]
    else:
        # 归一化后走一步
        step = [d / dist * step_size for d in delta]
        return [q_near[i] + step[i] for i in range(len(q_near))]

step_size怎么选?我有个经验公式:step_size ≈ 关节范围最大值的5%~10%。比如关节范围是[-180°, 180°],那step_size取18°左右。太小了树长太慢,太大了容易跳过狭窄通道。

避坑指南: 对于有旋转关节的机械臂,关节角度有周期性(比如-179°和181°其实只差2°)。Steer时一定要做角度归一化处理,否则步长计算会出错。我早期一个项目就因为这个,机械臂在奇异点附近疯狂抖动。

4.5 碰撞检测:最耗时的环节

碰撞检测是RRT的性能瓶颈。每次扩展都要检查q_near到q_new这一段路径是否与障碍物碰撞。你不能只检查q_new这一个点——万一中间穿模了呢?

标准做法是离散化检测:把路径分成若干小段,逐段检测。

def collision_free(q_near, q_new, resolution=0.01):
    # 计算路径长度
    dist = distance(q_near, q_new)
    # 按分辨率采样
    num_steps = max(2, int(dist / resolution))
    
    for i in range(num_steps + 1):
        t = i / num_steps
        # 线性插值
        q = [q_near[j] + t * (q_new[j] - q_near[j]) for j in range(len(q_near))]
        
        # 正运动学:关节角 → 末端位姿 + 连杆位姿
        poses = forward_kinematics(q)
        
        # 对每个连杆做碰撞检测
        for pose in poses:
            if check_collision(pose):
                return False  # 有碰撞
    
    return True

这里有个关键点:正运动学要算所有连杆的位姿,不只是末端。我见过有人只检查末端和基座,结果机械臂中间连杆穿过了障碍物——那画面太美我不敢看。

碰撞检测方法 精度 速度 适用场景
包围盒(AABB/OBB) 中等 快 快速预检、实时规划
球体包络 较低 极快 粗检测、高维空间
网格模型(Mesh) 高 慢 离线精细检测
SDF(符号距离场) 高 中等 动态环境、GPU加速
我的工程经验: 实际项目中我常用「两级检测」——先用球体包络快速过滤掉明显无碰撞的路径,再用网格模型做精确检测。这样能把碰撞检测的时间压缩到原来的1/5左右。你想想看,RRT里90%的扩展都是被碰撞检测挡掉的,这优化太值了。

4.6 完整伪代码整合

把上面所有模块拼起来,就是一个可运行的RRT规划器了。我贴一个完整版的伪代码,注意看各个模块之间的衔接:

class RRTPlanner:
    def __init__(self, joint_limits, obstacles, step_size=0.1, goal_bias=0.1):
        self.joint_limits = joint_limits
        self.obstacles = obstacles
        self.step_size = step_size
        self.goal_bias = goal_bias
        self.tree = RRTTree()
    
    def plan(self, q_start, q_goal, max_iter=1000):
        self.tree.add_node(q_start)
        
        for i in range(max_iter):
            # 采样
            q_rand = self.sample(self.goal_bias, q_goal)
            
            # 最近邻
            q_near, _ = self.tree.nearest_neighbor(q_rand)
            
            # Steer
            q_new = self.steer(q_near, q_rand, self.step_size)
            
            # 碰撞检测
            if not self.is_collision_free(q_near, q_new):
                continue
            
            # 添加节点
            idx_new = self.tree.add_node(q_new)
            self.tree.add_edge(q_near, q_new)
            
            # 检查目标
            if self.distance(q_new, q_goal) < 0.05:
                return self.extract_path(idx_new)
        
        return None  # 规划失败

这个框架我一直在用,从六轴工业臂到七轴协作臂,从静态环境到动态避障,核心逻辑基本没变过。你把这个吃透了,后面学RRT*、Informed RRT*、RRT-Connect都会轻松很多。

最后提醒一句: RRT找到的路径通常很「糙」——弯弯绕绕的,关节运动也不平滑。别指望它直接用于轨迹跟踪。我们后面会讲路径平滑和轨迹插补,那才是让机械臂真正「丝滑」起来的关键。但RRT负责的是「能不能到」,这一步做不好,后面全是白搭。