55"""
66
77import io
8+ import math
9+ from collections import deque
810from dataclasses import dataclass
911
1012from google .protobuf .message import DecodeError
1113from PIL import Image
14+ from vacuum_map_parser_base .config .color import ColorsPalette , SupportedColor
15+ from vacuum_map_parser_base .config .drawable import Drawable
1216from vacuum_map_parser_base .config .image_config import ImageConfig
13- from vacuum_map_parser_base .map_data import ImageData , MapData
17+ from vacuum_map_parser_base .map_data import Area , ImageData , MapData , Path , Point , Room
1418
1519from roborock .exceptions import RoborockException
1620from roborock .map .proto .b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
1721
18- from .map_parser import ParsedMapData
22+ from .map_parser import MapParserConfig , ParsedMapData , _create_image_generator
23+ from .room_colors import adjacency_aware_room_colors
1924
2025_MAP_FILE_FORMAT = "PNG"
2126
27+ _FLOOR = 127
28+ _WALL = 128
29+
30+ _B01_DRAWABLES = [
31+ Drawable .CHARGER ,
32+ Drawable .NO_GO_AREAS ,
33+ Drawable .PATH ,
34+ Drawable .ROOM_NAMES ,
35+ Drawable .VACUUM_POSITION ,
36+ ]
37+
2238
2339@dataclass
2440class B01MapParserConfig :
@@ -40,7 +56,11 @@ def parse(self, payload: bytes) -> ParsedMapData:
4056 size_x , size_y , grid = _extract_grid (parsed )
4157 room_names = _extract_room_names (parsed )
4258
43- image = _render_occupancy_image (grid , size_x = size_x , size_y = size_y , scale = self ._config .map_scale )
59+ room_pixels = _assign_room_pixels (parsed , grid , size_x = size_x , size_y = size_y )
60+ carpet_pixels = _carpet_pixel_indices (parsed , grid , size_x = size_x , size_y = size_y )
61+ image = _render_occupancy_image (
62+ grid , room_pixels , carpet_pixels , size_x = size_x , size_y = size_y , scale = self ._config .map_scale
63+ )
4464
4565 map_data = MapData ()
4666 map_data .image = ImageData (
@@ -51,11 +71,30 @@ def parse(self, payload: bytes) -> ParsedMapData:
5171 width = size_x ,
5272 image_config = ImageConfig (scale = self ._config .map_scale ),
5373 data = image ,
54- img_transformation = lambda p : p ,
74+ # Overlay points are stored in the rendered image's top-down pixel
75+ # space. ImageDimensions applies V1's bottom-up flip before drawing,
76+ # so this adapter cancels it (same approach as the Q10 renderer).
77+ img_transformation = lambda p : Point (p .x , size_y - p .y - 1 , p .a ),
5578 )
5679 if room_names :
5780 map_data .additional_parameters ["room_names" ] = room_names
5881
82+ projector = _WorldToPixel (parsed )
83+ has_drawables = _place_poses (map_data , parsed , projector )
84+ map_data .rooms = _extract_rooms (parsed , projector , room_names )
85+ has_drawables = has_drawables or bool (map_data .rooms )
86+ if carpet_pixels :
87+ # Same contract as the Q10 parser: flat top-down grid indices.
88+ map_data .carpet_map = {(size_y - 1 - index // size_x ) * size_x + index % size_x for index in carpet_pixels }
89+
90+ if has_drawables :
91+ generator = _create_image_generator (
92+ MapParserConfig (map_scale = self ._config .map_scale ),
93+ drawables = _B01_DRAWABLES ,
94+ )
95+ generator .draw_map (map_data )
96+ image = map_data .image .data
97+
5998 image_bytes = io .BytesIO ()
6099 image .save (image_bytes , format = _MAP_FILE_FORMAT )
61100
@@ -101,6 +140,100 @@ def _extract_grid(parsed: RobotMap) -> tuple[int, int, bytes]:
101140 return size_x , size_y , map_data [:expected_len ]
102141
103142
143+ class _WorldToPixel :
144+ """Project SCMap world coordinates (meters) into top-down image pixels."""
145+
146+ def __init__ (self , parsed : RobotMap ) -> None :
147+ head = parsed .mapHead
148+ self ._min_x = head .minX
149+ self ._min_y = head .minY
150+ self ._max_x = head .maxX
151+ self ._max_y = head .maxY
152+ self ._resolution = head .resolution or 0.05
153+ self ._size_y = head .sizeY
154+
155+ def in_bounds (self , x : float , y : float ) -> bool :
156+ """Whether a world point lies inside the map (rejects placeholder poses)."""
157+ return self ._min_x <= x <= self ._max_x and self ._min_y <= y <= self ._max_y
158+
159+ def to_pixel (self , x : float , y : float ) -> tuple [float , float ]:
160+ """World meters to top-down image pixel coordinates."""
161+ px = (x - self ._min_x ) / self ._resolution
162+ py = self ._size_y - 1 - (y - self ._min_y ) / self ._resolution
163+ return px , py
164+
165+
166+ def _place_poses (map_data : MapData , parsed : RobotMap , projector : _WorldToPixel ) -> bool :
167+ """Populate charger, robot position and path from the decoded SCMap."""
168+ has_drawables = False
169+
170+ if parsed .HasField ("chargeStation" ) and projector .in_bounds (parsed .chargeStation .x , parsed .chargeStation .y ):
171+ px , py = projector .to_pixel (parsed .chargeStation .x , parsed .chargeStation .y )
172+ map_data .charger = Point (px , py , math .degrees (parsed .chargeStation .phi ))
173+ has_drawables = True
174+
175+ if parsed .HasField ("currentPose" ) and projector .in_bounds (parsed .currentPose .x , parsed .currentPose .y ):
176+ px , py = projector .to_pixel (parsed .currentPose .x , parsed .currentPose .y )
177+ map_data .vacuum_position = Point (px , py , math .degrees (parsed .currentPose .phi ))
178+ has_drawables = True
179+ elif map_data .charger is not None :
180+ # A saved map carries no live pose; show the robot at its dock.
181+ map_data .vacuum_position = Point (map_data .charger .x , map_data .charger .y , map_data .charger .a )
182+
183+ areas = [
184+ Area (* (coord for point in area .points for coord in projector .to_pixel (point .x , point .y )))
185+ for area in parsed .areaInfo
186+ if len (area .points ) == 4
187+ ]
188+ if areas :
189+ # areaInfo type semantics are not yet mapped per zone kind; render all
190+ # restricted areas through the no-go drawable for now.
191+ map_data .no_go_areas = areas
192+ has_drawables = True
193+
194+ if parsed .HasField ("historyPose" ):
195+ pixels = [
196+ Point (* projector .to_pixel (point .x , point .y ))
197+ for point in parsed .historyPose .points
198+ if projector .in_bounds (point .x , point .y )
199+ ]
200+ if pixels :
201+ map_data .path = Path (len (pixels ), 1 , 0 , [pixels ])
202+ has_drawables = True
203+
204+ return has_drawables
205+
206+
207+ def _extract_rooms (parsed : RobotMap , projector : _WorldToPixel , room_names : dict [int , str ]) -> dict [int , Room ] | None :
208+ """Build room bounding boxes (image-pixel space) from room outlines."""
209+ rooms : dict [int , Room ] = {}
210+ label_positions = {
211+ room .roomId : projector .to_pixel (room .roomNamePost .x , room .roomNamePost .y )
212+ for room in parsed .roomDataInfo
213+ if room .HasField ("roomNamePost" )
214+ }
215+ size_y = parsed .mapHead .sizeY
216+ for outline in parsed .roomOutline :
217+ if not outline .points :
218+ continue
219+ room_id = outline .roomId
220+ # Outline points are top-down after the same vertical flip as the raster.
221+ xs = [point .x for point in outline .points ]
222+ ys = [size_y - 1 - point .y for point in outline .points ]
223+ pos = label_positions .get (room_id )
224+ rooms [room_id ] = Room (
225+ min (xs ),
226+ min (ys ),
227+ max (xs ),
228+ max (ys ),
229+ room_id ,
230+ room_names .get (room_id ),
231+ pos [0 ] if pos else None ,
232+ pos [1 ] if pos else None ,
233+ )
234+ return rooms or None
235+
236+
104237def _extract_room_names (parsed : RobotMap ) -> dict [int , str ]:
105238 # Expose room id/name mapping without inventing room geometry/polygons.
106239 room_names : dict [int , str ] = {}
@@ -111,21 +244,138 @@ def _extract_room_names(parsed: RobotMap) -> dict[int, str]:
111244 return room_names
112245
113246
114- def _render_occupancy_image (grid : bytes , * , size_x : int , size_y : int , scale : int ) -> Image .Image :
115- """Render the B01 occupancy grid into a simple image."""
247+ def _assign_room_pixels (parsed : RobotMap , grid : bytes , * , size_x : int , size_y : int ) -> bytearray :
248+ """Assign a room id to each floor pixel by flood-filling from room labels.
249+
250+ The grid itself carries no room ids; room geometry arrives as boundary
251+ pixel chains (``roomOutline``). Each room is filled from its label
252+ position, bounded by walls and by any room's outline pixels, all in the
253+ raw (bottom-up) grid space.
254+ """
255+ assignment = bytearray (len (grid ))
256+ outlines = {outline .roomId : outline for outline in parsed .roomOutline if outline .points }
257+ if not outlines :
258+ return assignment
259+
260+ barrier = {
261+ point .y * size_x + point .x
262+ for outline in outlines .values ()
263+ for point in outline .points
264+ if point .x < size_x and point .y < size_y
265+ }
266+ floor_count = grid .count (_FLOOR )
267+ # ponytail: leak guard — a gapped outline would flood the whole floor, so a
268+ # fill larger than half of it is discarded instead of tracing outline gaps.
269+ max_fill = floor_count // 2
270+
271+ head = parsed .mapHead
272+ label_positions = {
273+ room .roomId : (
274+ int ((room .roomNamePost .x - head .minX ) / head .resolution ),
275+ int ((room .roomNamePost .y - head .minY ) / head .resolution ),
276+ )
277+ for room in parsed .roomDataInfo
278+ if room .HasField ("roomNamePost" )
279+ }
280+
281+ for room_id , outline in outlines .items ():
282+ seed = label_positions .get (room_id )
283+ if seed is None :
284+ continue
285+ col , row = seed
286+ start = row * size_x + col
287+ if not (0 <= col < size_x and 0 <= row < size_y ) or grid [start ] != _FLOOR :
288+ continue
289+ filled : list [int ] = []
290+ queue = deque ([start ])
291+ seen = {start }
292+ while queue and len (filled ) <= max_fill :
293+ index = queue .popleft ()
294+ filled .append (index )
295+ for neighbor in (index - 1 , index + 1 , index - size_x , index + size_x ):
296+ if (
297+ 0 <= neighbor < len (grid )
298+ and neighbor not in seen
299+ and grid [neighbor ] == _FLOOR
300+ and assignment [neighbor ] == 0
301+ and neighbor not in barrier
302+ # Row-wrap guard for the horizontal neighbors.
303+ and abs (neighbor % size_x - index % size_x ) <= 1
304+ ):
305+ seen .add (neighbor )
306+ queue .append (neighbor )
307+ if len (filled ) > max_fill :
308+ continue
309+ for index in filled :
310+ assignment [index ] = room_id
311+ # Color the room's own boundary ring too where it sits on floor.
312+ for point in outline .points :
313+ index = point .y * size_x + point .x
314+ if index < len (grid ) and grid [index ] == _FLOOR and assignment [index ] == 0 :
315+ assignment [index ] = room_id
316+
317+ return assignment
318+
319+
320+ def _carpet_pixel_indices (parsed : RobotMap , grid : bytes , * , size_x : int , size_y : int ) -> set [int ]:
321+ """Raw-grid indices of floor pixels covered by enabled carpets."""
322+ head = parsed .mapHead
323+ resolution = head .resolution or 0.05
324+ indices : set [int ] = set ()
325+ for carpet in parsed .carpetInfo :
326+ if not carpet .points or (carpet .HasField ("enabled" ) and not carpet .enabled ):
327+ continue
328+ cols = [int ((point .x - head .minX ) / resolution ) for point in carpet .points ]
329+ rows = [int ((point .y - head .minY ) / resolution ) for point in carpet .points ]
330+ for row in range (max (min (rows ), 0 ), min (max (rows ), size_y - 1 ) + 1 ):
331+ for col in range (max (min (cols ), 0 ), min (max (cols ), size_x - 1 ) + 1 ):
332+ index = row * size_x + col
333+ if grid [index ] == _FLOOR :
334+ indices .add (index )
335+ return indices
336+
337+
338+ def _render_occupancy_image (
339+ grid : bytes , room_pixels : bytearray , carpet_pixels : set [int ], * , size_x : int , size_y : int , scale : int
340+ ) -> Image .Image :
341+ """Render the B01 occupancy grid with per-room colors."""
342+
343+ colors = ColorsPalette ()
344+ room_colors = {
345+ room_id : tuple (color [:3 ]) + (255 ,)
346+ for room_id , color in adjacency_aware_room_colors (
347+ room_pixels , size_x , colors , lambda value : value or None
348+ ).items ()
349+ }
116350
117351 # The observed occupancy grid contains only:
118352 # - 0: outside/unknown
119- # - 127: wall/obstacle
120- # - 128: floor/free
121- table = bytearray (range (256 ))
122- table [0 ] = 0
123- table [127 ] = 180
124- table [128 ] = 255
125-
126- mapped = grid .translate (bytes (table ))
127- img = Image .frombytes ("L" , (size_x , size_y ), mapped )
128- img = img .transpose (Image .Transpose .FLIP_TOP_BOTTOM ).convert ("RGB" )
353+ # - 127: floor/free
354+ # - 128: wall/obstacle
355+ # Same V1 palette roles as the Q10 renderer: transparent outside, grey
356+ # walls/obstacles, MAP_INSIDE for floor not assigned to any room.
357+ outside = (0 , 0 , 0 , 0 )
358+ floor = tuple (colors .get_color (SupportedColor .MAP_INSIDE )[:3 ]) + (255 ,)
359+ base_colors = {
360+ 0 : outside ,
361+ _FLOOR : floor ,
362+ _WALL : tuple (colors .get_color (SupportedColor .GREY_WALL )[:3 ]) + (255 ,),
363+ }
364+
365+ rgba = bytearray ()
366+ for index , value in enumerate (grid ):
367+ if value == _FLOOR and (room_id := room_pixels [index ]):
368+ color = room_colors .get (room_id , floor )
369+ else :
370+ color = base_colors .get (value , floor )
371+ if index in carpet_pixels and (index // size_x + index % size_x ) % 2 == 0 :
372+ # Checkerboard stipple, like the V1 carpet texture.
373+ color = tuple (min (channel + 60 , 255 ) for channel in color [:3 ]) + (255 ,)
374+ rgba .extend (color )
375+
376+ # RGBA so the shared V1 ImageGenerator can alpha-composite overlay glyphs.
377+ img = Image .frombytes ("RGBA" , (size_x , size_y ), bytes (rgba ))
378+ img = img .transpose (Image .Transpose .FLIP_TOP_BOTTOM )
129379
130380 if scale > 1 :
131381 img = img .resize ((size_x * scale , size_y * scale ), resample = Image .Resampling .NEAREST )
0 commit comments