valetudo-map-parser 0.1.2__tar.gz → 0.1.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: valetudo-map-parser
3
- Version: 0.1.2
3
+ Version: 0.1.3
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
@@ -0,0 +1,2 @@
1
+ """Valetudo map parser.
2
+ Version: 0.1.0"""
@@ -0,0 +1 @@
1
+ """Configuration module for the SCR package."""
@@ -0,0 +1,288 @@
1
+ """Auto Crop Class for trimming and zooming images.
2
+ Version: 2024.10.0"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+
8
+ import numpy as np
9
+ from numpy import rot90
10
+
11
+ from .types import Color, NumpyArray, TrimCropData
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+
16
+ class TrimError(Exception):
17
+ """Exception raised for errors in the trim process."""
18
+
19
+ def __init__(self, message, image):
20
+ super().__init__(message)
21
+ self.image = image
22
+
23
+
24
+ class AutoCrop:
25
+ """Auto Crop Class for trimming and zooming images."""
26
+
27
+ def __init__(self, image_handler):
28
+ self.imh = image_handler
29
+ self.file_name = self.imh.file_name
30
+ # self.path_to_data = self.hass.config.path(
31
+ # STORAGE_DIR, CAMERA_STORAGE, f"auto_crop_{self.file_name}.json"
32
+ # )
33
+
34
+ def check_trim(
35
+ self, trimmed_height, trimmed_width, margin_size, image_array, file_name, rotate
36
+ ):
37
+ """Check if the trim is okay."""
38
+ if trimmed_height <= margin_size or trimmed_width <= margin_size:
39
+ self.imh.crop_area = [0, 0, image_array.shape[1], image_array.shape[0]]
40
+ self.imh.img_size = (image_array.shape[1], image_array.shape[0])
41
+ raise TrimError(
42
+ f"{file_name}: Trimming failed at rotation {rotate}.", image_array
43
+ )
44
+
45
+ def _calculate_trimmed_dimensions(self):
46
+ """Calculate and update the dimensions after trimming."""
47
+ trimmed_width = max(
48
+ 0,
49
+ (
50
+ (self.imh.trim_right - self.imh.offset_right)
51
+ - (self.imh.trim_left + self.imh.offset_left)
52
+ ),
53
+ )
54
+ trimmed_height = max(
55
+ 0,
56
+ (
57
+ (self.imh.trim_down - self.imh.offset_bottom)
58
+ - (self.imh.trim_up + self.imh.offset_top)
59
+ ),
60
+ )
61
+ # Ensure shared reference dimensions are updated
62
+ if hasattr(self.imh.shared, "image_ref_height") and hasattr(
63
+ self.imh.shared, "image_ref_width"
64
+ ):
65
+ self.imh.shared.image_ref_height = trimmed_height
66
+ self.imh.shared.image_ref_width = trimmed_width
67
+ else:
68
+ _LOGGER.warning(
69
+ "Shared attributes for image dimensions are not initialized."
70
+ )
71
+ return trimmed_width, trimmed_height
72
+
73
+ async def _async_auto_crop_data(self, tdata=None):
74
+ """Load the auto crop data from the disk."""
75
+
76
+ if not self.imh.auto_crop:
77
+ trims_data = TrimCropData.from_dict(dict(tdata)).to_list()
78
+ (
79
+ self.imh.trim_left,
80
+ self.imh.trim_up,
81
+ self.imh.trim_right,
82
+ self.imh.trim_down,
83
+ ) = trims_data
84
+ self._calculate_trimmed_dimensions()
85
+ return trims_data
86
+ return None
87
+
88
+ def auto_crop_offset(self):
89
+ """Calculate the offset for the auto crop."""
90
+ if self.imh.auto_crop:
91
+ self.imh.auto_crop[0] += self.imh.offset_left
92
+ self.imh.auto_crop[1] += self.imh.offset_top
93
+ self.imh.auto_crop[2] -= self.imh.offset_right
94
+ self.imh.auto_crop[3] -= self.imh.offset_bottom
95
+
96
+ async def _init_auto_crop(self):
97
+ """Initialize the auto crop data."""
98
+ if not self.imh.auto_crop and self.imh.shared.vacuum_state == "docked":
99
+ self.imh.auto_crop = await self._async_auto_crop_data()
100
+ if self.imh.auto_crop:
101
+ self.auto_crop_offset()
102
+ else:
103
+ self.imh.max_frames = 5
104
+ return self.imh.auto_crop
105
+
106
+ # async def _async_save_auto_crop_data(self):
107
+ # """Save the auto crop data to the disk."""
108
+ # try:
109
+ # if not os.path.exists(self.path_to_data):
110
+ # data = TrimCropData(
111
+ # self.imh.trim_left,
112
+ # self.imh.trim_up,
113
+ # self.imh.trim_right,
114
+ # self.imh.trim_down,
115
+ # ).to_dict()
116
+ # except Exception as e:
117
+ # _LOGGER.error(f"Failed to save trim data due to an error: {e}")
118
+
119
+ async def async_image_margins(
120
+ self, image_array: NumpyArray, detect_colour: Color
121
+ ) -> tuple[int, int, int, int]:
122
+ """Crop the image based on the auto crop area."""
123
+ nonzero_coords = np.column_stack(np.where(image_array != list(detect_colour)))
124
+ # Calculate the trim box based on the first and last occurrences
125
+ min_y, min_x, _ = NumpyArray.min(nonzero_coords, axis=0)
126
+ max_y, max_x, _ = NumpyArray.max(nonzero_coords, axis=0)
127
+ del nonzero_coords
128
+ _LOGGER.debug(
129
+ "%s: Found trims max and min values (y,x) (%s, %s) (%s, %s)...",
130
+ self.file_name,
131
+ int(max_y),
132
+ int(max_x),
133
+ int(min_y),
134
+ int(min_x),
135
+ )
136
+ return min_y, min_x, max_x, max_y
137
+
138
+ async def async_check_if_zoom_is_on(
139
+ self,
140
+ image_array: NumpyArray,
141
+ margin_size: int = 100,
142
+ zoom: bool = False,
143
+ rand256: bool = False,
144
+ ) -> NumpyArray:
145
+ """Check if the image need to be zoom."""
146
+
147
+ if (
148
+ zoom
149
+ and self.imh.shared.vacuum_state == "cleaning"
150
+ and self.imh.shared.image_auto_zoom
151
+ ):
152
+ # Zoom the image based on the robot's position.
153
+ _LOGGER.debug(
154
+ "%s: Zooming the image on room %s.",
155
+ self.file_name,
156
+ self.imh.robot_in_room["room"],
157
+ )
158
+ if rand256:
159
+ trim_left = round(self.imh.robot_in_room["right"] / 10) - margin_size
160
+ trim_right = round(self.imh.robot_in_room["left"] / 10) + margin_size
161
+ trim_up = round(self.imh.robot_in_room["down"] / 10) - margin_size
162
+ trim_down = round(self.imh.robot_in_room["up"] / 10) + margin_size
163
+ else:
164
+ trim_left = self.imh.robot_in_room["left"] - margin_size
165
+ trim_right = self.imh.robot_in_room["right"] + margin_size
166
+ trim_up = self.imh.robot_in_room["up"] - margin_size
167
+ trim_down = self.imh.robot_in_room["down"] + margin_size
168
+ trim_left, trim_right = sorted([trim_left, trim_right])
169
+ trim_up, trim_down = sorted([trim_up, trim_down])
170
+ trimmed = image_array[trim_up:trim_down, trim_left:trim_right]
171
+ else:
172
+ # Apply the auto-calculated trims to the rotated image
173
+ trimmed = image_array[
174
+ self.imh.auto_crop[1] : self.imh.auto_crop[3],
175
+ self.imh.auto_crop[0] : self.imh.auto_crop[2],
176
+ ]
177
+ return trimmed
178
+
179
+ async def async_rotate_the_image(
180
+ self, trimmed: NumpyArray, rotate: int
181
+ ) -> NumpyArray:
182
+ """Rotate the image and return the new array."""
183
+ if rotate == 90:
184
+ rotated = rot90(trimmed)
185
+ self.imh.crop_area = [
186
+ self.imh.trim_left,
187
+ self.imh.trim_up,
188
+ self.imh.trim_right,
189
+ self.imh.trim_down,
190
+ ]
191
+ elif rotate == 180:
192
+ rotated = rot90(trimmed, 2)
193
+ self.imh.crop_area = self.imh.auto_crop
194
+ elif rotate == 270:
195
+ rotated = rot90(trimmed, 3)
196
+ self.imh.crop_area = [
197
+ self.imh.trim_left,
198
+ self.imh.trim_up,
199
+ self.imh.trim_right,
200
+ self.imh.trim_down,
201
+ ]
202
+ else:
203
+ rotated = trimmed
204
+ self.imh.crop_area = self.imh.auto_crop
205
+ return rotated
206
+
207
+ async def async_auto_trim_and_zoom_image(
208
+ self,
209
+ image_array: NumpyArray,
210
+ detect_colour: Color = (93, 109, 126, 255),
211
+ margin_size: int = 0,
212
+ rotate: int = 0,
213
+ zoom: bool = False,
214
+ rand256: bool = False,
215
+ ):
216
+ """
217
+ Automatically crops and trims a numpy array and returns the processed image.
218
+ """
219
+ try:
220
+ await self._init_auto_crop()
221
+ if self.imh.auto_crop is None:
222
+ _LOGGER.debug("%s: Calculating auto trim box", self.file_name)
223
+ # Find the coordinates of the first occurrence of a non-background color
224
+ min_y, min_x, max_x, max_y = await self.async_image_margins(
225
+ image_array, detect_colour
226
+ )
227
+ # Calculate and store the trims coordinates with margins
228
+ self.imh.trim_left = int(min_x) - margin_size
229
+ self.imh.trim_up = int(min_y) - margin_size
230
+ self.imh.trim_right = int(max_x) + margin_size
231
+ self.imh.trim_down = int(max_y) + margin_size
232
+ del min_y, min_x, max_x, max_y
233
+
234
+ # Calculate the dimensions after trimming using min/max values
235
+ trimmed_width, trimmed_height = self._calculate_trimmed_dimensions()
236
+
237
+ # Test if the trims are okay or not
238
+ try:
239
+ self.check_trim(
240
+ trimmed_height,
241
+ trimmed_width,
242
+ margin_size,
243
+ image_array,
244
+ self.file_name,
245
+ rotate,
246
+ )
247
+ except TrimError as e:
248
+ return e.image
249
+
250
+ # Store Crop area of the original image_array we will use from the next frame.
251
+ self.imh.auto_crop = TrimCropData(
252
+ self.imh.trim_left,
253
+ self.imh.trim_up,
254
+ self.imh.trim_right,
255
+ self.imh.trim_down,
256
+ ).to_list()
257
+ # if self.imh.shared.vacuum_state == "docked":
258
+ # await (
259
+ # self._async_save_auto_crop_data()
260
+ # ) # Save the crop data to the disk
261
+ self.auto_crop_offset()
262
+ # If it is needed to zoom the image.
263
+ trimmed = await self.async_check_if_zoom_is_on(
264
+ image_array, margin_size, zoom, rand256
265
+ )
266
+ del image_array # Free memory.
267
+ # Rotate the cropped image based on the given angle
268
+ rotated = await self.async_rotate_the_image(trimmed, rotate)
269
+ del trimmed # Free memory.
270
+ _LOGGER.debug(
271
+ "%s: Auto Trim Box data: %s", self.file_name, self.imh.crop_area
272
+ )
273
+ self.imh.crop_img_size = [rotated.shape[1], rotated.shape[0]]
274
+ _LOGGER.debug(
275
+ "%s: Auto Trimmed image size: %s",
276
+ self.file_name,
277
+ self.imh.crop_img_size,
278
+ )
279
+
280
+ except RuntimeError as e:
281
+ _LOGGER.warning(
282
+ "%s: Error %s during auto trim and zoom.",
283
+ self.file_name,
284
+ e,
285
+ exc_info=True,
286
+ )
287
+ return None
288
+ return rotated
@@ -0,0 +1,178 @@
1
+ """Colors for the maps Elements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from typing import List, Dict, Tuple
7
+ import logging
8
+
9
+ _LOGGER = logging.getLogger(__name__)
10
+
11
+ Color = Tuple[int, int, int, int] # RGBA color definition
12
+
13
+
14
+ class SupportedColor(StrEnum):
15
+ """Color of a supported map element."""
16
+
17
+ CHARGER = "color_charger"
18
+ PATH = "color_move"
19
+ PREDICTED_PATH = "color_predicted_move"
20
+ WALLS = "color_wall"
21
+ ROBOT = "color_robot"
22
+ GO_TO = "color_go_to"
23
+ NO_GO = "color_no_go"
24
+ ZONE_CLEAN = "color_zone_clean"
25
+ MAP_BACKGROUND = "color_background"
26
+ TEXT = "color_text"
27
+ TRANSPARENT = "color_transparent"
28
+ COLOR_ROOM_PREFIX = "color_room_"
29
+
30
+ @staticmethod
31
+ def room_key(index: int) -> str:
32
+ return f"{SupportedColor.COLOR_ROOM_PREFIX}{index}"
33
+
34
+
35
+ class DefaultColors:
36
+ """Container that simplifies retrieving default RGB and RGBA colors."""
37
+
38
+ COLORS_RGB: Dict[str, Tuple[int, int, int]] = {
39
+ SupportedColor.CHARGER: (255, 128, 0),
40
+ SupportedColor.PATH: (238, 247, 255),
41
+ SupportedColor.PREDICTED_PATH: (93, 109, 126),
42
+ SupportedColor.WALLS: (255, 255, 0),
43
+ SupportedColor.ROBOT: (255, 255, 204),
44
+ SupportedColor.GO_TO: (0, 255, 0),
45
+ SupportedColor.NO_GO: (255, 0, 0),
46
+ SupportedColor.ZONE_CLEAN: (255, 255, 255),
47
+ SupportedColor.MAP_BACKGROUND: (0, 125, 255),
48
+ SupportedColor.TEXT: (0, 0, 0),
49
+ SupportedColor.TRANSPARENT: (0, 0, 0),
50
+ }
51
+
52
+ DEFAULT_ROOM_COLORS: Dict[str, Tuple[int, int, int]] = {
53
+ SupportedColor.room_key(i): color
54
+ for i, color in enumerate(
55
+ [
56
+ (135, 206, 250),
57
+ (176, 226, 255),
58
+ (165, 105, 18),
59
+ (164, 211, 238),
60
+ (141, 182, 205),
61
+ (96, 123, 139),
62
+ (224, 255, 255),
63
+ (209, 238, 238),
64
+ (180, 205, 205),
65
+ (122, 139, 139),
66
+ (175, 238, 238),
67
+ (84, 153, 199),
68
+ (133, 193, 233),
69
+ (245, 176, 65),
70
+ (82, 190, 128),
71
+ (72, 201, 176),
72
+ ]
73
+ )
74
+ }
75
+
76
+ DEFAULT_ALPHA: Dict[str, float] = {
77
+ f"alpha_{key}": 255.0 for key in COLORS_RGB.keys()
78
+ }
79
+ DEFAULT_ALPHA.update({f"alpha_room_{i}": 255.0 for i in range(16)})
80
+
81
+ @classmethod
82
+ def get_rgba(cls, key: str, alpha: float) -> Color:
83
+ rgb = cls.COLORS_RGB.get(key, (0, 0, 0))
84
+ r, g, b = rgb # Explicitly unpack the RGB values
85
+ return r, g, b, int(alpha)
86
+
87
+
88
+ class ColorsManagment:
89
+ """Manages user-defined and default colors for map elements."""
90
+
91
+ def __init__(self, device_info: dict) -> None:
92
+ """
93
+ Initialize ColorsManagment with optional device_info from Home Assistant.
94
+ :param device_info: Dictionary containing user-defined RGB colors and alpha values.
95
+ """
96
+ self.user_colors = self.initialize_user_colors(device_info)
97
+ self.rooms_colors = self.initialize_rooms_colors(device_info)
98
+
99
+ def initialize_user_colors(self, device_info: dict) -> List[Color]:
100
+ """
101
+ Initialize user-defined colors with defaults as fallback.
102
+ :param device_info: Dictionary containing user-defined colors.
103
+ :return: List of RGBA colors for map elements.
104
+ """
105
+ colors = []
106
+ for key in SupportedColor:
107
+ if key.startswith(SupportedColor.COLOR_ROOM_PREFIX):
108
+ continue # Skip room colors for user_colors
109
+ rgb = device_info.get(key, DefaultColors.COLORS_RGB.get(key))
110
+ alpha = device_info.get(
111
+ f"alpha_{key}", DefaultColors.DEFAULT_ALPHA.get(f"alpha_{key}")
112
+ )
113
+ colors.append(self.add_alpha_to_color(rgb, alpha))
114
+ return colors
115
+
116
+ def initialize_rooms_colors(self, device_info: dict) -> List[Color]:
117
+ """
118
+ Initialize room colors with defaults as fallback.
119
+ :param device_info: Dictionary containing user-defined room colors.
120
+ :return: List of RGBA colors for rooms.
121
+ """
122
+ colors = []
123
+ for i in range(16):
124
+ rgb = device_info.get(
125
+ SupportedColor.room_key(i),
126
+ DefaultColors.DEFAULT_ROOM_COLORS.get(SupportedColor.room_key(i)),
127
+ )
128
+ alpha = device_info.get(
129
+ f"alpha_room_{i}", DefaultColors.DEFAULT_ALPHA.get(f"alpha_room_{i}")
130
+ )
131
+ colors.append(self.add_alpha_to_color(rgb, alpha))
132
+ return colors
133
+
134
+ @staticmethod
135
+ def add_alpha_to_color(rgb: Tuple[int, int, int], alpha: float) -> Color:
136
+ """
137
+ Convert RGB to RGBA by appending the alpha value.
138
+ :param rgb: RGB values.
139
+ :param alpha: Alpha value (0.0 to 255.0).
140
+ :return: RGBA color.
141
+ """
142
+ return (*rgb, int(alpha)) if rgb else (0, 0, 0, int(alpha))
143
+
144
+ def get_user_colors(self) -> List[Color]:
145
+ """Return the list of RGBA colors for user-defined map elements."""
146
+ return self.user_colors
147
+
148
+ def get_rooms_colors(self) -> List[Color]:
149
+ """Return the list of RGBA colors for rooms."""
150
+ return self.rooms_colors
151
+
152
+ def get_colour(self, supported_color: SupportedColor) -> Color:
153
+ """
154
+ Retrieve the color for a specific map element, prioritizing user-defined values.
155
+
156
+ :param supported_color: The SupportedColor key for the desired color.
157
+ :return: The RGBA color for the given map element.
158
+ """
159
+ # Handle room-specific colors
160
+ if supported_color.startswith("color_room_"):
161
+ room_index = int(supported_color.split("_")[-1])
162
+ try:
163
+ return self.rooms_colors[room_index]
164
+ except (IndexError, KeyError):
165
+ _LOGGER.warning("Room index %s not found, using default.", room_index)
166
+ r, g, b = DefaultColors.DEFAULT_ROOM_COLORS[f"color_room_{room_index}"]
167
+ a = DefaultColors.DEFAULT_ALPHA[f"alpha_room_{room_index}"]
168
+ return r, g, b, int(a)
169
+
170
+ # Handle general map element colors
171
+ try:
172
+ index = list(SupportedColor).index(supported_color)
173
+ return self.user_colors[index]
174
+ except (IndexError, KeyError, ValueError):
175
+ _LOGGER.warning(
176
+ "Color for %s not found. Returning default.", supported_color
177
+ )
178
+ return DefaultColors.get_rgba(supported_color, 255) # Transparent fallback