Buckets:
| #!/usr/bin/env python3 | |
| """ | |
| Auto-caption videos with audio using multimodal models. | |
| Backends: | |
| - Qwen3-Omni-30B-A3B-Thinking via a local vLLM HTTP server (default, | |
| ``qwen_omni``). Launch the server once with ``scripts/serve_captioner.py``. | |
| - Gemini Flash 3.5 via Google's API (``gemini_flash``). | |
| The paths in the output file are RELATIVE to the output file's directory, | |
| making the dataset portable. | |
| Basic usage: | |
| # Launch the captioner server once (separate terminal) | |
| uv run python scripts/serve_captioner.py | |
| # Caption a directory | |
| caption_videos.py videos_dir/ --output captions.json | |
| # Caption a single video with a custom prompt | |
| caption_videos.py video.mp4 --output cap.json --instruction "Describe in detail." | |
| Advanced usage: | |
| # Use Gemini Flash 3.5 (cloud, requires GEMINI_API_KEY) | |
| caption_videos.py videos_dir/ --captioner-type gemini_flash | |
| # Gemini with parallel workers | |
| caption_videos.py videos_dir/ --captioner-type gemini_flash --num-workers 5 | |
| # Talk to a remote vLLM server | |
| caption_videos.py videos_dir/ --vllm-url http://192.168.1.10:8001/v1 | |
| # Enable Qwen3 chain-of-thought (slower, more detail) | |
| caption_videos.py videos_dir/ --enable-thinking | |
| """ | |
| import csv | |
| import json | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from enum import Enum | |
| from pathlib import Path | |
| import typer | |
| from rich.console import Console | |
| from rich.progress import ( | |
| BarColumn, | |
| MofNCompleteColumn, | |
| Progress, | |
| SpinnerColumn, | |
| TextColumn, | |
| TimeElapsedColumn, | |
| TimeRemainingColumn, | |
| ) | |
| from ltx_trainer.captioning import ( | |
| DEFAULT_QWEN_MODEL, | |
| DEFAULT_VLLM_BASE_URL, | |
| CaptionerType, | |
| MediaCaptioningModel, | |
| create_captioner, | |
| ) | |
| VIDEO_EXTENSIONS = ["mp4", "avi", "mov", "mkv", "webm"] | |
| IMAGE_EXTENSIONS = ["jpg", "jpeg", "png"] | |
| MEDIA_EXTENSIONS = VIDEO_EXTENSIONS + IMAGE_EXTENSIONS | |
| SAVE_INTERVAL = 5 | |
| console = Console() | |
| app = typer.Typer( | |
| pretty_exceptions_enable=False, | |
| no_args_is_help=True, | |
| help="Auto-caption videos with audio using multimodal models.", | |
| ) | |
| class OutputFormat(str, Enum): | |
| """Available output formats for captions.""" | |
| TXT = "txt" # Separate files for captions and video paths, one caption / video path per line | |
| CSV = "csv" # CSV file with video path and caption columns | |
| JSON = "json" # JSON file with video paths as keys and captions as values | |
| JSONL = "jsonl" # JSON Lines file with one JSON object per line | |
| def caption_media( | |
| input_path: Path, | |
| output_path: Path, | |
| captioner: MediaCaptioningModel, | |
| extensions: list[str], | |
| recursive: bool, | |
| fps: int, | |
| output_format: OutputFormat, | |
| override: bool, | |
| num_workers: int = 1, | |
| ) -> None: | |
| """Caption videos and images using the provided captioning model. | |
| Args: | |
| input_path: Path to input video file or directory | |
| output_path: Path to output caption file | |
| captioner: Media captioning model | |
| extensions: List of media file extensions to include | |
| recursive: Whether to search subdirectories recursively | |
| fps: Frames per second to sample from videos (ignored for images) | |
| output_format: Format to save the captions in | |
| override: Whether to override existing captions | |
| num_workers: Number of parallel workers (only for cloud-based captioners like Gemini) | |
| """ | |
| # Get list of media files to process | |
| media_files = _get_media_files(input_path, extensions, recursive) | |
| if not media_files: | |
| console.print("[bold yellow]No media files found to process.[/]") | |
| return | |
| console.print(f"Found [bold]{len(media_files)}[/] media files to process.") | |
| # Load existing captions and determine which files need processing | |
| base_dir = output_path.parent.resolve() | |
| existing_captions = _load_existing_captions(output_path, output_format) | |
| existing_abs_paths = {str((base_dir / p).resolve()) for p in existing_captions} | |
| if override: | |
| media_to_process = media_files | |
| else: | |
| media_to_process = [f for f in media_files if str(f.resolve()) not in existing_abs_paths] | |
| if skipped := len(media_files) - len(media_to_process): | |
| console.print(f"[bold yellow]Skipping {skipped} media that already have captions.[/]") | |
| if not media_to_process: | |
| console.print("[bold yellow]All media already have captions. Use --override to recaption.[/]") | |
| return | |
| if num_workers > 1: | |
| console.print(f"Running with [bold cyan]{num_workers}[/] parallel workers.") | |
| captions = existing_captions.copy() | |
| successfully_captioned = 0 | |
| completed_since_save = 0 | |
| progress = Progress( | |
| SpinnerColumn(), | |
| TextColumn("{task.description}"), | |
| BarColumn(bar_width=40), | |
| MofNCompleteColumn(), | |
| TimeElapsedColumn(), | |
| TextColumn("•"), | |
| TimeRemainingColumn(), | |
| console=console, | |
| ) | |
| def process_one(media_file: Path) -> tuple[str, str]: | |
| """Caption a single media file and return (relative_path, caption).""" | |
| caption = captioner.caption( | |
| path=media_file, | |
| fps=fps, | |
| ) | |
| # Don't resolve the file itself, so a symlinked clip keeps its logical path under the | |
| # dataset dir instead of jumping to its (possibly external) link target. | |
| rel_path = str((media_file.parent.resolve() / media_file.name).relative_to(base_dir)) | |
| return rel_path, caption | |
| with progress: | |
| task = progress.add_task( | |
| f"Captioning (workers: {num_workers})" if num_workers > 1 else "Captioning", | |
| total=len(media_to_process), | |
| ) | |
| with ThreadPoolExecutor(max_workers=num_workers) as executor: | |
| futures = {executor.submit(process_one, f): f for f in media_to_process} | |
| for future in as_completed(futures): | |
| media_file = futures[future] | |
| progress.update(task, description=f"Captioning [bold blue]{media_file.name}[/]") | |
| try: | |
| rel_path, caption = future.result() | |
| captions[rel_path] = caption | |
| successfully_captioned += 1 | |
| completed_since_save += 1 | |
| if completed_since_save >= SAVE_INTERVAL: | |
| _save_captions(captions, output_path, output_format) | |
| completed_since_save = 0 | |
| except Exception as e: | |
| console.print(f"[bold red]Error captioning {media_file.name}: {e}[/]") | |
| progress.advance(task) | |
| # Final save with everything accumulated | |
| _save_captions(captions, output_path, output_format) | |
| # Print summary | |
| console.print( | |
| f"[bold green]✓[/] Captioned [bold]{successfully_captioned}/{len(media_to_process)}[/] media successfully.", | |
| ) | |
| def _get_media_files( | |
| input_path: Path, | |
| extensions: list[str] = MEDIA_EXTENSIONS, | |
| recursive: bool = False, | |
| ) -> list[Path]: | |
| """Get all media files from the input path.""" | |
| input_path = Path(input_path) | |
| # Normalize extensions to lowercase without dots | |
| extensions_set = {ext.lower().lstrip(".") for ext in extensions} | |
| if input_path.is_file(): | |
| # If input is a file, check if it has a valid extension | |
| if input_path.suffix.lstrip(".").lower() in extensions_set: | |
| return [input_path] | |
| else: | |
| typer.echo(f"Warning: {input_path} is not a recognized media file. Skipping.") | |
| return [] | |
| elif input_path.is_dir(): | |
| # Find all files and filter by extension case-insensitively | |
| glob_pattern = "**/*" if recursive else "*" | |
| media_files = [ | |
| f for f in input_path.glob(glob_pattern) if f.is_file() and f.suffix.lstrip(".").lower() in extensions_set | |
| ] | |
| return sorted(media_files) | |
| else: | |
| typer.echo(f"Error: {input_path} does not exist.") | |
| raise typer.Exit(code=1) | |
| def _save_captions( | |
| captions: dict[str, str], | |
| output_path: Path, | |
| format_type: OutputFormat, | |
| ) -> None: | |
| """Save captions to a file in the specified format. | |
| Args: | |
| captions: Dictionary mapping media paths to captions | |
| output_path: Path to save the output file | |
| format_type: Format to save the captions in | |
| """ | |
| # Create parent directories if they don't exist | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| console.print("[bold blue]Saving captions...[/]") | |
| match format_type: | |
| case OutputFormat.TXT: | |
| # Create two separate files for captions and media paths | |
| captions_file = output_path.with_stem(f"{output_path.stem}_captions") | |
| paths_file = output_path.with_stem(f"{output_path.stem}_paths") | |
| with captions_file.open("w", encoding="utf-8") as f: | |
| for caption in captions.values(): | |
| f.write(f"{caption}\n") | |
| with paths_file.open("w", encoding="utf-8") as f: | |
| for media_path in captions: | |
| f.write(f"{media_path}\n") | |
| console.print(f"[bold green]✓[/] Captions saved to [cyan]{captions_file}[/]") | |
| console.print(f"[bold green]✓[/] Media paths saved to [cyan]{paths_file}[/]") | |
| case OutputFormat.CSV: | |
| with output_path.open("w", encoding="utf-8", newline="") as f: | |
| writer = csv.writer(f) | |
| writer.writerow(["caption", "media_path"]) | |
| for media_path, caption in captions.items(): | |
| writer.writerow([caption, media_path]) | |
| console.print(f"[bold green]✓[/] Captions saved to [cyan]{output_path}[/]") | |
| case OutputFormat.JSON: | |
| # Format as list of dictionaries with caption and media_path keys | |
| json_data = [{"caption": caption, "media_path": media_path} for media_path, caption in captions.items()] | |
| with output_path.open("w", encoding="utf-8") as f: | |
| json.dump(json_data, f, indent=2, ensure_ascii=False) | |
| console.print(f"[bold green]✓[/] Captions saved to [cyan]{output_path}[/]") | |
| case OutputFormat.JSONL: | |
| with output_path.open("w", encoding="utf-8") as f: | |
| for media_path, caption in captions.items(): | |
| f.write(json.dumps({"caption": caption, "media_path": media_path}, ensure_ascii=False) + "\n") | |
| console.print(f"[bold green]✓[/] Captions saved to [cyan]{output_path}[/]") | |
| case _: | |
| raise ValueError(f"Unsupported output format: {format_type}") | |
| def _load_existing_captions( # noqa: PLR0912 | |
| output_path: Path, | |
| format_type: OutputFormat, | |
| ) -> dict[str, str]: | |
| """Load existing captions from a file. | |
| Args: | |
| output_path: Path to the captions file | |
| format_type: Format of the captions file | |
| Returns: | |
| Dictionary mapping media paths to captions, or empty dict if file doesn't exist | |
| """ | |
| if not output_path.exists(): | |
| return {} | |
| console.print(f"[bold blue]Loading existing captions from [cyan]{output_path}[/]...[/]") | |
| existing_captions = {} | |
| try: | |
| match format_type: | |
| case OutputFormat.TXT: | |
| # For TXT format, we have two separate files | |
| captions_file = output_path.with_stem(f"{output_path.stem}_captions") | |
| paths_file = output_path.with_stem(f"{output_path.stem}_paths") | |
| if captions_file.exists() and paths_file.exists(): | |
| captions = captions_file.read_text(encoding="utf-8").splitlines() | |
| paths = paths_file.read_text(encoding="utf-8").splitlines() | |
| if len(captions) == len(paths): | |
| existing_captions = dict(zip(paths, captions, strict=False)) | |
| case OutputFormat.CSV: | |
| with output_path.open("r", encoding="utf-8", newline="") as f: | |
| reader = csv.reader(f) | |
| # Skip header | |
| next(reader, None) | |
| for row in reader: | |
| if len(row) >= 2: | |
| caption, media_path = row[0], row[1] | |
| existing_captions[media_path] = caption | |
| case OutputFormat.JSON: | |
| with output_path.open("r", encoding="utf-8") as f: | |
| json_data = json.load(f) | |
| for item in json_data: | |
| if "caption" in item and "media_path" in item: | |
| existing_captions[item["media_path"]] = item["caption"] | |
| case OutputFormat.JSONL: | |
| with output_path.open("r", encoding="utf-8") as f: | |
| for line in f: | |
| item = json.loads(line) | |
| if "caption" in item and "media_path" in item: | |
| existing_captions[item["media_path"]] = item["caption"] | |
| case _: | |
| raise ValueError(f"Unsupported output format: {format_type}") | |
| console.print(f"[bold green]✓[/] Loaded [bold]{len(existing_captions)}[/] existing captions") | |
| return existing_captions | |
| except Exception as e: | |
| console.print(f"[bold yellow]Warning: Could not load existing captions: {e}[/]") | |
| return {} | |
| def main( # noqa: PLR0913 | |
| input_path: Path = typer.Argument( # noqa: B008 | |
| ..., | |
| help="Path to input video/image file or directory containing media files", | |
| exists=True, | |
| ), | |
| output: Path | None = typer.Option( # noqa: B008 | |
| None, | |
| "--output", | |
| "-o", | |
| help="Path to output file for captions. Format determined by file extension.", | |
| ), | |
| captioner_type: CaptionerType = typer.Option( # noqa: B008 | |
| CaptionerType.QWEN_OMNI, | |
| "--captioner-type", | |
| "-c", | |
| help="Type of captioner to use. Valid values: 'qwen_omni' (local), 'gemini_flash' (API)", | |
| case_sensitive=False, | |
| ), | |
| vllm_url: str = typer.Option( | |
| DEFAULT_VLLM_BASE_URL, | |
| "--vllm-url", | |
| help=( | |
| "Base URL of the vLLM OpenAI-compatible server (qwen_omni only). " | |
| "Launch the server with `uv run python scripts/serve_captioner.py`." | |
| ), | |
| ), | |
| vllm_model: str = typer.Option( | |
| DEFAULT_QWEN_MODEL, | |
| "--vllm-model", | |
| help="Served model identifier on the vLLM server (qwen_omni only).", | |
| ), | |
| enable_thinking: bool = typer.Option( | |
| False, | |
| "--enable-thinking/--no-thinking", | |
| help=( | |
| "Let Qwen3-Omni produce a <think>...</think> chain-of-thought before the caption. " | |
| "Off by default: ~5x slower with marginal quality benefit and occasional hallucinations." | |
| ), | |
| ), | |
| max_tokens: int = typer.Option( | |
| 4096, | |
| "--max-tokens", | |
| help="Maximum new tokens to generate per caption (qwen_omni only).", | |
| ), | |
| instruction: str | None = typer.Option( | |
| None, | |
| "--instruction", | |
| "-i", | |
| help="Custom instruction for the captioning model. If not provided, uses an appropriate default.", | |
| ), | |
| extensions: str = typer.Option( | |
| ",".join(MEDIA_EXTENSIONS), | |
| "--extensions", | |
| "-e", | |
| help="Comma-separated list of media file extensions to process", | |
| ), | |
| recursive: bool = typer.Option( | |
| False, | |
| "--recursive", | |
| "-r", | |
| help="Search for media files in subdirectories recursively", | |
| ), | |
| fps: int = typer.Option( | |
| 2, | |
| "--fps", | |
| "-f", | |
| help=( | |
| "Frames per second to sample from videos. 2 is a typical default; " | |
| "lower values use less compute per video. Ignored for images and for the " | |
| "Gemini backend (which decides its own sampling rate)." | |
| ), | |
| ), | |
| override: bool = typer.Option( | |
| False, | |
| "--override", | |
| help="Whether to override existing captions for media", | |
| ), | |
| api_key: str | None = typer.Option( | |
| None, | |
| "--api-key", | |
| envvar=["GOOGLE_API_KEY", "GEMINI_API_KEY"], | |
| help="API key for Gemini Flash (can also use GOOGLE_API_KEY or GEMINI_API_KEY env var)", | |
| ), | |
| num_workers: int = typer.Option( | |
| 1, | |
| "--num-workers", | |
| "-w", | |
| min=1, | |
| max=10, | |
| help=( | |
| "Number of parallel workers for captioning (1-10). " | |
| "Values above 1 are only supported for cloud-based captioners (gemini_flash). " | |
| "Using multiple workers with a local model will raise an error." | |
| ), | |
| ), | |
| ) -> None: | |
| """Auto-caption videos with audio using multimodal models. | |
| Backends: | |
| - ``qwen_omni`` (default): Qwen3-Omni-30B-A3B-Thinking via a local vLLM | |
| HTTP server. Launch the server once in a separate terminal with | |
| ``uv run python scripts/serve_captioner.py``. The server stays loaded | |
| across script invocations. | |
| - ``gemini_flash``: Google Gemini (``gemini-3.5-flash``) via the google-genai SDK. | |
| Auth is automatic -- ``GEMINI_API_KEY``/``GOOGLE_API_KEY`` for the Developer API, | |
| or Google Cloud credentials (gcloud / service account) for Vertex AI with no env vars. | |
| The paths in the output file will be relative to the output file's directory. | |
| Examples: | |
| # Caption videos using the local vLLM server (default) | |
| caption_videos.py videos_dir/ -o captions.json | |
| # Point at a remote vLLM server | |
| caption_videos.py videos_dir/ -o captions.json --vllm-url http://other-host:8001/v1 | |
| # Caption using Gemini Flash 3.5 | |
| caption_videos.py videos_dir/ -o captions.json -c gemini_flash | |
| # Caption with custom instruction | |
| caption_videos.py video.mp4 -o captions.json -i "Describe this video in detail" | |
| """ | |
| # Parallel workers are only supported for the cloud Gemini backend; qwen_omni | |
| # drives a single shared vLLM server and is captioned serially from here. | |
| if num_workers > 1 and captioner_type != CaptionerType.GEMINI_FLASH: | |
| console.print( | |
| "[bold red]Error:[/] --num-workers > 1 is only supported with " | |
| "[bold]--captioner-type gemini_flash[/]. Use --num-workers 1 (default) " | |
| "for the qwen_omni backend." | |
| ) | |
| raise typer.Exit(code=1) | |
| # Parse extensions | |
| ext_list = [ext.strip() for ext in extensions.split(",")] | |
| # Determine output path and format | |
| if output is None: | |
| output_format = OutputFormat.JSON | |
| if input_path.is_file(): # noqa: SIM108 | |
| # Default to a JSON file with the same name as the input media | |
| output = input_path.with_suffix(".dataset.json") | |
| else: | |
| # Default to a JSON file in the input directory | |
| output = input_path / "dataset.json" | |
| else: | |
| # Determine format from file extension | |
| output_format = OutputFormat(Path(output).suffix.lstrip(".").lower()) | |
| # Ensure output path is absolute | |
| output = Path(output).resolve() | |
| console.print(f"Output will be saved to [bold blue]{output}[/]") | |
| with console.status("Initializing captioner...", spinner="dots"): | |
| if captioner_type == CaptionerType.QWEN_OMNI: | |
| captioner = create_captioner( | |
| captioner_type=captioner_type, | |
| base_url=vllm_url, | |
| model=vllm_model, | |
| instruction=instruction, | |
| max_tokens=max_tokens, | |
| enable_thinking=enable_thinking, | |
| ) | |
| elif captioner_type == CaptionerType.GEMINI_FLASH: | |
| captioner = create_captioner( | |
| captioner_type=captioner_type, | |
| api_key=api_key, | |
| instruction=instruction, | |
| ) | |
| else: | |
| raise ValueError(f"Unsupported captioner type: {captioner_type}") | |
| console.print(f"[bold green]✓[/] {captioner_type.value} captioner ready") | |
| # Caption media files | |
| caption_media( | |
| input_path=input_path, | |
| output_path=output, | |
| captioner=captioner, | |
| extensions=ext_list, | |
| recursive=recursive, | |
| fps=fps, | |
| output_format=output_format, | |
| override=override, | |
| num_workers=num_workers, | |
| ) | |
| if __name__ == "__main__": | |
| app() | |
Xet Storage Details
- Size:
- 20.3 kB
- Xet hash:
- e77ab355e783ef9cd70650928add459d4b55774f63eec3c09a38eb74e71a3dba
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.