Skip to content

Commit e0e0664

Browse files
andigclaude
andcommitted
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>
1 parent e16bd49 commit e0e0664

2 files changed

Lines changed: 150 additions & 10 deletions

File tree

roborock/map/b01_map_parser.py

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66

77
import io
88
import math
9+
from collections import deque
910
from dataclasses import dataclass
1011

1112
from google.protobuf.message import DecodeError
1213
from PIL import Image
14+
from vacuum_map_parser_base.config.color import ColorsPalette
1315
from vacuum_map_parser_base.config.drawable import Drawable
1416
from vacuum_map_parser_base.config.image_config import ImageConfig
1517
from vacuum_map_parser_base.map_data import ImageData, MapData, Path, Point, Room
@@ -18,12 +20,17 @@
1820
from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
1921

2022
from .map_parser import MapParserConfig, ParsedMapData, _create_image_generator
23+
from .room_colors import adjacency_aware_room_colors
2124

2225
_MAP_FILE_FORMAT = "PNG"
2326

27+
_FLOOR = 127
28+
_WALL = 128
29+
2430
_B01_DRAWABLES = [
2531
Drawable.CHARGER,
2632
Drawable.PATH,
33+
Drawable.ROOM_NAMES,
2734
Drawable.VACUUM_POSITION,
2835
]
2936

@@ -48,7 +55,8 @@ def parse(self, payload: bytes) -> ParsedMapData:
4855
size_x, size_y, grid = _extract_grid(parsed)
4956
room_names = _extract_room_names(parsed)
5057

51-
image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale)
58+
room_pixels = _assign_room_pixels(parsed, grid, size_x=size_x, size_y=size_y)
59+
image = _render_occupancy_image(grid, room_pixels, size_x=size_x, size_y=size_y, scale=self._config.map_scale)
5260

5361
map_data = MapData()
5462
map_data.image = ImageData(
@@ -70,6 +78,7 @@ def parse(self, payload: bytes) -> ParsedMapData:
7078
projector = _WorldToPixel(parsed)
7179
has_drawables = _place_poses(map_data, parsed, projector)
7280
map_data.rooms = _extract_rooms(parsed, projector, room_names)
81+
has_drawables = has_drawables or bool(map_data.rooms)
7382

7483
if has_drawables:
7584
generator = _create_image_generator(
@@ -208,22 +217,108 @@ def _extract_room_names(parsed: RobotMap) -> dict[int, str]:
208217
return room_names
209218

210219

211-
def _render_occupancy_image(grid: bytes, *, size_x: int, size_y: int, scale: int) -> Image.Image:
212-
"""Render the B01 occupancy grid into a simple image."""
220+
def _assign_room_pixels(parsed: RobotMap, grid: bytes, *, size_x: int, size_y: int) -> bytearray:
221+
"""Assign a room id to each floor pixel by flood-filling from room labels.
222+
223+
The grid itself carries no room ids; room geometry arrives as boundary
224+
pixel chains (``roomOutline``). Each room is filled from its label
225+
position, bounded by walls and by any room's outline pixels, all in the
226+
raw (bottom-up) grid space.
227+
"""
228+
assignment = bytearray(len(grid))
229+
outlines = {outline.roomId: outline for outline in parsed.roomOutline if outline.points}
230+
if not outlines:
231+
return assignment
232+
233+
barrier = {
234+
point.y * size_x + point.x
235+
for outline in outlines.values()
236+
for point in outline.points
237+
if point.x < size_x and point.y < size_y
238+
}
239+
floor_count = grid.count(_FLOOR)
240+
# ponytail: leak guard — a gapped outline would flood the whole floor, so a
241+
# fill larger than half of it is discarded instead of tracing outline gaps.
242+
max_fill = floor_count // 2
243+
244+
head = parsed.mapHead
245+
label_positions = {
246+
room.roomId: (
247+
int((room.roomNamePost.x - head.minX) / head.resolution),
248+
int((room.roomNamePost.y - head.minY) / head.resolution),
249+
)
250+
for room in parsed.roomDataInfo
251+
if room.HasField("roomNamePost")
252+
}
253+
254+
for room_id, outline in outlines.items():
255+
seed = label_positions.get(room_id)
256+
if seed is None:
257+
continue
258+
col, row = seed
259+
start = row * size_x + col
260+
if not (0 <= col < size_x and 0 <= row < size_y) or grid[start] != _FLOOR:
261+
continue
262+
filled = []
263+
queue = deque([start])
264+
seen = {start}
265+
while queue and len(filled) <= max_fill:
266+
index = queue.popleft()
267+
filled.append(index)
268+
for neighbor in (index - 1, index + 1, index - size_x, index + size_x):
269+
if (
270+
0 <= neighbor < len(grid)
271+
and neighbor not in seen
272+
and grid[neighbor] == _FLOOR
273+
and assignment[neighbor] == 0
274+
and neighbor not in barrier
275+
# Row-wrap guard for the horizontal neighbors.
276+
and abs(neighbor % size_x - index % size_x) <= 1
277+
):
278+
seen.add(neighbor)
279+
queue.append(neighbor)
280+
if len(filled) > max_fill:
281+
continue
282+
for index in filled:
283+
assignment[index] = room_id
284+
# Color the room's own boundary ring too where it sits on floor.
285+
for point in outline.points:
286+
index = point.y * size_x + point.x
287+
if index < len(grid) and grid[index] == _FLOOR and assignment[index] == 0:
288+
assignment[index] = room_id
289+
290+
return assignment
291+
292+
293+
def _render_occupancy_image(
294+
grid: bytes, room_pixels: bytearray, *, size_x: int, size_y: int, scale: int
295+
) -> Image.Image:
296+
"""Render the B01 occupancy grid with per-room colors."""
297+
298+
room_colors = {
299+
room_id: tuple(color[:3]) + (255,)
300+
for room_id, color in adjacency_aware_room_colors(
301+
room_pixels, size_x, ColorsPalette(), lambda value: value or None
302+
).items()
303+
}
213304

214305
# The observed occupancy grid contains only:
215306
# - 0: outside/unknown
216307
# - 127: floor/free
217308
# - 128: wall/obstacle
218-
table = bytearray(range(256))
219-
table[0] = 0
220-
table[127] = 180
221-
table[128] = 255
309+
base_colors = {0: (0, 0, 0, 255), _FLOOR: (180, 180, 180, 255), _WALL: (255, 255, 255, 255)}
310+
fallback = (180, 180, 180, 255)
311+
312+
rgba = bytearray()
313+
for index, value in enumerate(grid):
314+
if value == _FLOOR and (room_id := room_pixels[index]):
315+
rgba.extend(room_colors.get(room_id, fallback))
316+
else:
317+
rgba.extend(base_colors.get(value, fallback))
222318

223-
mapped = grid.translate(bytes(table))
224-
img = Image.frombytes("L", (size_x, size_y), mapped)
225319
# RGBA so the shared V1 ImageGenerator can alpha-composite overlay glyphs.
226-
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM).convert("RGBA")
320+
img = Image.frombytes("RGBA", (size_x, size_y), bytes(rgba))
321+
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
227322

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

tests/map/test_b01_map_parser.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,48 @@ def test_b01_map_parser_extracts_rooms_from_outlines() -> None:
220220
# Outline grid rows flip top-down: y=1 -> 2, y=2 -> 1.
221221
assert (kitchen.x0, kitchen.y0, kitchen.x1, kitchen.y1) == (1, 1, 2, 2)
222222
assert (kitchen.pos_x, kitchen.pos_y) == pytest.approx((2.0, 1.0))
223+
224+
225+
def test_b01_map_parser_colors_enclosed_room_pixels() -> None:
226+
"""Floor pixels inside a room outline are tinted with the room color."""
227+
import io
228+
229+
from PIL import Image
230+
231+
payload = RobotMap()
232+
payload.mapHead.mapHeadId = 1
233+
payload.mapHead.sizeX = 6
234+
payload.mapHead.sizeY = 6
235+
payload.mapHead.minX = 0.0
236+
payload.mapHead.minY = 0.0
237+
payload.mapHead.maxX = 6.0
238+
payload.mapHead.maxY = 6.0
239+
payload.mapHead.resolution = 1.0
240+
grid = bytearray([128] * 36)
241+
for row in range(1, 5):
242+
for col in range(1, 5):
243+
grid[row * 6 + col] = 127
244+
payload.mapData.mapData = bytes(grid)
245+
246+
room = payload.roomDataInfo.add()
247+
room.roomId = 10
248+
room.roomName = "Kitchen"
249+
room.roomNamePost.x = 3.2
250+
room.roomNamePost.y = 3.2
251+
outline = payload.roomOutline.add()
252+
outline.roomId = 10
253+
for row in range(1, 5):
254+
for col in range(1, 5):
255+
if row in (1, 4) or col in (1, 4):
256+
point = outline.points.add()
257+
point.x = col
258+
point.y = row
259+
260+
parsed = B01MapParser().parse(payload.SerializeToString())
261+
img = Image.open(io.BytesIO(parsed.image_content)).convert("RGB")
262+
263+
# Raw (3, 3) flips to display row 2; scale 4 puts it at (12..15, 8..11).
264+
room_pixel = img.getpixel((13, 9))
265+
assert room_pixel != (180, 180, 180)
266+
# Wall border stays white.
267+
assert img.getpixel((1, 1)) == (255, 255, 255)

0 commit comments

Comments
 (0)