From 965294bb68567fa72ed9045ab9ba5d5ae612df60 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:27:07 +0000 Subject: [PATCH 1/3] Make NFL leaderboard pixel-perfect and support full rankings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leaderboard read as blurry on the panel and stopped partway through the list. Three separate causes: Anti-aliasing. Press Start 2P only rasterises without anti-aliasing at multiples of 8, but the renderer loaded it at 6/10/12/14 and left PIL's default font mode on. Measured on a 10-team NFL strip, 35% of lit text pixels were partially-lit greys across 39 distinct colours — on a 1:1 LED matrix each of those is a dim LED, not a smooth edge. Text now draws with fontmode "1" (the idiom the news and baseball plugins already use) and default sizes snap to the font's pixel grid, giving exactly two colours and zero anti-aliased pixels. Logos. They were scaled to 120% of the panel height and pasted at a negative offset, so the top and bottom of every logo was cropped, then squashed into a square box regardless of source aspect ratio. LANCZOS also left a ring of semi-transparent edge pixels that lit as a halo. Logos now fit inside the panel, keep their aspect ratio, and have their alpha thresholded. Layout. Measuring and drawing were two passes over the same data that budgeted differently — the width pass always counted a team logo while the draw pass skipped missing ones, and the draw pass advanced 10px further per league than the measure pass. The strip came out either padded with dead space or clipped short of the last team. Both now share one resolved layout, so measured width is drawn width. For showing the full rankings: top_teams accepts 0 to mean every team a league returns. A long list also needs the time to scroll — the display controller allows min(plugin cap, core cap) seconds and then moves on mid-scroll, which is what makes a long list appear to cut off at an arbitrary team. The plugin now computes whether the content fits that budget and warns at startup naming the limiting cap and how much of the list will not be reached; the README documents all three caps. Also seeds self.scroll_speed on the time-based path, where get_info() would otherwise raise AttributeError on the default config. Adds test_pixel_perfect.py (15 tests) covering anti-aliasing, grid snapping, panel bounds, logo alpha/aspect, and measure-vs-draw width, plus a test/harness.json fixture so the core safety harness renders the real drawing path instead of only the no-data fallback. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WexvwNDtWLVymGVqKD7BGk --- plugins.json | 4 +- plugins/ledmatrix-leaderboard/README.md | 46 ++ .../ledmatrix-leaderboard/config_schema.json | 77 ++- plugins/ledmatrix-leaderboard/data_fetcher.py | 19 +- .../ledmatrix-leaderboard/image_renderer.py | 508 ++++++++++++------ plugins/ledmatrix-leaderboard/manager.py | 107 +++- plugins/ledmatrix-leaderboard/manifest.json | 10 +- .../test/fixtures/mock.json | 109 ++++ .../ledmatrix-leaderboard/test/harness.json | 20 + .../test_pixel_perfect.py | 212 ++++++++ 10 files changed, 919 insertions(+), 193 deletions(-) create mode 100644 plugins/ledmatrix-leaderboard/test/fixtures/mock.json create mode 100644 plugins/ledmatrix-leaderboard/test/harness.json create mode 100644 plugins/ledmatrix-leaderboard/test_pixel_perfect.py diff --git a/plugins.json b/plugins.json index 926231b4..066caa73 100644 --- a/plugins.json +++ b/plugins.json @@ -409,10 +409,10 @@ "plugin_path": "plugins/ledmatrix-leaderboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.2.3" + "latest_version": "1.3.0" }, { "id": "ledmatrix-flights", diff --git a/plugins/ledmatrix-leaderboard/README.md b/plugins/ledmatrix-leaderboard/README.md index 74dfe28f..624bafc1 100644 --- a/plugins/ledmatrix-leaderboard/README.md +++ b/plugins/ledmatrix-leaderboard/README.md @@ -38,6 +38,52 @@ A plugin for LEDMatrix that displays scrolling leaderboards and standings for mu - `max_duration`: Maximum display duration (30-600 seconds, default: 300) - `loop`: Continuously loop the leaderboard (default: true) +### Appearance (`global.appearance`) + +The leaderboard renders for a 1:1 LED matrix, where a partially-lit pixel is a +visibly dim LED rather than a smooth edge. These options control that: + +- `pixel_perfect_text` (default `true`): draw text with anti-aliasing off, so + every glyph pixel is fully on or fully off. Set to `false` for the older, + softer look. +- `crisp_logos` (default `true`): give logos hard edges instead of a ring of + half-lit pixels. +- `text_outline` (default `true`): black outline behind text so it stays + readable where it sits near a logo. +- `logo_scale` (default `1.0`): logo height as a fraction of the panel height. + `1.0` fits the panel exactly; anything above `1.0` crops the top and bottom of + every logo. +- `font_size` (default `0` = pick a size for the panel height): sizes are + snapped to the font's pixel grid — multiples of 8 for Press Start 2P — because + off-grid sizes are what make pixel fonts look blurry. + +### How many teams are shown + +Each league's `top_teams` sets how far down the standings to go. **Set it to `0` +to show every team the league returns** (all 32 NFL teams, the full AP Top 25, +and so on). + +A longer list needs proportionally more time on screen. The display controller +gives the plugin `min(plugin cap, core cap)` seconds and then moves on +mid-scroll, so a list longer than that budget simply stops partway through — +which looks like the leaderboard cutting off at an arbitrary team. The relevant +settings: + +| Setting | Where | Default | +|---|---|---| +| `global.dynamic_duration.max_duration_seconds` | this plugin | 600 | +| `global.dynamic_duration.controller_cap_seconds` | this plugin | 600 | +| `display.dynamic_duration.max_duration_seconds` | LEDMatrix core config | 180 | + +The **lowest** of the three wins, so the core's 180s default is usually the one +that decides it. All 32 NFL teams is roughly 3,200px of ticker: about 240s at +the default 15 px/s, or about 36s at 100 px/s. + +If the content will not fit the budget, the plugin logs a warning at startup +naming which cap is limiting it and roughly how much of the list will not be +reached. Raise that cap, increase the scroll speed +(`global.display.scroll_speed` / `scroll_delay`), or lower `top_teams`. + ### Per-League Settings #### NFL Configuration diff --git a/plugins/ledmatrix-leaderboard/config_schema.json b/plugins/ledmatrix-leaderboard/config_schema.json index 28ddcb59..e1dbef97 100644 --- a/plugins/ledmatrix-leaderboard/config_schema.json +++ b/plugins/ledmatrix-leaderboard/config_schema.json @@ -228,6 +228,45 @@ "type": "boolean", "default": false, "description": "Continuously loop the leaderboard" + }, + "appearance": { + "type": "object", + "description": "Pixel-perfect rendering options for text and logos", + "properties": { + "pixel_perfect_text": { + "type": "boolean", + "default": true, + "description": "Render text with hard pixel edges. Disable only if you prefer the older anti-aliased (softer, blurrier) look" + }, + "crisp_logos": { + "type": "boolean", + "default": true, + "description": "Give logos hard edges instead of a ring of half-lit pixels" + }, + "text_outline": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Draw a black outline around text so it stays readable over logos" + }, + "logo_scale": { + "x-advanced": true, + "type": "number", + "default": 1.0, + "minimum": 0.5, + "maximum": 1.5, + "description": "Logo height as a fraction of the panel height. 1.0 fits the panel exactly; above 1.0 crops the top and bottom of every logo" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 32, + "description": "Font size in pixels. 0 picks a size that suits the panel height. Values are snapped to the font's pixel grid (multiples of 8 for Press Start 2P) to keep text sharp" + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -246,9 +285,9 @@ "top_teams": { "type": "integer", "default": 10, - "minimum": 5, + "minimum": 0, "maximum": 32, - "description": "Number of top NFL teams to display" + "description": "Number of top NFL teams to display. 0 shows every team the standings return (up to 32). Long lists need a matching display duration - see the README" } }, "additionalProperties": false @@ -264,9 +303,9 @@ "top_teams": { "type": "integer", "default": 10, - "minimum": 5, + "minimum": 0, "maximum": 30, - "description": "Number of top NBA teams to display" + "description": "Number of top NBA teams to display. 0 shows every team the standings return (up to 30). Long lists need a matching display duration - see the README" } }, "additionalProperties": false @@ -282,9 +321,9 @@ "top_teams": { "type": "integer", "default": 10, - "minimum": 5, + "minimum": 0, "maximum": 30, - "description": "Number of top MLB teams to display" + "description": "Number of top MLB teams to display. 0 shows every team the standings return (up to 30). Long lists need a matching display duration - see the README" } }, "additionalProperties": false @@ -300,9 +339,9 @@ "top_teams": { "type": "integer", "default": 25, - "minimum": 5, + "minimum": 0, "maximum": 130, - "description": "Number of top NCAA Football teams to display" + "description": "Number of top NCAA Football teams to display. 0 shows every team the standings return (up to 130). Long lists need a matching display duration - see the README" }, "show_ranking": { "type": "boolean", @@ -323,9 +362,9 @@ "top_teams": { "type": "integer", "default": 10, - "minimum": 5, + "minimum": 0, "maximum": 32, - "description": "Number of top NHL teams to display" + "description": "Number of top NHL teams to display. 0 shows every team the standings return (up to 32). Long lists need a matching display duration - see the README" } }, "additionalProperties": false @@ -341,9 +380,9 @@ "top_teams": { "type": "integer", "default": 25, - "minimum": 5, + "minimum": 0, "maximum": 350, - "description": "Number of top NCAA Men's Basketball teams to display" + "description": "Number of top NCAA Men's Basketball teams to display. 0 shows every team the standings return (up to 350). Long lists need a matching display duration - see the README" }, "show_ranking": { "type": "boolean", @@ -364,9 +403,9 @@ "top_teams": { "type": "integer", "default": 10, - "minimum": 5, + "minimum": 0, "maximum": 60, - "description": "Number of top NCAA Men's Hockey teams to display" + "description": "Number of top NCAA Men's Hockey teams to display. 0 shows every team the standings return (up to 60). Long lists need a matching display duration - see the README" }, "show_ranking": { "type": "boolean", @@ -387,9 +426,9 @@ "top_teams": { "type": "integer", "default": 25, - "minimum": 5, + "minimum": 0, "maximum": 350, - "description": "Number of top NCAA Women's Basketball teams to display" + "description": "Number of top NCAA Women's Basketball teams to display. 0 shows every team the standings return (up to 350). Long lists need a matching display duration - see the README" }, "show_ranking": { "type": "boolean", @@ -410,9 +449,9 @@ "top_teams": { "type": "integer", "default": 25, - "minimum": 5, + "minimum": 0, "maximum": 350, - "description": "Number of top NCAA Baseball teams to display" + "description": "Number of top NCAA Baseball teams to display. 0 shows every team the standings return (up to 350). Long lists need a matching display duration - see the README" }, "season": { "x-advanced": true, @@ -444,4 +483,4 @@ "required": [ "enabled" ] -} \ No newline at end of file +} diff --git a/plugins/ledmatrix-leaderboard/data_fetcher.py b/plugins/ledmatrix-leaderboard/data_fetcher.py index 2a5d96ab..cf85cf9e 100644 --- a/plugins/ledmatrix-leaderboard/data_fetcher.py +++ b/plugins/ledmatrix-leaderboard/data_fetcher.py @@ -52,8 +52,25 @@ def fetch_standings(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]] else: standings = self._fetch_teams_data(league_config) - # Apply top_teams limit centrally so config changes take effect immediately + # Apply top_teams limit centrally so config changes take effect immediately. + # 0 (or any non-positive value) means "show every team the API returned". top_teams = league_config.get('top_teams', 10) + try: + top_teams = int(top_teams) + except (TypeError, ValueError): + top_teams = 10 + + if top_teams <= 0: + self.logger.info( + "Showing all %d teams for %s (top_teams=0)", len(standings), league_key + ) + return standings + + if len(standings) < top_teams: + self.logger.info( + "Requested top %d teams for %s but only %d available", + top_teams, league_key, len(standings) + ) return standings[:top_teams] def _fetch_ncaa_fb_rankings(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]: diff --git a/plugins/ledmatrix-leaderboard/image_renderer.py b/plugins/ledmatrix-leaderboard/image_renderer.py index 991e2df9..5b1a2012 100644 --- a/plugins/ledmatrix-leaderboard/image_renderer.py +++ b/plugins/ledmatrix-leaderboard/image_renderer.py @@ -3,14 +3,33 @@ Handles all image creation and rendering for the scrolling leaderboard display. Includes logo loading, text drawing with outlines, and layout calculations. + +Rendering is pixel-perfect by design: an LED matrix has no sub-pixels, so any +anti-aliased grey a glyph or logo edge lands on shows up as a dim, smeared LED. +Two things keep the output crisp: + +- Text is drawn with ``fontmode = "1"``, so a glyph pixel is either fully lit or + fully off no matter what font/size is configured. Default sizes are + additionally snapped to the font's own pixel grid (8px for Press Start 2P, + 7px for the 4x6 fallback) so glyphs land on whole pixels. +- Logos are downscaled with LANCZOS for detail, then their alpha is thresholded + so the edges are hard instead of a ring of half-lit pixels. """ +import math import os import logging from pathlib import Path -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, Tuple from PIL import Image, ImageDraw, ImageFont +# Pillow compatibility: Image.Resampling.LANCZOS arrived in Pillow 9.1. +# Fall back to the module-level constant for older versions. +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: # pragma: no cover - Pillow < 9.1 + RESAMPLE_FILTER = Image.LANCZOS + # Try to import logo downloader try: from src.logo_downloader import download_missing_logo @@ -31,67 +50,217 @@ class ImageRenderer: MARCH_MADNESS_LOGO_PATH = 'assets/sports/ncaa_logos/MARCH_MADNESS.png' - def __init__(self, display_height: int, logger: Optional[logging.Logger] = None): + #: Fonts whose glyphs are drawn on a fixed pixel grid. Rendering them at a + #: size that is not a whole multiple of the grid forces the rasteriser to + #: split single design pixels across screen pixels, which is what makes the + #: text look blurry on the panel. + PIXEL_FONT_GRIDS = { + 'PressStart2P-Regular.ttf': 8, + '4x6-font.ttf': 7, + } + + PRIMARY_FONT = 'assets/fonts/PressStart2P-Regular.ttf' + FALLBACK_FONT = 'assets/fonts/4x6-font.ttf' + + #: Horizontal gaps, in pixels, used by the team layout. + RANK_LOGO_GAP = 4 + LOGO_TEXT_GAP = 4 + TEAM_GAP = 12 + #: Width of the league logo column, and the gap after it. + LEAGUE_LOGO_WIDTH = 64 + LEAGUE_LOGO_GAP = 10 + #: Blank space between one league's last team and the next league's logo. + LEAGUE_SPACING = 40 + + def __init__(self, display_height: int, logger: Optional[logging.Logger] = None, + appearance: Optional[Dict[str, Any]] = None): """ Initialize image renderer. - + Args: display_height: Height of the display in pixels logger: Optional logger instance + appearance: Optional appearance overrides (see config_schema.json + ``global.appearance``) """ self.display_height = display_height self.logger = logger or logging.getLogger(__name__) + + appearance = appearance or {} + self.pixel_perfect_text = bool(appearance.get('pixel_perfect_text', True)) + self.crisp_logos = bool(appearance.get('crisp_logos', True)) + 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) + + self.font_path = self._resolve_font_path() + self.font_grid = self.PIXEL_FONT_GRIDS.get( + os.path.basename(self.font_path or ''), 0 + ) self.fonts = self._load_fonts() - + + @staticmethod + def _clamp_float(value: Any, low: float, high: float, default: float) -> float: + try: + return max(low, min(high, float(value))) + except (TypeError, ValueError): + return default + + @staticmethod + def _clamp_int(value: Any, low: int, high: int, default: int) -> int: + try: + return max(low, min(high, int(value))) + except (TypeError, ValueError): + return default + + def _resolve_font_path(self) -> Optional[str]: + """Pick the best available font file, preferring Press Start 2P.""" + for candidate in (self.PRIMARY_FONT, self.FALLBACK_FONT): + if os.path.exists(candidate): + return candidate + return None + + def _snap_font_size(self, requested: int) -> int: + """ + Round a font size to the font's pixel grid. + + Press Start 2P only rasterises without anti-aliasing at multiples of 8 + (8, 16, 24...); the 4x6 fallback at multiples of 7. Off-grid sizes are + what produced the blurry text this renderer used to show. + """ + if self.font_grid <= 0: + return max(1, requested) + snapped = int(round(requested / self.font_grid)) * self.font_grid + return max(self.font_grid, snapped) + + def _auto_font_size(self) -> int: + """Choose an on-grid font size that suits the panel height.""" + if self.font_size_override: + return self._snap_font_size(self.font_size_override) + + grid = self.font_grid or 8 + # Aim for text about a quarter of the panel height, then snap to the + # grid. 32px panels land on one grid step, 64px panels on two. + target = max(grid, int(self.display_height / 4)) + return self._snap_font_size(target) + def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: - """Load fonts for the leaderboard display.""" + """ + Load fonts for the leaderboard display. + + All sizes are snapped to the font's pixel grid so glyph edges land on + whole panel pixels. + """ fonts = {} - try: - # Try to load the Press Start 2P font first - fonts['small'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 6) - fonts['medium'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - fonts['large'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) - fonts['xlarge'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 14) - self.logger.info("Successfully loaded Press Start 2P font") - except IOError: - self.logger.warning("Press Start 2P font not found, trying 4x6 font") + base = self._auto_font_size() + grid = self.font_grid or 8 + sizes = { + 'small': max(grid, base - grid) if base > grid else base, + 'medium': base, + 'large': base, + 'xlarge': base, + } + + if self.font_path: try: - fonts['small'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - fonts['medium'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8) - fonts['large'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 10) - fonts['xlarge'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 12) - self.logger.info("Successfully loaded 4x6 font") - except IOError: - self.logger.warning("4x6 font not found, using default PIL font") - default_font = ImageFont.load_default() - fonts = { - 'small': default_font, - 'medium': default_font, - 'large': default_font, - 'xlarge': default_font - } - except Exception as e: - self.logger.error(f"Error loading fonts: {e}") - default_font = ImageFont.load_default() - fonts = { - 'small': default_font, - 'medium': default_font, - 'large': default_font, - 'xlarge': default_font - } - return fonts - - def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, - font: ImageFont.FreeTypeFont, fill: tuple = (255, 255, 255), - outline_color: tuple = (0, 0, 0)): - """Draw text with a black outline for better readability on LED matrix.""" + for key, size in sizes.items(): + fonts[key] = ImageFont.truetype(self.font_path, size) + self.logger.info( + "Loaded %s at pixel-aligned sizes %s (grid=%spx)", + os.path.basename(self.font_path), sizes, self.font_grid or 'n/a' + ) + return fonts + except (IOError, OSError) as e: + self.logger.warning("Could not load %s: %s", self.font_path, e) + except Exception as e: # pragma: no cover - defensive + self.logger.error("Error loading fonts: %s", e) + + self.logger.warning("No pixel font available, using default PIL font") + default_font = ImageFont.load_default() + return {key: default_font for key in ('small', 'medium', 'large', 'xlarge')} + + def _text_advance(self, text: str, font: ImageFont.FreeTypeFont) -> int: + """Width a string occupies for layout purposes, in whole pixels.""" + try: + return int(math.ceil(font.getlength(text))) + except AttributeError: # pragma: no cover - very old Pillow + bbox = font.getbbox(text) + return int(bbox[2] - bbox[0]) + + def _text_ink_box(self, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]: + """Return (ink_top, ink_height) for a string relative to the draw origin.""" + try: + bbox = font.getbbox(text) + except AttributeError: # pragma: no cover - very old Pillow + return 0, getattr(font, 'size', self.display_height // 4) + return int(bbox[1]), int(bbox[3] - bbox[1]) + + def _text_baseline_y(self, text: str, font: ImageFont.FreeTypeFont, height: int) -> int: + """ + Y to pass to ``draw.text`` so the string's ink is vertically centred. + + ``draw.text`` positions by the ascender line, not by the ink box, so the + ink offset has to be subtracted or the text sits low and can clip off + the bottom of the panel. + """ + ink_top, ink_height = self._text_ink_box(text, font) + return (height - ink_height) // 2 - ink_top + + def _make_draw(self, image: Image.Image) -> ImageDraw.ImageDraw: + """ + Get a draw context that rasterises text without anti-aliasing. + + ``fontmode = "1"`` switches Pillow to 1-bit glyph rendering, so every + text pixel is either fully lit or off. Left on the default the panel + shows partially-lit LEDs around every glyph, which is what made this + display read as blurry. + """ + draw = ImageDraw.Draw(image) + if self.pixel_perfect_text: + draw.fontmode = "1" + return draw + + def _draw_text(self, draw: ImageDraw.ImageDraw, text: str, position: tuple, + font: ImageFont.FreeTypeFont, fill: tuple = (255, 255, 255), + outline_color: tuple = (0, 0, 0)): + """Draw text, optionally with a black outline for contrast over logos.""" + if not text: + return + x, y = position - # Draw outline - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - # Draw text + if self.text_outline: + for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), + (0, 1), (1, -1), (1, 0), (1, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) - + + def _prepare_logo(self, logo: Image.Image, max_width: int, + max_height: int) -> Optional[Image.Image]: + """ + Scale a logo to fit ``max_width`` x ``max_height`` with hard edges. + + Aspect ratio is preserved (squashing a non-square logo into a square box + is its own kind of blur), and the alpha channel is thresholded so the + edge is a clean on/off boundary rather than a ring of half-lit pixels. + """ + if logo is None or max_width <= 0 or max_height <= 0: + return None + try: + logo = logo.convert('RGBA') + scale = min(max_width / logo.width, max_height / logo.height) + target_w = max(1, int(logo.width * scale)) + target_h = max(1, int(logo.height * scale)) + logo = logo.resize((target_w, target_h), RESAMPLE_FILTER) + + if self.crisp_logos: + r, g, b, a = logo.split() + a = a.point(lambda p: 255 if p >= 128 else 0) + logo = Image.merge('RGBA', (r, g, b, a)) + return logo + except Exception as e: + self.logger.error("Error preparing logo: %s", e) + return None + 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: @@ -105,7 +274,7 @@ def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: st 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}") @@ -114,12 +283,12 @@ def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: st logo = Image.open(logo_path) self.logger.info(f"Successfully downloaded and loaded logo for {team_abbr}") return logo - + return None except Exception as e: self.logger.error(f"Error loading logo for {team_abbr}: {e}") return None - + def _get_league_logo(self, league_logo_path: str) -> Optional[Image.Image]: """Get league logo from the configured path.""" if not league_logo_path: @@ -135,140 +304,150 @@ def _get_league_logo(self, league_logo_path: str) -> Optional[Image.Image]: except Exception as e: self.logger.error(f"Error loading league logo: {e}") return None - + + def _build_layout(self, leaderboard_data: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]: + """ + Resolve every league/team into a measured, draw-ready layout. + + Measuring and drawing used to be two separate passes over the same data, + which let them disagree — the width pass always budgeted for a team logo + while the draw pass skipped it when the file was missing, so the strip + ended up either padded with dead space or clipped short of the last + team. Resolving logos once here means the measured width is exactly the + drawn width. + + Returns: + (layout, total_width) where total_width is the exact content width. + """ + height = self.display_height + logo_box = max(1, int(height * self.logo_scale)) + layout = [] + total_width = 0 + + for league_data in leaderboard_data: + league_key = league_data['league'] + league_config = league_data['league_config'] + + league_logo_path = league_config.get('league_logo') + 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 + ) + + teams = [] + teams_width = 0 + for i, team in enumerate(league_data['teams']): + number_text = self._get_number_text(league_key, league_config, team, i) + number_width = self._text_advance(number_text, self.fonts['xlarge']) + + 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 + ) + + width = number_width + text_width + self.TEAM_GAP + self.LOGO_TEXT_GAP + if team_logo: + width += self.RANK_LOGO_GAP + team_logo.width + + teams.append({ + 'number_text': number_text, + 'number_width': number_width, + 'text': team_text, + 'text_width': text_width, + 'logo': team_logo, + 'width': width, + }) + teams_width += width + + league_width = self.LEAGUE_LOGO_WIDTH + self.LEAGUE_LOGO_GAP + teams_width + layout.append({ + 'key': league_key, + 'logo': league_logo, + 'teams': teams, + 'width': league_width, + }) + total_width += league_width + self.LEAGUE_SPACING + + return layout, total_width + def create_leaderboard_image(self, leaderboard_data: List[Dict[str, Any]]) -> Optional[Image.Image]: """ Create the scrolling leaderboard image. - + Args: leaderboard_data: List of league data dictionaries with teams - + Returns: PIL Image containing the full scrolling leaderboard, or None on error """ if not leaderboard_data: self.logger.warning("No leaderboard data available") return None - + try: height = self.display_height - spacing = 40 # Spacing between leagues - - # Calculate total width needed - total_width = 0 - for league_data in leaderboard_data: - league_key = league_data['league'] - league_config = league_data['league_config'] - teams = league_data['teams'] - - league_logo_width = 64 - teams_width = 0 - logo_size = int(height * 1.2) - - for i, team in enumerate(teams): - number_text = self._get_number_text(league_key, league_config, team, i) - number_bbox = self.fonts['xlarge'].getbbox(number_text) - number_width = number_bbox[2] - number_bbox[0] - - team_text = team['abbreviation'] - text_bbox = self.fonts['large'].getbbox(team_text) - text_width = text_bbox[2] - text_bbox[0] - - team_width = number_width + 4 + logo_size + 4 + text_width + 12 - teams_width += team_width - - league_width = league_logo_width + teams_width + 20 - total_width += league_width + spacing - - # Create the main image + layout, total_width = self._build_layout(leaderboard_data) + if total_width <= 0: + self.logger.warning("Leaderboard layout measured zero width") + return None + leaderboard_image = Image.new('RGB', (total_width, height), (0, 0, 0)) - draw = ImageDraw.Draw(leaderboard_image) - + draw = self._make_draw(leaderboard_image) + current_x = 0 - for league_idx, league_data in enumerate(leaderboard_data): - league_key = league_data['league'] - league_config = league_data['league_config'] - teams = league_data['teams'] - - self.logger.info(f"Drawing League {league_idx+1} ({league_key}) starting at x={current_x}px") - - # Draw league logo (swap to March Madness logo during tournament) - league_logo_path = league_config['league_logo'] - 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._get_league_logo(league_logo_path) + for league_idx, league in enumerate(layout): + self.logger.info( + "Drawing League %d (%s) at x=%dpx, %d teams, %dpx wide", + league_idx + 1, league['key'], current_x, len(league['teams']), league['width'] + ) + + league_logo = league['logo'] if league_logo: - logo_height = height - 4 - logo_width = int(logo_height * league_logo.width / league_logo.height) - logo_x = current_x + (64 - logo_width) // 2 - logo_y = 2 - league_logo = league_logo.resize((logo_width, logo_height), Image.Resampling.LANCZOS) - leaderboard_image.paste(league_logo, (logo_x, logo_y), - league_logo if league_logo.mode == 'RGBA' else None) - - # Move to team section - current_x += 64 + 10 - team_x = current_x - logo_size = int(height * 1.2) - - # Draw teams - for i, team in enumerate(teams): - number_text = self._get_number_text(league_key, league_config, team, i) - number_bbox = self.fonts['xlarge'].getbbox(number_text) - number_width = number_bbox[2] - number_bbox[0] - number_height = number_bbox[3] - number_bbox[1] - number_y = (height - number_height) // 2 - self._draw_text_with_outline(draw, number_text, (team_x, number_y), - self.fonts['xlarge'], fill=(255, 255, 0)) - - # Draw team logo - team_logo = self._get_team_logo(league_key, team.get('id'), - team['abbreviation'], league_config['logo_dir']) + logo_x = current_x + (self.LEAGUE_LOGO_WIDTH - league_logo.width) // 2 + logo_y = (height - league_logo.height) // 2 + leaderboard_image.paste(league_logo, (logo_x, logo_y), league_logo) + + team_x = current_x + self.LEAGUE_LOGO_WIDTH + self.LEAGUE_LOGO_GAP + + for team in league['teams']: + number_text = team['number_text'] + number_y = self._text_baseline_y(number_text, self.fonts['xlarge'], height) + self._draw_text(draw, number_text, (team_x, number_y), + self.fonts['xlarge'], fill=(255, 255, 0)) + + text_x = team_x + team['number_width'] + team_logo = team['logo'] if team_logo: - team_logo = team_logo.resize((logo_size, logo_size), Image.Resampling.LANCZOS) - logo_x = team_x + number_width + 4 - logo_y_pos = (height - logo_size) // 2 - leaderboard_image.paste(team_logo, (logo_x, logo_y_pos), - team_logo if team_logo.mode == 'RGBA' else None) - - # Draw team abbreviation - team_text = team['abbreviation'] - text_bbox = self.fonts['large'].getbbox(team_text) - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] - text_x = logo_x + logo_size + 4 - text_y = (height - text_height) // 2 - self._draw_text_with_outline(draw, team_text, (text_x, text_y), - self.fonts['large'], fill=(255, 255, 255)) - - team_width = number_width + 4 + logo_size + 4 + text_width + 12 - else: - # Fallback if no logo - team_text = team['abbreviation'] - text_bbox = self.fonts['large'].getbbox(team_text) - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] - text_x = team_x + number_width + 4 - text_y = (height - text_height) // 2 - self._draw_text_with_outline(draw, team_text, (text_x, text_y), - self.fonts['large'], fill=(255, 255, 255)) - team_width = number_width + 4 + text_width + 12 - - team_x += team_width - - current_x = team_x + 20 + spacing - - # Calculate actual content width - actual_content_width = current_x - (20 + spacing) - - self.logger.info(f"Created leaderboard image: {total_width}px wide (actual: {actual_content_width}px)") + logo_x = text_x + self.RANK_LOGO_GAP + logo_y_pos = (height - team_logo.height) // 2 + leaderboard_image.paste(team_logo, (logo_x, logo_y_pos), team_logo) + text_x = logo_x + team_logo.width + + text_x += self.LOGO_TEXT_GAP + text_y = self._text_baseline_y(team['text'], self.fonts['large'], height) + self._draw_text(draw, team['text'], (text_x, text_y), + self.fonts['large'], fill=(255, 255, 255)) + + team_x += team['width'] + + current_x = team_x + self.LEAGUE_SPACING + + self.logger.info( + "Created leaderboard image: %dpx wide, %d league(s), %d team(s)", + total_width, len(layout), sum(len(l['teams']) for l in layout) + ) return leaderboard_image - + except Exception as e: self.logger.error(f"Error creating leaderboard image: {e}") return None - + def _get_number_text(self, league_key: str, league_config: Dict[str, Any], team: Dict[str, Any], index: int) -> str: """Get the number/ranking text to display for a team.""" @@ -289,4 +468,3 @@ def _get_number_text(self, league_key: str, league_config: Dict[str, Any], return f"{index+1}." else: return f"{index+1}." - diff --git a/plugins/ledmatrix-leaderboard/manager.py b/plugins/ledmatrix-leaderboard/manager.py index bb76567b..ccfc1339 100644 --- a/plugins/ledmatrix-leaderboard/manager.py +++ b/plugins/ledmatrix-leaderboard/manager.py @@ -56,6 +56,9 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.display_duration = self.global_config.get('display_duration', 30) # Scroll speed configuration - prefer display object (granular control), fallback to scroll_pixels_per_second for backward compatibility + # Seeded so get_info()/logging never touch an unset attribute on the + # time-based path, which does not assign scroll_speed. + self.scroll_speed = self.global_config.get('scroll_speed', 1.0) display_config = self.global_config.get('display', {}) if display_config and ('scroll_speed' in display_config or 'scroll_delay' in display_config): # New format: use display object for granular control @@ -89,9 +92,10 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.request_timeout = self.global_config.get('request_timeout', 30) # Initialize components + self.appearance = self.global_config.get('appearance', {}) or {} self.league_config = LeagueConfig(config, self.logger) self.data_fetcher = DataFetcher(cache_manager, self.logger, self.request_timeout) - self.image_renderer = ImageRenderer(self.display_height, self.logger) + self.image_renderer = ImageRenderer(self.display_height, self.logger, self.appearance) # Initialize scroll helper self.scroll_helper = ScrollHelper(self.display_width, self.display_height, self.logger) @@ -335,9 +339,10 @@ def _create_leaderboard_image(self) -> None: self.scroll_helper.set_scrolling_image(leaderboard_image) # Dynamic duration is automatically calculated by set_scrolling_image() self._cycle_complete = False - + self.logger.info(f"Created leaderboard image: {leaderboard_image.width}x{leaderboard_image.height}") self.logger.info(f"Dynamic duration: {self.scroll_helper.get_dynamic_duration()}s") + self._warn_if_content_will_be_truncated(leaderboard_image.width) else: self.logger.error("Failed to create leaderboard image") self.scroll_helper.clear_cache() @@ -346,6 +351,89 @@ def _create_leaderboard_image(self) -> None: self.logger.error(f"Error creating leaderboard image: {e}") self.scroll_helper.clear_cache() + #: The core's own fallback when display.dynamic_duration.max_duration_seconds + #: is unset (DEFAULT_DYNAMIC_DURATION_CAP in src/display_controller.py). + CORE_DEFAULT_DYNAMIC_CAP = 180.0 + + def _core_dynamic_cap(self) -> float: + """ + Read the core's global dynamic-duration cap. + + The display controller uses ``min(plugin cap, global cap)``, so the + global value is frequently the one that decides how much of the ticker + is actually reached. It cannot be read through ``self.global_config`` + here because this plugin reassigns that to its own config slice, so the + core's config managers are consulted the same way BasePlugin does. + """ + for owner in (self.plugin_manager, self.cache_manager): + config_manager = getattr(owner, 'config_manager', None) + if config_manager is None: + continue + try: + core_config = config_manager.get_config() + except Exception: + continue + if not isinstance(core_config, dict) or not core_config: + continue + cap = (core_config.get('display', {}) + .get('dynamic_duration', {}) + .get('max_duration_seconds')) + try: + cap = float(cap) + except (TypeError, ValueError): + return self.CORE_DEFAULT_DYNAMIC_CAP + return cap if cap > 0 else float('inf') + return self.CORE_DEFAULT_DYNAMIC_CAP + + def _effective_pixels_per_second(self) -> float: + """Resolve the configured scroll speed to pixels per second.""" + if getattr(self, 'scroll_pixels_per_second', None): + return float(self.scroll_pixels_per_second) + if self.scroll_delay and self.scroll_delay > 0: + return float(self.scroll_speed) / float(self.scroll_delay) + return float(self.scroll_speed) * 100.0 + + def _warn_if_content_will_be_truncated(self, image_width: int) -> None: + """ + Warn when the ticker is longer than the display controller will show. + + The controller caps a plugin's dynamic duration at + ``min(plugin cap, core global cap)`` and moves on when that expires, + mid-scroll. On a long list — a full 32-team league, say — that reads as + the leaderboard simply cutting off partway through, with no error + anywhere to explain it. Surfacing the arithmetic makes the fix obvious. + """ + if not self.dynamic_duration_enabled: + return + + try: + pixels_per_second = self._effective_pixels_per_second() + if pixels_per_second <= 0: + return + + required = (image_width + self.display_width) / pixels_per_second + required *= (1.0 + self.duration_buffer) + + core_cap = self._core_dynamic_cap() + budget = min(self.max_duration, self.dynamic_duration_cap, core_cap) + if required <= budget: + return + + shown_px = budget * pixels_per_second + limiter = ("the core's display.dynamic_duration.max_duration_seconds" + if core_cap <= min(self.max_duration, self.dynamic_duration_cap) + else "this plugin's global.dynamic_duration settings") + self.logger.warning( + "Leaderboard content (%dpx) needs %.0fs to scroll at %.0f px/s but the " + "duration budget is only %.0fs (limited by %s) - roughly the last %.0f%% " + "of the list will not be reached before the display moves on. Raise that " + "cap, increase the scroll speed, or lower top_teams.", + image_width, required, pixels_per_second, budget, limiter, + max(0.0, 100.0 * (1.0 - shown_px / max(image_width, 1))), + ) + except Exception as e: # pragma: no cover - diagnostics only + self.logger.debug("Could not evaluate content duration budget: %s", e) + def _display_fallback_message(self) -> None: """Display a fallback message when no data is available.""" try: @@ -583,15 +671,19 @@ def get_info(self) -> Dict[str, Any]: """Return plugin info for web UI.""" info = super().get_info() + fetched_counts = {d['league']: len(d['teams']) for d in self.leaderboard_data} leagues_config = {} for league_key in self.league_config.get_enabled_leagues(): league_config = self.league_config.get_league_config(league_key) if league_config: + top_teams = league_config.get('top_teams', 10) leagues_config[league_key] = { 'enabled': True, - 'top_teams': league_config.get('top_teams', 10) + 'top_teams': top_teams, + 'show_all': not top_teams or top_teams <= 0, + 'teams_displayed': fetched_counts.get(league_key, 0) } - + info.update({ 'total_teams': sum(len(d['teams']) for d in self.leaderboard_data), 'enabled_leagues': self.league_config.get_enabled_leagues(), @@ -604,6 +696,13 @@ def get_info(self) -> Dict[str, Any]: 'min_duration': self.min_duration, 'max_duration': self.max_duration, 'leagues_config': leagues_config, + 'appearance': { + 'pixel_perfect_text': self.image_renderer.pixel_perfect_text, + 'crisp_logos': self.image_renderer.crisp_logos, + 'text_outline': self.image_renderer.text_outline, + 'logo_scale': self.image_renderer.logo_scale, + 'font_size': self.image_renderer.fonts['large'].size, + }, 'scroll_info': self.scroll_helper.get_scroll_info() if self.scroll_helper else None }) return info diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index 49bf591b..8be2db01 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.2.3", + "version": "1.3.0", "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": [ + { + "version": "1.3.0", + "released": "2026-08-05", + "ledmatrix_min": "2.0.0", + "notes": "Pixel-perfect rendering: text is drawn with anti-aliasing off and at font sizes snapped to the pixel grid, and logos get hard alpha edges and are fitted inside the panel instead of being scaled to 120% and cropped top and bottom. Layout is now measured and drawn in a single pass so the strip width matches its contents. top_teams accepts 0 to show every team a league returns, and the plugin warns when the list is longer than the display-duration budget will reach. New global.appearance options: pixel_perfect_text, crisp_logos, text_outline, logo_scale, font_size." + }, { "version": "1.2.3", "released": "2026-07-31", @@ -73,7 +79,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "compatible_versions": [ ">=2.0.0" ] diff --git a/plugins/ledmatrix-leaderboard/test/fixtures/mock.json b/plugins/ledmatrix-leaderboard/test/fixtures/mock.json new file mode 100644 index 00000000..952cfafb --- /dev/null +++ b/plugins/ledmatrix-leaderboard/test/fixtures/mock.json @@ -0,0 +1,109 @@ +{ + "leaderboard_nfl_standings": { + "standings": [ + { + "name": "Kansas City Chiefs", + "id": "1", + "abbreviation": "KC", + "wins": 12, + "losses": 2, + "ties": 0, + "win_percentage": 0.857, + "record_summary": "12-2" + }, + { + "name": "Buffalo Bills", + "id": "2", + "abbreviation": "BUF", + "wins": 11, + "losses": 3, + "ties": 0, + "win_percentage": 0.786, + "record_summary": "11-3" + }, + { + "name": "Philadelphia Eagles", + "id": "3", + "abbreviation": "PHI", + "wins": 11, + "losses": 3, + "ties": 0, + "win_percentage": 0.786, + "record_summary": "11-3" + }, + { + "name": "Detroit Lions", + "id": "4", + "abbreviation": "DET", + "wins": 10, + "losses": 4, + "ties": 0, + "win_percentage": 0.714, + "record_summary": "10-4" + }, + { + "name": "Baltimore Ravens", + "id": "5", + "abbreviation": "BAL", + "wins": 10, + "losses": 4, + "ties": 0, + "win_percentage": 0.714, + "record_summary": "10-4" + }, + { + "name": "San Francisco 49ers", + "id": "6", + "abbreviation": "SF", + "wins": 9, + "losses": 5, + "ties": 0, + "win_percentage": 0.643, + "record_summary": "9-5" + }, + { + "name": "Green Bay Packers", + "id": "7", + "abbreviation": "GB", + "wins": 9, + "losses": 5, + "ties": 0, + "win_percentage": 0.643, + "record_summary": "9-5" + }, + { + "name": "Houston Texans", + "id": "8", + "abbreviation": "HOU", + "wins": 8, + "losses": 6, + "ties": 0, + "win_percentage": 0.571, + "record_summary": "8-6" + }, + { + "name": "Los Angeles Rams", + "id": "9", + "abbreviation": "LAR", + "wins": 8, + "losses": 6, + "ties": 0, + "win_percentage": 0.571, + "record_summary": "8-6" + }, + { + "name": "Minnesota Vikings", + "id": "10", + "abbreviation": "MIN", + "wins": 7, + "losses": 7, + "ties": 0, + "win_percentage": 0.5, + "record_summary": "7-7" + } + ], + "timestamp": 1735689600.0, + "league": "nfl", + "level": 1 + } +} diff --git a/plugins/ledmatrix-leaderboard/test/harness.json b/plugins/ledmatrix-leaderboard/test/harness.json new file mode 100644 index 00000000..7d40df23 --- /dev/null +++ b/plugins/ledmatrix-leaderboard/test/harness.json @@ -0,0 +1,20 @@ +{ + "_comment": "Renders the leaderboard from a fixed NFL standings fixture instead of the live ESPN API, so the harness exercises the real drawing path (fonts, logos, layout) rather than only the no-data fallback. Only NFL is enabled; the other default-on leagues would need network. No freeze_time: nothing in this plugin's output depends on the clock.", + "config": { + "enabled": true, + "enabled_sports": { + "nfl": { + "enabled": true, + "top_teams": 10 + }, + "ncaa_fb": { + "enabled": false + }, + "ncaam_hockey": { + "enabled": false + } + }, + "global": {} + }, + "mock_data": "test/fixtures/mock.json" +} diff --git a/plugins/ledmatrix-leaderboard/test_pixel_perfect.py b/plugins/ledmatrix-leaderboard/test_pixel_perfect.py new file mode 100644 index 00000000..838d2bb1 --- /dev/null +++ b/plugins/ledmatrix-leaderboard/test_pixel_perfect.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +Regression tests for pixel-perfect leaderboard rendering. + +The leaderboard draws pixel fonts and team logos on a 1:1 LED matrix, where +there is no sub-pixel to hide a blend in: a partially-lit pixel is a visibly +dim LED, and a row of them around every glyph and logo is what made this +display read as blurry. Three properties keep it crisp, and each is asserted +here: + +1. Text uses ``fontmode = "1"`` — no anti-aliased greys at any font size. +2. Default font sizes land on the font's pixel grid (multiples of 8 for + Press Start 2P), so glyph pixels map 1:1 onto panel pixels. +3. Logos fit inside the panel and get hard alpha edges instead of a halo. + +A fourth test covers the layout: the measured strip width has to match what is +actually drawn, or the ticker ends up padded with dead space or clipped short +of the last team. + +Run from a LEDMatrix (core) checkout so the bundled fonts/logos resolve: + cd /path/to/LEDMatrix && python -m pytest \\ + /path/to/ledmatrix-plugins/plugins/ledmatrix-leaderboard/test_pixel_perfect.py +""" + +import logging +import os +import sys + +import pytest + +pytest.importorskip("PIL") +from PIL import Image # noqa: E402 + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +from image_renderer import ImageRenderer # noqa: E402 + +LOGGER = logging.getLogger("test_pixel_perfect") + +TEAMS = ["KC", "BUF", "PHI", "DET", "BAL", "SF", "GB", "HOU", "LAR", "MIN"] + + +def _teams(count=len(TEAMS)): + return [ + {"name": abbr, "id": str(i + 1), "abbreviation": abbr, "record_summary": "10-2"} + for i, abbr in enumerate(TEAMS[:count]) + ] + + +def _league_data(logo_dir="assets/sports/nfl_logos"): + return [{ + "league": "nfl", + "league_config": { + "logo_dir": logo_dir, + "league_logo": os.path.join(logo_dir, "nfl.png") if logo_dir else "", + }, + "teams": _teams(), + }] + + +def _has_pixel_font(): + return os.path.exists(ImageRenderer.PRIMARY_FONT) or os.path.exists( + ImageRenderer.FALLBACK_FONT + ) + + +requires_font = pytest.mark.skipif( + not _has_pixel_font(), + reason="run from a core checkout so assets/fonts/ resolves", +) + + +@requires_font +@pytest.mark.parametrize("height", [32, 64, 96]) +def test_text_has_no_antialiased_pixels(height): + """Every lit text pixel is fully on — no dim anti-aliasing fringe. + + Logos are excluded (logo_dir="") because a real logo legitimately contains + many colours; only the text is required to be two-valued. + """ + renderer = ImageRenderer(height, LOGGER) + image = renderer.create_leaderboard_image(_league_data(logo_dir="")) + assert image is not None + + import numpy as np + pixels = np.array(image).reshape(-1, 3) + lit = pixels[pixels.sum(axis=1) > 0] + assert len(lit) > 0, "nothing was drawn" + + allowed = {(255, 255, 0), (255, 255, 255)} # rank yellow, abbreviation white + partial = [tuple(c) for c in lit if tuple(c) not in allowed] + assert not partial, ( + f"{len(partial)} anti-aliased text pixels at height {height}, " + f"e.g. {sorted(set(partial))[:5]}" + ) + + +@requires_font +def test_pixel_perfect_text_can_be_disabled(): + """Turning the option off restores the old anti-aliased rendering.""" + renderer = ImageRenderer(32, LOGGER, {"pixel_perfect_text": False}) + image = renderer.create_leaderboard_image(_league_data(logo_dir="")) + assert image is not None + draw = renderer._make_draw(Image.new("RGB", (8, 8))) + assert draw.fontmode != "1" + + +@requires_font +@pytest.mark.parametrize("height,expected", [(32, 8), (64, 16), (96, 24)]) +def test_default_font_sizes_land_on_the_pixel_grid(height, expected): + renderer = ImageRenderer(height, LOGGER) + if not renderer.font_grid: + pytest.skip("no grid-based pixel font available") + for key, font in renderer.fonts.items(): + assert font.size % renderer.font_grid == 0, ( + f"font '{key}' size {font.size} is off the {renderer.font_grid}px grid" + ) + if os.path.basename(renderer.font_path) == "PressStart2P-Regular.ttf": + assert renderer.fonts["large"].size == expected + + +@requires_font +def test_font_size_override_is_snapped_to_the_grid(): + renderer = ImageRenderer(32, LOGGER, {"font_size": 14}) + if not renderer.font_grid: + pytest.skip("no grid-based pixel font available") + assert renderer.fonts["large"].size % renderer.font_grid == 0 + + +@requires_font +@pytest.mark.parametrize("height", [32, 64]) +def test_nothing_is_drawn_past_the_panel_edge(height): + """Logos used to be scaled to 120% of the panel and cropped top and bottom.""" + renderer = ImageRenderer(height, LOGGER) + image = renderer.create_leaderboard_image(_league_data()) + assert image is not None + box = image.getbbox() + assert box is not None, "nothing was drawn" + assert box[1] >= 0 and box[3] <= height, ( + f"content spans y={box[1]}..{box[3]} on a {height}px panel" + ) + + +def test_logo_alpha_is_thresholded(): + """Crisp mode leaves no semi-transparent halo around a logo.""" + renderer = ImageRenderer(32, LOGGER) + source = Image.new("RGBA", (256, 256), (0, 0, 0, 0)) + for y in range(256): # a soft vertical alpha ramp + for x in range(256): + source.putpixel((x, y), (255, 0, 0, x)) + + crisp = renderer._prepare_logo(source, 32, 32) + assert crisp is not None + assert set(crisp.split()[3].tobytes()) <= {0, 255} + + renderer.crisp_logos = False + soft = renderer._prepare_logo(source, 32, 32) + assert len(set(soft.split()[3].tobytes())) > 2 + + +def test_logo_preserves_aspect_ratio(): + renderer = ImageRenderer(32, LOGGER) + wide = Image.new("RGBA", (200, 100), (255, 0, 0, 255)) + fitted = renderer._prepare_logo(wide, 32, 32) + assert fitted.size == (32, 16) + + +@requires_font +@pytest.mark.parametrize("logo_dir", ["assets/sports/nfl_logos", ""]) +def test_measured_width_matches_drawn_content(logo_dir): + """The strip is exactly as wide as what gets drawn into it. + + Measuring and drawing were previously two separate passes that budgeted for + a team logo differently when the file was missing, so the strip came out + either padded with dead space or clipped short of the last team. + """ + renderer = ImageRenderer(32, LOGGER) + data = _league_data(logo_dir=logo_dir) + if logo_dir and not os.path.isdir(logo_dir): + pytest.skip("core logo assets not reachable") + + image = renderer.create_leaderboard_image(data) + assert image is not None + box = image.getbbox() + + # The only blank after the last abbreviation is that team's own trailing + # gap plus the inter-league gap (+1 for the glyph's right side bearing). A + # mismatch between the two passes shows up as a much larger gap, or as ink + # running right up to the edge. + trailing_blank = image.width - box[2] + budget = ImageRenderer.TEAM_GAP + ImageRenderer.LEAGUE_SPACING + 2 + assert 0 < trailing_blank <= budget, ( + f"{trailing_blank}px of blank after the last team " + f"(strip={image.width}px, ink ends at {box[2]}px)" + ) + + +@requires_font +def test_all_teams_are_drawn(): + """Every team handed to the renderer appears in the strip.""" + renderer = ImageRenderer(32, LOGGER) + layout, total_width = renderer._build_layout(_league_data(logo_dir="")) + assert len(layout) == 1 + assert len(layout[0]["teams"]) == len(TEAMS) + assert [t["text"] for t in layout[0]["teams"]] == TEAMS + assert total_width > 0 + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) From 58714900fe5ceef061aac24a8bcc8113725800da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:29:13 +0000 Subject: [PATCH 2/3] Use ledmatrix_min_version in the 1.3.0 changelog entry The manifest gate requires the newly-added versions[] entry to use ledmatrix_min_version; ledmatrix_min is the deprecated spelling and is not what the store and loader read. Older entries are left as they are. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WexvwNDtWLVymGVqKD7BGk --- plugins/ledmatrix-leaderboard/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index 8be2db01..ee200369 100644 --- a/plugins/ledmatrix-leaderboard/manifest.json +++ b/plugins/ledmatrix-leaderboard/manifest.json @@ -34,7 +34,7 @@ { "version": "1.3.0", "released": "2026-08-05", - "ledmatrix_min": "2.0.0", + "ledmatrix_min_version": "2.0.0", "notes": "Pixel-perfect rendering: text is drawn with anti-aliasing off and at font sizes snapped to the pixel grid, and logos get hard alpha edges and are fitted inside the panel instead of being scaled to 120% and cropped top and bottom. Layout is now measured and drawn in a single pass so the strip width matches its contents. top_teams accepts 0 to show every team a league returns, and the plugin warns when the list is longer than the display-duration budget will reach. New global.appearance options: pixel_perfect_text, crisp_logos, text_outline, logo_scale, font_size." }, { From b1fc220e039d07a2da61a7d7578c392dead39384 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:30:41 +0000 Subject: [PATCH 3/3] Log instead of silently swallowing an unreadable core config The except/continue added for the duration-cap lookup discarded the exception with no trace, so a persistently broken config manager would show only as the plugin quietly using the default cap. Logs at debug with the traceback, matching how BasePlugin.global_config handles the same failure. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WexvwNDtWLVymGVqKD7BGk --- plugins/ledmatrix-leaderboard/manager.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/ledmatrix-leaderboard/manager.py b/plugins/ledmatrix-leaderboard/manager.py index ccfc1339..cc3400f9 100644 --- a/plugins/ledmatrix-leaderboard/manager.py +++ b/plugins/ledmatrix-leaderboard/manager.py @@ -372,6 +372,14 @@ def _core_dynamic_cap(self) -> float: try: core_config = config_manager.get_config() except Exception: + # An unreadable core config must not stop the plugin loading; + # fall through to the next source, then to the documented + # default. Logged rather than swallowed so a persistently + # broken config manager is diagnosable. + self.logger.debug( + "Could not read core config from %s", type(owner).__name__, + exc_info=True, + ) continue if not isinstance(core_config, dict) or not core_config: continue