Skip to content

Commit a3bba13

Browse files
fix: distinguish adjacent map rooms
1 parent 13e86e7 commit a3bba13

5 files changed

Lines changed: 146 additions & 4 deletions

File tree

roborock/map/b01_q10_map_parser.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
decompose_grid,
4141
)
4242
from .map_parser import ParsedMapData
43+
from .room_colors import adjacency_aware_room_colors
4344

4445
_MAP_FILE_FORMAT = "PNG"
4546

@@ -676,7 +677,7 @@ def parsed_from_packet(self, packet: Q10MapPacket) -> ParsedMapData:
676677

677678
def _render(self, packet: Q10MapPacket) -> Image.Image:
678679
"""Render the Q10 grid with the V1 map palette."""
679-
palette = _build_palette(packet.grid)
680+
palette = _build_palette(packet.grid, packet.width)
680681
rgba = bytearray()
681682
for value in packet.grid:
682683
rgba.extend(palette[value])
@@ -694,13 +695,23 @@ def _opaque(color: tuple[int, ...]) -> tuple[int, int, int, int]:
694695
return (color[0], color[1], color[2], color[3] if len(color) == 4 else 255)
695696

696697

697-
def _build_palette(grid: bytes) -> list[tuple[int, int, int, int]]:
698+
def _build_palette(grid: bytes, width: int) -> list[tuple[int, int, int, int]]:
698699
"""Map Q10 cells onto the same colors used by the V1 map renderer."""
700+
701+
def room_id(value: int) -> int | None:
702+
return max(1, value // 4) if 0 < value < _WALL_THRESHOLD else None
703+
699704
colors = ColorsPalette()
705+
room_colors = adjacency_aware_room_colors(
706+
grid,
707+
width,
708+
colors,
709+
room_id,
710+
)
700711
outside = (0, 0, 0, 0)
701712
palette = [outside] * 256
702713
for value in {value for value in grid if 0 < value < _WALL_THRESHOLD}:
703-
palette[value] = _opaque(colors.get_room_color(max(1, value // 4)))
714+
palette[value] = _opaque(room_colors[max(1, value // 4)])
704715
wall = _opaque(colors.get_color(SupportedColor.GREY_WALL))
705716
for value in range(_WALL_THRESHOLD, 256):
706717
palette[value] = wall

roborock/map/map_parser.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010
from vacuum_map_parser_base.config.size import Size, Sizes
1111
from vacuum_map_parser_base.image_generator import ImageGenerator
1212
from vacuum_map_parser_base.map_data import MapData
13+
from vacuum_map_parser_roborock.image_parser import RoborockImageParser
1314
from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
1415

1516
from roborock.exceptions import RoborockException
1617

18+
from .room_colors import adjacency_aware_room_colors
19+
1720
_LOGGER = logging.getLogger(__name__)
1821

1922
DEFAULT_DRAWABLES = {
@@ -99,16 +102,50 @@ def parse(self, map_bytes: bytes) -> ParsedMapData | None:
99102
return ParsedMapData(image_content=img_byte_arr.getvalue(), map_data=parsed_map)
100103

101104

105+
class _AdjacencyAwareRoborockImageParser(RoborockImageParser):
106+
"""Apply the shared adjacency color policy to V1 room cells."""
107+
108+
def __init__(self, palette: ColorsPalette, image_config: ImageConfig) -> None:
109+
super().__init__(palette, image_config)
110+
self._room_palette = palette
111+
self._base_room_colors = palette.cached_room_colors.copy()
112+
113+
def parse(
114+
self,
115+
raw_data: bytes,
116+
width: int,
117+
height: int,
118+
carpet_map: set[int] | None,
119+
removed_map: set[int] | None = None,
120+
):
121+
"""Assign non-conflicting room colors before the V1 image pass."""
122+
self._room_palette.cached_room_colors.clear()
123+
self._room_palette.cached_room_colors.update(self._base_room_colors)
124+
125+
def room_id(value: int) -> int | None:
126+
if value in (self.MAP_OUTSIDE, self.MAP_WALL, self.MAP_INSIDE, self.MAP_SCAN):
127+
return None
128+
return self._get_room_number(value) if value & 0x07 == 0x07 else None
129+
130+
room_colors = adjacency_aware_room_colors(raw_data, width, self._room_palette, room_id)
131+
for number, color in room_colors.items():
132+
self._room_palette.cached_room_colors[number] = color
133+
self._room_palette.cached_room_colors[str(number)] = color
134+
return super().parse(raw_data, width, height, carpet_map, removed_map)
135+
136+
102137
def _create_map_data_parser(config: MapParserConfig) -> RoborockMapDataParser:
103138
"""Create a RoborockMapDataParser based on the config entry."""
104139
palette, sizes, image_config = _create_rendering_components(config)
105-
return RoborockMapDataParser(
140+
parser = RoborockMapDataParser(
106141
palette,
107142
sizes,
108143
config.drawables,
109144
image_config,
110145
[],
111146
)
147+
parser._image_parser = _AdjacencyAwareRoborockImageParser(palette, image_config)
148+
return parser
112149

113150

114151
def create_image_generator(

roborock/map/room_colors.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Deterministic room colors that keep adjacent segments distinguishable."""
2+
3+
from collections.abc import Callable, Sequence
4+
5+
from vacuum_map_parser_base.config.color import Color, ColorsPalette
6+
7+
RoomIdFromCell = Callable[[int], int | None]
8+
9+
10+
def adjacency_aware_room_colors(
11+
grid: Sequence[int],
12+
width: int,
13+
palette: ColorsPalette,
14+
room_id_from_cell: RoomIdFromCell,
15+
) -> dict[int, Color]:
16+
"""Return room colors, changing only adjacent same-color conflicts.
17+
18+
Room IDs remain the stable preference, matching the existing V1 palette.
19+
When two rooms sharing an edge resolve to the same RGB value, the
20+
higher-numbered room receives the first palette color not already used by
21+
one of its colored neighbors.
22+
"""
23+
if width <= 0:
24+
return {}
25+
26+
room_ids: set[int] = set()
27+
neighbors: dict[int, set[int]] = {}
28+
for index, value in enumerate(grid):
29+
room_id = room_id_from_cell(value)
30+
if room_id is None:
31+
continue
32+
room_ids.add(room_id)
33+
neighbors.setdefault(room_id, set())
34+
35+
for neighbor_index in (index - 1 if index % width else -1, index - width):
36+
if neighbor_index < 0:
37+
continue
38+
neighbor_id = room_id_from_cell(grid[neighbor_index])
39+
if neighbor_id is None or neighbor_id == room_id:
40+
continue
41+
neighbors[room_id].add(neighbor_id)
42+
neighbors.setdefault(neighbor_id, set()).add(room_id)
43+
44+
candidates: list[Color] = []
45+
for palette_id in map(int, ColorsPalette.ROOM_COLORS):
46+
color = palette.get_room_color(palette_id)
47+
if color not in candidates:
48+
candidates.append(color)
49+
50+
assigned: dict[int, Color] = {}
51+
for room_id in sorted(room_ids):
52+
preferred = palette.get_room_color(room_id)
53+
neighbor_colors = {assigned[neighbor] for neighbor in neighbors[room_id] if neighbor in assigned}
54+
assigned[room_id] = (
55+
preferred
56+
if preferred not in neighbor_colors
57+
else next((color for color in candidates if color not in neighbor_colors), preferred)
58+
)
59+
return assigned

tests/map/test_b01_q10_map_parser.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,20 @@ def test_parser_renders_distinct_background_floor_and_walls() -> None:
176176
assert image.getpixel((scale * 3, 0)) == (133, 193, 233, 255)
177177

178178

179+
def test_parser_gives_adjacent_rooms_distinct_palette_colors() -> None:
180+
"""Repeated V1 palette entries do not merge neighboring Q10 rooms."""
181+
grid = bytes([2 * 4, 12 * 4])
182+
payload = _synthetic_map_payload(width=2, decoded_layout=grid + b"\x01\x00")
183+
parser = B01Q10MapParser()
184+
185+
parsed = parser.parse(payload)
186+
187+
assert parsed.image_content is not None
188+
image = Image.open(io.BytesIO(parsed.image_content))
189+
scale = parser.config.map_scale
190+
assert image.getpixel((0, 0)) != image.getpixel((scale, 0))
191+
192+
179193
def test_parse_map_packet_reads_header_height() -> None:
180194
"""Width and height come straight from the u16be header fields."""
181195
grid = bytes([8]) * 6 + bytes([12]) * 6 # two rooms, 4x3 grid

tests/map/test_map_parser.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
from pathlib import Path
44

55
import pytest
6+
from vacuum_map_parser_base.config.color import ColorsPalette
67
from vacuum_map_parser_base.config.drawable import Drawable
8+
from vacuum_map_parser_base.config.image_config import ImageConfig
79
from vacuum_map_parser_base.config.size import Size
810

911
from roborock.exceptions import RoborockException
1012
from roborock.map.map_parser import (
1113
MapParser,
1214
MapParserConfig,
15+
_AdjacencyAwareRoborockImageParser,
1316
_create_map_data_parser,
1417
create_image_generator,
1518
)
@@ -46,4 +49,22 @@ def test_shared_image_generator_matches_v1_rendering_components() -> None:
4649
assert shared._sizes.get_size(size) == v1._sizes.get_size(size)
4750

4851

52+
def test_v1_parser_gives_adjacent_rooms_distinct_palette_colors() -> None:
53+
"""Repeated palette entries do not merge neighboring V1 rooms."""
54+
palette = ColorsPalette()
55+
original_room_12 = palette.get_room_color(12)
56+
image_parser = _AdjacencyAwareRoborockImageParser(palette, ImageConfig())
57+
raw_data = bytes([(2 << 3) | 7, (12 << 3) | 7])
58+
59+
image, _rooms = image_parser.parse(raw_data, 2, 1, None)
60+
61+
assert image is not None
62+
assert image.getpixel((0, 0)) != image.getpixel((1, 0))
63+
64+
isolated_image, _rooms = image_parser.parse(bytes([(12 << 3) | 7]), 1, 1, None)
65+
66+
assert isolated_image is not None
67+
assert isolated_image.getpixel((0, 0))[: len(original_room_12)] == original_room_12
68+
69+
4970
# We can add additional tests here in the future that actually parse valid map data

0 commit comments

Comments
 (0)