Art: SVG -> 48x48 portrait converter, and it works

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>
This commit is contained in:
2026-07-29 20:46:12 +02:00
co-authored by Claude Opus 5
parent 1c0d2d9a88
commit db7de024de
8 changed files with 793 additions and 4 deletions
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Dependency-free PNG read/write plus helpers for the Kissaten art pipeline.
No Pillow on this machine, so decode/encode are hand-rolled. Supports the
colour types PixelLab and friends actually emit: 2 (RGB), 6 (RGBA), 3
(palette), 0 (grey), 4 (grey+alpha), 8 bits per channel.
"""
import zlib, struct, binascii, sys
def read_png(path):
"""-> (w, h, pixels) where pixels is a list of rows of (r,g,b,a) tuples."""
d = open(path, "rb").read()
assert d[:8] == b"\x89PNG\r\n\x1a\x0a", f"{path}: not a PNG"
pos, idat, plte, trns = 8, b"", None, None
w = h = depth = ctype = None
while pos < len(d):
ln = struct.unpack(">I", d[pos:pos + 4])[0]
typ = d[pos + 4:pos + 8]
body = d[pos + 8:pos + 8 + ln]
if typ == b"IHDR":
w, h, depth, ctype = struct.unpack(">IIBB", body[:10])
elif typ == b"PLTE":
plte = body
elif typ == b"tRNS":
trns = body
elif typ == b"IDAT":
idat += body
elif typ == b"IEND":
break
pos += 12 + ln
assert depth == 8, f"{path}: only 8-bit channels supported (got {depth})"
nch = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[ctype]
raw = zlib.decompress(idat)
stride = w * nch
out, prev = [], bytearray(stride)
p = 0
for _ in range(h):
f = raw[p]; p += 1
line = bytearray(raw[p:p + stride]); p += stride
for i in range(stride):
a = line[i - nch] if i >= nch else 0
b = prev[i]
c = prev[i - nch] if i >= nch else 0
if f == 1: line[i] = (line[i] + a) & 255
elif f == 2: line[i] = (line[i] + b) & 255
elif f == 3: line[i] = (line[i] + (a + b) // 2) & 255
elif f == 4:
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
line[i] = (line[i] + pr) & 255
prev = line
row = []
for x in range(w):
v = line[x * nch:(x + 1) * nch]
if ctype == 2: row.append((v[0], v[1], v[2], 255))
elif ctype == 6: row.append((v[0], v[1], v[2], v[3]))
elif ctype == 0: row.append((v[0], v[0], v[0], 255))
elif ctype == 4: row.append((v[0], v[0], v[0], v[1]))
else:
i = v[0]
a = trns[i] if (trns and i < len(trns)) else 255
row.append((plte[i * 3], plte[i * 3 + 1], plte[i * 3 + 2], a))
out.append(row)
return w, h, out
def write_png(path, rows):
h, w = len(rows), len(rows[0])
raw = b"".join(b"\x00" + bytes(c for px in r for c in px[:3]) for r in rows)
def chunk(t, b):
c = t + b
return struct.pack(">I", len(b)) + c + struct.pack(">I", binascii.crc32(c) & 0xffffffff)
open(path, "wb").write(
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b""))
def zoom(rows, n):
"""Nearest-neighbour upscale, so pixels stay square and countable."""
return [[px for px in r for _ in range(n)] for r in rows for _ in range(n)]
# The game palette, index-aligned with g_Palette in kissaten.c
PALETTE = [
(0x00, 0x00, 0x00), (0x24, 0x24, 0x24), (0x49, 0x24, 0x24), (0x92, 0x49, 0x24),
(0xB6, 0x6D, 0x49), (0xFF, 0xB6, 0x49), (0xFF, 0xDB, 0x92), (0x6D, 0x49, 0x49),
(0x49, 0x24, 0x92), (0x92, 0x49, 0xB6), (0xDB, 0x6D, 0x6D), (0xFF, 0x92, 0x49),
(0x6D, 0x24, 0x00), (0x49, 0x6D, 0xB6), (0xFF, 0xB6, 0x92), (0xFF, 0xFF, 0xDB),
]
SKY = {8, 9, 10, 11}
if __name__ == "__main__":
for path in sys.argv[1:]:
w, h, rows = read_png(path)
flat = [px for r in rows for px in r]
cols = {}
for px in flat:
cols[px[:3]] = cols.get(px[:3], 0) + 1
alphas = sorted({px[3] for px in flat})
exact = sum(n for c, n in cols.items() if c in PALETTE)
print(f"\n{path} {w}x{h}")
print(f" unique colours : {len(cols)}")
print(f" alpha values : {alphas if len(alphas) < 6 else str(alphas[:5]) + '...'}")
print(f" already in our palette: {len(cols) - len([c for c in cols if c not in PALETTE])}"
f" of {len(cols)} ({100*exact//len(flat)}% of pixels)")
print(" top 8 colours:")
for c, n in sorted(cols.items(), key=lambda kv: -kv[1])[:8]:
tag = " <- in palette" if c in PALETTE else ""
print(f" #{c[0]:02X}{c[1]:02X}{c[2]:02X} {n:5d} px {100*n//len(flat):3d}%{tag}")
@@ -0,0 +1,73 @@
#!/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)