mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06: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:
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Compensate for the order an LED panel lights its rows.
|
||||
|
||||
A HUB75 panel with 1:N multiplexing lights its rows in pairs: row ``d`` of the
|
||||
top half together with row ``d`` of the bottom half, ``d`` running from 0 to
|
||||
N-1 across each refresh. So the two rows either side of the middle of a panel
|
||||
are lit at opposite ends of every refresh: the last row of one half near the
|
||||
end, the first row of the other at the start. When the picture moves, each
|
||||
refresh shows it one step further on, and those two neighbouring rows -- lit
|
||||
almost a whole refresh apart -- show the text one step apart. On a strip
|
||||
scrolling a whole pixel per refresh that is a crisp 1px step across the middle
|
||||
of every panel, which the eye and a phone camera both see.
|
||||
|
||||
Measured on hdpi (4x128x64 on one chain, rotated 180) on 2026-09-24: switching
|
||||
the panel to interlaced scanning made the step vanish, and halving the scroll
|
||||
speed halved it, so it is the scan order and not a torn frame. Showing one half
|
||||
of the panel a refresh behind the other lines those two rows up again. What is
|
||||
left is a uniform lean of about one step per half from top to bottom, which
|
||||
reads as nothing at all where the step read as a tear.
|
||||
|
||||
Which half lags follows from the geometry. Walking the logical rows top to
|
||||
bottom, wherever the next row is lit near the start of a refresh and the one
|
||||
above it near the end, the section below must show one more refresh of lag to
|
||||
stay continuous with it (and one less where the order jumps the other way).
|
||||
Stacked parallel chains are lit simultaneously, so each further half adds one.
|
||||
|
||||
Only layouts whose physical row order is known are compensated: plain chains,
|
||||
parallel chains, and a 0 or 180 degree rotation. Other pixel mappers
|
||||
(U-mapper, 90/270 rotation, ...), special multiplexing and interlaced scan are
|
||||
left alone, as is the emulator, which has no scan order to compensate for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, List, Optional, Sequence, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.display_geometry import compose_pixel_mapper_config
|
||||
|
||||
#: (first row, last row + 1, refreshes of lag), for bands that lag at all.
|
||||
Band = Tuple[int, int, int]
|
||||
|
||||
|
||||
def _int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _rotation(hardware: Mapping[str, Any]) -> Optional[int]:
|
||||
"""The rotation applied by the pixel mappers, or None if they do anything else."""
|
||||
mappers = [m.strip() for m in compose_pixel_mapper_config(hardware).split(';')
|
||||
if m.strip()]
|
||||
if not mappers:
|
||||
return 0
|
||||
if len(mappers) == 1 and mappers[0].replace(' ', '') in ('Rotate:0', 'Rotate:180'):
|
||||
return int(mappers[0].split(':')[1])
|
||||
return None
|
||||
|
||||
|
||||
def scan_lag_bands(hardware: Mapping[str, Any], height: int,
|
||||
setting: str = "auto") -> Optional[List[Band]]:
|
||||
"""Which rows of the logical canvas to show how many refreshes behind.
|
||||
|
||||
Returns None when compensation is off or the layout is not one this
|
||||
understands; see the module docstring.
|
||||
"""
|
||||
if str(setting or "auto").lower() == "off":
|
||||
return None
|
||||
rows = _int(hardware.get('rows'), 0)
|
||||
parallel = max(1, _int(hardware.get('parallel'), 1))
|
||||
if rows < 4 or rows % 2:
|
||||
return None
|
||||
if _int(hardware.get('multiplexing'), 0) != 0 or _int(hardware.get('scan_mode'), 0) != 0:
|
||||
return None
|
||||
rotation = _rotation(hardware)
|
||||
if rotation is None:
|
||||
return None
|
||||
physical_height = rows * parallel
|
||||
if physical_height != height:
|
||||
return None # something remapped the canvas; its row order is unknown
|
||||
|
||||
half = rows // 2
|
||||
|
||||
def phase(y: int) -> int:
|
||||
"""When in the refresh logical row y is lit, as a row-pair index."""
|
||||
physical = y if rotation == 0 else physical_height - 1 - y
|
||||
return (physical % rows) % half
|
||||
|
||||
lags = [0]
|
||||
for y in range(1, physical_height):
|
||||
step = phase(y) - phase(y - 1)
|
||||
if step < -half / 2: # lit near the start after a row lit near the end
|
||||
lags.append(lags[-1] + 1)
|
||||
elif step > half / 2: # the other way round
|
||||
lags.append(lags[-1] - 1)
|
||||
else:
|
||||
lags.append(lags[-1])
|
||||
low = min(lags)
|
||||
bands: List[Band] = []
|
||||
for y, lag in enumerate(lags):
|
||||
lag -= low
|
||||
if bands and bands[-1][2] == lag and bands[-1][1] == y:
|
||||
bands[-1] = (bands[-1][0], y + 1, lag)
|
||||
else:
|
||||
bands.append((y, y + 1, lag))
|
||||
return [band for band in bands if band[2] > 0] or None
|
||||
|
||||
|
||||
def compose(image: Image.Image, history: Sequence[Image.Image],
|
||||
bands: Sequence[Band]) -> Image.Image:
|
||||
"""``image`` with each band taken from the frame ``lag`` refreshes back.
|
||||
|
||||
``history[0]`` is the previous frame. A band whose frame is not available
|
||||
yet (the first frames of a scroll) is left current. Returns ``image`` itself
|
||||
when nothing changes, so the caller pays for a copy only when it must.
|
||||
"""
|
||||
out = image
|
||||
for top, bottom, lag in bands:
|
||||
if lag > len(history):
|
||||
continue
|
||||
source = history[lag - 1]
|
||||
if source.size != image.size:
|
||||
continue
|
||||
if out is image:
|
||||
out = image.copy()
|
||||
out.paste(source.crop((0, top, source.width, bottom)), (0, top))
|
||||
return out
|
||||
Reference in New Issue
Block a user