wardrobegod/tools/doll_stage.py
type-two 294af7cf1d Phase 3: the 2D paper-doll tier — 392 garments you can actually change
The flat cut-outs were the cheapest content on the board and the only tier with a shipping
consumer, but wardrobegod couldn't show one: scan() filtered PNGs out of garments/ (fixed in
Phase 0) and there was no compositor, no slots, no preview. Now there is a working dress-up.

· library/doll/ seeded with 90sDJsim's 441 shipped sprites (165 top, 110 bottom, 60 shoes,
  57 hat, base body) — an instant starter wardrobe rather than a cold start.
· tools/doll.py wraps djsim's compositor. It deliberately does NOT import doll_composite:
  that module pulls numpy at import time for a keyer and an HSV recolour we never use (the
  farm's RMBG-2.0 does our keying), and numpy isn't installable here under PEP 668. Instead
  the measured anchors are parsed out of its source with ast, so there is still exactly one
  source of truth and they cannot drift; place() is a pure-PIL port.
· slugify() reproduces djsim's item_art() _art_slug character for character, verified against
  it. That equality IS the drop-in contract — a near-miss silently falls back to generic slot
  art instead of erroring, so it's the kind of bug you'd ship without noticing.
· /api/doll/gen runs the proven pipeline: flux_local on SOLID GREEN -> farm RMBG-2.0 ->
  staged to 704x1408. Green is not a preference: grey and checkerboard backgrounds eat
  garments in the key, and flux can't spell, so no text ever goes in the prompt.
· /api/doll/compose composes server-side with PIL, so the browser preview and the exported
  PNG are the same bytes rather than two renderers that drift.
· /api/doll/export writes into a LIVE game directory, so it defaults to a dry run and refuses
  any stem without _i_ — overwriting a generic slot fallback would restyle every unarted item
  in a shipping game.
· UI: a doll 2D tab with per-slot pickers, randomise, strip, and export.

Verified: composed a dressed doll from real sprites (correct base->bottom->shoes->top->hat
order), catalogue reports 392 layers, export dry-run and the generic-art guard both behave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:07:13 +10:00

113 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""LANE DOLL — stage keyed garment cutouts onto the 704x1408 paper-doll canvas.
Inputs are ALREADY keyed (transparent-bg RGBA, from MODELBEAST bg_remove_local), so unlike
doll_composite.install we skip key_white and just crop-scale-place via the SAME SLOT_DEF anchors
(measured off base.png). Output: web/world/media/doll/<slot>_i_<itemkey>.png — the server's
item_art() resolver (server.py:177) wears it for any fashion item whose _art_slug(name)==itemkey.
stage <flowrinse_dir> stage the curated flowrinse map (default ~/Documents/flowrinse_keyed)
one <cutout.png> <slot> <key> stage a single cutout (gen_wardrobe.py calls this)
The keyer isn't perfect — run `doll_composite.py fit` after to eyeball at doll scale (NOTES has the
cut list). Curated, not dumped: each flowrinse file maps to a matched EXISTING item slug where one
fits (auto-wires, no culture.json change) or a NEW itemkey (LANE DOLL D2 adds the item).
"""
import sys, os, json
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PIL import Image
from doll_composite import place, CANVAS, SLOT_DEF # reuse the measured anchors + compositor
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DOLL_DIR = os.path.join(HERE, 'web', 'world', 'media', 'doll')
LAYOUT = os.path.join(HERE, 'web', 'world', 'doll_layout.json')
# filename stem -> (slot, itemkey, wiring). wiring: 'match' = itemkey is an EXISTING culture.json
# item slug (art auto-wires); 'new' = LANE DOLL D2 adds an item named to slug back to itemkey.
FLOWRINSE = {
# --- MATCHES (itemkey == an existing item's slug; art auto-wires, no culture.json change) ---
'beanie_in_maroon_mustard_bands': ('hat', 'coogi_knit_beanie', 'match'),
'bucket_hat_neon_colour_blocked_p': ('hat', 'cross_colours_bucket_hat', 'match'),
'bucket_hat_with_surf_print': ('hat', 'mambo_surf_bucket_hat', 'match'),
'black_ribbed_tank_top_graphic': ('top', 'stussy_world_tour_womens_tank', 'match'),
'grey_hoodie_with_c_patch': ('top', 'champion_reverse_weave_hoodie', 'match'),
'silk_shirt_leopard_print': ('top', 'leopard_print_silk_shirt', 'match'),
'windbreaker_with_chevron_panels': ('top', 'womens_windbreaker', 'match'),
'white_cotton_tee_illustration': ('top', 'kmart_plain_white_tee', 'match'),
'baggy_black_cargo_pants_eight_ball': ('bottom', 'stussy_8_ball_cargo_pant', 'match'),
'camouflage_cargo_shorts_laid_flat': ('bottom', 'army_disposal_camo_cargo_shorts','match'),
'denim_shorts_on_white_background': ('bottom', 'kmart_acid_wash_denim_shorts', 'match'),
'knit_skirt_multicolour_purple_te': ('bottom', 'coogi_womens_knit_skirt', 'match'),
'silk_skirt_with_medallion_motif': ('bottom', 'versace_baroque_womens_silk_skirt','match'),
'track_pants_with_popper_studs': ('bottom', 'adidas_track_pants', 'match'),
'white_canvas_court_shoes': ('shoes', 'dunlop_volley_sneakers', 'match'),
'op_shop_tennis_shoe_cream_canvas': ('shoes', 'kmart_canvas_plimsolls', 'match'),
'worn_leather_work_boot_alone': ('shoes', 'combat_boots', 'match'),
'dad_sneakers_with_neon_accents': ('shoes', 'disruptor_ii_sneakers', 'match'),
# --- NEW (LANE DOLL D2 adds the item, named to slug back to itemkey) ---
'black_cap_gold_baroque_print': ('hat', 'versace_baroque_cap', 'new'),
'chocolate_brown_corduroy_cap': ('hat', 'corduroy_cap', 'new'),
'felt_hat_on_grey_background': ('hat', 'felt_hat', 'new'),
'chunky_knit_sweater_multicolour': ('top', 'coogi_knit_jumper', 'new'),
'oversized_white_tee_rave_splatter': ('top', 'rave_splatter_tee', 'new'),
'surf_shirt_printed_cartoon_art': ('top', 'mambo_loud_shirt', 'new'),
'navy_athletic_shorts_laid_flat': ('bottom', 'athletic_shorts', 'new'),
'gumboot_isolated_on_grey_background': ('shoes', 'gumboots', 'new'),
}
# bags use the separate ST.bag system (bag_<id> overlays), not fashion items — deferred, see NOTES.
BAGS = ['1990s_australian_bum_bag_maroon', 'navy_canvas_rucksack_early_1990s']
# non-garment / needs-eyeball — out of scope this round, listed in NOTES.
OUT = ['dive_bar_interior_dj_booth', 'paper_doll_clothing_item_dress_u']
# tiny per-stem canvas-px nudges from eyeballing at doll scale (filled after the first fit render).
# the two single-boot cutouts lie flat → place() scales them short and they sit high; drop to the feet.
NUDGE = {
'worn_leather_work_boot_alone': {'y': 95},
'gumboot_isolated_on_grey_background': {'y': 45},
}
def stage(cutout_path, slot, itemkey, nudge=None):
"""Crop-scale-place an already-keyed RGBA cutout to <slot>_i_<itemkey>.png. Returns the stem."""
if slot not in SLOT_DEF:
raise SystemExit(f'unknown slot {slot!r} (want {list(SLOT_DEF)})')
im = Image.open(cutout_path).convert('RGBA')
n = {'x': 0, 'y': 0, 's': 1.0, **(nudge or {})}
stem = f'{slot}_i_{itemkey}'
os.makedirs(DOLL_DIR, exist_ok=True)
# BAKE the (nudged) placement into the 704x1408 PNG — matches the shipped overlays, which are pre-placed
# with a {0,0,1} layout entry (432/433 are). The layout stays neutral so the DOM preview and the PIL
# `fit` compositor render identically (contract §4: "DOM and PIL match"); a baked nudge + a live layout
# nudge would double.
place(im, slot, n['x'], n['y'], n['s']).save(os.path.join(DOLL_DIR, stem + '.png'))
lay = json.load(open(LAYOUT)) if os.path.exists(LAYOUT) else {}
lay[stem] = {'x': 0, 'y': 0, 's': 1.0}
json.dump(lay, open(LAYOUT, 'w'), indent=2, sort_keys=True)
return stem
def cmd_stage(flowdir):
staged, missing = [], []
for fn, (slot, key, wiring) in sorted(FLOWRINSE.items()):
src = os.path.join(flowdir, fn + '.png')
if not os.path.exists(src):
missing.append(fn); continue
stem = stage(src, slot, key, NUDGE.get(fn))
staged.append((stem, wiring))
print(f' staged {stem:44} [{wiring}]')
print(f'\n{len(staged)} staged, {len(missing)} missing.')
print(f' matches (auto-wire): {sum(1 for _,w in staged if w=="match")}')
print(f' new items needed: {sum(1 for _,w in staged if w=="new")} -> D2 apply_fashion.py')
print(f' deferred (bags): {BAGS}')
print(f' out of scope: {OUT}')
if missing:
print(f' MISSING inputs: {missing}')
if __name__ == '__main__':
c = sys.argv[1] if len(sys.argv) > 1 else 'stage'
if c == 'one': # one <cutout.png> <slot> <itemkey>
print(stage(sys.argv[2], sys.argv[3], sys.argv[4]))
else: # stage [flowrinse_dir]
cmd_stage(sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser('~/Documents/flowrinse_keyed'))