feat(display): cancel the half-panel scan offset on slower, held-frame scrolls (#711)

Scan-order compensation only ran at one frame per refresh, so a crisp scroll
like 60 px/s on a 120 Hz panel (1px every 2 refreshes) showed a half-pixel
step across the middle of the panel. A held frame is now presented as a
sequence of swaps (scan_order.refresh_plan): the lagging half shows the
previous frame for its first refresh and the new one for the rest, so it
steps one refresh after the rest. Skipped when a blit takes over half a
refresh, since the second blit has to land before the next vsync.

Soaked on ledpi (60 px/s, 120 Hz): 0.16% late frames, as before the change.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 14:32:24 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 16b566e14f
commit eb8128a981
4 changed files with 142 additions and 43 deletions
+41 -9
View File
@@ -23,6 +23,10 @@ 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.
A frame held for several refreshes (a slower, crisp scroll) is presented as a
sequence of swaps instead of one long hold, so the lagging half can step one
refresh after the rest: see :func:`refresh_plan`.
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
@@ -109,19 +113,47 @@ def scan_lag_bands(hardware: Mapping[str, Any], height: int,
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.
def refresh_plan(bands: Sequence[Band], hold: int) -> List[Tuple[Tuple[int, ...], int]]:
"""How to present one frame that is held for ``hold`` refreshes.
``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.
A band lagging ``lag`` refreshes shows, on refresh ``r`` of the frame, what
the panel showed ``lag`` refreshes earlier: the current frame once
``r >= lag``, else a frame ``ceil((lag - r) / hold)`` back. At one refresh
per frame that is just ``lag`` frames back. Held longer, the lagging band
steps one refresh after the rest instead of one frame, which is the only
way to cancel the offset: it is a fraction of a frame there.
Returns ``[(frames_back_per_band, refreshes), ...]`` in order, merging
neighbouring refreshes that show the same thing so each costs one swap.
"""
plan: List[Tuple[Tuple[int, ...], int]] = []
for r in range(max(1, hold)):
backs = tuple(max(0, -((r - lag) // max(1, hold))) for _, _, lag in bands)
if plan and plan[-1][0] == backs:
plan[-1] = (backs, plan[-1][1] + 1)
else:
plan.append((backs, 1))
return plan
def compose(image: Image.Image, history: Sequence[Image.Image],
bands: Sequence[Band],
backs: Optional[Sequence[int]] = None) -> Image.Image:
"""``image`` with each band taken from an earlier frame.
``history[0]`` is the previous frame. ``backs`` is how many frames back each
band is taken from (0 = the current one); by default that is the band's lag,
which is right when every frame is held for one refresh. 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):
for i, (top, bottom, lag) in enumerate(bands):
back = lag if backs is None else backs[i]
if back <= 0 or back > len(history):
continue
source = history[lag - 1]
source = history[back - 1]
if source.size != image.size:
continue
if out is image: