Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5789ad8b3 | ||
|
|
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 |
|
After Width: | Height: | Size: 1.8 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
|
||||
|
Before Width: | Height: | Size: 650 B After Width: | Height: | Size: 650 B |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/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)
|
||||
|
||||
|
||||
# 3x5 glyphs, enough for indices and hex codes. No font library here.
|
||||
GLYPHS = {
|
||||
"0": "111101101101111", "1": "010110010010111", "2": "111001111100111",
|
||||
"3": "111001111001111", "4": "101101111001001", "5": "111100111001111",
|
||||
"6": "111100111101111", "7": "111001001001001", "8": "111101111101111",
|
||||
"9": "111101111001111", "A": "111101111101101", "B": "110101110101110",
|
||||
"C": "111100100100111", "D": "110101101101110", "E": "111100111100111",
|
||||
"F": "111100111100100", "#": "101111101111101", " ": "000000000000000",
|
||||
"-": "000000111000000",
|
||||
}
|
||||
|
||||
|
||||
def _text(px, x, y, s, col, scale=2):
|
||||
for ch in s.upper():
|
||||
g = GLYPHS.get(ch, GLYPHS[" "])
|
||||
for gy in range(5):
|
||||
for gx in range(3):
|
||||
if g[gy*3 + gx] == "1":
|
||||
for sy in range(scale):
|
||||
for sx in range(scale):
|
||||
yy, xx = y + gy*scale + sy, x + gx*scale + sx
|
||||
if 0 <= yy < len(px) and 0 <= xx < len(px[0]):
|
||||
px[yy][xx] = col
|
||||
x += 4 * scale
|
||||
|
||||
|
||||
def card(path, cols, names, idx, scale=2):
|
||||
"""Labelled reference: swatch, palette index, hex code, role name."""
|
||||
rowh, sww, pad = 7*scale + 8, 22*scale, 6
|
||||
W = sww + pad*2 + 4*scale*22
|
||||
H = rowh*len(cols) + pad*2
|
||||
bg, fg = (26, 26, 26), (255, 255, 219)
|
||||
px = [[bg]*W for _ in range(H)]
|
||||
for i, (c, n, ix) in enumerate(zip(cols, names, idx)):
|
||||
y = pad + i*rowh
|
||||
for yy in range(y, y + 7*scale):
|
||||
for xx in range(pad, pad + sww):
|
||||
px[yy][xx] = c
|
||||
label = f"{ix:02d} #{c[0]:02X}{c[1]:02X}{c[2]:02X}"
|
||||
_text(px, pad + sww + pad, y + scale, label, fg, scale)
|
||||
write_png(path, px)
|
||||
|
||||
|
||||
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)
|
||||
card(os.path.normpath(os.path.join(OUT, f"{stem}-card.png")), cols, names, idx)
|
||||
print(f"{stem}: {len(cols)} colours -> .gpl .ase .aco .act .png + -card.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)
|
||||