VisPromptStudio / app.py
speedn's picture
Update app.py
e83a465 verified
Raw
History Blame Contribute Delete
14.5 kB
# app.py
# VisPrompt Studio — Image/Text → Long, Detailed Prompts
# - Runs on Hugging Face Spaces (free CPU)
# - Stronger scene descriptions (esp. people/adult) using Qwen2-VL-2B-Instruct
# - BLIP base as a fast fallback
# - Model selector is non-typable (Radio) + server-side guard + automatic fallback
# - Uses Textboxes (not Code) for copy-friendly outputs (Gradio 5.x safe)
import os
import math
import random
from typing import Optional
import gradio as gr
from PIL import Image, ExifTags
import torch
from transformers import (
AutoProcessor,
AutoModelForVision2Seq,
BlipProcessor,
BlipForConditionalGeneration,
)
# -------------------- Models --------------------
MODEL_QWEN2_VL = "Qwen/Qwen2-VL-2B-Instruct"
MODEL_BLIP = "Salesforce/blip-image-captioning-base"
VALID_MODELS = ("Qwen2-VL-2B-Instruct", "BLIP base")
# Lazy globals
_q_proc = _q_model = None
_b_proc = _b_model = None
def device() -> str:
"""
Return runtime device. Keep 'cpu' for free Spaces.
If you later use ZeroGPU, you can switch to:
return "cuda" if torch.cuda.is_available() else "cpu"
"""
return "cpu"
# -------------------- Helpers --------------------
def safe_open_image(inp) -> Optional[Image.Image]:
if inp is None:
return None
if isinstance(inp, Image.Image):
return inp.convert("RGB")
return Image.open(inp).convert("RGB")
def exif_info(img: Image.Image) -> dict:
out = {}
try:
raw = img.getexif()
if not raw:
return out
tag_map = {ExifTags.TAGS.get(k, k): v for k, v in raw.items()}
fl = tag_map.get("FocalLength")
if isinstance(fl, tuple) and len(fl) == 2 and fl[1] != 0:
out["focal_length_mm"] = round(fl[0] / fl[1], 1)
elif isinstance(fl, (int, float)):
out["focal_length_mm"] = float(fl)
if "Model" in tag_map:
out["camera_model"] = str(tag_map["Model"]).strip()
if "Make" in tag_map:
out["camera_make"] = str(tag_map["Make"]).strip()
except Exception:
pass
return out
# Lexicons for enrichment
CAMERA_ANGLES = ["eye-level", "low-angle", "high-angle", "aerial/top-down", "worm's-eye", "Dutch tilt"]
SHOT_SIZES = ["extreme close-up", "close-up", "medium shot", "wide shot", "extreme wide shot"]
LENSES = ["14mm ultra-wide", "24mm wide-angle", "35mm street", "50mm standard", "85mm portrait", "135mm telephoto", "200mm telephoto"]
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"
]
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"
]
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"
]
STYLES = [
"documentary realism", "editorial fashion", "street photography", "nature landscape", "architectural minimalism",
"cyberpunk neon", "sci-fi cinematic", "fantasy epic", "moody noir", "retro 35mm film"
]
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"
]
VIDEO_META = ["24 fps", "30 fps", "48 fps", "cinematic motion blur", "shutter 1/48", "global illumination feel"]
VIDEO_TRANSITIONS = ["straight cut", "cinematic fade-in/out", "cross dissolve", "match cut"]
NEGATIVE = ["no distortion", "no chromatic aberration", "no blown highlights", "no excessive sharpening", "no artifacts", "no watermark", "no text overlay"]
def smart_choices(caption: str, w: int, h: int, exif: dict, seed: int):
rnd = random.Random(seed)
aspect = (w / h) if h > 0 else 1.0
shot = "wide shot" if aspect >= 1.5 else "medium shot"
t = caption.lower()
if any(k in t for k in ["portrait", "selfie", "face", "headshot"]):
shot = "close-up"
if any(k in t for k in ["aerial", "drone", "top-down", "overhead", "bird's-eye", "bird’s-eye"]):
angle = "aerial/top-down"
elif any(k in t for k in ["towering", "imposing", "hero", "statue"]):
angle = "low-angle"
else:
angle = rnd.choice(CAMERA_ANGLES[:3]) # eye/high/low
lens = f'{exif["focal_length_mm"]}mm' if "focal_length_mm" in exif else rnd.choice(LENSES)
light = ", ".join(rnd.sample(LIGHTING, k=2))
comp = ", ".join(rnd.sample(COMPOSITION, k=3))
style = rnd.choice(STYLES)
post = ", ".join(rnd.sample(POST, k=3))
neg = ", ".join(NEGATIVE[:3] + rnd.sample(NEGATIVE[3:], k=2))
return angle, shot, lens, light, comp, style, post, neg, aspect
def build_image_prompt(desc, angle, shot, lens, light, comp, style, post, aspect):
ar = "16:9" if aspect >= 1.6 else ("4:3" if aspect >= 1.3 else "1:1")
return (
f"{desc}\n"
f"Camera: {angle}; {shot}; lens {lens}.\n"
f"Lighting: {light}; realistic reflections and shadow behavior.\n"
f"Composition: {comp}; clean subject separation; coherent scale and perspective.\n"
f"Style: {style}; grounded materials and textures.\n"
f"Post: {post}; minimal artifacts; print-ready clarity.\n"
f"Aspect ratio hint: {ar}."
)
def build_video_prompt(img_prompt: str, motion: str, meta: list, transition: str, seconds: int):
meta_str = ", ".join(meta)
return (
f"{img_prompt}\n"
f"Video rendering: {seconds}s; camera motion: {motion}; {meta_str}; transitions: {transition}.\n"
f"Maintain temporal consistency, realistic parallax, and stable lighting throughout."
)
# -------------------- Caption backends --------------------
@torch.inference_mode()
def _ensure_qwen():
global _q_proc, _q_model
if _q_proc is None or _q_model is None:
_q_proc = AutoProcessor.from_pretrained(MODEL_QWEN2_VL, trust_remote_code=True)
_q_model = AutoModelForVision2Seq.from_pretrained(
MODEL_QWEN2_VL, torch_dtype=torch.float32, trust_remote_code=True
)
_q_model.to(device()).eval()
@torch.inference_mode()
def caption_qwen(img: Image.Image, max_new_tokens: int = 220, temperature: float = 0.3, top_p: float = 0.9) -> str:
_ensure_qwen()
# Force long, explicit visual descriptions
inst = (
"Describe this image in exhaustive visual detail. Include: subjects, appearance, clothing, pose, "
"body details, accessories, objects, background, textures, colors, time of day, mood, context. "
"Be concrete and specific. Minimum ~80 words."
)
inputs = _q_proc(text=inst, images=img, return_tensors="pt")
out = _q_model.generate(
**inputs,
max_new_tokens=int(max_new_tokens),
do_sample=True,
temperature=float(temperature),
top_p=float(top_p),
repetition_penalty=1.06,
)
return _q_proc.batch_decode(out, skip_special_tokens=True)[0].strip()
@torch.inference_mode()
def caption_qwen_retry_if_short(img: Image.Image, min_words: int, **gen_kwargs) -> str:
"""Retry once with a stricter structured instruction if too short."""
first = caption_qwen(img, **gen_kwargs)
if len(first.split()) >= min_words:
return first
_ensure_qwen()
retry_inst = (
"Rewrite a much longer, structured description of the image. Sections: "
"1) Subjects & actions; 2) Appearance & clothing; 3) Scene & background; "
"4) Lighting & colors; 5) Context & mood. Use 120–180 words. Be explicit and visual."
)
inputs = _q_proc(text=retry_inst, images=img, return_tensors="pt")
out = _q_model.generate(
**inputs,
max_new_tokens=max(220, gen_kwargs.get("max_new_tokens", 220)),
do_sample=True,
temperature=gen_kwargs.get("temperature", 0.3),
top_p=gen_kwargs.get("top_p", 0.9),
repetition_penalty=1.08,
)
second = _q_proc.batch_decode(out, skip_special_tokens=True)[0].strip()
return second if len(second.split()) >= min_words else first
@torch.inference_mode()
def _ensure_blip():
global _b_proc, _b_model
if _b_proc is None or _b_model is None:
_b_proc = BlipProcessor.from_pretrained(MODEL_BLIP)
_b_model = BlipForConditionalGeneration.from_pretrained(MODEL_BLIP)
_b_model.to(device()).eval()
@torch.inference_mode()
def caption_blip(img: Image.Image, beam_search: bool = True, max_len: int = 140) -> str:
_ensure_blip()
inputs = _b_proc(images=img, return_tensors="pt")
max_len = int(max(32, min(200, max_len)))
kwargs = dict(max_length=max_len, num_beams=4) if beam_search else dict(max_length=max_len)
out = _b_model.generate(**inputs, **kwargs)
return _b_proc.decode(out[0], skip_special_tokens=True).strip()
def caption_image(model_choice: str, img: Image.Image, min_words: int, max_new_tokens: int) -> str:
# Guard invalid values coming from the UI
if model_choice not in VALID_MODELS:
model_choice = "Qwen2-VL-2B-Instruct"
# Try requested model, fall back to BLIP on any exception so UX doesn't hard-crash
try:
if model_choice == "Qwen2-VL-2B-Instruct":
return caption_qwen_retry_if_short(
img,
min_words=min_words,
max_new_tokens=max_new_tokens,
temperature=0.35,
top_p=0.92,
)
else:
# BLIP tends to be briefer; push max_len up
return caption_blip(img, beam_search=True, max_len=min(200, max_new_tokens))
except Exception:
# Fallback
try:
return caption_blip(img, beam_search=True, max_len=min(200, max_new_tokens))
except Exception as e:
# Last-resort: return readable error text in the caption box
return f"[Error] Captioning failed: {type(e).__name__}: {e}"
# -------------------- Main generate --------------------
def generate(
image: Optional[Image.Image],
text_scene: str,
mode: str,
min_words: int,
max_new_tokens: int,
seconds: int,
seed: int,
model_choice: str,
):
rnd = random.Random(int(seed))
if image is not None:
img = safe_open_image(image)
caption = caption_image(model_choice, img, min_words=min_words, max_new_tokens=max_new_tokens)
w, h = img.size
exif = exif_info(img)
else:
# Text-only path: use user text as the scene description directly
if not text_scene or not text_scene.strip():
# Return empty strings so Gradio boxes clear but no crash
return "", "", ""
caption = text_scene.strip()
w, h, exif = 1920, 1080, {}
angle, shot, lens, light, comp, style, post, neg, aspect = smart_choices(caption, w, h, exif, seed)
image_prompt = build_image_prompt(caption, angle, shot, lens, light, comp, style, post, aspect)
negative_line = f"\nNegative prompt hints: {neg}"
video_prompt = build_video_prompt(
image_prompt,
motion=rnd.choice(VIDEO_MOTION),
meta=rnd.sample(VIDEO_META, k=2),
transition=rnd.choice(VIDEO_TRANSITIONS),
seconds=int(max(2, min(20, seconds))),
)
if mode == "Image prompt":
return caption, image_prompt + negative_line, ""
elif mode == "Video prompt":
return caption, "", video_prompt
else:
return caption, image_prompt + negative_line, video_prompt
# -------------------- UI --------------------
TITLE = "VisPrompt Studio — Long-Form Scene Descriptions"
DESC = """
Upload an image **or** describe a scene.
This build forces **long, explicit scene descriptions** first, then adds detailed camera/lighting/comp info for image and video prompts.
**Models**
- Qwen2-VL-2B-Instruct (default): richer descriptions on people/adult photos (slower on CPU).
- BLIP base: faster on CPU; simpler captions.
"""
with gr.Blocks(title=TITLE) as demo:
gr.Markdown(f"# {TITLE}\n{DESC}")
with gr.Row():
with gr.Column(scale=1):
model_choice = gr.Radio(
choices=["Qwen2-VL-2B-Instruct", "BLIP base"],
value="Qwen2-VL-2B-Instruct",
label="Captioning model (drives the base scene description)"
)
with gr.Tab("Image input"):
image = gr.Image(type="pil", label="Upload an image", height=360)
with gr.Tab("Text input"):
text_scene = gr.Textbox(lines=6, placeholder="Describe the scene...", label="Scene description")
mode = gr.Radio(choices=["Image prompt", "Video prompt", "Both"], value="Both", label="Output type")
with gr.Accordion("Description depth", open=True):
min_words = gr.Slider(40, 200, value=90, step=10, label="Minimum words for scene description (Qwen)")
max_new_tokens = gr.Slider(96, 320, value=220, step=4, label="Max tokens for caption generation")
with gr.Accordion("Video settings", open=False):
seconds = gr.Slider(3, 12, value=6, step=1, label="Video duration for prompt (seconds)")
seed = gr.Slider(0, 9999, value=1234, step=1, label="Style seed")
run = gr.Button("Generate prompts", variant="primary")
with gr.Column(scale=1):
caption_box = gr.Textbox(
label="Long scene description (from image or your text)",
interactive=False,
lines=10,
show_copy_button=True,
)
img_prompt_box = gr.Textbox(
label="Image Prompt (copy to image generators)",
interactive=False,
lines=14,
show_copy_button=True,
)
vid_prompt_box = gr.Textbox(
label="Video Prompt (copy to video generators)",
interactive=False,
lines=14,
show_copy_button=True,
)
run.click(
fn=generate,
inputs=[image, text_scene, mode, min_words, max_new_tokens, seconds, seed, model_choice],
outputs=[caption_box, img_prompt_box, vid_prompt_box],
)
if __name__ == "__main__":
demo.launch()