From 1168c3230929ce3139c8bfe68366eede297535b9 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Thu, 3 Sep 2026 19:54:47 -0400 Subject: [PATCH] fix(display): 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. 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. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- 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 @@ def _setup_matrix(self): # 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 @@ def _setup_matrix(self): 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 @@ def render_size(self, width: int, height: Optional[int] = None): # 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 @@ def clear(self): 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 @@ def clear(self): # 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 @@ def cleanup(self): 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 @@ def __init__(self, width: int = 128, height: int = 32): # 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 @@ def clear(self): 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 @@ def render_size(self, width: int, height: Optional[int] = None): 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 @@ def reset(self): 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 @@ def cleanup(self): """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.