diff --git a/plugins.json b/plugins.json index b1c3876a..e08d8253 100644 --- a/plugins.json +++ b/plugins.json @@ -633,7 +633,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.1.10" + "latest_version": "1.2.0" }, { "id": "of-the-day", @@ -806,10 +806,10 @@ "plugin_path": "plugins/stock-news", "stars": 0, "downloads": 0, - "last_updated": "2026-07-17", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.4.6" + "latest_version": "2.5.0" }, { "id": "stocks", @@ -829,10 +829,10 @@ "plugin_path": "plugins/ledmatrix-stocks", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.6.0", + "latest_version": "2.7.0", "icon": "fas fa-chart-line" }, { diff --git a/plugins/ledmatrix-stocks/chart_renderer.py b/plugins/ledmatrix-stocks/chart_renderer.py index b0dc2602..3cf7fb68 100644 --- a/plugins/ledmatrix-stocks/chart_renderer.py +++ b/plugins/ledmatrix-stocks/chart_renderer.py @@ -13,6 +13,21 @@ from src.common import TextHelper +def _pixel_draw(image): + """ImageDraw that renders text crisply on the LED grid. + + PIL anti-aliases by default, blending glyph edges into dim partial-lit + pixels. On a 1:1 LED matrix those read as blur rather than smoothing, so + every draw surface here -- including scratch canvases used only to measure + text -- sets fontmode "1" for 1-bit glyph rendering. Measuring through the + same helper keeps metrics and rendering in agreement. + """ + draw = ImageDraw.Draw(image) + draw.fontmode = "1" + return draw + + + class StockChartRenderer: """Handles rendering of stock and cryptocurrency charts.""" @@ -67,7 +82,7 @@ def _create_chart_image(self, symbol: str, data: Dict[str, Any]) -> Optional[Ima try: # Create image image = Image.new('RGB', (self.display_width, self.display_height), self.chart_colors['background']) - draw = ImageDraw.Draw(image) + draw = _pixel_draw(image) # Get price history price_history = data['price_history'] diff --git a/plugins/ledmatrix-stocks/display_renderer.py b/plugins/ledmatrix-stocks/display_renderer.py index 77f5d636..56708af1 100644 --- a/plugins/ledmatrix-stocks/display_renderer.py +++ b/plugins/ledmatrix-stocks/display_renderer.py @@ -13,6 +13,21 @@ from src.common import ScrollHelper, LogoHelper, TextHelper +def _pixel_draw(image): + """ImageDraw that renders text crisply on the LED grid. + + PIL anti-aliases by default, blending glyph edges into dim partial-lit + pixels. On a 1:1 LED matrix those read as blur rather than smoothing, so + every draw surface here -- including scratch canvases used only to measure + text -- sets fontmode "1" for 1-bit glyph rendering. Measuring through the + same helper keeps metrics and rendering in agreement. + """ + draw = ImageDraw.Draw(image) + draw.fontmode = "1" + return draw + + + class StockDisplayRenderer: """Handles rendering of stock and cryptocurrency displays.""" @@ -232,7 +247,7 @@ def create_stock_display(self, symbol: str, data: Dict[str, Any]) -> Image.Image # Measure the text on a scratch canvas so the real canvas can be sized to # the content (rather than a display-scaled guess that then needs clamping). - mdraw = ImageDraw.Draw(Image.new('RGB', (1, 1))) + mdraw = _pixel_draw(Image.new('RGB', (1, 1))) symbol_bbox = mdraw.textbbox((0, 0), symbol_text, font=symbol_font) price_bbox = mdraw.textbbox((0, 0), price_text, font=price_font) if change_text: @@ -270,7 +285,7 @@ def create_stock_display(self, symbol: str, data: Dict[str, Any]) -> Image.Image # Real canvas, sized to the content image = Image.new('RGB', (width, height), (0, 0, 0)) - draw = ImageDraw.Draw(image) + draw = _pixel_draw(image) # Draw the logo on the left, vertically centered if logo: @@ -312,7 +327,7 @@ def create_static_display(self, symbol: str, data: Dict[str, Any]) -> Image.Imag """Create a static display for one stock/crypto (no scrolling).""" # Ensure dimensions are integers image = Image.new('RGB', (int(self.display_width), int(self.display_height)), (0, 0, 0)) - draw = ImageDraw.Draw(image) + draw = _pixel_draw(image) is_crypto = data.get('is_crypto', False) @@ -512,7 +527,7 @@ def _create_error_display(self) -> Image.Image: """Create an error display when no data is available.""" # Ensure dimensions are integers image = Image.new('RGB', (int(self.display_width), int(self.display_height)), (0, 0, 0)) - draw = ImageDraw.Draw(image) + draw = _pixel_draw(image) # Use symbol font for error display error_font = self.symbol_font diff --git a/plugins/ledmatrix-stocks/manifest.json b/plugins/ledmatrix-stocks/manifest.json index d526e912..1087b42c 100644 --- a/plugins/ledmatrix-stocks/manifest.json +++ b/plugins/ledmatrix-stocks/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-stocks", "name": "Stock & Crypto Ticker", - "version": "2.6.0", + "version": "2.7.0", "description": "Displays stock tickers with prices, changes, and optional charts for stocks and cryptocurrencies. Supports scroll and switch display modes.", "author": "LEDMatrix Team", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/ledmatrix-stocks", @@ -39,6 +39,12 @@ } ], "versions": [ + { + "version": "2.7.0", + "released": "2026-08-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Pixel-perfect text: all draw surfaces in the display and chart renderers now render with anti-aliasing off, so custom font sizes stay hard-edged on the LED grid. Default sizes were already on the font's pixel grid, so the default look is unchanged." + }, { "version": "2.6.0", "released": "2026-07-31", @@ -104,7 +110,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/odds-ticker/manager.py b/plugins/odds-ticker/manager.py index ab396e9f..fced3108 100644 --- a/plugins/odds-ticker/manager.py +++ b/plugins/odds-ticker/manager.py @@ -36,6 +36,22 @@ from pathlib import Path import numpy as np + +def _pixel_draw(image): + """ImageDraw that renders text crisply on the LED grid. + + PIL anti-aliases by default, blending glyph edges into dim partial-lit + pixels. On a 1:1 LED matrix those read as blur rather than smoothing. The + ticker's default element sizes are on Press Start 2P's 8px grid and so were + already crisp, but any size a user picks in the customization UI was not -- + fontmode "1" makes 1-bit rendering unconditional. Scratch canvases used only + to measure text go through here too, so metrics match what gets drawn. + """ + draw = ImageDraw.Draw(image) + draw.fontmode = "1" + return draw + + # Import will be handled by the plugin system try: from src.plugin_system.base_plugin import BasePlugin @@ -1644,8 +1660,11 @@ def _create_game_display(self, game: Dict[str, Any]) -> Image.Image: width = self.display_manager.matrix.width height = self.display_manager.matrix.height - # Make logos use most of the display height, with a small margin - logo_size = int(height * 1.2) + # Fit logos inside the panel. This used to be int(height * 1.2), which + # with the (height - logo_size) // 2 centering below resolved to a + # negative y -- so the top and bottom of every team logo was cropped off + # the panel. + logo_size = height h_padding = 4 # Use a consistent horizontal padding # Fonts - use custom fonts from config @@ -1822,7 +1841,7 @@ def _create_game_display(self, game: Dict[str, Any]) -> Image.Image: time_text = "TBD" # Datetime column width - temp_draw = ImageDraw.Draw(Image.new('RGB', (1, 1))) + temp_draw = _pixel_draw(Image.new('RGB', (1, 1))) day_width = int(temp_draw.textlength(day_text, font=datetime_font)) date_width = int(temp_draw.textlength(date_text, font=datetime_font)) time_width = int(temp_draw.textlength(time_text, font=datetime_font)) @@ -2032,7 +2051,7 @@ def _create_game_display(self, game: Dict[str, Any]) -> Image.Image: # --- Create final image --- image = Image.new('RGB', (int(total_width), height), color=(0, 0, 0)) - draw = ImageDraw.Draw(image) + draw = _pixel_draw(image) # --- Draw elements --- current_x = 0 @@ -2202,7 +2221,7 @@ def _create_ticker_image(self): if idx < len(game_images) - 1: bar_x = current_x + gap_width // 2 # Use ImageDraw for more efficient drawing - draw = ImageDraw.Draw(self.ticker_image) + draw = _pixel_draw(self.ticker_image) draw.line([(bar_x, 0), (bar_x, height - 1)], fill=(255, 255, 255), width=1) current_x += gap_width @@ -2804,7 +2823,7 @@ def _display_fallback_message(self): # Create a simple fallback image with a brighter background image = Image.new('RGB', (width, height), color=(50, 50, 50)) # Dark gray instead of black - draw = ImageDraw.Draw(image) + draw = _pixel_draw(image) # Draw a simple message with larger font message = "No odds data" @@ -2820,7 +2839,7 @@ def _display_fallback_message(self): # Display the fallback image self.display_manager.image = image - self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) + self.display_manager.draw = _pixel_draw(self.display_manager.image) self.display_manager.update_display() logger.info("Fallback message display completed") diff --git a/plugins/odds-ticker/manifest.json b/plugins/odds-ticker/manifest.json index f2a41056..dd27fd79 100644 --- a/plugins/odds-ticker/manifest.json +++ b/plugins/odds-ticker/manifest.json @@ -1,7 +1,7 @@ { "id": "odds-ticker", "name": "Odds Ticker", - "version": "1.1.10", + "version": "1.2.0", "description": "Displays scrolling odds and betting lines for upcoming games across multiple sports leagues including NFL, NBA, MLB, NCAA Football, and more", "author": "ChuckBuilds", "category": "sports", @@ -20,6 +20,12 @@ "branch": "main", "plugin_path": "plugins/odds-ticker", "versions": [ + { + "version": "1.2.0", + "released": "2026-08-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Team logos now fit inside the panel instead of being scaled to 120% of the display height and cropped top and bottom. Text also renders with anti-aliasing off across all draw surfaces, so custom font sizes stay hard-edged; the default sizes were already on the font's pixel grid." + }, { "version": "1.1.10", "released": "2026-08-05", @@ -40,7 +46,7 @@ { "released": "2026-07-18", "version": "1.1.7", - "notes": "Fix config save failing with a 400 validation error. The per-league favorite-team pickers hardcoded several wrong team abbreviations that didn't match ESPN's (Rams LA\u2192LAR, White Sox CWS\u2192CHW, A's OAK\u2192ATH, Warriors GSW\u2192GS, Pelicans NOP\u2192NO, Knicks NYK\u2192NY, Spurs SAS\u2192SA, Jazz UTA\u2192UTAH, Wizards WAS\u2192WSH, NHL Kings LAK\u2192LA, Capitals WAS\u2192WSH), so valid favorites were rejected on save and never matched games. Also removed the overly strict regex on the NCAA/MiLB free-text team fields so documented values like AP_TOP_25 are accepted.", + "notes": "Fix config save failing with a 400 validation error. The per-league favorite-team pickers hardcoded several wrong team abbreviations that didn't match ESPN's (Rams LA→LAR, White Sox CWS→CHW, A's OAK→ATH, Warriors GSW→GS, Pelicans NOP→NO, Knicks NYK→NY, Spurs SAS→SA, Jazz UTA→UTAH, Wizards WAS→WSH, NHL Kings LAK→LA, Capitals WAS→WSH), so valid favorites were rejected on save and never matched games. Also removed the overly strict regex on the NCAA/MiLB free-text team fields so documented values like AP_TOP_25 are accepted.", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/odds-ticker/odds_renderer.py b/plugins/odds-ticker/odds_renderer.py index f54fb2f2..be9f7c10 100644 --- a/plugins/odds-ticker/odds_renderer.py +++ b/plugins/odds-ticker/odds_renderer.py @@ -19,6 +19,22 @@ from PIL import Image, ImageDraw, ImageFont from pathlib import Path + +def _pixel_draw(image): + """ImageDraw that renders text crisply on the LED grid. + + PIL anti-aliases by default, blending glyph edges into dim partial-lit + pixels. On a 1:1 LED matrix those read as blur rather than smoothing. The + ticker's default element sizes are on Press Start 2P's 8px grid and so were + already crisp, but any size a user picks in the customization UI was not -- + fontmode "1" makes 1-bit rendering unconditional. Scratch canvases used only + to measure text go through here too, so metrics match what gets drawn. + """ + draw = ImageDraw.Draw(image) + draw.fontmode = "1" + return draw + + logger = logging.getLogger(__name__) @@ -253,8 +269,11 @@ def _create_game_display(self, game: Dict) -> Image.Image: width = self.display_manager.matrix.width height = self.display_manager.matrix.height - # Make logos use most of the display height, with a small margin - logo_size = int(height * 1.2) + # Fit logos inside the panel. This used to be int(height * 1.2), which + # with the (height - logo_size) // 2 centering below resolved to a + # negative y -- so the top and bottom of every team logo was cropped off + # the panel. + logo_size = height h_padding = 4 # Use a consistent horizontal padding # Fonts - use custom fonts from config @@ -331,7 +350,7 @@ def _create_game_display(self, game: Dict) -> Image.Image: time_text = "TBD" # Calculate column widths (like original) - temp_draw = ImageDraw.Draw(Image.new('RGB', (1, 1))) + temp_draw = _pixel_draw(Image.new('RGB', (1, 1))) day_width = int(temp_draw.textlength(day_text, font=datetime_font)) date_width = int(temp_draw.textlength(date_text, font=datetime_font)) time_width = int(temp_draw.textlength(time_text, font=datetime_font)) @@ -385,7 +404,7 @@ def _create_game_display(self, game: Dict) -> Image.Image: # Create the image image = Image.new('RGB', (int(total_width), height), color=(0, 0, 0)) - draw = ImageDraw.Draw(image) + draw = _pixel_draw(image) # --- Draw elements (exactly like original) --- current_x = 0 @@ -563,7 +582,7 @@ def _create_no_data_image(self) -> Image.Image: matrix_height = self.display_manager.matrix.height img = Image.new('RGB', (matrix_width, matrix_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) + draw = _pixel_draw(img) draw.text((10, 12), "No Odds Available", font=self.team_font, fill=(150, 150, 150)) @@ -579,7 +598,7 @@ def _display_fallback_message(self): # Create a simple fallback image with a brighter background image = Image.new('RGB', (width, height), color=(50, 50, 50)) # Dark gray instead of black - draw = ImageDraw.Draw(image) + draw = _pixel_draw(image) # Draw a simple message with larger font message = "No odds data" @@ -596,7 +615,7 @@ def _display_fallback_message(self): # Display the fallback image self.display_manager.image = image if hasattr(self.display_manager, 'draw'): - self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) + self.display_manager.draw = _pixel_draw(self.display_manager.image) self.display_manager.update_display() except Exception as e: @@ -627,7 +646,7 @@ def _create_error_image(self, message: str) -> Image.Image: matrix_height = self.display_manager.matrix.height img = Image.new('RGB', (matrix_width, matrix_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) + draw = _pixel_draw(img) draw.text((10, 12), message, font=self.team_font, fill=(255, 0, 0)) @@ -760,7 +779,7 @@ def render_scrolling_ticker(self, ticker_image: Image.Image) -> bool: # Display the cropped image self.display_manager.image = visible_image if hasattr(self.display_manager, 'draw'): - self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) + self.display_manager.draw = _pixel_draw(self.display_manager.image) # Add timeout protection for display update to prevent hanging try: diff --git a/plugins/stock-news/manager.py b/plugins/stock-news/manager.py index f72c83a0..1c2066b1 100644 --- a/plugins/stock-news/manager.py +++ b/plugins/stock-news/manager.py @@ -136,9 +136,19 @@ def _apply_config(self) -> None: gc = self.global_config fc = self.feeds_config - # Auto font size: 0 or absent → derive from display height + # Auto font size: 0 or absent → derive from display height, snapped to + # the pixel font's 8px design grid. height//3 lands on 10 for the common + # 32px panel, and an off-grid size splits single design pixels across + # screen pixels, so strokes come out alternating 1px and 2px wide. An + # explicit font_size is honoured as typed -- fontmode still keeps it + # hard-edged, it just may have that uneven stroke width. raw_fs = gc.get('font_size', 0) - self.font_size = int(raw_fs) if raw_fs and int(raw_fs) > 0 else max(6, min(16, self.display_height // 3)) + if raw_fs and int(raw_fs) > 0: + self.font_size = int(raw_fs) + else: + self.font_size = self._snap_to_font_grid( + max(6, min(16, self.display_height // 3)) + ) # Item gap: 0 → use full display width (clean separation between stories) raw_gap = gc.get('item_gap', 0) @@ -231,6 +241,38 @@ def _apply_config(self) -> None: # Font / scroll # ------------------------------------------------------------------------- + #: Press Start 2P draws on an 8px grid; the 4x6 fallback on a 7px one. + PIXEL_FONT_GRIDS = {'PressStart2P-Regular.ttf': 8, '4x6-font.ttf': 7} + + def _snap_to_font_grid(self, size: int) -> int: + """Round an auto-derived size to the configured font's pixel grid.""" + font_name = Path( + self.global_config.get('font_path', 'assets/fonts/PressStart2P-Regular.ttf') + ).name + grid = self.PIXEL_FONT_GRIDS.get(font_name, 0) + if grid <= 0: + return size + return max(grid, int(round(size / grid)) * grid) + + @staticmethod + def _pixel_draw(image: Image.Image) -> ImageDraw.ImageDraw: + """ + Build an ImageDraw that renders text crisply on the LED grid. + + PIL anti-aliases by default, blending glyph edges into dim partial-lit + pixels. On a 1:1 LED matrix those read as blur rather than smoothing -- + at the size this ticker auto-picks for a 32px panel, better than half of + the lit pixels were partial. ``fontmode = "1"`` switches FreeType to + 1-bit rendering so every pixel is fully on or fully off. + + Every draw surface in this plugin -- including the throwaway ones used + only to measure text -- goes through here, so measurement and rendering + can never disagree about glyph metrics. + """ + draw = ImageDraw.Draw(image) + draw.fontmode = "1" + return draw + def _load_font(self, path: str, size: int) -> Optional[ImageFont.FreeTypeFont]: """Two-level fallback: given TTF → 4x6 bitmap. Returns None if neither loads.""" for p in [path, 'assets/fonts/4x6-font.ttf']: @@ -867,7 +909,7 @@ def _render_news_item(self, news_item: dict) -> Optional[Image.Image]: segments.append((" • " + age, age_c, font_age)) # Measure segments - draw_tmp = ImageDraw.Draw(Image.new('RGB', (1, 1))) + draw_tmp = self._pixel_draw(Image.new('RGB', (1, 1))) widths: List[int] = [] text_h = 1 for text, _, font in segments: @@ -882,7 +924,7 @@ def _render_news_item(self, news_item: dict) -> Optional[Image.Image]: total_w = max(logo_w + sum(widths), 1) img = Image.new('RGB', (total_w, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) + draw = self._pixel_draw(img) text_y = max(0, (self.display_height - text_h) // 2) x = 0 @@ -942,7 +984,7 @@ def get_vegas_content_type(self) -> str: def _display_no_news(self) -> None: img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) + draw = self._pixel_draw(img) text = "No Stock News" font = self._fit_fallback_font(draw, text) try: @@ -983,7 +1025,7 @@ def _fit_fallback_font(self, draw: ImageDraw.ImageDraw, text: str) -> Optional[I def _display_error(self, message: str) -> None: img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) + draw = self._pixel_draw(img) draw.text((4, self.display_height // 2 - 4), message, fill=(255, 0, 0)) self.display_manager.image = img.copy() self.display_manager.update_display() diff --git a/plugins/stock-news/manifest.json b/plugins/stock-news/manifest.json index ddaf6581..01d27464 100644 --- a/plugins/stock-news/manifest.json +++ b/plugins/stock-news/manifest.json @@ -1,7 +1,7 @@ { "id": "stock-news", "name": "Stock News Ticker", - "version": "2.4.6", + "version": "2.5.0", "author": "ChuckBuilds", "description": "Live stock headlines via Yahoo Finance search API with RSS fallback, company logos, configurable display styles (logo+ticker, ticker only, logo only), Vegas scroll integration, and per-day/hour request budgeting", "entry_point": "manager.py", @@ -25,6 +25,12 @@ "branch": "main", "plugin_path": "plugins/stock-news", "versions": [ + { + "version": "2.5.0", + "released": "2026-08-05", + "ledmatrix_min_version": "2.0.0", + "notes": "Pixel-perfect text: every draw surface, including the scratch canvases used only to measure text, now renders with anti-aliasing off. The auto font size is also snapped to the pixel font's 8px grid - it previously resolved to 10 on a 32px panel, where over half the lit pixels were partial-lit greys. An explicit font_size is unchanged." + }, { "released": "2026-07-17", "version": "2.4.6", @@ -142,7 +148,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-17", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true,