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