Skip to content

Commit af1531e

Browse files
refactor: render Q10 maps through one image path
1 parent 7f41f49 commit af1531e

2 files changed

Lines changed: 48 additions & 110 deletions

File tree

roborock/map/b01_q10_render.py

Lines changed: 18 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
99
It exists so the map trait stays about state management: the trait accumulates
1010
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
11+
returned image rather than mutating a pile of derived fields itself. All the
1212
low-level pixel work (erase-zone blanking, world->pixel overlay placement, path
1313
drawing) and the calibration policy live here, next to the rest of the map code.
1414
"""
@@ -66,36 +66,18 @@ class Q10MapOverlays:
6666
virtual_walls: Sequence[Q10Zone] = ()
6767

6868

69-
@dataclass
70-
class Q10MapRender:
71-
"""The image and projected geometry produced by Q10 map composition.
72-
73-
Built by :func:`render_q10_map` from one map packet, trace packet and DPS
74-
overlay snapshot. Source data and intermediate calibration/layer state stay
75-
on their owning packet or inside the renderer rather than being repeated on
76-
this result.
77-
"""
78-
79-
image_content: bytes
80-
"""The rendered base map (PNG) with erase zones blanked, path not drawn."""
81-
82-
map_data: MapData
83-
"""Parsed map data: image metadata, room names, and -- once a calibration is
84-
known -- the path / robot position / zones / walls placed in pixel space."""
85-
86-
8769
def render_q10_map(
8870
packet: Q10MapPacket,
8971
trace: Q10TracePacket | None,
9072
overlays: Q10MapOverlays,
9173
*,
9274
config: B01Q10MapParserConfig,
93-
) -> Q10MapRender:
94-
"""Compose the latest map, trace and DPS inputs into a render.
75+
) -> bytes:
76+
"""Compose the latest map, trace and DPS inputs into one PNG image.
9577
9678
Calibration is derived from ``packet`` (layers + header calibration) and
9779
``trace`` (path points). Once calibrated, erase zones are blanked out of the
98-
raster and trace/overlay data is projected into ``map_data`` pixel space.
80+
raster and trace/overlay data is projected and drawn in pixel space.
9981
Without a usable trace only the base raster is rendered. Raises
10082
:class:`RoborockException` if map rendering fails.
10183
"""
@@ -118,8 +100,9 @@ def render_q10_map(
118100
if calibration is not None and trace is not None:
119101
_place_trace(map_data, calibration, trace)
120102
_place_overlays(map_data, calibration, overlays)
103+
return _draw_map_content(parsed.image_content, map_data, config=config)
121104

122-
return Q10MapRender(image_content=parsed.image_content, map_data=map_data)
105+
return parsed.image_content
123106

124107

125108
def solve_q10_calibration(
@@ -233,23 +216,17 @@ def to_area(zone: Q10Zone) -> Area | None:
233216
map_data.walls = walls or None
234217

235218

236-
def draw_path_on_map(
237-
render: Q10MapRender,
219+
def _draw_map_content(
220+
image_content: bytes,
221+
map_data: MapData,
238222
*,
239223
config: B01Q10MapParserConfig,
240224
line_color: tuple[int, int, int, int] = (235, 64, 52, 255),
241225
position_color: tuple[int, int, int, int] = (255, 211, 0, 255),
242226
) -> bytes:
243-
"""Draw the projected ``MapData`` content onto the base map PNG.
244-
245-
Returns a fresh PNG; the base raster in
246-
:attr:`Q10MapRender.image_content` is left untouched.
247-
"""
248-
if render.map_data.path is None:
249-
raise RoborockException("No calibration available; a cleaning path must be captured during a clean")
250-
227+
"""Draw projected map content onto a base PNG and return a fresh PNG."""
251228
scale = config.map_scale
252-
base = Image.open(io.BytesIO(render.image_content)).convert("RGBA")
229+
base = Image.open(io.BytesIO(image_content)).convert("RGBA")
253230

254231
def to_image(point: Point) -> tuple[float, float]:
255232
return (point.x * scale, point.y * scale)
@@ -261,8 +238,8 @@ def to_image(point: Point) -> tuple[float, float]:
261238

262239
# No-go (blue) and no-mop (magenta) zones beneath the path.
263240
for areas, fill, outline in (
264-
(render.map_data.no_go_areas or [], (0, 120, 255, 70), (0, 80, 200, 255)),
265-
(render.map_data.no_mopping_areas or [], (255, 0, 200, 70), (200, 0, 160, 255)),
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)),
266243
):
267244
for area in areas:
268245
polygon = [
@@ -274,20 +251,20 @@ def to_image(point: Point) -> tuple[float, float]:
274251
draw.polygon(polygon, fill=fill, outline=outline)
275252

276253
# Virtual walls (line segments, not polygons) drawn over the zones.
277-
for wall in render.map_data.walls or []:
254+
for wall in map_data.walls or []:
278255
draw.line(
279256
[(wall.x0 * scale, wall.y0 * scale), (wall.x1 * scale, wall.y1 * scale)],
280257
fill=(255, 64, 64, 255),
281258
width=max(2, scale),
282259
)
283260

284-
for path in render.map_data.path.path if render.map_data.path else []:
261+
for path in map_data.path.path if map_data.path else []:
285262
if len(path) >= 2:
286263
draw.line([to_image(point) for point in path], fill=line_color, width=max(1, scale // 2))
287-
if render.map_data.charger is not None:
288-
dx, dy = to_image(render.map_data.charger)
264+
if map_data.charger is not None:
265+
dx, dy = to_image(map_data.charger)
289266
draw.ellipse([dx - scale, dy - scale, dx + scale, dy + scale], outline=(40, 200, 40, 255), width=2)
290-
robot_position = render.map_data.vacuum_position
267+
robot_position = map_data.vacuum_position
291268
if robot_position is not None:
292269
cx, cy = to_image(robot_position)
293270
radius = scale

tests/map/test_b01_q10_render.py

Lines changed: 30 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,8 @@
99
from dataclasses import replace
1010
from pathlib import Path
1111

12-
import pytest
1312
from PIL import Image
1413

15-
from roborock.exceptions import RoborockException
1614
from roborock.map.b01_grid_layers import GridCalibration
1715
from roborock.map.b01_q10_map_parser import (
1816
B01Q10MapParserConfig,
@@ -32,9 +30,7 @@
3230
from roborock.map.b01_q10_render import (
3331
_Q10_RESOLUTIONS,
3432
Q10MapOverlays,
35-
Q10MapRender,
3633
_erased_cells,
37-
draw_path_on_map,
3834
render_q10_map,
3935
solve_q10_calibration,
4036
)
@@ -58,7 +54,7 @@ def _render(
5854
*,
5955
trace: Q10TracePacket | None = None,
6056
overlays: Q10MapOverlays | None = None,
61-
) -> Q10MapRender:
57+
) -> bytes:
6258
return render_q10_map(
6359
packet if packet is not None else _packet(),
6460
trace,
@@ -96,44 +92,35 @@ def _calibrated_inputs(*, heading: int = 0) -> tuple[Q10MapPacket, Q10TracePacke
9692

9793
def test_render_base_map_without_calibration() -> None:
9894
"""Without a calibration only the base raster is produced."""
99-
render = _render()
100-
assert render.image_content[:8] == b"\x89PNG\r\n\x1a\n"
101-
assert render.map_data is not None
102-
# Overlays are world-coordinate only, so nothing is placed yet.
103-
assert render.map_data.path is None
95+
image = _render()
96+
assert image[:8] == b"\x89PNG\r\n\x1a\n"
10497

10598

106-
def test_render_places_path_and_position() -> None:
107-
"""The map and trace derive calibration, path and position together."""
108-
packet, trace = _calibrated_inputs(heading=45)
109-
render = _render(packet, trace=trace)
99+
def test_render_draws_path_and_position() -> None:
100+
"""The map and trace derive calibration and draw the robot position."""
101+
packet, trace = _calibrated_inputs()
102+
image = _render(packet, trace=trace)
110103
calibration = solve_q10_calibration(packet, trace)
111104
assert calibration is not None
112-
assert render.map_data.path is not None
113-
assert render.map_data.vacuum_position is not None
114105
assert trace.robot_position is not None
115-
expected = calibration.world_to_pixel(trace.robot_position.x, trace.robot_position.y)
116-
assert (render.map_data.vacuum_position.x, render.map_data.vacuum_position.y) == expected
117-
assert render.map_data.vacuum_position.a == -45
106+
px, py = calibration.world_to_pixel(trace.robot_position.x, trace.robot_position.y)
107+
image_position = (round(px * CONFIG.map_scale), round(py * CONFIG.map_scale))
108+
rendered = Image.open(io.BytesIO(image)).convert("RGBA")
109+
assert rendered.size == (8 * 4, 6 * 4)
110+
assert rendered.getpixel(image_position) == (255, 211, 0, 255)
118111

119112

120-
def test_render_places_zones_and_charger() -> None:
121-
"""Decoded no-go / no-mop zones become pixel-space MapData areas + charger."""
113+
def test_render_draws_zones_and_virtual_walls() -> None:
114+
"""Decoded DPS overlays are included in the composed image."""
122115
packet, trace = _calibrated_inputs()
123116
zones = [
124117
Q10Zone(type=ZONE_TYPE_NO_GO, vertices=[(0, 0), (4, 0), (4, 4), (0, 4)]),
125118
Q10Zone(type=ZONE_TYPE_NO_MOP, vertices=[(1, 1), (2, 1), (2, 2), (1, 2)]),
126119
]
127120
walls = [Q10Zone(type=ZONE_TYPE_VIRTUAL_WALL, vertices=[(0, 0), (4, 0)])]
128-
render = _render(packet, trace=trace, overlays=Q10MapOverlays(zones=zones, virtual_walls=walls))
129-
assert len(render.map_data.no_go_areas or []) == 1
130-
assert len(render.map_data.no_mopping_areas or []) == 1
131-
assert len(render.map_data.walls or []) == 1
132-
calibration = solve_q10_calibration(packet, trace)
133-
assert calibration is not None
134-
assert render.map_data.charger is not None
135-
expected = calibration.world_to_pixel(trace.points[0].x, trace.points[0].y)
136-
assert (render.map_data.charger.x, render.map_data.charger.y) == expected
121+
base = _render(packet, trace=trace)
122+
rendered = _render(packet, trace=trace, overlays=Q10MapOverlays(zones=zones, virtual_walls=walls))
123+
assert rendered != base
137124

138125

139126
def test_render_applies_erase_zones() -> None:
@@ -150,7 +137,7 @@ def test_render_applies_erase_zones() -> None:
150137
render = _render(replace(packet, erase_zones=[erase_zone]), trace=trace)
151138

152139
assert len(cells) == packet.layers.width * packet.layers.height
153-
assert render.image_content != base.image_content # re-rendered
140+
assert render != base
154141

155142

156143
def test_render_partial_erase() -> None:
@@ -167,55 +154,29 @@ def test_render_partial_erase() -> None:
167154
render = _render(replace(packet, erase_zones=[erase_zone]), trace=trace)
168155

169156
assert 0 < len(cells) < packet.layers.width * packet.layers.height
170-
assert render.image_content != base.image_content
171-
172-
173-
def test_draw_path_on_map_requires_projected_path() -> None:
174-
"""Drawing fails clearly when the source streams could not calibrate."""
175-
with pytest.raises(RoborockException, match="No calibration available"):
176-
draw_path_on_map(_render(), config=CONFIG)
177-
178-
179-
def test_draw_path_on_map_draws_position() -> None:
180-
"""The robot position is drawn at the mapped pixel."""
181-
packet, trace = _calibrated_inputs()
182-
render = _render(packet, trace=trace)
183-
png = draw_path_on_map(
184-
render,
185-
config=CONFIG,
186-
position_color=(255, 211, 0, 255),
187-
)
188-
img = Image.open(io.BytesIO(png)).convert("RGBA")
189-
position = render.map_data.vacuum_position
190-
assert position is not None
191-
image_position = (round(position.x * CONFIG.map_scale), round(position.y * CONFIG.map_scale))
192-
assert img.size == (8 * 4, 6 * 4)
193-
assert img.getpixel(image_position) == (255, 211, 0, 255)
157+
assert render != base
194158

195159

196-
def test_draw_path_on_map_draws_heading_indicator() -> None:
160+
def test_render_draws_heading_indicator() -> None:
197161
"""A known heading draws a facing tick from the robot marker.
198162
199163
With heading 0 (= +x world) and the identity-ish calibration, the tick
200164
extends to the right of the robot pixel; with the marker at image (12, 12)
201165
the tick covers pixels at x > 12 along y == 12.
202166
"""
203167
packet, trace = _calibrated_inputs(heading=0)
204-
render = _render(packet, trace=trace)
205-
png = draw_path_on_map(
206-
render,
207-
config=CONFIG,
208-
position_color=(255, 211, 0, 255),
209-
)
210-
img = Image.open(io.BytesIO(png)).convert("RGBA")
211-
position = render.map_data.vacuum_position
212-
assert position is not None
213-
cx = round(position.x * CONFIG.map_scale)
214-
cy = round(position.y * CONFIG.map_scale)
168+
image = _render(packet, trace=trace)
169+
calibration = solve_q10_calibration(packet, trace)
170+
assert calibration is not None
171+
assert trace.robot_position is not None
172+
px, py = calibration.world_to_pixel(trace.robot_position.x, trace.robot_position.y)
173+
cx = round(px * CONFIG.map_scale)
174+
cy = round(py * CONFIG.map_scale)
175+
rendered = Image.open(io.BytesIO(image)).convert("RGBA")
215176
# Tick runs +x from the marker (4 * radius = 16 px at scale 4).
216-
assert img.getpixel((cx + 8, cy)) == (255, 211, 0, 255)
177+
assert rendered.getpixel((cx + 8, cy)) == (255, 211, 0, 255)
217178
# ...and not behind it (the marker is a small disc; sample well to the left)
218-
assert img.getpixel((cx - 8, cy)) != (255, 211, 0, 255)
179+
assert rendered.getpixel((cx - 8, cy)) != (255, 211, 0, 255)
219180

220181

221182
def test_solve_q10_calibration_uses_header_origin_with_short_path() -> None:

0 commit comments

Comments
 (0)