Skip to content

Commit dbb6c46

Browse files
feat: align Q10 map colors with V1 (#902)
* feat: align Q10 map colors with V1 * refactor: clarify room palette cache handling
1 parent a098fd6 commit dbb6c46

5 files changed

Lines changed: 227 additions & 20 deletions

File tree

roborock/map/b01_q10_map_parser.py

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
https://github.com/v1b3c0d3x3r/roborock-qseries-map-bridge
2020
"""
2121

22-
import colorsys
2322
import io
2423
import math
2524
import statistics
2625
from dataclasses import dataclass, field, replace
2726

2827
from PIL import Image
28+
from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
2929
from vacuum_map_parser_base.config.image_config import ImageConfig
3030
from vacuum_map_parser_base.map_data import ImageData, MapData
3131

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

@@ -655,12 +656,12 @@ def parsed_from_packet(self, packet: Q10MapPacket) -> ParsedMapData:
655656
return ParsedMapData(image_content=image_bytes.getvalue(), map_data=map_data)
656657

657658
def _render(self, packet: Q10MapPacket) -> Image.Image:
658-
"""Render the Q10 grid: rooms get distinct colors, walls white, rest dark."""
659-
palette = _build_palette(packet.grid)
660-
rgb = bytearray()
659+
"""Render the Q10 grid with the V1 map palette."""
660+
palette = _build_palette(packet.grid, packet.width)
661+
rgba = bytearray()
661662
for value in packet.grid:
662-
rgb.extend(palette[value])
663-
img = Image.frombytes("RGB", (packet.width, packet.height), bytes(rgb))
663+
rgba.extend(palette[value])
664+
img = Image.frombytes("RGBA", (packet.width, packet.height), bytes(rgba))
664665
# The ss07 grid is stored top-down (row 0 = top of the home), so it is
665666
# rendered as-is -- unlike the V1/Q7 convention, no vertical flip.
666667
scale = self._config.map_scale
@@ -669,15 +670,32 @@ def _render(self, packet: Q10MapPacket) -> Image.Image:
669670
return img
670671

671672

672-
def _build_palette(grid: bytes) -> list[tuple[int, int, int]]:
673-
"""Map each grid value to an RGB color (rooms distinct, walls white)."""
674-
palette: list[tuple[int, int, int]] = [(28, 30, 38)] * 256 # default: unknown/outside
675-
room_values = sorted({v for v in set(grid) if 0 < v < _WALL_THRESHOLD})
676-
for index, value in enumerate(room_values):
677-
hue = (index * 0.139) % 1.0
678-
r, g, b = colorsys.hsv_to_rgb(hue, 0.5, 0.95)
679-
palette[value] = (int(r * 255), int(g * 255), int(b * 255))
673+
def _opaque(color: tuple[int, ...]) -> tuple[int, int, int, int]:
674+
"""Return a palette color as RGBA."""
675+
return (color[0], color[1], color[2], color[3] if len(color) == 4 else 255)
676+
677+
678+
def _build_palette(grid: bytes, width: int) -> list[tuple[int, int, int, int]]:
679+
"""Map Q10 cells onto the same colors used by the V1 map renderer."""
680+
681+
def room_id(value: int) -> int | None:
682+
return max(1, value // 4) if 0 < value < _WALL_THRESHOLD else None
683+
684+
colors = ColorsPalette()
685+
room_colors = adjacency_aware_room_colors(
686+
grid,
687+
width,
688+
colors,
689+
room_id,
690+
)
691+
outside = (0, 0, 0, 0)
692+
palette = [outside] * 256
693+
for value in {value for value in grid if 0 < value < _WALL_THRESHOLD}:
694+
palette[value] = _opaque(room_colors[max(1, value // 4)])
695+
wall = _opaque(colors.get_color(SupportedColor.GREY_WALL))
680696
for value in range(_WALL_THRESHOLD, 256):
681-
palette[value] = (235, 235, 240) # walls / borders
682-
palette[0] = (28, 30, 38)
697+
palette[value] = wall
698+
palette[_UNSEGMENTED_FLOOR_VALUE] = _opaque(colors.get_color(SupportedColor.MAP_INSIDE))
699+
palette[_BACKGROUND_VALUE] = outside
700+
palette[0] = outside
683701
return palette

roborock/map/map_parser.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
import io
44
import logging
5+
import threading
56
from dataclasses import dataclass, field
67

78
from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
89
from vacuum_map_parser_base.config.drawable import Drawable
910
from vacuum_map_parser_base.config.image_config import ImageConfig
1011
from vacuum_map_parser_base.config.size import Size, Sizes
1112
from vacuum_map_parser_base.map_data import MapData
13+
from vacuum_map_parser_roborock.image_parser import RoborockImageParser
1214
from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
1315

1416
from roborock.exceptions import RoborockException
1517

18+
from .room_colors import adjacency_aware_room_colors
19+
1620
_LOGGER = logging.getLogger(__name__)
1721

1822
DEFAULT_DRAWABLES = {
@@ -98,6 +102,52 @@ def parse(self, map_bytes: bytes) -> ParsedMapData | None:
98102
return ParsedMapData(image_content=img_byte_arr.getvalue(), map_data=parsed_map)
99103

100104

105+
class _AdjacencyAwareRoborockImageParser(RoborockImageParser):
106+
"""Apply the shared adjacency color policy to V1 room cells."""
107+
108+
def __init__(
109+
self,
110+
palette: ColorsPalette,
111+
image_config: ImageConfig,
112+
*,
113+
recolor_rooms: bool = True,
114+
) -> None:
115+
super().__init__(palette, image_config)
116+
self._room_palette = palette
117+
self._base_room_colors = palette.cached_room_colors.copy()
118+
self._recolor_rooms = recolor_rooms
119+
self._palette_lock = threading.Lock()
120+
121+
def parse(
122+
self,
123+
raw_data: bytes,
124+
width: int,
125+
height: int,
126+
carpet_map: set[int] | None,
127+
removed_map: set[int] | None = None,
128+
):
129+
"""Assign non-conflicting room colors before the V1 image pass."""
130+
with self._palette_lock:
131+
# cached_room_colors is a read-only property, so reset its dict in place.
132+
cached_room_colors = self._room_palette.cached_room_colors
133+
cached_room_colors.clear()
134+
cached_room_colors.update(self._base_room_colors)
135+
136+
if self._recolor_rooms:
137+
138+
def room_id(value: int) -> int | None:
139+
if value in (self.MAP_OUTSIDE, self.MAP_WALL, self.MAP_INSIDE, self.MAP_SCAN):
140+
return None
141+
return self._get_room_number(value) if value & 0x07 == 0x07 else None
142+
143+
room_colors = adjacency_aware_room_colors(raw_data, width, self._room_palette, room_id)
144+
for number, color in room_colors.items():
145+
# ColorsPalette caches both forms for get_room_color(str | int).
146+
cached_room_colors[number] = color
147+
cached_room_colors[str(number)] = color
148+
return super().parse(raw_data, width, height, carpet_map, removed_map)
149+
150+
101151
def _create_map_data_parser(config: MapParserConfig) -> RoborockMapDataParser:
102152
"""Create a RoborockMapDataParser based on the config entry."""
103153
color_dicts = {}
@@ -114,10 +164,18 @@ def _create_map_data_parser(config: MapParserConfig) -> RoborockMapDataParser:
114164
if not config.show_rooms:
115165
room_colors = {str(x): (0, 0, 0, 0) for x in range(1, 32)}
116166

117-
return RoborockMapDataParser(
118-
ColorsPalette(color_dicts, room_colors),
167+
palette = ColorsPalette(color_dicts, room_colors)
168+
image_config = ImageConfig(scale=config.map_scale)
169+
parser = RoborockMapDataParser(
170+
palette,
119171
Sizes({k: v * config.map_scale for k, v in Sizes.SIZES.items() if k != Size.MOP_PATH_WIDTH}),
120172
config.drawables,
121-
ImageConfig(scale=config.map_scale),
173+
image_config,
122174
[],
123175
)
176+
parser._image_parser = _AdjacencyAwareRoborockImageParser(
177+
palette,
178+
image_config,
179+
recolor_rooms=config.show_rooms,
180+
)
181+
return parser

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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,37 @@ def test_parse_map_packet_allows_zero_room_metadata() -> None:
159159
assert packet.rooms == []
160160

161161

162+
def test_parser_renders_distinct_background_floor_and_walls() -> None:
163+
"""Background must not hide the wall layer by sharing its color."""
164+
grid = bytes([243, 240, 249, 8])
165+
payload = _synthetic_map_payload(width=4, decoded_layout=grid + b"\x01\x00")
166+
parser = B01Q10MapParser()
167+
168+
parsed = parser.parse(payload)
169+
170+
assert parsed.image_content is not None
171+
image = Image.open(io.BytesIO(parsed.image_content))
172+
scale = parser.config.map_scale
173+
assert image.getpixel((0, 0)) == (0, 0, 0, 0)
174+
assert image.getpixel((scale, 0)) == (32, 115, 185, 255)
175+
assert image.getpixel((scale * 2, 0)) == (93, 109, 126, 255)
176+
assert image.getpixel((scale * 3, 0)) == (133, 193, 233, 255)
177+
178+
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+
162193
def test_parse_map_packet_reads_header_height() -> None:
163194
"""Width and height come straight from the u16be header fields."""
164195
grid = bytes([8]) * 6 + bytes([12]) * 6 # two rooms, 4x3 grid

tests/map/test_map_parser.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@
33
from pathlib import Path
44

55
import pytest
6+
from vacuum_map_parser_base.config.color import ColorsPalette
7+
from vacuum_map_parser_base.config.image_config import ImageConfig
68

79
from roborock.exceptions import RoborockException
8-
from roborock.map.map_parser import MapParser, MapParserConfig
10+
from roborock.map.map_parser import (
11+
MapParser,
12+
MapParserConfig,
13+
_AdjacencyAwareRoborockImageParser,
14+
)
915

1016
MAP_DATA_FILE = Path(__file__).parent / "raw_map_data"
1117
DEFAULT_MAP_CONFIG = MapParserConfig()
@@ -19,4 +25,39 @@ def test_invalid_map_content(map_content: bytes):
1925
parser.parse(map_content)
2026

2127

28+
def test_v1_parser_gives_adjacent_rooms_distinct_palette_colors() -> None:
29+
"""Repeated palette entries do not merge neighboring V1 rooms."""
30+
palette = ColorsPalette()
31+
original_room_12 = palette.get_room_color(12)
32+
image_parser = _AdjacencyAwareRoborockImageParser(palette, ImageConfig())
33+
raw_data = bytes([(2 << 3) | 7, (12 << 3) | 7])
34+
35+
image, _rooms = image_parser.parse(raw_data, 2, 1, None)
36+
37+
assert image is not None
38+
assert image.getpixel((0, 0)) != image.getpixel((1, 0))
39+
assert palette.get_room_color(12) == palette.get_room_color("12")
40+
41+
isolated_image, _rooms = image_parser.parse(bytes([(12 << 3) | 7]), 1, 1, None)
42+
43+
assert isolated_image is not None
44+
assert isolated_image.getpixel((0, 0))[: len(original_room_12)] == original_room_12
45+
46+
47+
def test_v1_parser_keeps_adjacent_rooms_hidden_when_rooms_disabled() -> None:
48+
"""Adjacency conflict handling cannot override intentional transparency."""
49+
hidden_rooms = {str(room_id): (0, 0, 0, 0) for room_id in range(1, 32)}
50+
parser = _AdjacencyAwareRoborockImageParser(
51+
ColorsPalette({}, hidden_rooms),
52+
ImageConfig(),
53+
recolor_rooms=False,
54+
)
55+
raw_data = bytes([(2 << 3) | 7, (12 << 3) | 7])
56+
57+
image, _rooms = parser.parse(raw_data, 2, 1, None)
58+
59+
assert image is not None
60+
assert [image.getpixel((x, 0)) for x in range(2)] == [(0, 0, 0, 0)] * 2
61+
62+
2263
# We can add additional tests here in the future that actually parse valid map data

0 commit comments

Comments
 (0)