diff --git a/roborock/devices/traits/b01/q10/map.py b/roborock/devices/traits/b01/q10/map.py index a33c2df22..c4e27f22d 100644 --- a/roborock/devices/traits/b01/q10/map.py +++ b/roborock/devices/traits/b01/q10/map.py @@ -17,11 +17,14 @@ from dataclasses import dataclass, field from typing import Any +from vacuum_map_parser_base.map_data import MapData + from roborock.data import RoborockBase from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener from roborock.exceptions import RoborockException +from roborock.map.b01_grid_layers import GridCalibration from roborock.map.b01_q10_map_parser import ( B01Q10MapParserConfig, Q10MapPacket, @@ -30,7 +33,12 @@ Q10TracePacket, ) from roborock.map.b01_q10_overlays import parse_virtual_wall_blob, parse_zone_blob -from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map +from roborock.map.b01_q10_render import ( + Q10MapOverlays, + build_base_q10_map_data, + render_q10_map, + solve_q10_calibration, +) from .command import CommandTrait from .common import UpdatableTrait @@ -105,6 +113,8 @@ def __init__( self._map_packet: Q10MapPacket | None = None self._trace_packet: Q10TracePacket | None = None self._image_content: bytes | None = None + self._base_map_data: MapData | None = None + self._trace_calibration: GridCalibration | None = None self._map_dps.add_update_listener(self._map_dps_updated) async def refresh(self) -> None: @@ -151,6 +161,8 @@ def robot_heading(self) -> int | None: def update_from_map_packet(self, packet: Q10MapPacket) -> None: """Store a map-protocol update and render the latest sources.""" self._map_packet = packet + self._base_map_data = None + self._trace_calibration = None self._render() self._notify_update() @@ -164,6 +176,8 @@ def _map_dps_updated(self) -> None: """Render after the low-level map DPS source changes.""" if self._map_packet is None: return + self._base_map_data = None + self._trace_calibration = None self._render() self._notify_update() @@ -172,12 +186,25 @@ def _render(self) -> None: if self._map_packet is None: return try: + trace = self._trace_packet if not self._map_dps.robot_at_dock else None + if self._trace_calibration is None and trace is not None: + self._trace_calibration = solve_q10_calibration(self._map_packet, trace) + if self._trace_calibration is not None and self._map_packet.erase_zones: + self._base_map_data = None + if self._base_map_data is None: + self._base_map_data = build_base_q10_map_data( + self._map_packet, + config=self._config, + trace_calibration=self._trace_calibration, + ) self._image_content = render_q10_map( self._map_packet, - self._trace_packet if not self._map_dps.robot_at_dock else None, + trace, self._map_dps.overlays, config=self._config, robot_at_dock=self._map_dps.robot_at_dock, + base_map_data=self._base_map_data, + trace_calibration=self._trace_calibration, ) except RoborockException as ex: _LOGGER.debug("Failed to render Q10 map packet: %s", ex) diff --git a/roborock/map/b01_q10_map_parser.py b/roborock/map/b01_q10_map_parser.py index e3c7d61b2..416b7c9ae 100644 --- a/roborock/map/b01_q10_map_parser.py +++ b/roborock/map/b01_q10_map_parser.py @@ -25,6 +25,7 @@ import statistics import struct from dataclasses import dataclass, field, replace +from functools import cached_property from PIL import Image from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor @@ -238,7 +239,7 @@ class Q10MapPacket: the same (top-down) pixel space as :attr:`grid`, where a non-zero cell is carpet (the value is the carpet kind). ``None`` if the packet carried none.""" - @property + @cached_property def layers(self) -> GridLayers: """Split the occupancy grid into separable grid-pixel layers.""" rooms = [(room.id, room.name, room.pixel_value, room.pixel_count) for room in self.rooms] diff --git a/roborock/map/b01_q10_render.py b/roborock/map/b01_q10_render.py index 1aaaac627..2feb95354 100644 --- a/roborock/map/b01_q10_render.py +++ b/roborock/map/b01_q10_render.py @@ -13,6 +13,7 @@ drawing) and the calibration policy live here, next to the rest of the map code. """ +import copy import io import math from collections.abc import Sequence @@ -82,6 +83,23 @@ class Q10MapOverlays: virtual_walls: Sequence[Q10Zone] = () +def build_base_q10_map_data( + packet: Q10MapPacket, + *, + config: B01Q10MapParserConfig, + trace_calibration: GridCalibration | None = None, +) -> MapData: + """Build reusable MapData containing the rendered base grid image.""" + parser = B01Q10MapParser(config) + vector_calibration = _vector_calibration(packet, trace_calibration) + render_packet = packet + if vector_calibration is not None: + cells = _erased_cells(packet.layers, packet.erase_zones, vector_calibration) + if cells: + render_packet = erased_packet(packet, cells) + return parser.map_data_from_packet(render_packet) + + def render_q10_map( packet: Q10MapPacket, trace: Q10TracePacket | Q10HistoricalTracePacket | None, @@ -89,6 +107,8 @@ def render_q10_map( *, config: B01Q10MapParserConfig, robot_at_dock: bool = False, + base_map_data: MapData | None = None, + trace_calibration: GridCalibration | None = None, ) -> bytes: """Compose the latest map, trace and DPS inputs into one PNG image. @@ -97,22 +117,20 @@ def render_q10_map( available trace and DPS overlays are projected and drawn in pixel space. Raises :class:`RoborockException` if map rendering fails. """ - parser = B01Q10MapParser(config) - trace_calibration = solve_q10_calibration(packet, trace) + if trace_calibration is None: + trace_calibration = solve_q10_calibration(packet, trace) vector_calibration = _vector_calibration(packet, trace_calibration) - render_packet = packet - if vector_calibration is not None: - cells = _erased_cells(packet.layers, packet.erase_zones, vector_calibration) - if cells: - # Blank the erase-zone cells before parsing the raster so phantom - # areas disappear (as the app shows). - render_packet = erased_packet(packet, cells) + if base_map_data is None: + base_map_data = build_base_q10_map_data(packet, config=config, trace_calibration=trace_calibration) - map_data = parser.map_data_from_packet(render_packet) - if map_data.image is None: + if base_map_data.image is None: raise RoborockException("Failed to render Q10 map image") + map_data = copy.copy(base_map_data) + map_data.image = copy.copy(base_map_data.image) + map_data.image.data = base_map_data.image.data.copy() + has_drawables = False if trace_calibration is not None and trace is not None: charger_heading = packet.header_calibration.charger_phi if packet.header_calibration is not None else None diff --git a/tests/devices/traits/b01/q10/test_map.py b/tests/devices/traits/b01/q10/test_map.py index 060553af5..52b46e51d 100644 --- a/tests/devices/traits/b01/q10/test_map.py +++ b/tests/devices/traits/b01/q10/test_map.py @@ -388,6 +388,47 @@ def test_render_failure_clears_stale_image(render_map: Mock) -> None: assert trait.image_content is None +def test_trace_updates_reuse_cached_base_map_and_calibration() -> None: + """Subsequent trace updates reuse the rendered base map without rebuilding it.""" + map_dps = MapDpsTrait() + trait = _map_trait(map_dps) + packet = parse_map_packet(FIXTURE.read_bytes()) + trace1 = parse_trace_packet(TRACE_SESSION_FIXTURE.read_bytes()) + trace2 = Q10TracePacket(points=[*trace1.points, Q10Point(100, 100)], sequence=trace1.sequence + 1) + + with ( + patch( + "roborock.devices.traits.b01.q10.map.build_base_q10_map_data", + wraps=None, + ) as mock_build_base, + patch( + "roborock.devices.traits.b01.q10.map.solve_q10_calibration", + wraps=None, + ) as mock_solve_cal, + patch("roborock.devices.traits.b01.q10.map.render_q10_map", return_value=b"png") as mock_render, + ): + mock_build_base.return_value = Mock() + mock_solve_cal.return_value = Mock() + + trait.update_from_map_packet(packet) + assert mock_build_base.call_count == 1 + assert mock_solve_cal.call_count == 0 + + trait.update_from_trace_packet(trace1) + assert mock_build_base.call_count == 1 + assert mock_solve_cal.call_count == 1 + + trait.update_from_trace_packet(trace2) + assert mock_build_base.call_count == 1 + assert mock_solve_cal.call_count == 1 + assert mock_render.call_count == 3 + + # A DPS update or new map packet invalidates the cached base map. + map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()}) + assert mock_build_base.call_count == 2 + assert mock_solve_cal.call_count == 2 + + # --- Overlays ---------------------------------------------------------------- diff --git a/tests/map/test_benchmarks.py b/tests/map/test_benchmarks.py index 1c596b85c..7e11cb320 100644 --- a/tests/map/test_benchmarks.py +++ b/tests/map/test_benchmarks.py @@ -39,7 +39,12 @@ parse_map_packet, parse_trace_packet, ) -from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map +from roborock.map.b01_q10_render import ( + Q10MapOverlays, + build_base_q10_map_data, + render_q10_map, + solve_q10_calibration, +) from roborock.map.map_parser import MapParser, MapParserConfig, ParsedMapData if TYPE_CHECKING: @@ -218,6 +223,27 @@ def test_benchmark_q10_map_render_with_trace(benchmark: BenchmarkFixture) -> Non _validate_image(result, (32, 24)) +def test_benchmark_q10_map_render_with_trace_cached(benchmark: BenchmarkFixture) -> None: + """Benchmark Q10 render_q10_map on trace updates using cached base MapData and calibration.""" + map_bytes = _Q10_MAP.read_bytes() + trace_bytes = _Q10_TRACE.read_bytes() + packet = parse_map_packet(map_bytes) + trace = parse_trace_packet(trace_bytes) + config = B01Q10MapParserConfig(map_scale=4) + trace_calibration = solve_q10_calibration(packet, trace) + base_map_data = build_base_q10_map_data(packet, config=config, trace_calibration=trace_calibration) + result = benchmark( + render_q10_map, + packet, + trace=trace, + overlays=Q10MapOverlays(), + config=config, + base_map_data=base_map_data, + trace_calibration=trace_calibration, + ) + _validate_image(result, (32, 24)) + + # --------------------------------------------------------------------------- # Standalone CLI Benchmark & Profiler # --------------------------------------------------------------------------- @@ -244,6 +270,8 @@ def _run_benchmarks(iterations: int, warmup: int, profile: bool) -> None: q10_map_packet = parse_map_packet(q10_data) q10_trace_packet = parse_trace_packet(q10_trace_data) q10_render_config = B01Q10MapParserConfig(map_scale=4) + q10_trace_cal = solve_q10_calibration(q10_map_packet, q10_trace_packet) + q10_base_map = build_base_q10_map_data(q10_map_packet, config=q10_render_config, trace_calibration=q10_trace_cal) benchmarks: list[tuple[str, Callable[[], Any]]] = [ ("V1 S5 Map (Scale 4, Default)", lambda: p_v1_s5.parse(v1_s5_data)), @@ -265,6 +293,17 @@ def _run_benchmarks(iterations: int, warmup: int, profile: bool) -> None: config=q10_render_config, ), ), + ( + "Q10 Render with Trace (Cached)", + lambda: render_q10_map( + q10_map_packet, + trace=q10_trace_packet, + overlays=Q10MapOverlays(), + config=q10_render_config, + base_map_data=q10_base_map, + trace_calibration=q10_trace_cal, + ), + ), ] print(f"\nRunning {len(benchmarks)} benchmarks ({warmup} warmup, {iterations} timed iterations)...\n")