机器人运动规划实战:从“钢琴搬运”到C空间建模的Python之旅

你是否曾想过,家里的扫地机器人是如何在桌椅腿之间灵巧穿梭,最终覆盖整个房间的?或者,工厂里的机械臂是如何在密集的流水线上精准抓取零件,而不会撞到任何障碍物的?这背后隐藏的核心技术,就是机器人运动规划。它远不止是让机器人“动起来”那么简单,而是要在复杂、充满约束的世界里,为机器人找出一条从A点到B点的“最优”或至少“可行”的路径。这听起来像是一个简单的寻路问题,但当你把机器人的形状、关节的转动、环境的几何结构都考虑进去时,它就变成了一个在高维空间中进行的、充满挑战的智力游戏。

今天,我们不打算从厚重的教科书理论开始。让我们从一个更直观、更经典的场景切入:“钢琴搬运问题”。想象一下,你需要将一架三角钢琴从一个房间的角落,穿过一道门,搬到另一个房间。钢琴是刚性的、有固定形状的物体,房间里有墙壁、门框、家具等障碍。你的目标很明确:找出一条移动钢琴的路径,确保在整个搬运过程中,钢琴的任何一个部分都不会与障碍物发生碰撞。这个问题完美地抽象了机器人运动规划的核心——在充满障碍物的环境中,为一个有特定几何形状的物体规划无碰撞路径。

对于刚接触这个领域的开发者或爱好者而言,理论公式和数学推导常常让人望而却步。本文的目标,就是绕过繁复的数学前置知识,直接通过Python代码和可视化,带你亲手构建一个简易的运动规划世界。我们将从“钢琴搬运”这个具体问题出发,逐步引出机器人学中至关重要的 “C空间” 概念,并最终用代码实现一个基础的规划算法。你会发现,那些听起来高深的概念,一旦被放在具体的、可操作的代码语境下,就会变得清晰而有趣。

1. 从具体问题出发:拆解“钢琴搬运”

在深入算法之前,我们必须先形式化地定义问题。运动规划不是一个模糊的想法,而是一个可以被精确定义和计算的数学问题。

1.1 问题定义与核心要素

任何一个标准的运动规划问题,都包含以下几个不可或缺的要素:

  1. 工作空间:机器人实际活动的物理环境。在我们的例子中,就是那两个房间以及连接它们的门廊。我们通常用二维或三维的欧几里得空间(如 R^2R^3)来建模工作空间。
  2. 障碍物:工作空间中机器人不能进入的区域。墙壁、家具、门框(在门未完全打开时)都是障碍物。我们需要在代码中明确地定义它们的几何形状(如多边形、圆形)。
  3. 机器人:我们需要规划其运动的物体。它有自己的几何形状。在“钢琴搬运”中,钢琴被简化为一个刚体,意味着在移动过程中,其内部各点的相对位置保持不变,它只能进行整体的平移旋转
  4. 起始配置与目标配置:配置描述了机器人在某一时刻的“姿态”。对于平面上的一个矩形钢琴,其配置可以用其中心点的坐标 (x, y) 和旋转角度 θ 来表示。起始配置是钢琴的初始位置和朝向,目标配置是我们希望它到达的位置和朝向。
  5. 路径:一条从起始配置连续变化到目标配置的“轨迹”。这条轨迹必须保证,在每一个中间配置上,机器人的几何形状都与所有障碍物无交集。

提示:这里我们做了“刚体”和“平面运动”的假设,这大大简化了问题。真实的机器人,如机械臂,是由多个连杆通过关节连接而成的,其运动更为复杂,但核心的规划思想是相通的。

1.2 碰撞检测:规划算法的基石

在规划路径之前,我们必须有一个可靠的方法来判断某个给定的机器人配置是否与障碍物发生碰撞。这就是碰撞检测。它是所有运动规划算法的底层基础,其效率和准确性直接决定了上层规划算法的性能。

对于简单的几何形状,碰撞检测可以很直观。例如,判断一个点是否在一个多边形内,或者两个凸多边形是否相交。在Python中,我们可以借助强大的几何计算库 shapely 来轻松实现。

# 示例:使用shapely进行基本的2D碰撞检测
from shapely.geometry import Polygon, Point

# 定义一个障碍物(一个矩形)
obstacle = Polygon([(2, 2), (5, 2), (5, 5), (2, 5)])

# 定义机器人(简化为一个点)
robot_position = Point(3, 3)
robot_as_circle = robot_position.buffer(0.5)  # 将点机器人视为一个半径为0.5的圆

# 进行碰撞检测
if robot_as_circle.intersects(obstacle):
    print("发生碰撞!")
else:
    print("安全。")

# 更复杂的情况:机器人是一个多边形(比如我们的钢琴)
robot_polygon = Polygon([(1, 1), (3, 1), (3, 2), (1, 2)])
if robot_polygon.intersects(obstacle):
    print("机器人多边形与障碍物碰撞!")

在实际的规划算法中,碰撞检测会被调用成千上万次,因此其优化至关重要。对于复杂模型,通常会使用层次包围盒等技术来加速,例如先快速判断两个物体的外接球或轴向包围盒是否相交,如果连包围盒都不相交,则无需进行更精细的几何计算。

2. 维度的跃升:引入C空间的概念

直接在工作空间中对机器人的形状进行碰撞检测和路径搜索是非常直观的,但也会遇到一个巨大的麻烦:机器人的朝向。当钢琴旋转时,它在工作空间中占据的区域是变化的。我们需要为每一个可能的位置 (x, y) 和每一个可能的角度 θ 检查碰撞。这相当于在一个三维的空间中搜索路径,这个空间就是配置空间

2.1 什么是C空间?

配置空间,简称 C空间,是运动规划中一个革命性的概念。它的核心思想是:将机器人的每一个可能的“姿态”映射为这个空间中的一个点

  • 对于一个在平面上自由移动的点机器人,它的配置就是其坐标 (x, y),所以它的C空间就是二维平面 R^2
  • 对于我们的矩形钢琴(刚体,可平移和旋转),它的配置是 (x, y, θ),所以它的C空间是三维的:R^2 × S^1。其中 S^1 表示一个圆环(因为角度θ从0到2π循环)。
  • 对于一个简单的两连杆机械臂,它的配置是两个关节的角度 (θ1, θ2),其C空间是一个二维环面 T^2(两个圆环的笛卡尔积)。

C空间的妙处在于,它将一个有形状的物体的运动规划问题,转化为了一个在该空间内的运动规划问题。在C空间中:

  • C空间障碍物:所有会导致机器人与工作空间障碍物发生碰撞的配置点的集合。它是一个在C空间内定义的区域。
  • 自由空间:C空间中去掉C空间障碍物后剩下的区域。自由空间中的任何一个点,都对应着工作空间中一个安全的、无碰撞的机器人姿态。
  • 路径规划问题:在C空间的自由空间中,寻找一条连接起始配置点和目标配置点的连续曲线。

2.2 C空间障碍物的构建

构建C空间障碍物是理论上的关键步骤,称为 Minkowski和 运算。简单来说,对于工作空间中的每个障碍物,想象用机器人的形状去“扫过”障碍物的边界,所有机器人参考点(如中心)所能到达的位置的集合,就构成了该障碍物在C空间中的映射。对于有旋转的机器人,这个过程需要在每个角度下都做一遍,导致C空间障碍物成为三维空间中一个复杂的形状。

在实践中,对于复杂的机器人,精确计算C空间障碍物非常困难。因此,大多数现代规划算法并不显式地计算整个C空间,而是采用“在需要时进行碰撞检测”的策略。算法在C空间中采样或搜索时,每当生成一个新的候选配置,就调用碰撞检测函数来判断该配置是否在自由空间中。

# 示例:一个简化版的C空间障碍物可视化(针对点机器人)
import numpy as np
import matplotlib.pyplot as plt

# 定义工作空间障碍物(多个多边形)
obstacles = [
    Polygon([(1, 1), (4, 1), (4, 3), (1, 3)]),
    Polygon([(7, 6), (9, 6), (9, 9), (7, 9)])
]

# 创建C空间的离散网格(这里C空间就是工作空间,因为机器人是一个点)
x = np.linspace(0, 10, 101)
y = np.linspace(0, 10, 101)
X, Y = np.meshgrid(x, y)

# 初始化C空间地图:0表示自由,1表示障碍
c_space_map = np.zeros((101, 101))

# 对于网格中的每个点,检查是否在任何障碍物内
for i in range(101):
    for j in range(101):
        p = Point(X[i, j], Y[i, j])
        for obs in obstacles:
            if p.within(obs):
                c_space_map[i, j] = 1
                break

# 可视化C空间
plt.figure(figsize=(8, 6))
plt.contourf(X, Y, c_space_map, levels=[-0.5, 0.5, 1.5], colors=['white', 'red'], alpha=0.5)
plt.plot(2, 2, 'go', markersize=10, label='起点 (q_start)')
plt.plot(8, 8, 'b*', markersize=15, label='终点 (q_goal)')
plt.xlabel('X (C空间维度1)')
plt.ylabel('Y (C空间维度2)')
plt.title('点机器人的C空间表示\n红色区域为C空间障碍物')
plt.legend()
plt.grid(True)
plt.axis('equal')
plt.show()

这个简单的例子展示了对于点机器人,C空间障碍物就是工作空间障碍物本身。但对于有体积的机器人,图中的红色区域会向外“膨胀”,这个膨胀的区域就是C空间障碍物。

3. 规划算法实战:基于采样的快速探索随机树

既然我们已经将问题抽象为“在C空间的自由空间中为点找路径”,那么就可以应用各种图搜索或采样规划算法。其中,快速探索随机树 因其简单性和在高维空间的有效性,成为入门和实践的首选。

3.1 RRT算法核心思想

RRT算法的核心思想非常直观,像一棵树在空间中生长:

  1. 初始化:树的根节点是起始配置 q_start
  2. 随机采样:在C空间(或自由空间)中随机采样一个点 q_rand
  3. 寻找最近邻:在当前的树中,找到距离 q_rand 最近的节点 q_near
  4. 扩展新节点:从 q_near 朝着 q_rand 的方向迈出一小步(步长 ε),得到一个新的候选配置 q_new
  5. 碰撞检测:检查从 q_nearq_new 的直线路径(在C空间中)是否完全位于自由空间内。如果是,则将 q_new 加入树中,并将其父节点设置为 q_near
  6. 循环与终止:重复步骤2-5。如果 q_new 距离目标配置 q_goal 足够近,或者树已经生长到包含目标点,则算法成功,可以通过回溯父节点得到路径。如果迭代次数超过上限仍未找到路径,则宣告失败。

RRT的优势在于它渐进最优(随着采样点增多,找到路径的概率趋近于1)和易于实现。它特别适合高维C空间,因为其探索不依赖于对空间的全局离散化。

3.2 Python实现一个基础的RRT

下面我们实现一个在二维C空间(对应点机器人或仅平移的刚体)中运行的简化版RRT。

import numpy as np
import matplotlib.pyplot as plt
from scipy.spatial import KDTree

class SimpleRRT:
    def __init__(self, space_bounds, obstacles, step_size=0.5, goal_tolerance=0.5, max_iter=5000):
        """
        初始化RRT规划器
        :param space_bounds: C空间边界,例如 [(0, 10), (0, 10)]
        :param obstacles: 障碍物列表,每个障碍物是shapely的Polygon
        :param step_size: 扩展步长
        :param goal_tolerance: 接近目标的容差
        :param max_iter: 最大迭代次数
        """
        self.bounds = space_bounds
        self.obstacles = obstacles
        self.step_size = step_size
        self.goal_tolerance = goal_tolerance
        self.max_iter = max_iter

        self.vertices = []  # 树的所有节点(配置)
        self.parents = {}   # 记录每个节点的父节点索引
        self.kd_tree = None # 用于快速最近邻搜索

    def is_collision_free(self, q):
        """检查单个配置点q是否无碰撞(点机器人)"""
        point = Point(q)
        for obs in self.obstacles:
            if point.within(obs):
                return False
        return True

    def steer(self, q_from, q_to):
        """从q_from向q_to方向移动步长距离,返回新点"""
        direction = np.array(q_to) - np.array(q_from)
        distance = np.linalg.norm(direction)
        if distance == 0:
            return q_from
        direction_unit = direction / distance
        step = min(self.step_size, distance)
        new_q = np.array(q_from) + direction_unit * step
        return tuple(new_q)

    def find_nearest(self, q):
        """在树中找到离q最近的节点"""
        if not self.vertices:
            return None
        # 使用KD树加速搜索(当节点很多时)
        if self.kd_tree is None:
            self.kd_tree = KDTree(self.vertices)
        dist, idx = self.kd_tree.query([q])
        return self.vertices[idx[0]], idx[0]

    def plan(self, q_start, q_goal):
        """执行RRT规划"""
        self.vertices = [q_start]
        self.parents = {0: -1}  # 起始节点的父节点索引为-1
        self.kd_tree = None

        for i in range(self.max_iter):
            # 1. 随机采样 (以一定概率直接采样目标点,加速收敛)
            if np.random.rand() < 0.1:
                q_rand = q_goal
            else:
                q_rand = tuple(np.random.rand(len(self.bounds)) * (np.array(self.bounds)[:,1] - np.array(self.bounds)[:,0]) + np.array(self.bounds)[:,0])

            # 2. 寻找最近邻
            q_near, near_idx = self.find_nearest(q_rand)

            # 3. 扩展新节点
            q_new = self.steer(q_near, q_rand)

            # 4. 碰撞检测(这里简化了,只检测终点。严谨做法应检测整条边)
            if self.is_collision_free(q_new):
                # 将新节点加入树
                new_idx = len(self.vertices)
                self.vertices.append(q_new)
                self.parents[new_idx] = near_idx
                # 更新KD树(简单重建,实际可增量更新)
                self.kd_tree = KDTree(self.vertices)

                # 5. 检查是否到达目标
                if np.linalg.norm(np.array(q_new) - np.array(q_goal)) < self.goal_tolerance:
                    print(f"在第 {i+1} 次迭代后找到路径!")
                    # 将目标点作为最终节点加入
                    goal_idx = len(self.vertices)
                    self.vertices.append(q_goal)
                    self.parents[goal_idx] = new_idx
                    return self.extract_path(goal_idx)

        print("达到最大迭代次数,未找到路径。")
        return None

    def extract_path(self, goal_idx):
        """从目标节点回溯至起点,提取路径"""
        path = []
        current = goal_idx
        while current != -1:
            path.append(self.vertices[current])
            current = self.parents[current]
        return path[::-1]  # 反转,使路径从起点到终点

# 使用示例
if __name__ == "__main__":
    # 定义环境
    bounds = [(0, 10), (0, 10)]
    obstacles = [
        Polygon([(2, 2), (6, 2), (6, 4), (2, 4)]),
        Polygon([(4, 6), (8, 6), (8, 9), (4, 9)])
    ]

    # 定义起点和终点
    start = (1.0, 1.0)
    goal = (9.0, 9.0)

    # 创建规划器并规划
    planner = SimpleRRT(bounds, obstacles, step_size=0.7, max_iter=3000)
    path = planner.plan(start, goal)

    # 可视化结果
    fig, ax = plt.subplots(figsize=(10, 10))
    # 绘制障碍物
    for obs in obstacles:
        x, y = obs.exterior.xy
        ax.fill(x, y, alpha=0.5, fc='red', ec='black')

    # 绘制RRT树
    vertices = np.array(planner.vertices)
    for idx, parent_idx in planner.parents.items():
        if parent_idx != -1:
            q_from = vertices[parent_idx]
            q_to = vertices[idx]
            ax.plot([q_from[0], q_to[0]], [q_from[1], q_to[1]], 'gray', lw=0.5, alpha=0.6)

    # 绘制起点、终点和最终路径
    ax.plot(start[0], start[1], 'go', markersize=12, label='起点', markeredgecolor='black')
    ax.plot(goal[0], goal[1], 'b*', markersize=18, label='终点', markeredgecolor='black')
    if path:
        path = np.array(path)
        ax.plot(path[:, 0], path[:, 1], 'y-', linewidth=3, label='规划路径', marker='o', markersize=4)

    ax.set_xlim(bounds[0])
    ax.set_ylim(bounds[1])
    ax.set_xlabel('X')
    ax.set_ylabel('Y')
    ax.set_title('RRT路径规划结果')
    ax.legend()
    ax.grid(True)
    ax.set_aspect('equal')
    plt.show()

运行这段代码,你将看到一棵灰色的树在红色障碍物之间生长,最终一条黄色的路径连接了起点和终点。这就是RRT算法在工作的直观体现。它可能不是最短路径,但通常能快速找到一条可行路径。

4. 超越基础:处理更复杂的机器人

我们的示例将机器人简化为一个点。但对于真正的“钢琴搬运”问题,我们需要处理有形状、可旋转的刚体。这意味着C空间变成了三维 (x, y, θ)。算法的核心逻辑不变,但需要升级几个关键部分:

4.1 刚体机器人的碰撞检测

现在,碰撞检测函数 is_collision_free(q) 接受的配置 q 是一个三元组 (x, y, θ)。我们需要:

  1. 根据 (x, y, θ) 计算机器人多边形在当前姿态下的顶点坐标(涉及旋转和平移变换)。
  2. 用这个变换后的多边形与所有障碍物进行相交测试。
from shapely.geometry import Polygon
from shapely.affinity import rotate, translate
import math

def is_collision_free_rigid(q, robot_shape, obstacles):
    """
    检查刚体机器人在配置q下是否无碰撞。
    :param q: 配置 (x, y, theta_in_radians)
    :param robot_shape: 机器人的基础形状(以原点为中心,未旋转)
    :param obstacles: 障碍物列表
    """
    x, y, theta = q
    # 1. 旋转机器人形状
    rotated_robot = rotate(robot_shape, theta, origin=(0, 0), use_radians=True)
    # 2. 平移机器人形状到(x, y)
    transformed_robot = translate(rotated_robot, xoff=x, yoff=y)

    # 3. 与所有障碍物进行碰撞检测
    for obs in obstacles:
        if transformed_robot.intersects(obs):
            return False
    return True

# 定义机器人的基础形状(一个长2宽1的矩形,中心在原点)
base_robot = Polygon([(-1, -0.5), (1, -0.5), (1, 0.5), (-1, 0.5)])

4.2 三维C空间中的距离度量与采样

在三维C空间 (x, y, θ) 中,我们需要定义两个配置之间的距离。xy 是欧氏距离,但 θ 是角度,具有周期性(0和2π是同一个点)。一个常见的做法是使用加权的欧氏距离:

distance(q1, q2) = sqrt( (x1-x2)^2 + (y1-y2)^2 + c * min( |θ1-θ2|, 2π - |θ1-θ2| )^2 )

其中 c 是一个权重系数,用于平衡位置和角度差异的重要性。采样时,我们需要在 [0, 2π] 范围内均匀采样角度。

4.3 可视化挑战与解决方案

三维路径的可视化比二维复杂。一种常见的方法是绘制机器人在路径关键帧上的姿态,或者将三维C空间路径投影到 (x, y) 平面上,并用颜色或箭头表示角度 θ

# 示例:可视化三维C空间中的路径(投影到XY平面,用箭头表示朝向)
def visualize_3d_path(path, robot_shape, obstacles):
    fig, ax = plt.subplots(figsize=(10, 10))
    # 绘制障碍物
    for obs in obstacles:
        x, y = obs.exterior.xy
        ax.fill(x, y, alpha=0.5, fc='lightcoral', ec='darkred')

    # 绘制路径
    path_arr = np.array(path)
    ax.plot(path_arr[:, 0], path_arr[:, 1], 'b-o', linewidth=2, markersize=4, label='路径中心轨迹')

    # 在路径上每隔几个点绘制机器人的姿态
    for i in range(0, len(path), max(1, len(path)//10)): # 采样显示
        q = path[i]
        x, y, theta = q
        # 计算机器人轮廓并绘制
        rotated_robot = rotate(robot_shape, theta, origin=(0, 0), use_radians=True)
        transformed_robot = translate(rotated_robot, xoff=x, yoff=y)
        x_r, y_r = transformed_robot.exterior.xy
        ax.plot(x_r, y_r, 'green', alpha=0.6, linewidth=1)
        # 绘制朝向箭头
        dx = 0.5 * math.cos(theta)
        dy = 0.5 * math.sin(theta)
        ax.arrow(x, y, dx, dy, head_width=0.2, head_length=0.3, fc='darkblue', ec='darkblue')

    ax.set_xlabel('X')
    ax.set_ylabel('Y')
    ax.set_title('刚体机器人运动规划路径(带朝向)')
    ax.legend()
    ax.grid(True)
    ax.set_aspect('equal')
    plt.show()

将上述升级后的碰撞检测、距离度量和可视化模块整合到RRT框架中,你就能为一个真正的矩形“钢琴”规划路径了。算法会在三维空间中生长一棵树,寻找连接起点姿态和终点姿态的无碰撞路径。

4.4 进阶方向与优化

基础的RRT找到了路径,但往往不是最优的。工业级或研究级的运动规划会考虑更多:

算法变种 核心思想 优点 适用场景
RRT* 在RRT基础上,每次增加新节点后,尝试在附近重新选择父节点以优化路径成本,并重布线以优化整棵树。 渐进最优,最终能找到接近最短的路径。 对路径质量有要求的场景,如无人机、自动驾驶。
Informed RRT* 在找到初始路径后,将采样范围限制在一个椭圆形的“启发式”区域内,该区域包含所有可能比当前路径更优的路径点。 大幅提高收敛到最优解的速度。 已知起点和终点,需要快速优化路径的场景。
PRM 先在整个自由空间随机采样大量点,连接邻近的点形成路线图,然后在图上搜索路径。 适合多查询问题(同一张地图,不同起终点)。 静态环境下的重复规划任务。
FMT* 在采样的点集上构建树,通过“惰性”评估边来高效搜索。 理论性能有保障,实践中也很快。 高维复杂空间的单次查询。

此外,还有处理动力学约束、非完整约束(如汽车不能横向移动)、多机器人协同规划等更高级的课题。但无论如何,C空间的概念和基于采样的规划思想,都是这些高级算法的基石。

我在第一次用RRT为一个小车模型规划路径时,发现它经常在狭窄通道口“卡住”,因为随机采样很难恰好命中通道。后来我尝试了目标偏置采样(就像示例代码中那样,以一定概率直接采样目标点),并适当增加了步长,成功率才显著提升。另一个常见的坑是距离度量的选择,对于混合了平移和旋转的C空间,权重系数 c 需要仔细调整,否则树可能会过度探索角度维度而忽略位置,或者相反。这些调参过程没有银弹,需要根据具体机器人和环境的特点进行实验。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐