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 0000000..6219f4f Binary files /dev/null and b/projects/kissaten_yugure/art/aki-neutral-48-x6.png differ diff --git a/projects/kissaten_yugure/art/aki-neutral-48.png b/projects/kissaten_yugure/art/aki-neutral-48.png new file mode 100644 index 0000000..421d37f Binary files /dev/null and b/projects/kissaten_yugure/art/aki-neutral-48.png differ 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 0000000..9ec8176 Binary files /dev/null and b/projects/kissaten_yugure/art/kissaten-full.act differ diff --git a/projects/kissaten_yugure/art/kissaten-portrait.act b/projects/kissaten_yugure/art/kissaten-portrait.act new file mode 100644 index 0000000..fda7d01 Binary files /dev/null and b/projects/kissaten_yugure/art/kissaten-portrait.act differ 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)