Compare commits

...
29 Commits
Author SHA1 Message Date
Chuck 853b5aa618 Merge remote-tracking branch 'origin/main' into claude/scan-order-compensation
# Conflicts:
#	CHANGELOG.md
#	src/display_manager.py
2026-09-24 19:39:12 -04:00
Chuck 86c27970ef Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 18:58:49 -04:00
ChuckandClaude Opus 5.5 ab39552cdb refactor(display): stop the frame-timing watchdog at the end of cleanup()
#628 adds its snapshot-writer stop at the top of cleanup(); with this at the
top as well, merging #629 after #628 conflicted. Same behaviour, no
overlap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:58:42 -04:00
Chuck 83fd3491c7 Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 18:34:11 -04:00
ChuckandClaude Opus 5.5 37203e635c fix(perf): recorder review fixes -- period adoption, timed frames, snapshot copy, watchdog stop
From review (CodeRabbit):

- The first refresh period is adopted only once two windows in a row agree.
  One loaded startup window, most frames a refresh late, used to fix a
  period twice the real one for the life of the process.
- totals.timed_frames counts the frames judged against a known period, and
  frame_soak rates late and early frames over it. Before, frames seen before
  any period was known counted as on time, so a short run could PASS
  having judged nothing; now it has no verdict.
- snapshot() copies totals. render_bench kept the snapshot object and
  differenced it against a later one sharing the same live dict, so every
  graded run reported zero frames.
- held_refresh_hz uses the p50 bucket's midpoint, not its upper edge (a
  1-2% low bias, the size of the idle-vs-held gap it exists to show).
- StallWatchdog.stop() and FrameTimingRecorder.close(); DisplayManager's
  cleanup() calls it, so the watchdog no longer outlives its manager.
- A stall that outlasts the 2s scroll-state expiry still gets its end
  reported (tracking is dropped only past GAP_SECONDS).
- test: EMULATOR through monkeypatch; docs: the 1s resume rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:33:34 -04:00
Chuck 695be34a1b Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 18:22:42 -04:00
ChuckandClaude Opus 5.5 ffdd0efd43 style(bench): say when the panel could not be blanked after a run (Codacy B110)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:22:38 -04:00
Chuck cfcdbfb18a Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 17:44:33 -04:00
Chuck 4c17e18786 Merge commit 'b9416ef8' into claude/frame-timing-harness 2026-09-24 17:42:43 -04:00
Chuck 764fc6fcdb Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 17:36:54 -04:00
Chuck 99a3608516 Merge remote-tracking branch 'origin/main' into claude/frame-timing-harness 2026-09-24 17:36:48 -04:00
Chuck b1510c209d Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation
# Conflicts:
#	CHANGELOG.md
2026-09-24 17:36:39 -04:00
Chuck 48a433328a Merge remote-tracking branch 'origin/main' into claude/scan-order-compensation
# Conflicts:
#	CHANGELOG.md
2026-09-24 17:36:25 -04:00
Chuck edfcd9e2a1 Merge remote-tracking branch 'origin/main' into claude/frame-timing-harness
# Conflicts:
#	CHANGELOG.md
2026-09-24 17:35:58 -04:00
Chuck 74ba36a059 Merge remote-tracking branch 'origin/claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 17:02:24 -04:00
ChuckandClaude Opus 5.5 d37a3a712a style(perf): say why render_bench fell back; mark the stats path as a safe fixed name (Codacy)
Codacy (Bandit B110, B108). The bench's silent except now prints why it read
config.json directly. The stats file's fixed name in /dev/shm is safe:
write() goes through mkstemp and os.replace, which replaces a planted
symlink instead of following it; the comment says so and marks it nosec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:02:15 -04:00
Chuck dc26baa134 Merge remote-tracking branch 'origin/claude/frame-timing-harness' into claude/scan-order-compensation
# Conflicts:
#	src/display_manager.py
2026-09-24 16:58:46 -04:00
ChuckandClaude Opus 5.5 6aef54598b docs(display): the mid-panel tear can be compensated; say how, and when it cannot
main's new section called the 1px step at mid-height a panel-scan effect with
nothing to fix in the render path. The scan explanation holds, but at one
pixel per refresh the step is one refresh of motion and lagging one half
cancels it, which this branch does. Rewrite that paragraph, list what
compensation covers and what it leaves to the old advice (held frames,
other layouts, the emulator), and add a CHANGELOG entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:52:33 -04:00
Chuck 7b16e953d2 Merge remote-tracking branch 'origin/main' into claude/scan-order-compensation 2026-09-24 16:49:41 -04:00
Chuck 52bc520335 Merge remote-tracking branch 'origin/main' into claude/frame-timing-harness
# Conflicts:
#	CHANGELOG.md
#	docs/SCROLL_PERFORMANCE.md
2026-09-24 16:30:49 -04:00
ChuckandClaude Opus 5.5 14c38a3189 fix(perf): count a stall even when the scroll state went missing across it
On hdpi the stall watchdog logged a 1.9s stall during the hourly sports
refresh that the soak report never had: its worst gap was 655ms. The frame
that ended the stall was recorded as static, so its interval was dropped.

"Scrolling" is DisplayManager's scroll state at the moment a frame is
presented, and it goes missing mid-scroll: it expires after 2s without
activity, and any thread can clear it. Plugins call
set_scrolling_state(False) from their own display() (news, stocks, the odds
ticker's fallback), and Vegas captures some of those on the render thread
between two of its own frames. Vegas sets the state again only after its
next frame, so that frame is recorded as static -- along with the capture
or stall it followed.

One static frame between two scrolling frames, with the scroll resuming
within RESUME_SECONDS (1s), is now a frame of the scroll and both of its
intervals count, the first at the scroll's own hold (clearing the state
drops the hold to 1 too). Two static frames in a row still end the scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:23:18 -04:00
ChuckandClaude Opus 5.5 b67818d5c5 feat(perf): LEDMATRIX_STALL_WATCHDOG_MS lowers the stall watchdog's threshold
250ms catches freezes; the hitches left on hdpi are frames 2-5 refreshes
late, which look like the render thread waiting for the GIL. At 30ms the
watchdog dumps those too, naming what the other threads were running when
the frame missed. It polls at a third of the threshold so a stall one poll
long is still seen, which costs some GIL time of its own: a diagnostic
setting, not one to soak with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:26:04 -04:00
ChuckandClaude Opus 5.5 c5281d9a45 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>
2026-09-24 13:36:51 -04:00
ChuckandClaude Opus 5.5 f79618d4f7 refactor(bench): grade render_bench with the shared frame-timing recorder
render_bench.py (from the parallel perf/render-bench work) had its own
grading module, frame_pacing, with its own definition of a missed frame
and its own refresh estimate. The soak already had both in frame_timing,
so the two could have drifted apart on what "late" means.

The bench now gives the display manager a fresh FrameTimingRecorder,
drains it synchronously at the start and end of the graded run, and prints
frame_soak's report with frame_soak's verdict. Its workload is unchanged:
the synthetic strip, --busy load, the shared speed resolver, the
per-frame scrolling announcement. frame_pacing, its tests and its
src.common exports are removed; measure_refresh_hz moves to frame_timing,
where scroll_speeds.py now finds it.

Two ideas from frame_pacing carry over. The bench seeds the recorder with
the idle refresh it measures, so a loop that free-runs (the 827fps bug
the first bench caught) shows as early frames and one stuck at half rate
as late frames, where an estimate taken from their own intervals finds
both self-consistent. And the soak, which has no idle measurement, now
calls a run NOT LOCKED when its refresh estimate beats the configured cap.
The report also gives the rate held while rendering.

Docs: the bench becomes "Without the service" under "Soaking a rig",
keeping its hdpi numbers and the idle-vs-rendering refresh finding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 11:56:44 -04:00
ChuckBuildsandClaude Opus 5 d56ec2ab3a feat(bench): measure a rig against the refresh it actually holds
There was no way to answer "does this hardware present every frame on
time?" other than watching the panel. `scripts/render_bench.py` drives the
production path -- a real DisplayManager and ScrollHelper, configured
through the same `scroll_config` resolver every ticker uses -- and grades
the run with a new `src.common.frame_pacing`, exiting non-zero when more
than 0.1% of frames slipped a refresh. Exit 2 when the run could not be set
up at all, so a rig that was never measured cannot pass by accident.

A missed frame is defined exactly: an interval that rounds up to at least
one more refresh than its frame hold asked for. The half-refresh rounding
boundary keeps a frame that ran 1ms long on a 10ms refresh out of the
count, because it still presented on the refresh it was meant to.

The verdict that matters more is NOT LOCKED. A loop that never blocked on
vsync reports a perfect zero misses while presenting nothing -- 8ms frames
on a 100Hz panel all land in the one-refresh bucket while running 25% too
fast -- so the report also checks the typical frame is not shorter than the
panel could physically present. That is what caught the first version of
this benchmark announcing its scrolling state once instead of per frame:
the state expires on an inactivity threshold, the dirty-tracking skip then
fires mid-scroll, and the loop free-ran at 827fps.

And the refresh is read back out of the frames rather than taken from an
idle measurement. Driving the matrix is bit-banging on the same machine, so
pushing frames slows the refresh: a Pi 4 on 512x64 measures 100.4Hz idle
and holds 96.3Hz while scrolling. Both are real, and grading against the
idle figure reports a locked loop as 4% slow -- or, once the gap passes
half a refresh, as missing every frame. The gap between the two is itself
worth watching: a rise in it is a render-cost regression even when nothing
is missed.

Measured on hdpi (Pi 4, 512x64, pwm_bits 8), two minutes each:

  plain      95.44 fps, 8 missed of 11,449 (0.070%)  PASS
  --busy 2   95.41 fps, 3 missed of 11,445 (0.026%)  PASS

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
2026-09-24 11:08:43 -04:00
ChuckandClaude Opus 5.5 c883a2fd1e feat(perf): a stall watchdog that logs what the render thread is waiting on
The recorder counts freezes; it cannot say why. hdpi showed 1-2s freezes
in both the #628 and offscreen builds, one lining up with hockey's 2s
NHL fetch on the update thread, and nothing in the logs explained it.

StallWatchdog polls every 50ms from its own thread. When a scroll's last
frame is more than 250ms old (and a scroll is still running, so the end
of a scroll is not a stall), it logs the stack of the thread that
presented that frame and the top of every other thread's, then the
stall's length when frames resume. It also measures how late its own
wake-up was: if it was held up as long as the render thread, the whole
interpreter was blocked (C code holding the GIL), not one thread on a
lock. One dump per 30s at most; LEDMATRIX_STALL_WATCHDOG=0 disables it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:58:27 -04:00
ChuckandClaude Opus 5.5 ac841f4583 docs(perf): hdpi soak results, main vs #628
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 08:56:49 -04:00
ChuckandClaude Opus 5.5 98728d3b81 fix(perf): count 1-2s stalls, flag early frames, and keep the refresh estimate honest
Three gaps found by the first hdpi soaks:

- Intervals of 1s or more between two scrolling frames were dropped as
  "gaps between scrolls". But the scrolling state lapses only after 2s, so
  every 1-2s stall inside a scroll vanished from the report. Those are now
  freezes (the gap bound is a 5s sanity limit), with a breakdown by length.
- A frame a whole refresh early means the swap did not wait for the panel.
  Those are counted, and a soak with more than the threshold of them fails
  as NOT LOCKED instead of reporting a flattering late rate.
- The refresh estimate took the lowest window it had seen, so one window of
  non-blocking swaps halved it and made every early frame look on time. A
  window may now lower it by at most 20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 08:49:37 -04:00
ChuckandClaude Opus 5.5 3eb7a2e349 feat(perf): time every presented frame, and a soak script to judge a rig
Each scroller already logs its own stats line, but in different formats,
per source, and Vegas logs a healthy window only at DEBUG. None of it
answers the question a release has to answer on each rig: over a long
run, how often did a moving frame reach the panel late?

Every frame reaches the panel through DisplayManager.update_display, so
it is timed there once, whoever drew it: the blit (SetImage), the vsync
wait, and the interval since the previous frame. The render thread only
appends a tuple. A worker thread aggregates cumulative counters and
histograms and rewrites /dev/shm/ledmatrix_frame_stats.json every 10s
(RAM, so no SD wear).

A frame due after `hold` refreshes that lands one or more refreshes
later is "late": the panel repeated the previous frame, a visible hitch.
Gaps of 250ms+ inside a scroll are "freezes" (recomposes, handovers,
blocking calls), counted separately so one handover does not read as 40
missed refreshes. Static frames, the first frame of a scroll and gaps
between scrolls are not timed. The refresh period is estimated from the
frames themselves.

scripts/frame_soak.py runs next to the service as any user, diffs two
snapshots over a run (default 10 minutes), optionally keeps the web
preview's viewer marker fresh, and exits non-zero above 0.1% late
frames. It also reports whether the loaded rgbmatrix binding releases
the GIL. Documented under "Soaking a rig" in docs/SCROLL_PERFORMANCE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:51:32 -04:00
6 changed files with 360 additions and 8 deletions
+6
View File
@@ -266,6 +266,12 @@ floor on the release that ships them):
stall watchdog logs the stack of whatever holds a scroll up for 250 ms or
more. See `docs/SCROLL_PERFORMANCE.md`, "Soaking a rig".
- `display.scan_order_compensation` (`"auto"` by default): while something
scrolls at one pixel per refresh, one half of each panel is shown a refresh
behind the other, which removes the 1px step a 1:N-scan panel shows across
its middle. Only for layouts whose row order is known; `"off"` disables it.
See `docs/SCROLL_PERFORMANCE.md`, "A tear across the middle on fast scrolls".
## 3.5.0
New modules a plugin may import via `src.*` (floor on 3.5.0):
+1
View File
@@ -105,6 +105,7 @@ logical image to multiple chained physical panels.
| `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `DisplayController._get_display_duration()` (`src/display_controller.py`) |
| `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `DisplayController._apply_plugin_rotation_order()` (`src/display_controller.py`) |
| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | Nothing since `src/base_classes` was removed; scoreboards read `display.use_short_date_format` from their own plugin config |
| `scan_order_compensation` | string, `"auto"` | `"auto"` shows one half of each panel a refresh behind while something scrolls at one frame per refresh, which removes the 1px step a 1:N-scan panel shows across its middle; `"off"` disables it. Applies only to layouts whose row order is known: plain or parallel chains, 0 or 180 degree orientation, `multiplexing` 0, `scan_mode` 0, and not in the emulator | `DisplayManager._setup_scan_order_compensation()` (`src/display_manager.py`, `src/scan_order.py`) |
| `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `DisplayController._get_global_dynamic_cap()` (`src/display_controller.py`) |
## `display.vegas_scroll` — continuous scroll mode
+35 -6
View File
@@ -495,10 +495,9 @@ of roughly
offset ≈ scroll speed × refresh period
```
Each frame already reaches the panel whole (`SwapOnVSync` swaps complete frames
between refreshes), so there is nothing to fix in the render path; the shift is
created inside a single refresh. Other panel heights show it too, at the point
where their two scan halves meet.
Each frame reaches the panel whole (`SwapOnVSync` swaps complete frames between
refreshes); the shift is created inside a single refresh. Other panel heights
show it too, at the point where their two scan halves meet.
On the 2×128×64 chain above, which refreshes at about 130 Hz flat out
(7.7 ms per pass):
@@ -509,7 +508,37 @@ On the 2×128×64 chain above, which refreshes at about 130 Hz flat out
| 100 px/s | ~0.8 px |
| 150 px/s | ~1.2 px, plainly visible |
### What changes it
### What the display does about it
At one pixel per refresh, the fastest crisp speed, the step is exactly one
refresh's worth of motion, so it can be cancelled: show one half of the panel
a refresh behind the other -- the half whose row at the seam lights at the
start of each refresh. The two rows either side of the seam then show the same
moment again. What is left is a
lean of one pixel per half from top to bottom, continuous across the panel,
which reads as nothing where the step read as a tear. `DisplayManager` does
this while something scrolls at one frame per refresh
(`display.scan_order_compensation`, `"auto"` by default, `"off"` to disable;
the geometry is in `src/scan_order.py`). The lagging rows come from the
previous frame the display presented, so it works for Vegas and every plugin
ticker without knowing how they scroll.
Checked on hdpi (4×128×64 on one chain, rotated 180, 2026-09-24) before it was
written: `scan_mode: 1` (interlaced) made the step vanish but turned moving
edges grainy, and halving the speed halved it, so it is the scan and not a torn
frame. With the compensation the step is gone at 90 px/s.
It is left off where the row order is unknown or the maths does not hold:
- **Slower speeds**, where each frame is held for two or more refreshes. The
offset there is half a pixel or less, and cancelling it would need a lag of
a fraction of a frame.
- **Other layouts:** pixel mappers other than a 0 or 180 degree rotation
(U-mapper, 90/270), non-zero `multiplexing`, interlaced `scan_mode`, and a
canvas remapped to another height (double-sided mode).
- **The emulator,** which has no scan order.
### When it cannot apply
Only a shorter scan period (a faster refresh) or a slower scroll. Measure what
the panel actually achieves first. The library prints the rate with a carriage
@@ -540,7 +569,7 @@ on its own output and set `parallel` to the number of outputs used and
should roughly double the refresh rate and halve the offset. That is a cable
change, so measure again afterwards.
Short of rewiring, keep fast scrolls moderate: at the default 50 px/s the
Short of rewiring, keep fast scrolls moderate on those layouts: at 50 px/s the
offset is under half a pixel.
## Rebuilding the binding
+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:
+130
View File
@@ -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
+142
View File
@@ -0,0 +1,142 @@
"""Scan-order compensation (src/scan_order.py) and its use in update_display.
Confirmed on hdpi's panel before any of this was written: rotated 180, one
chain, 64 rows. Showing the upper half a refresh behind removed the 1px step
across the middle of the panel that a whole-pixel-per-refresh scroll showed;
see the module docstring for why.
"""
import os
import sys
os.environ.setdefault("EMULATOR", "true")
import pytest
from PIL import Image, ImageDraw
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src import scan_order # noqa: E402
def hw(**overrides):
base = {"rows": 64, "cols": 128, "chain_length": 4, "parallel": 1,
"multiplexing": 0, "scan_mode": 0, "pixel_mapper_config": "",
"orientation": "normal"}
base.update(overrides)
return base
class TestWhichRowsLag:
def test_hdpi_rotated_180_lags_the_upper_half(self):
# The configuration the panel test confirmed.
assert scan_order.scan_lag_bands(hw(orientation="180"), 64) == [(0, 32, 1)]
def test_unrotated_lags_the_lower_half(self):
assert scan_order.scan_lag_bands(hw(), 64) == [(32, 64, 1)]
def test_a_32_row_panel_splits_at_16(self):
assert scan_order.scan_lag_bands(hw(rows=32), 32) == [(16, 32, 1)]
def test_a_48_row_panel_splits_at_24(self):
assert scan_order.scan_lag_bands(hw(rows=48), 48) == [(24, 48, 1)]
def test_stacked_parallel_chains_lag_one_more_per_half(self):
# Chains are lit together, so every half boundary down the stack is
# another start-after-end jump: a continuous lean, never a step.
assert scan_order.scan_lag_bands(hw(parallel=2), 128) == [
(32, 64, 1), (64, 96, 2), (96, 128, 3)]
def test_stacked_and_rotated_mirrors_it(self):
assert scan_order.scan_lag_bands(hw(parallel=2, orientation="180"), 128) == [
(0, 32, 3), (32, 64, 2), (64, 96, 1)]
@pytest.mark.parametrize("overrides", [
{"pixel_mapper_config": "U-mapper"},
{"orientation": "90"},
{"multiplexing": 3},
{"scan_mode": 1},
{"rows": 7},
])
def test_layouts_it_cannot_reason_about_are_left_alone(self, overrides):
assert scan_order.scan_lag_bands(hw(**overrides), 64) is None
def test_a_canvas_of_another_height_is_left_alone(self):
# Something remapped it (double-sided, a mapper): row order unknown.
assert scan_order.scan_lag_bands(hw(), 32) is None
def test_it_can_be_switched_off(self):
assert scan_order.scan_lag_bands(hw(), 64, setting="off") is None
def _frame(shade):
return Image.new("RGB", (8, 64), (shade, shade, shade))
class TestCompose:
def test_a_band_comes_from_the_frame_that_many_refreshes_back(self):
now, previous = _frame(30), _frame(20)
out = scan_order.compose(now, [previous], [(32, 64, 1)])
assert out.getpixel((0, 0)) == (30, 30, 30) # current half
assert out.getpixel((0, 63)) == (20, 20, 20) # lagging half
assert now.getpixel((0, 63)) == (30, 30, 30) # input untouched
def test_deeper_bands_use_older_frames(self):
out = scan_order.compose(_frame(40), [_frame(30), _frame(20)],
[(10, 20, 1), (20, 30, 2)])
assert [out.getpixel((0, y))[0] for y in (5, 15, 25)] == [40, 30, 20]
def test_without_enough_history_the_frame_goes_out_as_is(self):
now = _frame(30)
assert scan_order.compose(now, [], [(32, 64, 1)]) is now
class TestUpdateDisplay:
"""The wiring: when the display manager applies it, and when it doesn't."""
@pytest.fixture
def dm(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}}}, suppress_test_pattern=True)
presented = []
# update_display alternates between two canvases; watch both.
for canvas in (manager.offscreen_canvas, manager.current_canvas):
def capture(image, *args, _real=canvas.SetImage, **kwargs):
presented.append(image.copy())
return _real(image, *args, **kwargs)
canvas.SetImage = capture
manager._presented = presented
yield manager
manager.set_scrolling_state(False)
DisplayManager._instance = None
DisplayManager._initialized = False
def _push(self, dm, shade):
dm.draw.rectangle([0, 0, dm.width - 1, dm.height - 1], fill=(shade, 0, 0))
dm.update_display()
return dm._presented[-1]
def test_off_in_the_emulator(self, dm):
# No scan order there: lagging a half would add the step it removes.
assert dm._scan_lag_bands is None
def test_lags_mid_scroll_at_one_frame_per_refresh(self, dm):
dm._scan_lag_bands = [(16, 32, 1)]
dm.set_scrolling_state(True, 1)
self._push(dm, 10)
shown = self._push(dm, 20)
assert shown.getpixel((0, 0)) == (20, 0, 0)
assert shown.getpixel((0, 31)) == (10, 0, 0)
def test_not_on_a_static_screen_or_a_held_frame(self, dm):
dm._scan_lag_bands = [(16, 32, 1)]
self._push(dm, 10)
assert self._push(dm, 20).getpixel((0, 31)) == (20, 0, 0) # not scrolling
dm.set_scrolling_state(True, 2)
self._push(dm, 30)
assert self._push(dm, 40).getpixel((0, 31)) == (40, 0, 0) # hold 2