mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
fix(vegas): smooth Vegas scroll pacing -- whole pixels per refresh, measured refresh, off-thread preview writes (#628)
Vegas scrolls a whole number of pixels per panel refresh, locked to SwapOnVSync, against the refresh the panel really holds (measured from swap gaps), instead of blending sub-pixel positions against the refresh cap. The web preview PNG is encoded off the render thread while scrolling, with writes ordered and retried. On hdpi, late frames fell from 6.3% to 0.7%. See docs/SCROLL_PERFORMANCE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -353,3 +353,151 @@ class TestFrameHoldLifetime:
|
||||
assert dm._frame_hold == 1
|
||||
finally:
|
||||
dm.set_scrolling_state(False)
|
||||
|
||||
|
||||
class TestSnapshotOffRenderThread:
|
||||
"""Mid-scroll, the preview PNG is encoded off the render thread.
|
||||
|
||||
At 512x64 the encode takes 12-14ms on a Pi 4 -- longer than a refresh --
|
||||
so doing it inline made the next swap miss its vsync five times a second
|
||||
whenever the web preview was open.
|
||||
"""
|
||||
|
||||
def _record_saves(self, dm, monkeypatch):
|
||||
import threading
|
||||
threads = []
|
||||
done = threading.Event()
|
||||
real = dm._save_snapshot
|
||||
|
||||
def recording(image):
|
||||
threads.append(threading.current_thread().name)
|
||||
real(image)
|
||||
done.set()
|
||||
|
||||
monkeypatch.setattr(dm, "_save_snapshot", recording)
|
||||
return threads, done
|
||||
|
||||
def _due(self, dm, tmp_path, colour):
|
||||
dm._snapshot_path = str(tmp_path / "snap.png")
|
||||
dm._last_snapshot_ts = 0.0
|
||||
dm._last_snapshot_touch_ts = 0.0
|
||||
dm._last_snapshot_digest = None
|
||||
dm.draw.rectangle([0, 0, 10, 4], fill=colour)
|
||||
|
||||
def test_scrolling_frames_are_encoded_on_the_writer_thread(
|
||||
self, dm, tmp_path, monkeypatch):
|
||||
import threading
|
||||
threads, done = self._record_saves(dm, monkeypatch)
|
||||
self._due(dm, tmp_path, (0, 255, 255))
|
||||
dm.set_scrolling_state(True)
|
||||
try:
|
||||
dm.update_display()
|
||||
assert done.wait(5), "the snapshot writer never wrote the frame"
|
||||
finally:
|
||||
dm.set_scrolling_state(False)
|
||||
assert threads == ["snapshot-writer"]
|
||||
assert threads[0] != threading.current_thread().name
|
||||
assert os.path.exists(dm._snapshot_path)
|
||||
|
||||
def test_a_failed_background_write_is_retried_not_touched(
|
||||
self, dm, tmp_path, monkeypatch):
|
||||
# Queuing records the frame as written. If the writer then fails, an
|
||||
# unchanged frame must be written again, not mtime-touched: touching
|
||||
# would make a stale preview look healthy.
|
||||
import threading
|
||||
import time
|
||||
failed = threading.Event()
|
||||
|
||||
def failing(image):
|
||||
failed.set()
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(dm, "_save_snapshot", failing)
|
||||
self._due(dm, tmp_path, (0, 255, 0))
|
||||
dm.set_scrolling_state(True)
|
||||
try:
|
||||
dm.update_display()
|
||||
assert failed.wait(5)
|
||||
deadline = time.time() + 5
|
||||
while dm._last_snapshot_digest is not None and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
finally:
|
||||
dm.set_scrolling_state(False)
|
||||
assert dm._last_snapshot_digest is None
|
||||
|
||||
def test_a_frame_not_yet_on_disk_is_written_not_touched(
|
||||
self, dm, tmp_path, monkeypatch):
|
||||
# The digest is recorded when a frame is queued. Until the writer has
|
||||
# saved it, an unchanged frame must not mtime-touch the older file on
|
||||
# disk into looking current.
|
||||
import zlib
|
||||
from src.common import snapshot_policy
|
||||
touched, saved = [], []
|
||||
self._due(dm, tmp_path, (9, 9, 9))
|
||||
digest = zlib.adler32(dm.image.tobytes())
|
||||
dm._last_snapshot_digest = digest # queued earlier...
|
||||
dm._saved_snapshot_digest = 12345 # ...but an older frame is on disk
|
||||
monkeypatch.setattr(snapshot_policy, "decide",
|
||||
lambda *a, **k: snapshot_policy.SnapshotAction.TOUCH)
|
||||
monkeypatch.setattr(os, "utime", lambda *a, **k: touched.append(a))
|
||||
monkeypatch.setattr(dm, "_save_snapshot", lambda image: saved.append(image))
|
||||
dm.set_scrolling_state(False)
|
||||
dm._write_snapshot_if_due(digest)
|
||||
assert touched == []
|
||||
assert len(saved) == 1
|
||||
assert dm._saved_snapshot_digest == digest
|
||||
|
||||
# Once it is on disk, the same frame is only touched.
|
||||
dm._write_snapshot_if_due(digest)
|
||||
assert len(touched) == 1 and len(saved) == 1
|
||||
|
||||
def test_a_static_frame_lands_after_a_queued_one_still_being_written(
|
||||
self, dm, tmp_path, monkeypatch):
|
||||
# The last frame of a scroll can still be encoding when the first
|
||||
# static frame is due; the older one must not land on top.
|
||||
import threading
|
||||
written, started, release = [], threading.Event(), threading.Event()
|
||||
real = dm._save_snapshot
|
||||
|
||||
def slow_then_record(image):
|
||||
if threading.current_thread().name == "snapshot-writer":
|
||||
started.set()
|
||||
release.wait(5)
|
||||
written.append((threading.current_thread().name, image.getpixel((0, 0))))
|
||||
real(image)
|
||||
|
||||
monkeypatch.setattr(dm, "_save_snapshot", slow_then_record)
|
||||
self._due(dm, tmp_path, (0, 0, 255))
|
||||
dm.set_scrolling_state(True)
|
||||
dm.update_display() # queued: the writer blocks mid-write
|
||||
assert started.wait(5)
|
||||
dm.set_scrolling_state(False)
|
||||
self._due(dm, tmp_path, (255, 0, 0))
|
||||
static = threading.Thread(target=dm.update_display)
|
||||
static.start()
|
||||
static.join(0.2)
|
||||
assert static.is_alive(), "the static save must wait for the write in flight"
|
||||
release.set()
|
||||
static.join(5)
|
||||
assert [colour for _, colour in written] == [(0, 0, 255), (255, 0, 0)]
|
||||
|
||||
def test_cleanup_stops_the_writer(self, dm, tmp_path, monkeypatch):
|
||||
threads, done = self._record_saves(dm, monkeypatch)
|
||||
self._due(dm, tmp_path, (0, 255, 255))
|
||||
dm.set_scrolling_state(True)
|
||||
dm.update_display()
|
||||
assert done.wait(5)
|
||||
writer = dm._snapshot_thread
|
||||
dm.set_scrolling_state(False)
|
||||
dm._stop_snapshot_writer()
|
||||
writer.join(2)
|
||||
assert not writer.is_alive()
|
||||
|
||||
def test_static_frames_are_still_written_inline(
|
||||
self, dm, tmp_path, monkeypatch):
|
||||
import threading
|
||||
threads, _ = self._record_saves(dm, monkeypatch)
|
||||
self._due(dm, tmp_path, (255, 0, 255))
|
||||
dm.set_scrolling_state(False)
|
||||
dm.update_display()
|
||||
assert threads == [threading.current_thread().name]
|
||||
|
||||
@@ -140,6 +140,9 @@ def vegas_coordinator(controller):
|
||||
'enabled': True, 'max_cycle_duration': VEGAS_ITERATION_SECONDS}}})
|
||||
assert coord.vegas_config.continuous_scroll
|
||||
coord.render_pipeline = MagicMock()
|
||||
# Real numbers: the loop sleeps and reports against these.
|
||||
coord.render_pipeline.frame_interval = coord.vegas_config.get_frame_interval()
|
||||
coord.render_pipeline.target_fps = float(coord.vegas_config.target_fps)
|
||||
coord.stream_manager = MagicMock()
|
||||
coord.display_manager = controller.display_manager
|
||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
||||
|
||||
@@ -21,6 +21,9 @@ def _coordinator(plugins):
|
||||
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
|
||||
'enabled': True, 'max_cycle_duration': 0}}})
|
||||
coord.render_pipeline = MagicMock()
|
||||
# The loop paces itself from these (#628); a MagicMock can't be compared.
|
||||
coord.render_pipeline.frame_interval = 0.0
|
||||
coord.render_pipeline.target_fps = 90
|
||||
coord.stream_manager = MagicMock()
|
||||
coord.display_manager = MagicMock()
|
||||
coord.plugin_manager = SimpleNamespace(plugins=plugins, get_plugin=plugins.get)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""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
|
||||
@@ -837,6 +837,10 @@ class TestCycleEndsBeforeWrap:
|
||||
return p
|
||||
|
||||
def _advance_to(self, pipeline, distance):
|
||||
# render_frame() steps before it checks, and a whole-pixel pace steps
|
||||
# a fixed amount; start one step short so the checked frame is at
|
||||
# `distance`.
|
||||
distance -= pipeline.scroll_helper.fixed_pixels_per_frame or 0
|
||||
pipeline.scroll_helper.total_distance_scrolled = distance
|
||||
pipeline.scroll_helper.scroll_position = float(distance)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user