Files
mono/l1/test_funky_text.py
2026-02-24 23:07:19 +00:00

543 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Funky comparison tests: PIL vs TextStrip system.
Tests colors, opacity, fonts, sizes, edge positions, clipping, overlaps, etc.
"""
import numpy as np
import jax.numpy as jnp
from PIL import Image, ImageDraw, ImageFont
from streaming.jax_typography import (
render_text_strip, place_text_strip_jax, _load_font
)
FONTS = {
'sans': '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'bold': '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'serif': '/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf',
'serif_bold': '/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf',
'mono': '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf',
'mono_bold': '/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf',
'narrow': '/usr/share/fonts/truetype/liberation/LiberationSansNarrow-Regular.ttf',
'italic': '/usr/share/fonts/truetype/freefont/FreeSansOblique.ttf',
}
def render_pil(text, x, y, font_path=None, font_size=36, frame_size=(400, 100),
fill=(255, 255, 255), bg=(0, 0, 0), opacity=1.0,
stroke_width=0, stroke_fill=None, anchor="la",
multiline=False, line_spacing=4, align="left"):
"""Render with PIL directly, including color/opacity."""
frame = np.full((frame_size[1], frame_size[0], 3), bg, dtype=np.uint8)
# For opacity, render to RGBA then composite
txt_layer = Image.new('RGBA', frame_size, (0, 0, 0, 0))
draw = ImageDraw.Draw(txt_layer)
font = _load_font(font_path, font_size)
if stroke_fill is None:
stroke_fill = (0, 0, 0)
# PIL fill with alpha for opacity
alpha_int = int(round(opacity * 255))
fill_rgba = (*fill, alpha_int)
stroke_rgba = (*stroke_fill, alpha_int) if stroke_width > 0 else None
if multiline:
draw.multiline_text((x, y), text, fill=fill_rgba, font=font,
stroke_width=stroke_width, stroke_fill=stroke_rgba,
spacing=line_spacing, align=align, anchor=anchor)
else:
draw.text((x, y), text, fill=fill_rgba, font=font,
stroke_width=stroke_width, stroke_fill=stroke_rgba, anchor=anchor)
# Composite onto background
bg_img = Image.fromarray(frame).convert('RGBA')
result = Image.alpha_composite(bg_img, txt_layer)
return np.array(result.convert('RGB'))
def render_strip(text, x, y, font_path=None, font_size=36, frame_size=(400, 100),
fill=(255, 255, 255), bg=(0, 0, 0), opacity=1.0,
stroke_width=0, stroke_fill=None, anchor="la",
multiline=False, line_spacing=4, align="left"):
"""Render with TextStrip system."""
frame = jnp.full((frame_size[1], frame_size[0], 3), jnp.array(bg, dtype=jnp.uint8), dtype=jnp.uint8)
strip = render_text_strip(
text, font_path, font_size,
stroke_width=stroke_width, stroke_fill=stroke_fill,
anchor=anchor, multiline=multiline, line_spacing=line_spacing, align=align
)
strip_img = jnp.asarray(strip.image)
color = jnp.array(fill, dtype=jnp.float32)
result = place_text_strip_jax(
frame, strip_img, x, y,
strip.baseline_y, strip.bearing_x,
color, opacity,
anchor_x=strip.anchor_x, anchor_y=strip.anchor_y,
stroke_width=strip.stroke_width
)
return np.array(result)
def compare(name, tolerance=0, **kwargs):
"""Compare PIL and TextStrip rendering."""
pil = render_pil(**kwargs)
strip = render_strip(**kwargs)
diff = np.abs(pil.astype(np.int16) - strip.astype(np.int16))
max_diff = diff.max()
pixels_diff = (diff > 0).any(axis=2).sum()
if max_diff == 0:
print(f" PASS: {name}")
return True
if tolerance > 0:
best_diff = diff.copy()
for dy in range(-tolerance, tolerance + 1):
for dx in range(-tolerance, tolerance + 1):
if dy == 0 and dx == 0:
continue
shifted = np.roll(np.roll(strip, dy, axis=0), dx, axis=1)
sdiff = np.abs(pil.astype(np.int16) - shifted.astype(np.int16))
best_diff = np.minimum(best_diff, sdiff)
if best_diff.max() == 0:
print(f" PASS: {name} (within {tolerance}px tolerance)")
return True
print(f" FAIL: {name}")
print(f" Max diff: {max_diff}, Pixels different: {pixels_diff}")
Image.fromarray(pil).save(f"/tmp/pil_{name}.png")
Image.fromarray(strip).save(f"/tmp/strip_{name}.png")
diff_vis = np.clip(diff * 10, 0, 255).astype(np.uint8)
Image.fromarray(diff_vis).save(f"/tmp/diff_{name}.png")
print(f" Saved: /tmp/pil_{name}.png /tmp/strip_{name}.png /tmp/diff_{name}.png")
return False
def test_colors():
"""Test various text colors on various backgrounds."""
print("\n--- Colors ---")
results = []
# White on black (baseline)
results.append(compare("color_white_on_black",
text="Hello", x=20, y=30, fill=(255, 255, 255), bg=(0, 0, 0)))
# Red on black
results.append(compare("color_red",
text="Red Text", x=20, y=30, fill=(255, 0, 0), bg=(0, 0, 0)))
# Green on black
results.append(compare("color_green",
text="Green!", x=20, y=30, fill=(0, 255, 0), bg=(0, 0, 0)))
# Blue on black
results.append(compare("color_blue",
text="Blue sky", x=20, y=30, fill=(0, 100, 255), bg=(0, 0, 0)))
# Yellow on dark gray
results.append(compare("color_yellow_on_gray",
text="Yellow", x=20, y=30, fill=(255, 255, 0), bg=(40, 40, 40)))
# Magenta on white
results.append(compare("color_magenta_on_white",
text="Magenta", x=20, y=30, fill=(255, 0, 255), bg=(255, 255, 255)))
# Subtle: gray text on slightly lighter gray
results.append(compare("color_subtle_gray",
text="Subtle", x=20, y=30, fill=(128, 128, 128), bg=(64, 64, 64)))
# Orange on deep blue
results.append(compare("color_orange_on_blue",
text="Warm", x=20, y=30, fill=(255, 165, 0), bg=(0, 0, 80)))
return results
def test_opacity():
"""Test different opacity levels."""
print("\n--- Opacity ---")
results = []
results.append(compare("opacity_100",
text="Full", x=20, y=30, opacity=1.0))
results.append(compare("opacity_75",
text="75%", x=20, y=30, opacity=0.75))
results.append(compare("opacity_50",
text="Half", x=20, y=30, opacity=0.5))
results.append(compare("opacity_25",
text="Quarter", x=20, y=30, opacity=0.25))
results.append(compare("opacity_10",
text="Ghost", x=20, y=30, opacity=0.1))
# Opacity on colored background
results.append(compare("opacity_on_colored_bg",
text="Overlay", x=20, y=30, fill=(255, 255, 255), bg=(100, 0, 0),
opacity=0.5))
# Color + opacity combo
results.append(compare("opacity_red_on_green",
text="Blend", x=20, y=30, fill=(255, 0, 0), bg=(0, 100, 0),
opacity=0.6))
return results
def test_fonts():
"""Test different fonts and sizes."""
print("\n--- Fonts & Sizes ---")
results = []
for label, path in FONTS.items():
results.append(compare(f"font_{label}",
text="Quick Fox", x=20, y=30, font_path=path, font_size=28,
frame_size=(300, 80)))
# Tiny text
results.append(compare("size_tiny",
text="Tiny text at 12px", x=10, y=15, font_size=12,
frame_size=(200, 40)))
# Big text
results.append(compare("size_big",
text="BIG", x=20, y=10, font_size=72,
frame_size=(300, 100)))
# Huge text
results.append(compare("size_huge",
text="XL", x=10, y=10, font_size=120,
frame_size=(300, 160)))
return results
def test_anchors():
"""Test all anchor combinations."""
print("\n--- Anchors ---")
results = []
# All horizontal x vertical combos
for h in ['l', 'm', 'r']:
for v in ['a', 'm', 's', 'd']:
anchor = h + v
results.append(compare(f"anchor_{anchor}",
text="Anchor", x=200, y=50, anchor=anchor,
frame_size=(400, 100)))
return results
def test_strokes():
"""Test various stroke widths and colors."""
print("\n--- Strokes ---")
results = []
for sw in [1, 2, 3, 4, 6, 8]:
results.append(compare(f"stroke_w{sw}",
text="Stroke", x=30, y=20, font_size=40,
stroke_width=sw, stroke_fill=(0, 0, 0),
frame_size=(300, 80)))
# Colored strokes
results.append(compare("stroke_red",
text="Red outline", x=20, y=20, font_size=36,
stroke_width=3, stroke_fill=(255, 0, 0),
frame_size=(350, 80)))
results.append(compare("stroke_white_on_black",
text="Glow", x=20, y=20, font_size=40,
fill=(255, 255, 255), stroke_width=4, stroke_fill=(0, 0, 255),
frame_size=(250, 80)))
# Stroke with bold font
results.append(compare("stroke_bold",
text="Bold+Stroke", x=20, y=20,
font_path=FONTS['bold'], font_size=36,
stroke_width=3, stroke_fill=(0, 0, 0),
frame_size=(400, 80)))
# Stroke + colored text on colored bg
results.append(compare("stroke_colored_on_bg",
text="Party", x=20, y=20, font_size=48,
fill=(255, 255, 0), bg=(50, 0, 80),
stroke_width=3, stroke_fill=(255, 0, 0),
frame_size=(300, 80)))
return results
def test_edge_clipping():
"""Test text at frame edges - clipping behavior."""
print("\n--- Edge Clipping ---")
results = []
# Text at very left edge
results.append(compare("clip_left_edge",
text="LEFT", x=0, y=30, frame_size=(200, 80)))
# Text partially off right edge
results.append(compare("clip_right_edge",
text="RIGHT SIDE", x=150, y=30, frame_size=(200, 80)))
# Text at top edge
results.append(compare("clip_top",
text="TOP", x=20, y=0, frame_size=(200, 80)))
# Text at bottom edge - partially clipped
results.append(compare("clip_bottom",
text="BOTTOM", x=20, y=55, font_size=40,
frame_size=(200, 80)))
# Large text overflowing all sides from center
results.append(compare("clip_overflow_center",
text="HUGE", x=75, y=40, font_size=100, anchor="mm",
frame_size=(150, 80)))
# Corner placement
results.append(compare("clip_corner_br",
text="Corner", x=350, y=70, font_size=36,
frame_size=(400, 100)))
return results
def test_multiline_fancy():
"""Test multiline with various styles."""
print("\n--- Multiline Fancy ---")
results = []
# Right-aligned (1px tolerance: same sub-pixel issue as center alignment)
results.append(compare("multi_right",
text="Right\nAligned\nText", x=380, y=20,
frame_size=(400, 150),
multiline=True, anchor="ra", align="right",
tolerance=1))
# Center + stroke
results.append(compare("multi_center_stroke",
text="Title\nSubtitle", x=200, y=20,
font_size=32, frame_size=(400, 120),
multiline=True, anchor="ma", align="center",
stroke_width=2, stroke_fill=(0, 0, 0),
tolerance=1))
# Wide line spacing
results.append(compare("multi_wide_spacing",
text="Line A\nLine B\nLine C", x=20, y=10,
frame_size=(300, 200),
multiline=True, line_spacing=20))
# Zero extra spacing
results.append(compare("multi_tight_spacing",
text="Tight\nPacked\nLines", x=20, y=10,
frame_size=(300, 150),
multiline=True, line_spacing=0))
# Many lines
results.append(compare("multi_many_lines",
text="One\nTwo\nThree\nFour\nFive\nSix", x=20, y=5,
font_size=20, frame_size=(200, 200),
multiline=True, line_spacing=4))
# Bold multiline with stroke
results.append(compare("multi_bold_stroke",
text="BOLD\nSTROKE", x=20, y=10,
font_path=FONTS['bold'], font_size=48,
stroke_width=3, stroke_fill=(200, 0, 0),
frame_size=(350, 150), multiline=True))
# Multiline on colored bg with opacity
results.append(compare("multi_opacity_on_bg",
text="Semi\nTransparent", x=20, y=10,
fill=(255, 255, 0), bg=(0, 50, 100), opacity=0.7,
frame_size=(300, 120), multiline=True))
return results
def test_special_chars():
"""Test special characters and edge cases."""
print("\n--- Special Characters ---")
results = []
# Numbers and symbols
results.append(compare("chars_numbers",
text="0123456789", x=20, y=30, frame_size=(300, 80)))
results.append(compare("chars_punctuation",
text="Hello, World! (v2.0)", x=10, y=30, frame_size=(350, 80)))
results.append(compare("chars_symbols",
text="@#$%^&*+=", x=20, y=30, frame_size=(300, 80)))
# Single character
results.append(compare("chars_single",
text="X", x=50, y=30, font_size=48, frame_size=(100, 80)))
# Very long text (clipped)
results.append(compare("chars_long",
text="The quick brown fox jumps over the lazy dog", x=10, y=30,
font_size=24, frame_size=(400, 80)))
# Mixed case
results.append(compare("chars_mixed_case",
text="AaBbCcDdEeFf", x=10, y=30, frame_size=(350, 80)))
return results
def test_combos():
"""Complex combinations of features."""
print("\n--- Combos ---")
results = []
# Big bold stroke + color + opacity
results.append(compare("combo_all_features",
text="EPIC", x=20, y=10,
font_path=FONTS['bold'], font_size=64,
fill=(255, 200, 0), bg=(20, 0, 40), opacity=0.85,
stroke_width=4, stroke_fill=(180, 0, 0),
frame_size=(350, 100)))
# Small mono on busy background
results.append(compare("combo_mono_code",
text="fn main() {}", x=10, y=15,
font_path=FONTS['mono'], font_size=16,
fill=(0, 255, 100), bg=(30, 30, 30),
frame_size=(250, 50)))
# Serif italic multiline with stroke
results.append(compare("combo_serif_italic_multi",
text="Once upon\na time...", x=20, y=10,
font_path=FONTS['italic'], font_size=28,
stroke_width=1, stroke_fill=(80, 80, 80),
frame_size=(300, 120), multiline=True))
# Narrow font, big stroke, center anchored
results.append(compare("combo_narrow_stroke_center",
text="NARROW", x=150, y=40,
font_path=FONTS['narrow'], font_size=44,
stroke_width=5, stroke_fill=(0, 0, 0),
anchor="mm", frame_size=(300, 80)))
# Multiple strips on same frame (simulated by sequential placement)
results.append(compare("combo_opacity_blend",
text="Layered", x=20, y=30,
fill=(255, 0, 0), bg=(0, 0, 255), opacity=0.5,
font_size=48, frame_size=(300, 80)))
return results
def test_multi_strip_overlay():
"""Test placing multiple strips on the same frame."""
print("\n--- Multi-Strip Overlay ---")
results = []
frame_size = (400, 150)
bg = (20, 20, 40)
# PIL version - multiple draw calls
font1 = _load_font(FONTS['bold'], 48)
font2 = _load_font(None, 24)
font3 = _load_font(FONTS['mono'], 18)
pil_frame = np.full((frame_size[1], frame_size[0], 3), bg, dtype=np.uint8)
txt_layer = Image.new('RGBA', frame_size, (0, 0, 0, 0))
draw = ImageDraw.Draw(txt_layer)
draw.text((20, 10), "TITLE", fill=(255, 255, 0, 255), font=font1,
stroke_width=2, stroke_fill=(0, 0, 0, 255))
draw.text((20, 70), "Subtitle here", fill=(200, 200, 200, 255), font=font2)
draw.text((20, 110), "code_snippet()", fill=(0, 255, 128, 200), font=font3)
bg_img = Image.fromarray(pil_frame).convert('RGBA')
pil_result = np.array(Image.alpha_composite(bg_img, txt_layer).convert('RGB'))
# Strip version - multiple placements
frame = jnp.full((frame_size[1], frame_size[0], 3), jnp.array(bg, dtype=jnp.uint8), dtype=jnp.uint8)
s1 = render_text_strip("TITLE", FONTS['bold'], 48, stroke_width=2, stroke_fill=(0, 0, 0))
s2 = render_text_strip("Subtitle here", None, 24)
s3 = render_text_strip("code_snippet()", FONTS['mono'], 18)
frame = place_text_strip_jax(
frame, jnp.asarray(s1.image), 20, 10,
s1.baseline_y, s1.bearing_x,
jnp.array([255, 255, 0], dtype=jnp.float32), 1.0,
anchor_x=s1.anchor_x, anchor_y=s1.anchor_y,
stroke_width=s1.stroke_width)
frame = place_text_strip_jax(
frame, jnp.asarray(s2.image), 20, 70,
s2.baseline_y, s2.bearing_x,
jnp.array([200, 200, 200], dtype=jnp.float32), 1.0,
anchor_x=s2.anchor_x, anchor_y=s2.anchor_y,
stroke_width=s2.stroke_width)
frame = place_text_strip_jax(
frame, jnp.asarray(s3.image), 20, 110,
s3.baseline_y, s3.bearing_x,
jnp.array([0, 255, 128], dtype=jnp.float32), 200/255,
anchor_x=s3.anchor_x, anchor_y=s3.anchor_y,
stroke_width=s3.stroke_width)
strip_result = np.array(frame)
diff = np.abs(pil_result.astype(np.int16) - strip_result.astype(np.int16))
max_diff = diff.max()
pixels_diff = (diff > 0).any(axis=2).sum()
if max_diff <= 1:
print(f" PASS: multi_overlay (max_diff={max_diff})")
results.append(True)
else:
print(f" FAIL: multi_overlay")
print(f" Max diff: {max_diff}, Pixels different: {pixels_diff}")
Image.fromarray(pil_result).save("/tmp/pil_multi_overlay.png")
Image.fromarray(strip_result).save("/tmp/strip_multi_overlay.png")
diff_vis = np.clip(diff * 10, 0, 255).astype(np.uint8)
Image.fromarray(diff_vis).save("/tmp/diff_multi_overlay.png")
print(f" Saved: /tmp/pil_multi_overlay.png /tmp/strip_multi_overlay.png /tmp/diff_multi_overlay.png")
results.append(False)
return results
def main():
print("=" * 60)
print("Funky TextStrip vs PIL Comparison")
print("=" * 60)
all_results = []
all_results.extend(test_colors())
all_results.extend(test_opacity())
all_results.extend(test_fonts())
all_results.extend(test_anchors())
all_results.extend(test_strokes())
all_results.extend(test_edge_clipping())
all_results.extend(test_multiline_fancy())
all_results.extend(test_special_chars())
all_results.extend(test_combos())
all_results.extend(test_multi_strip_overlay())
print("\n" + "=" * 60)
passed = sum(all_results)
total = len(all_results)
print(f"Results: {passed}/{total} passed")
if passed == total:
print("ALL TESTS PASSED!")
else:
failed = [i for i, r in enumerate(all_results) if not r]
print(f"FAILED: {total - passed} tests (indices: {failed})")
print("=" * 60)
if __name__ == "__main__":
main()