From 08860336e013bb3901fefff94691a40bd32e6eda Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:19:42 +0400 Subject: [PATCH 1/2] feat: expose shared map layers for Q7 --- roborock/devices/traits/b01/q7/map_content.py | 19 ++++++- roborock/map/b01_map_parser.py | 56 +++++++++++++++++++ tests/map/test_b01_map_parser.py | 34 ++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/roborock/devices/traits/b01/q7/map_content.py b/roborock/devices/traits/b01/q7/map_content.py index 0becf91a..03dc8cd4 100644 --- a/roborock/devices/traits/b01/q7/map_content.py +++ b/roborock/devices/traits/b01/q7/map_content.py @@ -17,7 +17,8 @@ from roborock.devices.rpc.b01_q7_channel import Q7MapRpcChannel from roborock.devices.traits import Trait from roborock.exceptions import RoborockException -from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig +from roborock.map.b01_grid_layers import GridCalibration, GridLayers +from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig, decompose_q7_layers, q7_calibration from roborock.roborock_typing import RoborockB01Q7Methods from .map import MapTrait @@ -35,6 +36,16 @@ class MapContent(RoborockBase): map_data: MapData | None = None """Parsed map data (metadata for points on the map).""" + layers: GridLayers | None = None + """Separable map layers (background / wall / floor) in grid-pixel space. + + Q7's raster has no per-room segmentation, so ``layers.rooms`` is empty (room + ids/names are in the map metadata).""" + + calibration: GridCalibration | None = None + """World<->pixel transform, read directly from the SCMap ``mapHead`` + (``minX``/``minY``/``resolution``); world coordinates are in metres.""" + raw_api_response: bytes | None = None """Raw bytes of the map payload from the device. @@ -95,3 +106,9 @@ async def refresh(self) -> None: self.image_content = parsed_data.image_content self.map_data = parsed_data.map_data self.raw_api_response = raw_payload + try: + self.layers = decompose_q7_layers(raw_payload) + self.calibration = q7_calibration(raw_payload) + except RoborockException: + self.layers = None + self.calibration = None diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index b57912e4..30b5887e 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -15,11 +15,67 @@ from roborock.exceptions import RoborockException from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined] +from .b01_grid_layers import ( + LAYER_BACKGROUND, + LAYER_FLOOR, + LAYER_WALL, + GridCalibration, + GridLayers, + decompose_grid, +) from .map_parser import ParsedMapData _MAP_FILE_FORMAT = "PNG" +# The Q7 occupancy grid encodes only these classes (no per-room segmentation in +# the raster -- room ids/names live in the protobuf metadata, not the pixels). +_Q7_WALL_VALUE = 127 +_Q7_FLOOR_VALUE = 128 + + +def classify_q7_cell(value: int) -> str: + """Map a Q7 SCMap grid cell value to a canonical layer class.""" + if value == _Q7_WALL_VALUE: + return LAYER_WALL + if value == _Q7_FLOOR_VALUE: + return LAYER_FLOOR + return LAYER_BACKGROUND # 0 = outside / unknown + + +def decompose_q7_layers(payload: bytes) -> GridLayers: + """Split an inflated Q7 SCMap into background / wall / floor layers. + + Q7 has no per-room raster, so ``GridLayers.rooms`` is empty; room ids/names + are available separately via the map metadata. Reuses the same device-agnostic + decomposition as the Q10. + """ + parsed = _parse_scmap_payload(payload) + size_x, size_y, grid = _extract_grid(parsed) + return decompose_grid(size_x, size_y, grid, [], classify_q7_cell) + + +def q7_calibration(payload: bytes) -> GridCalibration | None: + """Build a world<->pixel calibration straight from the Q7 ``mapHead``. + + Unlike the Q10 (whose packet carries no calibration), the Q7 SCMap header + provides ``minX``/``minY``/``resolution`` directly, so no path fitting is + needed. World coordinates are in metres; resolution is metres-per-pixel. + """ + head = _parse_scmap_payload(payload).mapHead + if not head.HasField("resolution") or head.resolution <= 0 or not head.HasField("sizeY"): + return None + resolution = head.resolution + min_x = head.minX if head.HasField("minX") else 0.0 + min_y = head.minY if head.HasField("minY") else 0.0 + return GridCalibration( + resolution=resolution, + origin_x=-min_x / resolution, + origin_y=(head.sizeY - 1) + min_y / resolution, + y_sign=1, + ) + + @dataclass class B01MapParserConfig: """Configuration for the B01/Q7 map parser.""" diff --git a/tests/map/test_b01_map_parser.py b/tests/map/test_b01_map_parser.py index 0829182e..2e66cd4f 100644 --- a/tests/map/test_b01_map_parser.py +++ b/tests/map/test_b01_map_parser.py @@ -11,7 +11,14 @@ from PIL import Image from roborock.exceptions import RoborockException -from roborock.map.b01_map_parser import B01MapParser, _parse_scmap_payload +from roborock.map.b01_grid_layers import LAYER_BACKGROUND, LAYER_FLOOR, LAYER_WALL +from roborock.map.b01_map_parser import ( + B01MapParser, + _parse_scmap_payload, + classify_q7_cell, + decompose_q7_layers, + q7_calibration, +) from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined] from roborock.protocols.b01_q7_protocol import create_map_key, decode_map_payload @@ -126,6 +133,31 @@ def test_b01_scmap_parser_maps_observed_schema_fields() -> None: assert not parsed.roomDataInfo[1].HasField("roomName") +def test_classify_q7_cell() -> None: + assert classify_q7_cell(0) == LAYER_BACKGROUND + assert classify_q7_cell(127) == LAYER_WALL + assert classify_q7_cell(128) == LAYER_FLOOR + + +def test_q7_layers_and_calibration_from_fixture() -> None: + """Q7 reuses the shared grid decomposition + reads calibration from mapHead.""" + inflated = gzip.decompress(FIXTURE.read_bytes()) + + layers = decompose_q7_layers(inflated) + assert set(layers.class_counts) == {LAYER_BACKGROUND, LAYER_WALL, LAYER_FLOOR} + assert layers.class_counts[LAYER_FLOOR] > 0 + assert layers.rooms == [] # Q7 raster has no per-room segmentation + + cal = q7_calibration(inflated) + assert cal is not None + # mapHead gives minX=-5, minY=-7, resolution=0.05 -> origin from those. + assert cal.resolution == pytest.approx(0.05, abs=1e-4) + assert cal.origin_x == pytest.approx(5.0 / cal.resolution, abs=1.0) + # World origin (0,0) maps inside the grid. + px, py = cal.world_to_pixel(0.0, 0.0) + assert 0 <= px < layers.width and 0 <= py < layers.height + + def test_b01_map_parser_rejects_invalid_payload() -> None: parser = B01MapParser() with pytest.raises(RoborockException, match="Failed to parse B01 SCMap"): From 7cb28f452ecef43ed7417d75eb2dc5c44ff39b73 Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:14:53 +0400 Subject: [PATCH 2/2] refactor: keep Q7 map layers internal --- roborock/devices/traits/b01/q7/map_content.py | 19 +----- roborock/map/b01_map_parser.py | 66 +++++-------------- tests/map/test_b01_map_parser.py | 33 +++++----- 3 files changed, 31 insertions(+), 87 deletions(-) diff --git a/roborock/devices/traits/b01/q7/map_content.py b/roborock/devices/traits/b01/q7/map_content.py index 03dc8cd4..0becf91a 100644 --- a/roborock/devices/traits/b01/q7/map_content.py +++ b/roborock/devices/traits/b01/q7/map_content.py @@ -17,8 +17,7 @@ from roborock.devices.rpc.b01_q7_channel import Q7MapRpcChannel from roborock.devices.traits import Trait from roborock.exceptions import RoborockException -from roborock.map.b01_grid_layers import GridCalibration, GridLayers -from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig, decompose_q7_layers, q7_calibration +from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig from roborock.roborock_typing import RoborockB01Q7Methods from .map import MapTrait @@ -36,16 +35,6 @@ class MapContent(RoborockBase): map_data: MapData | None = None """Parsed map data (metadata for points on the map).""" - layers: GridLayers | None = None - """Separable map layers (background / wall / floor) in grid-pixel space. - - Q7's raster has no per-room segmentation, so ``layers.rooms`` is empty (room - ids/names are in the map metadata).""" - - calibration: GridCalibration | None = None - """World<->pixel transform, read directly from the SCMap ``mapHead`` - (``minX``/``minY``/``resolution``); world coordinates are in metres.""" - raw_api_response: bytes | None = None """Raw bytes of the map payload from the device. @@ -106,9 +95,3 @@ async def refresh(self) -> None: self.image_content = parsed_data.image_content self.map_data = parsed_data.map_data self.raw_api_response = raw_payload - try: - self.layers = decompose_q7_layers(raw_payload) - self.calibration = q7_calibration(raw_payload) - except RoborockException: - self.layers = None - self.calibration = None diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index 30b5887e..40592de7 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -19,7 +19,6 @@ LAYER_BACKGROUND, LAYER_FLOOR, LAYER_WALL, - GridCalibration, GridLayers, decompose_grid, ) @@ -32,6 +31,11 @@ # the raster -- room ids/names live in the protobuf metadata, not the pixels). _Q7_WALL_VALUE = 127 _Q7_FLOOR_VALUE = 128 +_Q7_RENDER_INTENSITY = { + LAYER_BACKGROUND: 0, + LAYER_WALL: 180, + LAYER_FLOOR: 255, +} def classify_q7_cell(value: int) -> str: @@ -43,39 +47,6 @@ def classify_q7_cell(value: int) -> str: return LAYER_BACKGROUND # 0 = outside / unknown -def decompose_q7_layers(payload: bytes) -> GridLayers: - """Split an inflated Q7 SCMap into background / wall / floor layers. - - Q7 has no per-room raster, so ``GridLayers.rooms`` is empty; room ids/names - are available separately via the map metadata. Reuses the same device-agnostic - decomposition as the Q10. - """ - parsed = _parse_scmap_payload(payload) - size_x, size_y, grid = _extract_grid(parsed) - return decompose_grid(size_x, size_y, grid, [], classify_q7_cell) - - -def q7_calibration(payload: bytes) -> GridCalibration | None: - """Build a world<->pixel calibration straight from the Q7 ``mapHead``. - - Unlike the Q10 (whose packet carries no calibration), the Q7 SCMap header - provides ``minX``/``minY``/``resolution`` directly, so no path fitting is - needed. World coordinates are in metres; resolution is metres-per-pixel. - """ - head = _parse_scmap_payload(payload).mapHead - if not head.HasField("resolution") or head.resolution <= 0 or not head.HasField("sizeY"): - return None - resolution = head.resolution - min_x = head.minX if head.HasField("minX") else 0.0 - min_y = head.minY if head.HasField("minY") else 0.0 - return GridCalibration( - resolution=resolution, - origin_x=-min_x / resolution, - origin_y=(head.sizeY - 1) + min_y / resolution, - y_sign=1, - ) - - @dataclass class B01MapParserConfig: """Configuration for the B01/Q7 map parser.""" @@ -95,8 +66,9 @@ def parse(self, payload: bytes) -> ParsedMapData: parsed = _parse_scmap_payload(payload) size_x, size_y, grid = _extract_grid(parsed) room_names = _extract_room_names(parsed) + layers = decompose_grid(size_x, size_y, grid, [], classify_q7_cell) - image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale) + image = _render_occupancy_image(layers, scale=self._config.map_scale) map_data = MapData() map_data.image = ImageData( @@ -158,23 +130,15 @@ def _extract_room_names(parsed: RobotMap) -> dict[int, str]: return room_names -def _render_occupancy_image(grid: bytes, *, size_x: int, size_y: int, scale: int) -> Image.Image: - """Render the B01 occupancy grid into a simple image.""" - - # The observed occupancy grid contains only: - # - 0: outside/unknown - # - 127: wall/obstacle - # - 128: floor/free - table = bytearray(range(256)) - table[0] = 0 - table[127] = 180 - table[128] = 255 - - mapped = grid.translate(bytes(table)) - img = Image.frombytes("L", (size_x, size_y), mapped) - img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM).convert("RGB") +def _render_occupancy_image(layers: GridLayers, *, scale: int) -> Image.Image: + """Render canonical Q7 grid classes into the composed map image.""" + mapped = bytes(_Q7_RENDER_INTENSITY[layers.cell_class(value)] for value in layers.grid) + img = Image.frombytes("L", (layers.width, layers.height), mapped) + if layers.flip: + img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + img = img.convert("RGB") if scale > 1: - img = img.resize((size_x * scale, size_y * scale), resample=Image.Resampling.NEAREST) + img = img.resize((layers.width * scale, layers.height * scale), resample=Image.Resampling.NEAREST) return img diff --git a/tests/map/test_b01_map_parser.py b/tests/map/test_b01_map_parser.py index 2e66cd4f..803e9e4f 100644 --- a/tests/map/test_b01_map_parser.py +++ b/tests/map/test_b01_map_parser.py @@ -14,10 +14,9 @@ from roborock.map.b01_grid_layers import LAYER_BACKGROUND, LAYER_FLOOR, LAYER_WALL from roborock.map.b01_map_parser import ( B01MapParser, + B01MapParserConfig, _parse_scmap_payload, classify_q7_cell, - decompose_q7_layers, - q7_calibration, ) from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined] from roborock.protocols.b01_q7_protocol import create_map_key, decode_map_payload @@ -139,23 +138,21 @@ def test_classify_q7_cell() -> None: assert classify_q7_cell(128) == LAYER_FLOOR -def test_q7_layers_and_calibration_from_fixture() -> None: - """Q7 reuses the shared grid decomposition + reads calibration from mapHead.""" - inflated = gzip.decompress(FIXTURE.read_bytes()) +def test_b01_map_parser_renders_shared_q7_layer_classes() -> None: + """The parser keeps shared layer decomposition behind its public API.""" + payload = RobotMap() + payload.mapHead.sizeX = 2 + payload.mapHead.sizeY = 2 + payload.mapData.mapData = bytes([0, 127, 128, 128]) + + parsed = B01MapParser(B01MapParserConfig(map_scale=1)).parse(payload.SerializeToString()) - layers = decompose_q7_layers(inflated) - assert set(layers.class_counts) == {LAYER_BACKGROUND, LAYER_WALL, LAYER_FLOOR} - assert layers.class_counts[LAYER_FLOOR] > 0 - assert layers.rooms == [] # Q7 raster has no per-room segmentation - - cal = q7_calibration(inflated) - assert cal is not None - # mapHead gives minX=-5, minY=-7, resolution=0.05 -> origin from those. - assert cal.resolution == pytest.approx(0.05, abs=1e-4) - assert cal.origin_x == pytest.approx(5.0 / cal.resolution, abs=1.0) - # World origin (0,0) maps inside the grid. - px, py = cal.world_to_pixel(0.0, 0.0) - assert 0 <= px < layers.width and 0 <= py < layers.height + assert parsed.image_content is not None + image = Image.open(io.BytesIO(parsed.image_content)) + assert image.getpixel((0, 0)) == (255, 255, 255) + assert image.getpixel((1, 0)) == (255, 255, 255) + assert image.getpixel((0, 1)) == (0, 0, 0) + assert image.getpixel((1, 1)) == (180, 180, 180) def test_b01_map_parser_rejects_invalid_payload() -> None: