mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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>
182 lines
7.1 KiB
Python
182 lines
7.1 KiB
Python
"""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 TestRefreshPlan:
|
|
BANDS = [(32, 64, 1)]
|
|
|
|
def test_one_refresh_per_frame_is_a_plain_lag(self):
|
|
assert scan_order.refresh_plan(self.BANDS, 1) == [((1,), 1)]
|
|
|
|
def test_a_held_frame_lags_only_its_first_refresh(self):
|
|
assert scan_order.refresh_plan(self.BANDS, 2) == [((1,), 1), ((0,), 1)]
|
|
assert scan_order.refresh_plan(self.BANDS, 5) == [((1,), 1), ((0,), 4)]
|
|
|
|
def test_a_lag_longer_than_the_hold_reaches_further_back(self):
|
|
# Three halves down a stack, held for two refreshes.
|
|
plan = scan_order.refresh_plan([(0, 8, 3)], 2)
|
|
assert plan == [((2,), 1), ((1,), 1)]
|
|
|
|
def test_every_refresh_of_the_frame_is_accounted_for(self):
|
|
for hold in range(1, 9):
|
|
plan = scan_order.refresh_plan([(0, 8, 1), (8, 16, 2)], hold)
|
|
assert sum(count for _, count in plan) == hold
|
|
|
|
|
|
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(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
|
|
|
|
def test_a_held_frame_is_split_so_the_lagging_half_steps_a_refresh_late(self, dm):
|
|
dm._scan_lag_bands = [(16, 32, 1)]
|
|
dm.set_scrolling_state(True, 2)
|
|
self._push(dm, 10)
|
|
before = len(dm._presented)
|
|
self._push(dm, 20)
|
|
first, second = dm._presented[before:]
|
|
assert first.getpixel((0, 0)) == (20, 0, 0)
|
|
assert first.getpixel((0, 31)) == (10, 0, 0) # lagging half: still old
|
|
assert second.getpixel((0, 31)) == (20, 0, 0) # caught up a refresh later
|
|
|
|
def test_a_slow_blit_is_not_split(self, dm):
|
|
dm._scan_lag_bands = [(16, 32, 1)]
|
|
dm.set_scrolling_state(True, 3)
|
|
self._push(dm, 10)
|
|
dm._last_blit_seconds = 1.0 # far longer than a refresh
|
|
before = len(dm._presented)
|
|
self._push(dm, 20)
|
|
assert len(dm._presented) - before == 1
|
|
assert dm._presented[-1].getpixel((0, 31)) == (20, 0, 0)
|