From fa3087609d4d815bf07ba91757c4067f8672ede7 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sun, 12 Jul 2026 19:43:06 -0400 Subject: [PATCH] perf(display): dirty tracking in update_display + plugin FPS declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_display now skips SetImage+SwapOnVSync when the frame is byte-identical to the last pushed one (adler32 digest) AND brightness is unchanged — brightness is part of the digest, and set_brightness additionally resets it, so a dim-schedule change can never be skipped. clear() resets the digest (it writes to the matrix directly). Skipping a swap is hardware-safe: the panel refreshes the current frame from the driver's own thread; swaps only change content. Kill switch: display.dirty_tracking: false restores always-push. display_controller's high-FPS decision gains a precedence step: a plugin exposing needs_high_fps is honored first (so static-image can declare False for still PNGs and stop burning a 125fps loop on them); static-image without the attribute keeps its historical forced high-FPS (GIF back-compat); scrolling logic is otherwise unchanged. Verified with 7 tests against the real DisplayManager on RGBMatrixEmulator (identical-frame skip, pixel-change push, clear and brightness invalidation, snapshot-through-skip, kill switch) plus the 202-test display/controller/vegas suites, and a clean devpi deploy. Audit: every SetImage/SwapOnVSync/Clear/brightness call site is inside display_manager — no external writer can bypass the digest. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam --- src/display_controller.py | 17 +++- src/display_manager.py | 46 +++++++++- test/test_display_dirty_tracking.py | 135 ++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 test/test_display_dirty_tracking.py diff --git a/src/display_controller.py b/src/display_controller.py index 8ae64f34..795e4873 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -2072,10 +2072,21 @@ class DisplayController: # For plugins, call display multiple times to allow game rotation if manager_to_display and hasattr(manager_to_display, 'display'): - # Check if plugin needs high FPS (like stock ticker) - # Always enable high-FPS for static-image plugin (for GIF animation support) + # High-FPS decision, in precedence order: + # 1. A plugin that declares needs_high_fps knows best + # (e.g. static-image sets it False for still PNGs, + # True for animated GIFs). + # 2. Back-compat: older static-image versions without + # the attribute keep the historical forced high-FPS + # (GIF support). + # 3. Otherwise scrolling plugins get high FPS. plugin_id = getattr(manager_to_display, 'plugin_id', None) - if plugin_id == 'static-image': + declared = getattr(manager_to_display, 'needs_high_fps', None) + if declared is not None: + needs_high_fps = bool(declared) + logger.debug("FPS check for %s - plugin declares needs_high_fps=%s", + active_mode, needs_high_fps) + elif plugin_id == 'static-image': needs_high_fps = True logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support") else: diff --git a/src/display_manager.py b/src/display_manager.py index 019c05c6..818815fa 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -36,6 +36,7 @@ import time from typing import Dict, Any, List, Optional import logging import math +import zlib import freetype # Get logger without configuring @@ -188,6 +189,12 @@ class DisplayManager: self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path self._snapshot_min_interval_sec = 0.2 # max ~5 fps self._last_snapshot_ts = 0.0 + # Dirty tracking: (image digest, brightness) of the last frame pushed + # to the panel; update_display() skips identical pushes. Kill switch: + # display.dirty_tracking: false. + self._dirty_tracking_enabled = bool( + self.config.get('display', {}).get('dirty_tracking', True)) + self._last_pushed_digest = None # Scrolling state tracking for graceful updates self._scrolling_state = { @@ -418,6 +425,10 @@ class DisplayManager: try: # RGBMatrix accepts brightness as a property self.matrix.brightness = brightness + # Brightness applies on the next swap — force a re-push even if + # the image itself is unchanged (belt-and-braces: brightness is + # also part of the dirty-tracking digest when readable). + self._last_pushed_digest = None logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%") return True except AttributeError as e: @@ -509,7 +520,20 @@ class DisplayManager: return phys def update_display(self): - """Update the display using double buffering with proper sync.""" + """Update the display using double buffering with proper sync. + + Skips the panel push entirely when the frame is byte-identical to + the last pushed one (same image digest AND same brightness) — static + content re-rendered every second, and 125 fps loops between actual + scroll steps, otherwise re-walk the full framebuffer for nothing. + The panel keeps refreshing the current frame from its own thread, + so skipping a swap never blanks or freezes the hardware. + + Correctness hinges on invalidation: clear() resets the digest (it + writes to the matrix directly), and brightness is PART of the digest + so a dim-schedule change is never skipped. Disable via config + ``display.dirty_tracking: false`` if a redraw issue is ever suspected. + """ try: if self.matrix is None: # Fallback mode - no actual hardware to update @@ -521,6 +545,19 @@ class DisplayManager: if self._capture_mode_active: return # Skip hardware write — content is being captured off-screen + digest = None + if self._dirty_tracking_enabled: + try: + brightness = getattr(self.matrix, 'brightness', None) + except Exception: + brightness = None + digest = (zlib.adler32(self.image.tobytes()), brightness) + if digest == self._last_pushed_digest: + # Nothing changed since the last push — the panel is + # already showing exactly this frame. + self._write_snapshot_if_due() + return + # Copy the current image to the offscreen canvas. In double-sided # mode the logical screen is first tiled across the full chain. if self._double_sided is not None: @@ -530,10 +567,12 @@ class DisplayManager: # Swap buffers immediately self.matrix.SwapOnVSync(self.offscreen_canvas) - + # Swap our canvas references self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas + self._last_pushed_digest = digest + # Write a snapshot for the web preview (throttled) self._write_snapshot_if_due() except Exception as e: @@ -569,6 +608,9 @@ class DisplayManager: # Clear both canvases and the underlying matrix to ensure no artifacts. # Failures are non-fatal — the image buffer is already black above, so # the next update_display() call will push clean content regardless. + # The matrix content no longer matches the last pushed digest, + # so dirty tracking must not skip the next push. + self._last_pushed_digest = None try: self.offscreen_canvas.Clear() except (RuntimeError, OSError) as e: diff --git a/test/test_display_dirty_tracking.py b/test/test_display_dirty_tracking.py new file mode 100644 index 00000000..dcd394d9 --- /dev/null +++ b/test/test_display_dirty_tracking.py @@ -0,0 +1,135 @@ +"""Tests for update_display dirty tracking (src/display_manager.py). + +Runs against RGBMatrixEmulator (EMULATOR=true), exercising the REAL +DisplayManager — not a mock — so the skip logic, its invalidation hooks, +and the kill switch are verified off-Pi. + +The invariants: +- identical frames are pushed exactly once (SwapOnVSync not re-called) +- ANY pixel change pushes +- clear() and set_brightness() invalidate (the two paths that alter panel + state outside the digest's view) +- the kill switch (display.dirty_tracking: false) restores always-push +""" + +import os +import sys + +os.environ["EMULATOR"] = "true" + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +@pytest.fixture(scope="module") +def dm(): + """One real DisplayManager on the emulator (it's a process singleton).""" + from src.display_manager import DisplayManager + DisplayManager._instance = None + DisplayManager._initialized = False + manager = DisplayManager({ + "display": { + "hardware": {"rows": 32, "cols": 64, "chain_length": 2, + "parallel": 1, "brightness": 90}, + "runtime": {"gpio_slowdown": 0}, + }, + }, suppress_test_pattern=True) + yield manager + + +class _SwapSpy: + """Counts SwapOnVSync calls through the real matrix object.""" + + def __init__(self, matrix): + self.matrix = matrix + self.count = 0 + self._orig = matrix.SwapOnVSync + + def __enter__(self): + def counting(canvas): + self.count += 1 + return self._orig(canvas) + self.matrix.SwapOnVSync = counting + return self + + def __exit__(self, *exc): + self.matrix.SwapOnVSync = self._orig + + +class TestDirtyTracking: + def test_identical_frames_push_once(self, dm): + dm.draw.rectangle([0, 0, 10, 10], fill=(255, 0, 0)) + with _SwapSpy(dm.matrix) as spy: + dm.update_display() + dm.update_display() + dm.update_display() + assert spy.count == 1 + + def test_pixel_change_pushes(self, dm): + dm.update_display() + with _SwapSpy(dm.matrix) as spy: + dm.draw.point((5, 5), fill=(0, 255, 0)) + dm.update_display() + dm.update_display() # unchanged again + assert spy.count == 1 + + def test_clear_invalidates(self, dm): + dm.draw.rectangle([0, 0, 20, 20], fill=(0, 0, 255)) + dm.update_display() + dm.clear() # writes to the matrix directly; digest must reset + with _SwapSpy(dm.matrix) as spy: + dm.update_display() # black frame after clear must still push + assert spy.count == 1 + + def test_brightness_change_forces_push(self, dm): + dm.draw.rectangle([0, 0, 20, 20], fill=(200, 200, 200)) + dm.update_display() + with _SwapSpy(dm.matrix) as spy: + dm.update_display() # identical -> skipped + assert spy.count == 0 + dm.set_brightness(40) # dim schedule scenario + dm.update_display() # same image, new brightness -> push + assert spy.count == 1 + dm.set_brightness(90) + + def test_snapshot_still_written_on_skip(self, dm, tmp_path): + """The web preview path must keep working through skipped pushes.""" + dm._snapshot_path = str(tmp_path / "snap.png") + dm._last_snapshot_ts = 0.0 + dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0)) + dm.update_display() # push + snapshot + assert os.path.exists(dm._snapshot_path) + + +class TestKillSwitch: + def test_dirty_tracking_can_be_disabled(self, dm): + dm._dirty_tracking_enabled = False + try: + dm.draw.rectangle([0, 0, 10, 10], fill=(1, 2, 3)) + with _SwapSpy(dm.matrix) as spy: + dm.update_display() + dm.update_display() + dm.update_display() + assert spy.count == 3 # always-push, exactly the old behavior + finally: + dm._dirty_tracking_enabled = True + dm._last_pushed_digest = None + + def test_config_flag_wires_through(self): + from src.display_manager import DisplayManager + DisplayManager._instance = None + DisplayManager._initialized = False + manager = DisplayManager({ + "display": { + "hardware": {"rows": 32, "cols": 64, "chain_length": 1, + "parallel": 1}, + "runtime": {"gpio_slowdown": 0}, + "dirty_tracking": False, + }, + }, suppress_test_pattern=True) + assert manager._dirty_tracking_enabled is False + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))