Magnific's SVG export is flat-shaded vector (~550 solid-fill paths, 6 gradients), which converts to pixel art cleanly. tools/svg2portrait.py rasterises at 16x the target via qlmanage, then takes the most common colour per 16x16 block — a true pixelate with no blending — and maps to the 11-colour portrait palette. Measured on Aki: 19 colours after pixelate, 10 after palette, visually near-identical. So the portrait palette is sufficient for this art. Contrast with the bitmap route this replaces: a 48x48 PNG resized in Photoshop had 1330 unique colours in 2304 pixels, because bicubic resampling turns flat regions into gradients. Quantising that speckled badly across the blazer and background. Also adds tools/pixtool.py — dependency-free PNG decode/encode (no Pillow on this machine) plus the palette table shared with kissaten.c. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
74 lines
2.8 KiB
Python
74 lines
2.8 KiB
Python
#!/usr/bin/env python3
|
|
"""SVG portrait -> 48x48 PNG in the Kissaten palette.
|
|
|
|
python3 tools/svg2portrait.py art/aki-neutral.svg [more.svg ...]
|
|
|
|
Why this route beats exporting a bitmap and resizing:
|
|
|
|
Magnific's SVG export is flat-shaded vector — hundreds of paths, each a solid
|
|
fill, with only a handful of gradients. Rendering that at an exact multiple of
|
|
48 and then taking the *most common* colour in each block is a true pixelate:
|
|
no blending, no half-pixels, no anti-aliased edges. Bicubic resizing of a
|
|
bitmap averages instead, which turns every flat region into a gradient and
|
|
makes the later palette reduction speckle badly.
|
|
|
|
Requires qlmanage (macOS built-in) to rasterise. No third-party deps.
|
|
"""
|
|
import subprocess, sys, os, tempfile
|
|
from collections import Counter
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from pixtool import read_png, write_png, zoom, PALETTE
|
|
|
|
SIZE = 48
|
|
SCALE = 16 # render at SIZE*SCALE, then block-reduce
|
|
PORTRAIT_IDX = [1, 2, 3, 4, 5, 6, 7, 12, 13, 14, 15] # no sky, no index 0
|
|
PP = [PALETTE[i] for i in PORTRAIT_IDX]
|
|
|
|
|
|
def nearest(c):
|
|
r, g, b = c[:3]
|
|
# green weighted heaviest: keeps eyes and mouth from dissolving into skin
|
|
return min(PP, key=lambda p: 3*(p[0]-r)**2 + 6*(p[1]-g)**2 + (p[2]-b)**2)
|
|
|
|
|
|
def rasterise(svg, px):
|
|
"""SVG -> PNG at px wide, via Quick Look. Returns decoded pixels."""
|
|
with tempfile.TemporaryDirectory() as td:
|
|
subprocess.run(["qlmanage", "-t", "-s", str(px), "-o", td, svg],
|
|
capture_output=True, check=True)
|
|
out = os.path.join(td, os.path.basename(svg) + ".png")
|
|
if not os.path.exists(out):
|
|
raise SystemExit(f"qlmanage produced nothing for {svg}")
|
|
return read_png(out)
|
|
|
|
|
|
def mode_reduce(rows, n):
|
|
"""Each n*n block collapses to its most common colour — a real pixelate."""
|
|
return [[Counter(rows[by*n + y][bx*n + x][:3]
|
|
for y in range(n) for x in range(n)).most_common(1)[0][0]
|
|
for bx in range(SIZE)] for by in range(SIZE)]
|
|
|
|
|
|
def convert(svg):
|
|
w, h, big = rasterise(svg, SIZE * SCALE)
|
|
if w != SIZE * SCALE:
|
|
raise SystemExit(f"{svg}: expected {SIZE*SCALE}px render, got {w}")
|
|
small = mode_reduce(big, SCALE)
|
|
quant = [[nearest(p) for p in r] for r in small]
|
|
|
|
stem = os.path.splitext(svg)[0]
|
|
write_png(f"{stem}-{SIZE}.png", [[p + (255,) for p in r] for r in quant])
|
|
write_png(f"{stem}-{SIZE}-x6.png",
|
|
zoom([[p + (255,) for p in r] for r in quant], 6))
|
|
print(f"{os.path.basename(svg)}: {len({p for r in small for p in r})} colours "
|
|
f"after pixelate -> {len({p for r in quant for p in r})} after palette")
|
|
print(f" wrote {stem}-{SIZE}.png and {stem}-{SIZE}-x6.png (preview)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
raise SystemExit(__doc__)
|
|
for f in sys.argv[1:]:
|
|
convert(f)
|