feat(avatar): add SVG, PNG and HDR avatar exports
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""End-to-end export checks; run with uv run python -m unittest discover -s tests."""
|
||||
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from PIL import Image
|
||||
from fontTools.pens.boundsPen import BoundsPen
|
||||
from fontTools.svgLib.path import parse_path
|
||||
import resvg_py
|
||||
|
||||
from cmatrix_avatar import BLUE, Cell, CMatrixAvatar, export, find_font, make_svg
|
||||
from avatar_color import sdr_color
|
||||
|
||||
|
||||
class AvatarExportTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.font = find_font()
|
||||
|
||||
def test_svg_is_portable_and_reproducible(self):
|
||||
svg = make_svg(CMatrixAvatar(42), self.font)
|
||||
self.assertEqual(svg, make_svg(CMatrixAvatar(42), self.font))
|
||||
self.assertNotEqual(svg, make_svg(CMatrixAvatar(43), self.font))
|
||||
root = ET.fromstring(svg)
|
||||
ns = "{http://www.w3.org/2000/svg}"
|
||||
self.assertFalse(list(root.iter(ns + "text")))
|
||||
self.assertFalse(list(root.iter(ns + "image")))
|
||||
ids = {node.get("id") for node in root.iter() if node.get("id")}
|
||||
for node in root.iter(ns + "use"):
|
||||
self.assertIn(node.get("href")[1:], ids)
|
||||
self.assertTrue(list(root.iter(ns + "path")))
|
||||
|
||||
def test_png_matches_svg_and_logo_geometry(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
svg_path, png_path = export(CMatrixAvatar(42), Path(directory) / "nested/gwb",
|
||||
self.font, 1024, previews=True)
|
||||
with Image.open(png_path) as image:
|
||||
self.assertEqual(image.size, (1024, 1024))
|
||||
rgba = image.convert("RGBA")
|
||||
self.assertEqual(rgba.getchannel("A").getextrema(), (255, 255))
|
||||
raster = resvg_py.svg_to_bytes(svg_string=svg_path.read_text(encoding="utf-8"))
|
||||
with Image.open(io.BytesIO(raster)) as rendered:
|
||||
self.assertEqual(rgba.tobytes(), rendered.convert("RGBA").tobytes())
|
||||
blue = tuple(bytes.fromhex(sdr_color(BLUE)[1:]))
|
||||
mask = Image.new("L", image.size)
|
||||
pixels = rgba.tobytes()
|
||||
mask.putdata([255 if pixels[i:i + 3] == bytes(blue) else 0
|
||||
for i in range(0, len(pixels), 4)])
|
||||
x0, y0, x1, y1 = mask.getbbox()
|
||||
self.assertTrue(0.70 < (x1 - x0) / 1024 < 0.75)
|
||||
self.assertAlmostEqual((x0 + x1) / 2, 512, delta=16)
|
||||
self.assertAlmostEqual((y0 + y1) / 2, 512, delta=16)
|
||||
for size in (128, 64):
|
||||
with Image.open(png_path.with_name(f"gwb-{size}.png")) as preview:
|
||||
self.assertEqual(preview.size, (size, size))
|
||||
|
||||
def test_cli_custom_size_and_svg_output_path(self):
|
||||
script = Path(__file__).resolve().parents[1] / "cmatrix_avatar.py"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "portrait.svg"
|
||||
result = subprocess.run([sys.executable, str(script), "--seed", "7", "--size", "256",
|
||||
"--output", str(output)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue(output.exists())
|
||||
with Image.open(output.with_suffix(".png")) as image:
|
||||
self.assertEqual(image.size, (256, 256))
|
||||
|
||||
def test_cli_default_resolution(self):
|
||||
script = Path(__file__).resolve().parents[1] / "cmatrix_avatar.py"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "default.png"
|
||||
result = subprocess.run([sys.executable, str(script), "--seed", "42",
|
||||
"--output", str(output)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
with Image.open(output) as image:
|
||||
self.assertEqual(image.size, (2048, 2048))
|
||||
|
||||
def test_explicit_logo_font_without_box_glyphs_is_rejected(self):
|
||||
ocr = Path("C:/Windows/Fonts/OCRAEXT.TTF")
|
||||
if not ocr.exists():
|
||||
self.skipTest("OCR A Extended is not installed")
|
||||
# Automatic fallback succeeds (covered by export tests); explicitly
|
||||
# choosing an incomplete logo font must never silently drop characters.
|
||||
with self.assertRaisesRegex(ValueError, "missing characters"):
|
||||
make_svg(CMatrixAvatar(42), ocr, logo_font_path=ocr)
|
||||
|
||||
def test_cli_rejects_invalid_options(self):
|
||||
script = Path(__file__).resolve().parents[1] / "cmatrix_avatar.py"
|
||||
for arguments in (("--size", "0"), ("--rows", "7"), ("--columns", "20"), ("--output", "avatar.jpg")):
|
||||
with self.subTest(arguments=arguments):
|
||||
result = subprocess.run([sys.executable, str(script), *arguments], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertNotIn("Traceback", result.stderr)
|
||||
|
||||
def test_complete_grid_and_edge_glyphs_are_inside_canvas(self):
|
||||
ns = "{http://www.w3.org/2000/svg}"
|
||||
for rows, columns in ((43, 77), (20, 35), (28, 67)):
|
||||
with self.subTest(rows=rows, columns=columns):
|
||||
avatar = CMatrixAvatar(42, rows, columns)
|
||||
# Color every border cell separately: the whole border must
|
||||
# survive, while only central interior cells may be reserved.
|
||||
avatar.cells = [[Cell("l", "#ffff00" if r in (0, rows - 1) or c in (0, columns - 1)
|
||||
else "#00ff00") for c in range(columns)] for r in range(rows)]
|
||||
root = ET.fromstring(make_svg(avatar, self.font))
|
||||
self.assertFalse(list(root.iter(ns + "clipPath")))
|
||||
side = float(root.get("viewBox").split()[2])
|
||||
group = root.find(ns + "g")
|
||||
dx, dy = map(float, re.findall(r"[-\d.]+", group.get("transform")))
|
||||
border = next(p for p in group if p.get("data-base") == "#ffff00")
|
||||
self.assertEqual(border.get("d").count("M"), 2 * rows + 2 * columns - 4)
|
||||
for path in group:
|
||||
bounds = BoundsPen(None)
|
||||
parse_path(path.get("d"), bounds)
|
||||
x0, y0, x1, y1 = bounds.bounds
|
||||
self.assertGreater(x0 + dx, 0)
|
||||
self.assertGreater(y0 + dy, 0)
|
||||
self.assertLess(x1 + dx, side)
|
||||
self.assertLess(y1 + dy, side)
|
||||
# Interior fill must leave the ENTIRE logo rectangle empty.
|
||||
rendered = resvg_py.svg_to_bytes(svg_string=ET.tostring(root, encoding="unicode"))
|
||||
with Image.open(io.BytesIO(rendered)) as image:
|
||||
box = image.crop((round(2048 * .14) + 2, round(2048 * .37),
|
||||
round(2048 * .86) - 2, round(2048 * .63))).convert("RGB")
|
||||
colors = box.getdata() if not hasattr(box, "get_flattened_data") else box.get_flattened_data()
|
||||
self.assertFalse(any(g > r + 20 and g > b + 20 for r, g, b in colors))
|
||||
|
||||
def test_logo_ratio_is_independent_of_background_grid(self):
|
||||
ns = "{http://www.w3.org/2000/svg}"
|
||||
for rows, columns in ((20, 35), (43, 77), (60, 107)):
|
||||
root = ET.fromstring(make_svg(CMatrixAvatar(42, rows, columns), self.font))
|
||||
side = float(root.get("viewBox").split()[2])
|
||||
logo = root.find(f"{ns}path[@id='logo']")
|
||||
bounds = BoundsPen(None)
|
||||
parse_path(logo.get("d"), bounds)
|
||||
x0, y0, x1, y1 = bounds.bounds
|
||||
x, y, scale, ox, oy = map(float, re.findall(r"[-\d.]+", logo.get("transform")))
|
||||
self.assertAlmostEqual((x1 - x0) * scale / side, .72)
|
||||
self.assertAlmostEqual(x + (x1 - x0) * scale / 2, side / 2)
|
||||
self.assertAlmostEqual(y + (y1 - y0) * scale / 2, side / 2)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Check physical luminance, PQ encoding, and the actual PNG byte stream."""
|
||||
|
||||
import struct
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
import zlib
|
||||
|
||||
import numpy as np
|
||||
|
||||
from avatar_color import BT2020_Y, SRGB_Y, SDR_PEAK_Y, TO_2020, equal_luminance, gradient_level, linear_srgb, sdr_color
|
||||
from avatar_hdr import export_hdr, pq_decode, pq_encode, render_linear_hdr
|
||||
from cmatrix_avatar import BLUE, GREEN, YELLOW, CMatrixAvatar
|
||||
|
||||
|
||||
def read_png(path):
|
||||
data = Path(path).read_bytes()
|
||||
assert data[:8] == b'\x89PNG\r\n\x1a\n'
|
||||
position, chunks = 8, {}
|
||||
while position < len(data):
|
||||
size = struct.unpack_from('>I', data, position)[0]
|
||||
kind = data[position + 4:position + 8]
|
||||
payload = data[position + 8:position + 8 + size]
|
||||
crc = struct.unpack_from('>I', data, position + 8 + size)[0]
|
||||
assert crc == zlib.crc32(kind + payload) & 0xffffffff
|
||||
chunks[kind] = chunks.get(kind, b'') + payload
|
||||
position += 12 + size
|
||||
return chunks
|
||||
|
||||
|
||||
class HDRTests(unittest.TestCase):
|
||||
def test_pq_reference_values_and_round_trip(self):
|
||||
self.assertAlmostEqual(float(pq_encode(100)), .50807842, places=7)
|
||||
self.assertAlmostEqual(float(pq_encode(1000)), .75182710, places=7)
|
||||
self.assertAlmostEqual(float(pq_encode(10000)), 1, places=7)
|
||||
nits = np.array([.02, 2, 100, 400, 1000, 4000])
|
||||
np.testing.assert_allclose(pq_decode(pq_encode(nits)), nits, rtol=1e-10)
|
||||
|
||||
def test_equal_peaks_and_stronger_gradient(self):
|
||||
self.assertEqual(sdr_color(BLUE), BLUE)
|
||||
for base in (BLUE, GREEN, YELLOW):
|
||||
rgb = equal_luminance(base, 400, 10000, hdr=True)
|
||||
self.assertAlmostEqual(sum(a * b for a, b in zip(rgb, BT2020_Y)), 400)
|
||||
reference = np.asarray(TO_2020) @ np.asarray(linear_srgb(base))
|
||||
np.testing.assert_allclose(np.asarray(rgb) / sum(rgb), reference / reference.sum())
|
||||
self.assertLessEqual(max(equal_luminance(base, 2000, 10000, hdr=True)), 10000)
|
||||
sdr_y = sum(a * b for a, b in zip(linear_srgb(sdr_color(base)), SRGB_Y))
|
||||
self.assertAlmostEqual(sdr_y, SDR_PEAK_Y, delta=.004)
|
||||
levels = [gradient_level(i / 31) for i in range(32)]
|
||||
self.assertEqual(len(set(levels)), 32)
|
||||
self.assertEqual(levels, sorted(levels))
|
||||
self.assertEqual(levels[0], .005)
|
||||
self.assertEqual(levels[-1], 1)
|
||||
avatar = CMatrixAvatar(42)
|
||||
yellow = [c.level for row in avatar.cells for c in row if c.color == YELLOW]
|
||||
self.assertGreater(len(set(yellow)), 5)
|
||||
|
||||
def test_hdr_file_encoding_and_decoded_luminance(self):
|
||||
svg = '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="16" viewBox="0 0 128 16">'
|
||||
for index, (base, level) in enumerate(((GREEN, 1), (BLUE, 1), (YELLOW, 1), (GREEN, .005))):
|
||||
x = index * 32
|
||||
svg += f'<path data-base="{base}" data-level="{level}" d="M{x} 0h32v16h-32z"/>'
|
||||
svg += '</svg>'
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'hdr.png'
|
||||
export_hdr(svg, path, 400)
|
||||
chunks = read_png(path)
|
||||
self.assertEqual(struct.unpack('>IIBBBBB', chunks[b'IHDR']), (128, 16, 16, 2, 0, 0, 0))
|
||||
self.assertEqual(chunks[b'cICP'], bytes((9, 16, 0, 1)))
|
||||
self.assertNotIn(b'sRGB', chunks)
|
||||
self.assertNotIn(b'gAMA', chunks)
|
||||
raw = np.frombuffer(zlib.decompress(chunks[b'IDAT']), dtype=np.uint8).reshape(16, 128 * 6 + 1)
|
||||
self.assertTrue((raw[:, 0] == 0).all())
|
||||
signal = np.frombuffer(raw[:, 1:].copy().tobytes(), dtype='>u2').reshape(16, 128, 3)
|
||||
self.assertTrue((signal % 257 != 0).any())
|
||||
decoded = pq_decode(signal.astype(float) / 65535)
|
||||
y = decoded @ np.asarray(BT2020_Y)
|
||||
for column in (16, 48, 80):
|
||||
self.assertAlmostEqual(y[8, column], 400, delta=.1)
|
||||
self.assertAlmostEqual(y[8, 112], 2, delta=.01)
|
||||
max_cll, max_fall = struct.unpack('>II', chunks[b'cLLI'])
|
||||
self.assertAlmostEqual(max_cll / 10000, y.max(), delta=.0001)
|
||||
self.assertAlmostEqual(max_fall / 10000, y.mean(), delta=.0001)
|
||||
|
||||
def test_invalid_hdr_peak_is_rejected(self):
|
||||
for value in (0, 3000, float('nan'), float('inf')):
|
||||
with self.assertRaises(ValueError):
|
||||
render_linear_hdr('<svg/>', value)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user