valetudo-map-parser 0.1.2__py3-none-any.whl → 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,561 @@
1
+ """
2
+ Collections of Drawing Utility
3
+ Drawable is part of the Image_Handler
4
+ used functions to draw the elements on the Numpy Array
5
+ that is actually our camera frame.
6
+ Version: v2024.12.0
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ from PIL import ImageDraw, ImageFont
14
+ import numpy as np
15
+
16
+ from .types import Color, NumpyArray, PilPNG, Point, Union
17
+
18
+
19
+ class Drawable:
20
+ """
21
+ Collection of drawing utility functions for the image handlers.
22
+ This class contains static methods to draw various elements on the Numpy Arrays (images).
23
+ We cant use openCV because it is not supported by the Home Assistant OS.
24
+ """
25
+
26
+ ERROR_OUTLINE = (0, 0, 0, 255) # Red color for error messages
27
+ ERROR_COLOR = (255, 0, 0, 191) # Red color with lower opacity for error outlines
28
+
29
+ @staticmethod
30
+ async def create_empty_image(
31
+ width: int, height: int, background_color: Color
32
+ ) -> NumpyArray:
33
+ """Create the empty background image numpy array.
34
+ Background color is specified as RGBA tuple."""
35
+ image_array = np.full((height, width, 4), background_color, dtype=np.uint8)
36
+ return image_array
37
+
38
+ @staticmethod
39
+ async def from_json_to_image(
40
+ layer: NumpyArray, pixels: Union[dict, list], pixel_size: int, color: Color
41
+ ) -> NumpyArray:
42
+ """Drawing the layers (rooms) from the vacuum json data."""
43
+ image_array = layer
44
+ # Loop through pixels to find min and max coordinates
45
+ for x, y, z in pixels:
46
+ col = x * pixel_size
47
+ row = y * pixel_size
48
+ # Draw pixels
49
+ for i in range(z):
50
+ image_array[
51
+ row : row + pixel_size,
52
+ col + i * pixel_size : col + (i + 1) * pixel_size,
53
+ ] = color
54
+ return image_array
55
+
56
+ @staticmethod
57
+ async def battery_charger(
58
+ layers: NumpyArray, x: int, y: int, color: Color
59
+ ) -> NumpyArray:
60
+ """Draw the battery charger on the input layer."""
61
+ charger_width = 10
62
+ charger_height = 20
63
+ # Get the starting and ending indices of the charger rectangle
64
+ start_row = y - charger_height // 2
65
+ end_row = start_row + charger_height
66
+ start_col = x - charger_width // 2
67
+ end_col = start_col + charger_width
68
+ # Fill in the charger rectangle with the specified color
69
+ layers[start_row:end_row, start_col:end_col] = color
70
+ return layers
71
+
72
+ @staticmethod
73
+ async def go_to_flag(
74
+ layer: NumpyArray, center: Point, rotation_angle: int, flag_color: Color
75
+ ) -> NumpyArray:
76
+ """
77
+ It is draw a flag on centered at specified coordinates on
78
+ the input layer. It uses the rotation angle of the image
79
+ to orientate the flag on the given layer.
80
+ """
81
+ # Define flag color
82
+ pole_color = (0, 0, 255, 255) # RGBA color (blue)
83
+ # Define flag size and position
84
+ flag_size = 50
85
+ pole_width = 6
86
+ # Adjust flag coordinates based on rotation angle
87
+ if rotation_angle == 90:
88
+ x1 = center[0] + flag_size
89
+ y1 = center[1] - (pole_width // 2)
90
+ x2 = x1 - (flag_size // 4)
91
+ y2 = y1 + (flag_size // 2)
92
+ x3 = center[0] + (flag_size // 2)
93
+ y3 = center[1] - (pole_width // 2)
94
+ # Define pole end position
95
+ xp1 = center[0]
96
+ yp1 = center[1] - (pole_width // 2)
97
+ xp2 = center[0] + flag_size
98
+ yp2 = center[1] - (pole_width // 2)
99
+ elif rotation_angle == 180:
100
+ x1 = center[0]
101
+ y1 = center[1] - (flag_size // 2)
102
+ x2 = center[0] - (flag_size // 2)
103
+ y2 = y1 + (flag_size // 4)
104
+ x3 = center[0]
105
+ y3 = center[1]
106
+ # Define pole end position
107
+ xp1 = center[0] + (pole_width // 2)
108
+ yp1 = center[1] - flag_size
109
+ xp2 = center[0] + (pole_width // 2)
110
+ yp2 = y3
111
+ elif rotation_angle == 270:
112
+ x1 = center[0] - flag_size
113
+ y1 = center[1] + (pole_width // 2)
114
+ x2 = x1 + (flag_size // 4)
115
+ y2 = y1 - (flag_size // 2)
116
+ x3 = center[0] - (flag_size // 2)
117
+ y3 = center[1] + (pole_width // 2)
118
+ # Define pole end position
119
+ xp1 = center[0] - flag_size
120
+ yp1 = center[1] + (pole_width // 2)
121
+ xp2 = center[0]
122
+ yp2 = center[1] + (pole_width // 2)
123
+ else:
124
+ # rotation_angle == 0 (no rotation)
125
+ x1 = center[0]
126
+ y1 = center[1]
127
+ x2 = center[0] + (flag_size // 2)
128
+ y2 = y1 + (flag_size // 4)
129
+ x3 = center[0]
130
+ y3 = center[1] + flag_size // 2
131
+ # Define pole end position
132
+ xp1 = center[0] - (pole_width // 2)
133
+ yp1 = y1
134
+ xp2 = center[0] - (pole_width // 2)
135
+ yp2 = center[1] + flag_size
136
+
137
+ # Draw flag outline using _polygon_outline
138
+ points = [(x1, y1), (x2, y2), (x3, y3)]
139
+ layer = Drawable._polygon_outline(layer, points, 1, flag_color, flag_color)
140
+
141
+ # Draw pole using _line
142
+ layer = Drawable._line(layer, xp1, yp1, xp2, yp2, pole_color, pole_width)
143
+
144
+ return layer
145
+
146
+ @staticmethod
147
+ def point_inside(x: int, y: int, points) -> bool:
148
+ """
149
+ Check if a point (x, y) is inside a polygon defined by a list of points.
150
+ Utility to establish the fill point of the geometry.
151
+ Parameters:
152
+ - x, y: Coordinates of the point to check.
153
+ - points: List of (x, y) coordinates defining the polygon.
154
+
155
+ Returns:
156
+ - True if the point is inside the polygon, False otherwise.
157
+ """
158
+ n = len(points)
159
+ inside = False
160
+ xinters = 0
161
+ p1x, p1y = points[0]
162
+ for i in range(1, n + 1):
163
+ p2x, p2y = points[i % n]
164
+ if y > min(p1y, p2y):
165
+ if y <= max(p1y, p2y):
166
+ if x <= max(p1x, p2x):
167
+ if p1y != p2y:
168
+ xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
169
+ if p1x == p2x or x <= xinters:
170
+ inside = not inside
171
+ p1x, p1y = p2x, p2y
172
+
173
+ return inside
174
+
175
+ @staticmethod
176
+ def _line(
177
+ layer: NumpyArray,
178
+ x1: int,
179
+ y1: int,
180
+ x2: int,
181
+ y2: int,
182
+ color: Color,
183
+ width: int = 3,
184
+ ) -> NumpyArray:
185
+ """
186
+ Draw a line on a NumPy array (layer) from point A to B.
187
+ Parameters:
188
+ - layer: NumPy array representing the image.
189
+ - x1, y1: Starting coordinates of the line.
190
+ - x2, y2: Ending coordinates of the line.
191
+ - color: Color of the line (e.g., [R, G, B] or [R, G, B, A] for RGBA).
192
+ - width: Width of the line (default is 3).
193
+
194
+ Returns:
195
+ - Modified layer with the line drawn.
196
+ """
197
+ # Ensure the coordinates are integers
198
+ x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
199
+
200
+ # Use Bresenham's line algorithm to get the coordinates of the line pixels
201
+ dx = abs(x2 - x1)
202
+ dy = abs(y2 - y1)
203
+ sx = 1 if x1 < x2 else -1
204
+ sy = 1 if y1 < y2 else -1
205
+ err = dx - dy
206
+
207
+ while True:
208
+ # Draw a rectangle with the specified width at the current coordinates
209
+ for i in range(-width // 2, (width + 1) // 2):
210
+ for j in range(-width // 2, (width + 1) // 2):
211
+ if 0 <= x1 + i < layer.shape[1] and 0 <= y1 + j < layer.shape[0]:
212
+ layer[y1 + j, x1 + i] = color
213
+
214
+ if x1 == x2 and y1 == y2:
215
+ break
216
+
217
+ e2 = 2 * err
218
+
219
+ if e2 > -dy:
220
+ err -= dy
221
+ x1 += sx
222
+
223
+ if e2 < dx:
224
+ err += dx
225
+ y1 += sy
226
+
227
+ return layer
228
+
229
+ @staticmethod
230
+ async def draw_virtual_walls(
231
+ layer: NumpyArray, virtual_walls, color: Color
232
+ ) -> NumpyArray:
233
+ """
234
+ Draw virtual walls on the input layer.
235
+ """
236
+ for wall in virtual_walls:
237
+ for i in range(0, len(wall), 4):
238
+ x1, y1, x2, y2 = wall[i : i + 4]
239
+ # Draw the virtual wall as a line with a fixed width of 6 pixels
240
+ layer = Drawable._line(layer, x1, y1, x2, y2, color, width=6)
241
+ return layer
242
+
243
+ @staticmethod
244
+ async def lines(arr: NumpyArray, coords, width: int, color: Color) -> NumpyArray:
245
+ """
246
+ it joins the coordinates creating a continues line.
247
+ the result is our path.
248
+ """
249
+ for coord in coords:
250
+ # Use Bresenham's line algorithm to get the coordinates of the line pixels
251
+ x0, y0 = coord[0]
252
+ try:
253
+ x1, y1 = coord[1]
254
+ except IndexError:
255
+ x1 = x0
256
+ y1 = y0
257
+ dx = abs(x1 - x0)
258
+ dy = abs(y1 - y0)
259
+ sx = 1 if x0 < x1 else -1
260
+ sy = 1 if y0 < y1 else -1
261
+ err = dx - dy
262
+ line_pixels = []
263
+ while True:
264
+ line_pixels.append((x0, y0))
265
+ if x0 == x1 and y0 == y1:
266
+ break
267
+ e2 = 2 * err
268
+ if e2 > -dy:
269
+ err -= dy
270
+ x0 += sx
271
+ if e2 < dx:
272
+ err += dx
273
+ y0 += sy
274
+
275
+ # Iterate over the line pixels and draw filled rectangles with the specified width
276
+ for pixel in line_pixels:
277
+ x, y = pixel
278
+ for i in range(width):
279
+ for j in range(width):
280
+ if 0 <= x + i < arr.shape[0] and 0 <= y + j < arr.shape[1]:
281
+ arr[y + i, x + j] = color
282
+ return arr
283
+
284
+ @staticmethod
285
+ def _filled_circle(
286
+ image: NumpyArray,
287
+ center: Point,
288
+ radius: int,
289
+ color: Color,
290
+ outline_color: Color = None,
291
+ outline_width: int = 0,
292
+ ) -> NumpyArray:
293
+ """
294
+ Draw a filled circle on the image using NumPy.
295
+
296
+ Parameters:
297
+ - image: NumPy array representing the image.
298
+ - center: Center coordinates of the circle (x, y).
299
+ - radius: Radius of the circle.
300
+ - color: Color of the circle (e.g., [R, G, B] or [R, G, B, A] for RGBA).
301
+
302
+ Returns:
303
+ - Modified image with the filled circle drawn.
304
+ """
305
+ y, x = center
306
+ rr, cc = np.ogrid[: image.shape[0], : image.shape[1]]
307
+ circle = (rr - x) ** 2 + (cc - y) ** 2 <= radius**2
308
+ image[circle] = color
309
+ if outline_width > 0:
310
+ # Create a mask for the outer circle
311
+ outer_circle = (rr - x) ** 2 + (cc - y) ** 2 <= (
312
+ radius + outline_width
313
+ ) ** 2
314
+ # Create a mask for the outline by subtracting the inner circle mask from the outer circle mask
315
+ outline_mask = outer_circle & ~circle
316
+ # Fill the outline with the outline color
317
+ image[outline_mask] = outline_color
318
+
319
+ return image
320
+
321
+ @staticmethod
322
+ def _ellipse(
323
+ image: NumpyArray, center: Point, radius: int, color: Color
324
+ ) -> NumpyArray:
325
+ """
326
+ Draw an ellipse on the image using NumPy.
327
+ """
328
+ # Create a copy of the image to avoid modifying the original
329
+ result_image = image
330
+ # Calculate the coordinates of the ellipse's bounding box
331
+ x, y = center
332
+ x1, y1 = x - radius, y - radius
333
+ x2, y2 = x + radius, y + radius
334
+ # Draw the filled ellipse
335
+ result_image[y1:y2, x1:x2] = color
336
+ return result_image
337
+
338
+ @staticmethod
339
+ def _polygon_outline(
340
+ arr: NumpyArray,
341
+ points,
342
+ width: int,
343
+ outline_color: Color,
344
+ fill_color: Color = None,
345
+ ) -> NumpyArray:
346
+ """
347
+ Draw the outline of a filled polygon on the array using _line.
348
+ """
349
+ for i, point in enumerate(points):
350
+ # Get the current and next points to draw a line between them
351
+ current_point = points[i]
352
+ next_point = points[(i + 1) % len(points)] # Wrap around to the first point
353
+ # Use the _line function to draw a line between the current and next points
354
+ arr = Drawable._line(
355
+ arr,
356
+ current_point[0],
357
+ current_point[1],
358
+ next_point[0],
359
+ next_point[1],
360
+ outline_color,
361
+ width,
362
+ )
363
+ # Fill the polygon area with the specified fill color
364
+ if fill_color is not None:
365
+ min_x = min(point[0] for point in points)
366
+ max_x = max(point[0] for point in points)
367
+ min_y = min(point[1] for point in points)
368
+ max_y = max(point[1] for point in points)
369
+ # check if we are inside the area and set the color
370
+ for x in range(min_x, max_x + 1):
371
+ for y in range(min_y, max_y + 1):
372
+ if Drawable.point_inside(x, y, points):
373
+ arr[y, x] = fill_color
374
+ return arr
375
+
376
+ @staticmethod
377
+ async def zones(layers: NumpyArray, coordinates, color: Color) -> NumpyArray:
378
+ """
379
+ Draw the zones on the input layer.
380
+ """
381
+ dot_radius = 1 # number of pixels the dot should be
382
+ dot_spacing = 4 # space between dots.
383
+ # Iterate over zones
384
+ for zone in coordinates:
385
+ points = zone["points"]
386
+ # determinate the points to cover.
387
+ min_x = min(points[::2])
388
+ max_x = max(points[::2])
389
+ min_y = min(points[1::2])
390
+ max_y = max(points[1::2])
391
+ # Draw ellipses (dots)
392
+ for y in range(min_y, max_y, dot_spacing):
393
+ for x in range(min_x, max_x, dot_spacing):
394
+ for _ in range(dot_radius):
395
+ layers = Drawable._ellipse(layers, (x, y), dot_radius, color)
396
+ return layers
397
+
398
+ @staticmethod
399
+ async def robot(
400
+ layers: NumpyArray,
401
+ x: int,
402
+ y: int,
403
+ angle: float,
404
+ fill: Color,
405
+ robot_state: str | None = None,
406
+ ) -> NumpyArray:
407
+ """
408
+ We Draw the robot with in a smaller array
409
+ this helps numpy to work faster and at lower
410
+ memory cost.
411
+ """
412
+ # Create a 52*52 empty image numpy array of the background
413
+ top_left_x = x - 26
414
+ top_left_y = y - 26
415
+ bottom_right_x = top_left_x + 52
416
+ bottom_right_y = top_left_y + 52
417
+ tmp_layer = layers[top_left_y:bottom_right_y, top_left_x:bottom_right_x].copy()
418
+ # centre of the above array is used from the rest of the code.
419
+ # to draw the robot.
420
+ tmp_x, tmp_y = 26, 26
421
+ # Draw Robot
422
+ radius = 25 # Radius of the vacuum constant
423
+ r_scaled = radius // 11 # Offset scale for placement of the objects.
424
+ r_cover = r_scaled * 12 # Scale factor for cover
425
+ lidar_angle = np.deg2rad(
426
+ angle + 90
427
+ ) # Convert angle to radians and adjust for LIDAR orientation
428
+ r_lidar = r_scaled * 3 # Scale factor for the lidar
429
+ r_button = r_scaled * 1 # scale factor of the button
430
+ # Outline colour from fill colour
431
+ if robot_state == "error":
432
+ outline = Drawable.ERROR_OUTLINE
433
+ fill = Drawable.ERROR_COLOR
434
+ else:
435
+ outline = (fill[0] // 2, fill[1] // 2, fill[2] // 2, fill[3])
436
+ # Draw the robot outline
437
+ tmp_layer = Drawable._filled_circle(
438
+ tmp_layer, (tmp_x, tmp_y), radius, fill, outline, 1
439
+ )
440
+ # Draw bin cover
441
+ angle -= 90 # we remove 90 for the cover orientation
442
+ a1 = ((angle + 90) - 80) / 180 * math.pi
443
+ a2 = ((angle + 90) + 80) / 180 * math.pi
444
+ x1 = int(tmp_x - r_cover * math.sin(a1))
445
+ y1 = int(tmp_y + r_cover * math.cos(a1))
446
+ x2 = int(tmp_x - r_cover * math.sin(a2))
447
+ y2 = int(tmp_y + r_cover * math.cos(a2))
448
+ tmp_layer = Drawable._line(tmp_layer, x1, y1, x2, y2, outline, width=1)
449
+ # Draw Lidar
450
+ lidar_x = int(tmp_x + 15 * np.cos(lidar_angle)) # Calculate LIDAR x-coordinate
451
+ lidar_y = int(tmp_y + 15 * np.sin(lidar_angle)) # Calculate LIDAR y-coordinate
452
+ tmp_layer = Drawable._filled_circle(
453
+ tmp_layer, (lidar_x, lidar_y), r_lidar, outline
454
+ )
455
+ # Draw Button
456
+ butt_x = int(
457
+ tmp_x - 20 * np.cos(lidar_angle)
458
+ ) # Calculate the button x-coordinate
459
+ butt_y = int(
460
+ tmp_y - 20 * np.sin(lidar_angle)
461
+ ) # Calculate the button y-coordinate
462
+ tmp_layer = Drawable._filled_circle(
463
+ tmp_layer, (butt_x, butt_y), r_button, outline
464
+ )
465
+ # at last overlay the new robot image to the layer in input.
466
+ layers = Drawable.overlay_robot(layers, tmp_layer, x, y)
467
+ # return the new layer as np array.
468
+ return layers
469
+
470
+ @staticmethod
471
+ def overlay_robot(
472
+ background_image: NumpyArray, robot_image: NumpyArray, x: int, y: int
473
+ ) -> NumpyArray:
474
+ """
475
+ Overlay the robot image on the background image at the specified coordinates.
476
+ @param background_image:
477
+ @param robot_image:
478
+ @param robot x:
479
+ @param robot y:
480
+ @return: robot image overlaid on the background image.
481
+ """
482
+ # Calculate the dimensions of the robot image
483
+ robot_height, robot_width, _ = robot_image.shape
484
+ # Calculate the center of the robot image (in case const changes)
485
+ robot_center_x = robot_width // 2
486
+ robot_center_y = robot_height // 2
487
+ # Calculate the position to overlay the robot on the background image
488
+ top_left_x = x - robot_center_x
489
+ top_left_y = y - robot_center_y
490
+ bottom_right_x = top_left_x + robot_width
491
+ bottom_right_y = top_left_y + robot_height
492
+ # Overlay the robot on the background image
493
+ background_image[top_left_y:bottom_right_y, top_left_x:bottom_right_x] = (
494
+ robot_image
495
+ )
496
+ return background_image
497
+
498
+ @staticmethod
499
+ def draw_obstacles(
500
+ image: NumpyArray, obstacle_info_list, color: Color
501
+ ) -> NumpyArray:
502
+ """
503
+ Draw filled circles for obstacles on the image.
504
+ Parameters:
505
+ - image: NumPy array representing the image.
506
+ - obstacle_info_list: List of dictionaries containing obstacle information.
507
+ Returns:
508
+ - Modified image with filled circles for obstacles.
509
+ """
510
+ for obstacle_info in obstacle_info_list:
511
+ enter = obstacle_info.get("points", {})
512
+ # label = obstacle_info.get("label", {})
513
+ center = (enter["x"], enter["y"])
514
+
515
+ radius = 6
516
+
517
+ # Draw filled circle
518
+ image = Drawable._filled_circle(image, center, radius, color)
519
+
520
+ return image
521
+
522
+ @staticmethod
523
+ def status_text(
524
+ image: PilPNG,
525
+ size: int,
526
+ color: Color,
527
+ status: list[str],
528
+ path_font: str,
529
+ position: bool,
530
+ ) -> None:
531
+ """Draw the Status Test on the image."""
532
+ # Load a fonts
533
+ path_default_font = (
534
+ "custom_components/mqtt_vacuum_camera/utils/fonts/FiraSans.ttf"
535
+ )
536
+ default_font = ImageFont.truetype(path_default_font, size)
537
+ user_font = ImageFont.truetype(path_font, size)
538
+ # Define the text and position
539
+ if position:
540
+ x, y = 10, 10
541
+ else:
542
+ x, y = 10, image.height - 20 - size
543
+ # Create a drawing object
544
+ draw = ImageDraw.Draw(image)
545
+ # Draw the text
546
+ for text in status:
547
+ if "\u2211" in text or "\u03de" in text:
548
+ font = default_font
549
+ width = None
550
+ else:
551
+ font = user_font
552
+ is_variable = path_font.endswith("VT.ttf")
553
+ if is_variable:
554
+ width = 2
555
+ else:
556
+ width = None
557
+ if width:
558
+ draw.text((x, y), text, font=font, fill=color, stroke_width=width)
559
+ else:
560
+ draw.text((x, y), text, font=font, fill=color)
561
+ x += draw.textlength(text, font=default_font)