feat(avatar): add SVG, PNG and HDR avatar exports

This commit is contained in:
2026-10-18 18:48:49 +08:00
parent 61d039beba
commit 899df27427
10 changed files with 1331 additions and 324 deletions
+148
View File
@@ -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()
+93
View File
@@ -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()