Skip to content

Commit 303fe71

Browse files
feat: compose Q10 maps in a pure renderer
1 parent 01d0cc9 commit 303fe71

2 files changed

Lines changed: 543 additions & 0 deletions

File tree

roborock/map/b01_q10_render.py

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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

Comments
 (0)