Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions plugins/football-scoreboard/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
82 changes: 52 additions & 30 deletions plugins/football-scoreboard/game_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion plugins/football-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
11 changes: 6 additions & 5 deletions plugins/football-scoreboard/test_adaptive_logo_league_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)))
Expand Down
160 changes: 160 additions & 0 deletions plugins/football-scoreboard/test_adaptive_logos_not_kept_full_size.py
Original file line number Diff line number Diff line change
@@ -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: <core-venv>/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())
Loading