From 91d15a8943b409c3ed10f0a127ebf651b78b4213 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:02:04 -0400 Subject: [PATCH] fix(display): draw text 1-bit, so glyphs stay crisp on the LED grid (#521) 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. DisplayManager creates its shared `draw` in six places and set fontmode at none of them, while _load_fonts loads extra_small_font as 4x6-font.ttf at size 6. Measured at draw time, that face at that size puts 74% of its lit pixels at partial coverage. Every plugin drawing small text through the shared draw inherited the blur; geochron was the case that surfaced it. The harness's VisualDisplayManager had the same gap, which mattered more than it looks: goldens were recording anti-aliased text that production would not produce, so the harness could not have caught this. Fixing only production left geochron still blurry under the harness -- that is how the second site was found. Both are set to "1" so the harness renders what the panel renders. Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 Co-authored-by: Claude Opus 5 (1M context) --- src/display_manager.py | 6 ++++++ src/plugin_system/testing/visual_display_manager.py | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/display_manager.py b/src/display_manager.py index 9cc7f622..e578ad7c 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -364,6 +364,7 @@ class DisplayManager: # Create image with the (logical) display dimensions self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}") # Initialize font with Press Start 2P @@ -403,6 +404,7 @@ class DisplayManager: self.image = Image.new('RGB', (fallback_width, fallback_height)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. # Simple fallback visualization so web UI shows a realistic canvas try: self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0)) @@ -705,6 +707,7 @@ class DisplayManager: # self.image, so swapping the buffer below is enough on its own. self.image = Image.new('RGB', (target_w, target_h)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. yield finally: self.matrix = real_matrix @@ -814,6 +817,7 @@ class DisplayManager: self.image = Image.new('RGB', (width, height)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. logger.debug("Cleared display in fallback mode") return @@ -825,6 +829,7 @@ class DisplayManager: # Create a new black image self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. if not self._capture_mode_active: # Clear both canvases and the underlying matrix to ensure no artifacts. @@ -1258,6 +1263,7 @@ class DisplayManager: try: self.image = Image.new('RGB', (self.width, self.height)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. except (OSError, RuntimeError, ValueError, MemoryError): logger.debug("Canvas reset during cleanup failed", exc_info=True) # Reset the singleton state when cleaning up diff --git a/src/plugin_system/testing/visual_display_manager.py b/src/plugin_system/testing/visual_display_manager.py index 5211a226..e33d2309 100644 --- a/src/plugin_system/testing/visual_display_manager.py +++ b/src/plugin_system/testing/visual_display_manager.py @@ -70,6 +70,7 @@ class VisualTestDisplayManager: # Canvas self.image = Image.new('RGB', (width, height), (0, 0, 0)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows. # Matrix proxy (plugins access display_manager.matrix.width/height) self.matrix = _MatrixProxy(width, height) @@ -184,6 +185,7 @@ class VisualTestDisplayManager: self.clear_called = True self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows. def update_display(self): """No-op for hardware; marks that display was updated.""" @@ -211,6 +213,7 @@ class VisualTestDisplayManager: self.matrix = _MatrixProxy(target_w, target_h) self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows. yield finally: self._width, self._height = prev_w, prev_h @@ -569,6 +572,7 @@ class VisualTestDisplayManager: self.draw_calls = [] self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows. self._scrolling_state = { 'is_scrolling': False, 'last_scroll_activity': 0, @@ -582,3 +586,4 @@ class VisualTestDisplayManager: """Clean up resources.""" self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.