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