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:
@@ -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}")
|
||||
Reference in New Issue
Block a user