Davidscut commited on
Commit
55db221
·
1 Parent(s): 4046b0f

Align Prompt Enhancer with try-on safetensors inference

Browse files
Files changed (4) hide show
  1. README.md +16 -0
  2. app.py +10 -5
  3. inference.py +186 -5
  4. requirements.txt +1 -0
README.md CHANGED
@@ -61,9 +61,23 @@ JOY_TRANSFORMER_PATH=transformer
61
  JOY_DIT_CKPT_TYPE=safetensor
62
  JOY_MODEL_PATH=FireRed-Image-Edit-1.0
63
  JOY_VAE_PATH=FireRed-Image-Edit-1.0/vae/diffusion_pytorch_model.safetensors
 
 
64
  HF_HOME=/data/.huggingface
65
  ```
66
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  ## Expected Model Repository Layout
68
 
69
  This wrapper downloads `MODEL_ID` with `huggingface_hub.snapshot_download`.
@@ -108,6 +122,8 @@ prompt = <image><image> + try-on instruction
108
  output = one generated PIL image
109
  ```
110
 
 
 
111
  If multiple garment images are uploaded, the app uses `primary_garment_index` to select one primary garment. It does not pretend to perform multi-reference fusion.
112
 
113
  ## Deployment Steps
 
61
  JOY_DIT_CKPT_TYPE=safetensor
62
  JOY_MODEL_PATH=FireRed-Image-Edit-1.0
63
  JOY_VAE_PATH=FireRed-Image-Edit-1.0/vae/diffusion_pytorch_model.safetensors
64
+ QWEN235_CHAT_URL=http://ai-api.jdcloud.com/v1/chat/completions
65
+ QWEN235_MODEL=qwen3-vl-235
66
  HF_HOME=/data/.huggingface
67
  ```
68
 
69
+ For Prompt Enhancer, add one of these as a Space Secret:
70
+
71
+ ```text
72
+ QWEN235_API_KEY=your_api_key
73
+ ```
74
+
75
+ or:
76
+
77
+ ```text
78
+ OPENAI_API_KEY=your_api_key
79
+ ```
80
+
81
  ## Expected Model Repository Layout
82
 
83
  This wrapper downloads `MODEL_ID` with `huggingface_hub.snapshot_download`.
 
122
  output = one generated PIL image
123
  ```
124
 
125
+ Prompt Enhancer is enabled by default in the UI. It sends the garment image and person image to the configured Qwen3-VL-235 endpoint, then displays the enhanced prompt used for generation.
126
+
127
  If multiple garment images are uploaded, the app uses `primary_garment_index` to select one primary garment. It does not pretend to perform multi-reference fusion.
128
 
129
  ## Deployment Steps
app.py CHANGED
@@ -29,6 +29,7 @@ def predict(
29
  garment_type: str,
30
  primary_garment_index: int,
31
  prompt: str,
 
32
  negative_prompt: str,
33
  seed: int,
34
  randomize_seed: bool,
@@ -45,7 +46,7 @@ def predict(
45
  person = resize_for_demo(person_image, max_side=1024)
46
  garments = [resize_for_demo(img, max_side=1024) for img in garment_images]
47
 
48
- result, used_index, used_prompt, status = run_tryon(
49
  person_image=person,
50
  garment_images=garments,
51
  garment_type=garment_type,
@@ -55,10 +56,11 @@ def predict(
55
  guidance_scale=float(guidance_scale),
56
  auto_crop=bool(crop_and_paste),
57
  prompt=prompt,
 
58
  negative_prompt=negative_prompt,
59
  )
60
 
61
- return result, int(seed), used_index, used_prompt, status
62
 
63
  except gr.Error:
64
  raise
@@ -108,11 +110,12 @@ with gr.Blocks(title="Virtual Try-On ZeroGPU Demo") as demo:
108
  )
109
 
110
  prompt = gr.Textbox(
111
- label="Try-On Prompt",
112
  value="",
113
  lines=3,
114
  placeholder="Leave empty to use the default prompt for the selected garment type.",
115
  )
 
116
 
117
  with gr.Accordion("Advanced Settings", open=False):
118
  seed = gr.Number(value=42, precision=0, label="Seed")
@@ -148,7 +151,8 @@ with gr.Blocks(title="Virtual Try-On ZeroGPU Demo") as demo:
148
  output_image = gr.Image(label="Try-On Result", type="pil")
149
  used_seed = gr.Number(label="Used Seed", precision=0)
150
  used_garment_index = gr.Number(label="Used Garment Index", precision=0)
151
- used_prompt = gr.Textbox(label="Prompt Used", interactive=False, lines=3)
 
152
  status = gr.Textbox(label="Status", interactive=False)
153
 
154
  run_button.click(
@@ -159,6 +163,7 @@ with gr.Blocks(title="Virtual Try-On ZeroGPU Demo") as demo:
159
  garment_type,
160
  primary_garment_index,
161
  prompt,
 
162
  negative_prompt,
163
  seed,
164
  randomize_seed,
@@ -166,7 +171,7 @@ with gr.Blocks(title="Virtual Try-On ZeroGPU Demo") as demo:
166
  guidance_scale,
167
  crop_and_paste,
168
  ],
169
- outputs=[output_image, used_seed, used_garment_index, used_prompt, status],
170
  api_name="tryon",
171
  )
172
 
 
29
  garment_type: str,
30
  primary_garment_index: int,
31
  prompt: str,
32
+ prompt_enhancer: bool,
33
  negative_prompt: str,
34
  seed: int,
35
  randomize_seed: bool,
 
46
  person = resize_for_demo(person_image, max_side=1024)
47
  garments = [resize_for_demo(img, max_side=1024) for img in garment_images]
48
 
49
+ result, used_index, enhanced_prompt, pe_status, status = run_tryon(
50
  person_image=person,
51
  garment_images=garments,
52
  garment_type=garment_type,
 
56
  guidance_scale=float(guidance_scale),
57
  auto_crop=bool(crop_and_paste),
58
  prompt=prompt,
59
+ prompt_enhancer=bool(prompt_enhancer),
60
  negative_prompt=negative_prompt,
61
  )
62
 
63
+ return result, int(seed), used_index, enhanced_prompt, pe_status, status
64
 
65
  except gr.Error:
66
  raise
 
110
  )
111
 
112
  prompt = gr.Textbox(
113
+ label="Original Try-On Prompt",
114
  value="",
115
  lines=3,
116
  placeholder="Leave empty to use the default prompt for the selected garment type.",
117
  )
118
+ prompt_enhancer = gr.Checkbox(value=True, label="Prompt Enhancer")
119
 
120
  with gr.Accordion("Advanced Settings", open=False):
121
  seed = gr.Number(value=42, precision=0, label="Seed")
 
151
  output_image = gr.Image(label="Try-On Result", type="pil")
152
  used_seed = gr.Number(label="Used Seed", precision=0)
153
  used_garment_index = gr.Number(label="Used Garment Index", precision=0)
154
+ enhanced_prompt = gr.Textbox(label="Enhanced Prompt Used", interactive=False, lines=5)
155
+ pe_status = gr.Textbox(label="Prompt Enhancer Status", interactive=False)
156
  status = gr.Textbox(label="Status", interactive=False)
157
 
158
  run_button.click(
 
163
  garment_type,
164
  primary_garment_index,
165
  prompt,
166
+ prompt_enhancer,
167
  negative_prompt,
168
  seed,
169
  randomize_seed,
 
171
  guidance_scale,
172
  crop_and_paste,
173
  ],
174
+ outputs=[output_image, used_seed, used_garment_index, enhanced_prompt, pe_status, status],
175
  api_name="tryon",
176
  )
177
 
inference.py CHANGED
@@ -1,15 +1,22 @@
1
  from __future__ import annotations
2
 
 
 
 
 
3
  import math
4
  import os
5
  import random
 
6
  import sys
7
  import contextlib
 
8
  from dataclasses import dataclass
9
  from pathlib import Path
10
  from typing import Any
11
 
12
  import numpy as np
 
13
  import torch
14
  import torchvision.transforms.functional as TF
15
  from huggingface_hub import snapshot_download
@@ -32,6 +39,28 @@ DEFAULT_PROMPTS = {
32
  "full_body": "Make the person in Picture 2 wear the clothing from Picture 1. Keep the pose, body shape, background, and lighting from Picture 2.",
33
  }
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  @dataclass
37
  class LoadedTryOnModel:
@@ -43,6 +72,7 @@ class LoadedTryOnModel:
43
 
44
  _MODEL: LoadedTryOnModel | None = None
45
  _MODEL_DIR: Path | None = None
 
46
 
47
 
48
  def _get_bool_env(name: str, default: bool = False) -> bool:
@@ -70,6 +100,155 @@ def set_seed(seed: int) -> torch.Generator:
70
  return torch.Generator(device="cuda").manual_seed(seed)
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  def _download_model_repo() -> Path:
74
  model_id = os.getenv("MODEL_ID")
75
  if not model_id:
@@ -504,7 +683,8 @@ def run_tryon(
504
  auto_crop: bool = False,
505
  prompt: str | None = None,
506
  negative_prompt: str = DEFAULT_NEG_PROMPT,
507
- ) -> tuple[Image.Image, int, str, str]:
 
508
  if person_image is None:
509
  raise ValueError("person_image is required.")
510
  if not garment_images:
@@ -517,6 +697,7 @@ def run_tryon(
517
  person = person_image.convert("RGB")
518
  garment = primary_garment.convert("RGB")
519
  effective_prompt = _build_prompt(prompt, garment_type)
 
520
  effective_negative = (negative_prompt or DEFAULT_NEG_PROMPT).strip()
521
 
522
  if auto_crop:
@@ -524,7 +705,7 @@ def run_tryon(
524
  model,
525
  person,
526
  garment,
527
- effective_prompt,
528
  num_inference_steps,
529
  guidance_scale,
530
  seed,
@@ -533,7 +714,7 @@ def run_tryon(
533
  if cropped_result is not None:
534
  image, path_status = cropped_result
535
  status = f"Success. Used garment index {used_index}. {path_status}."
536
- return image, used_index, effective_prompt, status
537
 
538
  target_h, target_w = get_bucket_size(person)
539
  input_person = resize_center_crop(person, (target_h, target_w))
@@ -541,7 +722,7 @@ def run_tryon(
541
  output = _run_pipeline(
542
  model,
543
  [input_garment, input_person],
544
- effective_prompt,
545
  target_h,
546
  target_w,
547
  num_inference_steps,
@@ -550,7 +731,7 @@ def run_tryon(
550
  effective_negative,
551
  )
552
  status = f"Success. Used garment index {used_index}. direct path."
553
- return output, used_index, effective_prompt, status
554
 
555
 
556
  def clear_model_cache() -> None:
 
1
  from __future__ import annotations
2
 
3
+ import base64
4
+ import hashlib
5
+ import io
6
+ import json
7
  import math
8
  import os
9
  import random
10
+ import re
11
  import sys
12
  import contextlib
13
+ import time
14
  from dataclasses import dataclass
15
  from pathlib import Path
16
  from typing import Any
17
 
18
  import numpy as np
19
+ import requests
20
  import torch
21
  import torchvision.transforms.functional as TF
22
  from huggingface_hub import snapshot_download
 
39
  "full_body": "Make the person in Picture 2 wear the clothing from Picture 1. Keep the pose, body shape, background, and lighting from Picture 2.",
40
  }
41
 
42
+ QWEN235_MODEL = os.getenv("QWEN235_MODEL", "qwen3-vl-235")
43
+ QWEN235_CHAT_URL = os.getenv("QWEN235_CHAT_URL", "http://ai-api.jdcloud.com/v1/chat/completions")
44
+ PE_SYSTEM_ROLE = "You are an expert vision AI prompt engineer."
45
+ PE_TEMPLATE_DEFAULT = (
46
+ "# Multi-Image Edit Instruction Rewriter\n\n"
47
+ "You are an expert at refining MULTI-IMAGE editing instructions. Given the "
48
+ "user's original prompt and the uploaded reference images (in order: Image 1, "
49
+ "Image 2, Image 3, Image 4), produce a single improved English editing "
50
+ "caption that a diffusion-based multi-image editing model can follow precisely.\n\n"
51
+ "## Core Rules\n"
52
+ "1. Preserve intent. Never invent new operations. Only clarify, enrich and correct what the user already asked for.\n"
53
+ "2. Always refer to the inputs as Image 1 / Image 2 / Image 3 / Image 4.\n"
54
+ "3. For every visual element in the output, state explicitly which reference image it comes from.\n"
55
+ "4. Output the rewritten English caption only. No preamble, no JSON, no markdown fences, no explanations.\n\n"
56
+ "## Replacement / Virtual Try-On Rules\n"
57
+ "For try-on, consistency is more important than creativity. Do not add cinematic effects, extra props, "
58
+ "new lighting, new camera angles, or creative embellishments. Preserve everything in the person image "
59
+ "that is not explicitly replaced: identity, face, hair, skin tone, body shape, pose, expression, "
60
+ "background, lighting, color tone, camera angle, and composition. Replace only the garment, preserving "
61
+ "the garment reference's color, pattern, texture, logo, silhouette, and natural fabric drape."
62
+ )
63
+
64
 
65
  @dataclass
66
  class LoadedTryOnModel:
 
72
 
73
  _MODEL: LoadedTryOnModel | None = None
74
  _MODEL_DIR: Path | None = None
75
+ _PE_CACHE: dict[str, str] = {}
76
 
77
 
78
  def _get_bool_env(name: str, default: bool = False) -> bool:
 
100
  return torch.Generator(device="cuda").manual_seed(seed)
101
 
102
 
103
+ def _encode_image_base64_png(pil_image: Image.Image) -> str:
104
+ buf = io.BytesIO()
105
+ pil_image.convert("RGB").save(buf, format="PNG")
106
+ return base64.b64encode(buf.getvalue()).decode("utf-8")
107
+
108
+
109
+ def _remove_think_block(text: str) -> str:
110
+ return re.sub(r"<think>.*?</think>", "", text or "", flags=re.IGNORECASE | re.DOTALL).strip()
111
+
112
+
113
+ def _normalize_chat_message_content(content: Any) -> str:
114
+ if content is None:
115
+ return ""
116
+ if isinstance(content, str):
117
+ return content.strip()
118
+
119
+ texts: list[str] = []
120
+
121
+ def walk(value: Any) -> None:
122
+ if value is None:
123
+ return
124
+ if isinstance(value, str):
125
+ if value.strip():
126
+ texts.append(value.strip())
127
+ return
128
+ if isinstance(value, dict):
129
+ text = value.get("text")
130
+ if isinstance(text, str) and text.strip():
131
+ texts.append(text.strip())
132
+ for child in value.values():
133
+ if isinstance(child, (dict, list)):
134
+ walk(child)
135
+ return
136
+ if isinstance(value, list):
137
+ for item in value:
138
+ walk(item)
139
+
140
+ walk(content)
141
+ return "\n".join(texts).strip()
142
+
143
+
144
+ def _extract_caption_text(text: str) -> str:
145
+ clean = _remove_think_block((text or "").strip())
146
+ if not clean:
147
+ return ""
148
+ clean = clean.replace("```json", "").replace("```", "").strip()
149
+ try:
150
+ obj = json.loads(clean)
151
+ if isinstance(obj, dict) and isinstance(obj.get("Rewritten"), str):
152
+ return obj["Rewritten"].strip().replace("\n", " ")
153
+ except Exception:
154
+ pass
155
+ return clean
156
+
157
+
158
+ def _image_signature(image: Image.Image | None) -> str:
159
+ if image is None:
160
+ return "none"
161
+ try:
162
+ buf = io.BytesIO()
163
+ image.convert("RGB").save(buf, format="PNG")
164
+ data = buf.getvalue()
165
+ digest = hashlib.md5(data[:65536]).hexdigest()
166
+ return f"{image.size[0]}x{image.size[1]}_{digest}"
167
+ except Exception:
168
+ return f"{image.size[0]}x{image.size[1]}"
169
+
170
+
171
+ def _build_pe_prompt_text(original_prompt: str) -> str:
172
+ original_prompt = str(original_prompt or "").strip()
173
+ if not original_prompt:
174
+ return PE_TEMPLATE_DEFAULT
175
+ return (
176
+ f"Based on the uploaded image(s) and the prompt: '{original_prompt}', "
177
+ "please follow the instructions below to produce the rewritten caption.\n\n"
178
+ f"{PE_TEMPLATE_DEFAULT}"
179
+ )
180
+
181
+
182
+ def _rewrite_prompt_qwen3vl235_chatapi(prompt: str, images: list[Image.Image]) -> str:
183
+ prompt = str(prompt or "").strip()
184
+ if not prompt:
185
+ return prompt
186
+
187
+ api_key = os.getenv("QWEN235_API_KEY") or os.getenv("OPENAI_API_KEY")
188
+ if not api_key:
189
+ raise EnvironmentError("QWEN235_API_KEY or OPENAI_API_KEY is not set")
190
+
191
+ user_content: list[dict[str, Any]] = []
192
+ for image in images:
193
+ user_content.append(
194
+ {
195
+ "type": "image_url",
196
+ "image_url": {"url": f"data:image/png;base64,{_encode_image_base64_png(image)}"},
197
+ }
198
+ )
199
+ user_content.append({"type": "text", "text": _build_pe_prompt_text(prompt)})
200
+
201
+ payload = {
202
+ "model": QWEN235_MODEL,
203
+ "messages": [
204
+ {"role": "system", "content": PE_SYSTEM_ROLE},
205
+ {"role": "user", "content": user_content},
206
+ ],
207
+ "max_tokens": int(os.getenv("QWEN235_MAX_TOKENS", "1024")),
208
+ "stream": False,
209
+ }
210
+ headers = {
211
+ "Authorization": f"Bearer {api_key}",
212
+ "Content-Type": "application/json",
213
+ "Trace-Id": f"tryon-space-qwen235-{int(time.time() * 1000)}",
214
+ }
215
+
216
+ last_err: Exception | None = None
217
+ for attempt in range(int(os.getenv("QWEN235_MAX_RETRIES", "3"))):
218
+ try:
219
+ resp = requests.post(QWEN235_CHAT_URL, headers=headers, json=payload, timeout=float(os.getenv("QWEN235_TIMEOUT", "60")))
220
+ if resp.status_code != 200:
221
+ raise RuntimeError(f"Qwen3-VL-235 API failed: status={resp.status_code}, text={resp.text[:2000]}")
222
+ data = resp.json()
223
+ content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content"))
224
+ caption = _extract_caption_text(_normalize_chat_message_content(content))
225
+ return caption or prompt
226
+ except Exception as exc:
227
+ last_err = exc
228
+ time.sleep(0.5 * (2**attempt))
229
+
230
+ raise RuntimeError(f"Prompt Enhancer failed: {last_err}")
231
+
232
+
233
+ def rewrite_prompt_if_enabled(prompt: str, images: list[Image.Image], enabled: bool) -> tuple[str, str]:
234
+ prompt = str(prompt or "")
235
+ if not enabled:
236
+ return prompt, "Prompt Enhancer disabled."
237
+ if not images:
238
+ return prompt, "Prompt Enhancer skipped: no reference images."
239
+
240
+ cache_key = f"qwen235|p={prompt.strip()}|imgs={'|'.join(_image_signature(img) for img in images)}"
241
+ if cache_key in _PE_CACHE:
242
+ return _PE_CACHE[cache_key], "Prompt Enhancer cache hit."
243
+
244
+ try:
245
+ rewritten = _rewrite_prompt_qwen3vl235_chatapi(prompt, [img.convert("RGB") for img in images])
246
+ _PE_CACHE[cache_key] = rewritten
247
+ return rewritten, "Prompt Enhancer applied."
248
+ except Exception as exc:
249
+ return prompt, f"Prompt Enhancer failed, using original prompt: {exc}"
250
+
251
+
252
  def _download_model_repo() -> Path:
253
  model_id = os.getenv("MODEL_ID")
254
  if not model_id:
 
683
  auto_crop: bool = False,
684
  prompt: str | None = None,
685
  negative_prompt: str = DEFAULT_NEG_PROMPT,
686
+ prompt_enhancer: bool = True,
687
+ ) -> tuple[Image.Image, int, str, str, str]:
688
  if person_image is None:
689
  raise ValueError("person_image is required.")
690
  if not garment_images:
 
697
  person = person_image.convert("RGB")
698
  garment = primary_garment.convert("RGB")
699
  effective_prompt = _build_prompt(prompt, garment_type)
700
+ enhanced_prompt, pe_status = rewrite_prompt_if_enabled(effective_prompt, [garment, person], prompt_enhancer)
701
  effective_negative = (negative_prompt or DEFAULT_NEG_PROMPT).strip()
702
 
703
  if auto_crop:
 
705
  model,
706
  person,
707
  garment,
708
+ enhanced_prompt,
709
  num_inference_steps,
710
  guidance_scale,
711
  seed,
 
714
  if cropped_result is not None:
715
  image, path_status = cropped_result
716
  status = f"Success. Used garment index {used_index}. {path_status}."
717
+ return image, used_index, enhanced_prompt, pe_status, status
718
 
719
  target_h, target_w = get_bucket_size(person)
720
  input_person = resize_center_crop(person, (target_h, target_w))
 
722
  output = _run_pipeline(
723
  model,
724
  [input_garment, input_person],
725
+ enhanced_prompt,
726
  target_h,
727
  target_w,
728
  num_inference_steps,
 
731
  effective_negative,
732
  )
733
  status = f"Success. Used garment index {used_index}. direct path."
734
+ return output, used_index, enhanced_prompt, pe_status, status
735
 
736
 
737
  def clear_model_cache() -> None:
requirements.txt CHANGED
@@ -9,6 +9,7 @@ transformers
9
  safetensors
10
  pillow
11
  opencv-python-headless
 
12
  numpy
13
  scipy
14
  einops
 
9
  safetensors
10
  pillow
11
  opencv-python-headless
12
+ requests
13
  numpy
14
  scipy
15
  einops