From f2a84630eecdd10d2fd09c0676580137d5145213 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 20:03:16 -0400 Subject: [PATCH 1/2] fix(plugins): draw text 1-bit, so glyphs stay crisp on the LED grid An LED panel has no partial brightness. PIL defaults ImageDraw's fontmode to "L", which anti-aliases TrueType glyphs into a grey fringe the panel can only round off -- a 4px glyph arrives smeared into 3px. Measured at draw time rather than grepped, because the source-level signal misleads in both directions. A missing fontmode is often harmless: birdnet-go and youtube-stats draw PressStart2P at its native 8px, which emits no partial pixels whatever the mode. A present one is not coverage: ledmatrix-flights set it at 3 of its 12 Draw() sites, and the one that actually blurred was not among them. pomodoro-timer looked like the worst offender and was already correct -- it masks, thresholds at >=128 and paints flat, so only its intermediate mask was ever anti-aliased. Hooking ImageDraw.text across 31 plugins x 8 panel sizes found 18 genuinely anti-aliased sites in 9 plugins, all of them 4x6-font.ttf at 6 (74% of lit pixels at partial coverage) or PressStart2P off its 8px grid. The other 52 sites here were clean only by luck: their faces happen to sit on the pixel grid at the size currently configured. Font sizes are user settings, so that is not a property to rely on -- typing 10 into the web UI reintroduces the blur. The setting belongs on every draw that renders text. Excluded: the offline asset generators (download_assets.py, logo_downloader.py and friends). They bake placeholder logos on a developer machine, not text on the panel, and logo resampling is a separately-decided question. Not render-verified: mqtt-notifications, nfl-draft, on-air and static-image render only with live data the harness has none of. They are fixed statically, on the same reasoning, but no render proves it. scripts/test_pixel_perfect_text.py keeps it from coming back, and is wired into the Plugin Structure workflow. It is AST-scoped, not a file-wide regex: a Draw() is reported only where that binding is used for .text() in the same function, which is the difference between 43 real findings and 130 mostly spurious ones. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .github/workflows/module-collisions.yml | 13 +++ plugins.json | 38 +++---- plugins/birdnet-go/manager.py | 4 + plugins/birdnet-go/manifest.json | 8 +- plugins/calendar/manager.py | 2 + plugins/calendar/manifest.json | 10 +- plugins/incoming-packages/manager.py | 3 + plugins/incoming-packages/manifest.json | 8 +- plugins/jellyfin-now-playing/manager.py | 2 + plugins/jellyfin-now-playing/manifest.json | 8 +- plugins/ledmatrix-elections/manifest.json | 10 +- plugins/ledmatrix-elections/renderer.py | 2 + plugins/ledmatrix-flights/manifest.json | 10 +- plugins/ledmatrix-flights/renderer.py | 1 + plugins/ledmatrix-leaderboard/manager.py | 1 + plugins/ledmatrix-leaderboard/manifest.json | 10 +- plugins/ledmatrix-weather/manager.py | 6 + plugins/ledmatrix-weather/manifest.json | 10 +- plugins/ledmatrix-weather/weather_radar.py | 1 + plugins/march-madness/manager.py | 1 + plugins/march-madness/manifest.json | 10 +- plugins/masters-tournament/manifest.json | 10 +- .../masters-tournament/masters_renderer.py | 10 ++ .../masters_renderer_enhanced.py | 4 + plugins/mqtt-notifications/manager.py | 4 + plugins/mqtt-notifications/manifest.json | 10 +- plugins/nfl-draft/manager.py | 4 + plugins/nfl-draft/manifest.json | 10 +- plugins/of-the-day/manager.py | 3 + plugins/of-the-day/manifest.json | 10 +- plugins/olympics/manifest.json | 8 +- plugins/olympics/renderers/alerts_renderer.py | 5 + .../olympics/renderers/countdown_renderer.py | 1 + plugins/olympics/renderers/event_renderer.py | 5 + plugins/olympics/renderers/medal_renderer.py | 4 + plugins/on-air/manager.py | 1 + plugins/on-air/manifest.json | 10 +- plugins/static-image/manager.py | 1 + plugins/static-image/manifest.json | 8 +- plugins/text-display/manager.py | 2 + plugins/text-display/manifest.json | 8 +- plugins/web-ui-info/manager.py | 1 + plugins/web-ui-info/manifest.json | 8 +- plugins/youtube-stats/manager.py | 2 + plugins/youtube-stats/manifest.json | 10 +- scripts/test_pixel_perfect_text.py | 105 ++++++++++++++++++ 46 files changed, 352 insertions(+), 50 deletions(-) create mode 100755 scripts/test_pixel_perfect_text.py diff --git a/.github/workflows/module-collisions.yml b/.github/workflows/module-collisions.yml index 2f948012..ee16205f 100644 --- a/.github/workflows/module-collisions.yml +++ b/.github/workflows/module-collisions.yml @@ -15,6 +15,10 @@ name: Plugin Structure # scroll_display_legacy.py. Three plugins shipped as those two files # concatenated, carrying ~2,000 lines nothing referenced, and that dead copy # is what hid the missing separator-icon constants that broke scroll mode. +# +# 3. Pixel-perfect text -- fails when a Draw() that renders text leaves PIL's +# fontmode at the anti-aliasing default. An LED panel has no partial +# brightness, so the grey fringe just smears the glyph. on: pull_request: @@ -25,6 +29,7 @@ on: - 'scripts/test_check_scroll_adoption.py' - 'scripts/check_sports_display_contract.py' - 'scripts/test_check_sports_display_contract.py' + - 'scripts/test_pixel_perfect_text.py' # Without this, a PR that only edits this workflow matches no path and # the workflow never runs against its own change. - '.github/workflows/module-collisions.yml' @@ -61,3 +66,11 @@ jobs: - name: Test the sports display()-contract gate if: always() run: python scripts/test_check_sports_display_contract.py + # PIL anti-aliases TrueType text by default, which on a panel with no + # partial brightness only smears glyphs -- 4x6-font.ttf at 6 puts 74% of + # its lit pixels at partial coverage. Static, because the plugins most at + # risk render only with live data the harness has none of, so a + # render-based check would skip exactly those. + - name: Check text draws are 1-bit (no anti-aliasing) + if: always() + run: python scripts/test_pixel_perfect_text.py diff --git a/plugins.json b/plugins.json index f818927f..f41090da 100644 --- a/plugins.json +++ b/plugins.json @@ -124,7 +124,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.2.4" + "latest_version": "1.2.5" }, { "id": "christmas-countdown", @@ -385,7 +385,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "1.2.2" + "latest_version": "1.2.3" }, { "id": "leaderboard", @@ -412,7 +412,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.2" + "latest_version": "1.3.3" }, { "id": "ledmatrix-flights", @@ -439,7 +439,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "1.12.19" + "latest_version": "1.12.20" }, { "id": "march-madness", @@ -463,7 +463,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.0.6" + "latest_version": "1.1.1" }, { "id": "masters-tournament", @@ -487,7 +487,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.5.5" + "latest_version": "2.5.6" }, { "id": "mqtt-notifications", @@ -510,7 +510,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.2.0" + "latest_version": "1.2.1" }, { "id": "music", @@ -581,7 +581,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.2.6", + "latest_version": "1.2.7", "icon": "fa-circle-dot" }, { @@ -605,7 +605,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.4.2", + "latest_version": "1.4.4", "icon": "fas fa-football-ball" }, { @@ -656,7 +656,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.4.2" + "latest_version": "1.4.3" }, { "id": "olympics", @@ -681,7 +681,7 @@ "last_updated": "2026-05-15", "verified": true, "screenshot": "", - "latest_version": "2.0.1" + "latest_version": "2.0.3" }, { "id": "pga-tour-leaderboard", @@ -783,7 +783,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.0.6" + "latest_version": "1.0.7" }, { "id": "stock-news", @@ -856,7 +856,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.1.6" + "latest_version": "1.1.7" }, { "id": "tide-display", @@ -928,7 +928,7 @@ "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins", "branch": "main", "plugin_path": "plugins/ledmatrix-weather", - "latest_version": "2.6.6", + "latest_version": "2.6.7", "stars": 0, "downloads": 0, "last_updated": "2026-09-03", @@ -955,7 +955,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.1.0" + "latest_version": "1.1.1" }, { "id": "youtube-stats", @@ -978,7 +978,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.2.0" + "latest_version": "1.2.1" }, { "id": "ledmatrix-elections", @@ -1001,7 +1001,7 @@ "last_updated": "2026-07-31", "verified": false, "screenshot": "", - "latest_version": "1.2.1" + "latest_version": "1.2.2" }, { "id": "ledmatrix-dresden-departures", @@ -1119,7 +1119,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.2.0" + "latest_version": "1.2.1" }, { "id": "incoming-packages", @@ -1143,7 +1143,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins/incoming-packages/assets/screenshot.png", - "latest_version": "1.0.1", + "latest_version": "1.0.2", "icon": "fas fa-box" }, { diff --git a/plugins/birdnet-go/manager.py b/plugins/birdnet-go/manager.py index 95588953..2673b1d9 100644 --- a/plugins/birdnet-go/manager.py +++ b/plugins/birdnet-go/manager.py @@ -708,6 +708,7 @@ def _build_scroll_cache(self, text: str, height: int, font, text_w, text_h, y_off = self._measure(text, font) cache = Image.new('RGB', (text_w + self.scroll_gap_width, height), self.bg_color) draw = ImageDraw.Draw(cache) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). draw.text((0, (height - text_h) // 2 - y_off), text, font=font, fill=color) return cache @@ -719,6 +720,7 @@ def _render_text_line(self, text: str, box_w: int, box_h: int, color = color or self.text_color frame = Image.new('RGB', (box_w, box_h), self.bg_color) draw = ImageDraw.Draw(frame) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). text_w, text_h, y_off = self._measure(text, font) if text_w <= box_w or not allow_scroll: @@ -810,6 +812,7 @@ def _cycle_detection(self, force_clear: bool) -> Optional[Dict[str, Any]]: def _render_centered(self, text: str, w: int, h: int) -> Image.Image: img = Image.new('RGB', (w, h), self.bg_color) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). font = self._font_for(min(self.font_size, max(5, h // 3))) text = self._truncate(text, font, w - 2) tw, th, y_off = self._measure(text, font) @@ -911,6 +914,7 @@ def _stats_layout(self, h: int) -> Tuple[int, int, Any]: def _render_stats(self, stats: Dict[str, Any], w: int, h: int) -> Image.Image: frame = Image.new('RGB', (w, h), self.bg_color) draw = ImageDraw.Draw(frame) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). rows, line_h, font = self._stats_layout(h) pad = 1 if w < 96 else 2 diff --git a/plugins/birdnet-go/manifest.json b/plugins/birdnet-go/manifest.json index 332ab908..50683989 100644 --- a/plugins/birdnet-go/manifest.json +++ b/plugins/birdnet-go/manifest.json @@ -1,7 +1,7 @@ { "id": "birdnet-go", "name": "BirdNET-Go", - "version": "1.2.2", + "version": "1.2.3", "author": "ChuckBuilds", "description": "Show what BirdNET-Go is hearing. One screen cycles a different species each turn \u2014 name, confidence, how many times it has been heard today, and a photo \u2014 and a second shows today's stats: species count, total detections and the most-heard species. Polls the BirdNET-Go REST API, with optional MQTT for instant pop-ups.", "category": "integration", @@ -21,6 +21,12 @@ "entry_point": "manager.py", "class_name": "BirdNetGoPlugin", "versions": [ + { + "released": "2026-09-03", + "version": "1.2.3", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-03", "version": "1.2.2", diff --git a/plugins/calendar/manager.py b/plugins/calendar/manager.py index e695f2ba..8ce3e77a 100644 --- a/plugins/calendar/manager.py +++ b/plugins/calendar/manager.py @@ -533,6 +533,7 @@ def _fresh_frame(self): width, height = self._get_display_dimensions() self.display_manager.image = Image.new('RGB', (width, height), (0, 0, 0)) self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) + self.display_manager.draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). return width, height def _display_event(self, event: Dict): @@ -578,6 +579,7 @@ def _render_event_to_image(self, event: Dict, image: Image.Image, self._load_fonts() draw = ImageDraw.Draw(image) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Calculate dynamic layout based on height datetime_font_height = self._get_font_height(self.datetime_font) diff --git a/plugins/calendar/manifest.json b/plugins/calendar/manifest.json index 986893d8..eab688a3 100644 --- a/plugins/calendar/manifest.json +++ b/plugins/calendar/manifest.json @@ -1,7 +1,7 @@ { "id": "calendar", "name": "Google Calendar", - "version": "1.2.4", + "version": "1.2.5", "author": "ChuckBuilds", "description": "Display upcoming events from Google Calendar with date, time, and event details. Shows next 1-3 events with automatic rotation and timezone support.", "category": "productivity", @@ -35,6 +35,12 @@ } ], "versions": [ + { + "released": "2026-09-03", + "version": "1.2.5", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "3.3.0" + }, { "version": "1.2.4", "released": "2026-09-02", @@ -71,7 +77,7 @@ "released": "2026-07-28", "version": "1.1.6", "notes": "Fix Google Calendar auth failing with 'invalid_grant: Missing code verifier'. The web-UI flow runs as two separate processes, but the PKCE code_verifier generated while building the auth URL was never carried into the token-exchange step. It is now persisted between the two steps (and cleaned up afterward).", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "released": "2026-07-17", diff --git a/plugins/incoming-packages/manager.py b/plugins/incoming-packages/manager.py index 3d74c759..32d8b649 100644 --- a/plugins/incoming-packages/manager.py +++ b/plugins/incoming-packages/manager.py @@ -492,6 +492,7 @@ def _render_static(self, message: str, color, force_clear: bool) -> None: _, small = self._tier_fonts() image = Image.new("RGB", (width, height)) draw = ImageDraw.Draw(image) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Wrap to two lines, then hard-truncate each so an over-wide unbroken # word (a long token, URL, …) can never spill past the panel edge. lines = [self._truncate(ln, small, width - 2) @@ -530,6 +531,7 @@ def _render_card(self, card: Dict[str, Any]) -> Image.Image: big, small = self._tier_fonts() image = Image.new("RGB", (width, height)) draw = ImageDraw.Draw(image) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). tall = height >= 46 if card["type"] in ("usps_image", "carrier_image"): @@ -693,6 +695,7 @@ def _carrier_badge(self, slug: str, size: int) -> Image.Image: bg, fg, abbrev = CARRIER_STYLE.get(slug, CARRIER_STYLE["other"]) img = Image.new("RGB", (size, size), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). radius = max(2, size // 6) draw.rounded_rectangle([0, 0, size - 1, size - 1], radius=radius, fill=bg) # Largest font whose abbrev fits; always fall back to the smallest so a diff --git a/plugins/incoming-packages/manifest.json b/plugins/incoming-packages/manifest.json index 6f7c1a87..18bf9f5e 100644 --- a/plugins/incoming-packages/manifest.json +++ b/plugins/incoming-packages/manifest.json @@ -1,7 +1,7 @@ { "id": "incoming-packages", "name": "Incoming Packages", - "version": "1.0.1", + "version": "1.0.2", "author": "ChuckBuilds", "description": "Rotating cards for packages headed your way - per carrier, with the count arriving today highlighted. Reads a normalized snapshot from a pluggable provider: the Home Assistant Mail and Packages integration (default), AfterShip, or a built-in demo. Email scanning stays inside Home Assistant; the plugin only stores an API URL + token.", "category": "productivity", @@ -27,6 +27,12 @@ "pillow" ], "versions": [ + { + "released": "2026-09-03", + "version": "1.0.2", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-03", "version": "1.0.1", diff --git a/plugins/jellyfin-now-playing/manager.py b/plugins/jellyfin-now-playing/manager.py index ad9b6e5e..a1c1d41b 100644 --- a/plugins/jellyfin-now-playing/manager.py +++ b/plugins/jellyfin-now-playing/manager.py @@ -485,6 +485,7 @@ def _render_static(self, message: str, color: Tuple[int, int, int], image = Image.new('RGB', (width, height)) draw = ImageDraw.Draw(image) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). font = self.subtitle_font text = self._truncate_text_with_ellipsis(message, font, width - 2) text_w = self._text_width(text, font) @@ -499,6 +500,7 @@ def _render_now_playing(self) -> Image.Image: width, height = self._dims() image = Image.new('RGB', (width, height)) draw = ImageDraw.Draw(image) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). info = self.now_playing # Poster on the left (or a placeholder when the fetch failed) diff --git a/plugins/jellyfin-now-playing/manifest.json b/plugins/jellyfin-now-playing/manifest.json index d7cc0328..ba159696 100644 --- a/plugins/jellyfin-now-playing/manifest.json +++ b/plugins/jellyfin-now-playing/manifest.json @@ -1,7 +1,7 @@ { "id": "jellyfin-now-playing", "name": "Jellyfin Now Playing", - "version": "1.2.0", + "version": "1.2.1", "author": "ChuckBuilds", "description": "Shows what's playing on your Jellyfin server: poster art, title, and playback progress", "category": "media", @@ -26,6 +26,12 @@ "pillow" ], "versions": [ + { + "released": "2026-09-03", + "version": "1.2.1", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-02", "version": "1.2.0", diff --git a/plugins/ledmatrix-elections/manifest.json b/plugins/ledmatrix-elections/manifest.json index 8df5fda0..55ca4bdf 100644 --- a/plugins/ledmatrix-elections/manifest.json +++ b/plugins/ledmatrix-elections/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-elections", "name": "Election Results", - "version": "1.2.1", + "version": "1.2.2", "author": "rpierce99", "description": "Live election results: a scrolling ticker of important races plus a full-screen interrupt when a race is newly called. Auto-activates for your state's primary + general (dormant otherwise), drains stale calls from the ticker, and survives restarts. Filterable to your state. NYT baseline provider + optional California SoS county/city rollup.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ "cache_manager" ], "versions": [ + { + "released": "2026-09-03", + "version": "1.2.2", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.2.1", "released": "2026-08-19", @@ -31,7 +37,7 @@ { "version": "1.2.0", "released": "2026-07-31", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "released": "2026-07-08", diff --git a/plugins/ledmatrix-elections/renderer.py b/plugins/ledmatrix-elections/renderer.py index 47ab66e5..f2dccaa9 100644 --- a/plugins/ledmatrix-elections/renderer.py +++ b/plugins/ledmatrix-elections/renderer.py @@ -186,6 +186,7 @@ def render_ticker_column(races: list, height: int) -> Image.Image: img = Image.new("RGB", (width, height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). y = _TICKER_PAD for header, status, status_color, rows in prepared: draw.text((_TICKER_PAD, y), header, font=fnt, fill=_HEADER_YELLOW) @@ -244,6 +245,7 @@ def render_called_card(race: Race, width: int, height: int) -> Image.Image: """ img = Image.new("RGB", (width, height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). small = font(6) use_big = height >= 48 diff --git a/plugins/ledmatrix-flights/manifest.json b/plugins/ledmatrix-flights/manifest.json index 37154315..68b3219f 100644 --- a/plugins/ledmatrix-flights/manifest.json +++ b/plugins/ledmatrix-flights/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-flights", "name": "Flight Tracker", - "version": "1.12.19", + "version": "1.12.20", "description": "Real-time aircraft tracking with ADS-B/FlightRadar24/OpenSky/adsb.fi/adsb.lol data, map backgrounds, area mode, flight tracking, anchor airport, flight records, and optional airport weather (METAR/TAF/PIREP/SIGMET via the free NOAA Aviation Weather Center API)", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -37,6 +37,12 @@ "min_ledmatrix_version": "2.0.0", "max_ledmatrix_version": "3.0.0", "versions": [ + { + "released": "2026-09-03", + "version": "1.12.20", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.12.19", "released": "2026-09-03", @@ -90,7 +96,7 @@ "released": "2026-08-03", "version": "1.12.7", "notes": "Fix the map background cache handing a render the wrong size. The cached composite is already cropped to the display aspect ratio and resized to the panel, but it was keyed on centre and zoom only. Vegas narrows the display manager, so rotation and the ticker request the same view at different widths and whichever rendered second was served the other's image -- a frame at the wrong size with aircraft and trails projected for the size it did not get. The cache now keys on size as well and keeps one entry per size.", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "released": "2026-07-29", diff --git a/plugins/ledmatrix-flights/renderer.py b/plugins/ledmatrix-flights/renderer.py index 68fa8ddd..4e00e0e3 100644 --- a/plugins/ledmatrix-flights/renderer.py +++ b/plugins/ledmatrix-flights/renderer.py @@ -1059,6 +1059,7 @@ def _render_stat_card_to_image(self, title, title_color, aircraft, stat_label, s def render_error(self, message="NO DATA"): img = Image.new("RGB", (self.width, self.height), (0, 0, 0)) draw = ImageDraw.Draw(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_centered(draw, message, (self.height - self._fh(self.font_large)) // 2, self.font_large, self.error_color) self.dm.image = img.copy() diff --git a/plugins/ledmatrix-leaderboard/manager.py b/plugins/ledmatrix-leaderboard/manager.py index 29121b8e..8a3c16b4 100644 --- a/plugins/ledmatrix-leaderboard/manager.py +++ b/plugins/ledmatrix-leaderboard/manager.py @@ -455,6 +455,7 @@ def _display_fallback_message(self) -> None: image = Image.new('RGB', (width, height), (0, 0, 0)) from PIL import ImageDraw draw = ImageDraw.Draw(image) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). text = "No Leaderboard Data" # Use default font if available diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index c8a93d23..c27afe62 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.2", + "version": "1.3.3", "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-09-03", + "version": "1.3.3", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-12", "version": "1.3.2", @@ -52,7 +58,7 @@ { "version": "1.2.3", "released": "2026-07-31", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "released": "2026-07-17", diff --git a/plugins/ledmatrix-weather/manager.py b/plugins/ledmatrix-weather/manager.py index 3d0c032c..b7e631ec 100644 --- a/plugins/ledmatrix-weather/manager.py +++ b/plugins/ledmatrix-weather/manager.py @@ -1025,6 +1025,7 @@ def _display_no_data(self) -> None: """Display a diagnostic message when no weather data is available.""" img = Image.new('RGB', (self.display_manager.matrix.width, self.display_manager.matrix.height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). from PIL import ImageFont try: @@ -1057,6 +1058,7 @@ def _render_current_weather_image(self, width: Optional[int] = None) -> Optional height = self.display_manager.matrix.height img = Image.new('RGB', (width, height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Get weather info temp = int(self.weather_data['main']['temp']) @@ -1267,6 +1269,7 @@ def _render_hourly_forecast_image(self, width: Optional[int] = None) -> Optional height = self.display_manager.matrix.height img = Image.new('RGB', (width, height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). layout = self._get_layout() hours_to_show = min(4, len(self.hourly_forecast)) @@ -1344,6 +1347,7 @@ def _render_daily_forecast_image(self, width: Optional[int] = None) -> Optional[ height = self.display_manager.matrix.height img = Image.new('RGB', (width, height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). layout = self._get_layout() days_to_show = min(3, len(self.daily_forecast)) @@ -1621,6 +1625,7 @@ def _display_almanac(self) -> None: height = self.display_manager.matrix.height img = Image.new('RGB', (width, height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). font_sm = self.display_manager.extra_small_font # 6px - secondary rows tz_offset = self.weather_data.get('timezone_offset', 0) if self.weather_data else 0 @@ -1783,6 +1788,7 @@ def _display_alerts(self) -> None: height = self.display_manager.matrix.height img = Image.new('RGB', (width, height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). font = self.display_manager.extra_small_font font_h = 7 diff --git a/plugins/ledmatrix-weather/manifest.json b/plugins/ledmatrix-weather/manifest.json index 3ef199ce..1c51205c 100644 --- a/plugins/ledmatrix-weather/manifest.json +++ b/plugins/ledmatrix-weather/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-weather", "name": "Weather Display", - "version": "2.6.6", + "version": "2.6.7", "author": "ChuckBuilds", "class_name": "WeatherPlugin", "update_interval": 60, @@ -26,6 +26,12 @@ "radar" ], "versions": [ + { + "released": "2026-09-03", + "version": "2.6.7", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.6.6", "released": "2026-09-03", @@ -54,7 +60,7 @@ "released": "2026-07-21", "version": "2.6.2", "notes": "Radar default range widened slightly, from 50 to 75 miles (center to panel edge), for a more regional default view. Existing configs are unaffected: an explicit radar_range_miles is used as-is, and the deprecated radar_zoom still maps to its previous range.", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "released": "2026-07-21", diff --git a/plugins/ledmatrix-weather/weather_radar.py b/plugins/ledmatrix-weather/weather_radar.py index 3be7be41..7aa5f944 100644 --- a/plugins/ledmatrix-weather/weather_radar.py +++ b/plugins/ledmatrix-weather/weather_radar.py @@ -593,6 +593,7 @@ def _add_overlay(self, img: Image.Image, current: Optional[RadarFrame], frames: List[RadarFrame], width: int, height: int, viewport: MercatorViewport) -> Image.Image: draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Crosshair at the configured location's true projected position cx, cy = viewport.latlon_to_panel(self.lat, self.lon, width, height) diff --git a/plugins/march-madness/manager.py b/plugins/march-madness/manager.py index 8310379c..b329d7f0 100644 --- a/plugins/march-madness/manager.py +++ b/plugins/march-madness/manager.py @@ -835,6 +835,7 @@ def _display_fallback(self) -> None: h = self.display_manager.matrix.height img = Image.new("RGB", (w, h), COLOR_BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). if self._is_tournament_window(): text = "No games" diff --git a/plugins/march-madness/manifest.json b/plugins/march-madness/manifest.json index 11a9b0f9..3bd7da52 100644 --- a/plugins/march-madness/manifest.json +++ b/plugins/march-madness/manifest.json @@ -1,7 +1,7 @@ { "id": "march-madness", "name": "March Madness", - "version": "1.0.6", + "version": "1.1.1", "description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting", "author": "ChuckBuilds", "category": "sports", @@ -20,6 +20,12 @@ ">=2.0.0" ], "versions": [ + { + "released": "2026-09-03", + "version": "1.1.1", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.0.6", "released": "2026-08-19", @@ -41,7 +47,7 @@ { "version": "1.0.3", "released": "2026-07-31", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "version": "1.0.2", diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index 1d655261..2955b2be 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.5.5", + "version": "2.5.6", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,12 @@ "height": 64 }, "versions": [ + { + "released": "2026-09-03", + "version": "2.5.6", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.5.5", "released": "2026-08-19", @@ -58,7 +64,7 @@ { "released": "2026-07-17", "version": "2.5.3", - "ledmatrix_min": "2.0.0", + "ledmatrix_min_version": "2.0.0", "notes": "Config schema reorganized for a clearer setup experience: fine-tuning fields moved behind the web UI's collapsed Advanced Settings section (x-advanced). UX-only; no fields, defaults, or behavior changed." }, { diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index 9ea4b380..07926059 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -617,6 +617,7 @@ def render_player_card(self, player: Dict, img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"], width=w, height=h) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Gold border draw.rectangle([(0, 0), (w - 1, h - 1)], @@ -939,6 +940,7 @@ def render_hole_card(self, hole_number: int, img = self._draw_gradient_bg((15, 80, 30), COLORS["augusta_green"], width=cw, height=ch) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Header header_h = self.header_height @@ -991,6 +993,7 @@ def render_hole_card(self, hole_number: int, def render_amen_corner(self, scoring_data: Optional[Dict] = None) -> Optional[Image.Image]: img = self._draw_gradient_bg((5, 50, 25), COLORS["augusta_green"]) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Header h = self.header_height + 2 @@ -1046,6 +1049,7 @@ def render_amen_corner(self, scoring_data: Optional[Dict] = None) -> Optional[Im def render_past_champions(self, page: int = 0) -> Optional[Image.Image]: img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"]) draw = ImageDraw.Draw(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_header_bar(img, draw, "CHAMPIONS", show_logo=False) @@ -1168,6 +1172,7 @@ def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0, img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"], width=cw, height=ch) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Header header_h = self.header_height @@ -1237,6 +1242,7 @@ def render_fun_fact_vegas(self, fact_index: int = -1, img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"], width=cw, height=ch) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Header header_h = self.header_height @@ -1258,6 +1264,7 @@ def render_fun_fact_vegas(self, fact_index: int = -1, def render_tournament_stats(self, page: int = 0) -> Optional[Image.Image]: img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) draw = ImageDraw.Draw(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_header_bar(img, draw, "RECORDS", show_logo=False) @@ -1302,6 +1309,7 @@ def render_tournament_stats(self, page: int = 0) -> Optional[Image.Image]: def render_schedule(self, schedule_data: List[Dict], page: int = 0) -> Optional[Image.Image]: img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) draw = ImageDraw.Draw(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_header_bar(img, draw, "TEE TIMES") @@ -1447,6 +1455,7 @@ def render_countdown(self, days: int, hours: int, minutes: int, img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"], width=w, height=h) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Countdown text — show days + hours for context, hours:minutes when # under 1 day, minutes-only in the final hour, then "NOW". @@ -1548,6 +1557,7 @@ def render_countdown(self, days: int, hours: int, minutes: int, def render_field_overview(self, leaderboard_data: List[Dict]) -> Optional[Image.Image]: img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) draw = ImageDraw.Draw(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_header_bar(img, draw, "THE FIELD") diff --git a/plugins/masters-tournament/masters_renderer_enhanced.py b/plugins/masters-tournament/masters_renderer_enhanced.py index 5202d051..fe080b5d 100644 --- a/plugins/masters-tournament/masters_renderer_enhanced.py +++ b/plugins/masters-tournament/masters_renderer_enhanced.py @@ -114,6 +114,7 @@ def render_player_card(self, player: Dict, img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"]) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Gold border draw.rectangle( @@ -254,6 +255,7 @@ def render_hole_card(self, hole_number: int, img = self._draw_gradient_bg((10, 70, 25), COLORS["augusta_green"], width=cw, height=ch) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). if ch >= self._HOLE_COMPACT_HEIGHT: return self._render_hole_card_with_image( @@ -484,6 +486,7 @@ def render_live_alert( """ img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). is_great = score_desc.lower() in ("eagle", "albatross", "hole in one") header_color = COLORS["gold"] if is_great else COLORS["masters_green"] @@ -558,6 +561,7 @@ def render_course_overview(self, page: int = 0) -> Optional[Image.Image]: """Render Augusta National overview - paginated across all 18 holes.""" img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"]) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). font = self.font_detail diff --git a/plugins/mqtt-notifications/manager.py b/plugins/mqtt-notifications/manager.py index 1a08a50d..cd2e0e89 100644 --- a/plugins/mqtt-notifications/manager.py +++ b/plugins/mqtt-notifications/manager.py @@ -561,6 +561,7 @@ def _create_text_cache(self, text: str) -> Optional[Image.Image]: # Create cache image cache_img = Image.new('RGB', (cache_width, cache_height), self.bg_color) draw = ImageDraw.Draw(cache_img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Calculate vertical centering y_pos = (cache_height - text_height) // 2 - (bbox[1] if isinstance(self.font, ImageFont.FreeTypeFont) else 0) @@ -685,6 +686,7 @@ def display(self, force_clear: bool = False) -> bool: # Fallback: static text img = Image.new('RGB', (matrix_width, matrix_height), self.bg_color) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). bbox = draw.textbbox((0, 0), text, font=self.font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] @@ -696,6 +698,7 @@ def display(self, force_clear: bool = False) -> bool: # Fallback: static text img = Image.new('RGB', (matrix_width, matrix_height), self.bg_color) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). bbox = draw.textbbox((0, 0), text, font=self.font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] @@ -707,6 +710,7 @@ def display(self, force_clear: bool = False) -> bool: # Static text (centered) img = Image.new('RGB', (matrix_width, matrix_height), self.bg_color) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). bbox = draw.textbbox((0, 0), text, font=self.font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] diff --git a/plugins/mqtt-notifications/manifest.json b/plugins/mqtt-notifications/manifest.json index 804e2989..7c05b16a 100644 --- a/plugins/mqtt-notifications/manifest.json +++ b/plugins/mqtt-notifications/manifest.json @@ -1,7 +1,7 @@ { "id": "mqtt-notifications", "name": "MQTT Notifications", - "version": "1.2.0", + "version": "1.2.1", "author": "ChuckBuilds", "description": "Display text or images from HomeAssistant via MQTT. Supports dynamic MQTT topics with wildcard support for flexible notification handling that interrupts the normal display rotation.", "category": "integration", @@ -18,6 +18,12 @@ "entry_point": "manager.py", "class_name": "MQTTNotificationsPlugin", "versions": [ + { + "released": "2026-09-03", + "version": "1.2.1", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.2.0", "released": "2026-09-02", @@ -33,7 +39,7 @@ { "version": "1.1.0", "released": "2026-07-31", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "released": "2026-07-17", diff --git a/plugins/nfl-draft/manager.py b/plugins/nfl-draft/manager.py index 8001caae..2b2cb9ad 100644 --- a/plugins/nfl-draft/manager.py +++ b/plugins/nfl-draft/manager.py @@ -846,6 +846,7 @@ def _create_round_label_item(self, round_num: int) -> Image.Image: w = bbox[2] - bbox[0] img = Image.new('RGB', (max(w, 1), self.display_height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). y = (self.display_height - self.player_name_font_size) // 2 draw.text((0, y), text, font=self.player_name_font, fill=(255, 200, 0)) return img @@ -1005,6 +1006,7 @@ def _create_pick_item(self, pick: Dict[str, Any]) -> Optional[Image.Image]: # Create item image item_img = Image.new('RGB', (total_width, item_height), (0, 0, 0)) draw = ImageDraw.Draw(item_img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). current_x = 0 @@ -1256,6 +1258,7 @@ def _display_no_data(self) -> None: """Display a no data message.""" img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). message = "No Draft Data" try: @@ -1276,6 +1279,7 @@ def _display_error(self) -> None: """Display an error message.""" img = Image.new('RGB', (self.display_width, self.display_height), (50, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). message = "Error" try: diff --git a/plugins/nfl-draft/manifest.json b/plugins/nfl-draft/manifest.json index 7a78d61f..b88aac5c 100644 --- a/plugins/nfl-draft/manifest.json +++ b/plugins/nfl-draft/manifest.json @@ -1,7 +1,7 @@ { "id": "nfl-draft", "name": "NFL Draft", - "version": "1.4.2", + "version": "1.4.4", "author": "ChuckBuilds", "description": "Displays projected NFL draft picks from ESPN with live draft tracking support during the annual NFL Draft event. Includes simulate_live mode to replay a completed draft using real ESPN core API data. Shows team logos, player names, positions, and pick numbers in a scrolling display.", "entry_point": "manager.py", @@ -24,6 +24,12 @@ ], "icon": "fas fa-football-ball", "versions": [ + { + "released": "2026-09-03", + "version": "1.4.4", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.4.2", "released": "2026-08-19", @@ -39,7 +45,7 @@ { "version": "1.4.0", "released": "2026-07-31", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "released": "2026-07-17", diff --git a/plugins/of-the-day/manager.py b/plugins/of-the-day/manager.py index 2a0490e9..b7c8641a 100644 --- a/plugins/of-the-day/manager.py +++ b/plugins/of-the-day/manager.py @@ -570,6 +570,7 @@ def _fresh_frame(self): (self.display_manager.width, self.display_manager.height), (0, 0, 0)) self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) + self.display_manager.draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). def _display_title(self, category_config: Dict, item_data: Dict): """Display the title/word with subtitle, matching old manager layout.""" @@ -799,6 +800,7 @@ def _display_no_data(self): self.display_manager.height), self.background_color) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). try: font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 8) @@ -816,6 +818,7 @@ def _display_error(self): self.display_manager.height), self.background_color) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). try: font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 8) diff --git a/plugins/of-the-day/manifest.json b/plugins/of-the-day/manifest.json index c8fcf6c4..2bf7036f 100644 --- a/plugins/of-the-day/manifest.json +++ b/plugins/of-the-day/manifest.json @@ -1,7 +1,7 @@ { "id": "of-the-day", "name": "Of The Day Display", - "version": "1.4.2", + "version": "1.4.3", "author": "ChuckBuilds", "description": "Display daily featured content like Word of the Day, Bible verses, or custom daily items. Supports multiple categories with rotating display and configurable data sources.", "category": "information", @@ -21,6 +21,12 @@ "of_the_day" ], "versions": [ + { + "released": "2026-09-03", + "version": "1.4.3", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.4.2", "released": "2026-09-02", @@ -44,7 +50,7 @@ { "version": "1.3.2", "released": "2026-07-31", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "released": "2026-07-17", diff --git a/plugins/olympics/manifest.json b/plugins/olympics/manifest.json index 3290b26c..6777c2ae 100644 --- a/plugins/olympics/manifest.json +++ b/plugins/olympics/manifest.json @@ -1,7 +1,7 @@ { "id": "olympics", "name": "Olympics", - "version": "2.0.1", + "version": "2.0.3", "author": "ChuckBuilds", "description": "Enhanced Olympics plugin with live medal counts, upcoming events, results, and countdown. Supports Vegas scroll mode and regular display mode.", "category": "sports", @@ -37,6 +37,12 @@ "country_tracking": true }, "versions": [ + { + "released": "2026-09-03", + "version": "2.0.3", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-05-15", "version": "2.0.1", diff --git a/plugins/olympics/renderers/alerts_renderer.py b/plugins/olympics/renderers/alerts_renderer.py index c8199a40..adb2d0df 100644 --- a/plugins/olympics/renderers/alerts_renderer.py +++ b/plugins/olympics/renderers/alerts_renderer.py @@ -77,6 +77,7 @@ def render_record_alert(self, sport: str, event: str, height = self.display_height img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Flashing effect - alternate background flash_color = GOLD if record_type == "WR" else RED @@ -119,6 +120,7 @@ def render_medal_celebration(self, country: str, medal_type: str, height = self.display_height img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). medal_colors = { "gold": GOLD, @@ -176,6 +178,7 @@ def render_live_final_alert(self, sport: str, event: str, height = self.display_height img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Pulsing "LIVE" indicator live_x = 2 @@ -221,6 +224,7 @@ def render_next_event_countdown(self, sport: str, event: str, height = self.display_height img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # "NEXT" label draw.text((2, 1), "NEXT", font=self.font_small, fill=ORANGE) @@ -259,6 +263,7 @@ def render_medal_summary_flash(self, country: str, gold: int, height = self.display_height img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Country + Rank header_y = 2 diff --git a/plugins/olympics/renderers/countdown_renderer.py b/plugins/olympics/renderers/countdown_renderer.py index 42ae54d9..281f7f48 100644 --- a/plugins/olympics/renderers/countdown_renderer.py +++ b/plugins/olympics/renderers/countdown_renderer.py @@ -177,6 +177,7 @@ def render_countdown_card(self, target_date: datetime, _games_name: str, img = Image.new('RGB', (w, h), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). days = self._calculate_days_until(target_date) margin = 2 diff --git a/plugins/olympics/renderers/event_renderer.py b/plugins/olympics/renderers/event_renderer.py index f1b3288f..cc12557c 100644 --- a/plugins/olympics/renderers/event_renderer.py +++ b/plugins/olympics/renderers/event_renderer.py @@ -348,6 +348,7 @@ def render_upcoming_event(self, event: OlympicEvent, img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Recalculate event lines with actual draw context event_lines = self._split_text_lines(event_text, max_event_line_width, draw, @@ -421,6 +422,7 @@ def render_live_event(self, event: OlympicEvent, img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Calculate line height based on display size line_height = 8 if height <= 32 else 10 @@ -537,6 +539,7 @@ def render_result_card(self, result: EventResult, img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Recalculate event lines with actual draw context event_lines = self._split_text_lines(event_text, max_event_line_width, draw, @@ -605,6 +608,7 @@ def render_events_summary(self, events: List[OlympicEvent], """ img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). if not events: draw.text((4, height // 2 - 4), "No events", font=self.font_small, fill=GRAY) @@ -652,6 +656,7 @@ def render_results_summary(self, results: List[EventResult], """ img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). if not results: draw.text((4, height // 2 - 4), "No results", font=self.font_small, fill=GRAY) diff --git a/plugins/olympics/renderers/medal_renderer.py b/plugins/olympics/renderers/medal_renderer.py index d3cf9bd0..87f7274d 100644 --- a/plugins/olympics/renderers/medal_renderer.py +++ b/plugins/olympics/renderers/medal_renderer.py @@ -183,6 +183,7 @@ def render_medal_card(self, medal: MedalCount, img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Get actual text height for proper centering try: @@ -255,6 +256,7 @@ def render_medal_card_compact(self, medal: MedalCount) -> Image.Image: img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). margin = 2 @@ -324,6 +326,7 @@ def render_medal_summary(self, medals: List[MedalCount], """ img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). if not medals: draw.text((4, height // 2 - 4), "No medal data", font=self.font_small, fill=GRAY) @@ -390,6 +393,7 @@ def render_medal_race(self, country1: MedalCount, country2: MedalCount, """ img = Image.new('RGB', (width, height), BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). margin = 2 diff --git a/plugins/on-air/manager.py b/plugins/on-air/manager.py index 09228e74..6eaade56 100644 --- a/plugins/on-air/manager.py +++ b/plugins/on-air/manager.py @@ -209,6 +209,7 @@ def display(self, force_clear: bool = False) -> bool: fill = bg_color if active else (0, 0, 0) canvas = Image.new('RGB', (dw, dh), fill) draw = ImageDraw.Draw(canvas) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). if active: self._draw_centered_text(draw, dw, dh, label, text_color) diff --git a/plugins/on-air/manifest.json b/plugins/on-air/manifest.json index f28a8536..cf8a4334 100644 --- a/plugins/on-air/manifest.json +++ b/plugins/on-air/manifest.json @@ -1,7 +1,7 @@ { "id": "on-air", "name": "On Air Light", - "version": "1.2.6", + "version": "1.2.7", "author": "ChuckBuilds", "description": "Retro broadcast ON AIR tally light. Activate remotely via MQTT or Home Assistant to signal you're on a call, recording, or live \u2014 stays on until you turn it off.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "released": "2026-09-03", + "version": "1.2.7", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.2.6", "released": "2026-09-02", @@ -37,7 +43,7 @@ { "version": "1.2.4", "released": "2026-07-31", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "released": "2026-07-17", diff --git a/plugins/static-image/manager.py b/plugins/static-image/manager.py index e5bd0fb2..897de926 100644 --- a/plugins/static-image/manager.py +++ b/plugins/static-image/manager.py @@ -953,6 +953,7 @@ def _display_error(self) -> None: # Fallback to direct PIL if font manager fails from PIL import ImageDraw, ImageFont draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). try: font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 8) diff --git a/plugins/static-image/manifest.json b/plugins/static-image/manifest.json index 85f75abf..5b329973 100644 --- a/plugins/static-image/manifest.json +++ b/plugins/static-image/manifest.json @@ -1,7 +1,7 @@ { "id": "static-image", "name": "Static Image Display", - "version": "1.0.6", + "version": "1.0.7", "author": "ChuckBuilds", "description": "Display static images on your LED matrix with automatic scaling, aspect ratio preservation, and transparency support. Perfect for logos, artwork, or custom graphics.", "entry_point": "manager.py", @@ -18,6 +18,12 @@ "static_image" ], "versions": [ + { + "released": "2026-09-03", + "version": "1.0.7", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-02", "version": "1.0.6", diff --git a/plugins/text-display/manager.py b/plugins/text-display/manager.py index 780e378a..356ff9ed 100644 --- a/plugins/text-display/manager.py +++ b/plugins/text-display/manager.py @@ -384,6 +384,7 @@ def _create_text_cache(self): # Create cache image self.text_image_cache = Image.new('RGB', (cache_width, matrix_height), self.bg_color) draw = ImageDraw.Draw(self.text_image_cache) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Calculate vertical centering temp_img = Image.new('RGB', (1, 1)) @@ -531,6 +532,7 @@ def display(self, force_clear: bool = False) -> None: # Fallback: direct draw img = Image.new('RGB', (matrix_width, matrix_height), self.bg_color) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). bbox = draw.textbbox((0, 0), self.render_text, font=self.font) text_height = bbox[3] - bbox[1] y_pos = (matrix_height - text_height) // 2 - bbox[1] diff --git a/plugins/text-display/manifest.json b/plugins/text-display/manifest.json index b1b52cb3..62640934 100644 --- a/plugins/text-display/manifest.json +++ b/plugins/text-display/manifest.json @@ -1,7 +1,7 @@ { "id": "text-display", "name": "Text Display", - "version": "1.1.6", + "version": "1.1.7", "author": "ChuckBuilds", "description": "Display custom scrolling or static text with configurable fonts, colors, and scroll speed. Perfect for announcements, messages, or custom displays.", "category": "display", @@ -24,6 +24,12 @@ } }, "versions": [ + { + "released": "2026-09-03", + "version": "1.1.7", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.1.6", "released": "2026-09-02", diff --git a/plugins/web-ui-info/manager.py b/plugins/web-ui-info/manager.py index eba0a1a4..32ba59f1 100644 --- a/plugins/web-ui-info/manager.py +++ b/plugins/web-ui-info/manager.py @@ -260,6 +260,7 @@ def display(self, force_clear: bool = False) -> None: # Create a new image for the display img = Image.new('RGB', (width, height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Try to load a small font (resolved once, reused across display calls) font_small = getattr(self, '_font_small', None) diff --git a/plugins/web-ui-info/manifest.json b/plugins/web-ui-info/manifest.json index 4dd896ac..e513e980 100644 --- a/plugins/web-ui-info/manifest.json +++ b/plugins/web-ui-info/manifest.json @@ -1,7 +1,7 @@ { "id": "web-ui-info", "name": "Web UI Info", - "version": "1.1.0", + "version": "1.1.1", "author": "LEDMatrix", "description": "Displays the web UI URL for easy access. Shows 'visit web ui at http://[deviceID]:5000'", "entry_point": "manager.py", @@ -19,6 +19,12 @@ "update_interval": 300, "default_duration": 10, "versions": [ + { + "released": "2026-09-03", + "version": "1.1.1", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.1.0", "released": "2026-09-02", diff --git a/plugins/youtube-stats/manager.py b/plugins/youtube-stats/manager.py index 7aebd10f..22bee251 100644 --- a/plugins/youtube-stats/manager.py +++ b/plugins/youtube-stats/manager.py @@ -286,6 +286,7 @@ def _create_display(self, channel_stats: Dict[str, Any]) -> Optional[Image.Image matrix_height = self.display_manager.matrix.height image = Image.new('RGB', (matrix_width, matrix_height)) draw = ImageDraw.Draw(image) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Calculate logo dimensions - 60% of display height to ensure text fits logo_height = int(matrix_height * 0.6) @@ -433,6 +434,7 @@ def _show_error_on_display(self, message: str) -> None: matrix_height = self.display_manager.matrix.height image = Image.new('RGB', (matrix_width, matrix_height)) draw = ImageDraw.Draw(image) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). font = self.font or ImageFont.load_default() bbox = draw.textbbox((0, 0), message, font=font) text_w = bbox[2] - bbox[0] diff --git a/plugins/youtube-stats/manifest.json b/plugins/youtube-stats/manifest.json index 58aecb6d..01bbc61a 100644 --- a/plugins/youtube-stats/manifest.json +++ b/plugins/youtube-stats/manifest.json @@ -1,7 +1,7 @@ { "id": "youtube-stats", "name": "YouTube Stats", - "version": "1.2.0", + "version": "1.2.1", "author": "ChuckBuilds", "description": "Display YouTube channel statistics including subscriber count, total views, and channel name on your LED matrix", "category": "social", @@ -33,6 +33,12 @@ } ], "versions": [ + { + "released": "2026-09-03", + "version": "1.2.1", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.2.0", "released": "2026-09-02", @@ -59,7 +65,7 @@ { "released": "2026-05-15", "version": "1.0.2", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "version": "1.0.1", diff --git a/scripts/test_pixel_perfect_text.py b/scripts/test_pixel_perfect_text.py new file mode 100755 index 00000000..595a03f2 --- /dev/null +++ b/scripts/test_pixel_perfect_text.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Every Draw() that renders text must ask for 1-bit text. + +An LED panel has no partial brightness. PIL defaults ``fontmode`` to "L", which +anti-aliases TrueType glyphs into a grey fringe the panel can only round off -- +so a 4px glyph arrives as a smeared 3px one. ``fontmode = "1"`` turns that off. + +Whether it currently *shows* depends on the size: 4x6-font.ttf at 6 is 74% +partial pixels, while PressStart2P at its native 8 is clean by luck. Font sizes +are user-configurable, so "clean today" is not worth relying on -- the setting +belongs on every draw that renders text to the panel. + +Checked statically, because the plugins that most need it (mqtt-notifications, +nfl-draft, on-air, static-image) only render with live data the harness has none +of; a render-based check would silently skip exactly those. + +Scoped with the AST rather than a file-wide regex: a Draw() is reported only +when *that* binding is used for .text() in the same function. A file-wide match +flags every unrelated `draw` that merely shares the name. +""" +import ast +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PLUGINS = os.path.join(ROOT, "plugins") + +# Offline asset generators: they bake placeholder PNGs on a developer machine, +# not text on the panel. Their output is a logo, and logo resampling is a +# separately-decided question. +SKIP_FILES = { + "download_assets.py", "logo_downloader.py", "logo_loader.py", + "cricket_logo_downloader.py", "headshot_downloader.py", + "generate_placeholder_icon.py", "render_preview.py", + "render_readme_assets.py", +} + + +def _is_draw_call(node): + return (isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "Draw" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "ImageDraw") + + +def _scan_scope(body, out, path): + """Report Draw() bindings in this scope that draw text and set no fontmode.""" + draws, texts, fontmodes = {}, set(), set() + + for node in ast.walk(ast.Module(body=body, type_ignores=[])): + if isinstance(node, ast.Assign) and _is_draw_call(node.value): + for t in node.targets: + if isinstance(t, ast.Name): + draws[t.id] = node.lineno + elif isinstance(node, ast.Assign): + for t in node.targets: + if isinstance(t, ast.Attribute) and t.attr == "fontmode": + if isinstance(t.value, ast.Name): + fontmodes.add(t.value.id) + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + if node.func.attr == "text" and isinstance(node.func.value, ast.Name): + texts.add(node.func.value.id) + + for var, lineno in sorted(draws.items(), key=lambda kv: kv[1]): + if var in texts and var not in fontmodes: + out.append(f"{os.path.relpath(path, ROOT)}:{lineno}: " + f"`{var}` renders text without fontmode") + + +def offenders(): + found = [] + for dirpath, _, names in os.walk(PLUGINS): + for name in sorted(names): + if not name.endswith(".py") or name.startswith("test_"): + continue + if name in SKIP_FILES: + continue + path = os.path.join(dirpath, name) + with open(path, encoding="utf-8", errors="replace") as fh: + src = fh.read() + try: + tree = ast.parse(src) + except SyntaxError: + continue + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + _scan_scope(node.body, found, path) + return found + + +def main(): + bad = offenders() + if bad: + print('[FAIL] Draw() renders text without fontmode = "1":') + for b in bad: + print(f" {b}") + print('\nAdd `.fontmode = "1"` right after the Draw() call.') + return 1 + print('[pass] every text-rendering Draw() sets fontmode = "1"') + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 6913edee5af4e9a591d3d205675e6d308ff03889 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 23:27:26 -0400 Subject: [PATCH 2/2] fix(plugins): catch draws handed to a text-drawing helper Addresses three CodeRabbit findings on #417, all of them real. The first is the one that mattered: ledmatrix-flights had six anti-aliased draws this PR had already claimed to fix. None of its ten Draw sites calls .text() itself -- every renderer hands the Draw to _draw_centered()/_draw(), which do. The first version of this gate matched `.text(` file-wide and caught them; tightening it to same-scope AST matching to cut false positives threw the real findings away and reported the file clean. The runtime probe missed them too, because the harness never renders those flight paths. So the gate now resolves, to a fixpoint, which functions draw text on a parameter, and treats a Draw handed to one of those as text-rendering. That sits between the file-wide regex (130 findings, mostly noise) and same-scope matching (missed real ones): an overlay Draw passed to a compositing helper is still ignored, while _draw_centered(draw, ...) counts. Two smaller gate defects, also reported and also real: * any `.fontmode` assignment satisfied the check, so `fontmode = "L"` -- the anti-aliasing default -- would have passed. Now only the constant "1". * ast.walk() descended into nested scopes and ignored statement order, so a fontmode set *before* its Draw() counted. Now scoped and ordered. Each is mutation-tested: removing a hand-off fontmode, setting it to "L", or moving it above its Draw() each make the gate fail. That found 40 further sites in 13 plugins, including overlay and celebration paths in all eight scoreboards that the harness never renders. Versions are picked above every number claimed by #409 and #412; football takes 3.4.3 so #424 keeps 3.5.0. Merge order: #409, #412, this, then #424. Verified: 246 passed / 2 skipped / 0 failed, 72-card scroll guard passes, all repo gates pass, and the runtime probe still reports 0 anti-aliased text draws across 31 plugins at every panel size. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- plugins.json | 18 +- plugins/afl-scoreboard/game_renderer.py | 2 + plugins/afl-scoreboard/manifest.json | 8 +- plugins/baseball-scoreboard/game_renderer.py | 4 + plugins/baseball-scoreboard/manifest.json | 8 +- .../basketball-scoreboard/game_renderer.py | 2 + plugins/basketball-scoreboard/manifest.json | 8 +- plugins/football-scoreboard/game_renderer.py | 4 + plugins/football-scoreboard/manifest.json | 8 +- plugins/hockey-scoreboard/game_renderer.py | 2 + plugins/hockey-scoreboard/manifest.json | 8 +- plugins/lacrosse-scoreboard/game_renderer.py | 2 + plugins/lacrosse-scoreboard/manifest.json | 8 +- plugins/ledmatrix-flights/manager.py | 1 + plugins/ledmatrix-flights/renderer.py | 6 + plugins/march-madness/manager.py | 2 + .../masters-tournament/masters_renderer.py | 1 + plugins/nrl-scoreboard/game_renderer.py | 2 + plugins/nrl-scoreboard/manifest.json | 8 +- plugins/soccer-scoreboard/game_renderer.py | 2 + plugins/soccer-scoreboard/manifest.json | 8 +- plugins/text-display/manager.py | 2 + plugins/ufc-scoreboard/fight_renderer.py | 3 + plugins/ufc-scoreboard/manifest.json | 8 +- plugins/ufc-scoreboard/sports.py | 5 + scripts/test_pixel_perfect_text.py | 155 ++++++++++++++---- 26 files changed, 237 insertions(+), 48 deletions(-) diff --git a/plugins.json b/plugins.json index f41090da..f47d45d4 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.40.1" + "latest_version": "1.40.2" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.29.1" + "latest_version": "1.29.3" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "3.4.1" + "latest_version": "3.4.3" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.25.1", + "latest_version": "1.25.3", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.24.1", + "latest_version": "1.24.3", "icon": "fas fa-baseball-ball" }, { @@ -760,7 +760,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "2.24.1" + "latest_version": "2.24.3" }, { "id": "static-image", @@ -905,7 +905,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.8.1", + "latest_version": "1.8.2", "icon": "fas fa-fist-raised" }, { @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.22.1", + "latest_version": "1.22.3", "last_updated": "2026-09-02" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.21.1" + "latest_version": "1.21.3" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/game_renderer.py b/plugins/afl-scoreboard/game_renderer.py index f2b1228d..b5cee59a 100644 --- a/plugins/afl-scoreboard/game_renderer.py +++ b/plugins/afl-scoreboard/game_renderer.py @@ -499,6 +499,7 @@ def render_game_card( main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) + draw_overlay.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Calculate score text width first to determine available space for logos home_score = str(game.get("home_score", "0")) @@ -523,6 +524,7 @@ def render_game_card( if not home_logo or not away_logo: # Draw placeholder text if logos fail 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( draw, f"{game.get('away_abbr', '?')}@{game.get('home_abbr', '?')}", diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 63636dcb..203952d7 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.22.1", + "version": "1.22.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "afl_upcoming" ], "versions": [ + { + "released": "2026-09-04", + "version": "1.22.3", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "3.3.0" + }, { "version": "1.22.1", "released": "2026-09-02", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 2720aa56..f300a1b6 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -433,6 +433,7 @@ def _render_live_game(self, game: Dict) -> Image.Image: main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). league = game.get('league', 'mlb') home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) @@ -591,6 +592,7 @@ def _render_recent_game(self, game: Dict) -> Image.Image: main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). league = game.get('league', 'mlb') home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) @@ -769,6 +771,7 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). league = game.get('league', 'mlb') home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) @@ -1098,5 +1101,6 @@ def _render_error_card(self, message: str) -> Image.Image: """Render an error message card.""" img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) draw = ImageDraw.Draw(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(draw, message, (5, 5), self.fonts['status']) return img diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 042f7514..1dd0303a 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.40.1", + "version": "1.40.2", "update_interval": 60, "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", @@ -31,6 +31,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-09-04", + "version": "1.40.2", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "3.3.0" + }, { "version": "1.40.1", "released": "2026-09-02", diff --git a/plugins/basketball-scoreboard/game_renderer.py b/plugins/basketball-scoreboard/game_renderer.py index 8dc0dbef..87d41025 100644 --- a/plugins/basketball-scoreboard/game_renderer.py +++ b/plugins/basketball-scoreboard/game_renderer.py @@ -475,6 +475,7 @@ def render_game_card( main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) + draw_overlay.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Get league for logo directory league = game.get('league', 'nba') @@ -503,6 +504,7 @@ def render_game_card( if not home_logo or not away_logo: # Draw placeholder text if logos fail 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( draw, f"{away_abbr or '?'}@{home_abbr or '?'}", diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 151dec6d..65fbc9c7 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.29.1", + "version": "1.29.3", "update_interval": 60, "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", @@ -19,6 +19,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "released": "2026-09-04", + "version": "1.29.3", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "3.3.0" + }, { "version": "1.29.1", "released": "2026-09-02", diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index 3dbd343b..7c424883 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -780,6 +780,7 @@ def render_game_card( main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) + draw_overlay.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Load logos home_logo = self._load_and_resize_logo( @@ -798,6 +799,7 @@ def render_game_card( if not home_logo or not away_logo: # Draw placeholder text if logos fail 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( draw, f"{game.get('away_abbr', '?')}@{game.get('home_abbr', '?')}", @@ -1212,6 +1214,7 @@ def _render_game_card_adaptive(self, game: Dict[str, Any], main_img = Image.new('RGBA', (width, height), (0, 0, 0, 255)) overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) + draw_overlay.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). regs = self._widen_logo_slots( scoreboard_regions(Region(0, 0, width, height), ctx=self._ctx)) @@ -1221,6 +1224,7 @@ def _render_game_card_adaptive(self, game: Dict[str, Any], home_raw = self._load_raw_logo(game.get("home_abbr", ""), game.get("home_logo_path")) if not away_raw or not home_raw: 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( draw, f"{game.get('away_abbr', '?')}@{game.get('home_abbr', '?')}", diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 2d928979..5859bac0 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.4.1", + "version": "3.4.3", "update_interval": 60, "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", @@ -25,6 +25,12 @@ "ncaa_fb_live" ], "versions": [ + { + "released": "2026-09-04", + "version": "3.4.3", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "3.3.0" + }, { "version": "3.4.1", "released": "2026-09-02", diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index ca633665..9d5456e6 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -538,6 +538,7 @@ def render_game_card( main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) + draw_overlay.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Get league for logo directory league = game.get('league', 'nhl') @@ -623,6 +624,7 @@ def _render_error_card(self, message: str) -> Image.Image: """Render an error message card.""" img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) draw = ImageDraw.Draw(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(draw, message, (5, 5), self.fonts['status']) return img diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index a3f6894a..bd6994ec 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.25.1", + "version": "1.25.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "released": "2026-09-04", + "version": "1.25.3", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "3.3.0" + }, { "version": "1.25.1", "released": "2026-09-02", diff --git a/plugins/lacrosse-scoreboard/game_renderer.py b/plugins/lacrosse-scoreboard/game_renderer.py index 454de92c..d2e9c6f3 100644 --- a/plugins/lacrosse-scoreboard/game_renderer.py +++ b/plugins/lacrosse-scoreboard/game_renderer.py @@ -526,6 +526,7 @@ def render_game_card( main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) + draw_overlay.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Get league for logo directory league = game.get('league', 'ncaa_mens') @@ -609,6 +610,7 @@ def _render_error_card(self, message: str) -> Image.Image: """Render an error message card.""" img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) draw = ImageDraw.Draw(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(draw, message, (5, 5), self.fonts['status']) return img diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index a115a0de..eea6cb18 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.24.1", + "version": "1.24.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,12 @@ } ], "versions": [ + { + "released": "2026-09-04", + "version": "1.24.3", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "3.3.0" + }, { "version": "1.24.1", "released": "2026-09-02", diff --git a/plugins/ledmatrix-flights/manager.py b/plugins/ledmatrix-flights/manager.py index ca513d50..ef81b4c0 100644 --- a/plugins/ledmatrix-flights/manager.py +++ b/plugins/ledmatrix-flights/manager.py @@ -3163,6 +3163,7 @@ def _render_map_image(self) -> Image.Image: img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Draw center position marker (white dot at our lat/lon) center_pixel = self._latlon_to_pixel(self.center_lat, self.center_lon) diff --git a/plugins/ledmatrix-flights/renderer.py b/plugins/ledmatrix-flights/renderer.py index 4e00e0e3..a34c4e7f 100644 --- a/plugins/ledmatrix-flights/renderer.py +++ b/plugins/ledmatrix-flights/renderer.py @@ -353,6 +353,7 @@ def render_banner(self, text="FLIGHTS"): return False img = Image.new("RGB", (self.width, self.height), (0, 0, 0)) draw = ImageDraw.Draw(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_centered(draw, text, (self.height - self._fh(self.font_large)) // 2, self.font_large, self.header_color) self.dm.image = img.copy() @@ -388,6 +389,7 @@ def _render_wide(self, tf) -> None: w, h = self.width, self.height img = Image.new("RGB", (w, h), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). if tf is None: self._draw_centered(draw, "No Flight Data", h // 2 - 4, self.font_medium, self.error_color) @@ -520,6 +522,7 @@ def _render_condensed(self, tf) -> None: w, h = self.width, self.height img = Image.new("RGB", (w, h), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). if tf is None: self._draw_centered(draw, "No Flight Data", h // 2 - 4, self.font_medium, self.error_color) @@ -596,6 +599,7 @@ def _render_area_card_to_image(self, aircraft, index=0, total_count=1, card_widt h = self.height img = Image.new("RGB", (w, h), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). if not aircraft: self._draw_centered(draw, "No Aircraft", h // 2 - 4, self.font_medium, self.dim_color) @@ -766,6 +770,7 @@ def _render_overhead_to_image(self, aircraft, progress=None, delay="", delay_col w, h = self.width, self.height img = Image.new("RGB", (w, h), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). if not aircraft: self._draw_centered(draw, "No Aircraft", (h - self._fh(self.font_medium)) // 2, @@ -971,6 +976,7 @@ def _render_stat_card_to_image(self, title, title_color, aircraft, stat_label, s w, h = self.width, self.height img = Image.new("RGB", (w, h), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). callsign = aircraft.get("callsign", "---") if aircraft else "---" alt = self._fmt_alt(aircraft.get("altitude")) if aircraft else "" diff --git a/plugins/march-madness/manager.py b/plugins/march-madness/manager.py index b329d7f0..a8be5477 100644 --- a/plugins/march-madness/manager.py +++ b/plugins/march-madness/manager.py @@ -544,6 +544,7 @@ def _create_round_separator(self, round_key: str) -> Image.Image: img = Image.new("RGB", (total_w, height), COLOR_DARK_BG) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Draw logos x = padding @@ -619,6 +620,7 @@ def _create_game_tile(self, game: Dict) -> Image.Image: img = Image.new("RGB", (tile_w, height), COLOR_BLACK) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Paste away logo x = h_pad diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index 07926059..ecace4cd 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -469,6 +469,7 @@ def render_leaderboard( img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) draw = ImageDraw.Draw(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_header_bar(img, draw, "LEADERBOARD") diff --git a/plugins/nrl-scoreboard/game_renderer.py b/plugins/nrl-scoreboard/game_renderer.py index 087ef3ec..6b2cbe25 100644 --- a/plugins/nrl-scoreboard/game_renderer.py +++ b/plugins/nrl-scoreboard/game_renderer.py @@ -475,6 +475,7 @@ def render_game_card( main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) + draw_overlay.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Calculate score text width first to determine available space for logos home_score = str(game.get("home_score", "0")) @@ -499,6 +500,7 @@ def render_game_card( if not home_logo or not away_logo: # Draw placeholder text if logos fail 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( draw, f"{game.get('away_abbr', '?')}@{game.get('home_abbr', '?')}", diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index a2b2a297..e861cc6e 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.21.1", + "version": "1.21.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "released": "2026-09-04", + "version": "1.21.3", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "3.3.0" + }, { "version": "1.21.1", "released": "2026-09-02", diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index b9e17205..d4a4a826 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -475,6 +475,7 @@ def render_game_card( main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) + draw_overlay.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Calculate score text width first to determine available space for logos home_score = str(game.get("home_score", "0")) @@ -499,6 +500,7 @@ def render_game_card( if not home_logo or not away_logo: # Draw placeholder text if logos fail 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( draw, f"{game.get('away_abbr', '?')}@{game.get('home_abbr', '?')}", diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 6977cc39..3c0025e1 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.24.1", + "version": "2.24.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,12 @@ "soccer_upcoming" ], "versions": [ + { + "released": "2026-09-04", + "version": "2.24.3", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "3.3.0" + }, { "version": "2.24.1", "released": "2026-09-02", diff --git a/plugins/text-display/manager.py b/plugins/text-display/manager.py index 356ff9ed..b5d4b547 100644 --- a/plugins/text-display/manager.py +++ b/plugins/text-display/manager.py @@ -546,6 +546,7 @@ def display(self, force_clear: bool = False) -> None: # Fallback: static text if cache creation failed img = Image.new('RGB', (matrix_width, matrix_height), self.bg_color) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). bbox = draw.textbbox((0, 0), self.render_text, font=self.font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] @@ -558,6 +559,7 @@ def display(self, force_clear: bool = False) -> None: # Static text (centered) img = Image.new('RGB', (matrix_width, matrix_height), self.bg_color) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). bbox = draw.textbbox((0, 0), self.render_text, font=self.font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] diff --git a/plugins/ufc-scoreboard/fight_renderer.py b/plugins/ufc-scoreboard/fight_renderer.py index 0037b85f..eb9db8f5 100644 --- a/plugins/ufc-scoreboard/fight_renderer.py +++ b/plugins/ufc-scoreboard/fight_renderer.py @@ -280,6 +280,7 @@ def _render_live_fight( main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Load headshots fighter1_img = self._load_headshot( @@ -348,6 +349,7 @@ def _render_recent_fight( main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Load headshots fighter1_img = self._load_headshot( @@ -411,6 +413,7 @@ def _render_upcoming_fight( main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). # Load headshots fighter1_img = self._load_headshot( diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 944f345c..e72c1772 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.8.1", + "version": "1.8.2", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,12 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "released": "2026-09-04", + "version": "1.8.2", + "changelog": "Render text 1-bit (no anti-aliasing) so glyphs stay crisp on the LED grid", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.8.1", "released": "2026-09-02", diff --git a/plugins/ufc-scoreboard/sports.py b/plugins/ufc-scoreboard/sports.py index eafcabc4..21c2c83b 100644 --- a/plugins/ufc-scoreboard/sports.py +++ b/plugins/ufc-scoreboard/sports.py @@ -348,6 +348,7 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: try: img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) draw = ImageDraw.Draw(img) + draw.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). status = game.get("status_text", "N/A") self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) self.display_manager.image.paste(img, (0, 0)) @@ -2098,6 +2099,7 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: "RGBA", (display_width, display_height), (0, 0, 0, 0) ) draw_overlay = ImageDraw.Draw(overlay) + draw_overlay.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). home_logo = self._load_and_resize_logo( game["home_id"], @@ -2117,6 +2119,7 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: f"Failed to load logos for game: {game.get('id')}" ) # Changed log prefix draw_final = ImageDraw.Draw(main_img.convert("RGB")) + draw_final.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( draw_final, "Logo Error", (5, 5), self.fonts["status"] ) @@ -2695,6 +2698,7 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: "RGBA", (display_width, display_height), (0, 0, 0, 0) ) draw_overlay = ImageDraw.Draw(overlay) + draw_overlay.fontmode = "1" # Pixel fonts on an LED panel: 1-bit text so every lit pixel is fully lit (no AA fringe). home_logo = self._load_and_resize_logo( game["home_id"], @@ -2715,6 +2719,7 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: ) # Changed log prefix # Draw placeholder text if logos fail (similar to live) draw_final = ImageDraw.Draw(main_img.convert("RGB")) + draw_final.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( draw_final, "Logo Error", (5, 5), self.fonts["status"] ) diff --git a/scripts/test_pixel_perfect_text.py b/scripts/test_pixel_perfect_text.py index 595a03f2..540d2b85 100755 --- a/scripts/test_pixel_perfect_text.py +++ b/scripts/test_pixel_perfect_text.py @@ -7,16 +7,26 @@ Whether it currently *shows* depends on the size: 4x6-font.ttf at 6 is 74% partial pixels, while PressStart2P at its native 8 is clean by luck. Font sizes -are user-configurable, so "clean today" is not worth relying on -- the setting +are user settings, so "clean today" is not worth relying on -- the setting belongs on every draw that renders text to the panel. Checked statically, because the plugins that most need it (mqtt-notifications, nfl-draft, on-air, static-image) only render with live data the harness has none of; a render-based check would silently skip exactly those. -Scoped with the AST rather than a file-wide regex: a Draw() is reported only -when *that* binding is used for .text() in the same function. A file-wide match -flags every unrelated `draw` that merely shares the name. +Scoping, and why it is not simply "same function": + + A file-wide regex flags every unrelated `draw` that merely shares the name. + But requiring `.text()` in the *same* scope is worse: it misses the common + shape where a renderer builds the Draw and hands it to a helper -- + ledmatrix-flights has ten Draw sites and not one of them calls .text() + itself, they all pass `draw` to _draw_centered()/_draw(). Six were + anti-aliased and an earlier same-scope version of this gate reported the + file clean. + + So a binding counts as text-rendering if it either draws text itself or is + passed to something that might. Both directions matter; only checking one + produced a green gate over a real defect. """ import ast import os @@ -35,6 +45,8 @@ "render_readme_assets.py", } +NESTED = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef) + def _is_draw_call(node): return (isinstance(node, ast.Call) @@ -44,28 +56,111 @@ def _is_draw_call(node): and node.func.value.id == "ImageDraw") -def _scan_scope(body, out, path): - """Report Draw() bindings in this scope that draw text and set no fontmode.""" - draws, texts, fontmodes = {}, set(), set() - - for node in ast.walk(ast.Module(body=body, type_ignores=[])): - if isinstance(node, ast.Assign) and _is_draw_call(node.value): - for t in node.targets: - if isinstance(t, ast.Name): - draws[t.id] = node.lineno - elif isinstance(node, ast.Assign): - for t in node.targets: - if isinstance(t, ast.Attribute) and t.attr == "fontmode": - if isinstance(t.value, ast.Name): - fontmodes.add(t.value.id) - elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): - if node.func.attr == "text" and isinstance(node.func.value, ast.Name): - texts.add(node.func.value.id) - - for var, lineno in sorted(draws.items(), key=lambda kv: kv[1]): - if var in texts and var not in fontmodes: - out.append(f"{os.path.relpath(path, ROOT)}:{lineno}: " - f"`{var}` renders text without fontmode") +def _walk_scope(nodes): + """Yield every node in this scope, without descending into nested scopes.""" + stack = list(nodes) + while stack: + node = stack.pop() + yield node + for child in ast.iter_child_nodes(node): + if not isinstance(child, NESTED): + stack.append(child) + + +def _text_drawing_helpers(tree): + """Names of functions in this module that draw text on a parameter. + + ledmatrix-flights' renderers never call .text() themselves -- they hand the + Draw to _draw_centered()/_draw(), which do. Resolving that is the difference + between catching those six anti-aliased sites and reporting the file clean. + + Iterated to a fixpoint so a helper that only forwards to another helper + (_draw_centered -> _truncate -> draw.text) still counts. + """ + funcs = {} + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + params = {a.arg for a in node.args.args} | {a.arg for a in node.args.kwonlyargs} + funcs[node.name] = (node, params) + + drawing = set() + changed = True + while changed: + changed = False + for name, (node, params) in funcs.items(): + if name in drawing: + continue + hit = False + for sub in ast.walk(node): + if not isinstance(sub, ast.Call) or not isinstance(sub.func, ast.Attribute): + continue + recv = sub.func.value + # param.text(...) + if (sub.func.attr == "text" and isinstance(recv, ast.Name) + and recv.id in params): + hit = True + break + # forwarded to a helper already known to draw text + if sub.func.attr in drawing: + args = list(sub.args) + [kw.value for kw in sub.keywords] + if any(isinstance(a, ast.Name) and a.id in params for a in args): + hit = True + break + if hit: + drawing.add(name) + changed = True + return drawing + + +def _scan_scope(body, out, path, helpers=frozenset()): + draws = [] # (name, lineno) in source order + texts = set() # names that call .text() + handed_off = set() # names passed to another call -- the callee may draw text + crisp = [] # (name, lineno) where fontmode is set to exactly "1" + + for node in _walk_scope(body): + if isinstance(node, ast.Assign): + for tgt in node.targets: + if isinstance(tgt, ast.Name) and _is_draw_call(node.value): + draws.append((tgt.id, node.lineno)) + # Only `fontmode = "1"` counts. Recording any assignment would + # let `fontmode = "L"` satisfy the gate. + elif (isinstance(tgt, ast.Attribute) and tgt.attr == "fontmode" + and isinstance(tgt.value, ast.Name) + and isinstance(node.value, ast.Constant) + and node.value.value == "1"): + crisp.append((tgt.value.id, node.lineno)) + elif isinstance(node, ast.Call): + if isinstance(node.func, ast.Attribute): + if (node.func.attr == "text" + and isinstance(node.func.value, ast.Name)): + texts.add(node.func.value.id) + if node.func.attr in helpers: + for arg in list(node.args) + [kw.value for kw in node.keywords]: + if isinstance(arg, ast.Name): + handed_off.add(arg.id) + elif isinstance(node.func, ast.Name) and node.func.id in helpers: + for arg in list(node.args) + [kw.value for kw in node.keywords]: + if isinstance(arg, ast.Name): + handed_off.add(arg.id) + + for name, lineno in sorted(draws, key=lambda d: d[1]): + if name not in texts and name not in handed_off: + continue # this Draw never renders text + # The setting must come after this binding, or it configured the + # previous object bound to the same name. + if any(cname == name and cline > lineno for cname, cline in crisp): + continue + rel = os.path.relpath(path, ROOT) + out.append(f"{rel}:{lineno}: `{name}` renders text without fontmode = \"1\"") + + +def _scopes(tree): + """Every function body in the module, plus module level.""" + yield tree.body + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + yield node.body def offenders(): @@ -83,10 +178,10 @@ def offenders(): tree = ast.parse(src) except SyntaxError: continue - for node in ast.walk(tree): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - _scan_scope(node.body, found, path) - return found + helpers = _text_drawing_helpers(tree) + for body in _scopes(tree): + _scan_scope(body, found, path, helpers) + return sorted(set(found)) def main():