Files
LEDMatrix/test/test_vegas_crisp_pacing.py
T
ChuckandClaude Opus 5.5 00e14ea55f fix(display): order snapshot writes, retry a failed one, stop the writer on cleanup; measure refresh from the low end
From review (CodeRabbit):

- A static frame's inline save now waits for a queued write in flight and
  drops a stale queued frame, under one write lock taken by both paths, so
  the last scrolling frame can no longer land on top of the first static one.
- A background write that fails clears the recorded digest, so an unchanged
  frame is written again instead of mtime-touching a stale file into looking
  healthy.
- cleanup() stops and joins the writer thread, dropping a frame it hasn't
  started, instead of leaving it (and its DisplayManager) alive.
- Vegas measures the panel's refresh from the 10th percentile of swap gaps,
  not the median: a late swap only lengthens a gap, so with most frames late
  in the window the median tracked the loop and locked in a rate too low
  (then the scroll ran fast until restart). Samples are dropped whenever
  the pacing is re-solved, so gaps from an old frame hold aren't divided by
  a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:28:42 -04:00

157 lines
5.5 KiB
Python

"""Vegas scrolls in whole pixels locked to the panel refresh.
It used to advance by elapsed time, blend neighbouring columns, and pace itself
with a sleep to target_fps. On a 512x64 chain refreshing at 95Hz that ran at
73-89fps with p99 frames of 20-28ms: the sleep drifted against the refresh and
missed a vsync every few frames, and the blend shimmered on the panel.
"""
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common.scroll_config import solve_crisp # noqa: E402
from src.vegas_mode import render_pipeline as rp_module # noqa: E402
from src.vegas_mode.config import VegasModeConfig # noqa: E402
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
W, H = 128, 32
class FakeStream:
def get_grouped_content_for_composition(self):
return [('a', [Image.new('RGB', (4000, H), (255, 255, 255))])]
def get_active_plugin_ids(self):
return ['a']
class FakeDM:
width = W
height = H
def __init__(self, refresh_hz=100.0, hardware=True):
self.refresh_hz = refresh_hz
self.matrix = object() if hardware else None
self.image = Image.new('RGB', (W, H))
self.holds = []
def set_scrolling_state(self, is_scrolling, frame_hold=1):
self.holds.append(frame_hold)
def update_display(self):
pass
def _pipeline(dm=None, **cfg):
p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), dm or FakeDM(),
FakeStream())
assert p.compose_scroll_content()
return p
def test_default_steps_whole_pixels_and_holds_frames():
dm = FakeDM(refresh_hz=100.0)
p = _pipeline(dm, scroll_speed=50)
want = solve_crisp(50, 100.0)
assert p.scroll_helper.fixed_pixels_per_frame == want.pixels_per_frame
assert not p.scroll_helper.sub_pixel_scrolling
before = p.scroll_helper.scroll_position
p.render_frame()
assert p.scroll_helper.scroll_position - before == want.pixels_per_frame
# The hold is what makes 1px every 2 refreshes 50px/s rather than 100.
assert dm.holds[-1] == want.frame_hold == 2
assert p.target_fps == want.frames_per_second
def test_sleep_floor_stays_below_the_refresh_period():
# A floor at or above the real period accumulates until a frame misses.
p = _pipeline(FakeDM(refresh_hz=100.0), scroll_speed=50)
assert p.frame_interval < p._frame_hold / 100.0
def test_sub_pixel_blend_keeps_the_old_time_based_blend():
dm = FakeDM()
p = _pipeline(dm, sub_pixel_blend=True, target_fps=90)
assert p.scroll_helper.fixed_pixels_per_frame is None
assert p.scroll_helper.sub_pixel_scrolling
p.render_frame()
assert dm.holds[-1] == 1
assert p.frame_interval == 1.0 / 90
assert p.target_fps == 90
def _run_swaps(p, period, frames):
"""Render `frames` frames whose swaps are `period` seconds apart."""
clock = [1000.0]
def monotonic():
return clock[0]
with patch.object(rp_module.time, 'monotonic', monotonic):
for _ in range(frames):
p.render_frame()
clock[0] += period
def test_a_panel_below_its_cap_is_measured_and_the_speed_re_solved():
# 4x128x64 on one chain: capped at 120Hz, really 95Hz. Against the cap
# 90px/s solves to 3px every 4 refreshes; against 95Hz, 1px every one.
p = _pipeline(FakeDM(refresh_hz=120.0), scroll_speed=90)
assert p._crisp.pixels_per_frame == 3
frames = RenderPipeline.REFRESH_WARMUP_FRAMES + RenderPipeline.REFRESH_SAMPLES + 2
_run_swaps(p, p._frame_hold / 95.0, frames)
assert p._measured_hz == 95.0
assert (p._crisp.pixels_per_frame, p._crisp.frame_hold) == (1, 1)
# The floor still comes from the cap, not the measurement.
assert p.frame_interval < 1 / 95.0
def test_a_panel_that_keeps_up_with_its_cap_is_left_alone():
p = _pipeline(FakeDM(refresh_hz=100.0), scroll_speed=50)
crisp = p._crisp
frames = RenderPipeline.REFRESH_WARMUP_FRAMES + RenderPipeline.REFRESH_SAMPLES + 2
_run_swaps(p, p._frame_hold / 99.5, frames)
assert p._measured_hz == 100.0
assert p._crisp == crisp
def test_a_window_of_mostly_late_frames_still_measures_the_panel():
# A late swap only lengthens its gap, by whole refreshes. A window where
# most frames missed a vsync (startup, a prefetch) must not read as a
# slower panel: with 7 frames in 10 a refresh late, the median would say
# 76Hz here and the speed would be solved for a panel that isn't there.
p = _pipeline(FakeDM(refresh_hz=120.0), scroll_speed=90)
period, hold = 1 / 95.0, p._frame_hold
gaps = [(hold + 1) * period if i % 10 < 7 else hold * period for i in range(400)]
clock = [1000.0]
with patch.object(rp_module.time, 'monotonic', lambda: clock[0]):
for gap in gaps:
p.render_frame()
clock[0] += gap
if p._measured_hz is not None:
break
assert p._measured_hz == pytest.approx(95.0)
def test_re_solving_the_pacing_drops_samples_timed_under_the_old_hold():
p = _pipeline(FakeDM(refresh_hz=120.0), scroll_speed=90)
p._swap_times.extend([1.0, 1.01, 1.02])
p._configure_scroll_helper()
assert len(p._swap_times) == 0
def test_no_measurement_without_hardware():
# Nothing blocks in the emulator, so swap gaps say nothing about a panel.
p = _pipeline(FakeDM(refresh_hz=120.0, hardware=False), scroll_speed=90)
frames = RenderPipeline.REFRESH_WARMUP_FRAMES + RenderPipeline.REFRESH_SAMPLES + 2
_run_swaps(p, 1 / 50.0, frames)
assert p._measured_hz is None