#!/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()