valetudo-map-parser 0.1.9b40__py3-none-any.whl → 0.1.9b42__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,10 +10,13 @@ from __future__ import annotations
10
10
  import json
11
11
  import logging
12
12
 
13
+ import numpy as np
13
14
  from PIL import Image
14
15
 
15
16
  from .config.auto_crop import AutoCrop
16
17
  from .config.drawable import Drawable
18
+ from .config.drawable_elements import DrawableElement, DrawingConfig
19
+ from .config.enhanced_drawable import EnhancedDrawable
17
20
  from .config.shared import CameraShared
18
21
  from .config.types import COLORS, CalibrationPoints, Colors, RoomsProperties, RoomStore
19
22
  from .config.utils import BaseHandler, prepare_resize_params
@@ -35,7 +38,11 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
35
38
  AutoCrop.__init__(self, self)
36
39
  self.calibration_data = None # camera shared data.
37
40
  self.data = ImageData # imported Image Data Module.
38
- self.draw = Drawable # imported Drawing utilities
41
+
42
+ # Initialize drawing configuration using the shared utility function
43
+ from .config.utils import initialize_drawing_config
44
+ self.drawing_config, self.draw, self.enhanced_draw = initialize_drawing_config(self)
45
+
39
46
  self.go_to = None # vacuum go to data
40
47
  self.img_hash = None # hash of the image calculated to check differences.
41
48
  self.img_base_layer = None # numpy array store the map base layer.
@@ -44,6 +51,73 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
44
51
  self.imd = ImDraw(self) # Image Draw class.
45
52
  self.color_grey = (128, 128, 128, 255)
46
53
  self.file_name = self.shared.file_name # file name of the vacuum.
54
+ self.element_map = None # Map of element codes
55
+
56
+ @staticmethod
57
+ def get_corners(x_max, x_min, y_max, y_min):
58
+ """Get the corners of the room."""
59
+ return [(x_min, y_min), (x_max, y_min), (x_max, y_max), (x_min, y_max)]
60
+
61
+ async def extract_room_outline_from_map(self, room_id_int, pixels, pixel_size):
62
+ """Extract the outline of a room using the pixel data and element map.
63
+
64
+ Args:
65
+ room_id_int: The room ID as an integer
66
+ pixels: List of pixel coordinates in the format [[x, y, z], ...]
67
+ pixel_size: Size of each pixel
68
+
69
+ Returns:
70
+ List of points forming the outline of the room
71
+ """
72
+ # Calculate x and y min/max from compressed pixels for rectangular fallback
73
+ x_values = []
74
+ y_values = []
75
+ for x, y, z in pixels:
76
+ for i in range(z):
77
+ x_values.append(x + i * pixel_size)
78
+ y_values.append(y)
79
+
80
+ if not x_values or not y_values:
81
+ return []
82
+
83
+ min_x, max_x = min(x_values), max(x_values)
84
+ min_y, max_y = min(y_values), max(y_values)
85
+
86
+ # If we don't have an element map, return a rectangular outline
87
+ if not hasattr(self, "element_map") or self.element_map is None:
88
+ # Return rectangular outline
89
+ return [(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)]
90
+
91
+ # Create a binary mask for this room using the pixel data
92
+ # This is more reliable than using the element_map since we're directly using the pixel data
93
+ height, width = self.element_map.shape
94
+ room_mask = np.zeros((height, width), dtype=np.uint8)
95
+
96
+ # Fill the mask with room pixels using the pixel data
97
+ for x, y, z in pixels:
98
+ for i in range(z):
99
+ px = x + i * pixel_size
100
+ py = y
101
+ # Make sure we're within bounds
102
+ if 0 <= py < height and 0 <= px < width:
103
+ # Mark a pixel_size x pixel_size block in the mask
104
+ for dx in range(pixel_size):
105
+ for dy in range(pixel_size):
106
+ if py + dy < height and px + dx < width:
107
+ room_mask[py + dy, px + dx] = 1
108
+
109
+ # Debug log to check if we have any room pixels
110
+ num_room_pixels = np.sum(room_mask)
111
+ _LOGGER.debug(
112
+ "%s: Room %s mask has %d pixels",
113
+ self.file_name, str(room_id_int), int(num_room_pixels)
114
+ )
115
+
116
+ # Use the shared utility function to extract the room outline
117
+ from .config.utils import async_extract_room_outline
118
+ return await async_extract_room_outline(
119
+ room_mask, min_x, min_y, max_x, max_y, self.file_name, room_id_int, _LOGGER
120
+ )
47
121
 
48
122
  async def async_extract_room_properties(self, json_data) -> RoomsProperties:
49
123
  """Extract room properties from the JSON data."""
@@ -67,7 +141,27 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
67
141
  x_max,
68
142
  y_max,
69
143
  ) = await self.data.async_get_rooms_coordinates(pixels, pixel_size)
144
+
145
+ # Get rectangular corners as a fallback
70
146
  corners = self.get_corners(x_max, x_min, y_max, y_min)
147
+
148
+ # Try to extract a more accurate room outline from the element map
149
+ try:
150
+ # Extract the room outline using the element map
151
+ outline = await self.extract_room_outline_from_map(
152
+ segment_id, pixels, pixel_size
153
+ )
154
+ _LOGGER.debug(
155
+ "%s: Traced outline for room %s with %d points",
156
+ self.file_name,
157
+ segment_id,
158
+ len(outline),
159
+ )
160
+ except (ValueError, IndexError, TypeError, ArithmeticError) as e:
161
+ from .config.utils import handle_room_outline_error
162
+ handle_room_outline_error(self.file_name, segment_id, e, _LOGGER)
163
+ outline = corners
164
+
71
165
  room_id = str(segment_id)
72
166
  self.rooms_pos.append(
73
167
  {
@@ -77,7 +171,7 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
77
171
  )
78
172
  room_properties[room_id] = {
79
173
  "number": segment_id,
80
- "outline": corners,
174
+ "outline": outline, # Use the detailed outline from the element map
81
175
  "name": name,
82
176
  "x": ((x_min + x_max) // 2),
83
177
  "y": ((y_min + y_max) // 2),
@@ -138,32 +232,153 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
138
232
  new_frame_hash = await self.calculate_array_hash(layers, active)
139
233
  if self.frame_number == 0:
140
234
  self.img_hash = new_frame_hash
141
- # empty image
235
+ # Create empty image
142
236
  img_np_array = await self.draw.create_empty_image(
143
237
  size_x, size_y, colors["background"]
144
238
  )
145
- # overlapping layers and segments
146
- for layer_type, compressed_pixels_list in layers.items():
147
- room_id, img_np_array = await self.imd.async_draw_base_layer(
148
- img_np_array,
149
- compressed_pixels_list,
150
- layer_type,
151
- colors["wall"],
152
- colors["zone_clean"],
153
- pixel_size,
239
+
240
+ # Create element map for tracking what's drawn where
241
+ self.element_map = np.zeros((size_y, size_x), dtype=np.int32)
242
+ self.element_map[:] = DrawableElement.FLOOR
243
+
244
+ _LOGGER.info("%s: Drawing map with color blending", self.file_name)
245
+
246
+ # Draw layers and segments if enabled
247
+ room_id = 0
248
+ # Keep track of disabled rooms to skip their walls later
249
+ disabled_rooms = set()
250
+
251
+ if self.drawing_config.is_enabled(DrawableElement.FLOOR):
252
+ # First pass: identify disabled rooms
253
+ for layer_type, compressed_pixels_list in layers.items():
254
+ # Check if this is a room layer
255
+ if layer_type == "segment":
256
+ # The room_id is the current room being processed (0-based index)
257
+ # We need to check if ROOM_{room_id+1} is enabled (1-based in DrawableElement)
258
+ current_room_id = room_id + 1
259
+ if 1 <= current_room_id <= 15:
260
+ room_element = getattr(
261
+ DrawableElement, f"ROOM_{current_room_id}", None
262
+ )
263
+ if (
264
+ room_element
265
+ and not self.drawing_config.is_enabled(
266
+ room_element
267
+ )
268
+ ):
269
+ # Add this room to the disabled rooms set
270
+ disabled_rooms.add(room_id)
271
+ _LOGGER.debug(
272
+ "%s: Room %d is disabled and will be skipped",
273
+ self.file_name,
274
+ current_room_id,
275
+ )
276
+ room_id = (
277
+ room_id + 1
278
+ ) % 16 # Cycle room_id back to 0 after 15
279
+
280
+ # Reset room_id for the actual drawing pass
281
+ room_id = 0
282
+
283
+ # Second pass: draw enabled rooms and walls
284
+ for layer_type, compressed_pixels_list in layers.items():
285
+ # Check if this is a room layer
286
+ is_room_layer = layer_type == "segment"
287
+
288
+ # If it's a room layer, check if the specific room is enabled
289
+ if is_room_layer:
290
+ # The room_id is the current room being processed (0-based index)
291
+ # We need to check if ROOM_{room_id+1} is enabled (1-based in DrawableElement)
292
+ current_room_id = room_id + 1
293
+ if 1 <= current_room_id <= 15:
294
+ room_element = getattr(
295
+ DrawableElement, f"ROOM_{current_room_id}", None
296
+ )
297
+ if room_element:
298
+ # Log the room check for debugging
299
+ _LOGGER.debug(
300
+ "%s: Checking if room %d is enabled: %s",
301
+ self.file_name,
302
+ current_room_id,
303
+ self.drawing_config.is_enabled(
304
+ room_element
305
+ ),
306
+ )
307
+
308
+ # Skip this room if it's disabled
309
+ if not self.drawing_config.is_enabled(
310
+ room_element
311
+ ):
312
+ room_id = (
313
+ room_id + 1
314
+ ) % 16 # Increment room_id even if we skip
315
+ continue
316
+
317
+ # Check if this is a wall layer and if walls are enabled
318
+ is_wall_layer = layer_type == "wall"
319
+ if is_wall_layer:
320
+ if not self.drawing_config.is_enabled(
321
+ DrawableElement.WALL
322
+ ):
323
+ _LOGGER.info(
324
+ "%s: Skipping wall layer because WALL element is disabled",
325
+ self.file_name,
326
+ )
327
+ continue
328
+
329
+ # Filter out walls for disabled rooms
330
+ if disabled_rooms:
331
+ # Need to modify compressed_pixels_list to exclude walls of disabled rooms
332
+ # This requires knowledge of which walls belong to which rooms
333
+ # For now, we'll just log that we're drawing walls for all rooms
334
+ _LOGGER.debug(
335
+ "%s: Drawing walls for all rooms (including disabled ones)",
336
+ self.file_name,
337
+ )
338
+ # In a real implementation, we would filter the walls here
339
+
340
+ # Draw the layer
341
+ (
342
+ room_id,
343
+ img_np_array,
344
+ ) = await self.imd.async_draw_base_layer(
345
+ img_np_array,
346
+ compressed_pixels_list,
347
+ layer_type,
348
+ colors["wall"],
349
+ colors["zone_clean"],
350
+ pixel_size,
351
+ disabled_rooms if layer_type == "wall" else None,
352
+ )
353
+
354
+ # Update element map for this layer
355
+ if is_room_layer and 0 < room_id <= 15:
356
+ # Mark the room in the element map
357
+ room_element = getattr(
358
+ DrawableElement, f"ROOM_{room_id}", None
359
+ )
360
+ if room_element:
361
+ # This is a simplification - in a real implementation we would
362
+ # need to identify the exact pixels that belong to this room
363
+ pass
364
+
365
+ # Draw the virtual walls if enabled
366
+ if self.drawing_config.is_enabled(DrawableElement.VIRTUAL_WALL):
367
+ img_np_array = await self.imd.async_draw_virtual_walls(
368
+ m_json, img_np_array, colors["no_go"]
369
+ )
370
+
371
+ # Draw charger if enabled
372
+ if self.drawing_config.is_enabled(DrawableElement.CHARGER):
373
+ img_np_array = await self.imd.async_draw_charger(
374
+ img_np_array, entity_dict, colors["charger"]
375
+ )
376
+
377
+ # Draw obstacles if enabled
378
+ if self.drawing_config.is_enabled(DrawableElement.OBSTACLE):
379
+ img_np_array = await self.imd.async_draw_obstacle(
380
+ img_np_array, entity_dict, colors["no_go"]
154
381
  )
155
- # Draw the virtual walls if any.
156
- img_np_array = await self.imd.async_draw_virtual_walls(
157
- m_json, img_np_array, colors["no_go"]
158
- )
159
- # Draw charger.
160
- img_np_array = await self.imd.async_draw_charger(
161
- img_np_array, entity_dict, colors["charger"]
162
- )
163
- # Draw obstacles if any.
164
- img_np_array = await self.imd.async_draw_obstacle(
165
- img_np_array, entity_dict, colors["no_go"]
166
- )
167
382
  # Robot and rooms position
168
383
  if (room_id > 0) and not self.room_propriety:
169
384
  self.room_propriety = await self.async_extract_room_properties(
@@ -193,36 +408,59 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
193
408
  # Copy the base layer to the new image.
194
409
  img_np_array = await self.async_copy_array(self.img_base_layer)
195
410
  # All below will be drawn at each frame.
196
- # Draw zones if any.
197
- img_np_array = await self.imd.async_draw_zones(
198
- m_json,
199
- img_np_array,
200
- colors["zone_clean"],
201
- colors["no_go"],
202
- )
203
- # Draw the go_to target flag.
204
- img_np_array = await self.imd.draw_go_to_flag(
205
- img_np_array, entity_dict, colors["go_to"]
206
- )
207
- # Draw path prediction and paths.
208
- img_np_array = await self.imd.async_draw_paths(
209
- img_np_array, m_json, colors["move"], self.color_grey
411
+ # Draw zones if any and if enabled
412
+ if self.drawing_config.is_enabled(DrawableElement.RESTRICTED_AREA):
413
+ img_np_array = await self.imd.async_draw_zones(
414
+ m_json,
415
+ img_np_array,
416
+ colors["zone_clean"],
417
+ colors["no_go"],
418
+ )
419
+
420
+ # Draw the go_to target flag if enabled
421
+ if self.drawing_config.is_enabled(DrawableElement.GO_TO_TARGET):
422
+ img_np_array = await self.imd.draw_go_to_flag(
423
+ img_np_array, entity_dict, colors["go_to"]
424
+ )
425
+
426
+ # Draw path prediction and paths if enabled
427
+ path_enabled = self.drawing_config.is_enabled(DrawableElement.PATH)
428
+ _LOGGER.info(
429
+ "%s: PATH element enabled: %s", self.file_name, path_enabled
210
430
  )
431
+ if path_enabled:
432
+ _LOGGER.info("%s: Drawing path", self.file_name)
433
+ img_np_array = await self.imd.async_draw_paths(
434
+ img_np_array, m_json, colors["move"], self.color_grey
435
+ )
436
+ else:
437
+ _LOGGER.info("%s: Skipping path drawing", self.file_name)
438
+
211
439
  # Check if the robot is docked.
212
440
  if self.shared.vacuum_state == "docked":
213
441
  # Adjust the robot angle.
214
442
  robot_position_angle -= 180
215
443
 
216
- if robot_pos:
444
+ # Draw the robot if enabled
445
+ if robot_pos and self.drawing_config.is_enabled(DrawableElement.ROBOT):
446
+ # Get robot color (allows for customization)
447
+ robot_color = self.drawing_config.get_property(
448
+ DrawableElement.ROBOT, "color", colors["robot"]
449
+ )
450
+
217
451
  # Draw the robot
218
452
  img_np_array = await self.draw.robot(
219
453
  layers=img_np_array,
220
454
  x=robot_position[0],
221
455
  y=robot_position[1],
222
456
  angle=robot_position_angle,
223
- fill=colors["robot"],
457
+ fill=robot_color,
224
458
  robot_state=self.shared.vacuum_state,
225
459
  )
460
+
461
+ # Update element map for robot position
462
+ from .config.utils import update_element_map_with_robot
463
+ update_element_map_with_robot(self.element_map, robot_position, DrawableElement.ROBOT)
226
464
  # Resize the image
227
465
  img_np_array = await self.async_auto_trim_and_zoom_image(
228
466
  img_np_array,
@@ -287,3 +525,69 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
287
525
  calibration_data.append(calibration_point)
288
526
  del vacuum_points, map_points, calibration_point, rotation_angle # free memory.
289
527
  return calibration_data
528
+
529
+ # Element selection methods
530
+ def enable_element(self, element_code: DrawableElement) -> None:
531
+ """Enable drawing of a specific element."""
532
+ self.drawing_config.enable_element(element_code)
533
+ _LOGGER.info(
534
+ "%s: Enabled element %s, now enabled: %s",
535
+ self.file_name,
536
+ element_code.name,
537
+ self.drawing_config.is_enabled(element_code),
538
+ )
539
+
540
+ def disable_element(self, element_code: DrawableElement) -> None:
541
+ """Disable drawing of a specific element."""
542
+ from .config.utils import manage_drawable_elements
543
+ manage_drawable_elements(self, "disable", element_code=element_code)
544
+
545
+ def set_elements(self, element_codes: list[DrawableElement]) -> None:
546
+ """Enable only the specified elements, disable all others."""
547
+ from .config.utils import manage_drawable_elements
548
+ manage_drawable_elements(self, "set_elements", element_codes=element_codes)
549
+
550
+ def set_element_property(
551
+ self, element_code: DrawableElement, property_name: str, value
552
+ ) -> None:
553
+ """Set a drawing property for an element."""
554
+ from .config.utils import manage_drawable_elements
555
+ manage_drawable_elements(self, "set_property", element_code=element_code, property_name=property_name, value=value)
556
+
557
+ def get_element_at_position(self, x: int, y: int) -> DrawableElement | None:
558
+ """Get the element code at a specific position."""
559
+ from .config.utils import get_element_at_position
560
+ return get_element_at_position(self.element_map, x, y)
561
+
562
+ def get_room_at_position(self, x: int, y: int) -> int | None:
563
+ """Get the room ID at a specific position, or None if not a room."""
564
+ from .config.utils import get_room_at_position
565
+ return get_room_at_position(self.element_map, x, y, DrawableElement.ROOM_1)
566
+
567
+ @staticmethod
568
+ def blend_colors(self, base_color, overlay_color):
569
+ """
570
+ Blend two RGBA colors, considering alpha channels.
571
+
572
+ Args:
573
+ base_color: The base RGBA color
574
+ overlay_color: The overlay RGBA color to blend on top
575
+
576
+ Returns:
577
+ The blended RGBA color
578
+ """
579
+ from .config.utils import blend_colors
580
+ return blend_colors(base_color, overlay_color)
581
+
582
+ def blend_pixel(self, array, x, y, color, element):
583
+ """
584
+ Blend a pixel color with the existing color at the specified position.
585
+ Also updates the element map if the new element has higher z-index.
586
+ """
587
+ from .config.utils import blend_pixel
588
+ return blend_pixel(array, x, y, color, element, self.element_map, self.drawing_config)
589
+
590
+ @staticmethod
591
+ async def async_copy_array(array):
592
+ """Copy the array."""
593
+ return array.copy()