55"""
66
77import io
8+ import math
89from dataclasses import dataclass
910
1011from google .protobuf .message import DecodeError
1112from PIL import Image
13+ from vacuum_map_parser_base .config .drawable import Drawable
1214from vacuum_map_parser_base .config .image_config import ImageConfig
13- from vacuum_map_parser_base .map_data import ImageData , MapData
15+ from vacuum_map_parser_base .map_data import ImageData , MapData , Path , Point , Room
1416
1517from roborock .exceptions import RoborockException
1618from roborock .map .proto .b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
1719
18- from .map_parser import ParsedMapData
20+ from .map_parser import MapParserConfig , ParsedMapData , _create_image_generator
1921
2022_MAP_FILE_FORMAT = "PNG"
2123
24+ _B01_DRAWABLES = [
25+ Drawable .CHARGER ,
26+ Drawable .PATH ,
27+ Drawable .VACUUM_POSITION ,
28+ ]
29+
2230
2331@dataclass
2432class B01MapParserConfig :
@@ -51,11 +59,26 @@ def parse(self, payload: bytes) -> ParsedMapData:
5159 width = size_x ,
5260 image_config = ImageConfig (scale = self ._config .map_scale ),
5361 data = image ,
54- img_transformation = lambda p : p ,
62+ # Overlay points are stored in the rendered image's top-down pixel
63+ # space. ImageDimensions applies V1's bottom-up flip before drawing,
64+ # so this adapter cancels it (same approach as the Q10 renderer).
65+ img_transformation = lambda p : Point (p .x , size_y - p .y - 1 , p .a ),
5566 )
5667 if room_names :
5768 map_data .additional_parameters ["room_names" ] = room_names
5869
70+ projector = _WorldToPixel (parsed )
71+ has_drawables = _place_poses (map_data , parsed , projector )
72+ map_data .rooms = _extract_rooms (parsed , projector , room_names )
73+
74+ if has_drawables :
75+ generator = _create_image_generator (
76+ MapParserConfig (map_scale = self ._config .map_scale ),
77+ drawables = _B01_DRAWABLES ,
78+ )
79+ generator .draw_map (map_data )
80+ image = map_data .image .data
81+
5982 image_bytes = io .BytesIO ()
6083 image .save (image_bytes , format = _MAP_FILE_FORMAT )
6184
@@ -92,6 +115,89 @@ def _extract_grid(parsed: RobotMap) -> tuple[int, int, bytes]:
92115 return size_x , size_y , map_data [:expected_len ]
93116
94117
118+ class _WorldToPixel :
119+ """Project SCMap world coordinates (meters) into top-down image pixels."""
120+
121+ def __init__ (self , parsed : RobotMap ) -> None :
122+ head = parsed .mapHead
123+ self ._min_x = head .minX
124+ self ._min_y = head .minY
125+ self ._max_x = head .maxX
126+ self ._max_y = head .maxY
127+ self ._resolution = head .resolution or 0.05
128+ self ._size_y = head .sizeY
129+
130+ def in_bounds (self , x : float , y : float ) -> bool :
131+ """Whether a world point lies inside the map (rejects placeholder poses)."""
132+ return self ._min_x <= x <= self ._max_x and self ._min_y <= y <= self ._max_y
133+
134+ def to_pixel (self , x : float , y : float ) -> tuple [float , float ]:
135+ """World meters to top-down image pixel coordinates."""
136+ px = (x - self ._min_x ) / self ._resolution
137+ py = self ._size_y - 1 - (y - self ._min_y ) / self ._resolution
138+ return px , py
139+
140+
141+ def _place_poses (map_data : MapData , parsed : RobotMap , projector : _WorldToPixel ) -> bool :
142+ """Populate charger, robot position and path from the decoded SCMap."""
143+ has_drawables = False
144+
145+ if parsed .HasField ("chargeStation" ) and projector .in_bounds (parsed .chargeStation .x , parsed .chargeStation .y ):
146+ px , py = projector .to_pixel (parsed .chargeStation .x , parsed .chargeStation .y )
147+ map_data .charger = Point (px , py , math .degrees (parsed .chargeStation .phi ))
148+ has_drawables = True
149+
150+ if parsed .HasField ("currentPose" ) and projector .in_bounds (parsed .currentPose .x , parsed .currentPose .y ):
151+ px , py = projector .to_pixel (parsed .currentPose .x , parsed .currentPose .y )
152+ map_data .vacuum_position = Point (px , py , math .degrees (parsed .currentPose .phi ))
153+ has_drawables = True
154+ elif map_data .charger is not None :
155+ # A saved map carries no live pose; show the robot at its dock.
156+ map_data .vacuum_position = Point (map_data .charger .x , map_data .charger .y , map_data .charger .a )
157+
158+ if parsed .HasField ("historyPose" ):
159+ pixels = [
160+ Point (* projector .to_pixel (point .x , point .y ))
161+ for point in parsed .historyPose .points
162+ if projector .in_bounds (point .x , point .y )
163+ ]
164+ if pixels :
165+ map_data .path = Path (len (pixels ), 1 , 0 , [pixels ])
166+ has_drawables = True
167+
168+ return has_drawables
169+
170+
171+ def _extract_rooms (parsed : RobotMap , projector : _WorldToPixel , room_names : dict [int , str ]) -> dict [int , Room ] | None :
172+ """Build room bounding boxes (image-pixel space) from room outlines."""
173+ rooms : dict [int , Room ] = {}
174+ label_positions = {
175+ room .roomId : projector .to_pixel (room .roomNamePost .x , room .roomNamePost .y )
176+ for room in parsed .roomDataInfo
177+ if room .HasField ("roomNamePost" )
178+ }
179+ size_y = parsed .mapHead .sizeY
180+ for outline in parsed .roomOutline :
181+ if not outline .points :
182+ continue
183+ room_id = outline .roomId
184+ # Outline points are top-down after the same vertical flip as the raster.
185+ xs = [point .x for point in outline .points ]
186+ ys = [size_y - 1 - point .y for point in outline .points ]
187+ pos = label_positions .get (room_id )
188+ rooms [room_id ] = Room (
189+ min (xs ),
190+ min (ys ),
191+ max (xs ),
192+ max (ys ),
193+ room_id ,
194+ room_names .get (room_id ),
195+ pos [0 ] if pos else None ,
196+ pos [1 ] if pos else None ,
197+ )
198+ return rooms or None
199+
200+
95201def _extract_room_names (parsed : RobotMap ) -> dict [int , str ]:
96202 # Expose room id/name mapping without inventing room geometry/polygons.
97203 room_names : dict [int , str ] = {}
@@ -116,7 +222,8 @@ def _render_occupancy_image(grid: bytes, *, size_x: int, size_y: int, scale: int
116222
117223 mapped = grid .translate (bytes (table ))
118224 img = Image .frombytes ("L" , (size_x , size_y ), mapped )
119- img = img .transpose (Image .Transpose .FLIP_TOP_BOTTOM ).convert ("RGB" )
225+ # RGBA so the shared V1 ImageGenerator can alpha-composite overlay glyphs.
226+ img = img .transpose (Image .Transpose .FLIP_TOP_BOTTOM ).convert ("RGBA" )
120227
121228 if scale > 1 :
122229 img = img .resize ((size_x * scale , size_y * scale ), resample = Image .Resampling .NEAREST )
0 commit comments