valetudo-map-parser 0.1.9b75__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 +25 -7
  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.9b75.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.9b75.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.9b75.dist-info/RECORD +0 -27
  32. {valetudo_map_parser-0.1.9b75.dist-info → valetudo_map_parser-0.1.10.dist-info/licenses}/LICENSE +0 -0
  33. {valetudo_map_parser-0.1.9b75.dist-info → valetudo_map_parser-0.1.10.dist-info/licenses}/NOTICE.txt +0 -0
@@ -1,31 +1,46 @@
1
1
  """Valetudo map parser.
2
- Version: 0.1.9"""
2
+ Version: 0.1.10"""
3
+
4
+ from pathlib import Path
3
5
 
4
6
  from .config.colors import ColorsManagement
5
7
  from .config.drawable import Drawable
6
8
  from .config.drawable_elements import DrawableElement, DrawingConfig
7
- from .config.enhanced_drawable import EnhancedDrawable
8
9
  from .config.rand256_parser import RRMapParser
9
10
  from .config.shared import CameraShared, CameraSharedManager
11
+ from .config.status_text.status_text import StatusText
12
+ from .config.status_text.translations import translations as STATUS_TEXT_TRANSLATIONS
10
13
  from .config.types import (
11
14
  CameraModes,
15
+ ImageSize,
16
+ JsonType,
17
+ NumpyArray,
18
+ PilPNG,
12
19
  RoomsProperties,
13
20
  RoomStore,
14
21
  SnapshotStore,
15
22
  TrimCropData,
16
23
  UserLanguageStore,
17
- JsonType,
18
- PilPNG,
19
- NumpyArray,
20
24
  )
21
25
  from .hypfer_handler import HypferMapImageHandler
26
+ from .map_data import HyperMapData
22
27
  from .rand256_handler import ReImageHandler
23
- from .rooms_handler import RoomsHandler, RandRoomsHandler
28
+ from .rooms_handler import RandRoomsHandler, RoomsHandler
29
+
30
+
31
+ def get_default_font_path() -> str:
32
+ """Return the absolute path to the bundled default font directory.
33
+
34
+ This returns the path to the fonts folder; the caller can join a specific font file
35
+ to avoid hard-coding a particular font here.
36
+ """
37
+ return str((Path(__file__).resolve().parent / "config" / "fonts").resolve())
24
38
 
25
39
 
26
40
  __all__ = [
27
41
  "RoomsHandler",
28
42
  "RandRoomsHandler",
43
+ "HyperMapData",
29
44
  "HypferMapImageHandler",
30
45
  "ReImageHandler",
31
46
  "RRMapParser",
@@ -35,7 +50,6 @@ __all__ = [
35
50
  "Drawable",
36
51
  "DrawableElement",
37
52
  "DrawingConfig",
38
- "EnhancedDrawable",
39
53
  "SnapshotStore",
40
54
  "UserLanguageStore",
41
55
  "RoomStore",
@@ -45,4 +59,8 @@ __all__ = [
45
59
  "JsonType",
46
60
  "PilPNG",
47
61
  "NumpyArray",
62
+ "ImageSize",
63
+ "StatusText",
64
+ "STATUS_TEXT_TRANSLATIONS",
65
+ "get_default_font_path",
48
66
  ]
@@ -6,10 +6,9 @@ from __future__ import annotations
6
6
  import logging
7
7
 
8
8
  import numpy as np
9
- from numpy import rot90
10
9
  from scipy import ndimage
11
10
 
12
- from .async_utils import AsyncNumPy, make_async
11
+ from .async_utils import AsyncNumPy
13
12
  from .types import Color, NumpyArray, TrimCropData, TrimsData
14
13
  from .utils import BaseHandler
15
14
 
@@ -91,7 +90,6 @@ class AutoCrop:
91
90
 
92
91
  async def _async_auto_crop_data(self, tdata: TrimsData): # , tdata=None
93
92
  """Load the auto crop data from the Camera config."""
94
- _LOGGER.debug("Auto Crop init data: %s, %s", str(tdata), str(self.auto_crop))
95
93
  if not self.auto_crop:
96
94
  trims_data = TrimCropData.from_dict(dict(tdata.to_dict())).to_list()
97
95
  (
@@ -100,7 +98,6 @@ class AutoCrop:
100
98
  self.trim_right,
101
99
  self.trim_down,
102
100
  ) = trims_data
103
- _LOGGER.debug("Auto Crop trims data: %s", trims_data)
104
101
  if trims_data != [0, 0, 0, 0]:
105
102
  self._calculate_trimmed_dimensions()
106
103
  else:
@@ -118,10 +115,6 @@ class AutoCrop:
118
115
 
119
116
  async def _init_auto_crop(self):
120
117
  """Initialize the auto crop data."""
121
- _LOGGER.debug("Auto Crop Init data: %s", str(self.auto_crop))
122
- _LOGGER.debug(
123
- "Auto Crop Init trims data: %r", self.handler.shared.trims.to_dict()
124
- )
125
118
  if not self.auto_crop: # and self.handler.shared.vacuum_state == "docked":
126
119
  self.auto_crop = await self._async_auto_crop_data(self.handler.shared.trims)
127
120
  if self.auto_crop:
@@ -131,7 +124,6 @@ class AutoCrop:
131
124
 
132
125
  # Fallback: Ensure auto_crop is valid
133
126
  if not self.auto_crop or any(v < 0 for v in self.auto_crop):
134
- _LOGGER.debug("Auto-crop data unavailable. Scanning full image.")
135
127
  self.auto_crop = None
136
128
 
137
129
  return self.auto_crop
@@ -164,14 +156,6 @@ class AutoCrop:
164
156
  min_y, max_y = y_slice.start, y_slice.stop - 1
165
157
  min_x, max_x = x_slice.start, x_slice.stop - 1
166
158
 
167
- _LOGGER.debug(
168
- "%s: Found trims max and min values (y,x) (%s, %s) (%s, %s)...",
169
- self.handler.file_name,
170
- int(max_y),
171
- int(max_x),
172
- int(min_y),
173
- int(min_x),
174
- )
175
159
  return min_y, min_x, max_x, max_y
176
160
 
177
161
  async def async_get_room_bounding_box(
@@ -247,7 +231,7 @@ class AutoCrop:
247
231
  return None
248
232
 
249
233
  except Exception as e:
250
- _LOGGER.error(
234
+ _LOGGER.warning(
251
235
  "%s: Error calculating room bounding box for '%s': %s",
252
236
  self.handler.file_name,
253
237
  room_name,
@@ -403,7 +387,6 @@ class AutoCrop:
403
387
  try:
404
388
  self.auto_crop = await self._init_auto_crop()
405
389
  if (self.auto_crop is None) or (self.auto_crop == [0, 0, 0, 0]):
406
- _LOGGER.debug("%s: Calculating auto trim box", self.handler.file_name)
407
390
  # Find the coordinates of the first occurrence of a non-background color
408
391
  min_y, min_x, max_x, max_y = await self.async_image_margins(
409
392
  image_array, detect_colour
@@ -456,15 +439,7 @@ class AutoCrop:
456
439
  # Rotate the cropped image based on the given angle
457
440
  rotated = await self.async_rotate_the_image(trimmed, rotate)
458
441
  del trimmed # Free memory.
459
- _LOGGER.debug(
460
- "%s: Auto Trim Box data: %s", self.handler.file_name, self.crop_area
461
- )
462
442
  self.handler.crop_img_size = [rotated.shape[1], rotated.shape[0]]
463
- _LOGGER.debug(
464
- "%s: Auto Trimmed image size: %s",
465
- self.handler.file_name,
466
- self.handler.crop_img_size,
467
- )
468
443
 
469
444
  except RuntimeError as e:
470
445
  _LOGGER.warning(
@@ -1,8 +1,7 @@
1
1
  """Utility functions for color operations in the map parser."""
2
2
 
3
- from typing import Optional, Tuple
3
+ from typing import Optional
4
4
 
5
- from .colors import ColorsManagement
6
5
  from .types import Color, NumpyArray
7
6
 
8
7
 
@@ -36,8 +35,8 @@ def get_blended_color(
36
35
  # Sample background at midpoint
37
36
  mid_x, mid_y = (x0 + x1) // 2, (y0 + y1) // 2
38
37
  if 0 <= mid_y < arr.shape[0] and 0 <= mid_x < arr.shape[1]:
39
- return tuple(arr[mid_y, mid_x])
40
- return (0, 0, 0, 0) # Default if out of bounds
38
+ return Color(arr[mid_y, mid_x])
39
+ return Color(0, 0, 0, 0) # Default if out of bounds
41
40
 
42
41
  # Calculate direction vector for offset sampling
43
42
  dx = x1 - x0
@@ -250,7 +250,7 @@ class ColorsManagement:
250
250
  List[Tuple[int, int, int, int]]: List of RGBA colors with alpha channel added.
251
251
  """
252
252
  if len(alpha_channels) != len(rgb_colors):
253
- LOGGER.error("Input lists must have the same length.")
253
+ LOGGER.warning("Input lists must have the same length.")
254
254
  return []
255
255
 
256
256
  # Fast path for empty lists
@@ -357,7 +357,7 @@ class ColorsManagement:
357
357
  self.color_cache.clear()
358
358
 
359
359
  except (ValueError, IndexError, UnboundLocalError) as e:
360
- LOGGER.error("Error while populating colors: %s", e)
360
+ LOGGER.warning("Error while populating colors: %s", e)
361
361
 
362
362
  def initialize_user_colors(self, device_info: dict) -> List[Color]:
363
363
  """
@@ -11,13 +11,13 @@ Optimized with NumPy and SciPy for better performance.
11
11
  from __future__ import annotations
12
12
 
13
13
  import logging
14
- import math
14
+ from pathlib import Path
15
15
 
16
16
  import numpy as np
17
+ from mvcrender.blend import get_blended_color, sample_and_blend_color
18
+ from mvcrender.draw import circle_u8, line_u8
17
19
  from PIL import Image, ImageDraw, ImageFont
18
20
 
19
- from .color_utils import get_blended_color
20
- from .colors import ColorsManagement
21
21
  from .types import Color, NumpyArray, PilPNG, Point, Tuple, Union
22
22
 
23
23
 
@@ -85,7 +85,7 @@ class Drawable:
85
85
  and 0 <= center_x < image_array.shape[1]
86
86
  ):
87
87
  # Get blended color
88
- blended_color = ColorsManagement.sample_and_blend_color(
88
+ blended_color = sample_and_blend_color(
89
89
  image_array, center_x, center_y, full_color
90
90
  )
91
91
  # Apply blended color to the region
@@ -131,9 +131,7 @@ class Drawable:
131
131
  center_x = (start_col + end_col) // 2
132
132
 
133
133
  # Get blended color
134
- blended_color = ColorsManagement.sample_and_blend_color(
135
- layers, center_x, center_y, color
136
- )
134
+ blended_color = sample_and_blend_color(layers, center_x, center_y, color)
137
135
 
138
136
  # Apply blended color
139
137
  layers[start_row:end_row, start_col:end_col] = blended_color
@@ -160,23 +158,24 @@ class Drawable:
160
158
 
161
159
  # Get blended colors for flag and pole
162
160
  flag_alpha = flag_color[3] if len(flag_color) == 4 else 255
163
- pole_color_base = (0, 0, 255) # Blue for the pole
161
+ pole_color_base = [0, 0, 255] # Blue for the pole
164
162
  pole_alpha = 255
165
163
 
166
164
  # Blend flag color if needed
167
165
  if flag_alpha < 255:
168
- flag_color = ColorsManagement.sample_and_blend_color(
169
- layer, x, y, flag_color
170
- )
166
+ flag_color = sample_and_blend_color(layer, x, y, flag_color)
171
167
 
172
168
  # Create pole color with alpha
173
- pole_color: Color = (*pole_color_base, pole_alpha)
169
+ pole_color: Color = (
170
+ pole_color_base[0],
171
+ pole_color_base[1],
172
+ pole_color_base[2],
173
+ pole_alpha,
174
+ )
174
175
 
175
176
  # Blend pole color if needed
176
177
  if pole_alpha < 255:
177
- pole_color = ColorsManagement.sample_and_blend_color(
178
- layer, x, y, pole_color
179
- )
178
+ pole_color = sample_and_blend_color(layer, x, y, pole_color)
180
179
 
181
180
  flag_size = 50
182
181
  pole_width = 6
@@ -223,83 +222,37 @@ class Drawable:
223
222
 
224
223
  @staticmethod
225
224
  def point_inside(x: int, y: int, points: list[Tuple[int, int]]) -> bool:
226
- """
227
- Check if a point (x, y) is inside a polygon defined by a list of points.
228
- """
225
+ """Check if a point (x, y) is inside a polygon defined by a list of points."""
229
226
  n = len(points)
230
227
  inside = False
231
- xinters = 0.0
228
+ inters_x = 0.0
232
229
  p1x, p1y = points[0]
233
230
  for i in range(1, n + 1):
234
231
  p2x, p2y = points[i % n]
235
232
  if y > min(p1y, p2y):
236
233
  if y <= max(p1y, p2y) and x <= max(p1x, p2x):
237
234
  if p1y != p2y:
238
- xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
239
- if p1x == p2x or x <= xinters:
235
+ inters_x = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
236
+ if p1x == p2x or x <= inters_x:
240
237
  inside = not inside
241
238
  p1x, p1y = p2x, p2y
242
239
  return inside
243
240
 
244
241
  @staticmethod
245
242
  def _line(
246
- layer: np.ndarray,
243
+ layer: NumpyArray,
247
244
  x1: int,
248
245
  y1: int,
249
246
  x2: int,
250
247
  y2: int,
251
248
  color: Color,
252
249
  width: int = 3,
253
- ) -> np.ndarray:
254
- """
255
- Draw a line on a NumPy array (layer) from point A to B using Bresenham's algorithm.
256
-
257
- Args:
258
- layer: The numpy array to draw on (H, W, C)
259
- x1, y1: Start point coordinates
260
- x2, y2: End point coordinates
261
- color: Color to draw with (tuple or array)
262
- width: Width of the line in pixels
263
- """
264
- x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
265
-
266
- blended_color = get_blended_color(x1, y1, x2, y2, layer, color)
267
-
268
- dx = abs(x2 - x1)
269
- dy = abs(y2 - y1)
270
- sx = 1 if x1 < x2 else -1
271
- sy = 1 if y1 < y2 else -1
272
- err = dx - dy
273
-
274
- half_w = width // 2
275
- h, w = layer.shape[:2]
276
-
277
- while True:
278
- # Draw a filled circle for thickness
279
- yy, xx = np.ogrid[-half_w : half_w + 1, -half_w : half_w + 1]
280
- mask = xx**2 + yy**2 <= half_w**2
281
- y_min = max(0, y1 - half_w)
282
- y_max = min(h, y1 + half_w + 1)
283
- x_min = max(0, x1 - half_w)
284
- x_max = min(w, x1 + half_w + 1)
285
-
286
- submask = mask[
287
- (y_min - (y1 - half_w)) : (y_max - (y1 - half_w)),
288
- (x_min - (x1 - half_w)) : (x_max - (x1 - half_w)),
289
- ]
290
- layer[y_min:y_max, x_min:x_max][submask] = blended_color
291
-
292
- if x1 == x2 and y1 == y2:
293
- break
294
-
295
- e2 = 2 * err
296
- if e2 > -dy:
297
- err -= dy
298
- x1 += sx
299
- if e2 < dx:
300
- err += dx
301
- y1 += sy
302
-
250
+ ) -> NumpyArray:
251
+ """Segment-aware preblend, then stamp a solid line."""
252
+ width = int(max(1, width))
253
+ # Preblend once for this segment
254
+ seg = get_blended_color(int(x1), int(y1), int(x2), int(y2), layer, color)
255
+ line_u8(layer, int(x1), int(y1), int(x2), int(y2), seg, width)
303
256
  return layer
304
257
 
305
258
  @staticmethod
@@ -317,12 +270,14 @@ class Drawable:
317
270
  return layer
318
271
 
319
272
  @staticmethod
320
- async def lines(arr: NumpyArray, coords, width: int, color: Color) -> NumpyArray:
273
+ async def lines(
274
+ arr: NumpyArray, coordinates, width: int, color: Color
275
+ ) -> NumpyArray:
321
276
  """
322
277
  Join the coordinates creating a continuous line (path).
323
278
  Optimized with vectorized operations for better performance.
324
279
  """
325
- for coord in coords:
280
+ for coord in coordinates:
326
281
  x0, y0 = coord[0]
327
282
  try:
328
283
  x1, y1 = coord[1]
@@ -333,11 +288,8 @@ class Drawable:
333
288
  if x0 == x1 and y0 == y1:
334
289
  continue
335
290
 
336
- # Get blended color for this line segment
337
- blended_color = get_blended_color(x0, y0, x1, y1, arr, color)
338
-
339
291
  # Use the optimized line drawing method
340
- arr = Drawable._line(arr, x0, y0, x1, y1, blended_color, width)
292
+ arr = Drawable._line(arr, x0, y0, x1, y1, color, width)
341
293
 
342
294
  return arr
343
295
 
@@ -351,35 +303,31 @@ class Drawable:
351
303
  outline_width: int = 0,
352
304
  ) -> NumpyArray:
353
305
  """
354
- Draw a filled circle on the image using NumPy.
355
- Optimized to only process the bounding box of the circle.
306
+ Draw a filled circle and optional outline using mvcrender.draw.circle_u8.
307
+ If alpha<255, preblend once at the center and stamp solid.
356
308
  """
357
- y, x = center
358
- height, width = image.shape[:2]
359
-
360
- # Calculate the bounding box of the circle
361
- min_y = max(0, y - radius - outline_width)
362
- max_y = min(height, y + radius + outline_width + 1)
363
- min_x = max(0, x - radius - outline_width)
364
- max_x = min(width, x + radius + outline_width + 1)
365
-
366
- # Create coordinate arrays for the bounding box
367
- y_indices, x_indices = np.ogrid[min_y:max_y, min_x:max_x]
368
-
369
- # Calculate distances from center
370
- dist_sq = (y_indices - y) ** 2 + (x_indices - x) ** 2
309
+ cy, cx = (
310
+ int(center[0]),
311
+ int(center[1]),
312
+ ) # incoming Point is (y,x) in your codebase
313
+ h, w = image.shape[:2]
314
+ if not (0 <= cx < w and 0 <= cy < h):
315
+ return image
371
316
 
372
- # Create masks for the circle and outline
373
- circle_mask = dist_sq <= radius**2
317
+ fill_rgba = color
318
+ if fill_rgba[3] < 255:
319
+ fill_rgba = sample_and_blend_color(image, cx, cy, fill_rgba)
374
320
 
375
- # Apply the fill color
376
- image[min_y:max_y, min_x:max_x][circle_mask] = color
321
+ circle_u8(image, int(cx), int(cy), int(radius), fill_rgba, -1)
377
322
 
378
- # Draw the outline if needed
379
- if outline_width > 0 and outline_color is not None:
380
- outer_mask = dist_sq <= (radius + outline_width) ** 2
381
- outline_mask = outer_mask & ~circle_mask
382
- image[min_y:max_y, min_x:max_x][outline_mask] = outline_color
323
+ if outline_color is not None and outline_width > 0:
324
+ out_rgba = outline_color
325
+ if out_rgba[3] < 255:
326
+ out_rgba = sample_and_blend_color(image, cx, cy, out_rgba)
327
+ # outlined stroke thickness = outline_width
328
+ circle_u8(
329
+ image, int(cx), int(cy), int(radius), out_rgba, int(outline_width)
330
+ )
383
331
 
384
332
  return image
385
333
 
@@ -466,9 +414,6 @@ class Drawable:
466
414
  # Adjust points to the mask's coordinate system
467
415
  adjusted_points = [(p[0] - min_x, p[1] - min_y) for p in points]
468
416
 
469
- # Create a grid of coordinates and use it to test all points at once
470
- y_indices, x_indices = np.mgrid[0 : mask.shape[0], 0 : mask.shape[1]]
471
-
472
417
  # Test each point in the grid
473
418
  for i in range(mask.shape[0]):
474
419
  for j in range(mask.shape[1]):
@@ -545,68 +490,63 @@ class Drawable:
545
490
  angle: float,
546
491
  fill: Color,
547
492
  robot_state: str | None = None,
493
+ radius: int = 25, # user-configurable
548
494
  ) -> NumpyArray:
549
495
  """
550
- Draw the robot on a smaller array to reduce memory cost.
551
- Optimized with NumPy vectorized operations for better performance.
496
+ Draw the robot with configurable size. All elements scale with radius.
552
497
  """
553
- # Ensure coordinates are within bounds
498
+ # Minimum radius to keep things visible
499
+ radius = max(8, min(radius, 25))
500
+
554
501
  height, width = layers.shape[:2]
555
502
  if not (0 <= x < width and 0 <= y < height):
556
503
  return layers
557
504
 
558
- # Calculate the bounding box for the robot
559
- radius = 25
560
- box_size = radius * 2 + 2 # Add a small margin
561
-
562
- # Calculate the region to draw on
505
+ # Bounding box
563
506
  top_left_x = max(0, x - radius - 1)
564
507
  top_left_y = max(0, y - radius - 1)
565
508
  bottom_right_x = min(width, x + radius + 1)
566
509
  bottom_right_y = min(height, y + radius + 1)
567
510
 
568
- # Skip if the robot is completely outside the image
569
511
  if top_left_x >= bottom_right_x or top_left_y >= bottom_right_y:
570
512
  return layers
571
513
 
572
- # Create a temporary layer for the robot
573
514
  tmp_width = bottom_right_x - top_left_x
574
515
  tmp_height = bottom_right_y - top_left_y
575
516
  tmp_layer = layers[top_left_y:bottom_right_y, top_left_x:bottom_right_x].copy()
576
517
 
577
- # Calculate the robot center in the temporary layer
578
518
  tmp_x = x - top_left_x
579
519
  tmp_y = y - top_left_y
580
520
 
581
- # Calculate robot parameters
582
- r_scaled = radius // 11
583
- r_cover = r_scaled * 12
521
+ # All geometry proportional to radius
522
+ r_scaled: float = max(1.0, radius / 11.0)
523
+ r_cover = int(r_scaled * 10)
524
+ r_lidar = max(1, int(r_scaled * 3))
525
+ r_button = max(1, int(r_scaled * 1))
526
+ lidar_offset = int(radius * 0.6) # was fixed 15
527
+ button_offset = int(radius * 0.8) # was fixed 20
528
+
584
529
  lidar_angle = np.deg2rad(angle + 90)
585
- r_lidar = r_scaled * 3
586
- r_button = r_scaled * 1
587
530
 
588
- # Set colors based on robot state
589
531
  if robot_state == "error":
590
532
  outline = Drawable.ERROR_OUTLINE
591
533
  fill = Drawable.ERROR_COLOR
592
534
  else:
593
535
  outline = (fill[0] // 2, fill[1] // 2, fill[2] // 2, fill[3])
594
536
 
595
- # Draw the main robot body
537
+ # Body
596
538
  tmp_layer = Drawable._filled_circle(
597
539
  tmp_layer, (tmp_y, tmp_x), radius, fill, outline, 1
598
540
  )
599
541
 
600
- # Draw the robot direction indicator
542
+ # Direction wedge
601
543
  angle -= 90
602
- a1 = ((angle + 90) - 80) / 180 * math.pi
603
- a2 = ((angle + 90) + 80) / 180 * math.pi
604
- x1 = int(tmp_x - r_cover * math.sin(a1))
605
- y1 = int(tmp_y + r_cover * math.cos(a1))
606
- x2 = int(tmp_x - r_cover * math.sin(a2))
607
- y2 = int(tmp_y + r_cover * math.cos(a2))
608
-
609
- # Draw the direction line
544
+ a1 = np.deg2rad((angle + 90) - 80)
545
+ a2 = np.deg2rad((angle + 90) + 80)
546
+ x1 = int(tmp_x - r_cover * np.sin(a1))
547
+ y1 = int(tmp_y + r_cover * np.cos(a1))
548
+ x2 = int(tmp_x - r_cover * np.sin(a2))
549
+ y2 = int(tmp_y + r_cover * np.cos(a2))
610
550
  if (
611
551
  0 <= x1 < tmp_width
612
552
  and 0 <= y1 < tmp_height
@@ -615,25 +555,23 @@ class Drawable:
615
555
  ):
616
556
  tmp_layer = Drawable._line(tmp_layer, x1, y1, x2, y2, outline, width=1)
617
557
 
618
- # Draw the lidar indicator
619
- lidar_x = int(tmp_x + 15 * np.cos(lidar_angle))
620
- lidar_y = int(tmp_y + 15 * np.sin(lidar_angle))
558
+ # Lidar
559
+ lidar_x = int(tmp_x + lidar_offset * np.cos(lidar_angle))
560
+ lidar_y = int(tmp_y + lidar_offset * np.sin(lidar_angle))
621
561
  if 0 <= lidar_x < tmp_width and 0 <= lidar_y < tmp_height:
622
562
  tmp_layer = Drawable._filled_circle(
623
563
  tmp_layer, (lidar_y, lidar_x), r_lidar, outline
624
564
  )
625
565
 
626
- # Draw the button indicator
627
- butt_x = int(tmp_x - 20 * np.cos(lidar_angle))
628
- butt_y = int(tmp_y - 20 * np.sin(lidar_angle))
566
+ # Button
567
+ butt_x = int(tmp_x - button_offset * np.cos(lidar_angle))
568
+ butt_y = int(tmp_y - button_offset * np.sin(lidar_angle))
629
569
  if 0 <= butt_x < tmp_width and 0 <= butt_y < tmp_height:
630
570
  tmp_layer = Drawable._filled_circle(
631
571
  tmp_layer, (butt_y, butt_x), r_button, outline
632
572
  )
633
573
 
634
- # Copy the robot layer back to the main layer
635
574
  layers[top_left_y:bottom_right_y, top_left_x:bottom_right_x] = tmp_layer
636
-
637
575
  return layers
638
576
 
639
577
  @staticmethod
@@ -781,11 +719,11 @@ class Drawable:
781
719
  continue
782
720
 
783
721
  t = np.linspace(0, 1, length * 2)
784
- x_coords = np.round(x1 * (1 - t) + x2 * t).astype(int)
785
- y_coords = np.round(y1 * (1 - t) + y2 * t).astype(int)
722
+ x_coordinates = np.round(x1 * (1 - t) + x2 * t).astype(int)
723
+ y_coordinates = np.round(y1 * (1 - t) + y2 * t).astype(int)
786
724
 
787
725
  # Add line points to mask
788
- for x, y in zip(x_coords, y_coords):
726
+ for x, y in zip(x_coordinates, y_coordinates):
789
727
  if width == 1:
790
728
  mask[y, x] = True
791
729
  else:
@@ -827,7 +765,6 @@ class Drawable:
827
765
 
828
766
  # Precompute circular mask for radius
829
767
  radius = 6
830
- diameter = radius * 2 + 1
831
768
  yy, xx = np.ogrid[-radius : radius + 1, -radius : radius + 1]
832
769
  circle_mask = (xx**2 + yy**2) <= radius**2
833
770
 
@@ -842,9 +779,7 @@ class Drawable:
842
779
  continue
843
780
 
844
781
  if need_blending:
845
- obs_color = ColorsManagement.sample_and_blend_color(
846
- image, x, y, color
847
- )
782
+ obs_color = sample_and_blend_color(image, x, y, color)
848
783
  else:
849
784
  obs_color = color
850
785
 
@@ -882,11 +817,25 @@ class Drawable:
882
817
  position: bool,
883
818
  ) -> None:
884
819
  """Draw the status text on the image."""
885
- path_default_font = (
886
- "custom_components/mqtt_vacuum_camera/utils/fonts/FiraSans.ttf"
887
- )
888
- default_font = ImageFont.truetype(path_default_font, size)
889
- user_font = ImageFont.truetype(path_font, size)
820
+ module_dir = Path(__file__).resolve().parent
821
+ default_font_path = module_dir / "fonts" / "FiraSans.ttf"
822
+ # Load default font with safety fallback to PIL's built-in if missing
823
+ try:
824
+ default_font = ImageFont.truetype(str(default_font_path), size)
825
+ except OSError:
826
+ _LOGGER.warning(
827
+ "Default font not found at %s; using PIL default font",
828
+ default_font_path,
829
+ )
830
+ default_font = ImageFont.load_default()
831
+
832
+ # Use provided font directly if available; else fall back to default
833
+ user_font = default_font
834
+ if path_font:
835
+ try:
836
+ user_font = ImageFont.truetype(str(path_font), size)
837
+ except OSError:
838
+ user_font = default_font
890
839
  if position:
891
840
  x, y = 10, 10
892
841
  else:
@@ -9,8 +9,6 @@ from __future__ import annotations
9
9
  from enum import IntEnum
10
10
  from typing import Dict, List, Tuple, Union
11
11
 
12
- import numpy as np
13
-
14
12
  from .colors import DefaultColors, SupportedColor
15
13
  from .types import LOGGER
16
14