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:
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")
|
||||
@@ -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 "
|
||||
|
||||
@@ -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])
|
||||
@@ -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])
|
||||
Reference in new issue
Block a user