# wm_dataset.py import os import glob import json import bisect import random as _random from collections import OrderedDict from typing import Dict, Mapping, Optional, Sequence, Union import torch from torch.utils.data import Dataset class WMDataset(Dataset): """ RGB-native world model dataset. data_dir and frames_dir may each be a str or list[str]. Lists are treated as paired roots; a length-1 root is broadcast to match a longer one. """ def __init__( self, data_dir: Union[str, Sequence[str]], frames_dir: Union[str, Sequence[str]], seq_len: int, img_size: int = 128, action_dim: int = 16, lang_dim: int = 512, cache_mb: int = 2048, verbose: bool = True, tasks_json: str = "../tasks.json", tasks: Optional[list[str]] = None, strict_tasks: bool = True, ddp_partition: bool = False, iid_sampling: bool = False, samples_per_shard: int = 1, task_weights: Optional[Mapping[str, float]] = None, ): super().__init__() # normalize data_dir / frames_dir to lists, and pair them if isinstance(data_dir, (str, os.PathLike)): data_dirs = [str(data_dir)] else: data_dirs = [str(x) for x in data_dir] if isinstance(frames_dir, (str, os.PathLike)): frames_dirs = [str(frames_dir)] else: frames_dirs = [str(x) for x in frames_dir] if len(data_dirs) != len(frames_dirs): if len(data_dirs) == 1: data_dirs = data_dirs * len(frames_dirs) elif len(frames_dirs) == 1: frames_dirs = frames_dirs * len(data_dirs) else: raise ValueError(f"data_dir and frames_dir must have same length (or one must be length-1). " f"Got {len(data_dirs)} and {len(frames_dirs)}") self.data_dirs = data_dirs self.frames_dirs = frames_dirs self.sources = list(zip(self.data_dirs, self.frames_dirs)) self.T = int(seq_len) self.H = int(img_size) self.W = int(img_size) self.A = int(action_dim) self.lang_dim = int(lang_dim) self.cache_bytes = int(cache_mb) * 1024 * 1024 self.verbose = bool(verbose) self.tasks_filter = None if tasks is None else set(tasks) self.strict_tasks = bool(strict_tasks) # --- Task metadata (action_dim + text_embedding) --- self.task_meta: Optional[dict] = None if tasks_json and os.path.exists(tasks_json): try: with open(tasks_json, "r") as f: self.task_meta = json.load(f) except Exception as e: if self.verbose: print(f"[WMDataset] Warning: failed to load tasks_json={tasks_json}: {e}") self.task_meta = None elif tasks_json and self.verbose: print(f"[WMDataset] Warning: tasks_json not found at {tasks_json} (continuing with zeros lang_emb + default action masks).") self._zero_lang = torch.zeros(self.lang_dim, dtype=torch.float32) # LRU cache for shards: key=(task_idx, seg_idx, shard_idx) -> frames tensor self._cache = OrderedDict() self._cache_nbytes = 0 # Discover tasks from ALL data_dirs/*.pt (dedup, preserve first-seen order) found_tasks = [] seen = set() for dd in self.data_dirs: demo_paths = sorted(glob.glob(os.path.join(dd, "*.pt"))) for p in demo_paths: t = os.path.splitext(os.path.basename(p))[0] if t not in seen: seen.add(t) found_tasks.append(t) if self.tasks_filter is not None: requested = [t for t in tasks if t in self.tasks_filter] if tasks is not None else [] if len(requested) == 0: requested = [t for t in found_tasks if t in self.tasks_filter] tasks = requested if self.verbose: missing = [t for t in self.tasks_filter if t not in set(found_tasks)] print(f"[WMDataset] Task filter: keeping {len(tasks)}/{len(found_tasks)} tasks") if missing: msg = f"[WMDataset] WARNING: {len(missing)} requested tasks not found in data_dir(s) (e.g. {missing[:5]})" if self.strict_tasks: raise FileNotFoundError(msg) else: print(msg) else: tasks = found_tasks # DDP-aware strided task partitioning: each rank handles every N-th task # so shard pools are disjoint and per-worker caches stay effective. if ddp_partition: rank = int(os.environ.get("RANK", "0")) world_size = int(os.environ.get("WORLD_SIZE", "1")) if world_size > 1 and len(tasks) >= world_size: tasks = [tasks[i] for i in range(rank, len(tasks), world_size)] if self.verbose: print(f"[WMDataset] DDP partition: rank {rank}/{world_size} " f"→ {len(tasks)} tasks (strided)") self.iid_sampling = bool(iid_sampling) self.samples_per_shard = int(samples_per_shard) # Per-worker state for iid_sampling locality (initialized lazily) self._cur_shard_entry: Optional[int] = None self._cur_shard_draws: int = 0 # Stored per-task self.tasks = [] self.demo_paths = [] # stores list of per-task demo paths (joined) self.shard_lists = [] # per task -> list[segments], each segment is list[shard_paths] self.shard_cum = [] # per task -> list[segments], each segment is list[int] cumulative frame counts per shard self.seg_cum_frames = [] # per task -> cumulative frame counts across segments (for indexing) self.ep = [] self.act = [] self.rew = [] self.valid_starts = [] self._cum_counts = [] # Precomputed per-task metadata used by __getitem__ self._emb_ids = [] self._act_dims = [] self._act_mask_1d = [] self._lang_embs = [] total = 0 for task in tasks: # --- gather segments for this task from each (data_dir, frames_dir) source --- seg_eps = [] seg_acts = [] seg_rews = [] seg_shards = [] seg_shard_cums = [] seg_num_frames = [] seg_demo_paths = [] ep_offset = 0 # ensures episode ids are unique across segments for (dd, fd) in self.sources: dp = os.path.join(dd, f"{task}.pt") shard_glob = os.path.join(fd, task, "*shard*.pt") shards = sorted(glob.glob(shard_glob)) if not os.path.exists(dp) or len(shards) == 0: continue try: td = torch.load(dp, map_location="cpu", weights_only=False) except Exception as e: if self.verbose: print(f"[WMDataset] Skipping task={task} source=({dd},{fd}): torch.load demo failed: {e}") continue try: ep = td["episode"].to(torch.int64).cpu() act = td["action"].cpu() rew = td["reward"].cpu() except Exception as e: if self.verbose: print(f"[WMDataset] Skipping task={task} source=({dd},{fd}): missing keys in demo: {e}") continue if rew.ndim == 2 and rew.shape[-1] == 1: rew = rew.squeeze(-1) rew = rew.to(torch.float32) if act.ndim == 1: act = act.unsqueeze(-1) act = act.to(torch.float32) N = int(rew.shape[0]) if act.shape[0] != N or ep.shape[0] != N: if self.verbose: print(f"[WMDataset] Skipping task={task} source=({dd},{fd}): length mismatch ep/act/rew.") continue # Determine per-shard frame counts from index file or last shard index_path = os.path.join(fd, task, f"{task}_index.json") shard_sizes = None if os.path.exists(index_path): try: with open(index_path, "r") as f: index = json.load(f) shard_sizes = [int(index[os.path.basename(s)]) for s in shards] except Exception: shard_sizes = None if shard_sizes is None: # Fallback: load each shard to get its size shard_sizes = [] fallback_ok = True for s in shards: try: td_s = torch.load(s, map_location="cpu", weights_only=False) shard_sizes.append(int(td_s["frames"].shape[0])) except Exception as e: if self.verbose: print(f"[WMDataset] Skipping task={task} source=({dd},{fd}): failed to read shard {s}: {e}") fallback_ok = False break if not fallback_ok: continue N_frames_avail = sum(shard_sizes) N_eff = min(N, N_frames_avail) if N_eff < (self.T + 1): if self.verbose: print(f"[WMDataset] Skipping task={task} source=({dd},{fd}): not enough frames (N_eff={N_eff}) for T={self.T}.") continue ep = ep[:N_eff] act = act[:N_eff] rew = rew[:N_eff] # Make episode IDs unique across segments to prevent windows crossing boundaries if ep.numel() > 0: seg_max = int(ep.max().item()) else: seg_max = 0 ep = ep + ep_offset ep_offset += seg_max + 1 # Build cumulative shard frame counts for this segment, capped at N_eff seg_shard_cum = [] running = 0 for sz in shard_sizes: running += sz seg_shard_cum.append(min(running, N_eff)) if running >= N_eff: break seg_eps.append(ep) seg_acts.append(act) seg_rews.append(rew) seg_shards.append(shards) seg_num_frames.append(int(N_eff)) seg_demo_paths.append(dp) seg_shard_cums.append(seg_shard_cum) if len(seg_eps) == 0: if self.verbose: print(f"[WMDataset] Skipping task={task}: missing demo+shards across all sources.") continue # Concatenate segments for this task ep = torch.cat(seg_eps, dim=0) act = torch.cat(seg_acts, dim=0) rew = torch.cat(seg_rews, dim=0) N_eff = int(rew.shape[0]) # Valid starts: need obs indices i..i+T and transitions at indices i+1..i+T start_count = N_eff - self.T # i in [0, start_count-1] # episode consistency: ensure the whole window is within same episode ep_ok = (ep[:start_count] == ep[self.T:self.T + start_count]) # filter invalid transitions (nan action or nan reward) act_nan = torch.isnan(act).any(dim=-1) rew_nan = torch.isnan(rew) step_ok = ~(act_nan | rew_nan) # length N_eff # transitions live at indices 1..N_eff-1 step_ok2 = step_ok[1:] # length N_eff-1 # for each start i, need step_ok at indices (i+1 .. i+T) all true cs = torch.cumsum(step_ok2.to(torch.int32), dim=0) end = torch.arange(start_count) + (self.T - 1) prev = torch.arange(start_count) - 1 prev_cs = torch.zeros(start_count, dtype=cs.dtype) m = prev >= 0 prev_cs[m] = cs[prev[m]] window_sum = cs[end] - prev_cs window_ok = (window_sum == self.T) valid = ep_ok & window_ok valid_idx = valid.nonzero(as_tuple=False).flatten() if valid_idx.numel() == 0: if self.verbose: print(f"[WMDataset] Skipping task={task}: no valid windows after filtering.") continue # --- per-task action_dim + mask from tasks.json --- act_dim = self.A if self.task_meta is not None and task in self.task_meta: md = self.task_meta[task] if "action_dim" in md: try: act_dim = int(md["action_dim"]) except Exception: act_dim = self.A act_dim = max(0, min(act_dim, self.A)) act_mask_1d = torch.zeros(self.A, dtype=torch.float32) if act_dim > 0: act_mask_1d[:act_dim] = 1.0 # --- per-task language embedding from tasks.json --- lang = self._zero_lang if self.lang_dim > 0 and self.task_meta is not None and task in self.task_meta and "text_embedding" in self.task_meta[task]: te = self.task_meta[task]["text_embedding"] l = torch.tensor(te, dtype=torch.float32) if l.numel() != self.lang_dim: raise RuntimeError(f"text_embedding dim mismatch for task {task}: {tuple(l.shape)} vs {self.lang_dim}") lang = l # Store self.tasks.append(task) self.demo_paths.append(seg_demo_paths) # per-task segments of shard lists + cumulative frame counts self.shard_lists.append(seg_shards) self.shard_cum.append(seg_shard_cums) cum = [] s = 0 for nf in seg_num_frames: s += int(nf) cum.append(s) self.seg_cum_frames.append(cum) self.ep.append(ep) self.act.append(act) self.rew.append(rew) self.valid_starts.append(valid_idx) # precomputed metadata per task index task_idx = len(self.tasks) - 1 self._emb_ids.append(torch.tensor(task_idx, dtype=torch.long)) self._act_dims.append(act_dim) self._act_mask_1d.append(act_mask_1d) self._lang_embs.append(lang) total += int(valid_idx.numel()) self._cum_counts.append(total) if self.verbose: print(f"[WMDataset] task={task} segments={len(seg_shards)} N_eff={N_eff} " f"valid={valid_idx.numel()} act_dim={act_dim} lang={'yes' if lang is not self._zero_lang else 'no'}") self.num_tasks = len(self.tasks) assert self.num_tasks > 0, "No tasks found with both demo .pt and frame shards." # Resolve optional per-task sampling weights (iid_sampling only). # task_weights is a dict {task_name: weight} specifying relative # task draw probabilities. Within a task we keep shard weights # proportional to per-shard valid_starts for cache locality. # Tasks not present in the dict default to weight 0 (excluded). # If None, fall back to the legacy behavior where shard weight = # its valid_starts count, which makes P(task) ∝ total valid_starts. if task_weights is not None: tw_list: list[float] = [] missing: list[str] = [] for t in self.tasks: if t in task_weights: w = float(task_weights[t]) if w < 0: raise ValueError(f"task_weights[{t!r}] is negative: {w}") tw_list.append(w) else: missing.append(t) tw_list.append(0.0) if missing and self.verbose: print(f"[WMDataset] Warning: {len(missing)} loaded tasks have no " f"task_weights entry and will be excluded from sampling " f"(e.g. {missing[:5]})") self.task_weights: Optional[list[float]] = tw_list else: self.task_weights = None # Precompute per-shard valid starts for iid_sampling. Each entry maps # a single on-disk shard file to the valid_starts whose first frame # lives in that shard, so drawing samples_per_shard consecutive samples # from one entry keeps a single file hot in the LRU cache. self._shard_entries: list[dict] = [] self._shard_weights: list[float] = [] if self.iid_sampling: # Collect raw shard entries grouped by task first so we can # renormalize shard weights to honor task_weights without # changing the intra-task distribution. per_task_entries: list[list[tuple[dict, float]]] = [[] for _ in range(self.num_tasks)] for ti in range(self.num_tasks): seg_cum = self.seg_cum_frames[ti] vs = self.valid_starts[ti] for si in range(len(self.shard_cum[ti])): seg_offset = 0 if si == 0 else seg_cum[si - 1] for shi in range(len(self.shard_cum[ti][si])): local_start = 0 if shi == 0 else self.shard_cum[ti][si][shi - 1] local_end = self.shard_cum[ti][si][shi] g_start = seg_offset + local_start g_end = seg_offset + local_end mask = (vs >= g_start) & (vs < g_end) shard_vs = vs[mask] if shard_vs.numel() > 0: entry = {"task_idx": ti, "valid_starts": shard_vs} per_task_entries[ti].append((entry, float(shard_vs.numel()))) if self.task_weights is None: # Legacy path: shard weight = its valid_starts count. for ti in range(self.num_tasks): for entry, w in per_task_entries[ti]: self._shard_entries.append(entry) self._shard_weights.append(w) else: # Rescale so that the total shard weight for task ti equals # task_weights[ti] (relative scale is what matters). Tasks # with weight 0 contribute no shard entries and are # effectively excluded. for ti in range(self.num_tasks): entries = per_task_entries[ti] if not entries: continue w_task = float(self.task_weights[ti]) if w_task <= 0: continue total_vs = sum(w for _, w in entries) if total_vs <= 0: continue scale = w_task / total_vs for entry, w in entries: self._shard_entries.append(entry) self._shard_weights.append(w * scale) if self.verbose: msg = f"[WMDataset] iid_sampling: {len(self._shard_entries)} shard entries " \ f"(samples_per_shard={self.samples_per_shard})" if self.task_weights is not None: nz = sum(1 for w in self.task_weights if w > 0) msg += f" | task_weights active ({nz}/{self.num_tasks} tasks with w>0)" print(msg) if self.verbose: print(f"[WMDataset] Total valid sequences: {self._cum_counts[-1]} across {self.num_tasks} tasks.") def __len__(self): return self._cum_counts[-1] def _lookup(self, idx: int): lo, hi = 0, len(self._cum_counts) - 1 while lo < hi: mid = (lo + hi) // 2 if idx < self._cum_counts[mid]: hi = mid else: lo = mid + 1 task_idx = lo prev = 0 if task_idx == 0 else self._cum_counts[task_idx - 1] local = idx - prev start = int(self.valid_starts[task_idx][local].item()) return task_idx, start def _cache_get(self, key): if key in self._cache: v = self._cache.pop(key) self._cache[key] = v return v return None def _cache_put(self, key, tensor): nbytes = tensor.nbytes while self._cache_nbytes + nbytes > self.cache_bytes and len(self._cache) > 0: _, v = self._cache.popitem(last=False) self._cache_nbytes -= v.nbytes self._cache[key] = tensor self._cache_nbytes += nbytes def _load_shard_frames(self, task_idx: int, seg_idx: int, shard_idx: int) -> torch.Tensor: key = (task_idx, seg_idx, shard_idx) cached = self._cache_get(key) if cached is not None: return cached path = self.shard_lists[task_idx][seg_idx][shard_idx] td = torch.load(path, map_location="cpu", weights_only=False) frames = td["frames"] # Normalize to (N,3,H,W) if frames.ndim == 4 and frames.shape[-1] == 3 and frames.shape[1] != 3: frames = frames.permute(0, 3, 1, 2).contiguous() # Ensure uint8 storage (robust to float in [0,1] or [0,255]) if frames.dtype != torch.uint8: frames_f = frames.to(torch.float32) mx = float(frames_f.max().item()) if frames_f.numel() > 0 else 0.0 if mx > 1.5: frames = frames_f.clamp(0, 255).to(torch.uint8) else: frames = (frames_f.clamp(0, 1) * 255.0).to(torch.uint8) if frames.shape[-2] != self.H or frames.shape[-1] != self.W: raise RuntimeError(f"Shard frame size {tuple(frames.shape[-2:])} != {(self.H, self.W)} in {path}") self._cache_put(key, frames) return frames # map global frame idx -> segment -> shard within segment def _get_frames(self, task_idx: int, start: int, length: int) -> torch.Tensor: out = [] idx = int(start) remaining = int(length) seg_cum = self.seg_cum_frames[task_idx] while remaining > 0: seg_idx = bisect.bisect_right(seg_cum, idx) prev_cum = 0 if seg_idx == 0 else seg_cum[seg_idx - 1] seg_end = seg_cum[seg_idx] local_idx = idx - prev_cum # map local frame index within segment to shard using cumulative counts shard_cum = self.shard_cum[task_idx][seg_idx] shard_idx = bisect.bisect_right(shard_cum, local_idx) shard_start = 0 if shard_idx == 0 else shard_cum[shard_idx - 1] off = local_idx - shard_start frames = self._load_shard_frames(task_idx, seg_idx, shard_idx) take = min(remaining, frames.shape[0] - off) # don't read past the segment's N_eff (important if segment was truncated) seg_remaining = seg_end - idx take = min(take, seg_remaining) if take <= 0: raise RuntimeError( f"Frame indexing error task={self.tasks[task_idx]} idx={idx} seg_idx={seg_idx} " f"local_idx={local_idx} shard_idx={shard_idx} off={off} shard_len={frames.shape[0]}" ) out.append(frames[off:off + take]) idx += take remaining -= take return torch.cat(out, dim=0) # (length,3,H,W) uint8 def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]: if self.iid_sampling: # Locality-friendly sampling: draw samples_per_shard consecutive # samples from the same on-disk shard before switching, keeping # the file hot in the LRU cache and reducing I/O. if (self._cur_shard_entry is None or self._cur_shard_draws >= self.samples_per_shard): self._cur_shard_entry = _random.choices( range(len(self._shard_entries)), weights=self._shard_weights, k=1, )[0] self._cur_shard_draws = 0 entry = self._shard_entries[self._cur_shard_entry] self._cur_shard_draws += 1 task_idx = entry["task_idx"] vs = entry["valid_starts"] start = int(vs[_random.randrange(vs.numel())].item()) else: task_idx, start = self._lookup(int(idx)) obs = self._get_frames(task_idx, start, self.T + 1) # (T+1,3,H,W) uint8 act = self.act[task_idx][start + 1 : start + 1 + self.T] # (T,16) float32 (padded) rew = self.rew[task_idx][start + 1 : start + 1 + self.T] # (T,) float32 Ad = int(self._act_dims[task_idx]) act_padded = torch.zeros(self.T, self.A, dtype=torch.float32) if Ad > 0: act_padded[:, :Ad] = torch.nan_to_num(act[:, :Ad], nan=0.0) act_mask = self._act_mask_1d[task_idx][None, :].expand(self.T, self.A).contiguous() return { "obs": obs, "act": act_padded, "act_mask": act_mask, "rew": rew, "lang_emb": self._lang_embs[task_idx], "emb_id": self._emb_ids[task_idx], } def collate_batch(batch): out = {} for k in batch[0].keys(): out[k] = torch.stack([b[k] for b in batch], dim=0) return out