这个项目有两种路径来源:没有路网时,用点云建 Costmap,再跑全局 A*;有路网时,直接拿着路网边的中心线走。两者看起来都是“给一条折线”,但计算路径的位置完全不同。
Costmap:把点云变成可搜索的栅格 点到点导航的目标是地图坐标。要跑 A*,必须先把点云转换成 2D 占据栅格。navigate 在执行线程里按需调用建图逻辑。
关键不是简单地把点投影到网格,而是处理几个工程问题。
首先是密度阈值。RSAIRY 是稀疏固态雷达,单点噪声多。只有某个 cell 内的点数达到阈值,才认为它是障碍。否则一两个飞点就会把地图点得到处是墙。
其次是障碍膨胀。机器人不是质点,有外接半径。障碍 cell 会按机体半径向外膨胀,让路径保持安全距离。
然后是轨迹雕刻。狗走过的 trajectory 是真实可通行路径,即使它看起来离障碍很近,也应该被强制保留为可走。没有这一步,窄走廊会先被膨胀封死,狗明明走过的地方反而规划不出来了。
以前会对整张图建栅格。户外 1km 点云会生成八千多万个 cell,一次就把主机拖垮。现在会先根据“当前位置和目标点”开一个包围盒,再只解析这个范围内的点云分片。这样规划只需要一块局部区域:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 MAX_CELLS = 6_000_000 WINDOW_R = 120.0 if bounds is not None : xmin, ymin, xmax, ymax = (float (bounds[0 ]) - margin, float (bounds[1 ]) - margin, float (bounds[2 ]) + margin, float (bounds[3 ]) + margin) if len (pts): m = ((pts[:, 0 ] >= xmin) & (pts[:, 0 ] <= xmax) & (pts[:, 1 ] >= ymin) & (pts[:, 1 ] <= ymax)) pts = pts[m] while max_cells > 0 and ((xmax - xmin) / res + 1 ) * ((ymax - ymin) / res + 1 ) > max_cells: res *= 1.5
全局 A*:8 连通加软惩罚 A* 是纯 Python 和 NumPy 实现,不依赖 ROS。搜索使用 8 连通邻域:
1 2 3 _N8 = [(-1 , 0 , 1.0 ), (1 , 0 , 1.0 ), (0 , -1 , 1.0 ), (0 , 1 , 1.0 ), (-1 , -1 , 1.41421356 ), (-1 , 1 , 1.41421356 ), (1 , -1 , 1.41421356 ), (1 , 1 , 1.41421356 )]
对角线移动代价是 1.414,四方向移动代价是 1.0。这样可以避免路径出现“一步走两格”的奇怪斜切。
除了距离代价,代码还叠加了渐变代价惩罚。Costmap 除了二值占据,还会维护一个渐变 cost 场,表示离墙有多近。A* 计算 step 时:
1 2 3 step = c if cost is not None : step += COST_WEIGHT * (float (cost[nj, ni]) / 99.0 )
效果是优先走通道中间,而不是贴着墙。窄道仍然能过,但会被罚分。这个权重是调出来的:太大时狗为了躲墙绕远路,太小时又起不到走中间的效果。
对角线还有一个防角切保护。当对角方向的两个正交邻居都被占据时,不允许斜着挤过去,避免路径从墙角削过去。
视线串拉:让折线更接近人规划的路线 A* 输出的是一串相邻 cell,直接用来跟踪会很碎。项目做了一步“String pulling”,保留真正需要转弯的顶点。
简化从起点开始,尽量找到视线最远的后续点。视线判断用 Bresenham:线段经过 lethal cell,或经过高 cost 的贴墙格,就不可直连。
这里的高 cost 格也当成阻挡。原因是如果不检查高 cost,直线简化会把路径抄近道拉进贴墙区,抵消掉前面 A* 的“走中间”偏好。
简化后的路径是稀疏拐点,而不是密集 cell。控制层沿着这些拐点走,转弯次数明显减少。
路网模式:路径已经存在,不需要再规划 当上层已经按路网算好折线时,navigate 不会再去建 Costmap 和跑 A*。调用参数里直接给 path:
1 { "path" : [ [ 1.0 , 2.0 ] , [ 1.5 , 2.3 ] , [ 2.0 , 2.8 ] ] }
这个分支有几个原因。一是省掉读点云、建栅格和搜索的成本。二是路网本身就是路径,再去 A* 反而可能因为户外 costmap 为空,把狗从路上拉直线穿出去。
更重要的是,A* 模式依赖真实位姿。取景窗、栅格和全局路径都建立在“我知道自己在哪”之上。如果当前处于推算态,绝对定位不可用,那么算出来的全局路径本身就是错的。路网模式不建栅格,只需要知道狗在这条边的哪一段,所以在推算态下仍然能开工,只是速度和避障会更保守。
动态障碍:从局部重规划到可控停避障 早期方案是实时点云发现 novel 障碍后,把它标进 costmap,然后触发局部重规划。这听起来完整,但真实环境里容易把墙误判成动态障碍,越规划越乱。
后来 navigate 把避障简化成更可控的策略:优先消费 freespace 感知 skill 给出的前方障碍距离、负障和扇区距离。远则全速,近则减速,太近则停。
如果 freespace 契约暂时断开,本地会用 live 点云做一次走廊检测作为回落,但会降速。长时间断开就直接停下,因为回落计算更贵,而且看不到负障。
也就是说,路径规划负责“宏观怎么走”,实时避障负责“这一拍能不能继续走”。两者通过同一套折线衔接,A* 决定大方向,控制器和安全判据决定每一步的实际速度。
关键代码示例 Costmap 不是“把点投到格子”那么简单 先按地面高度取一个高度带,再用点数阈值过滤 RSAIRY 的稀疏噪声:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 FLOOR_PCTL = 2.0 floor = float (np.percentile(pts[:, 2 ], FLOOR_PCTL)) if len (pts) else 0.0 band = (pts[:, 2 ] > floor + band_lo) & (pts[:, 2 ] < floor + band_hi) p = pts[band] cnt = np.zeros((H, W), np.int32) if len (p): gx = ((p[:, 0 ] - xmin) / res).astype(np.int64) gy = ((p[:, 1 ] - ymin) / res).astype(np.int64) m = (gx >= 0 ) & (gx < W) & (gy >= 0 ) & (gy < H) np.add.at(cnt, (gy[m], gx[m]), 1 ) occ = (cnt >= min_pts).astype(bool ) occ = _remove_small_blobs(occ, MIN_BLOB_CELLS)
一两个飞点不应该把一个 cell 点成墙。墙在同一 XY 上会叠很多垂直点,稀疏噪声通常只有 1 个,所以 min_pts=3 是这套固态雷达最重要的鲁棒性旋钮。
建图时人、临时物会散在可走区里,不连大墙。去小连通域用 8 连通 union-find 抹掉这些小团,避免 A* 绕出一堆假弯;它只清理静态规划图,live 点云仍然兜底真障:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 def _remove_small_blobs (occ: np.ndarray, min_cells: int ) -> np.ndarray: """抹掉小的孤立占据连通域(< min_cells)。 建图时的动态点(人/临时物)散在可走区、不连大墙,A* 得绕它们=假弯。 墙是大连通域(远超阈值)不动;贴墙家具连着墙也不动。 安全:只清静态规划图,实时 live 感知照常兜底真障。""" if min_cells <= 1 : return occ H, W = occ.shape ys, xs = np.nonzero(occ) n = len (ys) if n == 0 : return occ idx = -np.ones((H, W), np.int64) idx[ys, xs] = np.arange(n) parent = np.arange(n) def find (a ): root = a while parent[root] != root: root = parent[root] while parent[a] != root: parent[a], a = root, parent[a] return root def union (a, b ): ra, rb = find(int (a)), find(int (b)) if ra != rb: parent[max (ra, rb)] = min (ra, rb) pairs = [] m = occ[:, :-1 ] & occ[:, 1 :] pairs.append((idx[:, :-1 ][m], idx[:, 1 :][m])) m = occ[:-1 , :] & occ[1 :, :] pairs.append((idx[:-1 , :][m], idx[1 :, :][m])) m = occ[:-1 , :-1 ] & occ[1 :, 1 :] pairs.append((idx[:-1 , :-1 ][m], idx[1 :, 1 :][m])) m = occ[:-1 , 1 :] & occ[1 :, :-1 ] pairs.append((idx[:-1 , 1 :][m], idx[1 :, :-1 ][m])) for aa, bb in pairs: for a, b in zip (aa.tolist(), bb.tolist()): union(a, b) roots = np.array([find(k) for k in range (n)]) sizes = np.bincount(roots) small = sizes[roots] < min_cells out = occ.copy() out[ys[small], xs[small]] = False return out
轨迹雕刻:狗走过的窄道必须重新变成可通行 否则膨胀 + 噪声会把真实走过的走廊封死:
1 2 3 4 5 6 7 8 9 10 if traj_xy is not None and len (traj_xy): t = np.asarray(traj_xy, dtype=np.float32).reshape(-1 , 2 ) tube = np.zeros((H, W), bool ) tgx = ((t[:, 0 ] - xmin) / res).astype(np.int64) tgy = ((t[:, 1 ] - ymin) / res).astype(np.int64) mm = (tgx >= 0 ) & (tgx < W) & (tgy >= 0 ) & (tgy < H) tube[tgy[mm], tgx[mm]] = True tube = _dilate(tube, int (round (carve_r / res))) grid[tube] = 0 cost[tube] = 0
A* 的 8 连通、软惩罚和防角切 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 COST_WEIGHT = 1.0 LOS_COST_MAX = 40 for di, dj, c in _N8: ni, nj = ci + di, cj + dj if ni < 0 or ni >= W or nj < 0 or nj >= H or grid[nj, ni]: continue if di and dj and (grid[cj, ni] or grid[nj, ci]): continue step = c if cost is not None : step += COST_WEIGHT * (float (cost[nj, ni]) / 99.0 ) ng = gc + step if ng < gsc.get((ni, nj), 1e18 ): gsc[(ni, nj)] = ng came[(ni, nj)] = cur heapq.heappush(openh, (ng + h((ni, nj)), ng, (ni, nj)))
grid[nj, ni] 是硬墙,cost[nj, ni] 是“离墙有多近”的渐变代价。软惩罚让路径优先走通道中间,但窄道仍然能过。
直线简化不能只看 lethal 视线判断会把高 cost 格也当阻挡:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 def _los (cm, a, b ) -> bool : """Bresenham: True if the segment stays clear (no lethal, no high-cost). 高代价(贴墙)格也当"挡"——否则简化会把路径抄近道拉进贴墙区。""" cost = getattr (cm, "cost" , None ) (x0, y0), (x1, y1) = a, b dx, dy = abs (x1 - x0), abs (y1 - y0) sx = 1 if x0 < x1 else -1 sy = 1 if y0 < y1 else -1 err = dx - dy x, y = x0, y0 while True : if cm.grid[y, x] or (cost is not None and cost[y, x] > LOS_COST_MAX): return False if x == x1 and y == y1: return True e2 = 2 * err if e2 > -dy: err -= dy x += sx if e2 < dx: err += dx y += sy
如果只挡 lethal,A* 好不容易选出的“走中间”路径,会在简化阶段被直线抄近道拉回贴墙区。
小结 路径规划在这个项目里不是单一算法,而是按场景分成两条路:
自由目标点:点云分片、Costmap、8 连通 A*、视线串拉。
路网导航:直接走已经规划好的边中心线,不做二次全局搜索。
动态环境:不依赖频繁全局重规划,而用感知契约做停和减速。
这套设计最值得借鉴的是“不要把规划做得过度聪明”。能沿路走的时候,就不要重新搜索;能停下来的时候,就不要勉强绕一个看不清的障碍。