import gc import threading import gradio as gr import spaces import torch from PIL import Image, ImageOps from transformers import AutoModelForMultimodalLM, AutoProcessor MODEL_ID = "Qwen/Qwen3-VL-4B-Instruct" MAX_IMAGE_EDGE = 2048 model = None processor = None model_lock = threading.Lock() STYLE_GUIDANCE = { "Auto-detect": "Infer the most faithful medium and visual style from the image.", "Photorealistic": "Write for a photorealistic image model; emphasize optics, lighting, materials, and natural detail.", "Cinematic": "Write for a cinematic frame; emphasize shot design, lens language, lighting, atmosphere, and color grade.", "Anime / illustration": "Write for an illustration model; emphasize line work, rendering, palette, stylization, and composition.", "Product photography": "Write for product photography; emphasize object geometry, materials, surface finish, studio light, and backdrop.", "Architecture / interior": "Write for architectural visualization; emphasize space, structure, materials, lighting, perspective, and decor.", } DETAIL_TOKENS = { "Balanced": 700, "Very detailed": 1100, "Maximum detail": 1500, } def load_model(): """Load the model once, only when the first generation request arrives.""" global model, processor if model is not None: return model, processor with model_lock: if model is not None: return model, processor if not torch.cuda.is_available(): raise gr.Error("This Space needs a CUDA GPU. Select ZeroGPU or a GPU in the Space settings.") processor = AutoProcessor.from_pretrained(MODEL_ID) model = AutoModelForMultimodalLM.from_pretrained( MODEL_ID, dtype=torch.bfloat16, device_map="auto", low_cpu_mem_usage=True, ).eval() return model, processor def prepare_image(image: Image.Image) -> Image.Image: image = ImageOps.exif_transpose(image).convert("RGB") if max(image.size) > MAX_IMAGE_EDGE: image.thumbnail((MAX_IMAGE_EDGE, MAX_IMAGE_EDGE), Image.Resampling.LANCZOS) return image def build_instruction(style: str, detail: str, focus: str) -> str: extra_focus = (focus or "").strip() return f""" Act as an expert prompt engineer performing image-to-prompt reconstruction. Study the supplied image carefully and return a faithful, highly descriptive prompt that could recreate it in a modern text-to-image model. Describe only visually supported details; do not invent names, brands, hidden facts, or a backstory. If a detail is uncertain, use neutral visual language. Do not identify a real person. Requested style treatment: {STYLE_GUIDANCE[style]} Requested detail level: {detail}. User's optional focus: {extra_focus if extra_focus else "No extra focus; cover the whole image evenly."} Analyze and incorporate, where visible: - primary subject(s), count, appearance, pose, expression, gaze, and interaction - clothing, accessories, objects, textures, materials, and fine details - environment, foreground, midground, background, and spatial relationships - composition, crop, framing, viewpoint, perspective, symmetry, and depth - lighting direction, quality, intensity, shadows, highlights, time-of-day cues, and atmosphere - colors, contrast, palette, medium, rendering technique, and overall aesthetic - camera/lens cues such as shot type, focal-length feel, aperture/depth of field, focus, and motion - any legible text, reproduced exactly in quotation marks; omit text if it is not clearly readable Output exactly these two sections and nothing else: DETAILED PROMPT A cohesive, generator-ready prompt in natural language. Prefer precise visual terms over vague praise. Do not mention this analysis, the source image, or uncertainty. NEGATIVE PROMPT A concise comma-separated list of defects and unwanted changes that are specifically useful for preserving this image's composition and quality. """.strip() def split_response(text: str): cleaned = text.strip() marker = "NEGATIVE PROMPT" if marker in cleaned: positive, negative = cleaned.split(marker, 1) positive = positive.replace("DETAILED PROMPT", "", 1).strip(" \n:#") negative = negative.strip(" \n:#") else: positive = cleaned.replace("DETAILED PROMPT", "", 1).strip(" \n:#") negative = "" return positive, negative @spaces.GPU(duration=90) def generate_prompt(image, style, detail, focus, progress=gr.Progress()): if image is None: raise gr.Error("Upload an image first.") progress(0.05, desc="Loading vision model…") vision_model, vision_processor = load_model() image = prepare_image(image) instruction = build_instruction(style, detail, focus) messages = [ { "role": "user", "content": [ {"type": "image", "image": image}, {"type": "text", "text": instruction}, ], } ] progress(0.2, desc="Reading image details…") inputs = vision_processor.apply_chat_template( messages, tokenize=True, add_generation_prompt=True, return_dict=True, return_tensors="pt", ).to(vision_model.device) try: with model_lock, torch.inference_mode(): generated = vision_model.generate( **inputs, max_new_tokens=DETAIL_TOKENS[detail], do_sample=False, repetition_penalty=1.05, ) generated = generated[:, inputs.input_ids.shape[1] :] response = vision_processor.batch_decode( generated, skip_special_tokens=True, clean_up_tokenization_spaces=False, )[0] except torch.cuda.OutOfMemoryError as exc: gc.collect() torch.cuda.empty_cache() raise gr.Error("The GPU ran out of memory. Try a smaller image or a shorter detail level.") from exc progress(1, desc="Prompt ready") return split_response(response) CSS = """ :root { --paper: #f7f4ed; --ink: #191816; --muted: #6f6a61; --accent: #6750e8; } .gradio-container { max-width: 1180px !important; margin: 0 auto !important; background: var(--paper); } .hero { padding: 2.4rem 0 1.2rem; } .hero .kicker { color: var(--accent); font-weight: 800; letter-spacing: .14em; text-transform: uppercase; font-size: .75rem; } .hero h1 { color: var(--ink); font-size: clamp(2.5rem, 6vw, 5.2rem); line-height: .94; letter-spacing: -.055em; margin: .45rem 0 .85rem; } .hero p { color: var(--muted); max-width: 720px; font-size: 1.05rem; } .panel { border: 1px solid #dcd6ca !important; border-radius: 20px !important; background: rgba(255,255,255,.58) !important; } .run-btn { background: var(--accent) !important; border: 0 !important; color: white !important; font-weight: 800 !important; } .output textarea { font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; line-height: 1.55 !important; } .note { color: var(--muted); font-size: .82rem; padding: .8rem 0 1.5rem; text-align: center; } """ with gr.Blocks() as demo: gr.HTML("""
Image → Prompt

See every detail.
Write the whole scene.

Upload any image and turn it into a precise, generator-ready prompt covering subject, composition, lighting, camera, materials, palette, and style.

""") with gr.Row(equal_height=False): with gr.Column(scale=5, elem_classes="panel"): source = gr.Image( type="pil", image_mode="RGB", label="Source image", sources=["upload", "clipboard"], height=470, ) with gr.Row(): style = gr.Dropdown(list(STYLE_GUIDANCE), value="Auto-detect", label="Prompt style") detail = gr.Radio(list(DETAIL_TOKENS), value="Very detailed", label="Detail level") focus = gr.Textbox( label="Optional focus", placeholder="Example: emphasize the lighting, outfit, exact composition, or product materials", ) run = gr.Button("Generate detailed prompt", variant="primary", size="lg", elem_classes="run-btn") with gr.Column(scale=6): positive = gr.Textbox( label="Detailed prompt", lines=18, elem_classes="output", ) negative = gr.Textbox( label="Negative prompt", lines=5, elem_classes="output", ) gr.HTML("
Powered by Qwen3-VL-4B-Instruct · Generated descriptions may need human review for tiny or ambiguous details.
") run.click( fn=generate_prompt, inputs=[source, style, detail, focus], outputs=[positive, negative], api_name="generate_prompt", ) source.upload( fn=generate_prompt, inputs=[source, style, detail, focus], outputs=[positive, negative], ) if __name__ == "__main__": demo.queue(default_concurrency_limit=1).launch(css=CSS, show_error=True)