Skip to content

Commit 4da996c

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

2 files changed

Lines changed: 563 additions & 0 deletions

File tree

roborock/map/b01_q10_render.py

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

0 commit comments

Comments
 (0)