Spaces:
Sleeping
Sleeping
| 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'<img src="{uri}" alt="Try-on showcase {idx + 1}" loading="lazy" decoding="async" />') | |
| row = "".join(cards) | |
| compact_class = " compact" if compact else "" | |
| return f""" | |
| <div class="showcase-marquee{compact_class}" aria-label="效果展示"> | |
| <div class="showcase-track">{row}{row}</div> | |
| </div> | |
| """ | |
| RESULT_LAYOUT_CSS = """ | |
| <style> | |
| .tryon-hero { | |
| padding-top: 88px !important; | |
| padding-bottom: 14px !important; | |
| } | |
| .workspace { | |
| display: flex !important; | |
| flex-direction: column !important; | |
| align-items: center !important; | |
| padding-top: 16px !important; | |
| } | |
| .result-card-output { | |
| width: min(1180px, calc(100vw - 56px)) !important; | |
| margin: 0 auto !important; | |
| } | |
| .showcase-wrap { | |
| padding-top: 12px !important; | |
| padding-bottom: 24px !important; | |
| } | |
| @media (max-width: 760px) { | |
| .tryon-hero { | |
| padding-top: 86px !important; | |
| padding-bottom: 10px !important; | |
| } | |
| .result-card-output { | |
| width: min(100%, calc(100vw - 24px)) !important; | |
| } | |
| } | |
| </style> | |
| """ | |
| 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(), | |
| ) | |
| def predict_from_inputs(prompt: str, files): | |
| return _predict_from_prompt_files(prompt, files) | |
| 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""" | |
| <div class="tryon-topbar"> | |
| <img class="tryon-logo" src="{logo_uri}" alt="JoyAI Try-On" decoding="async" /> | |
| </div> | |
| <div class="tryon-loading" aria-live="polite"> | |
| <div class="tryon-spinner" aria-hidden="true"></div> | |
| <div class="tryon-loading-text"> | |
| <span>正在试穿</span> | |
| <span id="tryon-loading-time">0s</span> | |
| </div> | |
| </div> | |
| """ | |
| ) | |
| layout_css = gr.HTML("") | |
| with gr.Column(elem_classes=["tryon-hero"]): | |
| hero_title = gr.HTML('<div class="tryon-title">开始虚拟试穿之旅,放入图像或写指令来看效果</div>') | |
| 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) | |