From c7cf6f540bd72106a7ffa0bfdeb0884f085fa7d0 Mon Sep 17 00:00:00 2001 From: sytchi <1294953+sytchi@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:07:28 +0200 Subject: [PATCH 1/2] perf(map): build the overlay frame from the accumulated strip render_overlay allocated a fresh supersampled RGBA canvas every frame and composited the accumulated vacuumed strip onto it with the in-place Image.alpha_composite() method. On a 200x271 map at scale 4 that canvas is 1600x2168 (3.5 Mpx), and the in-place method is implemented as crop + composite + paste, so a single strip composite made three full passes over it plus two allocations. Nothing sits under the strip unless a cleaned-area tint or a target zone is being drawn, and compositing over a fully transparent canvas is the identity (extend_swath_layer leaves uncovered pixels at exactly (0,0,0,0)). So the usual frame now starts from a copy of the strip, and the built-up path uses the module-level alpha_composite(), which runs the same C routine in one pass. Output is unchanged in every branch. Measured on the target host (HAOS x86_64, 4 cores, Pillow 12.2, live map and live accumulators, 30 back-to-back render pairs): 548 ms -> 509 ms median, 44 ms saved per frame, decoded-pixel hashes identical. --- .../narwal/narwal_client/map_renderer.py | 107 +++++++++++------- narwal_client/map_renderer.py | 107 +++++++++++------- tests/test_incremental_layers.py | 94 ++++++++++++++- 3 files changed, 228 insertions(+), 80 deletions(-) diff --git a/custom_components/narwal/narwal_client/map_renderer.py b/custom_components/narwal/narwal_client/map_renderer.py index 87995c2..a175ea9 100644 --- a/custom_components/narwal/narwal_client/map_renderer.py +++ b/custom_components/narwal/narwal_client/map_renderer.py @@ -708,6 +708,12 @@ def extend_swath_layer( makes each frame O(new). Output is identical to drawing all quads on a fresh layer: the fill is a constant colour and ImageDraw REPLACES pixels, so re-drawing overlaps would be a no-op anyway. + + Invariant relied on by ``render_overlay``: every pixel this layer does not + cover is exactly (0, 0, 0, 0) — the fill colour is opaque enough that a + drawn pixel never has alpha 0. That is what makes compositing this layer + onto a transparent canvas a no-op, and lets render_overlay start a frame + from a copy of it instead. """ from PIL import Image, ImageDraw @@ -966,50 +972,75 @@ def render_overlay( from PIL import Image, ImageDraw s = scale * max(1, supersample) - layer = Image.new("RGBA", (grid_width * s, grid_height * s), (0, 0, 0, 0)) - draw = ImageDraw.Draw(layer) + size = (grid_width * s, grid_height * s) def to_img(gx: float, gy: float) -> tuple[float, float]: return ((gx + 0.5) * s, (grid_height - 0.5 - gy) * s) - # Cleaned-area tint (bottom-most overlay) - if cleaned_mask is not None: - mask = cleaned_mask.transpose(Image.FLIP_TOP_BOTTOM).resize( - layer.size, Image.NEAREST, - ) - layer.paste(COLOR_CLEANED_TINT, mask=mask) - - # Target zones (semi-transparent amber fill + solid outline) — drawn UNDER - # the vacuumed strip and lidar so cleaning progress and walls show ON TOP of - # the highlighted zone instead of being hidden by the amber fill. - if zones: - for x_min, y_min, x_max, y_max in zones: - gx0, gx1 = min(x_min, x_max), max(x_min, x_max) - gy0, gy1 = min(y_min, y_max), max(y_min, y_max) - px0 = gx0 * s - px1 = (gx1 + 1) * s - 1 - py0 = (grid_height - 1 - gy1) * s - py1 = (grid_height - gy0) * s - 1 - draw.rectangle( - [px0, py0, px1, py1], - fill=COLOR_ZONE_FILL, - outline=COLOR_ZONE_OUTLINE, - width=2 * s, - ) + # Bottom of the overlay stack: the cleaned-area tint, then the target + # zones, then the accumulated vacuumed strip. + # + # In the usual frame there is neither a cleaned-area tint nor a zone, so + # everything below the strip is fully transparent and the strip IS the + # bottom layer. Compositing it onto a freshly allocated transparent canvas + # then yields the strip back unchanged (see ``extend_swath_layer``: its + # transparent pixels are literally (0, 0, 0, 0)), so start from a copy of + # it instead and skip both the allocation and a full-canvas composite of + # the supersampled layer — the two most expensive fixed costs of a frame. + if swath_layer is not None and cleaned_mask is None and not zones: + layer = swath_layer.copy() + else: + layer = Image.new("RGBA", size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) - # Vacuumed strip: quads between the field-12 rail pair — the robot's - # own record of the freshly vacuumed 11.4 cm track. ImageDraw on an - # RGBA layer REPLACES pixels, so overlapping quads don't stack. - # A pre-accumulated ``swath_layer`` (built incrementally by the camera so - # per-frame cost stays O(new quads) instead of O(total)) is composited - # directly; ``swath_strips`` is the stateless fallback for callers/tests. - if swath_layer is not None: - layer.alpha_composite(swath_layer) - elif swath_strips: - for quad in swath_strips: - draw.polygon( - [to_img(px, py) for px, py in quad], fill=COLOR_TRAIL_STRIP, + # Cleaned-area tint (bottom-most overlay) + if cleaned_mask is not None: + mask = cleaned_mask.transpose(Image.FLIP_TOP_BOTTOM).resize( + size, Image.NEAREST, ) + layer.paste(COLOR_CLEANED_TINT, mask=mask) + + # Target zones (semi-transparent amber fill + solid outline) — drawn + # UNDER the vacuumed strip and lidar so cleaning progress and walls show + # ON TOP of the highlighted zone instead of being hidden by the amber + # fill. + if zones: + for x_min, y_min, x_max, y_max in zones: + gx0, gx1 = min(x_min, x_max), max(x_min, x_max) + gy0, gy1 = min(y_min, y_max), max(y_min, y_max) + px0 = gx0 * s + px1 = (gx1 + 1) * s - 1 + py0 = (grid_height - 1 - gy1) * s + py1 = (grid_height - gy0) * s - 1 + draw.rectangle( + [px0, py0, px1, py1], + fill=COLOR_ZONE_FILL, + outline=COLOR_ZONE_OUTLINE, + width=2 * s, + ) + + # Vacuumed strip: quads between the field-12 rail pair — the robot's + # own record of the freshly vacuumed 11.4 cm track. ImageDraw on an + # RGBA layer REPLACES pixels, so overlapping quads don't stack. + # A pre-accumulated ``swath_layer`` (built incrementally by the camera + # so per-frame cost stays O(new quads) instead of O(total)) is + # composited directly; ``swath_strips`` is the stateless fallback for + # callers/tests. + if swath_layer is not None: + # The module-level alpha_composite() runs the same C routine as the + # in-place Image.alpha_composite() method but in ONE pass: the + # method is implemented as crop + composite + paste, i.e. three + # full-canvas passes over a 4×-supersampled RGBA buffer. + layer = Image.alpha_composite(layer, swath_layer) + elif swath_strips: + for quad in swath_strips: + draw.polygon( + [to_img(px, py) for px, py in quad], fill=COLOR_TRAIL_STRIP, + ) + + # alpha_composite() above returns a NEW image, so bind the drawing context + # to whatever `layer` ended up being. + draw = ImageDraw.Draw(layer) # Lidar wall/obstacle observations (field 7) — cell marks refining the # rasterized walls with what the robot actually measured. Each mark diff --git a/narwal_client/map_renderer.py b/narwal_client/map_renderer.py index 87995c2..a175ea9 100644 --- a/narwal_client/map_renderer.py +++ b/narwal_client/map_renderer.py @@ -708,6 +708,12 @@ def extend_swath_layer( makes each frame O(new). Output is identical to drawing all quads on a fresh layer: the fill is a constant colour and ImageDraw REPLACES pixels, so re-drawing overlaps would be a no-op anyway. + + Invariant relied on by ``render_overlay``: every pixel this layer does not + cover is exactly (0, 0, 0, 0) — the fill colour is opaque enough that a + drawn pixel never has alpha 0. That is what makes compositing this layer + onto a transparent canvas a no-op, and lets render_overlay start a frame + from a copy of it instead. """ from PIL import Image, ImageDraw @@ -966,50 +972,75 @@ def render_overlay( from PIL import Image, ImageDraw s = scale * max(1, supersample) - layer = Image.new("RGBA", (grid_width * s, grid_height * s), (0, 0, 0, 0)) - draw = ImageDraw.Draw(layer) + size = (grid_width * s, grid_height * s) def to_img(gx: float, gy: float) -> tuple[float, float]: return ((gx + 0.5) * s, (grid_height - 0.5 - gy) * s) - # Cleaned-area tint (bottom-most overlay) - if cleaned_mask is not None: - mask = cleaned_mask.transpose(Image.FLIP_TOP_BOTTOM).resize( - layer.size, Image.NEAREST, - ) - layer.paste(COLOR_CLEANED_TINT, mask=mask) - - # Target zones (semi-transparent amber fill + solid outline) — drawn UNDER - # the vacuumed strip and lidar so cleaning progress and walls show ON TOP of - # the highlighted zone instead of being hidden by the amber fill. - if zones: - for x_min, y_min, x_max, y_max in zones: - gx0, gx1 = min(x_min, x_max), max(x_min, x_max) - gy0, gy1 = min(y_min, y_max), max(y_min, y_max) - px0 = gx0 * s - px1 = (gx1 + 1) * s - 1 - py0 = (grid_height - 1 - gy1) * s - py1 = (grid_height - gy0) * s - 1 - draw.rectangle( - [px0, py0, px1, py1], - fill=COLOR_ZONE_FILL, - outline=COLOR_ZONE_OUTLINE, - width=2 * s, - ) + # Bottom of the overlay stack: the cleaned-area tint, then the target + # zones, then the accumulated vacuumed strip. + # + # In the usual frame there is neither a cleaned-area tint nor a zone, so + # everything below the strip is fully transparent and the strip IS the + # bottom layer. Compositing it onto a freshly allocated transparent canvas + # then yields the strip back unchanged (see ``extend_swath_layer``: its + # transparent pixels are literally (0, 0, 0, 0)), so start from a copy of + # it instead and skip both the allocation and a full-canvas composite of + # the supersampled layer — the two most expensive fixed costs of a frame. + if swath_layer is not None and cleaned_mask is None and not zones: + layer = swath_layer.copy() + else: + layer = Image.new("RGBA", size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) - # Vacuumed strip: quads between the field-12 rail pair — the robot's - # own record of the freshly vacuumed 11.4 cm track. ImageDraw on an - # RGBA layer REPLACES pixels, so overlapping quads don't stack. - # A pre-accumulated ``swath_layer`` (built incrementally by the camera so - # per-frame cost stays O(new quads) instead of O(total)) is composited - # directly; ``swath_strips`` is the stateless fallback for callers/tests. - if swath_layer is not None: - layer.alpha_composite(swath_layer) - elif swath_strips: - for quad in swath_strips: - draw.polygon( - [to_img(px, py) for px, py in quad], fill=COLOR_TRAIL_STRIP, + # Cleaned-area tint (bottom-most overlay) + if cleaned_mask is not None: + mask = cleaned_mask.transpose(Image.FLIP_TOP_BOTTOM).resize( + size, Image.NEAREST, ) + layer.paste(COLOR_CLEANED_TINT, mask=mask) + + # Target zones (semi-transparent amber fill + solid outline) — drawn + # UNDER the vacuumed strip and lidar so cleaning progress and walls show + # ON TOP of the highlighted zone instead of being hidden by the amber + # fill. + if zones: + for x_min, y_min, x_max, y_max in zones: + gx0, gx1 = min(x_min, x_max), max(x_min, x_max) + gy0, gy1 = min(y_min, y_max), max(y_min, y_max) + px0 = gx0 * s + px1 = (gx1 + 1) * s - 1 + py0 = (grid_height - 1 - gy1) * s + py1 = (grid_height - gy0) * s - 1 + draw.rectangle( + [px0, py0, px1, py1], + fill=COLOR_ZONE_FILL, + outline=COLOR_ZONE_OUTLINE, + width=2 * s, + ) + + # Vacuumed strip: quads between the field-12 rail pair — the robot's + # own record of the freshly vacuumed 11.4 cm track. ImageDraw on an + # RGBA layer REPLACES pixels, so overlapping quads don't stack. + # A pre-accumulated ``swath_layer`` (built incrementally by the camera + # so per-frame cost stays O(new quads) instead of O(total)) is + # composited directly; ``swath_strips`` is the stateless fallback for + # callers/tests. + if swath_layer is not None: + # The module-level alpha_composite() runs the same C routine as the + # in-place Image.alpha_composite() method but in ONE pass: the + # method is implemented as crop + composite + paste, i.e. three + # full-canvas passes over a 4×-supersampled RGBA buffer. + layer = Image.alpha_composite(layer, swath_layer) + elif swath_strips: + for quad in swath_strips: + draw.polygon( + [to_img(px, py) for px, py in quad], fill=COLOR_TRAIL_STRIP, + ) + + # alpha_composite() above returns a NEW image, so bind the drawing context + # to whatever `layer` ended up being. + draw = ImageDraw.Draw(layer) # Lidar wall/obstacle observations (field 7) — cell marks refining the # rasterized walls with what the robot actually measured. Each mark diff --git a/tests/test_incremental_layers.py b/tests/test_incremental_layers.py index 5cbccba..507766d 100644 --- a/tests/test_incremental_layers.py +++ b/tests/test_incremental_layers.py @@ -74,10 +74,14 @@ def _pixels(png: bytes) -> bytes: return Image.open(io.BytesIO(png)).convert("RGB").tobytes() -def _render_incremental(cells, quads, *, chunks=5, show_swath=True, show_lidar=True): +def _render_incremental(cells, quads, *, chunks=5, show_swath=True, + show_lidar=True, zones=None, cleaned_mask=None): """Feed cells/quads in `chunks` frames (mimicking accumulation) and return the final PNG, exactly as the camera drives render_map_frame.""" base = _varied_base() + overlay = {**_OVERLAY, "zones": zones} + if cleaned_mask is not None: + overlay["cleaned_mask"] = cleaned_mask swath_layer = lidar_layer = lidar_mask = None drawn: set[tuple[int, int]] = set() png = b"" @@ -92,19 +96,23 @@ def _render_incremental(cells, quads, *, chunks=5, show_swath=True, show_lidar=T lidar_layer=lidar_layer, lidar_mask=lidar_mask, new_wall_cells=new_cells, show_lidar=show_lidar, - overlay_kwargs=_OVERLAY, + overlay_kwargs=overlay, ) drawn.update(new_cells) return png -def _render_full(cells, quads, *, show_swath=True, show_lidar=True): +def _render_full(cells, quads, *, show_swath=True, show_lidar=True, + zones=None, cleaned_mask=None): base = _varied_base() + overlay = {**_OVERLAY, "zones": zones} + if cleaned_mask is not None: + overlay["cleaned_mask"] = cleaned_mask return render_overlay( base, GW, GH, scale=SCALE, wall_cells=list(cells) if show_lidar else None, swath_strips=quads if show_swath else None, - **_OVERLAY, + **overlay, ) @@ -137,6 +145,84 @@ def test_switch_off_hides_layer(self) -> None: _pixels(_render_full([], quads, show_lidar=False)) +class TestBuiltUpBranchStillDrawsOnTop: + """A zone highlight or a cleaned-area tint sits UNDER the strip, so those + frames cannot start from the accumulated strip and are built up on a + transparent canvas instead — during which ``alpha_composite`` replaces the + layer object. Everything drawn afterwards (planned path, trail, dock, + robot) has to land on the composited image, not on the discarded one.""" + + def _variants(self): + mask = Image.new("L", (GW, GH), 0) + mask.paste(255, (5, 5, 25, 35)) + return [ + ("zones", {"zones": [(4, 4, 20, 30), (24, 32, 36, 46)]}), + ("cleaned_mask", {"cleaned_mask": mask}), + ] + + def _frame(self, base, layer, lidar, lmask, **over): + overlay = {**_OVERLAY, **over} + return _pixels(render_overlay( + base, GW, GH, scale=SCALE, + swath_layer=layer, lidar_layer=lidar, lidar_mask=lmask, + **overlay, + )) + + def _fixtures(self): + base = _varied_base() + layer = extend_swath_layer(None, GW, GH, SCALE, _quads(150)) + lidar, lmask = extend_lidar_layer( + None, None, GW, GH, SCALE, base, _cells(300), + ) + return base, layer, lidar, lmask + + def test_trail_still_drawn_over_the_composite(self) -> None: + base, layer, lidar, lmask = self._fixtures() + for name, kw in self._variants(): + on = self._frame(base, layer, lidar, lmask, + show_trail_line=True, **kw) + off = self._frame(base, layer, lidar, lmask, + show_trail_line=False, **kw) + assert on != off, f"trail lost in the {name} branch" + + def test_robot_still_drawn_over_the_composite(self) -> None: + base, layer, lidar, lmask = self._fixtures() + for name, kw in self._variants(): + a = self._frame(base, layer, lidar, lmask, robot_x=12.0, **kw) + b = self._frame(base, layer, lidar, lmask, robot_x=30.0, **kw) + assert a != b, f"robot lost in the {name} branch" + + +class TestSwathLayerTransparencyInvariant: + """``render_overlay`` starts a frame from a copy of the accumulated strip + instead of compositing it onto a freshly allocated transparent canvas. + That is only equivalent because every pixel the strip does not cover is + exactly (0, 0, 0, 0) — compositing a colour that is invisible (alpha 0) + onto transparency would NOT round-trip through alpha_composite.""" + + def test_uncovered_pixels_are_fully_zero(self) -> None: + layer = extend_swath_layer(None, GW, GH, SCALE, _quads(150)) + zero_alpha_but_coloured = [ + px for px in layer.getdata() if px[3] == 0 and px[:3] != (0, 0, 0) + ] + assert zero_alpha_but_coloured == [] + + def test_drawn_pixels_are_never_alpha_zero(self) -> None: + blank = extend_swath_layer(None, GW, GH, SCALE, []) + drawn = extend_swath_layer(None, GW, GH, SCALE, _quads(150)) + assert drawn.tobytes() != blank.tobytes() # something was drawn + alphas = {px[3] for px in drawn.getdata()} + assert alphas - {0}, "the strip fill must be visible" + assert 0 in alphas, "the strip must not cover the whole canvas" + + def test_copy_of_layer_equals_composite_onto_transparent(self) -> None: + # The exact substitution render_overlay makes, pinned on this layer. + s = SCALE * OVERLAY_SUPERSAMPLE + layer = extend_swath_layer(None, GW, GH, SCALE, _quads(150)) + blank = Image.new("RGBA", (GW * s, GH * s), (0, 0, 0, 0)) + assert layer.copy().tobytes() == Image.alpha_composite(blank, layer).tobytes() + + class TestExtendLayers: def test_layer_created_at_supersample_size(self) -> None: base = _varied_base() From 38d3109db94b4a0607c34b38dd24a65d4c3891e3 Mon Sep 17 00:00:00 2001 From: sytchi <1294953+sytchi@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:35:43 +0200 Subject: [PATCH 2/2] chore(release): v2.2.0 Map render: start the overlay frame from a copy of the accumulated vacuumed strip instead of allocating a canvas and compositing the strip onto it, and use the module-level Image.alpha_composite in the zone branch instead of the in-place method (Pillow implements it as crop + composite + paste, three passes over a 13.9 MB buffer). Output is byte-identical, verified pixel-for-pixel on real map data. About 44 ms less per rendered frame on a Home Assistant host, roughly -8%. --- CHANGELOG.md | 14 ++++++++++++++ custom_components/narwal/manifest.json | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c456a04..3fe510c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.2.0] - 2026-07-30 + +> ⚠️ Upgrading from 1.x? See the [2.0.0](#200---2026-07-25) breaking changes. + +### Changed +- Map render: the overlay frame now starts from a copy of the accumulated + vacuumed strip instead of allocating a canvas and compositing the strip onto it. +- Map render: the zone branch uses the module-level `Image.alpha_composite` + instead of the in-place method, which Pillow implements as crop + composite + + paste, three passes over a 13.9 MB buffer. + +Rendered output is byte-identical (verified pixel-for-pixel on real map data). +About 44 ms less per rendered frame on a Home Assistant host, roughly -8%. + ## [2.1.2] - 2026-07-29 > ⚠️ Upgrading from 1.x? See the [2.0.0](#200---2026-07-25) breaking changes. diff --git a/custom_components/narwal/manifest.json b/custom_components/narwal/manifest.json index 122016e..2610832 100644 --- a/custom_components/narwal/manifest.json +++ b/custom_components/narwal/manifest.json @@ -15,5 +15,5 @@ "bbpb>=1.4.0", "Pillow>=9.0.0" ], - "version": "2.1.2" + "version": "2.2.0" }