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
@@ -2,147 +2,131 @@
2
2
  Image Handler Module for Valetudo Re Vacuums.
3
3
  It returns the PIL PNG image frame relative to the Map Data extrapolated from the vacuum json.
4
4
  It also returns calibration, rooms data to the card and other images information to the camera.
5
- Version: v2024.12.0
5
+ Version: 0.1.9.b42
6
6
  """
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
10
  import logging
11
11
  import uuid
12
+ from typing import Any
12
13
 
13
- from PIL import Image, ImageOps
14
+ import numpy as np
15
+ from PIL import Image
14
16
 
15
- from .config.types import COLORS, DEFAULT_IMAGE_SIZE, DEFAULT_PIXEL_SIZE
16
- from .config.types import Colors, JsonType, PilPNG, RobotPosition, RoomsProperties
17
17
  from .config.auto_crop import AutoCrop
18
- from .images_utils import ImageUtils as ImUtils
19
- from .map_data import ImageData
18
+ from .config.drawable_elements import DrawableElement
19
+ from .config.types import (
20
+ COLORS,
21
+ DEFAULT_IMAGE_SIZE,
22
+ DEFAULT_PIXEL_SIZE,
23
+ Colors,
24
+ JsonType,
25
+ PilPNG,
26
+ RobotPosition,
27
+ RoomsProperties,
28
+ RoomStore,
29
+ )
30
+ from .config.utils import (
31
+ BaseHandler,
32
+ initialize_drawing_config,
33
+ manage_drawable_elements,
34
+ prepare_resize_params,
35
+ )
36
+ from .map_data import RandImageData
20
37
  from .reimg_draw import ImageDraw
38
+ from .rooms_handler import RandRoomsHandler
39
+
21
40
 
22
41
  _LOGGER = logging.getLogger(__name__)
23
42
 
24
43
 
25
44
  # noinspection PyTypeChecker
26
- class ReImageHandler(object):
45
+ class ReImageHandler(BaseHandler, AutoCrop):
27
46
  """
28
47
  Image Handler for Valetudo Re Vacuums.
29
48
  """
30
49
 
31
- def __init__(self, camera_shared):
50
+ def __init__(self, shared_data):
51
+ BaseHandler.__init__(self)
52
+ self.shared = shared_data # Shared data
53
+ AutoCrop.__init__(self, self)
32
54
  self.auto_crop = None # Auto crop flag
33
55
  self.segment_data = None # Segment data
34
56
  self.outlines = None # Outlines data
35
57
  self.calibration_data = None # Calibration data
36
- self.charger_pos = None # Charger position
37
- self.crop_area = None # Crop area
38
- self.crop_img_size = None # Crop image size
39
- self.data = ImageData # Image Data
40
- self.frame_number = 0 # Image Frame number
41
- self.max_frames = 1024
58
+ self.data = RandImageData # Image Data
59
+
60
+ # Initialize drawing configuration using the shared utility function
61
+ self.drawing_config, self.draw, self.enhanced_draw = initialize_drawing_config(
62
+ self
63
+ )
42
64
  self.go_to = None # Go to position data
43
65
  self.img_base_layer = None # Base image layer
44
- self.img_rotate = camera_shared.image_rotate # Image rotation
45
- self.img_size = None # Image size
46
- self.json_data = None # Json data
47
- self.json_id = None # Json id
48
- self.path_pixels = None # Path pixels data
49
- self.robot_in_room = None # Robot in room data
50
- self.robot_pos = None # Robot position
66
+ self.img_rotate = shared_data.image_rotate # Image rotation
51
67
  self.room_propriety = None # Room propriety data
52
- self.rooms_pos = None # Rooms position data
53
- self.shared = camera_shared # Shared data
54
68
  self.active_zones = None # Active zones
55
- self.trim_down = None # Trim down
56
- self.trim_left = None # Trim left
57
- self.trim_right = None # Trim right
58
- self.trim_up = None # Trim up
59
- self.zooming = False # Zooming flag
60
69
  self.file_name = self.shared.file_name # File name
61
- self.offset_x = 0 # offset x for the aspect ratio.
62
- self.offset_y = 0 # offset y for the aspect ratio.
63
- self.offset_top = self.shared.offset_top # offset top
64
- self.offset_bottom = self.shared.offset_down # offset bottom
65
- self.offset_left = self.shared.offset_left # offset left
66
- self.offset_right = self.shared.offset_right # offset right
67
70
  self.imd = ImageDraw(self) # Image Draw
68
- self.imu = ImUtils(self) # Image Utils
69
- self.ac = AutoCrop(self)
71
+ self.rooms_handler = RandRoomsHandler(self.file_name, self.drawing_config) # Room data handler
70
72
 
71
73
  async def extract_room_properties(
72
74
  self, json_data: JsonType, destinations: JsonType
73
75
  ) -> RoomsProperties:
74
76
  """Extract the room properties."""
75
- unsorted_id = ImageData.get_rrm_segments_ids(json_data)
76
- size_x, size_y = ImageData.get_rrm_image_size(json_data)
77
- top, left = ImageData.get_rrm_image_position(json_data)
77
+ # unsorted_id = RandImageData.get_rrm_segments_ids(json_data)
78
+ size_x, size_y = RandImageData.get_rrm_image_size(json_data)
79
+ top, left = RandImageData.get_rrm_image_position(json_data)
78
80
  try:
79
81
  if not self.segment_data or not self.outlines:
80
82
  (
81
83
  self.segment_data,
82
84
  self.outlines,
83
- ) = await ImageData.async_get_rrm_segments(
85
+ ) = await RandImageData.async_get_rrm_segments(
84
86
  json_data, size_x, size_y, top, left, True
85
87
  )
88
+
86
89
  dest_json = destinations
87
- room_data = dict(dest_json).get("rooms", [])
88
90
  zones_data = dict(dest_json).get("zones", [])
89
91
  points_data = dict(dest_json).get("spots", [])
90
- room_id_to_data = {room["id"]: room for room in room_data}
92
+
93
+ # Use the RandRoomsHandler to extract room properties
94
+ room_properties = await self.rooms_handler.async_extract_room_properties(
95
+ json_data, dest_json
96
+ )
97
+
98
+ # Update self.rooms_pos from room_properties for compatibility with other methods
91
99
  self.rooms_pos = []
92
- room_properties = {}
93
- if self.outlines:
94
- for id_x, room_id in enumerate(unsorted_id):
95
- if room_id in room_id_to_data:
96
- room_info = room_id_to_data[room_id]
97
- name = room_info.get("name")
98
- # Calculate x and y min/max from outlines
99
- x_min = self.outlines[id_x][0][0]
100
- x_max = self.outlines[id_x][1][0]
101
- y_min = self.outlines[id_x][0][1]
102
- y_max = self.outlines[id_x][1][1]
103
- corners = [
104
- (x_min, y_min),
105
- (x_max, y_min),
106
- (x_max, y_max),
107
- (x_min, y_max),
108
- ]
109
- # rand256 vacuums accept int(room_id) or str(name)
110
- # the card will soon support int(room_id) but the camera will send name
111
- # this avoids the manual change of the values in the card.
112
- self.rooms_pos.append(
113
- {
114
- "name": name,
115
- "corners": corners,
116
- }
117
- )
118
- room_properties[int(room_id)] = {
119
- "number": int(room_id),
120
- "outline": corners,
121
- "name": name,
122
- "x": (x_min + x_max) // 2,
123
- "y": (y_min + y_max) // 2,
124
- }
125
- # get the zones and points data
126
- zone_properties = await self.imu.async_zone_propriety(zones_data)
127
- # get the points data
128
- point_properties = await self.imu.async_points_propriety(points_data)
129
-
130
- if room_properties != {}:
131
- if zone_properties != {}:
132
- _LOGGER.debug("Rooms and Zones, data extracted!")
133
- else:
134
- _LOGGER.debug("Rooms, data extracted!")
135
- elif zone_properties != {}:
136
- _LOGGER.debug("Zones, data extracted!")
137
- else:
138
- self.rooms_pos = None
139
- _LOGGER.debug(
140
- f"{self.file_name}: Rooms and Zones data not available!"
141
- )
142
- return room_properties, zone_properties, point_properties
143
- except Exception as e:
100
+ for room_id, room_data in room_properties.items():
101
+ self.rooms_pos.append(
102
+ {"name": room_data["name"], "outline": room_data["outline"]}
103
+ )
104
+
105
+ # get the zones and points data
106
+ zone_properties = await self.async_zone_propriety(zones_data)
107
+ # get the points data
108
+ point_properties = await self.async_points_propriety(points_data)
109
+
110
+ if room_properties or zone_properties:
111
+ extracted_data = [
112
+ f"{len(room_properties)} Rooms" if room_properties else None,
113
+ f"{len(zone_properties)} Zones" if zone_properties else None,
114
+ ]
115
+ extracted_data = ", ".join(filter(None, extracted_data))
116
+ _LOGGER.debug("Extracted data: %s", extracted_data)
117
+ else:
118
+ self.rooms_pos = None
119
+ _LOGGER.debug(
120
+ "%s: Rooms and Zones data not available!", self.file_name
121
+ )
122
+
123
+ rooms = RoomStore(self.file_name, room_properties)
124
+ _LOGGER.debug("Rooms Data: %s", rooms.get_rooms())
125
+ return room_properties, zone_properties, point_properties
126
+ except (RuntimeError, ValueError) as e:
144
127
  _LOGGER.debug(
145
- f"No rooms Data or Error in extract_room_properties: {e}",
128
+ "No rooms Data or Error in extract_room_properties: %s",
129
+ e,
146
130
  exc_info=True,
147
131
  )
148
132
  return None, None, None
@@ -160,164 +144,175 @@ class ReImageHandler(object):
160
144
 
161
145
  try:
162
146
  if (m_json is not None) and (not isinstance(m_json, tuple)):
163
- _LOGGER.info(f"{self.file_name}: Composing the image for the camera.")
164
- # buffer json data
147
+ _LOGGER.info("%s: Composing the image for the camera.", self.file_name)
165
148
  self.json_data = m_json
166
- # get the image size
167
149
  size_x, size_y = self.data.get_rrm_image_size(m_json)
168
- ##########################
169
150
  self.img_size = DEFAULT_IMAGE_SIZE
170
- ###########################
171
151
  self.json_id = str(uuid.uuid4()) # image id
172
- _LOGGER.info(f"Vacuum Data ID: {self.json_id}")
173
- # get the robot position
152
+ _LOGGER.info("Vacuum Data ID: %s", self.json_id)
153
+
174
154
  (
175
- robot_pos,
155
+ img_np_array,
176
156
  robot_position,
177
157
  robot_position_angle,
178
- ) = await self.imd.async_get_robot_position(m_json)
179
- if self.frame_number == 0:
180
- room_id, img_np_array = await self.imd.async_draw_base_layer(
181
- m_json,
182
- size_x,
183
- size_y,
184
- colors["wall"],
185
- colors["zone_clean"],
186
- colors["background"],
187
- DEFAULT_PIXEL_SIZE,
188
- )
189
- _LOGGER.info(f"{self.file_name}: Completed base Layers")
190
- if (room_id > 0) and not self.room_propriety:
191
- self.room_propriety = await self.get_rooms_attributes(
192
- destinations
193
- )
194
- if self.rooms_pos:
195
- self.robot_pos = await self.async_get_robot_in_room(
196
- (robot_position[0] * 10),
197
- (robot_position[1] * 10),
198
- robot_position_angle,
199
- )
200
- self.img_base_layer = await self.imd.async_copy_array(img_np_array)
201
-
202
- # If there is a zone clean we draw it now.
203
- self.frame_number += 1
204
- img_np_array = await self.imd.async_copy_array(self.img_base_layer)
205
- _LOGGER.debug(f"{self.file_name}: Frame number {self.frame_number}")
206
- if self.frame_number > 5:
207
- self.frame_number = 0
208
- # All below will be drawn each time
209
- # charger
210
- img_np_array, self.charger_pos = await self.imd.async_draw_charger(
211
- img_np_array, m_json, colors["charger"]
212
- )
213
- # zone clean
214
- img_np_array = await self.imd.async_draw_zones(
215
- m_json, img_np_array, colors["zone_clean"]
216
- )
217
- # virtual walls
218
- img_np_array = await self.imd.async_draw_virtual_restrictions(
219
- m_json, img_np_array, colors["no_go"]
220
- )
221
- # draw path
222
- img_np_array = await self.imd.async_draw_path(
223
- img_np_array, m_json, colors["move"]
224
- )
225
- # go to flag and predicted path
226
- await self.imd.async_draw_go_to_flag(
227
- img_np_array, m_json, colors["go_to"]
228
- )
229
- # draw the robot
230
- img_np_array = await self.imd.async_draw_robot_on_map(
231
- img_np_array, robot_position, robot_position_angle, colors["robot"]
158
+ ) = await self._setup_robot_and_image(
159
+ m_json, size_x, size_y, colors, destinations
232
160
  )
161
+
162
+ # Increment frame number
163
+ self.frame_number += 1
164
+ img_np_array = await self.async_copy_array(self.img_base_layer)
233
165
  _LOGGER.debug(
234
- f"{self.file_name}:"
235
- f" Auto cropping the image with rotation {int(self.shared.image_rotate)}"
166
+ "%s: Frame number %s", self.file_name, str(self.frame_number)
236
167
  )
237
- img_np_array = await self.ac.async_auto_trim_and_zoom_image(
238
- img_np_array,
239
- colors["background"],
240
- int(self.shared.margins),
241
- int(self.shared.image_rotate),
242
- self.zooming,
243
- rand256=True,
168
+ if self.frame_number > 5:
169
+ self.frame_number = 0
170
+
171
+ # Draw map elements
172
+ img_np_array = await self._draw_map_elements(
173
+ img_np_array, m_json, colors, robot_position, robot_position_angle
244
174
  )
175
+
176
+ # Final adjustments
245
177
  pil_img = Image.fromarray(img_np_array, mode="RGBA")
246
178
  del img_np_array # free memory
247
- # reduce the image size if the zoomed image is bigger then the original.
248
- if (
249
- self.shared.image_auto_zoom
250
- and self.shared.vacuum_state == "cleaning"
251
- and self.zooming
252
- and self.shared.image_zoom_lock_ratio
253
- or self.shared.image_aspect_ratio != "None"
254
- ):
255
- width = self.shared.image_ref_width
256
- height = self.shared.image_ref_height
257
- if self.shared.image_aspect_ratio != "None":
258
- wsf, hsf = [
259
- int(x) for x in self.shared.image_aspect_ratio.split(",")
260
- ]
261
- _LOGGER.debug(f"Aspect Ratio: {wsf}, {hsf}")
262
- if wsf == 0 or hsf == 0:
263
- return pil_img
264
- new_aspect_ratio = wsf / hsf
265
- aspect_ratio = width / height
266
- if aspect_ratio > new_aspect_ratio:
267
- new_width = int(pil_img.height * new_aspect_ratio)
268
- new_height = pil_img.height
269
- else:
270
- new_width = pil_img.width
271
- new_height = int(pil_img.width / new_aspect_ratio)
272
-
273
- resized = ImageOps.pad(pil_img, (new_width, new_height))
274
- (
275
- self.crop_img_size[0],
276
- self.crop_img_size[1],
277
- ) = await self.async_map_coordinates_offset(
278
- wsf, hsf, new_width, new_height
279
- )
280
- _LOGGER.debug(
281
- f"{self.file_name}: Image Aspect Ratio ({wsf}, {hsf}): {new_width}x{new_height}"
282
- )
283
- _LOGGER.debug(f"{self.file_name}: Frame Completed.")
284
- return resized
285
- else:
286
- _LOGGER.debug(f"{self.file_name}: Frame Completed.")
287
- return ImageOps.pad(pil_img, (width, height))
288
- else:
289
- _LOGGER.debug(f"{self.file_name}: Frame Completed.")
290
- return pil_img
179
+
180
+ return await self._finalize_image(pil_img)
181
+
291
182
  except (RuntimeError, RuntimeWarning) as e:
292
183
  _LOGGER.warning(
293
- f"{self.file_name}: Error {e} during image creation.",
184
+ "%s: Runtime Error %s during image creation.",
185
+ self.file_name,
186
+ str(e),
294
187
  exc_info=True,
295
188
  )
296
189
  return None
297
190
 
298
- def get_frame_number(self) -> int:
299
- """Return the frame number."""
300
- return self.frame_number
191
+ # If we reach here without returning, return None
192
+ return None
193
+
194
+ async def _setup_robot_and_image(
195
+ self, m_json, size_x, size_y, colors, destinations
196
+ ):
197
+ (
198
+ _,
199
+ robot_position,
200
+ robot_position_angle,
201
+ ) = await self.imd.async_get_robot_position(m_json)
202
+
203
+ if self.frame_number == 0:
204
+ # Create element map for tracking what's drawn where
205
+ self.element_map = np.zeros((size_y, size_x), dtype=np.int32)
206
+ self.element_map[:] = DrawableElement.FLOOR
207
+
208
+ # Draw base layer if floor is enabled
209
+ if self.drawing_config.is_enabled(DrawableElement.FLOOR):
210
+ room_id, img_np_array = await self.imd.async_draw_base_layer(
211
+ m_json,
212
+ size_x,
213
+ size_y,
214
+ colors["wall"],
215
+ colors["zone_clean"],
216
+ colors["background"],
217
+ DEFAULT_PIXEL_SIZE,
218
+ )
219
+ _LOGGER.info("%s: Completed base Layers", self.file_name)
220
+
221
+ # Update element map for rooms
222
+ if 0 < room_id <= 15:
223
+ # This is a simplification - in a real implementation we would
224
+ # need to identify the exact pixels that belong to each room
225
+ pass
226
+
227
+ if room_id > 0 and not self.room_propriety:
228
+ self.room_propriety = await self.get_rooms_attributes(destinations)
229
+ if self.rooms_pos:
230
+ self.robot_pos = await self.async_get_robot_in_room(
231
+ (robot_position[0] * 10),
232
+ (robot_position[1] * 10),
233
+ robot_position_angle,
234
+ )
235
+ self.img_base_layer = await self.async_copy_array(img_np_array)
236
+ else:
237
+ # If floor is disabled, create an empty image
238
+ background_color = self.drawing_config.get_property(
239
+ DrawableElement.FLOOR, "color", colors["background"]
240
+ )
241
+ img_np_array = await self.draw.create_empty_image(
242
+ size_x, size_y, background_color
243
+ )
244
+ self.img_base_layer = await self.async_copy_array(img_np_array)
245
+ return self.img_base_layer, robot_position, robot_position_angle
246
+
247
+ async def _draw_map_elements(
248
+ self, img_np_array, m_json, colors, robot_position, robot_position_angle
249
+ ):
250
+ # Draw charger if enabled
251
+ if self.drawing_config.is_enabled(DrawableElement.CHARGER):
252
+ img_np_array, self.charger_pos = await self.imd.async_draw_charger(
253
+ img_np_array, m_json, colors["charger"]
254
+ )
301
255
 
302
- def get_robot_position(self) -> any:
303
- """Return the robot position."""
304
- return self.robot_pos
256
+ # Draw zones if enabled
257
+ if self.drawing_config.is_enabled(DrawableElement.RESTRICTED_AREA):
258
+ img_np_array = await self.imd.async_draw_zones(
259
+ m_json, img_np_array, colors["zone_clean"]
260
+ )
305
261
 
306
- def get_charger_position(self) -> any:
307
- """Return the charger position."""
308
- return self.charger_pos
262
+ # Draw virtual restrictions if enabled
263
+ if self.drawing_config.is_enabled(DrawableElement.VIRTUAL_WALL):
264
+ img_np_array = await self.imd.async_draw_virtual_restrictions(
265
+ m_json, img_np_array, colors["no_go"]
266
+ )
309
267
 
310
- def get_img_size(self) -> any:
311
- """Return the image size."""
312
- return self.img_size
268
+ # Draw path if enabled
269
+ if self.drawing_config.is_enabled(DrawableElement.PATH):
270
+ img_np_array = await self.imd.async_draw_path(
271
+ img_np_array, m_json, colors["move"]
272
+ )
273
+
274
+ # Draw go-to flag if enabled
275
+ if self.drawing_config.is_enabled(DrawableElement.GO_TO_TARGET):
276
+ img_np_array = await self.imd.async_draw_go_to_flag(
277
+ img_np_array, m_json, colors["go_to"]
278
+ )
313
279
 
314
- def get_json_id(self) -> str:
315
- """Return the json id."""
316
- return self.json_id
280
+ # Draw robot if enabled
281
+ if robot_position and self.drawing_config.is_enabled(DrawableElement.ROBOT):
282
+ # Get robot color (allows for customization)
283
+ robot_color = self.drawing_config.get_property(
284
+ DrawableElement.ROBOT, "color", colors["robot"]
285
+ )
286
+
287
+ img_np_array = await self.imd.async_draw_robot_on_map(
288
+ img_np_array, robot_position, robot_position_angle, robot_color
289
+ )
290
+
291
+ img_np_array = await self.async_auto_trim_and_zoom_image(
292
+ img_np_array,
293
+ detect_colour=colors["background"],
294
+ margin_size=int(self.shared.margins),
295
+ rotate=int(self.shared.image_rotate),
296
+ zoom=self.zooming,
297
+ rand256=True,
298
+ )
299
+ return img_np_array
300
+
301
+ async def _finalize_image(self, pil_img):
302
+ if not self.shared.image_ref_width or not self.shared.image_ref_height:
303
+ _LOGGER.warning(
304
+ "Image finalization failed: Invalid image dimensions. Returning original image."
305
+ )
306
+ return pil_img
307
+ if self.check_zoom_and_aspect_ratio():
308
+ resize_params = prepare_resize_params(self, pil_img, True)
309
+ pil_img = await self.async_resize_images(resize_params)
310
+ _LOGGER.debug("%s: Frame Completed.", self.file_name)
311
+ return pil_img
317
312
 
318
313
  async def get_rooms_attributes(
319
314
  self, destinations: JsonType = None
320
- ) -> RoomsProperties:
315
+ ) -> tuple[RoomsProperties, Any, Any]:
321
316
  """Return the rooms attributes."""
322
317
  if self.room_propriety:
323
318
  return self.room_propriety
@@ -330,101 +325,221 @@ class ReImageHandler(object):
330
325
  _LOGGER.debug("Got Rooms Attributes.")
331
326
  return self.room_propriety
332
327
 
328
+ @staticmethod
329
+ def point_in_polygon(x: int, y: int, polygon: list) -> bool:
330
+ """
331
+ Check if a point is inside a polygon using ray casting algorithm.
332
+ Enhanced version with better handling of edge cases.
333
+
334
+ Args:
335
+ x: X coordinate of the point
336
+ y: Y coordinate of the point
337
+ polygon: List of (x, y) tuples forming the polygon
338
+
339
+ Returns:
340
+ True if the point is inside the polygon, False otherwise
341
+ """
342
+ # Ensure we have a valid polygon with at least 3 points
343
+ if len(polygon) < 3:
344
+ return False
345
+
346
+ # Make sure the polygon is closed (last point equals first point)
347
+ if polygon[0] != polygon[-1]:
348
+ polygon = polygon + [polygon[0]]
349
+
350
+ # Use winding number algorithm for better accuracy
351
+ wn = 0 # Winding number counter
352
+
353
+ # Loop through all edges of the polygon
354
+ for i in range(len(polygon) - 1): # Last vertex is first vertex
355
+ p1x, p1y = polygon[i]
356
+ p2x, p2y = polygon[i + 1]
357
+
358
+ # Test if a point is left/right/on the edge defined by two vertices
359
+ if p1y <= y: # Start y <= P.y
360
+ if p2y > y: # End y > P.y (upward crossing)
361
+ # Point left of edge
362
+ if ((p2x - p1x) * (y - p1y) - (x - p1x) * (p2y - p1y)) > 0:
363
+ wn += 1 # Valid up intersect
364
+ else: # Start y > P.y
365
+ if p2y <= y: # End y <= P.y (downward crossing)
366
+ # Point right of edge
367
+ if ((p2x - p1x) * (y - p1y) - (x - p1x) * (p2y - p1y)) < 0:
368
+ wn -= 1 # Valid down intersect
369
+
370
+ # If winding number is not 0, the point is inside the polygon
371
+ return wn != 0
372
+
333
373
  async def async_get_robot_in_room(
334
374
  self, robot_x: int, robot_y: int, angle: float
335
375
  ) -> RobotPosition:
336
376
  """Get the robot position and return in what room is."""
377
+ # First check if we already have a cached room and if the robot is still in it
378
+ if self.robot_in_room:
379
+ # If we have outline data, use point_in_polygon for accurate detection
380
+ if "outline" in self.robot_in_room:
381
+ outline = self.robot_in_room["outline"]
382
+ if self.point_in_polygon(int(robot_x), int(robot_y), outline):
383
+ temp = {
384
+ "x": robot_x,
385
+ "y": robot_y,
386
+ "angle": angle,
387
+ "in_room": self.robot_in_room["room"],
388
+ }
389
+ # Handle active zones
390
+ self.active_zones = self.shared.rand256_active_zone
391
+ self.zooming = False
392
+ if self.active_zones and (
393
+ self.robot_in_room["id"]
394
+ in range(len(self.active_zones))
395
+ ):
396
+ self.zooming = bool(
397
+ self.active_zones[self.robot_in_room["id"]]
398
+ )
399
+ else:
400
+ self.zooming = False
401
+ return temp
402
+ # Fallback to bounding box check if no outline data
403
+ elif all(
404
+ k in self.robot_in_room for k in ["left", "right", "up", "down"]
405
+ ):
406
+ if (
407
+ (self.robot_in_room["right"] <= int(robot_x) <= self.robot_in_room["left"])
408
+ and (self.robot_in_room["up"] <= int(robot_y) <= self.robot_in_room["down"])
409
+ ):
410
+ temp = {
411
+ "x": robot_x,
412
+ "y": robot_y,
413
+ "angle": angle,
414
+ "in_room": self.robot_in_room["room"],
415
+ }
416
+ # Handle active zones
417
+ self.active_zones = self.shared.rand256_active_zone
418
+ self.zooming = False
419
+ if self.active_zones and (
420
+ self.robot_in_room["id"]
421
+ in range(len(self.active_zones))
422
+ ):
423
+ self.zooming = bool(
424
+ self.active_zones[self.robot_in_room["id"]]
425
+ )
426
+ else:
427
+ self.zooming = False
428
+ return temp
337
429
 
338
- def _check_robot_position(x: int, y: int) -> bool:
339
- x_in_room = (self.robot_in_room["left"] >= x) and (
340
- self.robot_in_room["right"] <= x
341
- )
342
- y_in_room = (self.robot_in_room["up"] >= y) and (
343
- self.robot_in_room["down"] <= y
344
- )
345
- return x_in_room and y_in_room
430
+ # If we don't have a cached room or the robot is not in it, search all rooms
431
+ last_room = None
432
+ room_count = 0
433
+ if self.robot_in_room:
434
+ last_room = self.robot_in_room
346
435
 
347
- # Check if the robot coordinates are inside the room's
348
- if self.robot_in_room and _check_robot_position(robot_x, robot_y):
436
+ # Check if the robot is far outside the normal map boundaries
437
+ # This helps prevent false positives for points very far from any room
438
+ map_boundary = 50000 # Typical map size is around 25000-30000 units for Rand25
439
+ if abs(robot_x) > map_boundary or abs(robot_y) > map_boundary:
440
+ _LOGGER.debug(
441
+ "%s robot position (%s, %s) is far outside map boundaries.",
442
+ self.file_name,
443
+ robot_x,
444
+ robot_y,
445
+ )
446
+ self.robot_in_room = last_room
447
+ self.zooming = False
349
448
  temp = {
350
449
  "x": robot_x,
351
450
  "y": robot_y,
352
451
  "angle": angle,
353
- "in_room": self.robot_in_room["room"],
452
+ "in_room": last_room["room"] if last_room else "unknown",
354
453
  }
355
- self.active_zones = self.shared.rand256_active_zone
454
+ return temp
455
+
456
+ # Search through all rooms to find which one contains the robot
457
+ if not self.rooms_pos:
458
+ _LOGGER.debug(
459
+ "%s: No rooms data available for robot position detection.",
460
+ self.file_name,
461
+ )
462
+ self.robot_in_room = last_room
356
463
  self.zooming = False
357
- if self.active_zones and (
358
- (self.robot_in_room["id"]) in range(len(self.active_zones))
359
- ): # issue #100 Index out of range
360
- self.zooming = bool(self.active_zones[self.robot_in_room["id"]])
464
+ temp = {
465
+ "x": robot_x,
466
+ "y": robot_y,
467
+ "angle": angle,
468
+ "in_room": last_room["room"] if last_room else "unknown",
469
+ }
361
470
  return temp
362
- # else we need to search and use the async method
363
- _LOGGER.debug(f"{self.file_name} changed room.. searching..")
364
- room_count = -1
365
- last_room = None
366
- if self.rooms_pos:
367
- if self.robot_in_room:
368
- last_room = self.robot_in_room
369
- for room in self.rooms_pos:
370
- corners = room["corners"]
371
- room_count += 1
372
- self.robot_in_room = {
373
- "id": room_count,
374
- "left": corners[0][0],
375
- "right": corners[2][0],
376
- "up": corners[0][1],
377
- "down": corners[2][1],
378
- "room": room["name"],
379
- }
380
- # Check if the robot coordinates are inside the room's corners
381
- if _check_robot_position(robot_x, robot_y):
471
+
472
+ _LOGGER.debug("%s: Searching for robot in rooms...", self.file_name)
473
+ for room in self.rooms_pos:
474
+ # Check if the room has an outline (polygon points)
475
+ if "outline" in room:
476
+ outline = room["outline"]
477
+ # Use point_in_polygon for accurate detection with complex shapes
478
+ if self.point_in_polygon(int(robot_x), int(robot_y), outline):
479
+ # Robot is in this room
480
+ self.robot_in_room = {
481
+ "id": room_count,
482
+ "room": str(room["name"]),
483
+ "outline": outline,
484
+ }
382
485
  temp = {
383
486
  "x": robot_x,
384
487
  "y": robot_y,
385
488
  "angle": angle,
386
489
  "in_room": self.robot_in_room["room"],
387
490
  }
491
+
492
+ # Handle active zones - Set zooming based on active zones
493
+ self.active_zones = self.shared.rand256_active_zone
494
+ self.zooming = False
495
+ if self.active_zones and (
496
+ self.robot_in_room["id"]
497
+ in range(len(self.active_zones))
498
+ ):
499
+ self.zooming = bool(
500
+ self.active_zones[self.robot_in_room["id"]]
501
+ )
502
+ else:
503
+ self.zooming = False
504
+
388
505
  _LOGGER.debug(
389
- f"{self.file_name} is in {self.robot_in_room['room']}"
506
+ "%s is in %s room (polygon detection).",
507
+ self.file_name,
508
+ self.robot_in_room["room"],
390
509
  )
391
- del room, corners, robot_x, robot_y # free memory.
392
510
  return temp
393
- del room, corners # free memory.
394
- _LOGGER.debug(
395
- f"{self.file_name}: Not located within Camera Rooms coordinates."
396
- )
397
- self.zooming = False
398
- self.robot_in_room = last_room
399
- temp = {
400
- "x": robot_x,
401
- "y": robot_y,
402
- "angle": angle,
403
- "in_room": self.robot_in_room["room"],
404
- }
405
- return temp
511
+ room_count += 1
512
+
513
+ # Robot not found in any room
514
+ _LOGGER.debug(
515
+ "%s not located within any room coordinates.",
516
+ self.file_name,
517
+ )
518
+ self.robot_in_room = last_room
519
+ self.zooming = False
520
+ temp = {
521
+ "x": robot_x,
522
+ "y": robot_y,
523
+ "angle": angle,
524
+ "in_room": last_room["room"] if last_room else "unknown",
525
+ }
526
+ return temp
406
527
 
407
- def get_calibration_data(self, rotation_angle: int = 0) -> any:
528
+ def get_calibration_data(self, rotation_angle: int = 0) -> Any:
408
529
  """Return the map calibration data."""
409
- if not self.calibration_data:
530
+ if not self.calibration_data and self.crop_img_size:
410
531
  self.calibration_data = []
411
532
  _LOGGER.info(
412
- f"{self.file_name}: Getting Calibrations points {self.crop_area}"
533
+ "%s: Getting Calibrations points %s",
534
+ self.file_name,
535
+ str(self.crop_area),
413
536
  )
414
537
 
415
538
  # Define the map points (fixed)
416
- map_points = [
417
- {"x": 0, "y": 0}, # Top-left corner 0
418
- {"x": self.crop_img_size[0], "y": 0}, # Top-right corner 1
419
- {
420
- "x": self.crop_img_size[0],
421
- "y": self.crop_img_size[1],
422
- }, # Bottom-right corner 2
423
- {"x": 0, "y": self.crop_img_size[1]}, # Bottom-left corner (optional) 3
424
- ]
539
+ map_points = self.get_map_points()
425
540
 
426
541
  # Valetudo Re version need corrections of the coordinates and are implemented with *10
427
- vacuum_points = self.imu.re_get_vacuum_points(rotation_angle)
542
+ vacuum_points = self.re_get_vacuum_points(rotation_angle)
428
543
 
429
544
  # Create the calibration data for each point
430
545
  for vacuum_point, map_point in zip(vacuum_points, map_points):
@@ -433,23 +548,27 @@ class ReImageHandler(object):
433
548
 
434
549
  return self.calibration_data
435
550
 
436
- async def async_map_coordinates_offset(
437
- self, wsf: int, hsf: int, width: int, height: int
438
- ) -> tuple[int, int]:
439
- """
440
- Offset the coordinates to the map.
441
- """
551
+ # Element selection methods
552
+ def enable_element(self, element_code: DrawableElement) -> None:
553
+ """Enable drawing of a specific element."""
554
+ self.drawing_config.enable_element(element_code)
555
+
556
+ def disable_element(self, element_code: DrawableElement) -> None:
557
+ """Disable drawing of a specific element."""
558
+ manage_drawable_elements(self, "disable", element_code=element_code)
559
+
560
+ def set_elements(self, element_codes: list[DrawableElement]) -> None:
561
+ """Enable only the specified elements, disable all others."""
562
+ manage_drawable_elements(self, "set_elements", element_codes=element_codes)
442
563
 
443
- if wsf == 1 and hsf == 1:
444
- self.imu.set_image_offset_ratio_1_1(width, height, rand256=True)
445
- elif wsf == 2 and hsf == 1:
446
- self.imu.set_image_offset_ratio_2_1(width, height, rand256=True)
447
- elif wsf == 3 and hsf == 2:
448
- self.imu.set_image_offset_ratio_3_2(width, height, rand256=True)
449
- elif wsf == 5 and hsf == 4:
450
- self.imu.set_image_offset_ratio_5_4(width, height, rand256=True)
451
- elif wsf == 9 and hsf == 16:
452
- self.imu.set_image_offset_ratio_9_16(width, height, rand256=True)
453
- elif wsf == 16 and hsf == 9:
454
- self.imu.set_image_offset_ratio_16_9(width, height, rand256=True)
455
- return width, height
564
+ def set_element_property(
565
+ self, element_code: DrawableElement, property_name: str, value
566
+ ) -> None:
567
+ """Set a drawing property for an element."""
568
+ manage_drawable_elements(
569
+ self,
570
+ "set_property",
571
+ element_code=element_code,
572
+ property_name=property_name,
573
+ value=value,
574
+ )