valetudo-map-parser 0.1.8__py3-none-any.whl → 0.1.9a2__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.
Files changed (28) hide show
  1. valetudo_map_parser/__init__.py +19 -12
  2. valetudo_map_parser/config/auto_crop.py +174 -116
  3. valetudo_map_parser/config/color_utils.py +105 -0
  4. valetudo_map_parser/config/colors.py +662 -13
  5. valetudo_map_parser/config/drawable.py +624 -279
  6. valetudo_map_parser/config/drawable_elements.py +292 -0
  7. valetudo_map_parser/config/enhanced_drawable.py +324 -0
  8. valetudo_map_parser/config/optimized_element_map.py +406 -0
  9. valetudo_map_parser/config/rand25_parser.py +42 -28
  10. valetudo_map_parser/config/room_outline.py +148 -0
  11. valetudo_map_parser/config/shared.py +73 -6
  12. valetudo_map_parser/config/types.py +102 -51
  13. valetudo_map_parser/config/utils.py +841 -0
  14. valetudo_map_parser/hypfer_draw.py +398 -132
  15. valetudo_map_parser/hypfer_handler.py +259 -241
  16. valetudo_map_parser/hypfer_rooms_handler.py +599 -0
  17. valetudo_map_parser/map_data.py +45 -64
  18. valetudo_map_parser/rand25_handler.py +429 -310
  19. valetudo_map_parser/reimg_draw.py +55 -74
  20. valetudo_map_parser/rooms_handler.py +470 -0
  21. valetudo_map_parser-0.1.9a2.dist-info/METADATA +93 -0
  22. valetudo_map_parser-0.1.9a2.dist-info/RECORD +27 -0
  23. {valetudo_map_parser-0.1.8.dist-info → valetudo_map_parser-0.1.9a2.dist-info}/WHEEL +1 -1
  24. valetudo_map_parser/images_utils.py +0 -398
  25. valetudo_map_parser-0.1.8.dist-info/METADATA +0 -23
  26. valetudo_map_parser-0.1.8.dist-info/RECORD +0 -20
  27. {valetudo_map_parser-0.1.8.dist-info → valetudo_map_parser-0.1.9a2.dist-info}/LICENSE +0 -0
  28. {valetudo_map_parser-0.1.8.dist-info → valetudo_map_parser-0.1.9a2.dist-info}/NOTICE.txt +0 -0
@@ -0,0 +1,148 @@
1
+ """
2
+ Room Outline Extraction Utilities.
3
+ Uses scipy for efficient room outline extraction.
4
+ Version: 0.1.9
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from scipy import ndimage
11
+
12
+ from .types import LOGGER
13
+
14
+
15
+ async def extract_room_outline_with_scipy(
16
+ room_mask, min_x, min_y, max_x, max_y, file_name=None, room_id=None
17
+ ):
18
+ """Extract a room outline using scipy for contour finding.
19
+
20
+ Args:
21
+ room_mask: Binary mask of the room (1 for room, 0 for non-room)
22
+ min_x, min_y, max_x, max_y: Bounding box coordinates
23
+ file_name: Optional file name for logging
24
+ room_id: Optional room ID for logging
25
+
26
+ Returns:
27
+ List of points forming the outline of the room
28
+ """
29
+ # If the mask is empty, return a rectangular outline
30
+ if np.sum(room_mask) == 0:
31
+ LOGGER.warning(
32
+ "%s: Empty room mask for room %s, using rectangular outline",
33
+ file_name or "RoomOutline",
34
+ str(room_id) if room_id is not None else "unknown",
35
+ )
36
+ return [(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)]
37
+
38
+ # Use scipy to clean up the mask (remove noise, fill holes)
39
+ # Fill small holes
40
+ room_mask = ndimage.binary_fill_holes(room_mask).astype(np.uint8)
41
+
42
+ # Remove small objects
43
+ labeled_array, num_features = ndimage.label(room_mask)
44
+ if num_features > 1:
45
+ # Find the largest connected component
46
+ component_sizes = np.bincount(labeled_array.ravel())[1:]
47
+ largest_component = np.argmax(component_sizes) + 1
48
+ room_mask = (labeled_array == largest_component).astype(np.uint8)
49
+
50
+ # Find the boundary points by tracing the perimeter
51
+ boundary_points = []
52
+ height, width = room_mask.shape
53
+
54
+ # Scan horizontally (top and bottom edges)
55
+ for x in range(width):
56
+ # Top edge
57
+ for y in range(height):
58
+ if room_mask[y, x] == 1:
59
+ boundary_points.append((x + min_x, y + min_y))
60
+ break
61
+
62
+ # Bottom edge
63
+ for y in range(height - 1, -1, -1):
64
+ if room_mask[y, x] == 1:
65
+ boundary_points.append((x + min_x, y + min_y))
66
+ break
67
+
68
+ # Scan vertically (left and right edges)
69
+ for y in range(height):
70
+ # Left edge
71
+ for x in range(width):
72
+ if room_mask[y, x] == 1:
73
+ boundary_points.append((x + min_x, y + min_y))
74
+ break
75
+
76
+ # Right edge
77
+ for x in range(width - 1, -1, -1):
78
+ if room_mask[y, x] == 1:
79
+ boundary_points.append((x + min_x, y + min_y))
80
+ break
81
+
82
+ # Remove duplicates while preserving order
83
+ unique_points = []
84
+ for point in boundary_points:
85
+ if point not in unique_points:
86
+ unique_points.append(point)
87
+
88
+ # If we have too few points, return a simple rectangle
89
+ if len(unique_points) < 4:
90
+ LOGGER.warning(
91
+ "%s: Too few boundary points for room %s, using rectangular outline",
92
+ file_name or "RoomOutline",
93
+ str(room_id) if room_id is not None else "unknown",
94
+ )
95
+ return [(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)]
96
+
97
+ # Simplify the outline by keeping only significant points
98
+ simplified = simplify_outline(unique_points, tolerance=5)
99
+
100
+ LOGGER.debug(
101
+ "%s: Extracted outline for room %s with %d points",
102
+ file_name or "RoomOutline",
103
+ str(room_id) if room_id is not None else "unknown",
104
+ len(simplified),
105
+ )
106
+
107
+ return simplified
108
+
109
+
110
+ def simplify_outline(points, tolerance=5):
111
+ """Simplify an outline by removing points that don't contribute much to the shape."""
112
+ if len(points) <= 4:
113
+ return points
114
+
115
+ # Start with the first point
116
+ simplified = [points[0]]
117
+
118
+ # Process remaining points
119
+ for i in range(1, len(points) - 1):
120
+ # Get previous and next points
121
+ prev = simplified[-1]
122
+ current = points[i]
123
+ next_point = points[i + 1]
124
+
125
+ # Calculate vectors
126
+ v1 = (current[0] - prev[0], current[1] - prev[1])
127
+ v2 = (next_point[0] - current[0], next_point[1] - current[1])
128
+
129
+ # Calculate change in direction
130
+ dot_product = v1[0] * v2[0] + v1[1] * v2[1]
131
+ len_v1 = (v1[0] ** 2 + v1[1] ** 2) ** 0.5
132
+ len_v2 = (v2[0] ** 2 + v2[1] ** 2) ** 0.5
133
+
134
+ # Avoid division by zero
135
+ if len_v1 == 0 or len_v2 == 0:
136
+ continue
137
+
138
+ # Calculate cosine of angle between vectors
139
+ cos_angle = dot_product / (len_v1 * len_v2)
140
+
141
+ # If angle is significant or distance is large, keep the point
142
+ if abs(cos_angle) < 0.95 or len_v1 > tolerance or len_v2 > tolerance:
143
+ simplified.append(current)
144
+
145
+ # Add the last point
146
+ simplified.append(points[-1])
147
+
148
+ return simplified
@@ -6,10 +6,13 @@ Version: v2024.12.0
6
6
 
7
7
  import asyncio
8
8
  import logging
9
+ from typing import List
9
10
 
10
11
  from .types import (
11
12
  ATTR_CALIBRATION_POINTS,
13
+ ATTR_CAMERA_MODE,
12
14
  ATTR_MARGINS,
15
+ ATTR_OBSTACLES,
13
16
  ATTR_POINTS,
14
17
  ATTR_ROOMS,
15
18
  ATTR_ROTATE,
@@ -19,8 +22,6 @@ from .types import (
19
22
  ATTR_VACUUM_POSITION,
20
23
  ATTR_VACUUM_STATUS,
21
24
  ATTR_ZONES,
22
- ATTR_CAMERA_MODE,
23
- ATTR_OBSTACLES,
24
25
  CONF_ASPECT_RATIO,
25
26
  CONF_AUTO_ZOOM,
26
27
  CONF_OFFSET_BOTTOM,
@@ -35,8 +36,10 @@ from .types import (
35
36
  CONF_ZOOM_LOCK_RATIO,
36
37
  DEFAULT_VALUES,
37
38
  CameraModes,
39
+ Colors,
40
+ TrimsData,
38
41
  )
39
- from .types import Colors
42
+
40
43
 
41
44
  _LOGGER = logging.getLogger(__name__)
42
45
 
@@ -85,6 +88,7 @@ class CameraShared:
85
88
  self.vac_json_id = None # Vacuum json id
86
89
  self.margins = "100" # Image margins
87
90
  self.obstacles_data = None # Obstacles data
91
+ self.obstacles_pos = None # Obstacles position
88
92
  self.offset_top = 0 # Image offset top
89
93
  self.offset_down = 0 # Image offset down
90
94
  self.offset_left = 0 # Image offset left
@@ -99,8 +103,50 @@ class CameraShared:
99
103
  self.map_pred_points = None # Predefined points data
100
104
  self.map_new_path = None # New path data
101
105
  self.map_old_path = None # Old path data
102
- self.trim_crop_data = None
103
106
  self.user_language = None # User language
107
+ self.trim_crop_data = None
108
+ self.trims = TrimsData.from_dict(DEFAULT_VALUES["trims_data"]) # Trims data
109
+ self.skip_room_ids: List[str] = []
110
+ self.device_info = None # Store the device_info
111
+
112
+ @staticmethod
113
+ def _compose_obstacle_links(vacuum_host_ip: str, obstacles: list) -> list | None:
114
+ """
115
+ Compose JSON with obstacle details including the image link.
116
+ """
117
+ obstacle_links = []
118
+ if not obstacles or not vacuum_host_ip:
119
+ return None
120
+
121
+ for obstacle in obstacles:
122
+ # Extract obstacle details
123
+ label = obstacle.get("label", "")
124
+ points = obstacle.get("points", {})
125
+ image_id = obstacle.get("id", "None")
126
+
127
+ if label and points and image_id and vacuum_host_ip:
128
+ # Append formatted obstacle data
129
+ if image_id != "None":
130
+ # Compose the link
131
+ image_link = (
132
+ f"http://{vacuum_host_ip}"
133
+ f"/api/v2/robot/capabilities/ObstacleImagesCapability/img/{image_id}"
134
+ )
135
+ obstacle_links.append(
136
+ {
137
+ "point": points,
138
+ "label": label,
139
+ "link": image_link,
140
+ }
141
+ )
142
+ else:
143
+ obstacle_links.append(
144
+ {
145
+ "point": points,
146
+ "label": label,
147
+ }
148
+ )
149
+ return obstacle_links
104
150
 
105
151
  def update_user_colors(self, user_colors):
106
152
  """Update the user colors."""
@@ -118,6 +164,11 @@ class CameraShared:
118
164
  """Get the rooms colors."""
119
165
  return self.rooms_colors
120
166
 
167
+ def reset_trims(self) -> dict:
168
+ """Reset the trims."""
169
+ self.trims = TrimsData.from_dict(DEFAULT_VALUES["trims_data"])
170
+ return self.trims
171
+
121
172
  async def batch_update(self, **kwargs):
122
173
  """Batch update multiple attributes."""
123
174
  for key, value in kwargs.items():
@@ -137,7 +188,11 @@ class CameraShared:
137
188
  ATTR_VACUUM_JSON_ID: self.vac_json_id,
138
189
  ATTR_CALIBRATION_POINTS: self.attr_calibration_points,
139
190
  }
140
- if self.obstacles_data:
191
+ if self.obstacles_pos and self.vacuum_ips:
192
+ _LOGGER.debug("Generating obstacle links from: %s", self.obstacles_pos)
193
+ self.obstacles_data = self._compose_obstacle_links(
194
+ self.vacuum_ips, self.obstacles_pos
195
+ )
141
196
  attrs[ATTR_OBSTACLES] = self.obstacles_data
142
197
 
143
198
  if self.enable_snapshots:
@@ -167,6 +222,7 @@ class CameraSharedManager:
167
222
  self._lock = asyncio.Lock()
168
223
  self.file_name = file_name
169
224
  self.device_info = device_info
225
+ self.update_shared_data(device_info)
170
226
 
171
227
  # Automatically initialize shared data for the instance
172
228
  # self._init_shared_data(device_info)
@@ -176,6 +232,12 @@ class CameraSharedManager:
176
232
  instance = self.get_instance() # Retrieve the correct instance
177
233
 
178
234
  try:
235
+ # Store the device_info in the instance
236
+ instance.device_info = device_info
237
+ _LOGGER.info(
238
+ "%s: Stored device_info in shared instance", instance.file_name
239
+ )
240
+
179
241
  instance.attr_calibration_points = None
180
242
 
181
243
  # Initialize shared data with defaults from DEFAULT_VALUES
@@ -218,11 +280,16 @@ class CameraSharedManager:
218
280
  instance.vacuum_status_position = device_info.get(
219
281
  CONF_VAC_STAT_POS, DEFAULT_VALUES["vac_status_position"]
220
282
  )
221
-
222
283
  # If enable_snapshots, check for png in www.
223
284
  instance.enable_snapshots = device_info.get(
224
285
  CONF_SNAPSHOTS_ENABLE, DEFAULT_VALUES["enable_www_snapshots"]
225
286
  )
287
+ # Ensure trims are updated correctly
288
+ trim_data = device_info.get("trims_data", DEFAULT_VALUES["trims_data"])
289
+ _LOGGER.debug(
290
+ "%s: Updating shared trims with: %s", instance.file_name, trim_data
291
+ )
292
+ instance.trims = TrimsData.from_dict(trim_data)
226
293
 
227
294
  except TypeError as ex:
228
295
  _LOGGER.error("Shared data can't be initialized due to a TypeError! %s", ex)
@@ -4,29 +4,27 @@ Version 0.0.1
4
4
  """
5
5
 
6
6
  import asyncio
7
- from dataclasses import dataclass
8
7
  import json
9
8
  import logging
10
- from typing import Any, Dict, Tuple, Union
9
+ import threading
10
+ from dataclasses import asdict, dataclass
11
+ from typing import Any, Dict, Optional, Tuple, TypedDict, Union
11
12
 
12
- from PIL import Image
13
13
  import numpy as np
14
+ from PIL import Image
15
+
14
16
 
15
17
  DEFAULT_ROOMS = 1
16
18
 
17
- MY_LOGGER = logging.getLogger(__name__)
19
+ LOGGER = logging.getLogger(__package__)
18
20
 
19
- Color = Union[Tuple[int, int, int], Tuple[int, int, int, int]]
20
- Colors = Dict[str, Color]
21
- CalibrationPoints = list[dict[str, Any]]
22
- RobotPosition = dict[str, int | float]
23
- ChargerPosition = dict[str, Any]
24
- RoomsProperties = dict[str, dict[str, int | list[tuple[Any, Any]]]]
25
- ImageSize = dict[str, int | list[int]]
26
- JsonType = Any # json.loads() return type is Any
27
- PilPNG = Image.Image
28
- NumpyArray = np.ndarray
29
- Point = Tuple[int, int]
21
+
22
+ class RoomProperty(TypedDict):
23
+ number: int
24
+ outline: list[tuple[int, int]]
25
+ name: str
26
+ x: int
27
+ y: int
30
28
 
31
29
 
32
30
  # pylint: disable=no-member
@@ -73,43 +71,37 @@ class TrimCropData:
73
71
  )
74
72
 
75
73
 
76
- # pylint: disable=no-member
77
74
  class RoomStore:
78
- """Store the room data for the vacuum."""
79
-
80
- _instance = None
81
- _lock = asyncio.Lock()
82
-
83
- def __init__(self):
84
- self.vacuums_data = {}
85
-
86
- def __new__(cls):
87
- if cls._instance is None:
88
- cls._instance = super(RoomStore, cls).__new__(cls)
89
- cls._instance.vacuums_data = {}
90
- return cls._instance
91
-
92
- async def async_set_rooms_data(self, vacuum_id: str, rooms_data: dict) -> None:
93
- """Set the room data for the vacuum."""
94
- async with self._lock:
95
- self.vacuums_data[vacuum_id] = rooms_data
75
+ _instances: Dict[str, "RoomStore"] = {}
76
+ _lock = threading.Lock()
77
+
78
+ def __new__(cls, vacuum_id: str, rooms_data: Optional[dict] = None) -> "RoomStore":
79
+ with cls._lock:
80
+ if vacuum_id not in cls._instances:
81
+ instance = super(RoomStore, cls).__new__(cls)
82
+ instance.vacuum_id = vacuum_id
83
+ instance.vacuums_data = rooms_data or {}
84
+ cls._instances[vacuum_id] = instance
85
+ else:
86
+ if rooms_data is not None:
87
+ cls._instances[vacuum_id].vacuums_data = rooms_data
88
+ return cls._instances[vacuum_id]
89
+
90
+ def get_rooms(self) -> dict:
91
+ return self.vacuums_data
92
+
93
+ def set_rooms(self, rooms_data: dict) -> None:
94
+ self.vacuums_data = rooms_data
95
+
96
+ def get_rooms_count(self) -> int:
97
+ if isinstance(self.vacuums_data, dict):
98
+ count = len(self.vacuums_data)
99
+ return count if count > 0 else DEFAULT_ROOMS
100
+ return DEFAULT_ROOMS
96
101
 
97
- async def async_get_rooms_data(self, vacuum_id: str) -> dict:
98
- """Get the room data for a vacuum."""
99
- async with self._lock:
100
- data = self.vacuums_data.get(vacuum_id, {})
101
- if isinstance(data, str):
102
- json_data = json.loads(data)
103
- return json_data
104
- return data
105
-
106
- async def async_get_rooms_count(self, vacuum_id: str) -> int:
107
- """Count the number of rooms for a vacuum."""
108
- async with self._lock:
109
- count = len(self.vacuums_data.get(vacuum_id, {}))
110
- if count == 0:
111
- return DEFAULT_ROOMS
112
- return count
102
+ @classmethod
103
+ def get_all_instances(cls) -> Dict[str, "RoomStore"]:
104
+ return cls._instances
113
105
 
114
106
 
115
107
  # pylint: disable=no-member
@@ -202,8 +194,19 @@ class SnapshotStore:
202
194
  self.vacuum_json_data[vacuum_id] = json_data
203
195
 
204
196
 
197
+ Color = Union[Tuple[int, int, int], Tuple[int, int, int, int]]
198
+ Colors = Dict[str, Color]
199
+ CalibrationPoints = list[dict[str, Any]]
200
+ RobotPosition = dict[str, int | float]
201
+ ChargerPosition = dict[str, Any]
202
+ RoomsProperties = dict[str, RoomProperty]
203
+ ImageSize = dict[str, int | list[int]]
204
+ JsonType = Any # json.loads() return type is Any
205
+ PilPNG = Image.Image
206
+ NumpyArray = np.ndarray
207
+ Point = Tuple[int, int]
208
+
205
209
  CAMERA_STORAGE = "valetudo_camera"
206
- DEFAULT_ROOMS = 1 # 15 is the maximum number of rooms.
207
210
  ATTR_ROTATE = "rotate_image"
208
211
  ATTR_CROP = "crop_image"
209
212
  ATTR_MARGINS = "margins"
@@ -284,6 +287,7 @@ DEFAULT_VALUES = {
284
287
  "vac_status_position": True,
285
288
  "get_svg_file": False,
286
289
  "save_trims": True,
290
+ "trims_data": {"trim_left": 0, "trim_up": 0, "trim_right": 0, "trim_down": 0},
287
291
  "enable_www_snapshots": False,
288
292
  "color_charger": [255, 128, 0],
289
293
  "color_move": [238, 247, 255],
@@ -345,6 +349,7 @@ KEYS_TO_UPDATE = [
345
349
  "offset_bottom",
346
350
  "offset_left",
347
351
  "offset_right",
352
+ "trims_data",
348
353
  "auto_zoom",
349
354
  "zoom_lock_ratio",
350
355
  "show_vac_status",
@@ -588,3 +593,49 @@ class CameraModes:
588
593
  CAMERA_STANDBY = "camera_standby"
589
594
  CAMERA_OFF = False
590
595
  CAMERA_ON = True
596
+
597
+
598
+ # noinspection PyTypeChecker
599
+ @dataclass
600
+ class TrimsData:
601
+ """Dataclass to store and manage trims data."""
602
+
603
+ floor: str = ""
604
+ trim_up: int = 0
605
+ trim_left: int = 0
606
+ trim_down: int = 0
607
+ trim_right: int = 0
608
+
609
+ @classmethod
610
+ def from_json(cls, json_data: str):
611
+ """Create a TrimsConfig instance from a JSON string."""
612
+ data = json.loads(json_data)
613
+ return cls(
614
+ floor=data.get("floor", ""),
615
+ trim_up=data.get("trim_up", 0),
616
+ trim_left=data.get("trim_left", 0),
617
+ trim_down=data.get("trim_down", 0),
618
+ trim_right=data.get("trim_right", 0),
619
+ )
620
+
621
+ def to_json(self) -> str:
622
+ """Convert TrimsConfig instance to a JSON string."""
623
+ return json.dumps(asdict(self))
624
+
625
+ @classmethod
626
+ def from_dict(cls, data: dict):
627
+ """Initialize TrimData from a dictionary."""
628
+ return cls(**data)
629
+
630
+ def to_dict(self) -> dict:
631
+ """Convert TrimData to a dictionary."""
632
+ return asdict(self)
633
+
634
+ def clear(self) -> dict:
635
+ """Clear all the trims."""
636
+ self.floor = ""
637
+ self.trim_up = 0
638
+ self.trim_left = 0
639
+ self.trim_down = 0
640
+ self.trim_right = 0
641
+ return asdict(self)