Download app.py from lijinghai/RabbitRobot-CoveragePath-Demo: direct link, hf CLI and curl.
- Browser
- Download file 9.78 kB
-
https://huggingface.co/spaces/lijinghai/RabbitRobot-CoveragePath-Demo/resolve/main/app.py
- Command line
-
hf download hf://spaces/lijinghai/RabbitRobot-CoveragePath-Demo/app.py
-
curl -L -o app.py https://huggingface.co/spaces/lijinghai/RabbitRobot-CoveragePath-Demo/resolve/main/app.py
9.78 kB
| import math | |
| from dataclasses import dataclass | |
| import gradio as gr | |
| import numpy as np | |
| from PIL import Image, ImageDraw, ImageFilter | |
| RESOLUTION_M = 0.10 | |
| MAP_W, MAP_H = 920, 520 | |
| class WorldBox: | |
| x_min: float | |
| x_max: float | |
| y_min: float | |
| y_max: float | |
| def build_demo_map() -> np.ndarray: | |
| """0 free, 1 obstacle, -1 unknown.""" | |
| grid = np.zeros((MAP_H, MAP_W), dtype=np.int8) | |
| grid[:, :] = 0 | |
| # Unknown bands and off-workspace corners. | |
| grid[:38, :] = -1 | |
| grid[-35:, :] = -1 | |
| grid[:, :42] = -1 | |
| grid[:, -45:] = -1 | |
| grid[390:480, 650:880] = -1 | |
| # Walls, shelves and typical AMR obstacles. | |
| obstacles = [ | |
| (115, 95, 225, 145), | |
| (315, 80, 365, 260), | |
| (510, 70, 555, 185), | |
| (675, 120, 805, 170), | |
| (190, 295, 310, 350), | |
| (420, 310, 520, 360), | |
| (610, 265, 720, 315), | |
| (765, 250, 815, 375), | |
| ] | |
| for x0, y0, x1, y1 in obstacles: | |
| grid[y0:y1, x0:x1] = 1 | |
| # Thin border obstacles. | |
| grid[70:75, 80:840] = 1 | |
| grid[440:445, 80:620] = 1 | |
| grid[70:445, 80:85] = 1 | |
| grid[70:390, 840:845] = 1 | |
| return grid | |
| def world_to_px(x_m: float, y_m: float) -> tuple[int, int]: | |
| x = int(round(x_m / RESOLUTION_M)) + 60 | |
| y = MAP_H - (int(round(y_m / RESOLUTION_M)) + 45) | |
| return x, y | |
| def px_to_world(x: int, y: int) -> tuple[float, float]: | |
| wx = (x - 60) * RESOLUTION_M | |
| wy = (MAP_H - y - 45) * RESOLUTION_M | |
| return wx, wy | |
| def clearance_free_mask(grid: np.ndarray, clearance_px: int) -> np.ndarray: | |
| unsafe = grid != 0 | |
| unsafe_image = Image.fromarray((unsafe.astype(np.uint8) * 255), "L") | |
| kernel = max(3, clearance_px * 2 + 1) | |
| if kernel % 2 == 0: | |
| kernel += 1 | |
| inflated = unsafe_image.filter(ImageFilter.MaxFilter(kernel)) | |
| return np.asarray(inflated) == 0 | |
| def contiguous_segments(row_mask: np.ndarray, min_len: int = 24) -> list[tuple[int, int]]: | |
| segments = [] | |
| start = None | |
| for i, ok in enumerate(row_mask): | |
| if ok and start is None: | |
| start = i | |
| if (not ok or i == len(row_mask) - 1) and start is not None: | |
| end = i if ok and i == len(row_mask) - 1 else i - 1 | |
| if end - start + 1 >= min_len: | |
| segments.append((start, end)) | |
| start = None | |
| return segments | |
| def plan_coverage( | |
| x_min: float, | |
| x_max: float, | |
| y_min: float, | |
| y_max: float, | |
| lane_spacing_m: float, | |
| robot_width_m: float, | |
| robot_length_m: float, | |
| safety_margin_m: float, | |
| map_aware: bool, | |
| ) -> tuple[Image.Image, str]: | |
| grid = build_demo_map() | |
| clearance_m = math.hypot(robot_width_m, robot_length_m) / 2.0 + safety_margin_m | |
| clearance_px = max(1, int(round(clearance_m / RESOLUTION_M))) | |
| free_mask = clearance_free_mask(grid, clearance_px) if map_aware else grid != 1 | |
| box = WorldBox(min(x_min, x_max), max(x_min, x_max), min(y_min, y_max), max(y_min, y_max)) | |
| px0, py1 = world_to_px(box.x_min, box.y_min) | |
| px1, py0 = world_to_px(box.x_max, box.y_max) | |
| x0, x1 = sorted((max(0, px0), min(MAP_W - 1, px1))) | |
| y0, y1 = sorted((max(0, py0), min(MAP_H - 1, py1))) | |
| lane_step = max(10, int(round(lane_spacing_m / RESOLUTION_M))) | |
| waypoints: list[tuple[int, int]] = [] | |
| path_lines: list[list[tuple[int, int]]] = [] | |
| previous_end: tuple[int, int] | None = None | |
| direction = 1 | |
| sampled_cells = 0 | |
| for y in range(y0, y1 + 1, lane_step): | |
| row = free_mask[y, x0 : x1 + 1] | |
| for seg_start, seg_end in contiguous_segments(row): | |
| sx = x0 + seg_start | |
| ex = x0 + seg_end | |
| sampled_cells += max(0, ex - sx + 1) | |
| if direction > 0: | |
| start, end = (sx, y), (ex, y) | |
| else: | |
| start, end = (ex, y), (sx, y) | |
| if previous_end is not None: | |
| connector = safe_connector(previous_end, start, free_mask) | |
| if connector: | |
| path_lines.append(connector) | |
| path_lines.append([start, end]) | |
| waypoints.extend([start, end]) | |
| previous_end = end | |
| direction *= -1 | |
| image = render_map(grid, free_mask, (x0, y0, x1, y1), waypoints, path_lines, clearance_px) | |
| covered_area = sampled_cells * lane_spacing_m * RESOLUTION_M | |
| path_len_m = estimate_path_length(path_lines) | |
| note = ( | |
| f"生成 {len(waypoints)} 个 waypoint,估算覆盖面积 {covered_area:.1f} m²," | |
| f"路径长度约 {path_len_m:.1f} m。\n\n" | |
| f"车体 footprint = {robot_width_m:.2f} m x {robot_length_m:.2f} m," | |
| f"安全边距 = {safety_margin_m:.2f} m,中心点 clearance 约 {clearance_m:.2f} m。\n\n" | |
| "工程含义:先把用户选择区域裁剪到已知 free-space,再生成往返覆盖线;" | |
| "unknown、地图外区域和按车体膨胀后的障碍区域不会参与规划。真实机器人执行时仍应走 Nav2 " | |
| "与 collision monitor 等安全链路。" | |
| ) | |
| return image, note | |
| def estimate_path_length(path_lines: list[list[tuple[int, int]]]) -> float: | |
| total_px = 0.0 | |
| for line in path_lines: | |
| for a, b in zip(line, line[1:]): | |
| total_px += math.hypot(a[0] - b[0], a[1] - b[1]) | |
| return total_px * RESOLUTION_M | |
| def line_is_free(a: tuple[int, int], b: tuple[int, int], free_mask: np.ndarray) -> bool: | |
| dx = b[0] - a[0] | |
| dy = b[1] - a[1] | |
| steps = max(abs(dx), abs(dy), 1) | |
| for i in range(steps + 1): | |
| x = int(round(a[0] + dx * i / steps)) | |
| y = int(round(a[1] + dy * i / steps)) | |
| if x < 0 or x >= MAP_W or y < 0 or y >= MAP_H or not free_mask[y, x]: | |
| return False | |
| return True | |
| def safe_connector( | |
| a: tuple[int, int], | |
| b: tuple[int, int], | |
| free_mask: np.ndarray, | |
| ) -> list[tuple[int, int]] | None: | |
| if line_is_free(a, b, free_mask): | |
| return [a, b] | |
| for corner in ((a[0], b[1]), (b[0], a[1])): | |
| if line_is_free(a, corner, free_mask) and line_is_free(corner, b, free_mask): | |
| return [a, corner, b] | |
| return None | |
| def render_map( | |
| grid: np.ndarray, | |
| free_mask: np.ndarray, | |
| selected: tuple[int, int, int, int], | |
| waypoints: list[tuple[int, int]], | |
| path_lines: list[list[tuple[int, int]]], | |
| clearance_px: int, | |
| ) -> Image.Image: | |
| rgb = np.zeros((MAP_H, MAP_W, 3), dtype=np.uint8) | |
| rgb[grid == 0] = [244, 247, 250] | |
| rgb[grid == -1] = [198, 204, 213] | |
| rgb[grid == 1] = [31, 41, 55] | |
| rgb[(grid == 0) & free_mask] = [236, 252, 242] | |
| image = Image.fromarray(rgb, "RGB") | |
| draw = ImageDraw.Draw(image, "RGBA") | |
| x0, y0, x1, y1 = selected | |
| draw.rectangle((x0, y0, x1, y1), outline=(37, 99, 235, 240), width=4, fill=(37, 99, 235, 28)) | |
| for line in path_lines: | |
| if len(line) >= 2: | |
| draw.line(line, fill=(18, 142, 76, 255), width=4, joint="curve") | |
| if waypoints: | |
| for idx, (x, y) in enumerate(waypoints): | |
| r = 4 if idx not in (0, len(waypoints) - 1) else 6 | |
| color = (22, 101, 52, 255) if idx == 0 else (21, 128, 61, 255) | |
| if idx == len(waypoints) - 1: | |
| color = (220, 38, 38, 255) | |
| draw.ellipse((x - r, y - r, x + r, y + r), fill=color) | |
| legend_x, legend_y = 20, 20 | |
| draw.rounded_rectangle((legend_x, legend_y, legend_x + 360, legend_y + 116), radius=8, fill=(255, 255, 255, 232)) | |
| draw.text((legend_x + 14, legend_y + 12), "RabbitRobot coverage planning demo", fill=(15, 23, 42)) | |
| legend = [ | |
| ("free / safe after clearance", (236, 252, 242)), | |
| ("unknown or off-map", (198, 204, 213)), | |
| ("inflated obstacle source", (31, 41, 55)), | |
| (f"path with clearance radius {clearance_px}px", (18, 142, 76)), | |
| ] | |
| for i, (text, color) in enumerate(legend): | |
| yy = legend_y + 36 + i * 18 | |
| draw.rectangle((legend_x + 16, yy + 3, legend_x + 30, yy + 14), fill=color + (255,)) | |
| draw.text((legend_x + 38, yy), text, fill=(51, 65, 85)) | |
| return image | |
| with gr.Blocks(title="RabbitRobot Coverage Path Planning") as demo: | |
| gr.Markdown( | |
| """ | |
| # RabbitRobot Coverage Path Planning | |
| 一个公开可交互的 AMR 覆盖路径规划演示:用合成占据栅格展示 map-aware clipping、 | |
| 车体 footprint 安全膨胀和往返式覆盖路径生成。 | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| x_min = gr.Slider(-2.0, 65.0, value=2.0, step=0.5, label="区域 x_min (m)") | |
| x_max = gr.Slider(2.0, 78.0, value=58.0, step=0.5, label="区域 x_max (m)") | |
| y_min = gr.Slider(-1.0, 42.0, value=5.5, step=0.5, label="区域 y_min (m)") | |
| y_max = gr.Slider(3.0, 46.0, value=34.0, step=0.5, label="区域 y_max (m)") | |
| lane_spacing = gr.Slider(0.35, 2.0, value=0.80, step=0.05, label="覆盖行距 / tool width (m)") | |
| robot_width = gr.Slider(0.25, 1.0, value=0.486, step=0.01, label="车体宽度 (m)") | |
| robot_length = gr.Slider(0.25, 1.2, value=0.450, step=0.01, label="车体长度 (m)") | |
| safety_margin = gr.Slider(0.0, 0.8, value=0.08, step=0.01, label="安全边距 (m)") | |
| map_aware = gr.Checkbox(value=True, label="启用 map-aware free-space clipping") | |
| run = gr.Button("Generate coverage path", variant="primary") | |
| with gr.Column(scale=2): | |
| output_image = gr.Image(label="Coverage path overlay", type="pil") | |
| output_note = gr.Textbox(label="Planning summary", lines=7) | |
| inputs = [x_min, x_max, y_min, y_max, lane_spacing, robot_width, robot_length, safety_margin, map_aware] | |
| run.click(plan_coverage, inputs=inputs, outputs=[output_image, output_note]) | |
| demo.load(plan_coverage, inputs=inputs, outputs=[output_image, output_note]) | |
| if __name__ == "__main__": | |
| demo.launch() | |