66
77import io
88import math
9+ from collections import deque
910from dataclasses import dataclass
1011
1112from google .protobuf .message import DecodeError
1213from PIL import Image
14+ from vacuum_map_parser_base .config .color import ColorsPalette
1315from vacuum_map_parser_base .config .drawable import Drawable
1416from vacuum_map_parser_base .config .image_config import ImageConfig
1517from vacuum_map_parser_base .map_data import ImageData , MapData , Path , Point , Room
1820from roborock .map .proto .b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
1921
2022from .map_parser import MapParserConfig , ParsedMapData , _create_image_generator
23+ from .room_colors import adjacency_aware_room_colors
2124
2225_MAP_FILE_FORMAT = "PNG"
2326
27+ _FLOOR = 127
28+ _WALL = 128
29+
2430_B01_DRAWABLES = [
2531 Drawable .CHARGER ,
2632 Drawable .PATH ,
33+ Drawable .ROOM_NAMES ,
2734 Drawable .VACUUM_POSITION ,
2835]
2936
@@ -48,7 +55,8 @@ def parse(self, payload: bytes) -> ParsedMapData:
4855 size_x , size_y , grid = _extract_grid (parsed )
4956 room_names = _extract_room_names (parsed )
5057
51- image = _render_occupancy_image (grid , size_x = size_x , size_y = size_y , scale = self ._config .map_scale )
58+ room_pixels = _assign_room_pixels (parsed , grid , size_x = size_x , size_y = size_y )
59+ image = _render_occupancy_image (grid , room_pixels , size_x = size_x , size_y = size_y , scale = self ._config .map_scale )
5260
5361 map_data = MapData ()
5462 map_data .image = ImageData (
@@ -70,6 +78,7 @@ def parse(self, payload: bytes) -> ParsedMapData:
7078 projector = _WorldToPixel (parsed )
7179 has_drawables = _place_poses (map_data , parsed , projector )
7280 map_data .rooms = _extract_rooms (parsed , projector , room_names )
81+ has_drawables = has_drawables or bool (map_data .rooms )
7382
7483 if has_drawables :
7584 generator = _create_image_generator (
@@ -208,22 +217,108 @@ def _extract_room_names(parsed: RobotMap) -> dict[int, str]:
208217 return room_names
209218
210219
211- def _render_occupancy_image (grid : bytes , * , size_x : int , size_y : int , scale : int ) -> Image .Image :
212- """Render the B01 occupancy grid into a simple image."""
220+ def _assign_room_pixels (parsed : RobotMap , grid : bytes , * , size_x : int , size_y : int ) -> bytearray :
221+ """Assign a room id to each floor pixel by flood-filling from room labels.
222+
223+ The grid itself carries no room ids; room geometry arrives as boundary
224+ pixel chains (``roomOutline``). Each room is filled from its label
225+ position, bounded by walls and by any room's outline pixels, all in the
226+ raw (bottom-up) grid space.
227+ """
228+ assignment = bytearray (len (grid ))
229+ outlines = {outline .roomId : outline for outline in parsed .roomOutline if outline .points }
230+ if not outlines :
231+ return assignment
232+
233+ barrier = {
234+ point .y * size_x + point .x
235+ for outline in outlines .values ()
236+ for point in outline .points
237+ if point .x < size_x and point .y < size_y
238+ }
239+ floor_count = grid .count (_FLOOR )
240+ # ponytail: leak guard — a gapped outline would flood the whole floor, so a
241+ # fill larger than half of it is discarded instead of tracing outline gaps.
242+ max_fill = floor_count // 2
243+
244+ head = parsed .mapHead
245+ label_positions = {
246+ room .roomId : (
247+ int ((room .roomNamePost .x - head .minX ) / head .resolution ),
248+ int ((room .roomNamePost .y - head .minY ) / head .resolution ),
249+ )
250+ for room in parsed .roomDataInfo
251+ if room .HasField ("roomNamePost" )
252+ }
253+
254+ for room_id , outline in outlines .items ():
255+ seed = label_positions .get (room_id )
256+ if seed is None :
257+ continue
258+ col , row = seed
259+ start = row * size_x + col
260+ if not (0 <= col < size_x and 0 <= row < size_y ) or grid [start ] != _FLOOR :
261+ continue
262+ filled = []
263+ queue = deque ([start ])
264+ seen = {start }
265+ while queue and len (filled ) <= max_fill :
266+ index = queue .popleft ()
267+ filled .append (index )
268+ for neighbor in (index - 1 , index + 1 , index - size_x , index + size_x ):
269+ if (
270+ 0 <= neighbor < len (grid )
271+ and neighbor not in seen
272+ and grid [neighbor ] == _FLOOR
273+ and assignment [neighbor ] == 0
274+ and neighbor not in barrier
275+ # Row-wrap guard for the horizontal neighbors.
276+ and abs (neighbor % size_x - index % size_x ) <= 1
277+ ):
278+ seen .add (neighbor )
279+ queue .append (neighbor )
280+ if len (filled ) > max_fill :
281+ continue
282+ for index in filled :
283+ assignment [index ] = room_id
284+ # Color the room's own boundary ring too where it sits on floor.
285+ for point in outline .points :
286+ index = point .y * size_x + point .x
287+ if index < len (grid ) and grid [index ] == _FLOOR and assignment [index ] == 0 :
288+ assignment [index ] = room_id
289+
290+ return assignment
291+
292+
293+ def _render_occupancy_image (
294+ grid : bytes , room_pixels : bytearray , * , size_x : int , size_y : int , scale : int
295+ ) -> Image .Image :
296+ """Render the B01 occupancy grid with per-room colors."""
297+
298+ room_colors = {
299+ room_id : tuple (color [:3 ]) + (255 ,)
300+ for room_id , color in adjacency_aware_room_colors (
301+ room_pixels , size_x , ColorsPalette (), lambda value : value or None
302+ ).items ()
303+ }
213304
214305 # The observed occupancy grid contains only:
215306 # - 0: outside/unknown
216307 # - 127: floor/free
217308 # - 128: wall/obstacle
218- table = bytearray (range (256 ))
219- table [0 ] = 0
220- table [127 ] = 180
221- table [128 ] = 255
309+ base_colors = {0 : (0 , 0 , 0 , 255 ), _FLOOR : (180 , 180 , 180 , 255 ), _WALL : (255 , 255 , 255 , 255 )}
310+ fallback = (180 , 180 , 180 , 255 )
311+
312+ rgba = bytearray ()
313+ for index , value in enumerate (grid ):
314+ if value == _FLOOR and (room_id := room_pixels [index ]):
315+ rgba .extend (room_colors .get (room_id , fallback ))
316+ else :
317+ rgba .extend (base_colors .get (value , fallback ))
222318
223- mapped = grid .translate (bytes (table ))
224- img = Image .frombytes ("L" , (size_x , size_y ), mapped )
225319 # RGBA so the shared V1 ImageGenerator can alpha-composite overlay glyphs.
226- img = img .transpose (Image .Transpose .FLIP_TOP_BOTTOM ).convert ("RGBA" )
320+ img = Image .frombytes ("RGBA" , (size_x , size_y ), bytes (rgba ))
321+ img = img .transpose (Image .Transpose .FLIP_TOP_BOTTOM )
227322
228323 if scale > 1 :
229324 img = img .resize ((size_x * scale , size_y * scale ), resample = Image .Resampling .NEAREST )
0 commit comments