rpx_benchmark.dataset_hub.cli

python -m rpx_benchmark.dataset_hub.cli โ€” sub-commands for the dataset hub.

Available sub-commands::

mock      --out PATH [--multi N] [--single M] [--frames F] [--size S]
scan      PATH                                                 [--json]
pack      --src PATH --staging PATH [--label-version V] [--overwrite]
manifest  --src PATH --pack-staging PATH --staging PATH [--splits FILE]
upload    --staging PATH --repo-id ORG/REPO [--dry-run] [--public]

The CLI uses argparse + sub-parsers so we can grow it without breaking existing invocations.

  1"""``python -m rpx_benchmark.dataset_hub.cli`` โ€” sub-commands for the dataset hub.
  2
  3Available sub-commands::
  4
  5    mock      --out PATH [--multi N] [--single M] [--frames F] [--size S]
  6    scan      PATH                                                 [--json]
  7    pack      --src PATH --staging PATH [--label-version V] [--overwrite]
  8    manifest  --src PATH --pack-staging PATH --staging PATH [--splits FILE]
  9    upload    --staging PATH --repo-id ORG/REPO [--dry-run] [--public]
 10
 11The CLI uses argparse + sub-parsers so we can grow it without breaking
 12existing invocations.
 13"""
 14
 15from __future__ import annotations
 16
 17import argparse
 18import json
 19import os
 20import sys
 21from pathlib import Path
 22from typing import List, Optional
 23
 24from .croissant import CroissantPatch, stage_croissant
 25from .dataset_card import CardSpec, write_dataset_card
 26from .downloader import download_for_task
 27from .ego_layout import prepare_ego_layout
 28from .lossless_convert import ConvertSpec, convert_capture_tree
 29from .manifest import build_frame_manifest
 30from .mock import MockSpec, generate_mock, measure_tree
 31from .packer import PackPlan, pack_capture_tree, pack_objects_meta
 32from .recipes import DEFAULT_REPO_ID, SceneType
 33from .scanner import scan_capture_root
 34from .staging import load_scene_splits, stage_splits
 35from .uploader import UploadPlan, upload_staging
 36
 37# --------------------------------------------------------------------- #
 38# Formatting helpers
 39# --------------------------------------------------------------------- #
 40
 41
 42def _human_bytes(n: int) -> str:
 43    for unit in ("B", "KB", "MB", "GB", "TB"):
 44        if n < 1024 or unit == "TB":
 45            return f"{n:6.1f} {unit}" if unit != "B" else f"{n:6d} {unit}"
 46        n /= 1024
 47    return f"{n:.1f} TB"
 48
 49
 50# --------------------------------------------------------------------- #
 51# `mock`
 52# --------------------------------------------------------------------- #
 53
 54
 55def _cmd_mock(args: argparse.Namespace) -> int:
 56    spec = MockSpec(
 57        multi_object_scenes=args.multi,
 58        single_object_scenes=args.single,
 59        frames_per_phase=args.frames,
 60        image_size=args.size,
 61    )
 62    out = generate_mock(Path(args.out), spec)
 63    files, total = measure_tree(out)
 64    print(f"[mock] wrote {files} files ({_human_bytes(total)}) under {out}")
 65    return 0
 66
 67
 68# --------------------------------------------------------------------- #
 69# `scan`
 70# --------------------------------------------------------------------- #
 71
 72
 73def _cmd_scan(args: argparse.Namespace) -> int:
 74    res = scan_capture_root(Path(args.root))
 75
 76    if args.json:
 77        payload = {
 78            "root": str(res.root),
 79            "totals": {
 80                "scenes": len(res.scenes),
 81                "files": res.file_count,
 82                "bytes": res.total_bytes,
 83            },
 84            "by_type": {
 85                scene_type.value: {
 86                    "scenes": len(res.by_type(scene_type)),
 87                    "bytes": sum(s.total_bytes for s in res.by_type(scene_type)),
 88                }
 89                for scene_type in SceneType
 90            },
 91            "modalities": {
 92                name: {"files": inv.file_count, "bytes": inv.total_bytes}
 93                for name, inv in res.modality_totals().items()
 94            },
 95            "skipped": [str(p) for p in res.skipped],
 96        }
 97        json.dump(payload, sys.stdout, indent=2)
 98        sys.stdout.write("\n")
 99        return 0
100
101    print(f"\n๐Ÿ“‚  {res.root}")
102    print(
103        f"    {len(res.scenes)} scenes, {res.file_count:,} files, "
104        f"{_human_bytes(res.total_bytes)} total\n"
105    )
106
107    for scene_type in SceneType:
108        subset = res.by_type(scene_type)
109        if not subset:
110            continue
111        bytes_ = sum(s.total_bytes for s in subset)
112        files_ = sum(s.file_count for s in subset)
113        print(
114            f"  ยท {scene_type.value:14s}  {len(subset):4d} scenes  "
115            f"{files_:>9,} files  {_human_bytes(bytes_)}"
116        )
117
118    print("\n  per-modality totals (across all scenes & phases):")
119    for name, inv in res.modality_totals().items():
120        print(f"    {name:24s}  {inv.file_count:>9,} files  {_human_bytes(inv.total_bytes)}")
121
122    if res.skipped:
123        print(f"\n  โš  skipped {len(res.skipped)} non-matching entries:")
124        for p in res.skipped[:5]:
125            print(f"    - {p.name}")
126        if len(res.skipped) > 5:
127            print(f"    ... and {len(res.skipped) - 5} more")
128    print()
129    return 0
130
131
132def _cmd_prepare_ego(args: argparse.Namespace) -> int:
133    scenes = args.scenes.split(",") if args.scenes else None
134    report = prepare_ego_layout(
135        ego_captures_root=Path(args.ego_root),
136        mos_root=Path(args.mos_root),
137        dst_root=Path(args.dst),
138        scenes=scenes,
139        include_masks_verified=args.include_masks_verified,
140        mode="copy" if args.copy else "symlink",
141    )
142    ok = report.ok
143    skipped = report.skipped
144    print(f"\n  arranged {len(ok)} ego scene(s) -> {Path(args.dst) / 'ego'}")
145    for r in ok:
146        print(f"    โœ“ {r.ego_scene_dir:12s} -> ego/{r.scene_id}/0")
147    if skipped:
148        print(f"\n  โš  skipped {len(skipped)}:")
149        for r in skipped:
150            print(f"    - {r.ego_scene_dir:12s} [{r.status}] {r.message}")
151    print(
152        "\n  NOTE: scene_id join to splits/scene_splits.json is an exact string "
153        "match โ€” verify your mos_root's directory names actually match the "
154        "splits file's naming (padding/casing can silently drift; run `scan` "
155        "and cross-check before trusting difficulty tiers downstream)."
156    )
157    print()
158    return 0
159
160
161# --------------------------------------------------------------------- #
162# `pack`
163# --------------------------------------------------------------------- #
164
165
166def _cmd_pack(args: argparse.Namespace) -> int:
167    src = Path(args.src)
168    staging = Path(args.staging)
169    scan = scan_capture_root(src)
170    plan = PackPlan(
171        src_root=src,
172        staging_root=staging,
173        label_version=args.label_version,
174        overwrite=args.overwrite,
175    )
176    res = pack_capture_tree(plan, scan)
177    print(
178        f"[pack] wrote {len(res.shards)} tar shards "
179        f"({_human_bytes(res.total_bytes)} total) under {staging}"
180    )
181    if res.skipped_keys:
182        print(
183            f"[pack] skipped {len(res.skipped_keys)} unknown modality keys "
184            f"(first 3: {res.skipped_keys[:3]})"
185        )
186    artefacts = pack_objects_meta(plan, scan)
187    print(f"[pack] wrote {len(artefacts)} per-object questionnaires under {staging}/objects_meta/")
188    return 0
189
190
191# --------------------------------------------------------------------- #
192# `manifest`
193# --------------------------------------------------------------------- #
194
195
196def _cmd_manifest(args: argparse.Namespace) -> int:
197    src = Path(args.src)
198    staging = Path(args.staging)
199    scan = scan_capture_root(src)
200    # Re-pack-walk: rebuild PackResult by mirroring what the staging dir
201    # currently has on disk. We re-derive shard metadata from the tars.
202    pack = _rehydrate_pack_result(scan, staging)
203
204    splits = {}
205    if args.splits:
206        splits = json.loads(Path(args.splits).read_text(encoding="utf-8"))
207        # Accept both a flat {scene_id: tier} dict and the
208        # scene_splits.json shape {"splits": {"easy": [...], ...}}.
209        if "splits" in splits and isinstance(splits["splits"], dict):
210            splits = {sid: tier for tier, ids in splits["splits"].items() for sid in ids}
211
212    paths = build_frame_manifest(scan, pack, staging, splits=splits)
213    print(f"[manifest] wrote {paths.parquet_path}")
214    print(f"[manifest] wrote {paths.current_json_path}")
215
216    # Per-task per-split JSONs the toolkit's reader (`download_split`)
217    # expects at ``manifests/<task>/<split>.json``. Without these, a
218    # freshly published HF dataset 404s on every benchmark call.
219    from .split_manifests import write_split_manifests  # local import to avoid cycles
220
221    written = write_split_manifests(staging)
222    if not written:
223        # Loud failure: the published dataset would be unusable.
224        # Most common cause: ``--splits`` was not passed, so every row
225        # in the parquet has ``split=None`` and no per-task JSONs are
226        # produced. The hub README's canonical sequence requires
227        # ``--splits benchmark/data/splits/scene_splits.json``.
228        from ..exceptions import DatasetError
229
230        raise DatasetError(
231            "no per-task per-split manifests written โ€” published HF tree "
232            "would be unusable for `download_split`. Most likely cause: "
233            "the --splits flag was not passed. Re-run with: "
234            "`--splits benchmark/data/splits/scene_splits.json` (or "
235            "another scene-tier mapping).",
236            hint="If you intentionally want to skip split-manifests for "
237            "a partial scene set, pass --splits with at least one "
238            "scene mapped to a tier.",
239        )
240    print(
241        f"[manifest] wrote {len(written)} per-task per-split manifests under {staging}/manifests/"
242    )
243    return 0
244
245
246def _rehydrate_pack_result(scan, staging):
247    """Best-effort reconstruction of PackResult from the staging dir.
248
249    Re-reads tar member counts and sizes from disk and **re-computes
250    each tar's SHA-256**. The packer writes deterministic tars, so the
251    recomputed hash is the same as the one the packer originally
252    produced. We need it persisted to ``checksums.json`` for
253    download-side verification โ€” silently dropping it to ``None``
254    (as the previous implementation did) would have left the
255    fault-proof contract impossible to enforce at load time.
256
257    For the ~800 GB full-dataset pack on a modern NVMe disk this adds
258    a few minutes to the manifest step โ€” a one-time, upload-machine
259    cost that buys per-tar tamper-evidence for every user from then on.
260    """
261    import hashlib
262    import tarfile
263
264    from .packer import (  # local import to avoid cycles
265        SCENE_ROOT_BY_TYPE,
266        PackedShard,
267        PackResult,
268        phase_segment,
269    )
270
271    def _file_sha256(path: Path) -> str:
272        h = hashlib.sha256()
273        with path.open("rb") as f:
274            for chunk in iter(lambda: f.read(1 << 20), b""):
275                h.update(chunk)
276        return h.hexdigest()
277
278    shards = []
279    for scene in scan.scenes:
280        for phase in scene.phases:
281            scene_root = SCENE_ROOT_BY_TYPE[scene.scene_type]
282            seg = phase_segment(scene.scene_type, phase.phase_index)
283            base = Path(staging) / scene_root / scene.scene_id / seg
284            for tar_path in sorted(base.rglob("*.tar")) if base.is_dir() else []:
285                try:
286                    with tarfile.open(tar_path, "r") as tf:
287                        members = tf.getmembers()
288                        fc = sum(1 for m in members if m.isfile())
289                except tarfile.TarError:
290                    fc = 0
291                rel = tar_path.relative_to(staging).as_posix()
292                # Modality name: rgb.tar -> "rgb"; labels/masks/v1.tar -> "masks"
293                if "/labels/" in rel:
294                    modality = rel.split("/labels/", 1)[1].split("/", 1)[0]
295                    is_label = True
296                else:
297                    modality = tar_path.stem
298                    is_label = False
299                shards.append(
300                    PackedShard(
301                        repo_path=rel,
302                        scene_id=scene.scene_id,
303                        scene_type=scene.scene_type,
304                        phase=phase.phase_index,
305                        modality=modality,
306                        is_label=is_label,
307                        file_count=fc,
308                        total_bytes=tar_path.stat().st_size,
309                        sha256=_file_sha256(tar_path),
310                    )
311                )
312    return PackResult(shards=shards)
313
314
315# --------------------------------------------------------------------- #
316# `upload`
317# --------------------------------------------------------------------- #
318
319
320def _cmd_upload(args: argparse.Namespace) -> int:
321    plan = UploadPlan(
322        staging_root=Path(args.staging),
323        repo_id=args.repo_id,
324        revision=args.revision,
325        private=not args.public,
326        dry_run=args.dry_run,
327        commit_message=args.message,
328    )
329    res = upload_staging(plan)
330    if res.dry_run if hasattr(res, "dry_run") else args.dry_run:
331        print(
332            f"[upload-dry-run] would push {res.files_planned} files "
333            f"({_human_bytes(res.bytes_planned)}) to {res.repo_id}"
334        )
335    else:
336        print(
337            f"[upload] pushed {res.files_planned} files "
338            f"({_human_bytes(res.bytes_planned)}) to {res.repo_id}"
339        )
340        if res.commit_url:
341            print(f"[upload] commit: {res.commit_url}")
342    return 0
343
344
345# --------------------------------------------------------------------- #
346# `stage-splits`
347# --------------------------------------------------------------------- #
348
349
350def _cmd_stage_splits(args: argparse.Namespace) -> int:
351    src = Path(args.splits_src) if args.splits_src else None
352    staged = stage_splits(
353        staging_root=Path(args.staging),
354        splits_src=src,
355        overwrite=args.overwrite,
356        require_all=not args.allow_missing,
357    )
358    print(f"[stage-splits] copied {len(staged)} files into {args.staging}/splits/")
359    for s in staged:
360        print(f"               {s.repo_path}  ({_human_bytes(s.bytes_)})")
361    return 0
362
363
364# --------------------------------------------------------------------- #
365# `dataset-card`
366# --------------------------------------------------------------------- #
367
368
369def _cmd_dataset_card(args: argparse.Namespace) -> int:
370    src = Path(args.src)
371    staging = Path(args.staging)
372    scan = scan_capture_root(src)
373
374    splits = None
375    if (staging / "splits" / "scene_splits.json").is_file():
376        try:
377            splits = load_scene_splits(staging)
378        except Exception as e:
379            print(f"[dataset-card] WARN: could not load scene splits: {e}", file=sys.stderr)
380
381    label_versions = None
382    cur_path = staging / "manifest" / "current.json"
383    if cur_path.is_file():
384        label_versions = json.loads(cur_path.read_text("utf-8")).get(
385            "label_versions",
386        )
387
388    spec = CardSpec(repo_id=args.repo_id)
389    out = write_dataset_card(
390        staging,
391        scan,
392        spec=spec,
393        splits=splits,
394        label_versions=label_versions,
395        overwrite=args.overwrite,
396    )
397    print(f"[dataset-card] wrote {out} ({out.stat().st_size:,} bytes)")
398    return 0
399
400
401# --------------------------------------------------------------------- #
402# `verify` โ€” per-file SHA-256 integrity check of an extracted snapshot
403# --------------------------------------------------------------------- #
404
405
406def _cmd_verify(args: argparse.Namespace) -> int:
407    from ..exceptions import DatasetError
408    from ..hub import verify_dataset
409
410    try:
411        report = verify_dataset(Path(args.snapshot), raise_on_error=False)
412    except DatasetError as e:
413        print(f"[verify] {e}", file=sys.stderr)
414        return 2
415
416    if not report:
417        print(
418            f"[verify] no file_checksums.json under "
419            f"{args.snapshot}/manifest/ โ€” legacy dataset, skipping per-file check.",
420            file=sys.stderr,
421        )
422        return 0
423
424    ok = sum(1 for v in report.values() if v == "ok")
425    bad = [(p, v) for p, v in report.items() if v != "ok"]
426    print(f"[verify] {ok} / {len(report)} files OK")
427    if not bad:
428        return 0
429    print(f"[verify] {len(bad)} failures:")
430    for p, status in sorted(bad)[:20]:
431        print(f"  {status:10s}  {p}")
432    if len(bad) > 20:
433        print(f"  ... and {len(bad) - 20} more")
434    return 1
435
436
437# --------------------------------------------------------------------- #
438# `lossless-convert`
439# --------------------------------------------------------------------- #
440
441
442def _cmd_lossless_convert(args: argparse.Namespace) -> int:
443    # verify=True is unconditional from the CLI. Per-frame round-trip
444    # verification is the load-bearing guarantee that the output tree
445    # is bit-faithful to the source; we do not expose any way to skip
446    # it from the published CLI surface. (The ConvertSpec field still
447    # exists for programmatic callers โ€” tests, in particular โ€” but
448    # operator-facing flags don't let it be set False.)
449    spec = ConvertSpec(
450        src_root=Path(args.src),
451        out_root=Path(args.out),
452        workers=args.workers,
453        dry_run=args.dry_run,
454        verify=True,
455        overwrite_out=args.overwrite_out,
456        webp_method=args.webp_method,
457        png_compress_level=args.png_compress_level,
458        skip_rgb_webp=args.skip_rgb_webp,
459        skip_png_recompress=args.skip_png_recompress,
460        skip_cam_pose=args.skip_cam_pose,
461    )
462    res = convert_capture_tree(spec)
463    if spec.dry_run:
464        print(
465            f"[lossless-convert] DRY-RUN: would convert {res.files_converted:,} files, "
466            f"link {res.files_linked:,} files (total {res.files_seen:,})"
467        )
468        for action, stats in sorted(res.by_action.items()):
469            print(f"  {action:18s} {stats.files:>7,} files")
470        return 0
471    # Per-action savings breakdown
472    for action, stats in sorted(res.by_action.items()):
473        if stats.files == 0:
474            continue
475        print(
476            f"[lossless-convert] {action:18s} "
477            f"{stats.files:>7,} files  "
478            f"{_human_bytes(stats.bytes_before)} โ†’ {_human_bytes(stats.bytes_after)}  "
479            f"saved {_human_bytes(stats.saved_bytes)} ({stats.saved_pct:.1f}%)"
480        )
481    print(
482        f"[lossless-convert] TOTAL: {res.files_seen:,} files  "
483        f"{_human_bytes(res.bytes_before)} โ†’ {_human_bytes(res.bytes_after)}  "
484        f"saved {_human_bytes(res.saved_bytes)} ({res.saved_pct:.1f}%)"
485    )
486    return 0
487
488
489# --------------------------------------------------------------------- #
490# `stage-croissant`
491# --------------------------------------------------------------------- #
492
493
494def _cmd_stage_croissant(args: argparse.Namespace) -> int:
495    src = Path(args.src) if args.src else None
496    patch = CroissantPatch(repo_id=args.repo_id, version=args.version, cite_as=args.cite_as)
497    out = stage_croissant(
498        staging_root=Path(args.staging), src=src, patch=patch, overwrite=args.overwrite
499    )
500    print(f"[stage-croissant] wrote {out} ({out.stat().st_size:,} bytes)")
501    return 0
502
503
504# --------------------------------------------------------------------- #
505# `download`
506# --------------------------------------------------------------------- #
507
508
509def _cmd_download(args: argparse.Namespace) -> int:
510    extras = tuple(args.modalities.split(",")) if args.modalities else ()
511    label_versions = {}
512    for kv in args.label_version or ():
513        k, _, v = kv.partition("=")
514        if not k or not v:
515            print(f"[download] ignoring malformed --label-version entry: {kv}", file=sys.stderr)
516            continue
517        label_versions[k] = v
518
519    res = download_for_task(
520        task=args.task,
521        split=args.split,
522        repo_id=args.repo_id,
523        revision=args.revision,
524        cache_dir=Path(args.cache_dir) if args.cache_dir else None,
525        extra_modalities=extras,
526        label_versions=label_versions,
527    )
528    print(f"[download] task={res.task} split={res.split} scene_type={res.scene_type.value}")
529    print(
530        f"[download] matched {len(res.matched_scenes)} scenes, {len(res.allow_patterns)} patterns"
531    )
532    print(f"[download] local_dir={res.local_dir}")
533    print(f"[download] {res.files_fetched} files on disk ({_human_bytes(res.bytes_fetched)})")
534    return 0
535
536
537# --------------------------------------------------------------------- #
538# Wiring
539# --------------------------------------------------------------------- #
540
541
542def build_parser() -> argparse.ArgumentParser:
543    parser = argparse.ArgumentParser(
544        prog="python -m rpx_benchmark.dataset_hub.cli",
545        description="RPX dataset-hub utilities (mock / scan).",
546    )
547    sub = parser.add_subparsers(dest="cmd", required=True)
548
549    p_mock = sub.add_parser("mock", help="Generate a synthetic capture tree.")
550    p_mock.add_argument("--out", required=True, help="Output root (will be created).")
551    p_mock.add_argument(
552        "--multi", type=int, default=3, help="Number of multi-object scenes (default: 3)."
553    )
554    p_mock.add_argument(
555        "--single", type=int, default=5, help="Number of single-object scenes (default: 5)."
556    )
557    p_mock.add_argument("--frames", type=int, default=4, help="Frames per phase (default: 4).")
558    p_mock.add_argument(
559        "--size", type=int, default=32, help="Image edge length in px (default: 32)."
560    )
561    p_mock.set_defaults(func=_cmd_mock)
562
563    p_scan = sub.add_parser("scan", help="Inventory an on-disk capture tree.")
564    p_scan.add_argument("root", help="Capture-tree root (test_dataset_aggregated/).")
565    p_scan.add_argument(
566        "--json", action="store_true", help="Emit machine-readable JSON instead of a table."
567    )
568    p_scan.set_defaults(func=_cmd_scan)
569
570    p_pe = sub.add_parser(
571        "prepare-ego",
572        help="Arrange raw ego (GoPro) captures into <DATA>/ego/<scene_id>/0/ for scan/pack.",
573    )
574    p_pe.add_argument("--ego-root", dest="ego_root", required=True,
575                      help="Root holding sceneNNN/ego/{rgb,sam2} captures.")
576    p_pe.add_argument("--mos-root", dest="mos_root", required=True,
577                      help="The mos/ dir to resolve each ego scene's sibling scene_id against.")
578    p_pe.add_argument("--dst", required=True,
579                      help="Capture-tree root to arrange into (writes <dst>/ego/...).")
580    p_pe.add_argument("--scenes", default=None,
581                      help="Comma-separated ego scene dir names to restrict to (default: all).")
582    p_pe.add_argument("--include-masks-verified", action="store_true",
583                      help="Include sam2/masks_verified/ (excluded by default โ€” no MOS precedent).")
584    p_pe.add_argument("--copy", action="store_true",
585                      help="Copy files instead of symlinking (default: symlink, no duplication).")
586    p_pe.set_defaults(func=_cmd_prepare_ego)
587
588    p_pack = sub.add_parser("pack", help="Pack a capture tree into HF tar shards.")
589    p_pack.add_argument("--src", required=True, help="Capture-tree root.")
590    p_pack.add_argument("--staging", required=True, help="Staging output dir.")
591    p_pack.add_argument("--label-version", default="v1", help="Label version tag (default: v1).")
592    p_pack.add_argument(
593        "--overwrite", action="store_true", help="Overwrite existing shards in the staging dir."
594    )
595    p_pack.set_defaults(func=_cmd_pack)
596
597    p_man = sub.add_parser("manifest", help="Build the per-frame Parquet manifest.")
598    p_man.add_argument("--src", required=True, help="Capture-tree root (for frame inventory).")
599    p_man.add_argument(
600        "--staging", required=True, help="Packed staging dir (must contain *.tar shards)."
601    )
602    p_man.add_argument(
603        "--splits", default=None, help="Optional scene_splits.json path for split labels."
604    )
605    p_man.set_defaults(func=_cmd_manifest)
606
607    p_up = sub.add_parser("upload", help="Push a staging dir to a HF dataset repo.")
608    p_up.add_argument("--staging", required=True, help="Packed staging dir.")
609    p_up.add_argument(
610        "--repo-id",
611        default=DEFAULT_REPO_ID,
612        help=f"HF dataset repo id (default: {DEFAULT_REPO_ID}).",
613    )
614    p_up.add_argument("--revision", default="main", help="HF revision/branch (default: main).")
615    p_up.add_argument("--public", action="store_true", help="Create as public; default is private.")
616    p_up.add_argument(
617        "--dry-run", action="store_true", help="Walk the tree and report; no network call."
618    )
619    p_up.add_argument("--message", default="RPX dataset hub upload", help="Commit message.")
620    p_up.set_defaults(func=_cmd_upload)
621
622    p_dl = sub.add_parser("download", help="Pull just the tar shards a (task, split) needs.")
623    p_dl.add_argument(
624        "--task", required=True, help="Recipe key (e.g. segmentation, relative_pose)."
625    )
626    p_dl.add_argument("--split", default=None, help="easy | medium | hard (multi-object only).")
627    p_dl.add_argument(
628        "--repo-id",
629        default=DEFAULT_REPO_ID,
630        help=f"HF dataset repo id (default: {DEFAULT_REPO_ID}).",
631    )
632    p_dl.add_argument("--revision", default=None, help="HF revision/branch/tag (default: main).")
633    p_dl.add_argument(
634        "--cache-dir", default=None, help="HF cache root (default: ~/.cache/huggingface)."
635    )
636    p_dl.add_argument(
637        "--modalities", default="", help="Comma-separated extras beyond the recipe defaults."
638    )
639    p_dl.add_argument(
640        "--label-version",
641        action="append",
642        default=[],
643        metavar="KEY=VALUE",
644        help="Override a label modality version (repeatable).",
645    )
646    p_dl.set_defaults(func=_cmd_download)
647
648    p_st = sub.add_parser("stage-splits", help="Copy splits/* into a staging dir.")
649    p_st.add_argument("--staging", required=True, help="Staging dir (the upload target).")
650    p_st.add_argument(
651        "--splits-src",
652        default=None,
653        help="Source dir holding scene_splits.json + tier txts (default: benchmark/data/splits/).",
654    )
655    p_st.add_argument(
656        "--overwrite", action="store_true", help="Overwrite existing files in <staging>/splits/."
657    )
658    p_st.add_argument(
659        "--allow-missing",
660        action="store_true",
661        help="Don't raise if some expected splits files are absent in the source dir.",
662    )
663    p_st.set_defaults(func=_cmd_stage_splits)
664
665    p_dc = sub.add_parser("dataset-card", help="Generate <staging>/README.md (HF dataset card).")
666    p_dc.add_argument("--src", required=True, help="Capture-tree root (used for scan totals).")
667    p_dc.add_argument(
668        "--staging", required=True, help="Staging dir; the README lands at <staging>/README.md."
669    )
670    p_dc.add_argument(
671        "--repo-id",
672        default=DEFAULT_REPO_ID,
673        help=f"HF dataset repo id (default: {DEFAULT_REPO_ID}).",
674    )
675    p_dc.add_argument("--overwrite", action="store_true", help="Overwrite an existing README.md.")
676    p_dc.set_defaults(func=_cmd_dataset_card)
677
678    p_vfy = sub.add_parser(
679        "verify",
680        help=(
681            "Per-file SHA-256 integrity check of a downloaded snapshot. "
682            "Catches local disk corruption after extraction; relies on "
683            "manifest/file_checksums.json produced by `manifest`."
684        ),
685    )
686    p_vfy.add_argument(
687        "snapshot",
688        help="Snapshot root (the dir returned by snapshot_download or your staging dir).",
689    )
690    p_vfy.set_defaults(func=_cmd_verify)
691
692    p_lc = sub.add_parser(
693        "lossless-convert",
694        help="Re-encode rgb/ and fisheye/ PNGs to lossless WebP (~25% smaller, bit-identical pixels).",
695    )
696    p_lc.add_argument("--src", required=True, help="Source capture-tree root.")
697    p_lc.add_argument(
698        "--out",
699        required=True,
700        help="Output capture-tree root; must not overlap --src.",
701    )
702    p_lc.add_argument(
703        "--workers",
704        type=int,
705        default=max(1, (os.cpu_count() or 4) - 1),
706        help="Parallel encode workers (default: CPU count - 1).",
707    )
708    p_lc.add_argument(
709        "--dry-run",
710        action="store_true",
711        help="Walk the tree and report planned counts without writing anything.",
712    )
713    # Per-frame round-trip verification is mandatory from the CLI โ€” it
714    # is the proof that every output frame is bit-faithful to its source.
715    # There is no --no-verify flag by design.
716    p_lc.add_argument(
717        "--overwrite-out",
718        action="store_true",
719        help="Allow writing into a non-empty --out directory.",
720    )
721    p_lc.add_argument(
722        "--webp-method",
723        type=int,
724        default=4,
725        choices=range(0, 7),
726        metavar="{0..6}",
727        help=(
728            "libwebp lossless effort level: 0=fastest/largest, 6=slowest/smallest. "
729            "Default 4: empirically the sweet spot on photo content (method=6 takes "
730            "10x longer for ~0%% extra savings on natural images)."
731        ),
732    )
733    p_lc.add_argument(
734        "--png-compress-level",
735        type=int,
736        default=9,
737        choices=range(0, 10),
738        metavar="{0..9}",
739        help=(
740            "PNG re-encode compression level (0-9). Default 9: densest, still "
741            "lossless. Applied to depth/* and sam2/masks/* PNGs."
742        ),
743    )
744    p_lc.add_argument(
745        "--skip-rgb-webp",
746        action="store_true",
747        help="Skip the rgb/fisheye/ego PNG โ†’ WebP-lossless pass.",
748    )
749    p_lc.add_argument(
750        "--skip-png-recompress",
751        action="store_true",
752        help="Skip the depth/masks PNG โ†’ PNG-level=9 re-encode pass.",
753    )
754    p_lc.add_argument(
755        "--skip-cam-pose",
756        action="store_true",
757        help="Skip the cam_pose .npz โ†’ per-frame .npy consolidation pass.",
758    )
759    p_lc.set_defaults(func=_cmd_lossless_convert)
760
761    p_cr = sub.add_parser("stage-croissant", help="Copy + patch the Croissant JSON.")
762    p_cr.add_argument(
763        "--staging", required=True, help="Staging dir; lands at <staging>/rpx_croissant.json."
764    )
765    p_cr.add_argument(
766        "--src",
767        default=None,
768        help="Source JSON path (default: <package>/dataset_hub/croissant_template.json).",
769    )
770    p_cr.add_argument(
771        "--repo-id",
772        default=DEFAULT_REPO_ID,
773        help=f"HF dataset repo id for url field (default: {DEFAULT_REPO_ID}).",
774    )
775    p_cr.add_argument("--version", default="1.0.0", help="Dataset version string (default: 1.0.0).")
776    p_cr.add_argument(
777        "--cite-as", default=None, help="Replace the citeAs field (e.g. a bibtex string)."
778    )
779    p_cr.add_argument(
780        "--overwrite", action="store_true", help="Overwrite an existing rpx_croissant.json."
781    )
782    p_cr.set_defaults(func=_cmd_stage_croissant)
783
784    return parser
785
786
787def main(argv: Optional[List[str]] = None) -> int:
788    from rpx_benchmark.cleanup import install_signal_cleanup
789
790    install_signal_cleanup()
791
792    args = build_parser().parse_args(argv)
793    return args.func(args)
794
795
796if __name__ == "__main__":
797    raise SystemExit(main())
def build_parser() -> argparse.ArgumentParser:
543def build_parser() -> argparse.ArgumentParser:
544    parser = argparse.ArgumentParser(
545        prog="python -m rpx_benchmark.dataset_hub.cli",
546        description="RPX dataset-hub utilities (mock / scan).",
547    )
548    sub = parser.add_subparsers(dest="cmd", required=True)
549
550    p_mock = sub.add_parser("mock", help="Generate a synthetic capture tree.")
551    p_mock.add_argument("--out", required=True, help="Output root (will be created).")
552    p_mock.add_argument(
553        "--multi", type=int, default=3, help="Number of multi-object scenes (default: 3)."
554    )
555    p_mock.add_argument(
556        "--single", type=int, default=5, help="Number of single-object scenes (default: 5)."
557    )
558    p_mock.add_argument("--frames", type=int, default=4, help="Frames per phase (default: 4).")
559    p_mock.add_argument(
560        "--size", type=int, default=32, help="Image edge length in px (default: 32)."
561    )
562    p_mock.set_defaults(func=_cmd_mock)
563
564    p_scan = sub.add_parser("scan", help="Inventory an on-disk capture tree.")
565    p_scan.add_argument("root", help="Capture-tree root (test_dataset_aggregated/).")
566    p_scan.add_argument(
567        "--json", action="store_true", help="Emit machine-readable JSON instead of a table."
568    )
569    p_scan.set_defaults(func=_cmd_scan)
570
571    p_pe = sub.add_parser(
572        "prepare-ego",
573        help="Arrange raw ego (GoPro) captures into <DATA>/ego/<scene_id>/0/ for scan/pack.",
574    )
575    p_pe.add_argument("--ego-root", dest="ego_root", required=True,
576                      help="Root holding sceneNNN/ego/{rgb,sam2} captures.")
577    p_pe.add_argument("--mos-root", dest="mos_root", required=True,
578                      help="The mos/ dir to resolve each ego scene's sibling scene_id against.")
579    p_pe.add_argument("--dst", required=True,
580                      help="Capture-tree root to arrange into (writes <dst>/ego/...).")
581    p_pe.add_argument("--scenes", default=None,
582                      help="Comma-separated ego scene dir names to restrict to (default: all).")
583    p_pe.add_argument("--include-masks-verified", action="store_true",
584                      help="Include sam2/masks_verified/ (excluded by default โ€” no MOS precedent).")
585    p_pe.add_argument("--copy", action="store_true",
586                      help="Copy files instead of symlinking (default: symlink, no duplication).")
587    p_pe.set_defaults(func=_cmd_prepare_ego)
588
589    p_pack = sub.add_parser("pack", help="Pack a capture tree into HF tar shards.")
590    p_pack.add_argument("--src", required=True, help="Capture-tree root.")
591    p_pack.add_argument("--staging", required=True, help="Staging output dir.")
592    p_pack.add_argument("--label-version", default="v1", help="Label version tag (default: v1).")
593    p_pack.add_argument(
594        "--overwrite", action="store_true", help="Overwrite existing shards in the staging dir."
595    )
596    p_pack.set_defaults(func=_cmd_pack)
597
598    p_man = sub.add_parser("manifest", help="Build the per-frame Parquet manifest.")
599    p_man.add_argument("--src", required=True, help="Capture-tree root (for frame inventory).")
600    p_man.add_argument(
601        "--staging", required=True, help="Packed staging dir (must contain *.tar shards)."
602    )
603    p_man.add_argument(
604        "--splits", default=None, help="Optional scene_splits.json path for split labels."
605    )
606    p_man.set_defaults(func=_cmd_manifest)
607
608    p_up = sub.add_parser("upload", help="Push a staging dir to a HF dataset repo.")
609    p_up.add_argument("--staging", required=True, help="Packed staging dir.")
610    p_up.add_argument(
611        "--repo-id",
612        default=DEFAULT_REPO_ID,
613        help=f"HF dataset repo id (default: {DEFAULT_REPO_ID}).",
614    )
615    p_up.add_argument("--revision", default="main", help="HF revision/branch (default: main).")
616    p_up.add_argument("--public", action="store_true", help="Create as public; default is private.")
617    p_up.add_argument(
618        "--dry-run", action="store_true", help="Walk the tree and report; no network call."
619    )
620    p_up.add_argument("--message", default="RPX dataset hub upload", help="Commit message.")
621    p_up.set_defaults(func=_cmd_upload)
622
623    p_dl = sub.add_parser("download", help="Pull just the tar shards a (task, split) needs.")
624    p_dl.add_argument(
625        "--task", required=True, help="Recipe key (e.g. segmentation, relative_pose)."
626    )
627    p_dl.add_argument("--split", default=None, help="easy | medium | hard (multi-object only).")
628    p_dl.add_argument(
629        "--repo-id",
630        default=DEFAULT_REPO_ID,
631        help=f"HF dataset repo id (default: {DEFAULT_REPO_ID}).",
632    )
633    p_dl.add_argument("--revision", default=None, help="HF revision/branch/tag (default: main).")
634    p_dl.add_argument(
635        "--cache-dir", default=None, help="HF cache root (default: ~/.cache/huggingface)."
636    )
637    p_dl.add_argument(
638        "--modalities", default="", help="Comma-separated extras beyond the recipe defaults."
639    )
640    p_dl.add_argument(
641        "--label-version",
642        action="append",
643        default=[],
644        metavar="KEY=VALUE",
645        help="Override a label modality version (repeatable).",
646    )
647    p_dl.set_defaults(func=_cmd_download)
648
649    p_st = sub.add_parser("stage-splits", help="Copy splits/* into a staging dir.")
650    p_st.add_argument("--staging", required=True, help="Staging dir (the upload target).")
651    p_st.add_argument(
652        "--splits-src",
653        default=None,
654        help="Source dir holding scene_splits.json + tier txts (default: benchmark/data/splits/).",
655    )
656    p_st.add_argument(
657        "--overwrite", action="store_true", help="Overwrite existing files in <staging>/splits/."
658    )
659    p_st.add_argument(
660        "--allow-missing",
661        action="store_true",
662        help="Don't raise if some expected splits files are absent in the source dir.",
663    )
664    p_st.set_defaults(func=_cmd_stage_splits)
665
666    p_dc = sub.add_parser("dataset-card", help="Generate <staging>/README.md (HF dataset card).")
667    p_dc.add_argument("--src", required=True, help="Capture-tree root (used for scan totals).")
668    p_dc.add_argument(
669        "--staging", required=True, help="Staging dir; the README lands at <staging>/README.md."
670    )
671    p_dc.add_argument(
672        "--repo-id",
673        default=DEFAULT_REPO_ID,
674        help=f"HF dataset repo id (default: {DEFAULT_REPO_ID}).",
675    )
676    p_dc.add_argument("--overwrite", action="store_true", help="Overwrite an existing README.md.")
677    p_dc.set_defaults(func=_cmd_dataset_card)
678
679    p_vfy = sub.add_parser(
680        "verify",
681        help=(
682            "Per-file SHA-256 integrity check of a downloaded snapshot. "
683            "Catches local disk corruption after extraction; relies on "
684            "manifest/file_checksums.json produced by `manifest`."
685        ),
686    )
687    p_vfy.add_argument(
688        "snapshot",
689        help="Snapshot root (the dir returned by snapshot_download or your staging dir).",
690    )
691    p_vfy.set_defaults(func=_cmd_verify)
692
693    p_lc = sub.add_parser(
694        "lossless-convert",
695        help="Re-encode rgb/ and fisheye/ PNGs to lossless WebP (~25% smaller, bit-identical pixels).",
696    )
697    p_lc.add_argument("--src", required=True, help="Source capture-tree root.")
698    p_lc.add_argument(
699        "--out",
700        required=True,
701        help="Output capture-tree root; must not overlap --src.",
702    )
703    p_lc.add_argument(
704        "--workers",
705        type=int,
706        default=max(1, (os.cpu_count() or 4) - 1),
707        help="Parallel encode workers (default: CPU count - 1).",
708    )
709    p_lc.add_argument(
710        "--dry-run",
711        action="store_true",
712        help="Walk the tree and report planned counts without writing anything.",
713    )
714    # Per-frame round-trip verification is mandatory from the CLI โ€” it
715    # is the proof that every output frame is bit-faithful to its source.
716    # There is no --no-verify flag by design.
717    p_lc.add_argument(
718        "--overwrite-out",
719        action="store_true",
720        help="Allow writing into a non-empty --out directory.",
721    )
722    p_lc.add_argument(
723        "--webp-method",
724        type=int,
725        default=4,
726        choices=range(0, 7),
727        metavar="{0..6}",
728        help=(
729            "libwebp lossless effort level: 0=fastest/largest, 6=slowest/smallest. "
730            "Default 4: empirically the sweet spot on photo content (method=6 takes "
731            "10x longer for ~0%% extra savings on natural images)."
732        ),
733    )
734    p_lc.add_argument(
735        "--png-compress-level",
736        type=int,
737        default=9,
738        choices=range(0, 10),
739        metavar="{0..9}",
740        help=(
741            "PNG re-encode compression level (0-9). Default 9: densest, still "
742            "lossless. Applied to depth/* and sam2/masks/* PNGs."
743        ),
744    )
745    p_lc.add_argument(
746        "--skip-rgb-webp",
747        action="store_true",
748        help="Skip the rgb/fisheye/ego PNG โ†’ WebP-lossless pass.",
749    )
750    p_lc.add_argument(
751        "--skip-png-recompress",
752        action="store_true",
753        help="Skip the depth/masks PNG โ†’ PNG-level=9 re-encode pass.",
754    )
755    p_lc.add_argument(
756        "--skip-cam-pose",
757        action="store_true",
758        help="Skip the cam_pose .npz โ†’ per-frame .npy consolidation pass.",
759    )
760    p_lc.set_defaults(func=_cmd_lossless_convert)
761
762    p_cr = sub.add_parser("stage-croissant", help="Copy + patch the Croissant JSON.")
763    p_cr.add_argument(
764        "--staging", required=True, help="Staging dir; lands at <staging>/rpx_croissant.json."
765    )
766    p_cr.add_argument(
767        "--src",
768        default=None,
769        help="Source JSON path (default: <package>/dataset_hub/croissant_template.json).",
770    )
771    p_cr.add_argument(
772        "--repo-id",
773        default=DEFAULT_REPO_ID,
774        help=f"HF dataset repo id for url field (default: {DEFAULT_REPO_ID}).",
775    )
776    p_cr.add_argument("--version", default="1.0.0", help="Dataset version string (default: 1.0.0).")
777    p_cr.add_argument(
778        "--cite-as", default=None, help="Replace the citeAs field (e.g. a bibtex string)."
779    )
780    p_cr.add_argument(
781        "--overwrite", action="store_true", help="Overwrite an existing rpx_croissant.json."
782    )
783    p_cr.set_defaults(func=_cmd_stage_croissant)
784
785    return parser
def main(argv: Optional[List[str]] = None) -> int:
788def main(argv: Optional[List[str]] = None) -> int:
789    from rpx_benchmark.cleanup import install_signal_cleanup
790
791    install_signal_cleanup()
792
793    args = build_parser().parse_args(argv)
794    return args.func(args)