mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
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:
+42
-7
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user