from concurrent.futures import ThreadPoolExecutor from pathlib import Path import torch from einops import rearrange from torch import Tensor from torch.utils.data import Dataset from ltx_trainer import logger # Constants for precomputed data directories PRECOMPUTED_DIR_NAME = ".precomputed" class DummyDataset(Dataset): """Produce random latents and prompt embeddings. For minimal demonstration and benchmarking purposes""" def __init__( self, width: int = 1024, height: int = 1024, num_frames: int = 25, fps: int = 24, dataset_length: int = 200, latent_dim: int = 128, latent_spatial_compression_ratio: int = 32, latent_temporal_compression_ratio: int = 8, prompt_embed_dim: int = 4096, prompt_sequence_length: int = 256, ) -> None: if width % 32 != 0: raise ValueError(f"Width must be divisible by 32, got {width=}") if height % 32 != 0: raise ValueError(f"Height must be divisible by 32, got {height=}") if num_frames % 8 != 1: raise ValueError(f"Number of frames must have a remainder of 1 when divided by 8, got {num_frames=}") self.width = width self.height = height self.num_frames = num_frames self.fps = fps self.dataset_length = dataset_length self.latent_dim = latent_dim self.num_latent_frames = (num_frames - 1) // latent_temporal_compression_ratio + 1 self.latent_height = height // latent_spatial_compression_ratio self.latent_width = width // latent_spatial_compression_ratio self.latent_sequence_length = self.num_latent_frames * self.latent_height * self.latent_width self.prompt_embed_dim = prompt_embed_dim self.prompt_sequence_length = prompt_sequence_length def __len__(self) -> int: return self.dataset_length def __getitem__(self, idx: int) -> dict[str, dict[str, Tensor]]: return { "latent_conditions": { "latents": torch.randn( self.latent_dim, self.num_latent_frames, self.latent_height, self.latent_width, ), "num_frames": self.num_latent_frames, "height": self.latent_height, "width": self.latent_width, "fps": self.fps, }, "text_conditions": { "video_prompt_embeds": torch.randn( self.prompt_sequence_length, self.prompt_embed_dim, ), "audio_prompt_embeds": torch.randn( self.prompt_sequence_length, self.prompt_embed_dim, ), "prompt_attention_mask": torch.ones( self.prompt_sequence_length, dtype=torch.bool, ), }, } class PrecomputedDataset(Dataset): def __init__(self, data_root: str, data_sources: dict[str, str] | list[str] | None = None) -> None: """ Generic dataset for loading precomputed data from multiple sources. Args: data_root: Root directory containing preprocessed data data_sources: Either: - Dict mapping directory names to output keys - List of directory names (keys will equal values) - None (defaults to ["latents", "conditions"]) Example: # Standard mode (list) dataset = PrecomputedDataset("data/", ["latents", "conditions"]) # Standard mode (dict) dataset = PrecomputedDataset("data/", {"latents": "latent_conditions", "conditions": "text_conditions"}) # IC-LoRA mode dataset = PrecomputedDataset("data/", ["latents", "conditions", "reference_latents"]) Note: Latents are always returned in non-patchified format [C, F, H, W]. Legacy patchified format [seq_len, C] is automatically converted. """ super().__init__() self.data_root = self._setup_data_root(data_root) self.data_sources = self._normalize_data_sources(data_sources) self.source_paths = self._setup_source_paths() self.sample_files = self._discover_samples() self._validate_setup() @staticmethod def _setup_data_root(data_root: str) -> Path: """Setup and validate the data root directory.""" data_root = Path(data_root).expanduser().resolve() if not data_root.exists(): raise FileNotFoundError(f"Data root directory does not exist: {data_root}") # If the given path is the dataset root, use the precomputed subdirectory if (data_root / PRECOMPUTED_DIR_NAME).exists(): data_root = data_root / PRECOMPUTED_DIR_NAME return data_root @staticmethod def _normalize_data_sources(data_sources: dict[str, str] | list[str] | None) -> dict[str, str]: """Normalize data_sources input to a consistent dict format.""" if data_sources is None: # Default sources return {"latents": "latent_conditions", "conditions": "text_conditions"} elif isinstance(data_sources, list): # Convert list to dict where keys equal values return {source: source for source in data_sources} elif isinstance(data_sources, dict): return data_sources.copy() else: raise TypeError(f"data_sources must be dict, list, or None, got {type(data_sources)}") def _setup_source_paths(self) -> dict[str, Path]: """Map data source names to their actual directory paths.""" source_paths = {} for dir_name in self.data_sources: source_path = self.data_root / dir_name source_paths[dir_name] = source_path # Check that all sources exist. if not source_path.exists(): raise FileNotFoundError(f"Required {dir_name} directory does not exist: {source_path}") return source_paths def _discover_samples(self) -> dict[str, list[Path]]: """Discover all valid sample files across all data sources. Uses a fast two-pass approach: first globs all sources in parallel to build full-path sets in memory, then checks expected paths via set membership. This avoids O(N * num_sources) stat calls on networked filesystems while correctly handling path remapping (e.g. latent_X.pt -> condition_X.pt). """ if not self.data_sources: raise ValueError("No data sources configured") data_key = "latents" if "latents" in self.data_sources else next(iter(self.data_sources.keys())) data_path = self.source_paths[data_key] # Pass 1: Glob all sources in parallel, build full-path sets def _glob_source(dir_name: str) -> tuple[list[Path], set[str]]: source_path = self.source_paths[dir_name] paths = list(source_path.glob("**/*.pt")) path_set = {str(p) for p in paths} return paths, path_set with ThreadPoolExecutor(max_workers=len(self.data_sources)) as executor: glob_results = dict( zip( self.data_sources.keys(), executor.map(_glob_source, self.data_sources.keys()), strict=True, ) ) # Get primary source files (cached from glob, no second scan) data_files, _ = glob_results[data_key] if not data_files: raise ValueError(f"No data files found in {data_path}") data_files.sort() # Log source sizes for dir_name, (paths, _) in glob_results.items(): logger.debug(f"Source {dir_name}: {len(paths)} files") # Build path sets for non-primary sources other_path_sets = { dir_name: path_set for dir_name, (_, path_set) in glob_results.items() if dir_name != data_key } # Pass 2: For each primary file, check if expected paths exist in other sources' sets sample_files: dict[str, list[Path]] = {output_key: [] for output_key in self.data_sources.values()} valid_count = 0 for data_file in data_files: rel_path = data_file.relative_to(data_path) # Check all other sources via set lookup (O(1) per source, no stat calls) all_exist = True for dir_name, path_set in other_path_sets.items(): expected = self._get_expected_file_path(dir_name, data_file, rel_path) if str(expected) not in path_set: logger.debug(f"Skipping {data_file.name}: no matching {dir_name} file at {expected}") all_exist = False break if all_exist: self._fill_sample_data_files(data_file, rel_path, sample_files) valid_count += 1 skipped = len(data_files) - valid_count if skipped > 0: logger.info(f"Fast index: {valid_count} valid samples from {len(data_files)} total ({skipped} skipped)") else: logger.debug(f"Fast index: {valid_count} valid samples from {len(data_files)} total") return sample_files def _get_expected_file_path(self, dir_name: str, data_file: Path, rel_path: Path) -> Path: """Get the expected file path for a given data source.""" source_path = self.source_paths[dir_name] # For conditions, handle legacy naming where latent_X.pt maps to condition_X.pt if dir_name == "conditions" and data_file.name.startswith("latent_"): return source_path / f"condition_{data_file.stem[7:]}.pt" return source_path / rel_path def _fill_sample_data_files(self, data_file: Path, rel_path: Path, sample_files: dict[str, list[Path]]) -> None: """Add a valid sample to the sample_files tracking.""" for dir_name, output_key in self.data_sources.items(): expected_path = self._get_expected_file_path(dir_name, data_file, rel_path) sample_files[output_key].append(expected_path.relative_to(self.source_paths[dir_name])) def _validate_setup(self) -> None: """Validate that the dataset setup is correct.""" sample_counts = {key: len(files) for key, files in self.sample_files.items()} if not sample_counts or all(count == 0 for count in sample_counts.values()): raise ValueError( f"No valid samples found in {self.data_root} - all configured data sources " f"({list(self.data_sources)}) must have matching files (per-source counts: {sample_counts})" ) # Verify all output keys have the same number of samples if len(set(sample_counts.values())) > 1: raise ValueError(f"Mismatched sample counts across sources: {sample_counts}") def __len__(self) -> int: # Use the first output key as reference count first_key = next(iter(self.sample_files.keys())) return len(self.sample_files[first_key]) def __getitem__(self, index: int) -> dict[str, torch.Tensor]: result = {} for dir_name, output_key in self.data_sources.items(): source_path = self.source_paths[dir_name] file_rel_path = self.sample_files[output_key][index] file_path = source_path / file_rel_path try: data = torch.load(file_path, map_location="cpu", weights_only=True) # Normalize video latent format if this is a latent source if "latent" in dir_name.lower(): data = self._normalize_video_latents(data) result[output_key] = data except Exception as e: raise RuntimeError(f"Failed to load {output_key} from {file_path}: {e}") from e # Add index for debugging result["idx"] = index return result @staticmethod def _normalize_video_latents(data: dict) -> dict: """ Normalize video latents to non-patchified format [C, F, H, W]. Used for keeping backward compatibility with legacy datasets. """ latents = data["latents"] # Check if latents are in legacy patchified format [seq_len, C] if latents.dim() == 2: # Legacy format: [seq_len, C] where seq_len = F * H * W num_frames = data["num_frames"] height = data["height"] width = data["width"] # Unpatchify: [seq_len, C] -> [C, F, H, W] latents = rearrange( latents, "(f h w) c -> c f h w", f=num_frames, h=height, w=width, ) # Update the data dict with unpatchified latents data = data.copy() data["latents"] = latents return data