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:
1 parent
210f17773a
commit
01e5326721
132 files changed
+1295
-43
No files matched your search
+343
-14
@@ -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
|
||||
|
||||
Reference in new issue
Block a user