1074 lines
44 KiB
Python
1074 lines
44 KiB
Python
# Adopted from https://github.com/guandeh17/Self-Forcing
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
from torch.utils.data import Dataset
|
|
import numpy as np
|
|
import torch
|
|
import random
|
|
import json
|
|
from pathlib import Path
|
|
from PIL import Image
|
|
import os
|
|
import subprocess
|
|
import time
|
|
import warnings
|
|
import torchvision.transforms as transforms
|
|
import torchvision.transforms.functional as F
|
|
|
|
try:
|
|
import decord
|
|
except ModuleNotFoundError:
|
|
decord = None
|
|
|
|
DEFAULT_SCENE_CUT_PREFIX = "The scene transitions. "
|
|
|
|
|
|
class TextDataset(Dataset):
|
|
def __init__(self, prompt_path, extended_prompt_path=None):
|
|
with open(prompt_path, encoding="utf-8") as f:
|
|
self.prompt_list = [line.rstrip() for line in f]
|
|
|
|
if extended_prompt_path is not None:
|
|
with open(extended_prompt_path, encoding="utf-8") as f:
|
|
self.extended_prompt_list = [line.rstrip() for line in f]
|
|
assert len(self.extended_prompt_list) == len(self.prompt_list)
|
|
else:
|
|
self.extended_prompt_list = None
|
|
|
|
def __len__(self):
|
|
return len(self.prompt_list)
|
|
|
|
def __getitem__(self, idx):
|
|
batch = {
|
|
"prompts": self.prompt_list[idx],
|
|
"idx": idx,
|
|
}
|
|
if self.extended_prompt_list is not None:
|
|
batch["extended_prompts"] = self.extended_prompt_list[idx]
|
|
return batch
|
|
|
|
|
|
class MultiTextDataset(Dataset):
|
|
"""Dataset for multi-segment prompts stored in a JSONL file.
|
|
|
|
Each line is a JSON object, e.g.
|
|
{"prompts": ["a cat", "a dog", "a bird"]}
|
|
|
|
Args
|
|
----
|
|
prompt_path : str
|
|
Path to the JSONL file
|
|
field : str
|
|
Name of the list-of-strings field, default "prompts"
|
|
cache_dir : str | None
|
|
``cache_dir`` passed to HF Datasets (optional)
|
|
"""
|
|
|
|
def __init__(self, prompt_path: str, field: str = "prompts", cache_dir: str | None = None):
|
|
try:
|
|
import datasets
|
|
except ModuleNotFoundError as exc:
|
|
raise ModuleNotFoundError(
|
|
"The 'datasets' package is required for MultiTextDataset. "
|
|
"Use MultiTextConcatDataset for plain txt/json-caption directories "
|
|
"or install datasets."
|
|
) from exc
|
|
self.ds = datasets.load_dataset(
|
|
"json",
|
|
data_files=prompt_path,
|
|
split="train",
|
|
cache_dir=cache_dir,
|
|
streaming=False,
|
|
)
|
|
|
|
assert len(self.ds) > 0, "JSONL is empty"
|
|
assert field in self.ds.column_names, f"Missing field '{field}'"
|
|
|
|
seg_len = len(self.ds[0][field])
|
|
for i, ex in enumerate(self.ds):
|
|
val = ex[field]
|
|
assert isinstance(val, list), f"Line {i} field '{field}' is not a list"
|
|
assert len(val) == seg_len, f"Line {i} list length mismatch"
|
|
|
|
self.field = field
|
|
|
|
def __len__(self):
|
|
return len(self.ds)
|
|
|
|
def __getitem__(self, idx: int):
|
|
return {
|
|
"idx": idx,
|
|
"prompts_list": self.ds[idx][self.field], # List[str]
|
|
}
|
|
|
|
|
|
class MultiTextConcatDataset(Dataset):
|
|
"""Text-only dataset for multi-shot training and inference.
|
|
|
|
Supports two input modes:
|
|
|
|
**txt file** — each line is one caption. Each sample uses the caption at
|
|
index ``idx``, repeated ``num_blocks`` times (single-shot, no scene cut
|
|
prefix).
|
|
|
|
**directory** — reads ``caption/<subfolder>/*.json`` files (no video dir
|
|
needed). Shot durations are resolved with a three-level fallback:
|
|
|
|
1. ``shot_durations.txt`` in the caption subfolder (per-sample override)
|
|
2. ``chunks_per_shot`` from config (global fixed repeat)
|
|
3. Even distribution across all available captions
|
|
|
|
Scene cut prefix is prepended at shot boundaries (first block of each
|
|
shot except shot 0). Output is always exactly ``num_blocks`` prompts:
|
|
truncated if too many, padded with the last caption if too few.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
data_path: str,
|
|
num_blocks: int,
|
|
chunks_per_shot: int = 0,
|
|
scene_cut_prefix: str = DEFAULT_SCENE_CUT_PREFIX,
|
|
caption_field: str = "caption",
|
|
deterministic: bool = False,
|
|
):
|
|
self.num_blocks = num_blocks
|
|
self.chunks_per_shot = chunks_per_shot
|
|
self.scene_cut_prefix = scene_cut_prefix
|
|
self.caption_field = caption_field
|
|
self.deterministic = deterministic
|
|
|
|
path = Path(data_path)
|
|
if data_path.endswith(".txt") or path.is_file():
|
|
self._mode = "txt"
|
|
with open(data_path, encoding="utf-8") as f:
|
|
self._prompts = [line.rstrip() for line in f if line.strip()]
|
|
assert len(self._prompts) > 0, f"No prompts found in {data_path}"
|
|
else:
|
|
self._mode = "dir"
|
|
self._caption_dir = path / "caption" if (path / "caption").is_dir() else path
|
|
self._folders = sorted([d for d in self._caption_dir.iterdir() if d.is_dir()])
|
|
assert len(self._folders) > 0, (
|
|
f"No caption subfolders found in {self._caption_dir}"
|
|
)
|
|
|
|
def __len__(self):
|
|
if self._mode == "txt":
|
|
return len(self._prompts)
|
|
return len(self._folders)
|
|
|
|
def __getitem__(self, idx):
|
|
if self._mode == "txt":
|
|
return self._get_txt_item(idx)
|
|
return self._get_dir_item(idx)
|
|
|
|
# ------------------------------------------------------------------
|
|
# txt mode
|
|
# ------------------------------------------------------------------
|
|
|
|
def _get_txt_item(self, idx):
|
|
caption = self._prompts[idx % len(self._prompts)]
|
|
return {
|
|
"prompts": [caption] * self.num_blocks,
|
|
"idx": idx,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# directory mode
|
|
# ------------------------------------------------------------------
|
|
|
|
def _get_dir_item(self, idx):
|
|
folder = self._folders[idx % len(self._folders)]
|
|
raw_captions = self._load_captions_from_folder(folder)
|
|
if not raw_captions:
|
|
raw_captions = [""]
|
|
|
|
shot_durations = self._resolve_shot_durations(folder, len(raw_captions))
|
|
prompts = self._apply_shot_durations(raw_captions, shot_durations)
|
|
|
|
# Ensure exactly num_blocks prompts
|
|
if len(prompts) > self.num_blocks:
|
|
prompts = prompts[: self.num_blocks]
|
|
elif len(prompts) < self.num_blocks:
|
|
last = prompts[-1] if prompts else ""
|
|
prompts.extend([last] * (self.num_blocks - len(prompts)))
|
|
|
|
return {
|
|
"prompts": prompts,
|
|
"idx": idx,
|
|
}
|
|
|
|
def _load_captions_from_folder(self, folder: Path):
|
|
json_files = sorted(
|
|
[f for f in folder.glob("*.json") if f.name != "global.json"],
|
|
key=lambda p: (p.stem.isdigit(), int(p.stem) if p.stem.isdigit() else 0, p.stem),
|
|
)
|
|
captions = []
|
|
for jf in json_files:
|
|
try:
|
|
with open(jf, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
captions.append(data.get(self.caption_field, ""))
|
|
except Exception:
|
|
captions.append("")
|
|
return captions
|
|
|
|
# ------------------------------------------------------------------
|
|
# shot duration helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _load_shot_durations(folder: Path):
|
|
txt_path = folder / "shot_durations.txt"
|
|
if not txt_path.exists():
|
|
return None
|
|
try:
|
|
with open(txt_path, "r") as f:
|
|
content = f.read().strip()
|
|
parts = content.replace(",", " ").split()
|
|
durations = [int(x) for x in parts if x.strip()]
|
|
return durations if durations else None
|
|
except Exception:
|
|
return None
|
|
|
|
def _resolve_shot_durations(self, folder: Path, num_captions: int):
|
|
durations = self._load_shot_durations(folder)
|
|
if durations is not None:
|
|
return durations[:num_captions]
|
|
if self.chunks_per_shot > 0:
|
|
return [self.chunks_per_shot] * num_captions
|
|
return self._even_durations(num_captions)
|
|
|
|
def _even_durations(self, num_shots: int):
|
|
total = self.num_blocks
|
|
base, extra = divmod(total, num_shots)
|
|
return [base + (1 if i < extra else 0) for i in range(num_shots)]
|
|
|
|
def _apply_shot_durations(self, raw_captions, shot_durations):
|
|
target = self.num_blocks
|
|
clamped: list[int] = []
|
|
remaining = target
|
|
for d in shot_durations:
|
|
if remaining <= 0:
|
|
break
|
|
take = min(d, remaining)
|
|
clamped.append(take)
|
|
remaining -= take
|
|
if remaining > 0 and clamped:
|
|
clamped[-1] += remaining
|
|
|
|
prompts: list[str] = []
|
|
for shot_idx, (caption, duration) in enumerate(zip(raw_captions, clamped)):
|
|
for block_in_shot in range(duration):
|
|
if shot_idx > 0 and block_in_shot == 0 and self.scene_cut_prefix:
|
|
prompts.append(self.scene_cut_prefix + caption)
|
|
else:
|
|
prompts.append(caption)
|
|
return prompts
|
|
|
|
|
|
class MultiVideoConcatDataset(Dataset):
|
|
"""Dataset that concatenates multiple videos from a folder into a fixed-length video.
|
|
|
|
Each item consists of multiple video segments concatenated together:
|
|
- First segment: first_chunk_frames frames (chunk)
|
|
- Subsequent segments: subsequent_chunk_frames frames each (chunk)
|
|
- Total: total_frames frames (first_chunk_frames + subsequent_chunk_frames*num_subsequent_segments = total_frames)
|
|
|
|
Videos are sampled preserving original duration, and if a video doesn't have enough frames,
|
|
it moves to the next video. If a video has enough frames, it can be sampled repeatedly.
|
|
"""
|
|
def __init__(
|
|
self,
|
|
data_dir,
|
|
video_size,
|
|
total_frames,
|
|
target_fps=16,
|
|
video_extensions=('.mp4', '.avi', '.mov', '.mkv', '.webm'),
|
|
caption_field='caption',
|
|
filter_invalid_folders=False,
|
|
deterministic: bool = False,
|
|
num_frame_per_block=8,
|
|
temporal_compression_ratio=4,
|
|
allow_padding: bool = False,
|
|
min_latent_frames: int = 0,
|
|
single_video_only: bool = False,
|
|
independent_first_frame: bool = False,
|
|
return_image: bool = False,
|
|
max_chunks_per_shot: int = 0,
|
|
scene_cut_prefix: str = DEFAULT_SCENE_CUT_PREFIX,
|
|
sample_warning_seconds: float = 60.0,
|
|
sample_warning_interval_seconds: float = 60.0,
|
|
):
|
|
self.root_dir = Path(data_dir)
|
|
self.data_dir = self.root_dir / "video"
|
|
self.caption_dir = self.root_dir / "caption"
|
|
self.video_size = video_size
|
|
self.total_frames = total_frames
|
|
|
|
total_latent_frames = 1 + (total_frames - 1) // temporal_compression_ratio
|
|
separate_first_latent = (
|
|
independent_first_frame
|
|
and total_latent_frames % num_frame_per_block != 0
|
|
)
|
|
if separate_first_latent:
|
|
assert (total_latent_frames - 1) % num_frame_per_block == 0, (
|
|
f"total latent frames ({total_latent_frames}) must be divisible by "
|
|
f"num_frame_per_block ({num_frame_per_block}) or equal to "
|
|
f"1 + N * num_frame_per_block when independent_first_frame=True"
|
|
)
|
|
first_chunk_latent_frames = (
|
|
num_frame_per_block + 1 if separate_first_latent else num_frame_per_block
|
|
)
|
|
first_chunk_frames = 1 + (first_chunk_latent_frames - 1) * temporal_compression_ratio
|
|
subsequent_chunk_frames = num_frame_per_block * temporal_compression_ratio
|
|
|
|
self.first_chunk_frames = first_chunk_frames
|
|
self.subsequent_chunk_frames = subsequent_chunk_frames
|
|
self.target_fps = target_fps
|
|
self.caption_field = caption_field
|
|
self.video_extensions = video_extensions
|
|
self.filter_invalid_folders = filter_invalid_folders
|
|
self.deterministic = deterministic
|
|
self.allow_padding = allow_padding
|
|
self.num_frame_per_block = num_frame_per_block
|
|
self.independent_first_frame = independent_first_frame
|
|
self.first_chunk_latent_frames = first_chunk_latent_frames
|
|
self.return_image = return_image
|
|
if min_latent_frames > 0:
|
|
assert min_latent_frames % num_frame_per_block == 0, (
|
|
f"min_latent_frames ({min_latent_frames}) must be a multiple of "
|
|
f"num_frame_per_block ({num_frame_per_block})"
|
|
)
|
|
self.min_latent_frames = min_latent_frames
|
|
self.single_video_only = single_video_only
|
|
self.max_chunks_per_shot = max_chunks_per_shot
|
|
self.scene_cut_prefix = scene_cut_prefix
|
|
self.sample_warning_seconds = float(sample_warning_seconds or 0.0)
|
|
self.sample_warning_interval_seconds = float(sample_warning_interval_seconds or 0.0)
|
|
|
|
remaining_frames = total_frames - first_chunk_frames
|
|
self.num_subsequent_segments = remaining_frames // subsequent_chunk_frames
|
|
self.total_segments = 1 + self.num_subsequent_segments
|
|
|
|
assert total_frames == first_chunk_frames + self.num_subsequent_segments * subsequent_chunk_frames, \
|
|
f"Total frames ({total_frames}) must equal first_chunk_frames ({first_chunk_frames}) + " \
|
|
f"num_subsequent_segments ({self.num_subsequent_segments}) * subsequent_chunk_frames ({subsequent_chunk_frames})"
|
|
|
|
if not self.data_dir.exists():
|
|
raise ValueError(f"Video directory not found: {self.data_dir}")
|
|
if not self.caption_dir.exists():
|
|
raise ValueError(f"Caption directory not found: {self.caption_dir}")
|
|
|
|
self.folders = [d for d in self.data_dir.iterdir() if d.is_dir()]
|
|
if len(self.folders) == 0:
|
|
raise ValueError(f"No subdirectories found in {self.data_dir}")
|
|
|
|
# Optionally pre-filter folders with insufficient frames
|
|
# Note: This can be slow for large datasets due to IO operations
|
|
if self.filter_invalid_folders:
|
|
print(f"[MultiVideoConcatDataset] Pre-filtering {len(self.folders)} folders for sufficient frames...")
|
|
valid_folders = []
|
|
skipped_folders = []
|
|
for folder in self.folders:
|
|
if self._check_folder_has_enough_frames(folder):
|
|
valid_folders.append(folder)
|
|
else:
|
|
skipped_folders.append(folder.name)
|
|
|
|
if len(skipped_folders) > 0:
|
|
print(f"[MultiVideoConcatDataset] Skipped {len(skipped_folders)} folders due to insufficient frames: {skipped_folders}")
|
|
|
|
self.folders = valid_folders
|
|
|
|
if len(self.folders) == 0:
|
|
raise ValueError(f"No folders with sufficient frames found in {self.data_dir}")
|
|
|
|
# Setup transforms
|
|
self.resize_transform = transforms.Resize(
|
|
self.video_size,
|
|
antialias=True
|
|
)
|
|
self.normalize = transforms.Normalize(
|
|
mean=[0.5, 0.5, 0.5],
|
|
std=[0.5, 0.5, 0.5]
|
|
)
|
|
|
|
# Lazy caches: avoid repeated decord.VideoReader / filesystem IO
|
|
self._video_info_cache = {} # video_path -> (total_frames, fps)
|
|
self._folder_files_cache = {} # folder_path -> list of video paths
|
|
|
|
if decord is not None:
|
|
decord.bridge.set_bridge('torch')
|
|
|
|
def _get_caption_folder(self, folder_name):
|
|
"""Return the caption directory path for a given folder (sample)."""
|
|
return self.caption_dir / folder_name
|
|
|
|
def _load_caption(self, video_path, folder_name):
|
|
"""Load caption for a video file."""
|
|
video_stem = video_path.stem
|
|
caption_folder = self._get_caption_folder(folder_name)
|
|
caption_path = caption_folder / f"{video_stem}.json"
|
|
|
|
if caption_path.exists():
|
|
try:
|
|
with open(caption_path, 'r', encoding='utf-8') as f:
|
|
caption_data = json.load(f)
|
|
return caption_data.get(self.caption_field, "")
|
|
except Exception:
|
|
return ""
|
|
return ""
|
|
|
|
def _get_video_files_in_folder(self, folder_path):
|
|
"""Get sorted video files in a folder, keeping only those with a per-video caption (cached)."""
|
|
key = str(folder_path)
|
|
if key in self._folder_files_cache:
|
|
return self._folder_files_cache[key]
|
|
|
|
video_files = []
|
|
for ext in self.video_extensions:
|
|
video_files.extend(list(folder_path.glob(f'*{ext}')))
|
|
video_files.extend(list(folder_path.glob(f'*{ext.upper()}')))
|
|
|
|
def get_numeric_key(path):
|
|
try:
|
|
return int(path.stem)
|
|
except ValueError:
|
|
return float('inf')
|
|
|
|
video_files.sort(key=get_numeric_key)
|
|
|
|
folder_name = folder_path.name
|
|
filtered_videos = []
|
|
for video_path in video_files:
|
|
caption = self._load_caption(video_path, folder_name)
|
|
if caption is not None and caption != "":
|
|
filtered_videos.append(video_path)
|
|
|
|
self._folder_files_cache[key] = filtered_videos
|
|
return filtered_videos
|
|
|
|
def _check_folder_has_enough_frames(self, folder_path):
|
|
"""Check if a folder has enough total frames across all videos to complete all segments.
|
|
|
|
This is a lenient check: we verify that the total available frames across all videos
|
|
is sufficient for the required segments, assuming ideal sampling.
|
|
"""
|
|
video_files = self._get_video_files_in_folder(folder_path)
|
|
if len(video_files) == 0:
|
|
return False
|
|
|
|
# Calculate total available frames (in target_fps timebase)
|
|
total_available_frames = 0
|
|
for video_path in video_files:
|
|
try:
|
|
total_frames, original_fps = self._get_video_info(video_path)
|
|
# Convert to target_fps timebase
|
|
available_in_target_fps = total_frames * self.target_fps / original_fps
|
|
total_available_frames += available_in_target_fps
|
|
except Exception:
|
|
# If we can't read a video, be conservative and skip this folder
|
|
return False
|
|
|
|
# Check if we have enough frames for all segments
|
|
required_frames = self.total_frames
|
|
return total_available_frames >= required_frames
|
|
|
|
def _get_video_info(self, video_path):
|
|
"""Get video information without loading frames (cached)."""
|
|
key = str(video_path)
|
|
if key in self._video_info_cache:
|
|
return self._video_info_cache[key]
|
|
if decord is None:
|
|
info = self._get_video_info_ffprobe(video_path)
|
|
self._video_info_cache[key] = info
|
|
return info
|
|
try:
|
|
vr = decord.VideoReader(key, width=self.video_size[1], height=self.video_size[0])
|
|
except:
|
|
vr = decord.VideoReader(key)
|
|
info = (len(vr), vr.get_avg_fps())
|
|
self._video_info_cache[key] = info
|
|
return info
|
|
|
|
@staticmethod
|
|
def _parse_fps(value):
|
|
if not value or value == "0/0":
|
|
return 0.0
|
|
if "/" in value:
|
|
num, den = value.split("/", 1)
|
|
den_f = float(den)
|
|
return float(num) / den_f if den_f != 0 else 0.0
|
|
return float(value)
|
|
|
|
def _get_video_info_ffprobe(self, video_path):
|
|
cmd = [
|
|
"ffprobe",
|
|
"-v",
|
|
"error",
|
|
"-select_streams",
|
|
"v:0",
|
|
"-count_frames",
|
|
"-show_entries",
|
|
"stream=nb_read_frames,nb_frames,avg_frame_rate,r_frame_rate,duration",
|
|
"-of",
|
|
"json",
|
|
str(video_path),
|
|
]
|
|
try:
|
|
proc = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
stream = json.loads(proc.stdout)["streams"][0]
|
|
fps = self._parse_fps(stream.get("avg_frame_rate")) or self._parse_fps(stream.get("r_frame_rate"))
|
|
frames_raw = stream.get("nb_read_frames") or stream.get("nb_frames")
|
|
if frames_raw and str(frames_raw).isdigit():
|
|
total_frames = int(frames_raw)
|
|
else:
|
|
total_frames = int(round(float(stream.get("duration", 0.0)) * fps))
|
|
if total_frames <= 0 or fps <= 0:
|
|
raise ValueError(f"Could not infer frames/fps from ffprobe output for {video_path}")
|
|
return total_frames, fps
|
|
except (subprocess.CalledProcessError, KeyError, IndexError, ValueError, json.JSONDecodeError) as exc:
|
|
raise RuntimeError(f"ffprobe failed to read video metadata for {video_path}: {exc}") from exc
|
|
|
|
def _can_sample_from_position(self, total_frames, original_fps, num_frames, start_frame):
|
|
"""Check if we can sample num_frames starting from start_frame.
|
|
|
|
Returns True if we can sample num_frames without exceeding video bounds.
|
|
"""
|
|
if start_frame >= total_frames:
|
|
return False
|
|
|
|
# Calculate sampling interval: original_fps / target_fps
|
|
sampling_interval = original_fps / self.target_fps
|
|
|
|
# Calculate the last frame index we need (with rounding)
|
|
# We need to check if we can get num_frames frames
|
|
last_frame_needed = start_frame + (num_frames - 1) * sampling_interval
|
|
|
|
# Account for rounding: the actual last frame index will be rounded
|
|
# So we need some margin to ensure we don't exceed bounds
|
|
return int(np.round(last_frame_needed)) < total_frames
|
|
|
|
def _can_complete_all_segments_without_wrap(self, video_files, start_video_idx, start_frame):
|
|
"""Check if from (start_video_idx, start_frame) we can sample all segments
|
|
without ever wrapping to the beginning (i.e. only use this video and later ones).
|
|
When single_video_only is True, all segments must come from the same video.
|
|
"""
|
|
vidx = start_video_idx
|
|
start = start_frame
|
|
|
|
# First segment
|
|
total_frames, original_fps = self._get_video_info(video_files[vidx])
|
|
if not self._can_sample_from_position(
|
|
total_frames, original_fps, self.first_chunk_frames, start
|
|
):
|
|
return False
|
|
sampling_interval = original_fps / self.target_fps
|
|
next_start = start + self.first_chunk_frames * sampling_interval
|
|
if int(np.round(next_start)) >= total_frames:
|
|
if self.single_video_only:
|
|
return self.num_subsequent_segments == 0
|
|
vidx += 1
|
|
start = 0
|
|
else:
|
|
start = int(np.round(next_start))
|
|
if vidx >= len(video_files):
|
|
return False
|
|
|
|
# Subsequent segments
|
|
for seg_i in range(self.num_subsequent_segments):
|
|
while vidx < len(video_files):
|
|
total_frames, original_fps = self._get_video_info(video_files[vidx])
|
|
if self._can_sample_from_position(
|
|
total_frames, original_fps, self.subsequent_chunk_frames, start
|
|
):
|
|
break
|
|
if self.single_video_only:
|
|
return False
|
|
vidx += 1
|
|
start = 0
|
|
if vidx >= len(video_files):
|
|
return False
|
|
sampling_interval = original_fps / self.target_fps
|
|
next_start = start + self.subsequent_chunk_frames * sampling_interval
|
|
if int(np.round(next_start)) >= total_frames:
|
|
if self.single_video_only and seg_i < self.num_subsequent_segments - 1:
|
|
return False
|
|
vidx += 1
|
|
start = 0
|
|
else:
|
|
start = int(np.round(next_start))
|
|
return True
|
|
|
|
def _sample_random_start(self, video_files):
|
|
"""Sample a random (video_idx, start_frame) valid for the first segment,
|
|
and from which we can complete ALL segments without wrapping to the start.
|
|
Returns (video_idx, start_frame); falls back to (0, 0) if no valid start found.
|
|
"""
|
|
candidates = []
|
|
for video_idx, video_path in enumerate(video_files):
|
|
total_frames, original_fps = self._get_video_info(video_path)
|
|
sampling_interval = original_fps / self.target_fps
|
|
last_needed = (self.first_chunk_frames - 1) * sampling_interval
|
|
max_start = int(np.floor(total_frames - 1 - last_needed))
|
|
if max_start < 0:
|
|
continue
|
|
step = max(1, max_start // 50)
|
|
for start in range(0, max_start + 1, step):
|
|
if not self._can_sample_from_position(
|
|
total_frames, original_fps, self.first_chunk_frames, start
|
|
):
|
|
continue
|
|
if self._can_complete_all_segments_without_wrap(video_files, video_idx, start):
|
|
candidates.append((video_idx, start))
|
|
if candidates:
|
|
chosen = random.choice(candidates)
|
|
return chosen
|
|
return (0, 0)
|
|
|
|
def _sample_frames_from_video(self, video_path, num_frames, start_frame=0):
|
|
"""Sample frames from a video preserving original duration.
|
|
|
|
Args:
|
|
video_path: Path to video file
|
|
num_frames: Number of frames to sample
|
|
start_frame: Starting frame index (for repeated sampling)
|
|
|
|
Returns:
|
|
tuple: (frames_tensor, total_frames_in_video, original_fps)
|
|
"""
|
|
if decord is None:
|
|
return self._sample_frames_from_video_ffmpeg(video_path, num_frames, start_frame)
|
|
|
|
try:
|
|
vr = decord.VideoReader(str(video_path), width=self.video_size[1], height=self.video_size[0])
|
|
except:
|
|
vr = decord.VideoReader(str(video_path))
|
|
|
|
total_frames = len(vr)
|
|
original_fps = vr.get_avg_fps()
|
|
|
|
if total_frames == 0:
|
|
raise ValueError(f"Video {video_path} has no frames")
|
|
|
|
# Calculate frame sampling based on fps to preserve duration
|
|
# Calculate sampling interval: original_fps / target_fps
|
|
sampling_interval = original_fps / self.target_fps
|
|
|
|
# Generate frame indices starting from start_frame
|
|
indices = []
|
|
current_frame = float(start_frame)
|
|
for _ in range(num_frames):
|
|
frame_idx = int(np.round(current_frame))
|
|
frame_idx = min(frame_idx, total_frames - 1)
|
|
indices.append(frame_idx)
|
|
current_frame += sampling_interval
|
|
if current_frame >= total_frames:
|
|
# If we run out of frames, pad with the last frame
|
|
remaining = num_frames - len(indices)
|
|
indices.extend([total_frames - 1] * remaining)
|
|
break
|
|
|
|
indices = np.array(indices[:num_frames], dtype=np.int32)
|
|
|
|
# Get video frames: shape (num_frames, height, width, 3)
|
|
video_frames = vr.get_batch(indices).numpy()
|
|
|
|
# Convert to tensor and permute to (num_frames, 3, height, width)
|
|
video_tensor = torch.from_numpy(video_frames).permute(0, 3, 1, 2).contiguous()
|
|
|
|
# Convert to float and normalize pixel values from [0, 255] to [0, 1]
|
|
video_tensor = video_tensor.float() / 255.0
|
|
|
|
# Resize if needed
|
|
if video_tensor.shape[2] != self.video_size[0] or video_tensor.shape[3] != self.video_size[1]:
|
|
resized_frames = []
|
|
for i in range(video_tensor.shape[0]):
|
|
resized_frames.append(self.resize_transform(video_tensor[i]))
|
|
video_tensor = torch.stack(resized_frames, dim=0)
|
|
|
|
# Apply normalization: (x - 0.5) / 0.5 -> range [-1, 1]
|
|
video_tensor = self.normalize(video_tensor)
|
|
video_tensor = video_tensor.to(torch.float16)
|
|
|
|
return video_tensor, total_frames, original_fps
|
|
|
|
def _sample_frames_from_video_ffmpeg(self, video_path, num_frames, start_frame=0):
|
|
total_frames, original_fps = self._get_video_info(video_path)
|
|
start_time = max(0.0, float(start_frame) / max(original_fps, 1e-6))
|
|
height, width = self.video_size
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-v",
|
|
"error",
|
|
"-ss",
|
|
f"{start_time:.6f}",
|
|
"-i",
|
|
str(video_path),
|
|
"-vf",
|
|
f"fps={self.target_fps},scale={width}:{height}",
|
|
"-frames:v",
|
|
str(num_frames),
|
|
"-f",
|
|
"rawvideo",
|
|
"-pix_fmt",
|
|
"rgb24",
|
|
"pipe:1",
|
|
]
|
|
try:
|
|
proc = subprocess.run(cmd, check=True, capture_output=True)
|
|
except subprocess.CalledProcessError as exc:
|
|
stderr = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else ""
|
|
raise RuntimeError(f"ffmpeg failed to sample {video_path}: {stderr}") from exc
|
|
|
|
frame_bytes = height * width * 3
|
|
decoded_frames = len(proc.stdout) // frame_bytes
|
|
if decoded_frames <= 0:
|
|
raise RuntimeError(f"ffmpeg produced no frames for {video_path}")
|
|
|
|
video_frames = np.frombuffer(proc.stdout[: decoded_frames * frame_bytes], dtype=np.uint8)
|
|
video_frames = video_frames.reshape(decoded_frames, height, width, 3)
|
|
video_tensor = torch.from_numpy(video_frames.copy()).permute(0, 3, 1, 2).contiguous()
|
|
video_tensor = video_tensor.float() / 255.0
|
|
if decoded_frames < num_frames:
|
|
pad = video_tensor[-1:].repeat(num_frames - decoded_frames, 1, 1, 1)
|
|
video_tensor = torch.cat([video_tensor, pad], dim=0)
|
|
elif decoded_frames > num_frames:
|
|
video_tensor = video_tensor[:num_frames]
|
|
|
|
video_tensor = self.normalize(video_tensor)
|
|
video_tensor = video_tensor.to(torch.float16)
|
|
return video_tensor, total_frames, original_fps
|
|
|
|
def __len__(self):
|
|
return len(self.folders)
|
|
|
|
@staticmethod
|
|
def _sample_failure(reason):
|
|
return False, reason
|
|
|
|
def _try_get_item_from_folder(self, folder_idx, deterministic: bool = False):
|
|
"""Try to get item from a specific folder.
|
|
|
|
Returns (True, result) on success and (False, reason) on failure.
|
|
The reason is used by __getitem__ warnings so a long scan does not
|
|
look like a silent hang when most folders are too short.
|
|
"""
|
|
folder_path = self.folders[folder_idx]
|
|
folder_name = folder_path.name
|
|
|
|
video_files = self._get_video_files_in_folder(folder_path)
|
|
|
|
if len(video_files) == 0:
|
|
return self._sample_failure(f"{folder_name}: no videos with captions")
|
|
|
|
# Collect all segments
|
|
all_segments = []
|
|
prompts_list = []
|
|
|
|
try:
|
|
# Start position
|
|
if deterministic:
|
|
current_video_idx, current_start_frame = 0, 0
|
|
else:
|
|
# Random start: don't always start from the first video, so we get more diversity
|
|
current_video_idx, current_start_frame = self._sample_random_start(video_files)
|
|
|
|
# Sample first segment (9 frames)
|
|
video_path = video_files[current_video_idx]
|
|
prev_seg_video_idx = current_video_idx
|
|
total_frames, original_fps = self._get_video_info(video_path)
|
|
|
|
# Ensure the current video is long enough for the first segment
|
|
while not self._can_sample_from_position(
|
|
total_frames, original_fps, self.first_chunk_frames, current_start_frame
|
|
):
|
|
if self.single_video_only:
|
|
return self._sample_failure(
|
|
f"{folder_name}: first video is too short for the first chunk"
|
|
)
|
|
current_video_idx += 1
|
|
current_start_frame = 0
|
|
if current_video_idx >= len(video_files):
|
|
return self._sample_failure(
|
|
f"{folder_name}: no video can provide the first chunk"
|
|
)
|
|
video_path = video_files[current_video_idx]
|
|
prev_seg_video_idx = current_video_idx
|
|
total_frames, original_fps = self._get_video_info(video_path)
|
|
|
|
# Sample first segment
|
|
segment_frames, total_frames, original_fps = self._sample_frames_from_video(
|
|
video_path, self.first_chunk_frames, current_start_frame
|
|
)
|
|
all_segments.append(segment_frames)
|
|
|
|
prompt = self._load_caption(video_path, folder_name)
|
|
prompts_list.append(prompt)
|
|
|
|
# Update position for next sampling
|
|
sampling_interval = original_fps / self.target_fps
|
|
next_start_frame = current_start_frame + self.first_chunk_frames * sampling_interval
|
|
|
|
# If we've exhausted this video, move to next
|
|
if int(np.round(next_start_frame)) >= total_frames:
|
|
if self.single_video_only:
|
|
if self.num_subsequent_segments > 0:
|
|
return self._sample_failure(
|
|
f"{folder_name}: single-video sample ends after first chunk"
|
|
)
|
|
else:
|
|
current_video_idx += 1
|
|
current_start_frame = 0
|
|
else:
|
|
current_start_frame = int(np.round(next_start_frame))
|
|
|
|
chunks_from_current_video = 1 if current_video_idx == prev_seg_video_idx else 0
|
|
|
|
# Sample subsequent segments (12 frames each). No wrap: we only use videos from start onward.
|
|
for seg_idx in range(self.num_subsequent_segments):
|
|
if current_video_idx >= len(video_files):
|
|
if self.allow_padding:
|
|
break
|
|
return self._sample_failure(
|
|
f"{folder_name}: ran out of videos before all chunks were filled"
|
|
)
|
|
|
|
# Force virtual scene cut if max_shot_chunks reached:
|
|
# skip 1 second of video and treat the remainder as a new shot.
|
|
forced_scene_cut = False
|
|
if (self.max_chunks_per_shot > 0
|
|
and chunks_from_current_video >= self.max_chunks_per_shot):
|
|
vp = video_files[current_video_idx]
|
|
_, ofps = self._get_video_info(vp)
|
|
current_start_frame += int(np.round(ofps))
|
|
chunks_from_current_video = 0
|
|
forced_scene_cut = True
|
|
|
|
video_path = video_files[current_video_idx]
|
|
total_frames, original_fps = self._get_video_info(video_path)
|
|
|
|
can_sample = True
|
|
while not self._can_sample_from_position(
|
|
total_frames, original_fps, self.subsequent_chunk_frames, current_start_frame
|
|
):
|
|
if self.single_video_only:
|
|
can_sample = False
|
|
break
|
|
current_video_idx += 1
|
|
current_start_frame = 0
|
|
chunks_from_current_video = 0
|
|
if current_video_idx >= len(video_files):
|
|
can_sample = False
|
|
break
|
|
video_path = video_files[current_video_idx]
|
|
total_frames, original_fps = self._get_video_info(video_path)
|
|
|
|
if not can_sample:
|
|
if self.allow_padding:
|
|
break
|
|
return self._sample_failure(
|
|
f"{folder_name}: remaining videos are too short for the next chunk"
|
|
)
|
|
|
|
is_scene_cut = (current_video_idx != prev_seg_video_idx) or forced_scene_cut
|
|
|
|
# Sample segment
|
|
segment_frames, total_frames, original_fps = self._sample_frames_from_video(
|
|
video_path, self.subsequent_chunk_frames, current_start_frame
|
|
)
|
|
all_segments.append(segment_frames)
|
|
|
|
prompt = self._load_caption(video_path, folder_name)
|
|
if is_scene_cut and self.scene_cut_prefix:
|
|
prompt = self.scene_cut_prefix + prompt
|
|
prompts_list.append(prompt)
|
|
|
|
prev_seg_video_idx = current_video_idx
|
|
chunks_from_current_video += 1
|
|
|
|
# Update position for next sampling
|
|
sampling_interval = original_fps / self.target_fps
|
|
next_start_frame = current_start_frame + self.subsequent_chunk_frames * sampling_interval
|
|
|
|
# If we've exhausted this video, move to next
|
|
if int(np.round(next_start_frame)) >= total_frames:
|
|
if self.single_video_only:
|
|
if seg_idx < self.num_subsequent_segments - 1:
|
|
return self._sample_failure(
|
|
f"{folder_name}: single-video sample is too short"
|
|
)
|
|
else:
|
|
current_video_idx += 1
|
|
current_start_frame = 0
|
|
chunks_from_current_video = 0
|
|
else:
|
|
current_start_frame = int(np.round(next_start_frame))
|
|
|
|
num_filled_segments = len(all_segments)
|
|
if num_filled_segments == 0:
|
|
num_valid_latent_frames = 0
|
|
else:
|
|
num_valid_latent_frames = (
|
|
self.first_chunk_latent_frames
|
|
+ (num_filled_segments - 1) * self.num_frame_per_block
|
|
)
|
|
|
|
# Reject if below minimum latent frame threshold
|
|
if self.allow_padding and self.min_latent_frames > 0:
|
|
if num_valid_latent_frames < self.min_latent_frames:
|
|
return self._sample_failure(
|
|
f"{folder_name}: only {num_valid_latent_frames} valid latent frames, "
|
|
f"below min_latent_frames={self.min_latent_frames}"
|
|
)
|
|
|
|
if num_filled_segments < self.total_segments:
|
|
last_prompt = prompts_list[-1] if prompts_list else ""
|
|
prompts_list.extend([last_prompt] * (self.total_segments - num_filled_segments))
|
|
|
|
# Concatenate all segments: (total_frames, 3, height, width)
|
|
concatenated_video = torch.cat(all_segments, dim=0)
|
|
|
|
# Ensure we have exactly total_frames
|
|
if concatenated_video.shape[0] != self.total_frames:
|
|
# Pad or trim if necessary
|
|
if concatenated_video.shape[0] < self.total_frames:
|
|
# Pad with last frame
|
|
last_frame = concatenated_video[-1:].repeat(self.total_frames - concatenated_video.shape[0], 1, 1, 1)
|
|
concatenated_video = torch.cat([concatenated_video, last_frame], dim=0)
|
|
else:
|
|
# Trim
|
|
concatenated_video = concatenated_video[:self.total_frames]
|
|
|
|
result = {
|
|
'frames': concatenated_video.permute(1, 0, 2, 3),
|
|
'prompts': prompts_list,
|
|
'idx': folder_idx
|
|
}
|
|
if self.return_image:
|
|
result['image'] = concatenated_video[0]
|
|
if self.allow_padding:
|
|
result['num_valid_latent_frames'] = num_valid_latent_frames
|
|
return True, result
|
|
except Exception as exc:
|
|
return self._sample_failure(f"{folder_name}: {type(exc).__name__}: {exc}")
|
|
|
|
def __getitem__(self, idx):
|
|
start_time = time.monotonic()
|
|
last_warning_time = start_time
|
|
attempts = 0
|
|
last_failure = None
|
|
|
|
def maybe_warn(folder_idx, failure_reason):
|
|
nonlocal last_warning_time
|
|
if self.sample_warning_seconds <= 0:
|
|
return
|
|
elapsed = time.monotonic() - start_time
|
|
if elapsed < self.sample_warning_seconds:
|
|
return
|
|
if (
|
|
self.sample_warning_interval_seconds > 0
|
|
and time.monotonic() - last_warning_time < self.sample_warning_interval_seconds
|
|
):
|
|
return
|
|
last_warning_time = time.monotonic()
|
|
folder_name = self.folders[folder_idx % len(self.folders)].name
|
|
warnings.warn(
|
|
"[MultiVideoConcatDataset] Still searching for a valid sample "
|
|
f"after {elapsed:.1f}s and {attempts} folder attempts "
|
|
f"(requested_idx={idx}, current_folder={folder_name}, "
|
|
f"last_failure={failure_reason}). This usually means the dataset "
|
|
f"does not contain enough video duration for total_frames={self.total_frames} "
|
|
f"at target_fps={self.target_fps}. Consider reducing the training "
|
|
"window, enabling allow_padding, lowering min_latent_frames, or "
|
|
"pre-filtering invalid folders.",
|
|
RuntimeWarning,
|
|
stacklevel=2,
|
|
)
|
|
|
|
# First try the requested folder
|
|
attempts += 1
|
|
success, result = self._try_get_item_from_folder(idx, deterministic=self.deterministic)
|
|
if success:
|
|
return result
|
|
last_failure = result
|
|
maybe_warn(idx, last_failure)
|
|
|
|
# If the requested folder fails, try other folders.
|
|
# If any valid folder exists in the dataset, try to return data from it:
|
|
# scan every other folder starting from idx + 1 and return the first
|
|
# successful sample.
|
|
num_folders = len(self.folders)
|
|
for i in range(1, num_folders):
|
|
alt_idx = (idx + i) % num_folders
|
|
attempts += 1
|
|
success, result = self._try_get_item_from_folder(
|
|
alt_idx,
|
|
deterministic=self.deterministic,
|
|
)
|
|
if success:
|
|
return result
|
|
last_failure = result
|
|
maybe_warn(alt_idx, last_failure)
|
|
|
|
# If all attempts fail, raise an error
|
|
elapsed = time.monotonic() - start_time
|
|
if self.sample_warning_seconds > 0 and elapsed >= self.sample_warning_seconds:
|
|
warnings.warn(
|
|
"[MultiVideoConcatDataset] No valid sample was found after "
|
|
f"{elapsed:.1f}s and {attempts} folder attempts "
|
|
f"(requested_idx={idx}, last_failure={last_failure}).",
|
|
RuntimeWarning,
|
|
stacklevel=2,
|
|
)
|
|
raise ValueError(
|
|
f"Failed to sample valid data from folder {idx} and nearby folders. "
|
|
f"This may indicate insufficient valid videos in the dataset. "
|
|
f"Tried {attempts} folders in {elapsed:.1f}s. Last failure: {last_failure}"
|
|
)
|
|
|
|
|
|
def cycle(dl):
|
|
while True:
|
|
for data in dl:
|
|
yield data
|
|
|
|
def multi_video_collate_fn(batch):
|
|
# batch is a length-B list of dictionaries returned by __getitem__.
|
|
frames = torch.stack([b["frames"] for b in batch], dim=0) # (B, T, C, H, W)
|
|
|
|
# Keep prompts as one list per sample:
|
|
# [[p0_seg0, p0_seg1, ...], [p1_seg0, ...], ...].
|
|
prompts_list = [b["prompts"] for b in batch] # List[List[str]]
|
|
|
|
idx = torch.tensor([b["idx"] for b in batch], dtype=torch.long)
|
|
|
|
result = {
|
|
"frames": frames,
|
|
"prompts": prompts_list,
|
|
"idx": idx,
|
|
}
|
|
|
|
if "image" in batch[0]:
|
|
result["image"] = torch.stack([b["image"] for b in batch], dim=0)
|
|
|
|
if "num_valid_latent_frames" in batch[0]:
|
|
result["num_valid_latent_frames"] = torch.tensor(
|
|
[b["num_valid_latent_frames"] for b in batch], dtype=torch.long
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def eval_collate_fn(batch):
|
|
"""Collate for text-only datasets (no frames)."""
|
|
prompts_list = [b["prompts"] for b in batch]
|
|
idx = torch.tensor([b["idx"] for b in batch], dtype=torch.long)
|
|
result = {
|
|
"prompts": prompts_list,
|
|
"idx": idx,
|
|
}
|
|
if "shot_durations" in batch[0]:
|
|
result["shot_durations"] = [b["shot_durations"] for b in batch]
|
|
return result
|