Detailed imp-style sprites for all 30 creatures in the high-res style; sprite texture in bands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-07 23:21:58 +01:00
1 parent 210f17773a
commit 01e5326721
132 files changed
+1295 -43

No files matched your search

+343 -14
View File
@@ -7,6 +7,8 @@ Usage:
pixelize_sprite.py raw.png out_prefix [--grid ROWSxCOLS | --views N] --size 32
[--crop x0,y0,x1,y1] [--colors 16] [--step S] [--feet Y]
[--palette sheet.png] [--add-colors R,G,B;R,G,B] [--measure]
[--outline-weight W] [--bg auto|magenta|R,G,B] [--split auto|cells]
[--row-counts 4,3] [--pick 0,1,2,m5,3] [--no-despeckle]
--grid / --views layout of the figures in the raw sheet (--views N = one row of N)
--crop fractions of the image (0..1) to use only a part of it
@@ -19,6 +21,16 @@ Usage:
(frames of one character keep exactly the same colours)
--add-colors extra palette colours (e.g. the light iron of a tool the reference lacks)
--outline-weight weight of near-black pixels when sampling (below 1: thin details survive)
--bg background key: auto (default; the colours of the sheet's border, only
colours close to them are removed, so purple figures on a green sheet
survive), magenta (the old fixed magenta/purple test) or R,G,B
--split auto (default: figures found by the empty gaps between them, falling back to
equal cells) or cells (equal cells of the grid)
--no-despeckle keep single pixels without any neighbour (removed by default)
--row-counts figures per row when the rows differ (e.g. 4,3 with --grid 2x4)
--pick build the output row from these figures (indices in reading order, an "m"
in front mirrors it), e.g. 0,1,2,m5,3; the default step uses only these
Frame or grid lines between cells (long straight lines in one flat colour) are removed.
Writes out_prefix-<size>.png (cells in the grid's order, row by row; transparent) and
out_prefix-<size>-preview.png (scaled up 8x on a grey background for viewing). Needs Pillow.
@@ -31,28 +43,328 @@ from PIL import Image
def is_background(p):
"""The old fixed test: magenta and its darker/greyer purple variants. Only used with
--bg magenta (it also removes purple and pink figures)."""
r, g, b = p[:3]
# magenta and its darker/greyer purple variants (sheet background and frame lines)
magenta = r > g + 50 and b > g + 50
frame = abs(r - b) < 40 and r > g + 12 and b > g + 12 and r > 55
return magenta or frame
def figure_boxes(img, rows, cols):
def _dist2(p, q):
return (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2
def _seg_dist2(p, a, b):
"""Squared distance of colour p to the segment a-b (blends of a and b lie on it)."""
d = [b[i] - a[i] for i in range(3)]
dd = sum(v * v for v in d) or 1
t = max(0.0, min(1.0, sum((p[i] - a[i]) * d[i] for i in range(3)) / dd))
return sum((p[i] - a[i] - t * d[i]) ** 2 for i in range(3))
def background_colours(img):
"""The sheet's background colours, sampled from the image border (a few px inside, so a
dark frame around the sheet does not count): every bright colour that covers a good part
of the border. Usually one (flat magenta or green), sometimes two (cells in two shades)."""
w, h = img.size
px = img.load()
mask = [[not is_background(px[x, y]) for x in range(w)] for y in range(h)]
samples = []
for d in (2, 6, 12):
samples += [px[x, min(d, h - 1)] for x in range(0, w, 2)]
samples += [px[x, max(h - 1 - d, 0)] for x in range(0, w, 2)]
samples += [px[min(d, w - 1), y] for y in range(0, h, 2)]
samples += [px[max(w - 1 - d, 0), y] for y in range(0, h, 2)]
q = Counter((p[0] // 32, p[1] // 32, p[2] // 32) for p in samples)
bgs = []
for k, n in q.most_common():
if n < 0.08 * len(samples):
break
ps = [p for p in samples if (p[0] // 32, p[1] // 32, p[2] // 32) == k]
m = tuple(sum(p[i] for p in ps) / len(ps) for i in range(3))
if max(m) >= 120: # dark border colours are frames or figures, not the background
bgs.append(m)
return bgs or [(255.0, 0.0, 255.0)]
BG_COLOURS = []
def bg_tinted(p):
"""True if p has the background's hue (e.g. magenta: red and blue clearly above green):
blends of the key colour with thin figure parts, which must not become a palette colour."""
for b in BG_COLOURS:
on = [p[i] for i in range(3) if b[i] >= 128]
off = [p[i] for i in range(3) if b[i] < 128]
if on and off and min(on) > max(off) + 60:
return True
return False
def background_mask(img, mode="auto", tol=70):
"""True where the pixel is background. mode: "auto" (colours from the border, only colours
near them are keyed, so purple figures on green survive), "magenta" (the old fixed test)
or "R,G,B" (an explicit key colour). Thin frame lines between cells are removed too."""
w, h = img.size
px = img.load()
data = list(img.get_flattened_data())
if mode == "magenta":
bg = [is_background(p) for p in data]
else:
bgs = background_colours(img) if mode == "auto" else [tuple(float(v) for v in mode.split(","))]
BG_COLOURS[:] = bgs
t2 = tol * tol
cache = {}
bg = []
for p in data:
v = cache.get(p)
if v is None:
# the background or a blend of it with the dark outline (anti-aliased edges;
# only the lighter half, the darker half belongs to the outline)
v = cache[p] = any(_dist2(p, b) < t2 or (_seg_dist2(p, b, OUTLINE) < 30 * 30
and _dist2(p, b) < _dist2(p, OUTLINE))
for b in bgs)
bg.append(v)
mask = [bg[y * w:(y + 1) * w] for y in range(h)]
remove_frame_lines(px, mask, w, h, [] if mode == "magenta" else bgs)
return mask
OUTLINE = (16, 12, 20)
def remove_frame_lines(px, mask, w, h, bgs=()):
"""Frame/grid lines: a column (row) where almost every pixel is one uniform colour that is
not background, the figures never cover that much of a line in one flat colour. Those
pixels, similar ones and blends with the background up to 8 px beside them become
background."""
def lines(n, m, get, isbg):
found = []
for i in range(n):
fg = [get(i, j) for j in range(m) if not isbg(i, j)]
if len(fg) < 0.97 * m:
continue
med = sorted(fg, key=sum)[len(fg) // 2]
if sum(1 for p in fg if _dist2(p, med) < 45 * 45) >= 0.97 * m:
found.append((i, med))
return found
cols = lines(w, h, lambda x, y: px[x, y], lambda x, y: mask[y][x])
rows = lines(h, w, lambda y, x: px[x, y], lambda y, x: mask[y][x])
def frameish(p, med):
return _dist2(p, med) < 70 * 70 or any(_seg_dist2(p, b, med) < 35 * 35 for b in bgs)
for x, med in cols:
for xx in range(max(0, x - 8), min(w, x + 9)):
for y in range(h):
if frameish(px[xx, y], med):
mask[y][xx] = True
for y, med in rows:
for yy in range(max(0, y - 8), min(h, y + 9)):
for x in range(w):
if frameish(px[x, yy], med):
mask[yy][x] = True
def split_runs(occ, lo, hi, n, min_gap):
"""Split [lo, hi) into n figure spans from an occupancy profile: runs of occupied
positions, the closest neighbours merged until n remain. None if fewer than n runs."""
runs, start = [], None
for i in range(lo, hi):
if occ[i] and start is None:
start = i
elif not occ[i] and start is not None:
runs.append([start, i])
start = None
if start is not None:
runs.append([start, hi])
# glue runs separated by tiny gaps (a detached spear tip, a sparkle)
merged = []
for r in runs:
if merged and r[0] - merged[-1][1] < min_gap:
merged[-1][1] = r[1]
else:
merged.append(r)
# drop specks far too small to be a figure
if merged:
big = max(r[1] - r[0] for r in merged)
merged = [r for r in merged if r[1] - r[0] >= 0.08 * big]
while len(merged) > n:
# merge the pair with the smallest gap
i = min(range(len(merged) - 1), key=lambda k: merged[k + 1][0] - merged[k][1])
merged[i] = [merged[i][0], merged[i + 1][1]]
del merged[i + 1]
return merged if len(merged) == n else None
def trim_slivers(mask, x0, x1, y0, y1, min_gap):
"""In equal cells a neighbour's spear tip or wing edge can reach over the cell border:
small runs of columns at the cell's left or right edge, separated from the figure by a gap,
are cleared."""
occ = [sum(mask[y][x] for y in range(y0, y1)) > 3 for x in range(x0, x1)]
runs, start = [], None
for i, o in enumerate(occ + [False]):
if o and start is None:
start = i
elif not o and start is not None:
if runs and start - runs[-1][1] < min_gap:
runs[-1][1] = i
else:
runs.append([start, i])
start = None
if len(runs) < 2:
return
big = max(r[1] - r[0] for r in runs)
for r in (runs[0], runs[-1]):
edge = r[0] == 0 or r[1] == len(occ)
if edge and r[1] - r[0] < 0.25 * big:
for x in range(x0 + r[0], x0 + r[1]):
for y in range(y0, y1):
mask[y][x] = False
def components(mask, x0, x1, y0, y1, n, f=4):
"""Find n figures in a band by connected parts (on a grid f times coarser): the n biggest
parts are the figures, smaller parts (a detached halo, a spear tip) join the nearest
figure, specks are dropped. Returns [(box, test(x, y))] or None."""
gw, gh = (x1 - x0 + f - 1) // f, (y1 - y0 + f - 1) // f
occ = [[False] * gw for _ in range(gh)]
for y in range(y0, y1):
row = mask[y]
orow = occ[(y - y0) // f]
for x in range(x0, x1):
if row[x]:
orow[(x - x0) // f] = True
lab = [[0] * gw for _ in range(gh)]
parts = [] # [count, gx0, gy0, gx1, gy1]
for sy in range(gh):
for sx in range(gw):
if occ[sy][sx] and not lab[sy][sx]:
k = len(parts) + 1
lab[sy][sx] = k
stack, part = [(sx, sy)], [0, sx, sy, sx + 1, sy + 1]
while stack:
cx, cy = stack.pop()
part[0] += 1
part[1], part[2] = min(part[1], cx), min(part[2], cy)
part[3], part[4] = max(part[3], cx + 1), max(part[4], cy + 1)
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
nx, ny = cx + dx, cy + dy
if 0 <= nx < gw and 0 <= ny < gh and occ[ny][nx] and not lab[ny][nx]:
lab[ny][nx] = k
stack.append((nx, ny))
parts.append(part)
if len(parts) < n:
return None
order = sorted(range(len(parts)), key=lambda i: -parts[i][0])
big = order[:n]
# the n-th biggest part must be a real figure, not a stray piece of a merged pair
if parts[big[-1]][0] < 0.25 * parts[big[0]][0]:
return None
owner = {i + 1: i for i in big}
for i in order[n:]:
if parts[i][0] < 0.01 * parts[big[0]][0]:
continue
p = parts[i]
cx, cy = (p[1] + p[3]) / 2, (p[2] + p[4]) / 2
def gap(j):
q = parts[j]
return max(q[1] - cx, cx - q[3], 0) + max(q[2] - cy, cy - q[4], 0)
owner[i + 1] = min(big, key=gap)
figs = []
for j in sorted(big, key=lambda j: parts[j][1]):
mine = {k for k, v in owner.items() if v == j}
ps_ = [parts[k - 1] for k in mine]
box = (x0 + min(p[1] for p in ps_) * f, y0 + min(p[2] for p in ps_) * f,
min(x1, x0 + max(p[3] for p in ps_) * f), min(y1, y0 + max(p[4] for p in ps_) * f))
def test(x, y, mine=mine):
return x0 <= x < x1 and y0 <= y < y1 and mask[y][x] and lab[(y - y0) // f][(x - x0) // f] in mine
figs.append((box, test))
return figs
def valley_split(cnt, n, min_gap):
"""Figures that touch (a pickaxe reaching into the neighbour): split the widest run of
occupied columns at its thinnest column (in its middle part) until there are n runs."""
runs, start = [], None
for i, c in enumerate(cnt + [0]):
if c > 3 and start is None:
start = i
elif c <= 3 and start is not None:
if runs and start - runs[-1][1] < min_gap:
runs[-1][1] = i
else:
runs.append([start, i])
start = None
if not runs or len(runs) > n:
return None
while len(runs) < n:
k = max(range(len(runs)), key=lambda i: runs[i][1] - runs[i][0])
a, b = runs[k]
lo, hi = a + (b - a) // 5, b - (b - a) // 5
cut = min(range(lo, hi), key=lambda x: cnt[x])
runs[k:k + 1] = [[a, cut], [cut, b]]
return [tuple(r) for r in runs]
def tight_box(mask, x0, y0, x1, y1, test=None):
test = test or (lambda x, y: mask[y][x])
xs = [x for x in range(x0, x1) if sum(test(x, y) for y in range(y0, y1)) > 3]
ys = [y for y in range(y0, y1) if sum(test(x, y) for x in range(x0, x1)) > 3]
return (min(xs), min(ys), max(xs) + 1, max(ys) + 1)
FIGURE_TESTS = [] # per box: None (any figure pixel) or a test(x, y) for that figure only
def figure_boxes(img, rows, cols, bg="auto", split="auto", row_counts=None):
"""Background mask and one box per figure. auto split: by the empty columns between the
figures, else by connected parts (figures whose tools overlap in x), else equal cells."""
w, h = img.size
mask = [[not v for v in row] for row in background_mask(img, bg)]
boxes = []
FIGURE_TESTS[:] = []
for r in range(rows):
y0, y1 = r * h // rows, (r + 1) * h // rows
for c in range(cols):
x0, x1 = c * w // cols, (c + 1) * w // cols
xs = [x for x in range(x0, x1) if sum(mask[y][x] for y in range(y0, y1)) > 3]
ys = [y for y in range(y0, y1) if sum(mask[y][x] for x in range(x0, x1)) > 3]
boxes.append((min(xs), min(ys), max(xs) + 1, max(ys) + 1))
n = row_counts[r] if row_counts else cols
spans = None
if split == "auto":
occ = [sum(mask[y][x] for y in range(y0, y1)) > 3 for x in range(w)]
spans = split_runs(occ, 0, w, n, max(3, w // 200))
if spans is None:
figs = components(mask, 0, w, y0, y1, n)
if figs:
for box, test in figs:
boxes.append(tight_box(mask, *box, test))
FIGURE_TESTS.append(test)
continue
spans = valley_split([sum(mask[y][x] for y in range(y0, y1)) for x in range(w)],
n, max(3, w // 200))
if spans is None:
spans = [(c * w // n, (c + 1) * w // n) for c in range(n)]
for (x0, x1) in spans:
trim_slivers(mask, x0, x1, y0, y1, max(3, w // 200))
boxes.append(tight_box(mask, x0, y0, x1, y1))
FIGURE_TESTS.append(None)
return mask, boxes
def despeckle(grid):
"""Remove single opaque pixels with no opaque 8-neighbour (stray sparkles, key leftovers)."""
n = len(grid)
out = [row[:] for row in grid]
for y in range(n):
for x in range(n):
if grid[y][x] and not any(
0 <= y + dy < n and 0 <= x + dx < n and grid[y + dy][x + dx]
for dy in (-1, 0, 1) for dx in (-1, 0, 1) if dy or dx):
out[y][x] = None
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("raw")
@@ -68,6 +380,11 @@ def main():
ap.add_argument("--add-colors", default="")
ap.add_argument("--outline-weight", type=float, default=1.0)
ap.add_argument("--measure", action="store_true")
ap.add_argument("--bg", default="auto")
ap.add_argument("--split", default="auto", choices=["auto", "cells"])
ap.add_argument("--no-despeckle", action="store_true")
ap.add_argument("--row-counts", default=None)
ap.add_argument("--pick", default=None)
a = ap.parse_args()
rows, cols = (int(v) for v in a.grid.split("x")) if a.grid else (1, a.views)
feet = a.size - 1 if a.feet is None else a.feet
@@ -77,17 +394,20 @@ def main():
f = [float(v) for v in a.crop.split(",")]
w, h = img.size
img = img.crop((int(f[0] * w), int(f[1] * h), int(f[2] * w), int(f[3] * h)))
mask, boxes = figure_boxes(img, rows, cols)
row_counts = [int(v) for v in a.row_counts.split(",")] if a.row_counts else None
mask, boxes = figure_boxes(img, rows, cols, a.bg, a.split, row_counts)
if a.measure:
for i, (x0, y0, x1, y1) in enumerate(boxes):
print(f"figure {i} (row {i // cols}, column {i % cols}): {x1 - x0} x {y1 - y0} source pixels")
print(f"figure {i}: x {x0}-{x1}, y {y0}-{y1}: {x1 - x0} x {y1 - y0} source pixels")
return
px = img.load()
# default: one scale for all figures, the tallest or widest fills the cell
step = a.step or max(max(b[3] - b[1], b[2] - b[0]) for b in boxes) / a.size
used_boxes = [boxes[int(t.lstrip("m"))] for t in a.pick.split(",")] if a.pick else boxes
step = a.step or max(max(b[3] - b[1], b[2] - b[0]) for b in used_boxes) / a.size
grids = []
for (x0, y0, x1, y1) in boxes:
for i, (x0, y0, x1, y1) in enumerate(boxes):
inside = FIGURE_TESTS[i] or (lambda x, y: mask[y][x])
gw, gh = round((x1 - x0) / step), round((y1 - y0) / step)
grid = [[None] * a.size for _ in range(a.size)]
ox, oy = (a.size - gw) // 2, feet + 1 - gh # centred, the feet on the feet row
@@ -100,18 +420,27 @@ def main():
for sx in range(4):
x = int(cx0 + (sx + 0.5) * step / 4)
y = int(cy0 + (sy + 0.5) * step / 4)
if x >= img.size[0] or y >= img.size[1] or not mask[y][x]:
if x >= img.size[0] or y >= img.size[1] or not inside(x, y):
bg += 1
else:
r, g, b = px[x, y]
if bg_tinted((r, g, b)):
continue # a blend with the background: no vote at all
fg += 1
# the outline counts a little less, so thin coloured parts
# (a tool's iron, a handle) are not swallowed by their outline
votes[(r // 8 * 8, g // 8 * 8, b // 8 * 8)] += a.outline_weight if r + g + b < 90 else 1
if votes and fg >= bg and 0 <= gy + oy < a.size and 0 <= gx + ox < a.size:
grid[gy + oy][gx + ox] = votes.most_common(1)[0][0]
grids.append(grid)
grids.append(grid if a.no_despeckle else despeckle(grid))
if a.pick:
# a new single row from chosen figures; "m" in front mirrors the figure
picked = []
for t in a.pick.split(","):
g = grids[int(t.lstrip("m"))]
picked.append([row[::-1] for row in g] if t.startswith("m") else g)
grids, rows, cols = picked, 1, len(picked)
used = [c for g in grids for row in g for c in row if c]
counts = Counter(used)
dist = lambda p, q: sum((x - y) ** 2 for x, y in zip(p, q)) ** 0.5