Skip to content

Commit 7f41f49

Browse files
refactor: keep Q10 render intermediates internal
1 parent 4da996c commit 7f41f49

2 files changed

Lines changed: 51 additions & 68 deletions

File tree

roborock/map/b01_q10_render.py

Lines changed: 22 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
B01Q10MapParserConfig,
3535
Q10EraseZone,
3636
Q10MapPacket,
37-
Q10Room,
3837
Q10TracePacket,
3938
erased_packet,
4039
)
@@ -69,13 +68,12 @@ class Q10MapOverlays:
6968

7069
@dataclass
7170
class Q10MapRender:
72-
"""The fully composed result of rendering a Q10 map packet.
71+
"""The image and projected geometry produced by Q10 map composition.
7372
7473
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`.
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.
7977
"""
8078

8179
image_content: bytes
@@ -85,17 +83,6 @@ class Q10MapRender:
8583
"""Parsed map data: image metadata, room names, and -- once a calibration is
8684
known -- the path / robot position / zones / walls placed in pixel space."""
8785

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-
9986

10087
def render_q10_map(
10188
packet: Q10MapPacket,
@@ -113,17 +100,15 @@ def render_q10_map(
113100
:class:`RoborockException` if map rendering fails.
114101
"""
115102
parser = B01Q10MapParser(config)
116-
layers = packet.layers
117103
calibration = solve_q10_calibration(packet, trace)
118104

119105
render_packet = packet
120106
if calibration is not None:
121-
cells = _erased_cells(layers, packet.erase_zones, calibration)
107+
cells = _erased_cells(packet.layers, packet.erase_zones, calibration)
122108
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).
109+
# Blank the erase-zone cells before parsing the raster so phantom
110+
# areas disappear (as the app shows).
125111
render_packet = erased_packet(packet, cells)
126-
layers = render_packet.layers
127112

128113
parsed = parser.parsed_from_packet(render_packet)
129114
if parsed.image_content is None or parsed.map_data is None:
@@ -134,13 +119,7 @@ def render_q10_map(
134119
_place_trace(map_data, calibration, trace)
135120
_place_overlays(map_data, calibration, overlays)
136121

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-
)
122+
return Q10MapRender(image_content=parsed.image_content, map_data=map_data)
144123

145124

146125
def solve_q10_calibration(
@@ -219,7 +198,9 @@ def _place_trace(
219198
robot_position = trace.robot_position
220199
if robot_position is not None:
221200
px, py = calibration.world_to_pixel(robot_position.x, robot_position.y)
222-
map_data.vacuum_position = Point(px, py, trace.heading)
201+
# Store the heading in projected image coordinates so drawing does not
202+
# need to retain the world-to-pixel calibration.
203+
map_data.vacuum_position = Point(px, py, -calibration.y_sign * trace.heading)
223204
if pixels:
224205
map_data.charger = pixels[0]
225206

@@ -261,11 +242,10 @@ def draw_path_on_map(
261242
) -> bytes:
262243
"""Draw the projected ``MapData`` content onto the base map PNG.
263244
264-
``render`` must carry its derived calibration. Returns a fresh PNG; the base
265-
raster in :attr:`Q10MapRender.image_content` is left untouched.
245+
Returns a fresh PNG; the base raster in
246+
:attr:`Q10MapRender.image_content` is left untouched.
266247
"""
267-
calibration = render.calibration
268-
if calibration is None:
248+
if render.map_data.path is None:
269249
raise RoborockException("No calibration available; a cleaning path must be captured during a clean")
270250

271251
scale = config.map_scale
@@ -314,21 +294,15 @@ def to_image(point: Point) -> tuple[float, float]:
314294
draw.ellipse([cx - radius, cy - radius, cx + radius, cy + radius], fill=position_color)
315295
robot_heading = robot_position.a
316296
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.
297+
# Heading was projected into image coordinates alongside the robot
298+
# position, so no calibration state is needed during drawing.
321299
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-
)
300+
tick = 4 * radius
301+
draw.line(
302+
[cx, cy, cx + math.cos(angle) * tick, cy + math.sin(angle) * tick],
303+
fill=position_color,
304+
width=max(1, scale // 2),
305+
)
332306
buffer = io.BytesIO()
333307
base.save(buffer, format="PNG")
334308
return buffer.getvalue()

tests/map/test_b01_q10_render.py

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

12+
import pytest
1213
from PIL import Image
1314

15+
from roborock.exceptions import RoborockException
1416
from roborock.map.b01_grid_layers import GridCalibration
1517
from roborock.map.b01_q10_map_parser import (
1618
B01Q10MapParserConfig,
@@ -31,6 +33,7 @@
3133
_Q10_RESOLUTIONS,
3234
Q10MapOverlays,
3335
Q10MapRender,
36+
_erased_cells,
3437
draw_path_on_map,
3538
render_q10_map,
3639
solve_q10_calibration,
@@ -92,13 +95,10 @@ def _calibrated_inputs(*, heading: int = 0) -> tuple[Q10MapPacket, Q10TracePacke
9295

9396

9497
def test_render_base_map_without_calibration() -> None:
95-
"""Without a calibration only the base raster/layers/rooms are produced."""
98+
"""Without a calibration only the base raster is produced."""
9699
render = _render()
97100
assert render.image_content[:8] == b"\x89PNG\r\n\x1a\n"
98101
assert render.map_data is not None
99-
assert render.calibration is None
100-
assert {room.id: room.name for room in render.rooms} == {2: "Living Room", 3: "Bedroom"}
101-
assert render.layers.class_counts.get("floor") == 26
102102
# Overlays are world-coordinate only, so nothing is placed yet.
103103
assert render.map_data.path is None
104104

@@ -107,13 +107,14 @@ def test_render_places_path_and_position() -> None:
107107
"""The map and trace derive calibration, path and position together."""
108108
packet, trace = _calibrated_inputs(heading=45)
109109
render = _render(packet, trace=trace)
110-
assert render.calibration is not None
110+
calibration = solve_q10_calibration(packet, trace)
111+
assert calibration is not None
111112
assert render.map_data.path is not None
112113
assert render.map_data.vacuum_position is not None
113114
assert trace.robot_position is not None
114-
expected = render.calibration.world_to_pixel(trace.robot_position.x, trace.robot_position.y)
115+
expected = calibration.world_to_pixel(trace.robot_position.x, trace.robot_position.y)
115116
assert (render.map_data.vacuum_position.x, render.map_data.vacuum_position.y) == expected
116-
assert render.map_data.vacuum_position.a == 45
117+
assert render.map_data.vacuum_position.a == -45
117118

118119

119120
def test_render_places_zones_and_charger() -> None:
@@ -128,43 +129,51 @@ def test_render_places_zones_and_charger() -> None:
128129
assert len(render.map_data.no_go_areas or []) == 1
129130
assert len(render.map_data.no_mopping_areas or []) == 1
130131
assert len(render.map_data.walls or []) == 1
131-
assert render.calibration is not None
132+
calibration = solve_q10_calibration(packet, trace)
133+
assert calibration is not None
132134
assert render.map_data.charger is not None
133-
expected = render.calibration.world_to_pixel(trace.points[0].x, trace.points[0].y)
135+
expected = calibration.world_to_pixel(trace.points[0].x, trace.points[0].y)
134136
assert (render.map_data.charger.x, render.map_data.charger.y) == expected
135137

136138

137139
def test_render_applies_erase_zones() -> None:
138-
"""With a calibration, erase-zone cells are blanked from layers + image."""
140+
"""With a calibration, erase-zone cells are blanked from the image."""
139141
packet, trace = _calibrated_inputs()
140142
base = _render(packet, trace=trace)
141-
before_floor = base.layers.class_counts.get("floor")
142-
assert before_floor and before_floor > 0
143-
assert base.calibration is not None
143+
calibration = solve_q10_calibration(packet, trace)
144+
assert calibration is not None
144145

145146
# A rectangle covering the whole grid in world coords erases every cell.
146147
corners = [(-1, -1), (8, -1), (8, 6), (-1, 6)]
147-
erase_zone = Q10EraseZone(vertices=_world_vertices(base.calibration, corners))
148+
erase_zone = Q10EraseZone(vertices=_world_vertices(calibration, corners))
149+
cells = _erased_cells(packet.layers, [erase_zone], calibration)
148150
render = _render(replace(packet, erase_zones=[erase_zone]), trace=trace)
149151

150-
assert render.layers.class_counts.get("floor", 0) == 0 # all floor erased
152+
assert len(cells) == packet.layers.width * packet.layers.height
151153
assert render.image_content != base.image_content # re-rendered
152154

153155

154156
def test_render_partial_erase() -> None:
155157
"""An erase rectangle only blanks the cells it covers, leaving the rest."""
156158
packet, trace = _calibrated_inputs()
157159
base = _render(packet, trace=trace)
158-
before_floor = base.layers.class_counts.get("floor", 0)
159-
assert base.calibration is not None
160+
calibration = solve_q10_calibration(packet, trace)
161+
assert calibration is not None
160162

161163
# Cover only the top two grid rows.
162164
corners = [(-1, -1), (8, -1), (8, 2), (-1, 2)]
163-
erase_zone = Q10EraseZone(vertices=_world_vertices(base.calibration, corners))
165+
erase_zone = Q10EraseZone(vertices=_world_vertices(calibration, corners))
166+
cells = _erased_cells(packet.layers, [erase_zone], calibration)
164167
render = _render(replace(packet, erase_zones=[erase_zone]), trace=trace)
165168

166-
after_floor = render.layers.class_counts.get("floor", 0)
167-
assert 0 < after_floor < before_floor # some, not all, floor removed
169+
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)
168177

169178

170179
def test_draw_path_on_map_draws_position() -> None:

0 commit comments

Comments
 (0)