Spaces:
Running on Zero
Running on Zero
File size: 6,108 Bytes
b5a226e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | """Captioners: Florence-2 for images, Qwen3.5 vision-language models for whole videos."""
import re
import numpy as np
import torch
from PIL import Image
from torchcodec.decoders import VideoDecoder
from transformers import AutoProcessor, Florence2ForConditionalGeneration, Qwen3_5ForConditionalGeneration
from hub import model_path
FLORENCE = {"dir": "florence-2-large", "repo": "florence-community/Florence-2-large"}
CAPTION_TASKS = {"Brief": "<CAPTION>", "Detailed": "<DETAILED_CAPTION>", "More detailed": "<MORE_DETAILED_CAPTION>"}
DEFAULT_CAPTION_DETAIL = "Brief"
CAPTION_MAX_NEW_TOKENS = 256
# All Qwen3.5 vision-language models, loaded from the Hub cache (too large to vendor).
# Marlin-2B is a Qwen3.5-2B fine-tune trained on one fixed prompt; it answers with a
# Scene paragraph and <start - end> events, which format_marlin turns into Markdown.
VIDEO_CAPTIONERS = {
"Qwen3.5-4B": {"repo": "Qwen/Qwen3.5-4B", "marlin": False},
"Qwen3.5-9B": {"repo": "Qwen/Qwen3.5-9B", "marlin": False},
"Marlin-2B (scene + events)": {"repo": "NemoStation/Marlin-2B", "marlin": True},
}
DEFAULT_VIDEO_CAPTIONERS = ["Qwen3.5-4B"]
DEFAULT_VIDEO_PROMPT = "Write a one-sentence caption that describes this video."
MARLIN_PROMPT = (
"Provide a spatial description of this clip followed by time-ranged events.\n"
"For each event, give the time range as <start - end> and a short description."
)
MARLIN_PIXELS_PER_FRAME = 200704 # ~448x448, the training-time cap
VIDEO_CAPTION_MAX_NEW_TOKENS = {"qwen": 256, "marlin": 1024}
VIDEO_MIN_FRAMES = 4
_CACHE: dict[str, tuple] = {}
_THINK_BLOCK = re.compile(r"^\s*<think>.*?</think>\s*", re.DOTALL)
_THINK_TAG = re.compile(r"^\s*<think>\s*")
_MARLIN_EVENT = re.compile(
r"^\s*<?\s*(\d+\.?\d*)\s*(?:seconds?|secs?|s)?\s*-\s*(\d+\.?\d*)\s*(?:seconds?|secs?|s)?\s*>?\s*[:\-]?\s*(.+?)\s*$"
)
def needs_prompt(captioners: list[str]) -> bool:
"""True when any ticked captioner takes the user's prompt (Marlin has its own)."""
return any(not VIDEO_CAPTIONERS[n]["marlin"] for n in captioners or [])
def load_image_captioner():
"""Load the Florence-2 (processor, model) once; kept on CPU until a GPU call."""
folder = FLORENCE["dir"]
if folder not in _CACHE:
path = model_path(FLORENCE)
_CACHE[folder] = (AutoProcessor.from_pretrained(path), Florence2ForConditionalGeneration.from_pretrained(path).eval())
return _CACHE[folder]
def load_video_captioner(repo: str):
"""Load a Qwen3.5 video captioner (processor, model) once in bf16; kept on CPU until a GPU call."""
if repo not in _CACHE:
processor = AutoProcessor.from_pretrained(repo)
model = Qwen3_5ForConditionalGeneration.from_pretrained(repo, dtype=torch.bfloat16).eval()
_CACHE[repo] = (processor, model)
return _CACHE[repo]
@torch.inference_mode()
def caption_image(processor, model, rgb: np.ndarray, detail: str, device: str) -> str:
"""Caption one RGB frame with Florence-2 at the given detail level."""
task = CAPTION_TASKS[detail]
pil = Image.fromarray(rgb)
inputs = processor(text=task, images=pil, return_tensors="pt").to(device)
generated = model.generate(**inputs, max_new_tokens=CAPTION_MAX_NEW_TOKENS, num_beams=3)
text = processor.batch_decode(generated, skip_special_tokens=False)[0]
parsed = processor.post_process_generation(text, task=task, image_size=pil.size)
return parsed[task].strip()
def count_frames(path: str, fps: float, max_frames: int) -> int:
"""Frames to sample at `fps`, clamped to [VIDEO_MIN_FRAMES, max_frames] and the video length.
The Qwen3.5 video processor ignores per-call min/max frame limits, so the clamp
happens here and the result goes to the processor as `num_frames` with `fps` cleared.
"""
metadata = VideoDecoder(path).metadata
wanted = int(metadata.duration_seconds * fps)
return min(max(wanted, VIDEO_MIN_FRAMES), int(max_frames), metadata.num_frames)
@torch.inference_mode()
def caption_video(processor, model, path: str, prompt: str, num_frames: int, device: str, marlin: bool) -> str:
"""Caption one video with a Qwen3.5 model from `num_frames` evenly sampled frames."""
messages = [{"role": "user", "content": [{"type": "video", "path": path}, {"type": "text", "text": prompt}]}]
processor_kwargs = {"num_frames": num_frames, "fps": None, "cap_pixels_per_frame": True}
template_kwargs = {}
if marlin:
processor_kwargs["size"] = {"shortest_edge": 4096, "longest_edge": MARLIN_PIXELS_PER_FRAME * num_frames}
else:
template_kwargs["enable_thinking"] = False
inputs = processor.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
processor_kwargs=processor_kwargs,
**template_kwargs,
).to(device)
max_new_tokens = VIDEO_CAPTION_MAX_NEW_TOKENS["marlin" if marlin else "qwen"]
output_ids = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
text = processor.decode(output_ids[0, inputs["input_ids"].shape[1]:], skip_special_tokens=True)
return strip_thinking(text)
def strip_thinking(text: str) -> str:
"""Drop a leading `<think>...</think>` block, or a bare `<think>` prefix, as Marlin emits."""
return _THINK_TAG.sub("", _THINK_BLOCK.sub("", text)).strip()
def format_marlin(text: str) -> str:
"""Turn Marlin's `Scene: ... Events: <a - b> ...` output into Markdown."""
lines = []
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
event = _MARLIN_EVENT.match(line)
if event:
start, end, desc = float(event.group(1)), float(event.group(2)), event.group(3)
lines.append(f"- `{start:5.1f}s - {end:5.1f}s` {desc}")
elif line.lower().startswith(("scene:", "events:")):
head, _, rest = line.partition(":")
lines.append(f"**{head.strip()}**" + (f" {rest.strip()}" if rest.strip() else ""))
else:
lines.append(line)
return "\n".join(lines)
|