import dataclasses import gc import os import random from collections.abc import Callable from typing import Any import numpy as np import pyarrow as pa import torch from datasets import Dataset, Video, load_dataset from fastvideo.configs.configs import (DatasetType, PreprocessConfig, VideoLoaderType) from fastvideo.dataset.dataloader.parquet_io import (ParquetDatasetWriter, records_to_table) from fastvideo.distributed.parallel_state import get_world_rank, get_world_size from fastvideo.logger import init_logger from fastvideo.pipelines.pipeline_batch_info import PreprocessBatch logger = init_logger(__name__) class PreprocessingDataValidator: def __init__(self, max_height: int = 1024, max_width: int = 1024, num_frames: int = 16, train_fps: int = 24, speed_factor: float = 1.0, video_length_tolerance_range: float = 5.0, drop_short_ratio: float = 0.0): self.max_height = max_height self.max_width = max_width self.num_frames = num_frames self.train_fps = train_fps self.speed_factor = speed_factor self.video_length_tolerance_range = video_length_tolerance_range self.drop_short_ratio = drop_short_ratio self.validators: dict[str, Callable[[dict[str, Any]], bool]] = {} self.filter_counts: dict[str, int] = {} self.num_items_before_filtering = 0 self.num_items_after_filtering = 0 self.register_validators() def register_validators(self) -> None: self.add_validator("data_type_validator", self._validate_data_type) self.add_validator("resolution_validator", self._validate_resolution) self.add_validator("frame_sampling_validator", self._validate_frame_sampling) def add_validator(self, name: str, validator: Callable[[dict[str, Any]], bool]) -> None: self.validators[name] = validator self.filter_counts[name] = 0 def __call__(self, batch: dict[str, Any]) -> bool: """ Validate whether the preprocessing data batch is valid. """ self.num_items_before_filtering += 1 for name, validator in self.validators.items(): if not validator(batch): self.filter_counts[name] += 1 return False self.num_items_after_filtering += 1 return True def _validate_data_type(self, batch: dict[str, Any]) -> bool: """Validate basic validity of data items""" return not (batch["caption"] is None or batch["caption"] == "" or batch["fps"] is None or batch["fps"] <= 0 or batch["num_frames"] is None or batch["num_frames"] <= 0) def _validate_resolution(self, batch: dict[str, Any]) -> bool: """Validate resolution constraints""" if batch["resolution"] is not None: height = batch["resolution"].get("height", None) width = batch["resolution"].get("width", None) return not (height is None or width is None) def _validate_frame_sampling(self, batch: dict[str, Any]) -> bool: """Validate frame sampling constraints""" if (batch["num_frames"] / batch["fps"] > self.video_length_tolerance_range * (self.num_frames / self.train_fps * self.speed_factor)): return False frame_interval = batch["fps"] / self.train_fps start_frame_idx = 0 frame_indices = np.arange(start_frame_idx, batch["num_frames"], frame_interval).astype(int) return not (len(frame_indices) < self.num_frames and random.random() < self.drop_short_ratio) def log_validation_stats(self): info = "" for name, count in self.filter_counts.items(): info += f"failed in {name}: {count}, " info += f"number of items before filtering: {self.num_items_before_filtering}, " info += f"number of items after filtering: {self.num_items_after_filtering}" logger.info(info) class VideoForwardBatchBuilder: def __init__(self, seed: int): self.seed = seed def __call__(self, batch: list) -> PreprocessBatch: forward_batch = PreprocessBatch( video_loader=[item["video"] for item in batch], video_file_name=[item["name"] for item in batch], height=[item["resolution"]["height"] for item in batch], width=[item["resolution"]["width"] for item in batch], fps=[item["fps"] for item in batch], num_frames=[item["num_frames"] for item in batch], prompt=[item["caption"] for item in batch], prompt_attention_mask=[], data_type="video", generator=torch.Generator("cpu").manual_seed(self.seed), ) return forward_batch class ParquetDatasetSaver: """Component for saving and writing Parquet datasets using shared parquet_io.""" def __init__(self, flush_frequency: int, samples_per_file: int, schema: pa.Schema, record_creator: Callable[..., list[dict[str, Any]]]): self.flush_frequency = flush_frequency self.samples_per_file = samples_per_file self.schema = schema self.create_records_from_batch = record_creator self.num_processed_samples: int = 0 self._writer: ParquetDatasetWriter | None = None def save_and_write_parquet_batch(self, batch: PreprocessBatch, output_dir: str, extra_features: dict[str, Any] | None = None) -> None: """ Save and write Parquet dataset batch Args: batch: PreprocessBatch containing video and metadata information output_dir: Output directory extra_features: Extra features Returns: Number of processed samples """ assert isinstance(batch.latents, torch.Tensor) assert isinstance(batch.prompt_embeds, list) assert isinstance(batch.prompt_attention_mask, list) # Process non-padded embeddings (if needed) if batch.prompt_attention_mask is not None: batch.prompt_embeds = self._process_non_padded_embeddings(batch.prompt_embeds[0], batch.prompt_attention_mask[0]) else: raise ValueError("prompt_attention_mask is None") # Prepare batch data for Parquet dataset batch_data: list[dict[str, Any]] = [] for key in dataclasses.fields(batch): value = getattr(batch, key.name) if isinstance(value, list): for idx in range(len(value)): if isinstance(value[idx], torch.Tensor): value[idx] = value[idx].cpu().numpy() elif isinstance(value, torch.Tensor): value = value.cpu().numpy() setattr(batch, key.name, value) # Create record for Parquet dataset records = self.create_records_from_batch(batch) batch_data.extend(records) if batch_data: self.num_processed_samples += len(batch_data) table = records_to_table(batch_data, self.schema) if self._writer is None: os.makedirs(output_dir, exist_ok=True) self._writer = ParquetDatasetWriter(out_dir=output_dir, samples_per_file=self.samples_per_file) self._writer.append_table(table) logger.debug("Collected batch with %s samples", len(table)) # If flush is needed if self.num_processed_samples >= self.flush_frequency: self.flush_tables() def _process_non_padded_embeddings(self, prompt_embeds: torch.Tensor, prompt_attention_mask: torch.Tensor) -> list[torch.Tensor]: """Process non-padded embeddings""" assert isinstance(prompt_embeds, torch.Tensor) assert isinstance(prompt_attention_mask, torch.Tensor) assert prompt_embeds.shape[0] == prompt_attention_mask.shape[0] # Get sequence lengths from attention masks (number of 1s) seq_lens = prompt_attention_mask.sum(dim=1) non_padded_embeds = [] # Process each item in the batch for i in range(prompt_embeds.size(0)): seq_len = seq_lens[i].item() # Slice the embeddings and masks to keep only non-padding parts non_padded_embeds.append(prompt_embeds[i, :seq_len]) return non_padded_embeds def flush_tables(self, write_remainder: bool = False): """Flush buffered records to disk. Args: output_dir: Directory where parquet files are written. Kept for API symmetry (writer already configured with this path). write_remainder: If True, also write any leftover rows smaller than ``samples_per_file`` as a final small file. Useful for the last flush. """ if self._writer is None: return _ = self._writer.flush(write_remainder=write_remainder) # Reset processed sample count modulo samples_per_file remainder = self.num_processed_samples % self.samples_per_file self.num_processed_samples = 0 if write_remainder else remainder def clean_up(self) -> None: """Clean up all tables""" self.flush_tables(write_remainder=True) self._writer = None self.num_processed_samples = 0 gc.collect() def __del__(self): self.clean_up() def build_dataset(preprocess_config: PreprocessConfig, split: str, validator: Callable[[dict[str, Any]], bool]) -> Dataset: if preprocess_config.dataset_type == DatasetType.HF: dataset = load_dataset(preprocess_config.dataset_path, split=split) dataset = dataset.filter(validator) dataset = dataset.shard(num_shards=get_world_size(), index=get_world_rank()) elif preprocess_config.dataset_type == DatasetType.MERGED: metadata_json_path = os.path.join(preprocess_config.dataset_path, "videos2caption.json") video_folder = os.path.join(preprocess_config.dataset_path, "videos") dataset = load_dataset("json", data_files=metadata_json_path, split=split) column_names = dataset.column_names # rename columns to match the schema if "cap" in column_names: dataset = dataset.rename_column("cap", "caption") if "path" in column_names: dataset = dataset.rename_column("path", "name") dataset = dataset.filter(validator) dataset = dataset.shard(num_shards=get_world_size(), index=get_world_rank()) # add video column def add_video_column(item: dict[str, Any]) -> dict[str, Any]: item["video"] = os.path.join(video_folder, item["name"]) return item dataset = dataset.map(add_video_column) if preprocess_config.video_loader_type == VideoLoaderType.TORCHCODEC: dataset = dataset.cast_column("video", Video()) else: raise ValueError(f"Invalid dataset type: {preprocess_config.dataset_type}") return dataset