rpx_benchmark.dataset_hub.lossless_convert

Re-encode a capture tree into denser lossless formats for HF upload.

Three modality-aware re-encodings are applied (each can be skipped via ConvertSpec toggles). Every encoded artefact is round-trip verified per-frame against the source: the worker decodes the freshly written file and asserts numpy.array_equal (or full pose-vector equality for cam_pose) before the run is allowed to continue. The first verification failure aborts the entire run with the offending filename and a diagnostic — no silent loss is possible.

Modalities and what each becomes

  • rgb / fisheye (and any nested rgb/ like ego/rgb/) *.png → *.webp (libwebp lossless, method=4). ~45-55% denser on real-world photo content. Decoded pixels are bit-identical to source via PIL / cv2.

  • depth, sam2/masks, sam2/masks_verified *.png → *.png re-encoded at compress_level=9. ~18-23% denser; format and mode are preserved exactly — PNG is lossless at every compression level by spec. Decoded pixel arrays are bit-identical to source via the same loaders adapters already use, with no API change.

  • cam_pose *.npz (per-frame zip wrapping position + orientation) → *.npy (per-frame raw, single (7,) float64 vector packing [x, y, z, qx, qy, qz, qw]). ~68% denser per frame (90% of the current size is per-file zip overhead). Per-frame access is preserved; the loader dispatches on suffix and remains backward compatible with the legacy .npz form.

  • everything else — hard-linked (or copied on cross-filesystem) into the output tree unchanged. Hard-linking is zero-cost on disk.

Why the format zoo is safe

Every change preserves the decoded pixel/value array the model sees. The encoder asserts np.array_equal(src, decoded(encoded(src))) at write time, so each artefact on disk is proven bit-faithful before the run continues. PNG-lossless and WebP-lossless are deterministic by spec (RFC 2083 / RFC 9649) — different decoder versions on different machines produce the same pixel arrays. .npy round-trips IEEE-754 floats exactly.

Why we did not go further (deferred to later work)

The 2-channel hi/lo split that would let depth/masks fit into 8-bit-WebP-lossless (~+19 GB on the full dataset) was rejected to keep the on-disk file format standard: contributors must not be required to go through the canonical loader to recover correct depth/masks values. That optimisation can be revisited after the loader is wrapped in a typed API that adapters cannot bypass.

Usage

::

from rpx_benchmark.dataset_hub.lossless_convert import (
    ConvertSpec, convert_capture_tree,
)
spec = ConvertSpec(src_root=Path("DATA"), out_root=Path("DATA_v2"))
result = convert_capture_tree(spec)
print(f"saved {result.saved_bytes/1e9:.1f} GB")
  1"""Re-encode a capture tree into denser lossless formats for HF upload.
  2
  3Three modality-aware re-encodings are applied (each can be skipped via
  4:class:`ConvertSpec` toggles). Every encoded artefact is round-trip
  5verified per-frame against the source: the worker decodes the freshly
  6written file and asserts ``numpy.array_equal`` (or full pose-vector
  7equality for cam_pose) before the run is allowed to continue. The first
  8verification failure aborts the entire run with the offending filename
  9and a diagnostic — no silent loss is possible.
 10
 11Modalities and what each becomes
 12--------------------------------
 13
 14* **rgb / fisheye** (and any nested ``rgb/`` like ``ego/rgb/``)
 15  ``*.png`` → ``*.webp`` (libwebp lossless, method=4).
 16  ~45-55% denser on real-world photo content. Decoded pixels are
 17  bit-identical to source via PIL / cv2.
 18
 19* **depth, sam2/masks, sam2/masks_verified**
 20  ``*.png`` → ``*.png`` re-encoded at ``compress_level=9``.
 21  ~18-23% denser; format and mode are preserved exactly — PNG is
 22  lossless at every compression level by spec. Decoded pixel arrays
 23  are bit-identical to source via the same loaders adapters already
 24  use, with no API change.
 25
 26* **cam_pose**
 27  ``*.npz`` (per-frame zip wrapping ``position`` + ``orientation``)
 28  → ``*.npy`` (per-frame raw, single ``(7,) float64`` vector packing
 29  ``[x, y, z, qx, qy, qz, qw]``). ~68% denser per frame (90% of the
 30  current size is per-file zip overhead). Per-frame access is
 31  preserved; the loader dispatches on suffix and remains backward
 32  compatible with the legacy ``.npz`` form.
 33
 34* **everything else** — hard-linked (or copied on cross-filesystem)
 35  into the output tree unchanged. Hard-linking is zero-cost on disk.
 36
 37Why the format zoo is safe
 38--------------------------
 39
 40Every change preserves the decoded pixel/value array the model sees.
 41The encoder asserts ``np.array_equal(src, decoded(encoded(src)))`` at
 42write time, so each artefact on disk is proven bit-faithful before the
 43run continues. PNG-lossless and WebP-lossless are deterministic by
 44spec (RFC 2083 / RFC 9649) — different decoder versions on different
 45machines produce the same pixel arrays. ``.npy`` round-trips IEEE-754
 46floats exactly.
 47
 48Why we did not go further (deferred to later work)
 49--------------------------------------------------
 50
 51The 2-channel hi/lo split that would let depth/masks fit into
 528-bit-WebP-lossless (~+19 GB on the full dataset) was rejected to keep
 53the on-disk file format standard: contributors must not be required to
 54go through the canonical loader to recover correct depth/masks values.
 55That optimisation can be revisited after the loader is wrapped in a
 56typed API that adapters cannot bypass.
 57
 58Usage
 59-----
 60
 61::
 62
 63    from rpx_benchmark.dataset_hub.lossless_convert import (
 64        ConvertSpec, convert_capture_tree,
 65    )
 66    spec = ConvertSpec(src_root=Path("DATA"), out_root=Path("DATA_v2"))
 67    result = convert_capture_tree(spec)
 68    print(f"saved {result.saved_bytes/1e9:.1f} GB")
 69"""
 70
 71from __future__ import annotations
 72
 73import os
 74import shutil
 75from concurrent.futures import ProcessPoolExecutor, as_completed
 76from dataclasses import dataclass, field
 77from pathlib import Path
 78from typing import List, Optional, Tuple
 79
 80from ..exceptions import DatasetError
 81from ..logging_utils import get_logger
 82
 83log = get_logger(__name__)
 84
 85
 86# --------------------------------------------------------------------------- #
 87# Per-file actions
 88# --------------------------------------------------------------------------- #
 89
 90ACTION_WEBP = "webp"  # 8-bit PNG → lossless WebP
 91ACTION_PNG_RECOMPRESS = "png-recompress"  # PNG → PNG at compress_level=9
 92ACTION_CAM_POSE = "cam-pose"  # per-frame .npz → per-frame .npy
 93ACTION_LINK = "link"  # hardlink (or copy)
 94
 95# Modality directory names whose 8-bit PNG frames are re-encoded as
 96# lossless WebP. A file is classified ``webp`` iff its suffix is .png
 97# AND any ancestor directory between src_root and the file is in this
 98# set. Covers ``rgb/``, ``fisheye/``, and nested cases like
 99# ``ego/rgb/`` produced by the GoPro frame extractor.
100WEBP_PARENT_DIRS = frozenset({"rgb", "fisheye"})
101
102# Modality directory names whose PNGs are re-encoded as PNG at
103# compress_level=9 (lossless, mode/dtype preserved, ~20% denser).
104# Depth must stay PNG because libwebp does not support 16-bit;
105# masks must stay PNG to preserve palette/I;16 instance-ID semantics.
106PNG_RECOMPRESS_PARENT_DIRS = frozenset({"depth", "masks", "masks_verified"})
107
108# Directory name that gates the cam_pose path.
109CAM_POSE_PARENT_DIR = "cam_pose"
110
111CONVERT_FROM_SUFFIXES = frozenset({".png", ".PNG"})
112WEBP_SUFFIX = ".webp"
113NPY_SUFFIX = ".npy"
114NPZ_SUFFIX = ".npz"
115
116
117@dataclass(frozen=True)
118class ConvertSpec:
119    """Inputs to :func:`convert_capture_tree`."""
120
121    src_root: Path
122    out_root: Path
123    workers: int = max(1, (os.cpu_count() or 4) - 1)
124    dry_run: bool = False
125    verify: bool = True
126    overwrite_out: bool = False
127    # libwebp lossless effort: 0=fastest/largest, 6=slowest/smallest.
128    # method=4 is the sweet spot on photo content (method=6 takes ~10x
129    # longer for ~0% extra savings on natural images).
130    webp_method: int = 4
131    # PNG re-encode compression level (0-9). 9 = densest, still lossless.
132    png_compress_level: int = 9
133    # Per-modality opt-outs (default: all three paths enabled).
134    skip_rgb_webp: bool = False
135    skip_png_recompress: bool = False
136    skip_cam_pose: bool = False
137
138
139@dataclass
140class ActionStats:
141    """Counts and byte totals for one action."""
142
143    files: int = 0
144    bytes_before: int = 0
145    bytes_after: int = 0
146
147    @property
148    def saved_bytes(self) -> int:
149        return self.bytes_before - self.bytes_after
150
151    @property
152    def saved_pct(self) -> float:
153        if self.bytes_before == 0:
154            return 0.0
155        return 100.0 * self.saved_bytes / self.bytes_before
156
157
158@dataclass
159class ConvertResult:
160    """Outputs of :func:`convert_capture_tree`."""
161
162    files_seen: int = 0
163    by_action: dict = field(default_factory=dict)
164    failures: List[str] = field(default_factory=list)
165
166    def _stats(self, action: str) -> ActionStats:
167        return self.by_action.setdefault(action, ActionStats())
168
169    @property
170    def files_converted(self) -> int:
171        """Files that produced a re-encoded artefact (sum of non-link actions)."""
172        return sum(s.files for a, s in self.by_action.items() if a != ACTION_LINK)
173
174    @property
175    def files_linked(self) -> int:
176        return self._stats(ACTION_LINK).files
177
178    @property
179    def bytes_before(self) -> int:
180        return sum(s.bytes_before for s in self.by_action.values())
181
182    @property
183    def bytes_after(self) -> int:
184        return sum(s.bytes_after for s in self.by_action.values())
185
186    @property
187    def saved_bytes(self) -> int:
188        return self.bytes_before - self.bytes_after
189
190    @property
191    def saved_pct(self) -> float:
192        if self.bytes_before == 0:
193            return 0.0
194        return 100.0 * self.saved_bytes / self.bytes_before
195
196
197# --------------------------------------------------------------------------- #
198# Classification
199# --------------------------------------------------------------------------- #
200
201
202def _classify(path: Path, src_root: Path, spec: Optional[ConvertSpec] = None) -> str:
203    """Return one of the ``ACTION_*`` constants for ``path``.
204
205    Classification is purely structural — it looks at ancestor directory
206    names and file suffix, never at file contents. The optional ``spec``
207    lets a path that *would* be re-encoded fall back to ``ACTION_LINK``
208    when its modality is opted out via a ``skip_*`` toggle.
209    """
210    parts = path.relative_to(src_root).parts[:-1]  # excludes the filename
211
212    # cam_pose: .npz under cam_pose/
213    if path.suffix == NPZ_SUFFIX and CAM_POSE_PARENT_DIR in parts:
214        if spec is not None and spec.skip_cam_pose:
215            return ACTION_LINK
216        return ACTION_CAM_POSE
217
218    # PNG variants
219    if path.suffix in CONVERT_FROM_SUFFIXES:
220        if any(p in WEBP_PARENT_DIRS for p in parts):
221            if spec is not None and spec.skip_rgb_webp:
222                return ACTION_LINK
223            return ACTION_WEBP
224        if any(p in PNG_RECOMPRESS_PARENT_DIRS for p in parts):
225            if spec is not None and spec.skip_png_recompress:
226                return ACTION_LINK
227            return ACTION_PNG_RECOMPRESS
228
229    return ACTION_LINK
230
231
232def _plan_tree(src_root: Path, spec: Optional[ConvertSpec] = None) -> List[Tuple[Path, str]]:
233    """Walk ``src_root`` once and return ``[(path, action), ...]``."""
234    out: List[Tuple[Path, str]] = []
235    for p in sorted(src_root.rglob("*")):
236        if not p.is_file():
237            continue
238        out.append((p, _classify(p, src_root, spec)))
239    return out
240
241
242# --------------------------------------------------------------------------- #
243# Worker functions (top-level so ProcessPoolExecutor can pickle them)
244# --------------------------------------------------------------------------- #
245
246
247def _convert_webp_one(
248    src_path_s: str,
249    dst_path_s: str,
250    verify: bool,
251    webp_method: int,
252) -> Tuple[str, int, int, Optional[str]]:
253    """Encode one PNG → lossless WebP. Optionally round-trip verify.
254
255    Returns ``(dst_str, bytes_before, bytes_after, err_or_None)``.
256    """
257    import numpy as np
258    from PIL import Image
259
260    src_path = Path(src_path_s)
261    dst_path = Path(dst_path_s)
262    try:
263        with Image.open(src_path) as src:
264            src.load()
265            mode = src.mode
266            src_arr = np.asarray(src)
267        before_b = src_path.stat().st_size
268        dst_path.parent.mkdir(parents=True, exist_ok=True)
269        Image.fromarray(src_arr, mode=mode).save(
270            dst_path,
271            format="WEBP",
272            lossless=True,
273            quality=100,
274            method=webp_method,
275        )
276        if verify:
277            with Image.open(dst_path) as restored:
278                restored.load()
279                if restored.mode != mode:
280                    restored = restored.convert(mode)
281                restored_arr = np.asarray(restored)
282            if not np.array_equal(src_arr, restored_arr):
283                return (
284                    dst_path_s,
285                    0,
286                    0,
287                    (
288                        f"WebP round-trip mismatch for {src_path} "
289                        f"(src shape={src_arr.shape} mode={mode}, "
290                        f"restored shape={restored_arr.shape})"
291                    ),
292                )
293        return (dst_path_s, before_b, dst_path.stat().st_size, None)
294    except Exception as e:  # noqa: BLE001 — surface any worker error
295        return (dst_path_s, 0, 0, f"{src_path}: {e!r}")
296
297
298def _recompress_png_one(
299    src_path_s: str,
300    dst_path_s: str,
301    verify: bool,
302    png_compress_level: int,
303) -> Tuple[str, int, int, Optional[str]]:
304    """Re-encode one PNG at higher compress_level. Mode/dtype preserved.
305
306    Returns ``(dst_str, bytes_before, bytes_after, err_or_None)``.
307    """
308    import numpy as np
309    from PIL import Image
310
311    src_path = Path(src_path_s)
312    dst_path = Path(dst_path_s)
313    try:
314        with Image.open(src_path) as src:
315            src.load()
316            src_mode = src.mode
317            src_arr = np.asarray(src)
318            before_b = src_path.stat().st_size
319            dst_path.parent.mkdir(parents=True, exist_ok=True)
320            # Re-save through the original Image so palette / I;16 / L
321            # modes are preserved exactly.
322            src.save(dst_path, format="PNG", compress_level=png_compress_level)
323        if verify:
324            with Image.open(dst_path) as restored:
325                restored.load()
326                restored_mode = restored.mode
327                restored_arr = np.asarray(restored)
328            if restored_mode != src_mode:
329                return (
330                    dst_path_s,
331                    0,
332                    0,
333                    (
334                        f"PNG mode drift for {src_path}: "
335                        f"src mode={src_mode}, restored mode={restored_mode}"
336                    ),
337                )
338            if not np.array_equal(src_arr, restored_arr):
339                return (
340                    dst_path_s,
341                    0,
342                    0,
343                    (
344                        f"PNG round-trip mismatch for {src_path} "
345                        f"(shape={src_arr.shape}, mode={src_mode})"
346                    ),
347                )
348        return (dst_path_s, before_b, dst_path.stat().st_size, None)
349    except Exception as e:  # noqa: BLE001
350        return (dst_path_s, 0, 0, f"{src_path}: {e!r}")
351
352
353def _convert_cam_pose_one(
354    src_path_s: str,
355    dst_path_s: str,
356    verify: bool,
357) -> Tuple[str, int, int, Optional[str]]:
358    """Per-frame ``.npz`` (position + orientation) → ``.npy`` ``(7,) float64``.
359
360    The output is a single float64 vector packing
361    ``[x, y, z, qx, qy, qz, qw]``. Per-frame round-trip verification
362    asserts every slice is bit-identical to the source npz arrays.
363
364    Returns ``(dst_str, bytes_before, bytes_after, err_or_None)``.
365    """
366    import numpy as np
367
368    src_path = Path(src_path_s)
369    dst_path = Path(dst_path_s)
370    try:
371        data = np.load(src_path)
372        if "position" not in data or "orientation" not in data:
373            return (
374                dst_path_s,
375                0,
376                0,
377                (
378                    f"cam_pose .npz missing required keys at {src_path}: "
379                    f"found {list(data.keys())}, need 'position' + 'orientation'"
380                ),
381            )
382        position = data["position"].astype(np.float64)
383        orientation = data["orientation"].astype(np.float64)
384        if position.shape != (3,) or orientation.shape != (4,):
385            return (
386                dst_path_s,
387                0,
388                0,
389                (
390                    f"cam_pose .npz unexpected shapes at {src_path}: "
391                    f"position={position.shape}, orientation={orientation.shape}"
392                ),
393            )
394        pose7 = np.concatenate([position, orientation])  # (7,) float64
395        before_b = src_path.stat().st_size
396        dst_path.parent.mkdir(parents=True, exist_ok=True)
397        np.save(dst_path, pose7)
398        if verify:
399            restored = np.load(dst_path)
400            if restored.shape != (7,) or restored.dtype != np.float64:
401                return (
402                    dst_path_s,
403                    0,
404                    0,
405                    (
406                        f"cam_pose .npy round-trip shape/dtype mismatch at "
407                        f"{dst_path}: shape={restored.shape}, "
408                        f"dtype={restored.dtype}"
409                    ),
410                )
411            if not (
412                np.array_equal(restored[:3], position) and np.array_equal(restored[3:], orientation)
413            ):
414                return (
415                    dst_path_s,
416                    0,
417                    0,
418                    f"cam_pose round-trip value mismatch at {src_path}",
419                )
420        return (dst_path_s, before_b, dst_path.stat().st_size, None)
421    except Exception as e:  # noqa: BLE001
422        return (dst_path_s, 0, 0, f"{src_path}: {e!r}")
423
424
425def _link_one(
426    src_path_s: str,
427    dst_path_s: str,
428) -> Tuple[str, int, int, Optional[str]]:
429    """Hard-link (or copy if cross-fs) a file into the output tree.
430
431    ``src_path`` may itself be a symlink (e.g. ego_layout.py arranges ego
432    captures via per-file symlinks into a scratch DATA tree). On this
433    platform ``os.link(symlink, dst)`` with the default
434    ``follow_symlinks=True`` does NOT dereference -- it creates a new
435    symlink at ``dst`` pointing at the same (possibly relative-to-nowhere)
436    target, not a hardlink to the real file. Confirmed via a direct
437    ``os.lstat`` check: the resulting dst had ``S_ISLNK`` set and the
438    symlink's own ~80-byte size, not the real file's size. That's a
439    silent, severe downstream bug: packer.py's tar writer uses
440    ``os.lstat`` semantics (via ``TarFile.gettarinfo`` without
441    ``dereference=True``), so a symlink dst would get packed as a broken
442    symlink tar entry pointing at a LOCAL machine path -- unreadable by
443    anyone who downloads the resulting shard. Resolve to the real
444    underlying file before linking so this always produces a true
445    hardlink (or a real byte copy, on cross-filesystem fallback) no
446    matter how many symlinks src_path chains through.
447    """
448    src_path = Path(src_path_s).resolve()
449    dst_path = Path(dst_path_s)
450    try:
451        dst_path.parent.mkdir(parents=True, exist_ok=True)
452        if dst_path.exists() or dst_path.is_symlink():
453            dst_path.unlink()
454        try:
455            os.link(src_path, dst_path)
456        except OSError:
457            shutil.copy2(src_path, dst_path)
458        sz = dst_path.stat().st_size
459        return (dst_path_s, sz, sz, None)
460    except Exception as e:  # noqa: BLE001
461        return (dst_path_s, 0, 0, f"{src_path}: {e!r}")
462
463
464# --------------------------------------------------------------------------- #
465# Public entry point
466# --------------------------------------------------------------------------- #
467
468
469def _ensure_disjoint(src: Path, out: Path) -> None:
470    """Refuse src/out overlap — would otherwise lead to infinite walking."""
471    src_r = src.resolve()
472    out_r = out.resolve()
473    if src_r == out_r:
474        raise DatasetError(
475            f"src_root and out_root must differ; got {src_r}",
476            hint="Pass --out pointing at a fresh directory.",
477        )
478    try:
479        if out_r.is_relative_to(src_r) or src_r.is_relative_to(out_r):
480            raise DatasetError(
481                f"src_root and out_root overlap: src={src_r}, out={out_r}",
482                hint="Pick a sibling directory outside src for --out.",
483            )
484    except AttributeError:
485        # Python <3.9 — relative-to check unsupported; fall back to string prefix.
486        if str(out_r).startswith(str(src_r) + os.sep) or str(src_r).startswith(str(out_r) + os.sep):
487            raise DatasetError(
488                f"src_root and out_root overlap: src={src_r}, out={out_r}",
489            ) from None
490
491
492def _dst_path_for(src_path: Path, action: str, src_root: Path, out_root: Path) -> Path:
493    """Return the destination path for ``src_path`` given the action."""
494    rel = src_path.relative_to(src_root)
495    if action == ACTION_WEBP:
496        return out_root / rel.with_suffix(WEBP_SUFFIX)
497    if action == ACTION_CAM_POSE:
498        return out_root / rel.with_suffix(NPY_SUFFIX)
499    # png-recompress and link keep the same filename
500    return out_root / rel
501
502
503def convert_capture_tree(spec: ConvertSpec) -> ConvertResult:
504    """Walk ``spec.src_root`` and produce a re-encoded capture tree.
505
506    On success, ``spec.out_root`` contains a complete capture tree where
507    rgb/fisheye/ego frames are lossless WebP, depth/mask PNGs are
508    re-encoded at maximum compression, and cam_pose frames are
509    consolidated to per-frame .npy. Everything else is hard-linked.
510    The output tree is a drop-in replacement for the source as input to
511    :func:`pack_capture_tree`.
512    """
513    if not spec.src_root.is_dir():
514        raise DatasetError(f"src_root does not exist or is not a directory: {spec.src_root}")
515    _ensure_disjoint(spec.src_root, spec.out_root)
516
517    if spec.out_root.exists() and not spec.overwrite_out:
518        if any(spec.out_root.iterdir()):
519            raise DatasetError(
520                f"out_root exists and is non-empty: {spec.out_root}",
521                hint="Pass overwrite_out=True or pick a fresh directory.",
522            )
523    spec.out_root.mkdir(parents=True, exist_ok=True)
524
525    plan = _plan_tree(spec.src_root, spec=spec)
526    action_counts = {}
527    for _, a in plan:
528        action_counts[a] = action_counts.get(a, 0) + 1
529    log.info(
530        "lossless-convert: scanned %d files under %s; per-action: %s",
531        len(plan),
532        spec.src_root,
533        ", ".join(f"{a}={n}" for a, n in sorted(action_counts.items())),
534    )
535
536    result = ConvertResult(files_seen=len(plan))
537
538    if spec.dry_run:
539        for action, n in action_counts.items():
540            result._stats(action).files = n
541        return result
542
543    # Build per-action worklists.
544    worklists: dict = {
545        ACTION_WEBP: [],
546        ACTION_PNG_RECOMPRESS: [],
547        ACTION_CAM_POSE: [],
548        ACTION_LINK: [],
549    }
550    for src_path, action in plan:
551        dst_path = _dst_path_for(src_path, action, spec.src_root, spec.out_root)
552        worklists[action].append((str(src_path), str(dst_path)))
553
554    # Convert each action's worklist in parallel. Each pass aborts on
555    # first verification failure so a corrupt output tree cannot escape.
556    def run_pass(action: str, fn, *extra_args):
557        worklist = worklists[action]
558        if not worklist:
559            return
560        log.info("lossless-convert: %s — %d files", action, len(worklist))
561        with ProcessPoolExecutor(max_workers=spec.workers) as ex:
562            futures = [ex.submit(fn, src_s, dst_s, *extra_args) for src_s, dst_s in worklist]
563            done = 0
564            for fut in as_completed(futures):
565                _, before_b, after_b, err = fut.result()
566                done += 1
567                if err:
568                    ex.shutdown(wait=False, cancel_futures=True)
569                    raise DatasetError(
570                        f"lossless-convert ({action}) aborted at {done}/{len(worklist)}: {err}",
571                        hint=(
572                            "Inspect the offending file and the relevant codec "
573                            "library version on this machine."
574                        ),
575                    )
576                stats = result._stats(action)
577                stats.files += 1
578                stats.bytes_before += before_b
579                stats.bytes_after += after_b
580                if done % 500 == 0:
581                    log.info("  ... %d / %d done", done, len(worklist))
582
583    run_pass(ACTION_WEBP, _convert_webp_one, spec.verify, spec.webp_method)
584    run_pass(ACTION_PNG_RECOMPRESS, _recompress_png_one, spec.verify, spec.png_compress_level)
585    run_pass(ACTION_CAM_POSE, _convert_cam_pose_one, spec.verify)
586
587    # Linking pass: failures here are collected (not fatal mid-pass) and
588    # raised at the end so we surface every broken file at once.
589    link_failures: List[str] = []
590    if worklists[ACTION_LINK]:
591        log.info("lossless-convert: link — %d files", len(worklists[ACTION_LINK]))
592        with ProcessPoolExecutor(max_workers=spec.workers) as ex:
593            futures = [ex.submit(_link_one, s, d) for s, d in worklists[ACTION_LINK]]
594            for fut in as_completed(futures):
595                _, before_b, after_b, err = fut.result()
596                if err:
597                    link_failures.append(err)
598                    continue
599                stats = result._stats(ACTION_LINK)
600                stats.files += 1
601                stats.bytes_before += before_b
602                stats.bytes_after += after_b
603    if link_failures:
604        result.failures = link_failures
605        raise DatasetError(
606            f"lossless-convert: {len(link_failures)} link/copy failures; first: {link_failures[0]}",
607        )
608
609    return result
ACTION_WEBP = 'webp'
ACTION_PNG_RECOMPRESS = 'png-recompress'
ACTION_CAM_POSE = 'cam-pose'
WEBP_PARENT_DIRS = frozenset({'fisheye', 'rgb'})
PNG_RECOMPRESS_PARENT_DIRS = frozenset({'depth', 'masks_verified', 'masks'})
CAM_POSE_PARENT_DIR = 'cam_pose'
CONVERT_FROM_SUFFIXES = frozenset({'.PNG', '.png'})
WEBP_SUFFIX = '.webp'
NPY_SUFFIX = '.npy'
NPZ_SUFFIX = '.npz'
@dataclass(frozen=True)
class ConvertSpec:
118@dataclass(frozen=True)
119class ConvertSpec:
120    """Inputs to :func:`convert_capture_tree`."""
121
122    src_root: Path
123    out_root: Path
124    workers: int = max(1, (os.cpu_count() or 4) - 1)
125    dry_run: bool = False
126    verify: bool = True
127    overwrite_out: bool = False
128    # libwebp lossless effort: 0=fastest/largest, 6=slowest/smallest.
129    # method=4 is the sweet spot on photo content (method=6 takes ~10x
130    # longer for ~0% extra savings on natural images).
131    webp_method: int = 4
132    # PNG re-encode compression level (0-9). 9 = densest, still lossless.
133    png_compress_level: int = 9
134    # Per-modality opt-outs (default: all three paths enabled).
135    skip_rgb_webp: bool = False
136    skip_png_recompress: bool = False
137    skip_cam_pose: bool = False
ConvertSpec( src_root: pathlib.Path, out_root: pathlib.Path, workers: int = 3, dry_run: bool = False, verify: bool = True, overwrite_out: bool = False, webp_method: int = 4, png_compress_level: int = 9, skip_rgb_webp: bool = False, skip_png_recompress: bool = False, skip_cam_pose: bool = False)
src_root: pathlib.Path
out_root: pathlib.Path
workers: int = 3
dry_run: bool = False
verify: bool = True
overwrite_out: bool = False
webp_method: int = 4
png_compress_level: int = 9
skip_rgb_webp: bool = False
skip_png_recompress: bool = False
skip_cam_pose: bool = False
@dataclass
class ActionStats:
140@dataclass
141class ActionStats:
142    """Counts and byte totals for one action."""
143
144    files: int = 0
145    bytes_before: int = 0
146    bytes_after: int = 0
147
148    @property
149    def saved_bytes(self) -> int:
150        return self.bytes_before - self.bytes_after
151
152    @property
153    def saved_pct(self) -> float:
154        if self.bytes_before == 0:
155            return 0.0
156        return 100.0 * self.saved_bytes / self.bytes_before

Counts and byte totals for one action.

ActionStats(files: int = 0, bytes_before: int = 0, bytes_after: int = 0)
files: int = 0
bytes_before: int = 0
bytes_after: int = 0
saved_bytes: int
148    @property
149    def saved_bytes(self) -> int:
150        return self.bytes_before - self.bytes_after
saved_pct: float
152    @property
153    def saved_pct(self) -> float:
154        if self.bytes_before == 0:
155            return 0.0
156        return 100.0 * self.saved_bytes / self.bytes_before
@dataclass
class ConvertResult:
159@dataclass
160class ConvertResult:
161    """Outputs of :func:`convert_capture_tree`."""
162
163    files_seen: int = 0
164    by_action: dict = field(default_factory=dict)
165    failures: List[str] = field(default_factory=list)
166
167    def _stats(self, action: str) -> ActionStats:
168        return self.by_action.setdefault(action, ActionStats())
169
170    @property
171    def files_converted(self) -> int:
172        """Files that produced a re-encoded artefact (sum of non-link actions)."""
173        return sum(s.files for a, s in self.by_action.items() if a != ACTION_LINK)
174
175    @property
176    def files_linked(self) -> int:
177        return self._stats(ACTION_LINK).files
178
179    @property
180    def bytes_before(self) -> int:
181        return sum(s.bytes_before for s in self.by_action.values())
182
183    @property
184    def bytes_after(self) -> int:
185        return sum(s.bytes_after for s in self.by_action.values())
186
187    @property
188    def saved_bytes(self) -> int:
189        return self.bytes_before - self.bytes_after
190
191    @property
192    def saved_pct(self) -> float:
193        if self.bytes_before == 0:
194            return 0.0
195        return 100.0 * self.saved_bytes / self.bytes_before
ConvertResult( files_seen: int = 0, by_action: dict = <factory>, failures: List[str] = <factory>)
files_seen: int = 0
by_action: dict
failures: List[str]
files_converted: int
170    @property
171    def files_converted(self) -> int:
172        """Files that produced a re-encoded artefact (sum of non-link actions)."""
173        return sum(s.files for a, s in self.by_action.items() if a != ACTION_LINK)

Files that produced a re-encoded artefact (sum of non-link actions).

files_linked: int
175    @property
176    def files_linked(self) -> int:
177        return self._stats(ACTION_LINK).files
bytes_before: int
179    @property
180    def bytes_before(self) -> int:
181        return sum(s.bytes_before for s in self.by_action.values())
bytes_after: int
183    @property
184    def bytes_after(self) -> int:
185        return sum(s.bytes_after for s in self.by_action.values())
saved_bytes: int
187    @property
188    def saved_bytes(self) -> int:
189        return self.bytes_before - self.bytes_after
saved_pct: float
191    @property
192    def saved_pct(self) -> float:
193        if self.bytes_before == 0:
194            return 0.0
195        return 100.0 * self.saved_bytes / self.bytes_before
def convert_capture_tree( spec: ConvertSpec) -> ConvertResult:
504def convert_capture_tree(spec: ConvertSpec) -> ConvertResult:
505    """Walk ``spec.src_root`` and produce a re-encoded capture tree.
506
507    On success, ``spec.out_root`` contains a complete capture tree where
508    rgb/fisheye/ego frames are lossless WebP, depth/mask PNGs are
509    re-encoded at maximum compression, and cam_pose frames are
510    consolidated to per-frame .npy. Everything else is hard-linked.
511    The output tree is a drop-in replacement for the source as input to
512    :func:`pack_capture_tree`.
513    """
514    if not spec.src_root.is_dir():
515        raise DatasetError(f"src_root does not exist or is not a directory: {spec.src_root}")
516    _ensure_disjoint(spec.src_root, spec.out_root)
517
518    if spec.out_root.exists() and not spec.overwrite_out:
519        if any(spec.out_root.iterdir()):
520            raise DatasetError(
521                f"out_root exists and is non-empty: {spec.out_root}",
522                hint="Pass overwrite_out=True or pick a fresh directory.",
523            )
524    spec.out_root.mkdir(parents=True, exist_ok=True)
525
526    plan = _plan_tree(spec.src_root, spec=spec)
527    action_counts = {}
528    for _, a in plan:
529        action_counts[a] = action_counts.get(a, 0) + 1
530    log.info(
531        "lossless-convert: scanned %d files under %s; per-action: %s",
532        len(plan),
533        spec.src_root,
534        ", ".join(f"{a}={n}" for a, n in sorted(action_counts.items())),
535    )
536
537    result = ConvertResult(files_seen=len(plan))
538
539    if spec.dry_run:
540        for action, n in action_counts.items():
541            result._stats(action).files = n
542        return result
543
544    # Build per-action worklists.
545    worklists: dict = {
546        ACTION_WEBP: [],
547        ACTION_PNG_RECOMPRESS: [],
548        ACTION_CAM_POSE: [],
549        ACTION_LINK: [],
550    }
551    for src_path, action in plan:
552        dst_path = _dst_path_for(src_path, action, spec.src_root, spec.out_root)
553        worklists[action].append((str(src_path), str(dst_path)))
554
555    # Convert each action's worklist in parallel. Each pass aborts on
556    # first verification failure so a corrupt output tree cannot escape.
557    def run_pass(action: str, fn, *extra_args):
558        worklist = worklists[action]
559        if not worklist:
560            return
561        log.info("lossless-convert: %s — %d files", action, len(worklist))
562        with ProcessPoolExecutor(max_workers=spec.workers) as ex:
563            futures = [ex.submit(fn, src_s, dst_s, *extra_args) for src_s, dst_s in worklist]
564            done = 0
565            for fut in as_completed(futures):
566                _, before_b, after_b, err = fut.result()
567                done += 1
568                if err:
569                    ex.shutdown(wait=False, cancel_futures=True)
570                    raise DatasetError(
571                        f"lossless-convert ({action}) aborted at {done}/{len(worklist)}: {err}",
572                        hint=(
573                            "Inspect the offending file and the relevant codec "
574                            "library version on this machine."
575                        ),
576                    )
577                stats = result._stats(action)
578                stats.files += 1
579                stats.bytes_before += before_b
580                stats.bytes_after += after_b
581                if done % 500 == 0:
582                    log.info("  ... %d / %d done", done, len(worklist))
583
584    run_pass(ACTION_WEBP, _convert_webp_one, spec.verify, spec.webp_method)
585    run_pass(ACTION_PNG_RECOMPRESS, _recompress_png_one, spec.verify, spec.png_compress_level)
586    run_pass(ACTION_CAM_POSE, _convert_cam_pose_one, spec.verify)
587
588    # Linking pass: failures here are collected (not fatal mid-pass) and
589    # raised at the end so we surface every broken file at once.
590    link_failures: List[str] = []
591    if worklists[ACTION_LINK]:
592        log.info("lossless-convert: link — %d files", len(worklists[ACTION_LINK]))
593        with ProcessPoolExecutor(max_workers=spec.workers) as ex:
594            futures = [ex.submit(_link_one, s, d) for s, d in worklists[ACTION_LINK]]
595            for fut in as_completed(futures):
596                _, before_b, after_b, err = fut.result()
597                if err:
598                    link_failures.append(err)
599                    continue
600                stats = result._stats(ACTION_LINK)
601                stats.files += 1
602                stats.bytes_before += before_b
603                stats.bytes_after += after_b
604    if link_failures:
605        result.failures = link_failures
606        raise DatasetError(
607            f"lossless-convert: {len(link_failures)} link/copy failures; first: {link_failures[0]}",
608        )
609
610    return result

Walk spec.src_root and produce a re-encoded capture tree.

On success, spec.out_root contains a complete capture tree where rgb/fisheye/ego frames are lossless WebP, depth/mask PNGs are re-encoded at maximum compression, and cam_pose frames are consolidated to per-frame .npy. Everything else is hard-linked. The output tree is a drop-in replacement for the source as input to pack_capture_tree().