mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
src/common/frame_timing.py times every frame the display presents, whoever drew it, and writes cumulative counters to /dev/shm. scripts/frame_soak.py grades a running service (late frames, freezes, where the time goes) and scripts/render_bench.py the hardware and render path alone. A stall watchdog logs the stacks behind any scroll held up for 250 ms or more (LEDMATRIX_STALL_WATCHDOG_MS lowers that). See docs/SCROLL_PERFORMANCE.md, "Soaking a rig". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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_timing, 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_timing so the benchmark grades against
|
|
the same measurement this ladder is built from.
|
|
"""
|
|
matrix = open_matrix(config, refresh_override=0)
|
|
measured = frame_timing.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()
|