mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
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
261 lines
10 KiB
Python
261 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Show and try the scroll speeds your panel can display cleanly.
|
|
|
|
Motion looks smooth when the strip advances a WHOLE number of pixels per panel
|
|
refresh. Anything else has to blend two columns (which on pixel-font text reads
|
|
as shimmer) or repeat frames unevenly (which reads as judder). So the speeds
|
|
worth using are not arbitrary -- they are
|
|
|
|
refresh_hz / frame_hold * pixels_per_frame
|
|
|
|
for whole numbers of frame_hold and pixels_per_frame, and that ladder depends
|
|
on how fast YOUR panel actually refreshes. A Pi Zero driving a big chain will
|
|
have a completely different set of good speeds from a Pi 4 driving a small one.
|
|
|
|
# what can this panel do? (no hardware needed, uses your configured rate)
|
|
python3 scripts/scroll_speeds.py
|
|
|
|
# measure what the panel ACTUALLY manages, rather than what is configured
|
|
sudo systemctl stop ledmatrix
|
|
sudo python3 scripts/scroll_speeds.py --measure
|
|
sudo systemctl start ledmatrix
|
|
|
|
# what would a 60Hz panel offer?
|
|
python3 scripts/scroll_speeds.py --hz 60
|
|
|
|
# try one on the panel
|
|
sudo systemctl stop ledmatrix
|
|
sudo python3 scripts/scroll_speeds.py --demo 50
|
|
sudo systemctl start ledmatrix
|
|
|
|
This script never starts or stops the display service itself -- that is left to
|
|
you, so a crash here can never leave the panel dark.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from src.common import frame_pacing, scroll_config # noqa: E402
|
|
|
|
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
|
|
|
|
|
|
def load_config():
|
|
"""The whole config.json, or {} when it is missing or unreadable."""
|
|
try:
|
|
with open(CONFIG, encoding="utf-8") as handle:
|
|
config = json.load(handle)
|
|
except (OSError, ValueError):
|
|
return {}
|
|
return config if isinstance(config, dict) else {}
|
|
|
|
|
|
def hardware_of(config):
|
|
return (config.get("display") or {}).get("hardware") or {}
|
|
|
|
|
|
def build_options(config, refresh_override=None):
|
|
"""The matrix options the display service would use for this config.
|
|
|
|
Built by DisplayManager.apply_matrix_options, not a copy of it, so the
|
|
measurement and the demo drive the panel exactly as the service does
|
|
(runtime gpio_slowdown, rp1_rio, panel_type, orientation, defaults).
|
|
``refresh_override`` replaces limit_refresh_rate_hz; 0 means uncapped.
|
|
"""
|
|
from src.display_manager import DisplayManager, RGBMatrixOptions
|
|
|
|
options = DisplayManager.apply_matrix_options(RGBMatrixOptions(), config)
|
|
if refresh_override is not None:
|
|
options.limit_refresh_rate_hz = int(refresh_override)
|
|
return options
|
|
|
|
|
|
def open_matrix(config, refresh_override=None):
|
|
"""Construct the matrix, or explain why it will not open."""
|
|
if os.geteuid() != 0:
|
|
sys.exit("this needs root for GPIO access - rerun with sudo")
|
|
try:
|
|
from src.display_manager import RGBMatrix
|
|
except ImportError as exc:
|
|
sys.exit("could not load the display stack ({}); is rgbmatrix "
|
|
"installed on this machine?".format(exc))
|
|
try:
|
|
return RGBMatrix(options=build_options(config, refresh_override))
|
|
except Exception as exc: # pragma: no cover - hardware dependent
|
|
sys.exit(
|
|
"could not open the panel ({}).\n"
|
|
"If the display service is running it owns the GPIO - stop it first:\n"
|
|
" sudo systemctl stop ledmatrix".format(exc)
|
|
)
|
|
|
|
|
|
def measure_refresh(config, seconds=6.0):
|
|
"""Actual refresh rate, by running uncapped and timing the swaps.
|
|
|
|
What an older Pi or a longer chain will really give you, as opposed to
|
|
whatever limit_refresh_rate_hz optimistically asks for. The timing loop
|
|
itself lives in src.common.frame_pacing so the benchmark grades a soak
|
|
against the same measurement this ladder is built from.
|
|
"""
|
|
matrix = open_matrix(config, refresh_override=0)
|
|
measured = frame_pacing.measure_refresh_hz(matrix, seconds)
|
|
matrix.Clear()
|
|
return measured
|
|
|
|
|
|
def demo(config, target, seconds):
|
|
"""Scroll text at the crisp speed nearest `target`."""
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from src.common.font_layout import load_truetype
|
|
|
|
hz = scroll_config.refresh_hz_from_config(config)
|
|
choice = scroll_config.solve_crisp(target, hz)
|
|
print("asked for {:.0f} px/s -> {}".format(target, choice.describe()))
|
|
|
|
matrix = open_matrix(config)
|
|
canvas = matrix.CreateFrameCanvas()
|
|
W, H = canvas.width, canvas.height
|
|
|
|
font = None
|
|
for path, size in (
|
|
(str(Path(__file__).resolve().parent.parent / "assets/fonts/PressStart2P-Regular.ttf"), 16),
|
|
("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 26),
|
|
):
|
|
try:
|
|
font = load_truetype(path, size)
|
|
break
|
|
except OSError:
|
|
continue
|
|
if font is None:
|
|
font = ImageFont.load_default()
|
|
|
|
text = " {:.0f} px/s *** THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG ***".format(
|
|
choice.pixels_per_second)
|
|
box = ImageDraw.Draw(Image.new("RGB", (8, 8))).textbbox((0, 0), text, font=font)
|
|
tw, th = box[2] - box[0], box[3] - box[1]
|
|
reps = max(2, (W * 3) // max(tw, 1) + 1)
|
|
strip = Image.new("RGB", (tw * reps, H), (0, 0, 0))
|
|
draw = ImageDraw.Draw(strip)
|
|
for i in range(reps):
|
|
draw.text((i * tw, (H - th) // 2 - box[1]), text, font=font, fill=(255, 210, 60))
|
|
|
|
offset = 0
|
|
frames = 0
|
|
started = time.time()
|
|
while time.time() - started < seconds:
|
|
window = strip.crop((offset, 0, offset + W, H))
|
|
if window.width < W:
|
|
whole = Image.new("RGB", (W, H), (0, 0, 0))
|
|
head = strip.crop((offset, 0, strip.width, H))
|
|
whole.paste(head, (0, 0))
|
|
whole.paste(strip.crop((0, 0, W - head.width, H)), (head.width, 0))
|
|
window = whole
|
|
canvas.SetImage(window)
|
|
canvas = matrix.SwapOnVSync(canvas, choice.frame_hold)
|
|
offset = (offset + choice.pixels_per_frame) % strip.width
|
|
frames += 1
|
|
elapsed = time.time() - started
|
|
print(" {} frames in {:.1f}s = {:.1f} fps = {:.1f} px/s actual".format(
|
|
frames, elapsed, frames / elapsed, frames * choice.pixels_per_frame / elapsed))
|
|
matrix.Clear()
|
|
|
|
|
|
def print_ladder(hz, highlight=None):
|
|
print("")
|
|
print("Whole-pixel scroll speeds at {:.1f}Hz refresh".format(hz))
|
|
print("(the panel refreshes at {:.0f}Hz for every one of these - holding a "
|
|
"frame costs no flicker)".format(hz))
|
|
print("")
|
|
for entry in scroll_config.crisp_ladder(hz):
|
|
if entry.pixels_per_second > hz * 3:
|
|
break
|
|
mark = " <-- nearest to {:.0f}".format(highlight) if (
|
|
highlight is not None
|
|
and entry.pixels_per_second == scroll_config.solve_crisp(highlight, hz).pixels_per_second
|
|
) else ""
|
|
print(" " + entry.describe() + mark)
|
|
print("")
|
|
print_config_advice(scroll_config.solve_crisp(highlight if highlight else hz / 2, hz))
|
|
|
|
|
|
def config_advice(choice):
|
|
"""The config that selects ``choice``, in the keys the resolver honours.
|
|
|
|
Tickers take a ``scroll_speed`` (px per step) + ``scroll_delay`` (seconds)
|
|
pair, and scroll_config ranks that pair ABOVE ``scroll_pixels_per_second``
|
|
-- deliberately, because some plugins give the flat key a schema default.
|
|
Many schemas default the pair too, so a flat key added by hand is usually
|
|
ignored. Advise the pair: pixels_per_frame every frame_hold/refresh
|
|
seconds is exactly the crisp speed.
|
|
"""
|
|
pair = {
|
|
"scroll_speed": choice.pixels_per_frame,
|
|
"scroll_delay": round(choice.frame_hold / choice.refresh_hz, 6),
|
|
}
|
|
scoreboard = {"scroll_speed": round(choice.pixels_per_second, 2)}
|
|
return pair, scoreboard
|
|
|
|
|
|
def print_config_advice(choice):
|
|
pair, scoreboard = config_advice(choice)
|
|
print("To use {:.1f} px/s, set it where the plugin keeps its scroll speed.".format(
|
|
choice.pixels_per_second))
|
|
print("Tickers take a scroll_speed (px per step) + scroll_delay (seconds) pair:")
|
|
print(' "display_options": {}'.format(json.dumps(pair)))
|
|
print("(some plugins keep the pair at the top level or under \"display\").")
|
|
print("The pair outranks scroll_pixels_per_second, which is ignored whenever the")
|
|
print("pair is present -- and schema defaults usually put it there.")
|
|
print("Sports scoreboards take pixels per second per league instead:")
|
|
print(' "scroll_settings": {}'.format(json.dumps(scoreboard)))
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--hz", type=float,
|
|
help="refresh rate to compute the ladder for (default: your config)")
|
|
ap.add_argument("--measure", action="store_true",
|
|
help="measure the panel's real refresh rate (needs root, service stopped)")
|
|
ap.add_argument("--demo", type=float, metavar="PXPS",
|
|
help="scroll text at the crisp speed nearest this (needs root)")
|
|
ap.add_argument("--seconds", type=float, default=15.0, help="demo duration")
|
|
ap.add_argument("--want", type=float, metavar="PXPS",
|
|
help="highlight the entry nearest this speed")
|
|
args = ap.parse_args()
|
|
|
|
config = load_config()
|
|
configured = float(hardware_of(config).get("limit_refresh_rate_hz") or 0)
|
|
|
|
if args.demo is not None:
|
|
demo(config, args.demo, args.seconds)
|
|
return
|
|
|
|
if args.measure:
|
|
measured = measure_refresh(config)
|
|
print("measured panel refresh: {:.1f}Hz".format(measured))
|
|
if configured:
|
|
print("configured limit_refresh_rate_hz: {:.0f}".format(configured))
|
|
if measured < configured * 0.95:
|
|
print(" -> the panel cannot reach the configured rate; the ladder")
|
|
print(" below uses what it actually manages")
|
|
print_ladder(measured, args.want)
|
|
return
|
|
|
|
hz = args.hz or configured or scroll_config.DEFAULT_REFRESH_HZ
|
|
if not args.hz and not configured:
|
|
print("no limit_refresh_rate_hz in config; assuming {:.0f}Hz".format(hz))
|
|
print("run with --measure to find your panel's real rate")
|
|
print_ladder(hz, args.want)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|