Skip to content

Commit 347a8f6

Browse files
refactor: render Q10 overlays with V1 (#903)
1 parent 3154f89 commit 347a8f6

6 files changed

Lines changed: 253 additions & 137 deletions

File tree

roborock/map/b01_q10_map_parser.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from PIL import Image
2828
from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
2929
from vacuum_map_parser_base.config.image_config import ImageConfig
30-
from vacuum_map_parser_base.map_data import ImageData, MapData
30+
from vacuum_map_parser_base.map_data import ImageData, MapData, Point
3131

3232
from roborock.exceptions import RoborockException
3333

@@ -94,15 +94,18 @@ def classify_q10_cell(value: int) -> str:
9494
# 50 mm/px, so dividing by 10 yields the origin in grid pixels -- the (ox, oy)
9595
# that solve_calibration otherwise recovers by sliding the path.
9696
# - 15-16 resolution (u16be): reads 5 (= 0.05 m/px = 50 mm/px) universally.
97-
# - 17-18 charger x, 19-20 charger y (s16be, 5 mm units), 21-22 charger phi.
97+
# - 17-18 charger x, 19-20 charger y (s16be, absolute map decipixels),
98+
# 21-22 charger phi. The official app's device-point transform confirms these
99+
# coordinates are already in the map-array frame and must not receive x_min /
100+
# y_min a second time.
98101
_ORIGIN_X_OFFSET = 11
99102
_ORIGIN_Y_OFFSET = 13
100103
_HEADER_RESOLUTION_OFFSET = 15
101104
_CHARGER_X_OFFSET = 17
102105
_CHARGER_Y_OFFSET = 19
103106
_CHARGER_PHI_OFFSET = 21
104-
# The header origin/charger are in 5 mm units and the grid is 50 mm/px, so a
105-
# header coordinate maps to grid pixels by dividing by this.
107+
# The header origin and charger fields both divide by 10 to reach grid pixels,
108+
# although they use different coordinate frames as documented above.
106109
_HEADER_UNITS_PER_PIXEL = 10
107110

108111
# Grid cell values >= this are walls / borders rather than room segments.
@@ -130,7 +133,8 @@ class Q10EraseZone:
130133
131134
These are the app's *Erase* tool rectangles -- regions the user marked to be
132135
removed from the map (e.g. phantom floor the lidar mapped through windows).
133-
Coordinates are world units (millimetres), same frame as the path/zones.
136+
Coordinates are 5 mm world units, the same frame as restriction zones.
137+
Trace points use a separate 2.5 mm scale.
134138
135139
Confirmed by a controlled diff: removing the two erase zones on a live device
136140
dropped this section's count from 2 to 0 while the grid and the trailing
@@ -149,9 +153,11 @@ class Q10HeaderCalibration:
149153
straight from the map packet -- no cleaning path / fit required, so it works
150154
docked or pre-clean. See :meth:`origin_pixels`.
151155
152-
``origin_x`` / ``origin_y`` and the charger coordinates are in 5 mm units;
153-
``resolution`` is the raw header field (5 == 50 mm/px). ``charger_phi`` is the
154-
raw dock heading field. Reported and verified by @andrewlyeats (ss07).
156+
``origin_x`` / ``origin_y`` are in 5 mm units and define the world-coordinate
157+
origin. The charger coordinates are absolute decipixels in the map-array
158+
frame, so they are divided by 10 without applying that origin again.
159+
``resolution`` is the raw header field (5 == 50 mm/px). ``charger_phi`` is
160+
the raw dock heading field. Reported and verified by @andrewlyeats (ss07).
155161
"""
156162

157163
origin_x: int
@@ -177,6 +183,15 @@ def origin_pixels(self) -> tuple[float, float] | None:
177183
return None
178184
return (self.origin_x / _HEADER_UNITS_PER_PIXEL, self.origin_y / _HEADER_UNITS_PER_PIXEL)
179185

186+
def charger_pixels(self) -> tuple[float, float] | None:
187+
"""Return the saved dock in absolute map-array pixel coordinates."""
188+
if (self.charger_x == 0 and self.charger_y == 0) or self.charger_x == -1 or self.charger_y == -1:
189+
return None
190+
return (
191+
self.charger_x / _HEADER_UNITS_PER_PIXEL,
192+
self.charger_y / _HEADER_UNITS_PER_PIXEL,
193+
)
194+
180195

181196
@dataclass
182197
class Q10MapPacket:
@@ -640,7 +655,12 @@ def parsed_from_packet(self, packet: Q10MapPacket) -> ParsedMapData:
640655
width=packet.width,
641656
image_config=ImageConfig(scale=self._config.map_scale),
642657
data=image,
643-
img_transformation=lambda p: p,
658+
# ImageDimensions uses V1's bottom-up map convention before
659+
# projecting into the top-down PNG. Q10 points are already
660+
# top-down grid pixels, so this adapter cancels that standard flip
661+
# and lets Q10 use the shared V1 ImageGenerator without moving
662+
# overlays vertically.
663+
img_transformation=lambda p: Point(p.x, packet.height - p.y - 1, p.a),
644664
)
645665
room_names = {room.id: room.name for room in packet.rooms}
646666
if room_names:

roborock/map/b01_q10_overlays.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@
1919
zones (first wire word = x), confirmed against the app. Provenance and the
2020
byte-level breakdown are in PR #850's review thread.
2121
22-
Coordinates are in the device's world units (the same space as the cleaning
23-
path), so a :class:`~roborock.map.b01_grid_layers.GridCalibration` maps them to
24-
map pixels. ``type`` distinguishes the restriction kind (2 = no-mop, 3 = door
25-
threshold, anything else -- incl. 0 -- a no-go zone); it is preserved verbatim
26-
so callers can route polygons to the right ``MapData`` layer.
22+
Coordinates are in 5 mm device world units, the same space as erase polygons.
23+
Cleaning trace points use a separate 2.5 mm scale, so callers must not project
24+
both through the same resolution. ``type`` distinguishes the restriction kind
25+
(2 = no-mop, 3 = door threshold, anything else -- incl. 0 -- a no-go zone); it
26+
is preserved verbatim so callers can route polygons to the right ``MapData``
27+
layer.
2728
"""
2829

2930
import base64

roborock/map/b01_q10_render.py

Lines changed: 101 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,10 @@
1414
"""
1515

1616
import io
17-
import math
1817
from collections.abc import Sequence
1918
from dataclasses import dataclass
2019

21-
from PIL import Image, ImageDraw
20+
from vacuum_map_parser_base.config.drawable import Drawable
2221
from vacuum_map_parser_base.map_data import Area, MapData, Path, Point, Wall
2322

2423
from roborock.exceptions import RoborockException
@@ -38,6 +37,7 @@
3837
erased_packet,
3938
)
4039
from .b01_q10_overlays import ZONE_TYPE_NO_GO, ZONE_TYPE_NO_MOP, Q10Zone
40+
from .map_parser import DEFAULT_DRAWABLES, MapParserConfig, _create_image_generator
4141

4242
# Path-units-per-pixel candidates for calibration. A dense ss07 path lands a
4343
# best fit of 20.0 around the header origin -- ground-truthed June 2026 on the
@@ -49,6 +49,10 @@
4949
# reach 20 (it railed at the bound), biasing the fit. A dense cleaning path
5050
# selects the best fit within this bracket.
5151
_Q10_RESOLUTIONS = [step * 0.5 for step in range(24, 53)] # 12.0 .. 26.0
52+
# The header resolution is expressed in centimetres per grid pixel, while
53+
# erase/restriction vectors use 5 mm units: one header unit therefore equals
54+
# two vector units. Trace points use a separate 2.5 mm coordinate scale.
55+
_Q10_VECTOR_UNITS_PER_HEADER_RESOLUTION_UNIT = 2.0
5256
# A path needs enough shape to constrain a full (origin + resolution) fit; a few
5357
# points cannot.
5458
_MIN_CALIBRATION_POINTS = 20
@@ -57,6 +61,16 @@
5761
# one). See :func:`solve_calibration_with_origin`.
5862
_MIN_HEADER_CALIBRATION_POINTS = 4
5963

64+
_Q10_DRAWABLE_TYPES = {
65+
Drawable.CHARGER,
66+
Drawable.NO_GO_AREAS,
67+
Drawable.NO_MOPPING_AREAS,
68+
Drawable.PATH,
69+
Drawable.VACUUM_POSITION,
70+
Drawable.VIRTUAL_WALLS,
71+
}
72+
_Q10_DRAWABLES = [drawable for drawable in DEFAULT_DRAWABLES if drawable in _Q10_DRAWABLE_TYPES]
73+
6074

6175
@dataclass(frozen=True)
6276
class Q10MapOverlays:
@@ -75,18 +89,18 @@ def render_q10_map(
7589
) -> bytes:
7690
"""Compose the latest map, trace and DPS inputs into one PNG image.
7791
78-
Calibration is derived from ``packet`` (layers + header calibration) and
79-
``trace`` (path points). Once calibrated, erase zones are blanked out of the
80-
raster and trace/overlay data is projected and drawn in pixel space.
81-
Without a usable trace only the base raster is rendered. Raises
82-
:class:`RoborockException` if map rendering fails.
92+
Separate transforms are derived for 2.5 mm trace points and 5 mm
93+
erase/restriction vectors. Erase zones are blanked out of the raster, while
94+
available trace and DPS overlays are projected and drawn in pixel space.
95+
Raises :class:`RoborockException` if map rendering fails.
8396
"""
8497
parser = B01Q10MapParser(config)
85-
calibration = solve_q10_calibration(packet, trace)
98+
trace_calibration = solve_q10_calibration(packet, trace)
99+
vector_calibration = _vector_calibration(packet, trace_calibration)
86100

87101
render_packet = packet
88-
if calibration is not None:
89-
cells = _erased_cells(packet.layers, packet.erase_zones, calibration)
102+
if vector_calibration is not None:
103+
cells = _erased_cells(packet.layers, packet.erase_zones, vector_calibration)
90104
if cells:
91105
# Blank the erase-zone cells before parsing the raster so phantom
92106
# areas disappear (as the app shows).
@@ -97,10 +111,17 @@ def render_q10_map(
97111
raise RoborockException("Failed to render Q10 map image")
98112
map_data = parsed.map_data
99113

100-
if calibration is not None and trace is not None:
101-
_place_trace(map_data, calibration, trace)
102-
_place_overlays(map_data, calibration, overlays)
103-
return _draw_map_content(parsed.image_content, map_data, config=config)
114+
has_drawables = False
115+
if trace_calibration is not None and trace is not None:
116+
charger_heading = packet.header_calibration.charger_phi if packet.header_calibration is not None else None
117+
_place_trace(map_data, trace_calibration, trace, charger_heading=charger_heading)
118+
has_drawables = True
119+
has_drawables = _place_charger_from_header(map_data, packet) or has_drawables
120+
if vector_calibration is not None:
121+
_place_overlays(map_data, vector_calibration, overlays)
122+
has_drawables = has_drawables or bool(map_data.no_go_areas or map_data.no_mopping_areas or map_data.walls)
123+
if has_drawables:
124+
return _draw_map_content(map_data, config=config)
104125

105126
return parsed.image_content
106127

@@ -137,6 +158,41 @@ def _calibration_from_header(
137158
return solve_calibration_with_origin(packet.layers, points, origin, resolutions=_Q10_RESOLUTIONS)
138159

139160

161+
def _calibration_from_header_metadata(
162+
packet: Q10MapPacket,
163+
*,
164+
y_sign: int = 1,
165+
) -> GridCalibration | None:
166+
"""Build the vector transform directly from a usable map header."""
167+
header = packet.header_calibration
168+
if header is None or header.resolution <= 0 or (origin := header.origin_pixels()) is None:
169+
return None
170+
return GridCalibration(
171+
resolution=header.resolution * _Q10_VECTOR_UNITS_PER_HEADER_RESOLUTION_UNIT,
172+
origin_x=origin[0],
173+
origin_y=origin[1],
174+
y_sign=y_sign,
175+
)
176+
177+
178+
def _vector_calibration(
179+
packet: Q10MapPacket,
180+
trace_calibration: GridCalibration | None,
181+
) -> GridCalibration | None:
182+
"""Derive the 5 mm erase/restriction-vector transform."""
183+
y_sign = trace_calibration.y_sign if trace_calibration is not None else 1
184+
if calibration := _calibration_from_header_metadata(packet, y_sign=y_sign):
185+
return calibration
186+
if trace_calibration is None:
187+
return None
188+
return GridCalibration(
189+
resolution=trace_calibration.resolution / 2,
190+
origin_x=trace_calibration.origin_x,
191+
origin_y=trace_calibration.origin_y,
192+
y_sign=trace_calibration.y_sign,
193+
)
194+
195+
140196
def _calibration_from_fit(layers: GridLayers, points: list[tuple[float, float]]) -> GridCalibration | None:
141197
"""Full origin + resolution fit; needs a reasonably dense path."""
142198
if len(points) < _MIN_CALIBRATION_POINTS:
@@ -170,6 +226,8 @@ def _place_trace(
170226
map_data: MapData,
171227
calibration: GridCalibration,
172228
trace: Q10TracePacket,
229+
*,
230+
charger_heading: int | None = None,
173231
) -> None:
174232
"""Project trace path, position, heading and charger into pixel space.
175233
@@ -181,11 +239,27 @@ def _place_trace(
181239
robot_position = trace.robot_position
182240
if robot_position is not None:
183241
px, py = calibration.world_to_pixel(robot_position.x, robot_position.y)
184-
# Store the heading in projected image coordinates so drawing does not
185-
# need to retain the world-to-pixel calibration.
186-
map_data.vacuum_position = Point(px, py, -calibration.y_sign * trace.heading)
242+
# The shared V1 marker expects a map-space heading (+Y is up), not the
243+
# top-down PNG angle previously used by Q10's custom marker.
244+
map_data.vacuum_position = Point(px, py, calibration.y_sign * trace.heading)
187245
if pixels:
188-
map_data.charger = pixels[0]
246+
map_data.charger = Point(
247+
pixels[0].x,
248+
pixels[0].y,
249+
calibration.y_sign * charger_heading if charger_heading is not None else None,
250+
)
251+
252+
253+
def _place_charger_from_header(
254+
map_data: MapData,
255+
packet: Q10MapPacket,
256+
) -> bool:
257+
"""Place the saved dock using its absolute header pixel coordinates."""
258+
header = packet.header_calibration
259+
if header is None or (position := header.charger_pixels()) is None:
260+
return False
261+
map_data.charger = Point(*position, header.charger_phi)
262+
return True
189263

190264

191265
def _place_overlays(
@@ -217,69 +291,18 @@ def to_area(zone: Q10Zone) -> Area | None:
217291

218292

219293
def _draw_map_content(
220-
image_content: bytes,
221294
map_data: MapData,
222295
*,
223296
config: B01Q10MapParserConfig,
224-
line_color: tuple[int, int, int, int] = (235, 64, 52, 255),
225-
position_color: tuple[int, int, int, int] = (255, 211, 0, 255),
226297
) -> bytes:
227-
"""Draw projected map content onto a base PNG and return a fresh PNG."""
228-
scale = config.map_scale
229-
base = Image.open(io.BytesIO(image_content)).convert("RGBA")
230-
231-
def to_image(point: Point) -> tuple[float, float]:
232-
return (point.x * scale, point.y * scale)
233-
234-
draw = ImageDraw.Draw(base, "RGBA")
235-
236-
# Erase zones are applied to the raster itself (cells blanked), so they are
237-
# not drawn here -- the base image already reflects them.
238-
239-
# No-go (blue) and no-mop (magenta) zones beneath the path.
240-
for areas, fill, outline in (
241-
(map_data.no_go_areas or [], (0, 120, 255, 70), (0, 80, 200, 255)),
242-
(map_data.no_mopping_areas or [], (255, 0, 200, 70), (200, 0, 160, 255)),
243-
):
244-
for area in areas:
245-
polygon = [
246-
(area.x0 * scale, area.y0 * scale),
247-
(area.x1 * scale, area.y1 * scale),
248-
(area.x2 * scale, area.y2 * scale),
249-
(area.x3 * scale, area.y3 * scale),
250-
]
251-
draw.polygon(polygon, fill=fill, outline=outline)
252-
253-
# Virtual walls (line segments, not polygons) drawn over the zones.
254-
for wall in map_data.walls or []:
255-
draw.line(
256-
[(wall.x0 * scale, wall.y0 * scale), (wall.x1 * scale, wall.y1 * scale)],
257-
fill=(255, 64, 64, 255),
258-
width=max(2, scale),
259-
)
260-
261-
for path in map_data.path.path if map_data.path else []:
262-
if len(path) >= 2:
263-
draw.line([to_image(point) for point in path], fill=line_color, width=max(1, scale // 2))
264-
if map_data.charger is not None:
265-
dx, dy = to_image(map_data.charger)
266-
draw.ellipse([dx - scale, dy - scale, dx + scale, dy + scale], outline=(40, 200, 40, 255), width=2)
267-
robot_position = map_data.vacuum_position
268-
if robot_position is not None:
269-
cx, cy = to_image(robot_position)
270-
radius = scale
271-
draw.ellipse([cx - radius, cy - radius, cx + radius, cy + radius], fill=position_color)
272-
robot_heading = robot_position.a
273-
if robot_heading is not None:
274-
# Heading was projected into image coordinates alongside the robot
275-
# position, so no calibration state is needed during drawing.
276-
angle = math.radians(robot_heading)
277-
tick = 4 * radius
278-
draw.line(
279-
[cx, cy, cx + math.cos(angle) * tick, cy + math.sin(angle) * tick],
280-
fill=position_color,
281-
width=max(1, scale // 2),
282-
)
298+
"""Draw Q10 content with the shared V1 image generator."""
299+
if map_data.image is None:
300+
raise RoborockException("Failed to render Q10 map image")
301+
generator = _create_image_generator(
302+
MapParserConfig(map_scale=config.map_scale),
303+
drawables=_Q10_DRAWABLES,
304+
)
305+
generator.draw_map(map_data)
283306
buffer = io.BytesIO()
284-
base.save(buffer, format="PNG")
307+
map_data.image.data.save(buffer, format="PNG")
285308
return buffer.getvalue()

0 commit comments

Comments
 (0)