mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
feat(display): compensate for the panel's scan order while scrolling
A 1:N-scan HUB75 panel lights its rows in pairs, row d of the top half with row d of the bottom half, d running 0..N-1 across each refresh. The two rows either side of the middle of a panel are therefore lit at opposite ends of every refresh, and a strip moving a whole pixel per refresh shows a crisp 1px step across the middle of every panel -- in a phone video as well as by eye. Established on hdpi's panel (4x128x64, one chain, rotated 180) on 2026-09-24: interlaced scanning (scan_mode 1) made the step vanish, and halving the scroll speed halved it. It is the scan order, not a torn frame, and crisp vsync-locked pacing (#523, #628) makes it visible where uneven, blended motion used to hide it. Showing the upper half one refresh behind removed it completely at full speed. src/scan_order.py works out which rows lag how many refreshes from the layout: walking the logical rows, wherever a row lit near the start of a refresh follows one lit near the end, the section below takes one more refresh of lag (one less the other way), so the result is a uniform lean rather than a step. Stacked parallel chains lean further. It covers plain and parallel chains at 0 or 180 degrees with standard multiplexing and progressive scan; anything else (U-mapper, 90/270, multiplexing, interlaced, double-sided) is left alone, as is the emulator, which has no scan order. DisplayManager applies it only mid-scroll at one frame per refresh, when consecutive frames are consecutive refreshes: lagging rows come from the previous input frames, so it works for Vegas and every plugin ticker without knowing how they scroll. display.scan_order_compensation ("auto" | "off") controls it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+46
-2
@@ -35,6 +35,7 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
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,
|
||||
ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size,
|
||||
@@ -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 logging
|
||||
import math
|
||||
@@ -243,6 +244,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()
|
||||
@@ -811,7 +813,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))
|
||||
|
||||
# Swap buffers immediately. framerate_fraction holds the frame
|
||||
# for N refreshes; SwapOnVSync blocks for all of them, which is
|
||||
@@ -828,6 +830,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:
|
||||
|
||||
Reference in New Issue
Block a user