Spaces:
Paused
Paused
| import os | |
| from threading import Thread | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| import spaces # noqa: E402 (must be imported before torch / transformers) | |
| import av # noqa: E402 | |
| import gradio as gr # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| import torch # noqa: E402 | |
| from transformers import ( # noqa: E402 | |
| AutoModelForImageTextToText, | |
| AutoProcessor, | |
| BitsAndBytesConfig, | |
| TextIteratorStreamer, | |
| ) | |
| MODEL_ID = "Qwen/Qwen3-VL-32B-Instruct" | |
| HF_TOKEN = os.environ.get("HF_TOKEN", None) | |
| TITLE = "Qwen3-VL-32B-Instruct — Vision & Video Chat" | |
| SUBTITLE = "Ask questions about images and videos on ZeroGPU (4-bit, NF4)" | |
| DESCRIPTION = ( | |
| "A multimodal chat demo for Qwen3-VL-32B-Instruct running on Hugging Face " | |
| "ZeroGPU. Upload one or more images, or a short video, and ask questions about them." | |
| ) | |
| SYSTEM_PROMPT = ( | |
| "You are Qwen3-VL-32B-Instruct, a highly capable multimodal assistant. " | |
| "Be clear, accurate, and helpful." | |
| ) | |
| PLACEHOLDER = ( | |
| "Upload images or a video and ask anything about them — or just type a question." | |
| ) | |
| MAX_NEW_TOKENS = 8192 | |
| DEFAULT_MAX_NEW_TOKENS = 2048 | |
| MAX_HISTORY_MESSAGES = 10 | |
| MAX_IMAGES_PER_MESSAGE = 3 | |
| MAX_VIDEO_FRAMES = 32 | |
| IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"} | |
| VIDEO_EXTS = {".mp4", ".webm", ".mov", ".mkv", ".avi", ".m4v", ".ogv"} | |
| # --------------------------------------------------------------------------- | |
| # Model loading (ZeroGPU: module scope, bnb 4-bit, device_map="cuda") | |
| # --------------------------------------------------------------------------- | |
| BNB_CONFIG = BitsAndBytesConfig( | |
| load_in_4bit=True, | |
| bnb_4bit_quant_type="nf4", | |
| bnb_4bit_use_double_quant=True, | |
| bnb_4bit_compute_dtype=torch.bfloat16, | |
| ) | |
| processor = AutoProcessor.from_pretrained(MODEL_ID, token=HF_TOKEN) | |
| # Cap per-image pixel budget so single images stay fast on shared ZeroGPU queues. | |
| processor.image_processor.size = {"shortest_edge": 65536, "longest_edge": 1_600_000} | |
| model = AutoModelForImageTextToText.from_pretrained( | |
| MODEL_ID, | |
| token=HF_TOKEN, | |
| quantization_config=BNB_CONFIG, | |
| device_map="cuda", | |
| dtype=torch.bfloat16, | |
| attn_implementation="sdpa", | |
| low_cpu_mem_usage=True, | |
| ) | |
| model.eval() | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def file_kind(path: str): | |
| ext = os.path.splitext(path)[1].lower() | |
| if ext in VIDEO_EXTS: | |
| return "video" | |
| if ext in IMAGE_EXTS: | |
| return "image" | |
| return None | |
| def sample_video_frames(path: str, max_frames: int = MAX_VIDEO_FRAMES) -> np.ndarray: | |
| """Decode a video into a uniformly-sampled stack of RGB frames (T, H, W, 3).""" | |
| container = av.open(path) | |
| stream = container.streams.video[0] | |
| frames = [frame.to_ndarray(format="rgb24") for frame in container.decode(stream)] | |
| container.close() | |
| if not frames: | |
| raise ValueError("Could not decode any frames from this video.") | |
| if len(frames) > max_frames: | |
| indices = np.linspace(0, len(frames) - 1, max_frames, dtype=int) | |
| frames = [frames[i] for i in indices] | |
| return np.stack(frames) | |
| def append_files_to_content(paths, items, allow_video, note_video): | |
| images = 0 | |
| for path in paths: | |
| if not path or not os.path.exists(path): | |
| continue | |
| kind = file_kind(path) | |
| if kind == "image": | |
| if images < MAX_IMAGES_PER_MESSAGE: | |
| items.append({"type": "image", "image": path}) | |
| images += 1 | |
| elif kind == "video": | |
| if allow_video: | |
| items.append({"type": "video", "video": path}) | |
| elif note_video: | |
| items.append({"type": "text", "text": "[a video was shared in this message]"}) | |
| def build_messages(history, message, system_prompt): | |
| """Convert Gradio multimodal history + message into transformers messages.""" | |
| messages = [] | |
| if system_prompt.strip(): | |
| messages.append({"role": "system", "content": system_prompt.strip()}) | |
| history = history[-MAX_HISTORY_MESSAGES:] | |
| for i, msg in enumerate(history): | |
| role = msg.get("role", "user") | |
| content = msg.get("content", "") | |
| if isinstance(content, str): | |
| if content.strip(): | |
| messages.append({"role": role, "content": content.strip()}) | |
| continue | |
| items = [] | |
| is_last_user = role == "user" and i == len(history) - 1 | |
| for item in content: | |
| if item.get("type") == "text": | |
| text = item.get("text", "") | |
| if text.strip(): | |
| items.append({"type": "text", "text": text.strip()}) | |
| paths = [item.get("file") for item in content if item.get("type") == "file"] | |
| append_files_to_content(paths, items, allow_video=is_last_user, note_video=True) | |
| if not items: | |
| items = [{"type": "text", "text": "(empty message)"}] | |
| messages.append({"role": role, "content": items}) | |
| items = [] | |
| paths = [f if isinstance(f, str) else f.get("path") for f in message.get("files") or []] | |
| append_files_to_content(paths, items, allow_video=True, note_video=False) | |
| text = (message.get("text") or "").strip() | |
| if text: | |
| items.append({"type": "text", "text": text}) | |
| elif not items: | |
| items = [{"type": "text", "text": "What do you see here?"}] | |
| messages.append({"role": "user", "content": items}) | |
| return messages | |
| def format_output(text: str) -> str: | |
| text = text.replace("<|im_end|>", "") | |
| return text | |
| def estimate_duration( | |
| message, | |
| history, | |
| system_prompt, | |
| temperature, | |
| max_new_tokens, | |
| top_p, | |
| top_k, | |
| ): | |
| del history, system_prompt, temperature, top_p, top_k | |
| base = 90 + int(max_new_tokens / 40) | |
| for f in message.get("files") or []: | |
| path = f if isinstance(f, str) else f.get("path") | |
| kind = file_kind(path) | |
| if kind == "video": | |
| base += 40 | |
| elif kind == "image": | |
| base += 15 | |
| return min(360, max(90, base)) | |
| # --------------------------------------------------------------------------- | |
| # ZeroGPU chat handler | |
| # --------------------------------------------------------------------------- | |
| def chat( | |
| message: dict, | |
| history: list, | |
| system_prompt: str, | |
| temperature: float, | |
| max_new_tokens: int, | |
| top_p: float, | |
| top_k: int, | |
| ): | |
| """Chat with Qwen3-VL-32B-Instruct over images and videos. Streams the answer.""" | |
| messages = build_messages(history, message, system_prompt) | |
| for msg in messages: | |
| if isinstance(msg["content"], list): | |
| for item in msg["content"]: | |
| if item.get("type") == "video": | |
| item["video"] = sample_video_frames(item["video"]) | |
| inputs = processor.apply_chat_template( | |
| messages, | |
| add_generation_prompt=True, | |
| tokenize=True, | |
| return_dict=True, | |
| return_tensors="pt", | |
| ).to(model.device) | |
| streamer = TextIteratorStreamer( | |
| processor.tokenizer, | |
| timeout=300.0, | |
| skip_prompt=True, | |
| skip_special_tokens=True, | |
| ) | |
| generation_kwargs = dict( | |
| **inputs, | |
| streamer=streamer, | |
| max_new_tokens=max_new_tokens, | |
| do_sample=temperature > 0, | |
| temperature=max(temperature, 1e-5), | |
| top_p=top_p, | |
| top_k=top_k, | |
| use_cache=True, | |
| ) | |
| worker = Thread(target=model.generate, kwargs=generation_kwargs) | |
| worker.start() | |
| output = "" | |
| for chunk in streamer: | |
| output += chunk | |
| yield format_output(output) | |
| # --------------------------------------------------------------------------- | |
| # Gradio UI | |
| # --------------------------------------------------------------------------- | |
| CSS = """ | |
| .gradio-container { max-width: 1200px !important; margin: 0 auto !important; } | |
| .title h1 { text-align: center; margin-bottom: 0.2rem !important; } | |
| .subtitle p, .meta p { text-align: center; } | |
| .meta p { font-size: 0.95rem; color: #6b7280; margin-top: 0.35rem !important; } | |
| .duplicate-button { margin: 0 auto 14px auto !important; } | |
| """ | |
| chatbot = gr.Chatbot( | |
| height=620, | |
| placeholder=PLACEHOLDER, | |
| avatar_images=(None, "https://huggingface.co/front/assets/huggingface_logo-noborder.svg"), | |
| ) | |
| with gr.Blocks() as demo: | |
| gr.Markdown(f"# {TITLE}", elem_classes="title") | |
| gr.Markdown(SUBTITLE, elem_classes="subtitle") | |
| gr.Markdown( | |
| f"{DESCRIPTION} Model: [{MODEL_ID}](https://huggingface.co/{MODEL_ID})", | |
| elem_classes="meta", | |
| ) | |
| gr.DuplicateButton("Duplicate Space", elem_classes="duplicate-button") | |
| gr.ChatInterface( | |
| fn=chat, | |
| chatbot=chatbot, | |
| fill_height=True, | |
| multimodal=True, | |
| additional_inputs_accordion=gr.Accordion("⚙️ Parameters", open=False, render=False), | |
| additional_inputs=[ | |
| gr.Textbox(value=SYSTEM_PROMPT, label="System prompt", lines=3, render=False), | |
| gr.Slider(minimum=0.0, maximum=1.2, step=0.05, value=0.7, label="Temperature", render=False), | |
| gr.Slider( | |
| minimum=256, | |
| maximum=MAX_NEW_TOKENS, | |
| step=256, | |
| value=DEFAULT_MAX_NEW_TOKENS, | |
| label="Max new tokens", | |
| render=False, | |
| ), | |
| gr.Slider(minimum=0.1, maximum=1.0, step=0.05, value=0.9, label="Top-p", render=False), | |
| gr.Slider(minimum=1, maximum=100, step=1, value=20, label="Top-k", render=False), | |
| ], | |
| examples=[ | |
| [{"text": "What animal is on the candy?", "files": ["examples/candy.jpg"]}], | |
| [{"text": "Solve the geometry problem in this image.", "files": ["examples/math.jpg"]}], | |
| [{"text": "How many porcelain jars were discovered in the niches located in the primary chamber of the tomb?", "files": ["examples/sample_video.mp4"]}], | |
| ["Explain the benefits of multimodal language models."], | |
| ], | |
| cache_examples=False, | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(theme="soft", css=CSS) | |