家庭扫地机器人路径规划Python版本

扫地机器人核心是全覆盖路径规划 CCPP(Complete Coverage Path Planning),分为:综述类、国内期刊(全覆盖 / 改进算法)、外文经典、SLAM + 规划工程类,适合毕设、课题调研,全部聚焦家庭室内家居场景。

关键指标:覆盖率、路径重复率、运行时间、内存开销,是扫地机器人区别普通点到点路径规划的重点。

一、中文综述(快速入门,必看)

  1. 移动机器人全覆盖路径规划算法综述

适合开题,梳理 Boustrophedon 分割、细胞分解、栅格遍历、智能算法、SLAM 融合;讲清扫地机主流:弓字形、随机、分区遍历优缺点。 检索:知网,大量中文毕设引用。

  1. 扫地机器人路径规划算法研究综述J 科技与创新

专门针对家庭扫地机器人,对比随机碰撞、螺旋、Boustrophedon、A*‑DWA、蚁群,分析实际家用环境(家具、狭小房间)痛点。

二、国内核心期刊论文(家庭扫地机,全覆盖 CCPP,可直接做复现)

1)分区 + 细胞分解(主流商用思路,石头 / 科沃斯底层思想)

  1. 许伦辉,林世城. 基于分治思想的扫地机器人全覆盖路径规划算法研究J. 广西师范大学学报 (自然科学版),2021,39 (6):54‑62.
  • 亮点:针对家庭零散障碍物,分治分割生成弓形清扫,降低路径重复率;栅格仿真,适合毕设复现。
  1. 郭峻宇等. 基于静态地图扫地机器人的路径规划J. 上海工程技术大学学报,2026.
  • 亮点:细胞自动机 + 改进蚁群,子区域串联,TSP 旅行商问题优化房间遍历顺序;重复率对比实验完整。

2)启发式 A*/DWA 融合(全局 + 局部避障,SLAM 地图后规划)

  1. 谢坤霖,李宗根. 基于启发式搜索算法的扫地机器人路径规划J. 西华大学学报,2019,38 (4):69‑76.
  • 亮点:改进 A * 用于扫地机全覆盖,栅格仿真,分析家庭多障碍物场景,算法对比清晰,适合入门复现。
  1. 刘智超. Indoor Robot Path Planning Incorporating Improved A Algorithms and DWA*J. 制造业自动化,中文核心.
  • 亮点:全局改进 A* + 局部 DWA 动态避障;适配家庭动态障碍物(椅子、拖鞋),解决静态规划无法应对家中移动物体。

3)仿生智能算法(蚁群、遗传,适合做改进创新点)

  1. 蒋玉杰,曾岑. 清洁机器人基于遗传算法的全区域路径规划J. 机械制造,2009.

早期经典,遗传算法做全覆盖,适合做对比基线。

三、外文经典论文(CCPP 基础 + 现代改进)

✨全覆盖经典奠基(必引)

  1. Boustrophedon Cellular Decomposition for Coverage Path Planning(1998)

Choset H, Pignon P. 扫地机器人分区清扫的鼻祖,Boustrophedon 牛耕式细胞分解,几乎所有商用扫地机分区清扫的理论源头。

  1. Complete Coverage Path Planning: A Survey

全覆盖综述,梳理细胞分解、栅格遍历、随机遍历,适合外文参考文献。

✨面向家庭扫地机器人

  1. Path planning algorithm development for autonomous vacuum cleaner robots (2014)ResearchGa...

对比随机行走、螺旋、S 型弓字形、沿墙行走 4 种早期扫地机策略;理解初代无 SLAM 扫地机工作原理。

  1. Si H, Miao Z. A Map Segmentation Method Based on Image Processing for Robot Complete Coverage OperationJ.Journal of Field Robotics,2025Wiley Onli....

2025 最新,基于图像处理做地图分割,处理家庭凹型房间、家具遮挡,适合前沿参考。

  1. Liu H, Zhang Y. ASL‑DWA: An Improved A‑Star Algorithm for Indoor Cleaning Robots, IEEE Access,2022Nature.

改进 A*‑DWA,面向室内清洁机器人;降低路径冗余,动态障碍物响应,工程性强。

四、SLAM + 规划联合(真实扫地机器人完整链路,工程方向)

家用扫地机器人不是单纯路径规划,是SLAM 建图 →地图分割 →全覆盖路径 →局部动态避障完整链路。

  1. Autonomous Visual Navigation System Based on a Single Camera for Floor‑Sweeping Robot, Applied Sciences,2023ResearchGa... 单目视觉 SLAM + 垃圾识别 + 局部动态规划;仿真基于 TurtleBot,适合做视觉扫地机器人课题。

注意:激光 SLAM 常用 GMapping、Cartographer,再在上层做 CCPP 全覆盖。

五、选题 / 复现建议(毕设参考)

  1. 简单复现:Boustrophedon 分割 + 弓字形遍历(Matlab/Python 栅格仿真)
  2. 中等创新:改进 A*+DWA,全局全覆盖,局部动态避障;对比覆盖率、重复率
  3. 高级创新:地图分割 + TSP 优化房间清扫顺序,模拟多房间家庭;加入电池回充约束

六、检索关键词(知网、IEEE Xplore、Semantic Scholar)

中文:

扫地机器人;全覆盖路径规划;CCPP;Boustrophedon;细胞分解;栅格地图;路径重复率

英文:

Sweeping robot / Vacuum cleaner robot; Complete Coverage Path Planning(CCPP); Boustrophedon cellular decomposition; indoor household environment

python 复制代码
"""Furnished-apartment coverage planning and 3D robot-vacuum simulation."""

from __future__ import annotations

import argparse
import csv
import heapq
import math
import time
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence

import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation, PillowWriter
from matplotlib.collections import LineCollection
from matplotlib.font_manager import fontManager
from matplotlib.lines import Line2D
from matplotlib.patches import Circle, Rectangle
from mpl_toolkits.mplot3d.art3d import Poly3DCollection


GridCell = tuple[int, int]


@dataclass(frozen=True)
class RectObject:
    name: str
    xmin: float
    xmax: float
    ymin: float
    ymax: float
    height: float
    color: str
    kind: str = "furniture"


@dataclass(frozen=True)
class Room:
    name: str
    xmin: float
    xmax: float
    ymin: float
    ymax: float
    color: str


@dataclass
class PlanResult:
    path: list[GridCell]
    reachable: np.ndarray
    cleaned: np.ndarray
    dock: GridCell
    path_length_m: float
    coverage_percent: float
    reachable_area_m2: float
    planning_time_s: float
    cleanup_targets: int


class Apartment:
    """Apartment geometry and its collision-inflated occupancy grid."""

    width = 12.0
    depth = 9.0

    def __init__(
        self,
        resolution: float = 0.25,
        robot_radius: float = 0.23,
        safety_margin: float = 0.06,
    ) -> None:
        self.resolution = resolution
        self.robot_radius = robot_radius
        self.safety_margin = safety_margin
        self.clearance = robot_radius + safety_margin
        self.rows = int(round(self.depth / resolution))
        self.cols = int(round(self.width / resolution))
        self.x_centers = (np.arange(self.cols) + 0.5) * resolution
        self.y_centers = (np.arange(self.rows) + 0.5) * resolution

        self.rooms = [
            Room("客厅", 0.15, 7.12, 0.15, 5.72, "#e8f0ed"),
            Room("卧室", 7.28, 11.85, 0.15, 5.72, "#e9edf6"),
            Room("餐厅与玄关", 0.15, 11.85, 5.88, 8.85, "#f4eee3"),
        ]
        self.walls = self._build_walls()
        self.furniture = self._build_furniture()
        self.objects = self.walls + self.furniture
        self.occupancy = self._build_occupancy()

    def _build_walls(self) -> list[RectObject]:
        wall_color = "#697078"
        walls = [
            RectObject("左外墙", 0.00, 0.15, 0.00, 9.00, 0.72, wall_color, "wall"),
            RectObject("右外墙", 11.85, 12.00, 0.00, 9.00, 0.72, wall_color, "wall"),
            RectObject("下外墙", 0.00, 12.00, 0.00, 0.15, 0.72, wall_color, "wall"),
            RectObject("上外墙", 0.00, 12.00, 8.85, 9.00, 0.72, wall_color, "wall"),
            # Horizontal wall, with two 1.6 m door openings. The openings
            # remain traversable after robot-radius collision inflation.
            RectObject("客厅北墙", 0.00, 2.45, 5.72, 5.88, 0.62, wall_color, "wall"),
            RectObject("中部北墙", 4.05, 8.00, 5.72, 5.88, 0.62, wall_color, "wall"),
            RectObject("卧室北墙", 9.60, 12.00, 5.72, 5.88, 0.62, wall_color, "wall"),
            # Vertical wall, with a 1.2 m living-room/bedroom door opening.
            RectObject("卧室西墙下", 7.12, 7.28, 0.00, 2.10, 0.62, wall_color, "wall"),
            RectObject("卧室西墙上", 7.12, 7.28, 3.35, 5.88, 0.62, wall_color, "wall"),
        ]
        return walls

    def _build_furniture(self) -> list[RectObject]:
        return [
            RectObject("沙发", 0.65, 3.20, 4.30, 5.35, 0.78, "#477f7a"),
            RectObject("茶几", 2.10, 3.65, 2.65, 3.55, 0.42, "#b77a42"),
            RectObject("电视柜", 5.95, 6.75, 4.45, 5.42, 0.52, "#454b50"),
            RectObject("落地灯", 0.70, 1.15, 2.45, 2.90, 1.35, "#d0a83f"),
            RectObject("床", 8.30, 11.35, 3.10, 5.00, 0.48, "#587ba8"),
            RectObject("床头柜", 7.55, 8.15, 4.25, 5.00, 0.58, "#9a704f"),
            RectObject("衣柜", 10.75, 11.65, 0.55, 2.40, 1.75, "#826d5f"),
            RectObject("书桌", 7.65, 9.45, 0.55, 1.25, 0.76, "#a06c3f"),
            RectObject("餐桌", 4.35, 6.20, 6.55, 8.05, 0.74, "#a66c3f"),
            RectObject("餐椅1", 3.70, 4.20, 6.75, 7.30, 0.82, "#66806a"),
            RectObject("餐椅2", 6.35, 6.85, 6.75, 7.30, 0.82, "#66806a"),
            RectObject("餐椅3", 7.20, 7.80, 7.95, 8.45, 0.82, "#66806a"),
            RectObject("鞋柜", 0.55, 1.90, 7.95, 8.58, 1.05, "#80746a"),
            RectObject("边柜", 9.80, 11.35, 7.90, 8.55, 0.88, "#9b7d59"),
        ]

    def _build_occupancy(self) -> np.ndarray:
        x_grid, y_grid = np.meshgrid(self.x_centers, self.y_centers)
        occupied = np.zeros((self.rows, self.cols), dtype=bool)
        for item in self.objects:
            occupied |= (
                (x_grid >= item.xmin - self.clearance)
                & (x_grid <= item.xmax + self.clearance)
                & (y_grid >= item.ymin - self.clearance)
                & (y_grid <= item.ymax + self.clearance)
            )
        return occupied

    def cell_to_xy(self, cell: GridCell) -> tuple[float, float]:
        row, col = cell
        return float(self.x_centers[col]), float(self.y_centers[row])

    def nearest_free_cell(self, x: float, y: float) -> GridCell:
        free_rows, free_cols = np.where(~self.occupancy)
        if free_rows.size == 0:
            raise RuntimeError("地图中没有可用的自由栅格。")
        distances = (
            (self.x_centers[free_cols] - x) ** 2
            + (self.y_centers[free_rows] - y) ** 2
        )
        index = int(np.argmin(distances))
        return int(free_rows[index]), int(free_cols[index])

    def valid_neighbors(self, cell: GridCell) -> Iterable[tuple[GridCell, float]]:
        row, col = cell
        for dr, dc in (
            (-1, 0),
            (1, 0),
            (0, -1),
            (0, 1),
            (-1, -1),
            (-1, 1),
            (1, -1),
            (1, 1),
        ):
            nr, nc = row + dr, col + dc
            if not (0 <= nr < self.rows and 0 <= nc < self.cols):
                continue
            if self.occupancy[nr, nc]:
                continue
            if dr != 0 and dc != 0:
                # A diagonal move may not cut through a furniture/wall corner.
                if self.occupancy[row, nc] or self.occupancy[nr, col]:
                    continue
            yield (nr, nc), math.sqrt(2.0) if dr and dc else 1.0

    def room_at(self, x: float, y: float) -> str:
        for room in self.rooms:
            if room.xmin <= x <= room.xmax and room.ymin <= y <= room.ymax:
                return room.name
        return "门厅/通道"


class CoveragePlanner:
    """Boustrophedon room sweeps connected by collision-safe A* paths."""

    def __init__(self, apartment: Apartment, cleaning_width: float = 0.52) -> None:
        self.apartment = apartment
        self.cleaning_width = cleaning_width
        self.cleaning_radius = cleaning_width / 2.0
        self.row_stride = max(
            1, int(math.floor(cleaning_width / apartment.resolution + 1e-9))
        )

    def astar(self, start: GridCell, goal: GridCell) -> list[GridCell]:
        if start == goal:
            return [start]

        def heuristic(cell: GridCell) -> float:
            dr = abs(cell[0] - goal[0])
            dc = abs(cell[1] - goal[1])
            return max(dr, dc) + (math.sqrt(2.0) - 1.0) * min(dr, dc)

        open_heap: list[tuple[float, float, GridCell]] = []
        heapq.heappush(open_heap, (heuristic(start), 0.0, start))
        best_cost: dict[GridCell, float] = {start: 0.0}
        parent: dict[GridCell, GridCell] = {}
        closed: set[GridCell] = set()

        while open_heap:
            _, cost, current = heapq.heappop(open_heap)
            if current in closed:
                continue
            if current == goal:
                path = [goal]
                while path[-1] != start:
                    path.append(parent[path[-1]])
                path.reverse()
                return path
            closed.add(current)

            for neighbor, move_cost in self.apartment.valid_neighbors(current):
                candidate = cost + move_cost
                if candidate + 1e-12 < best_cost.get(neighbor, math.inf):
                    best_cost[neighbor] = candidate
                    parent[neighbor] = current
                    heapq.heappush(
                        open_heap,
                        (candidate + heuristic(neighbor), candidate, neighbor),
                    )
        raise RuntimeError(f"A* 无法连接栅格 {start} 和 {goal}。")

    def reachable_from(self, start: GridCell) -> np.ndarray:
        reachable = np.zeros_like(self.apartment.occupancy)
        reachable[start] = True
        queue: deque[GridCell] = deque([start])
        while queue:
            cell = queue.popleft()
            for neighbor, _ in self.apartment.valid_neighbors(cell):
                if not reachable[neighbor]:
                    reachable[neighbor] = True
                    queue.append(neighbor)
        return reachable

    @staticmethod
    def _extend_path(path: list[GridCell], addition: Sequence[GridCell]) -> None:
        if not addition:
            return
        start_index = 1 if path and path[-1] == addition[0] else 0
        for cell in addition[start_index:]:
            if not path or path[-1] != cell:
                path.append(cell)

    def _room_sweep_segments(
        self, room: Room, reachable: np.ndarray
    ) -> list[list[GridCell]]:
        a = self.apartment
        row_candidates = np.where(
            (a.y_centers >= room.ymin) & (a.y_centers <= room.ymax)
        )[0]
        valid_rows = [
            int(row)
            for row in row_candidates
            if np.any(
                reachable[row]
                & (a.x_centers >= room.xmin)
                & (a.x_centers <= room.xmax)
            )
        ]
        if not valid_rows:
            return []

        selected_rows = valid_rows[:: self.row_stride]
        # Ensure the far room edge remains within one cleaning radius.
        if valid_rows[-1] - selected_rows[-1] > self.row_stride // 2:
            selected_rows.append(valid_rows[-1])

        segments: list[list[GridCell]] = []
        for row_number, row in enumerate(selected_rows):
            valid_columns = np.where(
                reachable[row]
                & (a.x_centers >= room.xmin)
                & (a.x_centers <= room.xmax)
            )[0]
            if valid_columns.size == 0:
                continue
            split_points = np.where(np.diff(valid_columns) > 1)[0] + 1
            runs = [part for part in np.split(valid_columns, split_points) if part.size]
            left_to_right = row_number % 2 == 0
            ordered_runs = runs if left_to_right else list(reversed(runs))
            for run in ordered_runs:
                columns = run if left_to_right else run[::-1]
                segments.append([(row, int(col)) for col in columns])
        return segments

    def _mark_cleaned(
        self, cleaned: np.ndarray, cells: Iterable[GridCell], reachable: np.ndarray
    ) -> None:
        a = self.apartment
        offset_limit = int(math.ceil(self.cleaning_radius / a.resolution))
        offsets: list[tuple[int, int]] = []
        for dr in range(-offset_limit, offset_limit + 1):
            for dc in range(-offset_limit, offset_limit + 1):
                distance = math.hypot(dr * a.resolution, dc * a.resolution)
                if distance <= self.cleaning_radius + 1e-9:
                    offsets.append((dr, dc))

        for row, col in cells:
            for dr, dc in offsets:
                nr, nc = row + dr, col + dc
                if (
                    0 <= nr < a.rows
                    and 0 <= nc < a.cols
                    and reachable[nr, nc]
                ):
                    cleaned[nr, nc] = True

    def plan(self, dock_xy: tuple[float, float] = (0.65, 0.65)) -> PlanResult:
        started_at = time.perf_counter()
        a = self.apartment
        dock = a.nearest_free_cell(*dock_xy)
        reachable = self.reachable_from(dock)
        path: list[GridCell] = [dock]

        # Each room is a coverage cell. Within it, alternate sweep direction.
        for room in a.rooms:
            for segment in self._room_sweep_segments(room, reachable):
                connector = self.astar(path[-1], segment[0])
                self._extend_path(path, connector)
                self._extend_path(path, segment)

        cleaned = np.zeros_like(reachable)
        self._mark_cleaned(cleaned, path, reachable)

        # Door thresholds and narrow residual pockets may fall between nominal
        # sweep rows. Visit only those residual cells before returning to dock.
        cleanup_targets = 0
        max_cleanup_targets = int(np.count_nonzero(reachable))
        while cleanup_targets < max_cleanup_targets:
            uncovered = np.argwhere(reachable & ~cleaned)
            if uncovered.size == 0:
                break
            current = np.array(path[-1])
            nearest_index = int(
                np.argmin(np.sum((uncovered - current[None, :]) ** 2, axis=1))
            )
            target = tuple(int(value) for value in uncovered[nearest_index])
            connector = self.astar(path[-1], target)
            self._extend_path(path, connector)
            self._mark_cleaned(cleaned, connector, reachable)
            cleanup_targets += 1

        self._extend_path(path, self.astar(path[-1], dock))
        self._mark_cleaned(cleaned, path[-1:], reachable)

        path_length = sum(
            math.hypot(b[0] - c[0], b[1] - c[1]) * a.resolution
            for c, b in zip(path, path[1:])
        )
        reachable_count = int(np.count_nonzero(reachable))
        cleaned_count = int(np.count_nonzero(cleaned & reachable))
        coverage = 100.0 * cleaned_count / reachable_count
        result = PlanResult(
            path=path,
            reachable=reachable,
            cleaned=cleaned,
            dock=dock,
            path_length_m=path_length,
            coverage_percent=coverage,
            reachable_area_m2=reachable_count * a.resolution**2,
            planning_time_s=time.perf_counter() - started_at,
            cleanup_targets=cleanup_targets,
        )
        self.validate(result)
        return result

    def validate(self, result: PlanResult) -> None:
        a = self.apartment
        if result.path[0] != result.dock or result.path[-1] != result.dock:
            raise AssertionError("规划路径没有从充电座出发并返回充电座。")
        x_grid, y_grid = np.meshgrid(a.x_centers, a.y_centers)
        for room in a.rooms:
            room_free = (
                ~a.occupancy
                & (x_grid >= room.xmin)
                & (x_grid <= room.xmax)
                & (y_grid >= room.ymin)
                & (y_grid <= room.ymax)
            )
            free_count = int(np.count_nonzero(room_free))
            reachable_count = int(np.count_nonzero(room_free & result.reachable))
            if free_count and reachable_count / free_count < 0.75:
                raise AssertionError(
                    f"{room.name} 只有 {reachable_count / free_count:.1%} 的自由区域"
                    "与充电座连通。"
                )
        for current, following in zip(result.path, result.path[1:]):
            if a.occupancy[current] or a.occupancy[following]:
                raise AssertionError("规划路径与膨胀后的障碍物碰撞。")
            dr = following[0] - current[0]
            dc = following[1] - current[1]
            if max(abs(dr), abs(dc)) > 1 or (dr == 0 and dc == 0):
                raise AssertionError("规划路径含有不连续的栅格跳跃。")
            if dr and dc:
                if a.occupancy[current[0], following[1]] or a.occupancy[
                    following[0], current[1]
                ]:
                    raise AssertionError("规划路径斜向穿过了障碍物拐角。")
        if result.coverage_percent < 99.999:
            raise AssertionError(
                f"覆盖率仅为 {result.coverage_percent:.3f}%,未达到完整覆盖。"
            )


def configure_chinese_font() -> None:
    available = {font.name for font in fontManager.ttflist}
    candidates = [
        "Microsoft YaHei",
        "SimHei",
        "Noto Sans CJK SC",
        "Source Han Sans SC",
        "Arial Unicode MS",
    ]
    selected = next((name for name in candidates if name in available), "DejaVu Sans")
    matplotlib.rcParams["font.sans-serif"] = [selected, "DejaVu Sans"]
    matplotlib.rcParams["axes.unicode_minus"] = False


def box_faces(item: RectObject) -> list[list[tuple[float, float, float]]]:
    x0, x1, y0, y1, z1 = (
        item.xmin,
        item.xmax,
        item.ymin,
        item.ymax,
        item.height,
    )
    p000, p100 = (x0, y0, 0.0), (x1, y0, 0.0)
    p110, p010 = (x1, y1, 0.0), (x0, y1, 0.0)
    p001, p101 = (x0, y0, z1), (x1, y0, z1)
    p111, p011 = (x1, y1, z1), (x0, y1, z1)
    return [
        [p000, p100, p110, p010],
        [p001, p101, p111, p011],
        [p000, p100, p101, p001],
        [p100, p110, p111, p101],
        [p110, p010, p011, p111],
        [p010, p000, p001, p011],
    ]


def cylinder_faces(
    x: float, y: float, radius: float, z0: float, z1: float, sides: int = 24
) -> list[list[tuple[float, float, float]]]:
    angles = np.linspace(0.0, 2.0 * math.pi, sides, endpoint=False)
    bottom = [(x + radius * math.cos(t), y + radius * math.sin(t), z0) for t in angles]
    top = [(px, py, z1) for px, py, _ in bottom]
    faces: list[list[tuple[float, float, float]]] = [bottom[::-1], top]
    for index in range(sides):
        next_index = (index + 1) % sides
        faces.append(
            [
                bottom[index],
                bottom[next_index],
                top[next_index],
                top[index],
            ]
        )
    return faces


def path_xy(apartment: Apartment, path: Sequence[GridCell]) -> np.ndarray:
    return np.array([apartment.cell_to_xy(cell) for cell in path], dtype=float)


def sample_polyline(points: np.ndarray, frame_count: int) -> np.ndarray:
    if len(points) <= 1:
        return points.copy()
    segment_lengths = np.linalg.norm(np.diff(points, axis=0), axis=1)
    cumulative = np.concatenate(([0.0], np.cumsum(segment_lengths)))
    if cumulative[-1] == 0.0:
        return np.repeat(points[:1], frame_count, axis=0)
    distances = np.linspace(0.0, cumulative[-1], frame_count)
    sampled_x = np.interp(distances, cumulative, points[:, 0])
    sampled_y = np.interp(distances, cumulative, points[:, 1])
    return np.column_stack((sampled_x, sampled_y))


def draw_2d(
    ax: plt.Axes, apartment: Apartment, result: PlanResult, points: np.ndarray
) -> tuple[Line2D, Circle]:
    for room in apartment.rooms:
        ax.add_patch(
            Rectangle(
                (room.xmin, room.ymin),
                room.xmax - room.xmin,
                room.ymax - room.ymin,
                facecolor=room.color,
                edgecolor="none",
                zorder=0,
            )
        )
        ax.text(
            (room.xmin + room.xmax) / 2.0,
            (room.ymin + room.ymax) / 2.0,
            room.name,
            color="#6a7075",
            fontsize=11,
            ha="center",
            va="center",
            zorder=1,
        )

    x_grid, y_grid = np.meshgrid(apartment.x_centers, apartment.y_centers)
    ax.contour(
        x_grid,
        y_grid,
        apartment.occupancy.astype(float),
        levels=[0.5],
        colors=["#b4413e"],
        linewidths=0.65,
        linestyles="--",
        alpha=0.85,
        zorder=2,
    )
    for item in apartment.objects:
        ax.add_patch(
            Rectangle(
                (item.xmin, item.ymin),
                item.xmax - item.xmin,
                item.ymax - item.ymin,
                facecolor=item.color,
                edgecolor="#30343a",
                linewidth=0.5,
                alpha=0.96,
                zorder=3,
            )
        )
        if item.kind == "furniture" and (item.xmax - item.xmin) > 0.55:
            ax.text(
                (item.xmin + item.xmax) / 2.0,
                (item.ymin + item.ymax) / 2.0,
                item.name,
                fontsize=6.7,
                ha="center",
                va="center",
                color="white",
                zorder=4,
            )

    segments = np.stack((points[:-1], points[1:]), axis=1)
    ax.add_collection(
        LineCollection(
            segments,
            colors="#147d92",
            linewidths=0.8,
            alpha=0.78,
            zorder=5,
        )
    )
    dock_x, dock_y = apartment.cell_to_xy(result.dock)
    ax.scatter(
        [dock_x],
        [dock_y],
        marker="s",
        s=58,
        color="#292d32",
        edgecolor="white",
        linewidth=0.8,
        zorder=7,
    )
    robot_marker = Line2D(
        [dock_x],
        [dock_y],
        marker="o",
        markersize=7,
        markerfacecolor="#e4583e",
        markeredgecolor="white",
        linewidth=1.5,
        color="#e4583e",
        zorder=8,
    )
    ax.add_line(robot_marker)
    safety_circle = Circle(
        (dock_x, dock_y),
        apartment.robot_radius,
        facecolor="#e4583e",
        edgecolor="#973324",
        alpha=0.18,
        zorder=6,
    )
    ax.add_patch(safety_circle)

    legend_items = [
        Line2D([0], [0], color="#147d92", lw=1.8, label="规划清扫路径"),
        Line2D(
            [0], [0], color="#b4413e", lw=1.2, ls="--", label="膨胀后安全边界"
        ),
        Line2D(
            [0],
            [0],
            marker="s",
            color="none",
            markerfacecolor="#292d32",
            label="充电座",
        ),
    ]
    ax.legend(handles=legend_items, loc="upper right", fontsize=8, framealpha=0.92)
    ax.set_xlim(0.0, apartment.width)
    ax.set_ylim(0.0, apartment.depth)
    ax.set_aspect("equal", adjustable="box")
    ax.set_xlabel("x / m")
    ax.set_ylabel("y / m")
    ax.set_title("2D 占据栅格与覆盖路径", fontsize=12)
    ax.grid(color="#c6cbd0", linewidth=0.35, alpha=0.45)
    return robot_marker, safety_circle


def draw_3d(
    ax: plt.Axes, apartment: Apartment, result: PlanResult, points: np.ndarray
) -> tuple[Poly3DCollection, Line2D, Line2D]:
    for room in apartment.rooms:
        floor = [
            (room.xmin, room.ymin, 0.0),
            (room.xmax, room.ymin, 0.0),
            (room.xmax, room.ymax, 0.0),
            (room.xmin, room.ymax, 0.0),
        ]
        ax.add_collection3d(
            Poly3DCollection(
                [floor],
                facecolors=room.color,
                edgecolors="#c1c5c8",
                linewidths=0.4,
                alpha=0.94,
            )
        )

    for item in apartment.objects:
        alpha = 0.72 if item.kind == "wall" else 0.94
        box = Poly3DCollection(
            box_faces(item),
            facecolors=item.color,
            edgecolors="#33383c",
            linewidths=0.35,
            alpha=alpha,
        )
        ax.add_collection3d(box)
        if item.kind == "furniture" and (item.xmax - item.xmin) > 0.8:
            ax.text(
                (item.xmin + item.xmax) / 2.0,
                (item.ymin + item.ymax) / 2.0,
                item.height + 0.06,
                item.name,
                fontsize=6.2,
                ha="center",
                va="bottom",
                color="#33363a",
            )

    ax.plot(
        points[:, 0],
        points[:, 1],
        np.full(len(points), 0.035),
        color="#147d92",
        linewidth=0.72,
        alpha=0.52,
    )
    trail, = ax.plot([], [], [], color="#f06445", linewidth=2.1, alpha=0.95)
    dock_x, dock_y = apartment.cell_to_xy(result.dock)
    robot = Poly3DCollection(
        cylinder_faces(
            dock_x, dock_y, apartment.robot_radius, z0=0.045, z1=0.19
        ),
        facecolors="#e4583e",
        edgecolors="#8c3024",
        linewidths=0.4,
        alpha=1.0,
    )
    ax.add_collection3d(robot)
    heading, = ax.plot(
        [dock_x, dock_x + apartment.robot_radius],
        [dock_y, dock_y],
        [0.20, 0.20],
        color="#f7e8d2",
        linewidth=2.0,
    )

    ax.set_xlim(0.0, apartment.width)
    ax.set_ylim(0.0, apartment.depth)
    ax.set_zlim(0.0, 2.05)
    ax.set_box_aspect((apartment.width, apartment.depth, 4.5))
    ax.view_init(elev=37, azim=-57)
    ax.set_xlabel("x / m", labelpad=5)
    ax.set_ylabel("y / m", labelpad=5)
    ax.set_zlabel("高度 / m", labelpad=4)
    ax.set_title("3D 家居环境与机器人运动", fontsize=12)
    ax.grid(False)
    ax.xaxis.pane.fill = False
    ax.yaxis.pane.fill = False
    ax.zaxis.pane.fill = False
    return robot, heading, trail


def render_simulation(
    apartment: Apartment,
    result: PlanResult,
    output_file: Path,
    animate: bool,
    frame_count: int,
    interval_ms: int,
    gif_file: Path | None,
) -> tuple[plt.Figure, FuncAnimation | None]:
    configure_chinese_font()
    points = path_xy(apartment, result.path)
    figure = plt.figure(figsize=(15.5, 7.2), constrained_layout=True)
    grid = figure.add_gridspec(1, 2, width_ratios=(1.0, 1.28))
    ax_2d = figure.add_subplot(grid[0, 0])
    ax_3d = figure.add_subplot(grid[0, 1], projection="3d")
    robot_2d, safety_circle = draw_2d(ax_2d, apartment, result, points)
    robot_3d, heading, trail_3d = draw_3d(ax_3d, apartment, result, points)

    status = figure.suptitle(
        (
            f"扫地机器人覆盖规划  |  路径 {result.path_length_m:.1f} m  |  "
            f"覆盖率 {result.coverage_percent:.1f}%"
        ),
        fontsize=14,
        fontweight="bold",
        color="#2d3338",
    )
    output_file.parent.mkdir(parents=True, exist_ok=True)
    figure.savefig(output_file, dpi=170, facecolor="white")

    animation: FuncAnimation | None = None
    if animate:
        sampled = sample_polyline(points, max(2, frame_count))

        def update(frame: int) -> tuple[object, ...]:
            x, y = sampled[frame]
            if frame + 1 < len(sampled):
                dx, dy = sampled[frame + 1] - sampled[frame]
            else:
                dx, dy = sampled[frame] - sampled[frame - 1]
            angle = math.atan2(dy, dx) if abs(dx) + abs(dy) > 1e-12 else 0.0

            robot_2d.set_data([x], [y])
            safety_circle.center = (x, y)
            robot_3d.set_verts(
                cylinder_faces(
                    x, y, apartment.robot_radius, z0=0.045, z1=0.19
                )
            )
            heading.set_data_3d(
                [x, x + apartment.robot_radius * math.cos(angle)],
                [y, y + apartment.robot_radius * math.sin(angle)],
                [0.20, 0.20],
            )
            trail_3d.set_data_3d(
                sampled[: frame + 1, 0],
                sampled[: frame + 1, 1],
                np.full(frame + 1, 0.045),
            )
            progress = 100.0 * frame / (len(sampled) - 1)
            status.set_text(
                f"正在清扫:{apartment.room_at(x, y)}  |  任务进度 {progress:5.1f}%  |  "
                f"规划覆盖率 {result.coverage_percent:.1f}%"
            )
            return robot_2d, safety_circle, robot_3d, heading, trail_3d, status

        animation = FuncAnimation(
            figure,
            update,
            frames=len(sampled),
            interval=interval_ms,
            repeat=True,
            blit=False,
        )
        if gif_file is not None:
            gif_file.parent.mkdir(parents=True, exist_ok=True)
            fps = max(1, int(round(1000.0 / interval_ms)))
            animation.save(gif_file, writer=PillowWriter(fps=fps), dpi=100)
    return figure, animation


def export_path(
    output_file: Path, apartment: Apartment, result: PlanResult
) -> None:
    output_file.parent.mkdir(parents=True, exist_ok=True)
    with output_file.open("w", newline="", encoding="utf-8-sig") as file_handle:
        writer = csv.writer(file_handle)
        writer.writerow(["step", "grid_row", "grid_col", "x_m", "y_m", "room"])
        for step, cell in enumerate(result.path):
            x, y = apartment.cell_to_xy(cell)
            writer.writerow([step, cell[0], cell[1], f"{x:.3f}", f"{y:.3f}", apartment.room_at(x, y)])


def parse_arguments() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="带家具户型中的扫地机器人覆盖路径规划与 3D 动画仿真"
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=Path(__file__).resolve().parent / "output",
        help="图片、路径和动画输出目录",
    )
    parser.add_argument(
        "--no-show", action="store_true", help="不打开 Matplotlib 交互窗口"
    )
    parser.add_argument(
        "--no-animation", action="store_true", help="只绘制静态 2D/3D 仿真结果"
    )
    parser.add_argument(
        "--save-gif",
        action="store_true",
        help="将 3D 运动过程保存为 robot_vacuum.gif(耗时较长)",
    )
    parser.add_argument(
        "--frames", type=int, default=600, help="动画采样帧数,默认 600"
    )
    parser.add_argument(
        "--interval", type=int, default=35, help="动画帧间隔(毫秒),默认 35"
    )
    return parser.parse_args()


def main() -> None:
    args = parse_arguments()
    if args.no_show:
        plt.switch_backend("Agg")

    apartment = Apartment()
    planner = CoveragePlanner(apartment)
    result = planner.plan()

    args.output_dir.mkdir(parents=True, exist_ok=True)
    image_file = args.output_dir / "vacuum_simulation_overview.png"
    path_file = args.output_dir / "planned_path.csv"
    gif_file = args.output_dir / "robot_vacuum.gif" if args.save_gif else None
    export_path(path_file, apartment, result)

    should_animate = not args.no_animation and (not args.no_show or args.save_gif)
    figure, animation = render_simulation(
        apartment=apartment,
        result=result,
        output_file=image_file,
        animate=should_animate,
        frame_count=max(2, args.frames),
        interval_ms=max(1, args.interval),
        gif_file=gif_file,
    )

    print("路径规划完成")
    print("  算法:分区往复式覆盖(Boustrophedon)+ 8 邻域 A*")
    print(f"  可达清扫面积:{result.reachable_area_m2:.2f} m^2")
    print(f"  总路径长度:{result.path_length_m:.2f} m")
    print(f"  覆盖率:{result.coverage_percent:.2f}%")
    print(f"  路径节点数:{len(result.path)}")
    print(f"  窄区补扫目标数:{result.cleanup_targets}")
    print(f"  规划耗时:{result.planning_time_s:.3f} s")
    print(f"  仿真总览:{image_file.resolve()}")
    print(f"  路径坐标:{path_file.resolve()}")
    if gif_file is not None:
        print(f"  动画文件:{gif_file.resolve()}")

    if not args.no_show:
        # Keep a local reference so Matplotlib does not collect the animation.
        _animation_reference = animation
        plt.show()
        del _animation_reference
    else:
        plt.close(figure)


if __name__ == "__main__":
    main()

当前使用的算法

代码采用混合覆盖规划:

占据栅格地图:将房间、墙体和家具离散为 0.25 m 栅格,并按照"机器人半径 + 安全距离"膨胀障碍物。Apartment

可达区域搜索:从充电座进行洪泛搜索,只统计机器人真正能够到达的区域。reachable_from

Boustrophedon 往复式覆盖:在各房间生成交替方向的"弓字形"清扫路径。room_sweep_segments

8 邻域 A*:连接不同清扫条带、绕开家具,并规划返回充电座的路径。astar

遗漏区域补扫:检测尚未被机器人清扫半径覆盖的栅格,使用 A* 前往补扫。plan

因此整体算法是:

障碍膨胀占据栅格 + Boustrophedon 覆盖规划 + A* 路径连接

没有户型图怎么办

当前程序属于"已知地图下的全局规划"。没有户型图时,不能在开始清扫前保证整个真实房屋的覆盖率,需要改成:

SLAM 在线建图 + 前沿探索 + 在线覆盖规划

工作流程如下:

通过激光雷达、深度相机、里程计和碰撞传感器采集环境信息。

使用 SLAM 实时生成占据栅格地图并定位机器人。

使用 Frontier Exploration 搜索"已知自由区域和未知区域的边界",不断探索新房间。

对已建图区域执行往复式覆盖或 STC 覆盖规划。

地图发生变化时使用 A* 或 D* Lite 重新规划。

单独维护清扫覆盖栅格,记录机器人清扫刷实际扫过的区域。

当不存在可达探索边界、地图连续多轮不再变化,并且所有已知可达栅格均被覆盖时结束。

覆盖率计算为:

覆盖率 = 已清扫的可达自由栅格数 / 已发现的可达自由栅格总数

需要注意:未知地图下无法对尚未发现的隐藏房间给出绝对覆盖保证。只有在传感器能够发现所有入口、定位误差受控、环境基本静态且所有区域物理可达的条件下,才能近似保证完整覆盖。

相关推荐
BD_Marathon2 小时前
多轮对话聊天机器人
开发语言·机器人·c#
海盗12344 小时前
AI 新闻日报 2026-10-02:Claude Code 开放 Mods、13 自由度直驱灵巧手同日双发、代码评审成新瓶颈
人工智能·机器人·aigc
硅基手札7 小时前
ROS2 全面解析 - 从架构到源码
单片机·架构·机器人
yl45308 小时前
水下清淤机器人设备制造商哪家靠谱
大数据·人工智能·机器人
胡家伟++8 小时前
RL 让人形机器人学踢球:13 轮实验、三次仪器级 Bug,触球率从 0 到 100%
人工智能·机器人·机器人足球
海盗12349 小时前
微软技术日报 2026-10-02:微软首发流式转录模型,Win11 26H2 悄悄省内存
人工智能·microsoft·机器人·aigc
深蓝学院10 小时前
给机器人直接加「触觉」,真是“多此一举”。。。
机器人·具身智能
云栈开源日记10 小时前
机器人运动规划从A*到Minimum Snap四大算法全解析
算法·ai·机器人
强壮的CAT11 小时前
VINS-Fusion 移植 ROS2 Jazzy 踩坑(一):环境与编译
人工智能·算法·机器人·自动驾驶
Jason_zhao_MR19 小时前
米尔电子参展预告_中国工博会2026-外发版
嵌入式硬件·机器人·工业控制