| """HoHo26k dataset. |
| |
| Two surface paths share the per-tar WebDataset → `build_scene_input` pipeline: |
| |
| - `HoHoStreamingDataset` (IterableDataset): reads the raw HF tars, applies |
| `build_scene_input` + `build_gt_verts` per sample. Used by the |
| preprocessing CLI to write per-tar shards, and by inline eval when no |
| cache exists. |
| |
| - `HoHoCachedDataset` (map-style Dataset): loads ALL semantic cache shards into memory |
| at construction (small enough — 26k samples × ~tens of KB each fits in a |
| few GB). Used for training and cached eval. Combine with |
| `DistributedSampler` for DDP-safe, shuffle-every-epoch loading. Optional |
| train-time augmentation transforms live in `data.transforms`. |
| |
| Cache filename (one shard per input tar): |
| `{cache_dir}/{split}.sem_v7.shard{NNN}-of-{TOTAL}.pkl` |
| |
| The shard index `NNN` matches the input tar's `dataset-NNNN.tar` index; TOTAL |
| is the total number of tars for the split. |
| """ |
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import pickle |
| import re |
| from pathlib import Path |
| from typing import Iterable, List, Optional |
|
|
| import numpy as np |
| import torch |
| from torch.utils.data import Dataset, IterableDataset |
|
|
| CACHE_VERSION = "sem_v10" |
| SUPPORTED_CACHE_VERSIONS = {"sem_v7", "sem_v10", "sem_v11"} |
| |
| |
| CHIMNEY_FILTER_VERSIONS = {"sem_v11"} |
| SHARD_FILENAME_TEMPLATE = "{split}.{ver}.shard{shard:03d}-of-{total:03d}.pkl" |
| SHARD_GLOB_TEMPLATE = "{split}.{ver}.shard*.pkl" |
| _SHARD_RE = re.compile(r"\.shard(\d{3})-of-(\d{3})\.pkl$") |
|
|
| from hoho2025.example_solutions import read_colmap_rec |
| from data.preprocess import build_scene_input, build_gt_verts, filter_small_components |
|
|
| |
| |
| |
| V11_MAX_VERTS_PER_CC = 4 |
| V11_MAX_AREA_M2 = 2.0 |
|
|
| DATASET_ID = "usm3d/hoho22k_2026_trainval" |
|
|
|
|
| |
| |
| |
|
|
| def _parse_npy(raw_bytes): |
| import io |
| return np.load(io.BytesIO(raw_bytes)) |
|
|
|
|
| def decode_wds_sample(sample: dict) -> dict: |
| """Decode one raw WebDataset sample dict into the canonical HoHo record.""" |
| order_id_str = str(sample.get("__key__", "")).replace("order_", "") |
|
|
| K_dict, R_dict, t_dict, pose_only_dict = {}, {}, {}, {} |
| ade_dict, depth_dict, gestalt_dict = {}, {}, {} |
| wf_vertices_list, wf_edges_list, wf_classifications_list = [], [], [] |
| colmap_zip_bytes = None |
|
|
| for k, v in sample.items(): |
| k_lower = k.lower() |
| if k_lower == "__key__": |
| continue |
| elif k_lower == "colmap.zip": |
| colmap_zip_bytes = v |
| elif k_lower.startswith("ade_") and k_lower.endswith(".png"): |
| ade_dict[k_lower[4:-4]] = {"bytes": v} |
| elif k_lower.startswith("depth_") and k_lower.endswith(".png"): |
| depth_dict[k_lower[6:-4]] = {"bytes": v} |
| elif k_lower.startswith("gestalt_") and k_lower.endswith(".png"): |
| gestalt_dict[k_lower[8:-4]] = {"bytes": v} |
| elif k_lower.endswith(".npy"): |
| if k_lower.startswith("k_"): |
| K_dict[k_lower[2:-4]] = _parse_npy(v) |
| elif k_lower.startswith("r_"): |
| R_dict[k_lower[2:-4]] = _parse_npy(v) |
| elif k_lower.startswith("t_"): |
| t_dict[k_lower[2:-4]] = _parse_npy(v) |
| elif k_lower.startswith("pose_only_in_colmap_"): |
| pose_only_dict[k_lower[20:-4]] = bool(_parse_npy(v)) |
| elif k_lower == "wf_vertices.npy": |
| wf_vertices_list = _parse_npy(v).tolist() |
| elif k_lower == "wf_edges.npy": |
| wf_edges_list = _parse_npy(v).tolist() |
| elif k_lower == "wf_classifications.npy": |
| arr = _parse_npy(v) |
| if arr.shape == (): |
| arr = arr.reshape((1,)) |
| wf_classifications_list = arr.tolist() |
|
|
| img_ids_list = sorted(K_dict.keys()) |
| K_list, R_list, t_list, pose_only_list = [], [], [], [] |
| ade_list, depth_list, gestalt_list = [], [], [] |
|
|
| for img_id in img_ids_list: |
| K_arr = K_dict.get(img_id, np.zeros((3, 3), dtype=np.float32)) |
| R_arr = R_dict.get(img_id, np.zeros((3, 3), dtype=np.float32)) |
| t_arr = np.array(t_dict.get(img_id, np.zeros((3, 1), dtype=np.float32)), dtype=np.float32) |
| if t_arr.ndim == 1: |
| t_arr = t_arr.reshape(3, 1) |
|
|
| K_list.append(K_arr.tolist()) |
| R_list.append(R_arr.tolist()) |
| t_list.append(t_arr.tolist()) |
| pose_only_list.append(pose_only_dict.get(img_id, False)) |
|
|
| ade_list.append(ade_dict.get(img_id, None)) |
| depth_list.append(depth_dict.get(img_id, None)) |
| gestalt_list.append(gestalt_dict.get(img_id, None)) |
|
|
| return { |
| "order_id": order_id_str, |
| "image_ids": img_ids_list, |
| "ade": ade_list, |
| "depth": depth_list, |
| "gestalt": gestalt_list, |
| "K": K_list, |
| "R": R_list, |
| "t": t_list, |
| "pose_only_in_colmap": pose_only_list, |
| "wf_vertices": wf_vertices_list, |
| "wf_edges": wf_edges_list, |
| "wf_classifications": wf_classifications_list, |
| "colmap": colmap_zip_bytes, |
| } |
|
|
|
|
| |
| |
| |
|
|
| _TAR_RE = re.compile(r"data/([a-z]+)/dataset-(\d+)\.tar") |
|
|
|
|
| def _split_aliases(split: str) -> str: |
| return {"val": "validation", "valid": "validation", "dev": "validation"}.get(split, split) |
|
|
|
|
| def discover_tars(cache_dir: Path, dataset_id: str, split: str) -> List[tuple[int, str]]: |
| """Return [(tar_index, tar_local_path), …] sorted by tar_index.""" |
| split = _split_aliases(split) |
| downloads_dir = Path(cache_dir) / "downloads" |
| out: list[tuple[int, str]] = [] |
| for jf in downloads_dir.glob("*.json"): |
| try: |
| data = json.loads(jf.read_text()) |
| except json.JSONDecodeError: |
| continue |
| url = data.get("url", "") |
| if not url.startswith(f"hf://datasets/{dataset_id}/data/{split}/"): |
| continue |
| m = _TAR_RE.search(url) |
| if m is None: |
| continue |
| tar_idx = int(m.group(2)) |
| tar_path = str(jf)[:-5] |
| out.append((tar_idx, tar_path)) |
| out.sort(key=lambda x: x[0]) |
| return out |
|
|
|
|
| def _normalize_cache_version(cache_version: str) -> str: |
| ver = str(cache_version).strip().lower() |
| if ver in {"v7", "7"}: |
| ver = "sem_v7" |
| elif ver in {"v10", "10"}: |
| ver = "sem_v10" |
| elif ver in {"v11", "11"}: |
| ver = "sem_v11" |
| if ver not in SUPPORTED_CACHE_VERSIONS: |
| raise ValueError( |
| f"Unsupported cache_version={cache_version!r}; " |
| f"expected one of {sorted(SUPPORTED_CACHE_VERSIONS)}" |
| ) |
| return ver |
|
|
|
|
| def shard_filename(split: str, shard_idx: int, total: int, cache_version: str = CACHE_VERSION) -> str: |
| cache_version = _normalize_cache_version(cache_version) |
| return SHARD_FILENAME_TEMPLATE.format( |
| split=_split_aliases(split), ver=cache_version, shard=shard_idx, total=total |
| ) |
|
|
|
|
| def discover_shards(cache_dir: Path, split: str, cache_version: str = CACHE_VERSION) -> List[Path]: |
| """Return existing semantic cache shard files for a split, sorted by shard index.""" |
| split = _split_aliases(split) |
| cache_version = _normalize_cache_version(cache_version) |
| paths = sorted(Path(cache_dir).glob(SHARD_GLOB_TEMPLATE.format(split=split, ver=cache_version))) |
| paths.sort(key=lambda p: int(_SHARD_RE.search(p.name).group(1))) |
| return paths |
|
|
|
|
| def _seed_from_order_id(order_id: str) -> int: |
| """Stable 64-bit seed derived from order_id (independent of stream position).""" |
| h = hashlib.sha1(order_id.encode("utf-8")).digest() |
| return int.from_bytes(h[:8], "big", signed=False) |
|
|
|
|
| |
| |
| |
|
|
| def process_sample( |
| sample: dict, |
| n_pts: int, |
| k_verts: int, |
| use_depth: bool, |
| voxel_size_m: float = 0.0, |
| cache_version: str = CACHE_VERSION, |
| ) -> Optional[dict]: |
| """Decode + featurise one raw WebDataset sample. Returns None on failure. |
| |
| For ``cache_version`` in :data:`CHIMNEY_FILTER_VERSIONS` (e.g. ``sem_v11``), |
| small connected components of the raw GT wireframe are dropped before |
| building ``verts_gt``. The original (unfiltered) ``wf_vertices_raw`` and |
| ``wf_edges_raw`` are still saved alongside boolean keep masks so eval and |
| visualisation can show the deleted parts. |
| """ |
| order_id = sample.get("order_id", "") |
| rng = np.random.default_rng(_seed_from_order_id(order_id)) |
| cache_version = _normalize_cache_version(cache_version) |
| try: |
| colmap_rec = read_colmap_rec(sample["colmap"]) |
| sample_dec = {**sample, "colmap": colmap_rec} |
|
|
| scene = build_scene_input( |
| sample_dec, |
| n_pts=n_pts, |
| use_depth=use_depth, |
| voxel_size_m=voxel_size_m, |
| rng=rng, |
| ) |
|
|
| wf_v_raw = np.asarray(sample["wf_vertices"], dtype=np.float32) |
| wf_e_raw = np.asarray(sample["wf_edges"], dtype=np.int64) |
| if wf_v_raw.ndim == 1: |
| wf_v_raw = wf_v_raw.reshape(0, 3) |
| if wf_e_raw.ndim == 1: |
| wf_e_raw = wf_e_raw.reshape(0, 2) |
|
|
| if cache_version in CHIMNEY_FILTER_VERSIONS: |
| keep_v, keep_e = filter_small_components( |
| wf_v_raw, wf_e_raw, |
| max_verts_per_cc=V11_MAX_VERTS_PER_CC, |
| max_area_m2=V11_MAX_AREA_M2, |
| ) |
| else: |
| keep_v = np.ones(len(wf_v_raw), dtype=bool) |
| keep_e = np.ones(len(wf_e_raw), dtype=bool) |
|
|
| |
| |
| kept_verts = wf_v_raw[keep_v] |
| sample_for_gt = {**sample_dec, "wf_vertices": kept_verts.tolist()} |
| gt = build_gt_verts( |
| sample_for_gt, |
| scene["bbox_center"], |
| scene["bbox_scale"], |
| k_verts, |
| ) |
|
|
| record = { |
| "scene_xyz": torch.from_numpy(scene["scene_xyz"]), |
| "scene_type_ids": torch.from_numpy(scene["scene_type_ids"]), |
| "scene_gestalt_ids": torch.from_numpy(scene["scene_gestalt_ids"]), |
| "scene_gestalt_id2": torch.from_numpy(scene["scene_gestalt_id2"]), |
| "scene_gestalt_w1": torch.from_numpy(scene["scene_gestalt_w1"]), |
| "scene_ade_ids": torch.from_numpy(scene["scene_ade_ids"]), |
| "scene_geom_conf": torch.from_numpy(scene["scene_geom_conf"]), |
| "scene_sem_conf": torch.from_numpy(scene["scene_sem_conf"]), |
| "scene_rgb": torch.from_numpy(scene["scene_rgb"]), |
| "verts_gt": torch.from_numpy(gt), |
| "bbox_center": torch.from_numpy(scene["bbox_center"]), |
| "bbox_scale": torch.tensor(scene["bbox_scale"], dtype=torch.float32), |
| "bbox_R": torch.from_numpy(scene["bbox_R"]), |
| "order_id": order_id, |
| "n_gt_verts": int(keep_v.sum()), |
| "wf_vertices_raw": wf_v_raw, |
| "wf_edges_raw": wf_e_raw, |
| } |
| if cache_version in CHIMNEY_FILTER_VERSIONS: |
| record["wf_vertex_kept_mask"] = keep_v |
| record["wf_edge_kept_mask"] = keep_e |
| return record |
| except Exception as e: |
| print(f"[dataset] skip sample {order_id}: {e}", flush=True) |
| return None |
|
|
|
|
| |
| |
| |
|
|
| class HoHoStreamingDataset(IterableDataset): |
| """Iterable view over raw HF tars; yields featurised samples. |
| |
| Used by the preprocessing CLI to build cache shards, and by HoHoCachedDataset |
| transparently as a fallback if no cache exists. |
| """ |
|
|
| def __init__( |
| self, |
| dataset_id: str = DATASET_ID, |
| split: str = "train", |
| cache_dir: Optional[str] = None, |
| n_pts: int = 8192, |
| k_verts: int = 64, |
| use_depth: bool = True, |
| voxel_size_m: float = 0.0, |
| tar_paths: Optional[Iterable[str]] = None, |
| n_samples: Optional[int] = None, |
| cache_version: str = CACHE_VERSION, |
| ): |
| self.dataset_id = dataset_id |
| self.split = _split_aliases(split) |
| self.cache_dir = Path(cache_dir) if cache_dir else None |
| self.n_pts = n_pts |
| self.k_verts = k_verts |
| self.use_depth = use_depth |
| self.voxel_size_m = float(voxel_size_m) |
| self.n_samples = n_samples |
| self.cache_version = _normalize_cache_version(cache_version) |
|
|
| if tar_paths is not None: |
| self.tar_paths = list(tar_paths) |
| else: |
| if self.cache_dir is None: |
| raise ValueError("HoHoStreamingDataset needs cache_dir or explicit tar_paths.") |
| tars = discover_tars(self.cache_dir, dataset_id, self.split) |
| self.tar_paths = [path for _, path in tars] |
| if not self.tar_paths: |
| raise ValueError( |
| f"No cached tar files found for split {self.split} in " |
| f"{self.cache_dir}/downloads. Download the dataset first." |
| ) |
|
|
| def __iter__(self): |
| import webdataset as wds |
|
|
| ds = wds.WebDataset(self.tar_paths, shardshuffle=False, empty_check=False) |
| ds = ds.map(decode_wds_sample) |
|
|
| emitted = 0 |
| for raw in ds: |
| if self.n_samples is not None and emitted >= self.n_samples: |
| break |
| item = process_sample( |
| raw, self.n_pts, self.k_verts, self.use_depth, |
| voxel_size_m=self.voxel_size_m, |
| cache_version=self.cache_version, |
| ) |
| if item is not None: |
| yield item |
| emitted += 1 |
|
|
|
|
| |
| |
| |
|
|
| class HoHoCachedDataset(Dataset): |
| """Map-style dataset that loads all semantic cache shards into memory. |
| |
| Use with `torch.utils.data.distributed.DistributedSampler(shuffle=True)` |
| to get unique-per-rank, reshuffled-every-epoch ordering across DDP ranks. |
| |
| Args: |
| cache_dir: directory containing `{split}.{cache_version}.shard*.pkl` files. |
| split: split name (`train`, `validation`). |
| n_pts: model-input point count (= transformer query count). |
| augment: if True, apply training augmentations on each `__getitem__` |
| (random subsample, flip, yaw rotation, jitter). If False, take a |
| deterministic prefix slice (eval/inference). |
| flip / yaw / jitter_sigma: enable/strength of individual augmentations. |
| n_samples: optional cap for debug runs. |
| """ |
|
|
| def __init__( |
| self, |
| cache_dir: str, |
| split: str = "train", |
| n_pts: int = 8192, |
| augment: bool = False, |
| flip: bool = True, |
| yaw: bool = True, |
| jitter_sigma: float = 0.01, |
| n_samples: Optional[int] = None, |
| cache_version: str = CACHE_VERSION, |
| ): |
| super().__init__() |
| self.cache_dir = Path(cache_dir) |
| self.split = _split_aliases(split) |
| self.cache_version = _normalize_cache_version(cache_version) |
| self.n_pts = int(n_pts) |
| self.augment = bool(augment) |
| self.flip = bool(flip) |
| self.yaw = bool(yaw) |
| self.jitter_sigma = float(jitter_sigma) |
| self.n_samples = n_samples |
|
|
| shard_paths = discover_shards(self.cache_dir, self.split, self.cache_version) |
| if not shard_paths: |
| raise FileNotFoundError( |
| f"No {self.cache_version} cache shards found at {self.cache_dir} for split {self.split}.\n" |
| f"Run preprocessing first, e.g.:\n" |
| f" python -m data.dataset --split {self.split} --cache_dir {self.cache_dir} " |
| f"--cache_version {self.cache_version} --shard_index 0 --total_shards 1" |
| ) |
|
|
| self.samples: list[dict] = [] |
| for p in shard_paths: |
| with open(p, "rb") as f: |
| self.samples.extend(pickle.load(f)) |
| if self.n_samples is not None: |
| self.samples = self.samples[: self.n_samples] |
|
|
| def __len__(self) -> int: |
| return len(self.samples) |
|
|
| def __getitem__(self, idx: int) -> dict: |
| |
| |
| from data.transforms import apply_train_transforms, apply_eval_transforms |
|
|
| src = self.samples[idx] |
| out = {k: v for k, v in src.items()} |
| |
| if "verts_gt" in out and torch.is_tensor(out["verts_gt"]): |
| out["verts_gt"] = out["verts_gt"].clone() |
|
|
| if self.augment: |
| return apply_train_transforms( |
| out, |
| n_pts=self.n_pts, |
| yaw=self.yaw, |
| flip=self.flip, |
| jitter_sigma=self.jitter_sigma, |
| ) |
| return apply_eval_transforms(out, n_pts=self.n_pts) |
|
|
|
|
| |
| |
| |
|
|
| def build_shards( |
| cache_dir: str, |
| split: str, |
| shard_index: int, |
| total_shards: int, |
| dataset_id: str = DATASET_ID, |
| n_pts: int = 8192, |
| k_verts: int = 64, |
| use_depth: bool = True, |
| voxel_size_m: float = 0.0, |
| cache_version: str = CACHE_VERSION, |
| ) -> List[Path]: |
| """Build the shards assigned to this array task. |
| |
| Tar selection is `tars[shard_index::total_shards]` over the sorted tar |
| list. Each tar produces exactly one output shard file whose `NNN` matches |
| the input `dataset-NNNN.tar` index. |
| """ |
| cache_dir_p = Path(cache_dir) |
| cache_dir_p.mkdir(parents=True, exist_ok=True) |
| cache_version = _normalize_cache_version(cache_version) |
|
|
| tars = discover_tars(cache_dir_p, dataset_id, split) |
| if not tars: |
| raise ValueError( |
| f"No cached tar files for split {split} in {cache_dir_p}/downloads." |
| ) |
|
|
| |
| |
| |
| total_tars = max(t for t, _ in tars) + 1 |
| my_tars = tars[shard_index::total_shards] |
| print( |
| f"[shard] split={split} task {shard_index}/{total_shards}: " |
| f"{len(my_tars)} tars (of {len(tars)} total)", |
| flush=True, |
| ) |
|
|
| written: list[Path] = [] |
| for tar_idx, tar_path in my_tars: |
| out_path = cache_dir_p / shard_filename(split, tar_idx, total_tars, cache_version) |
| if out_path.exists(): |
| print(f"[shard] skip (exists) {out_path.name}", flush=True) |
| written.append(out_path) |
| continue |
|
|
| ds = HoHoStreamingDataset( |
| dataset_id=dataset_id, |
| split=split, |
| cache_dir=str(cache_dir_p), |
| n_pts=n_pts, |
| k_verts=k_verts, |
| use_depth=use_depth, |
| voxel_size_m=voxel_size_m, |
| tar_paths=[tar_path], |
| cache_version=cache_version, |
| ) |
| rows = list(ds) |
| with open(out_path, "wb") as f: |
| pickle.dump(rows, f) |
| count_path = out_path.with_suffix(out_path.suffix + ".count") |
| count_path.write_text(str(len(rows))) |
| written.append(out_path) |
| print(f"[shard] wrote {out_path.name} ({len(rows)} samples)", flush=True) |
|
|
| return written |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| import argparse |
| from config_utils import load_config, resolve_arg, DEFAULT_CONFIG_PATH |
|
|
| p = argparse.ArgumentParser(description="Build HoHo semantic cache shards (one per input tar)") |
| p.add_argument("--config", default=DEFAULT_CONFIG_PATH) |
| p.add_argument("--split", default="train") |
| p.add_argument("--cache_dir", default=None) |
| p.add_argument("--dataset_id", default=None) |
| p.add_argument("--n_pts_cache", type=int, default=None, |
| help="Cache point count (default 8192 = 4096 COLMAP/camera + 4096 depth)") |
| p.add_argument("--k_verts", type=int, default=None) |
| p.add_argument("--use_depth", action="store_true", default=None) |
| p.add_argument("--no_depth", dest="use_depth", action="store_false") |
| p.add_argument("--shard_index", type=int, default=0, |
| help="This task's index in the SLURM array (0-based)") |
| p.add_argument("--total_shards", type=int, default=1, |
| help="Total array tasks; tars are split round-robin across these") |
| p.add_argument("--voxel_size_m", type=float, default=None, |
| help="Metric voxel size (m) for joint downsampling; 0 disables") |
| p.add_argument("--cache_version", default=CACHE_VERSION, choices=sorted(SUPPORTED_CACHE_VERSIONS), |
| help="Semantic cache filename version to write") |
| args = p.parse_args() |
|
|
| config, _ = load_config(args.config) |
| dataset_id = resolve_arg(args.dataset_id, config, "dataset.id", DATASET_ID) |
| cache_dir = resolve_arg(args.cache_dir, config, "paths.cache_dir", "cache/") |
| n_pts_cache = resolve_arg(args.n_pts_cache, config, "model.n_pts_cache", 8192) |
| k_verts = resolve_arg(args.k_verts, config, "model.k_verts", 64) |
| use_depth = resolve_arg(args.use_depth, config, "train.use_depth", True) |
| voxel_size_m = resolve_arg(args.voxel_size_m, config, "model.voxel_size_m", 0.0) |
|
|
| build_shards( |
| cache_dir=cache_dir, |
| split=args.split, |
| shard_index=args.shard_index, |
| total_shards=args.total_shards, |
| dataset_id=dataset_id, |
| n_pts=n_pts_cache, |
| k_verts=k_verts, |
| use_depth=use_depth, |
| voxel_size_m=voxel_size_m, |
| cache_version=args.cache_version, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def collate_fn(batch: list) -> dict: |
| """Stack fixed-shape tensor fields; keep ragged / string fields as lists.""" |
| if batch and "scene_rgb" not in batch[0]: |
| for b in batch: |
| b["scene_rgb"] = torch.zeros((*b["scene_xyz"].shape[:-1], 3), dtype=torch.uint8) |
| tensor_keys = ( |
| "scene_xyz", "scene_type_ids", |
| "scene_gestalt_ids", "scene_gestalt_id2", "scene_gestalt_w1", |
| "scene_ade_ids", "scene_geom_conf", "scene_sem_conf", |
| "scene_rgb", |
| "verts_gt", "bbox_center", "bbox_scale", "bbox_R", |
| ) |
| out = {k: torch.stack([b[k] for b in batch]) for k in tensor_keys} |
| out["order_id"] = [b["order_id"] for b in batch] |
| out["n_gt_verts"] = [b["n_gt_verts"] for b in batch] |
| out["wf_vertices_raw"] = [b.get("wf_vertices_raw") for b in batch] |
| out["wf_edges_raw"] = [b.get("wf_edges_raw") for b in batch] |
| out["wf_vertex_kept_mask"] = [b.get("wf_vertex_kept_mask") for b in batch] |
| out["wf_edge_kept_mask"] = [b.get("wf_edge_kept_mask") for b in batch] |
| return out |
|
|