valetudo-map-parser 0.1.7__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.
- valetudo_map_parser/__init__.py +28 -13
- valetudo_map_parser/config/async_utils.py +93 -0
- valetudo_map_parser/config/auto_crop.py +312 -123
- valetudo_map_parser/config/color_utils.py +105 -0
- valetudo_map_parser/config/colors.py +662 -13
- valetudo_map_parser/config/drawable.py +613 -268
- 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/rand256_parser.py +395 -0
- valetudo_map_parser/config/shared.py +94 -11
- valetudo_map_parser/config/types.py +105 -52
- valetudo_map_parser/config/utils.py +1025 -0
- valetudo_map_parser/hypfer_draw.py +464 -148
- valetudo_map_parser/hypfer_handler.py +366 -259
- valetudo_map_parser/hypfer_rooms_handler.py +599 -0
- valetudo_map_parser/map_data.py +56 -66
- valetudo_map_parser/rand256_handler.py +674 -0
- valetudo_map_parser/reimg_draw.py +68 -84
- valetudo_map_parser/rooms_handler.py +474 -0
- valetudo_map_parser-0.1.9a0.dist-info/METADATA +93 -0
- valetudo_map_parser-0.1.9a0.dist-info/RECORD +27 -0
- {valetudo_map_parser-0.1.7.dist-info → valetudo_map_parser-0.1.9a0.dist-info}/WHEEL +1 -1
- valetudo_map_parser/config/rand25_parser.py +0 -398
- valetudo_map_parser/images_utils.py +0 -398
- valetudo_map_parser/rand25_handler.py +0 -455
- 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.9a0.dist-info}/LICENSE +0 -0
- {valetudo_map_parser-0.1.7.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:
|
|
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 .
|
|
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
|
|
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,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
|
-
|
|
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
|
|
176
245
|
def _line(
|
|
177
|
-
layer:
|
|
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
|
-
) ->
|
|
253
|
+
) -> np.ndarray:
|
|
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 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
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
|
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
|
-
|
|
350
|
-
|
|
441
|
+
# Draw the outline
|
|
442
|
+
for i, _ in enumerate(points):
|
|
351
443
|
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
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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 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
|
|
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:
|