Skip to content

Commit 379a4ed

Browse files
andigclaude
andcommitted
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>
1 parent e8d5466 commit 379a4ed

4 files changed

Lines changed: 311 additions & 22 deletions

File tree

roborock/map/b01_map_parser.py

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,28 @@
55
"""
66

77
import io
8+
import math
89
from dataclasses import dataclass
910

1011
from google.protobuf.message import DecodeError
1112
from PIL import Image
13+
from vacuum_map_parser_base.config.drawable import Drawable
1214
from vacuum_map_parser_base.config.image_config import ImageConfig
13-
from vacuum_map_parser_base.map_data import ImageData, MapData
15+
from vacuum_map_parser_base.map_data import ImageData, MapData, Path, Point, Room
1416

1517
from roborock.exceptions import RoborockException
1618
from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
1719

18-
from .map_parser import ParsedMapData
20+
from .map_parser import MapParserConfig, ParsedMapData, _create_image_generator
1921

2022
_MAP_FILE_FORMAT = "PNG"
2123

24+
_B01_DRAWABLES = [
25+
Drawable.CHARGER,
26+
Drawable.PATH,
27+
Drawable.VACUUM_POSITION,
28+
]
29+
2230

2331
@dataclass
2432
class B01MapParserConfig:
@@ -51,11 +59,26 @@ def parse(self, payload: bytes) -> ParsedMapData:
5159
width=size_x,
5260
image_config=ImageConfig(scale=self._config.map_scale),
5361
data=image,
54-
img_transformation=lambda p: p,
62+
# Overlay points are stored in the rendered image's top-down pixel
63+
# space. ImageDimensions applies V1's bottom-up flip before drawing,
64+
# so this adapter cancels it (same approach as the Q10 renderer).
65+
img_transformation=lambda p: Point(p.x, size_y - p.y - 1, p.a),
5566
)
5667
if room_names:
5768
map_data.additional_parameters["room_names"] = room_names
5869

70+
projector = _WorldToPixel(parsed)
71+
has_drawables = _place_poses(map_data, parsed, projector)
72+
map_data.rooms = _extract_rooms(parsed, projector, room_names)
73+
74+
if has_drawables:
75+
generator = _create_image_generator(
76+
MapParserConfig(map_scale=self._config.map_scale),
77+
drawables=_B01_DRAWABLES,
78+
)
79+
generator.draw_map(map_data)
80+
image = map_data.image.data
81+
5982
image_bytes = io.BytesIO()
6083
image.save(image_bytes, format=_MAP_FILE_FORMAT)
6184

@@ -92,6 +115,89 @@ def _extract_grid(parsed: RobotMap) -> tuple[int, int, bytes]:
92115
return size_x, size_y, map_data[:expected_len]
93116

94117

118+
class _WorldToPixel:
119+
"""Project SCMap world coordinates (meters) into top-down image pixels."""
120+
121+
def __init__(self, parsed: RobotMap) -> None:
122+
head = parsed.mapHead
123+
self._min_x = head.minX
124+
self._min_y = head.minY
125+
self._max_x = head.maxX
126+
self._max_y = head.maxY
127+
self._resolution = head.resolution or 0.05
128+
self._size_y = head.sizeY
129+
130+
def in_bounds(self, x: float, y: float) -> bool:
131+
"""Whether a world point lies inside the map (rejects placeholder poses)."""
132+
return self._min_x <= x <= self._max_x and self._min_y <= y <= self._max_y
133+
134+
def to_pixel(self, x: float, y: float) -> tuple[float, float]:
135+
"""World meters to top-down image pixel coordinates."""
136+
px = (x - self._min_x) / self._resolution
137+
py = self._size_y - 1 - (y - self._min_y) / self._resolution
138+
return px, py
139+
140+
141+
def _place_poses(map_data: MapData, parsed: RobotMap, projector: _WorldToPixel) -> bool:
142+
"""Populate charger, robot position and path from the decoded SCMap."""
143+
has_drawables = False
144+
145+
if parsed.HasField("chargeStation") and projector.in_bounds(parsed.chargeStation.x, parsed.chargeStation.y):
146+
px, py = projector.to_pixel(parsed.chargeStation.x, parsed.chargeStation.y)
147+
map_data.charger = Point(px, py, math.degrees(parsed.chargeStation.phi))
148+
has_drawables = True
149+
150+
if parsed.HasField("currentPose") and projector.in_bounds(parsed.currentPose.x, parsed.currentPose.y):
151+
px, py = projector.to_pixel(parsed.currentPose.x, parsed.currentPose.y)
152+
map_data.vacuum_position = Point(px, py, math.degrees(parsed.currentPose.phi))
153+
has_drawables = True
154+
elif map_data.charger is not None:
155+
# A saved map carries no live pose; show the robot at its dock.
156+
map_data.vacuum_position = Point(map_data.charger.x, map_data.charger.y, map_data.charger.a)
157+
158+
if parsed.HasField("historyPose"):
159+
pixels = [
160+
Point(*projector.to_pixel(point.x, point.y))
161+
for point in parsed.historyPose.points
162+
if projector.in_bounds(point.x, point.y)
163+
]
164+
if pixels:
165+
map_data.path = Path(len(pixels), 1, 0, [pixels])
166+
has_drawables = True
167+
168+
return has_drawables
169+
170+
171+
def _extract_rooms(parsed: RobotMap, projector: _WorldToPixel, room_names: dict[int, str]) -> dict[int, Room] | None:
172+
"""Build room bounding boxes (image-pixel space) from room outlines."""
173+
rooms: dict[int, Room] = {}
174+
label_positions = {
175+
room.roomId: projector.to_pixel(room.roomNamePost.x, room.roomNamePost.y)
176+
for room in parsed.roomDataInfo
177+
if room.HasField("roomNamePost")
178+
}
179+
size_y = parsed.mapHead.sizeY
180+
for outline in parsed.roomOutline:
181+
if not outline.points:
182+
continue
183+
room_id = outline.roomId
184+
# Outline points are top-down after the same vertical flip as the raster.
185+
xs = [point.x for point in outline.points]
186+
ys = [size_y - 1 - point.y for point in outline.points]
187+
pos = label_positions.get(room_id)
188+
rooms[room_id] = Room(
189+
min(xs),
190+
min(ys),
191+
max(xs),
192+
max(ys),
193+
room_id,
194+
room_names.get(room_id),
195+
pos[0] if pos else None,
196+
pos[1] if pos else None,
197+
)
198+
return rooms or None
199+
200+
95201
def _extract_room_names(parsed: RobotMap) -> dict[int, str]:
96202
# Expose room id/name mapping without inventing room geometry/polygons.
97203
room_names: dict[int, str] = {}
@@ -116,7 +222,8 @@ def _render_occupancy_image(grid: bytes, *, size_x: int, size_y: int, scale: int
116222

117223
mapped = grid.translate(bytes(table))
118224
img = Image.frombytes("L", (size_x, size_y), mapped)
119-
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM).convert("RGB")
225+
# RGBA so the shared V1 ImageGenerator can alpha-composite overlay glyphs.
226+
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM).convert("RGBA")
120227

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

roborock/map/proto/b01_scmap.proto

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,69 @@ message MapDataInfo {
4747
optional bytes mapData = 1;
4848
}
4949

50+
message MapInfo {
51+
optional uint32 mapId = 1;
52+
optional string mapName = 2;
53+
}
54+
55+
message DevicePoseDataInfo {
56+
optional uint32 update = 1;
57+
optional float x = 2;
58+
optional float y = 3;
59+
}
60+
61+
message DeviceHistoryPoseInfo {
62+
optional uint32 poseId = 1;
63+
repeated DevicePoseDataInfo points = 2;
64+
}
65+
66+
message DevicePoseInfo {
67+
optional float x = 1;
68+
optional float y = 2;
69+
optional float phi = 3;
70+
}
71+
72+
message DeviceCurrentPoseInfo {
73+
optional uint32 poseId = 1;
74+
optional uint32 update = 2;
75+
optional float x = 3;
76+
optional float y = 4;
77+
optional float phi = 5;
78+
}
79+
80+
message DeviceAreaDataInfo {
81+
optional uint32 status = 1;
82+
optional uint32 type = 2;
83+
optional uint32 areaIndex = 3;
84+
repeated DevicePointInfo points = 4;
85+
}
86+
87+
message RoomMatrixInfo {
88+
optional bytes matrix = 1;
89+
}
90+
91+
message RoomOutlinePointInfo {
92+
optional uint32 x = 1;
93+
optional uint32 y = 2;
94+
optional uint32 value = 3;
95+
}
96+
97+
message RoomBorderPointInfo {
98+
optional uint32 x = 1;
99+
optional uint32 y = 2;
100+
}
101+
102+
message RoomBorderInfo {
103+
repeated RoomBorderPointInfo points = 1;
104+
repeated uint32 roomIds = 2;
105+
}
106+
107+
message RoomOutlineInfo {
108+
optional uint32 roomId = 1;
109+
repeated RoomOutlinePointInfo points = 2;
110+
repeated RoomBorderInfo borders = 3;
111+
}
112+
50113
message RoomDataInfo {
51114
optional uint32 roomId = 1;
52115
optional string roomName = 2;
@@ -66,5 +129,12 @@ message RobotMap {
66129
optional MapExtInfo mapExtInfo = 2;
67130
optional MapHeadInfo mapHead = 3;
68131
optional MapDataInfo mapData = 4;
132+
repeated MapInfo mapInfo = 5;
133+
optional DeviceHistoryPoseInfo historyPose = 6;
134+
optional DevicePoseInfo chargeStation = 7;
135+
optional DeviceCurrentPoseInfo currentPose = 8;
136+
repeated DeviceAreaDataInfo areaInfo = 9;
69137
repeated RoomDataInfo roomDataInfo = 12;
138+
optional RoomMatrixInfo roomMatrix = 13;
139+
repeated RoomOutlineInfo roomOutline = 14;
70140
}

roborock/map/proto/b01_scmap_pb2.py

Lines changed: 40 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)