feat(layout): add measure_font_crispness — verify a ladder rung isn't blurry

PIL antialiases TTF outlines by default; a 'pixel-style' font only
rasterizes without antialiasing at specific sizes (for PressStart2P:
exact multiples of its 8px design grid). A ladder rung at an unverified
size silently renders blurry on an LED panel — this exact bug shipped in
both text-display's and football-scoreboard's custom TTF ladders
(non-8-multiple PressStart2P sizes, and '5by7.regular'/'4x6-font' at
sizes that were never actually crisp).

measure_font_crispness(font, sample_text) renders the sample and reports
the fraction of ink-bbox pixels that are neither pure black nor pure
white. BDF fonts (real bitmaps) always score 0.0; TTF ladders should be
verified against this before shipping — see the new
TestFontFitting::test_ladder_arcade_is_crisp pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-07-11 09:00:37 -04:00
co-authored by Claude Fable 5
parent af96c6ffd6
commit 013a2663e5
2 changed files with 64 additions and 7 deletions
+42 -7
View File
@@ -273,6 +273,38 @@ def font_line_height(font: Any) -> int:
return ascent + descent return ascent + descent
def measure_font_crispness(font: Any, sample_text: str = "Ay0",
canvas_size: Tuple[int, int] = (250, 60)) -> float:
"""Fraction of the rendered sample's ink-bbox pixels that are neither
pure black nor pure white — i.e. antialiased.
BDF (freetype.Face) glyphs are true bitmaps and always render at 0.0.
"Pixel-style" TTFs (PressStart2P, and similar fonts bundled for
plugins that draw through ImageDraw.text() and so can't take a BDF
face) are NOT automatically crisp at arbitrary sizes — PIL antialiases
TTF outlines by default, and a pixel-grid font only lands on whole
pixels at specific sizes (for PressStart2P: exact multiples of 8).
Requesting an unverified size silently produces soft/blurry glyphs on
an LED panel, which reads as fuzzy compared to a true BDF rung.
Use this to vet any custom FontLadder rung that mixes TTF fonts before
shipping it — see test_adaptive_layout.py::test_ladder_is_crisp for the
pattern. A rung should score 0.0 (or very close, to allow for the odd
diagonal stroke) before it belongs in a "crisp" ladder.
"""
if isinstance(font, freetype.Face):
return 0.0
from PIL import Image, ImageDraw
img = Image.new("L", canvas_size, 0)
ImageDraw.Draw(img).text((2, 2), sample_text, font=font, fill=255)
bbox = img.getbbox()
if bbox is None:
return 0.0
pixels = img.crop(bbox).tobytes()
pure = sum(1 for p in pixels if p == 0 or p == 255)
return (len(pixels) - pure) / len(pixels)
class LayoutContext: class LayoutContext:
"""Per-render-size layout facts and fit-text queries for one panel size. """Per-render-size layout facts and fit-text queries for one panel size.
@@ -542,10 +574,13 @@ def scoreboard_regions(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
Encodes the invariant duplicated across the sports plugins: Encodes the invariant duplicated across the sports plugins:
``logo_slot = min(height, width // 2)`` (capped at half the card so the ``logo_slot = min(height, width // 2)`` (capped at half the card so the
home slot never collapses), away logo centered in the left slot, home in home slot never collapses), away logo centered in the left slot, home in
the right, score/status/detail stacked in the middle column. Band the right. The text bands (status/score/detail) span the FULL card width
heights default to the classic 128x32 values, scaled by the context's and overlay the logo slots — exactly like the classic layouts, where
geometry factor when one is provided. Works on a full panel or on a outlined text is drawn over the logos; on square-ish panels the column
scroll-mode card Region. between the slots can be zero-wide, so full-width bands are the only
correct home for text. Band heights default to the classic 128x32
values, scaled by the context's geometry factor when one is provided.
Works on a full panel or on a scroll-mode card Region.
""" """
if status_h is None: if status_h is None:
status_h = ctx.px(9, minimum=7) if ctx else 9 status_h = ctx.px(9, minimum=7) if ctx else 9
@@ -557,9 +592,9 @@ def scoreboard_regions(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
home_slot = bounds.right_col(logo_slot) home_slot = bounds.right_col(logo_slot)
center_col = Region(bounds.x + logo_slot, bounds.y, center_col = Region(bounds.x + logo_slot, bounds.y,
bounds.w - 2 * logo_slot, bounds.h) bounds.w - 2 * logo_slot, bounds.h)
status_band = center_col.top_band(status_h) status_band = bounds.top_band(status_h)
detail_band = center_col.bottom_band(detail_h) detail_band = bounds.bottom_band(detail_h)
score_area = center_col.middle(status_band.h, detail_band.h) score_area = bounds.middle(status_band.h, detail_band.h)
bottom = bounds.bottom_band(detail_h) bottom = bounds.bottom_band(detail_h)
return ScoreboardRegions( return ScoreboardRegions(
bounds=bounds, logo_slot=logo_slot, bounds=bounds, logo_slot=logo_slot,
+22
View File
@@ -9,6 +9,7 @@ from src.adaptive_layout import (
LayoutContext, LayoutContext,
Region, Region,
draw_fitted_text, draw_fitted_text,
measure_font_crispness,
measure_ink, measure_ink,
media_row, media_row,
scoreboard_regions, scoreboard_regions,
@@ -182,6 +183,27 @@ class TestFontFitting:
assert heights == sorted(heights, reverse=True), ( assert heights == sorted(heights, reverse=True), (
f"ladder not monotonically shrinking: {heights}") f"ladder not monotonically shrinking: {heights}")
def test_ladder_grid_is_crisp(self, font_manager):
"""LADDER_GRID's BDF fonts are real bitmaps — always 0% antialiased."""
for step in LADDER_GRID:
font = font_manager.get_font(step.family, step.size_px)
assert measure_font_crispness(font, "Ay0") == 0.0
def test_ladder_arcade_is_crisp(self, font_manager):
"""PressStart2P only rasterizes without antialiasing at exact
multiples of its 8px design grid — every LADDER_ARCADE rung must
land on one."""
for step in LADDER_ARCADE:
assert step.size_px % 8 == 0, f"{step} is not a multiple of 8"
font = font_manager.get_font(step.family, step.size_px)
assert measure_font_crispness(font, "17-21") == 0.0
def test_crispness_catches_a_bad_size(self, font_manager):
"""Sanity check the measurement itself: a known-bad size for a
pixel-grid font must NOT read as crisp."""
font = font_manager.get_font("press_start", 10) # not a multiple of 8
assert measure_font_crispness(font, "17-21") > 0.1
def test_fit_text_grows_on_taller_panel(self, font_manager): def test_fit_text_grows_on_taller_panel(self, font_manager):
small = LayoutContext(64, 32, font_manager) small = LayoutContext(64, 32, font_manager)
large = LayoutContext(128, 64, font_manager) large = LayoutContext(128, 64, font_manager)