Files
LTX-2/packages/ltx-trainer/src/ltx_trainer/captioning.py
T
2026-06-17 14:06:32 +00:00

456 lines
20 KiB
Python

"""
Audio-visual media captioning using multimodal models.
This module provides captioning capabilities for videos with audio using:
- Qwen3-Omni via a local vLLM server (default)
- Gemini Flash 3.5 (cloud API)
Both produce a single combined English caption per video as a single
continuous paragraph of prose.
The Qwen3-Omni backend runs in a separately-launched vLLM server rather than
in-process, so vLLM's heavy CUDA dependencies stay out of this package. The
captioner talks to it over the OpenAI-compatible HTTP API.
Launch the server once (in an isolated environment) with:
.. code-block:: bash
uv run python scripts/serve_captioner.py
That helper picks BF16 vs FP8 dynamic quantization based on the GPU's free
memory and forwards everything else to ``vllm serve``. To check the recommended
command without running it, pass ``--print-cmd``.
To use Gemini instead, install ``google-genai`` and either set ``GEMINI_API_KEY``
(Gemini Developer API) or have Google Cloud credentials available (gcloud / an
attached service account), in which case it uses Vertex AI automatically.
"""
import json
import os
import re
import subprocess
import tempfile
from abc import ABC, abstractmethod
from enum import Enum
from pathlib import Path
from typing import ClassVar
DEFAULT_VIDEO_CAPTION_INSTRUCTION = """\
Analyze this video and produce a single detailed caption covering both its visual content and its audio. Be \
detailed enough that someone reading the caption could form an accurate mental picture of what happens on screen \
and what can be heard. Be exhaustive: include every meaningful detail you can see and hear, including small \
objects, textures, secondary movements, and minor background sounds.
Begin the caption directly with the action or visual detail; do not preface it with phrases like \
"The video opens with...", "The scene shows...", "We see...", or "There is...".
For every shot, include:
- The shot type and framing (extreme wide / wide / medium / medium close-up / close-up / extreme close-up) and any \
camera motion.
- Characters' clothing, appearance, posture, and movement (direction, speed, quality).
- The environment's materials, textures, lighting, and colors.
- All audio: spoken dialogue (quoted exactly in the original language), tone of voice, music (style, mood, \
volume changes), and environmental sounds. If a category is absent -- for example no music is playing, or no one is \
speaking -- state that explicitly. Do not invent specific instruments, music genres, moods, or ambient sounds \
that are not actually present.
- Any on-screen text (signs, titles, labels).
Describe only what is visible or audible. Do not infer emotions, intentions, or anything outside the segment. \
Refer to people descriptively (e.g., "the man in the blue jacket"). Narrate strictly in chronological order; if \
the video contains multiple shots, describe each one in turn.
Write everything as a single continuous paragraph of prose. Do not use section headers, bullet points, or labels \
like "Audio:" / "Visual:" / "Shot:". Integrate visual and audio details naturally within the same sentences.
Return a JSON object with exactly one key:
{"combined_caption_english": "<your caption here>"}"""
DEFAULT_IMAGE_CAPTION_INSTRUCTION = """\
Analyze this image and produce a single detailed caption of its visual content. Be detailed enough that \
someone reading the caption could form an accurate mental picture of the image. Be thorough: include every meaningful \
detail that is actually present, including small objects, textures, and background elements.
Begin the caption directly with the main subject or a visual detail; do not preface it with phrases like \
"The image shows...", "This is a photo of...", "We see...", or "There is...".
Include:
- The framing and composition (close-up / medium / wide / overhead, etc.) and the vantage point.
- The medium or style if distinctive (photograph, illustration, 3D render, painting).
- People's clothing, appearance, and posture, and what they are doing.
- The setting's materials, textures, lighting, and colors.
- Transcribe any visible text verbatim (signs, labels, titles, captions).
Describe only what is visible. Do not infer emotions or intentions, and do not describe sounds, motion, or \
events before or after the moment shown -- this is a single still image. When something is ambiguous, describe \
the visible cue (e.g., "warm low-angle light") rather than guessing the underlying fact (e.g., "sunrise"). \
Refer to people descriptively (e.g., "the man in the blue jacket").
Only describe what is present. Never state that something is absent or missing -- do not write phrases like \
"there is no text", "no people are present", or "no other objects". If a category such as people or text does \
not appear, simply leave it out.
Write everything as a single continuous paragraph of prose. Do not use section headers, bullet points, or \
labels.
Return a JSON object with exactly one key:
{"combined_caption_english": "<your caption here>"}"""
# Default model served by ``scripts/serve_captioner.py``. The captioner does not
# download or load this model itself -- it just sends requests to the vLLM
# server, which already has the model loaded.
DEFAULT_QWEN_MODEL = "Qwen/Qwen3-Omni-30B-A3B-Thinking"
DEFAULT_VLLM_BASE_URL = "http://127.0.0.1:8001/v1"
# Key the combined-caption prompt asks the model to return its caption under.
_CAPTION_JSON_KEY = "combined_caption_english"
class CaptionerType(str, Enum):
"""Enum for different types of media captioners."""
QWEN_OMNI = "qwen_omni" # Qwen3-Omni via local vLLM HTTP server
GEMINI_FLASH = "gemini_flash" # Gemini Flash 3.5 cloud API
def create_captioner(captioner_type: CaptionerType, **kwargs) -> "MediaCaptioningModel":
"""Factory function to create a media captioner."""
match captioner_type:
case CaptionerType.QWEN_OMNI:
return QwenOmniCaptioner(**kwargs)
case CaptionerType.GEMINI_FLASH:
return GeminiFlashCaptioner(**kwargs)
case _:
raise ValueError(f"Unsupported captioner type: {captioner_type}")
class MediaCaptioningModel(ABC):
"""Abstract base class for audio-visual media captioning models."""
instruction: str | None = None
@abstractmethod
def caption(self, path: str | Path, **kwargs) -> str:
"""Generate a caption for the given video or image."""
def _resolve_instruction(self, path: str | Path) -> str:
"""Return the custom instruction, or the image/video default for this input."""
if self.instruction is not None:
return self.instruction
return DEFAULT_IMAGE_CAPTION_INSTRUCTION if self._is_image_file(path) else DEFAULT_VIDEO_CAPTION_INSTRUCTION
@staticmethod
def _is_image_file(path: str | Path) -> bool:
return str(path).lower().endswith((".png", ".jpg", ".jpeg", ".heic", ".heif", ".webp"))
@staticmethod
def _is_video_file(path: str | Path) -> bool:
return str(path).lower().endswith((".mp4", ".avi", ".mov", ".mkv", ".webm"))
class QwenOmniCaptioner(MediaCaptioningModel):
"""Audio-visual captioning via a local vLLM server running Qwen3-Omni.
The vLLM server must already be running. See ``scripts/serve_captioner.py``
for a helper that launches one in an isolated environment (no impact on
this package's dependency tree).
The captioner uses the OpenAI-compatible chat completions API. It sends
a ``file://`` URL pointing at the local video, the default combined-caption
prompt, and parses the JSON-wrapped response.
"""
def __init__(
self,
base_url: str = DEFAULT_VLLM_BASE_URL,
model: str = DEFAULT_QWEN_MODEL,
api_key: str = "EMPTY",
instruction: str | None = None,
max_tokens: int = 4096,
enable_thinking: bool = False,
timeout_s: float = 600.0,
):
"""Initialize the Qwen3-Omni captioner.
Args:
base_url: Base URL of the vLLM OpenAI-compatible server (default
``http://127.0.0.1:8001/v1``).
model: Model identifier the server is serving. Must match the
server's ``--served-model-name`` (defaults to the HuggingFace
model ID).
api_key: Token sent in the ``Authorization`` header. vLLM accepts
any value by default.
instruction: Custom instruction prompt. If ``None``, uses the
default combined-caption prompt.
max_tokens: Maximum new tokens to generate per caption. 4096 leaves
comfortable headroom for both ``enable_thinking`` modes.
enable_thinking: Whether to let the Thinking model produce a
``<think>...</think>`` chain-of-thought before the caption.
Off by default: it makes captioning ~5x slower with little
quality benefit and occasionally introduces hallucinations
(e.g., inventing dialogue or background music).
timeout_s: Per-request HTTP timeout.
"""
from openai import OpenAI # noqa: PLC0415
self.model = model
self.instruction = instruction
self.max_tokens = max_tokens
self.enable_thinking = enable_thinking
self._client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout_s)
def caption(
self,
path: str | Path,
fps: int = 2,
) -> str:
"""Generate a caption for the given video or image.
Args:
path: Path to the video/image file to caption.
fps: Frames per second to sample from the video. Passed through to
vLLM's multimodal processor (``mm_processor_kwargs.fps``).
Default 2 is a typical choice for video MLLMs at this resolution.
Ignored for image inputs.
Returns:
The extracted caption string.
"""
path = Path(path)
is_image = self._is_image_file(path)
is_video = self._is_video_file(path)
if not (is_image or is_video):
raise ValueError(f"Unsupported media file: {path}")
instruction = self._resolve_instruction(path)
if is_image:
content = [
{"type": "image_url", "image_url": {"url": f"file://{path.resolve()}"}},
{"type": "text", "text": instruction},
]
return _parse_caption_response(self._chat(content)).strip()
return self._caption_video(path, instruction, fps)
def _chat(self, content: list[dict], mm_kwargs: dict | None = None) -> str:
"""Send one chat-completions request and return the raw response text."""
extra_body: dict = {
"repetition_penalty": 1.05,
"chat_template_kwargs": {"enable_thinking": self.enable_thinking},
}
if mm_kwargs:
extra_body["mm_processor_kwargs"] = mm_kwargs
response = self._client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": content}],
max_tokens=self.max_tokens,
temperature=0.0,
extra_body=extra_body,
)
return response.choices[0].message.content or ""
def _caption_video(self, path: Path, instruction: str, fps: int) -> str:
"""Caption a video, sending its audio track as a separate modality.
vLLM does not extract a video's audio on its own (and its
``use_audio_in_video`` path is broken server-side), so we pull the audio
into a 16 kHz mono WAV and send it alongside the video -- otherwise the
model only sees frames and fabricates any spoken content.
"""
with tempfile.TemporaryDirectory(prefix="qwencap_") as tmp:
work = Path(tmp)
# Best-effort: ffmpeg fails (and we send video only) if there's no audio.
audio_url: str | None = None
try:
wav = work / "audio.wav"
_extract_audio_wav(path, wav)
audio_url = f"file://{wav.resolve()}"
except subprocess.CalledProcessError:
pass
def content(video: Path) -> list[dict]:
parts: list[dict] = [{"type": "video_url", "video_url": {"url": f"file://{video.resolve()}"}}]
if audio_url:
parts.append({"type": "audio_url", "audio_url": {"url": audio_url}})
parts.append({"type": "text", "text": instruction})
return parts
mm_kwargs = {"fps": fps}
try:
raw = self._chat(content(path), mm_kwargs)
except Exception as e:
# Raw / variable-frame-rate videos over-report their frame count, which
# breaks the server's frame sampler ("... frames from video"). Re-encode
# to a constant frame rate and retry once.
if "frames from video" not in str(e):
raise
cfr = work / "video_cfr.mp4"
_transcode_cfr(path, cfr)
raw = self._chat(content(cfr), mm_kwargs)
return _parse_caption_response(raw).strip()
class GeminiFlashCaptioner(MediaCaptioningModel):
"""Audio-visual captioning using Google's Gemini via the Google Gen AI SDK.
Uses the ``google-genai`` package (the current SDK; ``google-generativeai``
is deprecated). Auth is resolved automatically:
1. If an API key is given (``api_key`` argument, or ``GEMINI_API_KEY`` /
``GOOGLE_API_KEY`` in the environment) -> the Gemini Developer API (AI Studio).
2. Otherwise, if Google Cloud Application Default Credentials are available
(an attached service account or ``gcloud auth application-default login``)
-> Vertex AI. The project comes from ADC (or ``GOOGLE_CLOUD_PROJECT``) and
the location defaults to ``global`` (override with ``GOOGLE_CLOUD_LOCATION``).
This means it "just works" on a gcloud-authed GCP VM with no env vars.
If neither is available, a clear error explains how to authenticate.
Media is sent inline (``Part.from_bytes``), which works on both backends.
"""
MODEL_ID = "gemini-3.5-flash"
_MIME_TYPES: ClassVar[dict[str, str]] = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".heic": "image/heic",
".heif": "image/heif",
".mp4": "video/mp4",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".mkv": "video/x-matroska",
".webm": "video/webm",
}
def __init__(
self,
api_key: str | None = None,
instruction: str | None = None,
model: str | None = None,
):
"""Initialize the Gemini captioner.
Args:
api_key: Gemini Developer API key. If ``None``, falls back to
``GEMINI_API_KEY`` / ``GOOGLE_API_KEY``; if no key is set at all,
uses Vertex AI via Application Default Credentials.
instruction: Custom instruction prompt. If ``None``, uses the default
image or video prompt depending on the input.
model: Override the served model id (defaults to ``MODEL_ID``).
"""
self.instruction = instruction
self.model = model or self.MODEL_ID
self._client = self._make_client(api_key)
def caption(
self,
path: str | Path,
fps: int = 2, # noqa: ARG002 - kept for API compatibility
) -> str:
from google.genai import types # noqa: PLC0415
path = Path(path)
instruction = self._resolve_instruction(path)
media = types.Part.from_bytes(data=path.read_bytes(), mime_type=self._mime_type(path))
response = self._client.models.generate_content(
model=self.model,
contents=[media, instruction],
config=types.GenerateContentConfig(temperature=0.0),
)
# Gemini may also return JSON if it followed our prompt format.
return _parse_caption_response(response.text or "").strip()
@classmethod
def _mime_type(cls, path: Path) -> str:
try:
return cls._MIME_TYPES[path.suffix.lower()]
except KeyError:
raise ValueError(f"Unsupported media type for Gemini: {path.suffix}") from None
def _make_client(self, api_key: str | None): # noqa: ANN202 - genai.Client type is lazy-imported
from google import genai # noqa: PLC0415
# 1. API key (explicit arg or env) -> Gemini Developer API.
key = api_key or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
if key:
return genai.Client(api_key=key)
# 2. No key -> Vertex AI via Application Default Credentials (gcloud / service account).
import google.auth # noqa: PLC0415
try:
_, adc_project = google.auth.default()
except Exception as e:
raise ValueError(
"No Gemini credentials found. Provide an API key (--api-key, or "
"GEMINI_API_KEY / GOOGLE_API_KEY), or set up Google Cloud credentials "
"for Vertex AI (e.g. `gcloud auth application-default login` or an "
"attached service account)."
) from e
project = os.environ.get("GOOGLE_CLOUD_PROJECT") or adc_project
location = os.environ.get("GOOGLE_CLOUD_LOCATION", "global")
return genai.Client(vertexai=True, project=project, location=location)
def _parse_caption_response(raw: str) -> str:
"""Extract the caption text from a model response.
Backend-agnostic: works for any model that follows the combined-caption
prompt. Handles the formats a model may produce:
- Plain caption text
- JSON ``{"combined_caption_english": "..."}``
- ``<think>...</think>`` chain-of-thought followed by either of the above
- Truncated JSON (when generation hits a token limit mid-string)
"""
text = re.sub(r"<think>[\s\S]*?</think>", "", raw).strip()
# Thinking models (e.g. Qwen3-Omni-*-Thinking) emit the reasoning trace
# without an opening ``<think>`` tag, because the chat template injects it
# for them -- so the response starts mid-thought and is terminated by a lone
# ``</think>`` before the real answer. Drop everything up to that closer.
if "</think>" in text:
text = text.rsplit("</think>", 1)[1].strip()
if not text:
return raw.strip()
try:
parsed = json.loads(text)
if isinstance(parsed, dict) and _CAPTION_JSON_KEY in parsed:
return parsed[_CAPTION_JSON_KEY]
except (json.JSONDecodeError, ValueError):
pass
match = re.search(rf"\{{[^{{}}]*\"{_CAPTION_JSON_KEY}\"[^{{}}]*\}}", text)
if match:
try:
parsed = json.loads(match.group())
if isinstance(parsed, dict) and _CAPTION_JSON_KEY in parsed:
return parsed[_CAPTION_JSON_KEY]
except (json.JSONDecodeError, ValueError):
pass
# Truncated JSON: extract the string value even if the closing quote/brace is missing.
match = re.search(rf'"{_CAPTION_JSON_KEY}"\s*:\s*"((?:[^"\\]|\\.)*)', text)
if match:
try:
return json.loads('"' + match.group(1) + '"')
except (json.JSONDecodeError, ValueError):
return match.group(1)
return text
def _run_ffmpeg(args: list[str]) -> None:
"""Run the ffmpeg binary bundled with ``imageio-ffmpeg`` (a dependency)."""
import imageio_ffmpeg # noqa: PLC0415
cmd = [imageio_ffmpeg.get_ffmpeg_exe(), "-y", "-loglevel", "error", *args]
subprocess.run(cmd, check=True, capture_output=True)
def _extract_audio_wav(src: Path, dest: Path) -> None:
"""Extract the audio track to a 16 kHz mono PCM WAV (matches pretraining).
Raises ``CalledProcessError`` when the video has no audio stream.
"""
_run_ffmpeg(["-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", str(dest)])
def _transcode_cfr(src: Path, dest: Path) -> None:
"""Re-encode the video to a constant frame rate so the server's frame sampler can
read every requested index (raw / variable-frame-rate videos over-report frames)."""
_run_ffmpeg(["-i", str(src), "-fps_mode", "cfr", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-an", str(dest)])