rpx_benchmark.dataset_hub.scanner

Walk an on-disk RPX capture tree and report structured inventory.

Source layout (as captured by the team)::

<root>/
├── mos/                                  # multi-object scenes
│   └── scene<N>.<building>.<area>/<phase>/<modality>/...
├── sos/                                  # single-object scenes
│   └── object<N>.<name>/0/<modality>/...
└── ego/                                  # egocentric scenes (one phase, "0")
    └── scene<N>.<building>.<area>/0/<modality>/...
        # scene_id MUST match its sibling mos/ scene exactly — that's
        # how manifest.py joins an ego scene to its MOS split/difficulty
        # tier. Use `prepare-ego` (see ego_layout.py) to arrange a raw
        # `<scene>/ego/` capture into this shape before scanning.

Scene type is determined by which top-level subdirectory the scene lives under (mos/, sos/, or ego/) — not by directory name pattern. This makes naming flexible and reduces classification errors.

The scanner walks every modality subdir, counts files, sums bytes, and returns a structured ScanResult that downstream tooling (packer, manifest builder, CLI --dry-run) can format however it likes. The scanner is purely informational — it never mutates the tree.

  1"""Walk an on-disk RPX capture tree and report structured inventory.
  2
  3Source layout (as captured by the team)::
  4
  5    <root>/
  6    ├── mos/                                  # multi-object scenes
  7    │   └── scene<N>.<building>.<area>/<phase>/<modality>/...
  8    ├── sos/                                  # single-object scenes
  9    │   └── object<N>.<name>/0/<modality>/...
 10    └── ego/                                  # egocentric scenes (one phase, "0")
 11        └── scene<N>.<building>.<area>/0/<modality>/...
 12            # scene_id MUST match its sibling mos/ scene exactly — that's
 13            # how manifest.py joins an ego scene to its MOS split/difficulty
 14            # tier. Use `prepare-ego` (see ego_layout.py) to arrange a raw
 15            # `<scene>/ego/` capture into this shape before scanning.
 16
 17Scene type is determined by which top-level subdirectory the scene lives
 18under (``mos/``, ``sos/``, or ``ego/``) — not by directory name pattern.
 19This makes naming flexible and reduces classification errors.
 20
 21The scanner walks every modality subdir, counts files, sums bytes, and
 22returns a structured ``ScanResult`` that downstream tooling (packer,
 23manifest builder, CLI ``--dry-run``) can format however it likes. The
 24scanner is *purely informational* — it never mutates the tree.
 25"""
 26
 27from __future__ import annotations
 28
 29from dataclasses import dataclass, field
 30from pathlib import Path
 31from typing import Dict, List
 32
 33from .recipes import SceneType
 34
 35# Top-level subdirectory → scene type. Anything else under root (READMEs,
 36# stray captures, half-finished work) is reported via ``ScanResult.skipped``.
 37_TYPE_DIRS: Dict[str, SceneType] = {
 38    "mos": SceneType.MULTI_OBJECT,
 39    "sos": SceneType.SINGLE_OBJECT,
 40    "ego": SceneType.EGO,
 41}
 42
 43#: The inverse of ``_TYPE_DIRS`` — the single source of truth for "where does
 44#: a scene of this type live on disk". Every other module (packer, manifest,
 45#: ...) that needs this mapping should import it from here rather than
 46#: keeping its own copy — a second, un-synced copy is exactly how the
 47#: pre-ego manifest.py bug happened (its own `"mos" if ... else "sos"`
 48#: ternary never learned about a third scene type).
 49SRC_SUBDIR_BY_TYPE: Dict[SceneType, str] = {v: k for k, v in _TYPE_DIRS.items()}
 50
 51
 52@dataclass(frozen=True)
 53class ModalityInventory:
 54    """Inventory of a single modality directory under one phase."""
 55
 56    name: str  # e.g. "rgb", "depth", "fisheye", "cam_pose", "sam2/masks"
 57    file_count: int
 58    total_bytes: int
 59
 60
 61@dataclass(frozen=True)
 62class PhaseInventory:
 63    """Inventory of one phase directory (set of modalities)."""
 64
 65    phase_index: int
 66    modalities: Dict[str, ModalityInventory]
 67
 68    @property
 69    def total_bytes(self) -> int:
 70        return sum(m.total_bytes for m in self.modalities.values())
 71
 72    @property
 73    def file_count(self) -> int:
 74        return sum(m.file_count for m in self.modalities.values())
 75
 76
 77@dataclass(frozen=True)
 78class SceneInventory:
 79    """Inventory of one scene directory (set of phases)."""
 80
 81    scene_id: str
 82    scene_type: SceneType
 83    phases: List[PhaseInventory]
 84
 85    @property
 86    def total_bytes(self) -> int:
 87        return sum(p.total_bytes for p in self.phases)
 88
 89    @property
 90    def file_count(self) -> int:
 91        return sum(p.file_count for p in self.phases)
 92
 93
 94@dataclass(frozen=True)
 95class ScanResult:
 96    """Top-level scan result over a capture root."""
 97
 98    root: Path
 99    scenes: List[SceneInventory]
100    skipped: List[Path] = field(default_factory=list)
101
102    # ── aggregate helpers ────────────────────────────────────────────
103    def by_type(self, scene_type: SceneType) -> List[SceneInventory]:
104        return [s for s in self.scenes if s.scene_type is scene_type]
105
106    @property
107    def total_bytes(self) -> int:
108        return sum(s.total_bytes for s in self.scenes)
109
110    @property
111    def file_count(self) -> int:
112        return sum(s.file_count for s in self.scenes)
113
114    def modality_totals(self) -> Dict[str, ModalityInventory]:
115        """Aggregate per-modality file counts and bytes across all phases."""
116        agg: Dict[str, list[int]] = {}
117        for scene in self.scenes:
118            for phase in scene.phases:
119                for name, inv in phase.modalities.items():
120                    if name not in agg:
121                        agg[name] = [0, 0]
122                    agg[name][0] += inv.file_count
123                    agg[name][1] += inv.total_bytes
124        return {
125            name: ModalityInventory(name=name, file_count=fc, total_bytes=tb)
126            for name, (fc, tb) in sorted(agg.items())
127        }
128
129
130# --------------------------------------------------------------------- #
131# Scanner
132# --------------------------------------------------------------------- #
133
134
135def _measure_dir(d: Path) -> tuple[int, int]:
136    """``(file_count, total_bytes)`` for everything under ``d`` (recursive)."""
137    files, total = 0, 0
138    for p in d.rglob("*"):
139        if p.is_file():
140            files += 1
141            total += p.stat().st_size
142    return files, total
143
144
145def _scan_phase(phase_dir: Path) -> PhaseInventory:
146    """Walk a phase dir and inventory every immediate modality subdir.
147
148    ``sam2/`` is special: each of its subdirs (``masks/``, ``bbox_overlay/``,
149    ...) is reported separately under ``sam2/<sub>`` so the packer can
150    decide which subset to ship per modality. Loose files at the ``sam2/``
151    root (``mask_to_object.json``, ``verified_masks.txt``,
152    ``iter*_faulty.txt``) are bundled into a synthetic ``sam2/_meta``
153    pseudo-modality to keep the inventory complete.
154    """
155    try:
156        idx = int(phase_dir.name)
157    except ValueError:
158        idx = -1
159
160    modalities: Dict[str, ModalityInventory] = {}
161    for child in sorted(phase_dir.iterdir()):
162        if not child.is_dir():
163            continue
164        if child.name == "sam2":
165            for sub in sorted(child.iterdir()):
166                if sub.is_dir():
167                    fc, tb = _measure_dir(sub)
168                    modalities[f"sam2/{sub.name}"] = ModalityInventory(
169                        name=f"sam2/{sub.name}",
170                        file_count=fc,
171                        total_bytes=tb,
172                    )
173            meta_files = [p for p in child.iterdir() if p.is_file()]
174            if meta_files:
175                fc = len(meta_files)
176                tb = sum(p.stat().st_size for p in meta_files)
177                modalities["sam2/_meta"] = ModalityInventory(
178                    name="sam2/_meta",
179                    file_count=fc,
180                    total_bytes=tb,
181                )
182            continue
183        fc, tb = _measure_dir(child)
184        modalities[child.name] = ModalityInventory(
185            name=child.name,
186            file_count=fc,
187            total_bytes=tb,
188        )
189
190    return PhaseInventory(phase_index=idx, modalities=modalities)
191
192
193def scan_capture_root(root: Path) -> ScanResult:
194    """Scan a capture-tree root and return a :class:`ScanResult`.
195
196    Walks ``root/mos/*`` (multi-object scenes) and ``root/sos/*`` (single-
197    object scenes). Anything else under root, and anything inside ``mos/``
198    or ``sos/`` that isn't a scene directory, is collected into
199    :attr:`ScanResult.skipped` so the caller can flag typos / strays.
200    """
201    root = Path(root)
202    if not root.is_dir():
203        raise FileNotFoundError(f"capture root does not exist: {root}")
204
205    scenes: List[SceneInventory] = []
206    skipped: List[Path] = []
207    for entry in sorted(root.iterdir()):
208        # Top-level: only ``mos/`` and ``sos/`` are recognised.
209        if not entry.is_dir() or entry.name not in _TYPE_DIRS:
210            skipped.append(entry)
211            continue
212
213        scene_type = _TYPE_DIRS[entry.name]
214        for scene_dir in sorted(entry.iterdir()):
215            if not scene_dir.is_dir():
216                skipped.append(scene_dir)
217                continue
218            phase_dirs = sorted(
219                [p for p in scene_dir.iterdir() if p.is_dir()],
220                key=lambda p: p.name,
221            )
222            phases = [_scan_phase(p) for p in phase_dirs]
223            scenes.append(
224                SceneInventory(
225                    scene_id=scene_dir.name,
226                    scene_type=scene_type,
227                    phases=phases,
228                )
229            )
230
231    return ScanResult(root=root, scenes=scenes, skipped=skipped)
SRC_SUBDIR_BY_TYPE: Dict[rpx_benchmark.dataset_hub.SceneType, str] = {<SceneType.MULTI_OBJECT: 'multi_object'>: 'mos', <SceneType.SINGLE_OBJECT: 'single_object'>: 'sos', <SceneType.EGO: 'ego'>: 'ego'}
@dataclass(frozen=True)
class ModalityInventory:
53@dataclass(frozen=True)
54class ModalityInventory:
55    """Inventory of a single modality directory under one phase."""
56
57    name: str  # e.g. "rgb", "depth", "fisheye", "cam_pose", "sam2/masks"
58    file_count: int
59    total_bytes: int

Inventory of a single modality directory under one phase.

ModalityInventory(name: str, file_count: int, total_bytes: int)
name: str
file_count: int
total_bytes: int
@dataclass(frozen=True)
class PhaseInventory:
62@dataclass(frozen=True)
63class PhaseInventory:
64    """Inventory of one phase directory (set of modalities)."""
65
66    phase_index: int
67    modalities: Dict[str, ModalityInventory]
68
69    @property
70    def total_bytes(self) -> int:
71        return sum(m.total_bytes for m in self.modalities.values())
72
73    @property
74    def file_count(self) -> int:
75        return sum(m.file_count for m in self.modalities.values())

Inventory of one phase directory (set of modalities).

PhaseInventory( phase_index: int, modalities: Dict[str, ModalityInventory])
phase_index: int
modalities: Dict[str, ModalityInventory]
total_bytes: int
69    @property
70    def total_bytes(self) -> int:
71        return sum(m.total_bytes for m in self.modalities.values())
file_count: int
73    @property
74    def file_count(self) -> int:
75        return sum(m.file_count for m in self.modalities.values())
@dataclass(frozen=True)
class SceneInventory:
78@dataclass(frozen=True)
79class SceneInventory:
80    """Inventory of one scene directory (set of phases)."""
81
82    scene_id: str
83    scene_type: SceneType
84    phases: List[PhaseInventory]
85
86    @property
87    def total_bytes(self) -> int:
88        return sum(p.total_bytes for p in self.phases)
89
90    @property
91    def file_count(self) -> int:
92        return sum(p.file_count for p in self.phases)

Inventory of one scene directory (set of phases).

SceneInventory( scene_id: str, scene_type: rpx_benchmark.dataset_hub.SceneType, phases: List[PhaseInventory])
scene_id: str
phases: List[PhaseInventory]
total_bytes: int
86    @property
87    def total_bytes(self) -> int:
88        return sum(p.total_bytes for p in self.phases)
file_count: int
90    @property
91    def file_count(self) -> int:
92        return sum(p.file_count for p in self.phases)
@dataclass(frozen=True)
class ScanResult:
 95@dataclass(frozen=True)
 96class ScanResult:
 97    """Top-level scan result over a capture root."""
 98
 99    root: Path
100    scenes: List[SceneInventory]
101    skipped: List[Path] = field(default_factory=list)
102
103    # ── aggregate helpers ────────────────────────────────────────────
104    def by_type(self, scene_type: SceneType) -> List[SceneInventory]:
105        return [s for s in self.scenes if s.scene_type is scene_type]
106
107    @property
108    def total_bytes(self) -> int:
109        return sum(s.total_bytes for s in self.scenes)
110
111    @property
112    def file_count(self) -> int:
113        return sum(s.file_count for s in self.scenes)
114
115    def modality_totals(self) -> Dict[str, ModalityInventory]:
116        """Aggregate per-modality file counts and bytes across all phases."""
117        agg: Dict[str, list[int]] = {}
118        for scene in self.scenes:
119            for phase in scene.phases:
120                for name, inv in phase.modalities.items():
121                    if name not in agg:
122                        agg[name] = [0, 0]
123                    agg[name][0] += inv.file_count
124                    agg[name][1] += inv.total_bytes
125        return {
126            name: ModalityInventory(name=name, file_count=fc, total_bytes=tb)
127            for name, (fc, tb) in sorted(agg.items())
128        }

Top-level scan result over a capture root.

ScanResult( root: pathlib.Path, scenes: List[SceneInventory], skipped: List[pathlib.Path] = <factory>)
root: pathlib.Path
scenes: List[SceneInventory]
skipped: List[pathlib.Path]
def by_type( self, scene_type: rpx_benchmark.dataset_hub.SceneType) -> List[SceneInventory]:
104    def by_type(self, scene_type: SceneType) -> List[SceneInventory]:
105        return [s for s in self.scenes if s.scene_type is scene_type]
total_bytes: int
107    @property
108    def total_bytes(self) -> int:
109        return sum(s.total_bytes for s in self.scenes)
file_count: int
111    @property
112    def file_count(self) -> int:
113        return sum(s.file_count for s in self.scenes)
def modality_totals(self) -> Dict[str, ModalityInventory]:
115    def modality_totals(self) -> Dict[str, ModalityInventory]:
116        """Aggregate per-modality file counts and bytes across all phases."""
117        agg: Dict[str, list[int]] = {}
118        for scene in self.scenes:
119            for phase in scene.phases:
120                for name, inv in phase.modalities.items():
121                    if name not in agg:
122                        agg[name] = [0, 0]
123                    agg[name][0] += inv.file_count
124                    agg[name][1] += inv.total_bytes
125        return {
126            name: ModalityInventory(name=name, file_count=fc, total_bytes=tb)
127            for name, (fc, tb) in sorted(agg.items())
128        }

Aggregate per-modality file counts and bytes across all phases.

def scan_capture_root(root: pathlib.Path) -> ScanResult:
194def scan_capture_root(root: Path) -> ScanResult:
195    """Scan a capture-tree root and return a :class:`ScanResult`.
196
197    Walks ``root/mos/*`` (multi-object scenes) and ``root/sos/*`` (single-
198    object scenes). Anything else under root, and anything inside ``mos/``
199    or ``sos/`` that isn't a scene directory, is collected into
200    :attr:`ScanResult.skipped` so the caller can flag typos / strays.
201    """
202    root = Path(root)
203    if not root.is_dir():
204        raise FileNotFoundError(f"capture root does not exist: {root}")
205
206    scenes: List[SceneInventory] = []
207    skipped: List[Path] = []
208    for entry in sorted(root.iterdir()):
209        # Top-level: only ``mos/`` and ``sos/`` are recognised.
210        if not entry.is_dir() or entry.name not in _TYPE_DIRS:
211            skipped.append(entry)
212            continue
213
214        scene_type = _TYPE_DIRS[entry.name]
215        for scene_dir in sorted(entry.iterdir()):
216            if not scene_dir.is_dir():
217                skipped.append(scene_dir)
218                continue
219            phase_dirs = sorted(
220                [p for p in scene_dir.iterdir() if p.is_dir()],
221                key=lambda p: p.name,
222            )
223            phases = [_scan_phase(p) for p in phase_dirs]
224            scenes.append(
225                SceneInventory(
226                    scene_id=scene_dir.name,
227                    scene_type=scene_type,
228                    phases=phases,
229                )
230            )
231
232    return ScanResult(root=root, scenes=scenes, skipped=skipped)

Scan a capture-tree root and return a ScanResult.

Walks root/mos/* (multi-object scenes) and root/sos/* (single- object scenes). Anything else under root, and anything inside mos/ or sos/ that isn't a scene directory, is collected into ScanResult.skipped so the caller can flag typos / strays.