Files
LEDMatrix/src/plugin_system/testing/visual_display_manager.py
T
ChuckandClaude Opus 5 91d15a8943 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) <noreply@anthropic.com>
2026-09-04 16:02:04 -04:00

590 lines
22 KiB
Python

"""
Visual Test Display Manager for LEDMatrix.
A display manager that performs real pixel rendering using PIL,
without requiring hardware or the RGBMatrixEmulator. Used for:
- Local dev preview server
- CLI render script (AI visual feedback)
- Visual assertions in pytest
Unlike MockDisplayManager (which logs calls but doesn't render) or
MagicMock (which tracks nothing visual), this class creates a real
PIL Image canvas and draws text using the actual project fonts.
MAINTENANCE WARNING: this class is a deliberate fork of
src/display_manager.py so it can run without hardware. It mirrors
these DisplayManager methods by name and behavior: _load_fonts,
_draw_bdf_text, get_font_height, get_text_width, draw_text,
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
capture_mode, set_scrolling_state, is_currently_scrolling,
process_deferred_updates, update_display, render_size. A behavior
change to any of those in DisplayManager must be mirrored here, or
plugin visual tests will pass against stale behavior.
"""
import math
import os
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
from src.logging_config import get_logger
logger = get_logger(__name__)
class _MatrixProxy:
"""Lightweight proxy so plugins can access display_manager.matrix.width/height."""
def __init__(self, width: int, height: int):
self.width = width
self.height = height
class VisualTestDisplayManager:
"""
Display manager that renders real pixels for testing and development.
Implements the same interface that plugins expect from DisplayManager,
but operates entirely in-memory with PIL — no hardware, no singleton,
no emulator dependency.
"""
# Weather icon color constants (same as DisplayManager)
WEATHER_COLORS = {
'sun': (255, 200, 0),
'cloud': (200, 200, 200),
'rain': (0, 100, 255),
'snow': (220, 220, 255),
'storm': (255, 255, 0),
}
def __init__(self, width: int = 128, height: int = 32):
self._width = width
self._height = height
# 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)
# Set while inside capture_mode(); mirrors DisplayManager's flag.
self._capture_mode_active = False
# Scrolling state (interface compat, no-op)
self._scrolling_state = {
'is_scrolling': False,
'last_scroll_activity': 0,
'scroll_inactivity_threshold': 2.0,
'deferred_updates': [],
'max_deferred_updates': 50,
'deferred_update_ttl': 300.0,
}
# Call tracking (preserves MockDisplayManager capabilities)
self.clear_called = False
self.update_called = False
self.draw_calls = []
# Load fonts
self._load_fonts()
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def width(self) -> int:
return self.image.width
@property
def height(self) -> int:
return self.image.height
@property
def display_width(self) -> int:
return self.image.width
@property
def display_height(self) -> int:
return self.image.height
# ------------------------------------------------------------------
# Font loading
# ------------------------------------------------------------------
def _find_project_root(self) -> Optional[Path]:
"""Walk up from this file to find the project root (contains assets/fonts)."""
current = Path(__file__).resolve().parent
for _ in range(10):
if (current / 'assets' / 'fonts').exists():
return current
current = current.parent
return None
def _load_fonts(self):
"""Load fonts with graceful fallback, matching DisplayManager._load_fonts()."""
project_root = self._find_project_root()
try:
if project_root is None:
raise FileNotFoundError("Could not find project root with assets/fonts")
fonts_dir = project_root / 'assets' / 'fonts'
# Press Start 2P — regular and small (both 8px)
ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf')
self.regular_font = ImageFont.truetype(ttf_path, 8)
self.small_font = ImageFont.truetype(ttf_path, 8)
self.font = self.regular_font # alias used by some code paths
# 5x7 BDF font via freetype
try:
import freetype
bdf_path = str(fonts_dir / '5x7.bdf')
if not os.path.exists(bdf_path):
raise FileNotFoundError(f"BDF font not found: {bdf_path}")
face = freetype.Face(bdf_path)
self.calendar_font = face
self.bdf_5x7_font = face
except (ImportError, FileNotFoundError, OSError) as e:
logger.debug("BDF font not available, using small_font as fallback: %s", e)
self.calendar_font = self.small_font
self.bdf_5x7_font = self.small_font
# 4x6 extra small TTF
try:
xs_path = str(fonts_dir / '4x6-font.ttf')
self.extra_small_font = ImageFont.truetype(xs_path, 6)
except (FileNotFoundError, OSError) as e:
logger.debug("Extra small font not available, using fallback: %s", e)
self.extra_small_font = self.small_font
except (FileNotFoundError, OSError) as e:
logger.debug("Font loading fallback: %s", e)
self.regular_font = ImageFont.load_default()
self.small_font = self.regular_font
self.font = self.regular_font
self.calendar_font = self.regular_font
self.bdf_5x7_font = self.regular_font
self.extra_small_font = self.regular_font
# ------------------------------------------------------------------
# Core display methods
# ------------------------------------------------------------------
def clear(self):
"""Clear the display to black."""
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."""
self.update_called = True
@contextmanager
def render_size(self, width: int, height: Optional[int] = None):
"""
Interface parity with DisplayManager.render_size().
Vegas mode narrows the canvas so plugins lay out compactly instead of
being cropped. The harness must offer the same context or that path
cannot be exercised offline — and because the adapter catches broadly,
a missing method shows up as "no content" rather than an error.
"""
prev_image = self.image
prev_draw = self.draw
prev_w, prev_h = self._width, self._height
target_w = max(1, min(int(width), prev_w))
target_h = max(1, min(int(height) if height else prev_h, prev_h))
try:
self._width, self._height = target_w, target_h
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
self.matrix = _MatrixProxy(prev_w, prev_h)
self.image = prev_image
self.draw = prev_draw
@contextmanager
def capture_mode(self):
"""
Interface parity with DisplayManager.capture_mode().
There is no hardware to suppress here, but Vegas mode's PluginAdapter
wraps every off-screen content fetch in this context, so the harness
must provide it for that code path to be exercisable in tests.
"""
self._capture_mode_active = True
try:
yield
finally:
self._capture_mode_active = False
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
font: Optional[Any] = None, centered: bool = False) -> None:
"""Draw text on the canvas, matching DisplayManager.draw_text() signature."""
# Track the call
self.draw_calls.append({
'type': 'text', 'text': text, 'x': x, 'y': y,
'color': color, 'font': font,
})
try:
# Normalize color to tuple (plugins may pass lists from JSON config)
if isinstance(color, list):
color = tuple(color)
# Select font
if font:
current_font = font
else:
current_font = self.small_font if small_font else self.regular_font
# Calculate x position
if x is None:
text_width = self.get_text_width(text, current_font)
x = (self.width - text_width) // 2
elif centered:
text_width = self.get_text_width(text, current_font)
x = x - (text_width // 2)
if y is None:
y = 0
# Draw
try:
import freetype
is_bdf = isinstance(current_font, freetype.Face)
except ImportError:
is_bdf = False
if is_bdf:
self._draw_bdf_text(text, x, y, color, current_font)
else:
self.draw.text((x, y), text, font=current_font, fill=color)
except Exception as e:
logger.debug(f"Error drawing text: {e}")
def draw_image(self, image: Image.Image, x: int, y: int):
"""Draw an image on the display."""
self.draw_calls.append({
'type': 'image', 'image': image, 'x': x, 'y': y,
})
try:
self.image.paste(image, (x, y))
except Exception as e:
logger.debug(f"Error drawing image: {e}")
def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None):
"""Draw text using BDF font with proper bitmap handling.
Replicated from DisplayManager._draw_bdf_text().
"""
try:
if isinstance(color, list):
color = tuple(color)
face = font if font else self.calendar_font
# Compute baseline from font ascender
try:
ascender_px = face.size.ascender >> 6
except Exception:
ascender_px = 0
baseline_y = y + ascender_px
for char in text:
face.load_char(char)
bitmap = face.glyph.bitmap
glyph_left = face.glyph.bitmap_left
glyph_top = face.glyph.bitmap_top
for i in range(bitmap.rows):
for j in range(bitmap.width):
byte_index = i * bitmap.pitch + (j // 8)
if byte_index < len(bitmap.buffer):
byte = bitmap.buffer[byte_index]
if byte & (1 << (7 - (j % 8))):
pixel_x = x + glyph_left + j
pixel_y = baseline_y - glyph_top + i
if 0 <= pixel_x < self.width and 0 <= pixel_y < self.height:
self.draw.point((pixel_x, pixel_y), fill=color)
x += face.glyph.advance.x >> 6
except Exception as e:
logger.debug(f"Error drawing BDF text: {e}")
# ------------------------------------------------------------------
# Text measurement
# ------------------------------------------------------------------
def get_text_width(self, text: str, font=None) -> int:
"""Get text width in pixels, matching DisplayManager.get_text_width()."""
if font is None:
font = self.regular_font
try:
try:
import freetype
is_bdf = isinstance(font, freetype.Face)
except ImportError:
is_bdf = False
if is_bdf:
width = 0
for char in text:
font.load_char(char)
width += font.glyph.advance.x >> 6
return width
else:
bbox = self.draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
except Exception:
return 0
def get_font_height(self, font=None) -> int:
"""Get font height in pixels, matching DisplayManager.get_font_height()."""
if font is None:
font = self.regular_font
try:
try:
import freetype
is_bdf = isinstance(font, freetype.Face)
except ImportError:
is_bdf = False
if is_bdf:
return font.size.height >> 6
else:
ascent, descent = font.getmetrics()
return ascent + descent
except Exception:
if hasattr(font, 'size'):
return font.size
return 8
# ------------------------------------------------------------------
# Weather drawing helpers
# ------------------------------------------------------------------
def draw_sun(self, x: int, y: int, size: int = 16):
"""Draw a sun icon using yellow circles and lines."""
self._draw_sun(x, y, size)
def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):
"""Draw a cloud icon."""
self._draw_cloud(x, y, size, color)
def draw_rain(self, x: int, y: int, size: int = 16):
"""Draw rain icon with cloud and droplets."""
self._draw_rain(x, y, size)
def draw_snow(self, x: int, y: int, size: int = 16):
"""Draw snow icon with cloud and snowflakes."""
self._draw_snow(x, y, size)
def _draw_sun(self, x: int, y: int, size: int) -> None:
"""Draw a sun icon with rays (internal weather icon version)."""
center_x, center_y = x + size // 2, y + size // 2
radius = size // 4
ray_length = size // 3
self.draw.ellipse(
[center_x - radius, center_y - radius,
center_x + radius, center_y + radius],
fill=self.WEATHER_COLORS['sun'],
)
for angle in range(0, 360, 45):
rad = math.radians(angle)
start_x = center_x + int((radius + 2) * math.cos(rad))
start_y = center_y + int((radius + 2) * math.sin(rad))
end_x = center_x + int((radius + ray_length) * math.cos(rad))
end_y = center_y + int((radius + ray_length) * math.sin(rad))
self.draw.line([start_x, start_y, end_x, end_y], fill=self.WEATHER_COLORS['sun'], width=2)
def _draw_cloud(self, x: int, y: int, size: int, color: Optional[Tuple[int, int, int]] = None) -> None:
"""Draw a cloud using multiple circles (internal weather icon version)."""
cloud_color = color if color is not None else self.WEATHER_COLORS['cloud']
base_y = y + size // 2
circle_radius = size // 4
positions = [
(x + size // 3, base_y),
(x + size // 2, base_y - size // 6),
(x + 2 * size // 3, base_y),
]
for cx, cy in positions:
self.draw.ellipse(
[cx - circle_radius, cy - circle_radius,
cx + circle_radius, cy + circle_radius],
fill=cloud_color,
)
def _draw_rain(self, x: int, y: int, size: int) -> None:
"""Draw rain drops falling from a cloud."""
self._draw_cloud(x, y, size)
rain_color = self.WEATHER_COLORS['rain']
drop_size = size // 8
drops = [
(x + size // 4, y + 2 * size // 3),
(x + size // 2, y + 3 * size // 4),
(x + 3 * size // 4, y + 2 * size // 3),
]
for dx, dy in drops:
self.draw.line([dx, dy, dx - drop_size // 2, dy + drop_size], fill=rain_color, width=2)
def _draw_snow(self, x: int, y: int, size: int) -> None:
"""Draw snowflakes falling from a cloud."""
self._draw_cloud(x, y, size)
snow_color = self.WEATHER_COLORS['snow']
flake_size = size // 6
flakes = [
(x + size // 4, y + 2 * size // 3),
(x + size // 2, y + 3 * size // 4),
(x + 3 * size // 4, y + 2 * size // 3),
]
for fx, fy in flakes:
for angle in range(0, 360, 60):
rad = math.radians(angle)
end_x = fx + int(flake_size * math.cos(rad))
end_y = fy + int(flake_size * math.sin(rad))
self.draw.line([fx, fy, end_x, end_y], fill=snow_color, width=1)
def _draw_storm(self, x: int, y: int, size: int) -> None:
"""Draw a storm cloud with lightning bolt."""
self._draw_cloud(x, y, size)
bolt_color = self.WEATHER_COLORS['storm']
bolt_points = [
(x + size // 2, y + size // 2),
(x + 3 * size // 5, y + 2 * size // 3),
(x + 2 * size // 5, y + 2 * size // 3),
(x + size // 2, y + 5 * size // 6),
]
self.draw.polygon(bolt_points, fill=bolt_color)
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
"""Draw a weather icon based on the condition."""
cond = condition.lower()
if cond in ('clear', 'sunny'):
self._draw_sun(x, y, size)
elif cond in ('clouds', 'cloudy', 'partly cloudy'):
self._draw_cloud(x, y, size)
elif cond in ('rain', 'drizzle', 'shower'):
self._draw_rain(x, y, size)
elif cond in ('snow', 'sleet', 'hail'):
self._draw_snow(x, y, size)
elif cond in ('thunderstorm', 'storm'):
self._draw_storm(x, y, size)
else:
self._draw_sun(x, y, size)
def draw_text_with_icons(self, text: str, icons: List[tuple] = None,
x: int = None, y: int = None,
color: tuple = (255, 255, 255)):
"""Draw text with weather icons at specified positions."""
self.draw_text(text, x, y, color)
if icons:
for icon_type, icon_x, icon_y in icons:
self.draw_weather_icon(icon_type, icon_x, icon_y)
self.update_display()
# ------------------------------------------------------------------
# Scrolling state (no-op interface compat)
# ------------------------------------------------------------------
def set_scrolling_state(self, is_scrolling: bool):
"""Set the current scrolling state (no-op for testing)."""
self._scrolling_state['is_scrolling'] = is_scrolling
if is_scrolling:
self._scrolling_state['last_scroll_activity'] = time.time()
def is_currently_scrolling(self) -> bool:
"""Check if display is currently scrolling."""
return self._scrolling_state['is_scrolling']
def process_deferred_updates(self):
"""Process any deferred updates (no-op for testing).
Several ticker-style plugins (news, odds-ticker, leaderboard,
stock-news, stocks) call this unconditionally between
set_scrolling_state() and their scroll-position update, mirroring the
real display_manager's deferred-update queue. This double has no such
queue, so there is nothing to process — the no-op just lets those
plugins render under the harness instead of raising AttributeError.
"""
pass
# ------------------------------------------------------------------
# Utility methods
# ------------------------------------------------------------------
def format_date_with_ordinal(self, dt):
"""Formats a datetime object into 'Mon Aug 30th' style."""
day = dt.day
if 11 <= day <= 13:
suffix = 'th'
else:
suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th')
return dt.strftime(f"%b %-d{suffix}")
# ------------------------------------------------------------------
# Snapshot / image capture
# ------------------------------------------------------------------
def save_snapshot(self, path: str) -> None:
"""Save the current display as a PNG image."""
self.image.save(path, format='PNG')
def get_image(self) -> Image.Image:
"""Return the current display image."""
return self.image
def get_image_base64(self) -> str:
"""Return the current display as a base64-encoded PNG string."""
import base64
import io
buffer = io.BytesIO()
self.image.save(buffer, format='PNG')
return base64.b64encode(buffer.getvalue()).decode('utf-8')
# ------------------------------------------------------------------
# Cleanup / reset
# ------------------------------------------------------------------
def reset(self):
"""Reset all tracking state (for test reuse)."""
self.clear_called = False
self.update_called = False
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,
'scroll_inactivity_threshold': 2.0,
'deferred_updates': [],
'max_deferred_updates': 50,
'deferred_update_ttl': 300.0,
}
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.