rpx_benchmark.dataset_hub.ego_layout

Arrange raw ego (GoPro) captures into the layout scan_capture_root expects.

Ego captures land on disk as <ego_root>/scene<NNN>/ego/{rgb,sam2} — one continuous, non-phased video per scene (see the ego annotation pipeline, ~/Desktop/EGO_ANNOTATION_PIPELINE.md). That does not match dataset_hub's <DATA>/{mos,sos}/<scene_id>/<phase>/<modality> shape: no top-level ego/ scene family recognised before this module, and no numeric phase subdirectory (ego has one continuous clip, not three).

This module bridges the two, non-destructively (symlinks by default — nothing here ever writes into the source ego captures):

<DATA>/ego/<scene_id>/0/{rgb,sam2/...}

<scene_id> is ego's own bare, zero-padded name (scene020) — VERIFIED against the live IRVLUTD/RPX repo, which publishes mos/ scenes as bare scene001..scene100 (a local working copy may use a location-suffixed directory name like scene20.su.checkerboard.; the frame/mask bytes underneath are identical to the live repo, only that local directory name differs). Using the bare form means manifest.py's split/difficulty lookup (an exact-string splits.get(scene.scene_id)) works against the live splits file with zero extra code — same physical scene, same scene number, captured on independent cameras (rig D435 vs. head-worn GoPro).

A few loose files/dirs are intentionally left OUT of the arranged tree — internal to the ego labeling pipeline, not something a downstream benchmark user needs:

ref_frame.txt              which frame the DINOv3 correspondence used
                            as its reference (pipeline provenance only)
mask_to_object_multi.json  multi-anchor labeling metadata (only exists
                            on a handful of experimentally-relabeled
                            scenes; not part of the shipped label set)
jpg/                       secondary JPEG copies of the rgb/ frames

masks_verified/ has no MOS precedent either — MOS only ships the verified_masks.txt file (which both ego and MOS already carry via the existing sam2/_meta bundle), never a directory of rendered frames. Excluded by default; pass include_masks_verified=True if that decision changes.

  1"""Arrange raw ego (GoPro) captures into the layout scan_capture_root expects.
  2
  3Ego captures land on disk as ``<ego_root>/scene<NNN>/ego/{rgb,sam2}`` — one
  4continuous, non-phased video per scene (see the ego annotation pipeline,
  5``~/Desktop/EGO_ANNOTATION_PIPELINE.md``). That does not match dataset_hub's
  6``<DATA>/{mos,sos}/<scene_id>/<phase>/<modality>`` shape: no top-level
  7``ego/`` scene family recognised before this module, and no numeric phase
  8subdirectory (ego has one continuous clip, not three).
  9
 10This module bridges the two, non-destructively (symlinks by default — nothing
 11here ever writes into the source ego captures):
 12
 13    <DATA>/ego/<scene_id>/0/{rgb,sam2/...}
 14
 15``<scene_id>`` is ego's own bare, zero-padded name (``scene020``) —
 16VERIFIED against the live IRVLUTD/RPX repo, which publishes mos/ scenes as
 17bare ``scene001``..``scene100`` (a local working copy may use a
 18location-suffixed directory name like ``scene20.su.checkerboard.``; the
 19frame/mask bytes underneath are identical to the live repo, only that
 20local directory name differs). Using the bare form means
 21``manifest.py``'s split/difficulty lookup (an exact-string
 22``splits.get(scene.scene_id)``) works against the live splits file with
 23zero extra code — same physical scene, same scene number, captured on
 24independent cameras (rig D435 vs. head-worn GoPro).
 25
 26A few loose files/dirs are intentionally left OUT of the arranged tree —
 27internal to the ego labeling pipeline, not something a downstream benchmark
 28user needs:
 29
 30    ref_frame.txt              which frame the DINOv3 correspondence used
 31                                as its reference (pipeline provenance only)
 32    mask_to_object_multi.json  multi-anchor labeling metadata (only exists
 33                                on a handful of experimentally-relabeled
 34                                scenes; not part of the shipped label set)
 35    jpg/                       secondary JPEG copies of the rgb/ frames
 36
 37``masks_verified/`` has no MOS precedent either — MOS only ships the
 38``verified_masks.txt`` *file* (which both ego and MOS already carry via the
 39existing ``sam2/_meta`` bundle), never a directory of rendered frames.
 40Excluded by default; pass ``include_masks_verified=True`` if that decision
 41changes.
 42"""
 43
 44from __future__ import annotations
 45
 46import re
 47import shutil
 48from dataclasses import dataclass, field
 49from pathlib import Path
 50from typing import List, Optional
 51
 52from ..exceptions import ConfigError
 53
 54#: Loose files directly under sam2/ that are pipeline-internal, not shipped.
 55EXCLUDED_META_FILES = frozenset({"ref_frame.txt", "mask_to_object_multi.json"})
 56
 57#: Directories directly under sam2/ that are pipeline-internal refinement
 58#: provenance (iteration-by-iteration snapshots from manual mask review),
 59#: not final shipped data -- same category as EXCLUDED_META_FILES. Only
 60#: present on scenes that went through >=1 refinement pass; absent on
 61#: clean single-pass scenes. Not recognised by any packer.py modality
 62#: mapping either way, so excluding it is a pure size/time win with no
 63#: behavior change for what actually gets packed.
 64EXCLUDED_META_DIRS = frozenset({"mask_refinement"})
 65
 66#: Top-level dirs under <scene>/ego/ that are never part of the arranged tree
 67#: (only rgb/ and sam2/ are recognised modalities).
 68_ARRANGED_TOP_LEVEL = ("rgb", "sam2")
 69
 70
 71@dataclass(frozen=True)
 72class EgoArrangeResult:
 73    """One scene's outcome from :func:`prepare_ego_layout`."""
 74
 75    ego_scene_dir: str  # e.g. "scene020"
 76    scene_id: Optional[str] = None  # resolved mos/ sibling name, if found
 77    dst: Optional[Path] = None
 78    status: str = "ok"  # "ok" | "no_mos_sibling" | "no_rgb" | "no_sam2"
 79    message: str = ""
 80
 81
 82@dataclass(frozen=True)
 83class EgoLayoutReport:
 84    results: List[EgoArrangeResult] = field(default_factory=list)
 85
 86    @property
 87    def ok(self) -> List[EgoArrangeResult]:
 88        return [r for r in self.results if r.status == "ok"]
 89
 90    @property
 91    def skipped(self) -> List[EgoArrangeResult]:
 92        return [r for r in self.results if r.status != "ok"]
 93
 94
 95_SCENE_NUM_RE = re.compile(r"^scene0*(\d+)$")
 96_MOS_SCENE_NUM_RE = re.compile(r"^scene0*(\d+)(\.|$)")
 97
 98
 99def _ego_scene_number(ego_scene_dir_name: str) -> Optional[int]:
100    m = _SCENE_NUM_RE.match(ego_scene_dir_name)
101    return int(m.group(1)) if m else None
102
103
104def resolve_mos_scene_id(mos_root: Path, ego_scene_dir_name: str) -> Optional[str]:
105    """Validate an ego scene dir has a ``mos/`` sibling, by scene NUMBER, and
106    return the CANONICAL scene_id to use for both.
107
108    IMPORTANT: the canonical scene_id is ego's own bare, zero-padded form
109    (``scene020``) — verified against the live IRVLUTD/RPX repo, where
110    scenes are published as bare ``scene001``..``scene100`` (NOT the
111    location-suffixed form some local working copies use, e.g.
112    ``scene20.su.checkerboard`` — that's a local/staging naming quirk that
113    predates upload; the actual frame/mask bytes underneath match the live
114    repo exactly, only the on-disk directory name differs). Do not use the
115    matched mos/ directory's own (possibly suffixed) name as the scene_id —
116    that was the bug in an earlier version of this function.
117
118    ``mos_root`` is used purely as a validation gate — confirms a scene
119    with this number actually exists as a mos/ scene, regardless of what
120    its local directory happens to be named. Returns ``None`` if there's no
121    exactly-one match (missing, or ambiguous — caller should treat either
122    as "skip, don't guess").
123    """
124    n = _ego_scene_number(ego_scene_dir_name)
125    if n is None or not mos_root.is_dir():
126        return None
127    matches = [
128        d.name for d in mos_root.iterdir()
129        if d.is_dir() and _MOS_SCENE_NUM_RE.match(d.name)
130        and int(_MOS_SCENE_NUM_RE.match(d.name).group(1)) == n
131    ]
132    if len(matches) != 1:
133        return None
134    return ego_scene_dir_name  # canonical bare form, e.g. "scene020"
135
136
137def _link_or_copy(src: Path, dst: Path, mode: str) -> None:
138    dst.parent.mkdir(parents=True, exist_ok=True)
139    if dst.exists() or dst.is_symlink():
140        return
141    if mode == "symlink":
142        if src.is_dir():
143            # Mirror with a REAL directory containing per-file symlinks,
144            # not one directory-level symlink. A directory symlink is
145            # invisible to any standard recursive tree walk that doesn't
146            # explicitly opt in to following symlinks -- notably
147            # ``Path.rglob`` (Python's default: does not descend into
148            # symlinked directories), which lossless_convert.py's
149            # _plan_tree relies on. A single dir-symlink here would make
150            # every rgb/mask frame invisible to lossless-convert while
151            # still resolving fine for direct-path reads (scan/pack) --
152            # confirmed via a real lossless-convert dry-run before this
153            # fix: 0 of ~1500 real ego frames were discovered, only the
154            # two individually-symlinked meta files per scene were. Still
155            # 100% symlinks (nothing written into the source), just at
156            # file granularity so any tree walk works uniformly.
157            dst.mkdir(parents=True, exist_ok=True)
158            for entry in sorted(src.rglob("*")):
159                if not entry.is_file():
160                    continue
161                target = dst / entry.relative_to(src)
162                target.parent.mkdir(parents=True, exist_ok=True)
163                target.symlink_to(entry.resolve())
164        else:
165            dst.symlink_to(src.resolve())
166    elif mode == "copy":
167        if src.is_dir():
168            shutil.copytree(src, dst)
169        else:
170            shutil.copy2(src, dst)
171    else:
172        raise ConfigError(f"unknown mode {mode!r}", hint="Use 'symlink' or 'copy'.")
173
174
175def arrange_ego_scene(
176    ego_scene_src: Path,
177    dst_ego_root: Path,
178    scene_id: str,
179    include_masks_verified: bool = False,
180    mode: str = "symlink",
181) -> Path:
182    """Arrange one ``<ego_root>/scene<NNN>/ego/`` capture into
183    ``<dst_ego_root>/<scene_id>/0/``. Returns the phase-0 dst dir.
184
185    Idempotent: existing links/files at the destination are left alone (so
186    re-running after adding a new scene doesn't touch already-arranged
187    ones). Remove the destination scene dir first if you want a clean
188    re-arrange.
189    """
190    rgb_src = ego_scene_src / "rgb"
191    sam2_src = ego_scene_src / "sam2"
192    if not rgb_src.is_dir():
193        raise ConfigError(f"no rgb/ under {ego_scene_src}")
194    if not sam2_src.is_dir():
195        raise ConfigError(f"no sam2/ under {ego_scene_src}")
196
197    dst_phase = dst_ego_root / scene_id / "0"
198
199    _link_or_copy(rgb_src, dst_phase / "rgb", mode)
200
201    # Allow-list, not exclude-list: ship exactly rgb/ + sam2/masks/ +
202    # mask_to_object.json (+ verified_masks.txt, tiny provenance) --
203    # nothing else. masks_aux (bbox_overlay/dino_output/palette/
204    # contour_gt_masks/rgb_and_mask/masks_contour_with_hidden) is QC/
205    # visualization data, not read by any ego task recipe, and dropped
206    # entirely per the "just rgb + masks (+ the id->name mapping)"
207    # decision -- ego's per-frame composite images are large (high-res
208    # GoPro source), so this is also the single biggest size lever.
209    dst_sam2 = dst_phase / "sam2"
210    masks_src = sam2_src / "masks"
211    if not masks_src.is_dir():
212        raise ConfigError(f"no sam2/masks/ under {ego_scene_src}")
213    _link_or_copy(masks_src, dst_sam2 / "masks", mode)
214    if include_masks_verified and (sam2_src / "masks_verified").is_dir():
215        _link_or_copy(sam2_src / "masks_verified", dst_sam2 / "masks_verified", mode)
216    for fname in ("mask_to_object.json", "verified_masks.txt"):
217        src_file = sam2_src / fname
218        if src_file.is_file():
219            _link_or_copy(src_file, dst_sam2 / fname, mode)
220
221    return dst_phase
222
223
224def prepare_ego_layout(
225    ego_captures_root: Path,
226    mos_root: Path,
227    dst_root: Path,
228    scenes: Optional[List[str]] = None,
229    include_masks_verified: bool = False,
230    mode: str = "symlink",
231) -> EgoLayoutReport:
232    """Arrange every (or a selected subset of) ego scene into
233    ``<dst_root>/ego/<scene_id>/0/`` — ready for ``scan_capture_root``.
234
235    ``scenes``: optional list of ego scene dir names (``["scene020",
236    "scene032"]``) to restrict to. Default: every ``scene*`` dir under
237    ``ego_captures_root``.
238    """
239    ego_captures_root = Path(ego_captures_root)
240    mos_root = Path(mos_root)
241    dst_ego_root = Path(dst_root) / "ego"
242
243    if scenes is None:
244        candidates = sorted(
245            d.name for d in ego_captures_root.iterdir()
246            if d.is_dir() and _ego_scene_number(d.name) is not None
247        )
248    else:
249        candidates = list(scenes)
250
251    results: List[EgoArrangeResult] = []
252    for name in candidates:
253        ego_scene_dir = ego_captures_root / name / "ego"
254        if not (ego_scene_dir / "rgb").is_dir():
255            results.append(EgoArrangeResult(name, status="no_rgb",
256                                             message=f"no rgb/ under {ego_scene_dir}"))
257            continue
258        if not (ego_scene_dir / "sam2" / "masks").is_dir():
259            results.append(EgoArrangeResult(name, status="no_sam2",
260                                             message=f"no sam2/masks/ under {ego_scene_dir}"))
261            continue
262
263        scene_id = resolve_mos_scene_id(mos_root, name)
264        if scene_id is None:
265            results.append(EgoArrangeResult(
266                name, status="no_mos_sibling",
267                message=f"no unique mos/ scene matching {name}'s number",
268            ))
269            continue
270
271        dst = arrange_ego_scene(ego_scene_dir, dst_ego_root, scene_id,
272                                include_masks_verified=include_masks_verified, mode=mode)
273        results.append(EgoArrangeResult(name, scene_id=scene_id, dst=dst))
274
275    return EgoLayoutReport(results=results)
EXCLUDED_META_FILES = frozenset({'mask_to_object_multi.json', 'ref_frame.txt'})
EXCLUDED_META_DIRS = frozenset({'mask_refinement'})
@dataclass(frozen=True)
class EgoArrangeResult:
72@dataclass(frozen=True)
73class EgoArrangeResult:
74    """One scene's outcome from :func:`prepare_ego_layout`."""
75
76    ego_scene_dir: str  # e.g. "scene020"
77    scene_id: Optional[str] = None  # resolved mos/ sibling name, if found
78    dst: Optional[Path] = None
79    status: str = "ok"  # "ok" | "no_mos_sibling" | "no_rgb" | "no_sam2"
80    message: str = ""

One scene's outcome from prepare_ego_layout().

EgoArrangeResult( ego_scene_dir: str, scene_id: Optional[str] = None, dst: Optional[pathlib.Path] = None, status: str = 'ok', message: str = '')
ego_scene_dir: str
scene_id: Optional[str] = None
dst: Optional[pathlib.Path] = None
status: str = 'ok'
message: str = ''
@dataclass(frozen=True)
class EgoLayoutReport:
83@dataclass(frozen=True)
84class EgoLayoutReport:
85    results: List[EgoArrangeResult] = field(default_factory=list)
86
87    @property
88    def ok(self) -> List[EgoArrangeResult]:
89        return [r for r in self.results if r.status == "ok"]
90
91    @property
92    def skipped(self) -> List[EgoArrangeResult]:
93        return [r for r in self.results if r.status != "ok"]
EgoLayoutReport( results: List[EgoArrangeResult] = <factory>)
results: List[EgoArrangeResult]
ok: List[EgoArrangeResult]
87    @property
88    def ok(self) -> List[EgoArrangeResult]:
89        return [r for r in self.results if r.status == "ok"]
skipped: List[EgoArrangeResult]
91    @property
92    def skipped(self) -> List[EgoArrangeResult]:
93        return [r for r in self.results if r.status != "ok"]
def resolve_mos_scene_id(mos_root: pathlib.Path, ego_scene_dir_name: str) -> Optional[str]:
105def resolve_mos_scene_id(mos_root: Path, ego_scene_dir_name: str) -> Optional[str]:
106    """Validate an ego scene dir has a ``mos/`` sibling, by scene NUMBER, and
107    return the CANONICAL scene_id to use for both.
108
109    IMPORTANT: the canonical scene_id is ego's own bare, zero-padded form
110    (``scene020``) — verified against the live IRVLUTD/RPX repo, where
111    scenes are published as bare ``scene001``..``scene100`` (NOT the
112    location-suffixed form some local working copies use, e.g.
113    ``scene20.su.checkerboard`` — that's a local/staging naming quirk that
114    predates upload; the actual frame/mask bytes underneath match the live
115    repo exactly, only the on-disk directory name differs). Do not use the
116    matched mos/ directory's own (possibly suffixed) name as the scene_id —
117    that was the bug in an earlier version of this function.
118
119    ``mos_root`` is used purely as a validation gate — confirms a scene
120    with this number actually exists as a mos/ scene, regardless of what
121    its local directory happens to be named. Returns ``None`` if there's no
122    exactly-one match (missing, or ambiguous — caller should treat either
123    as "skip, don't guess").
124    """
125    n = _ego_scene_number(ego_scene_dir_name)
126    if n is None or not mos_root.is_dir():
127        return None
128    matches = [
129        d.name for d in mos_root.iterdir()
130        if d.is_dir() and _MOS_SCENE_NUM_RE.match(d.name)
131        and int(_MOS_SCENE_NUM_RE.match(d.name).group(1)) == n
132    ]
133    if len(matches) != 1:
134        return None
135    return ego_scene_dir_name  # canonical bare form, e.g. "scene020"

Validate an ego scene dir has a mos/ sibling, by scene NUMBER, and return the CANONICAL scene_id to use for both.

IMPORTANT: the canonical scene_id is ego's own bare, zero-padded form (scene020) — verified against the live IRVLUTD/RPX repo, where scenes are published as bare scene001..scene100 (NOT the location-suffixed form some local working copies use, e.g. scene20.su.checkerboard — that's a local/staging naming quirk that predates upload; the actual frame/mask bytes underneath match the live repo exactly, only the on-disk directory name differs). Do not use the matched mos/ directory's own (possibly suffixed) name as the scene_id — that was the bug in an earlier version of this function.

mos_root is used purely as a validation gate — confirms a scene with this number actually exists as a mos/ scene, regardless of what its local directory happens to be named. Returns None if there's no exactly-one match (missing, or ambiguous — caller should treat either as "skip, don't guess").

def arrange_ego_scene( ego_scene_src: pathlib.Path, dst_ego_root: pathlib.Path, scene_id: str, include_masks_verified: bool = False, mode: str = 'symlink') -> pathlib.Path:
176def arrange_ego_scene(
177    ego_scene_src: Path,
178    dst_ego_root: Path,
179    scene_id: str,
180    include_masks_verified: bool = False,
181    mode: str = "symlink",
182) -> Path:
183    """Arrange one ``<ego_root>/scene<NNN>/ego/`` capture into
184    ``<dst_ego_root>/<scene_id>/0/``. Returns the phase-0 dst dir.
185
186    Idempotent: existing links/files at the destination are left alone (so
187    re-running after adding a new scene doesn't touch already-arranged
188    ones). Remove the destination scene dir first if you want a clean
189    re-arrange.
190    """
191    rgb_src = ego_scene_src / "rgb"
192    sam2_src = ego_scene_src / "sam2"
193    if not rgb_src.is_dir():
194        raise ConfigError(f"no rgb/ under {ego_scene_src}")
195    if not sam2_src.is_dir():
196        raise ConfigError(f"no sam2/ under {ego_scene_src}")
197
198    dst_phase = dst_ego_root / scene_id / "0"
199
200    _link_or_copy(rgb_src, dst_phase / "rgb", mode)
201
202    # Allow-list, not exclude-list: ship exactly rgb/ + sam2/masks/ +
203    # mask_to_object.json (+ verified_masks.txt, tiny provenance) --
204    # nothing else. masks_aux (bbox_overlay/dino_output/palette/
205    # contour_gt_masks/rgb_and_mask/masks_contour_with_hidden) is QC/
206    # visualization data, not read by any ego task recipe, and dropped
207    # entirely per the "just rgb + masks (+ the id->name mapping)"
208    # decision -- ego's per-frame composite images are large (high-res
209    # GoPro source), so this is also the single biggest size lever.
210    dst_sam2 = dst_phase / "sam2"
211    masks_src = sam2_src / "masks"
212    if not masks_src.is_dir():
213        raise ConfigError(f"no sam2/masks/ under {ego_scene_src}")
214    _link_or_copy(masks_src, dst_sam2 / "masks", mode)
215    if include_masks_verified and (sam2_src / "masks_verified").is_dir():
216        _link_or_copy(sam2_src / "masks_verified", dst_sam2 / "masks_verified", mode)
217    for fname in ("mask_to_object.json", "verified_masks.txt"):
218        src_file = sam2_src / fname
219        if src_file.is_file():
220            _link_or_copy(src_file, dst_sam2 / fname, mode)
221
222    return dst_phase

Arrange one <ego_root>/scene<NNN>/ego/ capture into <dst_ego_root>/<scene_id>/0/. Returns the phase-0 dst dir.

Idempotent: existing links/files at the destination are left alone (so re-running after adding a new scene doesn't touch already-arranged ones). Remove the destination scene dir first if you want a clean re-arrange.

def prepare_ego_layout( ego_captures_root: pathlib.Path, mos_root: pathlib.Path, dst_root: pathlib.Path, scenes: Optional[List[str]] = None, include_masks_verified: bool = False, mode: str = 'symlink') -> EgoLayoutReport:
225def prepare_ego_layout(
226    ego_captures_root: Path,
227    mos_root: Path,
228    dst_root: Path,
229    scenes: Optional[List[str]] = None,
230    include_masks_verified: bool = False,
231    mode: str = "symlink",
232) -> EgoLayoutReport:
233    """Arrange every (or a selected subset of) ego scene into
234    ``<dst_root>/ego/<scene_id>/0/`` — ready for ``scan_capture_root``.
235
236    ``scenes``: optional list of ego scene dir names (``["scene020",
237    "scene032"]``) to restrict to. Default: every ``scene*`` dir under
238    ``ego_captures_root``.
239    """
240    ego_captures_root = Path(ego_captures_root)
241    mos_root = Path(mos_root)
242    dst_ego_root = Path(dst_root) / "ego"
243
244    if scenes is None:
245        candidates = sorted(
246            d.name for d in ego_captures_root.iterdir()
247            if d.is_dir() and _ego_scene_number(d.name) is not None
248        )
249    else:
250        candidates = list(scenes)
251
252    results: List[EgoArrangeResult] = []
253    for name in candidates:
254        ego_scene_dir = ego_captures_root / name / "ego"
255        if not (ego_scene_dir / "rgb").is_dir():
256            results.append(EgoArrangeResult(name, status="no_rgb",
257                                             message=f"no rgb/ under {ego_scene_dir}"))
258            continue
259        if not (ego_scene_dir / "sam2" / "masks").is_dir():
260            results.append(EgoArrangeResult(name, status="no_sam2",
261                                             message=f"no sam2/masks/ under {ego_scene_dir}"))
262            continue
263
264        scene_id = resolve_mos_scene_id(mos_root, name)
265        if scene_id is None:
266            results.append(EgoArrangeResult(
267                name, status="no_mos_sibling",
268                message=f"no unique mos/ scene matching {name}'s number",
269            ))
270            continue
271
272        dst = arrange_ego_scene(ego_scene_dir, dst_ego_root, scene_id,
273                                include_masks_verified=include_masks_verified, mode=mode)
274        results.append(EgoArrangeResult(name, scene_id=scene_id, dst=dst))
275
276    return EgoLayoutReport(results=results)

Arrange every (or a selected subset of) ego scene into <dst_root>/ego/<scene_id>/0/ — ready for scan_capture_root.

scenes: optional list of ego scene dir names (["scene020", "scene032"]) to restrict to. Default: every scene* dir under ego_captures_root.