passport-maker / normalize_garments.py
hdremover's picture
Update normalize_garments.py
349e70e verified
Raw
History Blame Contribute Delete
5.13 kB
"""
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_png> <garment_id> <out_dir>
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))