valetudo-map-parser 0.1.8__py3-none-any.whl → 0.1.9a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. valetudo_map_parser/__init__.py +28 -13
  2. valetudo_map_parser/config/async_utils.py +93 -0
  3. valetudo_map_parser/config/auto_crop.py +312 -123
  4. valetudo_map_parser/config/color_utils.py +105 -0
  5. valetudo_map_parser/config/colors.py +662 -13
  6. valetudo_map_parser/config/drawable.py +613 -268
  7. valetudo_map_parser/config/drawable_elements.py +292 -0
  8. valetudo_map_parser/config/enhanced_drawable.py +324 -0
  9. valetudo_map_parser/config/optimized_element_map.py +406 -0
  10. valetudo_map_parser/config/rand256_parser.py +395 -0
  11. valetudo_map_parser/config/shared.py +94 -11
  12. valetudo_map_parser/config/types.py +105 -52
  13. valetudo_map_parser/config/utils.py +1025 -0
  14. valetudo_map_parser/hypfer_draw.py +464 -148
  15. valetudo_map_parser/hypfer_handler.py +366 -259
  16. valetudo_map_parser/hypfer_rooms_handler.py +599 -0
  17. valetudo_map_parser/map_data.py +56 -66
  18. valetudo_map_parser/rand256_handler.py +674 -0
  19. valetudo_map_parser/reimg_draw.py +68 -84
  20. valetudo_map_parser/rooms_handler.py +474 -0
  21. valetudo_map_parser-0.1.9a0.dist-info/METADATA +93 -0
  22. valetudo_map_parser-0.1.9a0.dist-info/RECORD +27 -0
  23. {valetudo_map_parser-0.1.8.dist-info → valetudo_map_parser-0.1.9a0.dist-info}/WHEEL +1 -1
  24. valetudo_map_parser/config/rand25_parser.py +0 -398
  25. valetudo_map_parser/images_utils.py +0 -398
  26. valetudo_map_parser/rand25_handler.py +0 -455
  27. valetudo_map_parser-0.1.8.dist-info/METADATA +0 -23
  28. valetudo_map_parser-0.1.8.dist-info/RECORD +0 -20
  29. {valetudo_map_parser-0.1.8.dist-info → valetudo_map_parser-0.1.9a0.dist-info}/LICENSE +0 -0
  30. {valetudo_map_parser-0.1.8.dist-info → valetudo_map_parser-0.1.9a0.dist-info}/NOTICE.txt +0 -0
@@ -3,70 +3,144 @@ Collections of Drawing Utility
3
3
  Drawable is part of the Image_Handler
4
4
  used functions to draw the elements on the Numpy Array
5
5
  that is actually our camera frame.
6
- Version: v2024.12.0
6
+ Version: v0.1.10
7
+ Refactored for clarity, consistency, and optimized parameter usage.
8
+ Optimized with NumPy and SciPy for better performance.
7
9
  """
8
10
 
9
11
  from __future__ import annotations
10
12
 
13
+ import logging
11
14
  import math
12
15
 
13
- from PIL import ImageDraw, ImageFont
14
16
  import numpy as np
17
+ from PIL import Image, ImageDraw, ImageFont
15
18
 
16
- from .types import Color, NumpyArray, PilPNG, Point, Union
19
+ from .color_utils import get_blended_color
20
+ from .colors import ColorsManagement
21
+ from .types import Color, NumpyArray, PilPNG, Point, Tuple, Union
22
+
23
+
24
+ _LOGGER = logging.getLogger(__name__)
17
25
 
18
26
 
19
27
  class Drawable:
20
28
  """
21
29
  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.
30
+ This class contains static methods to draw various elements on NumPy arrays (images).
31
+ We can't use OpenCV because it is not supported by the Home Assistant OS.
24
32
  """
25
33
 
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
34
+ ERROR_OUTLINE: Color = (0, 0, 0, 255) # Red color for error messages
35
+ ERROR_COLOR: Color = (
36
+ 255,
37
+ 0,
38
+ 0,
39
+ 191,
40
+ ) # Red color with lower opacity for error outlines
28
41
 
29
42
  @staticmethod
30
43
  async def create_empty_image(
31
44
  width: int, height: int, background_color: Color
32
45
  ) -> 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
46
+ """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)
37
49
 
38
50
  @staticmethod
39
51
  async def from_json_to_image(
40
52
  layer: NumpyArray, pixels: Union[dict, list], pixel_size: int, color: Color
41
53
  ) -> NumpyArray:
42
- """Drawing the layers (rooms) from the vacuum json data."""
54
+ """Draw the layers (rooms) from the vacuum JSON data onto the image array."""
43
55
  image_array = layer
56
+ # Extract alpha from color
57
+ alpha = color[3] if len(color) == 4 else 255
58
+
59
+ # Create the full color with alpha
60
+ full_color = color if len(color) == 4 else (*color, 255)
61
+
62
+ # Check if we need to blend colors (alpha < 255)
63
+ need_blending = alpha < 255
64
+
44
65
  # Loop through pixels to find min and max coordinates
45
66
  for x, y, z in pixels:
46
67
  col = x * pixel_size
47
68
  row = y * pixel_size
48
- # Draw pixels
69
+ # Draw pixels as blocks
49
70
  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
71
+ # Get the region to update
72
+ region_slice = (
73
+ slice(row, row + pixel_size),
74
+ slice(col + i * pixel_size, col + (i + 1) * pixel_size),
75
+ )
76
+
77
+ if need_blending:
78
+ # Sample the center of the region for blending
79
+ center_y = row + pixel_size // 2
80
+ center_x = col + i * pixel_size + pixel_size // 2
81
+
82
+ # Only blend if coordinates are valid
83
+ if (
84
+ 0 <= center_y < image_array.shape[0]
85
+ and 0 <= center_x < image_array.shape[1]
86
+ ):
87
+ # Get blended color
88
+ blended_color = ColorsManagement.sample_and_blend_color(
89
+ image_array, center_x, center_y, full_color
90
+ )
91
+ # Apply blended color to the region
92
+ image_array[region_slice] = blended_color
93
+ else:
94
+ # Use original color if out of bounds
95
+ image_array[region_slice] = full_color
96
+ else:
97
+ # No blending needed, use direct assignment
98
+ image_array[region_slice] = full_color
99
+
54
100
  return image_array
55
101
 
56
102
  @staticmethod
57
103
  async def battery_charger(
58
104
  layers: NumpyArray, x: int, y: int, color: Color
59
105
  ) -> NumpyArray:
60
- """Draw the battery charger on the input layer."""
106
+ """Draw the battery charger on the input layer with color blending."""
107
+ # Check if coordinates are within bounds
108
+ height, width = layers.shape[:2]
109
+ if not (0 <= x < width and 0 <= y < height):
110
+ return layers
111
+
112
+ # Calculate charger dimensions
61
113
  charger_width = 10
62
114
  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
115
+ start_row = max(0, y - charger_height // 2)
116
+ end_row = min(height, start_row + charger_height)
117
+ start_col = max(0, x - charger_width // 2)
118
+ end_col = min(width, start_col + charger_width)
119
+
120
+ # Skip if charger is completely outside the image
121
+ if start_row >= end_row or start_col >= end_col:
122
+ return layers
123
+
124
+ # Extract alpha from color
125
+ alpha = color[3] if len(color) == 4 else 255
126
+
127
+ # Check if we need to blend colors (alpha < 255)
128
+ if alpha < 255:
129
+ # Sample the center of the charger for blending
130
+ center_y = (start_row + end_row) // 2
131
+ center_x = (start_col + end_col) // 2
132
+
133
+ # Get blended color
134
+ blended_color = ColorsManagement.sample_and_blend_color(
135
+ layers, center_x, center_y, color
136
+ )
137
+
138
+ # Apply blended color
139
+ layers[start_row:end_row, start_col:end_col] = blended_color
140
+ else:
141
+ # No blending needed, use direct assignment
142
+ layers[start_row:end_row, start_col:end_col] = color
143
+
70
144
  return layers
71
145
 
72
146
  @staticmethod
@@ -74,13 +148,36 @@ class Drawable:
74
148
  layer: NumpyArray, center: Point, rotation_angle: int, flag_color: Color
75
149
  ) -> NumpyArray:
76
150
  """
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.
151
+ Draw a flag centered at specified coordinates on the input layer.
152
+ It uses the rotation angle of the image to orient the flag.
153
+ Includes color blending for better visual integration.
80
154
  """
81
- # Define flag color
82
- pole_color = (0, 0, 255, 255) # RGBA color (blue)
83
- # Define flag size and position
155
+ # Check if coordinates are within bounds
156
+ height, width = layer.shape[:2]
157
+ x, y = center
158
+ if not (0 <= x < width and 0 <= y < height):
159
+ return layer
160
+
161
+ # Get blended colors for flag and pole
162
+ flag_alpha = flag_color[3] if len(flag_color) == 4 else 255
163
+ pole_color_base = (0, 0, 255) # Blue for the pole
164
+ pole_alpha = 255
165
+
166
+ # Blend flag color if needed
167
+ if flag_alpha < 255:
168
+ flag_color = ColorsManagement.sample_and_blend_color(
169
+ layer, x, y, flag_color
170
+ )
171
+
172
+ # Create pole color with alpha
173
+ pole_color: Color = (*pole_color_base, pole_alpha)
174
+
175
+ # Blend pole color if needed
176
+ if pole_alpha < 255:
177
+ pole_color = ColorsManagement.sample_and_blend_color(
178
+ layer, x, y, pole_color
179
+ )
180
+
84
181
  flag_size = 50
85
182
  pole_width = 6
86
183
  # Adjust flag coordinates based on rotation angle
@@ -91,23 +188,16 @@ class Drawable:
91
188
  y2 = y1 + (flag_size // 2)
92
189
  x3 = center[0] + (flag_size // 2)
93
190
  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)
191
+ xp1, yp1 = center[0], center[1] - (pole_width // 2)
192
+ xp2, yp2 = center[0] + flag_size, center[1] - (pole_width // 2)
99
193
  elif rotation_angle == 180:
100
194
  x1 = center[0]
101
195
  y1 = center[1] - (flag_size // 2)
102
196
  x2 = center[0] - (flag_size // 2)
103
197
  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
198
+ x3, y3 = center[0], center[1]
199
+ xp1, yp1 = center[0] + (pole_width // 2), center[1] - flag_size
200
+ xp2, yp2 = center[0] + (pole_width // 2), y3
111
201
  elif rotation_angle == 270:
112
202
  x1 = center[0] - flag_size
113
203
  y1 = center[1] + (pole_width // 2)
@@ -115,111 +205,97 @@ class Drawable:
115
205
  y2 = y1 - (flag_size // 2)
116
206
  x3 = center[0] - (flag_size // 2)
117
207
  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
208
+ xp1, yp1 = center[0] - flag_size, center[1] + (pole_width // 2)
209
+ xp2, yp2 = center[0], center[1] + (pole_width // 2)
210
+ else: # rotation_angle == 0 (no rotation)
211
+ x1, y1 = center[0], center[1]
212
+ x2, y2 = center[0] + (flag_size // 2), center[1] + (flag_size // 4)
213
+ x3, y3 = center[0], center[1] + flag_size // 2
214
+ xp1, yp1 = center[0] - (pole_width // 2), y1
215
+ xp2, yp2 = center[0] - (pole_width // 2), center[1] + flag_size
136
216
 
137
217
  # Draw flag outline using _polygon_outline
138
218
  points = [(x1, y1), (x2, y2), (x3, y3)]
139
219
  layer = Drawable._polygon_outline(layer, points, 1, flag_color, flag_color)
140
-
141
220
  # Draw pole using _line
142
221
  layer = Drawable._line(layer, xp1, yp1, xp2, yp2, pole_color, pole_width)
143
-
144
222
  return layer
145
223
 
146
224
  @staticmethod
147
- def point_inside(x: int, y: int, points) -> bool:
225
+ def point_inside(x: int, y: int, points: list[Tuple[int, int]]) -> bool:
148
226
  """
149
227
  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
228
  """
158
229
  n = len(points)
159
230
  inside = False
160
- xinters = 0
231
+ xinters = 0.0
161
232
  p1x, p1y = points[0]
162
233
  for i in range(1, n + 1):
163
234
  p2x, p2y = points[i % n]
164
235
  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
236
+ if y <= max(p1y, p2y) and x <= max(p1x, p2x):
237
+ if p1y != p2y:
238
+ xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
239
+ if p1x == p2x or x <= xinters:
240
+ inside = not inside
171
241
  p1x, p1y = p2x, p2y
172
-
173
242
  return inside
174
243
 
175
244
  @staticmethod
176
245
  def _line(
177
- layer: NumpyArray,
246
+ layer: np.ndarray,
178
247
  x1: int,
179
248
  y1: int,
180
249
  x2: int,
181
250
  y2: int,
182
251
  color: Color,
183
252
  width: int = 3,
184
- ) -> NumpyArray:
253
+ ) -> np.ndarray:
185
254
  """
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.
255
+ Draw a line on a NumPy array (layer) from point A to B using Bresenham's algorithm.
256
+
257
+ Args:
258
+ layer: The numpy array to draw on (H, W, C)
259
+ x1, y1: Start point coordinates
260
+ x2, y2: End point coordinates
261
+ color: Color to draw with (tuple or array)
262
+ width: Width of the line in pixels
196
263
  """
197
- # Ensure the coordinates are integers
198
264
  x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
199
265
 
200
- # Use Bresenham's line algorithm to get the coordinates of the line pixels
266
+ blended_color = get_blended_color(x1, y1, x2, y2, layer, color)
267
+
201
268
  dx = abs(x2 - x1)
202
269
  dy = abs(y2 - y1)
203
270
  sx = 1 if x1 < x2 else -1
204
271
  sy = 1 if y1 < y2 else -1
205
272
  err = dx - dy
206
273
 
274
+ half_w = width // 2
275
+ h, w = layer.shape[:2]
276
+
207
277
  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
278
+ # Draw a filled circle for thickness
279
+ yy, xx = np.ogrid[-half_w : half_w + 1, -half_w : half_w + 1]
280
+ mask = xx**2 + yy**2 <= half_w**2
281
+ y_min = max(0, y1 - half_w)
282
+ y_max = min(h, y1 + half_w + 1)
283
+ x_min = max(0, x1 - half_w)
284
+ x_max = min(w, x1 + half_w + 1)
285
+
286
+ submask = mask[
287
+ (y_min - (y1 - half_w)) : (y_max - (y1 - half_w)),
288
+ (x_min - (x1 - half_w)) : (x_max - (x1 - half_w)),
289
+ ]
290
+ layer[y_min:y_max, x_min:x_max][submask] = blended_color
213
291
 
214
292
  if x1 == x2 and y1 == y2:
215
293
  break
216
294
 
217
295
  e2 = 2 * err
218
-
219
296
  if e2 > -dy:
220
297
  err -= dy
221
298
  x1 += sx
222
-
223
299
  if e2 < dx:
224
300
  err += dx
225
301
  y1 += sy
@@ -243,42 +319,26 @@ class Drawable:
243
319
  @staticmethod
244
320
  async def lines(arr: NumpyArray, coords, width: int, color: Color) -> NumpyArray:
245
321
  """
246
- it joins the coordinates creating a continues line.
247
- the result is our path.
322
+ Join the coordinates creating a continuous line (path).
323
+ Optimized with vectorized operations for better performance.
248
324
  """
249
325
  for coord in coords:
250
- # Use Bresenham's line algorithm to get the coordinates of the line pixels
251
326
  x0, y0 = coord[0]
252
327
  try:
253
328
  x1, y1 = coord[1]
254
329
  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
330
+ x1, y1 = x0, y0
331
+
332
+ # Skip if coordinates are the same
333
+ if x0 == x1 and y0 == y1:
334
+ continue
335
+
336
+ # Get blended color for this line segment
337
+ blended_color = get_blended_color(x0, y0, x1, y1, arr, color)
338
+
339
+ # Use the optimized line drawing method
340
+ arr = Drawable._line(arr, x0, y0, x1, y1, blended_color, width)
341
+
282
342
  return arr
283
343
 
284
344
  @staticmethod
@@ -292,32 +352,67 @@ class Drawable:
292
352
  ) -> NumpyArray:
293
353
  """
294
354
  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.
355
+ Optimized to only process the bounding box of the circle.
304
356
  """
305
357
  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
358
+ height, width = image.shape[:2]
359
+
360
+ # Calculate the bounding box of the circle
361
+ min_y = max(0, y - radius - outline_width)
362
+ max_y = min(height, y + radius + outline_width + 1)
363
+ min_x = max(0, x - radius - outline_width)
364
+ max_x = min(width, x + radius + outline_width + 1)
365
+
366
+ # Create coordinate arrays for the bounding box
367
+ y_indices, x_indices = np.ogrid[min_y:max_y, min_x:max_x]
368
+
369
+ # Calculate distances from center
370
+ dist_sq = (y_indices - y) ** 2 + (x_indices - x) ** 2
371
+
372
+ # Create masks for the circle and outline
373
+ circle_mask = dist_sq <= radius**2
374
+
375
+ # Apply the fill color
376
+ image[min_y:max_y, min_x:max_x][circle_mask] = color
377
+
378
+ # Draw the outline if needed
379
+ if outline_width > 0 and outline_color is not None:
380
+ outer_mask = dist_sq <= (radius + outline_width) ** 2
381
+ outline_mask = outer_mask & ~circle_mask
382
+ image[min_y:max_y, min_x:max_x][outline_mask] = outline_color
318
383
 
319
384
  return image
320
385
 
386
+ @staticmethod
387
+ def _filled_circle_optimized(
388
+ image: np.ndarray,
389
+ center: Tuple[int, int],
390
+ radius: int,
391
+ color: Color,
392
+ outline_color: Color = None,
393
+ outline_width: int = 0,
394
+ ) -> np.ndarray:
395
+ """
396
+ Optimized _filled_circle ensuring dtype compatibility with uint8.
397
+ """
398
+ x, y = center
399
+ h, w = image.shape[:2]
400
+ color_np = np.array(color, dtype=image.dtype)
401
+ outline_color_np = (
402
+ np.array(outline_color, dtype=image.dtype)
403
+ if outline_color is not None
404
+ else None
405
+ )
406
+ y_indices, x_indices = np.meshgrid(np.arange(h), np.arange(w), indexing="ij")
407
+ dist_sq = (y_indices - y) ** 2 + (x_indices - x) ** 2
408
+ circle_mask = dist_sq <= radius**2
409
+ image[circle_mask] = color_np
410
+ if outline_width > 0 and outline_color_np is not None:
411
+ outer_mask = dist_sq <= (radius + outline_width) ** 2
412
+ outline_mask = outer_mask & ~circle_mask
413
+ image[outline_mask] = outline_color_np
414
+ return image
415
+
321
416
  @staticmethod
322
417
  def _ellipse(
323
418
  image: NumpyArray, center: Point, radius: int, color: Color
@@ -325,32 +420,28 @@ class Drawable:
325
420
  """
326
421
  Draw an ellipse on the image using NumPy.
327
422
  """
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
423
  x, y = center
332
424
  x1, y1 = x - radius, y - radius
333
425
  x2, y2 = x + radius, y + radius
334
- # Draw the filled ellipse
335
- result_image[y1:y2, x1:x2] = color
336
- return result_image
426
+ image[y1:y2, x1:x2] = color
427
+ return image
337
428
 
338
429
  @staticmethod
339
430
  def _polygon_outline(
340
431
  arr: NumpyArray,
341
- points,
432
+ points: list[Tuple[int, int]],
342
433
  width: int,
343
434
  outline_color: Color,
344
435
  fill_color: Color = None,
345
436
  ) -> NumpyArray:
346
437
  """
347
- Draw the outline of a filled polygon on the array using _line.
438
+ Draw the outline of a polygon on the array using _line, and optionally fill it.
439
+ Uses NumPy vectorized operations for improved performance.
348
440
  """
349
- for i, point in enumerate(points):
350
- # Get the current and next points to draw a line between them
441
+ # Draw the outline
442
+ for i, _ in enumerate(points):
351
443
  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
444
+ next_point = points[(i + 1) % len(points)]
354
445
  arr = Drawable._line(
355
446
  arr,
356
447
  current_point[0],
@@ -360,39 +451,90 @@ class Drawable:
360
451
  outline_color,
361
452
  width,
362
453
  )
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
454
+
455
+ # Fill the polygon if a fill color is provided
456
+ if fill_color is not None:
457
+ # Get the bounding box of the polygon
458
+ min_x = max(0, min(p[0] for p in points))
459
+ max_x = min(arr.shape[1] - 1, max(p[0] for p in points))
460
+ min_y = max(0, min(p[1] for p in points))
461
+ max_y = min(arr.shape[0] - 1, max(p[1] for p in points))
462
+
463
+ # Create a mask for the polygon region
464
+ mask = np.zeros((max_y - min_y + 1, max_x - min_x + 1), dtype=bool)
465
+
466
+ # Adjust points to the mask's coordinate system
467
+ adjusted_points = [(p[0] - min_x, p[1] - min_y) for p in points]
468
+
469
+ # Create a grid of coordinates and use it to test all points at once
470
+ y_indices, x_indices = np.mgrid[0 : mask.shape[0], 0 : mask.shape[1]]
471
+
472
+ # Test each point in the grid
473
+ for i in range(mask.shape[0]):
474
+ for j in range(mask.shape[1]):
475
+ mask[i, j] = Drawable.point_inside(j, i, adjusted_points)
476
+
477
+ # Apply the fill color to the masked region
478
+ arr[min_y : max_y + 1, min_x : max_x + 1][mask] = fill_color
479
+
374
480
  return arr
375
481
 
376
482
  @staticmethod
377
483
  async def zones(layers: NumpyArray, coordinates, color: Color) -> NumpyArray:
378
484
  """
379
- Draw the zones on the input layer.
485
+ Draw zones as solid filled polygons with alpha blending using a per-zone mask.
486
+ Keeps API the same; no dotted rendering.
380
487
  """
381
- dot_radius = 1 # number of pixels the dot should be
382
- dot_spacing = 4 # space between dots.
383
- # Iterate over zones
488
+ if not coordinates:
489
+ return layers
490
+
491
+ height, width = layers.shape[:2]
492
+ # Precompute color and alpha
493
+ r, g, b, a = color
494
+ alpha = a / 255.0
495
+ inv_alpha = 1.0 - alpha
496
+ color_rgb = np.array([r, g, b], dtype=np.float32)
497
+
384
498
  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)
499
+ try:
500
+ pts = zone["points"]
501
+ except (KeyError, TypeError):
502
+ continue
503
+ if not pts or len(pts) < 6:
504
+ continue
505
+
506
+ # Compute bounding box and clamp
507
+ min_x = max(0, int(min(pts[::2])))
508
+ max_x = min(width - 1, int(max(pts[::2])))
509
+ min_y = max(0, int(min(pts[1::2])))
510
+ max_y = min(height - 1, int(max(pts[1::2])))
511
+ if min_x >= max_x or min_y >= max_y:
512
+ continue
513
+
514
+ # Adjust polygon points to local bbox coordinates
515
+ poly_xy = [
516
+ (int(pts[i] - min_x), int(pts[i + 1] - min_y))
517
+ for i in range(0, len(pts), 2)
518
+ ]
519
+ box_w = max_x - min_x + 1
520
+ box_h = max_y - min_y + 1
521
+
522
+ # Build mask via PIL polygon fill (fast, C-impl)
523
+ mask_img = Image.new("L", (box_w, box_h), 0)
524
+ draw = ImageDraw.Draw(mask_img)
525
+ draw.polygon(poly_xy, fill=255)
526
+ zone_mask = np.array(mask_img, dtype=bool)
527
+ if not np.any(zone_mask):
528
+ continue
529
+
530
+ # Vectorized alpha blend on RGB channels only
531
+ region = layers[min_y : max_y + 1, min_x : max_x + 1]
532
+ rgb = region[..., :3].astype(np.float32)
533
+ mask3 = zone_mask[:, :, None]
534
+ blended_rgb = np.where(mask3, rgb * inv_alpha + color_rgb * alpha, rgb)
535
+ region[..., :3] = blended_rgb.astype(np.uint8)
536
+ # Leave alpha channel unchanged to avoid stacking transparency
537
+
396
538
  return layers
397
539
 
398
540
  @staticmethod
@@ -405,66 +547,93 @@ class Drawable:
405
547
  robot_state: str | None = None,
406
548
  ) -> NumpyArray:
407
549
  """
408
- We Draw the robot with in a smaller array
409
- this helps numpy to work faster and at lower
410
- memory cost.
550
+ Draw the robot on a smaller array to reduce memory cost.
551
+ Optimized with NumPy vectorized operations for better performance.
411
552
  """
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
553
+ # Ensure coordinates are within bounds
554
+ height, width = layers.shape[:2]
555
+ if not (0 <= x < width and 0 <= y < height):
556
+ return layers
557
+
558
+ # Calculate the bounding box for the robot
559
+ radius = 25
560
+ box_size = radius * 2 + 2 # Add a small margin
561
+
562
+ # Calculate the region to draw on
563
+ top_left_x = max(0, x - radius - 1)
564
+ top_left_y = max(0, y - radius - 1)
565
+ bottom_right_x = min(width, x + radius + 1)
566
+ bottom_right_y = min(height, y + radius + 1)
567
+
568
+ # Skip if the robot is completely outside the image
569
+ if top_left_x >= bottom_right_x or top_left_y >= bottom_right_y:
570
+ return layers
571
+
572
+ # Create a temporary layer for the robot
573
+ tmp_width = bottom_right_x - top_left_x
574
+ tmp_height = bottom_right_y - top_left_y
417
575
  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
576
+
577
+ # Calculate the robot center in the temporary layer
578
+ tmp_x = x - top_left_x
579
+ tmp_y = y - top_left_y
580
+
581
+ # Calculate robot parameters
582
+ r_scaled = radius // 11
583
+ r_cover = r_scaled * 12
584
+ lidar_angle = np.deg2rad(angle + 90)
585
+ r_lidar = r_scaled * 3
586
+ r_button = r_scaled * 1
587
+
588
+ # Set colors based on robot state
431
589
  if robot_state == "error":
432
590
  outline = Drawable.ERROR_OUTLINE
433
591
  fill = Drawable.ERROR_COLOR
434
592
  else:
435
593
  outline = (fill[0] // 2, fill[1] // 2, fill[2] // 2, fill[3])
436
- # Draw the robot outline
594
+
595
+ # Draw the main robot body
437
596
  tmp_layer = Drawable._filled_circle(
438
- tmp_layer, (tmp_x, tmp_y), radius, fill, outline, 1
597
+ tmp_layer, (tmp_y, tmp_x), radius, fill, outline, 1
439
598
  )
440
- # Draw bin cover
441
- angle -= 90 # we remove 90 for the cover orientation
599
+
600
+ # Draw the robot direction indicator
601
+ angle -= 90
442
602
  a1 = ((angle + 90) - 80) / 180 * math.pi
443
603
  a2 = ((angle + 90) + 80) / 180 * math.pi
444
604
  x1 = int(tmp_x - r_cover * math.sin(a1))
445
605
  y1 = int(tmp_y + r_cover * math.cos(a1))
446
606
  x2 = int(tmp_x - r_cover * math.sin(a2))
447
607
  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.
608
+
609
+ # Draw the direction line
610
+ if (
611
+ 0 <= x1 < tmp_width
612
+ and 0 <= y1 < tmp_height
613
+ and 0 <= x2 < tmp_width
614
+ and 0 <= y2 < tmp_height
615
+ ):
616
+ tmp_layer = Drawable._line(tmp_layer, x1, y1, x2, y2, outline, width=1)
617
+
618
+ # Draw the lidar indicator
619
+ lidar_x = int(tmp_x + 15 * np.cos(lidar_angle))
620
+ lidar_y = int(tmp_y + 15 * np.sin(lidar_angle))
621
+ if 0 <= lidar_x < tmp_width and 0 <= lidar_y < tmp_height:
622
+ tmp_layer = Drawable._filled_circle(
623
+ tmp_layer, (lidar_y, lidar_x), r_lidar, outline
624
+ )
625
+
626
+ # Draw the button indicator
627
+ butt_x = int(tmp_x - 20 * np.cos(lidar_angle))
628
+ butt_y = int(tmp_y - 20 * np.sin(lidar_angle))
629
+ if 0 <= butt_x < tmp_width and 0 <= butt_y < tmp_height:
630
+ tmp_layer = Drawable._filled_circle(
631
+ tmp_layer, (butt_y, butt_x), r_button, outline
632
+ )
633
+
634
+ # Copy the robot layer back to the main layer
635
+ layers[top_left_y:bottom_right_y, top_left_x:bottom_right_x] = tmp_layer
636
+
468
637
  return layers
469
638
 
470
639
  @staticmethod
@@ -473,49 +642,233 @@ class Drawable:
473
642
  ) -> NumpyArray:
474
643
  """
475
644
  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
645
  """
482
- # Calculate the dimensions of the robot image
483
646
  robot_height, robot_width, _ = robot_image.shape
484
- # Calculate the center of the robot image (in case const changes)
485
647
  robot_center_x = robot_width // 2
486
648
  robot_center_y = robot_height // 2
487
- # Calculate the position to overlay the robot on the background image
488
649
  top_left_x = x - robot_center_x
489
650
  top_left_y = y - robot_center_y
490
651
  bottom_right_x = top_left_x + robot_width
491
652
  bottom_right_y = top_left_y + robot_height
492
- # Overlay the robot on the background image
493
653
  background_image[top_left_y:bottom_right_y, top_left_x:bottom_right_x] = (
494
654
  robot_image
495
655
  )
496
656
  return background_image
497
657
 
498
658
  @staticmethod
499
- def draw_obstacles(
500
- image: NumpyArray, obstacle_info_list, color: Color
501
- ) -> NumpyArray:
659
+ def draw_filled_circle(
660
+ image: np.ndarray,
661
+ centers: Tuple[int, int],
662
+ radius: int,
663
+ color: Tuple[int, int, int, int],
664
+ ) -> np.ndarray:
665
+ """
666
+ Draw multiple filled circles at once using a single NumPy mask.
667
+ """
668
+ h, w = image.shape[:2]
669
+ y_indices, x_indices = np.ogrid[:h, :w] # Precompute coordinate grids
670
+ mask = np.zeros((h, w), dtype=bool)
671
+ for cx, cy in centers:
672
+ mask |= (x_indices - cx) ** 2 + (y_indices - cy) ** 2 <= radius**2
673
+ image[mask] = color
674
+ return image
675
+
676
+ @staticmethod
677
+ def batch_draw_elements(
678
+ image: np.ndarray,
679
+ elements: list,
680
+ element_type: str,
681
+ color: Color,
682
+ ) -> np.ndarray:
502
683
  """
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.
684
+ Efficiently draw multiple elements of the same type at once.
685
+
686
+ Args:
687
+ image: The image array to draw on
688
+ elements: List of element data (coordinates, etc.)
689
+ element_type: Type of element to draw ('circle', 'line', etc.)
690
+ color: Color to use for drawing
691
+
507
692
  Returns:
508
- - Modified image with filled circles for obstacles.
693
+ Modified image array
509
694
  """
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"])
695
+ if not elements or len(elements) == 0:
696
+ return image
697
+
698
+ # Get image dimensions
699
+ height, width = image.shape[:2]
700
+
701
+ if element_type == "circle":
702
+ # Extract circle centers and radii
703
+ centers = []
704
+ radii = []
705
+ for elem in elements:
706
+ if isinstance(elem, dict) and "center" in elem and "radius" in elem:
707
+ centers.append(elem["center"])
708
+ radii.append(elem["radius"])
709
+ elif isinstance(elem, (list, tuple)) and len(elem) >= 3:
710
+ # Format: (x, y, radius)
711
+ centers.append((elem[0], elem[1]))
712
+ radii.append(elem[2])
713
+
714
+ # Process circles with the same radius together
715
+ for radius in set(radii):
716
+ same_radius_centers = [
717
+ centers[i] for i in range(len(centers)) if radii[i] == radius
718
+ ]
719
+ if same_radius_centers:
720
+ # Create a combined mask for all circles with this radius
721
+ mask = np.zeros((height, width), dtype=bool)
722
+ for cx, cy in same_radius_centers:
723
+ if 0 <= cx < width and 0 <= cy < height:
724
+ # Calculate circle bounds
725
+ min_y = max(0, cy - radius)
726
+ max_y = min(height, cy + radius + 1)
727
+ min_x = max(0, cx - radius)
728
+ max_x = min(width, cx + radius + 1)
729
+
730
+ # Create coordinate arrays for the circle
731
+ y_indices, x_indices = np.ogrid[min_y:max_y, min_x:max_x]
732
+
733
+ # Add this circle to the mask
734
+ circle_mask = (y_indices - cy) ** 2 + (
735
+ x_indices - cx
736
+ ) ** 2 <= radius**2
737
+ mask[min_y:max_y, min_x:max_x] |= circle_mask
738
+
739
+ # Apply color to all circles at once
740
+ image[mask] = color
741
+
742
+ elif element_type == "line":
743
+ # Extract line endpoints
744
+ lines = []
745
+ widths = []
746
+ for elem in elements:
747
+ if isinstance(elem, dict) and "start" in elem and "end" in elem:
748
+ lines.append((elem["start"], elem["end"]))
749
+ widths.append(elem.get("width", 1))
750
+ elif isinstance(elem, (list, tuple)) and len(elem) >= 4:
751
+ # Format: (x1, y1, x2, y2, [width])
752
+ lines.append(((elem[0], elem[1]), (elem[2], elem[3])))
753
+ widths.append(elem[4] if len(elem) > 4 else 1)
754
+
755
+ # Process lines with the same width together
756
+ for width in set(widths):
757
+ same_width_lines = [
758
+ lines[i] for i in range(len(lines)) if widths[i] == width
759
+ ]
760
+ if same_width_lines:
761
+ # Create a combined mask for all lines with this width
762
+ mask = np.zeros((height, width), dtype=bool)
763
+
764
+ # Draw all lines into the mask
765
+ for start, end in same_width_lines:
766
+ x1, y1 = start
767
+ x2, y2 = end
768
+
769
+ # Skip invalid lines
770
+ if not (
771
+ 0 <= x1 < width
772
+ and 0 <= y1 < height
773
+ and 0 <= x2 < width
774
+ and 0 <= y2 < height
775
+ ):
776
+ continue
777
+
778
+ # Use Bresenham's algorithm to get line points
779
+ length = max(abs(x2 - x1), abs(y2 - y1))
780
+ if length == 0:
781
+ continue
782
+
783
+ t = np.linspace(0, 1, length * 2)
784
+ x_coords = np.round(x1 * (1 - t) + x2 * t).astype(int)
785
+ y_coords = np.round(y1 * (1 - t) + y2 * t).astype(int)
786
+
787
+ # Add line points to mask
788
+ for x, y in zip(x_coords, y_coords):
789
+ if width == 1:
790
+ mask[y, x] = True
791
+ else:
792
+ # For thicker lines
793
+ half_width = width // 2
794
+ min_y = max(0, y - half_width)
795
+ max_y = min(height, y + half_width + 1)
796
+ min_x = max(0, x - half_width)
797
+ max_x = min(width, x + half_width + 1)
798
+
799
+ # Create a circular brush
800
+ y_indices, x_indices = np.ogrid[
801
+ min_y:max_y, min_x:max_x
802
+ ]
803
+ brush = (y_indices - y) ** 2 + (
804
+ x_indices - x
805
+ ) ** 2 <= half_width**2
806
+ mask[min_y:max_y, min_x:max_x] |= brush
807
+
808
+ # Apply color to all lines at once
809
+ image[mask] = color
514
810
 
515
- radius = 6
811
+ return image
516
812
 
517
- # Draw filled circle
518
- image = Drawable._filled_circle(image, center, radius, color)
813
+ @staticmethod
814
+ async def async_draw_obstacles(
815
+ image: np.ndarray, obstacle_info_list, color: Color
816
+ ) -> np.ndarray:
817
+ """
818
+ Optimized async version of draw_obstacles using a precomputed mask
819
+ and minimal Python overhead. Handles hundreds of obstacles efficiently.
820
+ """
821
+ if not obstacle_info_list:
822
+ return image
823
+
824
+ h, w = image.shape[:2]
825
+ alpha = color[3] if len(color) == 4 else 255
826
+ need_blending = alpha < 255
827
+
828
+ # Precompute circular mask for radius
829
+ radius = 6
830
+ diameter = radius * 2 + 1
831
+ yy, xx = np.ogrid[-radius : radius + 1, -radius : radius + 1]
832
+ circle_mask = (xx**2 + yy**2) <= radius**2
833
+
834
+ # Collect valid obstacles
835
+ centers = []
836
+ for obs in obstacle_info_list:
837
+ try:
838
+ x = obs["points"]["x"]
839
+ y = obs["points"]["y"]
840
+
841
+ if not (0 <= x < w and 0 <= y < h):
842
+ continue
843
+
844
+ if need_blending:
845
+ obs_color = ColorsManagement.sample_and_blend_color(
846
+ image, x, y, color
847
+ )
848
+ else:
849
+ obs_color = color
850
+
851
+ centers.append((x, y, obs_color))
852
+ except (KeyError, TypeError):
853
+ continue
854
+
855
+ # Draw all obstacles
856
+ for cx, cy, obs_color in centers:
857
+ min_y = max(0, cy - radius)
858
+ max_y = min(h, cy + radius + 1)
859
+ min_x = max(0, cx - radius)
860
+ max_x = min(w, cx + radius + 1)
861
+
862
+ # Slice mask to fit image edges
863
+ mask_y_start = min_y - (cy - radius)
864
+ mask_y_end = mask_y_start + (max_y - min_y)
865
+ mask_x_start = min_x - (cx - radius)
866
+ mask_x_end = mask_x_start + (max_x - min_x)
867
+
868
+ mask = circle_mask[mask_y_start:mask_y_end, mask_x_start:mask_x_end]
869
+
870
+ # Apply color in one vectorized step
871
+ image[min_y:max_y, min_x:max_x][mask] = obs_color
519
872
 
520
873
  return image
521
874
 
@@ -528,32 +881,24 @@ class Drawable:
528
881
  path_font: str,
529
882
  position: bool,
530
883
  ) -> None:
531
- """Draw the Status Test on the image."""
532
- # Load a fonts
884
+ """Draw the status text on the image."""
533
885
  path_default_font = (
534
886
  "custom_components/mqtt_vacuum_camera/utils/fonts/FiraSans.ttf"
535
887
  )
536
888
  default_font = ImageFont.truetype(path_default_font, size)
537
889
  user_font = ImageFont.truetype(path_font, size)
538
- # Define the text and position
539
890
  if position:
540
891
  x, y = 10, 10
541
892
  else:
542
893
  x, y = 10, image.height - 20 - size
543
- # Create a drawing object
544
894
  draw = ImageDraw.Draw(image)
545
- # Draw the text
546
895
  for text in status:
547
896
  if "\u2211" in text or "\u03de" in text:
548
897
  font = default_font
549
898
  width = None
550
899
  else:
551
900
  font = user_font
552
- is_variable = path_font.endswith("VT.ttf")
553
- if is_variable:
554
- width = 2
555
- else:
556
- width = None
901
+ width = 2 if path_font.endswith("VT.ttf") else None
557
902
  if width:
558
903
  draw.text((x, y), text, font=font, fill=color, stroke_width=width)
559
904
  else: