PRM算法原理与Python实现:自动驾驶路径规划核心工具详解

发布时间:2026/7/29 13:43:04
PRM算法原理与Python实现:自动驾驶路径规划核心工具详解 1. 项目概述在自动驾驶的研发流程里路径规划是连接感知决策与车辆控制的桥梁它决定了车辆如何从A点安全、高效地驶向B点。面对复杂多变的城市道路、停车场等环境规划算法不仅要能“算得出来”更要“算得快”、“算得稳”。基于采样的路径规划算法特别是概率路图法因其在处理高维、复杂约束空间时的出色表现成为了这一领域不可或缺的核心工具。今天我们就来深入拆解PRM算法的原理、实现细节并附上可直接运行的Python代码让你不仅能理解其思想更能亲手搭建一个属于自己的路径规划模块。PRM的核心思想非常直观与其在连续的、可能无限的空间里苦苦搜索一条路径不如先用随机采样的方式在这个空间里撒下一把“路标点”然后用简单的局部规划器比如直线连接尝试连接这些点构建一张稀疏的“路网”。当需要查询从起点到终点的路径时我们只需要将起点和终点也临时加入到这张路网中然后在这张图上运行一次图搜索算法如Dijkstra或A*即可。这种方法将连续的路径规划问题巧妙地转化为了离散的图搜索问题极大地提高了计算效率尤其擅长解决含有大量障碍物的复杂环境路径规划。2. PRM算法核心原理与设计思路2.1 算法流程拆解PRM算法通常分为两个主要阶段学习阶段和查询阶段。这种“离线构建在线查询”的模式是其能够快速响应的关键。学习阶段是算法的预处理环节其目标是在配置空间C中构建一张概率路图G(V, E)。这里的配置空间C可以简单理解为车辆所有可能位姿位置和朝向的集合。对于二维平面移动的车辆C就是(x, y)坐标空间如果考虑朝向则是三维的(x, y, θ)空间。学习阶段包含以下核心步骤初始化创建一个空的顶点集合V和一个空的边集合E。随机采样在自由的配置空间C_free中即不被障碍物占据的空间随机生成一个配置q_rand。最近邻搜索在当前的顶点集V中找到距离q_rand最近的k个顶点通常使用欧氏距离。这里的k是一个重要参数称为“邻域大小”。局部连接尝试对于每一个找到的最近邻顶点q_near尝试用一条局部路径最简单就是直线连接q_rand和q_near。这条路径必须完全位于C_free中即不能与任何障碍物相交。添加顶点与边如果局部路径有效则将q_rand加入顶点集V并将连接q_rand和q_near的边加入边集E。循环重复步骤2-5直到采样了预定数量的节点N个或者路图达到了某种覆盖度要求。注意学习阶段是离线的可以花费较多时间构建一张覆盖良好的路图。一旦路图构建完成它可以被重复用于在同一张地图上进行多次起点-终点的路径查询。查询阶段是在线执行的当给定具体的起点q_start和终点q_goal时连接起终点尝试将q_start和q_goal分别连接到路图G上。方法同样是找到它们在G中的k个最近邻并尝试用局部路径连接。如果连接成功则将q_start和q_goal作为临时顶点加入图G’G的一个副本或扩展。图搜索在扩展后的图G’上运行最短路径搜索算法如Dijkstra算法寻找从q_start到q_goal的路径。路径返回如果搜索成功则返回一条由一系列顶点配置组成的路径否则报告规划失败。2.2 关键参数与设计抉择PRM的性能和效果深受几个关键参数的影响理解它们背后的权衡是灵活应用算法的前提。采样点数N这是最直观的参数。N越大路图对自由空间的覆盖越密集找到路径的概率越高但构建路图的时间也越长图也会更复杂可能影响后续搜索速度。通常需要根据环境复杂度和性能要求进行折中。对于简单空旷的环境几百个点可能就够了对于复杂的迷宫式环境可能需要数千甚至上万个点。邻域大小k它决定了每个新采样点尝试连接多少个已有节点。k值太小可能导致图连通性差形成多个孤立的“岛屿”即使空间本身是连通的也可能因为缺少“桥梁”而无法找到路径。k值太大则会大幅增加局部碰撞检测的次数这是PRM中最耗时的操作降低构建效率。一个经验法则是k值通常设置在10到20之间并可以随着节点数增加而动态调整例如k min(20, log2(|V|))。采样策略纯粹的均匀随机采样虽然简单但在狭窄通道区域采样点落入其中的概率很低容易导致这些关键区域的路图覆盖不足形成“瓶颈”。因此衍生出了许多改进策略如高斯采样在障碍物边界附近进行更高密度的采样以提高在狭窄通道处采样的概率。桥测试采样专门针对狭窄通道设计通过检测采样点对是否分别位于障碍物两侧且其中点位于自由空间来主动在通道内生成节点。启发式采样在学习过程中根据当前图的连通分量情况有偏向地在未连通区域增加采样。局部规划器最常用的是直线连接器因为它简单快速。但对于某些机器人如非完整约束的车辆模型直线路径可能不可行。此时需要更复杂的局部规划器如Reeds-Shepp曲线适用于可前进/后退的车辆或Dubins曲线适用于仅能前进的车辆。在我们的Python实现中为简化起见默认使用直线连接。3. Python实现详解与核心代码解析下面我们将一步步实现一个基础的PRM规划器并可视化其过程。我们将使用numpy进行数学运算matplotlib进行可视化并利用scipy的KDTree来加速最近邻搜索。3.1 环境与依赖设置首先确保你的Python环境已安装必要的库。可以通过pip安装pip install numpy matplotlib scipy我们的规划场景设定在一个二维平面内障碍物用多边形这里是矩形表示。车辆被简化为一个点即点机器人模型这是路径规划中最基础的假设。在实际自动驾驶中需要将车辆轮廓膨胀到障碍物中将车辆模型收缩为一个点这就是所谓的“配置空间障碍物膨胀”。3.2 核心类与数据结构设计我们创建一个名为PRM的类来封装整个算法。import numpy as np import matplotlib.pyplot as plt from scipy.spatial import KDTree from matplotlib.patches import Rectangle import heapq import math class PRM: def __init__(self, num_samples500, k_neighbors15, map_size(100, 100)): 初始化PRM规划器。 :param num_samples: 学习阶段采样点数 :param k_neighbors: 最近邻连接数 :param map_size: 地图尺寸 (width, height) self.num_samples num_samples self.k k_neighbors self.map_width, self.map_height map_size self.vertices [] # 路图顶点列表每个元素是np.array([x, y]) self.edges [] # 路图边列表每个元素是(v_i_index, v_j_index) self.obstacles [] # 障碍物列表每个障碍物是((x, y), width, height) self.kd_tree None # 用于最近邻搜索的KDTree3.3 障碍物与碰撞检测实现碰撞检测是路径规划中最核心、最频繁调用的函数其效率直接影响整体性能。我们采用轴对齐包围盒AABB检测和线段-矩形相交检测。def add_rect_obstacle(self, bottom_left, width, height): 添加一个矩形障碍物。 self.obstacles.append((bottom_left, width, height)) def is_collision_free(self, point): 检查一个点是否在自由空间不与任何障碍物相交。 x, y point for (ox, oy), w, h in self.obstacles: if ox x ox w and oy y oy h: return False return True def is_path_collision_free(self, p1, p2, num_checks20): 检查连接p1和p2的线段是否与障碍物相交。 采用离散点采样进行近似检测对于简单场景足够精确。 :param num_checks: 在线段上采样的点数 for i in range(num_checks 1): t i / num_checks # 线性插值得到线段上的点 check_point p1 t * (p2 - p1) if not self.is_collision_free(check_point): return False return True实操心得is_path_collision_free函数中的num_checks参数是一个精度与效率的权衡。num_checks越大检测越精确但计算量也越大。对于直线和矩形障碍物理论上可以通过解析几何精确计算线段与矩形的相交性但实现稍复杂。离散采样法实现简单且通过适当增加num_checks如50在大多数情况下足以满足精度要求是快速原型开发的实用选择。在生产环境中则会使用更高效的几何库如Shapely或物理引擎进行精确碰撞检测。3.4 学习阶段构建概率路图这是PRM的离线构建阶段我们按照之前描述的流程实现。def build_roadmap(self): 学习阶段构建概率路图。 print(f“开始构建路图计划采样{self.num_samples}个点...”) self.vertices [] self.edges [] # 步骤1 2: 随机采样并筛选自由点 samples_collected 0 while samples_collected self.num_samples: q_rand np.random.rand(2) * [self.map_width, self.map_height] if self.is_collision_free(q_rand): self.vertices.append(q_rand) samples_collected 1 # 构建KDTree用于快速最近邻搜索 self.kd_tree KDTree(self.vertices) # 步骤3 4 5: 为每个顶点尝试连接其k个最近邻 for i, v in enumerate(self.vertices): # 查询最近邻注意要排除自己距离为0的那个 distances, indices self.kd_tree.query(v, kself.k1) # 多查一个包含自己 # indices[0]是自己所以从1开始 for idx in indices[1:]: if idx i: # 避免重复添加无向边例如连接(i,j)和(j,i) q_near self.vertices[idx] if self.is_path_collision_free(v, q_near): self.edges.append((i, idx)) print(f“路图构建完成。顶点数{len(self.vertices)} 边数{len(self.edges)}”)注意事项在连接边时我们通过判断idx i来确保同一条无向边只被添加一次。这虽然是一个小细节但能避免图搜索时产生重复的邻接关系也使得可视化更清晰。另外KDTree.query返回的indices顺序是按距离从近到远排列的这符合我们的需求。3.5 查询阶段路径搜索查询阶段需要将起点和终点连接到路图上并进行图搜索。def find_path(self, start, goal): 查询阶段给定起点和终点寻找路径。 if not (self.is_collision_free(start) and self.is_collision_free(goal)): print(“错误起点或终点位于障碍物内”) return None # 临时扩展图 temp_vertices self.vertices.copy() temp_edges self.edges.copy() start_idx len(temp_vertices) goal_idx start_idx 1 temp_vertices.append(start) temp_vertices.append(goal) # 尝试将起点和终点连接到原路图 temp_kd_tree KDTree(self.vertices) # 注意这里用的是原路图的顶点树 for temp_v, temp_idx in [(start, start_idx), (goal, goal_idx)]: distances, indices temp_kd_tree.query(temp_v, kself.k) connected False for idx in indices: if self.is_path_collision_free(temp_v, self.vertices[idx]): temp_edges.append((temp_idx, idx)) connected True if not connected: print(f“警告无法将{‘起点’ if temp_idx start_idx else ‘终点’}连接到路图。”) # 即使连接不上也继续尝试搜索但可能失败 # 使用Dijkstra算法在图由temp_edges定义上搜索最短路径 path_indices self._dijkstra(temp_vertices, temp_edges, start_idx, goal_idx) if path_indices: path [temp_vertices[i] for i in path_indices] return np.array(path) else: print(“路径查找失败。”) return None def _dijkstra(self, vertices, edges, start_idx, goal_idx): Dijkstra最短路径算法实现。 # 构建邻接表 adj_list {i: [] for i in range(len(vertices))} for i, j in edges: dist np.linalg.norm(vertices[i] - vertices[j]) adj_list[i].append((j, dist)) adj_list[j].append((i, dist)) # 无向图 # 初始化距离和前驱节点 dist {i: float(‘inf’) for i in range(len(vertices))} prev {i: None for i in range(len(vertices))} dist[start_idx] 0 # 优先队列 (距离, 顶点索引) pq [(0, start_idx)] while pq: current_dist, u heapq.heappop(pq) if current_dist dist[u]: continue if u goal_idx: break for v, weight in adj_list[u]: alt current_dist weight if alt dist[v]: dist[v] alt prev[v] u heapq.heappush(pq, (alt, v)) # 回溯路径 if dist[goal_idx] float(‘inf’): return None path [] u goal_idx while u is not None: path.append(u) u prev[u] return path[::-1] # 反转得到从起点到终点的路径3.6 可视化与主程序示例最后我们编写一个主函数来演示整个流程并可视化结果。def main(): # 1. 创建PRM规划器实例 planner PRM(num_samples300, k_neighbors10, map_size(100, 100)) # 2. 添加障碍物矩形 planner.add_rect_obstacle((20, 20), 20, 60) # 左侧长条形障碍物 planner.add_rect_obstacle((60, 20), 20, 60) # 右侧长条形障碍物 # 中间留出一个狭窄通道 # 3. 构建路图 planner.build_roadmap() # 4. 定义起点和终点 start np.array([10.0, 50.0]) goal np.array([90.0, 50.0]) # 5. 查询路径 path planner.find_path(start, goal) # 6. 可视化 plt.figure(figsize(10, 10)) # 绘制障碍物 for (ox, oy), w, h in planner.obstacles: plt.gca().add_patch(Rectangle((ox, oy), w, h, color‘gray’, alpha0.5)) # 绘制路图顶点绿色点 if planner.vertices: vertices_np np.array(planner.vertices) plt.scatter(vertices_np[:, 0], vertices_np[:, 1], s10, c‘green’, alpha0.6, label‘PRM Nodes’) # 绘制路图边灰色细线 for i, j in planner.edges: plt.plot([planner.vertices[i][0], planner.vertices[j][0]], [planner.vertices[i][1], planner.vertices[j][1]], ‘gray’, linewidth0.5, alpha0.5) # 绘制起点和终点 plt.scatter(start[0], start[1], s100, c‘red’, marker‘s’, label‘Start’) plt.scatter(goal[0], goal[1], s100, c‘blue’, marker‘s’, label‘Goal’) # 绘制最终路径红色粗线 if path is not None: path_np np.array(path) plt.plot(path_np[:, 0], path_np[:, 1], ‘r-’, linewidth3, label‘Found Path’) plt.xlim(0, planner.map_width) plt.ylim(0, planner.map_height) plt.xlabel(‘X’) plt.ylabel(‘Y’) plt.title(‘Probabilistic Roadmap (PRM) Path Planning’) plt.legend() plt.grid(True, alpha0.3) plt.gca().set_aspect(‘equal’, adjustable‘box’) plt.show() if __name__ “__main__”: main()运行这段代码你将看到一张可视化图灰色矩形是障碍物绿色散点是PRM随机采样的节点灰色细线是构建的路图连接红色方块是起点蓝色方块是终点最终寻找到的路径会用一条粗红线标出。在中间有狭窄通道的环境中PRM能够成功采样到通道内的点并构建连接从而找到穿越通道的路径。4. 性能调优、常见问题与进阶思考4.1 算法性能瓶颈分析在实际应用中PRM的性能瓶颈主要来自两方面碰撞检测is_path_collision_free函数被调用次数极多大约O(N*k)次。在复杂环境或高维空间这是最主要的计算开销。最近邻搜索虽然KDTree将最近邻搜索的复杂度从O(N)降到了O(log N)但在节点数N极大时10万构建和查询KDTree本身也有开销。优化策略空间划分与粗略检测在精确碰撞检测前先进行粗略的空间划分检测。例如将地图划分为网格只有线段穿过的网格内有障碍物时才进行精确检测。并行计算采样和碰撞检测是高度并行的任务可以利用多核CPU或GPU进行加速。增量式构建对于动态变化不大的环境可以采用增量式PRM只在新区域或障碍物变化区域进行重新采样和连接避免全局重建。4.2 常见问题与排查技巧路径查找失败即使空间明显连通原因采样点不足或邻域k值太小导致路图本身不连通形成多个连通分量。起点和终点可能位于不同的连通分量内。排查可视化路图观察节点和边的分布。检查起点/终点是否成功连接到路图代码中已打印警告。可以尝试增加num_samples或k_neighbors。技巧在构建路图后可以运行一次连通分量分析如深度优先搜索统计有多少个连通分量。如果多于一个说明采样策略需要改进。算法在狭窄通道处失效原因均匀随机采样在狭窄通道处命中概率极低导致通道内没有节点路图在此断开。解决方案采用桥测试采样或高斯采样等非均匀采样策略。桥测试的基本思想是随机采样一对点如果它们分别位于障碍物两侧且它们连线的中点位于自由空间那么这个中点很可能就在狭窄通道内将其加入路图。找到的路径“绕远”或不平滑原因PRM找到的是图上的最短路径按欧氏距离但由于采样点的随机性这条路径可能由许多折线段组成看起来不自然且可能不是全局最优。后处理路径规划完成后通常需要进行路径后处理。最常用的方法是路径缩短和平滑。缩短遍历路径上的节点尝试直接连接不相邻的节点如第i个和第i2个如果无碰撞则删除中间节点。平滑使用曲线拟合如B样条、贝塞尔曲线或优化方法如梯度下降对路径进行平滑使其更符合车辆的运动学约束。4.3 PRM在自动驾驶中的定位与进阶在真实的自动驾驶系统中单纯的PRM可能不会直接作为最终的规划模块但它扮演着至关重要的角色全局路径规划PRM非常适合在大型、先验的静态地图如城市路网、停车场地图上进行全局路径规划。它可以快速找到一条连接起点和停车点的、无碰撞的粗略路径。作为复杂规划器的组件PRM常与其他算法结合。例如在Lattice Planner状态格子规划器中PRM可以用来生成一条粗略的“参考线”或“通道”Lattice再在这个通道内进行精细的、考虑动力学约束的轨迹采样和搜索。处理复杂约束基础的PRM只考虑了几何碰撞。在实际中需要扩展为考虑车辆运动学非完整约束、动力学速度、加速度限制、交通规则车道线、交通灯的规划。这通常通过定义更复杂的“成本函数”和“有效性检查”来实现而PRM的图搜索框架如将Dijkstra改为A*可以很好地融入这些成本。一个简单的路径平滑后处理示例迭代缩短def smooth_path(path, planner, max_iter100): 对路径进行迭代缩短后处理。 if path is None or len(path) 3: return path smoothed_path path.copy().tolist() changed True iter_count 0 while changed and iter_count max_iter: changed False i 0 while i len(smoothed_path) - 2: if planner.is_path_collision_free(smoothed_path[i], smoothed_path[i2]): # 如果第i个点和第i2个点可以直接连接则删除第i1个点 del smoothed_path[i1] changed True else: i 1 iter_count 1 return np.array(smoothed_path)在主函数中找到路径后调用path smooth_path(path, planner)可以看到最终的路径节点数减少路径更加直接。通过以上近万字的拆解我们从PRM算法的核心思想出发深入到了每一个参数的意义、每一行代码的实现细节并探讨了其性能瓶颈、常见问题及在自动驾驶领域的实际应用与扩展方向。这份代码提供了一个坚实、可运行的起点你可以通过调整参数、修改采样策略、集成更高效的碰撞检测库、添加路径后处理模块来让它更加强大逐步贴近工程实践的需求。路径规划的世界远不止PRM但理解它无疑是打开这扇大门的一把关键钥匙。

相关新闻