Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa8fa3ddf9 | ||
|
|
db7de024de |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 537 B |
|
After Width: | Height: | Size: 610 B |
|
After Width: | Height: | Size: 310 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
GIMP Palette
|
||||||
|
Name: Kissaten Yugure (full)
|
||||||
|
Columns: 16
|
||||||
|
#
|
||||||
|
0 0 0 transparent
|
||||||
|
36 36 36 outline
|
||||||
|
73 36 36 dark wood
|
||||||
|
146 73 36 mid wood
|
||||||
|
182 109 73 light wood
|
||||||
|
255 182 73 lamp glow
|
||||||
|
255 219 146 cream
|
||||||
|
109 73 73 muted wall
|
||||||
|
73 36 146 dusk deep
|
||||||
|
146 73 182 dusk violet
|
||||||
|
219 109 109 dusk rose
|
||||||
|
255 146 73 horizon amber
|
||||||
|
109 36 0 coffee
|
||||||
|
73 109 182 coat blue
|
||||||
|
255 182 146 skin
|
||||||
|
255 255 219 warm white
|
||||||
|
After Width: | Height: | Size: 650 B |
@@ -0,0 +1,15 @@
|
|||||||
|
GIMP Palette
|
||||||
|
Name: Kissaten Yugure (portrait)
|
||||||
|
Columns: 11
|
||||||
|
#
|
||||||
|
36 36 36 outline
|
||||||
|
73 36 36 dark wood
|
||||||
|
146 73 36 mid wood
|
||||||
|
182 109 73 light wood
|
||||||
|
255 182 73 lamp glow
|
||||||
|
255 219 146 cream
|
||||||
|
109 73 73 muted wall
|
||||||
|
109 36 0 coffee
|
||||||
|
73 109 182 coat blue
|
||||||
|
255 182 146 skin
|
||||||
|
255 255 219 warm white
|
||||||
|
After Width: | Height: | Size: 474 B |
@@ -1,9 +1,32 @@
|
|||||||
# Kissaten Yūgure — Art brief
|
# Kissaten Yūgure — Art brief
|
||||||
|
|
||||||
Pipeline: **you generate in PixelLab from the prompts below, drop the PNGs in
|
## Pipeline — export SVG, not PNG
|
||||||
`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
|
**Generate at high resolution, export as SVG, drop it in `art/`, and run:**
|
||||||
precedent in `projects/mazegame/convert_screens.py`.
|
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Emit the Kissaten palette in every format the art tools want.
|
||||||
|
|
||||||
|
python3 tools/make_palettes.py
|
||||||
|
|
||||||
|
.gpl GIMP / Inkscape / Krita / Aseprite
|
||||||
|
.ase Affinity (Photo, Designer, Publisher), Illustrator
|
||||||
|
.aco Affinity, Photoshop
|
||||||
|
.act Photoshop indexed-colour tables
|
||||||
|
.png 16x1 exact + a swatch strip, and a palette reference for generators
|
||||||
|
|
||||||
|
Two sets are written: the full 16 (scene work) and the 11-colour portrait
|
||||||
|
subset with the sky slots and the transparent index removed.
|
||||||
|
"""
|
||||||
|
import struct, os, sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from pixtool import PALETTE, write_png
|
||||||
|
|
||||||
|
NAMES = ["transparent", "outline", "dark wood", "mid wood", "light wood",
|
||||||
|
"lamp glow", "cream", "muted wall", "dusk deep", "dusk violet",
|
||||||
|
"dusk rose", "horizon amber", "coffee", "coat blue", "skin",
|
||||||
|
"warm white"]
|
||||||
|
PORTRAIT_IDX = [1, 2, 3, 4, 5, 6, 7, 12, 13, 14, 15]
|
||||||
|
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "art")
|
||||||
|
|
||||||
|
|
||||||
|
def gpl(path, cols, names, title):
|
||||||
|
L = [f"GIMP Palette", f"Name: {title}", f"Columns: {len(cols)}", "#"]
|
||||||
|
for c, n in zip(cols, names):
|
||||||
|
L.append(f"{c[0]:3d} {c[1]:3d} {c[2]:3d}\t{n}")
|
||||||
|
open(path, "w").write("\n".join(L) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def ase(path, cols, names):
|
||||||
|
blocks = b""
|
||||||
|
for c, n in zip(cols, names):
|
||||||
|
nm = n.encode("utf-16-be") + b"\x00\x00"
|
||||||
|
body = (struct.pack(">H", len(n) + 1) + nm + b"RGB "
|
||||||
|
+ struct.pack(">fff", c[0] / 255, c[1] / 255, c[2] / 255)
|
||||||
|
+ struct.pack(">H", 2)) # 2 = normal colour
|
||||||
|
blocks += struct.pack(">HI", 0x0001, len(body)) + body
|
||||||
|
open(path, "wb").write(b"ASEF" + struct.pack(">HHI", 1, 0, len(cols)) + blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def aco(path, cols, names):
|
||||||
|
# v1 then v2 concatenated — v1 for old readers, v2 carries the names
|
||||||
|
v1 = struct.pack(">HH", 1, len(cols))
|
||||||
|
for c in cols:
|
||||||
|
v1 += struct.pack(">HHHHH", 0, c[0] * 257, c[1] * 257, c[2] * 257, 0)
|
||||||
|
v2 = struct.pack(">HH", 2, len(cols))
|
||||||
|
for c, n in zip(cols, names):
|
||||||
|
v2 += struct.pack(">HHHHH", 0, c[0] * 257, c[1] * 257, c[2] * 257, 0)
|
||||||
|
v2 += struct.pack(">I", len(n) + 1) + n.encode("utf-16-be") + b"\x00\x00"
|
||||||
|
open(path, "wb").write(v1 + v2)
|
||||||
|
|
||||||
|
|
||||||
|
def act(path, cols):
|
||||||
|
d = bytearray()
|
||||||
|
for c in cols:
|
||||||
|
d += bytes(c)
|
||||||
|
d += bytes(768 - len(d))
|
||||||
|
open(path, "wb").write(bytes(d))
|
||||||
|
|
||||||
|
|
||||||
|
def swatches(path, cols, sw=32, h=64):
|
||||||
|
write_png(path, [[c for c in cols for _ in range(sw)]] * h)
|
||||||
|
|
||||||
|
|
||||||
|
def emit(stem, idx, title):
|
||||||
|
cols = [PALETTE[i] for i in idx]
|
||||||
|
names = [NAMES[i] for i in idx]
|
||||||
|
p = lambda ext: os.path.normpath(os.path.join(OUT, f"{stem}.{ext}"))
|
||||||
|
gpl(p("gpl"), cols, names, title)
|
||||||
|
ase(p("ase"), cols, names)
|
||||||
|
aco(p("aco"), cols, names)
|
||||||
|
act(p("act"), cols)
|
||||||
|
swatches(p("png"), cols)
|
||||||
|
print(f"{stem}: {len(cols)} colours -> .gpl .ase .aco .act .png")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
emit("kissaten-full", list(range(16)), "Kissaten Yugure (full)")
|
||||||
|
emit("kissaten-portrait", PORTRAIT_IDX, "Kissaten Yugure (portrait)")
|
||||||
|
# exact 1px-per-entry image, for generators that take a palette image
|
||||||
|
write_png(os.path.normpath(os.path.join(OUT, "palette.png")), [list(PALETTE)])
|
||||||
|
print("palette.png: 16x1 exact")
|
||||||
@@ -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)
|
||||||