valetudo-map-parser 0.1.9b60__tar.gz → 0.1.9b62__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 (28) hide show
  1. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/PKG-INFO +2 -1
  2. valetudo_map_parser-0.1.9b62/SCR/valetudo_map_parser/config/async_utils.py +89 -0
  3. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/auto_crop.py +4 -3
  4. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/drawable.py +120 -39
  5. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/shared.py +9 -1
  6. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/types.py +2 -1
  7. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/hypfer_draw.py +32 -24
  8. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/hypfer_handler.py +55 -17
  9. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/map_data.py +51 -90
  10. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/pyproject.toml +2 -1
  11. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/LICENSE +0 -0
  12. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/NOTICE.txt +0 -0
  13. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/README.md +0 -0
  14. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/__init__.py +0 -0
  15. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/__init__.py +0 -0
  16. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/color_utils.py +0 -0
  17. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/colors.py +0 -0
  18. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/drawable_elements.py +0 -0
  19. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/enhanced_drawable.py +0 -0
  20. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/optimized_element_map.py +0 -0
  21. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/rand256_parser.py +0 -0
  22. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/rand25_parser.py +0 -0
  23. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/config/utils.py +0 -0
  24. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/hypfer_rooms_handler.py +0 -0
  25. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/py.typed +0 -0
  26. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/rand256_handler.py +0 -0
  27. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/reimg_draw.py +0 -0
  28. {valetudo_map_parser-0.1.9b60 → valetudo_map_parser-0.1.9b62}/SCR/valetudo_map_parser/rooms_handler.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: valetudo-map-parser
3
- Version: 0.1.9b60
3
+ Version: 0.1.9b62
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
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python :: 3.12
12
12
  Classifier: Programming Language :: Python :: 3.13
13
13
  Requires-Dist: Pillow (>=10.3.0)
14
14
  Requires-Dist: numpy (>=1.26.4)
15
+ Requires-Dist: pandas (>=2.3.0)
15
16
  Requires-Dist: scipy (>=1.12.0)
16
17
  Project-URL: Bug Tracker, https://github.com/sca075/Python-package-valetudo-map-parser/issues
17
18
  Project-URL: Changelog, https://github.com/sca075/Python-package-valetudo-map-parser/releases
@@ -0,0 +1,89 @@
1
+ """Async utility functions for making NumPy and PIL operations truly async."""
2
+
3
+ import asyncio
4
+ import io
5
+ from typing import Any, Callable
6
+
7
+ import numpy as np
8
+ from numpy import rot90
9
+ from PIL import Image
10
+
11
+
12
+ async def make_async(func: Callable, *args, **kwargs) -> Any:
13
+ """Convert a synchronous function to async by yielding control to the event loop."""
14
+ await asyncio.sleep(0)
15
+ result = func(*args, **kwargs)
16
+ await asyncio.sleep(0)
17
+ return result
18
+
19
+
20
+ class AsyncNumPy:
21
+ """Async wrappers for NumPy operations that yield control to the event loop."""
22
+
23
+ @staticmethod
24
+ async def async_copy(array: np.ndarray) -> np.ndarray:
25
+ """Async array copying."""
26
+ return await make_async(np.copy, array)
27
+
28
+ @staticmethod
29
+ async def async_full(shape: tuple, fill_value: Any, dtype: np.dtype = None) -> np.ndarray:
30
+ """Async array creation with fill value."""
31
+ return await make_async(np.full, shape, fill_value, dtype=dtype)
32
+
33
+ @staticmethod
34
+ async def async_rot90(array: np.ndarray, k: int = 1) -> np.ndarray:
35
+ """Async array rotation."""
36
+ return await make_async(rot90, array, k)
37
+
38
+
39
+ class AsyncPIL:
40
+ """Async wrappers for PIL operations that yield control to the event loop."""
41
+
42
+ @staticmethod
43
+ async def async_fromarray(array: np.ndarray, mode: str = "RGBA") -> Image.Image:
44
+ """Async PIL Image creation from NumPy array."""
45
+ return await make_async(Image.fromarray, array, mode)
46
+
47
+ @staticmethod
48
+ async def async_resize(image: Image.Image, size: tuple, resample: int = None) -> Image.Image:
49
+ """Async image resizing."""
50
+ if resample is None:
51
+ resample = Image.LANCZOS
52
+ return await make_async(image.resize, size, resample)
53
+
54
+ @staticmethod
55
+ async def async_save_to_bytes(image: Image.Image, format_type: str = "WEBP", **kwargs) -> bytes:
56
+ """Async image saving to bytes."""
57
+ def save_to_bytes():
58
+ buffer = io.BytesIO()
59
+ image.save(buffer, format=format_type, **kwargs)
60
+ return buffer.getvalue()
61
+
62
+ return await make_async(save_to_bytes)
63
+
64
+
65
+ class AsyncParallel:
66
+ """Helper functions for parallel processing with asyncio.gather()."""
67
+
68
+ @staticmethod
69
+ async def parallel_data_preparation(*tasks):
70
+ """Execute multiple data preparation tasks in parallel."""
71
+ return await asyncio.gather(*tasks, return_exceptions=True)
72
+
73
+ @staticmethod
74
+ async def parallel_array_operations(base_array: np.ndarray, operations: list):
75
+ """Execute multiple array operations in parallel on copies of the base array."""
76
+
77
+ # Create tasks for parallel execution
78
+ tasks = []
79
+ for operation_func, *args in operations:
80
+ # Each operation works on a copy of the base array
81
+ array_copy = await AsyncNumPy.async_copy(base_array)
82
+ tasks.append(operation_func(array_copy, *args))
83
+
84
+ # Execute all operations in parallel
85
+ results = await asyncio.gather(*tasks, return_exceptions=True)
86
+
87
+ # Filter out exceptions and return successful results
88
+ successful_results = [r for r in results if not isinstance(r, Exception)]
89
+ return successful_results
@@ -9,6 +9,7 @@ import numpy as np
9
9
  from numpy import rot90
10
10
  from scipy import ndimage
11
11
 
12
+ from .async_utils import AsyncNumPy, make_async
12
13
  from .types import Color, NumpyArray, TrimCropData, TrimsData
13
14
  from .utils import BaseHandler
14
15
 
@@ -364,7 +365,7 @@ class AutoCrop:
364
365
  ) -> NumpyArray:
365
366
  """Rotate the image and return the new array."""
366
367
  if rotate == 90:
367
- rotated = rot90(trimmed)
368
+ rotated = await AsyncNumPy.async_rot90(trimmed)
368
369
  self.crop_area = [
369
370
  self.trim_left,
370
371
  self.trim_up,
@@ -372,10 +373,10 @@ class AutoCrop:
372
373
  self.trim_down,
373
374
  ]
374
375
  elif rotate == 180:
375
- rotated = rot90(trimmed, 2)
376
+ rotated = await AsyncNumPy.async_rot90(trimmed, 2)
376
377
  self.crop_area = self.auto_crop
377
378
  elif rotate == 270:
378
- rotated = rot90(trimmed, 3)
379
+ rotated = await AsyncNumPy.async_rot90(trimmed, 3)
379
380
  self.crop_area = [
380
381
  self.trim_left,
381
382
  self.trim_up,
@@ -12,6 +12,8 @@ from __future__ import annotations
12
12
 
13
13
  import logging
14
14
  import math
15
+ import asyncio
16
+ import inspect
15
17
 
16
18
  import numpy as np
17
19
  from PIL import ImageDraw, ImageFont
@@ -44,8 +46,12 @@ class Drawable:
44
46
  width: int, height: int, background_color: Color
45
47
  ) -> NumpyArray:
46
48
  """Create the empty background image NumPy array.
47
- Background color is specified as an RGBA tuple."""
48
- return np.full((height, width, 4), background_color, dtype=np.uint8)
49
+ Background color is specified as an RGBA tuple.
50
+ Optimized: Uses np.empty + broadcast instead of np.full for better performance."""
51
+ # Use np.empty + broadcast instead of np.full (avoids double initialization)
52
+ img_array = np.empty((height, width, 4), dtype=np.uint8)
53
+ img_array[:] = background_color # Broadcast color to all pixels efficiently
54
+ return img_array
49
55
 
50
56
  @staticmethod
51
57
  async def from_json_to_image(
@@ -152,6 +158,8 @@ class Drawable:
152
158
  It uses the rotation angle of the image to orient the flag.
153
159
  Includes color blending for better visual integration.
154
160
  """
161
+ await asyncio.sleep(0) # Yield control
162
+
155
163
  # Check if coordinates are within bounds
156
164
  height, width = layer.shape[:2]
157
165
  x, y = center
@@ -323,7 +331,12 @@ class Drawable:
323
331
  Join the coordinates creating a continuous line (path).
324
332
  Optimized with vectorized operations for better performance.
325
333
  """
326
- for coord in coords:
334
+
335
+ # Handle case where arr might be a coroutine (shouldn't happen but let's be safe)
336
+ if inspect.iscoroutine(arr):
337
+ arr = await arr
338
+
339
+ for i, coord in enumerate(coords):
327
340
  x0, y0 = coord[0]
328
341
  try:
329
342
  x1, y1 = coord[1]
@@ -340,6 +353,10 @@ class Drawable:
340
353
  # Use the optimized line drawing method
341
354
  arr = Drawable._line(arr, x0, y0, x1, y1, blended_color, width)
342
355
 
356
+ # Yield control every 100 operations to prevent blocking
357
+ if i % 100 == 0:
358
+ await asyncio.sleep(0)
359
+
343
360
  return arr
344
361
 
345
362
  @staticmethod
@@ -484,56 +501,120 @@ class Drawable:
484
501
  async def zones(layers: NumpyArray, coordinates, color: Color) -> NumpyArray:
485
502
  """
486
503
  Draw the zones on the input layer with color blending.
487
- Optimized with NumPy vectorized operations for better performance.
504
+ Optimized with parallel processing for better performance.
488
505
  """
506
+ await asyncio.sleep(0) # Yield control
507
+
489
508
  dot_radius = 1 # Number of pixels for the dot
490
509
  dot_spacing = 4 # Space between dots
491
510
 
492
- for zone in coordinates:
493
- points = zone["points"]
494
- min_x = max(0, min(points[::2]))
495
- max_x = min(layers.shape[1] - 1, max(points[::2]))
496
- min_y = max(0, min(points[1::2]))
497
- max_y = min(layers.shape[0] - 1, max(points[1::2]))
511
+ # Process zones in parallel if there are multiple zones
512
+ if len(coordinates) > 1:
513
+ # Create tasks for parallel zone processing
514
+ zone_tasks = []
515
+ for zone in coordinates:
516
+ zone_tasks.append(Drawable._process_single_zone(layers.copy(), zone, color, dot_radius, dot_spacing))
517
+
518
+ # Execute all zone processing tasks in parallel
519
+ zone_results = await asyncio.gather(*zone_tasks, return_exceptions=True)
520
+
521
+ # Merge results back into the main layer
522
+ for result in zone_results:
523
+ if not isinstance(result, Exception):
524
+ # Simple overlay - pixels that are different from original get updated
525
+ mask = result != layers
526
+ layers[mask] = result[mask]
527
+ else:
528
+ # Single zone - process directly
529
+ for zone in coordinates:
530
+ points = zone["points"]
531
+ min_x = max(0, min(points[::2]))
532
+ max_x = min(layers.shape[1] - 1, max(points[::2]))
533
+ min_y = max(0, min(points[1::2]))
534
+ max_y = min(layers.shape[0] - 1, max(points[1::2]))
535
+
536
+ # Skip if zone is outside the image
537
+ if min_x >= max_x or min_y >= max_y:
538
+ continue
539
+
540
+ # Sample a point from the zone to get the background color
541
+ # Use the center of the zone for sampling
542
+ sample_x = (min_x + max_x) // 2
543
+ sample_y = (min_y + max_y) // 2
498
544
 
499
- # Skip if zone is outside the image
500
- if min_x >= max_x or min_y >= max_y:
501
- continue
545
+ # Blend the color with the background color at the sample point
546
+ if 0 <= sample_y < layers.shape[0] and 0 <= sample_x < layers.shape[1]:
547
+ blended_color = ColorsManagement.sample_and_blend_color(
548
+ layers, sample_x, sample_y, color
549
+ )
550
+ else:
551
+ blended_color = color
502
552
 
503
- # Sample a point from the zone to get the background color
504
- # Use the center of the zone for sampling
505
- sample_x = (min_x + max_x) // 2
506
- sample_y = (min_y + max_y) // 2
553
+ # Create a grid of dot centers
554
+ x_centers = np.arange(min_x, max_x, dot_spacing)
555
+ y_centers = np.arange(min_y, max_y, dot_spacing)
507
556
 
508
- # Blend the color with the background color at the sample point
509
- if 0 <= sample_y < layers.shape[0] and 0 <= sample_x < layers.shape[1]:
510
- blended_color = ColorsManagement.sample_and_blend_color(
511
- layers, sample_x, sample_y, color
512
- )
513
- else:
514
- blended_color = color
557
+ # Draw dots at each grid point
558
+ for y in y_centers:
559
+ for x in x_centers:
560
+ # Create a small mask for the dot
561
+ y_min = max(0, y - dot_radius)
562
+ y_max = min(layers.shape[0], y + dot_radius + 1)
563
+ x_min = max(0, x - dot_radius)
564
+ x_max = min(layers.shape[1], x + dot_radius + 1)
515
565
 
516
- # Create a grid of dot centers
517
- x_centers = np.arange(min_x, max_x, dot_spacing)
518
- y_centers = np.arange(min_y, max_y, dot_spacing)
566
+ # Create coordinate arrays for the dot
567
+ y_indices, x_indices = np.ogrid[y_min:y_max, x_min:x_max]
519
568
 
520
- # Draw dots at each grid point
521
- for y in y_centers:
522
- for x in x_centers:
523
- # Create a small mask for the dot
569
+ # Create a circular mask
570
+ mask = (y_indices - y) ** 2 + (x_indices - x) ** 2 <= dot_radius**2
571
+
572
+ # Apply the color to the masked region
573
+ layers[y_min:y_max, x_min:x_max][mask] = blended_color
574
+
575
+ return layers
576
+
577
+ @staticmethod
578
+ async def _process_single_zone(layers: NumpyArray, zone, color: Color, dot_radius: int, dot_spacing: int) -> NumpyArray:
579
+ """Process a single zone for parallel execution."""
580
+ await asyncio.sleep(0) # Yield control
581
+
582
+ points = zone["points"]
583
+ min_x = max(0, min(points[::2]))
584
+ max_x = min(layers.shape[1] - 1, max(points[::2]))
585
+ min_y = max(0, min(points[1::2]))
586
+ max_y = min(layers.shape[0] - 1, max(points[1::2]))
587
+
588
+ # Skip if zone is outside the image
589
+ if min_x >= max_x or min_y >= max_y:
590
+ return layers
591
+
592
+ # Sample a point from the zone to get the background color
593
+ sample_x = (min_x + max_x) // 2
594
+ sample_y = (min_y + max_y) // 2
595
+
596
+ # Blend the color with the background color at the sample point
597
+ if 0 <= sample_y < layers.shape[0] and 0 <= sample_x < layers.shape[1]:
598
+ blended_color = ColorsManagement.sample_and_blend_color(
599
+ layers, sample_x, sample_y, color
600
+ )
601
+ else:
602
+ blended_color = color
603
+
604
+ # Create a dotted pattern within the zone
605
+ for y in range(min_y, max_y + 1, dot_spacing):
606
+ for x in range(min_x, max_x + 1, dot_spacing):
607
+ if Drawable.point_inside(x, y, points):
608
+ # Draw a small filled circle (dot) using vectorized operations
524
609
  y_min = max(0, y - dot_radius)
525
610
  y_max = min(layers.shape[0], y + dot_radius + 1)
526
611
  x_min = max(0, x - dot_radius)
527
612
  x_max = min(layers.shape[1], x + dot_radius + 1)
528
613
 
529
- # Create coordinate arrays for the dot
530
- y_indices, x_indices = np.ogrid[y_min:y_max, x_min:x_max]
531
-
532
- # Create a circular mask
533
- mask = (y_indices - y) ** 2 + (x_indices - x) ** 2 <= dot_radius**2
534
-
535
- # Apply the color to the masked region
536
- layers[y_min:y_max, x_min:x_max][mask] = blended_color
614
+ if y_min < y_max and x_min < x_max:
615
+ y_indices, x_indices = np.ogrid[y_min:y_max, x_min:x_max]
616
+ mask = (y_indices - y) ** 2 + (x_indices - x) ** 2 <= dot_radius**2
617
+ layers[y_min:y_max, x_min:x_max][mask] = blended_color
537
618
 
538
619
  return layers
539
620
 
@@ -18,6 +18,7 @@ from .types import (
18
18
  ATTR_ROTATE,
19
19
  ATTR_SNAPSHOT,
20
20
  ATTR_VACUUM_BATTERY,
21
+ ATTR_VACUUM_CHARGING,
21
22
  ATTR_VACUUM_JSON_ID,
22
23
  ATTR_VACUUM_POSITION,
23
24
  ATTR_VACUUM_STATUS,
@@ -60,7 +61,7 @@ class CameraShared:
60
61
  self.last_image = None # Last image received
61
62
  self.current_image = None # Current image
62
63
  self.binary_image = None # Current image in binary format
63
- self.image_format = "WebP" # Image format
64
+ self.image_format = "image/pil" # Image format
64
65
  self.image_size = None # Image size
65
66
  self.image_auto_zoom: bool = False # Auto zoom image
66
67
  self.image_zoom_lock_ratio: bool = True # Zoom lock ratio
@@ -112,6 +113,12 @@ class CameraShared:
112
113
  self.skip_room_ids: List[str] = []
113
114
  self.device_info = None # Store the device_info
114
115
 
116
+
117
+
118
+ def _state_charging(self) -> bool:
119
+ """Check if the vacuum is charging."""
120
+ return self.vacuum_state == "charging"
121
+
115
122
  @staticmethod
116
123
  def _compose_obstacle_links(vacuum_host_ip: str, obstacles: list) -> list | None:
117
124
  """
@@ -186,6 +193,7 @@ class CameraShared:
186
193
  attrs = {
187
194
  ATTR_CAMERA_MODE: self.camera_mode,
188
195
  ATTR_VACUUM_BATTERY: f"{self.vacuum_battery}%",
196
+ ATTR_VACUUM_CHARGING: self._state_charging(),
189
197
  ATTR_VACUUM_POSITION: self.current_room,
190
198
  ATTR_VACUUM_STATUS: self.vacuum_state,
191
199
  ATTR_VACUUM_JSON_ID: self.vac_json_id,
@@ -568,7 +568,8 @@ ALPHA_ROOM_15 = "alpha_room_15"
568
568
 
569
569
  """ Constants for the attribute keys """
570
570
  ATTR_FRIENDLY_NAME = "friendly_name"
571
- ATTR_VACUUM_BATTERY = "vacuum_battery"
571
+ ATTR_VACUUM_BATTERY = "battery"
572
+ ATTR_VACUUM_CHARGING = "charging"
572
573
  ATTR_VACUUM_POSITION = "vacuum_position"
573
574
  ATTR_VACUUM_TOPIC = "vacuum_topic"
574
575
  ATTR_VACUUM_STATUS = "vacuum_status"
@@ -6,10 +6,11 @@ Version: 2024.07.2
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  import logging
10
11
 
11
12
  from .config.drawable_elements import DrawableElement
12
- from .config.types import Color, JsonType, NumpyArray, RobotPosition
13
+ from .config.types import Color, JsonType, NumpyArray, RobotPosition, RoomStore
13
14
 
14
15
 
15
16
  _LOGGER = logging.getLogger(__name__)
@@ -92,7 +93,7 @@ class ImageDraw:
92
93
  pixel_size,
93
94
  disabled_rooms=None,
94
95
  ):
95
- """Draw the base layer of the map.
96
+ """Draw the base layer of the map with parallel processing for rooms.
96
97
 
97
98
  Args:
98
99
  img_np_array: The image array to draw on
@@ -108,6 +109,7 @@ class ImageDraw:
108
109
  """
109
110
  room_id = 0
110
111
 
112
+ # Sequential processing for rooms/segments (dependencies require this)
111
113
  for compressed_pixels in compressed_pixels_list:
112
114
  pixels = self.img_h.data.sublist(compressed_pixels, 3)
113
115
 
@@ -325,41 +327,49 @@ class ImageDraw:
325
327
  color_zone_clean: Color,
326
328
  color_no_go: Color,
327
329
  ) -> NumpyArray:
328
- """Get the zone clean from the JSON data."""
330
+ """Get the zone clean from the JSON data with parallel processing."""
331
+
329
332
  try:
330
333
  zone_clean = self.img_h.data.find_zone_entities(m_json)
331
334
  except (ValueError, KeyError):
332
335
  zone_clean = None
333
336
  else:
334
337
  _LOGGER.info("%s: Got zones.", self.file_name)
338
+
335
339
  if zone_clean:
336
- try:
337
- zones_active = zone_clean.get("active_zone")
338
- except KeyError:
339
- zones_active = None
340
+ # Prepare zone drawing tasks for parallel execution
341
+ zone_tasks = []
342
+
343
+ # Active zones
344
+ zones_active = zone_clean.get("active_zone")
340
345
  if zones_active:
341
- np_array = await self.img_h.draw.zones(
342
- np_array, zones_active, color_zone_clean
346
+ zone_tasks.append(
347
+ self.img_h.draw.zones(np_array.copy(), zones_active, color_zone_clean)
343
348
  )
344
- try:
345
- no_go_zones = zone_clean.get("no_go_area")
346
- except KeyError:
347
- no_go_zones = None
348
349
 
350
+ # No-go zones
351
+ no_go_zones = zone_clean.get("no_go_area")
349
352
  if no_go_zones:
350
- np_array = await self.img_h.draw.zones(
351
- np_array, no_go_zones, color_no_go
353
+ zone_tasks.append(
354
+ self.img_h.draw.zones(np_array.copy(), no_go_zones, color_no_go)
352
355
  )
353
356
 
354
- try:
355
- no_mop_zones = zone_clean.get("no_mop_area")
356
- except KeyError:
357
- no_mop_zones = None
358
-
357
+ # No-mop zones
358
+ no_mop_zones = zone_clean.get("no_mop_area")
359
359
  if no_mop_zones:
360
- np_array = await self.img_h.draw.zones(
361
- np_array, no_mop_zones, color_no_go
360
+ zone_tasks.append(
361
+ self.img_h.draw.zones(np_array.copy(), no_mop_zones, color_no_go)
362
362
  )
363
+
364
+ # Execute all zone drawing tasks in parallel
365
+ if zone_tasks:
366
+ zone_results = await asyncio.gather(*zone_tasks)
367
+ # Merge results back into the main array
368
+ for result in zone_results:
369
+ # Simple overlay - in practice you might want more sophisticated blending
370
+ mask = result != np_array
371
+ np_array[mask] = result[mask]
372
+
363
373
  return np_array
364
374
 
365
375
  async def async_draw_virtual_walls(
@@ -429,7 +439,6 @@ class ImageDraw:
429
439
  def _check_active_zone_and_set_zooming(self) -> None:
430
440
  """Helper function to check active zones and set zooming state."""
431
441
  if self.img_h.active_zones and self.img_h.robot_in_room:
432
- from .config.types import RoomStore
433
442
 
434
443
  segment_id = str(self.img_h.robot_in_room["id"])
435
444
  room_store = RoomStore(self.file_name)
@@ -606,7 +615,6 @@ class ImageDraw:
606
615
 
607
616
  # Handle active zones - Map segment ID to active_zones position
608
617
  if self.img_h.active_zones:
609
- from .config.types import RoomStore
610
618
 
611
619
  segment_id = str(self.img_h.robot_in_room["id"])
612
620
  room_store = RoomStore(self.file_name)
@@ -7,13 +7,16 @@ Version: 0.1.9
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import asyncio
10
11
  import json
11
12
 
12
13
  from PIL import Image
13
14
 
15
+ from .config.async_utils import AsyncNumPy, AsyncPIL, AsyncParallel
14
16
  from .config.auto_crop import AutoCrop
15
17
  from .config.drawable_elements import DrawableElement
16
18
  from .config.shared import CameraShared
19
+ from .config.utils import pil_to_webp_bytes
17
20
  from .config.types import (
18
21
  COLORS,
19
22
  LOGGER,
@@ -291,14 +294,34 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
291
294
  )
292
295
  # Copy the base layer to the new image.
293
296
  img_np_array = await self.async_copy_array(self.img_base_layer)
294
- # All below will be drawn at each frame.
295
- # Draw zones if any and if enabled
297
+
298
+ # Prepare parallel data extraction tasks
299
+ data_tasks = []
300
+
301
+ # Prepare zone data extraction
302
+ if self.drawing_config.is_enabled(DrawableElement.RESTRICTED_AREA):
303
+ data_tasks.append(self._prepare_zone_data(m_json))
304
+
305
+ # Prepare go_to flag data extraction
306
+ if self.drawing_config.is_enabled(DrawableElement.GO_TO_TARGET):
307
+ data_tasks.append(self._prepare_goto_data(entity_dict))
308
+
309
+ # Prepare path data extraction
310
+ path_enabled = self.drawing_config.is_enabled(DrawableElement.PATH)
311
+ LOGGER.info("%s: PATH element enabled: %s", self.file_name, path_enabled)
312
+ if path_enabled:
313
+ LOGGER.info("%s: Drawing path", self.file_name)
314
+ data_tasks.append(self._prepare_path_data(m_json))
315
+
316
+ # Execute data preparation in parallel if we have tasks
317
+ if data_tasks:
318
+ prepared_data = await AsyncParallel.parallel_data_preparation(*data_tasks)
319
+
320
+ # Process drawing operations sequentially (since they modify the same array)
321
+ # Draw zones if enabled
296
322
  if self.drawing_config.is_enabled(DrawableElement.RESTRICTED_AREA):
297
323
  img_np_array = await self.imd.async_draw_zones(
298
- m_json,
299
- img_np_array,
300
- colors["zone_clean"],
301
- colors["no_go"],
324
+ m_json, img_np_array, colors["zone_clean"], colors["no_go"]
302
325
  )
303
326
 
304
327
  # Draw the go_to target flag if enabled
@@ -307,13 +330,8 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
307
330
  img_np_array, entity_dict, colors["go_to"]
308
331
  )
309
332
 
310
- # Draw path prediction and paths if enabled
311
- path_enabled = self.drawing_config.is_enabled(DrawableElement.PATH)
312
- LOGGER.info(
313
- "%s: PATH element enabled: %s", self.file_name, path_enabled
314
- )
333
+ # Draw paths if enabled
315
334
  if path_enabled:
316
- LOGGER.info("%s: Drawing path", self.file_name)
317
335
  img_np_array = await self.imd.async_draw_paths(
318
336
  img_np_array, m_json, colors["move"], self.color_grey
319
337
  )
@@ -371,15 +389,13 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
371
389
  # Handle resizing if needed, then return based on format preference
372
390
  if self.check_zoom_and_aspect_ratio():
373
391
  # Convert to PIL for resizing
374
- pil_img = Image.fromarray(img_np_array, mode="RGBA")
392
+ pil_img = await AsyncPIL.async_fromarray(img_np_array, mode="RGBA")
375
393
  del img_np_array
376
394
  resize_params = prepare_resize_params(self, pil_img, False)
377
395
  resized_image = await self.async_resize_images(resize_params)
378
396
 
379
397
  # Return WebP bytes or PIL Image based on parameter
380
398
  if return_webp:
381
- from .config.utils import pil_to_webp_bytes
382
-
383
399
  webp_bytes = await pil_to_webp_bytes(resized_image)
384
400
  return webp_bytes
385
401
  else:
@@ -394,7 +410,7 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
394
410
  return webp_bytes
395
411
  else:
396
412
  # Convert to PIL Image (original behavior)
397
- pil_img = Image.fromarray(img_np_array, mode="RGBA")
413
+ pil_img = await AsyncPIL.async_fromarray(img_np_array, mode="RGBA")
398
414
  del img_np_array
399
415
  LOGGER.debug("%s: Frame Completed.", self.file_name)
400
416
  return pil_img
@@ -474,4 +490,26 @@ class HypferMapImageHandler(BaseHandler, AutoCrop):
474
490
  @staticmethod
475
491
  async def async_copy_array(original_array):
476
492
  """Copy the array."""
477
- return original_array.copy()
493
+ return await AsyncNumPy.async_copy(original_array)
494
+
495
+ async def _prepare_zone_data(self, m_json):
496
+ """Prepare zone data for parallel processing."""
497
+ await asyncio.sleep(0) # Yield control
498
+ try:
499
+ return self.data.find_zone_entities(m_json)
500
+ except (ValueError, KeyError):
501
+ return None
502
+
503
+ async def _prepare_goto_data(self, entity_dict):
504
+ """Prepare go-to flag data for parallel processing."""
505
+ await asyncio.sleep(0) # Yield control
506
+ # Extract go-to target data from entity_dict
507
+ return entity_dict.get("go_to_target", None)
508
+
509
+ async def _prepare_path_data(self, m_json):
510
+ """Prepare path data for parallel processing."""
511
+ await asyncio.sleep(0) # Yield control
512
+ try:
513
+ return self.data.find_path_entities(m_json)
514
+ except (ValueError, KeyError):
515
+ return None
@@ -9,7 +9,7 @@ Version: v0.1.6
9
9
  from __future__ import annotations
10
10
 
11
11
  import numpy as np
12
-
12
+ import pandas as pd
13
13
  from .config.types import ImageSize, JsonType
14
14
 
15
15
 
@@ -18,65 +18,48 @@ class ImageData:
18
18
 
19
19
  @staticmethod
20
20
  def sublist(lst, n):
21
- """Sub lists of specific n number of elements"""
22
- return [lst[i : i + n] for i in range(0, len(lst), n)]
21
+ """Split a list into n chunks of specified size."""
22
+ return [lst[i: i + n] for i in range(0, len(lst), n)]
23
23
 
24
24
  @staticmethod
25
25
  def sublist_join(lst, n):
26
- """Join the lists in a unique list of n elements"""
26
+ """Join the lists in a unique list of n elements."""
27
27
  arr = np.array(lst)
28
28
  num_windows = len(lst) - n + 1
29
- result = [arr[i : i + n].tolist() for i in range(num_windows)]
29
+ result = [arr[i: i + n].tolist() for i in range(num_windows)]
30
30
  return result
31
31
 
32
- # The below functions are basically the same ech one
33
- # of them is allowing filtering and putting together in a
34
- # list the specific Layers, Paths, Zones and Pints in the
35
- # Vacuums Json in parallel.
36
-
37
32
  @staticmethod
38
33
  def get_obstacles(entity_dict: dict) -> list:
39
34
  """Get the obstacles positions from the entity data."""
40
- try:
41
- obstacle_data = entity_dict.get("obstacle")
42
- except KeyError:
43
- return []
35
+ obstacles = entity_dict.get("obstacle", [])
44
36
  obstacle_positions = []
45
- if obstacle_data:
46
- for obstacle in obstacle_data:
47
- label = obstacle.get("metaData", {}).get("label")
48
- points = obstacle.get("points", [])
49
- image_id = obstacle.get("metaData", {}).get("id")
50
-
51
- if label and points:
52
- obstacle_pos = {
53
- "label": label,
54
- "points": {"x": points[0], "y": points[1]},
55
- "id": image_id,
56
- }
57
- obstacle_positions.append(obstacle_pos)
58
- return obstacle_positions
59
- return []
60
-
61
- @staticmethod
62
- def find_layers(
63
- json_obj: JsonType, layer_dict: dict, active_list: list
64
- ) -> tuple[dict, list]:
37
+ for obstacle in obstacles:
38
+ label = obstacle.get("metaData", {}).get("label")
39
+ points = obstacle.get("points", [])
40
+ image_id = obstacle.get("metaData", {}).get("id")
41
+ if label and points:
42
+ obstacle_positions.append({
43
+ "label": label,
44
+ "points": {"x": points[0], "y": points[1]},
45
+ "id": image_id,
46
+ })
47
+ return obstacle_positions
48
+
49
+ @staticmethod
50
+ def find_layers(json_obj: JsonType, layer_dict: dict, active_list: list) -> tuple[dict, list]:
65
51
  """Find the layers in the json object."""
66
52
  layer_dict = {} if layer_dict is None else layer_dict
67
53
  active_list = [] if active_list is None else active_list
68
54
  if isinstance(json_obj, dict):
69
- if "__class" in json_obj and json_obj["__class"] == "MapLayer":
55
+ if json_obj.get("__class") == "MapLayer":
70
56
  layer_type = json_obj.get("type")
71
57
  active_type = json_obj.get("metaData")
72
58
  if layer_type:
73
- if layer_type not in layer_dict:
74
- layer_dict[layer_type] = []
75
- layer_dict[layer_type].append(json_obj.get("compressedPixels", []))
59
+ layer_dict.setdefault(layer_type, []).append(json_obj.get("compressedPixels", []))
76
60
  if layer_type == "segment":
77
- active_list.append(int(active_type["active"]))
78
-
79
- for value in json_obj.items():
61
+ active_list.append(int(active_type.get("active", 0)))
62
+ for value in json_obj.values():
80
63
  ImageData.find_layers(value, layer_dict, active_list)
81
64
  elif isinstance(json_obj, list):
82
65
  for item in json_obj:
@@ -86,8 +69,7 @@ class ImageData:
86
69
  @staticmethod
87
70
  def find_points_entities(json_obj: JsonType, entity_dict: dict = None) -> dict:
88
71
  """Find the points entities in the json object."""
89
- if entity_dict is None:
90
- entity_dict = {}
72
+ entity_dict = {} if entity_dict is None else entity_dict
91
73
  if isinstance(json_obj, dict):
92
74
  if json_obj.get("__class") == "PointMapEntity":
93
75
  entity_type = json_obj.get("type")
@@ -103,9 +85,7 @@ class ImageData:
103
85
  @staticmethod
104
86
  def find_paths_entities(json_obj: JsonType, entity_dict: dict = None) -> dict:
105
87
  """Find the paths entities in the json object."""
106
-
107
- if entity_dict is None:
108
- entity_dict = {}
88
+ entity_dict = {} if entity_dict is None else entity_dict
109
89
  if isinstance(json_obj, dict):
110
90
  if json_obj.get("__class") == "PathMapEntity":
111
91
  entity_type = json_obj.get("type")
@@ -121,8 +101,7 @@ class ImageData:
121
101
  @staticmethod
122
102
  def find_zone_entities(json_obj: JsonType, entity_dict: dict = None) -> dict:
123
103
  """Find the zone entities in the json object."""
124
- if entity_dict is None:
125
- entity_dict = {}
104
+ entity_dict = {} if entity_dict is None else entity_dict
126
105
  if isinstance(json_obj, dict):
127
106
  if json_obj.get("__class") == "PolygonMapEntity":
128
107
  entity_type = json_obj.get("type")
@@ -138,59 +117,41 @@ class ImageData:
138
117
  @staticmethod
139
118
  def find_virtual_walls(json_obj: JsonType) -> list:
140
119
  """Find the virtual walls in the json object."""
141
- virtual_walls = []
120
+ walls = []
142
121
 
143
- def find_virtual_walls_recursive(obj):
144
- """Find the virtual walls in the json object recursively."""
122
+ def _recursive(obj):
145
123
  if isinstance(obj, dict):
146
- if obj.get("__class") == "LineMapEntity":
147
- entity_type = obj.get("type")
148
- if entity_type == "virtual_wall":
149
- virtual_walls.append(obj["points"])
124
+ if obj.get("__class") == "LineMapEntity" and obj.get("type") == "virtual_wall":
125
+ walls.append(obj["points"])
150
126
  for value in obj.values():
151
- find_virtual_walls_recursive(value)
127
+ _recursive(value)
152
128
  elif isinstance(obj, list):
153
129
  for item in obj:
154
- find_virtual_walls_recursive(item)
130
+ _recursive(item)
155
131
 
156
- find_virtual_walls_recursive(json_obj)
157
- return virtual_walls
132
+ _recursive(json_obj)
133
+ return walls
158
134
 
159
135
  @staticmethod
160
- async def async_get_rooms_coordinates(
161
- pixels: list, pixel_size: int = 5, rand: bool = False
162
- ) -> tuple:
163
- """
164
- Extract the room coordinates from the vacuum pixels data.
165
- piexels: dict: The pixels data format [[x,y,z], [x1,y1,z1], [xn,yn,zn]].
166
- pixel_size: int: The size of the pixel in mm (optional).
167
- rand: bool: Return the coordinates in a rand256 format (optional).
168
- """
169
- # Initialize variables to store max and min coordinates
170
- max_x, max_y = pixels[0][0], pixels[0][1]
171
- min_x, min_y = pixels[0][0], pixels[0][1]
172
- # Iterate through the data list to find max and min coordinates
173
- for entry in pixels:
174
- if rand:
175
- x, y, _ = entry # Extract x and y coordinates
176
- max_x = max(max_x, x) # Update max x coordinate
177
- max_y = max(max_y, y + pixel_size) # Update max y coordinate
178
- min_x = min(min_x, x) # Update min x coordinate
179
- min_y = min(min_y, y) # Update min y coordinate
180
- else:
181
- x, y, z = entry # Extract x and y coordinates
182
- max_x = max(max_x, x + z) # Update max x coordinate
183
- max_y = max(max_y, y + pixel_size) # Update max y coordinate
184
- min_x = min(min_x, x) # Update min x coordinate
185
- min_y = min(min_y, y) # Update min y coordinate
136
+ async def async_get_rooms_coordinates(pixels: list, pixel_size: int = 5, rand: bool = False) -> tuple:
137
+ """Extract the room coordinates from the vacuum pixels data."""
138
+ df = pd.DataFrame(pixels, columns=["x", "y", "length"])
139
+ if rand:
140
+ df["x_end"] = df["x"]
141
+ df["y_end"] = df["y"] + pixel_size
142
+ else:
143
+ df["x_end"] = df["x"] + df["length"]
144
+ df["y_end"] = df["y"] + pixel_size
145
+
146
+ min_x, max_x = df["x"].min(), df["x_end"].max()
147
+ min_y, max_y = df["y"].min(), df["y_end"].max()
148
+
186
149
  if rand:
187
150
  return (
188
- (((max_x * pixel_size) * 10), ((max_y * pixel_size) * 10)),
189
- (
190
- ((min_x * pixel_size) * 10),
191
- ((min_y * pixel_size) * 10),
192
- ),
151
+ ((max_x * pixel_size) * 10, (max_y * pixel_size) * 10),
152
+ ((min_x * pixel_size) * 10, (min_y * pixel_size) * 10),
193
153
  )
154
+
194
155
  return (
195
156
  min_x * pixel_size,
196
157
  min_y * pixel_size,
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "valetudo-map-parser"
3
- version = "0.1.9b60"
3
+ version = "0.1.9b62"
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"
@@ -18,6 +18,7 @@ python = ">=3.12"
18
18
  numpy = ">=1.26.4"
19
19
  Pillow = ">=10.3.0"
20
20
  scipy = ">=1.12.0"
21
+ pandas = ">=2.3.0"
21
22
 
22
23
  [tool.poetry.group.dev.dependencies]
23
24
  ruff = "*"