valetudo-map-parser 0.1.10rc6__py3-none-any.whl → 0.1.11b0__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 +8 -10
- valetudo_map_parser/config/drawable.py +91 -329
- valetudo_map_parser/config/drawable_elements.py +0 -2
- valetudo_map_parser/config/rand256_parser.py +162 -44
- valetudo_map_parser/config/shared.py +30 -12
- valetudo_map_parser/config/status_text/status_text.py +1 -0
- valetudo_map_parser/config/types.py +12 -3
- valetudo_map_parser/config/utils.py +44 -136
- valetudo_map_parser/hypfer_draw.py +0 -2
- valetudo_map_parser/hypfer_handler.py +14 -22
- valetudo_map_parser/map_data.py +17 -11
- valetudo_map_parser/rand256_handler.py +79 -53
- valetudo_map_parser/reimg_draw.py +13 -18
- valetudo_map_parser/rooms_handler.py +10 -10
- {valetudo_map_parser-0.1.10rc6.dist-info → valetudo_map_parser-0.1.11b0.dist-info}/METADATA +2 -2
- valetudo_map_parser-0.1.11b0.dist-info/RECORD +32 -0
- valetudo_map_parser/config/auto_crop.py +0 -452
- valetudo_map_parser/config/color_utils.py +0 -105
- valetudo_map_parser/config/enhanced_drawable.py +0 -324
- valetudo_map_parser/hypfer_rooms_handler.py +0 -599
- valetudo_map_parser-0.1.10rc6.dist-info/RECORD +0 -36
- {valetudo_map_parser-0.1.10rc6.dist-info → valetudo_map_parser-0.1.11b0.dist-info}/WHEEL +0 -0
- {valetudo_map_parser-0.1.10rc6.dist-info → valetudo_map_parser-0.1.11b0.dist-info}/licenses/LICENSE +0 -0
- {valetudo_map_parser-0.1.10rc6.dist-info → valetudo_map_parser-0.1.11b0.dist-info}/licenses/NOTICE.txt +0 -0
@@ -1,452 +0,0 @@
|
|
1
|
-
"""Auto Crop Class for trimming and zooming images.
|
2
|
-
Version: 2024.10.0"""
|
3
|
-
|
4
|
-
from __future__ import annotations
|
5
|
-
|
6
|
-
import logging
|
7
|
-
|
8
|
-
import numpy as np
|
9
|
-
from scipy import ndimage
|
10
|
-
|
11
|
-
from .async_utils import AsyncNumPy
|
12
|
-
from .types import Color, NumpyArray, TrimCropData, TrimsData
|
13
|
-
from .utils import BaseHandler
|
14
|
-
|
15
|
-
|
16
|
-
_LOGGER = logging.getLogger(__name__)
|
17
|
-
|
18
|
-
|
19
|
-
class TrimError(Exception):
|
20
|
-
"""Exception raised for errors in the trim process."""
|
21
|
-
|
22
|
-
def __init__(self, message, image):
|
23
|
-
super().__init__(message)
|
24
|
-
self.image = image
|
25
|
-
|
26
|
-
|
27
|
-
class AutoCrop:
|
28
|
-
"""Auto Crop Class for trimming and zooming images."""
|
29
|
-
|
30
|
-
def __init__(self, handler: BaseHandler):
|
31
|
-
self.auto_crop = None # auto crop data to be calculate once.
|
32
|
-
self.crop_area = None
|
33
|
-
self.handler = handler
|
34
|
-
trim_data = self.handler.shared.trims.to_dict() # trims data
|
35
|
-
self.trim_up = trim_data.get("trim_up", 0) # trim up
|
36
|
-
self.trim_down = trim_data.get("trim_down", 0) # trim down
|
37
|
-
self.trim_left = trim_data.get("trim_left", 0) # trim left
|
38
|
-
self.trim_right = trim_data.get("trim_right", 0) # trim right
|
39
|
-
self.offset_top = self.handler.shared.offset_top # offset top
|
40
|
-
self.offset_bottom = self.handler.shared.offset_down # offset bottom
|
41
|
-
self.offset_left = self.handler.shared.offset_left # offset left
|
42
|
-
self.offset_right = self.handler.shared.offset_right # offset right
|
43
|
-
|
44
|
-
@staticmethod
|
45
|
-
def validate_crop_dimensions(shared):
|
46
|
-
"""Ensure width and height are valid before processing cropping."""
|
47
|
-
if shared.image_ref_width <= 0 or shared.image_ref_height <= 0:
|
48
|
-
_LOGGER.warning(
|
49
|
-
"Auto-crop failed: Invalid dimensions (width=%s, height=%s). Using original image.",
|
50
|
-
shared.image_ref_width,
|
51
|
-
shared.image_ref_height,
|
52
|
-
)
|
53
|
-
return False
|
54
|
-
return True
|
55
|
-
|
56
|
-
def check_trim(
|
57
|
-
self, trimmed_height, trimmed_width, margin_size, image_array, file_name, rotate
|
58
|
-
):
|
59
|
-
"""Check if the trim is okay."""
|
60
|
-
if trimmed_height <= margin_size or trimmed_width <= margin_size:
|
61
|
-
self.crop_area = [0, 0, image_array.shape[1], image_array.shape[0]]
|
62
|
-
self.handler.img_size = (image_array.shape[1], image_array.shape[0])
|
63
|
-
raise TrimError(
|
64
|
-
f"{file_name}: Trimming failed at rotation {rotate}.", image_array
|
65
|
-
)
|
66
|
-
|
67
|
-
def _calculate_trimmed_dimensions(self):
|
68
|
-
"""Calculate and update the dimensions after trimming."""
|
69
|
-
trimmed_width = max(
|
70
|
-
1, # Ensure at least 1px
|
71
|
-
(self.trim_right - self.offset_right) - (self.trim_left + self.offset_left),
|
72
|
-
)
|
73
|
-
trimmed_height = max(
|
74
|
-
1, # Ensure at least 1px
|
75
|
-
(self.trim_down - self.offset_bottom) - (self.trim_up + self.offset_top),
|
76
|
-
)
|
77
|
-
|
78
|
-
# Ensure shared reference dimensions are updated
|
79
|
-
if hasattr(self.handler.shared, "image_ref_height") and hasattr(
|
80
|
-
self.handler.shared, "image_ref_width"
|
81
|
-
):
|
82
|
-
self.handler.shared.image_ref_height = trimmed_height
|
83
|
-
self.handler.shared.image_ref_width = trimmed_width
|
84
|
-
else:
|
85
|
-
_LOGGER.warning(
|
86
|
-
"Shared attributes for image dimensions are not initialized."
|
87
|
-
)
|
88
|
-
|
89
|
-
return trimmed_width, trimmed_height
|
90
|
-
|
91
|
-
async def _async_auto_crop_data(self, tdata: TrimsData): # , tdata=None
|
92
|
-
"""Load the auto crop data from the Camera config."""
|
93
|
-
if not self.auto_crop:
|
94
|
-
trims_data = TrimCropData.from_dict(dict(tdata.to_dict())).to_list()
|
95
|
-
(
|
96
|
-
self.trim_left,
|
97
|
-
self.trim_up,
|
98
|
-
self.trim_right,
|
99
|
-
self.trim_down,
|
100
|
-
) = trims_data
|
101
|
-
if trims_data != [0, 0, 0, 0]:
|
102
|
-
self._calculate_trimmed_dimensions()
|
103
|
-
else:
|
104
|
-
trims_data = None
|
105
|
-
return trims_data
|
106
|
-
return None
|
107
|
-
|
108
|
-
def auto_crop_offset(self):
|
109
|
-
"""Calculate the offset for the auto crop."""
|
110
|
-
if self.auto_crop:
|
111
|
-
self.auto_crop[0] += self.offset_left
|
112
|
-
self.auto_crop[1] += self.offset_top
|
113
|
-
self.auto_crop[2] -= self.offset_right
|
114
|
-
self.auto_crop[3] -= self.offset_bottom
|
115
|
-
|
116
|
-
async def _init_auto_crop(self):
|
117
|
-
"""Initialize the auto crop data."""
|
118
|
-
if not self.auto_crop: # and self.handler.shared.vacuum_state == "docked":
|
119
|
-
self.auto_crop = await self._async_auto_crop_data(self.handler.shared.trims)
|
120
|
-
if self.auto_crop:
|
121
|
-
self.auto_crop_offset()
|
122
|
-
else:
|
123
|
-
self.handler.max_frames = 1205
|
124
|
-
|
125
|
-
# Fallback: Ensure auto_crop is valid
|
126
|
-
if not self.auto_crop or any(v < 0 for v in self.auto_crop):
|
127
|
-
self.auto_crop = None
|
128
|
-
|
129
|
-
return self.auto_crop
|
130
|
-
|
131
|
-
async def async_image_margins(
|
132
|
-
self, image_array: NumpyArray, detect_colour: Color
|
133
|
-
) -> tuple[int, int, int, int]:
|
134
|
-
"""Crop the image based on the auto crop area using scipy.ndimage for better performance."""
|
135
|
-
# Import scipy.ndimage here to avoid import at module level
|
136
|
-
|
137
|
-
# Create a binary mask where True = non-background pixels
|
138
|
-
# This is much more memory efficient than storing coordinates
|
139
|
-
mask = ~np.all(image_array == list(detect_colour), axis=2)
|
140
|
-
|
141
|
-
# Use scipy.ndimage.find_objects to efficiently find the bounding box
|
142
|
-
# This returns a list of slice objects that define the bounding box
|
143
|
-
# Label the mask with a single label (1) and find its bounding box
|
144
|
-
labeled_mask = mask.astype(np.int8) # Convert to int8 (smallest integer type)
|
145
|
-
objects = ndimage.find_objects(labeled_mask)
|
146
|
-
|
147
|
-
if not objects: # No objects found
|
148
|
-
_LOGGER.warning(
|
149
|
-
"%s: No non-background pixels found in image", self.handler.file_name
|
150
|
-
)
|
151
|
-
# Return full image dimensions as fallback
|
152
|
-
return 0, 0, image_array.shape[1], image_array.shape[0]
|
153
|
-
|
154
|
-
# Extract the bounding box coordinates from the slice objects
|
155
|
-
y_slice, x_slice = objects[0]
|
156
|
-
min_y, max_y = y_slice.start, y_slice.stop - 1
|
157
|
-
min_x, max_x = x_slice.start, x_slice.stop - 1
|
158
|
-
|
159
|
-
return min_y, min_x, max_x, max_y
|
160
|
-
|
161
|
-
async def async_get_room_bounding_box(
|
162
|
-
self, room_name: str, rand256: bool = False
|
163
|
-
) -> tuple[int, int, int, int] | None:
|
164
|
-
"""Calculate bounding box coordinates from room outline for zoom functionality.
|
165
|
-
|
166
|
-
Args:
|
167
|
-
room_name: Name of the room to get bounding box for
|
168
|
-
rand256: Whether this is for a rand256 vacuum (applies /10 scaling)
|
169
|
-
|
170
|
-
Returns:
|
171
|
-
Tuple of (left, right, up, down) coordinates or None if room not found
|
172
|
-
"""
|
173
|
-
try:
|
174
|
-
# For Hypfer vacuums, check room_propriety first, then rooms_pos
|
175
|
-
if hasattr(self.handler, "room_propriety") and self.handler.room_propriety:
|
176
|
-
# Handle different room_propriety formats
|
177
|
-
room_data_dict = None
|
178
|
-
|
179
|
-
if isinstance(self.handler.room_propriety, dict):
|
180
|
-
# Hypfer handler: room_propriety is a dictionary
|
181
|
-
room_data_dict = self.handler.room_propriety
|
182
|
-
elif (
|
183
|
-
isinstance(self.handler.room_propriety, tuple)
|
184
|
-
and len(self.handler.room_propriety) >= 1
|
185
|
-
):
|
186
|
-
# Rand256 handler: room_propriety is a tuple (room_properties, zone_properties, point_properties)
|
187
|
-
room_data_dict = self.handler.room_propriety[0]
|
188
|
-
|
189
|
-
if room_data_dict and isinstance(room_data_dict, dict):
|
190
|
-
for room_id, room_data in room_data_dict.items():
|
191
|
-
if room_data.get("name") == room_name:
|
192
|
-
outline = room_data.get("outline", [])
|
193
|
-
if outline:
|
194
|
-
xs, ys = zip(*outline)
|
195
|
-
left, right = min(xs), max(xs)
|
196
|
-
up, down = min(ys), max(ys)
|
197
|
-
|
198
|
-
if rand256:
|
199
|
-
# Apply scaling for rand256 vacuums
|
200
|
-
left = round(left / 10)
|
201
|
-
right = round(right / 10)
|
202
|
-
up = round(up / 10)
|
203
|
-
down = round(down / 10)
|
204
|
-
|
205
|
-
return left, right, up, down
|
206
|
-
|
207
|
-
# Fallback: check rooms_pos (used by both Hypfer and Rand256)
|
208
|
-
if hasattr(self.handler, "rooms_pos") and self.handler.rooms_pos:
|
209
|
-
for room in self.handler.rooms_pos:
|
210
|
-
if room.get("name") == room_name:
|
211
|
-
outline = room.get("outline", [])
|
212
|
-
if outline:
|
213
|
-
xs, ys = zip(*outline)
|
214
|
-
left, right = min(xs), max(xs)
|
215
|
-
up, down = min(ys), max(ys)
|
216
|
-
|
217
|
-
if rand256:
|
218
|
-
# Apply scaling for rand256 vacuums
|
219
|
-
left = round(left / 10)
|
220
|
-
right = round(right / 10)
|
221
|
-
up = round(up / 10)
|
222
|
-
down = round(down / 10)
|
223
|
-
|
224
|
-
return left, right, up, down
|
225
|
-
|
226
|
-
_LOGGER.warning(
|
227
|
-
"%s: Room '%s' not found for zoom bounding box calculation",
|
228
|
-
self.handler.file_name,
|
229
|
-
room_name,
|
230
|
-
)
|
231
|
-
return None
|
232
|
-
|
233
|
-
except Exception as e:
|
234
|
-
_LOGGER.warning(
|
235
|
-
"%s: Error calculating room bounding box for '%s': %s",
|
236
|
-
self.handler.file_name,
|
237
|
-
room_name,
|
238
|
-
e,
|
239
|
-
)
|
240
|
-
return None
|
241
|
-
|
242
|
-
async def async_check_if_zoom_is_on(
|
243
|
-
self,
|
244
|
-
image_array: NumpyArray,
|
245
|
-
margin_size: int = 100,
|
246
|
-
zoom: bool = False,
|
247
|
-
rand256: bool = False,
|
248
|
-
) -> NumpyArray:
|
249
|
-
"""Check if the image needs to be zoomed."""
|
250
|
-
|
251
|
-
if (
|
252
|
-
zoom
|
253
|
-
and self.handler.shared.vacuum_state == "cleaning"
|
254
|
-
and self.handler.shared.image_auto_zoom
|
255
|
-
):
|
256
|
-
# Get the current room name from robot_pos (not robot_in_room)
|
257
|
-
current_room = (
|
258
|
-
self.handler.robot_pos.get("in_room")
|
259
|
-
if self.handler.robot_pos
|
260
|
-
else None
|
261
|
-
)
|
262
|
-
_LOGGER.info(f"Current room: {current_room}")
|
263
|
-
|
264
|
-
if not current_room:
|
265
|
-
# For Rand256 handler, try to zoom based on robot position even without room data
|
266
|
-
if (
|
267
|
-
rand256
|
268
|
-
and hasattr(self.handler, "robot_position")
|
269
|
-
and self.handler.robot_position
|
270
|
-
):
|
271
|
-
robot_x, robot_y = (
|
272
|
-
self.handler.robot_position[0],
|
273
|
-
self.handler.robot_position[1],
|
274
|
-
)
|
275
|
-
|
276
|
-
# Create a zoom area around the robot position (e.g., 800x800 pixels for better view)
|
277
|
-
zoom_size = 800
|
278
|
-
trim_left = max(0, int(robot_x - zoom_size // 2))
|
279
|
-
trim_right = min(
|
280
|
-
image_array.shape[1], int(robot_x + zoom_size // 2)
|
281
|
-
)
|
282
|
-
trim_up = max(0, int(robot_y - zoom_size // 2))
|
283
|
-
trim_down = min(image_array.shape[0], int(robot_y + zoom_size // 2))
|
284
|
-
|
285
|
-
_LOGGER.info(
|
286
|
-
"%s: Zooming to robot position area (%d, %d) with size %dx%d",
|
287
|
-
self.handler.file_name,
|
288
|
-
robot_x,
|
289
|
-
robot_y,
|
290
|
-
trim_right - trim_left,
|
291
|
-
trim_down - trim_up,
|
292
|
-
)
|
293
|
-
|
294
|
-
return image_array[trim_up:trim_down, trim_left:trim_right]
|
295
|
-
else:
|
296
|
-
_LOGGER.warning(
|
297
|
-
"%s: No room information available for zoom. Using full image.",
|
298
|
-
self.handler.file_name,
|
299
|
-
)
|
300
|
-
return image_array[
|
301
|
-
self.auto_crop[1] : self.auto_crop[3],
|
302
|
-
self.auto_crop[0] : self.auto_crop[2],
|
303
|
-
]
|
304
|
-
|
305
|
-
# Calculate bounding box from room outline
|
306
|
-
bounding_box = await self.async_get_room_bounding_box(current_room, rand256)
|
307
|
-
|
308
|
-
if not bounding_box:
|
309
|
-
_LOGGER.warning(
|
310
|
-
"%s: Could not calculate bounding box for room '%s'. Using full image.",
|
311
|
-
self.handler.file_name,
|
312
|
-
current_room,
|
313
|
-
)
|
314
|
-
return image_array[
|
315
|
-
self.auto_crop[1] : self.auto_crop[3],
|
316
|
-
self.auto_crop[0] : self.auto_crop[2],
|
317
|
-
]
|
318
|
-
|
319
|
-
left, right, up, down = bounding_box
|
320
|
-
|
321
|
-
# Apply margins
|
322
|
-
trim_left = left - margin_size
|
323
|
-
trim_right = right + margin_size
|
324
|
-
trim_up = up - margin_size
|
325
|
-
trim_down = down + margin_size
|
326
|
-
# Ensure valid trim values
|
327
|
-
trim_left, trim_right = sorted([trim_left, trim_right])
|
328
|
-
trim_up, trim_down = sorted([trim_up, trim_down])
|
329
|
-
|
330
|
-
# Prevent zero-sized images
|
331
|
-
if trim_right - trim_left < 1 or trim_down - trim_up < 1:
|
332
|
-
_LOGGER.warning(
|
333
|
-
"Zooming resulted in an invalid crop area. Using full image."
|
334
|
-
)
|
335
|
-
return image_array # Return original image
|
336
|
-
|
337
|
-
trimmed = image_array[trim_up:trim_down, trim_left:trim_right]
|
338
|
-
|
339
|
-
else:
|
340
|
-
trimmed = image_array[
|
341
|
-
self.auto_crop[1] : self.auto_crop[3],
|
342
|
-
self.auto_crop[0] : self.auto_crop[2],
|
343
|
-
]
|
344
|
-
|
345
|
-
return trimmed
|
346
|
-
|
347
|
-
async def async_rotate_the_image(
|
348
|
-
self, trimmed: NumpyArray, rotate: int
|
349
|
-
) -> NumpyArray:
|
350
|
-
"""Rotate the image and return the new array."""
|
351
|
-
if rotate == 90:
|
352
|
-
rotated = await AsyncNumPy.async_rot90(trimmed)
|
353
|
-
self.crop_area = [
|
354
|
-
self.trim_left,
|
355
|
-
self.trim_up,
|
356
|
-
self.trim_right,
|
357
|
-
self.trim_down,
|
358
|
-
]
|
359
|
-
elif rotate == 180:
|
360
|
-
rotated = await AsyncNumPy.async_rot90(trimmed, 2)
|
361
|
-
self.crop_area = self.auto_crop
|
362
|
-
elif rotate == 270:
|
363
|
-
rotated = await AsyncNumPy.async_rot90(trimmed, 3)
|
364
|
-
self.crop_area = [
|
365
|
-
self.trim_left,
|
366
|
-
self.trim_up,
|
367
|
-
self.trim_right,
|
368
|
-
self.trim_down,
|
369
|
-
]
|
370
|
-
else:
|
371
|
-
rotated = trimmed
|
372
|
-
self.crop_area = self.auto_crop
|
373
|
-
return rotated
|
374
|
-
|
375
|
-
async def async_auto_trim_and_zoom_image(
|
376
|
-
self,
|
377
|
-
image_array: NumpyArray,
|
378
|
-
detect_colour: Color = (93, 109, 126, 255),
|
379
|
-
margin_size: int = 0,
|
380
|
-
rotate: int = 0,
|
381
|
-
zoom: bool = False,
|
382
|
-
rand256: bool = False,
|
383
|
-
):
|
384
|
-
"""
|
385
|
-
Automatically crops and trims a numpy array and returns the processed image.
|
386
|
-
"""
|
387
|
-
try:
|
388
|
-
self.auto_crop = await self._init_auto_crop()
|
389
|
-
if (self.auto_crop is None) or (self.auto_crop == [0, 0, 0, 0]):
|
390
|
-
# Find the coordinates of the first occurrence of a non-background color
|
391
|
-
min_y, min_x, max_x, max_y = await self.async_image_margins(
|
392
|
-
image_array, detect_colour
|
393
|
-
)
|
394
|
-
# Calculate and store the trims coordinates with margins
|
395
|
-
self.trim_left = int(min_x) - margin_size
|
396
|
-
self.trim_up = int(min_y) - margin_size
|
397
|
-
self.trim_right = int(max_x) + margin_size
|
398
|
-
self.trim_down = int(max_y) + margin_size
|
399
|
-
del min_y, min_x, max_x, max_y
|
400
|
-
|
401
|
-
# Calculate the dimensions after trimming using min/max values
|
402
|
-
trimmed_width, trimmed_height = self._calculate_trimmed_dimensions()
|
403
|
-
|
404
|
-
# Test if the trims are okay or not
|
405
|
-
try:
|
406
|
-
self.check_trim(
|
407
|
-
trimmed_height,
|
408
|
-
trimmed_width,
|
409
|
-
margin_size,
|
410
|
-
image_array,
|
411
|
-
self.handler.file_name,
|
412
|
-
rotate,
|
413
|
-
)
|
414
|
-
except TrimError as e:
|
415
|
-
return e.image
|
416
|
-
|
417
|
-
# Store Crop area of the original image_array we will use from the next frame.
|
418
|
-
self.auto_crop = TrimCropData(
|
419
|
-
self.trim_left,
|
420
|
-
self.trim_up,
|
421
|
-
self.trim_right,
|
422
|
-
self.trim_down,
|
423
|
-
).to_list()
|
424
|
-
# Update the trims data in the shared instance
|
425
|
-
self.handler.shared.trims = TrimsData.from_dict(
|
426
|
-
{
|
427
|
-
"trim_left": self.trim_left,
|
428
|
-
"trim_up": self.trim_up,
|
429
|
-
"trim_right": self.trim_right,
|
430
|
-
"trim_down": self.trim_down,
|
431
|
-
}
|
432
|
-
)
|
433
|
-
self.auto_crop_offset()
|
434
|
-
# If it is needed to zoom the image.
|
435
|
-
trimmed = await self.async_check_if_zoom_is_on(
|
436
|
-
image_array, margin_size, zoom, rand256
|
437
|
-
)
|
438
|
-
del image_array # Free memory.
|
439
|
-
# Rotate the cropped image based on the given angle
|
440
|
-
rotated = await self.async_rotate_the_image(trimmed, rotate)
|
441
|
-
del trimmed # Free memory.
|
442
|
-
self.handler.crop_img_size = [rotated.shape[1], rotated.shape[0]]
|
443
|
-
|
444
|
-
except RuntimeError as e:
|
445
|
-
_LOGGER.warning(
|
446
|
-
"%s: Error %s during auto trim and zoom.",
|
447
|
-
self.handler.file_name,
|
448
|
-
e,
|
449
|
-
exc_info=True,
|
450
|
-
)
|
451
|
-
return None
|
452
|
-
return rotated
|
@@ -1,105 +0,0 @@
|
|
1
|
-
"""Utility functions for color operations in the map parser."""
|
2
|
-
|
3
|
-
from typing import Optional, Tuple
|
4
|
-
|
5
|
-
from .colors import ColorsManagement
|
6
|
-
from .types import Color, NumpyArray
|
7
|
-
|
8
|
-
|
9
|
-
def get_blended_color(
|
10
|
-
x0: int,
|
11
|
-
y0: int,
|
12
|
-
x1: int,
|
13
|
-
y1: int,
|
14
|
-
arr: Optional[NumpyArray],
|
15
|
-
color: Color,
|
16
|
-
) -> Color:
|
17
|
-
"""
|
18
|
-
Get a blended color for a pixel based on the current element map and the new element to draw.
|
19
|
-
|
20
|
-
This function:
|
21
|
-
1. Gets the background colors at the start and end points (with offset to avoid sampling already drawn pixels)
|
22
|
-
2. Directly blends the foreground color with the background using straight alpha
|
23
|
-
3. Returns the average of the two blended colors
|
24
|
-
|
25
|
-
Returns:
|
26
|
-
Blended RGBA color to use for drawing
|
27
|
-
"""
|
28
|
-
# Extract foreground color components
|
29
|
-
fg_r, fg_g, fg_b, fg_a = color
|
30
|
-
fg_alpha = fg_a / 255.0 # Convert to 0-1 range
|
31
|
-
|
32
|
-
# Fast path for fully opaque or transparent foreground
|
33
|
-
if fg_a == 255:
|
34
|
-
return color
|
35
|
-
if fg_a == 0:
|
36
|
-
# Sample background at midpoint
|
37
|
-
mid_x, mid_y = (x0 + x1) // 2, (y0 + y1) // 2
|
38
|
-
if 0 <= mid_y < arr.shape[0] and 0 <= mid_x < arr.shape[1]:
|
39
|
-
return tuple(arr[mid_y, mid_x])
|
40
|
-
return (0, 0, 0, 0) # Default if out of bounds
|
41
|
-
|
42
|
-
# Calculate direction vector for offset sampling
|
43
|
-
dx = x1 - x0
|
44
|
-
dy = y1 - y0
|
45
|
-
length = max(1, (dx**2 + dy**2) ** 0.5) # Avoid division by zero
|
46
|
-
offset = 5 # 5-pixel offset to avoid sampling already drawn pixels
|
47
|
-
|
48
|
-
# Calculate offset coordinates for start point (move away from the line)
|
49
|
-
offset_x0 = int(x0 - (offset * dx / length))
|
50
|
-
offset_y0 = int(y0 - (offset * dy / length))
|
51
|
-
|
52
|
-
# Calculate offset coordinates for end point (move away from the line)
|
53
|
-
offset_x1 = int(x1 + (offset * dx / length))
|
54
|
-
offset_y1 = int(y1 + (offset * dy / length))
|
55
|
-
|
56
|
-
# Sample background at offset start point
|
57
|
-
if 0 <= offset_y0 < arr.shape[0] and 0 <= offset_x0 < arr.shape[1]:
|
58
|
-
bg_color_start = arr[offset_y0, offset_x0]
|
59
|
-
# Direct straight alpha blending
|
60
|
-
start_r = int(fg_r * fg_alpha + bg_color_start[0] * (1 - fg_alpha))
|
61
|
-
start_g = int(fg_g * fg_alpha + bg_color_start[1] * (1 - fg_alpha))
|
62
|
-
start_b = int(fg_b * fg_alpha + bg_color_start[2] * (1 - fg_alpha))
|
63
|
-
start_a = int(fg_a + bg_color_start[3] * (1 - fg_alpha))
|
64
|
-
start_blended_color = (start_r, start_g, start_b, start_a)
|
65
|
-
else:
|
66
|
-
# If offset point is out of bounds, try original point
|
67
|
-
if 0 <= y0 < arr.shape[0] and 0 <= x0 < arr.shape[1]:
|
68
|
-
bg_color_start = arr[y0, x0]
|
69
|
-
start_r = int(fg_r * fg_alpha + bg_color_start[0] * (1 - fg_alpha))
|
70
|
-
start_g = int(fg_g * fg_alpha + bg_color_start[1] * (1 - fg_alpha))
|
71
|
-
start_b = int(fg_b * fg_alpha + bg_color_start[2] * (1 - fg_alpha))
|
72
|
-
start_a = int(fg_a + bg_color_start[3] * (1 - fg_alpha))
|
73
|
-
start_blended_color = (start_r, start_g, start_b, start_a)
|
74
|
-
else:
|
75
|
-
start_blended_color = color
|
76
|
-
|
77
|
-
# Sample background at offset end point
|
78
|
-
if 0 <= offset_y1 < arr.shape[0] and 0 <= offset_x1 < arr.shape[1]:
|
79
|
-
bg_color_end = arr[offset_y1, offset_x1]
|
80
|
-
# Direct straight alpha blending
|
81
|
-
end_r = int(fg_r * fg_alpha + bg_color_end[0] * (1 - fg_alpha))
|
82
|
-
end_g = int(fg_g * fg_alpha + bg_color_end[1] * (1 - fg_alpha))
|
83
|
-
end_b = int(fg_b * fg_alpha + bg_color_end[2] * (1 - fg_alpha))
|
84
|
-
end_a = int(fg_a + bg_color_end[3] * (1 - fg_alpha))
|
85
|
-
end_blended_color = (end_r, end_g, end_b, end_a)
|
86
|
-
else:
|
87
|
-
# If offset point is out of bounds, try original point
|
88
|
-
if 0 <= y1 < arr.shape[0] and 0 <= x1 < arr.shape[1]:
|
89
|
-
bg_color_end = arr[y1, x1]
|
90
|
-
end_r = int(fg_r * fg_alpha + bg_color_end[0] * (1 - fg_alpha))
|
91
|
-
end_g = int(fg_g * fg_alpha + bg_color_end[1] * (1 - fg_alpha))
|
92
|
-
end_b = int(fg_b * fg_alpha + bg_color_end[2] * (1 - fg_alpha))
|
93
|
-
end_a = int(fg_a + bg_color_end[3] * (1 - fg_alpha))
|
94
|
-
end_blended_color = (end_r, end_g, end_b, end_a)
|
95
|
-
else:
|
96
|
-
end_blended_color = color
|
97
|
-
|
98
|
-
# Use the average of the two blended colors
|
99
|
-
blended_color = (
|
100
|
-
(start_blended_color[0] + end_blended_color[0]) // 2,
|
101
|
-
(start_blended_color[1] + end_blended_color[1]) // 2,
|
102
|
-
(start_blended_color[2] + end_blended_color[2]) // 2,
|
103
|
-
(start_blended_color[3] + end_blended_color[3]) // 2,
|
104
|
-
)
|
105
|
-
return blended_color
|