virtual-try-on / app.py
SpaceNugget's picture
fix: prevent extra legs from garment reference bleeding into lower bodyTwo fixes for the 'three legs' artifact:1. Prompt: add explicit 'exactly two legs' constraint to upper-body prompt2. Crop: upper-body garments are cropped to top 75% before being used as the reference latent β€” hanging ties, drawstrings and tails in the bottom quarter were being read as limbs by the model
0f1978d
Raw
History Blame Contribute Delete
42.4 kB
import os
import json
import time
import uuid
import shutil
import subprocess
import threading
import requests
from io import BytesIO
from pathlib import Path
import numpy as np
import huggingface_hub as _hfhub
# Patch: huggingface_hub >= 0.24 removed HfFolder; gradio 4.44.0 still uses it in oauth.py.
if not hasattr(_hfhub, "HfFolder"):
class _HfFolderCompat:
@staticmethod
def get_token():
try:
return _hfhub.utils.get_token()
except Exception:
return None
@staticmethod
def save_token(token): pass
@staticmethod
def delete_token(): pass
_hfhub.HfFolder = _HfFolderCompat
from PIL import ImageEnhance
import sys as _sys
from types import ModuleType as _ModuleType
from uvicorn.protocols.websockets.wsproto_impl import WSProtocol as _WSP
import gradio.networking as _gnet
import starlette.templating as _st
import jinja2.utils as _jutils
import gradio_client.utils as _gcu
# Load .env for local development (ignored if not installed / not present)
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
# Compatibility shim β€” newer huggingface_hub removed HfFolder which old gradio needs
if not hasattr(_hfhub, "HfFolder"):
class _HfFolder:
@staticmethod
def get_token(): return _hfhub.get_token()
@staticmethod
def save_token(token): pass
_hfhub.HfFolder = _HfFolder
import spaces
import torch
import gradio as gr
from PIL import Image
from huggingface_hub import hf_hub_download
from transformers import CLIPProcessor, CLIPModel
# ---------------------------------------------------------------------------
# Paths β€” use /data (persistent storage) if available, else /home/user
# ---------------------------------------------------------------------------
BASE_DIR = "/data" if os.path.exists("/data") else "/home/user"
COMFYUI_DIR = f"{BASE_DIR}/ComfyUI"
COMFYUI_INPUT = f"{COMFYUI_DIR}/input"
COMFYUI_OUTPUT = f"{COMFYUI_DIR}/output"
COMFYUI_MODELS = f"{COMFYUI_DIR}/models"
COMFYUI_CUSTOM_NODES = f"{COMFYUI_DIR}/custom_nodes"
COMFYUI_URL = "http://127.0.0.1:8188"
# ---------------------------------------------------------------------------
# Custom nodes
# ---------------------------------------------------------------------------
CUSTOM_NODES = {
"ComfyUI-GGUF": "https://github.com/city96/ComfyUI-GGUF",
"masquerade-nodes-comfyui": "https://github.com/BadCafeCode/masquerade-nodes-comfyui",
"ComfyUI-KJNodes": "https://github.com/kijai/ComfyUI-KJNodes",
"ComfyUI_LayerStyle_Advance": "https://github.com/chflame163/ComfyUI_LayerStyle_Advance",
"comfyui-sam3": "https://github.com/PozzettiAndrea/ComfyUI-SAM3",
}
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
MODELS = [
{
"repo_id": "unsloth/FLUX.2-klein-4B-GGUF",
"filename": "flux-2-klein-4b-Q8_0.gguf",
"dest": f"{COMFYUI_MODELS}/unet/flux-2-klein-4b-Q8_0.gguf",
},
{
"repo_id": "Comfy-Org/z_image_turbo",
"filename": "split_files/text_encoders/qwen_3_4b.safetensors",
"dest": f"{COMFYUI_MODELS}/text_encoders/qwen_3_4b.safetensors",
},
{
"repo_id": "Comfy-Org/flux2-dev",
"filename": "split_files/vae/flux2-vae.safetensors",
"dest": f"{COMFYUI_MODELS}/vae/flux2-vae.safetensors",
},
{
"repo_id": "p1atdev/auraflow-v0.3-pvc-style-lora",
"filename": "aura-pvc-2-_00010e_074520s.safetensors",
"revision": "cafeee8ab8681ab679944b4e75ab0bdc4bdec6f7",
"dest": f"{COMFYUI_MODELS}/loras/aura-pvc-2-_00010e_074520s.safetensors",
},
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def run(cmd: str, **kwargs):
print(f"$ {cmd}")
subprocess.run(cmd, shell=True, check=True, **kwargs)
def download_model(model: dict):
dest = Path(model["dest"])
if dest.exists():
print(f" already exists: {dest.name}")
return
dest.parent.mkdir(parents=True, exist_ok=True)
print(f" downloading {dest.name} ...")
kwargs = dict(repo_id=model["repo_id"], filename=model["filename"])
if "revision" in model:
kwargs["revision"] = model["revision"]
cached = hf_hub_download(**kwargs)
shutil.copy(cached, dest)
print(f" saved β†’ {dest}")
# ---------------------------------------------------------------------------
# Body composite β€” preserve upper or lower body from original after generation
# ---------------------------------------------------------------------------
def composite_body(result: Image.Image, original_arr, garment_type: str) -> Image.Image:
"""
Blend the un-swapped body region from the original back onto the result
using a feathered horizontal mask to avoid hard seams.
"""
if garment_type == "full":
return result
rw, rh = result.size
original = Image.fromarray(original_arr).resize((rw, rh), Image.LANCZOS)
# Waist is roughly at 57% from top for a standing portrait
split = 0.57 if garment_type == "upper" else 0.43
split_px = int(rh * split)
feather = int(rh * 0.08) # smooth over 8% of height
mask = np.zeros((rh, rw), dtype=np.float32)
if garment_type == "upper":
# White (use result) above waist, black (use original) below
mask[:max(0, split_px - feather)] = 1.0
for i in range(feather * 2):
y = split_px - feather + i
if 0 <= y < rh:
mask[y] = 1.0 - (i / (feather * 2))
else:
# White (use result) below waist, black (use original) above
mask[min(rh, split_px + feather):] = 1.0
for i in range(feather * 2):
y = split_px - feather + i
if 0 <= y < rh:
mask[y] = i / (feather * 2)
mask_img = Image.fromarray((mask * 255).astype(np.uint8))
return Image.composite(result, original, mask_img)
# ---------------------------------------------------------------------------
# Product photo detector β€” if garment is already on plain background, skip SAM3
# ---------------------------------------------------------------------------
def is_product_photo(img_pil: Image.Image, bg_threshold: float = 0.80) -> bool:
"""
Returns True if the garment image has a plain, uniform background
(white, beige, cream, grey β€” i.e. a product/hanger shot, not a lifestyle photo).
Uses average brightness + low colour variance rather than per-channel thresholds,
so beige/cream backgrounds are correctly identified.
"""
arr = np.array(img_pil.convert("RGB"))
h, w = arr.shape[:2]
m_h, m_w = max(1, h // 8), max(1, w // 8)
corners = [
arr[:m_h, :m_w], arr[:m_h, -m_w:],
arr[-m_h:, :m_w], arr[-m_h:, -m_w:],
]
pixels = np.concatenate([c.reshape(-1, 3) for c in corners]).astype(float)
# Plain background: high average brightness AND low per-pixel colour variance
avg_brightness = pixels.mean(axis=1) # mean of R,G,B per pixel
channel_std = pixels.std(axis=1) # colour variance per pixel (low = neutral tone)
bright_ratio = float((avg_brightness > 175).mean())
uniform_ratio = float((channel_std < 30).mean())
result = bright_ratio > bg_threshold and uniform_ratio > 0.70
print(f"Product photo: {result} (bright={bright_ratio:.2f}, uniform={uniform_ratio:.2f})")
return result
def remove_garment_bg(img_pil: Image.Image) -> Image.Image:
"""
Use rembg to strip the background and return the garment on white.
Falls back to the original image if rembg is unavailable.
"""
try:
# Force CPU execution β€” onnxruntime would otherwise try to use CUDA
# inside @spaces.GPU and may fail to initialise the CUDA provider.
session = new_session("u2net", providers=["CPUExecutionProvider"])
result = remove(img_pil.convert("RGBA"), session=session)
white_bg = Image.new("RGBA", result.size, (255, 255, 255, 255))
white_bg.paste(result, mask=result.split()[3]) # alpha as mask
return white_bg.convert("RGB")
except Exception as e:
print(f"rembg background removal failed ({e}), using original image")
return img_pil
# ---------------------------------------------------------------------------
# Garment type classifier (CLIP zero-shot, runs on CPU)
# ---------------------------------------------------------------------------
_clip_model = None
_clip_processor = None
def _load_clip():
global _clip_model, _clip_processor
if _clip_model is None:
print("Loading CLIP classifier...")
_clip_model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32").cpu()
_clip_processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
_clip_model.eval()
return _clip_model, _clip_processor
def _clip_inputs_to_cpu(inputs):
"""Move all tensor values in a processor output dict to CPU."""
return {k: v.cpu() if hasattr(v, "cpu") else v for k, v in inputs.items()}
_GARMENT_LABELS = [
"upper body clothing: shirt, top, blouse, jacket, sweater, hoodie",
"lower body clothing: pants, jeans, shorts, skirt, trousers",
"full body clothing: dress, jumpsuit, romper, overall, bodysuit",
]
_GARMENT_TYPES = ["upper", "lower", "full"]
_PROMPTS = {
"upper": (
"start with Picture 1 as the base image, keeping its lighting, environment, and background. "
"Do NOT change the pants, trousers, shorts, skirt, shoes or accessories β€” keep the entire lower body from Picture 1 pixel-perfect. "
"The person has exactly two legs β€” preserve both legs exactly as they appear in Picture 1, do not add, remove, or alter any limbs. "
"Remove only the top/shirt/jacket from Picture 1 and replace it with the exact garment shown in Picture 2, "
"strictly preserving the color, fabric texture, pattern, sleeve style, silhouette, and overall design of the garment in Picture 2. "
"Only add features that are clearly visible in Picture 2 β€” do not invent or add details not present in Picture 2. "
"Do not add or alter any lower body clothing. "
"Match the pose from Picture 1, high quality, sharp details, 4k. "
"Preserve the face, hair and expression from Picture 1 exactly."
),
"lower": (
"start with Picture 1 as the base image, keeping its lighting, environment, and background. "
"Keep the top/shirt/jacket and the shoes/feet/accessories from Picture 1 exactly as they are. "
"Remove only the pants/skirt/shorts from Picture 1 and replace them with the exact garment from Picture 2, "
"strictly preserving its color, fabric, pattern, length, silhouette, and overall design. "
"Only add features that are clearly visible in Picture 2 β€” do not invent or add details not present in Picture 2. "
"Match the pose from Picture 1, high quality, sharp details, 4k. "
"Preserve the face and expression from Picture 1 exactly."
),
"full": (
"start with Picture 1 as the base image, keeping its lighting, environment, and background. "
"Remove the entire outfit from Picture 1 and replace it completely with the exact garment from Picture 2, "
"strictly preserving its color, fabric, pattern, silhouette, sleeve style, neckline, and overall design. "
"Only add features that are clearly visible in Picture 2 β€” do not invent or add details not present in Picture 2. "
"Match the pose from Picture 1, high quality, sharp details, 4k. "
"Preserve the face and expression from Picture 1 exactly."
),
}
def classify_garment(img_pil: Image.Image) -> str:
"""Returns 'upper', 'lower', or 'full'."""
model, processor = _load_clip()
inputs = _clip_inputs_to_cpu(
processor(text=_GARMENT_LABELS, images=img_pil, return_tensors="pt", padding=True)
)
with torch.no_grad():
outputs = model(**inputs)
probs = outputs.logits_per_image.softmax(dim=1)[0]
idx = probs.argmax().item()
label = _GARMENT_TYPES[idx]
print(f"Garment classified as: {label} (scores: {probs.tolist()})")
return label
_SPECIFIC_GARMENTS = [
"shirt", "t-shirt", "blouse", "top",
"cardigan", "sweater", "hoodie",
"coat", "jacket", "blazer",
"dress", "jumpsuit", "romper",
"skirt", "skort",
"jeans", "pants", "trousers", "shorts",
]
# Maps specific garment β†’ upper/lower/full category (overrides CLIP body-part classifier)
_GARMENT_CATEGORY = {
"shirt": "upper", "t-shirt": "upper", "blouse": "upper", "top": "upper",
"cardigan": "upper", "sweater": "upper", "hoodie": "upper",
"coat": "upper", "jacket": "upper", "blazer": "upper",
"dress": "full", "jumpsuit": "full", "romper": "full",
"skirt": "lower", "skort": "lower",
"jeans": "lower", "pants": "lower", "trousers": "lower", "shorts": "lower",
}
def detect_specific_garment(img_pil: Image.Image) -> str:
"""Use CLIP to identify the specific garment type (shirt, skirt, jeans, etc.).
Runs on CPU β€” no GPU needed."""
model, processor = _load_clip()
texts = [f"a photo of a {g}" for g in _SPECIFIC_GARMENTS]
inputs = _clip_inputs_to_cpu(
processor(text=texts, images=img_pil, return_tensors="pt", padding=True)
)
with torch.no_grad():
logits = model(**inputs).logits_per_image[0]
detected = _SPECIFIC_GARMENTS[logits.argmax().item()]
print(f"Specific garment detected: {detected}")
return detected
_COLOR_CANDIDATES = [
"black", "white", "grey", "beige", "cream", "brown", "navy blue",
"red", "orange", "yellow", "green", "teal", "blue", "purple",
"pink", "coral", "olive", "burgundy", "light blue", "dark green",
]
_PATTERN_CANDIDATES = [
"solid plain color", "stripes", "plaid or checkered", "floral print",
"polka dots", "geometric pattern", "animal print", "abstract print",
"camouflage", "color block", "embroidered", "lace",
]
_SLEEVE_CANDIDATES = [
"sleeveless", "short sleeve", "long sleeve",
]
def describe_garment(img_pil: Image.Image) -> str:
"""Use CLIP to detect color, pattern, and sleeve length.
Kept intentionally narrow β€” CLIP zero-shot is reliable for these broad categories
but unreliable for fine-grained attributes (necklines, ruffles, ties), which cause
the model to hallucinate features that aren't there if CLIP guesses wrong."""
model, processor = _load_clip()
def _best(candidates, prompt_template):
texts = [prompt_template.format(c) for c in candidates]
inputs = _clip_inputs_to_cpu(
processor(text=texts, images=img_pil, return_tensors="pt", padding=True)
)
with torch.no_grad():
logits = model(**inputs).logits_per_image[0]
return candidates[logits.argmax().item()]
color = _best(_COLOR_CANDIDATES, "a {} colored piece of clothing")
pattern = _best(_PATTERN_CANDIDATES, "a {} fabric or textile")
sleeve = _best(_SLEEVE_CANDIDATES, "a piece of clothing with {} sleeves")
pattern_str = "" if ("solid" in pattern or "plain" in pattern) else f" {pattern}"
desc = f"{color}{pattern_str} {sleeve}"
print(f"Garment description: {desc}")
return desc
# ---------------------------------------------------------------------------
# Setup β€” split into two parts:
# setup_env() : clone repos, install packages, download models (no GPU needed)
# start_comfyui(): launch ComfyUI subprocess (must run inside @spaces.GPU)
# ---------------------------------------------------------------------------
def setup_env():
# 1. Clone ComfyUI
if not Path(f"{COMFYUI_DIR}/main.py").exists():
print("=== Cloning ComfyUI ===")
run(f"git clone --depth 1 https://github.com/comfyanonymous/ComfyUI {COMFYUI_DIR}")
print("=== Installing ComfyUI requirements ===")
run(f"pip install -r {COMFYUI_DIR}/requirements.txt -q --break-system-packages")
# 2. Custom nodes
print("=== Installing custom nodes ===")
os.makedirs(COMFYUI_CUSTOM_NODES, exist_ok=True)
for name, url in CUSTOM_NODES.items():
node_dir = Path(f"{COMFYUI_CUSTOM_NODES}/{name}")
if not node_dir.exists():
print(f" cloning {name}")
run(f"git clone --depth 1 {url} {node_dir}")
req = node_dir / "requirements.txt"
if req.exists():
run(f"pip install -r {req} -q --break-system-packages")
# 3. Models
print("=== Downloading models ===")
for model in MODELS:
download_model(model)
# 4. Config
config_path = Path(f"{COMFYUI_DIR}/user/__manager/config.ini")
if config_path.exists():
content = config_path.read_text()
content = content.replace("network_mode = public", "network_mode = personal_cloud")
content = content.replace("security_level = strict", "security_level = normal")
config_path.write_text(content)
_comfyui_started = False
def start_comfyui():
"""Start ComfyUI subprocess. Must be called from within a @spaces.GPU context."""
global _comfyui_started
if _comfyui_started:
return
print("=== Starting ComfyUI ===")
os.makedirs(COMFYUI_INPUT, exist_ok=True)
os.makedirs(COMFYUI_OUTPUT, exist_ok=True)
proc = subprocess.Popen(
f"python {COMFYUI_DIR}/main.py --listen --port 8188",
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
def _stream(p):
for line in p.stdout:
print(line.decode(errors="replace"), end="")
threading.Thread(target=_stream, args=(proc,), daemon=True).start()
print("Waiting for ComfyUI...")
for _ in range(60):
try:
if requests.get(f"{COMFYUI_URL}/system_stats", timeout=3).status_code == 200:
print("ComfyUI ready!")
_comfyui_started = True
return
except Exception:
pass
time.sleep(3)
raise RuntimeError("ComfyUI failed to start within 3 minutes")
# ---------------------------------------------------------------------------
# Inference
# ---------------------------------------------------------------------------
def _preprocess_garment(clothing_arr):
"""Run ALL CLIP inference on the uploaded garment image.
Executes OUTSIDE @spaces.GPU β€” CLIP runs on CPU and should not
consume ZeroGPU allocation or risk CUDA device conflicts.
Returns (dropdown_update, garment_info_dict)."""
if clothing_arr is None:
return gr.update(), None
try:
img_pil = Image.fromarray(clothing_arr)
img_pil_small = img_pil.copy()
img_pil_small.thumbnail((512, 512), Image.LANCZOS)
specific_type = detect_specific_garment(img_pil_small)
garment_type = _GARMENT_CATEGORY.get(specific_type, "upper")
# Fall back to body-part classifier if specific type not in our map
if specific_type not in _GARMENT_CATEGORY:
garment_type = classify_garment(img_pil_small)
color_pattern = describe_garment(img_pil_small)
garment_desc = f"{color_pattern} {specific_type}"
info = {
"specific_type": specific_type,
"garment_type": garment_type,
"garment_desc": garment_desc,
"color_pattern": color_pattern,
}
print(f"Pre-computed garment info (CPU): {info}")
return gr.update(value=specific_type), info
except Exception as e:
import traceback as _tb
print(f"_preprocess_garment failed: {e}\n{_tb.format_exc()}")
return gr.update(), None
@spaces.GPU(duration=180)
def generate(target_img, clothing_img, garment_type_override=None,
progress=gr.Progress(track_tqdm=True)):
"""Main generation function β€” runs inside ZeroGPU worker (after fork).
CLIP inference is safe here because it happens post-fork in the worker,
not in the parent Gradio process."""
import traceback as _tb
try:
return _run_generate(target_img, clothing_img, garment_type_override, progress)
except gr.Error:
raise
except Exception as e:
# Surface the actual error message in the UI instead of bare 'RuntimeError'
msg = f"{type(e).__name__}: {e}"
print(f"generate() unhandled error:\n{_tb.format_exc()}")
raise gr.Error(msg)
def _run_generate(target_img, clothing_img, garment_type_override, progress):
"""Actual generation logic β€” called inside @spaces.GPU context."""
if target_img is None or clothing_img is None:
raise gr.Error("Please upload both images before generating.")
start_comfyui()
# Clear ComfyUI node output cache so previous results don't bleed into this run
try:
requests.post(f"{COMFYUI_URL}/free", json={"unload_models": False, "free_memory": True})
except Exception:
pass
# Save user images to ComfyUI input folder (cap at 1024px to save GPU time)
def _prep(arr):
img = Image.fromarray(arr)
img.thumbnail((1024, 1024), Image.LANCZOS)
# Add 1-pixel invisible noise so content hash is unique every run,
# preventing ComfyUI from serving cached node outputs.
px = np.array(img)
px[0, 0, 0] = (int(px[0, 0, 0]) + 1) % 256
return Image.fromarray(px)
def _boost_contrast(img_pil):
"""Boost contrast on low-contrast garment images so rembg and the model
can distinguish garment from background. Applies proportionally β€”
images with normal contrast are left unchanged."""
std = np.array(img_pil.convert("RGB")).std()
if std < 30: # very flat (white linen on cream etc.) β€” strong boost
factor = 2.2
elif std < 50: # mildly low contrast β€” moderate boost
factor = 1.6
else: # normal contrast β€” no change
return img_pil
print(f"Contrast boost Γ—{factor:.1f} (image std={std:.1f})")
img_pil = ImageEnhance.Contrast(img_pil).enhance(factor)
img_pil = ImageEnhance.Sharpness(img_pil).enhance(1.3) # recover edge detail
return img_pil
uid = uuid.uuid4().hex[:8]
target_name = f"target_{uid}.png"
clothing_name = f"clothing_{uid}.png"
clothing_pil = _boost_contrast(_prep(clothing_img))
_prep(target_img).save(f"{COMFYUI_INPUT}/{target_name}")
def _crop_upper_garment(img_pil):
"""Crop an upper-body garment image to its top 75%.
Hanging elements (ties, tails) in the bottom quarter can be misread
as legs by the model when they appear in the reference latent."""
w, h = img_pil.size
return img_pil.crop((0, 0, w, int(h * 0.75)))
clothing_pil.save(f"{COMFYUI_INPUT}/{clothing_name}")
# Garment type β€” (1) user dropdown override, (2) CLIP auto-detect inside GPU worker
# All CLIP calls are intentionally inside _run_generate (ZeroGPU worker, post-fork)
# so they don't initialise CUDA in the parent Gradio process.
product_photo = is_product_photo(clothing_pil)
if garment_type_override and garment_type_override in _GARMENT_CATEGORY:
specific_type = garment_type_override
garment_type = _GARMENT_CATEGORY[specific_type]
print(f"User-overridden garment type: {specific_type} β†’ {garment_type}")
# Still describe color/pattern even when type is overridden
try:
color_pattern = describe_garment(clothing_pil)
garment_desc = f"{color_pattern} {specific_type}".strip()
except Exception as _e:
print(f"describe_garment failed ({_e})")
garment_desc = specific_type
else:
try:
specific_type = detect_specific_garment(clothing_pil)
garment_type = _GARMENT_CATEGORY.get(specific_type) or classify_garment(clothing_pil)
color_pattern = describe_garment(clothing_pil)
garment_desc = f"{color_pattern} {specific_type}".strip()
print(f"Auto-detected: {specific_type} β†’ {garment_type} | {garment_desc}")
except Exception as _e:
print(f"CLIP garment detection failed ({_e}), falling back")
specific_type = "garment"
garment_type = classify_garment(clothing_pil)
garment_desc = garment_type
# Load workflow and inject filenames, seed, and description-enriched prompt
with open("workflow_api.json") as f:
workflow = json.load(f)
workflow["76"]["inputs"]["image"] = target_name
workflow["81"]["inputs"]["image"] = clothing_name
workflow["104"]["inputs"]["noise_seed"] = int(time.time() * 1000) % (2 ** 32)
# Inject garment description into prompt so the model knows what to copy
base_prompt = _PROMPTS[garment_type]
enriched_prompt = base_prompt.replace(
"the garment from Picture 2",
f"the {garment_desc} garment from Picture 2"
).replace(
"the cloth from Picture 2",
f"the {garment_desc} garment from Picture 2"
).replace(
"the exact garment shown in Picture 2",
f"the exact {garment_desc} garment shown in Picture 2"
)
workflow["107"]["inputs"]["text"] = enriched_prompt
# For upper-body garments, crop to top 75% so that hanging elements
# (ties, belts, tails) don't bleed into the lower-body reference latent
# and get misread as limbs by the model.
clothing_ref = clothing_pil
if garment_type == "upper":
clothing_ref = _crop_upper_garment(clothing_pil)
print(f"Cropped upper garment to 75% height: {clothing_ref.size}")
# If garment is already on a plain background, bypass SAM3 + PersonMaskUltra.
# First strip the background with rembg so the garment is on clean white β€”
# this prevents beige/cream backgrounds from confusing the model.
if product_photo:
print("Product photo β€” removing background with rembg, bypassing SAM3")
clothing_clean = remove_garment_bg(clothing_ref)
clean_name = f"clothing_clean_{uid}.png"
clothing_clean.save(f"{COMFYUI_INPUT}/{clean_name}")
workflow["81"]["inputs"]["image"] = clean_name
workflow["110"]["inputs"]["image"] = ["81", 0]
elif garment_type == "upper":
# Save the cropped version as the reference even when not a product photo
cropped_name = f"clothing_crop_{uid}.png"
clothing_ref.save(f"{COMFYUI_INPUT}/{cropped_name}")
workflow["81"]["inputs"]["image"] = cropped_name
# Submit to ComfyUI
progress(0.05, desc="Submitting to ComfyUI...")
client_id = uuid.uuid4().hex
resp = requests.post(
f"{COMFYUI_URL}/prompt",
json={"prompt": workflow, "client_id": client_id},
)
resp.raise_for_status()
prompt_id = resp.json()["prompt_id"]
# Poll /history until done
progress(0.1, desc="Generating β€” this takes 1–2 minutes...")
started = time.time()
while True:
history = requests.get(f"{COMFYUI_URL}/history/{prompt_id}").json()
if prompt_id in history:
entry = history[prompt_id]
status = entry.get("status", {})
if status.get("status_str") == "error" or entry.get("error"):
raise gr.Error(f"Generation failed: {entry.get('error', 'unknown error')}")
break
elapsed = int(time.time() - started)
progress(min(0.9, 0.1 + elapsed / 150 * 0.8), desc=f"Generating... ({elapsed}s)")
time.sleep(3)
# Retrieve output image
outputs = history[prompt_id].get("outputs", {})
for node_output in outputs.values():
if "images" in node_output:
img_info = node_output["images"][0]
img_bytes = requests.get(
f"{COMFYUI_URL}/view",
params={
"filename": img_info["filename"],
"subfolder": img_info.get("subfolder", ""),
"type": img_info.get("type", "output"),
},
).content
progress(1.0, desc="Done!")
result = Image.open(BytesIO(img_bytes))
tmp_path = f"/tmp/cloth_swap_{uid}.png"
result.save(tmp_path)
type_display = {"upper": "πŸ‘• Upper body", "lower": "πŸ‘– Lower body", "full": "πŸ‘— Full body"}
label_md = gr.update(visible=True, value=f"*Detected garment type: **{type_display[garment_type]}***")
return result, gr.update(value=tmp_path, visible=True), label_md
raise gr.Error("No output image was returned by ComfyUI.") # noqa: returns (image, file) on success
@spaces.GPU(duration=90)
def generate_model(description):
"""Generate a standing fashion model from a text description."""
start_comfyui()
try:
requests.post(f"{COMFYUI_URL}/free", json={"unload_models": False, "free_memory": True})
except Exception:
pass
seed = int(time.time() * 1000) % (2 ** 32)
with open("workflow_model_api.json") as f:
workflow = json.load(f)
base = "professional fashion model, full body, standing, neutral pose, plain white background, high quality photograph"
prompt = f"{base}, {description.strip()}" if description.strip() else base
workflow["m_text"]["inputs"]["text"] = prompt
workflow["m_noise"]["inputs"]["noise_seed"] = seed
client_id = uuid.uuid4().hex
resp = requests.post(f"{COMFYUI_URL}/prompt", json={"prompt": workflow, "client_id": client_id})
resp.raise_for_status()
prompt_id = resp.json()["prompt_id"]
started = time.time()
while True:
history = requests.get(f"{COMFYUI_URL}/history/{prompt_id}").json()
if prompt_id in history:
entry = history[prompt_id]
if entry.get("status", {}).get("status_str") == "error" or entry.get("error"):
raise gr.Error(f"Model generation failed: {entry.get('error', 'unknown error')}")
break
if time.time() - started > 80:
raise gr.Error("Model generation timed out.")
time.sleep(3)
outputs = history[prompt_id].get("outputs", {})
for node_output in outputs.values():
if "images" in node_output:
img_info = node_output["images"][0]
img_bytes = requests.get(
f"{COMFYUI_URL}/view",
params={"filename": img_info["filename"], "subfolder": img_info.get("subfolder", ""), "type": img_info.get("type", "output")},
).content
img = Image.open(BytesIO(img_bytes)).convert("RGB")
return np.array(img)
raise gr.Error("No image returned by model generation.")
# ---------------------------------------------------------------------------
# Access code β€” set the same code somewhere visible in your CV
# ---------------------------------------------------------------------------
ACCESS_CODE = os.getenv("SECRET_CODE", "")
# Pure client-side unlock β€” no server roundtrip, no loading spinner.
# The code is embedded in JS (same security level as a plain-text textbox).
_gate_js = f"""
function() {{
const CODE = {repr(ACCESS_CODE)};
function injectCSS(id, rules) {{
let el = document.getElementById(id);
if (!el) {{ el = document.createElement('style'); el.id = id; document.head.appendChild(el); }}
el.textContent = rules;
}}
function tryUnlock() {{
const codeEl = document.querySelector('#code_input textarea') ||
document.querySelector('#code_input input');
if (!codeEl) return;
if (codeEl.value.trim() === CODE) {{
injectCSS('_unlock_css',
'#gate_col {{ display: none !important; }}' +
'#app_col {{ display: block !important; }}' +
'#error_msg {{ display: none !important; }}'
);
}} else {{
injectCSS('_unlock_css',
'#error_msg {{ display: block !important; }}'
);
const err = document.getElementById('error_msg');
if (err) {{
const p = err.querySelector('p') || err;
p.textContent = 'Incorrect code β€” check your CV and try again.';
}}
}}
}}
function wireUp() {{
const btn = document.getElementById('unlock_btn');
const codeEl = document.querySelector('#code_input textarea') ||
document.querySelector('#code_input input');
if (!btn || !codeEl) return false;
btn.addEventListener('click', tryUnlock);
codeEl.addEventListener('keydown', (e) => {{
if (e.key === 'Enter') {{ e.preventDefault(); tryUnlock(); }}
}});
return true;
}}
let attempts = 0;
const poll = setInterval(() => {{
if (wireUp() || ++attempts > 30) clearInterval(poll);
}}, 200);
}}
"""
CV_FILE = "Sofia_Metelitsa_CV.pdf"
# ---------------------------------------------------------------------------
# Run setup at module load (no GPU needed). ComfyUI starts inside @spaces.GPU.
# ---------------------------------------------------------------------------
setup_env()
# ---------------------------------------------------------------------------
# Gradio UI
# ---------------------------------------------------------------------------
with gr.Blocks(title="Virtual Try On", theme=gr.themes.Soft(), js=_gate_js) as demo:
# ── Gate screen ──────────────────────────────────────────────────────────
with gr.Column(visible=True, elem_id="gate_col") as gate:
gr.Markdown(
"""
*AI-powered outfit swapping built with FLUX.2 Kontext + ComfyUI*
---
### πŸ”‘ To access this tool, download my CV.
The access code is inside the CV.
"""
)
dl_btn = gr.DownloadButton(
label="πŸ“„ Download CV",
value=CV_FILE,
variant="secondary",
size="lg",
)
gr.Markdown("---")
code_input = gr.Textbox(
label="Enter access code from CV",
placeholder="Access code",
type="text",
max_lines=1,
elem_id="code_input",
)
unlock_btn = gr.Button("Unlock β†’", variant="primary", elem_id="unlock_btn")
error_msg = gr.Markdown("Incorrect code β€” check your CV and try again.", visible=False, elem_id="error_msg")
# ── Main app (hidden until unlocked) ─────────────────────────────────────
# Build preset image lists
_people_paths = sorted(Path("images/people").glob("*.*"))
_garments_paths = sorted(Path("images/garments").glob("*.*"))
with gr.Column(visible=False, elem_id="app_col") as main_app:
gr.Markdown(
"""
# Virtual Try On
Pick a **person** and a **garment** from the presets below, or upload your own.
The AI swaps the outfit while preserving pose, lighting, and expression.
"""
)
with gr.Row():
target_input = gr.Image(label="Person", type="numpy", height=350)
clothing_input = gr.Image(label="Garment", type="numpy", height=350)
with gr.Row():
model_prompt = gr.Textbox(
label="Or generate a model",
placeholder="e.g. woman with curly red hair, 30s / young man, athletic build",
lines=1,
scale=5,
)
gen_model_btn = gr.Button("Generate Model", scale=1)
with gr.Row():
with gr.Column():
gr.Markdown("**People presets β€” click to select**")
gr.Examples(
examples=[[str(p)] for p in _people_paths],
inputs=[target_input],
label=None,
examples_per_page=8,
)
with gr.Column():
gr.Markdown("**Garment presets β€” click to select**")
gr.Examples(
examples=[[str(p)] for p in _garments_paths],
inputs=[clothing_input],
label=None,
examples_per_page=12,
)
garment_type_dd = gr.Dropdown(
choices=_SPECIFIC_GARMENTS,
value=None,
label="Garment type (auto-detected β€” correct if wrong)",
interactive=True,
allow_custom_value=False,
)
garment_label = gr.Markdown(visible=False)
btn = gr.Button("✨ Generate", variant="primary", size="lg")
output = gr.Image(label="Result", height=500)
download = gr.File(label="⬇️ Download result", visible=False)
gr.Markdown(
"*Generation takes ~2–3 minutes on this hardware. "
"Results may vary β€” if the swap doesn't look right, try hitting Generate again. "
"Each run uses a different random seed so you may get a better result on the next try.*"
)
# NOTE: CLIP inference must NOT run in the main Gradio process β€” calling PyTorch
# in the parent initialises the CUDA context, which poisons ZeroGPU's fork-based
# workers (_is_in_bad_fork). All CLIP calls happen inside _run_generate()
# which executes in the ZeroGPU worker (after fork), so it is safe there.
btn.click(
fn=generate,
inputs=[target_input, clothing_input, garment_type_dd],
outputs=[output, download, garment_label],
)
gen_model_btn.click(fn=generate_model, inputs=[model_prompt], outputs=[target_input])
# Unlock is handled entirely client-side via _gate_js β€” no server call needed.
# The HF base image ships websockets v13+ which removed the legacy API that
# the installed uvicorn version uses. Redirect uvicorn's WebSocket backend to
# wsproto before demo.launch() triggers the import of uvicorn.protocols.websockets.auto.
_ws_auto = _ModuleType("uvicorn.protocols.websockets.auto")
_sys.modules["uvicorn.protocols.websockets.auto"] = _ws_auto
try:
_ws_auto.AutoWebSocketsProtocol = _WSP
print("uvicorn β†’ wsproto WebSocket backend active")
except Exception as _e:
print(f"WARNING: wsproto backend setup failed: {_e}")
# When server_name="0.0.0.0", gradio constructs local_url as "http://0.0.0.0:7860/"
# which is a bind address and can't be used as a connection target β€” url_ok fails.
# The server IS running; patch url_ok so gradio doesn't block on this false negative.
try:
_gnet.url_ok = lambda url: True
except Exception as _e:
print(f"url_ok patch failed: {_e}")
# Starlette 0.36+ changed TemplateResponse(name, context) β†’ TemplateResponse(request, name, context).
# Gradio 4.44.0 still uses the old signature, so "index.html" ends up as `request`
# and the context dict ends up as `name`. Patch to restore the old behaviour.
try:
_orig_TR = _st.Jinja2Templates.TemplateResponse
def _compat_TR(self, *args, **kwargs):
if args and isinstance(args[0], str):
# Old-style call: TemplateResponse("template.html", context_dict, ...)
name = args[0]
context = args[1] if len(args) > 1 else kwargs.pop("context", {})
request = context.get("request")
return _orig_TR(self, request, name, context, **kwargs)
return _orig_TR(self, *args, **kwargs)
_st.Jinja2Templates.TemplateResponse = _compat_TR
except Exception as _e:
print(f"starlette TemplateResponse patch failed: {_e}")
# Jinja2 3.1.4 bug: LRUCache uses unhashable dict as cache key when globals is
# non-empty, causing TemplateResponse to crash. Convert TypeError β†’ KeyError so
# templates are loaded fresh when the key can't be cached.
try:
_LRU = _jutils.LRUCache
_orig_gi = _LRU.__getitem__
_orig_si = _LRU.__setitem__
def _safe_gi(self, key):
try:
return _orig_gi(self, key)
except TypeError:
raise KeyError(key)
def _safe_si(self, key, value):
try:
_orig_si(self, key, value)
except TypeError:
pass
_LRU.__getitem__ = _safe_gi
_LRU.__setitem__ = _safe_si
except Exception as _e:
print(f"jinja2 LRUCache patch failed: {_e}")
# gradio_client bug: _json_schema_to_python_type() can't handle bool schemas
# (e.g. additionalProperties: true). Patch the internal recursive function so
# any non-dict schema is treated as "Any" instead of raising APIInfoParseError.
try:
_orig_j2p = _gcu._json_schema_to_python_type
def _safe_j2p(schema, defs=None):
if not isinstance(schema, dict):
return "Any"
return _orig_j2p(schema, defs)
_gcu._json_schema_to_python_type = _safe_j2p
except Exception as _e:
print(f"gradio_client patch failed: {_e}")
demo.launch(server_name="0.0.0.0", show_error=True, show_api=False)