Skip to content

Commit 09c9a5b

Browse files
andigclaude
andauthored
feat: decode Q7 (B01) map geometry — dock, robot pose, path and rooms (#911)
* feat: decode Q7 (B01) map geometry — dock, robot pose, path and rooms Decode the previously unmapped SCMap RobotMap fields, established empirically from live MQTT captures of a Q7 Series (roborock.vacuum.sc05): - 5 mapInfo: saved-map list (id + name) - 6 historyPose: cleaning path points (meters) - 7 chargeStation: dock pose - 8 currentPose: live robot pose with path index and activity flag - 9 areaInfo: zone polygons - 13 roomMatrix, 14 roomOutline: room boundary pixel chains and room-to-room border chains The parser now projects dock, robot position (falling back to the dock on saved maps, which carry a (1100, 1100) placeholder pose), cleaning path and room bounding boxes + label positions into MapData, and renders the shared V1 glyphs (charger, vacuum, path) through the common image generator, same as the Q10 renderer. Also corrects the occupancy value comment: 127 is floor and 128 is wall (verified against the rendered floor plan). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: color and label Q7 rooms in the rendered map The occupancy grid carries no room ids, so each room is flood-filled from its label position, bounded by walls and the roomOutline boundary chains. Room colors come from the shared adjacency-aware V1 palette and room names are drawn through the standard ROOM_NAMES drawable. A fill that escapes a gapped outline would flood the whole floor, so fills larger than half the floor area are discarded and those pixels keep the plain floor color. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: align Q7 map colors with V1 Use the shared V1 palette roles, matching the Q10 renderer and the app look: transparent outside, GREY_WALL for walls and interior obstacles, MAP_INSIDE for floor not assigned to any room. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: render Q7 restricted areas Project areaInfo zone polygons into pixel space and draw them through the shared no-go drawable. Per-kind type mapping (no-go vs no-mop) is left for when more zone samples are available. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: decode and render Q7 carpets Carpets arrive as RobotMap field 20 (id, type, four vertices in meters, enabled flag), confirmed live by adding a carpet in the app and diffing map frames. Enabled carpet rectangles are stippled into the raster with a checkerboard texture like the V1 carpet look, and exposed as the same flat top-down carpet_map contract the Q10 parser uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: resolve Q7 map mypy errors Confidence: high Scope-risk: narrow --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 20aa61b commit 09c9a5b

4 files changed

Lines changed: 516 additions & 22 deletions

File tree

roborock/map/b01_map_parser.py

Lines changed: 266 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,36 @@
55
"""
66

77
import io
8+
import math
9+
from collections import deque
810
from dataclasses import dataclass
911

1012
from google.protobuf.message import DecodeError
1113
from PIL import Image
14+
from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
15+
from vacuum_map_parser_base.config.drawable import Drawable
1216
from vacuum_map_parser_base.config.image_config import ImageConfig
13-
from vacuum_map_parser_base.map_data import ImageData, MapData
17+
from vacuum_map_parser_base.map_data import Area, ImageData, MapData, Path, Point, Room
1418

1519
from roborock.exceptions import RoborockException
1620
from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
1721

18-
from .map_parser import ParsedMapData
22+
from .map_parser import MapParserConfig, ParsedMapData, _create_image_generator
23+
from .room_colors import adjacency_aware_room_colors
1924

2025
_MAP_FILE_FORMAT = "PNG"
2126

27+
_FLOOR = 127
28+
_WALL = 128
29+
30+
_B01_DRAWABLES = [
31+
Drawable.CHARGER,
32+
Drawable.NO_GO_AREAS,
33+
Drawable.PATH,
34+
Drawable.ROOM_NAMES,
35+
Drawable.VACUUM_POSITION,
36+
]
37+
2238

2339
@dataclass
2440
class B01MapParserConfig:
@@ -40,7 +56,11 @@ def parse(self, payload: bytes) -> ParsedMapData:
4056
size_x, size_y, grid = _extract_grid(parsed)
4157
room_names = _extract_room_names(parsed)
4258

43-
image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale)
59+
room_pixels = _assign_room_pixels(parsed, grid, size_x=size_x, size_y=size_y)
60+
carpet_pixels = _carpet_pixel_indices(parsed, grid, size_x=size_x, size_y=size_y)
61+
image = _render_occupancy_image(
62+
grid, room_pixels, carpet_pixels, size_x=size_x, size_y=size_y, scale=self._config.map_scale
63+
)
4464

4565
map_data = MapData()
4666
map_data.image = ImageData(
@@ -51,11 +71,30 @@ def parse(self, payload: bytes) -> ParsedMapData:
5171
width=size_x,
5272
image_config=ImageConfig(scale=self._config.map_scale),
5373
data=image,
54-
img_transformation=lambda p: p,
74+
# Overlay points are stored in the rendered image's top-down pixel
75+
# space. ImageDimensions applies V1's bottom-up flip before drawing,
76+
# so this adapter cancels it (same approach as the Q10 renderer).
77+
img_transformation=lambda p: Point(p.x, size_y - p.y - 1, p.a),
5578
)
5679
if room_names:
5780
map_data.additional_parameters["room_names"] = room_names
5881

82+
projector = _WorldToPixel(parsed)
83+
has_drawables = _place_poses(map_data, parsed, projector)
84+
map_data.rooms = _extract_rooms(parsed, projector, room_names)
85+
has_drawables = has_drawables or bool(map_data.rooms)
86+
if carpet_pixels:
87+
# Same contract as the Q10 parser: flat top-down grid indices.
88+
map_data.carpet_map = {(size_y - 1 - index // size_x) * size_x + index % size_x for index in carpet_pixels}
89+
90+
if has_drawables:
91+
generator = _create_image_generator(
92+
MapParserConfig(map_scale=self._config.map_scale),
93+
drawables=_B01_DRAWABLES,
94+
)
95+
generator.draw_map(map_data)
96+
image = map_data.image.data
97+
5998
image_bytes = io.BytesIO()
6099
image.save(image_bytes, format=_MAP_FILE_FORMAT)
61100

@@ -101,6 +140,100 @@ def _extract_grid(parsed: RobotMap) -> tuple[int, int, bytes]:
101140
return size_x, size_y, map_data[:expected_len]
102141

103142

143+
class _WorldToPixel:
144+
"""Project SCMap world coordinates (meters) into top-down image pixels."""
145+
146+
def __init__(self, parsed: RobotMap) -> None:
147+
head = parsed.mapHead
148+
self._min_x = head.minX
149+
self._min_y = head.minY
150+
self._max_x = head.maxX
151+
self._max_y = head.maxY
152+
self._resolution = head.resolution or 0.05
153+
self._size_y = head.sizeY
154+
155+
def in_bounds(self, x: float, y: float) -> bool:
156+
"""Whether a world point lies inside the map (rejects placeholder poses)."""
157+
return self._min_x <= x <= self._max_x and self._min_y <= y <= self._max_y
158+
159+
def to_pixel(self, x: float, y: float) -> tuple[float, float]:
160+
"""World meters to top-down image pixel coordinates."""
161+
px = (x - self._min_x) / self._resolution
162+
py = self._size_y - 1 - (y - self._min_y) / self._resolution
163+
return px, py
164+
165+
166+
def _place_poses(map_data: MapData, parsed: RobotMap, projector: _WorldToPixel) -> bool:
167+
"""Populate charger, robot position and path from the decoded SCMap."""
168+
has_drawables = False
169+
170+
if parsed.HasField("chargeStation") and projector.in_bounds(parsed.chargeStation.x, parsed.chargeStation.y):
171+
px, py = projector.to_pixel(parsed.chargeStation.x, parsed.chargeStation.y)
172+
map_data.charger = Point(px, py, math.degrees(parsed.chargeStation.phi))
173+
has_drawables = True
174+
175+
if parsed.HasField("currentPose") and projector.in_bounds(parsed.currentPose.x, parsed.currentPose.y):
176+
px, py = projector.to_pixel(parsed.currentPose.x, parsed.currentPose.y)
177+
map_data.vacuum_position = Point(px, py, math.degrees(parsed.currentPose.phi))
178+
has_drawables = True
179+
elif map_data.charger is not None:
180+
# A saved map carries no live pose; show the robot at its dock.
181+
map_data.vacuum_position = Point(map_data.charger.x, map_data.charger.y, map_data.charger.a)
182+
183+
areas = [
184+
Area(*(coord for point in area.points for coord in projector.to_pixel(point.x, point.y)))
185+
for area in parsed.areaInfo
186+
if len(area.points) == 4
187+
]
188+
if areas:
189+
# areaInfo type semantics are not yet mapped per zone kind; render all
190+
# restricted areas through the no-go drawable for now.
191+
map_data.no_go_areas = areas
192+
has_drawables = True
193+
194+
if parsed.HasField("historyPose"):
195+
pixels = [
196+
Point(*projector.to_pixel(point.x, point.y))
197+
for point in parsed.historyPose.points
198+
if projector.in_bounds(point.x, point.y)
199+
]
200+
if pixels:
201+
map_data.path = Path(len(pixels), 1, 0, [pixels])
202+
has_drawables = True
203+
204+
return has_drawables
205+
206+
207+
def _extract_rooms(parsed: RobotMap, projector: _WorldToPixel, room_names: dict[int, str]) -> dict[int, Room] | None:
208+
"""Build room bounding boxes (image-pixel space) from room outlines."""
209+
rooms: dict[int, Room] = {}
210+
label_positions = {
211+
room.roomId: projector.to_pixel(room.roomNamePost.x, room.roomNamePost.y)
212+
for room in parsed.roomDataInfo
213+
if room.HasField("roomNamePost")
214+
}
215+
size_y = parsed.mapHead.sizeY
216+
for outline in parsed.roomOutline:
217+
if not outline.points:
218+
continue
219+
room_id = outline.roomId
220+
# Outline points are top-down after the same vertical flip as the raster.
221+
xs = [point.x for point in outline.points]
222+
ys = [size_y - 1 - point.y for point in outline.points]
223+
pos = label_positions.get(room_id)
224+
rooms[room_id] = Room(
225+
min(xs),
226+
min(ys),
227+
max(xs),
228+
max(ys),
229+
room_id,
230+
room_names.get(room_id),
231+
pos[0] if pos else None,
232+
pos[1] if pos else None,
233+
)
234+
return rooms or None
235+
236+
104237
def _extract_room_names(parsed: RobotMap) -> dict[int, str]:
105238
# Expose room id/name mapping without inventing room geometry/polygons.
106239
room_names: dict[int, str] = {}
@@ -111,21 +244,138 @@ def _extract_room_names(parsed: RobotMap) -> dict[int, str]:
111244
return room_names
112245

113246

114-
def _render_occupancy_image(grid: bytes, *, size_x: int, size_y: int, scale: int) -> Image.Image:
115-
"""Render the B01 occupancy grid into a simple image."""
247+
def _assign_room_pixels(parsed: RobotMap, grid: bytes, *, size_x: int, size_y: int) -> bytearray:
248+
"""Assign a room id to each floor pixel by flood-filling from room labels.
249+
250+
The grid itself carries no room ids; room geometry arrives as boundary
251+
pixel chains (``roomOutline``). Each room is filled from its label
252+
position, bounded by walls and by any room's outline pixels, all in the
253+
raw (bottom-up) grid space.
254+
"""
255+
assignment = bytearray(len(grid))
256+
outlines = {outline.roomId: outline for outline in parsed.roomOutline if outline.points}
257+
if not outlines:
258+
return assignment
259+
260+
barrier = {
261+
point.y * size_x + point.x
262+
for outline in outlines.values()
263+
for point in outline.points
264+
if point.x < size_x and point.y < size_y
265+
}
266+
floor_count = grid.count(_FLOOR)
267+
# ponytail: leak guard — a gapped outline would flood the whole floor, so a
268+
# fill larger than half of it is discarded instead of tracing outline gaps.
269+
max_fill = floor_count // 2
270+
271+
head = parsed.mapHead
272+
label_positions = {
273+
room.roomId: (
274+
int((room.roomNamePost.x - head.minX) / head.resolution),
275+
int((room.roomNamePost.y - head.minY) / head.resolution),
276+
)
277+
for room in parsed.roomDataInfo
278+
if room.HasField("roomNamePost")
279+
}
280+
281+
for room_id, outline in outlines.items():
282+
seed = label_positions.get(room_id)
283+
if seed is None:
284+
continue
285+
col, row = seed
286+
start = row * size_x + col
287+
if not (0 <= col < size_x and 0 <= row < size_y) or grid[start] != _FLOOR:
288+
continue
289+
filled: list[int] = []
290+
queue = deque([start])
291+
seen = {start}
292+
while queue and len(filled) <= max_fill:
293+
index = queue.popleft()
294+
filled.append(index)
295+
for neighbor in (index - 1, index + 1, index - size_x, index + size_x):
296+
if (
297+
0 <= neighbor < len(grid)
298+
and neighbor not in seen
299+
and grid[neighbor] == _FLOOR
300+
and assignment[neighbor] == 0
301+
and neighbor not in barrier
302+
# Row-wrap guard for the horizontal neighbors.
303+
and abs(neighbor % size_x - index % size_x) <= 1
304+
):
305+
seen.add(neighbor)
306+
queue.append(neighbor)
307+
if len(filled) > max_fill:
308+
continue
309+
for index in filled:
310+
assignment[index] = room_id
311+
# Color the room's own boundary ring too where it sits on floor.
312+
for point in outline.points:
313+
index = point.y * size_x + point.x
314+
if index < len(grid) and grid[index] == _FLOOR and assignment[index] == 0:
315+
assignment[index] = room_id
316+
317+
return assignment
318+
319+
320+
def _carpet_pixel_indices(parsed: RobotMap, grid: bytes, *, size_x: int, size_y: int) -> set[int]:
321+
"""Raw-grid indices of floor pixels covered by enabled carpets."""
322+
head = parsed.mapHead
323+
resolution = head.resolution or 0.05
324+
indices: set[int] = set()
325+
for carpet in parsed.carpetInfo:
326+
if not carpet.points or (carpet.HasField("enabled") and not carpet.enabled):
327+
continue
328+
cols = [int((point.x - head.minX) / resolution) for point in carpet.points]
329+
rows = [int((point.y - head.minY) / resolution) for point in carpet.points]
330+
for row in range(max(min(rows), 0), min(max(rows), size_y - 1) + 1):
331+
for col in range(max(min(cols), 0), min(max(cols), size_x - 1) + 1):
332+
index = row * size_x + col
333+
if grid[index] == _FLOOR:
334+
indices.add(index)
335+
return indices
336+
337+
338+
def _render_occupancy_image(
339+
grid: bytes, room_pixels: bytearray, carpet_pixels: set[int], *, size_x: int, size_y: int, scale: int
340+
) -> Image.Image:
341+
"""Render the B01 occupancy grid with per-room colors."""
342+
343+
colors = ColorsPalette()
344+
room_colors = {
345+
room_id: tuple(color[:3]) + (255,)
346+
for room_id, color in adjacency_aware_room_colors(
347+
room_pixels, size_x, colors, lambda value: value or None
348+
).items()
349+
}
116350

117351
# The observed occupancy grid contains only:
118352
# - 0: outside/unknown
119-
# - 127: wall/obstacle
120-
# - 128: floor/free
121-
table = bytearray(range(256))
122-
table[0] = 0
123-
table[127] = 180
124-
table[128] = 255
125-
126-
mapped = grid.translate(bytes(table))
127-
img = Image.frombytes("L", (size_x, size_y), mapped)
128-
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM).convert("RGB")
353+
# - 127: floor/free
354+
# - 128: wall/obstacle
355+
# Same V1 palette roles as the Q10 renderer: transparent outside, grey
356+
# walls/obstacles, MAP_INSIDE for floor not assigned to any room.
357+
outside = (0, 0, 0, 0)
358+
floor = tuple(colors.get_color(SupportedColor.MAP_INSIDE)[:3]) + (255,)
359+
base_colors = {
360+
0: outside,
361+
_FLOOR: floor,
362+
_WALL: tuple(colors.get_color(SupportedColor.GREY_WALL)[:3]) + (255,),
363+
}
364+
365+
rgba = bytearray()
366+
for index, value in enumerate(grid):
367+
if value == _FLOOR and (room_id := room_pixels[index]):
368+
color = room_colors.get(room_id, floor)
369+
else:
370+
color = base_colors.get(value, floor)
371+
if index in carpet_pixels and (index // size_x + index % size_x) % 2 == 0:
372+
# Checkerboard stipple, like the V1 carpet texture.
373+
color = tuple(min(channel + 60, 255) for channel in color[:3]) + (255,)
374+
rgba.extend(color)
375+
376+
# RGBA so the shared V1 ImageGenerator can alpha-composite overlay glyphs.
377+
img = Image.frombytes("RGBA", (size_x, size_y), bytes(rgba))
378+
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
129379

130380
if scale > 1:
131381
img = img.resize((size_x * scale, size_y * scale), resample=Image.Resampling.NEAREST)

0 commit comments

Comments
 (0)