diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index b57912e4..8c9dccb0 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -5,20 +5,36 @@ """ import io +import math +from collections import deque from dataclasses import dataclass from google.protobuf.message import DecodeError from PIL import Image +from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor +from vacuum_map_parser_base.config.drawable import Drawable from vacuum_map_parser_base.config.image_config import ImageConfig -from vacuum_map_parser_base.map_data import ImageData, MapData +from vacuum_map_parser_base.map_data import Area, ImageData, MapData, Path, Point, Room from roborock.exceptions import RoborockException from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined] -from .map_parser import ParsedMapData +from .map_parser import MapParserConfig, ParsedMapData, _create_image_generator +from .room_colors import adjacency_aware_room_colors _MAP_FILE_FORMAT = "PNG" +_FLOOR = 127 +_WALL = 128 + +_B01_DRAWABLES = [ + Drawable.CHARGER, + Drawable.NO_GO_AREAS, + Drawable.PATH, + Drawable.ROOM_NAMES, + Drawable.VACUUM_POSITION, +] + @dataclass class B01MapParserConfig: @@ -40,7 +56,11 @@ def parse(self, payload: bytes) -> ParsedMapData: size_x, size_y, grid = _extract_grid(parsed) room_names = _extract_room_names(parsed) - image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale) + room_pixels = _assign_room_pixels(parsed, grid, size_x=size_x, size_y=size_y) + carpet_pixels = _carpet_pixel_indices(parsed, grid, size_x=size_x, size_y=size_y) + image = _render_occupancy_image( + grid, room_pixels, carpet_pixels, size_x=size_x, size_y=size_y, scale=self._config.map_scale + ) map_data = MapData() map_data.image = ImageData( @@ -51,11 +71,30 @@ def parse(self, payload: bytes) -> ParsedMapData: width=size_x, image_config=ImageConfig(scale=self._config.map_scale), data=image, - img_transformation=lambda p: p, + # Overlay points are stored in the rendered image's top-down pixel + # space. ImageDimensions applies V1's bottom-up flip before drawing, + # so this adapter cancels it (same approach as the Q10 renderer). + img_transformation=lambda p: Point(p.x, size_y - p.y - 1, p.a), ) if room_names: map_data.additional_parameters["room_names"] = room_names + projector = _WorldToPixel(parsed) + has_drawables = _place_poses(map_data, parsed, projector) + map_data.rooms = _extract_rooms(parsed, projector, room_names) + has_drawables = has_drawables or bool(map_data.rooms) + if carpet_pixels: + # Same contract as the Q10 parser: flat top-down grid indices. + map_data.carpet_map = {(size_y - 1 - index // size_x) * size_x + index % size_x for index in carpet_pixels} + + if has_drawables: + generator = _create_image_generator( + MapParserConfig(map_scale=self._config.map_scale), + drawables=_B01_DRAWABLES, + ) + generator.draw_map(map_data) + image = map_data.image.data + image_bytes = io.BytesIO() image.save(image_bytes, format=_MAP_FILE_FORMAT) @@ -92,6 +131,100 @@ def _extract_grid(parsed: RobotMap) -> tuple[int, int, bytes]: return size_x, size_y, map_data[:expected_len] +class _WorldToPixel: + """Project SCMap world coordinates (meters) into top-down image pixels.""" + + def __init__(self, parsed: RobotMap) -> None: + head = parsed.mapHead + self._min_x = head.minX + self._min_y = head.minY + self._max_x = head.maxX + self._max_y = head.maxY + self._resolution = head.resolution or 0.05 + self._size_y = head.sizeY + + def in_bounds(self, x: float, y: float) -> bool: + """Whether a world point lies inside the map (rejects placeholder poses).""" + return self._min_x <= x <= self._max_x and self._min_y <= y <= self._max_y + + def to_pixel(self, x: float, y: float) -> tuple[float, float]: + """World meters to top-down image pixel coordinates.""" + px = (x - self._min_x) / self._resolution + py = self._size_y - 1 - (y - self._min_y) / self._resolution + return px, py + + +def _place_poses(map_data: MapData, parsed: RobotMap, projector: _WorldToPixel) -> bool: + """Populate charger, robot position and path from the decoded SCMap.""" + has_drawables = False + + if parsed.HasField("chargeStation") and projector.in_bounds(parsed.chargeStation.x, parsed.chargeStation.y): + px, py = projector.to_pixel(parsed.chargeStation.x, parsed.chargeStation.y) + map_data.charger = Point(px, py, math.degrees(parsed.chargeStation.phi)) + has_drawables = True + + if parsed.HasField("currentPose") and projector.in_bounds(parsed.currentPose.x, parsed.currentPose.y): + px, py = projector.to_pixel(parsed.currentPose.x, parsed.currentPose.y) + map_data.vacuum_position = Point(px, py, math.degrees(parsed.currentPose.phi)) + has_drawables = True + elif map_data.charger is not None: + # A saved map carries no live pose; show the robot at its dock. + map_data.vacuum_position = Point(map_data.charger.x, map_data.charger.y, map_data.charger.a) + + areas = [ + Area(*(coord for point in area.points for coord in projector.to_pixel(point.x, point.y))) + for area in parsed.areaInfo + if len(area.points) == 4 + ] + if areas: + # areaInfo type semantics are not yet mapped per zone kind; render all + # restricted areas through the no-go drawable for now. + map_data.no_go_areas = areas + has_drawables = True + + if parsed.HasField("historyPose"): + pixels = [ + Point(*projector.to_pixel(point.x, point.y)) + for point in parsed.historyPose.points + if projector.in_bounds(point.x, point.y) + ] + if pixels: + map_data.path = Path(len(pixels), 1, 0, [pixels]) + has_drawables = True + + return has_drawables + + +def _extract_rooms(parsed: RobotMap, projector: _WorldToPixel, room_names: dict[int, str]) -> dict[int, Room] | None: + """Build room bounding boxes (image-pixel space) from room outlines.""" + rooms: dict[int, Room] = {} + label_positions = { + room.roomId: projector.to_pixel(room.roomNamePost.x, room.roomNamePost.y) + for room in parsed.roomDataInfo + if room.HasField("roomNamePost") + } + size_y = parsed.mapHead.sizeY + for outline in parsed.roomOutline: + if not outline.points: + continue + room_id = outline.roomId + # Outline points are top-down after the same vertical flip as the raster. + xs = [point.x for point in outline.points] + ys = [size_y - 1 - point.y for point in outline.points] + pos = label_positions.get(room_id) + rooms[room_id] = Room( + min(xs), + min(ys), + max(xs), + max(ys), + room_id, + room_names.get(room_id), + pos[0] if pos else None, + pos[1] if pos else None, + ) + return rooms or None + + def _extract_room_names(parsed: RobotMap) -> dict[int, str]: # Expose room id/name mapping without inventing room geometry/polygons. room_names: dict[int, str] = {} @@ -102,21 +235,138 @@ 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.""" +def _assign_room_pixels(parsed: RobotMap, grid: bytes, *, size_x: int, size_y: int) -> bytearray: + """Assign a room id to each floor pixel by flood-filling from room labels. + + The grid itself carries no room ids; room geometry arrives as boundary + pixel chains (``roomOutline``). Each room is filled from its label + position, bounded by walls and by any room's outline pixels, all in the + raw (bottom-up) grid space. + """ + assignment = bytearray(len(grid)) + outlines = {outline.roomId: outline for outline in parsed.roomOutline if outline.points} + if not outlines: + return assignment + + barrier = { + point.y * size_x + point.x + for outline in outlines.values() + for point in outline.points + if point.x < size_x and point.y < size_y + } + floor_count = grid.count(_FLOOR) + # ponytail: leak guard — a gapped outline would flood the whole floor, so a + # fill larger than half of it is discarded instead of tracing outline gaps. + max_fill = floor_count // 2 + + head = parsed.mapHead + label_positions = { + room.roomId: ( + int((room.roomNamePost.x - head.minX) / head.resolution), + int((room.roomNamePost.y - head.minY) / head.resolution), + ) + for room in parsed.roomDataInfo + if room.HasField("roomNamePost") + } + + for room_id, outline in outlines.items(): + seed = label_positions.get(room_id) + if seed is None: + continue + col, row = seed + start = row * size_x + col + if not (0 <= col < size_x and 0 <= row < size_y) or grid[start] != _FLOOR: + continue + filled = [] + queue = deque([start]) + seen = {start} + while queue and len(filled) <= max_fill: + index = queue.popleft() + filled.append(index) + for neighbor in (index - 1, index + 1, index - size_x, index + size_x): + if ( + 0 <= neighbor < len(grid) + and neighbor not in seen + and grid[neighbor] == _FLOOR + and assignment[neighbor] == 0 + and neighbor not in barrier + # Row-wrap guard for the horizontal neighbors. + and abs(neighbor % size_x - index % size_x) <= 1 + ): + seen.add(neighbor) + queue.append(neighbor) + if len(filled) > max_fill: + continue + for index in filled: + assignment[index] = room_id + # Color the room's own boundary ring too where it sits on floor. + for point in outline.points: + index = point.y * size_x + point.x + if index < len(grid) and grid[index] == _FLOOR and assignment[index] == 0: + assignment[index] = room_id + + return assignment + + +def _carpet_pixel_indices(parsed: RobotMap, grid: bytes, *, size_x: int, size_y: int) -> set[int]: + """Raw-grid indices of floor pixels covered by enabled carpets.""" + head = parsed.mapHead + resolution = head.resolution or 0.05 + indices: set[int] = set() + for carpet in parsed.carpetInfo: + if not carpet.points or (carpet.HasField("enabled") and not carpet.enabled): + continue + cols = [int((point.x - head.minX) / resolution) for point in carpet.points] + rows = [int((point.y - head.minY) / resolution) for point in carpet.points] + for row in range(max(min(rows), 0), min(max(rows), size_y - 1) + 1): + for col in range(max(min(cols), 0), min(max(cols), size_x - 1) + 1): + index = row * size_x + col + if grid[index] == _FLOOR: + indices.add(index) + return indices + + +def _render_occupancy_image( + grid: bytes, room_pixels: bytearray, carpet_pixels: set[int], *, size_x: int, size_y: int, scale: int +) -> Image.Image: + """Render the B01 occupancy grid with per-room colors.""" + + colors = ColorsPalette() + room_colors = { + room_id: tuple(color[:3]) + (255,) + for room_id, color in adjacency_aware_room_colors( + room_pixels, size_x, colors, lambda value: value or None + ).items() + } # 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") + # - 127: floor/free + # - 128: wall/obstacle + # Same V1 palette roles as the Q10 renderer: transparent outside, grey + # walls/obstacles, MAP_INSIDE for floor not assigned to any room. + outside = (0, 0, 0, 0) + floor = tuple(colors.get_color(SupportedColor.MAP_INSIDE)[:3]) + (255,) + base_colors = { + 0: outside, + _FLOOR: floor, + _WALL: tuple(colors.get_color(SupportedColor.GREY_WALL)[:3]) + (255,), + } + + rgba = bytearray() + for index, value in enumerate(grid): + if value == _FLOOR and (room_id := room_pixels[index]): + color = room_colors.get(room_id, floor) + else: + color = base_colors.get(value, floor) + if index in carpet_pixels and (index // size_x + index % size_x) % 2 == 0: + # Checkerboard stipple, like the V1 carpet texture. + color = tuple(min(channel + 60, 255) for channel in color[:3]) + (255,) + rgba.extend(color) + + # RGBA so the shared V1 ImageGenerator can alpha-composite overlay glyphs. + img = Image.frombytes("RGBA", (size_x, size_y), bytes(rgba)) + img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM) if scale > 1: img = img.resize((size_x * scale, size_y * scale), resample=Image.Resampling.NEAREST) diff --git a/roborock/map/proto/b01_scmap.proto b/roborock/map/proto/b01_scmap.proto index b3659813..22e5f06d 100644 --- a/roborock/map/proto/b01_scmap.proto +++ b/roborock/map/proto/b01_scmap.proto @@ -1,6 +1,6 @@ // Checked-in B01/Q7 SCMap schema for the generated runtime protobuf module. // Regenerate the checked-in Python module after edits with: -// python -m grpc_tools.protoc -I./roborock/map/proto --python_out=./roborock/map/proto roborock/map/proto/b01_scmap.proto +// python -m grpc_tools.protoc -I. --python_out=. roborock/map/proto/b01_scmap.proto // The generated file `b01_scmap_pb2.py` is checked in for runtime use and should // not be edited by hand. syntax = "proto2"; @@ -47,6 +47,79 @@ message MapDataInfo { optional bytes mapData = 1; } +message MapInfo { + optional uint32 mapId = 1; + optional string mapName = 2; +} + +message DevicePoseDataInfo { + optional uint32 update = 1; + optional float x = 2; + optional float y = 3; +} + +message DeviceHistoryPoseInfo { + optional uint32 poseId = 1; + repeated DevicePoseDataInfo points = 2; +} + +message DevicePoseInfo { + optional float x = 1; + optional float y = 2; + optional float phi = 3; +} + +message DeviceCurrentPoseInfo { + optional uint32 poseId = 1; + optional uint32 update = 2; + optional float x = 3; + optional float y = 4; + optional float phi = 5; +} + +message DeviceAreaDataInfo { + optional uint32 status = 1; + optional uint32 type = 2; + optional uint32 areaIndex = 3; + repeated DevicePointInfo points = 4; +} + +message RoomMatrixInfo { + optional bytes matrix = 1; +} + +message RoomOutlinePointInfo { + optional uint32 x = 1; + optional uint32 y = 2; + optional uint32 value = 3; +} + +message RoomBorderPointInfo { + optional uint32 x = 1; + optional uint32 y = 2; +} + +message RoomBorderInfo { + repeated RoomBorderPointInfo points = 1; + repeated uint32 roomIds = 2; +} + +message RoomOutlineInfo { + optional uint32 roomId = 1; + repeated RoomOutlinePointInfo points = 2; + repeated RoomBorderInfo borders = 3; +} + +message CarpetDataInfo { + optional uint32 carpetId = 1; + optional uint32 type = 2; + optional uint32 unknown3 = 3; + repeated DevicePointInfo points = 4; + optional uint32 enabled = 6; + optional uint32 unknown7 = 7; + optional uint32 unknown8 = 8; +} + message RoomDataInfo { optional uint32 roomId = 1; optional string roomName = 2; @@ -66,5 +139,13 @@ message RobotMap { optional MapExtInfo mapExtInfo = 2; optional MapHeadInfo mapHead = 3; optional MapDataInfo mapData = 4; + repeated MapInfo mapInfo = 5; + optional DeviceHistoryPoseInfo historyPose = 6; + optional DevicePoseInfo chargeStation = 7; + optional DeviceCurrentPoseInfo currentPose = 8; + repeated DeviceAreaDataInfo areaInfo = 9; repeated RoomDataInfo roomDataInfo = 12; + optional RoomMatrixInfo roomMatrix = 13; + repeated RoomOutlineInfo roomOutline = 14; + repeated CarpetDataInfo carpetInfo = 20; } diff --git a/roborock/map/proto/b01_scmap_pb2.py b/roborock/map/proto/b01_scmap_pb2.py index 66cc0843..1d973bff 100644 --- a/roborock/map/proto/b01_scmap_pb2.py +++ b/roborock/map/proto/b01_scmap_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"roborock/map/proto/b01_scmap.proto\x12\tb01.scmap\"\'\n\x0f\x44\x65vicePointInfo\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\"]\n\x0fMapBoundaryInfo\x12\x0e\n\x06mapMd5\x18\x01 \x01(\t\x12\r\n\x05vMinX\x18\x02 \x01(\r\x12\r\n\x05vMaxX\x18\x03 \x01(\r\x12\r\n\x05vMinY\x18\x04 \x01(\r\x12\r\n\x05vMaxY\x18\x05 \x01(\r\"\xd9\x01\n\nMapExtInfo\x12\x15\n\rtaskBeginDate\x18\x01 \x01(\r\x12\x15\n\rmapUploadDate\x18\x02 \x01(\r\x12\x10\n\x08mapValid\x18\x03 \x01(\r\x12\x0e\n\x06radian\x18\x04 \x01(\r\x12\r\n\x05\x66orce\x18\x05 \x01(\r\x12\x11\n\tcleanPath\x18\x06 \x01(\r\x12/\n\x0b\x62oudaryInfo\x18\x07 \x01(\x0b\x32\x1a.b01.scmap.MapBoundaryInfo\x12\x12\n\nmapVersion\x18\x08 \x01(\r\x12\x14\n\x0cmapValueType\x18\t \x01(\r\"\x8a\x01\n\x0bMapHeadInfo\x12\x11\n\tmapHeadId\x18\x01 \x01(\r\x12\r\n\x05sizeX\x18\x02 \x01(\r\x12\r\n\x05sizeY\x18\x03 \x01(\r\x12\x0c\n\x04minX\x18\x04 \x01(\x02\x12\x0c\n\x04minY\x18\x05 \x01(\x02\x12\x0c\n\x04maxX\x18\x06 \x01(\x02\x12\x0c\n\x04maxY\x18\x07 \x01(\x02\x12\x12\n\nresolution\x18\x08 \x01(\x02\"\x1e\n\x0bMapDataInfo\x12\x0f\n\x07mapData\x18\x01 \x01(\x0c\"\x87\x02\n\x0cRoomDataInfo\x12\x0e\n\x06roomId\x18\x01 \x01(\r\x12\x10\n\x08roomName\x18\x02 \x01(\t\x12\x12\n\nroomTypeId\x18\x03 \x01(\r\x12\x12\n\nmeterialId\x18\x04 \x01(\r\x12\x12\n\ncleanState\x18\x05 \x01(\r\x12\x11\n\troomClean\x18\x06 \x01(\r\x12\x16\n\x0eroomCleanIndex\x18\x07 \x01(\r\x12\x30\n\x0croomNamePost\x18\x08 \x01(\x0b\x32\x1a.b01.scmap.DevicePointInfo\x12\x0f\n\x07\x63olorId\x18\n \x01(\r\x12\x17\n\x0f\x66loor_direction\x18\x0b \x01(\r\x12\x12\n\nglobal_seq\x18\x0c \x01(\r\"\xc7\x01\n\x08RobotMap\x12\x0f\n\x07mapType\x18\x01 \x01(\r\x12)\n\nmapExtInfo\x18\x02 \x01(\x0b\x32\x15.b01.scmap.MapExtInfo\x12\'\n\x07mapHead\x18\x03 \x01(\x0b\x32\x16.b01.scmap.MapHeadInfo\x12\'\n\x07mapData\x18\x04 \x01(\x0b\x32\x16.b01.scmap.MapDataInfo\x12-\n\x0croomDataInfo\x18\x0c \x03(\x0b\x32\x17.b01.scmap.RoomDataInfo') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"roborock/map/proto/b01_scmap.proto\x12\tb01.scmap\"\'\n\x0f\x44\x65vicePointInfo\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\"]\n\x0fMapBoundaryInfo\x12\x0e\n\x06mapMd5\x18\x01 \x01(\t\x12\r\n\x05vMinX\x18\x02 \x01(\r\x12\r\n\x05vMaxX\x18\x03 \x01(\r\x12\r\n\x05vMinY\x18\x04 \x01(\r\x12\r\n\x05vMaxY\x18\x05 \x01(\r\"\xd9\x01\n\nMapExtInfo\x12\x15\n\rtaskBeginDate\x18\x01 \x01(\r\x12\x15\n\rmapUploadDate\x18\x02 \x01(\r\x12\x10\n\x08mapValid\x18\x03 \x01(\r\x12\x0e\n\x06radian\x18\x04 \x01(\r\x12\r\n\x05\x66orce\x18\x05 \x01(\r\x12\x11\n\tcleanPath\x18\x06 \x01(\r\x12/\n\x0b\x62oudaryInfo\x18\x07 \x01(\x0b\x32\x1a.b01.scmap.MapBoundaryInfo\x12\x12\n\nmapVersion\x18\x08 \x01(\r\x12\x14\n\x0cmapValueType\x18\t \x01(\r\"\x8a\x01\n\x0bMapHeadInfo\x12\x11\n\tmapHeadId\x18\x01 \x01(\r\x12\r\n\x05sizeX\x18\x02 \x01(\r\x12\r\n\x05sizeY\x18\x03 \x01(\r\x12\x0c\n\x04minX\x18\x04 \x01(\x02\x12\x0c\n\x04minY\x18\x05 \x01(\x02\x12\x0c\n\x04maxX\x18\x06 \x01(\x02\x12\x0c\n\x04maxY\x18\x07 \x01(\x02\x12\x12\n\nresolution\x18\x08 \x01(\x02\"\x1e\n\x0bMapDataInfo\x12\x0f\n\x07mapData\x18\x01 \x01(\x0c\")\n\x07MapInfo\x12\r\n\x05mapId\x18\x01 \x01(\r\x12\x0f\n\x07mapName\x18\x02 \x01(\t\":\n\x12\x44\x65vicePoseDataInfo\x12\x0e\n\x06update\x18\x01 \x01(\r\x12\t\n\x01x\x18\x02 \x01(\x02\x12\t\n\x01y\x18\x03 \x01(\x02\"V\n\x15\x44\x65viceHistoryPoseInfo\x12\x0e\n\x06poseId\x18\x01 \x01(\r\x12-\n\x06points\x18\x02 \x03(\x0b\x32\x1d.b01.scmap.DevicePoseDataInfo\"3\n\x0e\x44\x65vicePoseInfo\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\x12\x0b\n\x03phi\x18\x03 \x01(\x02\"Z\n\x15\x44\x65viceCurrentPoseInfo\x12\x0e\n\x06poseId\x18\x01 \x01(\r\x12\x0e\n\x06update\x18\x02 \x01(\r\x12\t\n\x01x\x18\x03 \x01(\x02\x12\t\n\x01y\x18\x04 \x01(\x02\x12\x0b\n\x03phi\x18\x05 \x01(\x02\"q\n\x12\x44\x65viceAreaDataInfo\x12\x0e\n\x06status\x18\x01 \x01(\r\x12\x0c\n\x04type\x18\x02 \x01(\r\x12\x11\n\tareaIndex\x18\x03 \x01(\r\x12*\n\x06points\x18\x04 \x03(\x0b\x32\x1a.b01.scmap.DevicePointInfo\" \n\x0eRoomMatrixInfo\x12\x0e\n\x06matrix\x18\x01 \x01(\x0c\";\n\x14RoomOutlinePointInfo\x12\t\n\x01x\x18\x01 \x01(\r\x12\t\n\x01y\x18\x02 \x01(\r\x12\r\n\x05value\x18\x03 \x01(\r\"+\n\x13RoomBorderPointInfo\x12\t\n\x01x\x18\x01 \x01(\r\x12\t\n\x01y\x18\x02 \x01(\r\"Q\n\x0eRoomBorderInfo\x12.\n\x06points\x18\x01 \x03(\x0b\x32\x1e.b01.scmap.RoomBorderPointInfo\x12\x0f\n\x07roomIds\x18\x02 \x03(\r\"~\n\x0fRoomOutlineInfo\x12\x0e\n\x06roomId\x18\x01 \x01(\r\x12/\n\x06points\x18\x02 \x03(\x0b\x32\x1f.b01.scmap.RoomOutlinePointInfo\x12*\n\x07\x62orders\x18\x03 \x03(\x0b\x32\x19.b01.scmap.RoomBorderInfo\"\xa3\x01\n\x0e\x43\x61rpetDataInfo\x12\x10\n\x08\x63\x61rpetId\x18\x01 \x01(\r\x12\x0c\n\x04type\x18\x02 \x01(\r\x12\x10\n\x08unknown3\x18\x03 \x01(\r\x12*\n\x06points\x18\x04 \x03(\x0b\x32\x1a.b01.scmap.DevicePointInfo\x12\x0f\n\x07\x65nabled\x18\x06 \x01(\r\x12\x10\n\x08unknown7\x18\x07 \x01(\r\x12\x10\n\x08unknown8\x18\x08 \x01(\r\"\x87\x02\n\x0cRoomDataInfo\x12\x0e\n\x06roomId\x18\x01 \x01(\r\x12\x10\n\x08roomName\x18\x02 \x01(\t\x12\x12\n\nroomTypeId\x18\x03 \x01(\r\x12\x12\n\nmeterialId\x18\x04 \x01(\r\x12\x12\n\ncleanState\x18\x05 \x01(\r\x12\x11\n\troomClean\x18\x06 \x01(\r\x12\x16\n\x0eroomCleanIndex\x18\x07 \x01(\r\x12\x30\n\x0croomNamePost\x18\x08 \x01(\x0b\x32\x1a.b01.scmap.DevicePointInfo\x12\x0f\n\x07\x63olorId\x18\n \x01(\r\x12\x17\n\x0f\x66loor_direction\x18\x0b \x01(\r\x12\x12\n\nglobal_seq\x18\x0c \x01(\r\"\xcc\x04\n\x08RobotMap\x12\x0f\n\x07mapType\x18\x01 \x01(\r\x12)\n\nmapExtInfo\x18\x02 \x01(\x0b\x32\x15.b01.scmap.MapExtInfo\x12\'\n\x07mapHead\x18\x03 \x01(\x0b\x32\x16.b01.scmap.MapHeadInfo\x12\'\n\x07mapData\x18\x04 \x01(\x0b\x32\x16.b01.scmap.MapDataInfo\x12#\n\x07mapInfo\x18\x05 \x03(\x0b\x32\x12.b01.scmap.MapInfo\x12\x35\n\x0bhistoryPose\x18\x06 \x01(\x0b\x32 .b01.scmap.DeviceHistoryPoseInfo\x12\x30\n\rchargeStation\x18\x07 \x01(\x0b\x32\x19.b01.scmap.DevicePoseInfo\x12\x35\n\x0b\x63urrentPose\x18\x08 \x01(\x0b\x32 .b01.scmap.DeviceCurrentPoseInfo\x12/\n\x08\x61reaInfo\x18\t \x03(\x0b\x32\x1d.b01.scmap.DeviceAreaDataInfo\x12-\n\x0croomDataInfo\x18\x0c \x03(\x0b\x32\x17.b01.scmap.RoomDataInfo\x12-\n\nroomMatrix\x18\r \x01(\x0b\x32\x19.b01.scmap.RoomMatrixInfo\x12/\n\x0broomOutline\x18\x0e \x03(\x0b\x32\x1a.b01.scmap.RoomOutlineInfo\x12-\n\ncarpetInfo\x18\x14 \x03(\x0b\x32\x19.b01.scmap.CarpetDataInfo') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -41,8 +41,32 @@ _globals['_MAPHEADINFO']._serialized_end=544 _globals['_MAPDATAINFO']._serialized_start=546 _globals['_MAPDATAINFO']._serialized_end=576 - _globals['_ROOMDATAINFO']._serialized_start=579 - _globals['_ROOMDATAINFO']._serialized_end=842 - _globals['_ROBOTMAP']._serialized_start=845 - _globals['_ROBOTMAP']._serialized_end=1044 + _globals['_MAPINFO']._serialized_start=578 + _globals['_MAPINFO']._serialized_end=619 + _globals['_DEVICEPOSEDATAINFO']._serialized_start=621 + _globals['_DEVICEPOSEDATAINFO']._serialized_end=679 + _globals['_DEVICEHISTORYPOSEINFO']._serialized_start=681 + _globals['_DEVICEHISTORYPOSEINFO']._serialized_end=767 + _globals['_DEVICEPOSEINFO']._serialized_start=769 + _globals['_DEVICEPOSEINFO']._serialized_end=820 + _globals['_DEVICECURRENTPOSEINFO']._serialized_start=822 + _globals['_DEVICECURRENTPOSEINFO']._serialized_end=912 + _globals['_DEVICEAREADATAINFO']._serialized_start=914 + _globals['_DEVICEAREADATAINFO']._serialized_end=1027 + _globals['_ROOMMATRIXINFO']._serialized_start=1029 + _globals['_ROOMMATRIXINFO']._serialized_end=1061 + _globals['_ROOMOUTLINEPOINTINFO']._serialized_start=1063 + _globals['_ROOMOUTLINEPOINTINFO']._serialized_end=1122 + _globals['_ROOMBORDERPOINTINFO']._serialized_start=1124 + _globals['_ROOMBORDERPOINTINFO']._serialized_end=1167 + _globals['_ROOMBORDERINFO']._serialized_start=1169 + _globals['_ROOMBORDERINFO']._serialized_end=1250 + _globals['_ROOMOUTLINEINFO']._serialized_start=1252 + _globals['_ROOMOUTLINEINFO']._serialized_end=1378 + _globals['_CARPETDATAINFO']._serialized_start=1381 + _globals['_CARPETDATAINFO']._serialized_end=1544 + _globals['_ROOMDATAINFO']._serialized_start=1547 + _globals['_ROOMDATAINFO']._serialized_end=1810 + _globals['_ROBOTMAP']._serialized_start=1813 + _globals['_ROBOTMAP']._serialized_end=2401 # @@protoc_insertion_point(module_scope) diff --git a/tests/map/test_b01_map_parser.py b/tests/map/test_b01_map_parser.py index 0829182e..115d5282 100644 --- a/tests/map/test_b01_map_parser.py +++ b/tests/map/test_b01_map_parser.py @@ -130,3 +130,138 @@ def test_b01_map_parser_rejects_invalid_payload() -> None: parser = B01MapParser() with pytest.raises(RoborockException, match="Failed to parse B01 SCMap"): parser.parse(b"not a map") + + +def _pose_map_payload() -> RobotMap: + """A minimal 4x4 map with pose, path and room-outline data.""" + payload = RobotMap() + payload.mapType = 0 + payload.mapHead.mapHeadId = 1 + payload.mapHead.sizeX = 4 + payload.mapHead.sizeY = 4 + payload.mapHead.minX = -0.1 + payload.mapHead.minY = -0.1 + payload.mapHead.maxX = 0.1 + payload.mapHead.maxY = 0.1 + payload.mapHead.resolution = 0.05 + payload.mapData.mapData = bytes([127] * 16) + return payload + + +def test_b01_map_parser_projects_poses_and_path() -> None: + payload = _pose_map_payload() + payload.chargeStation.x = 0.0 + payload.chargeStation.y = 0.0 + payload.chargeStation.phi = 0.0 + payload.currentPose.poseId = 2 + payload.currentPose.update = 6 + payload.currentPose.x = 0.05 + payload.currentPose.y = -0.05 + payload.currentPose.phi = 0.0 + for x, y in [(0.0, 0.0), (0.05, 0.0)]: + point = payload.historyPose.points.add() + point.x = x + point.y = y + + parsed = B01MapParser().parse(payload.SerializeToString()) + map_data = parsed.map_data + + # World (0, 0) with min (-0.1, -0.1) at 0.05 m/px is pixel (2, 2), + # flipped top-down to row sizeY - 1 - 2 = 1. + assert map_data.charger is not None + assert (map_data.charger.x, map_data.charger.y) == pytest.approx((2.0, 1.0)) + assert map_data.vacuum_position is not None + assert (map_data.vacuum_position.x, map_data.vacuum_position.y) == pytest.approx((3.0, 2.0)) + assert map_data.path is not None + assert [(p.x, p.y) for p in map_data.path.path[0]] == [ + pytest.approx((2.0, 1.0)), + pytest.approx((3.0, 1.0)), + ] + + +def test_b01_map_parser_rejects_placeholder_pose() -> None: + """Saved maps carry a placeholder (1100, 1100) pose that must not render.""" + payload = _pose_map_payload() + payload.chargeStation.x = 0.0 + payload.chargeStation.y = 0.0 + payload.chargeStation.phi = 0.5 + payload.currentPose.x = 1100.0 + payload.currentPose.y = 1100.0 + + parsed = B01MapParser().parse(payload.SerializeToString()) + map_data = parsed.map_data + + # The out-of-bounds pose is ignored; the robot is shown at its dock. + assert map_data.vacuum_position is not None + assert (map_data.vacuum_position.x, map_data.vacuum_position.y) == pytest.approx((2.0, 1.0)) + + +def test_b01_map_parser_extracts_rooms_from_outlines() -> None: + payload = _pose_map_payload() + room = payload.roomDataInfo.add() + room.roomId = 10 + room.roomName = "Kitchen" + room.roomNamePost.x = 0.0 + room.roomNamePost.y = 0.0 + outline = payload.roomOutline.add() + outline.roomId = 10 + for x, y in [(1, 1), (2, 2)]: + point = outline.points.add() + point.x = x + point.y = y + + parsed = B01MapParser().parse(payload.SerializeToString()) + rooms = parsed.map_data.rooms + + assert rooms is not None + assert set(rooms) == {10} + kitchen = rooms[10] + assert kitchen.name == "Kitchen" + # Outline grid rows flip top-down: y=1 -> 2, y=2 -> 1. + assert (kitchen.x0, kitchen.y0, kitchen.x1, kitchen.y1) == (1, 1, 2, 2) + assert (kitchen.pos_x, kitchen.pos_y) == pytest.approx((2.0, 1.0)) + + +def test_b01_map_parser_colors_enclosed_room_pixels() -> None: + """Floor pixels inside a room outline are tinted with the room color.""" + import io + + from PIL import Image + + payload = RobotMap() + payload.mapHead.mapHeadId = 1 + payload.mapHead.sizeX = 6 + payload.mapHead.sizeY = 6 + payload.mapHead.minX = 0.0 + payload.mapHead.minY = 0.0 + payload.mapHead.maxX = 6.0 + payload.mapHead.maxY = 6.0 + payload.mapHead.resolution = 1.0 + grid = bytearray([128] * 36) + for row in range(1, 5): + for col in range(1, 5): + grid[row * 6 + col] = 127 + payload.mapData.mapData = bytes(grid) + + room = payload.roomDataInfo.add() + room.roomId = 10 + room.roomName = "Kitchen" + room.roomNamePost.x = 3.2 + room.roomNamePost.y = 3.2 + outline = payload.roomOutline.add() + outline.roomId = 10 + for row in range(1, 5): + for col in range(1, 5): + if row in (1, 4) or col in (1, 4): + point = outline.points.add() + point.x = col + point.y = row + + parsed = B01MapParser().parse(payload.SerializeToString()) + img = Image.open(io.BytesIO(parsed.image_content)).convert("RGB") + + # Raw (3, 3) flips to display row 2; scale 4 puts it at (12..15, 8..11). + room_pixel = img.getpixel((13, 9)) + assert room_pixel != (32, 115, 185) # not the plain MAP_INSIDE floor color + # Walls/obstacles render with the shared V1 grey wall color. + assert img.getpixel((1, 1)) == (93, 109, 126)