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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. valetudo_map_parser/__init__.py +19 -12
  2. valetudo_map_parser/config/auto_crop.py +174 -116
  3. valetudo_map_parser/config/color_utils.py +105 -0
  4. valetudo_map_parser/config/colors.py +662 -13
  5. valetudo_map_parser/config/drawable.py +624 -279
  6. valetudo_map_parser/config/drawable_elements.py +292 -0
  7. valetudo_map_parser/config/enhanced_drawable.py +324 -0
  8. valetudo_map_parser/config/optimized_element_map.py +406 -0
  9. valetudo_map_parser/config/rand25_parser.py +42 -28
  10. valetudo_map_parser/config/room_outline.py +148 -0
  11. valetudo_map_parser/config/shared.py +29 -5
  12. valetudo_map_parser/config/types.py +102 -51
  13. valetudo_map_parser/config/utils.py +841 -0
  14. valetudo_map_parser/hypfer_draw.py +398 -132
  15. valetudo_map_parser/hypfer_handler.py +259 -241
  16. valetudo_map_parser/hypfer_rooms_handler.py +599 -0
  17. valetudo_map_parser/map_data.py +45 -64
  18. valetudo_map_parser/rand25_handler.py +429 -310
  19. valetudo_map_parser/reimg_draw.py +55 -74
  20. valetudo_map_parser/rooms_handler.py +470 -0
  21. valetudo_map_parser-0.1.9a1.dist-info/METADATA +93 -0
  22. valetudo_map_parser-0.1.9a1.dist-info/RECORD +27 -0
  23. {valetudo_map_parser-0.1.8.dist-info → valetudo_map_parser-0.1.9a1.dist-info}/WHEEL +1 -1
  24. valetudo_map_parser/images_utils.py +0 -398
  25. valetudo_map_parser-0.1.8.dist-info/METADATA +0 -23
  26. valetudo_map_parser-0.1.8.dist-info/RECORD +0 -20
  27. {valetudo_map_parser-0.1.8.dist-info → valetudo_map_parser-0.1.9a1.dist-info}/LICENSE +0 -0
  28. {valetudo_map_parser-0.1.8.dist-info → valetudo_map_parser-0.1.9a1.dist-info}/NOTICE.txt +0 -0
@@ -1,33 +1,40 @@
1
1
  """Valetudo map parser.
2
- Version: 0.1.8"""
2
+ Version: 0.1.9"""
3
3
 
4
- from .hypfer_handler import HypferMapImageHandler
5
- from .rand25_handler import ReImageHandler
4
+ from .config.colors import ColorsManagement
5
+ from .config.drawable import Drawable
6
+ from .config.drawable_elements import DrawableElement, DrawingConfig
7
+ from .config.enhanced_drawable import EnhancedDrawable
6
8
  from .config.rand25_parser import RRMapParser
7
9
  from .config.shared import CameraShared, CameraSharedManager
8
- from .config.colors import ColorsManagment
9
- from .config.drawable import Drawable
10
10
  from .config.types import (
11
- SnapshotStore,
12
- UserLanguageStore,
13
- RoomStore,
11
+ CameraModes,
14
12
  RoomsProperties,
13
+ RoomStore,
14
+ SnapshotStore,
15
15
  TrimCropData,
16
- CameraModes,
16
+ UserLanguageStore,
17
17
  )
18
+ from .hypfer_handler import HypferMapImageHandler
19
+ from .rand25_handler import ReImageHandler
20
+ from .rooms_handler import RoomsHandler, RandRoomsHandler
21
+
18
22
 
19
23
  __all__ = [
24
+ "RoomsHandler",
25
+ "RandRoomsHandler",
20
26
  "HypferMapImageHandler",
21
27
  "ReImageHandler",
22
28
  "RRMapParser",
23
29
  "CameraShared",
24
30
  "CameraSharedManager",
25
- "ColorsManagment",
31
+ "ColorsManagement",
26
32
  "Drawable",
33
+ "DrawableElement",
34
+ "DrawingConfig",
35
+ "EnhancedDrawable",
27
36
  "SnapshotStore",
28
37
  "UserLanguageStore",
29
- "UserLanguageStore",
30
- "SnapshotStore",
31
38
  "RoomStore",
32
39
  "RoomsProperties",
33
40
  "TrimCropData",
@@ -8,7 +8,9 @@ import logging
8
8
  import numpy as np
9
9
  from numpy import rot90
10
10
 
11
- from .types import Color, NumpyArray, TrimCropData
11
+ from .types import Color, NumpyArray, TrimCropData, TrimsData
12
+ from .utils import BaseHandler
13
+
12
14
 
13
15
  _LOGGER = logging.getLogger(__name__)
14
16
 
@@ -24,20 +26,39 @@ class TrimError(Exception):
24
26
  class AutoCrop:
25
27
  """Auto Crop Class for trimming and zooming images."""
26
28
 
27
- def __init__(self, image_handler):
28
- self.imh = image_handler
29
- self.file_name = self.imh.file_name
30
- # self.path_to_data = self.hass.config.path(
31
- # STORAGE_DIR, CAMERA_STORAGE, f"auto_crop_{self.file_name}.json"
32
- # )
29
+ def __init__(self, handler: BaseHandler):
30
+ self.auto_crop = None # auto crop data to be calculate once.
31
+ self.crop_area = None
32
+ self.handler = handler
33
+ trim_data = self.handler.shared.trims.to_dict() # trims data
34
+ self.trim_up = trim_data.get("trim_up", 0) # trim up
35
+ self.trim_down = trim_data.get("trim_down", 0) # trim down
36
+ self.trim_left = trim_data.get("trim_left", 0) # trim left
37
+ self.trim_right = trim_data.get("trim_right", 0) # trim right
38
+ self.offset_top = self.handler.shared.offset_top # offset top
39
+ self.offset_bottom = self.handler.shared.offset_down # offset bottom
40
+ self.offset_left = self.handler.shared.offset_left # offset left
41
+ self.offset_right = self.handler.shared.offset_right # offset right
42
+
43
+ @staticmethod
44
+ def validate_crop_dimensions(shared):
45
+ """Ensure width and height are valid before processing cropping."""
46
+ if shared.image_ref_width <= 0 or shared.image_ref_height <= 0:
47
+ _LOGGER.warning(
48
+ "Auto-crop failed: Invalid dimensions (width=%s, height=%s). Using original image.",
49
+ shared.image_ref_width,
50
+ shared.image_ref_height,
51
+ )
52
+ return False
53
+ return True
33
54
 
34
55
  def check_trim(
35
56
  self, trimmed_height, trimmed_width, margin_size, image_array, file_name, rotate
36
57
  ):
37
58
  """Check if the trim is okay."""
38
59
  if trimmed_height <= margin_size or trimmed_width <= margin_size:
39
- self.imh.crop_area = [0, 0, image_array.shape[1], image_array.shape[0]]
40
- self.imh.img_size = (image_array.shape[1], image_array.shape[0])
60
+ self.crop_area = [0, 0, image_array.shape[1], image_array.shape[0]]
61
+ self.handler.img_size = (image_array.shape[1], image_array.shape[0])
41
62
  raise TrimError(
42
63
  f"{file_name}: Trimming failed at rotation {rotate}.", image_array
43
64
  )
@@ -45,89 +66,106 @@ class AutoCrop:
45
66
  def _calculate_trimmed_dimensions(self):
46
67
  """Calculate and update the dimensions after trimming."""
47
68
  trimmed_width = max(
48
- 0,
49
- (
50
- (self.imh.trim_right - self.imh.offset_right)
51
- - (self.imh.trim_left + self.imh.offset_left)
52
- ),
69
+ 1, # Ensure at least 1px
70
+ (self.trim_right - self.offset_right) - (self.trim_left + self.offset_left),
53
71
  )
54
72
  trimmed_height = max(
55
- 0,
56
- (
57
- (self.imh.trim_down - self.imh.offset_bottom)
58
- - (self.imh.trim_up + self.imh.offset_top)
59
- ),
73
+ 1, # Ensure at least 1px
74
+ (self.trim_down - self.offset_bottom) - (self.trim_up + self.offset_top),
60
75
  )
76
+
61
77
  # Ensure shared reference dimensions are updated
62
- if hasattr(self.imh.shared, "image_ref_height") and hasattr(
63
- self.imh.shared, "image_ref_width"
78
+ if hasattr(self.handler.shared, "image_ref_height") and hasattr(
79
+ self.handler.shared, "image_ref_width"
64
80
  ):
65
- self.imh.shared.image_ref_height = trimmed_height
66
- self.imh.shared.image_ref_width = trimmed_width
81
+ self.handler.shared.image_ref_height = trimmed_height
82
+ self.handler.shared.image_ref_width = trimmed_width
67
83
  else:
68
84
  _LOGGER.warning(
69
85
  "Shared attributes for image dimensions are not initialized."
70
86
  )
87
+
71
88
  return trimmed_width, trimmed_height
72
89
 
73
- async def _async_auto_crop_data(self, tdata=None):
90
+ async def _async_auto_crop_data(self, tdata: TrimsData): # , tdata=None
74
91
  """Load the auto crop data from the Camera config."""
75
- # todo: implement this method but from config data
76
- # if not self.imh.auto_crop:
77
- # trims_data = TrimCropData.from_dict(dict(tdata)).to_list()
78
- # (
79
- # self.imh.trim_left,
80
- # self.imh.trim_up,
81
- # self.imh.trim_right,
82
- # self.imh.trim_down,
83
- # ) = trims_data
84
- # self._calculate_trimmed_dimensions()
85
- # return trims_data
92
+ _LOGGER.debug("Auto Crop data: %s, %s", str(tdata), str(self.auto_crop))
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
+ _LOGGER.debug("Auto Crop trims data: %s", trims_data)
102
+ if trims_data != [0, 0, 0, 0]:
103
+ self._calculate_trimmed_dimensions()
104
+ else:
105
+ trims_data = None
106
+ return trims_data
86
107
  return None
87
108
 
88
109
  def auto_crop_offset(self):
89
110
  """Calculate the offset for the auto crop."""
90
- if self.imh.auto_crop:
91
- self.imh.auto_crop[0] += self.imh.offset_left
92
- self.imh.auto_crop[1] += self.imh.offset_top
93
- self.imh.auto_crop[2] -= self.imh.offset_right
94
- self.imh.auto_crop[3] -= self.imh.offset_bottom
111
+ if self.auto_crop:
112
+ self.auto_crop[0] += self.offset_left
113
+ self.auto_crop[1] += self.offset_top
114
+ self.auto_crop[2] -= self.offset_right
115
+ self.auto_crop[3] -= self.offset_bottom
95
116
 
96
117
  async def _init_auto_crop(self):
97
118
  """Initialize the auto crop data."""
98
- if not self.imh.auto_crop and self.imh.shared.vacuum_state == "docked":
99
- self.imh.auto_crop = await self._async_auto_crop_data()
100
- if self.imh.auto_crop:
119
+ _LOGGER.debug("Auto Crop Init data: %s", str(self.auto_crop))
120
+ _LOGGER.debug(
121
+ "Auto Crop Init trims data: %r", self.handler.shared.trims.to_dict()
122
+ )
123
+ if not self.auto_crop: # and self.handler.shared.vacuum_state == "docked":
124
+ self.auto_crop = await self._async_auto_crop_data(self.handler.shared.trims)
125
+ if self.auto_crop:
101
126
  self.auto_crop_offset()
102
127
  else:
103
- self.imh.max_frames = 5
104
- return self.imh.auto_crop
105
-
106
- # async def _async_save_auto_crop_data(self):
107
- # """Save the auto crop data to the disk."""
108
- # try:
109
- # if not os.path.exists(self.path_to_data):
110
- # data = TrimCropData(
111
- # self.imh.trim_left,
112
- # self.imh.trim_up,
113
- # self.imh.trim_right,
114
- # self.imh.trim_down,
115
- # ).to_dict()
116
- # except Exception as e:
117
- # _LOGGER.error(f"Failed to save trim data due to an error: {e}")
128
+ self.handler.max_frames = 1205
129
+
130
+ # Fallback: Ensure auto_crop is valid
131
+ if not self.auto_crop or any(v < 0 for v in self.auto_crop):
132
+ _LOGGER.debug("Auto-crop data unavailable. Scanning full image.")
133
+ self.auto_crop = None
134
+
135
+ return self.auto_crop
118
136
 
119
137
  async def async_image_margins(
120
138
  self, image_array: NumpyArray, detect_colour: Color
121
139
  ) -> tuple[int, int, int, int]:
122
- """Crop the image based on the auto crop area."""
123
- nonzero_coords = np.column_stack(np.where(image_array != list(detect_colour)))
124
- # Calculate the trim box based on the first and last occurrences
125
- min_y, min_x, _ = NumpyArray.min(nonzero_coords, axis=0)
126
- max_y, max_x, _ = NumpyArray.max(nonzero_coords, axis=0)
127
- del nonzero_coords
140
+ """Crop the image based on the auto crop area using scipy.ndimage for better performance."""
141
+ # Import scipy.ndimage here to avoid import at module level
142
+ from scipy import ndimage
143
+
144
+ # Create a binary mask where True = non-background pixels
145
+ # This is much more memory efficient than storing coordinates
146
+ mask = ~np.all(image_array == list(detect_colour), axis=2)
147
+
148
+ # Use scipy.ndimage.find_objects to efficiently find the bounding box
149
+ # This returns a list of slice objects that define the bounding box
150
+ # Label the mask with a single label (1) and find its bounding box
151
+ labeled_mask = mask.astype(np.int8) # Convert to int8 (smallest integer type)
152
+ objects = ndimage.find_objects(labeled_mask)
153
+
154
+ if not objects: # No objects found
155
+ _LOGGER.warning(
156
+ "%s: No non-background pixels found in image", self.handler.file_name
157
+ )
158
+ # Return full image dimensions as fallback
159
+ return 0, 0, image_array.shape[1], image_array.shape[0]
160
+
161
+ # Extract the bounding box coordinates from the slice objects
162
+ y_slice, x_slice = objects[0]
163
+ min_y, max_y = y_slice.start, y_slice.stop - 1
164
+ min_x, max_x = x_slice.start, x_slice.stop - 1
165
+
128
166
  _LOGGER.debug(
129
167
  "%s: Found trims max and min values (y,x) (%s, %s) (%s, %s)...",
130
- self.file_name,
168
+ self.handler.file_name,
131
169
  int(max_y),
132
170
  int(max_x),
133
171
  int(min_y),
@@ -142,38 +180,53 @@ class AutoCrop:
142
180
  zoom: bool = False,
143
181
  rand256: bool = False,
144
182
  ) -> NumpyArray:
145
- """Check if the image need to be zoom."""
183
+ """Check if the image needs to be zoomed."""
146
184
 
147
185
  if (
148
186
  zoom
149
- and self.imh.shared.vacuum_state == "cleaning"
150
- and self.imh.shared.image_auto_zoom
187
+ and self.handler.shared.vacuum_state == "cleaning"
188
+ and self.handler.shared.image_auto_zoom
151
189
  ):
152
- # Zoom the image based on the robot's position.
153
190
  _LOGGER.debug(
154
191
  "%s: Zooming the image on room %s.",
155
- self.file_name,
156
- self.imh.robot_in_room["room"],
192
+ self.handler.file_name,
193
+ self.handler.robot_in_room["room"],
157
194
  )
195
+
158
196
  if rand256:
159
- trim_left = round(self.imh.robot_in_room["right"] / 10) - margin_size
160
- trim_right = round(self.imh.robot_in_room["left"] / 10) + margin_size
161
- trim_up = round(self.imh.robot_in_room["down"] / 10) - margin_size
162
- trim_down = round(self.imh.robot_in_room["up"] / 10) + margin_size
197
+ trim_left = (
198
+ round(self.handler.robot_in_room["right"] / 10) - margin_size
199
+ )
200
+ trim_right = (
201
+ round(self.handler.robot_in_room["left"] / 10) + margin_size
202
+ )
203
+ trim_up = round(self.handler.robot_in_room["down"] / 10) - margin_size
204
+ trim_down = round(self.handler.robot_in_room["up"] / 10) + margin_size
163
205
  else:
164
- trim_left = self.imh.robot_in_room["left"] - margin_size
165
- trim_right = self.imh.robot_in_room["right"] + margin_size
166
- trim_up = self.imh.robot_in_room["up"] - margin_size
167
- trim_down = self.imh.robot_in_room["down"] + margin_size
206
+ trim_left = self.handler.robot_in_room["left"] - margin_size
207
+ trim_right = self.handler.robot_in_room["right"] + margin_size
208
+ trim_up = self.handler.robot_in_room["up"] - margin_size
209
+ trim_down = self.handler.robot_in_room["down"] + margin_size
210
+
211
+ # Ensure valid trim values
168
212
  trim_left, trim_right = sorted([trim_left, trim_right])
169
213
  trim_up, trim_down = sorted([trim_up, trim_down])
214
+
215
+ # Prevent zero-sized images
216
+ if trim_right - trim_left < 1 or trim_down - trim_up < 1:
217
+ _LOGGER.warning(
218
+ "Zooming resulted in an invalid crop area. Using full image."
219
+ )
220
+ return image_array # Return original image
221
+
170
222
  trimmed = image_array[trim_up:trim_down, trim_left:trim_right]
223
+
171
224
  else:
172
- # Apply the auto-calculated trims to the rotated image
173
225
  trimmed = image_array[
174
- self.imh.auto_crop[1] : self.imh.auto_crop[3],
175
- self.imh.auto_crop[0] : self.imh.auto_crop[2],
226
+ self.auto_crop[1] : self.auto_crop[3],
227
+ self.auto_crop[0] : self.auto_crop[2],
176
228
  ]
229
+
177
230
  return trimmed
178
231
 
179
232
  async def async_rotate_the_image(
@@ -182,26 +235,26 @@ class AutoCrop:
182
235
  """Rotate the image and return the new array."""
183
236
  if rotate == 90:
184
237
  rotated = rot90(trimmed)
185
- self.imh.crop_area = [
186
- self.imh.trim_left,
187
- self.imh.trim_up,
188
- self.imh.trim_right,
189
- self.imh.trim_down,
238
+ self.crop_area = [
239
+ self.trim_left,
240
+ self.trim_up,
241
+ self.trim_right,
242
+ self.trim_down,
190
243
  ]
191
244
  elif rotate == 180:
192
245
  rotated = rot90(trimmed, 2)
193
- self.imh.crop_area = self.imh.auto_crop
246
+ self.crop_area = self.auto_crop
194
247
  elif rotate == 270:
195
248
  rotated = rot90(trimmed, 3)
196
- self.imh.crop_area = [
197
- self.imh.trim_left,
198
- self.imh.trim_up,
199
- self.imh.trim_right,
200
- self.imh.trim_down,
249
+ self.crop_area = [
250
+ self.trim_left,
251
+ self.trim_up,
252
+ self.trim_right,
253
+ self.trim_down,
201
254
  ]
202
255
  else:
203
256
  rotated = trimmed
204
- self.imh.crop_area = self.imh.auto_crop
257
+ self.crop_area = self.auto_crop
205
258
  return rotated
206
259
 
207
260
  async def async_auto_trim_and_zoom_image(
@@ -217,18 +270,18 @@ class AutoCrop:
217
270
  Automatically crops and trims a numpy array and returns the processed image.
218
271
  """
219
272
  try:
220
- await self._init_auto_crop()
221
- if self.imh.auto_crop is None:
222
- _LOGGER.debug("%s: Calculating auto trim box", self.file_name)
273
+ self.auto_crop = await self._init_auto_crop()
274
+ if (self.auto_crop is None) or (self.auto_crop == [0, 0, 0, 0]):
275
+ _LOGGER.debug("%s: Calculating auto trim box", self.handler.file_name)
223
276
  # Find the coordinates of the first occurrence of a non-background color
224
277
  min_y, min_x, max_x, max_y = await self.async_image_margins(
225
278
  image_array, detect_colour
226
279
  )
227
280
  # Calculate and store the trims coordinates with margins
228
- self.imh.trim_left = int(min_x) - margin_size
229
- self.imh.trim_up = int(min_y) - margin_size
230
- self.imh.trim_right = int(max_x) + margin_size
231
- self.imh.trim_down = int(max_y) + margin_size
281
+ self.trim_left = int(min_x) - margin_size
282
+ self.trim_up = int(min_y) - margin_size
283
+ self.trim_right = int(max_x) + margin_size
284
+ self.trim_down = int(max_y) + margin_size
232
285
  del min_y, min_x, max_x, max_y
233
286
 
234
287
  # Calculate the dimensions after trimming using min/max values
@@ -241,23 +294,28 @@ class AutoCrop:
241
294
  trimmed_width,
242
295
  margin_size,
243
296
  image_array,
244
- self.file_name,
297
+ self.handler.file_name,
245
298
  rotate,
246
299
  )
247
300
  except TrimError as e:
248
301
  return e.image
249
302
 
250
303
  # Store Crop area of the original image_array we will use from the next frame.
251
- self.imh.auto_crop = TrimCropData(
252
- self.imh.trim_left,
253
- self.imh.trim_up,
254
- self.imh.trim_right,
255
- self.imh.trim_down,
304
+ self.auto_crop = TrimCropData(
305
+ self.trim_left,
306
+ self.trim_up,
307
+ self.trim_right,
308
+ self.trim_down,
256
309
  ).to_list()
257
- # if self.imh.shared.vacuum_state == "docked":
258
- # await (
259
- # self._async_save_auto_crop_data()
260
- # ) # Save the crop data to the disk
310
+ # Update the trims data in the shared instance
311
+ self.handler.shared.trims = TrimsData.from_dict(
312
+ {
313
+ "trim_left": self.trim_left,
314
+ "trim_up": self.trim_up,
315
+ "trim_right": self.trim_right,
316
+ "trim_down": self.trim_down,
317
+ }
318
+ )
261
319
  self.auto_crop_offset()
262
320
  # If it is needed to zoom the image.
263
321
  trimmed = await self.async_check_if_zoom_is_on(
@@ -268,19 +326,19 @@ class AutoCrop:
268
326
  rotated = await self.async_rotate_the_image(trimmed, rotate)
269
327
  del trimmed # Free memory.
270
328
  _LOGGER.debug(
271
- "%s: Auto Trim Box data: %s", self.file_name, self.imh.crop_area
329
+ "%s: Auto Trim Box data: %s", self.handler.file_name, self.crop_area
272
330
  )
273
- self.imh.crop_img_size = [rotated.shape[1], rotated.shape[0]]
331
+ self.handler.crop_img_size = [rotated.shape[1], rotated.shape[0]]
274
332
  _LOGGER.debug(
275
333
  "%s: Auto Trimmed image size: %s",
276
- self.file_name,
277
- self.imh.crop_img_size,
334
+ self.handler.file_name,
335
+ self.handler.crop_img_size,
278
336
  )
279
337
 
280
338
  except RuntimeError as e:
281
339
  _LOGGER.warning(
282
340
  "%s: Error %s during auto trim and zoom.",
283
- self.file_name,
341
+ self.handler.file_name,
284
342
  e,
285
343
  exc_info=True,
286
344
  )
@@ -0,0 +1,105 @@
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