|
| 1 | +"""Compose a Q10 (B01/ss07) map into a single rendered result. |
| 2 | +
|
| 3 | +The :class:`~roborock.map.b01_q10_map_parser.B01Q10MapParser` turns wire bytes |
| 4 | +into a :class:`~roborock.map.b01_q10_map_parser.Q10MapPacket`; this module takes |
| 5 | +that packet plus the *other* inputs the device streams separately -- the cleaning |
| 6 | +path, the vector overlays (no-go / no-mop zones, virtual walls) and a solved |
| 7 | +world<->pixel calibration -- and composes them into one :class:`Q10MapRender` result object |
| 8 | +(image + ``MapData`` + layers). |
| 9 | +
|
| 10 | +It exists so the map trait stays about state management: the trait accumulates |
| 11 | +the pushed inputs and calls :func:`render_q10_map` once per change, holding the |
| 12 | +returned object rather than mutating a pile of derived fields itself. All the |
| 13 | +low-level pixel work (erase-zone blanking, world->pixel overlay placement, path |
| 14 | +drawing) and the calibration policy live here, next to the rest of the map code. |
| 15 | +""" |
| 16 | + |
| 17 | +import io |
| 18 | +import math |
| 19 | +from collections.abc import Sequence |
| 20 | +from dataclasses import dataclass |
| 21 | + |
| 22 | +from PIL import Image, ImageDraw |
| 23 | +from vacuum_map_parser_base.map_data import Area, MapData, Path, Point, Wall |
| 24 | + |
| 25 | +from roborock.exceptions import RoborockException |
| 26 | + |
| 27 | +from .b01_grid_layers import ( |
| 28 | + GridCalibration, |
| 29 | + GridLayers, |
| 30 | + solve_calibration, |
| 31 | + solve_calibration_with_origin, |
| 32 | +) |
| 33 | +from .b01_q10_map_parser import ( |
| 34 | + B01Q10MapParser, |
| 35 | + B01Q10MapParserConfig, |
| 36 | + Q10HeaderCalibration, |
| 37 | + Q10MapPacket, |
| 38 | + Q10Point, |
| 39 | + Q10Room, |
| 40 | + erased_packet, |
| 41 | +) |
| 42 | +from .b01_q10_overlays import ZONE_TYPE_NO_GO, ZONE_TYPE_NO_MOP, Q10Zone |
| 43 | + |
| 44 | +# Path-units-per-pixel candidates for calibration. A dense ss07 path lands a |
| 45 | +# best fit of 20.0 around the header origin -- ground-truthed June 2026 on the |
| 46 | +# R1: a corridor drive registered at 20 (matching the format author's |
| 47 | +# independent "20 path-units/px"), and the dock->corridor span lined up with the |
| 48 | +# ruler-measured 8.81 m corridor. With the header resolution=5 (50 mm/px grid) |
| 49 | +# that makes one path-unit exactly 50/20 = 2.5 mm -- so a path-unit is NOT a |
| 50 | +# millimetre (the open scale question). An earlier [10.0..18.0] range couldn't |
| 51 | +# reach 20 (it railed at the bound), biasing the fit. A dense cleaning path |
| 52 | +# selects the best fit within this bracket. |
| 53 | +_Q10_RESOLUTIONS = [step * 0.5 for step in range(24, 53)] # 12.0 .. 26.0 |
| 54 | +# A path needs enough shape to constrain a full (origin + resolution) fit; a few |
| 55 | +# points cannot. |
| 56 | +_MIN_CALIBRATION_POINTS = 20 |
| 57 | +# When the grid-frame header supplies the origin, only the resolution is fit, so |
| 58 | +# a much shorter path suffices to confirm it (early in a clean, not just a dense |
| 59 | +# one). See :func:`solve_calibration_with_origin`. |
| 60 | +_MIN_HEADER_CALIBRATION_POINTS = 4 |
| 61 | + |
| 62 | + |
| 63 | +@dataclass |
| 64 | +class Q10MapRender: |
| 65 | + """The fully composed result of rendering a Q10 map packet. |
| 66 | +
|
| 67 | + Built by :func:`render_q10_map` from the packet plus the current path, |
| 68 | + overlays and calibration, so every derived field is consistent with one set |
| 69 | + of inputs. Analogous to :class:`~roborock.map.map_parser.ParsedMapData`, but |
| 70 | + also carrying the separable :attr:`layers` and the :attr:`calibration` used |
| 71 | + to place the vector overlays. |
| 72 | + """ |
| 73 | + |
| 74 | + image_content: bytes |
| 75 | + """The rendered base map (PNG) with erase zones blanked, path not drawn.""" |
| 76 | + |
| 77 | + map_data: MapData |
| 78 | + """Parsed map data: image metadata, room names, and -- once a calibration is |
| 79 | + known -- the path / robot position / zones / walls placed in pixel space.""" |
| 80 | + |
| 81 | + layers: GridLayers |
| 82 | + """Separable map layers (background / wall / floor / per-room) in grid-pixel |
| 83 | + space, each renderable to a transparent PNG for frontend compositing.""" |
| 84 | + |
| 85 | + rooms: list[Q10Room] |
| 86 | + """Rooms (segments) reported by the device, with ids and names.""" |
| 87 | + |
| 88 | + calibration: GridCalibration | None |
| 89 | + """World<->pixel transform used to place the overlays, or ``None`` if no |
| 90 | + calibration was available (the overlays are then absent from ``map_data``).""" |
| 91 | + |
| 92 | + |
| 93 | +def render_q10_map( |
| 94 | + packet: Q10MapPacket, |
| 95 | + *, |
| 96 | + calibration: GridCalibration | None, |
| 97 | + path: Sequence[Q10Point], |
| 98 | + robot_position: Q10Point | None, |
| 99 | + zones: Sequence[Q10Zone], |
| 100 | + virtual_walls: Sequence[Q10Zone], |
| 101 | + config: B01Q10MapParserConfig, |
| 102 | +) -> Q10MapRender: |
| 103 | + """Compose a Q10 map packet and its overlays into a :class:`Q10MapRender`. |
| 104 | +
|
| 105 | + With a ``calibration`` the erase zones are blanked out of the raster and the |
| 106 | + path / robot position / restricted zones / virtual walls are placed onto |
| 107 | + ``map_data`` in pixel space; without one only the base raster is rendered |
| 108 | + (the overlays are world-coordinate only and can't be placed yet). Raises |
| 109 | + :class:`RoborockException` if the packet fails to render. |
| 110 | + """ |
| 111 | + parser = B01Q10MapParser(config) |
| 112 | + layers = packet.layers |
| 113 | + |
| 114 | + render_packet = packet |
| 115 | + if calibration is not None: |
| 116 | + cells = _erased_cells(layers, packet.erase_zones, calibration) |
| 117 | + if cells: |
| 118 | + # Blank the erase-zone cells and re-derive the raster/layers from the |
| 119 | + # modified packet so the phantom areas disappear (as the app shows). |
| 120 | + render_packet = erased_packet(packet, cells) |
| 121 | + layers = render_packet.layers |
| 122 | + |
| 123 | + parsed = parser.parsed_from_packet(render_packet) |
| 124 | + if parsed.image_content is None or parsed.map_data is None: |
| 125 | + raise RoborockException("Failed to render Q10 map image") |
| 126 | + map_data = parsed.map_data |
| 127 | + |
| 128 | + if calibration is not None: |
| 129 | + _place_path(map_data, calibration, path, robot_position) |
| 130 | + _place_zones(map_data, calibration, path, zones, virtual_walls) |
| 131 | + |
| 132 | + return Q10MapRender( |
| 133 | + image_content=parsed.image_content, |
| 134 | + map_data=map_data, |
| 135 | + layers=layers, |
| 136 | + rooms=packet.rooms, |
| 137 | + calibration=calibration, |
| 138 | + ) |
| 139 | + |
| 140 | + |
| 141 | +def solve_q10_calibration( |
| 142 | + layers: GridLayers, |
| 143 | + header_calibration: Q10HeaderCalibration | None, |
| 144 | + path: Sequence[Q10Point], |
| 145 | +) -> GridCalibration | None: |
| 146 | + """Fit the world<->pixel calibration from the current cleaning path. |
| 147 | +
|
| 148 | + When the map packet's grid-frame header carries a calibration origin (ss07), |
| 149 | + only the resolution is fit -- around that fixed origin -- so a short path |
| 150 | + suffices and the origin is exact rather than recovered by a slide. Otherwise |
| 151 | + the full origin + resolution fit is used, which needs a reasonably dense |
| 152 | + cleaning path. Returns ``None`` if the path is too short/featureless to fit. |
| 153 | + """ |
| 154 | + points: list[tuple[float, float]] = [(point.x, point.y) for point in path] |
| 155 | + return _calibration_from_header(layers, header_calibration, points) or _calibration_from_fit(layers, points) |
| 156 | + |
| 157 | + |
| 158 | +def _calibration_from_header( |
| 159 | + layers: GridLayers, |
| 160 | + header_calibration: Q10HeaderCalibration | None, |
| 161 | + points: list[tuple[float, float]], |
| 162 | +) -> GridCalibration | None: |
| 163 | + """Calibrate around the header-supplied origin (resolution fit to a path).""" |
| 164 | + if header_calibration is None or len(points) < _MIN_HEADER_CALIBRATION_POINTS: |
| 165 | + return None |
| 166 | + origin = header_calibration.origin_pixels() |
| 167 | + if origin is None: # keepalive frame -- no usable origin |
| 168 | + return None |
| 169 | + return solve_calibration_with_origin(layers, points, origin, resolutions=_Q10_RESOLUTIONS) |
| 170 | + |
| 171 | + |
| 172 | +def _calibration_from_fit(layers: GridLayers, points: list[tuple[float, float]]) -> GridCalibration | None: |
| 173 | + """Full origin + resolution fit; needs a reasonably dense path.""" |
| 174 | + if len(points) < _MIN_CALIBRATION_POINTS: |
| 175 | + return None |
| 176 | + return solve_calibration(layers, points, resolutions=_Q10_RESOLUTIONS) |
| 177 | + |
| 178 | + |
| 179 | +def _erased_cells(layers: GridLayers, erase_zones: Sequence, calibration: GridCalibration) -> set[int]: |
| 180 | + """Grid-cell indices covered by the erase zones (axis-aligned bbox fill).""" |
| 181 | + if not erase_zones: |
| 182 | + return set() |
| 183 | + width, height = layers.width, layers.height |
| 184 | + cells: set[int] = set() |
| 185 | + for zone in erase_zones: |
| 186 | + pixels = [calibration.world_to_pixel(x, y) for x, y in zone.vertices] |
| 187 | + xs = [p[0] for p in pixels] |
| 188 | + ys = [p[1] for p in pixels] |
| 189 | + x0, x1 = int(min(xs)), int(max(xs)) |
| 190 | + y0, y1 = int(min(ys)), int(max(ys)) |
| 191 | + for py in range(max(0, y0), min(height, y1 + 1)): |
| 192 | + for px in range(max(0, x0), min(width, x1 + 1)): |
| 193 | + cells.add(py * width + px) |
| 194 | + return cells |
| 195 | + |
| 196 | + |
| 197 | +def _place_path( |
| 198 | + map_data: MapData, |
| 199 | + calibration: GridCalibration, |
| 200 | + path: Sequence[Q10Point], |
| 201 | + robot_position: Q10Point | None, |
| 202 | +) -> None: |
| 203 | + """Fill ``MapData.path`` / ``vacuum_position`` in grid-pixel coords. |
| 204 | +
|
| 205 | + Points are stored in grid-pixel space (origin top-left), matching the Q10's |
| 206 | + top-down, un-flipped raster so they line up with the rendered image. |
| 207 | + """ |
| 208 | + pixels = [Point(*calibration.world_to_pixel(point.x, point.y)) for point in path] |
| 209 | + map_data.path = Path(len(pixels), 1, 0, [pixels]) |
| 210 | + if robot_position is not None: |
| 211 | + px, py = calibration.world_to_pixel(robot_position.x, robot_position.y) |
| 212 | + map_data.vacuum_position = Point(px, py) |
| 213 | + |
| 214 | + |
| 215 | +def _place_zones( |
| 216 | + map_data: MapData, |
| 217 | + calibration: GridCalibration, |
| 218 | + path: Sequence[Q10Point], |
| 219 | + zones: Sequence[Q10Zone], |
| 220 | + virtual_walls: Sequence[Q10Zone], |
| 221 | +) -> None: |
| 222 | + """Convert world-coordinate zones/walls into pixel-space ``MapData`` layers.""" |
| 223 | + |
| 224 | + def to_area(zone: Q10Zone) -> Area | None: |
| 225 | + if len(zone.vertices) != 4: |
| 226 | + return None # MapData.Area is a quad |
| 227 | + pts = [calibration.world_to_pixel(x, y) for x, y in zone.vertices] |
| 228 | + return Area(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1], pts[3][0], pts[3][1]) |
| 229 | + |
| 230 | + no_go = [area for zone in zones if zone.type == ZONE_TYPE_NO_GO and (area := to_area(zone))] |
| 231 | + no_mop = [area for zone in zones if zone.type == ZONE_TYPE_NO_MOP and (area := to_area(zone))] |
| 232 | + map_data.no_go_areas = no_go or None |
| 233 | + map_data.no_mopping_areas = no_mop or None |
| 234 | + |
| 235 | + walls: list[Wall] = [] |
| 236 | + for zone in virtual_walls: |
| 237 | + if len(zone.vertices) >= 2: |
| 238 | + (x0, y0), (x1, y1) = zone.vertices[0], zone.vertices[1] |
| 239 | + p0 = calibration.world_to_pixel(x0, y0) |
| 240 | + p1 = calibration.world_to_pixel(x1, y1) |
| 241 | + walls.append(Wall(p0[0], p0[1], p1[0], p1[1])) |
| 242 | + map_data.walls = walls or None |
| 243 | + |
| 244 | + # The robot starts a session at its dock, so the path origin is the charger. |
| 245 | + if path: |
| 246 | + cx, cy = calibration.world_to_pixel(path[0].x, path[0].y) |
| 247 | + map_data.charger = Point(cx, cy) |
| 248 | + |
| 249 | + |
| 250 | +def draw_path_on_map( |
| 251 | + render: Q10MapRender, |
| 252 | + *, |
| 253 | + config: B01Q10MapParserConfig, |
| 254 | + path: Sequence[Q10Point], |
| 255 | + robot_position: Q10Point | None, |
| 256 | + robot_heading: int | None, |
| 257 | + zones: Sequence[Q10Zone], |
| 258 | + virtual_walls: Sequence[Q10Zone], |
| 259 | + line_color: tuple[int, int, int, int] = (235, 64, 52, 255), |
| 260 | + position_color: tuple[int, int, int, int] = (255, 211, 0, 255), |
| 261 | +) -> bytes: |
| 262 | + """Draw the session path + robot position + overlays onto the base map (PNG). |
| 263 | +
|
| 264 | + ``render`` must carry a calibration (its :attr:`Q10MapRender.calibration`) -- |
| 265 | + the caller is responsible for solving one first. Returns a fresh PNG; the |
| 266 | + ``render.image_content`` base raster is left untouched. |
| 267 | + """ |
| 268 | + calibration = render.calibration |
| 269 | + if calibration is None: |
| 270 | + raise RoborockException("No calibration available; a cleaning path must be captured during a clean") |
| 271 | + |
| 272 | + scale = config.map_scale |
| 273 | + base = Image.open(io.BytesIO(render.image_content)).convert("RGBA") |
| 274 | + |
| 275 | + def world_to_image(x: float, y: float) -> tuple[float, float]: |
| 276 | + px, py = calibration.world_to_pixel(x, y) |
| 277 | + # The ss07 grid renders top-down (no flip), so grid-pixel (px, py) maps |
| 278 | + # straight to image space, only upscaled by ``scale``. |
| 279 | + return (px * scale, py * scale) |
| 280 | + |
| 281 | + def to_image(point: Q10Point) -> tuple[float, float]: |
| 282 | + return world_to_image(point.x, point.y) |
| 283 | + |
| 284 | + draw = ImageDraw.Draw(base, "RGBA") |
| 285 | + |
| 286 | + # Erase zones are applied to the raster itself (cells blanked), so they are |
| 287 | + # not drawn here -- the base image already reflects them. |
| 288 | + |
| 289 | + # No-go (blue) and no-mop (magenta) zones beneath the path. |
| 290 | + for zone in zones: |
| 291 | + if len(zone.vertices) < 3: |
| 292 | + continue |
| 293 | + polygon = [world_to_image(x, y) for x, y in zone.vertices] |
| 294 | + fill = (0, 120, 255, 70) if zone.type == ZONE_TYPE_NO_GO else (255, 0, 200, 70) |
| 295 | + outline = (0, 80, 200, 255) if zone.type == ZONE_TYPE_NO_GO else (200, 0, 160, 255) |
| 296 | + draw.polygon(polygon, fill=fill, outline=outline) |
| 297 | + |
| 298 | + # Virtual walls (line segments, not polygons) drawn over the zones. |
| 299 | + for wall in virtual_walls: |
| 300 | + if len(wall.vertices) < 2: |
| 301 | + continue |
| 302 | + draw.line( |
| 303 | + [world_to_image(x, y) for x, y in wall.vertices[:2]], |
| 304 | + fill=(255, 64, 64, 255), |
| 305 | + width=max(2, scale), |
| 306 | + ) |
| 307 | + |
| 308 | + if len(path) >= 2: |
| 309 | + draw.line([to_image(point) for point in path], fill=line_color, width=max(1, scale // 2)) |
| 310 | + if path: # path origin == dock / charger |
| 311 | + dx, dy = to_image(path[0]) |
| 312 | + draw.ellipse([dx - scale, dy - scale, dx + scale, dy + scale], outline=(40, 200, 40, 255), width=2) |
| 313 | + if robot_position is not None: |
| 314 | + cx, cy = to_image(robot_position) |
| 315 | + radius = scale |
| 316 | + draw.ellipse([cx - radius, cy - radius, cx + radius, cy + radius], fill=position_color) |
| 317 | + if robot_heading is not None: |
| 318 | + # Heading is world-space degrees (0 = +x, +90 = +y). Map a unit |
| 319 | + # world-space facing vector through the same transform (so the |
| 320 | + # Y-flip/scale match the marker), then normalize to a fixed |
| 321 | + # pixel-length tick so it reads at any calibration resolution. |
| 322 | + angle = math.radians(robot_heading) |
| 323 | + hx, hy = world_to_image( |
| 324 | + robot_position.x + math.cos(angle), |
| 325 | + robot_position.y + math.sin(angle), |
| 326 | + ) |
| 327 | + norm = math.hypot(hx - cx, hy - cy) |
| 328 | + if norm > 0: |
| 329 | + tick = 4 * radius |
| 330 | + draw.line( |
| 331 | + [cx, cy, cx + (hx - cx) / norm * tick, cy + (hy - cy) / norm * tick], |
| 332 | + fill=position_color, |
| 333 | + width=max(1, scale // 2), |
| 334 | + ) |
| 335 | + buffer = io.BytesIO() |
| 336 | + base.save(buffer, format="PNG") |
| 337 | + return buffer.getvalue() |
0 commit comments