rpx_benchmark.dataset_hub.split_manifests

Per-task, per-split manifest writer for the dataset hub.

The toolkit's rpx_benchmark.hub.download_split() expects a JSON file at manifests/<recipe_key>/<split>.json on the published HF repo. The hub's existing manifest.py only emits the all-frames Parquet (manifest/frames_v1.parquet); this module fills the gap so a freshly published HF release works for every task the loader supports.

Per-task entry contracts

Each TaskType the loader can parse expects a specific set of keys on each manifest entry. Using the wrong key (cam_pose instead of pose, masks instead of mask, fisheye instead of fisheye_left/fisheye_right) is a silent 404 at load time.

This module owns the per-task contract:

================== =================== ============================================ Recipe key TaskType.value Required entry keys + GT keys ================== =================== ============================================ monocular_depth monocular_depth rgb, depth segmentation object_segmentation rgb, mask rgbd_segmentation object_segmentation rgb, depth, mask stereo_depth monocular_depth fisheye_left, fisheye_right, depth relative_pose relative_camera_pose rgb, rgb_b, pose_a, pose_b [paired] rgbd_relative_pose relative_camera_pose rgb, depth, rgb_b, depth_b, pose_a, pose_b [paired] object_tracking object_tracking rgb, mask [per-frame] vqa visual_grounding rgb, text [needs labels] ================== =================== ============================================

Modality file layout (where the actual files land after extraction)

On-disk:

========== ============================================ ============ Modality Subdir under extracted/scenes/<s>/<p>/ Suffix ========== ============================================ ============ rgb rgb/ .png depth depth/ .png (16-bit mm) fisheye fisheye/{left,right}/ .png masks sam2/masks/ .png cam_pose cam_pose/ .npz ========== ============================================ ============

Splits — scene-wise, mode-aggregated

Splits are scene-wise (every phase of a scene shares one tier) so STR / cross-phase comparisons aren't biased by tier reassignment.

Usage

::

from rpx_benchmark.dataset_hub.split_manifests import write_split_manifests
written = write_split_manifests("/path/to/staging")
# → {(recipe_key, split): manifest_path, ...}
  1"""Per-task, per-split manifest writer for the dataset hub.
  2
  3The toolkit's :func:`rpx_benchmark.hub.download_split` expects a JSON file at
  4``manifests/<recipe_key>/<split>.json`` on the published HF repo. The hub's
  5existing ``manifest.py`` only emits the all-frames Parquet
  6(``manifest/frames_v1.parquet``); this module fills the gap so a freshly
  7published HF release works for every task the loader supports.
  8
  9Per-task entry contracts
 10------------------------
 11Each TaskType the loader can parse expects a specific set of keys on each
 12manifest entry. Using the wrong key (``cam_pose`` instead of ``pose``,
 13``masks`` instead of ``mask``, ``fisheye`` instead of
 14``fisheye_left``/``fisheye_right``) is a silent 404 at load time.
 15
 16This module owns the per-task contract:
 17
 18==================  ===================  ============================================
 19Recipe key          TaskType.value       Required entry keys + GT keys
 20==================  ===================  ============================================
 21monocular_depth     monocular_depth      rgb, depth
 22segmentation        object_segmentation  rgb, mask
 23rgbd_segmentation   object_segmentation  rgb, depth, mask
 24stereo_depth        monocular_depth      fisheye_left, fisheye_right, depth
 25relative_pose       relative_camera_pose rgb, rgb_b, pose_a, pose_b           [paired]
 26rgbd_relative_pose  relative_camera_pose rgb, depth, rgb_b, depth_b,
 27                                          pose_a, pose_b                       [paired]
 28object_tracking     object_tracking      rgb, mask                            [per-frame]
 29vqa                 visual_grounding     rgb, text                            [needs labels]
 30==================  ===================  ============================================
 31
 32Modality file layout (where the actual files land after extraction)
 33-------------------------------------------------------------------
 34On-disk:
 35
 36==========  ============================================  ============
 37Modality    Subdir under ``extracted/scenes/<s>/<p>/``    Suffix
 38==========  ============================================  ============
 39rgb         ``rgb/``                                       ``.png``
 40depth       ``depth/``                                     ``.png`` (16-bit mm)
 41fisheye     ``fisheye/{left,right}/``                      ``.png``
 42masks       ``sam2/masks/``                                ``.png``
 43cam_pose    ``cam_pose/``                                  ``.npz``
 44==========  ============================================  ============
 45
 46Splits — scene-wise, mode-aggregated
 47------------------------------------
 48Splits are scene-wise (every phase of a scene shares one tier) so STR /
 49cross-phase comparisons aren't biased by tier reassignment.
 50
 51Usage
 52-----
 53::
 54
 55    from rpx_benchmark.dataset_hub.split_manifests import write_split_manifests
 56    written = write_split_manifests("/path/to/staging")
 57    # → {(recipe_key, split): manifest_path, ...}
 58"""
 59
 60from __future__ import annotations
 61
 62import json
 63import logging
 64from pathlib import Path
 65from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
 66
 67from .packer import SCENE_ROOT_BY_TYPE, phase_segment
 68from .recipes import SceneType
 69
 70log = logging.getLogger(__name__)
 71
 72
 73# ---------------------------------------------------------------------- #
 74# Per-modality on-disk layout (after extraction)
 75# ---------------------------------------------------------------------- #
 76#
 77# (parquet column suffix, on-disk subdir, file extension)
 78#
 79# The parquet's ``has_<m>`` / ``shard_<m>`` columns use the column suffix.
 80# The on-disk subdir is what ``hub._extract_snapshot_tars`` materialises
 81# under ``extracted/scenes/<scene>/<phase>/``.  The file extension is
 82# what each frame ends in once extracted (rgb/depth → .png from the
 83# 16-bit mm, cam_pose → .npz containing the SE(3) matrix).
 84
 85_MODALITY_LAYOUT: Dict[str, Tuple[str, str, str]] = {
 86    # modality_name → (parquet_col_suffix, extracted_subdir, default_ext)
 87    #
 88    # ``default_ext`` is the v1-style extension. Real-world datasets are
 89    # described by ``current.json``'s ``modality_extensions`` block
 90    # (written by the manifest builder); when a per-modality entry is
 91    # present there, it overrides this default at write-split-manifests
 92    # time. Anything missing from ``current.json`` keeps the v1 default
 93    # for backward compatibility with already-uploaded datasets.
 94    "rgb": ("rgb", "rgb", ".png"),
 95    "depth": ("depth", "depth", ".png"),
 96    "fisheye": ("fisheye", "fisheye", ".png"),  # see note for left/right
 97    "fisheye_left": ("fisheye", "fisheye/left", ".png"),
 98    "fisheye_right": ("fisheye", "fisheye/right", ".png"),
 99    "masks": ("masks", "sam2/masks", ".png"),
100    "cam_pose": ("cam_pose", "cam_pose", ".npz"),
101}
102
103
104# Active layout for the duration of one ``write_split_manifests`` call.
105# Module-level rather than a thread-local because this whole subsystem
106# is invoked single-threadedly from the CLI. Reset to ``None`` on the
107# way out so a subsequent call without overrides falls back to defaults.
108_active_layout: Optional[Dict[str, Tuple[str, str, str]]] = None
109
110
111def _current_layout() -> Dict[str, Tuple[str, str, str]]:
112    return _active_layout if _active_layout is not None else _MODALITY_LAYOUT
113
114
115def _layout_with_overrides(
116    extensions: Mapping[str, str],
117) -> Dict[str, Tuple[str, str, str]]:
118    """Return a copy of ``_MODALITY_LAYOUT`` with per-modality extensions
119    swapped in from ``extensions``. Unknown modality names in
120    ``extensions`` are ignored (forward compatibility with any new
121    modality the manifest writer might add later)."""
122    out: Dict[str, Tuple[str, str, str]] = {}
123    for modality, (col, subdir, default_ext) in _MODALITY_LAYOUT.items():
124        ext = extensions.get(modality, default_ext)
125        out[modality] = (col, subdir, ext)
126    return out
127
128
129def _modality_path(
130    scene: str, phase: int, modality: str, frame_stem: str,
131    scene_type: str = SceneType.MULTI_OBJECT.value,
132) -> str:
133    """Build the manifest's path string for one (scene, phase, modality, frame).
134
135    Path is relative to the manifest's ``root`` (set by ``download_split``
136    to the snapshot root). After ``_extract_snapshot_tars`` runs, the file
137    lives under ``extracted/<repo_root>/...`` where ``<repo_root>`` is
138    ``scenes`` for mos, ``objects`` for sos, ``ego`` for ego (see
139    packer.SCENE_ROOT_BY_TYPE — the single source of truth this must stay
140    in sync with). ``scene_type`` defaults to multi_object/"scenes" so
141    every pre-ego call site (all mos-only specs) is unaffected; only the
142    specs actually shared with ego (_SegmentationSpec, _ObjectTrackingSpec)
143    pass the row's real scene_type.
144
145    Reads the active layout (set by :func:`write_split_manifests`); falls
146    back to :data:`_MODALITY_LAYOUT` defaults outside that context.
147    """
148    layout = _current_layout()
149    if modality not in layout:
150        from ..exceptions import ConfigError
151
152        raise ConfigError(
153            f"unknown modality {modality!r}",
154            hint=f"Known modalities: {', '.join(sorted(layout))}",
155        )
156    _, subdir, ext = layout[modality]
157    st = SceneType(scene_type)
158    repo_root = SCENE_ROOT_BY_TYPE[st]
159    seg = phase_segment(st, phase)
160    return f"extracted/{repo_root}/{scene}/{seg}/{subdir}/{frame_stem}{ext}"
161
162
163def _has_col(modality: str) -> str:
164    """Return the parquet ``has_<col>`` column name for a modality."""
165    col_suffix, _, _ = _MODALITY_LAYOUT[modality]
166    return f"has_{col_suffix}"
167
168
169def _load_modality_extensions(staging_root: Path) -> Dict[str, str]:
170    """Read ``modality_extensions`` from ``<staging>/manifest/current.json``.
171
172    Returns an empty dict if the file is missing, malformed, or
173    pre-dates the ``modality_extensions`` field — that's the backward-
174    compatible signal to keep the v1 hardcoded extensions.
175    """
176    cur_path = staging_root / "manifest" / "current.json"
177    if not cur_path.is_file():
178        return {}
179    try:
180        payload = json.loads(cur_path.read_text(encoding="utf-8"))
181    except json.JSONDecodeError:
182        return {}
183    raw = payload.get("modality_extensions") or {}
184    if not isinstance(raw, dict):
185        return {}
186    return {str(k): str(v) for k, v in raw.items()}
187
188
189# ---------------------------------------------------------------------- #
190# Per-task spec — links recipe keys → TaskType value + entry-builder
191# ---------------------------------------------------------------------- #
192
193
194class _TaskSpec:
195    """How to build manifest entries for one recipe key.
196
197    Subclasses override ``build_entries`` to return a list of dicts
198    matching the loader's expected entry shape for that task.
199    """
200
201    #: The string the loader's ``TaskType()`` constructor needs to see.
202    task_type_value: str = ""
203
204    #: Required modalities, by parquet column suffix. Used to filter the
205    #: parquet rows (a row enters the manifest only if every required
206    #: modality is present).
207    required_modalities: Tuple[str, ...] = ()
208
209    #: scan/manifest's SceneType.value strings this spec applies to.
210    #: Defaults to multi_object (mos) — every spec below predates ego and
211    #: was written assuming only mos rows would ever reach it (nothing here
212    #: checked scene_type at all, so ego rows silently satisfied the same
213    #: rgb+masks requirement and leaked into e.g. "segmentation" instead of
214    #: a distinct "ego_segmentation" — see the Ego* specs below, which are
215    #: the fix, not new functionality).
216    scene_types: Tuple[str, ...] = ("multi_object",)
217
218    def build_entries(self, df) -> List[Dict[str, Any]]:
219        raise NotImplementedError
220
221
222class _SingleFrameDepthSpec(_TaskSpec):
223    """``monocular_depth``: rgb + depth GT, one entry per frame."""
224
225    task_type_value = "monocular_depth"
226    required_modalities = ("rgb", "depth")
227
228    def build_entries(self, df) -> List[Dict[str, Any]]:
229        return [_single_frame_entry(row, ["rgb", "depth"]) for _, row in df.iterrows()]
230
231
232class _VideoDepthSpec(_TaskSpec):
233    """``video_depth``: rgb + depth GT, one entry per ``(scene, phase)`` clip.
234
235    Emits ONE entry per (scene, phase) — not per-frame — with the
236    relevant frame filename lists pre-resolved so ``VideoDepthDataset`` can
237    iterate clips directly. The structure differs from every other
238    spec on this page because Video Depth's iteration unit is a clip, not a
239    frame; the cell-log key for the runner becomes
240    ``(model, task, scene, phase, frame_budget)`` and one row per clip
241    is the right granularity.
242    """
243
244    task_type_value = "video_depth"
245    required_modalities = ("rgb", "depth")
246
247    def build_entries(self, df) -> List[Dict[str, Any]]:
248        # Group the per-frame parquet rows by (scene_id, phase) and
249        # collapse each group into a single clip entry.
250        rows: List[Dict[str, Any]] = []
251        # GroupBy preserves first-row metadata for scene_type/difficulty
252        # which are constant within a (scene, phase) cell.
253        for (scene, phase), group in df.groupby(["scene_id", "phase"], sort=True):
254            stems = sorted(_frame_stem(r) for _, r in group.iterrows())
255            rgb_paths = [_modality_path(scene, int(phase), "rgb", s) for s in stems]
256            depth_paths = [_modality_path(scene, int(phase), "depth", s) for s in stems]
257            pose_paths = [_modality_path(scene, int(phase), "cam_pose", s) for s in stems]
258            # Use the first row for cell-level metadata (split, difficulty,
259            # scene_type). All rows in this group carry the same values.
260            first = next(iter(group.itertuples()))
261            rows.append(
262                {
263                    "id": f"{scene}__{int(phase)}",
264                    "scene_id": scene,
265                    "phase": int(phase),
266                    "scene_type": getattr(first, "scene_type", None),
267                    "difficulty": str(getattr(first, "split", "")) or None,
268                    "frame_filenames": rgb_paths,
269                    "depth_filenames": depth_paths,
270                    "pose_filenames": pose_paths,
271                    "metadata": {
272                        "scene_id": scene,
273                        "phase_idx": int(phase),
274                        "n_frames": len(stems),
275                    },
276                }
277            )
278        return rows
279
280
281class _SegmentationSpec(_TaskSpec):
282    """``segmentation``: rgb + mask (singular!) GT.
283
284    Loader's ``_load_ground_truth`` does ``self._load_mask(entry["mask"])``;
285    the entry key is ``mask`` (singular) but the modality directory is
286    ``sam2/masks``. We rename the entry key explicitly here.
287    """
288
289    task_type_value = "object_segmentation"
290    required_modalities = ("rgb", "masks")
291
292    def build_entries(self, df) -> List[Dict[str, Any]]:
293        rows: List[Dict[str, Any]] = []
294        for _, row in df.iterrows():
295            e = _base_entry(row)
296            scene = e["scene_id"]
297            phase = e["phase"]
298            stem = _frame_stem(row)
299            st = str(row["scene_type"])
300            e["rgb"] = _modality_path(scene, phase, "rgb", stem, scene_type=st)
301            e["mask"] = _modality_path(scene, phase, "masks", stem, scene_type=st)
302            rows.append(e)
303        return rows
304
305
306class _RGBDSegmentationSpec(_TaskSpec):
307    """``rgbd_segmentation``: rgb + depth + mask. Loader's task is still
308    OBJECT_SEGMENTATION (depth is an extra input the model sees, not GT)."""
309
310    task_type_value = "object_segmentation"
311    required_modalities = ("rgb", "depth", "masks")
312
313    def build_entries(self, df) -> List[Dict[str, Any]]:
314        rows: List[Dict[str, Any]] = []
315        for _, row in df.iterrows():
316            e = _base_entry(row)
317            scene = e["scene_id"]
318            phase = e["phase"]
319            stem = _frame_stem(row)
320            e["rgb"] = _modality_path(scene, phase, "rgb", stem)
321            e["depth"] = _modality_path(scene, phase, "depth", stem)
322            e["mask"] = _modality_path(scene, phase, "masks", stem)
323            rows.append(e)
324        return rows
325
326
327class _StereoDepthSpec(_TaskSpec):
328    """``stereo_depth``: fisheye_left + fisheye_right inputs, depth GT.
329
330    Loader's TaskType is MONOCULAR_DEPTH (depth GT is the same;
331    stereo-vs-mono is an input-side distinction).
332    """
333
334    task_type_value = "monocular_depth"
335    required_modalities = ("rgb", "depth", "fisheye")
336
337    def build_entries(self, df) -> List[Dict[str, Any]]:
338        rows: List[Dict[str, Any]] = []
339        for _, row in df.iterrows():
340            e = _base_entry(row)
341            scene = e["scene_id"]
342            phase = e["phase"]
343            stem = _frame_stem(row)
344            e["rgb"] = _modality_path(scene, phase, "rgb", stem)
345            e["depth"] = _modality_path(scene, phase, "depth", stem)
346            e["fisheye_left"] = _modality_path(scene, phase, "fisheye_left", stem)
347            e["fisheye_right"] = _modality_path(scene, phase, "fisheye_right", stem)
348            rows.append(e)
349        return rows
350
351
352class _RelativePoseSpec(_TaskSpec):
353    """``relative_pose``: paired-frame task.
354
355    Each entry pairs frame N (`rgb`, `pose_a`) with frame N+stride
356    (`rgb_b`, `pose_b`) within the same scene+phase. Stride defaults to 5
357    frames (~0.5 s at 10 Hz capture; matches existing
358    ``scripts/generate_keypoint_pairs.py`` convention).
359    """
360
361    task_type_value = "relative_camera_pose"
362    required_modalities = ("rgb", "cam_pose")
363    pair_stride: int = 5
364
365    def build_entries(self, df) -> List[Dict[str, Any]]:
366        rows: List[Dict[str, Any]] = []
367        # Group rows by (scene, phase) — pairs only within a phase.
368        group_iter = df.groupby(["scene_id", "phase"], sort=False)
369        for (scene, phase), grp in group_iter:
370            grp_sorted = grp.sort_values("frame_idx").reset_index(drop=True)
371            n = len(grp_sorted)
372            for i in range(n - self.pair_stride):
373                row_a = grp_sorted.iloc[i]
374                row_b = grp_sorted.iloc[i + self.pair_stride]
375                stem_a = _frame_stem(row_a)
376                stem_b = _frame_stem(row_b)
377                e: Dict[str, Any] = {
378                    "id": f"{scene}__{phase}__{stem_a}__{stem_b}",
379                    "scene_id": scene,
380                    "phase": int(phase),
381                    "frame_idx": int(row_a["frame_idx"]),
382                    "frame_idx_b": int(row_b["frame_idx"]),
383                    "difficulty": str(row_a["split"]),
384                    "metadata": {
385                        "scene_id": scene,
386                        "phase_idx": int(phase),
387                        "frame": stem_a,
388                        "frame_b": stem_b,
389                        "pair_stride": self.pair_stride,
390                    },
391                    "rgb": _modality_path(scene, int(phase), "rgb", stem_a),
392                    "rgb_b": _modality_path(scene, int(phase), "rgb", stem_b),
393                    "pose_a": _modality_path(scene, int(phase), "cam_pose", stem_a),
394                    "pose_b": _modality_path(scene, int(phase), "cam_pose", stem_b),
395                }
396                rows.append(e)
397        return rows
398
399
400class _RGBDRelativePoseSpec(_RelativePoseSpec):
401    """``rgbd_relative_pose``: paired with depth on both frames."""
402
403    required_modalities = ("rgb", "depth", "cam_pose")
404
405    def build_entries(self, df) -> List[Dict[str, Any]]:
406        rows = super().build_entries(df)
407        # Augment each paired entry with depth_a / depth_b.
408        for e in rows:
409            scene = e["scene_id"]
410            phase = e["phase"]
411            stem_a = e["metadata"]["frame"]
412            stem_b = e["metadata"]["frame_b"]
413            e["depth"] = _modality_path(scene, phase, "depth", stem_a)
414            e["depth_b"] = _modality_path(scene, phase, "depth", stem_b)
415        return rows
416
417
418class _ObjectTrackingSpec(_TaskSpec):
419    """``object_tracking``: RGB plus temporally consistent instance masks.
420
421    A mask value greater than zero is the persistent instance ID within one
422    ``(scene, phase)`` clip.  The production tracking runner groups these
423    frame entries into clips and uses the first mask as model initialisation.
424    No separate tracklet JSON is needed (and the pinned RPX release does not
425    publish one).
426    """
427
428    task_type_value = "object_tracking"
429    required_modalities = ("rgb", "masks")
430
431    def build_entries(self, df) -> List[Dict[str, Any]]:
432        rows: List[Dict[str, Any]] = []
433        for _, row in df.iterrows():
434            e = _base_entry(row)
435            scene = e["scene_id"]
436            phase = e["phase"]
437            stem = _frame_stem(row)
438            st = str(row["scene_type"])
439            e["rgb"] = _modality_path(scene, phase, "rgb", stem, scene_type=st)
440            e["mask"] = _modality_path(scene, phase, "masks", stem, scene_type=st)
441            rows.append(e)
442        return rows
443
444
445class _EgoSegmentationSpec(_SegmentationSpec):
446    """``ego_segmentation``: same shape as ``segmentation`` (rgb + mask),
447    scoped to ego (egocentric/GoPro) scenes only. Reuses
448    TaskType.OBJECT_SEGMENTATION — same evaluation task, just a distinct
449    manifest key so a downloader can pull ego's slice without also
450    pulling mos's rig-camera segmentation data (and vice versa)."""
451
452    scene_types = ("ego",)
453
454
455class _EgoObjectTrackingSpec(_ObjectTrackingSpec):
456    """``ego_object_tracking``: same shape as ``object_tracking``, scoped
457    to ego scenes only. See _EgoSegmentationSpec for why this is a
458    separate manifest key rather than a new TaskType."""
459
460    scene_types = ("ego",)
461
462
463class _VQASpec(_TaskSpec):
464    """``vqa``: not yet wired — needs spatial_qa / questionnaire labels.
465
466    Returns no entries until the team's VQA generation pipeline lands
467    (tracked separately in `paper-submission/VQA_DESIGN.md`).
468    Logging an explicit warning so this is visible in the upload run.
469    """
470
471    task_type_value = "visual_grounding"
472    required_modalities = ("rgb",)
473
474    def build_entries(self, df) -> List[Dict[str, Any]]:
475        log.warning(
476            "vqa: spec exists but no entries produced — needs the team's "
477            "VQA label generation pipeline (see VQA_DESIGN.md). Skipping."
478        )
479        return []
480
481
482class _EgoVQASpec(_VQASpec):
483    """``ego_vqa``: same reserved-slot shape as ``vqa``, scoped to ego
484    scenes only. See _EgoSegmentationSpec for why this is a separate
485    manifest key rather than a new TaskType."""
486
487    scene_types = ("ego",)
488
489    def build_entries(self, df) -> List[Dict[str, Any]]:
490        log.warning(
491            "ego_vqa: spec exists but no entries produced — needs the team's "
492            "VQA label generation pipeline (see VQA_DESIGN.md). Skipping."
493        )
494        return []
495
496
497# Recipe-key → spec instance.
498_TASK_SPECS: Dict[str, _TaskSpec] = {
499    "monocular_depth": _SingleFrameDepthSpec(),
500    "video_depth": _VideoDepthSpec(),
501    "segmentation": _SegmentationSpec(),
502    "rgbd_segmentation": _RGBDSegmentationSpec(),
503    "stereo_depth": _StereoDepthSpec(),
504    "relative_pose": _RelativePoseSpec(),
505    "rgbd_relative_pose": _RGBDRelativePoseSpec(),
506    "object_tracking": _ObjectTrackingSpec(),
507    "ego_segmentation": _EgoSegmentationSpec(),
508    "ego_object_tracking": _EgoObjectTrackingSpec(),
509    "vqa": _VQASpec(),
510    "ego_vqa": _EgoVQASpec(),
511}
512
513
514# ---------------------------------------------------------------------- #
515# Helpers
516# ---------------------------------------------------------------------- #
517
518_SPLITS: Tuple[str, ...] = ("easy", "medium", "hard")
519
520
521def _frame_stem(row) -> str:
522    """Return the frame's stem (no extension)."""
523    return Path(str(row["frame_filename"])).stem
524
525
526def _base_entry(row) -> Dict[str, Any]:
527    """Common per-row fields: id, scene_id, phase, frame_idx, difficulty,
528    metadata. Task-specific specs add modality paths on top."""
529    scene = str(row["scene_id"])
530    phase = int(row["phase"])
531    stem = _frame_stem(row)
532    return {
533        "id": f"{scene}__{phase}__{stem}",
534        "scene_id": scene,
535        "phase": phase,
536        "frame_idx": int(row["frame_idx"]),
537        "difficulty": str(row["split"]),
538        "metadata": {
539            "scene_id": scene,
540            "phase_idx": phase,
541            "frame": stem,
542        },
543    }
544
545
546def _single_frame_entry(row, modalities: Iterable[str]) -> Dict[str, Any]:
547    """Build a single-frame entry where each modality's *entry key* is the
548    same as its *modality name* (only correct for monocular_depth where
549    the loader's keys are literally ``rgb`` + ``depth``)."""
550    e = _base_entry(row)
551    scene = e["scene_id"]
552    phase = e["phase"]
553    stem = e["metadata"]["frame"]
554    for m in modalities:
555        e[m] = _modality_path(scene, phase, m, stem)
556    return e
557
558
559def _parquet_path(staging_root: Path) -> Path:
560    cand = staging_root / "manifest" / "frames_v1.parquet"
561    if cand.is_file():
562        return cand
563    matches = list((staging_root / "manifest").glob("frames_*.parquet"))
564    if not matches:
565        from ..exceptions import DatasetError
566
567        raise DatasetError(
568            f"no frames Parquet under {staging_root}/manifest/",
569            hint="Run `dataset_hub.cli manifest` first to build the parquet from your captures.",
570        )
571    return matches[-1]
572
573
574def _scene_wise_splits(df):
575    """Mode-aggregate per-row split into per-scene tier."""
576    return df.groupby("scene_id")["split"].agg(
577        lambda s: s.dropna().value_counts().idxmax() if s.notna().any() else None
578    )
579
580
581def _filter_required(df, required_cols: Iterable[str]):
582    """Return rows that have every required modality column set to True."""
583    sub = df
584    for col in required_cols:
585        if col not in sub.columns:
586            return None
587        sub = sub[sub[col].fillna(False).astype(bool)]
588    return sub
589
590
591# ---------------------------------------------------------------------- #
592# Public entry point
593# ---------------------------------------------------------------------- #
594
595
596def write_split_manifests(
597    staging_root: str | Path,
598    *,
599    tasks: Optional[Iterable[str]] = None,
600    splits: Iterable[str] = _SPLITS,
601) -> Dict[Tuple[str, str], Path]:
602    """Write per-(task, split) manifest JSONs under
603    ``<staging_root>/manifests/<task>/<split>.json``.
604
605    Returns ``{(recipe_key, split): path}`` for every JSON written.
606    Empty (task, split) combinations (no rows after filtering, or specs
607    that intentionally emit nothing — e.g. VQA) are skipped silently.
608    """
609    try:
610        import pandas as pd  # noqa: PLC0415
611    except ImportError as e:
612        raise ImportError(
613            "pandas is required for split_manifests; install via `pip install pandas`."
614        ) from e
615
616    staging_root = Path(staging_root)
617    parquet = _parquet_path(staging_root)
618    df = pd.read_parquet(parquet)
619
620    # Authoritative scene-wise splits.
621    scene_split = _scene_wise_splits(df)
622    df = df.drop(columns=["split"]).merge(
623        scene_split.rename("split"),
624        left_on="scene_id",
625        right_index=True,
626    )
627
628    # Read per-modality extensions from current.json (v2+ datasets).
629    # Datasets without this field (v1) fall through to the hardcoded
630    # defaults in _MODALITY_LAYOUT.
631    extensions = _load_modality_extensions(staging_root)
632    layout_override = _layout_with_overrides(extensions) if extensions else None
633    if extensions:
634        log.info(
635            "split_manifests: using modality_extensions from current.json: %s",
636            ", ".join(f"{k}={v}" for k, v in sorted(extensions.items())),
637        )
638
639    chosen = list(tasks) if tasks is not None else list(_TASK_SPECS.keys())
640    manifests_dir = staging_root / "manifests"
641    written: Dict[Tuple[str, str], Path] = {}
642
643    global _active_layout
644    _active_layout = layout_override
645    try:
646        written = _write_loop(
647            chosen=chosen,
648            df=df,
649            splits=splits,
650            manifests_dir=manifests_dir,
651        )
652    finally:
653        _active_layout = None
654
655    return written
656
657
658def _write_loop(
659    *,
660    chosen: List[str],
661    df,
662    splits: Iterable[str],
663    manifests_dir: Path,
664) -> Dict[Tuple[str, str], Path]:
665    """The per-task per-split write loop, factored out so the layout
666    override at module level wraps it in try/finally cleanly."""
667    written: Dict[Tuple[str, str], Path] = {}
668    for recipe_key in chosen:
669        if recipe_key not in _TASK_SPECS:
670            log.warning("unknown task %r — skipping", recipe_key)
671            continue
672        spec = _TASK_SPECS[recipe_key]
673
674        # Scope to this spec's scene family FIRST — required_modalities
675        # alone (rgb+masks) is satisfied by both mos and ego rows, so
676        # without this an ego frame would qualify for "segmentation" too,
677        # not just "ego_segmentation".
678        sub_all = df[df["scene_type"].isin(spec.scene_types)]
679
680        # Filter to rows that have every required modality.
681        required_cols = [_has_col(m) for m in spec.required_modalities]
682        sub_all = _filter_required(sub_all, required_cols)
683        if sub_all is None or sub_all.empty:
684            log.info(
685                "no parquet rows satisfy %s's required modalities (%s); skipping all splits.",
686                recipe_key,
687                list(spec.required_modalities),
688            )
689            continue
690
691        for split in splits:
692            sub = sub_all[sub_all["split"].astype(str) == split]
693            if sub.empty:
694                log.info("no samples for task=%s split=%s; skipping", recipe_key, split)
695                continue
696
697            samples = spec.build_entries(sub)
698            if not samples:
699                # Spec opted out (e.g., VQA needs labels not present yet).
700                continue
701
702            payload = {
703                "task": spec.task_type_value,  # ← TaskType.value, not recipe_key
704                "split": split,
705                "root": None,  # filled at download time
706                "samples": samples,
707            }
708            out = manifests_dir / recipe_key / f"{split}.json"
709            out.parent.mkdir(parents=True, exist_ok=True)
710            out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
711            log.info("wrote %s (%d samples, task_type=%s)", out, len(samples), spec.task_type_value)
712            written[(recipe_key, split)] = out
713
714    return written
715
716
717__all__ = ["write_split_manifests"]
def write_split_manifests( staging_root: str | pathlib.Path, *, tasks: Optional[Iterable[str]] = None, splits: Iterable[str] = ('easy', 'medium', 'hard')) -> Dict[Tuple[str, str], pathlib.Path]:
597def write_split_manifests(
598    staging_root: str | Path,
599    *,
600    tasks: Optional[Iterable[str]] = None,
601    splits: Iterable[str] = _SPLITS,
602) -> Dict[Tuple[str, str], Path]:
603    """Write per-(task, split) manifest JSONs under
604    ``<staging_root>/manifests/<task>/<split>.json``.
605
606    Returns ``{(recipe_key, split): path}`` for every JSON written.
607    Empty (task, split) combinations (no rows after filtering, or specs
608    that intentionally emit nothing — e.g. VQA) are skipped silently.
609    """
610    try:
611        import pandas as pd  # noqa: PLC0415
612    except ImportError as e:
613        raise ImportError(
614            "pandas is required for split_manifests; install via `pip install pandas`."
615        ) from e
616
617    staging_root = Path(staging_root)
618    parquet = _parquet_path(staging_root)
619    df = pd.read_parquet(parquet)
620
621    # Authoritative scene-wise splits.
622    scene_split = _scene_wise_splits(df)
623    df = df.drop(columns=["split"]).merge(
624        scene_split.rename("split"),
625        left_on="scene_id",
626        right_index=True,
627    )
628
629    # Read per-modality extensions from current.json (v2+ datasets).
630    # Datasets without this field (v1) fall through to the hardcoded
631    # defaults in _MODALITY_LAYOUT.
632    extensions = _load_modality_extensions(staging_root)
633    layout_override = _layout_with_overrides(extensions) if extensions else None
634    if extensions:
635        log.info(
636            "split_manifests: using modality_extensions from current.json: %s",
637            ", ".join(f"{k}={v}" for k, v in sorted(extensions.items())),
638        )
639
640    chosen = list(tasks) if tasks is not None else list(_TASK_SPECS.keys())
641    manifests_dir = staging_root / "manifests"
642    written: Dict[Tuple[str, str], Path] = {}
643
644    global _active_layout
645    _active_layout = layout_override
646    try:
647        written = _write_loop(
648            chosen=chosen,
649            df=df,
650            splits=splits,
651            manifests_dir=manifests_dir,
652        )
653    finally:
654        _active_layout = None
655
656    return written

Write per-(task, split) manifest JSONs under <staging_root>/manifests/<task>/<split>.json.

Returns {(recipe_key, split): path} for every JSON written. Empty (task, split) combinations (no rows after filtering, or specs that intentionally emit nothing — e.g. VQA) are skipped silently.