from __future__ import annotations import base64 import os import random import time import traceback from pathlib import Path import gradio as gr import spaces from PIL import Image, ImageDraw, ImageFilter, ImageFont from inference import load_model, run_tryon from preprocess import resize_for_demo def _patch_gradio_bool_schema() -> None: """Allow Gradio's API info route to handle JSON schema boolean nodes.""" try: import gradio_client.utils as client_utils except Exception: return original = getattr(client_utils, "_json_schema_to_python_type", None) if original is None or getattr(original, "_tryon_bool_schema_patched", False): return def patched_json_schema_to_python_type(schema, defs=None): if isinstance(schema, bool): return "Any" if not isinstance(schema, dict): return "Any" additional_properties = schema.get("additionalProperties") if isinstance(additional_properties, bool): schema = dict(schema) if additional_properties: schema["additionalProperties"] = {} else: schema.pop("additionalProperties", None) return original(schema, defs) patched_json_schema_to_python_type._tryon_bool_schema_patched = True client_utils._json_schema_to_python_type = patched_json_schema_to_python_type _patch_gradio_bool_schema() APP_DIR = Path(__file__).resolve().parent LOGO_PATH = APP_DIR / "assets" / "tryon_logo_compact.png" LOGO_FAST_PATH = APP_DIR / "assets" / "tryon_logo_compact.webp" SHOWCASE_DIR = APP_DIR / "assets" / "showcase" SHOWCASE_FAST_DIR = APP_DIR / "assets" / "showcase_fast" SHOWCASE_IMAGES = [ SHOWCASE_FAST_DIR / f"case_{idx}.webp" if (SHOWCASE_FAST_DIR / f"case_{idx}.webp").exists() else SHOWCASE_DIR / f"case_{idx}.png" for idx in range(4) ] ZERO_GPU_DURATION_SECONDS = int(os.getenv("ZERO_GPU_DURATION_SECONDS", "240")) USE_ZEROGPU = os.getenv("USE_ZEROGPU", "1").strip().lower() not in {"0", "false", "no", "off"} def _gpu(fn): if USE_ZEROGPU: return spaces.GPU(size="xlarge", duration=ZERO_GPU_DURATION_SECONDS)(fn) return fn def _asset_url(path: Path) -> str: return f"/file={path}" if path.exists() else "" def _image_data_uri(path: Path) -> str: if not path.exists(): return "" mime = "image/webp" if path.suffix.lower() == ".webp" else "image/png" data = base64.b64encode(path.read_bytes()).decode("utf-8") return f"data:{mime};base64,{data}" def _showcase_marquee_html(compact: bool = False) -> str: cards = [] for idx, path in enumerate(SHOWCASE_IMAGES): uri = _image_data_uri(path) if uri: cards.append(f'Try-on showcase {idx + 1}') row = "".join(cards) compact_class = " compact" if compact else "" return f"""
{row}{row}
""" RESULT_LAYOUT_CSS = """ """ START_LOADING_JS = """ (message) => { const body = document.body; body.classList.add("tryon-running"); const label = document.querySelector("#tryon-loading-time"); const start = Date.now(); if (window.tryonLoadingTimer) { clearInterval(window.tryonLoadingTimer); } const update = () => { if (!label) return; const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000)); const minutes = Math.floor(seconds / 60); const remain = seconds % 60; label.textContent = minutes > 0 ? `${minutes}m ${remain}s` : `${remain}s`; }; update(); window.tryonLoadingTimer = setInterval(update, 250); return message; } """ STOP_LOADING_JS = """ () => { document.body.classList.remove("tryon-running"); if (window.tryonLoadingTimer) { clearInterval(window.tryonLoadingTimer); window.tryonLoadingTimer = null; } } """ def _file_path(item) -> str | None: if item is None: return None if isinstance(item, str): return item if isinstance(item, dict): return item.get("path") or item.get("name") return getattr(item, "path", None) or getattr(item, "name", None) def _parse_user_inputs(prompt: str, files) -> tuple[Image.Image, list[Image.Image], str]: text = (prompt or "").strip() files = files or [] paths = [path for path in (_file_path(item) for item in files) if path] if len(paths) < 2: raise gr.Error("请点击左下角 + 上传至少 2 张图:第 1 张人物图,后面的图作为服装参考图。") person = Image.open(paths[0]).convert("RGB") garments = [Image.open(path).convert("RGB") for path in paths[1:]] return person, garments, text def _message_to_prompt_files(message) -> tuple[str, list]: if message is None: return "", [] if isinstance(message, str): return message, [] if isinstance(message, dict): return message.get("text") or "", message.get("files") or [] text = getattr(message, "text", "") or "" files = getattr(message, "files", []) or [] return text, files def _font(size: int, bold: bool = False): candidates = [ "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc" if bold else "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf", ] for path in candidates: try: return ImageFont.truetype(path, size) except OSError: continue return ImageFont.load_default() def _cover(image: Image.Image, size: tuple[int, int]) -> Image.Image: image = image.convert("RGB") source_ratio = image.width / image.height target_ratio = size[0] / size[1] if source_ratio > target_ratio: new_height = size[1] new_width = int(new_height * source_ratio) else: new_width = size[0] new_height = int(new_width / source_ratio) resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS) left = (new_width - size[0]) // 2 top = (new_height - size[1]) // 2 return resized.crop((left, top, left + size[0], top + size[1])) def _rounded_image(image: Image.Image, radius: int) -> Image.Image: mask = Image.new("L", image.size, 0) draw = ImageDraw.Draw(mask) draw.rounded_rectangle((0, 0, image.width, image.height), radius=radius, fill=255) rounded = image.convert("RGBA") rounded.putalpha(mask) return rounded def _draw_badge(draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str): font = _font(18, bold=True) left, top = xy bbox = draw.textbbox((0, 0), text, font=font) width = bbox[2] - bbox[0] + 28 height = bbox[3] - bbox[1] + 14 draw.rounded_rectangle((left, top, left + width, top + height), radius=18, fill=(17, 23, 46)) draw.text((left + 14, top + 6), text, font=font, fill=(255, 255, 255)) def _paste_panel(canvas: Image.Image, image: Image.Image, box: tuple[int, int, int, int], label: str): left, top, right, bottom = box panel = _cover(image, (right - left, bottom - top)) panel = _rounded_image(panel, radius=18) canvas.alpha_composite(panel, (left, top)) draw = ImageDraw.Draw(canvas) draw.rounded_rectangle((left, top, right, bottom), radius=18, outline=(228, 228, 228), width=2) _draw_badge(draw, (left + 14, top + 12), label) def _result_card(person: Image.Image, garment: Image.Image, result: Image.Image) -> Image.Image: width, height = 1180, 680 card_left, card_top, card_right, card_bottom = 60, 58, width - 60, height - 58 canvas = Image.new("RGBA", (width, height), (255, 255, 255, 0)) shadow = Image.new("RGBA", (width, height), (255, 255, 255, 0)) shadow_draw = ImageDraw.Draw(shadow) shadow_draw.rounded_rectangle( (card_left, card_top + 18, card_right, card_bottom + 18), radius=58, fill=(0, 0, 0, 55), ) shadow = shadow.filter(ImageFilter.GaussianBlur(22)) canvas.alpha_composite(shadow) draw = ImageDraw.Draw(canvas) draw.rounded_rectangle((card_left, card_top, card_right, card_bottom), radius=58, fill=(250, 250, 250, 255)) gap = 24 left_col = (card_left + 34, card_top + 34, card_left + 420, card_bottom - 34) right_panel = (left_col[2] + gap, left_col[1], card_right - 34, left_col[3]) left_h = (left_col[3] - left_col[1] - gap) // 2 person_panel = (left_col[0], left_col[1], left_col[2], left_col[1] + left_h) garment_panel = (left_col[0], person_panel[3] + gap, left_col[2], left_col[3]) _paste_panel(canvas, person, person_panel, "User Image") _paste_panel(canvas, garment, garment_panel, "Clothing Image") _paste_panel(canvas, result, right_panel, "Virtual Try-On Result") draw = ImageDraw.Draw(canvas) draw.ellipse((card_left + 32, card_bottom - 98, card_left + 132, card_bottom + 2), fill=(194, 194, 194, 220)) draw.text((card_left + 62, card_bottom - 63), "编辑", font=_font(28, bold=True), fill=(255, 255, 255)) draw.ellipse((card_right - 116, card_bottom - 98, card_right - 16, card_bottom + 2), fill=(178, 178, 178, 230)) draw.text((card_right - 82, card_bottom - 73), "↥", font=_font(42, bold=True), fill=(255, 255, 255)) return canvas.convert("RGB") def _predict_from_prompt_files(prompt: str, files): seed = None try: started_at = time.time() person_image, garment_images, prompt = _parse_user_inputs(prompt, files) seed = random.randint(0, 2**31 - 1) person = resize_for_demo(person_image, max_side=1024) garments = [resize_for_demo(img, max_side=1024) for img in garment_images] result, used_index, enhanced_prompt, pe_status, status = run_tryon( person_image=person, garment_images=garments, garment_type="full_body", primary_garment_index=0, seed=int(seed), num_inference_steps=30, guidance_scale=6.0, auto_crop=False, prompt=prompt, prompt_enhancer=True, pe_images=[person, *garments], negative_prompt="", ) result_card = _result_card(person, garments[used_index], result) elapsed = max(1, int(time.time() - started_at)) display_status = f"Thought for {elapsed}s > {status}" return ( gr.update(value=result_card, visible=True), int(seed), used_index, enhanced_prompt, pe_status, gr.update(value=display_status, visible=True), gr.update(value="", visible=False), gr.update(visible=False), RESULT_LAYOUT_CSS, _showcase_marquee_html(compact=True), ) except gr.Error: raise except Exception as exc: tb = traceback.format_exc() print(tb, flush=True) return ( gr.update(value=None, visible=False), gr.update(value=seed), gr.update(value=None), "", "Prompt Enhancer or inference failed. See traceback below.", gr.update(value=f"推理失败:{exc}", visible=True), gr.update(value=tb, visible=True), gr.update(visible=True), "", _showcase_marquee_html(), ) @_gpu def predict_from_inputs(prompt: str, files): return _predict_from_prompt_files(prompt, files) @_gpu def predict_from_message(message): prompt, files = _message_to_prompt_files(message) return _predict_from_prompt_files(prompt, files) def warmup_model(): try: load_model() return "Model preloaded." except Exception: print(traceback.format_exc(), flush=True) return "Model preload failed." CSS = """ :root { --tryon-border: #dedede; --tryon-muted: #6f6f6f; } body, .gradio-container { background: #fff !important; color: #111 !important; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .gradio-container { max-width: none !important; min-height: 100vh; overflow-x: hidden; } .tryon-topbar { position: fixed; z-index: 20; top: 24px; left: 34px; display: flex; align-items: center; } .tryon-logo { width: 273px; height: auto; max-height: 129px; border-radius: 0; object-fit: contain; box-shadow: none; } .tryon-loading { position: fixed; z-index: 24; top: 146px; right: 70px; display: none; align-items: center; gap: 12px; color: #4b4b4b; font-size: 15px; font-weight: 650; letter-spacing: 0; pointer-events: none; } body.tryon-running .tryon-loading { display: flex; } .tryon-spinner { width: 34px; height: 34px; border-radius: 50%; background: conic-gradient(from 0deg, #ff7a1a 0 92deg, rgba(255,122,26,.16) 92deg 360deg); animation: tryon-spin 1s linear infinite; position: relative; } .tryon-spinner::after { content: ""; position: absolute; inset: 7px; border-radius: 50%; background: #fff; } .tryon-loading-text { display: flex; flex-direction: column; gap: 2px; min-width: 76px; } .tryon-loading-text span:first-child { color: #202020; } .tryon-loading-text span:last-child { color: #8a8a8a; font-size: 13px; font-weight: 600; } .tryon-hero { min-height: auto; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 112px 20px 34px; } .tryon-title { font-size: 32px; line-height: 1.2; font-weight: 760; letter-spacing: 0; text-align: center; margin-bottom: 18px; } .prompt-shell { --block-background-fill: transparent; --block-border-color: transparent; --input-background-fill: #fff; --input-border-color: #d7d7d7; --input-shadow: none; max-width: 980px; width: min(980px, calc(100vw - 640px)); margin: 0 auto !important; min-height: 40px; padding: 0 !important; border: 2px solid #d0d0d0 !important; border-radius: 999px !important; background: #fff !important; box-shadow: none !important; filter: none !important; overflow: visible; } .prompt-shell:focus-within { box-shadow: none !important; } .tryon-hero .block:has(.prompt-shell), .tryon-hero .form:has(.prompt-shell), .tryon-hero .wrap:has(.prompt-shell) { border: 0 !important; background: transparent !important; box-shadow: none !important; filter: none !important; } .prompt-shell .block, .prompt-shell .wrap, .prompt-shell .form, .prompt-shell label, .prompt-shell fieldset { border: 0 !important; background: transparent !important; box-shadow: none !important; filter: none !important; } .prompt-shell > *, .prompt-shell .block, .prompt-shell .wrap, .prompt-shell label, .prompt-shell fieldset { background: transparent !important; box-shadow: none !important; filter: none !important; } .prompt-shell textarea, .prompt-shell [contenteditable="true"], .prompt-shell .input-container, .prompt-shell .textarea-container, .prompt-shell [data-testid="textbox"], .prompt-shell [data-testid="textbox"] > div { background: transparent !important; } .prompt-shell .wrap, .prompt-shell .input-container, .prompt-shell .textarea-container, .prompt-shell [data-testid="textbox"] { min-height: 40px !important; border: 0 !important; border-radius: 999px !important; box-shadow: none !important; overflow: visible !important; } .prompt-shell img, .prompt-shell video { width: 42px !important; height: 42px !important; object-fit: cover !important; border-radius: 14px !important; border: 2px solid #d9e7ff !important; } .prompt-shell .file-preview, .prompt-shell .file-preview-holder, .prompt-shell .file-preview-container, .prompt-shell [data-testid="file-preview"], .prompt-shell [role="list"] { display: flex !important; flex-wrap: nowrap !important; gap: 10px !important; overflow-x: auto !important; justify-content: flex-start !important; align-items: flex-start !important; } .prompt-shell textarea, .prompt-shell [contenteditable="true"] { min-height: 38px !important; border: 0 !important; background: transparent !important; box-shadow: none !important; resize: none !important; color: #111 !important; caret-color: #111 !important; font-size: 18px !important; line-height: 1.45 !important; padding: 8px 74px 5px 64px !important; } .prompt-shell textarea::placeholder { color: #8b8b8b !important; opacity: 1 !important; } .prompt-shell button, .prompt-shell label, .prompt-shell input[type="file"] { pointer-events: auto !important; cursor: pointer !important; } .prompt-shell button { box-shadow: none !important; } .prompt-shell .upload-button, .prompt-shell .submit-button { position: absolute !important; top: 50% !important; transform: translateY(-50%) !important; z-index: 4 !important; margin: 0 !important; } .prompt-shell .upload-button { left: 14px !important; width: 34px !important; min-width: 34px !important; height: 34px !important; border-radius: 999px !important; background: #050505 !important; color: #fff !important; } .prompt-shell .upload-button svg { width: 22px !important; height: 22px !important; color: #fff !important; stroke: #fff !important; } .prompt-shell .submit-button { right: 8px !important; width: 34px !important; min-width: 34px !important; height: 34px !important; border-radius: 999px !important; background: #050505 !important; color: #fff !important; font-size: 0 !important; overflow: hidden !important; } .prompt-shell .submit-button::before { content: "↑"; display: block; font-size: 22px; line-height: 1; color: #fff; } .workspace { max-width: 1180px; margin: 0 auto; padding: 0 24px 8px; } .result-card-output { border: 0 !important; background: transparent !important; } .result-card-output img { border-radius: 34px !important; width: min(100%, 1180px) !important; max-height: min(68vh, 680px) !important; object-fit: contain !important; display: block !important; margin: 0 auto !important; box-shadow: none !important; } .result-meta textarea, .result-meta input { color: #8b8b8b !important; border: 0 !important; background: transparent !important; } .traceback-box textarea { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace !important; font-size: 12px !important; line-height: 1.45 !important; color: #b42318 !important; background: #fff7f6 !important; border: 1px solid #f2b8b5 !important; } .showcase-wrap { width: 100%; overflow: hidden; padding: 24px 0 42px; } .mode-preview img, .showcase-marquee img { border-radius: 14px !important; } .showcase-marquee { width: 100%; overflow: hidden; mask-image: linear-gradient(to right, transparent, black 7%, black 93%, transparent); } .showcase-track { display: flex; width: max-content; gap: 34px; animation: showcase-scroll 118s linear infinite; } .showcase-track:hover { animation-play-state: paused; } .showcase-track img { width: min(58vw, 860px); min-width: 640px; aspect-ratio: 16 / 9; object-fit: cover; box-shadow: 0 14px 42px rgba(0,0,0,.10); transition: transform .28s ease, box-shadow .28s ease; } .showcase-track img:hover { transform: scale(1.055); box-shadow: 0 22px 56px rgba(0,0,0,.16); } .showcase-marquee.compact { mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent); } .showcase-marquee.compact .showcase-track { gap: 14px; animation-duration: 92s; } .showcase-marquee.compact .showcase-track img { width: min(19vw, 286px); min-width: 220px; border-radius: 10px !important; box-shadow: 0 8px 22px rgba(0,0,0,.08); } .showcase-marquee.compact .showcase-track img:hover { transform: scale(1.035); box-shadow: 0 12px 30px rgba(0,0,0,.12); } @keyframes showcase-scroll { from { transform: translateX(0); } to { transform: translateX(calc(-50% - 17px)); } } @keyframes tryon-spin { to { transform: rotate(360deg); } } @media (max-width: 760px) { .tryon-topbar { top: 14px; left: 16px; } .tryon-logo { width: 177px; max-height: 84px; } .tryon-hero { padding-top: 104px; } .tryon-title { font-size: 24px; } .tryon-loading { top: 118px; right: 18px; font-size: 13px; } .tryon-spinner { width: 28px; height: 28px; } .tryon-spinner::after { inset: 6px; } .prompt-shell { width: min(100%, calc(100vw - 28px)); } .prompt-shell textarea, .prompt-shell [contenteditable="true"] { font-size: 17px !important; min-height: 38px !important; padding-left: 58px !important; } .showcase-track img { width: 430px; min-width: 430px; } .showcase-marquee.compact .showcase-track img { width: 168px; min-width: 168px; } } """ with gr.Blocks(title="JoyAI Virtual Try-On", css=CSS) as demo: logo_uri = _image_data_uri(LOGO_FAST_PATH if LOGO_FAST_PATH.exists() else LOGO_PATH) gr.HTML( f"""
正在试穿 0s
""" ) layout_css = gr.HTML("") with gr.Column(elem_classes=["tryon-hero"]): hero_title = gr.HTML('
开始虚拟试穿之旅,放入图像或写指令来看效果
') prompt_box = gr.MultimodalTextbox( label="", placeholder="描述你想要的试穿效果", file_count="multiple", file_types=["image"], lines=1, max_lines=2, show_label=False, interactive=True, submit_btn="↑", elem_classes=["prompt-shell"], ) with gr.Column(elem_classes=["workspace"]): status = gr.Textbox(label="", interactive=False, visible=False, elem_classes=["result-meta"]) output_image = gr.Image(label="", type="pil", visible=False, elem_classes=["result-card-output"]) enhanced_prompt = gr.Textbox(label="PE 后完整文本", interactive=False, lines=3, visible=False) pe_status = gr.Textbox(label="Prompt Enhancer 状态", interactive=False, visible=False) traceback_box = gr.Textbox( label="错误详情 / Traceback", interactive=False, lines=14, max_lines=22, visible=False, elem_classes=["traceback-box"], ) with gr.Accordion("运行信息", open=False, visible=False): used_seed = gr.Number(label="Used Seed", precision=0) used_garment_index = gr.Number(label="Used Garment Index", precision=0) with gr.Column(elem_classes=["showcase-wrap"]): showcase = gr.HTML(_showcase_marquee_html()) predict_outputs = [ output_image, used_seed, used_garment_index, enhanced_prompt, pe_status, status, traceback_box, hero_title, layout_css, showcase, ] prompt_box.submit( fn=predict_from_message, inputs=[prompt_box], outputs=predict_outputs, api_name="tryon", js=START_LOADING_JS, show_progress="hidden", trigger_mode="once", concurrency_limit=1, concurrency_id="tryon_gpu", ).then(fn=None, js=STOP_LOADING_JS, queue=False) if os.getenv("PRELOAD_MODEL", "0").strip().lower() in {"1", "true", "yes", "on"}: demo.load(fn=warmup_model, outputs=None, show_progress="hidden") if __name__ == "__main__": demo.queue(max_size=4, default_concurrency_limit=1).launch(show_error=True)