From db7de024deb13389169a633f2556b7fa8e6a307a Mon Sep 17 00:00:00 2001 From: Jurjen Ladenius Date: Wed, 29 Jul 2026 20:46:12 +0200 Subject: [PATCH] Art: SVG -> 48x48 portrait converter, and it works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../kissaten_yugure/art/aki-neutral-48-x6.png | Bin 0 -> 1700 bytes .../kissaten_yugure/art/aki-neutral-48.png | Bin 0 -> 537 bytes projects/kissaten_yugure/art/aki-neutral.svg | 576 ++++++++++++++++++ .../kissaten_yugure/art/kissaten-full.act | Bin 0 -> 768 bytes .../kissaten_yugure/art/kissaten-portrait.act | Bin 0 -> 768 bytes projects/kissaten_yugure/docs/ART.md | 31 +- projects/kissaten_yugure/tools/pixtool.py | 117 ++++ .../kissaten_yugure/tools/svg2portrait.py | 73 +++ 8 files changed, 793 insertions(+), 4 deletions(-) create mode 100644 projects/kissaten_yugure/art/aki-neutral-48-x6.png create mode 100644 projects/kissaten_yugure/art/aki-neutral-48.png create mode 100644 projects/kissaten_yugure/art/aki-neutral.svg create mode 100644 projects/kissaten_yugure/art/kissaten-full.act create mode 100644 projects/kissaten_yugure/art/kissaten-portrait.act create mode 100644 projects/kissaten_yugure/tools/pixtool.py create mode 100644 projects/kissaten_yugure/tools/svg2portrait.py diff --git a/projects/kissaten_yugure/art/aki-neutral-48-x6.png b/projects/kissaten_yugure/art/aki-neutral-48-x6.png new file mode 100644 index 0000000000000000000000000000000000000000..6219f4f6b929d06d7e053d4ff2f42bee22c6cbf5 GIT binary patch literal 1700 zcmeAS@N?(olHy`uVBq!ia0y~yU{nBM4kiW$hS?7-uQM>PWqZ0fhE&{od-wFjR|W#C z0ZodM?9N6kKy)~IpVsN%6)xM?o`1RP^ugqmxsh8;FXisO@2a9{?ySHxgCju+Of;O7 zm)vM$dVqnM$AHlXM8cTei$2}h7W?k>l%EU|2@Q;FK!OuWgE${FI2a+SeHelv(u~U( zqSzi{BTO_`s3TW4H*%}BC~*cpX z?#KZNXmlsKu}nL@iDg-#y}TtvN(5Ou<2UOK|HN6oo4r(ILADNNO3v2D-Jcq!3UAak zef!mn?P=+!Y5&ii62fX#`+{`;l^O}An(ObB%{7i>LUkzA$r1B&e`Qbq|IF~j?&lgG z0{3%cHR^zyV!6Z9&-YaGe}yf5TNige_SQQ|>?#>nGk)7~zf7}!nRS9=xdiK*1fGeo zL4qVe`2yVYj{34?MGolf~-X_67fgUKeEDPAlodZuW-wtZT<=f9^e%x*M1AR!>e< zuS3o`$O^?m{}yZUtHn#s=yk{H#oyTi>8jz6Rz5U2-nLM=2&*f6wC$(G@Xj;yLpB*H zIwPVV|BITITd`=HfyBg99dqEh967*eub(RamFdCfsyg4>-xdVUS;S|OB|Sg)Ap?>@ z@T9!WMQ;6iOM$PI^=D_gt6pW$elH*gGYnCP9AMcn>C{1%RSabg>zHmhS}}(`dvH34 z1#SwG$v3XN{kq45$y>SngV%zWbyDmsSmWcsvSmev8x&f^nOC1!bGid-hW5u74kO`=% z3L1=OHr=%;3Jl)U7AyzNvgqM^c}oAU;7R63Z?BUnwRg4)gM}1oVwP5^)&2 z7B!@}1SOtp|G67Bc*fhTkKE3aQPar;3PfZz;1sQUZO8hDBCR{OuT^3YMzRGKm?rN& z-+EoF^OhAUj^GL>-)lI#@y7QDkir~n0njBt8i@XX=I& zKsiSqL|A#y8AJc=WsP8>pyGKNKnh0|gyZJAQOp&o%CZ4nH$dk;?9ga3xsFaT6U`kp z%+wE3BB-0?#<%qI$_d9oELelEeUE{7dfgbh%4`mzMGDCnDviszpmb(c`!yGojwF4{ b*yZ~Ndr-e!E@A_=00000NkvXXu0mjf$OH3} literal 0 HcmV?d00001 diff --git a/projects/kissaten_yugure/art/aki-neutral.svg b/projects/kissaten_yugure/art/aki-neutral.svg new file mode 100644 index 0000000..fd42fb9 --- /dev/null +++ b/projects/kissaten_yugure/art/aki-neutral.svg @@ -0,0 +1,576 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/projects/kissaten_yugure/art/kissaten-full.act b/projects/kissaten_yugure/art/kissaten-full.act new file mode 100644 index 0000000000000000000000000000000000000000..9ec817675988b86eaac12242a37b983de9b4dd10 GIT binary patch literal 768 zcmeIuffN8y5CXtpk_jf6V3Jvw&g0%6gaJTOWW-vlcCjQT>e-7-$X&A?o;UELfe*j; B77hRa literal 0 HcmV?d00001 diff --git a/projects/kissaten_yugure/art/kissaten-portrait.act b/projects/kissaten_yugure/art/kissaten-portrait.act new file mode 100644 index 0000000000000000000000000000000000000000..fda7d015c489d22c8fffb76a4d7cef36bdea4bf0 GIT binary patch literal 768 vcmeIufe-*d5CXu1NhX+Nf=T9JdXJx5gatsrM8t+&ixh@R<96rVz`p|zp9v4| literal 0 HcmV?d00001 diff --git a/projects/kissaten_yugure/docs/ART.md b/projects/kissaten_yugure/docs/ART.md index dbc6187..65b4d61 100644 --- a/projects/kissaten_yugure/docs/ART.md +++ b/projects/kissaten_yugure/docs/ART.md @@ -1,9 +1,32 @@ # Kissaten Yūgure — Art brief -Pipeline: **you generate in PixelLab from the prompts below, drop the PNGs in -`art/`, and I convert them to SCREEN 5 data.** I'll write the converter -(`tools/convert_art.py`) once the first real images land — there's working -precedent in `projects/mazegame/convert_screens.py`. +## Pipeline — export SVG, not PNG + +**Generate at high resolution, export as SVG, drop it in `art/`, and run:** + +``` +python3 tools/svg2portrait.py art/aki-neutral.svg +``` + +That writes `art/aki-neutral-48.png` (the real asset) and +`art/aki-neutral-48-x6.png` (a 6× preview for judging). + +**Export SVG, never a resized bitmap.** This was learned the hard way. A +48×48 PNG produced by resizing in Photoshop came back with **1330 unique +colours** in 2304 pixels — bicubic resampling turns every flat region into a +gradient, and reducing *that* to 11 colours speckles horribly, worst on large +flat areas like the blazer and background. + +The SVG from Magnific is flat-shaded vector: ~550 paths, solid fills, a +handful of gradients. The converter rasterises it at 768×768 (16× the target) +via `qlmanage`, then takes the **most common colour in each 16×16 block** — +a true pixelate with no blending at all. Measured on Aki: 19 colours after +the pixelate, 10 after the palette reduction, and the two are nearly +indistinguishable. **The 11-colour portrait palette is sufficient for this +art** — that was the open question and it's settled. + +If you must work from bitmaps, the same logic applies: resize only by exact +integer factors with nearest-neighbour, never bicubic. --- diff --git a/projects/kissaten_yugure/tools/pixtool.py b/projects/kissaten_yugure/tools/pixtool.py new file mode 100644 index 0000000..79aff5a --- /dev/null +++ b/projects/kissaten_yugure/tools/pixtool.py @@ -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}") diff --git a/projects/kissaten_yugure/tools/svg2portrait.py b/projects/kissaten_yugure/tools/svg2portrait.py new file mode 100644 index 0000000..cf43740 --- /dev/null +++ b/projects/kissaten_yugure/tools/svg2portrait.py @@ -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)