valetudo-map-parser 0.1.9b51__tar.gz → 0.1.9b53__tar.gz

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 (27) hide show
  1. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/PKG-INFO +1 -1
  2. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/colors.py +14 -4
  3. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/drawable.py +0 -3
  4. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/enhanced_drawable.py +2 -2
  5. valetudo_map_parser-0.1.9b53/SCR/valetudo_map_parser/hypfer_rooms_handler.py +599 -0
  6. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/pyproject.toml +1 -1
  7. valetudo_map_parser-0.1.9b51/SCR/valetudo_map_parser/hypfer_rooms_handler.py +0 -406
  8. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/LICENSE +0 -0
  9. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/NOTICE.txt +0 -0
  10. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/README.md +0 -0
  11. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/__init__.py +0 -0
  12. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/__init__.py +0 -0
  13. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/auto_crop.py +0 -0
  14. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/color_utils.py +0 -0
  15. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/drawable_elements.py +0 -0
  16. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/optimized_element_map.py +0 -0
  17. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/rand25_parser.py +0 -0
  18. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/room_outline.py +0 -0
  19. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/shared.py +0 -0
  20. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/types.py +0 -0
  21. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/config/utils.py +0 -0
  22. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/hypfer_draw.py +0 -0
  23. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/hypfer_handler.py +0 -0
  24. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/map_data.py +0 -0
  25. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/py.typed +0 -0
  26. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/rand25_handler.py +0 -0
  27. {valetudo_map_parser-0.1.9b51 → valetudo_map_parser-0.1.9b53}/SCR/valetudo_map_parser/reimg_draw.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: valetudo-map-parser
3
- Version: 0.1.9b51
3
+ Version: 0.1.9b53
4
4
  Summary: A Python library to parse Valetudo map data returning a PIL Image object.
5
5
  License: Apache-2.0
6
6
  Author: Sandro Cantarella
@@ -492,10 +492,20 @@ class ColorsManagement:
492
492
  background = tuple(map(int, array[y, x]))
493
493
 
494
494
  # Update cache (with simple LRU-like behavior)
495
- if len(cache) >= ColorsManagement._cache_size:
496
- # Remove a random entry if cache is full
497
- cache.pop(next(iter(cache)))
498
- cache[cache_key] = background
495
+ try:
496
+ if len(cache) >= ColorsManagement._cache_size:
497
+ # Remove a random entry if cache is full
498
+ if cache: # Make sure cache is not empty
499
+ cache.pop(next(iter(cache)))
500
+ else:
501
+ # If cache is somehow empty but len reported >= _cache_size
502
+ # This is an edge case that shouldn't happen but we handle it
503
+ pass
504
+ cache[cache_key] = background
505
+ except KeyError:
506
+ # If we encounter a KeyError, reset the cache
507
+ # This is a rare edge case that might happen in concurrent access
508
+ ColorsManagement._bg_color_cache = {cache_key: background}
499
509
 
500
510
  except (IndexError, ValueError):
501
511
  return foreground
@@ -57,9 +57,6 @@ class Drawable:
57
57
  # Extract alpha from color
58
58
  alpha = color[3] if len(color) == 4 else 255
59
59
 
60
- # For debugging
61
- _LOGGER.debug("Drawing with color %s and alpha %s", color, alpha)
62
-
63
60
  # Create the full color with alpha
64
61
  full_color = color if len(color) == 4 else (*color, 255)
65
62
 
@@ -14,11 +14,11 @@ from typing import Optional, Tuple
14
14
  import numpy as np
15
15
 
16
16
  from .drawable import Drawable
17
- from SCR.valetudo_map_parser.config.drawable_elements import (
17
+ from .drawable_elements import (
18
18
  DrawableElement,
19
19
  DrawingConfig,
20
20
  )
21
- from SCR.valetudo_map_parser.config.colors import ColorsManagement
21
+ from .colors import ColorsManagement
22
22
 
23
23
 
24
24
  # Type aliases
@@ -0,0 +1,599 @@
1
+ """
2
+ Hipfer Rooms Handler Module.
3
+ Handles room data extraction and processing for Valetudo Hipfer vacuum maps.
4
+ Provides async methods for room outline extraction and properties management.
5
+ Version: 0.1.9
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from math import sqrt
11
+ from typing import Any, Dict, Optional, List, Tuple
12
+
13
+ import numpy as np
14
+
15
+ from .config.drawable_elements import DrawableElement, DrawingConfig
16
+ from .config.types import LOGGER, RoomsProperties, RoomStore
17
+
18
+
19
+ class HypferRoomsHandler:
20
+ """
21
+ Handler for extracting and managing room data from Hipfer vacuum maps.
22
+
23
+ This class provides methods to:
24
+ - Extract room outlines using the Ramer-Douglas-Peucker algorithm
25
+ - Process room properties from JSON data
26
+ - Generate room masks and extract contours
27
+
28
+ All methods are async for better integration with the rest of the codebase.
29
+ """
30
+
31
+ def __init__(self, vacuum_id: str, drawing_config: Optional[DrawingConfig] = None):
32
+ """
33
+ Initialize the HipferRoomsHandler.
34
+
35
+ Args:
36
+ vacuum_id: Identifier for the vacuum
37
+ drawing_config: Configuration for which elements to draw (optional)
38
+ """
39
+ self.vacuum_id = vacuum_id
40
+ self.drawing_config = drawing_config
41
+ self.current_json_data = None # Will store the current JSON data being processed
42
+
43
+ @staticmethod
44
+ def sublist(data: list, chunk_size: int) -> list:
45
+ return [data[i : i + chunk_size] for i in range(0, len(data), chunk_size)]
46
+
47
+ # Cache for RDP results
48
+ _rdp_cache = {}
49
+
50
+ @staticmethod
51
+ def perpendicular_distance(
52
+ point: tuple[int, int], line_start: tuple[int, int], line_end: tuple[int, int]
53
+ ) -> float:
54
+ """Calculate the perpendicular distance from a point to a line.
55
+ Optimized for performance.
56
+ """
57
+ # Fast path for point-to-point distance
58
+ if line_start == line_end:
59
+ dx = point[0] - line_start[0]
60
+ dy = point[1] - line_start[1]
61
+ return sqrt(dx*dx + dy*dy)
62
+
63
+ x, y = point
64
+ x1, y1 = line_start
65
+ x2, y2 = line_end
66
+
67
+ # Precompute differences for efficiency
68
+ dx = x2 - x1
69
+ dy = y2 - y1
70
+
71
+ # Calculate the line length squared (avoid sqrt until needed)
72
+ line_length_sq = dx*dx + dy*dy
73
+ if line_length_sq == 0:
74
+ return 0
75
+
76
+ # Calculate the distance from the point to the line
77
+ # Using the formula: |cross_product| / |line_vector|
78
+ # This is more efficient than the original formula
79
+ cross_product = abs(dy * x - dx * y + x2 * y1 - y2 * x1)
80
+ return cross_product / sqrt(line_length_sq)
81
+
82
+ async def rdp(
83
+ self, points: List[Tuple[int, int]], epsilon: float
84
+ ) -> List[Tuple[int, int]]:
85
+ """Ramer-Douglas-Peucker algorithm for simplifying a curve.
86
+ Optimized with caching and better performance.
87
+ """
88
+ # Create a hashable key for caching
89
+ # Convert points to a tuple for hashing
90
+ points_tuple = tuple(points)
91
+ cache_key = (points_tuple, epsilon)
92
+
93
+ # Check cache first
94
+ if cache_key in self._rdp_cache:
95
+ return self._rdp_cache[cache_key]
96
+
97
+ # Base case
98
+ if len(points) <= 2:
99
+ return points
100
+
101
+ # For very small point sets, process directly without recursion
102
+ if len(points) <= 5:
103
+ # Find the point with the maximum distance
104
+ dmax = 0
105
+ index = 0
106
+ for i in range(1, len(points) - 1):
107
+ d = self.perpendicular_distance(points[i], points[0], points[-1])
108
+ if d > dmax:
109
+ index = i
110
+ dmax = d
111
+
112
+ # If max distance is greater than epsilon, keep the point
113
+ if dmax > epsilon:
114
+ result = [points[0]] + [points[index]] + [points[-1]]
115
+ else:
116
+ result = [points[0], points[-1]]
117
+
118
+ # Cache and return
119
+ self._rdp_cache[cache_key] = result
120
+ return result
121
+
122
+ # For larger point sets, use numpy for faster distance calculation
123
+ if len(points) > 20:
124
+ # Convert to numpy arrays for vectorized operations
125
+ points_array = np.array(points)
126
+ start = points_array[0]
127
+ end = points_array[-1]
128
+
129
+ # Calculate perpendicular distances in one vectorized operation
130
+ line_vector = end - start
131
+ line_length = np.linalg.norm(line_vector)
132
+
133
+ if line_length == 0:
134
+ # If start and end are the same, use direct distance
135
+ distances = np.linalg.norm(points_array[1:-1] - start, axis=1)
136
+ else:
137
+ # Normalize line vector
138
+ line_vector = line_vector / line_length
139
+ # Calculate perpendicular distances using vector operations
140
+ vectors_to_points = points_array[1:-1] - start
141
+ # Project vectors onto line vector
142
+ projections = np.dot(vectors_to_points, line_vector)
143
+ # Calculate projected points on line
144
+ projected_points = start + np.outer(projections, line_vector)
145
+ # Calculate distances from points to their projections
146
+ distances = np.linalg.norm(points_array[1:-1] - projected_points, axis=1)
147
+
148
+ # Find the point with maximum distance
149
+ if len(distances) > 0:
150
+ max_idx = np.argmax(distances)
151
+ dmax = distances[max_idx]
152
+ index = max_idx + 1 # +1 because we skipped the first point
153
+ else:
154
+ dmax = 0
155
+ index = 0
156
+ else:
157
+ # For medium-sized point sets, use the original algorithm
158
+ dmax = 0
159
+ index = 0
160
+ for i in range(1, len(points) - 1):
161
+ d = self.perpendicular_distance(points[i], points[0], points[-1])
162
+ if d > dmax:
163
+ index = i
164
+ dmax = d
165
+
166
+ # If max distance is greater than epsilon, recursively simplify
167
+ if dmax > epsilon:
168
+ # Recursive call
169
+ first_segment = await self.rdp(points[: index + 1], epsilon)
170
+ second_segment = await self.rdp(points[index:], epsilon)
171
+
172
+ # Build the result list (avoiding duplicating the common point)
173
+ result = first_segment[:-1] + second_segment
174
+ else:
175
+ result = [points[0], points[-1]]
176
+
177
+ # Limit cache size
178
+ if len(self._rdp_cache) > 100: # Keep only 100 most recent items
179
+ try:
180
+ self._rdp_cache.pop(next(iter(self._rdp_cache)))
181
+ except (StopIteration, KeyError):
182
+ pass
183
+
184
+ # Cache the result
185
+ self._rdp_cache[cache_key] = result
186
+ return result
187
+
188
+ # Cache for corner results
189
+ _corners_cache = {}
190
+
191
+ async def async_get_corners(
192
+ self, mask: np.ndarray, epsilon_factor: float = 0.05
193
+ ) -> List[Tuple[int, int]]:
194
+ """
195
+ Get the corners of a room shape as a list of (x, y) tuples.
196
+ Uses contour detection and Douglas-Peucker algorithm to simplify the contour.
197
+ Optimized with caching and faster calculations.
198
+
199
+ Args:
200
+ mask: Binary mask of the room (1 for room, 0 for background)
201
+ epsilon_factor: Controls the level of simplification (higher = fewer points)
202
+
203
+ Returns:
204
+ List of (x, y) tuples representing the corners of the room
205
+ """
206
+ # Create a hash of the mask and epsilon factor for caching
207
+ mask_hash = hash((mask.tobytes(), epsilon_factor))
208
+
209
+ # Check if we have a cached result
210
+ if mask_hash in self._corners_cache:
211
+ return self._corners_cache[mask_hash]
212
+
213
+ # Fast path for empty masks
214
+ if not np.any(mask):
215
+ return []
216
+
217
+ # Find contours in the mask - this uses our optimized method with caching
218
+ contour = await self.async_moore_neighbor_trace(mask)
219
+
220
+ if not contour:
221
+ # Fallback to bounding box if contour detection fails
222
+ y_indices, x_indices = np.where(mask > 0)
223
+ if len(x_indices) == 0 or len(y_indices) == 0:
224
+ return []
225
+
226
+ x_min, x_max = np.min(x_indices), np.max(x_indices)
227
+ y_min, y_max = np.min(y_indices), np.max(y_indices)
228
+
229
+ result = [
230
+ (x_min, y_min), # Top-left
231
+ (x_max, y_min), # Top-right
232
+ (x_max, y_max), # Bottom-right
233
+ (x_min, y_max), # Bottom-left
234
+ (x_min, y_min), # Back to top-left to close the polygon
235
+ ]
236
+
237
+ # Cache the result
238
+ self._corners_cache[mask_hash] = result
239
+ return result
240
+
241
+ # For small contours (less than 10 points), skip simplification
242
+ if len(contour) <= 10:
243
+ # Ensure the contour is closed
244
+ if contour[0] != contour[-1]:
245
+ contour.append(contour[0])
246
+
247
+ # Cache and return
248
+ self._corners_cache[mask_hash] = contour
249
+ return contour
250
+
251
+ # For larger contours, calculate perimeter more efficiently using numpy
252
+ points = np.array(contour)
253
+ # Calculate differences between consecutive points
254
+ diffs = np.diff(points, axis=0)
255
+ # Calculate squared distances
256
+ squared_dists = np.sum(diffs**2, axis=1)
257
+ # Calculate perimeter as sum of distances
258
+ perimeter = np.sum(np.sqrt(squared_dists))
259
+
260
+ # Apply Douglas-Peucker algorithm to simplify the contour
261
+ epsilon = epsilon_factor * perimeter
262
+ simplified_contour = await self.rdp(contour, epsilon=epsilon)
263
+
264
+ # Ensure the contour has at least 3 points to form a polygon
265
+ if len(simplified_contour) < 3:
266
+ # Fallback to bounding box
267
+ y_indices, x_indices = np.where(mask > 0)
268
+ x_min, x_max = int(np.min(x_indices)), int(np.max(x_indices))
269
+ y_min, y_max = int(np.min(y_indices)), int(np.max(y_indices))
270
+
271
+ LOGGER.debug(
272
+ f"{self.vacuum_id}: Too few points in contour, using bounding box"
273
+ )
274
+ result = [
275
+ (x_min, y_min), # Top-left
276
+ (x_max, y_min), # Top-right
277
+ (x_max, y_max), # Bottom-right
278
+ (x_min, y_max), # Bottom-left
279
+ (x_min, y_min), # Back to top-left to close the polygon
280
+ ]
281
+
282
+ # Cache the result
283
+ self._corners_cache[mask_hash] = result
284
+ return result
285
+
286
+ # Ensure the contour is closed
287
+ if simplified_contour[0] != simplified_contour[-1]:
288
+ simplified_contour.append(simplified_contour[0])
289
+
290
+ # Limit cache size
291
+ if len(self._corners_cache) > 50: # Keep only 50 most recent items
292
+ try:
293
+ self._corners_cache.pop(next(iter(self._corners_cache)))
294
+ except (StopIteration, KeyError):
295
+ pass
296
+
297
+ # Cache the result
298
+ self._corners_cache[mask_hash] = simplified_contour
299
+ return simplified_contour
300
+
301
+ # Cache for labeled arrays to avoid redundant calculations
302
+ _label_cache = {}
303
+ _hull_cache = {}
304
+
305
+ @staticmethod
306
+ async def async_moore_neighbor_trace(mask: np.ndarray) -> List[Tuple[int, int]]:
307
+ """
308
+ Trace the contour of a binary mask using an optimized approach.
309
+ Uses caching and simplified algorithms for better performance.
310
+
311
+ Args:
312
+ mask: Binary mask of the room (1 for room, 0 for background)
313
+
314
+ Returns:
315
+ List of (x, y) tuples representing the contour
316
+ """
317
+ # Create a hash of the mask for caching
318
+ mask_hash = hash(mask.tobytes())
319
+
320
+ # Check if we have a cached result
321
+ if mask_hash in HypferRoomsHandler._hull_cache:
322
+ return HypferRoomsHandler._hull_cache[mask_hash]
323
+
324
+ # Fast path for empty masks
325
+ if not np.any(mask):
326
+ return []
327
+
328
+ # Find bounding box of non-zero elements (much faster than full labeling for simple cases)
329
+ y_indices, x_indices = np.where(mask > 0)
330
+ if len(x_indices) == 0 or len(y_indices) == 0:
331
+ return []
332
+
333
+ # For very small rooms (less than 100 pixels), just use bounding box
334
+ if len(x_indices) < 100:
335
+ x_min, x_max = np.min(x_indices), np.max(x_indices)
336
+ y_min, y_max = np.min(y_indices), np.max(y_indices)
337
+
338
+ # Create a simple rectangle
339
+ hull_vertices = [
340
+ (int(x_min), int(y_min)), # Top-left
341
+ (int(x_max), int(y_min)), # Top-right
342
+ (int(x_max), int(y_max)), # Bottom-right
343
+ (int(x_min), int(y_max)), # Bottom-left
344
+ (int(x_min), int(y_min)), # Back to top-left to close the polygon
345
+ ]
346
+
347
+ # Cache and return the result
348
+ HypferRoomsHandler._hull_cache[mask_hash] = hull_vertices
349
+ return hull_vertices
350
+
351
+ # For larger rooms, use convex hull but with optimizations
352
+ try:
353
+ # Import here to avoid overhead for small rooms
354
+ from scipy import ndimage
355
+ from scipy.spatial import ConvexHull
356
+
357
+ # Use cached labeled array if available
358
+ if mask_hash in HypferRoomsHandler._label_cache:
359
+ labeled_array = HypferRoomsHandler._label_cache[mask_hash]
360
+ else:
361
+ # Find connected components - this is expensive
362
+ labeled_array, _ = ndimage.label(mask)
363
+ # Cache the result for future use
364
+ HypferRoomsHandler._label_cache[mask_hash] = labeled_array
365
+
366
+ # Limit cache size to prevent memory issues
367
+ if len(HypferRoomsHandler._label_cache) > 50: # Keep only 50 most recent items
368
+ # Remove oldest item (first key)
369
+ try:
370
+ HypferRoomsHandler._label_cache.pop(next(iter(HypferRoomsHandler._label_cache)))
371
+ except (StopIteration, KeyError):
372
+ # Handle edge case of empty cache
373
+ pass
374
+
375
+ # Create a mask with all components
376
+ all_components_mask = (labeled_array > 0)
377
+
378
+ # Sample points instead of using all points for large masks
379
+ # This significantly reduces computation time for ConvexHull
380
+ if len(x_indices) > 1000:
381
+ # Sample every 10th point for very large rooms
382
+ step = 10
383
+ elif len(x_indices) > 500:
384
+ # Sample every 5th point for medium-sized rooms
385
+ step = 5
386
+ else:
387
+ # Use all points for smaller rooms
388
+ step = 1
389
+
390
+ # Sample points using the step size
391
+ sampled_y = y_indices[::step]
392
+ sampled_x = x_indices[::step]
393
+
394
+ # Create a list of points
395
+ points = np.column_stack((sampled_x, sampled_y))
396
+
397
+ # Compute the convex hull
398
+ hull = ConvexHull(points)
399
+
400
+ # Extract the vertices of the convex hull
401
+ hull_vertices = [(int(points[v, 0]), int(points[v, 1])) for v in hull.vertices]
402
+
403
+ # Ensure the hull is closed
404
+ if hull_vertices[0] != hull_vertices[-1]:
405
+ hull_vertices.append(hull_vertices[0])
406
+
407
+ # Cache and return the result
408
+ HypferRoomsHandler._hull_cache[mask_hash] = hull_vertices
409
+
410
+ # Limit hull cache size
411
+ if len(HypferRoomsHandler._hull_cache) > 50:
412
+ try:
413
+ HypferRoomsHandler._hull_cache.pop(next(iter(HypferRoomsHandler._hull_cache)))
414
+ except (StopIteration, KeyError):
415
+ pass
416
+
417
+ return hull_vertices
418
+
419
+ except Exception as e:
420
+ LOGGER.warning(f"Failed to compute convex hull: {e}. Falling back to bounding box.")
421
+
422
+ # Fallback to bounding box if convex hull fails
423
+ x_min, x_max = np.min(x_indices), np.max(x_indices)
424
+ y_min, y_max = np.min(y_indices), np.max(y_indices)
425
+
426
+ # Create a simple rectangle
427
+ hull_vertices = [
428
+ (int(x_min), int(y_min)), # Top-left
429
+ (int(x_max), int(y_min)), # Top-right
430
+ (int(x_max), int(y_max)), # Bottom-right
431
+ (int(x_min), int(y_max)), # Bottom-left
432
+ (int(x_min), int(y_min)), # Back to top-left to close the polygon
433
+ ]
434
+
435
+ # Cache and return the result
436
+ HypferRoomsHandler._hull_cache[mask_hash] = hull_vertices
437
+ return hull_vertices
438
+
439
+
440
+
441
+ async def async_extract_room_properties(
442
+ self, json_data: Dict[str, Any]
443
+ ) -> RoomsProperties:
444
+ """
445
+ Extract room properties from the JSON data.
446
+
447
+ Args:
448
+ json_data: JSON data from the vacuum
449
+
450
+ Returns:
451
+ Dictionary of room properties
452
+ """
453
+ room_properties = {}
454
+ pixel_size = json_data.get("pixelSize", 5)
455
+ height = json_data["size"]["y"]
456
+ width = json_data["size"]["x"]
457
+ vacuum_id = self.vacuum_id
458
+ room_id_counter = 0
459
+
460
+ # Store the JSON data for reference in other methods
461
+ self.current_json_data = json_data
462
+
463
+ for layer in json_data.get("layers", []):
464
+ if layer.get("__class") == "MapLayer" and layer.get("type") == "segment":
465
+ meta_data = layer.get("metaData", {})
466
+ segment_id = meta_data.get("segmentId")
467
+ name = meta_data.get("name", f"Room {segment_id}")
468
+
469
+ # Check if this room is disabled in the drawing configuration
470
+ # The room_id_counter is 0-based, but DrawableElement.ROOM_X is 1-based
471
+ current_room_id = room_id_counter + 1
472
+ room_id_counter = (
473
+ room_id_counter + 1
474
+ ) % 16 # Cycle room_id back to 0 after 15
475
+
476
+ if 1 <= current_room_id <= 15 and self.drawing_config is not None:
477
+ room_element = getattr(
478
+ DrawableElement, f"ROOM_{current_room_id}", None
479
+ )
480
+ if room_element and not self.drawing_config.is_enabled(
481
+ room_element
482
+ ):
483
+ LOGGER.debug(
484
+ "%s: Room %d is disabled and will be skipped",
485
+ self.vacuum_id,
486
+ current_room_id,
487
+ )
488
+ continue
489
+
490
+ compressed_pixels = layer.get("compressedPixels", [])
491
+ pixels = self.sublist(compressed_pixels, 3)
492
+
493
+ # Create a binary mask for the room
494
+ if not pixels:
495
+ LOGGER.warning(f"Skipping segment {segment_id}: no pixels found")
496
+ continue
497
+
498
+ mask = np.zeros((height, width), dtype=np.uint8)
499
+ for x, y, length in pixels:
500
+ if 0 <= y < height and 0 <= x < width and x + length <= width:
501
+ mask[y, x : x + length] = 1
502
+
503
+ # Find the room outline using the improved get_corners function
504
+ # Adjust epsilon_factor to control the level of simplification (higher = fewer points)
505
+ outline = await self.async_get_corners(mask, epsilon_factor=0.05)
506
+
507
+ if not outline:
508
+ LOGGER.warning(
509
+ f"Skipping segment {segment_id}: failed to generate outline"
510
+ )
511
+ continue
512
+
513
+ # Calculate the center of the room
514
+ xs, ys = zip(*outline)
515
+ x_min, x_max = min(xs), max(xs)
516
+ y_min, y_max = min(ys), max(ys)
517
+
518
+ # Scale coordinates by pixel_size
519
+ scaled_outline = [(x * pixel_size, y * pixel_size) for x, y in outline]
520
+
521
+ room_id = str(segment_id)
522
+ room_properties[room_id] = {
523
+ "number": segment_id,
524
+ "outline": scaled_outline, # Already includes the closing point
525
+ "name": name,
526
+ "x": ((x_min + x_max) * pixel_size) // 2,
527
+ "y": ((y_min + y_max) * pixel_size) // 2,
528
+ }
529
+
530
+ RoomStore(vacuum_id, room_properties)
531
+ return room_properties
532
+
533
+ async def get_room_at_position(
534
+ self, x: int, y: int, room_properties: Optional[RoomsProperties] = None
535
+ ) -> Optional[Dict[str, Any]]:
536
+ """
537
+ Get the room at a specific position.
538
+
539
+ Args:
540
+ x: X coordinate
541
+ y: Y coordinate
542
+ room_properties: Room properties dictionary (optional)
543
+
544
+ Returns:
545
+ Room data dictionary or None if no room at position
546
+ """
547
+ if room_properties is None:
548
+ room_store = RoomStore(self.vacuum_id)
549
+ room_properties = room_store.get_rooms()
550
+
551
+ if not room_properties:
552
+ return None
553
+
554
+ for room_id, room_data in room_properties.items():
555
+ outline = room_data.get("outline", [])
556
+ if not outline or len(outline) < 3:
557
+ continue
558
+
559
+ # Check if point is inside the polygon
560
+ if self.point_in_polygon(x, y, outline):
561
+ return {
562
+ "id": room_id,
563
+ "name": room_data.get("name", f"Room {room_id}"),
564
+ "x": room_data.get("x", 0),
565
+ "y": room_data.get("y", 0),
566
+ }
567
+
568
+ return None
569
+
570
+ @staticmethod
571
+ def point_in_polygon(x: int, y: int, polygon: List[Tuple[int, int]]) -> bool:
572
+ """
573
+ Check if a point is inside a polygon using ray casting algorithm.
574
+
575
+ Args:
576
+ x: X coordinate of the point
577
+ y: Y coordinate of the point
578
+ polygon: List of (x, y) tuples forming the polygon
579
+
580
+ Returns:
581
+ True if the point is inside the polygon, False otherwise
582
+ """
583
+ n = len(polygon)
584
+ inside = False
585
+
586
+ p1x, p1y = polygon[0]
587
+ xinters = None # Initialize with default value
588
+ for i in range(1, n + 1):
589
+ p2x, p2y = polygon[i % n]
590
+ if y > min(p1y, p2y):
591
+ if y <= max(p1y, p2y):
592
+ if x <= max(p1x, p2x):
593
+ if p1y != p2y:
594
+ xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
595
+ if p1x == p2x or x <= xinters:
596
+ inside = not inside
597
+ p1x, p1y = p2x, p2y
598
+
599
+ return inside
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "valetudo-map-parser"
3
- version = "0.1.9.b51"
3
+ version = "0.1.9.b53"
4
4
  description = "A Python library to parse Valetudo map data returning a PIL Image object."
5
5
  authors = ["Sandro Cantarella <gsca075@gmail.com>"]
6
6
  license = "Apache-2.0"
@@ -1,406 +0,0 @@
1
- """
2
- Hipfer Rooms Handler Module.
3
- Handles room data extraction and processing for Valetudo Hipfer vacuum maps.
4
- Provides async methods for room outline extraction and properties management.
5
- Version: 0.1.9
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from math import sqrt
11
- from typing import Any, Dict, Optional, List, Tuple
12
-
13
- import numpy as np
14
-
15
- from .config.drawable_elements import DrawableElement, DrawingConfig
16
- from .config.types import LOGGER, RoomsProperties, RoomStore
17
-
18
-
19
- class HypferRoomsHandler:
20
- """
21
- Handler for extracting and managing room data from Hipfer vacuum maps.
22
-
23
- This class provides methods to:
24
- - Extract room outlines using the Ramer-Douglas-Peucker algorithm
25
- - Process room properties from JSON data
26
- - Generate room masks and extract contours
27
-
28
- All methods are async for better integration with the rest of the codebase.
29
- """
30
-
31
- def __init__(self, vacuum_id: str, drawing_config: Optional[DrawingConfig] = None):
32
- """
33
- Initialize the HipferRoomsHandler.
34
-
35
- Args:
36
- vacuum_id: Identifier for the vacuum
37
- drawing_config: Configuration for which elements to draw (optional)
38
- """
39
- self.vacuum_id = vacuum_id
40
- self.drawing_config = drawing_config
41
-
42
- @staticmethod
43
- def sublist(data: list, chunk_size: int) -> list:
44
- return [data[i : i + chunk_size] for i in range(0, len(data), chunk_size)]
45
-
46
- @staticmethod
47
- def perpendicular_distance(
48
- point: tuple[int, int], line_start: tuple[int, int], line_end: tuple[int, int]
49
- ) -> float:
50
- """Calculate the perpendicular distance from a point to a line."""
51
- if line_start == line_end:
52
- return sqrt(
53
- (point[0] - line_start[0]) ** 2 + (point[1] - line_start[1]) ** 2
54
- )
55
-
56
- x, y = point
57
- x1, y1 = line_start
58
- x2, y2 = line_end
59
-
60
- # Calculate the line length
61
- line_length = sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
62
- if line_length == 0:
63
- return 0
64
-
65
- # Calculate the distance from the point to the line
66
- return abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) / line_length
67
-
68
- async def rdp(
69
- self, points: List[Tuple[int, int]], epsilon: float
70
- ) -> List[Tuple[int, int]]:
71
- """Ramer-Douglas-Peucker algorithm for simplifying a curve."""
72
- if len(points) <= 2:
73
- return points
74
-
75
- # Find the point with the maximum distance
76
- dmax = 0
77
- index = 0
78
- for i in range(1, len(points) - 1):
79
- d = self.perpendicular_distance(points[i], points[0], points[-1])
80
- if d > dmax:
81
- index = i
82
- dmax = d
83
-
84
- # If max distance is greater than epsilon, recursively simplify
85
- if dmax > epsilon:
86
- # Recursive call
87
- first_segment = await self.rdp(points[: index + 1], epsilon)
88
- second_segment = await self.rdp(points[index:], epsilon)
89
-
90
- # Build the result list (avoiding duplicating the common point)
91
- return first_segment[:-1] + second_segment
92
- else:
93
- return [points[0], points[-1]]
94
-
95
- async def async_get_corners(
96
- self, mask: np.ndarray, epsilon_factor: float = 0.05
97
- ) -> List[Tuple[int, int]]:
98
- """
99
- Get the corners of a room shape as a list of (x, y) tuples.
100
- Uses contour detection and Douglas-Peucker algorithm to simplify the contour.
101
-
102
- Args:
103
- mask: Binary mask of the room (1 for room, 0 for background)
104
- epsilon_factor: Controls the level of simplification (higher = fewer points)
105
-
106
- Returns:
107
- List of (x, y) tuples representing the corners of the room
108
- """
109
- # Find contours in the mask
110
- contour = await self.async_moore_neighbor_trace(mask)
111
-
112
- if not contour:
113
- # Fallback to bounding box if contour detection fails
114
- y_indices, x_indices = np.where(mask > 0)
115
- if len(x_indices) == 0 or len(y_indices) == 0:
116
- return []
117
-
118
- x_min, x_max = np.min(x_indices), np.max(x_indices)
119
- y_min, y_max = np.min(y_indices), np.max(y_indices)
120
-
121
- return [
122
- (x_min, y_min), # Top-left
123
- (x_max, y_min), # Top-right
124
- (x_max, y_max), # Bottom-right
125
- (x_min, y_max), # Bottom-left
126
- (x_min, y_min), # Back to top-left to close the polygon
127
- ]
128
-
129
- # Calculate the perimeter of the contour
130
- perimeter = 0
131
- for i in range(len(contour) - 1):
132
- x1, y1 = contour[i]
133
- x2, y2 = contour[i + 1]
134
- perimeter += np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
135
-
136
- # Apply Douglas-Peucker algorithm to simplify the contour
137
- epsilon = epsilon_factor * perimeter
138
- simplified_contour = await self.rdp(contour, epsilon=epsilon)
139
-
140
- # Ensure the contour has at least 3 points to form a polygon
141
- if len(simplified_contour) < 3:
142
- # Fallback to bounding box
143
- y_indices, x_indices = np.where(mask > 0)
144
- x_min, x_max = int(np.min(x_indices)), int(np.max(x_indices))
145
- y_min, y_max = int(np.min(y_indices)), int(np.max(y_indices))
146
-
147
- LOGGER.debug(
148
- f"{self.vacuum_id}: Too few points in contour, using bounding box"
149
- )
150
- return [
151
- (x_min, y_min), # Top-left
152
- (x_max, y_min), # Top-right
153
- (x_max, y_max), # Bottom-right
154
- (x_min, y_max), # Bottom-left
155
- (x_min, y_min), # Back to top-left to close the polygon
156
- ]
157
-
158
- # Ensure the contour is closed
159
- if simplified_contour[0] != simplified_contour[-1]:
160
- simplified_contour.append(simplified_contour[0])
161
-
162
- return simplified_contour
163
-
164
- @staticmethod
165
- async def async_moore_neighbor_trace(mask: np.ndarray) -> List[Tuple[int, int]]:
166
- """
167
- Trace the contour of a binary mask using an optimized Moore-Neighbor tracing.
168
-
169
- Args:
170
- mask: Binary mask of the room (1 for room, 0 for background)
171
-
172
- Returns:
173
- List of (x, y) tuples representing the contour
174
- """
175
- # Convert to uint8 and pad
176
- padded = np.pad(mask.astype(np.uint8), 1, mode="constant")
177
- height, width = padded.shape
178
-
179
- # Find the first non-zero point efficiently (scan row by row)
180
- # This is much faster than np.argwhere() for large arrays
181
- start = None
182
- for y in range(height):
183
- # Use NumPy's any() to quickly check if there are any 1s in this row
184
- if np.any(padded[y]):
185
- # Find the first 1 in this row
186
- x = np.where(padded[y] == 1)[0][0]
187
- start = (int(x), int(y))
188
- break
189
-
190
- if start is None:
191
- return []
192
-
193
- # Pre-compute directions
194
- directions = [
195
- (-1, -1),
196
- (-1, 0),
197
- (-1, 1),
198
- (0, 1),
199
- (1, 1),
200
- (1, 0),
201
- (1, -1),
202
- (0, -1),
203
- ]
204
-
205
- # Use a 2D array for visited tracking (faster than set)
206
- visited = np.zeros((height, width), dtype=bool)
207
-
208
- # Initialize contour
209
- contour = []
210
- contour.append((int(start[0] - 1), int(start[1] - 1))) # Adjust for padding
211
-
212
- current = start
213
- prev_dir = 7
214
- visited[current[1], current[0]] = True
215
-
216
- # Main tracing loop
217
- while True:
218
- found = False
219
-
220
- # Check all 8 directions
221
- for i in range(8):
222
- dir_idx = (prev_dir + i) % 8
223
- dx, dy = directions[dir_idx]
224
- nx, ny = current[0] + dx, current[1] + dy
225
-
226
- # Bounds check and value check
227
- if (
228
- 0 <= ny < height
229
- and 0 <= nx < width
230
- and padded[ny, nx] == 1
231
- and not visited[ny, nx]
232
- ):
233
- current = (nx, ny)
234
- visited[ny, nx] = True
235
- contour.append(
236
- (int(nx - 1), int(ny - 1))
237
- ) # Adjust for padding and convert to int
238
- prev_dir = (dir_idx + 5) % 8
239
- found = True
240
- break
241
-
242
- # Check termination conditions
243
- if not found or (
244
- len(contour) > 3
245
- and (int(current[0] - 1), int(current[1] - 1)) == contour[0]
246
- ):
247
- break
248
-
249
- return contour
250
-
251
- async def async_extract_room_properties(
252
- self, json_data: Dict[str, Any]
253
- ) -> RoomsProperties:
254
- """
255
- Extract room properties from the JSON data.
256
-
257
- Args:
258
- json_data: JSON data from the vacuum
259
-
260
- Returns:
261
- Dictionary of room properties
262
- """
263
- room_properties = {}
264
- pixel_size = json_data.get("pixelSize", 5)
265
- height = json_data["size"]["y"]
266
- width = json_data["size"]["x"]
267
- vacuum_id = self.vacuum_id
268
- room_id_counter = 0
269
-
270
- for layer in json_data.get("layers", []):
271
- if layer.get("__class") == "MapLayer" and layer.get("type") == "segment":
272
- meta_data = layer.get("metaData", {})
273
- segment_id = meta_data.get("segmentId")
274
- name = meta_data.get("name", f"Room {segment_id}")
275
-
276
- # Check if this room is disabled in the drawing configuration
277
- # The room_id_counter is 0-based, but DrawableElement.ROOM_X is 1-based
278
- current_room_id = room_id_counter + 1
279
- room_id_counter = (
280
- room_id_counter + 1
281
- ) % 16 # Cycle room_id back to 0 after 15
282
-
283
- if 1 <= current_room_id <= 15 and self.drawing_config is not None:
284
- room_element = getattr(
285
- DrawableElement, f"ROOM_{current_room_id}", None
286
- )
287
- if room_element and not self.drawing_config.is_enabled(
288
- room_element
289
- ):
290
- LOGGER.debug(
291
- "%s: Room %d is disabled and will be skipped",
292
- self.vacuum_id,
293
- current_room_id,
294
- )
295
- continue
296
-
297
- compressed_pixels = layer.get("compressedPixels", [])
298
- pixels = self.sublist(compressed_pixels, 3)
299
-
300
- # Create a binary mask for the room
301
- if not pixels:
302
- LOGGER.warning(f"Skipping segment {segment_id}: no pixels found")
303
- continue
304
-
305
- mask = np.zeros((height, width), dtype=np.uint8)
306
- for x, y, length in pixels:
307
- if 0 <= y < height and 0 <= x < width and x + length <= width:
308
- mask[y, x : x + length] = 1
309
-
310
- # Find the room outline using the improved get_corners function
311
- # Adjust epsilon_factor to control the level of simplification (higher = fewer points)
312
- outline = await self.async_get_corners(mask, epsilon_factor=0.05)
313
-
314
- if not outline:
315
- LOGGER.warning(
316
- f"Skipping segment {segment_id}: failed to generate outline"
317
- )
318
- continue
319
-
320
- # Calculate the center of the room
321
- xs, ys = zip(*outline)
322
- x_min, x_max = min(xs), max(xs)
323
- y_min, y_max = min(ys), max(ys)
324
-
325
- # Scale coordinates by pixel_size
326
- scaled_outline = [(x * pixel_size, y * pixel_size) for x, y in outline]
327
-
328
- room_id = str(segment_id)
329
- room_properties[room_id] = {
330
- "number": segment_id,
331
- "outline": scaled_outline, # Already includes the closing point
332
- "name": name,
333
- "x": ((x_min + x_max) * pixel_size) // 2,
334
- "y": ((y_min + y_max) * pixel_size) // 2,
335
- }
336
-
337
- RoomStore(vacuum_id, room_properties)
338
- return room_properties
339
-
340
- async def get_room_at_position(
341
- self, x: int, y: int, room_properties: Optional[RoomsProperties] = None
342
- ) -> Optional[Dict[str, Any]]:
343
- """
344
- Get the room at a specific position.
345
-
346
- Args:
347
- x: X coordinate
348
- y: Y coordinate
349
- room_properties: Room properties dictionary (optional)
350
-
351
- Returns:
352
- Room data dictionary or None if no room at position
353
- """
354
- if room_properties is None:
355
- room_store = RoomStore(self.vacuum_id)
356
- room_properties = room_store.get_rooms()
357
-
358
- if not room_properties:
359
- return None
360
-
361
- for room_id, room_data in room_properties.items():
362
- outline = room_data.get("outline", [])
363
- if not outline or len(outline) < 3:
364
- continue
365
-
366
- # Check if point is inside the polygon
367
- if self.point_in_polygon(x, y, outline):
368
- return {
369
- "id": room_id,
370
- "name": room_data.get("name", f"Room {room_id}"),
371
- "x": room_data.get("x", 0),
372
- "y": room_data.get("y", 0),
373
- }
374
-
375
- return None
376
-
377
- @staticmethod
378
- def point_in_polygon(x: int, y: int, polygon: List[Tuple[int, int]]) -> bool:
379
- """
380
- Check if a point is inside a polygon using ray casting algorithm.
381
-
382
- Args:
383
- x: X coordinate of the point
384
- y: Y coordinate of the point
385
- polygon: List of (x, y) tuples forming the polygon
386
-
387
- Returns:
388
- True if the point is inside the polygon, False otherwise
389
- """
390
- n = len(polygon)
391
- inside = False
392
-
393
- p1x, p1y = polygon[0]
394
- xinters = None # Initialize with default value
395
- for i in range(1, n + 1):
396
- p2x, p2y = polygon[i % n]
397
- if y > min(p1y, p2y):
398
- if y <= max(p1y, p2y):
399
- if x <= max(p1x, p2x):
400
- if p1y != p2y:
401
- xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
402
- if p1x == p2x or x <= xinters:
403
- inside = not inside
404
- p1x, p1y = p2x, p2y
405
-
406
- return inside