valetudo-map-parser 0.1.9b100__py3-none-any.whl → 0.1.10__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 (33) hide show
  1. valetudo_map_parser/__init__.py +24 -8
  2. valetudo_map_parser/config/auto_crop.py +2 -27
  3. valetudo_map_parser/config/color_utils.py +3 -4
  4. valetudo_map_parser/config/colors.py +2 -2
  5. valetudo_map_parser/config/drawable.py +102 -153
  6. valetudo_map_parser/config/drawable_elements.py +0 -2
  7. valetudo_map_parser/config/fonts/FiraSans.ttf +0 -0
  8. valetudo_map_parser/config/fonts/Inter-VF.ttf +0 -0
  9. valetudo_map_parser/config/fonts/Lato-Regular.ttf +0 -0
  10. valetudo_map_parser/config/fonts/MPLUSRegular.ttf +0 -0
  11. valetudo_map_parser/config/fonts/NotoKufiArabic-VF.ttf +0 -0
  12. valetudo_map_parser/config/fonts/NotoSansCJKhk-VF.ttf +0 -0
  13. valetudo_map_parser/config/fonts/NotoSansKhojki.ttf +0 -0
  14. valetudo_map_parser/config/rand256_parser.py +169 -44
  15. valetudo_map_parser/config/shared.py +103 -101
  16. valetudo_map_parser/config/status_text/status_text.py +96 -0
  17. valetudo_map_parser/config/status_text/translations.py +280 -0
  18. valetudo_map_parser/config/types.py +42 -13
  19. valetudo_map_parser/config/utils.py +221 -181
  20. valetudo_map_parser/hypfer_draw.py +6 -169
  21. valetudo_map_parser/hypfer_handler.py +40 -130
  22. valetudo_map_parser/map_data.py +403 -84
  23. valetudo_map_parser/rand256_handler.py +53 -197
  24. valetudo_map_parser/reimg_draw.py +14 -24
  25. valetudo_map_parser/rooms_handler.py +3 -18
  26. {valetudo_map_parser-0.1.9b100.dist-info → valetudo_map_parser-0.1.10.dist-info}/METADATA +7 -4
  27. valetudo_map_parser-0.1.10.dist-info/RECORD +34 -0
  28. {valetudo_map_parser-0.1.9b100.dist-info → valetudo_map_parser-0.1.10.dist-info}/WHEEL +1 -1
  29. valetudo_map_parser/config/enhanced_drawable.py +0 -324
  30. valetudo_map_parser/hypfer_rooms_handler.py +0 -599
  31. valetudo_map_parser-0.1.9b100.dist-info/RECORD +0 -27
  32. {valetudo_map_parser-0.1.9b100.dist-info → valetudo_map_parser-0.1.10.dist-info/licenses}/LICENSE +0 -0
  33. {valetudo_map_parser-0.1.9b100.dist-info → valetudo_map_parser-0.1.10.dist-info/licenses}/NOTICE.txt +0 -0
@@ -1,27 +1,29 @@
1
1
  """Utility code for the valetudo map parser."""
2
2
 
3
3
  import datetime
4
- from time import time
5
4
  import hashlib
5
+ import io
6
6
  import json
7
7
  from dataclasses import dataclass
8
- from typing import Callable, List, Optional
9
- import io
8
+ from time import time
9
+ from typing import Callable, List, Optional, Tuple
10
10
 
11
11
  import numpy as np
12
12
  from PIL import Image, ImageOps
13
13
 
14
+ from ..map_data import HyperMapData
15
+ from .async_utils import AsyncNumPy
14
16
  from .drawable import Drawable
15
17
  from .drawable_elements import DrawingConfig
16
- from .enhanced_drawable import EnhancedDrawable
18
+ from .status_text.status_text import StatusText
17
19
  from .types import (
18
20
  LOGGER,
19
21
  ChargerPosition,
20
- ImageSize,
22
+ Destinations,
21
23
  NumpyArray,
22
24
  PilPNG,
23
25
  RobotPosition,
24
- WebPBytes,
26
+ Size,
25
27
  )
26
28
 
27
29
 
@@ -69,24 +71,27 @@ class BaseHandler:
69
71
  self.crop_img_size = [0, 0]
70
72
  self.offset_x = 0
71
73
  self.offset_y = 0
72
- self.crop_area = None
74
+ self.crop_area = [0, 0, 0, 0]
73
75
  self.zooming = False
74
76
  self.async_resize_images = async_resize_image
77
+ # Drawing components are initialized by initialize_drawing_config in handlers
78
+ self.drawing_config: Optional[DrawingConfig] = None
79
+ self.draw: Optional[Drawable] = None
75
80
 
76
81
  def get_frame_number(self) -> int:
77
82
  """Return the frame number of the image."""
78
83
  return self.frame_number
79
84
 
80
- def get_robot_position(self) -> RobotPosition | None:
85
+ def get_robot_position(self) -> RobotPosition:
81
86
  """Return the robot position."""
82
87
  return self.robot_pos
83
88
 
84
89
  async def async_get_image(
85
90
  self,
86
91
  m_json: dict | None,
87
- destinations: list | None = None,
92
+ destinations: Destinations | None = None,
88
93
  bytes_format: bool = False,
89
- ) -> PilPNG | None:
94
+ ) -> Tuple[PilPNG | bytes, dict]:
90
95
  """
91
96
  Unified async function to get PIL image from JSON data for both Hypfer and Rand256 handlers.
92
97
 
@@ -99,7 +104,9 @@ class BaseHandler:
99
104
  @param m_json: The JSON data to use to draw the image
100
105
  @param destinations: MQTT destinations for labels (used by Rand256)
101
106
  @param bytes_format: If True, also convert to PNG bytes and store in shared.binary_image
102
- @return: PIL Image or None
107
+ @param text_enabled: If True, draw text on the image
108
+ @param vacuum_status: Vacuum status to display on the image
109
+ @return: PIL Image or None and data dictionary
103
110
  """
104
111
  try:
105
112
  # Backup current image to last_image before processing new one
@@ -112,13 +119,13 @@ class BaseHandler:
112
119
  new_image = await self.get_image_from_rrm(
113
120
  m_json=m_json,
114
121
  destinations=destinations,
115
- return_webp=False, # Always return PIL Image
116
122
  )
123
+
117
124
  elif hasattr(self, "async_get_image_from_json"):
118
125
  # This is a Hypfer handler
126
+ self.json_data = await HyperMapData.async_from_valetudo_json(m_json)
119
127
  new_image = await self.async_get_image_from_json(
120
128
  m_json=m_json,
121
- return_webp=False, # Always return PIL Image
122
129
  )
123
130
  else:
124
131
  LOGGER.warning(
@@ -133,36 +140,47 @@ class BaseHandler:
133
140
 
134
141
  # Store the new image in shared data
135
142
  if new_image is not None:
143
+ # Update shared data
144
+ await self._async_update_shared_data(destinations)
136
145
  self.shared.new_image = new_image
137
-
146
+ # Add text to the image
147
+ if self.shared.show_vacuum_state:
148
+ text_editor = StatusText(self.shared)
149
+ img_text = await text_editor.get_status_text(new_image)
150
+ Drawable.status_text(
151
+ new_image,
152
+ img_text[1],
153
+ self.shared.user_colors[8],
154
+ img_text[0],
155
+ self.shared.vacuum_status_font,
156
+ self.shared.vacuum_status_position,
157
+ )
138
158
  # Convert to binary (PNG bytes) if requested
139
159
  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
- )
160
+ self.shared.binary_image = pil_to_png_bytes(new_image)
146
161
  else:
147
- self.shared.binary_image = None
162
+ self.shared.binary_image = pil_to_png_bytes(self.shared.last_image)
148
163
  # Update the timestamp with current datetime
149
164
  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
165
+ LOGGER.debug("%s: Frame Completed.", self.file_name)
166
+ data = {}
167
+ if bytes_format:
168
+ data = self.shared.to_dict()
169
+ return new_image, data
154
170
  else:
155
171
  LOGGER.warning(
156
172
  "%s: Failed to generate image from JSON data", self.file_name
157
173
  )
174
+ if bytes_format and hasattr(self.shared, "last_image"):
175
+ return pil_to_png_bytes(self.shared.last_image), {}
158
176
  return (
159
177
  self.shared.last_image
160
178
  if hasattr(self.shared, "last_image")
161
179
  else None
162
- )
180
+ ), {}
163
181
 
164
182
  except Exception as e:
165
- LOGGER.error(
183
+ LOGGER.warning(
166
184
  "%s: Error in async_get_image: %s",
167
185
  self.file_name,
168
186
  str(e),
@@ -172,11 +190,71 @@ class BaseHandler:
172
190
  self.shared.last_image if hasattr(self.shared, "last_image") else None
173
191
  )
174
192
 
193
+ async def _async_update_shared_data(self, destinations: Destinations | None = None):
194
+ """Update the shared data with the latest information."""
195
+
196
+ if hasattr(self, "get_rooms_attributes") and (
197
+ self.shared.map_rooms is None and destinations is not None
198
+ ):
199
+ self.shared.map_rooms = await self.get_rooms_attributes(destinations)
200
+ if self.shared.map_rooms:
201
+ LOGGER.debug("%s: Rand256 attributes rooms updated", self.file_name)
202
+
203
+
204
+ if hasattr(self, "async_get_rooms_attributes") and (
205
+ self.shared.map_rooms is None
206
+ ):
207
+ if self.shared.map_rooms is None:
208
+ self.shared.map_rooms = await self.async_get_rooms_attributes()
209
+ if self.shared.map_rooms:
210
+ LOGGER.debug("%s: Hyper attributes rooms updated", self.file_name)
211
+
212
+ if (
213
+ hasattr(self, "get_calibration_data")
214
+ and self.shared.attr_calibration_points is None
215
+ ):
216
+ self.shared.attr_calibration_points = self.get_calibration_data(
217
+ self.shared.image_rotate
218
+ )
219
+
220
+ if not self.shared.image_size:
221
+ self.shared.image_size = self.get_img_size()
222
+
223
+ self.shared.vac_json_id = self.get_json_id()
224
+
225
+ if not self.shared.charger_position:
226
+ self.shared.charger_position = self.get_charger_position()
227
+
228
+ self.shared.current_room = self.get_robot_position()
229
+
230
+ def prepare_resize_params(
231
+ self, pil_img: PilPNG, rand: bool = False
232
+ ) -> ResizeParams:
233
+ """Prepare resize parameters for image resizing."""
234
+ if self.shared.image_rotate in [0, 180]:
235
+ width, height = pil_img.size
236
+ else:
237
+ height, width = pil_img.size
238
+ LOGGER.debug(
239
+ "Shared PIL image size: %s x %s",
240
+ self.shared.image_ref_width,
241
+ self.shared.image_ref_height,
242
+ )
243
+ return ResizeParams(
244
+ pil_img=pil_img,
245
+ width=width,
246
+ height=height,
247
+ aspect_ratio=self.shared.image_aspect_ratio,
248
+ crop_size=self.crop_img_size,
249
+ offset_func=self.async_map_coordinates_offset,
250
+ is_rand=rand,
251
+ )
252
+
175
253
  def get_charger_position(self) -> ChargerPosition | None:
176
254
  """Return the charger position."""
177
255
  return self.charger_pos
178
256
 
179
- def get_img_size(self) -> ImageSize | None:
257
+ def get_img_size(self) -> Size | None:
180
258
  """Return the size of the image."""
181
259
  return self.img_size
182
260
 
@@ -194,6 +272,30 @@ class BaseHandler:
194
272
  or self.shared.image_aspect_ratio != "None"
195
273
  )
196
274
 
275
+ # Element selection methods centralized here
276
+ def enable_element(self, element_code):
277
+ """Enable drawing of a specific element."""
278
+ if hasattr(self, "drawing_config") and self.drawing_config is not None:
279
+ self.drawing_config.enable_element(element_code)
280
+
281
+ def disable_element(self, element_code):
282
+ """Disable drawing of a specific element."""
283
+ manage_drawable_elements(self, "disable", element_code=element_code)
284
+
285
+ def set_elements(self, element_codes: list):
286
+ """Enable only the specified elements, disable all others."""
287
+ manage_drawable_elements(self, "set_elements", element_codes=element_codes)
288
+
289
+ def set_element_property(self, element_code, property_name: str, value):
290
+ """Set a drawing property for an element."""
291
+ manage_drawable_elements(
292
+ self,
293
+ "set_property",
294
+ element_code=element_code,
295
+ property_name=property_name,
296
+ value=value,
297
+ )
298
+
197
299
  def _set_image_offset_ratio_1_1(
198
300
  self, width: int, height: int, rand256: Optional[bool] = False
199
301
  ) -> None:
@@ -214,12 +316,6 @@ class BaseHandler:
214
316
  elif rotation in [90, 270]:
215
317
  self.offset_y = (self.crop_img_size[0] - width) // 2
216
318
  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
319
 
224
320
  def _set_image_offset_ratio_2_1(
225
321
  self, width: int, height: int, rand256: Optional[bool] = False
@@ -242,13 +338,6 @@ class BaseHandler:
242
338
  self.offset_x = width - self.crop_img_size[0]
243
339
  self.offset_y = height - self.crop_img_size[1]
244
340
 
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
341
  def _set_image_offset_ratio_3_2(
253
342
  self, width: int, height: int, rand256: Optional[bool] = False
254
343
  ) -> None:
@@ -273,13 +362,6 @@ class BaseHandler:
273
362
  self.offset_y = (self.crop_img_size[0] - width) // 2
274
363
  self.offset_x = self.crop_img_size[1] - height
275
364
 
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
365
  def _set_image_offset_ratio_5_4(
284
366
  self, width: int, height: int, rand256: Optional[bool] = False
285
367
  ) -> None:
@@ -305,13 +387,6 @@ class BaseHandler:
305
387
  self.offset_y = (self.crop_img_size[0] - width) // 2
306
388
  self.offset_x = self.crop_img_size[1] - height
307
389
 
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
390
  def _set_image_offset_ratio_9_16(
316
391
  self, width: int, height: int, rand256: Optional[bool] = False
317
392
  ) -> None:
@@ -333,13 +408,6 @@ class BaseHandler:
333
408
  self.offset_x = width - self.crop_img_size[0]
334
409
  self.offset_y = height - self.crop_img_size[1]
335
410
 
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
411
  def _set_image_offset_ratio_16_9(
344
412
  self, width: int, height: int, rand256: Optional[bool] = False
345
413
  ) -> None:
@@ -361,13 +429,6 @@ class BaseHandler:
361
429
  self.offset_x = width - self.crop_img_size[0]
362
430
  self.offset_y = height - self.crop_img_size[1]
363
431
 
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
432
  async def async_map_coordinates_offset(
372
433
  self, params: OffsetParams
373
434
  ) -> tuple[int, int]:
@@ -414,15 +475,21 @@ class BaseHandler:
414
475
  return hashlib.sha256(data_json.encode()).hexdigest()
415
476
  return None
416
477
 
417
- @staticmethod
418
- async def async_copy_array(original_array: NumpyArray) -> NumpyArray:
419
- """Copy the array."""
420
- return NumpyArray.copy(original_array)
478
+ async def async_copy_array(self, original_array: NumpyArray) -> NumpyArray:
479
+ """Copy the array using AsyncNumPy to yield control to the event loop."""
480
+ return await AsyncNumPy.async_copy(original_array)
421
481
 
422
482
  def get_map_points(
423
483
  self,
424
484
  ) -> list[dict[str, int] | dict[str, int] | dict[str, int] | dict[str, int]]:
425
485
  """Return the map points."""
486
+ if not self.crop_img_size:
487
+ return [
488
+ {"x": 0, "y": 0},
489
+ {"x": 0, "y": 0},
490
+ {"x": 0, "y": 0},
491
+ {"x": 0, "y": 0},
492
+ ]
426
493
  return [
427
494
  {"x": 0, "y": 0}, # Top-left corner 0
428
495
  {"x": self.crop_img_size[0], "y": 0}, # Top-right corner 1
@@ -435,7 +502,13 @@ class BaseHandler:
435
502
 
436
503
  def get_vacuum_points(self, rotation_angle: int) -> list[dict[str, int]]:
437
504
  """Calculate the calibration points based on the rotation angle."""
438
-
505
+ if not self.crop_area:
506
+ return [
507
+ {"x": 0, "y": 0},
508
+ {"x": 0, "y": 0},
509
+ {"x": 0, "y": 0},
510
+ {"x": 0, "y": 0},
511
+ ]
439
512
  # get_calibration_data
440
513
  vacuum_points = [
441
514
  {
@@ -528,7 +601,8 @@ class BaseHandler:
528
601
 
529
602
  return vacuum_points
530
603
 
531
- async def async_zone_propriety(self, zones_data) -> dict:
604
+ @staticmethod
605
+ async def async_zone_propriety(zones_data) -> dict:
532
606
  """Get the zone propriety"""
533
607
  zone_properties = {}
534
608
  id_count = 1
@@ -546,10 +620,11 @@ class BaseHandler:
546
620
  }
547
621
  id_count += 1
548
622
  if id_count > 1:
549
- LOGGER.debug("%s: Zones Properties updated.", self.file_name)
623
+ pass
550
624
  return zone_properties
551
625
 
552
- async def async_points_propriety(self, points_data) -> dict:
626
+ @staticmethod
627
+ async def async_points_propriety(points_data) -> dict:
553
628
  """Get the point propriety"""
554
629
  point_properties = {}
555
630
  id_count = 1
@@ -567,7 +642,7 @@ class BaseHandler:
567
642
  }
568
643
  id_count += 1
569
644
  if id_count > 1:
570
- LOGGER.debug("%s: Point Properties updated.", self.file_name)
645
+ pass
571
646
  return point_properties
572
647
 
573
648
  @staticmethod
@@ -585,8 +660,14 @@ class BaseHandler:
585
660
 
586
661
  async def async_resize_image(params: ResizeParams):
587
662
  """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(",")]
663
+ LOGGER.debug("Resizing image to aspect ratio: %s", params.aspect_ratio)
664
+ LOGGER.debug("Original image size: %s x %s", params.width, params.height)
665
+ LOGGER.debug("Image crop size: %s", params.crop_size)
666
+ if params.aspect_ratio == "None":
667
+ return params.pil_img
668
+ if params.aspect_ratio != "None":
669
+ ratio = params.aspect_ratio.replace(",", ":").replace(" ", "")
670
+ wsf, hsf = [int(x) for x in ratio.split(":")]
590
671
 
591
672
  if wsf == 0 or hsf == 0 or params.width <= 0 or params.height <= 0:
592
673
  LOGGER.warning(
@@ -609,29 +690,13 @@ async def async_resize_image(params: ResizeParams):
609
690
  new_width = params.pil_img.width
610
691
  new_height = int(params.pil_img.width / new_aspect_ratio)
611
692
 
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
693
  if (params.crop_size is not None) and (params.offset_func is not None):
616
694
  offset = OffsetParams(wsf, hsf, new_width, new_height, params.is_rand)
617
695
  params.crop_size[0], params.crop_size[1] = await params.offset_func(offset)
618
-
696
+ LOGGER.debug("New image size: %r * %r", new_width, new_height)
619
697
  return ImageOps.pad(params.pil_img, (new_width, new_height))
620
698
 
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
- )
699
+ return params.pil_img
635
700
 
636
701
 
637
702
  def initialize_drawing_config(handler):
@@ -642,7 +707,7 @@ def initialize_drawing_config(handler):
642
707
  handler: The handler instance with shared data and file_name attributes
643
708
 
644
709
  Returns:
645
- Tuple of (DrawingConfig, Drawable, EnhancedDrawable)
710
+ Tuple of (DrawingConfig, Drawable)
646
711
  """
647
712
 
648
713
  # Initialize drawing configuration
@@ -654,11 +719,10 @@ def initialize_drawing_config(handler):
654
719
  ):
655
720
  drawing_config.update_from_device_info(handler.shared.device_info)
656
721
 
657
- # Initialize both drawable systems for backward compatibility
658
- draw = Drawable() # Legacy drawing utilities
659
- enhanced_draw = EnhancedDrawable(drawing_config) # New enhanced drawing system
722
+ # Initialize drawing utilities
723
+ draw = Drawable()
660
724
 
661
- return drawing_config, draw, enhanced_draw
725
+ return drawing_config, draw
662
726
 
663
727
 
664
728
  def blend_colors(base_color, overlay_color):
@@ -787,6 +851,51 @@ def manage_drawable_elements(
787
851
  handler.drawing_config.set_property(element_code, property_name, value)
788
852
 
789
853
 
854
+ def point_in_polygon(x: int, y: int, polygon: list) -> bool:
855
+ """
856
+ Check if a point is inside a polygon using ray casting algorithm.
857
+ Enhanced version with better handling of edge cases.
858
+
859
+ Args:
860
+ x: X coordinate of the point
861
+ y: Y coordinate of the point
862
+ polygon: List of (x, y) tuples forming the polygon
863
+
864
+ Returns:
865
+ True if the point is inside the polygon, False otherwise
866
+ """
867
+ # Ensure we have a valid polygon with at least 3 points
868
+ if len(polygon) < 3:
869
+ return False
870
+
871
+ # Make sure the polygon is closed (last point equals first point)
872
+ if polygon[0] != polygon[-1]:
873
+ polygon = polygon + [polygon[0]]
874
+
875
+ # Use winding number algorithm for better accuracy
876
+ wn = 0 # Winding number counter
877
+
878
+ # Loop through all edges of the polygon
879
+ for i in range(len(polygon) - 1): # Last vertex is first vertex
880
+ p1x, p1y = polygon[i]
881
+ p2x, p2y = polygon[i + 1]
882
+
883
+ # Test if a point is left/right/on the edge defined by two vertices
884
+ if p1y <= y: # Start y <= P.y
885
+ if p2y > y: # End y > P.y (upward crossing)
886
+ # Point left of edge
887
+ if ((p2x - p1x) * (y - p1y) - (x - p1x) * (p2y - p1y)) > 0:
888
+ wn += 1 # Valid up intersect
889
+ else: # Start y > P.y
890
+ if p2y <= y: # End y <= P.y (downward crossing)
891
+ # Point right of edge
892
+ if ((p2x - p1x) * (y - p1y) - (x - p1x) * (p2y - p1y)) < 0:
893
+ wn -= 1 # Valid down intersect
894
+
895
+ # If winding number is not 0, the point is inside the polygon
896
+ return wn != 0
897
+
898
+
790
899
  def handle_room_outline_error(file_name, room_id, error):
791
900
  """
792
901
  Handle errors during room outline extraction.
@@ -943,83 +1052,14 @@ async def async_extract_room_outline(
943
1052
  return rect_outline
944
1053
 
945
1054
 
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.
1055
+ def pil_to_png_bytes(pil_img: Image.Image, compress_level: int = 1) -> bytes:
1056
+ """Convert PIL Image to PNG bytes asynchronously."""
1057
+ with io.BytesIO() as buf:
1058
+ pil_img.save(buf, format="PNG", compress_level=compress_level)
1059
+ return buf.getvalue()
951
1060
 
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
1061
 
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
1062
+ def png_bytes_to_pil(png_bytes: bytes) -> Image.Image:
1063
+ """Convert PNG bytes back to a PIL Image."""
1064
+ png_buffer = io.BytesIO(png_bytes)
1065
+ return Image.open(png_buffer)