feat(display): compensate for the panel's scan order while scrolling (#634)

A 1:N-scan HUB75 panel lights the two rows either side of its middle at opposite ends of each refresh, so a scroll at one pixel per refresh shows a 1px step across the middle of every panel. While something scrolls at one frame per refresh, DisplayManager now shows the half whose seam row lights first one refresh behind the other (src/scan_order.py), which lines the two up again. Only for layouts whose row order is known; display.scan_order_compensation "off" disables it. Confirmed on hdpi before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 19:47:44 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8ad9d191a7
commit 865d62f67b
6 changed files with 360 additions and 8 deletions
+46 -2
View File
@@ -36,6 +36,7 @@ from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from src.common.bdf_font import draw_bdf_text, load_bdf_face
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
from src import scan_order
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
compose_pixel_mapper_config, physical_size, resolve_double_sided,
@@ -44,7 +45,7 @@ from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_
from src.pi5_matrix_support import is_raspberry_pi_5
import threading
import time
from collections import OrderedDict
from collections import OrderedDict, deque
from typing import Dict, Any, List, Optional, Tuple
import math
import zlib
@@ -253,6 +254,7 @@ class DisplayManager:
self._setup_matrix()
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
self._setup_scan_order_compensation()
font_time = time.time()
self._load_fonts()
@@ -808,7 +810,7 @@ class DisplayManager:
if self._double_sided is not None:
self.offscreen_canvas.SetImage(self._composite_double_sided())
else:
self.offscreen_canvas.SetImage(self.image)
self.offscreen_canvas.SetImage(self._scan_compensated(self.image))
blit_done = time.perf_counter()
# Swap buffers immediately. framerate_fraction holds the frame
@@ -830,6 +832,48 @@ class DisplayManager:
except Exception as e:
logger.error(f"Error updating display: {e}")
def _setup_scan_order_compensation(self) -> None:
"""Work out which rows to show a refresh behind while scrolling.
See src/scan_order.py. Only on real hardware: the emulator has no scan
order, so there the lag would add the very step it removes elsewhere.
"""
self._scan_lag_bands = None
self._scan_history = deque(maxlen=1)
if (self.matrix is None or self._double_sided is not None
or os.environ.get('EMULATOR', 'false') == 'true'):
return
display = self.config.get('display') or {}
bands = scan_order.scan_lag_bands(
display.get('hardware') or {}, self.height,
display.get('scan_order_compensation', 'auto'))
if not bands:
return
self._scan_lag_bands = bands
self._scan_history = deque(maxlen=max(lag for _, _, lag in bands))
logger.info(
"Scan-order compensation on: while scrolling, %s",
", ".join(f"rows {top}-{bottom - 1} show {lag} refresh(es) behind"
for top, bottom, lag in bands))
def _scan_compensated(self, image: Image.Image) -> Image.Image:
"""The frame to present, with lagging rows taken from earlier frames.
Only mid-scroll at one frame per refresh: that is when consecutive
frames are consecutive refreshes. At a longer hold, or on a static
screen, the history is dropped and the frame goes out as it is.
"""
bands = getattr(self, '_scan_lag_bands', None)
if not bands:
return image
if self._frame_hold != 1 or not self.is_currently_scrolling():
self._scan_history.clear()
return image
presented = scan_order.compose(image, self._scan_history, bands)
# A copy: plugins draw into the same image object frame after frame.
self._scan_history.appendleft(image.copy())
return presented
def clear(self):
"""Clear the display completely."""
try: