Skip to content

Commit 7cb28f4

Browse files
refactor: keep Q7 map layers internal
1 parent 13a6e00 commit 7cb28f4

3 files changed

Lines changed: 31 additions & 87 deletions

File tree

roborock/devices/traits/b01/q7/map_content.py

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@
1717
from roborock.devices.rpc.b01_q7_channel import Q7MapRpcChannel
1818
from roborock.devices.traits import Trait
1919
from roborock.exceptions import RoborockException
20-
from roborock.map.b01_grid_layers import GridCalibration, GridLayers
21-
from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig, decompose_q7_layers, q7_calibration
20+
from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig
2221
from roborock.roborock_typing import RoborockB01Q7Methods
2322

2423
from .map import MapTrait
@@ -36,16 +35,6 @@ class MapContent(RoborockBase):
3635
map_data: MapData | None = None
3736
"""Parsed map data (metadata for points on the map)."""
3837

39-
layers: GridLayers | None = None
40-
"""Separable map layers (background / wall / floor) in grid-pixel space.
41-
42-
Q7's raster has no per-room segmentation, so ``layers.rooms`` is empty (room
43-
ids/names are in the map metadata)."""
44-
45-
calibration: GridCalibration | None = None
46-
"""World<->pixel transform, read directly from the SCMap ``mapHead``
47-
(``minX``/``minY``/``resolution``); world coordinates are in metres."""
48-
4938
raw_api_response: bytes | None = None
5039
"""Raw bytes of the map payload from the device.
5140
@@ -106,9 +95,3 @@ async def refresh(self) -> None:
10695
self.image_content = parsed_data.image_content
10796
self.map_data = parsed_data.map_data
10897
self.raw_api_response = raw_payload
109-
try:
110-
self.layers = decompose_q7_layers(raw_payload)
111-
self.calibration = q7_calibration(raw_payload)
112-
except RoborockException:
113-
self.layers = None
114-
self.calibration = None

roborock/map/b01_map_parser.py

Lines changed: 15 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
LAYER_BACKGROUND,
2020
LAYER_FLOOR,
2121
LAYER_WALL,
22-
GridCalibration,
2322
GridLayers,
2423
decompose_grid,
2524
)
@@ -32,6 +31,11 @@
3231
# the raster -- room ids/names live in the protobuf metadata, not the pixels).
3332
_Q7_WALL_VALUE = 127
3433
_Q7_FLOOR_VALUE = 128
34+
_Q7_RENDER_INTENSITY = {
35+
LAYER_BACKGROUND: 0,
36+
LAYER_WALL: 180,
37+
LAYER_FLOOR: 255,
38+
}
3539

3640

3741
def classify_q7_cell(value: int) -> str:
@@ -43,39 +47,6 @@ def classify_q7_cell(value: int) -> str:
4347
return LAYER_BACKGROUND # 0 = outside / unknown
4448

4549

46-
def decompose_q7_layers(payload: bytes) -> GridLayers:
47-
"""Split an inflated Q7 SCMap into background / wall / floor layers.
48-
49-
Q7 has no per-room raster, so ``GridLayers.rooms`` is empty; room ids/names
50-
are available separately via the map metadata. Reuses the same device-agnostic
51-
decomposition as the Q10.
52-
"""
53-
parsed = _parse_scmap_payload(payload)
54-
size_x, size_y, grid = _extract_grid(parsed)
55-
return decompose_grid(size_x, size_y, grid, [], classify_q7_cell)
56-
57-
58-
def q7_calibration(payload: bytes) -> GridCalibration | None:
59-
"""Build a world<->pixel calibration straight from the Q7 ``mapHead``.
60-
61-
Unlike the Q10 (whose packet carries no calibration), the Q7 SCMap header
62-
provides ``minX``/``minY``/``resolution`` directly, so no path fitting is
63-
needed. World coordinates are in metres; resolution is metres-per-pixel.
64-
"""
65-
head = _parse_scmap_payload(payload).mapHead
66-
if not head.HasField("resolution") or head.resolution <= 0 or not head.HasField("sizeY"):
67-
return None
68-
resolution = head.resolution
69-
min_x = head.minX if head.HasField("minX") else 0.0
70-
min_y = head.minY if head.HasField("minY") else 0.0
71-
return GridCalibration(
72-
resolution=resolution,
73-
origin_x=-min_x / resolution,
74-
origin_y=(head.sizeY - 1) + min_y / resolution,
75-
y_sign=1,
76-
)
77-
78-
7950
@dataclass
8051
class B01MapParserConfig:
8152
"""Configuration for the B01/Q7 map parser."""
@@ -95,8 +66,9 @@ def parse(self, payload: bytes) -> ParsedMapData:
9566
parsed = _parse_scmap_payload(payload)
9667
size_x, size_y, grid = _extract_grid(parsed)
9768
room_names = _extract_room_names(parsed)
69+
layers = decompose_grid(size_x, size_y, grid, [], classify_q7_cell)
9870

99-
image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale)
71+
image = _render_occupancy_image(layers, scale=self._config.map_scale)
10072

10173
map_data = MapData()
10274
map_data.image = ImageData(
@@ -158,23 +130,15 @@ def _extract_room_names(parsed: RobotMap) -> dict[int, str]:
158130
return room_names
159131

160132

161-
def _render_occupancy_image(grid: bytes, *, size_x: int, size_y: int, scale: int) -> Image.Image:
162-
"""Render the B01 occupancy grid into a simple image."""
163-
164-
# The observed occupancy grid contains only:
165-
# - 0: outside/unknown
166-
# - 127: wall/obstacle
167-
# - 128: floor/free
168-
table = bytearray(range(256))
169-
table[0] = 0
170-
table[127] = 180
171-
table[128] = 255
172-
173-
mapped = grid.translate(bytes(table))
174-
img = Image.frombytes("L", (size_x, size_y), mapped)
175-
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM).convert("RGB")
133+
def _render_occupancy_image(layers: GridLayers, *, scale: int) -> Image.Image:
134+
"""Render canonical Q7 grid classes into the composed map image."""
135+
mapped = bytes(_Q7_RENDER_INTENSITY[layers.cell_class(value)] for value in layers.grid)
136+
img = Image.frombytes("L", (layers.width, layers.height), mapped)
137+
if layers.flip:
138+
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
139+
img = img.convert("RGB")
176140

177141
if scale > 1:
178-
img = img.resize((size_x * scale, size_y * scale), resample=Image.Resampling.NEAREST)
142+
img = img.resize((layers.width * scale, layers.height * scale), resample=Image.Resampling.NEAREST)
179143

180144
return img

tests/map/test_b01_map_parser.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@
1414
from roborock.map.b01_grid_layers import LAYER_BACKGROUND, LAYER_FLOOR, LAYER_WALL
1515
from roborock.map.b01_map_parser import (
1616
B01MapParser,
17+
B01MapParserConfig,
1718
_parse_scmap_payload,
1819
classify_q7_cell,
19-
decompose_q7_layers,
20-
q7_calibration,
2120
)
2221
from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
2322
from roborock.protocols.b01_q7_protocol import create_map_key, decode_map_payload
@@ -139,23 +138,21 @@ def test_classify_q7_cell() -> None:
139138
assert classify_q7_cell(128) == LAYER_FLOOR
140139

141140

142-
def test_q7_layers_and_calibration_from_fixture() -> None:
143-
"""Q7 reuses the shared grid decomposition + reads calibration from mapHead."""
144-
inflated = gzip.decompress(FIXTURE.read_bytes())
141+
def test_b01_map_parser_renders_shared_q7_layer_classes() -> None:
142+
"""The parser keeps shared layer decomposition behind its public API."""
143+
payload = RobotMap()
144+
payload.mapHead.sizeX = 2
145+
payload.mapHead.sizeY = 2
146+
payload.mapData.mapData = bytes([0, 127, 128, 128])
147+
148+
parsed = B01MapParser(B01MapParserConfig(map_scale=1)).parse(payload.SerializeToString())
145149

146-
layers = decompose_q7_layers(inflated)
147-
assert set(layers.class_counts) == {LAYER_BACKGROUND, LAYER_WALL, LAYER_FLOOR}
148-
assert layers.class_counts[LAYER_FLOOR] > 0
149-
assert layers.rooms == [] # Q7 raster has no per-room segmentation
150-
151-
cal = q7_calibration(inflated)
152-
assert cal is not None
153-
# mapHead gives minX=-5, minY=-7, resolution=0.05 -> origin from those.
154-
assert cal.resolution == pytest.approx(0.05, abs=1e-4)
155-
assert cal.origin_x == pytest.approx(5.0 / cal.resolution, abs=1.0)
156-
# World origin (0,0) maps inside the grid.
157-
px, py = cal.world_to_pixel(0.0, 0.0)
158-
assert 0 <= px < layers.width and 0 <= py < layers.height
150+
assert parsed.image_content is not None
151+
image = Image.open(io.BytesIO(parsed.image_content))
152+
assert image.getpixel((0, 0)) == (255, 255, 255)
153+
assert image.getpixel((1, 0)) == (255, 255, 255)
154+
assert image.getpixel((0, 1)) == (0, 0, 0)
155+
assert image.getpixel((1, 1)) == (180, 180, 180)
159156

160157

161158
def test_b01_map_parser_rejects_invalid_payload() -> None:

0 commit comments

Comments
 (0)