From 8d287a63b964b23828d95ab899e8a08fdc136250 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:46:12 -0400 Subject: [PATCH 1/2] perf(leaderboard): decode each logo once, not once per rebuild Image.open defers the PNG decode to first access, so the decode landed inside _prepare_logo's convert(), with a LANCZOS resize behind it -- for every team, on every rebuild, for files that had not changed. Measured on the real asset directory: 25 logos cost 276ms cold and 0.3ms warm. It matters more than a normal cache miss because leaderboard scroll content is generated on the render thread. The adapter's capture path needs the shared canvas, so it cannot be prepared in the background (render_pipeline.drain_deferred explains why), and the cost lands directly on the Vegas scroll as stutter. Keyed on path and mtime, so a logo the downloader backfills is picked up rather than served stale. A missing file is deliberately not cached: remembering it as absent would keep a logo downloaded moments later invisible until restart. Honest scope: this does not fix the 3.2s freeze that led here. That one is still present with this change in place, and the watchdog work on the core side now shows why it was never caught -- it holds the GIL. This removes a real and separate cost on the same path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins.json | 4 +- .../ledmatrix-leaderboard/image_renderer.py | 62 +++++++- plugins/ledmatrix-leaderboard/manifest.json | 8 +- .../ledmatrix-leaderboard/test_logo_cache.py | 150 ++++++++++++++++++ 4 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 plugins/ledmatrix-leaderboard/test_logo_cache.py diff --git a/plugins.json b/plugins.json index 8c714be9..6447f170 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-11", + "last_updated": "2026-08-12", "plugins": [ { "id": "cricket-scoreboard", @@ -412,7 +412,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.0" + "latest_version": "1.3.1" }, { "id": "ledmatrix-flights", diff --git a/plugins/ledmatrix-leaderboard/image_renderer.py b/plugins/ledmatrix-leaderboard/image_renderer.py index 5b1a2012..b42bef8e 100644 --- a/plugins/ledmatrix-leaderboard/image_renderer.py +++ b/plugins/ledmatrix-leaderboard/image_renderer.py @@ -89,6 +89,14 @@ def __init__(self, display_height: int, logger: Optional[logging.Logger] = None, appearance = appearance or {} self.pixel_perfect_text = bool(appearance.get('pixel_perfect_text', True)) self.crisp_logos = bool(appearance.get('crisp_logos', True)) + # Prepared logos, keyed by file identity and target box. Both halves + # of preparing one are expensive and neither varies between rebuilds: + # Image.open defers the PNG decode to first access, so the decode lands + # inside _prepare_logo's convert(), and a LANCZOS resize follows it. A + # rebuild did that for every team, every time, for files that had not + # changed. On a live rig this ran on the render thread and was the + # largest single contributor to a 3.2s freeze of the scroll. + self._logo_cache: Dict[Any, Optional[Image.Image]] = {} self.text_outline = bool(appearance.get('text_outline', True)) self.logo_scale = self._clamp_float(appearance.get('logo_scale', 1.0), 0.5, 1.5, 1.0) self.font_size_override = self._clamp_int(appearance.get('font_size', 0), 0, 32, 0) @@ -261,6 +269,44 @@ def _prepare_logo(self, logo: Image.Image, max_width: int, self.logger.error("Error preparing logo: %s", e) return None + # Logos are small; the ceiling only exists so a long-running process with + # many leagues cannot grow this without bound. Clearing wholesale on + # overflow keeps it predictable -- the next rebuild simply repopulates + # what it needs, which is a handful of entries. + _LOGO_CACHE_MAX = 512 + + def _cached_prepared_logo(self, path: Optional[str], max_width: int, + max_height: int, load) -> Optional[Image.Image]: + """A prepared logo, decoded and resized at most once per file version. + + Keyed on the file's mtime as well as its path, so a logo replaced on + disk (the downloader backfills missing ones) is picked up rather than + served stale forever. + + The result is shared, not copied: callers only ever paste from it, and + copying per team would put back a slice of the cost this removes. + """ + stamp = None + if path: + try: + stamp = os.path.getmtime(path) + except OSError: + stamp = None + + key = (path, stamp, max_width, max_height, self.crisp_logos) + if stamp is not None and key in self._logo_cache: + return self._logo_cache[key] + + prepared = self._prepare_logo(load(), max_width, max_height) + + # Only a file that exists is cacheable. Caching a miss would make a + # logo that is downloaded a moment later invisible until restart. + if stamp is not None: + if len(self._logo_cache) >= self._LOGO_CACHE_MAX: + self._logo_cache.clear() + self._logo_cache[key] = prepared + return prepared + def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: """Get team logo from the configured directory, downloading if missing.""" if not team_abbr or not logo_dir: @@ -332,8 +378,9 @@ def _build_layout(self, leaderboard_data: List[Dict[str, Any]]) -> Tuple[List[Di if league_data.get('is_tournament') and league_key in ('ncaam_basketball', 'ncaaw_basketball'): if os.path.exists(self.MARCH_MADNESS_LOGO_PATH): league_logo_path = self.MARCH_MADNESS_LOGO_PATH - league_logo = self._prepare_logo( - self._get_league_logo(league_logo_path), self.LEAGUE_LOGO_WIDTH, height + league_logo = self._cached_prepared_logo( + league_logo_path or None, self.LEAGUE_LOGO_WIDTH, height, + lambda: self._get_league_logo(league_logo_path), ) teams = [] @@ -345,10 +392,13 @@ def _build_layout(self, leaderboard_data: List[Dict[str, Any]]) -> Tuple[List[Di team_text = team.get('abbreviation', '') text_width = self._text_advance(team_text, self.fonts['large']) - team_logo = self._prepare_logo( - self._get_team_logo(league_key, team.get('id'), team_text, - league_config.get('logo_dir')), - logo_box, logo_box + team_logo_dir = league_config.get('logo_dir') + team_logo = self._cached_prepared_logo( + str(Path(team_logo_dir, f"{team_text}.png")) + if (team_text and team_logo_dir) else None, + logo_box, logo_box, + lambda: self._get_team_logo( + league_key, team.get('id'), team_text, team_logo_dir), ) width = number_width + text_width + self.TEAM_GAP + self.LOGO_TEXT_GAP diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index ee200369..c62dbd0b 100644 --- a/plugins/ledmatrix-leaderboard/manifest.json +++ b/plugins/ledmatrix-leaderboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-leaderboard", "name": "Sports Leaderboard", - "version": "1.3.0", + "version": "1.3.1", "description": "Displays scrolling leaderboards and standings for multiple sports leagues including NFL, NBA, MLB, NCAA Football, NCAA Basketball, and more", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -31,6 +31,12 @@ "requirements_file": "requirements.txt", "min_ledmatrix_version": "2.0.0", "versions": [ + { + "released": "2026-08-12", + "version": "1.3.1", + "ledmatrix_min_version": "2.0.0", + "notes": "Decode and resize each team logo once instead of once per rebuild. Image.open defers the PNG decode to first access, so it landed inside _prepare_logo's convert(), with a LANCZOS resize after it, for every team on every rebuild of files that had not changed. Measured on real assets: 25 logos cost 276ms uncached and 0.3ms warm. This matters because leaderboard scroll content is built on the render thread -- the adapter's capture path needs the shared canvas -- so the cost lands directly on the Vegas scroll. Cached on path plus mtime, so a logo the downloader backfills is picked up rather than served stale, and a missing one is retried rather than remembered as absent." + }, { "version": "1.3.0", "released": "2026-08-05", diff --git a/plugins/ledmatrix-leaderboard/test_logo_cache.py b/plugins/ledmatrix-leaderboard/test_logo_cache.py new file mode 100644 index 00000000..47732b0a --- /dev/null +++ b/plugins/ledmatrix-leaderboard/test_logo_cache.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Tests that logos are decoded and resized once, not once per rebuild. + +Preparing a logo is expensive twice over: Image.open defers the PNG decode to +first access, so the decode lands inside _prepare_logo's convert(), and a +LANCZOS resize follows it. A rebuild did that for every team, every time, for +files that had not changed. + +That mattered because leaderboard scroll content is generated on the render +thread -- the plugin adapter's capture path needs the shared canvas, so it +cannot be prepared in the background. Measured on a live rig, a rebuild froze +the Vegas scroll for 3.2 seconds; 25 logos alone accounted for 276ms of it, +and a real board carries far more. + +Run: python3 plugins/ledmatrix-leaderboard/test_logo_cache.py +""" + +import sys +import time +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +try: + from PIL import Image +except ImportError: + print("SKIP: Pillow not installed") + sys.exit(2) + +import image_renderer as ir # noqa: E402 + +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +def _renderer(): + r = ir.ImageRenderer.__new__(ir.ImageRenderer) + r.logger = type("L", (), {m: (lambda *a, **k: None) for m in + ("debug", "info", "warning", "error", "exception")})() + r.crisp_logos = True + r._logo_cache = {} + return r + + +def _png(path, size=(32, 32), colour=(255, 0, 0, 255)): + Image.new('RGBA', size, colour).save(path) + return str(path) + + +def main(): + import tempfile + tmp = Path(tempfile.mkdtemp()) + r = _renderer() + p = _png(tmp / 'AAA.png') + + print("a logo is loaded once, however many rebuilds ask for it") + loads = {'n': 0} + + def load(): + loads['n'] += 1 + return Image.open(p) + + first = r._cached_prepared_logo(p, 20, 20, load) + for _ in range(24): + r._cached_prepared_logo(p, 20, 20, load) + check("loaded exactly once", loads['n'] == 1, "%d loads" % loads['n']) + check("and returns a real image", first is not None) + check("the same one each time", + r._cached_prepared_logo(p, 20, 20, load) is first) + + print("\ndifferent target sizes are different entries") + small = r._cached_prepared_logo(p, 10, 10, load) + check("a second size reloads", loads['n'] == 2, "%d loads" % loads['n']) + check("and is actually a different size", + small is not None and first is not None + and small.size != first.size, "%r vs %r" % (small.size, first.size)) + + print("\na logo replaced on disk is picked up, not served stale") + # The downloader backfills missing logos, so a path can gain new content. + time.sleep(0.01) + Image.new('RGBA', (48, 48), (0, 0, 255, 255)).save(p) + import os + os.utime(p, (time.time() + 5, time.time() + 5)) # ensure a distinct mtime + before = loads['n'] + r._cached_prepared_logo(p, 20, 20, load) + check("reloaded after the file changed", loads['n'] == before + 1, + "%d -> %d" % (before, loads['n'])) + + print("\na missing logo is not cached as missing") + # Otherwise a logo downloaded a moment later stays invisible until restart. + missing = str(tmp / 'ZZZ.png') + misses = {'n': 0} + + def load_missing(): + misses['n'] += 1 + return None + + check("absent logo prepares to None", + r._cached_prepared_logo(missing, 20, 20, load_missing) is None) + r._cached_prepared_logo(missing, 20, 20, load_missing) + check("and is retried rather than remembered", misses['n'] == 2, + "%d attempts" % misses['n']) + + _png(tmp / 'ZZZ.png') + check("so it appears once it exists", + r._cached_prepared_logo(missing, 20, 20, + lambda: Image.open(missing)) is not None) + + print("\nthe cache cannot grow without bound") + r2 = _renderer() + r2._LOGO_CACHE_MAX = 8 + for i in range(20): + q = _png(tmp / ('T%02d.png' % i)) + r2._cached_prepared_logo(q, 20, 20, lambda q=q: Image.open(q)) + check("stays within its ceiling", len(r2._logo_cache) <= 8, + "%d entries" % len(r2._logo_cache)) + + print("\nthe saving is real, not theoretical") + r3 = _renderer() + paths = [_png(tmp / ('B%02d.png' % i)) for i in range(25)] + for q in paths: # warm the OS page cache: be fair + r3._prepare_logo(Image.open(q), 20, 20) + t = time.perf_counter() + for q in paths: + r3._prepare_logo(Image.open(q), 20, 20) + uncached = time.perf_counter() - t + for q in paths: + r3._cached_prepared_logo(q, 20, 20, lambda q=q: Image.open(q)) + t = time.perf_counter() + for q in paths: + r3._cached_prepared_logo(q, 20, 20, lambda q=q: Image.open(q)) + warm = time.perf_counter() - t + check("a warm rebuild is far cheaper", warm * 10 < uncached, + "%.2fms cached vs %.2fms uncached" % (warm * 1e3, uncached * 1e3)) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 15249793daf0637f5c2d693c8b5bfc6ee0f5e6a5 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:48:26 -0400 Subject: [PATCH 2/2] fix(leaderboard): move missing-logo downloads off the render thread (#271) * fix(leaderboard): move missing-logo downloads off the render thread _get_team_logo ran from layout construction inside display(), and on a cache miss it invoked download_missing_logo -- a network call on the render thread the Vegas scroll depends on. Missing logos are now backfilled by a new download_missing_logos(), called from update() instead; _get_team_logo only ever reads local files, and a backfilled logo becomes visible on the next rebuild via the existing mtime-keyed cache. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RRsTMmLUcpd8tpN1qciEgC * test(leaderboard): drop unused variable in logo cache test Codacy flagged an unused local in the new test coverage. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RRsTMmLUcpd8tpN1qciEgC --------- Co-authored-by: Claude --- plugins.json | 2 +- .../ledmatrix-leaderboard/image_renderer.py | 59 +++++++++++++------ plugins/ledmatrix-leaderboard/manager.py | 6 +- plugins/ledmatrix-leaderboard/manifest.json | 8 ++- .../ledmatrix-leaderboard/test_logo_cache.py | 37 ++++++++++++ 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/plugins.json b/plugins.json index 6447f170..097232cf 100644 --- a/plugins.json +++ b/plugins.json @@ -412,7 +412,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.1" + "latest_version": "1.3.2" }, { "id": "ledmatrix-flights", diff --git a/plugins/ledmatrix-leaderboard/image_renderer.py b/plugins/ledmatrix-leaderboard/image_renderer.py index b42bef8e..55311cda 100644 --- a/plugins/ledmatrix-leaderboard/image_renderer.py +++ b/plugins/ledmatrix-leaderboard/image_renderer.py @@ -307,8 +307,15 @@ def _cached_prepared_logo(self, path: Optional[str], max_width: int, self._logo_cache[key] = prepared return prepared - def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: - """Get team logo from the configured directory, downloading if missing.""" + def _get_team_logo(self, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: + """ + Get team logo from the configured directory. + + Local files only -- this runs from layout construction on the render + thread, and a network download there would block the Vegas scroll. + Missing logos are backfilled by ``download_missing_logos()``, which + the plugin calls from ``update()`` instead. + """ if not team_abbr or not logo_dir: self.logger.debug("Cannot get team logo with missing team_abbr or logo_dir") return None @@ -318,23 +325,42 @@ def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: st logo = Image.open(logo_path) self.logger.debug(f"Successfully loaded logo for {team_abbr}") return logo - else: - self.logger.warning(f"Logo not found at path: {logo_path}") - - # Try to download the missing logo - if league: - self.logger.info(f"Attempting to download missing logo for {team_abbr} in league {league}") - success = download_missing_logo(league, team_id, team_abbr, logo_path, None) - if success and os.path.exists(logo_path): - logo = Image.open(logo_path) - self.logger.info(f"Successfully downloaded and loaded logo for {team_abbr}") - return logo - - return None + self.logger.debug(f"Logo not found at path: {logo_path}") + return None except Exception as e: self.logger.error(f"Error loading logo for {team_abbr}: {e}") return None + def download_missing_logos(self, leaderboard_data: List[Dict[str, Any]]) -> None: + """ + Backfill any missing team logo files from the network. + + Intended to be called from ``update()``, off the render thread, so a + download never blocks ``display()``. Once a file lands on disk, + ``_get_team_logo`` picks it up on the next rebuild. + """ + for league_data in leaderboard_data: + league_key = league_data.get('league') + league_config = league_data.get('league_config') or {} + logo_dir = league_config.get('logo_dir') + if not league_key or not logo_dir: + continue + for team in league_data.get('teams', []): + team_abbr = team.get('abbreviation', '') + if not team_abbr: + continue + logo_path = Path(logo_dir, f"{team_abbr}.png") + if os.path.exists(logo_path): + continue + try: + self.logger.info( + "Attempting to download missing logo for %s in league %s", + team_abbr, league_key + ) + download_missing_logo(league_key, team.get('id'), team_abbr, logo_path, None) + except Exception as e: + self.logger.error("Error downloading missing logo for %s: %s", team_abbr, e) + def _get_league_logo(self, league_logo_path: str) -> Optional[Image.Image]: """Get league logo from the configured path.""" if not league_logo_path: @@ -397,8 +423,7 @@ def _build_layout(self, leaderboard_data: List[Dict[str, Any]]) -> Tuple[List[Di str(Path(team_logo_dir, f"{team_text}.png")) if (team_text and team_logo_dir) else None, logo_box, logo_box, - lambda: self._get_team_logo( - league_key, team.get('id'), team_text, team_logo_dir), + lambda: self._get_team_logo(team_text, team_logo_dir), ) width = number_width + text_width + self.TEAM_GAP + self.LOGO_TEXT_GAP diff --git a/plugins/ledmatrix-leaderboard/manager.py b/plugins/ledmatrix-leaderboard/manager.py index cc3400f9..29121b8e 100644 --- a/plugins/ledmatrix-leaderboard/manager.py +++ b/plugins/ledmatrix-leaderboard/manager.py @@ -246,7 +246,11 @@ def update(self, force: bool = False) -> None: self.logger.warning(f"No standings data returned for {league_key}") self.last_update = current_time - + + # Backfill any missing team logos here, off the render thread -- + # display() only ever reads local files, never downloads. + self.image_renderer.download_missing_logos(self.leaderboard_data) + # Clear scroll cache when data updates self.scroll_helper.clear_cache() diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index c62dbd0b..c8a93d23 100644 --- a/plugins/ledmatrix-leaderboard/manifest.json +++ b/plugins/ledmatrix-leaderboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-leaderboard", "name": "Sports Leaderboard", - "version": "1.3.1", + "version": "1.3.2", "description": "Displays scrolling leaderboards and standings for multiple sports leagues including NFL, NBA, MLB, NCAA Football, NCAA Basketball, and more", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -31,6 +31,12 @@ "requirements_file": "requirements.txt", "min_ledmatrix_version": "2.0.0", "versions": [ + { + "released": "2026-08-12", + "version": "1.3.2", + "ledmatrix_min_version": "2.0.0", + "notes": "Missing team logos are no longer downloaded from the render path. A network call inside layout construction (display()) could stall the Vegas scroll; the download now happens in update(), and the render path only ever reads local files, picking up a backfilled logo on the next rebuild." + }, { "released": "2026-08-12", "version": "1.3.1", diff --git a/plugins/ledmatrix-leaderboard/test_logo_cache.py b/plugins/ledmatrix-leaderboard/test_logo_cache.py index 47732b0a..038077b8 100644 --- a/plugins/ledmatrix-leaderboard/test_logo_cache.py +++ b/plugins/ledmatrix-leaderboard/test_logo_cache.py @@ -141,6 +141,43 @@ def load_missing(): check("a warm rebuild is far cheaper", warm * 10 < uncached, "%.2fms cached vs %.2fms uncached" % (warm * 1e3, uncached * 1e3)) + print("\na missing team logo is not downloaded from the render path") + # _get_team_logo runs from layout construction on the render thread; a + # network call there would block the Vegas scroll. Downloading is + # update()'s job (download_missing_logos), not the renderer's. + calls = {'n': 0} + ir.download_missing_logo = lambda *a, **k: calls.__setitem__('n', calls['n'] + 1) or True + r4 = _renderer() + result = r4._get_team_logo('NOPE', str(tmp)) # no NOPE.png on disk + check("returns None for a missing local file", result is None) + check("without ever invoking the downloader", calls['n'] == 0, "%d calls" % calls['n']) + + print("\nupdate()'s backfill downloads missing logos and a later render sees them") + downloaded = {'n': 0} + + def fake_download(league, team_id, team_abbr, logo_path, session): + downloaded['n'] += 1 + _png(logo_path) # simulate the downloader writing the file + return True + + ir.download_missing_logo = fake_download + r5 = _renderer() + leaderboard_data = [{ + 'league': 'nfl', + 'league_config': {'logo_dir': str(tmp)}, + 'teams': [{'id': '1', 'abbreviation': 'BFL'}], + }] + bfl_path = tmp / 'BFL.png' + check("logo does not exist yet", not bfl_path.exists()) + r5.download_missing_logos(leaderboard_data) + check("update()'s backfill invoked the downloader once", downloaded['n'] == 1, + "%d calls" % downloaded['n']) + check("and the file now exists locally", bfl_path.exists()) + check("so a later render finds it without downloading", + r5._get_team_logo('BFL', str(tmp)) is not None) + check("still without the render path calling the downloader", + downloaded['n'] == 1, "%d calls" % downloaded['n']) + print("\n%s" % ("FAILED: %d" % len(failures) if failures else "All checks passed")) return 1 if failures else 0