Skip to content

Add (simple) custom map overlay support#41

Open
ishiharas wants to merge 2 commits into
Lash-L:mainfrom
ishiharas:feature/custom-image-overlay
Open

Add (simple) custom map overlay support#41
ishiharas wants to merge 2 commits into
Lash-L:mainfrom
ishiharas:feature/custom-image-overlay

Conversation

@ishiharas

@ishiharas ishiharas commented Jun 1, 2026

Copy link
Copy Markdown

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:

  • The implementation works by placing a file (like roborock_custom_map.webp or roborock_custom_map.png) in one of the Home Assistant folders (such as www or media).
  • If the file doesn't exist, the integration falls back to the original behavior and nothing changes.
  • To make it work, you only need to disable "Floors" (rooms/walls/background) on the official Roborock integration settings so that the background layer becomes transparent. (Home Assistant -> Devices -> Roborock -> Cogwheel)
  • The new custom floor plan image should have the same width and height as the original source image, that was provided by the roborock integration.

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:

image image

(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).

@Lash-L

Lash-L commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Very cool! I will take a look at this shortly. (you can ignore failing test)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_image with a _process_image pipeline that loads/resizes a custom image, optionally removes the carpet pattern, alpha-composites, then rotates.
  • Convert image_last_updated to a property that takes the max of reload time, coordinator update time, and the custom file's mtime; broaden the async_image error handler accordingly.
  • Add _get_candidate_paths, _load_custom_image, and _remove_carpet_pattern helpers, and populate initial cached map info in async_added_to_hass.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +132 to +150
@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
Comment on lines +140 to +148
# 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
Comment on lines +346 to 351
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
Comment on lines +276 to +278
except Exception as err:
_LOGGER.error("Error filtering carpet pattern: %s", err)
return img
Comment on lines 342 to 345
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 6 comments.

Comment on lines +134 to +139
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)
Comment on lines +237 to +242
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
Comment on lines +251 to +253
img = img.convert("RGBA")
import numpy as np
img_arr = np.array(img)
Comment on lines 346 to 352
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
)
Comment on lines +353 to 358
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
@Lash-L

Lash-L commented Jun 21, 2026

Copy link
Copy Markdown
Owner

@ishiharas theres a few comments I think I agree with copilot on, can you take a look?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants