Files
vllm-omni/examples/online_serving/gradio_demo.py
T
Samit b2d845742a [Feature] Add Gradio Demo for Qwen2.5Omni (#60)
Signed-off-by: SamitHuang <285365963@qq.com>
Signed-off-by: samithuang <285365963@qq.com>
2025-11-26 09:23:46 +08:00

536 lines
19 KiB
Python

import argparse
import os
import random
import signal
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Optional
import gradio as gr
import numpy as np
import soundfile as sf
import torch
from PIL import Image
from vllm.assets.video import video_get_metadata, video_to_ndarrays
from vllm.sampling_params import SamplingParams
from vllm_omni.entrypoints.async_omni_llm import AsyncOmniLLM
# Import utils from offline inference example
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../offline_inference/qwen2_5_omni"))
try:
from utils import make_omni_prompt
except ImportError:
# Fallback if utils doesn't exist - we'll build prompts directly
make_omni_prompt = None
SEED = 42
ASYNC_INIT_TIMEOUT = 600
SUPPORTED_MODELS: dict[str, dict[str, Any]] = {
"Qwen/Qwen2.5-Omni-7B": {
"sampling_params": {
"thinker": {
"temperature": 0.0,
"top_p": 1.0,
"top_k": -1,
"max_tokens": 2048,
"detokenize": True,
"repetition_penalty": 1.1,
},
"talker": {
"temperature": 0.0,
"top_p": 1.0,
"top_k": -1,
"max_tokens": 2048,
"detokenize": True,
"repetition_penalty": 1.1,
"stop_token_ids": [8294],
},
"code2wav": {
"temperature": 0.0,
"top_p": 1.0,
"top_k": -1,
"max_tokens": 2048,
"detokenize": True,
"repetition_penalty": 1.1,
},
},
},
}
# Ensure deterministic behavior across runs.
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
torch.cuda.manual_seed(SEED)
torch.cuda.manual_seed_all(SEED)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
os.environ["PYTHONHASHSEED"] = str(SEED)
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
def parse_args():
parser = argparse.ArgumentParser(description="Gradio demo for Qwen2.5-Omni online inference.")
parser.add_argument(
"--model",
default="Qwen/Qwen2.5-Omni-7B",
help="Path to local model checkpoint.",
)
parser.add_argument(
"--ip",
default="127.0.0.1",
help="Host/IP for gradio `launch`.",
)
parser.add_argument("--port", type=int, default=7861, help="Port for gradio `launch`.")
parser.add_argument("--share", action="store_true", help="Share the Gradio demo publicly.")
parser.add_argument(
"--stage-configs-path",
type=str,
default=None,
help="Path to custom stage configs YAML file (optional).",
)
return parser.parse_args()
def build_sampling_params(seed: int, model_key: str) -> list[SamplingParams]:
"""Build SamplingParams objects by reusing the dict definitions."""
return [SamplingParams(**params_dict) for params_dict in build_sampling_params_dict(seed, model_key)]
def build_sampling_params_dict(seed: int, model_key: str) -> list[dict]:
"""Build sampling params as dict for HTTP API mode."""
model_conf = SUPPORTED_MODELS.get(model_key)
if model_conf is None:
raise ValueError(f"Unsupported model '{model_key}'")
sampling_templates: dict[str, dict[str, Any]] = model_conf["sampling_params"]
sampling_params: list[dict] = []
for stage_name, template in sampling_templates.items():
params = dict(template)
params["seed"] = seed
sampling_params.append(params)
return sampling_params
def create_prompt_args(base_args: argparse.Namespace) -> SimpleNamespace:
# The prompt builder expects a minimal namespace with these attributes.
return SimpleNamespace(
model=base_args.model,
prompt_type="text",
tokenize=True,
use_torchvision=True,
legacy_omni_video=False,
)
def process_audio_file(
audio_file: Optional[Any],
) -> Optional[tuple[np.ndarray, int]]:
"""Normalize Gradio audio input to (np.ndarray, sample_rate)."""
if audio_file is None:
return None
sample_rate: Optional[int] = None
audio_np: Optional[np.ndarray] = None
def _load_from_path(path_str: str) -> Optional[tuple[np.ndarray, int]]:
if not path_str:
return None
path = Path(path_str)
if not path.exists():
return None
data, sr = sf.read(path)
if data.ndim > 1:
data = data[:, 0]
return data.astype(np.float32), int(sr)
if isinstance(audio_file, tuple):
if len(audio_file) == 2:
first, second = audio_file
# Case 1: (sample_rate, np.ndarray)
if isinstance(first, (int, float)) and isinstance(second, np.ndarray):
sample_rate = int(first)
audio_np = second
# Case 2: (filepath, (sample_rate, np.ndarray or list))
elif isinstance(first, str):
if isinstance(second, tuple) and len(second) == 2:
sr_candidate, data_candidate = second
if isinstance(sr_candidate, (int, float)) and isinstance(data_candidate, np.ndarray):
sample_rate = int(sr_candidate)
audio_np = data_candidate
if audio_np is None:
loaded = _load_from_path(first)
if loaded is not None:
audio_np, sample_rate = loaded
# Case 3: (None, (sample_rate, np.ndarray))
elif first is None and isinstance(second, tuple) and len(second) == 2:
sr_candidate, data_candidate = second
if isinstance(sr_candidate, (int, float)) and isinstance(data_candidate, np.ndarray):
sample_rate = int(sr_candidate)
audio_np = data_candidate
elif len(audio_file) == 1 and isinstance(audio_file[0], str):
loaded = _load_from_path(audio_file[0])
if loaded is not None:
audio_np, sample_rate = loaded
elif isinstance(audio_file, str):
loaded = _load_from_path(audio_file)
if loaded is not None:
audio_np, sample_rate = loaded
if audio_np is None or sample_rate is None:
return None
if audio_np.ndim > 1:
audio_np = audio_np[:, 0]
return audio_np.astype(np.float32), sample_rate
def process_image_file(image_file: Optional[Image.Image]) -> Optional[Image.Image]:
"""Process image file from Gradio input.
Returns:
PIL Image in RGB mode or None if no image provided.
"""
if image_file is None:
return None
# Convert to RGB if needed
if image_file.mode != "RGB":
image_file = image_file.convert("RGB")
return image_file
def process_video_file(
video_file: Optional[str],
enable_audio_in_video: bool = False,
max_frames: int = 32,
) -> Optional[tuple[np.ndarray, dict[str, Any], Optional[tuple[np.ndarray, int]]]]:
"""Process video file and optionally extract audio track."""
if video_file is None:
return None
video_path = Path(video_file)
if not video_path.exists():
print(f"Video file not found: {video_path}")
return None
try:
frames = video_to_ndarrays(str(video_path), num_frames=max_frames)
metadata = video_get_metadata(str(video_path), num_frames=frames.shape[0])
except Exception as exc: # pylint: disable=broad-except
print(f"Failed to decode video {video_path}: {exc}")
return None
audio_tuple: Optional[tuple[np.ndarray, int]] = None
if enable_audio_in_video:
try:
import librosa # type: ignore import
audio_signal, sampling_rate = librosa.load(str(video_path), sr=16000)
audio_tuple = (audio_signal.astype(np.float32), sampling_rate)
except Exception as exc: # pylint: disable=broad-except
print(f"Failed to extract audio from video {video_path}: {exc}")
return frames, metadata, audio_tuple
async def run_inference_async_omni_llm(
omni_llm: AsyncOmniLLM,
sampling_params: list[SamplingParams],
prompt_args_template: SimpleNamespace,
user_prompt: str,
audio_file: Optional[tuple[str, tuple[int, np.ndarray]]] = None,
image_file: Optional[Image.Image] = None,
video_file: Optional[str] = None,
use_audio_in_video: bool = False,
):
"""Run inference using AsyncOmniLLM directly with multimodal support."""
if not user_prompt.strip() and not audio_file and not image_file and not video_file:
return "Please provide at least a text prompt or multimodal input.", None
try:
# Build prompt with multimodal data
prompt_args = SimpleNamespace(**prompt_args_template.__dict__)
# Process multimodal inputs
multi_modal_data = {}
mm_processor_kwargs = {}
# Process audio
audio_data = process_audio_file(audio_file)
if audio_data is not None:
multi_modal_data["audio"] = audio_data
# Process image
image_data = process_image_file(image_file)
if image_data is not None:
multi_modal_data["image"] = image_data
# Process video
video_payload = process_video_file(video_file, enable_audio_in_video=use_audio_in_video)
if video_payload is not None:
video_frames, video_metadata, extracted_audio = video_payload
video_entry: Any
if video_metadata:
video_entry = (video_frames, video_metadata)
else:
video_entry = video_frames
multi_modal_data["video"] = video_entry
if use_audio_in_video and extracted_audio is not None and "audio" not in multi_modal_data:
multi_modal_data["audio"] = extracted_audio
mm_processor_kwargs["use_audio_in_video"] = True
# Build the prompt input
if make_omni_prompt is not None:
omni_prompt = make_omni_prompt(prompt_args, user_prompt)
# Add multimodal data if present
if multi_modal_data:
if isinstance(omni_prompt, dict):
omni_prompt["multi_modal_data"] = multi_modal_data
if mm_processor_kwargs:
omni_prompt["mm_processor_kwargs"] = mm_processor_kwargs
else:
# If make_omni_prompt returns a string, we need to wrap it
omni_prompt = {
"prompt": omni_prompt,
"multi_modal_data": multi_modal_data,
}
if mm_processor_kwargs:
omni_prompt["mm_processor_kwargs"] = mm_processor_kwargs
else:
# Fallback: build prompt directly
default_system = (
"You are Qwen, a virtual human developed by the Qwen Team, "
"Alibaba Group, capable of perceiving auditory and visual inputs, "
"as well as generating text and speech."
)
prompt = f"<|im_start|>system\n{default_system}<|im_end|>\n<|im_start|>user\n"
if audio_data:
prompt += "<|audio_bos|><|AUDIO|><|audio_eos|>"
if image_data:
prompt += "<|vision_bos|><|IMAGE|><|vision_eos|>"
if video_payload is not None:
prompt += "<|vision_bos|><|VIDEO|><|vision_eos|>"
if user_prompt.strip():
prompt += f"{user_prompt}"
prompt += "<|im_end|>\n<|im_start|>assistant\n"
omni_prompt = {
"prompt": prompt,
"multi_modal_data": multi_modal_data,
}
if mm_processor_kwargs:
omni_prompt["mm_processor_kwargs"] = mm_processor_kwargs
request_id = "0"
text_outputs: list[str] = []
audio_output = None
async for stage_outputs in omni_llm.generate(
prompt=omni_prompt,
request_id=request_id,
sampling_params_list=sampling_params,
):
# stage_outputs.request_output is a RequestOutput object, not a list
request_output = stage_outputs.request_output
if stage_outputs.final_output_type == "text":
if request_output.outputs:
for output in request_output.outputs:
if output.text:
text_outputs.append(output.text)
elif stage_outputs.final_output_type == "audio":
# multimodal_output is on the RequestOutput object
# See vllm_omni/entrypoints/openai/serving_chat.py:680 for reference
if hasattr(request_output, "multimodal_output") and request_output.multimodal_output:
if "audio" in request_output.multimodal_output:
audio_tensor = request_output.multimodal_output["audio"]
# Ensure audio is 1D (flatten if needed)
if hasattr(audio_tensor, "ndim") and audio_tensor.ndim > 1:
audio_tensor = audio_tensor.flatten()
audio_np = audio_tensor.detach().cpu().numpy()
audio_output = (
24000, # sampling rate in Hz
audio_np,
)
text_response = "\n\n".join(text_outputs) if text_outputs else "No text output."
return text_response, audio_output
except Exception as exc: # pylint: disable=broad-except
return f"Inference failed: {exc}", None
def build_interface(
omni_llm: AsyncOmniLLM,
sampling_params: list[SamplingParams],
prompt_args_template: SimpleNamespace,
model: str,
):
"""Build Gradio interface for AsyncOmniLLM mode."""
async def run_inference(
user_prompt: str,
audio_file: Optional[tuple[str, tuple[int, np.ndarray]]],
image_file: Optional[Image.Image],
video_file: Optional[str],
use_audio_in_video: bool,
):
return await run_inference_async_omni_llm(
omni_llm,
sampling_params,
prompt_args_template,
user_prompt,
audio_file,
image_file,
video_file,
use_audio_in_video,
)
css = """
.media-input-container {
display: flex;
gap: 10px;
}
.media-input-container > div {
flex: 1;
}
.media-input-container .image-input,
.media-input-container .audio-input {
height: 300px;
}
.media-input-container .video-column {
height: 300px;
display: flex;
flex-direction: column;
}
.media-input-container .video-input {
flex: 1;
min-height: 0;
}
#generate-btn button {
width: 100%;
}
"""
with gr.Blocks(css=css) as demo:
gr.Markdown("# vLLM-Omni Online Serving Demo")
gr.Markdown(f"**Model:** {model} \n\n")
with gr.Column():
with gr.Row():
input_box = gr.Textbox(
label="Text Prompt",
placeholder="For example: Describe what happens in the media inputs.",
lines=4,
scale=1,
)
with gr.Row(elem_classes="media-input-container"):
image_input = gr.Image(
label="Image Input (optional)",
type="pil",
sources=["upload"],
scale=1,
elem_classes="image-input",
)
with gr.Column(scale=1, elem_classes="video-column"):
video_input = gr.Video(
label="Video Input (optional)",
sources=["upload"],
elem_classes="video-input",
)
use_audio_in_video_checkbox = gr.Checkbox(
label="Use audio from video",
value=False,
info="Extract the video's audio track when provided.",
)
audio_input = gr.Audio(
label="Audio Input (optional)",
type="numpy",
sources=["upload", "microphone"],
scale=1,
elem_classes="audio-input",
)
with gr.Row():
generate_btn = gr.Button(
"Generate",
variant="primary",
size="lg",
elem_id="generate-btn",
)
with gr.Row():
text_output = gr.Textbox(label="Text Output", lines=10, scale=2)
audio_output = gr.Audio(label="Audio Output", interactive=False, scale=1)
generate_btn.click(
fn=run_inference,
inputs=[input_box, audio_input, image_input, video_input, use_audio_in_video_checkbox],
outputs=[text_output, audio_output],
)
demo.queue()
return demo
def main():
args = parse_args()
omni_llm = None
model_name = "/".join(args.model.split("/")[-2:])
assert model_name in SUPPORTED_MODELS, (
f"Unsupported model '{model_name}'. Supported models: {SUPPORTED_MODELS.keys()}"
)
# Register signal handlers for graceful shutdown
def signal_handler(sig, frame):
print("\nReceived interrupt signal, shutting down...")
if omni_llm is not None:
try:
omni_llm.shutdown()
except Exception as e:
print(f"Error during shutdown: {e}")
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
print(f"Initializing AsyncOmniLLM with model: {args.model}")
if args.stage_configs_path:
print(f"Using custom stage configs: {args.stage_configs_path}")
sampling_params = build_sampling_params(SEED, model_name)
omni_llm = AsyncOmniLLM(
model=args.model,
stage_configs_path=args.stage_configs_path,
init_timeout=ASYNC_INIT_TIMEOUT,
)
print("✓ AsyncOmniLLM initialized successfully")
prompt_args_template = create_prompt_args(args)
demo = build_interface(
omni_llm,
sampling_params,
prompt_args_template,
args.model,
)
try:
demo.launch(
server_name=args.ip,
server_port=args.port,
share=args.share,
)
except KeyboardInterrupt:
print("\nShutting down...")
finally:
# Cleanup
if omni_llm is not None:
try:
omni_llm.shutdown()
except Exception as e:
print(f"Error during cleanup: {e}")
if __name__ == "__main__":
main()