Compare commits

..
5 Commits
Author SHA1 Message Date
jurjen.ladeniusandClaude Opus 5 d5789ad8b3 Art: labelled palette reference cards
make_palettes.py now also emits kissaten-full-card.png and
kissaten-portrait-card.png — each swatch shown with its palette index and
hex code, so a colour can be identified while hand-editing without
counting along an anonymous strip.

Text is drawn with a hand-rolled 3x5 bitmap font covering digits, A-F and
'#'; there is no font library available here.

Drops palette_ref.png, superseded by kissaten-full.png.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:12:26 +02:00
jurjen.ladeniusandClaude Opus 5 aa8fa3ddf9 Art: emit the palette as .gpl/.ase/.aco as well as .act
The .act files only helped Photoshop. Affinity imports .ase and .aco;
Inkscape, GIMP, Krita and Aseprite want .gpl. tools/make_palettes.py now
generates all four plus swatch PNGs, for both the full 16 and the
11-colour portrait subset, all derived from the same PALETTE table shared
with kissaten.c so nothing can drift.

Colour names ride along in .gpl/.ase/.aco, so the swatches show up labelled
("skin", "lamp glow", "dusk violet") rather than as anonymous chips.

.ase output verified by reading it back and checking all 11 colours
round-trip exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:34:14 +02:00
jurjen.ladeniusandClaude Opus 5 db7de024de 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>
2026-07-29 20:46:12 +02:00
jurjenwerk 1c0d2d9a88 Add AKI images 2026-07-28 11:41:10 +02:00
jurjen.ladeniusandClaude Opus 5 60c9e13fa8 Art brief: give each asset type its own palette block
The palette constraint is not uniform across assets, so a single generic
line was not enough:

- Portraits list only the 11 non-sky colours, since a face never shows sky;
  omitting the sky slots is safer than asking a generator to avoid them.
  Adds the flat #492424 backdrop requirement (HMMM blits have no
  transparency, so whatever surrounds the head gets copied literally).
- The background block is split by region: four sky colours inside the
  window opening, eleven interior colours everywhere else, with the frame
  and mullions counted as interior wood. Also reserves the top 16 and
  bottom 64 rows the game draws over.
- Sprites take no palette at all — they are hardware sprites coloured at
  runtime, so the art only defines shape.

Notes that the available clothing colours are few enough that characters
need distinct ones or they blur together in the dialogue box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 10:23:34 +02:00
24 changed files with 1020 additions and 12 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

+88 -12
View File
@@ -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.
---
@@ -86,13 +109,28 @@ Character notes in `SCRIPT.md` §1. Written as a girl there; that's one word to
change if you'd rather — the dialogue beats themselves use no gendered
pronouns.
**Common style stem** — prepend to all three:
### Portrait palette block — paste into every portrait prompt
Portraits never show sky, so the four sky slots are simply absent from the
list. That is safer than asking the generator to avoid them.
```
Use only these 11 colours and no others:
#242424 #492424 #924924 #B66D49 #FFB649 #FFDB92 #6D4949 #6D2400
#496DB6 #FFB692 #FFFFDB
Skin: #FFDB92 highlight, #FFB692 base, #B66D49 shadow, #924924 deep shadow.
Hair and outlines: #242424 or #492424.
Eye whites and highlights: #FFFFDB.
Fill the entire background behind the head with flat #492424.
Do not use pure black #000000 anywhere.
```
**Common style stem** — prepend to all three, then append the palette block:
> 48x48 pixel art portrait, 1987 Japanese anime style as drawn for MSX2 or
> PC-88 games, head and shoulders, three-quarter view, hard-edged pixels, no
> anti-aliasing, limited 16 colour palette of warm browns oranges and creams,
> flat cel shading, dark navy school uniform, simple readable silhouette,
> plain dark background
> anti-aliasing, flat cel shading, simple readable silhouette, flat single
> colour background
**Aki — neutral**
> ...17 year old Japanese high school student, straight dark bob-length hair,
@@ -112,7 +150,11 @@ pronouns.
## 4. Prompts — the rest of the cast (M8, not urgent)
Same style stem, swapping the uniform line.
Same style stem and the **same portrait palette block from §3**, swapping the
clothing line. Note the clothing colours available are only `#496DB6` (blue),
`#6D4949` (muted brown-grey), `#6D2400` (dark red-brown), `#492424` and
`#242424` — so give each character a distinct one or they'll blur together in
the dialogue box.
**Ōta-san** — retired stationmaster, ~70, black coffee, punctual.
> elderly Japanese man in his seventies, close-cropped grey hair, deeply
@@ -158,10 +200,38 @@ Composition is fixed by the code — see `DESIGN.md` §4 and `DrawScene()`:
> violet and amber dusk sky, wooden door on the right, hanging lamp with warm
> glow above the counter, wood panelling, shelves with glassware, cosy and
> quiet, 1987 MSX2 game art, hard-edged pixels, no anti-aliasing, flat cel
> shading, warm brown and orange interior with the sky in violet and amber
> shading
**The sky through that window must use only slots 811.** Everything else in
the room must avoid them. That's the whole seasonal system riding on one rule.
### Background palette block — this one is split by region
Unlike the portraits, the background needs all 15 colours — but **which
colours may appear is decided by where you are in the image**. This is the
constraint that makes seasons free, and it is the easiest one to get wrong.
```
Use only these 15 colours and no others.
For the sky visible through the window, use ONLY these four:
#492492 #9249B6 #DB6D6D #FF9249
For everything else — walls, floor, counter, door, window frame, lamp,
shelves, glassware, every object in the room — use ONLY these eleven:
#242424 #492424 #924924 #B66D49 #FFB649 #FFDB92 #6D4949 #6D2400
#496DB6 #FFB692 #FFFFDB
The four sky colours must appear nowhere except inside the window opening.
The window frame, mullions and sill are wood and use the interior colours.
Do not use pure black #000000 anywhere.
Leave the top 16 rows and the bottom 64 rows flat #242424 — the game draws
its own status bar and dialogue box over them.
```
**Why this one rule matters more than the rest of this document:** each season
and hour of day is a 32-byte palette swap on the same bitmap. Repaint slots
811 and the sky goes from dusk to noon to rain for free. But if a chair leg
or a coffee cup is painted in `#9249B6`, that chair leg changes colour every
time the sun moves — and you won't find out until M7, long after the art is
finished.
---
@@ -172,5 +242,11 @@ is the same silhouette dilated by one pixel, drawn behind. Sprite mode 2, so
each layer is a single colour — these are shapes, not shaded art. Current
placeholders are in `kissaten.c` as `g_SprCustFill` / `g_SprCustLine`.
**Palette does not apply here in the usual sense.** These are hardware
sprites, not bitmap art: each layer is one flat colour chosen at runtime by
the code, so the image only needs to define *shape*. Draw them as pure
black-on-white silhouettes and I'll assign colours — currently `#496DB6` for
the fill layer and `#242424` for the outline, per customer.
Worth doing by hand rather than generating; a 16×16 single-colour silhouette
is faster to draw than to prompt for.
@@ -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")
+117
View File
@@ -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)