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.
- valetudo_map_parser/__init__.py +19 -12
- valetudo_map_parser/config/auto_crop.py +174 -116
- valetudo_map_parser/config/color_utils.py +105 -0
- valetudo_map_parser/config/colors.py +662 -13
- valetudo_map_parser/config/drawable.py +624 -279
- valetudo_map_parser/config/drawable_elements.py +292 -0
- valetudo_map_parser/config/enhanced_drawable.py +324 -0
- valetudo_map_parser/config/optimized_element_map.py +406 -0
- valetudo_map_parser/config/rand25_parser.py +42 -28
- valetudo_map_parser/config/room_outline.py +148 -0
- valetudo_map_parser/config/shared.py +29 -5
- valetudo_map_parser/config/types.py +102 -51
- valetudo_map_parser/config/utils.py +841 -0
- valetudo_map_parser/hypfer_draw.py +398 -132
- valetudo_map_parser/hypfer_handler.py +259 -241
- valetudo_map_parser/hypfer_rooms_handler.py +599 -0
- valetudo_map_parser/map_data.py +45 -64
- valetudo_map_parser/rand25_handler.py +429 -310
- valetudo_map_parser/reimg_draw.py +55 -74
- valetudo_map_parser/rooms_handler.py +470 -0
- valetudo_map_parser-0.1.9a1.dist-info/METADATA +93 -0
- valetudo_map_parser-0.1.9a1.dist-info/RECORD +27 -0
- {valetudo_map_parser-0.1.7.dist-info → valetudo_map_parser-0.1.9a1.dist-info}/WHEEL +1 -1
- valetudo_map_parser/images_utils.py +0 -398
- valetudo_map_parser-0.1.7.dist-info/METADATA +0 -23
- valetudo_map_parser-0.1.7.dist-info/RECORD +0 -20
- {valetudo_map_parser-0.1.7.dist-info → valetudo_map_parser-0.1.9a1.dist-info}/LICENSE +0 -0
- {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:
|
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
|
-
|
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
|
23
|
-
We
|
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 = (
|
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
|
34
|
-
Background color is specified as RGBA tuple."""
|
35
|
-
|
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
|
-
"""
|
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
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
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
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
#
|
69
|
-
|
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
|
-
|
78
|
-
|
79
|
-
|
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
|
-
#
|
82
|
-
|
83
|
-
|
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
|
-
|
95
|
-
|
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
|
-
|
106
|
-
|
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
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
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
|
167
|
-
|
168
|
-
|
169
|
-
|
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
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
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
|
264
|
+
# Ensure coordinates are integers
|
198
265
|
x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
|
199
266
|
|
200
|
-
#
|
201
|
-
|
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
|
-
|
208
|
-
|
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] =
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
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
|
-
|
247
|
-
|
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
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
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
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
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
|
-
|
335
|
-
|
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
|
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
|
-
|
350
|
-
|
442
|
+
# Draw the outline
|
443
|
+
for i, _ in enumerate(points):
|
351
444
|
current_point = points[i]
|
352
|
-
next_point = points[(i + 1) % len(points)]
|
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
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
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 #
|
382
|
-
dot_spacing = 4 #
|
383
|
-
|
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
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
#
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
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
|
-
|
409
|
-
|
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
|
-
#
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
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
|
-
|
419
|
-
#
|
420
|
-
tmp_x
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
#
|
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
|
-
|
594
|
+
|
595
|
+
# Draw the main robot body
|
437
596
|
tmp_layer = Drawable._filled_circle(
|
438
|
-
tmp_layer, (
|
597
|
+
tmp_layer, (tmp_y, tmp_x), radius, fill, outline, 1
|
439
598
|
)
|
440
|
-
|
441
|
-
|
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
|
-
|
449
|
-
# Draw
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
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
|
500
|
-
image:
|
501
|
-
|
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
|
504
|
-
|
505
|
-
|
506
|
-
|
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
|
-
|
693
|
+
Modified image array
|
509
694
|
"""
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
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
|
-
|
811
|
+
return image
|
516
812
|
|
517
|
-
|
518
|
-
|
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
|
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
|
-
|
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:
|