valetudo-map-parser 0.1.7__py3-none-any.whl → 0.1.9a1__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 (28) hide show
  1. valetudo_map_parser/__init__.py +19 -12
  2. valetudo_map_parser/config/auto_crop.py +174 -116
  3. valetudo_map_parser/config/color_utils.py +105 -0
  4. valetudo_map_parser/config/colors.py +662 -13
  5. valetudo_map_parser/config/drawable.py +624 -279
  6. valetudo_map_parser/config/drawable_elements.py +292 -0
  7. valetudo_map_parser/config/enhanced_drawable.py +324 -0
  8. valetudo_map_parser/config/optimized_element_map.py +406 -0
  9. valetudo_map_parser/config/rand25_parser.py +42 -28
  10. valetudo_map_parser/config/room_outline.py +148 -0
  11. valetudo_map_parser/config/shared.py +29 -5
  12. valetudo_map_parser/config/types.py +102 -51
  13. valetudo_map_parser/config/utils.py +841 -0
  14. valetudo_map_parser/hypfer_draw.py +398 -132
  15. valetudo_map_parser/hypfer_handler.py +259 -241
  16. valetudo_map_parser/hypfer_rooms_handler.py +599 -0
  17. valetudo_map_parser/map_data.py +45 -64
  18. valetudo_map_parser/rand25_handler.py +429 -310
  19. valetudo_map_parser/reimg_draw.py +55 -74
  20. valetudo_map_parser/rooms_handler.py +470 -0
  21. valetudo_map_parser-0.1.9a1.dist-info/METADATA +93 -0
  22. valetudo_map_parser-0.1.9a1.dist-info/RECORD +27 -0
  23. {valetudo_map_parser-0.1.7.dist-info → valetudo_map_parser-0.1.9a1.dist-info}/WHEEL +1 -1
  24. valetudo_map_parser/images_utils.py +0 -398
  25. valetudo_map_parser-0.1.7.dist-info/METADATA +0 -23
  26. valetudo_map_parser-0.1.7.dist-info/RECORD +0 -20
  27. {valetudo_map_parser-0.1.7.dist-info → valetudo_map_parser-0.1.9a1.dist-info}/LICENSE +0 -0
  28. {valetudo_map_parser-0.1.7.dist-info → valetudo_map_parser-0.1.9a1.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 ImageDraw, ImageFont
18
+
19
+ from .color_utils import get_blended_color
20
+ from .colors import ColorsManagement
21
+ from .types import Color, NumpyArray, PilPNG, Point, Tuple, Union
22
+
15
23
 
16
- from .types import Color, NumpyArray, PilPNG, Point, Union
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,61 +205,40 @@ 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
@@ -183,46 +252,54 @@ class Drawable:
183
252
  width: int = 3,
184
253
  ) -> NumpyArray:
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 vectorized operations.
256
+
257
+ Args:
258
+ layer: The numpy array to draw on
259
+ x1, y1: Start point coordinates
260
+ x2, y2: End point coordinates
261
+ color: Color to draw with
262
+ width: Width of the line
196
263
  """
197
- # Ensure the coordinates are integers
264
+ # Ensure coordinates are integers
198
265
  x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
199
266
 
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
267
+ # Get blended color for the line
268
+ blended_color = get_blended_color(x1, y1, x2, y2, layer, color)
206
269
 
207
- while True:
208
- # Draw a rectangle with the specified width at the current coordinates
270
+ # Calculate line length
271
+ length = max(abs(x2 - x1), abs(y2 - y1))
272
+ if length == 0: # Handle case of a single point
273
+ # Draw a dot with the specified width
209
274
  for i in range(-width // 2, (width + 1) // 2):
210
275
  for j in range(-width // 2, (width + 1) // 2):
211
276
  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
277
+ layer[y1 + j, x1 + i] = blended_color
278
+ return layer
279
+
280
+ # Create parametric points along the line
281
+ t = np.linspace(0, 1, length * 2) # Double the points for smoother lines
282
+ x_coords = np.round(x1 * (1 - t) + x2 * t).astype(int)
283
+ y_coords = np.round(y1 * (1 - t) + y2 * t).astype(int)
284
+
285
+ # Draw the line with the specified width
286
+ if width == 1:
287
+ # Fast path for width=1
288
+ for x, y in zip(x_coords, y_coords):
289
+ if 0 <= x < layer.shape[1] and 0 <= y < layer.shape[0]:
290
+ layer[y, x] = blended_color
291
+ else:
292
+ # For thicker lines, draw a rectangle at each point
293
+ half_width = width // 2
294
+ for x, y in zip(x_coords, y_coords):
295
+ for i in range(-half_width, half_width + 1):
296
+ for j in range(-half_width, half_width + 1):
297
+ if (
298
+ i * i + j * j <= half_width * half_width # Make it round
299
+ and 0 <= x + i < layer.shape[1]
300
+ and 0 <= y + j < layer.shape[0]
301
+ ):
302
+ layer[y + j, x + i] = blended_color
226
303
 
227
304
  return layer
228
305
 
@@ -243,42 +320,26 @@ class Drawable:
243
320
  @staticmethod
244
321
  async def lines(arr: NumpyArray, coords, width: int, color: Color) -> NumpyArray:
245
322
  """
246
- it joins the coordinates creating a continues line.
247
- the result is our path.
323
+ Join the coordinates creating a continuous line (path).
324
+ Optimized with vectorized operations for better performance.
248
325
  """
249
326
  for coord in coords:
250
- # Use Bresenham's line algorithm to get the coordinates of the line pixels
251
327
  x0, y0 = coord[0]
252
328
  try:
253
329
  x1, y1 = coord[1]
254
330
  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
331
+ x1, y1 = x0, y0
332
+
333
+ # Skip if coordinates are the same
334
+ if x0 == x1 and y0 == y1:
335
+ continue
336
+
337
+ # Get blended color for this line segment
338
+ blended_color = get_blended_color(x0, y0, x1, y1, arr, color)
339
+
340
+ # Use the optimized line drawing method
341
+ arr = Drawable._line(arr, x0, y0, x1, y1, blended_color, width)
342
+
282
343
  return arr
283
344
 
284
345
  @staticmethod
@@ -292,32 +353,67 @@ class Drawable:
292
353
  ) -> NumpyArray:
293
354
  """
294
355
  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.
356
+ Optimized to only process the bounding box of the circle.
304
357
  """
305
358
  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
359
+ height, width = image.shape[:2]
360
+
361
+ # Calculate the bounding box of the circle
362
+ min_y = max(0, y - radius - outline_width)
363
+ max_y = min(height, y + radius + outline_width + 1)
364
+ min_x = max(0, x - radius - outline_width)
365
+ max_x = min(width, x + radius + outline_width + 1)
366
+
367
+ # Create coordinate arrays for the bounding box
368
+ y_indices, x_indices = np.ogrid[min_y:max_y, min_x:max_x]
369
+
370
+ # Calculate distances from center
371
+ dist_sq = (y_indices - y) ** 2 + (x_indices - x) ** 2
372
+
373
+ # Create masks for the circle and outline
374
+ circle_mask = dist_sq <= radius**2
375
+
376
+ # Apply the fill color
377
+ image[min_y:max_y, min_x:max_x][circle_mask] = color
378
+
379
+ # Draw the outline if needed
380
+ if outline_width > 0 and outline_color is not None:
381
+ outer_mask = dist_sq <= (radius + outline_width) ** 2
382
+ outline_mask = outer_mask & ~circle_mask
383
+ image[min_y:max_y, min_x:max_x][outline_mask] = outline_color
318
384
 
319
385
  return image
320
386
 
387
+ @staticmethod
388
+ def _filled_circle_optimized(
389
+ image: np.ndarray,
390
+ center: Tuple[int, int],
391
+ radius: int,
392
+ color: Color,
393
+ outline_color: Color = None,
394
+ outline_width: int = 0,
395
+ ) -> np.ndarray:
396
+ """
397
+ Optimized _filled_circle ensuring dtype compatibility with uint8.
398
+ """
399
+ x, y = center
400
+ h, w = image.shape[:2]
401
+ color_np = np.array(color, dtype=image.dtype)
402
+ outline_color_np = (
403
+ np.array(outline_color, dtype=image.dtype)
404
+ if outline_color is not None
405
+ else None
406
+ )
407
+ y_indices, x_indices = np.meshgrid(np.arange(h), np.arange(w), indexing="ij")
408
+ dist_sq = (y_indices - y) ** 2 + (x_indices - x) ** 2
409
+ circle_mask = dist_sq <= radius**2
410
+ image[circle_mask] = color_np
411
+ if outline_width > 0 and outline_color_np is not None:
412
+ outer_mask = dist_sq <= (radius + outline_width) ** 2
413
+ outline_mask = outer_mask & ~circle_mask
414
+ image[outline_mask] = outline_color_np
415
+ return image
416
+
321
417
  @staticmethod
322
418
  def _ellipse(
323
419
  image: NumpyArray, center: Point, radius: int, color: Color
@@ -325,32 +421,28 @@ class Drawable:
325
421
  """
326
422
  Draw an ellipse on the image using NumPy.
327
423
  """
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
424
  x, y = center
332
425
  x1, y1 = x - radius, y - radius
333
426
  x2, y2 = x + radius, y + radius
334
- # Draw the filled ellipse
335
- result_image[y1:y2, x1:x2] = color
336
- return result_image
427
+ image[y1:y2, x1:x2] = color
428
+ return image
337
429
 
338
430
  @staticmethod
339
431
  def _polygon_outline(
340
432
  arr: NumpyArray,
341
- points,
433
+ points: list[Tuple[int, int]],
342
434
  width: int,
343
435
  outline_color: Color,
344
436
  fill_color: Color = None,
345
437
  ) -> NumpyArray:
346
438
  """
347
- Draw the outline of a filled polygon on the array using _line.
439
+ Draw the outline of a polygon on the array using _line, and optionally fill it.
440
+ Uses NumPy vectorized operations for improved performance.
348
441
  """
349
- for i, point in enumerate(points):
350
- # Get the current and next points to draw a line between them
442
+ # Draw the outline
443
+ for i, _ in enumerate(points):
351
444
  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
445
+ next_point = points[(i + 1) % len(points)]
354
446
  arr = Drawable._line(
355
447
  arr,
356
448
  current_point[0],
@@ -360,39 +452,89 @@ class Drawable:
360
452
  outline_color,
361
453
  width,
362
454
  )
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
455
+
456
+ # Fill the polygon if a fill color is provided
457
+ if fill_color is not None:
458
+ # Get the bounding box of the polygon
459
+ min_x = max(0, min(p[0] for p in points))
460
+ max_x = min(arr.shape[1] - 1, max(p[0] for p in points))
461
+ min_y = max(0, min(p[1] for p in points))
462
+ max_y = min(arr.shape[0] - 1, max(p[1] for p in points))
463
+
464
+ # Create a mask for the polygon region
465
+ mask = np.zeros((max_y - min_y + 1, max_x - min_x + 1), dtype=bool)
466
+
467
+ # Adjust points to the mask's coordinate system
468
+ adjusted_points = [(p[0] - min_x, p[1] - min_y) for p in points]
469
+
470
+ # Create a grid of coordinates and use it to test all points at once
471
+ y_indices, x_indices = np.mgrid[0 : mask.shape[0], 0 : mask.shape[1]]
472
+
473
+ # Test each point in the grid
474
+ for i in range(mask.shape[0]):
475
+ for j in range(mask.shape[1]):
476
+ mask[i, j] = Drawable.point_inside(j, i, adjusted_points)
477
+
478
+ # Apply the fill color to the masked region
479
+ arr[min_y : max_y + 1, min_x : max_x + 1][mask] = fill_color
480
+
374
481
  return arr
375
482
 
376
483
  @staticmethod
377
484
  async def zones(layers: NumpyArray, coordinates, color: Color) -> NumpyArray:
378
485
  """
379
- Draw the zones on the input layer.
486
+ Draw the zones on the input layer with color blending.
487
+ Optimized with NumPy vectorized operations for better performance.
380
488
  """
381
- dot_radius = 1 # number of pixels the dot should be
382
- dot_spacing = 4 # space between dots.
383
- # Iterate over zones
489
+ dot_radius = 1 # Number of pixels for the dot
490
+ dot_spacing = 4 # Space between dots
491
+
384
492
  for zone in coordinates:
385
493
  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)
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]))
498
+
499
+ # Skip if zone is outside the image
500
+ if min_x >= max_x or min_y >= max_y:
501
+ continue
502
+
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
507
+
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
515
+
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)
519
+
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
524
+ y_min = max(0, y - dot_radius)
525
+ y_max = min(layers.shape[0], y + dot_radius + 1)
526
+ x_min = max(0, x - dot_radius)
527
+ x_max = min(layers.shape[1], x + dot_radius + 1)
528
+
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
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:
502
665
  """
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.
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:
683
+ """
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 batch processing.
819
+ Includes color blending for better visual integration.
820
+ """
821
+ if not obstacle_info_list:
822
+ return image
823
+
824
+ # Extract alpha from color
825
+ alpha = color[3] if len(color) == 4 else 255
826
+ need_blending = alpha < 255
827
+
828
+ # Extract obstacle centers and prepare for batch processing
829
+ centers = []
830
+ for obs in obstacle_info_list:
831
+ try:
832
+ x = obs["points"]["x"]
833
+ y = obs["points"]["y"]
834
+
835
+ # Skip if coordinates are out of bounds
836
+ if not (0 <= x < image.shape[1] and 0 <= y < image.shape[0]):
837
+ continue
838
+
839
+ # Apply color blending if needed
840
+ obstacle_color = color
841
+ if need_blending:
842
+ obstacle_color = ColorsManagement.sample_and_blend_color(
843
+ image, x, y, color
844
+ )
845
+
846
+ # Add to centers list with radius
847
+ centers.append({"center": (x, y), "radius": 6, "color": obstacle_color})
848
+ except (KeyError, TypeError):
849
+ continue
850
+
851
+ # Draw each obstacle with its blended color
852
+ if centers:
853
+ for obstacle in centers:
854
+ cx, cy = obstacle["center"]
855
+ radius = obstacle["radius"]
856
+ obs_color = obstacle["color"]
857
+
858
+ # Create a small mask for the obstacle
859
+ min_y = max(0, cy - radius)
860
+ max_y = min(image.shape[0], cy + radius + 1)
861
+ min_x = max(0, cx - radius)
862
+ max_x = min(image.shape[1], cx + radius + 1)
863
+
864
+ # Create coordinate arrays for the circle
865
+ y_indices, x_indices = np.ogrid[min_y:max_y, min_x:max_x]
866
+
867
+ # Create a circular mask
868
+ mask = (y_indices - cy) ** 2 + (x_indices - cx) ** 2 <= radius**2
869
+
870
+ # Apply the color to the masked region
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: