diff --git a/plugins.json b/plugins.json index 50a2cb15..647c5cba 100644 --- a/plugins.json +++ b/plugins.json @@ -280,12 +280,12 @@ "plugin_path": "plugins/football-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-04", "verified": true, "screenshot": "", - "latest_version": "3.18.5", + "latest_version": "3.18.6", "ledmatrix_min_version": "3.8.0", - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + "commit": "d97226b3de9e9c980fbe94d6bb5cca3d3dd628dd" }, { "id": "geochron", diff --git a/plugins/football-scoreboard/CHANGELOG.md b/plugins/football-scoreboard/CHANGELOG.md index d70e5839..d860c1e5 100644 --- a/plugins/football-scoreboard/CHANGELOG.md +++ b/plugins/football-scoreboard/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [3.18.6] - 2026-10-04 + +### Fixed +- Adaptive layout (`layout_mode: "adaptive"`) no longer keeps team logos at + full size. `GameRenderer._load_raw_logo` cached every logo it loaded, + unresized, up to 128 per renderer -- and the plugin builds several + renderers (a scorebug per manager, scroll cards, Vegas cards). The source + files are 768x768 RGBA for the NFL (2.3 MB decoded) and 500x500 for most of + NCAA, so those caches grew by hundreds of MB over a day of live games as new + teams came on, and the display's memory rose all day with them. The card + only draws the logo fitted into its slot, so that is what is cached now + (`_fit_logo`, keyed by logo directory, abbreviation and slot size); a miss + reads the file, fits it and lets the source go. Cards are pixel-identical. + `test_adaptive_logos_not_kept_full_size.py` draws 40 teams through one + renderer: it held 94.7 MB of logo pixels before and holds none larger than + the panel now. + ## [3.18.5] - 2026-10-02 ### Documentation diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index b6853dbd..cc8651f6 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -284,7 +284,9 @@ def __init__( self._ctx = LayoutContext(display_width, display_height, _get_font_manager(), design_size=(128, 32)) - self._raw_logo_cache: Dict[str, Image.Image] = {} + # Logos as fitted into their slot, keyed by team and slot size. + # The unresized source is NOT kept: see _fit_logo. + self._fitted_logo_cache: Dict[Any, Any] = {} # Rankings cache (populated externally) self._team_rankings_cache: Dict[str, int] = {} @@ -1059,28 +1061,48 @@ def _fit_upcoming_rows(self, rows, region: "Region", ladder=None): return bands, rebuilt def _load_raw_logo(self, team_abbrev: str, logo_path) -> Optional[Image.Image]: - """Load a logo unresized (the adaptive path fits it per region; - results are cached per size by the LayoutContext). - - Keyed by logo directory as well as abbreviation, as the classic cache - is (see _logo_scope): one renderer draws a strip carrying both - leagues, and keyed by "MIA" alone the Hurricanes' card drew the - Dolphins' logo, or the other way round.""" - key = f"{self._logo_scope(logo_path)}:{team_abbrev}" - cached = self._lru_get(self._raw_logo_cache, key) - if cached is not None: - return cached + """Load a logo unresized, for _fit_logo to fit into its slot. + + Not cached. These are the source files at full size -- 768x768 RGBA + for the NFL, 2.3 MB decoded, and 500x500 for most of NCAA -- and they + used to be kept, up to 128 per renderer, in every renderer the plugin + builds (a scorebug per manager, scroll cards, Vegas cards). On a + Saturday slate that was hundreds of MB that grew all day as new teams + came on; a display measured on a Pi 4 went from 325 MB to 549 MB in + six hours of live games. The card only ever draws the fitted logo, + which _fit_logo keeps instead.""" try: if logo_path and os.path.exists(logo_path): - logo = Image.open(logo_path) - if logo.mode != "RGBA": - logo = logo.convert("RGBA") - self._lru_put(self._raw_logo_cache, key, logo) - return logo + with Image.open(logo_path) as source: + return source.convert("RGBA") except Exception as e: self.logger.error(f"Error loading logo for {team_abbrev}: {e}") return None + def _fit_logo(self, team_abbrev: str, logo_path, slot: "Region"): + """The team's logo fitted into ``slot``, or None if it has no logo. + + Cached per renderer by logo directory, abbreviation and slot size -- + the directory as well as the abbreviation, as the classic cache is + (see _logo_scope): one renderer draws a strip carrying both leagues, + and keyed by "MIA" alone the Hurricanes' card drew the Dolphins' + logo, or the other way round. A hit costs no disk read and no decode; + a miss loads the source, fits it and lets it go. + """ + scope = self._logo_scope(logo_path) + key = (scope, team_abbrev, slot.w, slot.h) + cached = self._lru_get(self._fitted_logo_cache, key) + if cached is not None: + return cached + raw = self._load_raw_logo(team_abbrev, logo_path) + if not raw: + return None + ifit = self._ctx.fit_image(raw, slot, mode="fill_height", + crop_to_ink=True, + cache_key=f"logo:{scope}:{team_abbrev}") + self._lru_put(self._fitted_logo_cache, key, ifit) + return ifit + #: Ladder rung the adaptive score should be able to reach. 8 is what fits #: a 48px gap and matches classic, but reads thin on a tall card; 24 needs #: a 128px gap and buys mostly dead space. 16 doubles the score for 40px @@ -1251,9 +1273,16 @@ def _render_game_card_adaptive(self, game: Dict[str, Any], scoreboard_regions(Region(0, 0, width, height), ctx=self._ctx)) self._adaptive_score_px = 0 - away_raw = self._load_raw_logo(game.get("away_abbr", ""), game.get("away_logo_path")) - home_raw = self._load_raw_logo(game.get("home_abbr", ""), game.get("home_logo_path")) - if not away_raw or not home_raw: + fits = [ + self._fit_logo(abbr, path, self._region_for(slot, element)) + for slot, element, abbr, path in ( + (regs.away_slot, 'away_logo', game.get("away_abbr", ""), + game.get("away_logo_path")), + (regs.home_slot, 'home_logo', game.get("home_abbr", ""), + game.get("home_logo_path")), + ) + ] + if fits[0] is None or fits[1] is None: draw = ImageDraw.Draw(main_img) draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). self._draw_text_with_outline( @@ -1264,18 +1293,11 @@ def _render_game_card_adaptive(self, game: Dict[str, Any], ) return main_img.convert('RGB') - for raw, slot, element, abbr, path in ( - (away_raw, regs.away_slot, 'away_logo', game.get("away_abbr", ""), - game.get("away_logo_path")), - (home_raw, regs.home_slot, 'home_logo', game.get("home_abbr", ""), - game.get("home_logo_path")), + for ifit, slot, element in ( + (fits[0], regs.away_slot, 'away_logo'), + (fits[1], regs.home_slot, 'home_logo'), ): slot = self._region_for(slot, element) - # Scoped like the raw logo: the fitted-image cache is keyed by - # name, so "logo:MIA" would hand one league's fit to the other. - ifit = self._ctx.fit_image(raw, slot, mode="fill_height", - crop_to_ink=True, - cache_key=f"logo:{self._logo_scope(path)}:{abbr}") if not ifit.is_empty: x, y = slot.align_xy(ifit.width, ifit.height) main_img.paste(ifit.image, (x, y), ifit.image) diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index d029cc13..0d49f876 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "3.18.5", + "version": "3.18.6", "update_interval": 60, "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", @@ -25,6 +25,12 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "3.18.6", + "released": "2026-10-04", + "ledmatrix_min_version": "3.8.0", + "notes": "Adaptive layout no longer keeps every team's logo at full size: each card renderer held up to 128 unresized logos (2.3 MB each for the NFL), which grew the display's memory by hundreds of MB over a day of live games. Only the fitted logos are kept now; cards are pixel-identical." + }, { "version": "3.18.5", "released": "2026-10-02", diff --git a/plugins/football-scoreboard/test_adaptive_logo_league_scope.py b/plugins/football-scoreboard/test_adaptive_logo_league_scope.py index 1900d06d..456e1bf6 100644 --- a/plugins/football-scoreboard/test_adaptive_logo_league_scope.py +++ b/plugins/football-scoreboard/test_adaptive_logo_league_scope.py @@ -5,7 +5,8 @@ path (layout_mode: "adaptive") keeps two caches of its own, and both were keyed by abbreviation alone: -* _raw_logo_cache, the unresized logos, keyed "MIA"; +* the renderer's logo cache, keyed "MIA" (now _fitted_logo_cache, which + holds the fitted logos; the unresized ones are no longer kept); * the LayoutContext's fitted-image cache, keyed "logo:MIA". One renderer draws a whole scroll strip, and a strip carries both leagues, so @@ -103,13 +104,13 @@ def game(league_dir): f"{_count(card, want)} px of its own logo, " f"{_count(card, wrong)} px of the other league's") - # The same file twice is one cached logo, not two. + # The same file twice is one cached fitted logo, not two. r = GameRenderer(128, 64, {"layout_mode": "adaptive"}) r._render_game_card_adaptive(game("nfl_logos"), "live") r._render_game_card_adaptive(game("nfl_logos"), "live") - check("the same directory and abbreviation share one raw entry", - sum(1 for k in r._raw_logo_cache if k.endswith(":MIA")) == 1, - str(sorted(r._raw_logo_cache))) + check("the same directory and abbreviation share one fitted entry", + sum(1 for k in r._fitted_logo_cache if k[1] == "MIA") == 1, + str(sorted(r._fitted_logo_cache))) failed = [c for c, ok in results if not ok] print("\n%d passed, %d failed" % (len(results) - len(failed), len(failed))) diff --git a/plugins/football-scoreboard/test_adaptive_logos_not_kept_full_size.py b/plugins/football-scoreboard/test_adaptive_logos_not_kept_full_size.py new file mode 100644 index 00000000..06d48335 --- /dev/null +++ b/plugins/football-scoreboard/test_adaptive_logos_not_kept_full_size.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Adaptive cards keep fitted logos, not the full-size source files. + +The adaptive card path loaded each team's logo unresized and cached it, up to +128 per renderer, in every renderer the plugin builds (a scorebug per manager, +scroll cards, Vegas cards). The source files are big -- 768x768 RGBA for the +NFL, 2.3 MB decoded; 500x500 for most of NCAA -- so on a Saturday slate those +caches grew by hundreds of MB as new teams came on. A Pi 4 running football in +adaptive mode next to three other scoreboards went from 325 MB to 549 MB in six +hours of live games, and the display's live heap plateaued only once every +renderer had filled its 128 slots. + +The card only ever draws the logo fitted into its slot, so that is what is kept +now. This draws cards for 40 teams with NFL-sized logos through one renderer and +checks that: + +* no image the renderer holds is larger than the panel; +* the decoded pixels it holds stay a small fraction of one full-size logo per + team (the old cache held 40 x 2.3 MB = 94 MB here); +* a card drawn from the cache is pixel-identical to the same card drawn cold. + +Run: /bin/python plugins/football-scoreboard/test_adaptive_logos_not_kept_full_size.py +""" + +import gc +import os +import sys +import tempfile +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +REPO = Path(__file__).resolve().parents[2] +CORE = None +for _c in (os.environ.get("LEDMATRIX_CORE", ""), + str(REPO.parent / "LEDMatrix"), + str(Path.home() / "projects" / "LEDMatrix")): + if _c and (Path(_c) / "assets" / "fonts").is_dir(): + CORE = Path(_c) + break +if CORE is None: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) +sys.path.insert(0, str(CORE)) + +import logging # noqa: E402 +logging.disable(logging.CRITICAL) + +from PIL import Image, ImageDraw # noqa: E402 + +TEAMS = 40 +SOURCE = 768 # the NFL logo files' size +WIDTH, HEIGHT = 192, 48 # the rig the growth was measured on + +results = [] + + +def check(case, passed, detail=""): + results.append((case, passed)) + print(" [%s] %s%s" % ("pass" if passed else "FAIL", case, + (" -- " + detail) if detail and not passed else "")) + + +def _logo(path, i): + """A full-size logo with some ink, distinct per team.""" + img = Image.new("RGBA", (SOURCE, SOURCE), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + colour = (40 + 5 * i, 255 - 5 * i, (97 * i) % 256, 255) + draw.ellipse((60, 60, SOURCE - 60, SOURCE - 60), fill=colour) + draw.rectangle((SOURCE // 3, SOURCE // 3, SOURCE // 2, SOURCE // 2), + fill=(255, 255, 255, 255)) + img.save(path) + + +def _game(away, home, logo_dir): + return { + "home_id": "1", "home_abbr": home, + "home_logo_path": str(logo_dir / ("%s.png" % home)), + "away_id": "2", "away_abbr": away, + "away_logo_path": str(logo_dir / ("%s.png" % away)), + "home_score": "21", "away_score": "17", + "period_text": "Q3", "clock": "8:42", "is_live": True, + "league": "nfl", + } + + +def _held_images(root): + """Decoded images reachable from ``root`` (attributes, dicts, lists, + tuples, dataclasses), each counted once.""" + seen, found, stack = set(), [], [root] + while stack: + obj = stack.pop() + if id(obj) in seen: + continue + seen.add(id(obj)) + if isinstance(obj, Image.Image): + if getattr(obj, "_im", None) is not None: + found.append(obj) + continue + if isinstance(obj, dict): + stack.extend(obj.values()) + elif isinstance(obj, (list, tuple, set, frozenset)): + stack.extend(obj) + elif hasattr(obj, "__dict__") and not isinstance(obj, type) \ + and type(obj).__module__ not in ("builtins", "logging"): + stack.extend(vars(obj).values()) + elif hasattr(obj, "__slots__"): + stack.extend(getattr(obj, s, None) for s in obj.__slots__) + return found + + +def main(): + os.chdir(str(CORE)) + from game_renderer import GameRenderer, ADAPTIVE_AVAILABLE + if not ADAPTIVE_AVAILABLE: + print("SKIP: this core has no src.adaptive_layout") + return 2 + + logo_dir = Path(tempfile.mkdtemp()) / "assets" / "sports" / "nfl_logos" + logo_dir.mkdir(parents=True) + teams = ["T%02d" % i for i in range(TEAMS)] + for i, abbr in enumerate(teams): + _logo(logo_dir / ("%s.png" % abbr), i) + + renderer = GameRenderer(WIDTH, HEIGHT, {"layout_mode": "adaptive"}) + games = [_game(teams[i], teams[i + 1], logo_dir) for i in range(0, TEAMS, 2)] + first = [renderer.render_game_card(g, "live") for g in games] + gc.collect() + + held = _held_images(renderer) + biggest = max((im.width * im.height for im in held), default=0) + check("no held image is larger than the panel", + biggest <= WIDTH * HEIGHT, + "largest held image has %d px; the panel has %d" + % (biggest, WIDTH * HEIGHT)) + + held_bytes = sum(im.width * im.height * len(im.getbands()) for im in held) + full_size = SOURCE * SOURCE * 4 + check("held logo pixels are far below one full-size logo per team", + held_bytes < TEAMS * full_size // 20, + "%.1f MB held for %d teams (full size would be %.1f MB)" + % (held_bytes / 1e6, TEAMS, TEAMS * full_size / 1e6)) + + again = [renderer.render_game_card(g, "live") for g in games] + check("a card drawn from the cache matches the card drawn cold", + all(a.tobytes() == b.tobytes() for a, b in zip(first, again))) + + cold = GameRenderer(WIDTH, HEIGHT, {"layout_mode": "adaptive"}) + check("a fresh renderer draws the same cards", + all(cold.render_game_card(g, "live").tobytes() == a.tobytes() + for g, a in zip(games, first))) + + failed = [c for c, ok in results if not ok] + print("\n%d passed, %d failed" % (len(results) - len(failed), len(failed))) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())