Add (simple) custom map overlay support#41
Conversation
|
Very cool! I will take a look at this shortly. (you can ignore failing test) |
There was a problem hiding this comment.
Pull request overview
Adds an optional custom floor-plan overlay feature to the Roborock map image entity. If a roborock_custom_map[...]{.webp,.png} file is present under www, media, /media, or config_dir, it is loaded, resized, alpha-composited under the live vacuum map, and optionally has its carpet/rug pattern stripped via a numpy color filter when the filename ends in _hide_rugs. Cache busting is reworked so the entity's image_last_updated reflects the custom file's mtime.
Changes:
- Replace
_rotate_imagewith a_process_imagepipeline that loads/resizes a custom image, optionally removes the carpet pattern, alpha-composites, then rotates. - Convert
image_last_updatedto a property that takes the max of reload time, coordinator update time, and the custom file's mtime; broaden theasync_imageerror handler accordingly. - Add
_get_candidate_paths,_load_custom_image, and_remove_carpet_patternhelpers, and populate initial cached map info inasync_added_to_hass.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @property | ||
| def image_last_updated(self) -> datetime | None: | ||
| """Return the time the image was last updated, dynamically busting caches.""" | ||
| base_dt = self._reload_time | ||
| coord_dt = self.coordinator.last_home_update | ||
| if coord_dt is not None: | ||
| base_dt = max(base_dt, coord_dt) | ||
|
|
||
| # Lightly scan custom image paths to find the current mtime dynamically | ||
| try: | ||
| for path in self._get_candidate_paths(): | ||
| if os.path.isfile(path): | ||
| mtime = os.path.getmtime(path) | ||
| custom_dt = datetime.fromtimestamp(mtime, tz=timezone.utc) | ||
| return max(base_dt, custom_dt) | ||
| except Exception: | ||
| pass | ||
|
|
||
| return base_dt |
| # Lightly scan custom image paths to find the current mtime dynamically | ||
| try: | ||
| for path in self._get_candidate_paths(): | ||
| if os.path.isfile(path): | ||
| mtime = os.path.getmtime(path) | ||
| custom_dt = datetime.fromtimestamp(mtime, tz=timezone.utc) | ||
| return max(base_dt, custom_dt) | ||
| except Exception: | ||
| pass |
| except Exception as err: | ||
| _LOGGER.debug( | ||
| "Failed to rotate Roborock map image: %s, returning original image", | ||
| "Failed to process Roborock map image: %s, returning original image", | ||
| err, | ||
| ) | ||
| return raw |
| except Exception as err: | ||
| _LOGGER.error("Error filtering carpet pattern: %s", err) | ||
| return img |
| try: | ||
| return await self.hass.async_add_executor_job( | ||
| self._rotate_image, raw, rotation | ||
| self._process_image, raw, rotation | ||
| ) |
| for path in paths: | ||
| if os.path.isfile(path): | ||
| try: | ||
| _LOGGER.info("Loading custom map background from %s", path) |
| """Filter out the grey and colored carpet checkerboard patterns from the foreground map.""" | ||
| try: | ||
| img = img.convert("RGBA") | ||
| import numpy as np |
| def image_last_updated(self) -> datetime | None: | ||
| """Return the time the image was last updated, dynamically busting caches.""" | ||
| base_dt = self._reload_time | ||
| coord_dt = self.coordinator.last_home_update | ||
| if coord_dt is not None: | ||
| base_dt = max(base_dt, coord_dt) |
| for path in paths: | ||
| if os.path.isfile(path): | ||
| try: | ||
| _LOGGER.info("Loading custom map background from %s", path) |
| img = Image.open(path) | ||
| img.load() | ||
| img = img.convert("RGBA") | ||
| if img.size != target_size: | ||
| img = img.resize(target_size, Image.Resampling.LANCZOS) | ||
| return img, path |
| img = img.convert("RGBA") | ||
| import numpy as np | ||
| img_arr = np.array(img) |
| raw = map_content.image_content | ||
| rotation = self._get_rotation() | ||
|
|
||
| if rotation == DEFAULT_MAP_ROTATION: | ||
| return raw | ||
|
|
||
| try: | ||
| return await self.hass.async_add_executor_job( | ||
| self._rotate_image, raw, rotation | ||
| self._process_image, raw, rotation | ||
| ) |
| except Exception as err: | ||
| _LOGGER.debug( | ||
| "Failed to rotate Roborock map image: %s, returning original image", | ||
| "Failed to process Roborock map image: %s, returning original image", | ||
| err, | ||
| ) | ||
| return raw |
|
@ishiharas theres a few comments I think I agree with copilot on, can you take a look? |
This is a PR for overlaying custom floor plan images under the existing vacuum map. The code is pretty simple.
No dependencies on other repositories.
How it works:
Optional Rug Filtering:
One issue with the official integration is that detected rugs/carpets still can't be switched off, which puts a checkerboard pattern over your custom floor plan.
To solve this, I added a self-contained helper method with numpy to filter them out. This method will only trigger if the custom floor plan filename ends with the specific suffix _hide_rugs (for example, roborock_custom_map_hide_rugs.webp).
This way, we have no dependencies on other repositories or cards, and the code will probably continue to work even if something changes in the official integration.
Please check it out, maybe you like it.
Example Images:
(The first image was done with Sweet Home 3D)
(A small bonus: Changing the floor plans with AI to a pixelated map of your floor works great :D).