from __future__ import annotations import heapq import math from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Optional, Tuple import numpy as np def _safe_float(v: Any, default: float) -> float: try: return float(v) except Exception: return float(default) def _safe_int(v: Any, default: int) -> int: try: return int(v) except Exception: return int(default) def _as_bool(v: Any, default: bool) -> bool: if v is None: return default if isinstance(v, bool): return v if isinstance(v, (int, float)): return bool(v) return str(v).strip().lower() in ("1", "true", "yes", "on") def _inflate_binary(mask: np.ndarray, radius_cells: int) -> np.ndarray: if radius_cells <= 0: return mask.copy() h, w = mask.shape out = mask.copy() rr = int(radius_cells) * int(radius_cells) offsets: List[Tuple[int, int]] = [] for dy in range(-radius_cells, radius_cells + 1): for dx in range(-radius_cells, radius_cells + 1): if (dy * dy + dx * dx) <= rr: offsets.append((dy, dx)) for dy, dx in offsets: ys = max(0, -dy) ye = min(h, h - dy) xs = max(0, -dx) xe = min(w, w - dx) yd = max(0, dy) xd = max(0, dx) out[yd : yd + (ye - ys), xd : xd + (xe - xs)] |= mask[ys:ye, xs:xe] return out @dataclass class LiveCostmapConfig: enabled: bool = True resolution_m: float = 0.10 z_min_m: float = -0.40 z_max_m: float = 1.20 padding_m: float = 0.80 inflation_radius_m: float = 0.25 dynamic_decay_sec: float = 1.5 dynamic_min_hits: int = 2 blocked_cost: int = 220 max_width_cells: int = 900 max_height_cells: int = 900 @staticmethod def from_dict(d: Dict[str, Any] | None) -> "LiveCostmapConfig": src = d or {} return LiveCostmapConfig( enabled=_as_bool(src.get("enabled", True), True), resolution_m=max(0.02, _safe_float(src.get("resolution_m", 0.10), 0.10)), z_min_m=_safe_float(src.get("z_min_m", -0.40), -0.40), z_max_m=_safe_float(src.get("z_max_m", 1.20), 1.20), padding_m=max(0.0, _safe_float(src.get("padding_m", 0.80), 0.80)), inflation_radius_m=max(0.0, _safe_float(src.get("inflation_radius_m", 0.25), 0.25)), dynamic_decay_sec=max(0.2, _safe_float(src.get("dynamic_decay_sec", 1.5), 1.5)), dynamic_min_hits=max(1, _safe_int(src.get("dynamic_min_hits", 2), 2)), blocked_cost=int(np.clip(_safe_int(src.get("blocked_cost", 220), 220), 100, 255)), max_width_cells=max(100, _safe_int(src.get("max_width_cells", 900), 900)), max_height_cells=max(100, _safe_int(src.get("max_height_cells", 900), 900)), ) def patched(self, patch: Dict[str, Any] | None) -> "LiveCostmapConfig": src = patch or {} return LiveCostmapConfig.from_dict( { "enabled": src.get("enabled", self.enabled), "resolution_m": src.get("resolution_m", self.resolution_m), "z_min_m": src.get("z_min_m", self.z_min_m), "z_max_m": src.get("z_max_m", self.z_max_m), "padding_m": src.get("padding_m", self.padding_m), "inflation_radius_m": src.get("inflation_radius_m", self.inflation_radius_m), "dynamic_decay_sec": src.get("dynamic_decay_sec", self.dynamic_decay_sec), "dynamic_min_hits": src.get("dynamic_min_hits", self.dynamic_min_hits), "blocked_cost": src.get("blocked_cost", self.blocked_cost), "max_width_cells": src.get("max_width_cells", self.max_width_cells), "max_height_cells": src.get("max_height_cells", self.max_height_cells), } ) class LiveCostmapRuntime: """ Maintains real-time static + dynamic + inflated obstacle layers. Static layer comes from SLAM stable points; dynamic layer comes from live points. """ def __init__(self, cfg: LiveCostmapConfig): self.cfg = cfg self._dynamic_cells: Dict[Tuple[int, int], Tuple[int, float]] = {} self._grid: Optional[Dict[str, Any]] = None def reset(self) -> None: self._dynamic_cells.clear() self._grid = None def _clip_nav_band(self, points: Optional[np.ndarray]) -> np.ndarray: if points is None: return np.zeros((0, 3), dtype=np.float32) pts = np.asarray(points, dtype=np.float32) if pts.ndim != 2 or pts.shape[1] < 3 or len(pts) == 0: return np.zeros((0, 3), dtype=np.float32) zmin = float(self.cfg.z_min_m) zmax = float(self.cfg.z_max_m) m = (pts[:, 2] >= zmin) & (pts[:, 2] <= zmax) return pts[m, :3] @staticmethod def _grid_indices(xy: np.ndarray, min_xy: np.ndarray, res: float, w: int, h: int) -> Tuple[np.ndarray, np.ndarray]: gx = np.floor((xy[:, 0] - min_xy[0]) / res).astype(np.int32) gy = np.floor((xy[:, 1] - min_xy[1]) / res).astype(np.int32) gx = np.clip(gx, 0, w - 1) gy = np.clip(gy, 0, h - 1) return gx, gy def world_to_grid(self, x: float, y: float) -> Optional[Tuple[int, int]]: if self._grid is None: return None origin = np.asarray(self._grid["origin_xy"], dtype=np.float64) res = float(self._grid["resolution_m"]) shape = tuple(self._grid["shape_hw"]) h, w = int(shape[0]), int(shape[1]) gx = int(math.floor((float(x) - origin[0]) / res)) gy = int(math.floor((float(y) - origin[1]) / res)) if gx < 0 or gy < 0 or gx >= w or gy >= h: return None return gx, gy def grid_to_world(self, gx: int, gy: int) -> Optional[Tuple[float, float]]: if self._grid is None: return None origin = np.asarray(self._grid["origin_xy"], dtype=np.float64) res = float(self._grid["resolution_m"]) x = origin[0] + (float(gx) + 0.5) * res y = origin[1] + (float(gy) + 0.5) * res return float(x), float(y) def cost_at_world(self, x: float, y: float) -> int: if self._grid is None: return 0 idx = self.world_to_grid(x, y) if idx is None: return 255 gx, gy = idx cost = np.asarray(self._grid["costmap"], dtype=np.uint8) return int(cost[gy, gx]) def occupied_near(self, x: float, y: float, radius_m: float, cost_thresh: Optional[int] = None) -> bool: if self._grid is None: return False thresh = int(self.cfg.blocked_cost if cost_thresh is None else cost_thresh) idx = self.world_to_grid(x, y) if idx is None: return True gx, gy = idx cost = np.asarray(self._grid["costmap"], dtype=np.uint8) h, w = cost.shape rc = int(math.ceil(max(0.0, float(radius_m)) / float(self._grid["resolution_m"]))) ys = max(0, gy - rc) ye = min(h, gy + rc + 1) xs = max(0, gx - rc) xe = min(w, gx + rc + 1) return bool(np.any(cost[ys:ye, xs:xe] >= thresh)) def _compute_bounds(self, static_pts: np.ndarray, live_pts: np.ndarray) -> Optional[Tuple[np.ndarray, np.ndarray]]: has_static = len(static_pts) > 0 has_live = len(live_pts) > 0 if not has_static and not has_live: return None if has_static and has_live: all_xy = np.vstack((static_pts[:, :2], live_pts[:, :2])) elif has_static: all_xy = static_pts[:, :2] else: all_xy = live_pts[:, :2] pad = float(self.cfg.padding_m) mn = all_xy.min(axis=0) - pad mx = all_xy.max(axis=0) + pad return mn.astype(np.float64), mx.astype(np.float64) def _clamp_grid_size(self, mn: np.ndarray, mx: np.ndarray, res: float) -> Tuple[np.ndarray, np.ndarray, float, int, int]: span = np.maximum(mx - mn, res) w = int(np.ceil(span[0] / res)) + 1 h = int(np.ceil(span[1] / res)) + 1 res_out = float(res) if w > int(self.cfg.max_width_cells) or h > int(self.cfg.max_height_cells): sx = float(w) / float(self.cfg.max_width_cells) sy = float(h) / float(self.cfg.max_height_cells) scale = max(sx, sy) res_out = res_out * scale span = np.maximum(mx - mn, res_out) w = int(np.ceil(span[0] / res_out)) + 1 h = int(np.ceil(span[1] / res_out)) + 1 return mn, mx, float(res_out), int(w), int(h) def update( self, stable_points_world: Optional[np.ndarray], live_points_world: Optional[np.ndarray], now: float, ) -> Optional[Dict[str, Any]]: if not self.cfg.enabled: return None static_pts = self._clip_nav_band(stable_points_world) live_pts = self._clip_nav_band(live_points_world) bounds = self._compute_bounds(static_pts, live_pts) if bounds is None: # decay dynamic memory even when there is no input self._dynamic_cells = { k: v for k, v in self._dynamic_cells.items() if (float(now) - float(v[1])) <= float(self.cfg.dynamic_decay_sec) } return None mn, mx = bounds mn, mx, res, w, h = self._clamp_grid_size(mn, mx, float(self.cfg.resolution_m)) static_occ = np.zeros((h, w), dtype=bool) if len(static_pts) > 0: gx, gy = self._grid_indices(static_pts[:, :2], mn, res, w, h) static_occ[gy, gx] = True # age out dynamic memory first max_age = float(self.cfg.dynamic_decay_sec) new_dyn: Dict[Tuple[int, int], Tuple[int, float]] = {} for (cx, cy), (hits, ts) in self._dynamic_cells.items(): if (float(now) - float(ts)) <= max_age and 0 <= cx < w and 0 <= cy < h: new_dyn[(cx, cy)] = (int(hits), float(ts)) self._dynamic_cells = new_dyn if len(live_pts) > 0: gx_l, gy_l = self._grid_indices(live_pts[:, :2], mn, res, w, h) for gx, gy in zip(gx_l.tolist(), gy_l.tolist()): if static_occ[gy, gx]: continue k = (int(gx), int(gy)) old_hits, _ = self._dynamic_cells.get(k, (0, float(now))) self._dynamic_cells[k] = (int(old_hits) + 1, float(now)) dynamic_occ = np.zeros((h, w), dtype=bool) min_hits = int(self.cfg.dynamic_min_hits) for (gx, gy), (hits, ts) in self._dynamic_cells.items(): if hits >= min_hits and (float(now) - float(ts)) <= max_age: if 0 <= gx < w and 0 <= gy < h: dynamic_occ[gy, gx] = True occ = static_occ | dynamic_occ inf_cells = int(np.ceil(float(self.cfg.inflation_radius_m) / res)) inflated = _inflate_binary(occ, inf_cells) cost = np.zeros((h, w), dtype=np.uint8) cost[inflated] = 180 cost[dynamic_occ] = 220 cost[static_occ] = 255 out = { "costmap": cost, "static_mask": static_occ, "dynamic_mask": dynamic_occ, "inflated_mask": inflated, "origin_xy": mn.astype(np.float32), "resolution_m": float(res), "shape_hw": [int(h), int(w)], "static_cells": int(np.count_nonzero(static_occ)), "dynamic_cells": int(np.count_nonzero(dynamic_occ)), "inflated_cells": int(np.count_nonzero(inflated)), } self._grid = out return { "shape": [int(h), int(w)], "resolution_m": float(res), "origin_xy": [float(mn[0]), float(mn[1])], "static_cells": int(out["static_cells"]), "dynamic_cells": int(out["dynamic_cells"]), "inflated_cells": int(out["inflated_cells"]), } @property def grid(self) -> Optional[Dict[str, Any]]: return self._grid class GlobalAStarPlanner: def __init__(self, blocked_cost: int = 220): self.blocked_cost = int(np.clip(int(blocked_cost), 100, 255)) @staticmethod def _heur(a: Tuple[int, int], b: Tuple[int, int]) -> float: return float(math.hypot(float(a[0] - b[0]), float(a[1] - b[1]))) @staticmethod def _neighbors(x: int, y: int, w: int, h: int) -> Iterable[Tuple[int, int, float]]: for dy in (-1, 0, 1): for dx in (-1, 0, 1): if dx == 0 and dy == 0: continue nx = x + dx ny = y + dy if 0 <= nx < w and 0 <= ny < h: step = 1.41421356 if (dx != 0 and dy != 0) else 1.0 yield nx, ny, step def plan( self, costmap: np.ndarray, origin_xy: np.ndarray, resolution_m: float, start_xy: Tuple[float, float], goal_xy: Tuple[float, float], max_expansions: int = 120000, ) -> List[Tuple[float, float]]: cost = np.asarray(costmap, dtype=np.uint8) if cost.ndim != 2: return [] h, w = cost.shape origin = np.asarray(origin_xy, dtype=np.float64) res = float(resolution_m) def to_grid(x: float, y: float) -> Optional[Tuple[int, int]]: gx = int(math.floor((float(x) - origin[0]) / res)) gy = int(math.floor((float(y) - origin[1]) / res)) if gx < 0 or gy < 0 or gx >= w or gy >= h: return None return gx, gy def to_world(gx: int, gy: int) -> Tuple[float, float]: return ( float(origin[0] + (float(gx) + 0.5) * res), float(origin[1] + (float(gy) + 0.5) * res), ) s = to_grid(float(start_xy[0]), float(start_xy[1])) g = to_grid(float(goal_xy[0]), float(goal_xy[1])) if s is None or g is None: return [] if int(cost[s[1], s[0]]) >= self.blocked_cost or int(cost[g[1], g[0]]) >= self.blocked_cost: return [] open_heap: List[Tuple[float, float, Tuple[int, int]]] = [] heapq.heappush(open_heap, (self._heur(s, g), 0.0, s)) came_from: Dict[Tuple[int, int], Tuple[int, int]] = {} g_score: Dict[Tuple[int, int], float] = {s: 0.0} closed: Dict[Tuple[int, int], bool] = {} expansions = 0 while open_heap: _, cur_g, cur = heapq.heappop(open_heap) if closed.get(cur, False): continue closed[cur] = True if cur == g: break expansions += 1 if expansions >= int(max_expansions): import sys print( f"[A*] expansion limit {max_expansions} reached; no path found " f"from {start_xy} to {goal_xy}", file=sys.stderr, ) return [] cx, cy = cur for nx, ny, step in self._neighbors(cx, cy, w, h): if int(cost[ny, nx]) >= self.blocked_cost: continue ng = float(cur_g + step + (float(cost[ny, nx]) / 255.0) * 2.0) node = (nx, ny) if ng < float(g_score.get(node, 1e18)): g_score[node] = ng came_from[node] = cur f = ng + self._heur(node, g) heapq.heappush(open_heap, (f, ng, node)) if g not in came_from and g != s: return [] path_cells: List[Tuple[int, int]] = [g] cur = g visited_back: set = {g} while cur != s: nxt = came_from.get(cur) if nxt is None or nxt in visited_back: return [] visited_back.add(nxt) cur = nxt path_cells.append(cur) path_cells.reverse() return [to_world(px, py) for (px, py) in path_cells] @dataclass class LocalPlannerConfig: lookahead_m: float = 0.8 max_linear_mps: float = 0.6 max_angular_rps: float = 1.3 goal_tolerance_m: float = 0.30 collision_probe_m: float = 0.6 @staticmethod def from_dict(d: Dict[str, Any] | None) -> "LocalPlannerConfig": src = d or {} return LocalPlannerConfig( lookahead_m=max(0.1, _safe_float(src.get("lookahead_m", 0.8), 0.8)), max_linear_mps=max(0.05, _safe_float(src.get("max_linear_mps", 0.6), 0.6)), max_angular_rps=max(0.1, _safe_float(src.get("max_angular_rps", 1.3), 1.3)), goal_tolerance_m=max(0.05, _safe_float(src.get("goal_tolerance_m", 0.30), 0.30)), collision_probe_m=max(0.1, _safe_float(src.get("collision_probe_m", 0.6), 0.6)), ) class LocalReactivePlanner: def __init__(self, cfg: LocalPlannerConfig): self.cfg = cfg @staticmethod def _yaw_from_pose(pose: np.ndarray) -> float: # ZYX yaw from rotation matrix return float(math.atan2(float(pose[1, 0]), float(pose[0, 0]))) @staticmethod def _wrap_pi(a: float) -> float: while a > math.pi: a -= 2.0 * math.pi while a < -math.pi: a += 2.0 * math.pi return a def compute_command( self, pose_world: np.ndarray, path_world: List[Tuple[float, float]], runtime: LiveCostmapRuntime, ) -> Dict[str, Any]: cmd = { "linear_mps": 0.0, "angular_rps": 0.0, "goal_reached": False, "blocked": False, } if pose_world is None or np.asarray(pose_world).shape != (4, 4): return cmd if not path_world: return cmd p = np.asarray(pose_world, dtype=np.float64) x = float(p[0, 3]) y = float(p[1, 3]) yaw = self._yaw_from_pose(p) goal_x, goal_y = float(path_world[-1][0]), float(path_world[-1][1]) dist_goal = float(math.hypot(goal_x - x, goal_y - y)) if dist_goal <= float(self.cfg.goal_tolerance_m): cmd["goal_reached"] = True return cmd target = path_world[-1] for wx, wy in path_world: d = float(math.hypot(float(wx) - x, float(wy) - y)) if d >= float(self.cfg.lookahead_m): target = (float(wx), float(wy)) break dx = float(target[0] - x) dy = float(target[1] - y) target_yaw = float(math.atan2(dy, dx)) yaw_err = self._wrap_pi(target_yaw - yaw) ang = float(np.clip(1.5 * yaw_err, -float(self.cfg.max_angular_rps), float(self.cfg.max_angular_rps))) lin_scale = max(0.0, 1.0 - min(1.0, abs(yaw_err) / math.pi)) lin = float(self.cfg.max_linear_mps) * lin_scale # Probe short horizon for immediate collision. n_probe = 6 step = float(self.cfg.collision_probe_m) / float(n_probe) for i in range(1, n_probe + 1): px = x + float(i) * step * math.cos(yaw) py = y + float(i) * step * math.sin(yaw) if runtime.cost_at_world(px, py) >= int(runtime.cfg.blocked_cost): lin = 0.0 cmd["blocked"] = True break cmd["linear_mps"] = float(lin) cmd["angular_rps"] = float(ang) return cmd