speedn commited on
Commit
e83a465
·
verified ·
1 Parent(s): 963d407

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +196 -110
app.py CHANGED
@@ -1,95 +1,145 @@
1
  # app.py
2
- # VisPrompt Studio — Image/Text → Detailed Prompts (CPU)
3
- # Focus: ensure LONG, DETAILED scene descriptions (esp. people/adult photos)
4
- # Models: Qwen2-VL-2B-Instruct (default, richer captions) and BLIP base (faster)
5
-
6
- import os, random, math
 
 
 
 
 
7
  from typing import Optional
8
 
9
  import gradio as gr
10
  from PIL import Image, ExifTags
11
  import torch
12
  from transformers import (
13
- AutoProcessor, AutoModelForVision2Seq,
14
- BlipProcessor, BlipForConditionalGeneration,
 
 
15
  )
16
 
 
17
  MODEL_QWEN2_VL = "Qwen/Qwen2-VL-2B-Instruct"
18
- MODEL_BLIP = "Salesforce/blip-image-captioning-base"
 
19
 
 
20
  _q_proc = _q_model = None
21
  _b_proc = _b_model = None
22
 
23
- def device():
24
- return "cpu" # (If you upgrade to ZeroGPU, switch to "cuda" when available)
25
 
26
- def safe_open_image(inp):
27
- if inp is None: return None
28
- if isinstance(inp, Image.Image): return inp.convert("RGB")
 
 
 
 
 
 
 
 
 
 
 
 
29
  return Image.open(inp).convert("RGB")
30
 
 
31
  def exif_info(img: Image.Image) -> dict:
32
  out = {}
33
  try:
34
  raw = img.getexif()
35
- if raw:
36
- tag_map = {ExifTags.TAGS.get(k, k): v for k, v in raw.items()}
37
- fl = tag_map.get("FocalLength")
38
- if isinstance(fl, tuple) and len(fl)==2 and fl[1]!=0:
39
- out["focal_length_mm"] = round(fl[0]/fl[1], 1)
40
- elif isinstance(fl, (int,float)):
41
- out["focal_length_mm"] = float(fl)
42
- if "Model" in tag_map: out["camera_model"] = str(tag_map["Model"]).strip()
43
- if "Make" in tag_map: out["camera_make"] = str(tag_map["Make"]).strip()
 
 
 
 
 
44
  except Exception:
45
  pass
46
  return out
47
 
48
- CAMERA_ANGLES = ["eye-level","low-angle","high-angle","aerial/top-down","worm's-eye","Dutch tilt"]
49
- SHOT_SIZES = ["extreme close-up","close-up","medium shot","wide shot","extreme wide shot"]
50
- LENSES = ["14mm ultra-wide","24mm wide-angle","35mm street","50mm standard","85mm portrait","135mm telephoto","200mm telephoto"]
51
- LIGHTING = ["soft diffused light","golden hour rim light","overcast softbox feel","hard noon sun with crisp shadows","blue hour ambient","tungsten practicals","neon key with colored rim","studio softbox left + kicker right","volumetric god rays","moody chiaroscuro"]
52
- COMPOSITION = ["rule of thirds","leading lines","central composition","frame within a frame","negative space","foreground bokeh","shallow depth of field","symmetry","diagonal dynamics","layered depth"]
53
- POST = ["high micro-contrast","subtle film grain","cinematic color grade","Kodak Portra palette","teal–orange split tone","matte shadows","HDR tonality restrained","8k detail","photorealistic finish"]
54
- STYLES = ["documentary realism","editorial fashion","street photography","nature landscape","architectural minimalism","cyberpunk neon","sci-fi cinematic","fantasy epic","moody noir","retro 35mm film"]
55
- VIDEO_MOTION = ["slow dolly-in","gentle push-in","handheld micro-jitters","locked-off tripod","gimbal glide","orbit right-to-left","crane rise","parallax truck shot","rack focus mid-shot"]
56
- VIDEO_META = ["24 fps","30 fps","48 fps","cinematic motion blur","shutter 1/48","global illumination feel"]
57
- VIDEO_TRANS = ["straight cut","cinematic fade-in/out","cross dissolve","match cut"]
58
- NEGATIVE = ["no distortion","no chromatic aberration","no blown highlights","no excessive sharpening","no artifacts","no watermark","no text overlay"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
  def smart_choices(caption: str, w: int, h: int, exif: dict, seed: int):
61
- rnd = random.Random(seed)
62
  aspect = (w / h) if h > 0 else 1.0
63
- shot = "wide shot" if aspect >= 1.5 else "medium shot"
64
- text = caption.lower()
65
- if any(k in text for k in ["portrait","selfie","face","headshot"]): shot = "close-up"
66
- if any(k in text for k in ["aerial","drone","top-down","overhead","bird's-eye","bird’s-eye"]):
 
 
 
67
  angle = "aerial/top-down"
68
- elif any(k in text for k in ["towering","imposing","hero","statue"]):
69
  angle = "low-angle"
70
  else:
71
- angle = rnd.choice(CAMERA_ANGLES[:3])
 
72
  lens = f'{exif["focal_length_mm"]}mm' if "focal_length_mm" in exif else rnd.choice(LENSES)
73
- light= rnd.choice(LIGHTING)
74
  comp = ", ".join(rnd.sample(COMPOSITION, k=3))
75
- style= rnd.choice(STYLES)
76
  post = ", ".join(rnd.sample(POST, k=3))
77
- neg = ", ".join(NEGATIVE[:3] + rnd.sample(NEGATIVE[3:], k=2))
78
  return angle, shot, lens, light, comp, style, post, neg, aspect
79
 
 
80
  def build_image_prompt(desc, angle, shot, lens, light, comp, style, post, aspect):
81
  ar = "16:9" if aspect >= 1.6 else ("4:3" if aspect >= 1.3 else "1:1")
82
- # Expanded, sectioned prompt for richer output
83
  return (
84
  f"{desc}\n"
85
  f"Camera: {angle}; {shot}; lens {lens}.\n"
86
- f"Lighting: {light}; natural falloff; realistic reflections and shadows.\n"
87
- f"Composition: {comp}; controlled perspective; clean subject separation; coherent scale.\n"
88
  f"Style: {style}; grounded materials and textures.\n"
89
  f"Post: {post}; minimal artifacts; print-ready clarity.\n"
90
  f"Aspect ratio hint: {ar}."
91
  )
92
 
 
93
  def build_video_prompt(img_prompt: str, motion: str, meta: list, transition: str, seconds: int):
94
  meta_str = ", ".join(meta)
95
  return (
@@ -98,95 +148,113 @@ def build_video_prompt(img_prompt: str, motion: str, meta: list, transition: str
98
  f"Maintain temporal consistency, realistic parallax, and stable lighting throughout."
99
  )
100
 
101
- # ---------------- Caption backends ----------------
 
102
  @torch.inference_mode()
103
  def _ensure_qwen():
104
  global _q_proc, _q_model
105
  if _q_proc is None or _q_model is None:
106
- _q_proc = AutoProcessor.from_pretrained(MODEL_QWEN2_VL, trust_remote_code=True)
107
- _q_model = AutoModelForVision2Seq.from_pretrained(MODEL_QWEN2_VL, torch_dtype=torch.float32, trust_remote_code=True)
 
 
108
  _q_model.to(device()).eval()
109
 
 
110
  @torch.inference_mode()
111
- def caption_qwen(img: Image.Image, max_new_tokens: int = 220, temperature: float = 0.2, top_p: float = 0.9) -> str:
112
  _ensure_qwen()
113
- # Strong, explicit instruction for LONG descriptions
114
- system_inst = (
115
- "Describe this image in exhaustive detail. Include: subject(s), appearance, clothing, pose, "
116
- "body details, interactions, accessories, objects, environment, background, textures, colors, "
117
- "time of day, mood, and context. Be explicit, precise, and visual. Minimum ~80 words."
118
  )
119
- inputs = _q_proc(text=system_inst, images=img, return_tensors="pt")
120
  out = _q_model.generate(
121
  **inputs,
122
  max_new_tokens=int(max_new_tokens),
123
  do_sample=True,
124
  temperature=float(temperature),
125
  top_p=float(top_p),
126
- repetition_penalty=1.05,
127
  )
128
  return _q_proc.batch_decode(out, skip_special_tokens=True)[0].strip()
129
 
 
130
  @torch.inference_mode()
131
  def caption_qwen_retry_if_short(img: Image.Image, min_words: int, **gen_kwargs) -> str:
132
- """Retry once with an even stronger instruction if the result is under min_words."""
133
- text = caption_qwen(img, **gen_kwargs)
134
- if len(text.split()) >= min_words:
135
- return text
136
- # Second pass: force structure and length
137
  _ensure_qwen()
138
  retry_inst = (
139
- "Describe the image again with strict structure and more detail. Sections: "
140
- "1) Subjects & actions, 2) Appearance & clothing, 3) Scene & background, "
141
- "4) Lighting & colors, 5) Camera & framing, 6) Context & mood. "
142
- "Write 120–180 words. Be concrete and visual."
143
  )
144
  inputs = _q_proc(text=retry_inst, images=img, return_tensors="pt")
145
  out = _q_model.generate(
146
  **inputs,
147
  max_new_tokens=max(220, gen_kwargs.get("max_new_tokens", 220)),
148
  do_sample=True,
149
- temperature=gen_kwargs.get("temperature", 0.2),
150
  top_p=gen_kwargs.get("top_p", 0.9),
151
- repetition_penalty=1.07,
152
  )
153
- text2 = _q_proc.batch_decode(out, skip_special_tokens=True)[0].strip()
154
- if len(text2.split()) > len(text.split()):
155
- return text2
156
- return text # fallback to first if somehow shorter
157
 
158
  @torch.inference_mode()
159
  def _ensure_blip():
160
  global _b_proc, _b_model
161
  if _b_proc is None or _b_model is None:
162
- _b_proc = BlipProcessor.from_pretrained(MODEL_BLIP)
163
  _b_model = BlipForConditionalGeneration.from_pretrained(MODEL_BLIP)
164
  _b_model.to(device()).eval()
165
 
 
166
  @torch.inference_mode()
167
- def caption_blip(img: Image.Image, beam_search: bool = True, max_len: int = 96) -> str:
168
  _ensure_blip()
169
  inputs = _b_proc(images=img, return_tensors="pt")
170
- max_len = int(max(32, min(160, max_len)))
171
  kwargs = dict(max_length=max_len, num_beams=4) if beam_search else dict(max_length=max_len)
172
  out = _b_model.generate(**inputs, **kwargs)
173
  return _b_proc.decode(out[0], skip_special_tokens=True).strip()
174
 
175
- def caption_image(model_choice: str, img: Image.Image, min_words: int, max_new_tokens: int):
176
- if model_choice == "Qwen2-VL-2B-Instruct":
177
- return caption_qwen_retry_if_short(
178
- img,
179
- min_words=min_words,
180
- max_new_tokens=max_new_tokens,
181
- temperature=0.35,
182
- top_p=0.92,
183
- )
184
- else:
185
- # BLIP won't reliably hit min_words; we just raise max_len and num_beams
186
- base = caption_blip(img, beam_search=True, max_len=min(160, max_new_tokens))
187
- return base
188
 
189
- # ---------------- Main generate ----------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  def generate(
191
  image: Optional[Image.Image],
192
  text_scene: str,
@@ -195,10 +263,9 @@ def generate(
195
  max_new_tokens: int,
196
  seconds: int,
197
  seed: int,
198
- model_choice: str
199
  ):
200
- rng_seed = int(seed)
201
- rnd = random.Random(rng_seed)
202
 
203
  if image is not None:
204
  img = safe_open_image(image)
@@ -206,12 +273,14 @@ def generate(
206
  w, h = img.size
207
  exif = exif_info(img)
208
  else:
 
209
  if not text_scene or not text_scene.strip():
 
210
  return "", "", ""
211
  caption = text_scene.strip()
212
  w, h, exif = 1920, 1080, {}
213
 
214
- angle, shot, lens, light, comp, style, post, neg, aspect = smart_choices(caption, w, h, exif, rng_seed)
215
  image_prompt = build_image_prompt(caption, angle, shot, lens, light, comp, style, post, aspect)
216
  negative_line = f"\nNegative prompt hints: {neg}"
217
 
@@ -219,8 +288,8 @@ def generate(
219
  image_prompt,
220
  motion=rnd.choice(VIDEO_MOTION),
221
  meta=rnd.sample(VIDEO_META, k=2),
222
- transition=rnd.choice(VIDEO_TRANS),
223
- seconds=int(max(2, min(20, seconds)))
224
  )
225
 
226
  if mode == "Image prompt":
@@ -230,27 +299,29 @@ def generate(
230
  else:
231
  return caption, image_prompt + negative_line, video_prompt
232
 
233
- # ---------------- UI ----------------
 
234
  TITLE = "VisPrompt Studio — Long-Form Scene Descriptions"
235
  DESC = """
236
  Upload an image **or** describe a scene.
237
- This version enforces **long, detailed scene descriptions** before building the camera/lighting prompts.
238
 
239
- Models:
240
- - **Qwen2-VL-2B-Instruct (default)** — richer people/adult descriptions on CPU (slower).
241
- - **BLIP base** — faster but briefer.
242
  """
243
 
244
  with gr.Blocks(title=TITLE) as demo:
245
  gr.Markdown(f"# {TITLE}\n{DESC}")
 
246
  with gr.Row():
247
  with gr.Column(scale=1):
248
- model_choice = gr.Dropdown(
249
  choices=["Qwen2-VL-2B-Instruct", "BLIP base"],
250
  value="Qwen2-VL-2B-Instruct",
251
- label="Captioning model (drives the base scene description)",
252
- allow_custom_value=False
253
  )
 
254
  with gr.Tab("Image input"):
255
  image = gr.Image(type="pil", label="Upload an image", height=360)
256
  with gr.Tab("Text input"):
@@ -259,24 +330,39 @@ with gr.Blocks(title=TITLE) as demo:
259
  mode = gr.Radio(choices=["Image prompt", "Video prompt", "Both"], value="Both", label="Output type")
260
 
261
  with gr.Accordion("Description depth", open=True):
262
- min_words = gr.Slider(40, 180, value=90, step=10, label="Min words for scene description (Qwen)")
263
- max_new_tokens = gr.Slider(96, 300, value=220, step=4, label="Max tokens for caption generation")
264
 
265
  with gr.Accordion("Video settings", open=False):
266
- seconds = gr.Slider(3, 12, value=6, step=1, label="Video duration (seconds)")
267
 
268
  seed = gr.Slider(0, 9999, value=1234, step=1, label="Style seed")
269
  run = gr.Button("Generate prompts", variant="primary")
270
 
271
  with gr.Column(scale=1):
272
- caption_box = gr.Textbox(label="Long scene description (from image or your text)", interactive=False, lines=10, show_copy_button=True)
273
- img_prompt_box = gr.Textbox(label="Image Prompt (copy to image generators)", interactive=False, lines=14, show_copy_button=True)
274
- vid_prompt_box = gr.Textbox(label="Video Prompt (copy to video generators)", interactive=False, lines=14, show_copy_button=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
  run.click(
277
  fn=generate,
278
  inputs=[image, text_scene, mode, min_words, max_new_tokens, seconds, seed, model_choice],
279
- outputs=[caption_box, img_prompt_box, vid_prompt_box]
280
  )
281
 
282
  if __name__ == "__main__":
 
1
  # app.py
2
+ # VisPrompt Studio — Image/Text → Long, Detailed Prompts
3
+ # - Runs on Hugging Face Spaces (free CPU)
4
+ # - Stronger scene descriptions (esp. people/adult) using Qwen2-VL-2B-Instruct
5
+ # - BLIP base as a fast fallback
6
+ # - Model selector is non-typable (Radio) + server-side guard + automatic fallback
7
+ # - Uses Textboxes (not Code) for copy-friendly outputs (Gradio 5.x safe)
8
+
9
+ import os
10
+ import math
11
+ import random
12
  from typing import Optional
13
 
14
  import gradio as gr
15
  from PIL import Image, ExifTags
16
  import torch
17
  from transformers import (
18
+ AutoProcessor,
19
+ AutoModelForVision2Seq,
20
+ BlipProcessor,
21
+ BlipForConditionalGeneration,
22
  )
23
 
24
+ # -------------------- Models --------------------
25
  MODEL_QWEN2_VL = "Qwen/Qwen2-VL-2B-Instruct"
26
+ MODEL_BLIP = "Salesforce/blip-image-captioning-base"
27
+ VALID_MODELS = ("Qwen2-VL-2B-Instruct", "BLIP base")
28
 
29
+ # Lazy globals
30
  _q_proc = _q_model = None
31
  _b_proc = _b_model = None
32
 
 
 
33
 
34
+ def device() -> str:
35
+ """
36
+ Return runtime device. Keep 'cpu' for free Spaces.
37
+ If you later use ZeroGPU, you can switch to:
38
+ return "cuda" if torch.cuda.is_available() else "cpu"
39
+ """
40
+ return "cpu"
41
+
42
+
43
+ # -------------------- Helpers --------------------
44
+ def safe_open_image(inp) -> Optional[Image.Image]:
45
+ if inp is None:
46
+ return None
47
+ if isinstance(inp, Image.Image):
48
+ return inp.convert("RGB")
49
  return Image.open(inp).convert("RGB")
50
 
51
+
52
  def exif_info(img: Image.Image) -> dict:
53
  out = {}
54
  try:
55
  raw = img.getexif()
56
+ if not raw:
57
+ return out
58
+ tag_map = {ExifTags.TAGS.get(k, k): v for k, v in raw.items()}
59
+
60
+ fl = tag_map.get("FocalLength")
61
+ if isinstance(fl, tuple) and len(fl) == 2 and fl[1] != 0:
62
+ out["focal_length_mm"] = round(fl[0] / fl[1], 1)
63
+ elif isinstance(fl, (int, float)):
64
+ out["focal_length_mm"] = float(fl)
65
+
66
+ if "Model" in tag_map:
67
+ out["camera_model"] = str(tag_map["Model"]).strip()
68
+ if "Make" in tag_map:
69
+ out["camera_make"] = str(tag_map["Make"]).strip()
70
  except Exception:
71
  pass
72
  return out
73
 
74
+
75
+ # Lexicons for enrichment
76
+ CAMERA_ANGLES = ["eye-level", "low-angle", "high-angle", "aerial/top-down", "worm's-eye", "Dutch tilt"]
77
+ SHOT_SIZES = ["extreme close-up", "close-up", "medium shot", "wide shot", "extreme wide shot"]
78
+ LENSES = ["14mm ultra-wide", "24mm wide-angle", "35mm street", "50mm standard", "85mm portrait", "135mm telephoto", "200mm telephoto"]
79
+ LIGHTING = [
80
+ "soft diffused light", "golden hour rim light", "overcast softbox feel", "hard noon sun with crisp shadows",
81
+ "blue hour ambient", "tungsten practicals", "neon key with colored rim", "studio softbox left + kicker right",
82
+ "volumetric god rays", "moody chiaroscuro"
83
+ ]
84
+ COMPOSITION = [
85
+ "rule of thirds", "leading lines", "central composition", "frame within a frame", "negative space",
86
+ "foreground bokeh", "shallow depth of field", "symmetry", "diagonal dynamics", "layered depth"
87
+ ]
88
+ POST = [
89
+ "high micro-contrast", "subtle film grain", "cinematic color grade", "Kodak Portra palette",
90
+ "teal–orange split tone", "matte shadows", "HDR tonality restrained", "8k detail", "photorealistic finish"
91
+ ]
92
+ STYLES = [
93
+ "documentary realism", "editorial fashion", "street photography", "nature landscape", "architectural minimalism",
94
+ "cyberpunk neon", "sci-fi cinematic", "fantasy epic", "moody noir", "retro 35mm film"
95
+ ]
96
+ VIDEO_MOTION = [
97
+ "slow dolly-in", "gentle push-in", "handheld micro-jitters", "locked-off tripod", "gimbal glide",
98
+ "orbit right-to-left", "crane rise", "parallax truck shot", "rack focus mid-shot"
99
+ ]
100
+ VIDEO_META = ["24 fps", "30 fps", "48 fps", "cinematic motion blur", "shutter 1/48", "global illumination feel"]
101
+ VIDEO_TRANSITIONS = ["straight cut", "cinematic fade-in/out", "cross dissolve", "match cut"]
102
+ NEGATIVE = ["no distortion", "no chromatic aberration", "no blown highlights", "no excessive sharpening", "no artifacts", "no watermark", "no text overlay"]
103
+
104
 
105
  def smart_choices(caption: str, w: int, h: int, exif: dict, seed: int):
106
+ rnd = random.Random(seed)
107
  aspect = (w / h) if h > 0 else 1.0
108
+
109
+ shot = "wide shot" if aspect >= 1.5 else "medium shot"
110
+ t = caption.lower()
111
+ if any(k in t for k in ["portrait", "selfie", "face", "headshot"]):
112
+ shot = "close-up"
113
+
114
+ if any(k in t for k in ["aerial", "drone", "top-down", "overhead", "bird's-eye", "bird’s-eye"]):
115
  angle = "aerial/top-down"
116
+ elif any(k in t for k in ["towering", "imposing", "hero", "statue"]):
117
  angle = "low-angle"
118
  else:
119
+ angle = rnd.choice(CAMERA_ANGLES[:3]) # eye/high/low
120
+
121
  lens = f'{exif["focal_length_mm"]}mm' if "focal_length_mm" in exif else rnd.choice(LENSES)
122
+ light = ", ".join(rnd.sample(LIGHTING, k=2))
123
  comp = ", ".join(rnd.sample(COMPOSITION, k=3))
124
+ style = rnd.choice(STYLES)
125
  post = ", ".join(rnd.sample(POST, k=3))
126
+ neg = ", ".join(NEGATIVE[:3] + rnd.sample(NEGATIVE[3:], k=2))
127
  return angle, shot, lens, light, comp, style, post, neg, aspect
128
 
129
+
130
  def build_image_prompt(desc, angle, shot, lens, light, comp, style, post, aspect):
131
  ar = "16:9" if aspect >= 1.6 else ("4:3" if aspect >= 1.3 else "1:1")
 
132
  return (
133
  f"{desc}\n"
134
  f"Camera: {angle}; {shot}; lens {lens}.\n"
135
+ f"Lighting: {light}; realistic reflections and shadow behavior.\n"
136
+ f"Composition: {comp}; clean subject separation; coherent scale and perspective.\n"
137
  f"Style: {style}; grounded materials and textures.\n"
138
  f"Post: {post}; minimal artifacts; print-ready clarity.\n"
139
  f"Aspect ratio hint: {ar}."
140
  )
141
 
142
+
143
  def build_video_prompt(img_prompt: str, motion: str, meta: list, transition: str, seconds: int):
144
  meta_str = ", ".join(meta)
145
  return (
 
148
  f"Maintain temporal consistency, realistic parallax, and stable lighting throughout."
149
  )
150
 
151
+
152
+ # -------------------- Caption backends --------------------
153
  @torch.inference_mode()
154
  def _ensure_qwen():
155
  global _q_proc, _q_model
156
  if _q_proc is None or _q_model is None:
157
+ _q_proc = AutoProcessor.from_pretrained(MODEL_QWEN2_VL, trust_remote_code=True)
158
+ _q_model = AutoModelForVision2Seq.from_pretrained(
159
+ MODEL_QWEN2_VL, torch_dtype=torch.float32, trust_remote_code=True
160
+ )
161
  _q_model.to(device()).eval()
162
 
163
+
164
  @torch.inference_mode()
165
+ def caption_qwen(img: Image.Image, max_new_tokens: int = 220, temperature: float = 0.3, top_p: float = 0.9) -> str:
166
  _ensure_qwen()
167
+ # Force long, explicit visual descriptions
168
+ inst = (
169
+ "Describe this image in exhaustive visual detail. Include: subjects, appearance, clothing, pose, "
170
+ "body details, accessories, objects, background, textures, colors, time of day, mood, context. "
171
+ "Be concrete and specific. Minimum ~80 words."
172
  )
173
+ inputs = _q_proc(text=inst, images=img, return_tensors="pt")
174
  out = _q_model.generate(
175
  **inputs,
176
  max_new_tokens=int(max_new_tokens),
177
  do_sample=True,
178
  temperature=float(temperature),
179
  top_p=float(top_p),
180
+ repetition_penalty=1.06,
181
  )
182
  return _q_proc.batch_decode(out, skip_special_tokens=True)[0].strip()
183
 
184
+
185
  @torch.inference_mode()
186
  def caption_qwen_retry_if_short(img: Image.Image, min_words: int, **gen_kwargs) -> str:
187
+ """Retry once with a stricter structured instruction if too short."""
188
+ first = caption_qwen(img, **gen_kwargs)
189
+ if len(first.split()) >= min_words:
190
+ return first
191
+
192
  _ensure_qwen()
193
  retry_inst = (
194
+ "Rewrite a much longer, structured description of the image. Sections: "
195
+ "1) Subjects & actions; 2) Appearance & clothing; 3) Scene & background; "
196
+ "4) Lighting & colors; 5) Context & mood. Use 120–180 words. Be explicit and visual."
 
197
  )
198
  inputs = _q_proc(text=retry_inst, images=img, return_tensors="pt")
199
  out = _q_model.generate(
200
  **inputs,
201
  max_new_tokens=max(220, gen_kwargs.get("max_new_tokens", 220)),
202
  do_sample=True,
203
+ temperature=gen_kwargs.get("temperature", 0.3),
204
  top_p=gen_kwargs.get("top_p", 0.9),
205
+ repetition_penalty=1.08,
206
  )
207
+ second = _q_proc.batch_decode(out, skip_special_tokens=True)[0].strip()
208
+ return second if len(second.split()) >= min_words else first
209
+
 
210
 
211
  @torch.inference_mode()
212
  def _ensure_blip():
213
  global _b_proc, _b_model
214
  if _b_proc is None or _b_model is None:
215
+ _b_proc = BlipProcessor.from_pretrained(MODEL_BLIP)
216
  _b_model = BlipForConditionalGeneration.from_pretrained(MODEL_BLIP)
217
  _b_model.to(device()).eval()
218
 
219
+
220
  @torch.inference_mode()
221
+ def caption_blip(img: Image.Image, beam_search: bool = True, max_len: int = 140) -> str:
222
  _ensure_blip()
223
  inputs = _b_proc(images=img, return_tensors="pt")
224
+ max_len = int(max(32, min(200, max_len)))
225
  kwargs = dict(max_length=max_len, num_beams=4) if beam_search else dict(max_length=max_len)
226
  out = _b_model.generate(**inputs, **kwargs)
227
  return _b_proc.decode(out[0], skip_special_tokens=True).strip()
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
+ def caption_image(model_choice: str, img: Image.Image, min_words: int, max_new_tokens: int) -> str:
231
+ # Guard invalid values coming from the UI
232
+ if model_choice not in VALID_MODELS:
233
+ model_choice = "Qwen2-VL-2B-Instruct"
234
+
235
+ # Try requested model, fall back to BLIP on any exception so UX doesn't hard-crash
236
+ try:
237
+ if model_choice == "Qwen2-VL-2B-Instruct":
238
+ return caption_qwen_retry_if_short(
239
+ img,
240
+ min_words=min_words,
241
+ max_new_tokens=max_new_tokens,
242
+ temperature=0.35,
243
+ top_p=0.92,
244
+ )
245
+ else:
246
+ # BLIP tends to be briefer; push max_len up
247
+ return caption_blip(img, beam_search=True, max_len=min(200, max_new_tokens))
248
+ except Exception:
249
+ # Fallback
250
+ try:
251
+ return caption_blip(img, beam_search=True, max_len=min(200, max_new_tokens))
252
+ except Exception as e:
253
+ # Last-resort: return readable error text in the caption box
254
+ return f"[Error] Captioning failed: {type(e).__name__}: {e}"
255
+
256
+
257
+ # -------------------- Main generate --------------------
258
  def generate(
259
  image: Optional[Image.Image],
260
  text_scene: str,
 
263
  max_new_tokens: int,
264
  seconds: int,
265
  seed: int,
266
+ model_choice: str,
267
  ):
268
+ rnd = random.Random(int(seed))
 
269
 
270
  if image is not None:
271
  img = safe_open_image(image)
 
273
  w, h = img.size
274
  exif = exif_info(img)
275
  else:
276
+ # Text-only path: use user text as the scene description directly
277
  if not text_scene or not text_scene.strip():
278
+ # Return empty strings so Gradio boxes clear but no crash
279
  return "", "", ""
280
  caption = text_scene.strip()
281
  w, h, exif = 1920, 1080, {}
282
 
283
+ angle, shot, lens, light, comp, style, post, neg, aspect = smart_choices(caption, w, h, exif, seed)
284
  image_prompt = build_image_prompt(caption, angle, shot, lens, light, comp, style, post, aspect)
285
  negative_line = f"\nNegative prompt hints: {neg}"
286
 
 
288
  image_prompt,
289
  motion=rnd.choice(VIDEO_MOTION),
290
  meta=rnd.sample(VIDEO_META, k=2),
291
+ transition=rnd.choice(VIDEO_TRANSITIONS),
292
+ seconds=int(max(2, min(20, seconds))),
293
  )
294
 
295
  if mode == "Image prompt":
 
299
  else:
300
  return caption, image_prompt + negative_line, video_prompt
301
 
302
+
303
+ # -------------------- UI --------------------
304
  TITLE = "VisPrompt Studio — Long-Form Scene Descriptions"
305
  DESC = """
306
  Upload an image **or** describe a scene.
307
+ This build forces **long, explicit scene descriptions** first, then adds detailed camera/lighting/comp info for image and video prompts.
308
 
309
+ **Models**
310
+ - Qwen2-VL-2B-Instruct (default): richer descriptions on people/adult photos (slower on CPU).
311
+ - BLIP base: faster on CPU; simpler captions.
312
  """
313
 
314
  with gr.Blocks(title=TITLE) as demo:
315
  gr.Markdown(f"# {TITLE}\n{DESC}")
316
+
317
  with gr.Row():
318
  with gr.Column(scale=1):
319
+ model_choice = gr.Radio(
320
  choices=["Qwen2-VL-2B-Instruct", "BLIP base"],
321
  value="Qwen2-VL-2B-Instruct",
322
+ label="Captioning model (drives the base scene description)"
 
323
  )
324
+
325
  with gr.Tab("Image input"):
326
  image = gr.Image(type="pil", label="Upload an image", height=360)
327
  with gr.Tab("Text input"):
 
330
  mode = gr.Radio(choices=["Image prompt", "Video prompt", "Both"], value="Both", label="Output type")
331
 
332
  with gr.Accordion("Description depth", open=True):
333
+ min_words = gr.Slider(40, 200, value=90, step=10, label="Minimum words for scene description (Qwen)")
334
+ max_new_tokens = gr.Slider(96, 320, value=220, step=4, label="Max tokens for caption generation")
335
 
336
  with gr.Accordion("Video settings", open=False):
337
+ seconds = gr.Slider(3, 12, value=6, step=1, label="Video duration for prompt (seconds)")
338
 
339
  seed = gr.Slider(0, 9999, value=1234, step=1, label="Style seed")
340
  run = gr.Button("Generate prompts", variant="primary")
341
 
342
  with gr.Column(scale=1):
343
+ caption_box = gr.Textbox(
344
+ label="Long scene description (from image or your text)",
345
+ interactive=False,
346
+ lines=10,
347
+ show_copy_button=True,
348
+ )
349
+ img_prompt_box = gr.Textbox(
350
+ label="Image Prompt (copy to image generators)",
351
+ interactive=False,
352
+ lines=14,
353
+ show_copy_button=True,
354
+ )
355
+ vid_prompt_box = gr.Textbox(
356
+ label="Video Prompt (copy to video generators)",
357
+ interactive=False,
358
+ lines=14,
359
+ show_copy_button=True,
360
+ )
361
 
362
  run.click(
363
  fn=generate,
364
  inputs=[image, text_scene, mode, min_words, max_new_tokens, seconds, seed, model_choice],
365
+ outputs=[caption_box, img_prompt_box, vid_prompt_box],
366
  )
367
 
368
  if __name__ == "__main__":