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
@@ -0,0 +1,841 @@
1
+ """Utility code for the valetudo map parser."""
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Callable, List, Optional
7
+
8
+ import numpy as np
9
+ from PIL import ImageOps
10
+
11
+ from .drawable import Drawable
12
+ from .drawable_elements import DrawableElement, DrawingConfig
13
+ from .enhanced_drawable import EnhancedDrawable
14
+ from .types import LOGGER, ChargerPosition, ImageSize, NumpyArray, PilPNG, RobotPosition
15
+
16
+
17
+ @dataclass
18
+ class ResizeParams:
19
+ """Resize the image to the given dimensions and aspect ratio."""
20
+
21
+ pil_img: PilPNG
22
+ width: int
23
+ height: int
24
+ aspect_ratio: str
25
+ crop_size: List[int]
26
+ is_rand: Optional[bool] = False
27
+ offset_func: Optional[Callable] = None
28
+
29
+
30
+ @dataclass
31
+ class OffsetParams:
32
+ """Map parameters."""
33
+
34
+ wsf: int
35
+ hsf: int
36
+ width: int
37
+ height: int
38
+ rand256: Optional[bool] = False
39
+
40
+
41
+ class BaseHandler:
42
+ """Avoid Code duplication"""
43
+
44
+ def __init__(self):
45
+ self.file_name = None
46
+ self.shared = None
47
+ self.img_size = None
48
+ self.json_data = None
49
+ self.json_id = None
50
+ self.path_pixels = None
51
+ self.robot_in_room = None
52
+ self.robot_pos = None
53
+ self.room_propriety = None
54
+ self.rooms_pos = None
55
+ self.charger_pos = None
56
+ self.frame_number = 0
57
+ self.max_frames = 1024
58
+ self.crop_img_size = [0, 0]
59
+ self.offset_x = 0
60
+ self.offset_y = 0
61
+ self.crop_area = None
62
+ self.zooming = False
63
+ self.async_resize_images = async_resize_image
64
+
65
+ def get_frame_number(self) -> int:
66
+ """Return the frame number of the image."""
67
+ return self.frame_number
68
+
69
+ def get_robot_position(self) -> RobotPosition | None:
70
+ """Return the robot position."""
71
+ return self.robot_pos
72
+
73
+ def get_charger_position(self) -> ChargerPosition | None:
74
+ """Return the charger position."""
75
+ return self.charger_pos
76
+
77
+ def get_img_size(self) -> ImageSize | None:
78
+ """Return the size of the image."""
79
+ return self.img_size
80
+
81
+ def get_json_id(self) -> str | None:
82
+ """Return the JSON ID from the image."""
83
+ return self.json_id
84
+
85
+ def check_zoom_and_aspect_ratio(self) -> bool:
86
+ """Check if the image is zoomed and has an aspect ratio."""
87
+ return (
88
+ self.shared.image_auto_zoom
89
+ and self.shared.vacuum_state == "cleaning"
90
+ and self.zooming
91
+ and self.shared.image_zoom_lock_ratio
92
+ or self.shared.image_aspect_ratio != "None"
93
+ )
94
+
95
+ def _set_image_offset_ratio_1_1(
96
+ self, width: int, height: int, rand256: Optional[bool] = False
97
+ ) -> None:
98
+ """Set the image offset ratio to 1:1."""
99
+
100
+ rotation = self.shared.image_rotate
101
+ if not rand256:
102
+ if rotation in [0, 180]:
103
+ self.offset_y = self.crop_img_size[0] - width
104
+ self.offset_x = (height - self.crop_img_size[1]) // 2
105
+ elif rotation in [90, 270]:
106
+ self.offset_y = width - self.crop_img_size[0]
107
+ self.offset_x = (self.crop_img_size[1] - height) // 2
108
+ else:
109
+ if rotation in [0, 180]:
110
+ self.offset_x = (width - self.crop_img_size[0]) // 2
111
+ self.offset_y = height - self.crop_img_size[1]
112
+ elif rotation in [90, 270]:
113
+ self.offset_y = (self.crop_img_size[0] - width) // 2
114
+ self.offset_x = self.crop_img_size[1] - height
115
+ LOGGER.debug(
116
+ "%s Image Coordinates Offsets (x,y): %s. %s",
117
+ self.file_name,
118
+ self.offset_x,
119
+ self.offset_y,
120
+ )
121
+
122
+ def _set_image_offset_ratio_2_1(
123
+ self, width: int, height: int, rand256: Optional[bool] = False
124
+ ) -> None:
125
+ """Set the image offset ratio to 2:1."""
126
+
127
+ rotation = self.shared.image_rotate
128
+ if not rand256:
129
+ if rotation in [0, 180]:
130
+ self.offset_y = width - self.crop_img_size[0]
131
+ self.offset_x = height - self.crop_img_size[1]
132
+ elif rotation in [90, 270]:
133
+ self.offset_x = width - self.crop_img_size[0]
134
+ self.offset_y = height - self.crop_img_size[1]
135
+ else:
136
+ if rotation in [0, 180]:
137
+ self.offset_y = width - self.crop_img_size[0]
138
+ self.offset_x = height - self.crop_img_size[1]
139
+ elif rotation in [90, 270]:
140
+ self.offset_x = width - self.crop_img_size[0]
141
+ self.offset_y = height - self.crop_img_size[1]
142
+
143
+ LOGGER.debug(
144
+ "%s Image Coordinates Offsets (x,y): %s. %s",
145
+ self.file_name,
146
+ self.offset_x,
147
+ self.offset_y,
148
+ )
149
+
150
+ def _set_image_offset_ratio_3_2(
151
+ self, width: int, height: int, rand256: Optional[bool] = False
152
+ ) -> None:
153
+ """Set the image offset ratio to 3:2."""
154
+
155
+ rotation = self.shared.image_rotate
156
+
157
+ if not rand256:
158
+ if rotation in [0, 180]:
159
+ self.offset_y = width - self.crop_img_size[0]
160
+ self.offset_x = ((height - self.crop_img_size[1]) // 2) - (
161
+ self.crop_img_size[1] // 10
162
+ )
163
+ elif rotation in [90, 270]:
164
+ self.offset_y = (self.crop_img_size[0] - width) // 2
165
+ self.offset_x = (self.crop_img_size[1] - height) + ((height // 10) // 2)
166
+ else:
167
+ if rotation in [0, 180]:
168
+ self.offset_x = (width - self.crop_img_size[0]) // 2
169
+ self.offset_y = height - self.crop_img_size[1]
170
+ elif rotation in [90, 270]:
171
+ self.offset_y = (self.crop_img_size[0] - width) // 2
172
+ self.offset_x = self.crop_img_size[1] - height
173
+
174
+ LOGGER.debug(
175
+ "%s Image Coordinates Offsets (x,y): %s. %s",
176
+ self.file_name,
177
+ self.offset_x,
178
+ self.offset_y,
179
+ )
180
+
181
+ def _set_image_offset_ratio_5_4(
182
+ self, width: int, height: int, rand256: Optional[bool] = False
183
+ ) -> None:
184
+ """Set the image offset ratio to 5:4."""
185
+
186
+ rotation = self.shared.image_rotate
187
+ if not rand256:
188
+ if rotation in [0, 180]:
189
+ self.offset_x = ((width - self.crop_img_size[0]) // 2) - (
190
+ self.crop_img_size[0] // 2
191
+ )
192
+ self.offset_y = (self.crop_img_size[1] - height) - (
193
+ self.crop_img_size[1] // 2
194
+ )
195
+ elif rotation in [90, 270]:
196
+ self.offset_y = ((self.crop_img_size[0] - width) // 2) - 10
197
+ self.offset_x = (self.crop_img_size[1] - height) + (height // 10)
198
+ else:
199
+ if rotation in [0, 180]:
200
+ self.offset_y = (width - self.crop_img_size[0]) // 2
201
+ self.offset_x = self.crop_img_size[1] - height
202
+ elif rotation in [90, 270]:
203
+ self.offset_y = (self.crop_img_size[0] - width) // 2
204
+ self.offset_x = self.crop_img_size[1] - height
205
+
206
+ LOGGER.debug(
207
+ "%s Image Coordinates Offsets (x,y): %s. %s",
208
+ self.file_name,
209
+ self.offset_x,
210
+ self.offset_y,
211
+ )
212
+
213
+ def _set_image_offset_ratio_9_16(
214
+ self, width: int, height: int, rand256: Optional[bool] = False
215
+ ) -> None:
216
+ """Set the image offset ratio to 9:16."""
217
+
218
+ rotation = self.shared.image_rotate
219
+ if not rand256:
220
+ if rotation in [0, 180]:
221
+ self.offset_y = width - self.crop_img_size[0]
222
+ self.offset_x = height - self.crop_img_size[1]
223
+ elif rotation in [90, 270]:
224
+ self.offset_x = (width - self.crop_img_size[0]) + (height // 10)
225
+ self.offset_y = height - self.crop_img_size[1]
226
+ else:
227
+ if rotation in [0, 180]:
228
+ self.offset_y = width - self.crop_img_size[0]
229
+ self.offset_x = height - self.crop_img_size[1]
230
+ elif rotation in [90, 270]:
231
+ self.offset_x = width - self.crop_img_size[0]
232
+ self.offset_y = height - self.crop_img_size[1]
233
+
234
+ LOGGER.debug(
235
+ "%s Image Coordinates Offsets (x,y): %s. %s",
236
+ self.file_name,
237
+ self.offset_x,
238
+ self.offset_y,
239
+ )
240
+
241
+ def _set_image_offset_ratio_16_9(
242
+ self, width: int, height: int, rand256: Optional[bool] = False
243
+ ) -> None:
244
+ """Set the image offset ratio to 16:9."""
245
+
246
+ rotation = self.shared.image_rotate
247
+ if not rand256:
248
+ if rotation in [0, 180]:
249
+ self.offset_y = width - self.crop_img_size[0]
250
+ self.offset_x = height - self.crop_img_size[1]
251
+ elif rotation in [90, 270]:
252
+ self.offset_x = width - self.crop_img_size[0]
253
+ self.offset_y = height - self.crop_img_size[1]
254
+ else:
255
+ if rotation in [0, 180]:
256
+ self.offset_y = width - self.crop_img_size[0]
257
+ self.offset_x = height - self.crop_img_size[1]
258
+ elif rotation in [90, 270]:
259
+ self.offset_x = width - self.crop_img_size[0]
260
+ self.offset_y = height - self.crop_img_size[1]
261
+
262
+ LOGGER.debug(
263
+ "%s Image Coordinates Offsets (x,y): %s. %s",
264
+ self.file_name,
265
+ self.offset_x,
266
+ self.offset_y,
267
+ )
268
+
269
+ async def async_map_coordinates_offset(
270
+ self, params: OffsetParams
271
+ ) -> tuple[int, int]:
272
+ """
273
+ Offset the coordinates to the map.
274
+ """
275
+ if params.wsf == 1 and params.hsf == 1:
276
+ self._set_image_offset_ratio_1_1(
277
+ params.width, params.height, params.rand256
278
+ )
279
+ elif params.wsf == 2 and params.hsf == 1:
280
+ self._set_image_offset_ratio_2_1(
281
+ params.width, params.height, params.rand256
282
+ )
283
+ elif params.wsf == 3 and params.hsf == 2:
284
+ self._set_image_offset_ratio_3_2(
285
+ params.width, params.height, params.rand256
286
+ )
287
+ elif params.wsf == 5 and params.hsf == 4:
288
+ self._set_image_offset_ratio_5_4(
289
+ params.width, params.height, params.rand256
290
+ )
291
+ elif params.wsf == 9 and params.hsf == 16:
292
+ self._set_image_offset_ratio_9_16(
293
+ params.width, params.height, params.rand256
294
+ )
295
+ elif params.wsf == 16 and params.hsf == 9:
296
+ self._set_image_offset_ratio_16_9(
297
+ params.width, params.height, params.rand256
298
+ )
299
+ return params.width, params.height
300
+
301
+ @staticmethod
302
+ async def calculate_array_hash(
303
+ layers: dict, active: Optional[List[int]]
304
+ ) -> str | None:
305
+ """Calculate the hash of the image based on layers and active zones."""
306
+ if layers and active:
307
+ data_to_hash = {
308
+ "layers": len(layers["wall"][0]),
309
+ "active_segments": tuple(active),
310
+ }
311
+ data_json = json.dumps(data_to_hash, sort_keys=True)
312
+ return hashlib.sha256(data_json.encode()).hexdigest()
313
+ return None
314
+
315
+ @staticmethod
316
+ async def async_copy_array(original_array: NumpyArray) -> NumpyArray:
317
+ """Copy the array."""
318
+ return NumpyArray.copy(original_array)
319
+
320
+ def get_map_points(
321
+ self,
322
+ ) -> list[dict[str, int] | dict[str, int] | dict[str, int] | dict[str, int]]:
323
+ """Return the map points."""
324
+ return [
325
+ {"x": 0, "y": 0}, # Top-left corner 0
326
+ {"x": self.crop_img_size[0], "y": 0}, # Top-right corner 1
327
+ {
328
+ "x": self.crop_img_size[0],
329
+ "y": self.crop_img_size[1],
330
+ }, # Bottom-right corner 2
331
+ {"x": 0, "y": self.crop_img_size[1]}, # Bottom-left corner (optional) 3
332
+ ]
333
+
334
+ def get_vacuum_points(self, rotation_angle: int) -> list[dict[str, int]]:
335
+ """Calculate the calibration points based on the rotation angle."""
336
+
337
+ # get_calibration_data
338
+ vacuum_points = [
339
+ {
340
+ "x": self.crop_area[0] + self.offset_x,
341
+ "y": self.crop_area[1] + self.offset_y,
342
+ }, # Top-left corner 0
343
+ {
344
+ "x": self.crop_area[2] - self.offset_x,
345
+ "y": self.crop_area[1] + self.offset_y,
346
+ }, # Top-right corner 1
347
+ {
348
+ "x": self.crop_area[2] - self.offset_x,
349
+ "y": self.crop_area[3] - self.offset_y,
350
+ }, # Bottom-right corner 2
351
+ {
352
+ "x": self.crop_area[0] + self.offset_x,
353
+ "y": self.crop_area[3] - self.offset_y,
354
+ }, # Bottom-left corner (optional)3
355
+ ]
356
+
357
+ # Rotate the vacuum points based on the rotation angle
358
+ if rotation_angle == 90:
359
+ vacuum_points = [
360
+ vacuum_points[1],
361
+ vacuum_points[2],
362
+ vacuum_points[3],
363
+ vacuum_points[0],
364
+ ]
365
+ elif rotation_angle == 180:
366
+ vacuum_points = [
367
+ vacuum_points[2],
368
+ vacuum_points[3],
369
+ vacuum_points[0],
370
+ vacuum_points[1],
371
+ ]
372
+ elif rotation_angle == 270:
373
+ vacuum_points = [
374
+ vacuum_points[3],
375
+ vacuum_points[0],
376
+ vacuum_points[1],
377
+ vacuum_points[2],
378
+ ]
379
+
380
+ return vacuum_points
381
+
382
+ def re_get_vacuum_points(self, rotation_angle: int) -> list[dict[str, int]]:
383
+ """Recalculate the calibration points based on the rotation angle.
384
+ RAND256 Vacuums Calibration Points are in 10th of a mm."""
385
+ vacuum_points = [
386
+ {
387
+ "x": ((self.crop_area[0] + self.offset_x) * 10),
388
+ "y": ((self.crop_area[1] + self.offset_y) * 10),
389
+ }, # Top-left corner 0
390
+ {
391
+ "x": ((self.crop_area[2] - self.offset_x) * 10),
392
+ "y": ((self.crop_area[1] + self.offset_y) * 10),
393
+ }, # Top-right corner 1
394
+ {
395
+ "x": ((self.crop_area[2] - self.offset_x) * 10),
396
+ "y": ((self.crop_area[3] - self.offset_y) * 10),
397
+ }, # Bottom-right corner 2
398
+ {
399
+ "x": ((self.crop_area[0] + self.offset_x) * 10),
400
+ "y": ((self.crop_area[3] - self.offset_y) * 10),
401
+ }, # Bottom-left corner (optional)3
402
+ ]
403
+
404
+ # Rotate the vacuum points based on the rotation angle
405
+ if rotation_angle == 90:
406
+ vacuum_points = [
407
+ vacuum_points[1],
408
+ vacuum_points[2],
409
+ vacuum_points[3],
410
+ vacuum_points[0],
411
+ ]
412
+ elif rotation_angle == 180:
413
+ vacuum_points = [
414
+ vacuum_points[2],
415
+ vacuum_points[3],
416
+ vacuum_points[0],
417
+ vacuum_points[1],
418
+ ]
419
+ elif rotation_angle == 270:
420
+ vacuum_points = [
421
+ vacuum_points[3],
422
+ vacuum_points[0],
423
+ vacuum_points[1],
424
+ vacuum_points[2],
425
+ ]
426
+
427
+ return vacuum_points
428
+
429
+ async def async_zone_propriety(self, zones_data) -> dict:
430
+ """Get the zone propriety"""
431
+ zone_properties = {}
432
+ id_count = 1
433
+ for zone in zones_data:
434
+ zone_name = zone.get("name")
435
+ coordinates = zone.get("coordinates")
436
+ if coordinates and len(coordinates) > 0:
437
+ coordinates[0].pop()
438
+ x1, y1, x2, y2 = coordinates[0]
439
+ zone_properties[zone_name] = {
440
+ "zones": coordinates,
441
+ "name": zone_name,
442
+ "x": ((x1 + x2) // 2),
443
+ "y": ((y1 + y2) // 2),
444
+ }
445
+ id_count += 1
446
+ if id_count > 1:
447
+ LOGGER.debug("%s: Zones Properties updated.", self.file_name)
448
+ return zone_properties
449
+
450
+ async def async_points_propriety(self, points_data) -> dict:
451
+ """Get the point propriety"""
452
+ point_properties = {}
453
+ id_count = 1
454
+ for point in points_data:
455
+ point_name = point.get("name")
456
+ coordinates = point.get("coordinates")
457
+ if coordinates and len(coordinates) > 0:
458
+ coordinates = point.get("coordinates")
459
+ x1, y1 = coordinates
460
+ point_properties[id_count] = {
461
+ "position": coordinates,
462
+ "name": point_name,
463
+ "x": x1,
464
+ "y": y1,
465
+ }
466
+ id_count += 1
467
+ if id_count > 1:
468
+ LOGGER.debug("%s: Point Properties updated.", self.file_name)
469
+ return point_properties
470
+
471
+ @staticmethod
472
+ def get_corners(
473
+ x_max: int, x_min: int, y_max: int, y_min: int
474
+ ) -> list[tuple[int, int]]:
475
+ """Return the corners of the image."""
476
+ return [
477
+ (x_min, y_min),
478
+ (x_max, y_min),
479
+ (x_max, y_max),
480
+ (x_min, y_max),
481
+ ]
482
+
483
+
484
+ async def async_resize_image(params: ResizeParams):
485
+ """Resize the image to the given dimensions and aspect ratio."""
486
+ if params.aspect_ratio:
487
+ wsf, hsf = [int(x) for x in params.aspect_ratio.split(",")]
488
+
489
+ if wsf == 0 or hsf == 0 or params.width <= 0 or params.height <= 0:
490
+ LOGGER.warning(
491
+ "Invalid aspect ratio parameters: width=%s, height=%s, wsf=%s, hsf=%s. Returning original image.",
492
+ params.width,
493
+ params.height,
494
+ wsf,
495
+ hsf,
496
+ )
497
+ return params.pil_img # Return original image if invalid
498
+ if params.width == 0:
499
+ params.width = params.pil_img.width
500
+ if params.height == 0:
501
+ params.height = params.pil_img.height
502
+ new_aspect_ratio = wsf / hsf
503
+ if params.width / params.height > new_aspect_ratio:
504
+ new_width = int(params.pil_img.height * new_aspect_ratio)
505
+ new_height = params.pil_img.height
506
+ else:
507
+ new_width = params.pil_img.width
508
+ new_height = int(params.pil_img.width / new_aspect_ratio)
509
+
510
+ LOGGER.debug("Resizing image to aspect ratio: %s, %s", wsf, hsf)
511
+ LOGGER.debug("New image size: %s x %s", new_width, new_height)
512
+
513
+ if (params.crop_size is not None) and (params.offset_func is not None):
514
+ offset = OffsetParams(wsf, hsf, new_width, new_height, params.is_rand)
515
+ params.crop_size[0], params.crop_size[1] = await params.offset_func(offset)
516
+
517
+ return ImageOps.pad(params.pil_img, (new_width, new_height))
518
+
519
+ return ImageOps.pad(params.pil_img, (params.width, params.height))
520
+
521
+
522
+ def prepare_resize_params(handler, pil_img, rand):
523
+ """Prepare resize parameters for image resizing."""
524
+ return ResizeParams(
525
+ pil_img=pil_img,
526
+ width=handler.shared.image_ref_width,
527
+ height=handler.shared.image_ref_height,
528
+ aspect_ratio=handler.shared.image_aspect_ratio,
529
+ crop_size=handler.crop_img_size,
530
+ offset_func=handler.async_map_coordinates_offset,
531
+ is_rand=rand,
532
+ )
533
+
534
+
535
+ def initialize_drawing_config(handler):
536
+ """
537
+ Initialize drawing configuration from device_info.
538
+
539
+ Args:
540
+ handler: The handler instance with shared data and file_name attributes
541
+
542
+ Returns:
543
+ Tuple of (DrawingConfig, Drawable, EnhancedDrawable)
544
+ """
545
+
546
+ # Initialize drawing configuration
547
+ drawing_config = DrawingConfig()
548
+
549
+ if (
550
+ hasattr(handler.shared, "device_info")
551
+ and handler.shared.device_info is not None
552
+ ):
553
+ drawing_config.update_from_device_info(handler.shared.device_info)
554
+
555
+ # Initialize both drawable systems for backward compatibility
556
+ draw = Drawable() # Legacy drawing utilities
557
+ enhanced_draw = EnhancedDrawable(drawing_config) # New enhanced drawing system
558
+
559
+ return drawing_config, draw, enhanced_draw
560
+
561
+
562
+ def blend_colors(base_color, overlay_color):
563
+ """
564
+ Blend two RGBA colors using alpha compositing.
565
+
566
+ Args:
567
+ base_color: Base RGBA color tuple (r, g, b, a)
568
+ overlay_color: Overlay RGBA color tuple (r, g, b, a)
569
+
570
+ Returns:
571
+ Blended RGBA color tuple (r, g, b, a)
572
+ """
573
+ r1, g1, b1, a1 = base_color
574
+ r2, g2, b2, a2 = overlay_color
575
+
576
+ # Convert alpha to 0-1 range
577
+ a1 = a1 / 255.0
578
+ a2 = a2 / 255.0
579
+
580
+ # Calculate resulting alpha
581
+ a_out = a1 + a2 * (1 - a1)
582
+
583
+ # Avoid division by zero
584
+ if a_out < 0.0001:
585
+ return [0, 0, 0, 0]
586
+
587
+ # Calculate blended RGB components
588
+ r_out = (r1 * a1 + r2 * a2 * (1 - a1)) / a_out
589
+ g_out = (g1 * a1 + g2 * a2 * (1 - a1)) / a_out
590
+ b_out = (b1 * a1 + b2 * a2 * (1 - a1)) / a_out
591
+
592
+ # Convert back to 0-255 range and return as tuple
593
+ return (
594
+ int(max(0, min(255, r_out))),
595
+ int(max(0, min(255, g_out))),
596
+ int(max(0, min(255, b_out))),
597
+ int(max(0, min(255, a_out * 255))),
598
+ )
599
+
600
+
601
+ def blend_pixel(array, x, y, color, element, element_map=None, drawing_config=None):
602
+ """
603
+ Blend a pixel color with the existing color at the specified position.
604
+ Also updates the element map if the new element has higher z-index.
605
+
606
+ Args:
607
+ array: The image array to modify
608
+ x: X coordinate
609
+ y: Y coordinate
610
+ color: RGBA color tuple to blend
611
+ element: Element code for the pixel
612
+ element_map: Optional element map to update
613
+ drawing_config: Optional drawing configuration for z-index lookup
614
+
615
+ Returns:
616
+ None
617
+ """
618
+ # Check bounds
619
+ if not (0 <= y < array.shape[0] and 0 <= x < array.shape[1]):
620
+ return
621
+
622
+ # Get current element at this position
623
+ current_element = None
624
+ if element_map is not None:
625
+ current_element = element_map[y, x]
626
+
627
+ # Get z-index values for comparison
628
+ current_z = 0
629
+ new_z = 0
630
+
631
+ if drawing_config is not None:
632
+ current_z = (
633
+ drawing_config.get_property(current_element, "z_index", 0)
634
+ if current_element
635
+ else 0
636
+ )
637
+ new_z = drawing_config.get_property(element, "z_index", 0)
638
+
639
+ # Update element map if new element has higher z-index
640
+ if element_map is not None and new_z >= current_z:
641
+ element_map[y, x] = element
642
+
643
+ # Blend colors
644
+ base_color = array[y, x]
645
+ blended_color = blend_colors(base_color, color)
646
+ array[y, x] = blended_color
647
+
648
+
649
+ def manage_drawable_elements(
650
+ handler,
651
+ action,
652
+ element_code=None,
653
+ element_codes=None,
654
+ property_name=None,
655
+ value=None,
656
+ ):
657
+ """
658
+ Manage drawable elements (enable, disable, set elements, set properties).
659
+
660
+ Args:
661
+ handler: The handler instance with drawing_config attribute
662
+ action: Action to perform ('enable', 'disable', 'set_elements', 'set_property')
663
+ element_code: Element code for enable/disable/set_property actions
664
+ element_codes: List of element codes for set_elements action
665
+ property_name: Property name for set_property action
666
+ value: Property value for set_property action
667
+
668
+ Returns:
669
+ None
670
+ """
671
+ if not hasattr(handler, "drawing_config") or handler.drawing_config is None:
672
+ return
673
+
674
+ if action == "enable" and element_code is not None:
675
+ handler.drawing_config.enable_element(element_code)
676
+ elif action == "disable" and element_code is not None:
677
+ handler.drawing_config.disable_element(element_code)
678
+ elif action == "set_elements" and element_codes is not None:
679
+ handler.drawing_config.set_elements(element_codes)
680
+ elif (
681
+ action == "set_property"
682
+ and element_code is not None
683
+ and property_name is not None
684
+ ):
685
+ handler.drawing_config.set_property(element_code, property_name, value)
686
+
687
+
688
+ def handle_room_outline_error(file_name, room_id, error):
689
+ """
690
+ Handle errors during room outline extraction.
691
+
692
+ Args:
693
+ file_name: Name of the file for logging
694
+ room_id: Room ID for logging
695
+ error: The error that occurred
696
+
697
+ Returns:
698
+ None
699
+ """
700
+
701
+ LOGGER.warning(
702
+ "%s: Failed to trace outline for room %s: %s",
703
+ file_name,
704
+ str(room_id),
705
+ str(error),
706
+ )
707
+
708
+
709
+ async def async_extract_room_outline(
710
+ room_mask, min_x, min_y, max_x, max_y, file_name, room_id_int
711
+ ):
712
+ """
713
+ Extract the outline of a room from a binary mask.
714
+
715
+ Args:
716
+ room_mask: Binary mask where room pixels are 1 and non-room pixels are 0
717
+ min_x: Minimum x coordinate of the room
718
+ min_y: Minimum y coordinate of the room
719
+ max_x: Maximum x coordinate of the room
720
+ max_y: Maximum y coordinate of the room
721
+ file_name: Name of the file for logging
722
+ room_id_int: Room ID for logging
723
+ Returns:
724
+ List of (x, y) points forming the room outline
725
+ """
726
+
727
+ # Get the dimensions of the mask
728
+ height, width = room_mask.shape
729
+
730
+ # Find the coordinates of all room pixels
731
+ room_y, room_x = np.where(room_mask > 0)
732
+ if len(room_y) == 0:
733
+ return [(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)]
734
+
735
+ # Get the bounding box of the room
736
+ min_y, max_y = np.min(room_y), np.max(room_y)
737
+ min_x, max_x = np.min(room_x), np.max(room_x)
738
+
739
+ # For simple rooms, just use the rectangular outline
740
+ rect_outline = [
741
+ (min_x, min_y), # Top-left
742
+ (max_x, min_y), # Top-right
743
+ (max_x, max_y), # Bottom-right
744
+ (min_x, max_y), # Bottom-left
745
+ ]
746
+
747
+ # For more complex room shapes, trace the boundary
748
+ # This is a custom boundary tracing algorithm that works without OpenCV
749
+ try:
750
+ # Create a padded mask to handle edge cases
751
+ padded_mask = np.zeros((height + 2, width + 2), dtype=np.uint8)
752
+ padded_mask[1:-1, 1:-1] = room_mask
753
+
754
+ # Find boundary pixels (pixels that have at least one non-room neighbor)
755
+ boundary_points = []
756
+
757
+ # More efficient boundary detection - only check pixels that are part of the room
758
+ for y, x in zip(room_y, room_x):
759
+ # Check if this is a boundary pixel (at least one neighbor is 0)
760
+ is_boundary = False
761
+ for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
762
+ ny, nx = y + dy, x + dx
763
+ if (
764
+ ny < 0
765
+ or ny >= height
766
+ or nx < 0
767
+ or nx >= width
768
+ or room_mask[ny, nx] == 0
769
+ ):
770
+ is_boundary = True
771
+ break
772
+ if is_boundary:
773
+ boundary_points.append((x, y))
774
+
775
+ # Log the number of boundary points found
776
+ LOGGER.debug(
777
+ "%s: Room %s has %d boundary points",
778
+ file_name,
779
+ str(room_id_int),
780
+ len(boundary_points),
781
+ )
782
+
783
+ # If we found too few boundary points, use the rectangular outline
784
+ if len(boundary_points) < 8: # Need at least 8 points for a meaningful shape
785
+ LOGGER.debug(
786
+ "%s: Room %s has too few boundary points (%d), using rectangular outline",
787
+ file_name,
788
+ str(room_id_int),
789
+ len(boundary_points),
790
+ )
791
+ return rect_outline
792
+
793
+ # Use a more sophisticated algorithm to create a coherent outline
794
+ # We'll use a convex hull approach to get the main shape
795
+ # Sort points by angle from centroid
796
+ centroid_x = np.mean([p[0] for p in boundary_points])
797
+ centroid_y = np.mean([p[1] for p in boundary_points])
798
+
799
+ # Calculate angles from centroid
800
+ def calculate_angle(point):
801
+ return np.arctan2(point[1] - int(centroid_y), point[0] - int(centroid_x))
802
+
803
+ # Sort boundary points by angle
804
+ boundary_points.sort(key=calculate_angle)
805
+
806
+ # Simplify the outline if it has too many points
807
+ if len(boundary_points) > 20:
808
+ # Take every Nth point to simplify
809
+ step = len(boundary_points) // 20
810
+ simplified_outline = [
811
+ boundary_points[i] for i in range(0, len(boundary_points), step)
812
+ ]
813
+ # Make sure we have at least 8 points
814
+ if len(simplified_outline) < 8:
815
+ simplified_outline = boundary_points[:: len(boundary_points) // 8]
816
+ else:
817
+ simplified_outline = boundary_points
818
+
819
+ # Make sure to close the loop
820
+ if simplified_outline[0] != simplified_outline[-1]:
821
+ simplified_outline.append(simplified_outline[0])
822
+
823
+ # Convert NumPy int64 values to regular Python integers
824
+ simplified_outline = [(int(x), int(y)) for x, y in simplified_outline]
825
+
826
+ LOGGER.debug(
827
+ "%s: Room %s outline has %d points",
828
+ file_name,
829
+ str(room_id_int),
830
+ len(simplified_outline),
831
+ )
832
+
833
+ return simplified_outline
834
+
835
+ except (ValueError, IndexError, TypeError, ArithmeticError) as e:
836
+ LOGGER.warning(
837
+ "%s: Error tracing room outline: %s. Using rectangular outline instead.",
838
+ file_name,
839
+ str(e),
840
+ )
841
+ return rect_outline