Skip to content

Commit 2ec0591

Browse files
committed
Add custom map overlay support
1 parent 52b4ffe commit 2ec0591

1 file changed

Lines changed: 142 additions & 12 deletions

File tree

  • custom_components/roborock_custom_map

custom_components/roborock_custom_map/image.py

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
from __future__ import annotations
44

5-
from datetime import datetime
5+
from datetime import datetime, timezone
66
import io
77
import logging
8+
import os
89

9-
from PIL import Image, UnidentifiedImageError
10+
from PIL import Image
1011
from roborock.devices.traits.v1.home import HomeTrait
1112
from roborock.devices.traits.v1.map_content import MapContent
1213

@@ -121,12 +122,56 @@ def __init__(
121122
self._raw_image_size: tuple[int, int] | None = None
122123

123124
self._attr_entity_category = EntityCategory.DIAGNOSTIC
125+
self._reload_time = dt_util.utcnow()
124126

125127
@property
126128
def is_selected(self) -> bool:
127129
"""Return if this map is the currently selected map."""
128130
return self.map_flag == self.coordinator.properties_api.maps.current_map
129131

132+
@property
133+
def image_last_updated(self) -> datetime | None:
134+
"""Return the time the image was last updated, dynamically busting caches."""
135+
base_dt = self._reload_time
136+
coord_dt = self.coordinator.last_home_update
137+
if coord_dt is not None:
138+
base_dt = max(base_dt, coord_dt)
139+
140+
# Lightly scan custom image paths to find the current mtime dynamically
141+
try:
142+
for path in self._get_candidate_paths():
143+
if os.path.isfile(path):
144+
mtime = os.path.getmtime(path)
145+
custom_dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
146+
return max(base_dt, custom_dt)
147+
except Exception:
148+
pass
149+
150+
return base_dt
151+
152+
def _get_candidate_paths(self) -> list[str]:
153+
"""Return the list of candidate paths for the custom map image."""
154+
config_dir = self.hass.config.config_dir
155+
search_dirs = [
156+
os.path.join(config_dir, "www"),
157+
os.path.join(config_dir, "media"),
158+
"/media",
159+
config_dir,
160+
]
161+
basenames = [
162+
f"roborock_custom_map_{self.map_flag}_hide_rugs",
163+
f"roborock_custom_map_{self.map_flag}",
164+
"roborock_custom_map_hide_rugs",
165+
"roborock_custom_map",
166+
]
167+
extensions = [".webp", ".png"]
168+
return [
169+
os.path.join(sdir, f"{base}{ext}")
170+
for sdir in search_dirs
171+
for base in basenames
172+
for ext in extensions
173+
]
174+
130175
@property
131176
def _map_content(self) -> MapContent | None:
132177
if self._home_trait.home_map_content and (
@@ -150,6 +195,11 @@ async def async_added_to_hass(self) -> None:
150195
)
151196
)
152197

198+
# Populate initial map info if already loaded
199+
if (map_content := self._map_content) is not None:
200+
self.cached_map = map_content.image_content
201+
self._raw_image_size = _png_dimensions(self.cached_map)
202+
153203
self.async_write_ha_state()
154204

155205
def _handle_rotation_changed(self) -> None:
@@ -169,10 +219,93 @@ def _handle_coordinator_update(self) -> None:
169219

170220
super()._handle_coordinator_update()
171221

172-
def _rotate_image(self, raw: bytes, rotation: int) -> bytes:
173-
"""Rotate image in executor thread."""
222+
def _load_custom_image(self, target_size: tuple[int, int]) -> tuple[Image.Image, str] | tuple[None, None]:
223+
"""Find, load, and resize a custom map image from the filesystem on demand."""
224+
paths = self._get_candidate_paths()
225+
226+
for path in paths:
227+
if os.path.isfile(path):
228+
try:
229+
_LOGGER.info("Loading custom map background from %s", path)
230+
img = Image.open(path)
231+
img.load()
232+
img = img.convert("RGBA")
233+
if img.size != target_size:
234+
img = img.resize(target_size, Image.Resampling.LANCZOS)
235+
return img, path
236+
except Exception as err:
237+
_LOGGER.error("Failed to load custom map image from %s: %s", path, err)
238+
239+
return None, None
240+
241+
def _remove_carpet_pattern(self, img: Image.Image) -> Image.Image:
242+
"""Filter out the grey and colored carpet checkerboard patterns from the foreground map."""
243+
try:
244+
img = img.convert("RGBA")
245+
import numpy as np
246+
img_arr = np.array(img)
247+
r = img_arr[:, :, 0].astype(int)
248+
g = img_arr[:, :, 1].astype(int)
249+
b = img_arr[:, :, 2].astype(int)
250+
a = img_arr[:, :, 3].astype(int)
251+
252+
# Grey condition: R, G, B components are very close to each other
253+
max_val = np.maximum(np.maximum(r, g), b)
254+
min_val = np.minimum(np.minimum(r, g), b)
255+
is_grey = (max_val - min_val < 15)
256+
257+
# Green carpet condition: (169, 247, 169)
258+
is_green_carpet = (np.abs(r - 169) < 15) & (np.abs(g - 247) < 15) & (np.abs(b - 169) < 15)
259+
260+
# Sage/teal carpet condition: (101, 181, 170)
261+
is_sage_carpet = (np.abs(r - 101) < 15) & (np.abs(g - 181) < 15) & (np.abs(b - 170) < 15)
262+
263+
# Combine all carpet detections
264+
is_carpet = is_grey | is_green_carpet | is_sage_carpet
265+
266+
# Protect pure white outlines/paths
267+
is_not_white = (r < 240) | (g < 240) | (b < 240)
268+
269+
# Protect dark black outlines
270+
is_not_black = (r > 40) | (g > 40) | (b > 40)
271+
272+
# Make carpet pattern pixels transparent
273+
carpet_mask = is_carpet & is_not_white & is_not_black & (a > 0)
274+
img_arr[carpet_mask] = [0, 0, 0, 0]
275+
return Image.fromarray(img_arr)
276+
except Exception as err:
277+
_LOGGER.error("Error filtering carpet pattern: %s", err)
278+
return img
279+
280+
def _process_image(self, raw: bytes, rotation: int) -> bytes:
281+
"""Overlay custom map image and rotate, stateless on demand."""
174282
img = Image.open(io.BytesIO(raw))
175-
img = img.rotate(rotation, expand=True)
283+
target_size = img.size
284+
285+
custom_img, custom_path = self._load_custom_image(target_size)
286+
287+
# Check if we should filter out carpets based on the file name indicator
288+
filter_carpet = False
289+
if custom_path:
290+
filename = os.path.basename(custom_path).lower()
291+
name_part, _ = os.path.splitext(filename)
292+
if name_part.endswith("_hide_rugs"):
293+
filter_carpet = True
294+
295+
if custom_img is not None:
296+
try:
297+
if filter_carpet:
298+
img = self._remove_carpet_pattern(img)
299+
else:
300+
img = img.convert("RGBA")
301+
302+
# Simple and fast alpha composite overlay!
303+
img = Image.alpha_composite(custom_img, img)
304+
except Exception as err:
305+
_LOGGER.error("Error overlaying custom map image: %s", err)
306+
307+
if rotation != 0:
308+
img = img.rotate(rotation, expand=True)
176309

177310
out = io.BytesIO()
178311
img.save(out, format="PNG")
@@ -199,23 +332,20 @@ def _get_rotation(self) -> int:
199332
return rotation
200333

201334
async def async_image(self) -> bytes | None:
202-
"""Get the image (with optional rotation)."""
335+
"""Get the image (with optional rotation and custom overlay)."""
203336
if (map_content := self._map_content) is None:
204337
raise HomeAssistantError("Map flag not found in coordinator maps")
205338

206339
raw = map_content.image_content
207340
rotation = self._get_rotation()
208341

209-
if rotation == DEFAULT_MAP_ROTATION:
210-
return raw
211-
212342
try:
213343
return await self.hass.async_add_executor_job(
214-
self._rotate_image, raw, rotation
344+
self._process_image, raw, rotation
215345
)
216-
except (OSError, UnidentifiedImageError) as err:
346+
except Exception as err:
217347
_LOGGER.debug(
218-
"Failed to rotate Roborock map image: %s, returning original image",
348+
"Failed to process Roborock map image: %s, returning original image",
219349
err,
220350
)
221351
return raw

0 commit comments

Comments
 (0)