Walk, die and attack animations for all creatures (sheets, prompts and scripts)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 01:00:31 +01:00
1 parent 8c5afe872f
commit 48c20fabbc
405 files changed
+1339 -39

No files matched your search

@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""anim_manifest.py NOTES: write anim/manifest.json (accepted animations per creature) and the
per-sheet status/notes into anim/progress.json. NOTES lines: <id>-<anim>|accepted|skipped|note"""
import json, os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from make_anim import BY_ID, ANIM, update_progress # noqa: E402
ORDER = ["imp"] + [c for c in BY_ID if c != "imp"]
notes = {}
for line in open(sys.argv[1]):
if line.strip():
sheet, status, note = line.rstrip("\n").split("|", 2)
notes[sheet] = (status, note)
# the pilot sheets
for s in ("knight-walk", "knight-attack", "kobold-walk", "kobold-attack"):
notes.setdefault(s, ("accepted", "pilot (see the pilot section)"))
man = []
for cid in ORDER:
anims = [a for a in ("walk", "die", "attack")
if notes.get(f"{cid}-{a}", ("",))[0] == "accepted" and os.path.exists(os.path.join(ANIM, f"{cid}-{a}-32.png"))]
man.append({"id": cid, "kind": BY_ID[cid]["kind"], "anims": anims})
json.dump(man, open(os.path.join(ANIM, "manifest.json"), "w"), indent=1)
def f(d):
for sheet, (status, note) in notes.items():
e = d["sheets"].setdefault(sheet, {"images": 0})
e["status"], e["note"] = status, note
for sheet, e in d["sheets"].items():
if e.get("status") == "generating":
e["status"] = "unreviewed"
update_progress(f)
print(sum(len(m["anims"]) for m in man), "sheets in the manifest")
+357 -35
View File
@@ -18,7 +18,7 @@ figures' "core" height (the body without thin weapon parts sticking out above or
core()) is the median core height of the matching idle view; each frame is then shifted so that
its core bottom sits on the feet row and its core centre on the idle view's core centre.
"""
import argparse, base64, io, json, os, statistics, subprocess, sys, urllib.request
import argparse, base64, io, json, os, statistics, subprocess, sys, time, urllib.error, urllib.request
from PIL import Image
@@ -29,9 +29,24 @@ ROOT = os.path.normpath(os.path.join(SPR, "..", "..", ".."))
TOOL = os.path.join(ROOT, "tools", "pixelize_sprite.py")
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(ROOT, "tools"))
from creatures import BY_ID, GREEN # noqa: E402
from creatures import BY_ID as _BY_ID, GREEN, HOVER, SIEGE # noqa: E402
import pixelize_sprite as ps # noqa: E402
# the imp (kind Worker) has its own folder: raw = the lower row of imp-sprite/raw-1.png
IMP_DIR = os.path.normpath(os.path.join(SPR, "..", "imp-sprite"))
BY_ID = dict(_BY_ID)
BY_ID["imp"] = dict(id="imp", kind="Worker", name="Pit Imp",
desc="A small goblin-like worker demon: short and stocky, big head, oversized pointed ears and two small black horns, orange skin, glowing yellow eyes, a wide mischievous grin with tiny fangs, a ragged dark-red tunic with a rope belt, bare feet with claws, a small iron pickaxe with a wooden handle carried over its right shoulder. Thin tail.")
QUAD = {"spider", "cavebear", "beetle"}
PROGRESS = os.path.join(ANIM, "progress.json")
def idle_sheet(cid, size):
if cid == "imp":
return os.path.join(IMP_DIR, f"imp-{size}.png")
return os.path.join(SPR, f"{cid}-{size}.png")
VIEWS = ["front", "front three-quarter", "side", "back three-quarter", "back"]
SIZES = ((32, 31), (64, 63)) # cell size, feet row
@@ -40,6 +55,24 @@ SIZES = ((32, 31), (64, 63)) # cell size, feet row
WEAPON = {
"knight": "the same short straight steel sword in the right hand",
"kobold": "the same wooden spear with the steel tip in the right hand",
"imp": "the same small iron pickaxe over the right shoulder",
"mage": "the same wooden staff with the glowing yellow gem in the right hand",
"archer": "the same wooden bow in the left hand",
"dwarf": "the same steel pickaxe with the wooden handle in the right hand",
"paladin": "the same white holy sword with the golden hilt in the right hand",
"jailer": "the same coiled iron chain in the right hand",
"priestess": "the same small fireball above the right hand",
"chainbreaker": "the same steel battle axe in each hand",
"gargoyle": "the same iron chain around one arm",
"cogling": "the same steel hammer in the right hand",
"revenant": "the same rusty steel sword in the right hand",
"giant": "the same big wooden tree-trunk club in the right hand",
"battlemage": "the same wooden staff with the burning flame on top in the right hand",
"trader": "the same big backpack and the small glowing lantern in the right hand",
"thief": "the same short steel dagger in the right hand and the sack of gold on the back",
"ranger": "the same wooden bow in the left hand",
"inquisitor": "the same glowing yellow holy book in both hands",
"priest": "the same golden halo floating above the head",
}
ATTACK = {
"knight": dict(
@@ -63,11 +96,139 @@ ATTACK = {
"impact - the spear fully thrust forward, arms stretched out, the spear hidden by the body except a short piece of shaft, a small white spark at the tip beyond the head",
"recover - the spear pulled back to the ready position, the shaft visible beside the body"]),
}
ATTACK.update({
"ogre": dict(
what="a fist smash: the ogre raises both huge stone fists high and slams them down on an enemy in front of it",
front=["wind-up - both fists raised high above the head, body leaning back",
"strike - the fists coming down in front of the face, body bending forward",
"impact - the fists smashed down low in front of the feet, body bent forward, a few small grey stone chips flying",
"recover - straightening up, the arms hanging low and ready"],
back=["wind-up - both fists raised high above the head, seen from behind",
"strike - the fists coming down in front, the arms partly hidden by the head and shoulders",
"impact - bent forward, the fists down in front hidden by the body, the broad back hunched",
"recover - straightening up, the arms hanging low at the sides"]),
"spider": dict(
what="a pouncing bite: the spider rears up on its back legs, raises its front legs and lunges forward to bite an enemy in front of it with its fangs",
front=["wind-up - the front of the body reared up, the two front legs raised high, fangs open",
"strike - lunging forward and down, the front legs reaching forward",
"impact - the body pressed low and forward, fangs biting, the front legs stabbing down",
"recover - pulling back to the normal low stance, all eight legs on the ground"],
back=["wind-up - the front of the body reared up, the two front legs raised high above the round back",
"strike - lunging forward away from the viewer, the front legs reaching forward",
"impact - the body pressed low and forward, the round back seen from behind",
"recover - back in the normal low stance, all eight legs on the ground"]),
"mage": dict(
what="casting a shadow bolt: the mage raises the staff and thrusts it forward, the gem flashing and a small purple-black orb of shadow magic shooting from it",
front=["wind-up - the staff raised high and drawn back, the gem glowing brighter, the free hand raised",
"strike - the staff swung forward and pointed at the enemy, a small dark-purple magic orb forming at the gem",
"impact - the staff thrust forward, the small purple orb leaving the gem with a few sparks",
"recover - the staff pulled back upright, the gem glow fading"],
back=["wind-up - the staff raised high above the hood, the gem glowing",
"strike - the staff swung forward away from the viewer, mostly hidden by the hood, a purple glow beyond",
"impact - the staff thrust forward, hidden by the body except its upper end, a small purple orb beyond",
"recover - the staff pulled back upright beside the body"]),
"archer": dict(
what="shooting an arrow: the archer raises the bow, draws an arrow from the quiver, pulls the string back and shoots forward",
front=["wind-up - drawing an arrow from the quiver on the back with the right hand, the bow lowered in the left hand",
"strike - the bow raised and held out forward, the arrow nocked, the string pulled back to the cheek",
"impact - the arrow just released, the string snapped forward, the bow still raised",
"recover - the bow lowered back to the upright idle position"],
back=["wind-up - reaching to the quiver on the back with the right hand",
"strike - the bow raised forward away from the viewer, the right elbow pulled back drawing the string",
"impact - the arrow released, the bow held out forward, the right arm relaxed",
"recover - the bow lowered back to the upright idle position"]),
"dwarf": dict(
what="a pickaxe blow: the dwarf swings the pickaxe from over the right shoulder down in front of it onto an enemy",
front=["wind-up - the pickaxe raised high over the right shoulder with both hands, body leaning back",
"strike - the pickaxe swinging down over the helmet",
"impact - the pickaxe head struck down low in front of the feet, body bent forward, a few small sparks",
"recover - the pickaxe lifted back onto the right shoulder"],
back=["wind-up - the pickaxe raised high over the right shoulder, its head showing above the helmet",
"strike - the pickaxe coming down, partly hidden by the helmet and shoulders",
"impact - bent forward, the pickaxe down in front hidden by the body, only the handle end shows",
"recover - the pickaxe lifted back onto the right shoulder"]),
"paladin": dict(
what="a holy sword swing: the paladin swings the white sword with both hands from high over the right shoulder diagonally down across the body at an enemy in front of it, the blade glowing",
front=["wind-up - the sword raised high over the right shoulder with both hands, the blade pointing up and back, glowing",
"strike - the glowing sword swinging down diagonally in front of the helmet",
"impact - the sword fully swung through, the blade low in front of the body at the left hip, a small golden spark",
"recover - the sword raised back to the upright ready position"],
back=["wind-up - the sword raised high over the right shoulder, the blade showing above the helmet",
"strike - the sword coming down, partly hidden by the helmet and shoulders",
"impact - the sword in front of the paladin, mostly hidden by the body, only the blade tip shows low at the left side",
"recover - the sword raised back up, its blade visible beside the right shoulder"]),
})
WALK_FRONT = ["contact - the right leg stepping forward with the heel down, the left leg behind, arms swinging opposite to the legs",
"passing - the legs passing each other under the body, the body slightly higher",
"contact - the left leg stepping forward with the heel down, the right leg behind, arms swinging the other way",
"passing - the legs passing each other under the body, the body slightly higher"]
WALK_BACK = WALK_FRONT
HOVER_FRAMES = ["upstroke - the wings raised straight up high above the head, the wing tips pointing up, the body at its highest point",
"downstroke - the wings swept out level to the sides, fully spread",
"down - the wings pulled down below the body, the wing tips pointing down, the body at its lowest point",
"recovery - the wings half raised and folded a little, swinging back up"]
SIEGE_FRAMES = ["the wheels in their start position, the spokes or wheel marks at 0 degrees",
"the wheels turned a quarter turn further (the wheel marks visibly rotated)",
"the wheels turned another quarter turn",
"the wheels turned another quarter turn, the machine rocking slightly"]
QUAD_FRAMES = {
"spider": ["legs 1 and 3 on the left and 2 and 4 on the right stepping forward, the others pushing back",
"the legs passing, the body slightly raised",
"the other set of legs stepping forward",
"the legs passing, the body slightly raised"],
"beetle": ["the front and back legs on the left and the middle leg on the right stepping forward",
"the legs passing under the shell, the body slightly raised",
"the other three legs stepping forward",
"the legs passing under the shell, the body slightly raised"],
"cavebear": ["the right front paw and the left hind paw stepping forward, the head low",
"the paws passing under the body, the shaggy body slightly raised",
"the left front paw and the right hind paw stepping forward",
"the paws passing under the body, the shaggy body slightly raised"],
}
DIE = {
# (what, front frames, back frames)
"biped": ("a death: the character is struck by a fatal blow and falls over backwards (away from the direction it faces) onto its back, where it lies still - in every view it falls the same way, backwards relative to its own facing",
["hit - recoiling from the blow, still standing upright on both feet, head thrown back, eyes shut in pain, arms flung out",
"falling - tipping over backwards, the knees buckling, the arms flailing",
"hitting the ground - landing on its back, the body flat and low, limbs bouncing up a little",
"lying still - lying flat on its back on the ground, limbs limp, eyes closed or crossed out, motionless"],
["hit - recoiling from the blow, still standing upright on both feet, the head thrown back towards the viewer, arms flung out",
"falling - tipping over backwards towards the viewer, the knees buckling",
"hitting the ground - landing on its back, the body flat and low on the ground",
"lying still - lying flat on its back on the ground, motionless"]),
"quad": ("a death: the creature is struck by a fatal blow, staggers, collapses and rolls over onto its side where it lies still - the same way in every view",
["hit - recoiling from the blow, still standing on all its legs, flinching back, eyes shut in pain",
"falling - the legs giving way, the body collapsing and tipping over sideways",
"hitting the ground - crashing down onto its side, the legs in the air",
"lying still - lying on its side or back, the legs curled up and limp, motionless"],
["hit - recoiling from the blow, still standing on all its legs, flinching",
"falling - the legs giving way, the body collapsing and tipping over sideways",
"hitting the ground - crashing down onto its side, the legs in the air",
"lying still - lying on its side or back, the legs curled up and limp, motionless"]),
"hover": ("a death: the flying creature is struck by a fatal blow, its wings fail and it drops out of the air to the ground, where it lies still",
["hit - recoiling in the air from the blow, wings jerked up, eyes shut in pain",
"falling - dropping down, the wings folding and limp, the body tumbling",
"hitting the ground - crashing onto the ground on its back, the wings splayed",
"lying still - lying on the ground with the wings spread flat and limp, eyes closed or crossed out, motionless"],
["hit - recoiling in the air from the blow, wings jerked up",
"falling - dropping down, the wings folding and limp",
"hitting the ground - crashing onto the ground, the wings splayed",
"lying still - lying on the ground with the wings spread flat and limp, motionless"]),
"siege": ("its destruction: the machine is smashed - planks crack and splinter, a wheel breaks off, and it collapses into a heap of broken wood",
["hit - the machine jolted by a heavy blow, cracks in the planks, a few wood splinters flying, still standing on its wheels",
"breaking - a front wheel breaking off, the frame tilting and sagging, planks coming loose",
"collapsing - the frame crashing down, the roof or arm broken, planks and wheels falling apart",
"wreck - a low flat heap of broken planks, beams and a loose wheel on the ground, motionless"],
["hit - the machine jolted by a heavy blow, cracks in the planks, still standing on its wheels",
"breaking - a wheel breaking off, the frame tilting and sagging",
"collapsing - the frame crashing down, planks and wheels falling apart",
"wreck - a low flat heap of broken planks, beams and a loose wheel on the ground, motionless"]),
}
def kind_of(cid):
return "siege" if cid in SIEGE else "hover" if cid in HOVER else "quad" if cid in QUAD else "biped"
def bg_of(cid):
@@ -77,11 +238,27 @@ def bg_of(cid):
def anim_text(cid, anim, part):
c = BY_ID[cid]
weapon = WEAPON.get(cid, "")
name = c['name'].lower()
k = kind_of(cid)
if anim == "walk":
what = f"a looping walk cycle: the {c['name'].lower()} walks forward in the direction it faces, at a steady pace" + (
f", carrying {weapon} as in the idle pose" if weapon else "")
frames = WALK_FRONT if part == "front" else WALK_BACK
feet = "The figure walks in place (it does not move across the cell); the feet stand on the baseline of the row."
if k == "hover":
what = f"a looping hovering flight cycle: the {name} hovers in place in the air, flapping its wings"
frames = HOVER_FRAMES
feet = "The creature stays in the middle of its cell (it does not fly across the cell); the wings flap with big, clearly different wing positions in the four frames and the body bobs up and down a little."
elif k == "siege":
what = f"rolling forward: the {name} rolls forward in the direction it faces, its wooden wheels turning"
frames = SIEGE_FRAMES
feet = "The machine rolls in place (it does not move across the cell); the wheels stand on the baseline of the row and visibly turn from frame to frame."
else:
what = f"a looping walk cycle: the {name} walks forward in the direction it faces, at a steady pace" + (
f", carrying {weapon} as in the idle pose" if weapon else "")
frames = QUAD_FRAMES.get(cid) or (WALK_FRONT if part == "front" else WALK_BACK)
feet = "The figure walks in place (it does not move across the cell); the feet stand on the baseline of the row."
elif anim == "die":
what, ff, bf = DIE[k]
frames = ff if part == "front" else bf
feet = ("Frame 1 has the same size and position as the idle figure; in frames 2-4 the body sinks to the ground, the lowest part always on the baseline of the row, and stays within its own cell. "
"Frame 4 lies flat and low on the ground.")
else:
a = ATTACK[cid]
what = a["what"]
@@ -95,7 +272,12 @@ def prompt_front(cid, anim):
c = BY_ID[cid]
bgname, _ = bg_of(cid)
what, cols, feet = anim_text(cid, anim, "front")
hold = f"the character holds {WEAPON[cid]}" if cid in WEAPON else "the character keeps all its equipment"
if anim == "die":
hold = "the character keeps its clothes and equipment (a held weapon may drop beside it in frames 3-4)"
if cid in SIEGE:
hold = "the machine keeps its colours and materials even when broken"
else:
hold = f"the character keeps {WEAPON[cid]}" if cid in WEAPON else "the character keeps all its equipment"
return f"""The attached image is a pixel art sprite sheet of the "{c['name']}" in five views (left to right: front, front three-quarter turned right, side facing right, back three-quarter turned away to the right, back). {c['desc']}
Create a new pixel art sprite sheet of exactly the same character - same proportions, colours, outline, pixel size, clothes and equipment - showing {what}.
@@ -104,7 +286,7 @@ Layout: a grid of 5 rows and 4 columns, every figure the same size, evenly space
Rows (one view per row, top to bottom): 1 front view facing the viewer, 2 front three-quarter view turned to the right, 3 side view facing right, 4 back three-quarter view turned away to the right, 5 back view.
Columns (animation frames, left to right): {cols}.
{feet}
Every frame of a row keeps exactly that row's view direction (the side view row stays in profile facing right in all four frames, the back rows never show the face), and {hold} in every single frame.
Every frame of a row keeps exactly that row's view direction (the side view row stays in profile facing right in all four frames{", also when falling" if anim == "die" else ""}, the back rows never show the face), and {hold} in every single frame.
Style: identical to the attached sheet - crisp retro pixel art, hard square pixels, one-pixel near-black outline, limited palette, no anti-aliasing, no blur. Flat pure {bgname} background everywhere, no ground, no shadows, no text, no labels, no frame lines, no grid lines, no motion lines."""
@@ -165,15 +347,62 @@ def fal(prompt, images, aspect):
"resolution": "2K", "num_images": 1, "output_format": "png"}
req = urllib.request.Request("https://fal.run/fal-ai/nano-banana-pro/edit", data=json.dumps(body).encode(),
headers={"Authorization": "Key " + key, "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=900) as r:
res = json.load(r)
for attempt in range(5):
try:
with urllib.request.urlopen(req, timeout=900) as r:
res = json.load(r)
break
except urllib.error.HTTPError as e:
msg = e.read().decode(errors="replace")[:500]
if e.code in (429, 500, 502, 503, 504) and attempt < 4:
print(f" HTTP {e.code}, retrying in {60 * (attempt + 1)} s")
time.sleep(60 * (attempt + 1))
continue
raise SystemExit(f"HTTP {e.code}: {msg}")
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
if attempt < 4:
print(f" {e}, retrying in {60 * (attempt + 1)} s")
time.sleep(60 * (attempt + 1))
continue
raise
count_image()
with urllib.request.urlopen(res["images"][0]["url"], timeout=300) as r:
return Image.open(io.BytesIO(r.read())).convert("RGB")
def update_progress(fn):
"""Apply fn(dict) to progress.json under a file lock."""
import fcntl
os.makedirs(ANIM, exist_ok=True)
with open(PROGRESS + ".lock", "w") as lk:
fcntl.flock(lk, fcntl.LOCK_EX)
d = json.load(open(PROGRESS)) if os.path.exists(PROGRESS) else {"images": 0, "price_per_image": 0.15, "sheets": {}}
fn(d)
d["cost_usd"] = round(d["images"] * d.get("price_per_image", 0.15), 2)
tmp = PROGRESS + ".tmp"
json.dump(d, open(tmp, "w"), indent=1, sort_keys=True)
os.replace(tmp, PROGRESS)
CURRENT = {}
def count_image():
def f(d):
d["images"] += 1
sh = d["sheets"].setdefault(CURRENT.get("sheet", "?"), {"status": "generating", "images": 0})
sh["images"] = sh.get("images", 0) + 1
update_progress(f)
def idle_ref(cid):
raw = Image.open(os.path.join(SPR, f"{cid}-raw.png")).convert("RGB")
f = idle_crop(cid)
if cid == "imp":
raw = Image.open(os.path.join(IMP_DIR, "raw-1.png")).convert("RGB")
f = [0, 0.27, 1, 0.72]
else:
raw = Image.open(os.path.join(SPR, f"{cid}-raw.png")).convert("RGB")
f = idle_crop(cid)
if f:
w, h = raw.size
raw = raw.crop((int(f[0] * w), int(f[1] * h), int(f[2] * w), int(f[3] * h)))
@@ -188,14 +417,24 @@ def row_bands(img, n):
mask = [[not v for v in r] for r in ps.background_mask(img)]
occ = [sum(r) for r in mask]
h = len(occ)
bands, y0 = [], None
raw, y0 = [], None
for y, v in enumerate(occ + [0]):
if v > 2 and y0 is None:
y0 = y
elif v <= 2 and y0 is not None:
if y - y0 > 0.06 * h:
bands.append((y0, y))
raw.append((y0, y))
y0 = None
# bands lower than 6% of the image (a halo or sparkles floating above a row, specks) belong
# to the next band (the last one to the previous band)
bands, pend = [], None
for b in raw:
if b[1] - b[0] > 0.06 * h:
bands.append((pend if pend is not None else b[0], b[1]))
pend = None
elif pend is None:
pend = b[0]
if pend is not None and bands:
bands[-1] = (bands[-1][0], h)
if len(bands) == n:
# widen to the midpoints of the gaps
cuts = [0] + [(bands[i][1] + bands[i + 1][0]) // 2 for i in range(n - 1)] + [h]
@@ -212,6 +451,7 @@ def row_bands(img, n):
def cmd_gen(a):
os.makedirs(ANIM, exist_ok=True)
CURRENT["sheet"] = f"{a.id}-{a.anim}"
ref = idle_ref(a.id)
front = os.path.join(ANIM, f"{a.id}-{a.anim}-front-raw{a.suffix}.png")
if a.part in ("front", "both"):
@@ -309,18 +549,76 @@ def shift(cell, dx, dy):
return out, lost
def pix_source(raw_crop, n, target, size, feet, palette, add):
"""All n figures of one raw row, at the step that makes their median core height = target."""
def pix_source(raw_crop, n, target, size, feet, palette, add, ref=None):
"""All n figures of one raw row, at the step that makes their median core height = target
(ref = a figure index: only that figure's core height counts, for die rows whose later
frames lie on the ground). Also returns each figure's horizontal offset in source pixels
from the centre of its equal column (the tool centres every figure in its cell; die rows
need the original positions back)."""
step = None
for _ in range(4): # the core height is linear in 1/step; converge in a few passes
sheet, used = run_tool(raw_crop, n, size, feet, step, palette, add)
hs = [c[1] - c[0] for c in (core(x, size) for x in cells(sheet, size, 0)) if c]
cs = [core(x, size) for x in cells(sheet, size, 0)]
hs = [c[1] - c[0] for c in ([cs[ref]] if ref is not None else cs) if c]
new = used * statistics.median(hs) / target
if step and abs(new - step) / step < 0.01:
break
step = new
sheet, used = run_tool(raw_crop, n, size, feet, step, palette, add)
return cells(sheet, size, 0), used
offs = [0.0] * n
try:
_, boxes = ps.figure_boxes(raw_crop.convert("RGB"), 1, n)
if len(boxes) == n:
col = raw_crop.width / n
offs = [(b[0] + b[2]) / 2 - (i + 0.5) * col for i, b in enumerate(boxes)]
except Exception as e: # noqa: BLE001
print(" note: no figure offsets:", e)
return cells(sheet, size, 0), used, offs
def bbox(cell):
b = cell.getchannel("A").point(lambda v: 255 if v > 128 else 0).getbbox()
return b
def strip_lines(img, key):
"""Paint dark horizontal lines across nearly the whole image (ground lines, row separators
the model drew despite the prompt; at most 12 px thick) in the key colour. Wide dark
figure parts (a row of spider legs) are not lines: they are thicker or have gaps."""
w, h = img.size
px = img.load()
corners = [px[x, y] for x, y in ((2, 2), (w - 3, 2), (2, h - 3), (w - 3, h - 3))]
c0 = corners[0]
d2 = lambda p, q: sum((a - b) ** 2 for a, b in zip(p, q))
if all(d2(c, c0) < 400 for c in corners) and d2(c0, key) > 3600:
# the model drew another flat background (white, pale blue) instead of the key colour
img = img.copy()
px = img.load()
for y in range(h):
for x in range(w):
if d2(px[x, y], c0) < 2000:
px[x, y] = key
print(f" note: background {c0} keyed out")
darkrow = [sum(1 for x in range(0, w, 4) if sum(px[x, y]) < 160) > 0.75 * (w / 4) for y in range(h)]
out, y = None, 0
while y < h:
if darkrow[y]:
y1 = y
while y1 < h and darkrow[y1]:
y1 += 1
if y1 - y <= 12:
if out is None:
out = img.copy()
opx = out.load()
for yy in range(max(0, y - 1), min(h, y1 + 1)):
for x in range(w):
opx[x, yy] = key
y = y1
else:
y += 1
if out is not None:
print(" note: removed horizontal lines")
return out or img
def parse_view(spec, default_file):
@@ -328,9 +626,11 @@ def parse_view(spec, default_file):
be empty (= default_file), PICKS like 0,2,1,3 (m = mirrored, e.g. m1)."""
out = []
for seg in spec.split(";"):
f, bn, count, picks = seg.split(":")
parts = seg.split(":")
f, bn, count, picks = parts[:4]
ref = int(parts[4]) if len(parts) > 4 else None # die: the figure that sets the scale
band, nb = (int(v) for v in bn.split("/"))
out.append((f or default_file, band, nb, int(count), picks.split(",")))
out.append((f or default_file, band, nb, int(count), picks.split(","), ref))
return out
@@ -345,40 +645,62 @@ def cmd_pix(a):
views = [parse_view(sp, front) for sp in specs]
images, bands = {}, {}
for segs in views:
for f, band, nb, count, picks in segs:
for f, band, nb, count, picks, _ in segs:
p = f if os.path.isabs(f) or os.path.exists(f) else os.path.join(ANIM, f)
if p not in images:
images[p] = Image.open(p).convert("RGB")
images[p] = strip_lines(Image.open(p).convert("RGB"), bg_of(a.id)[1])
if (p, nb) not in bands:
bands[p, nb] = row_bands(images[p], nb)
for size, feet in SIZES:
idle_path = os.path.join(SPR, f"{a.id}-{size}.png")
idle_path = idle_sheet(a.id, size)
idle = Image.open(idle_path).convert("RGBA")
sheet = Image.new("RGBA", (4 * size, 5 * size), (0, 0, 0, 0))
log = [f"{a.id}-{a.anim}-{size}"]
for v, segs in enumerate(views):
icell = idle.crop((v * size, 0, (v + 1) * size, size))
ic, iw = core(icell, size), body_width(icell, size)
frames, notes = [], []
for f, band, nb, count, picks in segs:
frames, notes = [], [] # frames: (cell, source offset in output pixels)
die = a.anim == "die"
for f, band, nb, count, picks, ref in segs:
p = f if os.path.isabs(f) or os.path.exists(f) else os.path.join(ANIM, f)
y0, y1 = bands[p, nb][band]
im = images[p]
figs, used = pix_source(im.crop((0, y0, im.width, y1)), count, ic[1] - ic[0], size, feet, idle_path, a.add_colors)
if die and ref is None:
ref = int(picks[0].lstrip("m"))
figs, used, offs = pix_source(im.crop((0, y0, im.width, y1)), count, ic[1] - ic[0], size, feet, idle_path,
a.add_colors, ref if die else None)
notes.append(f"{os.path.basename(p)} row {band}/{nb} step {used:.2f}")
for t in picks:
c = figs[int(t.lstrip("m"))]
frames.append(c.transpose(Image.FLIP_LEFT_RIGHT) if t.startswith("m") else c)
i = int(t.lstrip("m"))
c, o = figs[i], offs[i] / used
frames.append((c.transpose(Image.FLIP_LEFT_RIGHT), -o) if t.startswith("m") else (c, o))
if len(frames) != 4:
raise SystemExit(f"view {v}: {len(frames)} frames, want 4")
fn = []
wr = statistics.median(body_width(f, size) for f in frames) / iw
for col, cell in enumerate(frames):
wr = (body_width(frames[0][0], size) if die else statistics.median(body_width(f, size) for f, _ in frames)) / iw
dx0 = o0 = None
for col, (cell, off) in enumerate(frames):
c = core(cell, size)
dx, dy = round(ic[2] - c[2]), feet - c[1]
if not die or col == 0:
dx, dy = round(ic[2] - c[2]), feet - c[1]
if die:
dx0, o0 = dx, off
else:
# keep the position relative to frame 1 as drawn; the lowest part on the feet row
b = bbox(cell)
dx = round(dx0 + off - o0)
bot = c[1] if c else b[3] - 1
dy = feet - bot
if b[2] - b[0] <= size: # pull a body that fits back into the cell
if b[0] + dx < 0:
dx = -b[0]
elif b[2] + dx > size:
dx = size - b[2]
else:
dx = round(size / 2 - (b[0] + b[2]) / 2)
moved, lost = shift(cell, dx, dy)
sheet.alpha_composite(moved, (col * size, v * size))
fn.append(f"h{c[1] - c[0]} dx{dx:+d} dy{dy:+d}" + (f" CLIPPED {lost}px" if lost else ""))
fn.append(f"h{c[1] - c[0] if c else 0} dx{dx:+d} dy{dy:+d}" + (f" CLIPPED {lost}px" if lost else ""))
log.append(f" {VIEWS[v]} (idle core h {ic[1] - ic[0]}, width ratio {wr:.2f}"
+ (" WIDER/NARROWER THAN IDLE" if abs(wr - 1) > 0.1 else "") + "): " + "; ".join(notes) + " | " + ", ".join(fn))
out = pre + f"-{size}.png"
@@ -412,7 +734,7 @@ def main():
sub = ap.add_subparsers(dest="cmd", required=True)
g = sub.add_parser("gen")
g.add_argument("id")
g.add_argument("anim", choices=["walk", "attack"])
g.add_argument("anim", choices=["walk", "attack", "die"])
g.add_argument("part", nargs="?", default="both", choices=["front", "back", "both"])
g.add_argument("--suffix", default="")
g.add_argument("--front-src", default=None, help="front raw to show in the back request")
@@ -420,7 +742,7 @@ def main():
g.add_argument("--print", action="store_true", help="only print the prompts")
p = sub.add_parser("pix")
p.add_argument("id")
p.add_argument("anim", choices=["walk", "attack"])
p.add_argument("anim", choices=["walk", "attack", "die"])
p.add_argument("--front", default=None)
p.add_argument("--back", default=None)
p.add_argument("--view", action="append", help="V=FILE:BAND/NBANDS:COUNT:PICKS[;...] frames of view V "
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""Contact image of raws for a quick look: peek.py OUT RAW [RAW ...] (each scaled to 520 px high, side by side)."""
import sys
from PIL import Image
H = 520
ims = [Image.open(p).convert("RGB") for p in sys.argv[2:]]
ims = [i.resize((round(i.width * H / i.height), H), Image.LANCZOS) for i in ims]
out = Image.new("RGB", (sum(i.width for i in ims) + 10 * (len(ims) - 1), H), (40, 40, 40))
x = 0
for i in ims:
out.paste(i, (x, 0))
x += i.width + 10
out.save(sys.argv[1])
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""pv.py OUT SHEET [SHEET ...]: the 32 previews of sheets (e.g. imp-walk) at half size, side by side."""
import os, sys
from PIL import Image
D = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "anim")
ims = [Image.open(os.path.join(D, f"{s}-32-preview.png")) for s in sys.argv[2:]]
ims = [i.resize((i.width // 2, i.height // 2), Image.NEAREST) for i in ims]
out = Image.new("RGB", (sum(i.width for i in ims) + 8 * (len(ims) - 1), ims[0].height), (20, 20, 20))
x = 0
for i in ims:
out.paste(i, (x, 0))
x += i.width + 8
out.save(sys.argv[1])