""" One-time asset prep script — NOT part of the runtime app. Run locally (or once, manually) whenever a new garment PNG is added to assets/garments_raw/. Produces a canonical, tight-cropped PNG plus a sidecar .json anchor file that the runtime outfit engine reads. Canonical format per garment: - Tight-cropped to alpha bounding box (no wasted transparent margin) - anchor.json stores: shoulder_width_px: width of the garment at the collar-band row collar_y_px: y-coordinate (in the cropped image) of the collar band collar_cx_px: x-coordinate of the collar center (garment's own horizontal center, since these assets are symmetric) This lets runtime code do a single scale+translate per request instead of re-measuring pixels on every generate click. """ from __future__ import annotations import json import os import sys import numpy as np from PIL import Image COLLAR_BAND_FRACTION = 0.45 # measure garment width at 45% down from the # top of its content bbox. Original value of 0.15 was measuring the # narrow collar/lapel gap near the neck opening, not the actual # shoulder-to-shoulder width — confirmed by a live runtime failure where # a garment scaled to 121% of the destination canvas width because the # scale factor was computed against that too-narrow neck measurement. # These garment PNGs are torso silhouettes that flare outward from the # collar down to the arms/shoulders; a live measurement across the # sample garments showed width increasing from ~119px at 15% down to # ~279px at 50% down. 45% approximates where real shoulder points (as # MoveNet's pose model detects them on an actual photo) sit relative to # the garment's own collar-to-hem span. COLLAR_TOP_FRACTION = 0.08 # separate, much shallower measurement for the # actual collar/neckline anchor point — this is where the garment aligns # to the subject's neck at runtime, and must stay near the top of the # garment rather than at the (much lower) shoulder-width measurement row. # Previously these two were incorrectly the same value, which positioned # the garment's collar-alignment point at the shoulder line instead of # the neck — confirmed by a runtime paste landing far too low in-frame. def normalize_garment(src_path: str, out_dir: str, garment_id: str) -> dict: img = Image.open(src_path).convert("RGBA") bbox = img.getbbox() if bbox is None: raise ValueError(f"{src_path}: fully transparent, no content found") cropped = img.crop(bbox) w, h = cropped.size arr = np.array(cropped) alpha = arr[:, :, 3] def _measure_width_at(frac: float) -> tuple[int, int, int]: """Returns (row_y, x_min, x_max) of non-transparent content at the given fractional height, scanning nearby rows if the exact row is empty (can happen right at a garment's top point/collar peak).""" y = int(h * frac) row = alpha[y] nz = np.where(row > 10)[0] if len(nz) == 0: for dy in range(1, 20): y2 = min(h - 1, y + dy) row = alpha[y2] nz = np.where(row > 10)[0] if len(nz): y = y2 break if len(nz) == 0: raise ValueError(f"{src_path}: no content found near frac={frac}") return y, int(nz.min()), int(nz.max()) # Shoulder width: measured further down the garment (COLLAR_BAND_FRACTION, # despite its name, is really "shoulder band" — kept the name for # backward compat with any already-generated JSON) where the garment # silhouette has flared out to its actual shoulder-seam width. shoulder_y, shoulder_x0, shoulder_x1 = _measure_width_at(COLLAR_BAND_FRACTION) shoulder_width_px = shoulder_x1 - shoulder_x0 shoulder_cx_px = int((shoulder_x0 + shoulder_x1) / 2) # Collar point: measured separately, near the TOP of the garment where # the actual collar/neckline sits — this is the point that gets # aligned to the subject's neck/collar line at runtime, and must NOT # be the same row as the (much lower, much wider) shoulder measurement # above. COLLAR_TOP_FRACTION intentionally stays close to the top. collar_y, collar_x0, collar_x1 = _measure_width_at(COLLAR_TOP_FRACTION) collar_cx_px = int((collar_x0 + collar_x1) / 2) out_png = os.path.join(out_dir, f"{garment_id}.png") cropped.save(out_png) anchor = { "garment_id": garment_id, "width": w, "height": h, "shoulder_width_px": shoulder_width_px, "shoulder_y_px": shoulder_y, "shoulder_cx_px": shoulder_cx_px, "collar_y_px": collar_y, "collar_cx_px": collar_cx_px, } out_json = os.path.join(out_dir, f"{garment_id}.json") with open(out_json, "w") as f: json.dump(anchor, f, indent=2) return anchor if __name__ == "__main__": # args: src, gid, out_dir = sys.argv[1], sys.argv[2], sys.argv[3] os.makedirs(out_dir, exist_ok=True) result = normalize_garment(src, out_dir, gid) print(json.dumps(result, indent=2))