rpx_benchmark.dataset_hub.manifest
Build the per-frame Parquet manifest the dataset hub ships.
The manifest is the single source of truth for what's in the dataset:
one row per frame, with boolean columns marking which modalities are
present, plus split assignment, scene type, and pointers to the tar
shards that contain the actual bytes. It is shipped at
manifest/frames_<schema_version>.parquet on the HF repo and is
small enough (~30 MB for 750k frames) that the downloader pulls it
eagerly before deciding what else to fetch.
Schema (column / dtype / meaning)::
scene_id str "scene1.library.fountain" or "object001.mug"
scene_type str "multi_object" | "single_object"
phase int32 0/1/2 for multi, 0 for single
frame_idx int32 0-based frame index inside the phase
frame_filename str the source filename (e.g. "00000.png")
split str | null "easy" | "medium" | "hard" (null if no split)
has_rgb bool True iff the frame appears in rgb.tar
has_depth bool
has_fisheye bool
has_cam_pose bool
has_masks bool
has_masks_aux bool
has_sam2_meta bool
shard_rgb str | null repo-relative path to rgb.tar (or null)
shard_depth str | null
shard_fisheye str | null
shard_cam_pose str | null
shard_masks str | null
shard_masks_aux str | null
shard_sam2_meta str | null
Plus a sibling manifest/current.json that maps logical label names
to the version currently considered "default" (e.g. masks → v1).
The downloader reads this first to resolve recipe modalities to actual
shard paths.
1"""Build the per-frame Parquet manifest the dataset hub ships. 2 3The manifest is the *single source of truth* for what's in the dataset: 4one row per frame, with boolean columns marking which modalities are 5present, plus split assignment, scene type, and pointers to the tar 6shards that contain the actual bytes. It is shipped at 7``manifest/frames_<schema_version>.parquet`` on the HF repo and is 8small enough (~30 MB for 750k frames) that the downloader pulls it 9eagerly before deciding what else to fetch. 10 11Schema (column / dtype / meaning):: 12 13 scene_id str "scene1.library.fountain" or "object001.mug" 14 scene_type str "multi_object" | "single_object" 15 phase int32 0/1/2 for multi, 0 for single 16 frame_idx int32 0-based frame index inside the phase 17 frame_filename str the source filename (e.g. "00000.png") 18 split str | null "easy" | "medium" | "hard" (null if no split) 19 has_rgb bool True iff the frame appears in rgb.tar 20 has_depth bool 21 has_fisheye bool 22 has_cam_pose bool 23 has_masks bool 24 has_masks_aux bool 25 has_sam2_meta bool 26 shard_rgb str | null repo-relative path to rgb.tar (or null) 27 shard_depth str | null 28 shard_fisheye str | null 29 shard_cam_pose str | null 30 shard_masks str | null 31 shard_masks_aux str | null 32 shard_sam2_meta str | null 33 34Plus a sibling ``manifest/current.json`` that maps logical label names 35to the version currently considered "default" (e.g. ``masks → v1``). 36The downloader reads this first to resolve recipe modalities to actual 37shard paths. 38""" 39 40from __future__ import annotations 41 42import json 43from dataclasses import dataclass 44from pathlib import Path 45from typing import Any, Dict, List, Mapping, Optional 46 47from ..exceptions import DatasetError 48from ..logging_utils import get_logger 49from .packer import PackedShard, PackResult 50from .recipes import ( 51 CAM_POSE, 52 DEPTH, 53 FISHEYE, 54 MASKS, 55 MASKS_AUX, 56 RGB, 57 SceneType, 58) # noqa: F401 CAM_POSE used in default label_versions 59from .scanner import SRC_SUBDIR_BY_TYPE, ScanResult 60 61log = get_logger(__name__) 62 63 64SCHEMA_VERSION = "v1" 65 66# Modality → ("has_X", "shard_X") column-name pair. Order is the canonical 67# column order in the Parquet table. 68_COLUMNS: Dict[str, tuple[str, str]] = { 69 RGB: ("has_rgb", "shard_rgb"), 70 DEPTH: ("has_depth", "shard_depth"), 71 FISHEYE: ("has_fisheye", "shard_fisheye"), 72 CAM_POSE: ("has_cam_pose", "shard_cam_pose"), 73 MASKS: ("has_masks", "shard_masks"), 74 MASKS_AUX: ("has_masks_aux", "shard_masks_aux"), 75 "sam2_meta": ("has_sam2_meta", "shard_sam2_meta"), 76} 77 78 79@dataclass(frozen=True) 80class ManifestPaths: 81 """Where the manifest writer dropped its outputs.""" 82 83 parquet_path: Path 84 current_json_path: Path 85 86 87def _arrow(): 88 """Lazy-import pyarrow with a friendly error message.""" 89 try: 90 import pyarrow as pa # noqa: WPS433 91 import pyarrow.parquet as pq # noqa: WPS433 92 except ImportError as e: 93 raise DatasetError( 94 "pyarrow is required to write the dataset manifest.", 95 hint="Install with: pip install 'rpx-benchmark[hub]'", 96 ) from e 97 return pa, pq 98 99 100def _index_shards_by_phase( 101 pack: PackResult, 102) -> Dict[tuple[SceneType, str, int], Dict[str, PackedShard]]: 103 """Group shards by (scene_type, scene_id, phase) for fast lookup while 104 walking frames. 105 106 scene_type is part of the key deliberately — ego scenes intentionally 107 share their exact scene_id string with their mos/ sibling (that's how 108 split/difficulty assignment joins them), so (scene_id, phase) alone is 109 NOT unique once ego scenes exist. Keying by scene_id alone here used to 110 silently let one scene type's shards clobber the other's for any 111 (scene_id, phase) pair they both used — same bug class as 112 _frame_filenames_for below. 113 """ 114 out: Dict[tuple[SceneType, str, int], Dict[str, PackedShard]] = {} 115 for s in pack.shards: 116 key = (s.scene_type, s.scene_id, s.phase) 117 out.setdefault(key, {})[s.modality] = s 118 return out 119 120 121def _frame_filenames_for( 122 scan: ScanResult, 123 scene_type: SceneType, 124 scene_id: str, 125 phase_index: int, 126) -> List[str]: 127 """Pick one modality (rgb, then depth, then anything) and return its 128 sorted filenames as the canonical frame list for that phase. 129 130 scene_type disambiguates scenes that share a scene_id string across 131 families (ego scenes deliberately reuse their mos/ sibling's scene_id — 132 see _index_shards_by_phase) — without it, `next(... if s.scene_id == 133 scene_id)` could resolve to the WRONG scene's phase entirely. 134 """ 135 scene = next( 136 s for s in scan.scenes if s.scene_id == scene_id and s.scene_type is scene_type 137 ) 138 phase = next(p for p in scene.phases if p.phase_index == phase_index) 139 src = scan.root / SRC_SUBDIR_BY_TYPE[scene_type] / scene_id / str(phase_index) 140 for prefer in ("rgb", "depth", "fisheye"): 141 if prefer in phase.modalities: 142 return sorted(p.name for p in (src / prefer).iterdir() if p.is_file()) 143 return [] 144 145 146# Logical-modality → on-disk sub-path relative to a phase root. These map 147# from the split-manifest's modality vocabulary onto where the actual 148# files live in the scanned tree. Used by :func:`_detect_modality_extensions` 149# to sniff per-modality file extensions for ``current.json``. 150_MODALITY_SUBPATH_FOR_DETECTION: Dict[str, str] = { 151 "rgb": "rgb", 152 "depth": "depth", 153 "fisheye": "fisheye", 154 "fisheye_left": "fisheye/left", 155 "fisheye_right": "fisheye/right", 156 "masks": "sam2/masks", 157 "cam_pose": "cam_pose", 158} 159 160 161def _detect_modality_extensions(scan: ScanResult) -> Dict[str, str]: 162 """Sniff one file per modality directory in the scanned tree and 163 return ``{modality_name: ".png" | ".webp" | ".npz" | ".npy"}``. 164 165 Records the dataset's *on-disk* extension per modality so the 166 split-manifest writer (and any other downstream consumer) can build 167 paths that resolve after extraction without hardcoded assumptions. 168 169 Backward-compatible: if a modality's directory is missing or empty 170 in the scanned tree, that modality is omitted from the returned 171 dict, and downstream code falls back to its built-in defaults. 172 173 Detection runs per scene_type first, then merges: ``current.json``'s 174 ``modality_extensions`` is a single flat ``{modality: ext}`` dict 175 shared by every scene family (mos/sos/ego), with no scene_type 176 dimension. If two families actually disagree on a modality's on-disk 177 extension at manifest-build time (e.g. mos ``rgb`` already 178 lossless-converted to ``.webp`` while a freshly-added ego family is 179 still raw ``.png``), silently picking whichever scene the scan 180 happens to hit first would corrupt the OTHER family's manifest paths 181 without any error. Raise instead of guessing. 182 """ 183 per_type: Dict[SceneType, Dict[str, str]] = {} 184 for scene in scan.scenes: 185 if not scene.phases: 186 continue 187 by_modality = per_type.setdefault(scene.scene_type, {}) 188 if len(by_modality) == len(_MODALITY_SUBPATH_FOR_DETECTION): 189 continue # this scene_type is already fully covered 190 sub = SRC_SUBDIR_BY_TYPE[scene.scene_type] 191 phase_root = scan.root / sub / scene.scene_id / str(scene.phases[0].phase_index) 192 for modality, subpath in _MODALITY_SUBPATH_FOR_DETECTION.items(): 193 if modality in by_modality: 194 continue 195 mod_dir = phase_root / subpath 196 if not mod_dir.is_dir(): 197 continue 198 # Pick the first regular file under this modality's subdir; 199 # rglob handles the case where files live one level deeper 200 # (e.g. ``fisheye/`` has ``left/`` and ``right/`` subdirs but 201 # no files directly inside). 202 for entry in sorted(mod_dir.rglob("*")): 203 if entry.is_file(): 204 by_modality[modality] = entry.suffix 205 break 206 207 found: Dict[str, str] = {} 208 conflicts: List[str] = [] 209 for scene_type, by_modality in per_type.items(): 210 for modality, ext in by_modality.items(): 211 if modality in found and found[modality] != ext: 212 conflicts.append( 213 f"{modality!r} is {found[modality]!r} elsewhere but " 214 f"{ext!r} for scene_type={scene_type.value!r}" 215 ) 216 continue 217 found[modality] = ext 218 219 if conflicts: 220 raise DatasetError( 221 "Conflicting on-disk file extensions for the same modality " 222 f"across scene types: {'; '.join(conflicts)}.", 223 hint=( 224 "modality_extensions in current.json has no scene_type " 225 "dimension -- every family must agree on the same on-disk " 226 "extension per modality before the manifest is rebuilt. " 227 "Run lossless_convert over whichever family is out of sync " 228 "(commonly: a newly-added family that hasn't been " 229 "converted yet) and retry." 230 ), 231 ) 232 return found 233 234 235def build_frame_manifest( 236 scan: ScanResult, 237 pack: PackResult, 238 out_dir: Path, 239 splits: Optional[Mapping[str, str]] = None, 240 label_versions: Optional[Mapping[str, str]] = None, 241) -> ManifestPaths: 242 """Write ``manifest/frames_<v>.parquet`` and ``manifest/current.json``. 243 244 Parameters 245 ---------- 246 scan : ScanResult 247 Output of :func:`scan_capture_root`. 248 pack : PackResult 249 Output of :func:`pack_capture_tree`. 250 out_dir : Path 251 Where to write ``frames_<v>.parquet`` and ``current.json``. Both 252 end up under ``out_dir / "manifest" / ...``. 253 splits : Mapping[str, str], optional 254 ``{scene_id: "easy" | "medium" | "hard"}`` for multi-object scenes. 255 Single-object scenes are always assigned ``None``. 256 label_versions : Mapping[str, str], optional 257 Logical-label → version map written verbatim to ``current.json``. 258 Defaults to ``{"masks": "v1", "masks_aux": "v1", "sam2_meta": "v1"}``. 259 """ 260 pa, pq = _arrow() 261 splits = dict(splits or {}) 262 label_versions = dict( 263 label_versions 264 or { 265 MASKS: "v1", 266 MASKS_AUX: "v1", 267 "sam2_meta": "v1", 268 CAM_POSE: "v1", 269 } 270 ) 271 out_dir = Path(out_dir) 272 (out_dir / "manifest").mkdir(parents=True, exist_ok=True) 273 274 shard_index = _index_shards_by_phase(pack) 275 276 # Build the table column-by-column for memory efficiency. 277 cols: Dict[str, list] = { 278 "scene_id": [], 279 "scene_type": [], 280 "phase": [], 281 "frame_idx": [], 282 "frame_filename": [], 283 "split": [], 284 } 285 for has_col, shard_col in _COLUMNS.values(): 286 cols[has_col] = [] 287 cols[shard_col] = [] 288 289 for scene in scan.scenes: 290 for phase in scene.phases: 291 shards_here = shard_index.get( 292 (scene.scene_type, scene.scene_id, phase.phase_index), {} 293 ) 294 filenames = _frame_filenames_for( 295 scan, 296 scene.scene_type, 297 scene.scene_id, 298 phase.phase_index, 299 ) 300 # Ego scenes reuse their MOS sibling's split/difficulty tier — 301 # same scene_id (e.g. "scene20.su.checkerboard"), no separate 302 # ego difficulty scoring. SOS (single-object, no scene-level 303 # difficulty concept) still gets None. 304 split_label = ( 305 splits.get(scene.scene_id) 306 if scene.scene_type in (SceneType.MULTI_OBJECT, SceneType.EGO) 307 else None 308 ) 309 for idx, fname in enumerate(filenames): 310 cols["scene_id"].append(scene.scene_id) 311 cols["scene_type"].append(scene.scene_type.value) 312 cols["phase"].append(phase.phase_index) 313 cols["frame_idx"].append(idx) 314 cols["frame_filename"].append(fname) 315 cols["split"].append(split_label) 316 for modality, (has_col, shard_col) in _COLUMNS.items(): 317 shard = shards_here.get(modality) 318 cols[has_col].append(shard is not None) 319 cols[shard_col].append(shard.repo_path if shard else None) 320 321 schema = pa.schema( 322 [ 323 ("scene_id", pa.string()), 324 ("scene_type", pa.string()), 325 ("phase", pa.int32()), 326 ("frame_idx", pa.int32()), 327 ("frame_filename", pa.string()), 328 ("split", pa.string()), 329 *[(c, pa.bool_()) for (c, _) in _COLUMNS.values()], 330 *[(c, pa.string()) for (_, c) in _COLUMNS.values()], 331 ] 332 ) 333 table = pa.table({k: cols[k] for k in schema.names}, schema=schema) 334 335 parquet_path = out_dir / "manifest" / f"frames_{SCHEMA_VERSION}.parquet" 336 pq.write_table(table, parquet_path, compression="zstd") 337 338 current_path = out_dir / "manifest" / "current.json" 339 modality_extensions = _detect_modality_extensions(scan) 340 341 # Preserve any pre-existing current.json keys we do not own. The live 342 # IRVLUTD/RPX repo's current.json carries additional top-level blocks 343 # (``manifests``, ``sos``, ``mos``, ``metadata_versions``, …) that 344 # the teammate's separate manifest tooling writes. A naive overwrite 345 # would strip them and break downstream consumers; instead we read 346 # the existing payload (if any), update only the keys we are 347 # authoritative for, and write the merged result back. 348 # 349 # The keys we own (and will overwrite on every run): 350 # 351 # * ``label_versions`` — from the ``--label-version`` flag 352 # * ``schema_version`` — fixed at this module's SCHEMA_VERSION 353 # * ``modality_extensions`` — sniffed from the scanned source tree 354 # 355 # Everything else is left untouched. 356 OWNED_KEYS = {"label_versions", "schema_version", "modality_extensions"} 357 if current_path.is_file(): 358 try: 359 current_payload: Dict[str, Any] = json.loads( 360 current_path.read_text(encoding="utf-8"), 361 ) 362 if not isinstance(current_payload, dict): 363 current_payload = {} 364 except json.JSONDecodeError: 365 current_payload = {} 366 else: 367 current_payload = {} 368 current_payload["label_versions"] = label_versions 369 current_payload["schema_version"] = SCHEMA_VERSION 370 if modality_extensions: 371 current_payload["modality_extensions"] = modality_extensions 372 # Don't carry stale modality_extensions if it has nothing to say 373 # AND no prior writer set it. 374 elif "modality_extensions" in current_payload and not current_payload["modality_extensions"]: 375 current_payload.pop("modality_extensions") 376 preserved = sorted(k for k in current_payload if k not in OWNED_KEYS) 377 if preserved: 378 log.info( 379 "current.json: preserved %d unmanaged top-level key(s): %s", 380 len(preserved), 381 ", ".join(preserved), 382 ) 383 current_path.write_text( 384 json.dumps(current_payload, indent=2), 385 encoding="utf-8", 386 ) 387 388 # Persist per-tar SHA-256 to manifest/checksums.json. The packer 389 # computes these as part of pack_capture_tree; the downstream 390 # download-side verification in hub._extract_snapshot_tars reads 391 # this file and aborts on any tar whose bytes don't match. Tar 392 # entries whose sha256 is None (rare — only when re-running 393 # manifest without a fresh pack) are still written with a null 394 # value so the absence is explicit, not silent. 395 checksums_path = out_dir / "manifest" / "checksums.json" 396 checksums = {s.repo_path: s.sha256 for s in pack.shards} 397 checksums_path.write_text( 398 json.dumps( 399 {"sha256": checksums, "algorithm": "sha256"}, 400 indent=2, 401 sort_keys=True, 402 ), 403 encoding="utf-8", 404 ) 405 406 log.info("wrote manifest: %d rows → %s", len(table), parquet_path) 407 n_with_sha = sum(1 for v in checksums.values() if v) 408 log.info( 409 "wrote checksums.json: %d/%d tar shards have SHA-256 → %s", 410 n_with_sha, 411 len(checksums), 412 checksums_path, 413 ) 414 415 # Per-file SHA-256: walk every packed tar, stream each member through 416 # SHA-256, and persist the mapping at manifest/file_checksums.json. 417 # This is the foundation for ``verify_dataset(snapshot_root)`` to 418 # detect local-disk corruption between extract and load — the case 419 # the tar-level SHA-256 cannot guard against. 420 file_checksums = _compute_per_file_checksums(out_dir, pack) 421 file_checksums_path = out_dir / "manifest" / "file_checksums.json" 422 file_checksums_path.write_text( 423 json.dumps( 424 {"algorithm": "sha256", "sha256": file_checksums}, 425 indent=2, 426 sort_keys=True, 427 ), 428 encoding="utf-8", 429 ) 430 log.info( 431 "wrote file_checksums.json: %d extracted-file SHA-256 entries → %s", 432 len(file_checksums), 433 file_checksums_path, 434 ) 435 436 if modality_extensions: 437 log.info( 438 "current.json modality_extensions: %s", 439 ", ".join(f"{k}={v}" for k, v in sorted(modality_extensions.items())), 440 ) 441 return ManifestPaths(parquet_path=parquet_path, current_json_path=current_path) 442 443 444def _compute_per_file_checksums( 445 out_dir: Path, 446 pack: PackResult, 447) -> Dict[str, str]: 448 """For every member of every packed tar, compute SHA-256 and key it 449 by the *extracted* repo path the user will see after 450 ``_extract_snapshot_tars`` runs. 451 452 Returns ``{ "extracted/scenes/<scene>/<phase>/<member>": sha256_hex }``. 453 454 The per-tar SHA-256 (in ``checksums.json``) guards transport 455 corruption from operator → HF → user. This per-file map guards 456 everything *after* extraction: bit rot on the user's disk, OS-level 457 file corruption, accidental overwrites. 458 """ 459 import hashlib 460 import tarfile 461 462 out: Dict[str, str] = {} 463 for shard in pack.shards: 464 tar_path = out_dir / shard.repo_path 465 if not tar_path.is_file(): 466 continue 467 # The extracted layout mirrors the tar's nesting: a tar at 468 # ``scenes/scene_x/0/rgb.tar`` whose members are ``rgb/<frame>.webp`` 469 # extracts to ``extracted/scenes/scene_x/0/rgb/<frame>.webp``. 470 # Strip the ``.tar`` filename to get the extracted prefix. 471 rel_parts = shard.repo_path.split("/") 472 if not rel_parts or not rel_parts[-1].endswith(".tar"): 473 continue 474 prefix_parts = rel_parts[:-1] 475 # labels/<modality>/<version>.tar extracts straight to 476 # extracted/.../<modality>'s file layout — drop the ``labels/`` and 477 # ``<version>`` segments so the result lives under the modality. 478 if "labels" in prefix_parts: 479 li = prefix_parts.index("labels") 480 prefix_parts = prefix_parts[:li] 481 extracted_prefix = "extracted/" + "/".join(prefix_parts) if prefix_parts else "extracted" 482 try: 483 with tarfile.open(tar_path, "r") as tf: 484 for member in tf.getmembers(): 485 if not member.isfile(): 486 continue 487 f = tf.extractfile(member) 488 if f is None: 489 continue 490 h = hashlib.sha256() 491 for chunk in iter(lambda f=f: f.read(1 << 20), b""): 492 h.update(chunk) 493 extracted_path = f"{extracted_prefix}/{member.name}" 494 out[extracted_path] = h.hexdigest() 495 except tarfile.TarError: 496 continue 497 return out 498 499 500def read_frame_manifest(parquet_path: Path): 501 """Read a manifest Parquet back as a pyarrow Table (test/debug aid).""" 502 _, pq = _arrow() 503 return pq.read_table(parquet_path)
80@dataclass(frozen=True) 81class ManifestPaths: 82 """Where the manifest writer dropped its outputs.""" 83 84 parquet_path: Path 85 current_json_path: Path
Where the manifest writer dropped its outputs.
236def build_frame_manifest( 237 scan: ScanResult, 238 pack: PackResult, 239 out_dir: Path, 240 splits: Optional[Mapping[str, str]] = None, 241 label_versions: Optional[Mapping[str, str]] = None, 242) -> ManifestPaths: 243 """Write ``manifest/frames_<v>.parquet`` and ``manifest/current.json``. 244 245 Parameters 246 ---------- 247 scan : ScanResult 248 Output of :func:`scan_capture_root`. 249 pack : PackResult 250 Output of :func:`pack_capture_tree`. 251 out_dir : Path 252 Where to write ``frames_<v>.parquet`` and ``current.json``. Both 253 end up under ``out_dir / "manifest" / ...``. 254 splits : Mapping[str, str], optional 255 ``{scene_id: "easy" | "medium" | "hard"}`` for multi-object scenes. 256 Single-object scenes are always assigned ``None``. 257 label_versions : Mapping[str, str], optional 258 Logical-label → version map written verbatim to ``current.json``. 259 Defaults to ``{"masks": "v1", "masks_aux": "v1", "sam2_meta": "v1"}``. 260 """ 261 pa, pq = _arrow() 262 splits = dict(splits or {}) 263 label_versions = dict( 264 label_versions 265 or { 266 MASKS: "v1", 267 MASKS_AUX: "v1", 268 "sam2_meta": "v1", 269 CAM_POSE: "v1", 270 } 271 ) 272 out_dir = Path(out_dir) 273 (out_dir / "manifest").mkdir(parents=True, exist_ok=True) 274 275 shard_index = _index_shards_by_phase(pack) 276 277 # Build the table column-by-column for memory efficiency. 278 cols: Dict[str, list] = { 279 "scene_id": [], 280 "scene_type": [], 281 "phase": [], 282 "frame_idx": [], 283 "frame_filename": [], 284 "split": [], 285 } 286 for has_col, shard_col in _COLUMNS.values(): 287 cols[has_col] = [] 288 cols[shard_col] = [] 289 290 for scene in scan.scenes: 291 for phase in scene.phases: 292 shards_here = shard_index.get( 293 (scene.scene_type, scene.scene_id, phase.phase_index), {} 294 ) 295 filenames = _frame_filenames_for( 296 scan, 297 scene.scene_type, 298 scene.scene_id, 299 phase.phase_index, 300 ) 301 # Ego scenes reuse their MOS sibling's split/difficulty tier — 302 # same scene_id (e.g. "scene20.su.checkerboard"), no separate 303 # ego difficulty scoring. SOS (single-object, no scene-level 304 # difficulty concept) still gets None. 305 split_label = ( 306 splits.get(scene.scene_id) 307 if scene.scene_type in (SceneType.MULTI_OBJECT, SceneType.EGO) 308 else None 309 ) 310 for idx, fname in enumerate(filenames): 311 cols["scene_id"].append(scene.scene_id) 312 cols["scene_type"].append(scene.scene_type.value) 313 cols["phase"].append(phase.phase_index) 314 cols["frame_idx"].append(idx) 315 cols["frame_filename"].append(fname) 316 cols["split"].append(split_label) 317 for modality, (has_col, shard_col) in _COLUMNS.items(): 318 shard = shards_here.get(modality) 319 cols[has_col].append(shard is not None) 320 cols[shard_col].append(shard.repo_path if shard else None) 321 322 schema = pa.schema( 323 [ 324 ("scene_id", pa.string()), 325 ("scene_type", pa.string()), 326 ("phase", pa.int32()), 327 ("frame_idx", pa.int32()), 328 ("frame_filename", pa.string()), 329 ("split", pa.string()), 330 *[(c, pa.bool_()) for (c, _) in _COLUMNS.values()], 331 *[(c, pa.string()) for (_, c) in _COLUMNS.values()], 332 ] 333 ) 334 table = pa.table({k: cols[k] for k in schema.names}, schema=schema) 335 336 parquet_path = out_dir / "manifest" / f"frames_{SCHEMA_VERSION}.parquet" 337 pq.write_table(table, parquet_path, compression="zstd") 338 339 current_path = out_dir / "manifest" / "current.json" 340 modality_extensions = _detect_modality_extensions(scan) 341 342 # Preserve any pre-existing current.json keys we do not own. The live 343 # IRVLUTD/RPX repo's current.json carries additional top-level blocks 344 # (``manifests``, ``sos``, ``mos``, ``metadata_versions``, …) that 345 # the teammate's separate manifest tooling writes. A naive overwrite 346 # would strip them and break downstream consumers; instead we read 347 # the existing payload (if any), update only the keys we are 348 # authoritative for, and write the merged result back. 349 # 350 # The keys we own (and will overwrite on every run): 351 # 352 # * ``label_versions`` — from the ``--label-version`` flag 353 # * ``schema_version`` — fixed at this module's SCHEMA_VERSION 354 # * ``modality_extensions`` — sniffed from the scanned source tree 355 # 356 # Everything else is left untouched. 357 OWNED_KEYS = {"label_versions", "schema_version", "modality_extensions"} 358 if current_path.is_file(): 359 try: 360 current_payload: Dict[str, Any] = json.loads( 361 current_path.read_text(encoding="utf-8"), 362 ) 363 if not isinstance(current_payload, dict): 364 current_payload = {} 365 except json.JSONDecodeError: 366 current_payload = {} 367 else: 368 current_payload = {} 369 current_payload["label_versions"] = label_versions 370 current_payload["schema_version"] = SCHEMA_VERSION 371 if modality_extensions: 372 current_payload["modality_extensions"] = modality_extensions 373 # Don't carry stale modality_extensions if it has nothing to say 374 # AND no prior writer set it. 375 elif "modality_extensions" in current_payload and not current_payload["modality_extensions"]: 376 current_payload.pop("modality_extensions") 377 preserved = sorted(k for k in current_payload if k not in OWNED_KEYS) 378 if preserved: 379 log.info( 380 "current.json: preserved %d unmanaged top-level key(s): %s", 381 len(preserved), 382 ", ".join(preserved), 383 ) 384 current_path.write_text( 385 json.dumps(current_payload, indent=2), 386 encoding="utf-8", 387 ) 388 389 # Persist per-tar SHA-256 to manifest/checksums.json. The packer 390 # computes these as part of pack_capture_tree; the downstream 391 # download-side verification in hub._extract_snapshot_tars reads 392 # this file and aborts on any tar whose bytes don't match. Tar 393 # entries whose sha256 is None (rare — only when re-running 394 # manifest without a fresh pack) are still written with a null 395 # value so the absence is explicit, not silent. 396 checksums_path = out_dir / "manifest" / "checksums.json" 397 checksums = {s.repo_path: s.sha256 for s in pack.shards} 398 checksums_path.write_text( 399 json.dumps( 400 {"sha256": checksums, "algorithm": "sha256"}, 401 indent=2, 402 sort_keys=True, 403 ), 404 encoding="utf-8", 405 ) 406 407 log.info("wrote manifest: %d rows → %s", len(table), parquet_path) 408 n_with_sha = sum(1 for v in checksums.values() if v) 409 log.info( 410 "wrote checksums.json: %d/%d tar shards have SHA-256 → %s", 411 n_with_sha, 412 len(checksums), 413 checksums_path, 414 ) 415 416 # Per-file SHA-256: walk every packed tar, stream each member through 417 # SHA-256, and persist the mapping at manifest/file_checksums.json. 418 # This is the foundation for ``verify_dataset(snapshot_root)`` to 419 # detect local-disk corruption between extract and load — the case 420 # the tar-level SHA-256 cannot guard against. 421 file_checksums = _compute_per_file_checksums(out_dir, pack) 422 file_checksums_path = out_dir / "manifest" / "file_checksums.json" 423 file_checksums_path.write_text( 424 json.dumps( 425 {"algorithm": "sha256", "sha256": file_checksums}, 426 indent=2, 427 sort_keys=True, 428 ), 429 encoding="utf-8", 430 ) 431 log.info( 432 "wrote file_checksums.json: %d extracted-file SHA-256 entries → %s", 433 len(file_checksums), 434 file_checksums_path, 435 ) 436 437 if modality_extensions: 438 log.info( 439 "current.json modality_extensions: %s", 440 ", ".join(f"{k}={v}" for k, v in sorted(modality_extensions.items())), 441 ) 442 return ManifestPaths(parquet_path=parquet_path, current_json_path=current_path)
Write manifest/frames_<v>.parquet and manifest/current.json.
Parameters
scan : ScanResult
Output of scan_capture_root().
pack : PackResult
Output of pack_capture_tree().
out_dir : Path
Where to write frames_<v>.parquet and current.json. Both
end up under out_dir / "manifest" / ....
splits : Mapping[str, str], optional
{scene_id: "easy" | "medium" | "hard"} for multi-object scenes.
Single-object scenes are always assigned None.
label_versions : Mapping[str, str], optional
Logical-label → version map written verbatim to current.json.
Defaults to {"masks": "v1", "masks_aux": "v1", "sam2_meta": "v1"}.
501def read_frame_manifest(parquet_path: Path): 502 """Read a manifest Parquet back as a pyarrow Table (test/debug aid).""" 503 _, pq = _arrow() 504 return pq.read_table(parquet_path)
Read a manifest Parquet back as a pyarrow Table (test/debug aid).