mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
feat(scroll): let users pick a crisp speed for their own panel
Whole-pixel motion was previously only available at multiples of the refresh rate -- 100, 200, 300 px/s on a 100Hz panel. 100 px/s crosses a 256px panel in 2.6s, which is brisk for reading, and everything slower had to blend (blur) or repeat frames unevenly (judder). There was no way to ask for 50 px/s and get clean motion. SwapOnVSync takes a framerate_fraction the display manager never passed. It holds each frame for N panel refreshes; the panel keeps refreshing at its full rate throughout, so holding costs nothing in flicker and only changes how often a NEW image is presented. That turns 50 px/s into one whole pixel every second refresh instead of half a pixel every refresh. The crisp speeds are therefore refresh_hz / hold * pixels_per_frame, and that ladder depends on the panel: a Pi Zero on a long chain has a different set of good speeds from a Pi 4 on a short one. crisp_ladder() enumerates them and solve_crisp() picks the best match for a requested speed. solve_crisp weights motion quality rather than picking the numerically nearest entry, which matters more than it sounds. Asked for 30 px/s, nearest-by-value answers 28.6 -- 2px jumps at 14fps -- over 33.3, which is single-pixel motion at 33fps and obviously better on the panel. The target is also clamped into the ladder's range first, because relative error saturates near 1.0 for a target far outside it and the quality penalty would otherwise answer "10000 px/s" with the slowest entry. configure() snaps to the ladder and applies the hold when given a display manager. Without one the hold silently cannot happen and motion falls back to fractional pixels, so it warns rather than failing quietly. set_frame_hold() resets to 1 when scrolling stops, so one plugin's pacing cannot leak into whatever is on screen next. scripts/scroll_speeds.py is the user-facing part: it prints the ladder for the configured rate, measures what the panel ACTUALLY manages (--measure, for hardware that cannot reach its configured limit), highlights the nearest option to a wanted speed, and demos one live. It never starts or stops the display service itself -- doing that inside a script stranded the panel twice today. Speeds below ~20 px/s remain stepped regardless. That is the pixel pitch, not a software limit. Also fixes the dirty-tracking test spy, which stubbed SwapOnVSync with a single-argument function and would have masked the new call as a failed push, and rewrites a configure() test that had started passing for the wrong reason: it asserted a judder warning, which snapping now prevents, and was matching the unrelated "hold could not be applied" warning instead. 183 tests pass on the Pi. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,12 +47,17 @@ class _SwapSpy:
|
||||
def __init__(self, matrix):
|
||||
self.matrix = matrix
|
||||
self.count = 0
|
||||
self.last_frame_hold = None
|
||||
self._orig = matrix.SwapOnVSync
|
||||
|
||||
def __enter__(self):
|
||||
def counting(canvas):
|
||||
def counting(canvas, *args):
|
||||
# *args carries framerate_fraction, which display_manager passes so
|
||||
# a frame can be held for several refreshes. Signature must match
|
||||
# the real binding or the spy hides a TypeError as a failed push.
|
||||
self.count += 1
|
||||
return self._orig(canvas)
|
||||
self.last_frame_hold = args[0] if args else 1
|
||||
return self._orig(canvas, *args)
|
||||
self.matrix.SwapOnVSync = counting
|
||||
return self
|
||||
|
||||
@@ -221,3 +226,49 @@ class TestKillSwitch:
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
|
||||
|
||||
class TestFrameHold:
|
||||
"""Holding a frame for N refreshes is how a scroll runs slower than one
|
||||
pixel per refresh without fractional pixel positions."""
|
||||
|
||||
def test_hold_reaches_swap_on_vsync(self, dm):
|
||||
dm.set_scrolling_state(True)
|
||||
dm.set_frame_hold(3)
|
||||
try:
|
||||
dm.draw.rectangle([0, 0, 9, 9], fill=(120, 0, 200))
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display()
|
||||
assert spy.count == 1
|
||||
assert spy.last_frame_hold == 3
|
||||
finally:
|
||||
dm.set_scrolling_state(False)
|
||||
|
||||
def test_default_is_every_refresh(self, dm):
|
||||
dm.set_scrolling_state(True)
|
||||
try:
|
||||
dm.draw.rectangle([0, 0, 11, 11], fill=(0, 120, 200))
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display()
|
||||
assert spy.last_frame_hold == 1
|
||||
finally:
|
||||
dm.set_scrolling_state(False)
|
||||
|
||||
def test_hold_resets_when_scrolling_stops(self, dm):
|
||||
"""One plugin's pacing must not leak into whatever is on screen next."""
|
||||
dm.set_scrolling_state(True)
|
||||
dm.set_frame_hold(5)
|
||||
dm.set_scrolling_state(False)
|
||||
dm.set_scrolling_state(True)
|
||||
try:
|
||||
dm.draw.rectangle([0, 0, 13, 13], fill=(200, 120, 0))
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display()
|
||||
assert spy.last_frame_hold == 1
|
||||
finally:
|
||||
dm.set_scrolling_state(False)
|
||||
|
||||
@pytest.mark.parametrize("bad,expected", [(0, 1), (-4, 1), (None, 1), ("x", 1)])
|
||||
def test_unusable_holds_are_ignored_or_floored(self, dm, bad, expected):
|
||||
dm.set_frame_hold(bad)
|
||||
assert dm._frame_hold == expected
|
||||
|
||||
Reference in New Issue
Block a user