rpx_benchmark.data.esd

Effort-Stratified Difficulty (ESD) feature extraction.

Computes the 18 per-(scene, phase) features defined in paper-submission/overleaf/text/12_appendix.tex (\section{ESD Feature Definitions}). The output of this module is the raw feature table; weight calibration and tertile assignment live downstream.

On-disk layout expected

::

<data_root>/
  <scene_dir>/                       # e.g. "scene1.GDC.cse" or just "scene1"
    0/  1/  2/                       # phase indices (clutter/interaction/clean)
      rgb/      00000.png ...        # uint8 RGB
      depth/    00000.png ...        # uint16 millimetres, 0 = invalid
      cam_pose/ 00000.npz            # T265 pose: position + orientation [x,y,z,w]
      sam2/
        masks/  00000.png            # 1-channel, pixel value > 0 = instance ID
        mask_to_object.json          # {"<id>": "<label>", ...} (optional)
        iter1_faulty.txt             # frame indices still faulty after iter i
        iter2_faulty.txt             # (one entry per line, basename or int)
        iter3_faulty.txt
        iter4_faulty.txt

Frame indices align across modalities by basename (00000.png ↔ 00000.npz).

Implementation notes

The per-phase loop is streaming: one frame's RGB / depth / mask / pose is decoded, contributes to per-feature accumulators, then is released. Peak memory per worker is ~tens of MB regardless of phase length, so the CLI can default to os.cpu_count() workers.

Mask geometry uses a single np.bincount per frame to derive visible-IDs, per-instance areas, and the population for occlusion bboxes — replacing the previous N-pass-over-mask design.

  1"""Effort-Stratified Difficulty (ESD) feature extraction.
  2
  3Computes the 18 per-(scene, phase) features defined in
  4``paper-submission/overleaf/text/12_appendix.tex``
  5(``\\section{ESD Feature Definitions}``). The output of this module
  6is the raw feature table; weight calibration and tertile assignment
  7live downstream.
  8
  9On-disk layout expected
 10-----------------------
 11
 12::
 13
 14    <data_root>/
 15      <scene_dir>/                       # e.g. "scene1.GDC.cse" or just "scene1"
 16        0/  1/  2/                       # phase indices (clutter/interaction/clean)
 17          rgb/      00000.png ...        # uint8 RGB
 18          depth/    00000.png ...        # uint16 millimetres, 0 = invalid
 19          cam_pose/ 00000.npz            # T265 pose: position + orientation [x,y,z,w]
 20          sam2/
 21            masks/  00000.png            # 1-channel, pixel value > 0 = instance ID
 22            mask_to_object.json          # {"<id>": "<label>", ...} (optional)
 23            iter1_faulty.txt             # frame indices still faulty after iter i
 24            iter2_faulty.txt             # (one entry per line, basename or int)
 25            iter3_faulty.txt
 26            iter4_faulty.txt
 27
 28Frame indices align across modalities by basename
 29(``00000.png`` ↔ ``00000.npz``).
 30
 31Implementation notes
 32--------------------
 33
 34The per-phase loop is **streaming**: one frame's RGB / depth / mask /
 35pose is decoded, contributes to per-feature accumulators, then is
 36released. Peak memory per worker is ~tens of MB regardless of phase
 37length, so the CLI can default to ``os.cpu_count()`` workers.
 38
 39Mask geometry uses a single ``np.bincount`` per frame to derive
 40visible-IDs, per-instance areas, and the population for occlusion
 41bboxes — replacing the previous N-pass-over-mask design.
 42"""
 43
 44from __future__ import annotations
 45
 46import functools
 47import json
 48import math
 49import re
 50from dataclasses import asdict, dataclass, field
 51from pathlib import Path
 52from typing import Dict, Iterator, List, Optional, Tuple
 53
 54import numpy as np
 55from PIL import Image
 56
 57from ..exceptions import DatasetError
 58from ..logging_utils import get_logger
 59
 60log = get_logger(__name__)
 61
 62# Optional cv2 fast-path for PNG decode. Falls back to PIL if cv2 isn't
 63# installed; tests run on PIL so behaviour stays identical.
 64try:
 65    import cv2  # type: ignore
 66
 67    _HAS_CV2 = True
 68except ImportError:  # pragma: no cover
 69    _HAS_CV2 = False
 70
 71
 72# Names of the 31 features, in the order they appear in the appendix.
 73# Tests should diff against this list to catch silent additions / renames.
 74FEATURE_NAMES: Tuple[str, ...] = (
 75    # Annotation effort
 76    "iter_mean",
 77    "iter_max",
 78    # Scene complexity
 79    "obj_mean",
 80    "obj_std",
 81    "obj_consist",
 82    # Occlusion
 83    "occ_mean",
 84    "occ_p90",
 85    "occ_heavy",
 86    # Depth quality
 87    "depth_invalid",
 88    "depth_invalid_mask",
 89    "depth_std",
 90    "depth_std_mask",
 91    # Photometric--depth conflict (D435 RGB + invalid depth)
 92    "specular",
 93    "dark",
 94    # Image quality (D435 RGB) — grounded in CEMS (Zhao et al. 2024)
 95    "rgb_blur",
 96    "rgb_texture",
 97    # Object size distribution
 98    "mask_area_mean",
 99    "mask_area_std",
100    # Temporal annotation stability
101    "area_cv",
102    "area_drop",
103    "vis_instability",
104    # Camera motion (MAD-filtered)
105    "trans_mean",
106    "trans_p90",
107    "rot_mean",
108    "rot_p90",
109    "jerk",
110    # Fisheye / stereo (T265 fisheye pairs) — NaN if no fisheye dir present
111    "fisheye_dark",
112    "fisheye_bright",
113    "fisheye_sharpness",
114    "fisheye_corr",
115    "fisheye_texture",
116)
117
118# Threshold used by ``occ-heavy`` (appendix §"Occlusion").
119_OCC_HEAVY_THRESHOLD: float = 0.3
120# Photometric thresholds used by ``specular`` / ``dark`` / ``fisheye_{dark,bright}``.
121_SPECULAR_LUMA_MIN: int = 230
122_DARK_LUMA_MAX: int = 30
123# Threshold used by ``area_drop`` (old paper §3.4 eq 15).
124# Relative area collapse from frame t-1 to frame t exceeding this fraction counts
125# as a "drop event". 0.5 = 50% area lost between consecutive frames.
126_AREA_DROP_THRESHOLD: float = 0.5
127
128
129@dataclass
130class PhaseFeatures:
131    """Features and provenance for one (scene, phase) pair."""
132
133    scene_id: str  # full scene-dir basename
134    phase: int  # 0 / 1 / 2 (clutter / interaction / clean)
135    n_frames_total: int  # frames present in rgb/
136    n_frames_used: int  # frames where every required modality was present
137    features: Dict[str, float] = field(default_factory=dict)
138
139    def as_json(self) -> Dict[str, object]:
140        return asdict(self)
141
142
143# --------------------------------------------------------------------------- #
144# Frame discovery / loaders
145# --------------------------------------------------------------------------- #
146
147
148def _stem_set(d: Path, suffix: str) -> set[str]:
149    if not d.is_dir():
150        return set()
151    return {p.stem for p in d.iterdir() if p.suffix == suffix and not p.name.startswith(".")}
152
153
154def _aligned_frames(phase_dir: Path) -> List[str]:
155    """Frame stems that exist across rgb / depth / mask / pose, sorted.
156
157    Logs a warning summarising any per-modality misalignment so silent
158    data loss is visible without spamming the log per missing frame.
159    """
160    rgb = _stem_set(phase_dir / "rgb", ".png")
161    depth = _stem_set(phase_dir / "depth", ".png")
162    masks = _stem_set(phase_dir / "sam2/masks", ".png")
163    pose = _stem_set(phase_dir / "cam_pose", ".npz")
164    common = rgb & depth & masks & pose
165
166    union = rgb | depth | masks | pose
167    if union and len(common) < len(union):
168        missing = {
169            "rgb": len(union - rgb),
170            "depth": len(union - depth),
171            "mask": len(union - masks),
172            "pose": len(union - pose),
173        }
174        gaps = ", ".join(f"{k}:{v}" for k, v in missing.items() if v > 0)
175        log.warning(
176            "%s: dropped %d frames due to modality misalignment (missing in: %s)",
177            phase_dir,
178            len(union) - len(common),
179            gaps,
180        )
181    return sorted(common)
182
183
184@functools.lru_cache(maxsize=None)
185def _warn_multichannel_once(parent_dir: str, n_channels: int) -> None:
186    """Emit one multi-channel-PNG warning per parent directory.
187
188    A phase with hundreds of mistakenly-RGB-saved masks would otherwise spam
189    the log with one warning per frame; this collapses them to one line per
190    directory. ``lru_cache`` is the dedupe; tests call ``cache_clear()`` to
191    reset between cases.
192    """
193    log.warning(
194        "%s: multi-channel PNGs detected (%d ch); using first channel "
195        "(further warnings for this directory suppressed)",
196        parent_dir,
197        n_channels,
198    )
199
200
201def _decode_png_gray(path: Path) -> np.ndarray:
202    """Decode a single-channel PNG. Uses cv2 if available, PIL otherwise.
203
204    Multi-channel inputs (e.g. masks accidentally saved as RGB) are collapsed
205    to the first channel; one warning is emitted per containing directory
206    (see :func:`_warn_multichannel_once`).
207    """
208    if _HAS_CV2:
209        arr = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
210        if arr is None:
211            raise DatasetError(
212                f"failed to decode PNG: {path}", hint="file may be corrupt or zero-byte."
213            )
214        if arr.ndim == 3:
215            _warn_multichannel_once(str(path.parent), arr.shape[2])
216            arr = arr[..., 0]
217        return arr
218    img = Image.open(path)
219    arr = np.asarray(img)
220    if arr.ndim == 3:
221        _warn_multichannel_once(str(path.parent), arr.shape[2])
222        arr = arr[..., 0]
223    return arr
224
225
226def _load_depth_m(path: Path) -> np.ndarray:
227    """16-bit PNG millimetres → float32 metres; 0 stays 0 (sentinel)."""
228    arr = _decode_png_gray(path).astype(np.float32) / 1000.0
229    return arr
230
231
232def _load_mask(path: Path) -> np.ndarray:
233    """Single-channel PNG; pixel value > 0 = instance ID. Returned as int32."""
234    arr = _decode_png_gray(path)
235    return arr.astype(np.int32, copy=False)
236
237
238def _load_rgb_luma(path: Path) -> np.ndarray:
239    """RGB → ITU-R BT.601 luma (uint8) for photometric thresholds."""
240    if _HAS_CV2:
241        arr = cv2.imread(str(path), cv2.IMREAD_COLOR)  # BGR
242        if arr is None:
243            raise DatasetError(
244                f"failed to decode PNG: {path}", hint="file may be corrupt or zero-byte."
245            )
246        # cv2 returns BGR; BT.601 weights stay the same when applied to channels.
247        b, g, r = arr[..., 0], arr[..., 1], arr[..., 2]
248        return (0.114 * b + 0.587 * g + 0.299 * r).astype(np.uint8)
249    img = np.asarray(Image.open(path).convert("RGB"), dtype=np.float32)
250    luma = 0.299 * img[..., 0] + 0.587 * img[..., 1] + 0.114 * img[..., 2]
251    return luma.astype(np.uint8)
252
253
254def _load_pose(path: Path) -> Tuple[np.ndarray, np.ndarray]:
255    """T265 pose: ``position`` (3,), ``orientation`` (4,) in [x,y,z,w]."""
256    with np.load(path) as f:
257        pos = np.asarray(f["position"], dtype=np.float64).reshape(3)
258        quat = np.asarray(f["orientation"], dtype=np.float64).reshape(4)
259    return pos, quat
260
261
262# --------------------------------------------------------------------------- #
263# Annotation-effort extractor (no per-frame I/O — reads small txt files only)
264# --------------------------------------------------------------------------- #
265
266
267def _annotation_effort(phase_dir: Path, frame_stems: List[str]) -> Dict[str, float]:
268    """``iter-mean`` / ``iter-max``.
269
270    ``k_j = 1 + |{ i : frame j ∈ iter_i_faulty.txt }|``. A frame absent from
271    every iter_i_faulty.txt was accepted on the first pass (k=1); a frame
272    listed in all four was still faulty after iter 4 (k=5).
273    """
274    sam_dir = phase_dir / "sam2"
275    counts = np.ones(len(frame_stems), dtype=np.int32)
276    if not sam_dir.is_dir() or counts.size == 0:
277        return {"iter_mean": 1.0, "iter_max": 1.0}
278
279    stem_to_idx = {s: i for i, s in enumerate(frame_stems)}
280    for i in (1, 2, 3, 4):
281        f = sam_dir / f"iter{i}_faulty.txt"
282        if not f.is_file():
283            continue
284        for raw in f.read_text().splitlines():
285            entry = raw.strip()
286            if not entry:
287                continue
288            stem = Path(entry).stem  # tolerates "00007", "00007.png", "/abs/.../00007.png"
289            idx = stem_to_idx.get(stem)
290            if idx is not None:
291                counts[idx] += 1
292
293    return {"iter_mean": float(counts.mean()), "iter_max": float(counts.max())}
294
295
296# --------------------------------------------------------------------------- #
297# Geometry helpers
298# --------------------------------------------------------------------------- #
299
300
301def _bbox_xywh(binary_mask: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
302    """Tight bbox of a boolean mask, or None if empty."""
303    rows = np.flatnonzero(binary_mask.any(axis=1))
304    cols = np.flatnonzero(binary_mask.any(axis=0))
305    if rows.size == 0 or cols.size == 0:
306        return None
307    y0, y1 = int(rows[0]), int(rows[-1])
308    x0, x1 = int(cols[0]), int(cols[-1])
309    return x0, y0, x1 - x0 + 1, y1 - y0 + 1
310
311
312def _occlusion_from_per_frame_bboxes(
313    bboxes_per_frame: List[Dict[int, Tuple[int, int, int, int]]],
314) -> Dict[str, float]:
315    """``occ-mean`` / ``occ-p90`` / ``occ-heavy`` from per-frame bbox dicts.
316
317    Per-object occlusion = mean over frames (where the object is visible) of
318    that frame's bbox-overlap-fraction. Then aggregate over objects.
319    """
320    per_object: Dict[int, List[float]] = {}
321    for bboxes in bboxes_per_frame:
322        if not bboxes:
323            continue
324        if len(bboxes) < 2:
325            for i in bboxes:
326                per_object.setdefault(i, []).append(0.0)
327            continue
328        for i, (xi, yi, wi, hi) in bboxes.items():
329            area_i = wi * hi
330            if area_i == 0:
331                continue
332            inter_sum = 0
333            for j, (xj, yj, wj, hj) in bboxes.items():
334                if i == j:
335                    continue
336                ix0, iy0 = max(xi, xj), max(yi, yj)
337                ix1, iy1 = min(xi + wi, xj + wj), min(yi + hi, yj + hj)
338                if ix1 > ix0 and iy1 > iy0:
339                    inter_sum += (ix1 - ix0) * (iy1 - iy0)
340            per_object.setdefault(i, []).append(inter_sum / area_i)
341
342    if not per_object:
343        return {"occ_mean": 0.0, "occ_p90": 0.0, "occ_heavy": 0.0}
344
345    per_object_mean = np.asarray(
346        [float(np.mean(v)) for v in per_object.values()],
347        dtype=np.float32,
348    )
349    return {
350        "occ_mean": float(per_object_mean.mean()),
351        "occ_p90": float(np.percentile(per_object_mean, 90)),
352        "occ_heavy": float((per_object_mean > _OCC_HEAVY_THRESHOLD).mean()),
353    }
354
355
356def _temporal_stability_from_areas(areas_T_by_id: np.ndarray) -> Dict[str, float]:
357    """``area-cv`` / ``area-drop`` / ``vis-instability`` from a (T, n_ids) area matrix.
358
359    Column ``j`` is the per-frame pixel count of instance ID ``j+1``.
360
361    ``area_drop`` follows old paper §3.4 eq 15: per-instance fraction of frames
362    where ``(A_{t-1} - A_t) / A_{t-1} > δ`` (with ``A_{t-1} > 0``), then
363    averaged over instances. Captures sudden mask collapses (typically caused
364    by an arm sweeping across the object during interaction).
365    """
366    T, n_ids = areas_T_by_id.shape
367    if n_ids == 0 or T == 0:
368        return {"area_cv": 0.0, "area_drop": 0.0, "vis_instability": 0.0}
369
370    present = areas_T_by_id > 0
371    cvs, drops, instabilities = [], [], []
372    for j in range(n_ids):
373        col = areas_T_by_id[:, j]
374        pres_col = present[:, j]
375
376        if not pres_col.any():
377            continue  # instance never observed (column would be all zeros)
378
379        positive = col[pres_col]
380        mean_a = float(positive.mean())
381        if mean_a > 0:
382            cvs.append(float(positive.std() / mean_a))
383
384        if T > 1:
385            flips = int(np.sum(pres_col[1:] != pres_col[:-1]))
386            instabilities.append(flips / T)
387
388            prev = col[:-1].astype(np.float64)
389            curr = col[1:].astype(np.float64)
390            valid = prev > 0
391            if valid.any():
392                ratios = np.zeros_like(prev)
393                ratios[valid] = (prev[valid] - curr[valid]) / prev[valid]
394                n_drop = int(np.sum(ratios > _AREA_DROP_THRESHOLD))
395                drops.append(n_drop / T)
396
397    return {
398        "area_cv": float(np.mean(cvs)) if cvs else 0.0,
399        "area_drop": float(np.mean(drops)) if drops else 0.0,
400        "vis_instability": float(np.mean(instabilities)) if instabilities else 0.0,
401    }
402
403
404def _quat_angle(q1: np.ndarray, q2: np.ndarray) -> float:
405    """Geodesic angle (rad) between two unit quaternions [x,y,z,w]."""
406    n1 = float(np.linalg.norm(q1))
407    n2 = float(np.linalg.norm(q2))
408    if n1 == 0 or n2 == 0:
409        return 0.0
410    dot = abs(float(np.dot(q1 / n1, q2 / n2)))
411    if dot > 1.0:
412        dot = 1.0
413    return float(2.0 * np.arccos(dot))
414
415
416def _mad_filter(arr: np.ndarray, k: float = 5.0) -> np.ndarray:
417    """Median-absolute-deviation outlier filter.
418
419    Replaces values more than ``k`` MADs from the median with the median.
420    This removes T265 pose jumps (relocalization spikes) that would
421    otherwise dominate jerk and trans_p90.
422    """
423    if arr.size < 3:
424        return arr
425    med = float(np.median(arr))
426    mad = float(np.median(np.abs(arr - med)))
427    if mad == 0:
428        return arr  # constant or near-constant signal
429    threshold = med + k * mad * 1.4826  # 1.4826 ≈ consistency constant for normal
430    out = arr.copy()
431    out[out > threshold] = med
432    return out
433
434
435def _camera_motion_from_arrays(
436    positions: np.ndarray,
437    quats: np.ndarray,
438) -> Dict[str, float]:
439    """Camera motion features with MAD outlier filtering.
440
441    - ``trans_mean`` / ``trans_p90``: per-frame translation magnitude (m)
442    - ``rot_mean`` / ``rot_p90``: per-frame rotation angle (rad)
443    - ``jerk``: mean |Δtranslation_{t+1} - Δtranslation_t| (m/frame²)
444
445    T265 VIO can produce occasional pose jumps from tracking-loss events;
446    MAD filtering removes these before computing statistics so they don't
447    dominate the p90 and jerk features.
448    """
449    if positions.shape[0] < 2:
450        return {"trans_mean": 0.0, "trans_p90": 0.0, "rot_mean": 0.0, "rot_p90": 0.0, "jerk": 0.0}
451
452    delta_t = np.linalg.norm(np.diff(positions, axis=0), axis=1)
453    delta_r = np.asarray(
454        [_quat_angle(quats[i], quats[i + 1]) for i in range(quats.shape[0] - 1)],
455        dtype=np.float64,
456    )
457    # MAD-filter to remove T265 relocalization spikes.
458    delta_t = _mad_filter(delta_t)
459    delta_r = _mad_filter(delta_r)
460    jerk = np.abs(np.diff(delta_t)) if delta_t.size >= 2 else np.zeros(0)
461
462    return {
463        "trans_mean": float(delta_t.mean()),
464        "trans_p90": float(np.percentile(delta_t, 90)),
465        "rot_mean": float(delta_r.mean()),
466        "rot_p90": float(np.percentile(delta_r, 90)),
467        "jerk": float(jerk.mean()) if jerk.size else 0.0,
468    }
469
470
471# --------------------------------------------------------------------------- #
472# Fisheye / stereo features (old paper §3.7 eqs 25-29)
473# --------------------------------------------------------------------------- #
474
475_FISHEYE_FEATURE_NAMES: Tuple[str, ...] = (
476    "fisheye_dark",
477    "fisheye_bright",
478    "fisheye_sharpness",
479    "fisheye_corr",
480    "fisheye_texture",
481)
482# Regex for ``*_L.png`` / ``*_R.png`` style fisheye pairs.
483_FISHEYE_LR_RE = re.compile(r"^(?P<stem>.+?)[_-](?P<side>[LRlr])$")
484
485
486def _discover_fisheye(
487    fisheye_dir: Path,
488) -> Tuple[List[str], Dict[str, Path], Dict[str, Path]]:
489    """Find fisheye frames; return (sorted_stems, left_path_map, right_path_map).
490
491    Auto-detects three layouts in priority order:
492
493    1. Subdirs ``left/`` + ``right/`` with matching basenames.
494    2. Files like ``00000_L.png`` + ``00000_R.png`` (or ``-L``/``-R``).
495    3. Single PNGs in the dir → treated as left only; ``fisheye_corr`` will
496       be unset for those frames.
497
498    Returns ``([], {}, {})`` if the directory is missing or empty.
499    """
500    if not fisheye_dir.is_dir():
501        return [], {}, {}
502
503    # Layout A: left/ + right/ subdirectories.
504    ld, rd = fisheye_dir / "left", fisheye_dir / "right"
505    if ld.is_dir() and rd.is_dir():
506        left = {p.stem: p for p in ld.iterdir() if p.suffix.lower() in (".png", ".jpg")}
507        right = {p.stem: p for p in rd.iterdir() if p.suffix.lower() in (".png", ".jpg")}
508        common = sorted(set(left) & set(right))
509        return common, left, right
510
511    # Layout B: <stem>_L.png + <stem>_R.png.
512    files = [
513        p for p in fisheye_dir.iterdir() if p.is_file() and p.suffix.lower() in (".png", ".jpg")
514    ]
515    left, right = {}, {}
516    for f in files:
517        m = _FISHEYE_LR_RE.match(f.stem)
518        if m:
519            stem = m.group("stem")
520            if m.group("side").lower() == "l":
521                left[stem] = f
522            else:
523                right[stem] = f
524    if left and right:
525        common = sorted(set(left) & set(right))
526        return common, left, right
527
528    # Layout C: single fisheye images (no stereo pair). corr → undefined.
529    singles = {p.stem: p for p in files}
530    if singles:
531        return sorted(singles), singles, {}
532
533    return [], {}, {}
534
535
536def _laplacian_var(img: np.ndarray) -> float:
537    """Variance of Laplacian — image sharpness / blur measure."""
538    if _HAS_CV2:
539        lap = cv2.Laplacian(img, cv2.CV_32F)
540        return float(lap.var())
541    f = img.astype(np.float32)
542    lap = np.roll(f, 1, 0) + np.roll(f, -1, 0) + np.roll(f, 1, 1) + np.roll(f, -1, 1) - 4.0 * f
543    return float(lap.var())
544
545
546def _gradient_magnitude_mean(img: np.ndarray) -> float:
547    """Mean of pixelwise |∇F| — texture density."""
548    f = img.astype(np.float32)
549    gy, gx = np.gradient(f)
550    return float(np.sqrt(gx * gx + gy * gy).mean())
551
552
553def _pearson_corr_2d(a: np.ndarray, b: np.ndarray) -> Optional[float]:
554    """Pearson correlation between two same-size single-channel images.
555
556    Resizes ``b`` to ``a``'s shape if they differ — matches the literal paper
557    formula ``corr(F^L, F^R)`` without requiring stereo rectification.
558    """
559    if a.shape != b.shape:
560        if _HAS_CV2:
561            b = cv2.resize(b, (a.shape[1], a.shape[0]))
562        else:
563            b = np.asarray(Image.fromarray(b).resize((a.shape[1], a.shape[0])))
564    af = a.astype(np.float64).ravel()
565    bf = b.astype(np.float64).ravel()
566    af -= af.mean()
567    bf -= bf.mean()
568    denom = math.sqrt((af * af).sum() * (bf * bf).sum())
569    if denom == 0:
570        return None  # constant image — correlation undefined
571    return float((af * bf).sum() / denom)
572
573
574def _fisheye_features(phase_dir: Path) -> Dict[str, float]:
575    """Compute the 5 fisheye features for one (scene, phase).
576
577    Returns all-NaN dict if no fisheye dir / no decodable frames found,
578    so that percentile normalization can exclude missing scenes instead of
579    biasing them toward low difficulty.
580    Logs a warning when stereo pairs are missing (``fisheye_corr`` then
581    aggregates only over frames that DO have pairs; if none, → NaN).
582    """
583    empty = {name: float("nan") for name in _FISHEYE_FEATURE_NAMES}
584    stems, left_paths, right_paths = _discover_fisheye(phase_dir / "fisheye")
585    if not stems:
586        return empty
587
588    has_right = bool(right_paths)
589    if not has_right:
590        log.warning(
591            "%s/fisheye: no stereo pairs found; fisheye_corr will be 0",
592            phase_dir,
593        )
594
595    dark_acc: List[float] = []
596    bright_acc: List[float] = []
597    sharp_acc: List[float] = []
598    tex_acc: List[float] = []
599    corr_acc: List[float] = []
600
601    for s in stems:
602        try:
603            L = _decode_png_gray(left_paths[s])
604        except Exception as e:
605            log.warning("fisheye decode failed for %s: %s", left_paths[s], e)
606            continue
607
608        dark_acc.append(float((L < _DARK_LUMA_MAX).mean()))
609        bright_acc.append(float((L > _SPECULAR_LUMA_MIN).mean()))
610        sharp_acc.append(_laplacian_var(L))
611        tex_acc.append(_gradient_magnitude_mean(L))
612
613        if has_right and s in right_paths:
614            try:
615                R = _decode_png_gray(right_paths[s])
616                c = _pearson_corr_2d(L, R)
617                if c is not None:
618                    corr_acc.append(c)
619            except Exception as e:
620                log.warning("fisheye-R decode failed for %s: %s", right_paths[s], e)
621
622    return {
623        "fisheye_dark": float(np.mean(dark_acc)) if dark_acc else 0.0,
624        "fisheye_bright": float(np.mean(bright_acc)) if bright_acc else 0.0,
625        "fisheye_sharpness": float(np.mean(sharp_acc)) if sharp_acc else 0.0,
626        "fisheye_corr": float(np.mean(corr_acc)) if corr_acc else 0.0,
627        "fisheye_texture": float(np.mean(tex_acc)) if tex_acc else 0.0,
628    }
629
630
631# --------------------------------------------------------------------------- #
632# Top-level driver — streaming, single I/O pass
633# --------------------------------------------------------------------------- #
634
635
636def _scene_phase_from_dir(phase_dir: Path) -> Tuple[str, int]:
637    scene_id = phase_dir.parent.name
638    try:
639        phase_idx = int(phase_dir.name)
640    except ValueError as e:
641        raise DatasetError(
642            f"phase dir name must be an integer, got {phase_dir.name!r}",
643            hint="phase_dir is expected to be <data_root>/<scene>/<phase_idx>; "
644            "rename or pass a deeper path.",
645        ) from e
646    return scene_id, phase_idx
647
648
649# Features that should be NaN (not 0) when their modality is absent.
650_NAN_WHEN_ABSENT: frozenset = frozenset(
651    {
652        "fisheye_dark",
653        "fisheye_bright",
654        "fisheye_sharpness",
655        "fisheye_corr",
656        "fisheye_texture",
657    }
658)
659
660
661def _empty_features() -> Dict[str, float]:
662    return {k: (float("nan") if k in _NAN_WHEN_ABSENT else 0.0) for k in FEATURE_NAMES}
663
664
665def extract_phase_features(phase_dir: Path) -> PhaseFeatures:
666    """Compute all 18 ESD features for one (scene, phase) directory.
667
668    ``phase_dir`` is expected to be ``<data_root>/<scene_dir>/<phase_idx>``;
669    e.g. ``test_dataset_aggregated/scene1.GDC.cse/0``. The scene_id and
670    phase fields of the result are inferred from this path.
671
672    The per-frame loop is streaming: one frame's modalities are decoded,
673    fed into per-feature accumulators, and discarded. Peak memory is
674    independent of phase length.
675    """
676    phase_dir = Path(phase_dir)
677    scene_id, phase_idx = _scene_phase_from_dir(phase_dir)
678
679    rgb_dir = phase_dir / "rgb"
680    n_total = len(_stem_set(rgb_dir, ".png")) if rgb_dir.is_dir() else 0
681    stems = _aligned_frames(phase_dir)
682
683    if not stems:
684        log.warning("no aligned frames found for %s/%d", scene_id, phase_idx)
685        return PhaseFeatures(
686            scene_id=scene_id,
687            phase=phase_idx,
688            n_frames_total=n_total,
689            n_frames_used=0,
690            features=_empty_features(),
691        )
692
693    T = len(stems)
694
695    # Annotation-effort features need no per-frame I/O.
696    feats: Dict[str, float] = dict(_annotation_effort(phase_dir, stems))
697
698    # Streaming accumulators.
699    obj_counts = np.zeros(T, dtype=np.int32)
700    depth_invalid_frac = np.zeros(T, dtype=np.float64)
701    depth_inv_mask_frac = np.full(T, np.nan, dtype=np.float64)  # NaN → no mask pixels
702    depth_std_per_frame = np.full(T, np.nan, dtype=np.float64)  # NaN → all-invalid
703    depth_std_mask_per_frame = np.full(T, np.nan, dtype=np.float64)  # NaN → no valid depth in mask
704    spec_frac = np.zeros(T, dtype=np.float64)
705    dark_frac = np.zeros(T, dtype=np.float64)
706    rgb_blur_per_frame = np.zeros(T, dtype=np.float64)  # Laplacian variance
707    rgb_tex_per_frame = np.zeros(T, dtype=np.float64)  # gradient magnitude mean
708    bboxes_per_frame: List[Dict[int, Tuple[int, int, int, int]]] = []
709    # Per-frame dense bincount of mask values (length varies by frame max ID);
710    # zipped at the end into a (T, max_id) matrix for area_cv / vis_instability.
711    bincount_per_frame: List[np.ndarray] = []
712    positions = np.zeros((T, 3), dtype=np.float64)
713    quats = np.zeros((T, 4), dtype=np.float64)
714
715    for t, s in enumerate(stems):
716        depth_m = _load_depth_m(phase_dir / "depth" / f"{s}.png")
717        mask = _load_mask(phase_dir / "sam2/masks" / f"{s}.png")
718        rgb_luma = _load_rgb_luma(phase_dir / "rgb" / f"{s}.png")
719        pos, q = _load_pose(phase_dir / "cam_pose" / f"{s}.npz")
720
721        # ── Depth-quality features ────────────────────────────────────────
722        invalid = depth_m <= 0
723        depth_invalid_frac[t] = float(invalid.mean())
724
725        in_mask = mask > 0
726        if in_mask.any():
727            depth_inv_mask_frac[t] = float(invalid[in_mask].mean())
728
729        valid = depth_m[~invalid]
730        if valid.size > 0:
731            depth_std_per_frame[t] = float(valid.std())
732
733        # In-mask depth std: only valid-depth pixels that also fall inside any
734        # instance mask. Captures object-relevant depth noise (paper's
735        # depth_std is whole-image and dominated by background).
736        valid_in_mask = depth_m[(~invalid) & in_mask]
737        if valid_in_mask.size > 0:
738            depth_std_mask_per_frame[t] = float(valid_in_mask.std())
739
740        # ── Photometric-conflict features ─────────────────────────────────
741        spec_frac[t] = float(((rgb_luma > _SPECULAR_LUMA_MIN) & invalid).mean())
742        dark_frac[t] = float(((rgb_luma < _DARK_LUMA_MAX) & invalid).mean())
743
744        # ── Image quality (D435 RGB) — grounded in CEMS (Zhao et al. 2024) ──
745        rgb_blur_per_frame[t] = _laplacian_var(rgb_luma)
746        rgb_tex_per_frame[t] = _gradient_magnitude_mean(rgb_luma)
747
748        # ── Mask-derived features (one bincount per frame, reused 3×) ─────
749        flat = mask.ravel()
750        # Drop negative IDs (defensive — masks should be unsigned).
751        if flat.min() < 0:
752            flat = flat[flat >= 0]
753        counts = np.bincount(flat)  # counts[i] = pixel count for ID i
754        ids = np.flatnonzero(counts[1:]) + 1 if counts.size > 1 else np.empty(0, dtype=np.int64)
755        obj_counts[t] = ids.size
756        bincount_per_frame.append(counts)
757
758        bboxes: Dict[int, Tuple[int, int, int, int]] = {}
759        for oid in ids:
760            b = _bbox_xywh(mask == oid)
761            if b is not None:
762                bboxes[int(oid)] = b
763        bboxes_per_frame.append(bboxes)
764
765        # ── Pose ──────────────────────────────────────────────────────────
766        positions[t] = pos
767        quats[t] = q
768
769    # ── n_total_objects: prefer mask_to_object.json, else union of observed ──
770    obj_json = phase_dir / "sam2" / "mask_to_object.json"
771    if obj_json.is_file():
772        try:
773            n_total_objects = len(json.loads(obj_json.read_text()))
774        except (ValueError, OSError) as e:
775            log.warning("%s: cannot parse, falling back to observed IDs (%s)", obj_json, e)
776            n_total_objects = max(
777                (int(c.size - 1) for c in bincount_per_frame if c.size > 1),
778                default=0,
779            )
780    else:
781        all_ids = {
782            int(i)
783            for c in bincount_per_frame
784            for i in (np.flatnonzero(c[1:]) + 1 if c.size > 1 else ())
785        }
786        n_total_objects = len(all_ids)
787
788    # ── Scene-complexity features ────────────────────────────────────────
789    feats["obj_mean"] = float(obj_counts.mean())
790    feats["obj_std"] = float(obj_counts.std())
791    feats["obj_consist"] = (
792        float((obj_counts == n_total_objects).mean()) if n_total_objects > 0 else 0.0
793    )
794
795    # ── Occlusion ────────────────────────────────────────────────────────
796    feats.update(_occlusion_from_per_frame_bboxes(bboxes_per_frame))
797
798    # ── Depth aggregates (NaN means "skip frame for this aggregate") ─────
799    feats["depth_invalid"] = float(depth_invalid_frac.mean())
800    feats["depth_invalid_mask"] = (
801        float(np.nanmean(depth_inv_mask_frac)) if not np.isnan(depth_inv_mask_frac).all() else 0.0
802    )
803    feats["depth_std"] = (
804        float(np.nanmean(depth_std_per_frame)) if not np.isnan(depth_std_per_frame).all() else 0.0
805    )
806    feats["depth_std_mask"] = (
807        float(np.nanmean(depth_std_mask_per_frame))
808        if not np.isnan(depth_std_mask_per_frame).all()
809        else 0.0
810    )
811
812    # ── Photometric ──────────────────────────────────────────────────────
813    feats["specular"] = float(spec_frac.mean())
814    feats["dark"] = float(dark_frac.mean())
815
816    # ── Image quality (D435 RGB) ─────────────────────────────────────
817    feats["rgb_blur"] = float(rgb_blur_per_frame.mean())
818    feats["rgb_texture"] = float(rgb_tex_per_frame.mean())
819
820    # ── Compute max_id once for reuse by object-size and temporal-stability ─
821    max_id = max((int(c.size - 1) for c in bincount_per_frame if c.size > 1), default=0)
822
823    # ── Object size distribution ────────────────────────────────────
824    # Per-instance mean mask area fraction (over frames where visible),
825    # then mean/std across instances. Small objects are universally harder.
826    if max_id >= 1 and T > 0:
827        _frame_pixels = bincount_per_frame[0].sum() if bincount_per_frame else 1
828        _areas_for_size = np.zeros((T, max_id), dtype=np.int64)
829        for _t, _c in enumerate(bincount_per_frame):
830            _n = min(int(_c.size) - 1, max_id)
831            if _n > 0:
832                _areas_for_size[_t, :_n] = _c[1 : 1 + _n]
833        _present = _areas_for_size > 0
834        _per_inst_mean = []
835        for _j in range(max_id):
836            _vis = _areas_for_size[:, _j][_present[:, _j]]
837            if _vis.size > 0:
838                _per_inst_mean.append(float(_vis.mean()) / max(_frame_pixels, 1))
839        if _per_inst_mean:
840            feats["mask_area_mean"] = float(np.mean(_per_inst_mean))
841            feats["mask_area_std"] = float(np.std(_per_inst_mean))
842        else:
843            feats["mask_area_mean"] = 0.0
844            feats["mask_area_std"] = 0.0
845    else:
846        feats["mask_area_mean"] = 0.0
847        feats["mask_area_std"] = 0.0
848
849    # ── Temporal stability: build dense (T, max_id) area matrix ──────────
850    if max_id >= 1:
851        areas = np.zeros((T, max_id), dtype=np.int64)
852        for t, c in enumerate(bincount_per_frame):
853            n = min(int(c.size) - 1, max_id)
854            if n > 0:
855                areas[t, :n] = c[1 : 1 + n]
856        feats.update(_temporal_stability_from_areas(areas))
857    else:
858        feats["area_cv"] = 0.0
859        feats["vis_instability"] = 0.0
860
861    # ── Camera motion ────────────────────────────────────────────────────
862    feats.update(_camera_motion_from_arrays(positions, quats))
863
864    # ── Fisheye / stereo (independent I/O — uses fisheye/, not the
865    # rgb/depth/mask/pose modalities streamed above) ─────────────────────
866    feats.update(_fisheye_features(phase_dir))
867
868    # Stable column order matching FEATURE_NAMES.
869    # Missing fisheye features → NaN (not 0) so percentile normalization
870    # can exclude them instead of biasing toward low difficulty.
871    feats = {
872        k: float(feats.get(k, float("nan") if k in _NAN_WHEN_ABSENT else 0.0))
873        for k in FEATURE_NAMES
874    }
875
876    return PhaseFeatures(
877        scene_id=scene_id,
878        phase=phase_idx,
879        n_frames_total=n_total,
880        n_frames_used=T,
881        features=feats,
882    )
883
884
885def iter_phase_dirs(data_root: Path) -> Iterator[Path]:
886    """Yield every ``<scene>/<phase>`` directory under ``data_root``.
887
888    A phase directory is any immediate child of a scene directory whose
889    name is purely numeric and that contains an ``rgb/`` subdirectory.
890    """
891    data_root = Path(data_root)
892    if not data_root.is_dir():
893        raise DatasetError(
894            f"data_root is not a directory: {data_root}", hint="check the --data-root argument."
895        )
896    for scene_dir in sorted(p for p in data_root.iterdir() if p.is_dir()):
897        for child in sorted(scene_dir.iterdir()):
898            if child.is_dir() and child.name.isdigit() and (child / "rgb").is_dir():
899                yield child
log = <Logger rpx_benchmark.data.esd (WARNING)>
FEATURE_NAMES: Tuple[str, ...] = ('iter_mean', 'iter_max', 'obj_mean', 'obj_std', 'obj_consist', 'occ_mean', 'occ_p90', 'occ_heavy', 'depth_invalid', 'depth_invalid_mask', 'depth_std', 'depth_std_mask', 'specular', 'dark', 'rgb_blur', 'rgb_texture', 'mask_area_mean', 'mask_area_std', 'area_cv', 'area_drop', 'vis_instability', 'trans_mean', 'trans_p90', 'rot_mean', 'rot_p90', 'jerk', 'fisheye_dark', 'fisheye_bright', 'fisheye_sharpness', 'fisheye_corr', 'fisheye_texture')
@dataclass
class PhaseFeatures:
130@dataclass
131class PhaseFeatures:
132    """Features and provenance for one (scene, phase) pair."""
133
134    scene_id: str  # full scene-dir basename
135    phase: int  # 0 / 1 / 2 (clutter / interaction / clean)
136    n_frames_total: int  # frames present in rgb/
137    n_frames_used: int  # frames where every required modality was present
138    features: Dict[str, float] = field(default_factory=dict)
139
140    def as_json(self) -> Dict[str, object]:
141        return asdict(self)

Features and provenance for one (scene, phase) pair.

PhaseFeatures( scene_id: str, phase: int, n_frames_total: int, n_frames_used: int, features: Dict[str, float] = <factory>)
scene_id: str
phase: int
n_frames_total: int
n_frames_used: int
features: Dict[str, float]
def as_json(self) -> Dict[str, object]:
140    def as_json(self) -> Dict[str, object]:
141        return asdict(self)
def extract_phase_features(phase_dir: pathlib.Path) -> PhaseFeatures:
666def extract_phase_features(phase_dir: Path) -> PhaseFeatures:
667    """Compute all 18 ESD features for one (scene, phase) directory.
668
669    ``phase_dir`` is expected to be ``<data_root>/<scene_dir>/<phase_idx>``;
670    e.g. ``test_dataset_aggregated/scene1.GDC.cse/0``. The scene_id and
671    phase fields of the result are inferred from this path.
672
673    The per-frame loop is streaming: one frame's modalities are decoded,
674    fed into per-feature accumulators, and discarded. Peak memory is
675    independent of phase length.
676    """
677    phase_dir = Path(phase_dir)
678    scene_id, phase_idx = _scene_phase_from_dir(phase_dir)
679
680    rgb_dir = phase_dir / "rgb"
681    n_total = len(_stem_set(rgb_dir, ".png")) if rgb_dir.is_dir() else 0
682    stems = _aligned_frames(phase_dir)
683
684    if not stems:
685        log.warning("no aligned frames found for %s/%d", scene_id, phase_idx)
686        return PhaseFeatures(
687            scene_id=scene_id,
688            phase=phase_idx,
689            n_frames_total=n_total,
690            n_frames_used=0,
691            features=_empty_features(),
692        )
693
694    T = len(stems)
695
696    # Annotation-effort features need no per-frame I/O.
697    feats: Dict[str, float] = dict(_annotation_effort(phase_dir, stems))
698
699    # Streaming accumulators.
700    obj_counts = np.zeros(T, dtype=np.int32)
701    depth_invalid_frac = np.zeros(T, dtype=np.float64)
702    depth_inv_mask_frac = np.full(T, np.nan, dtype=np.float64)  # NaN → no mask pixels
703    depth_std_per_frame = np.full(T, np.nan, dtype=np.float64)  # NaN → all-invalid
704    depth_std_mask_per_frame = np.full(T, np.nan, dtype=np.float64)  # NaN → no valid depth in mask
705    spec_frac = np.zeros(T, dtype=np.float64)
706    dark_frac = np.zeros(T, dtype=np.float64)
707    rgb_blur_per_frame = np.zeros(T, dtype=np.float64)  # Laplacian variance
708    rgb_tex_per_frame = np.zeros(T, dtype=np.float64)  # gradient magnitude mean
709    bboxes_per_frame: List[Dict[int, Tuple[int, int, int, int]]] = []
710    # Per-frame dense bincount of mask values (length varies by frame max ID);
711    # zipped at the end into a (T, max_id) matrix for area_cv / vis_instability.
712    bincount_per_frame: List[np.ndarray] = []
713    positions = np.zeros((T, 3), dtype=np.float64)
714    quats = np.zeros((T, 4), dtype=np.float64)
715
716    for t, s in enumerate(stems):
717        depth_m = _load_depth_m(phase_dir / "depth" / f"{s}.png")
718        mask = _load_mask(phase_dir / "sam2/masks" / f"{s}.png")
719        rgb_luma = _load_rgb_luma(phase_dir / "rgb" / f"{s}.png")
720        pos, q = _load_pose(phase_dir / "cam_pose" / f"{s}.npz")
721
722        # ── Depth-quality features ────────────────────────────────────────
723        invalid = depth_m <= 0
724        depth_invalid_frac[t] = float(invalid.mean())
725
726        in_mask = mask > 0
727        if in_mask.any():
728            depth_inv_mask_frac[t] = float(invalid[in_mask].mean())
729
730        valid = depth_m[~invalid]
731        if valid.size > 0:
732            depth_std_per_frame[t] = float(valid.std())
733
734        # In-mask depth std: only valid-depth pixels that also fall inside any
735        # instance mask. Captures object-relevant depth noise (paper's
736        # depth_std is whole-image and dominated by background).
737        valid_in_mask = depth_m[(~invalid) & in_mask]
738        if valid_in_mask.size > 0:
739            depth_std_mask_per_frame[t] = float(valid_in_mask.std())
740
741        # ── Photometric-conflict features ─────────────────────────────────
742        spec_frac[t] = float(((rgb_luma > _SPECULAR_LUMA_MIN) & invalid).mean())
743        dark_frac[t] = float(((rgb_luma < _DARK_LUMA_MAX) & invalid).mean())
744
745        # ── Image quality (D435 RGB) — grounded in CEMS (Zhao et al. 2024) ──
746        rgb_blur_per_frame[t] = _laplacian_var(rgb_luma)
747        rgb_tex_per_frame[t] = _gradient_magnitude_mean(rgb_luma)
748
749        # ── Mask-derived features (one bincount per frame, reused 3×) ─────
750        flat = mask.ravel()
751        # Drop negative IDs (defensive — masks should be unsigned).
752        if flat.min() < 0:
753            flat = flat[flat >= 0]
754        counts = np.bincount(flat)  # counts[i] = pixel count for ID i
755        ids = np.flatnonzero(counts[1:]) + 1 if counts.size > 1 else np.empty(0, dtype=np.int64)
756        obj_counts[t] = ids.size
757        bincount_per_frame.append(counts)
758
759        bboxes: Dict[int, Tuple[int, int, int, int]] = {}
760        for oid in ids:
761            b = _bbox_xywh(mask == oid)
762            if b is not None:
763                bboxes[int(oid)] = b
764        bboxes_per_frame.append(bboxes)
765
766        # ── Pose ──────────────────────────────────────────────────────────
767        positions[t] = pos
768        quats[t] = q
769
770    # ── n_total_objects: prefer mask_to_object.json, else union of observed ──
771    obj_json = phase_dir / "sam2" / "mask_to_object.json"
772    if obj_json.is_file():
773        try:
774            n_total_objects = len(json.loads(obj_json.read_text()))
775        except (ValueError, OSError) as e:
776            log.warning("%s: cannot parse, falling back to observed IDs (%s)", obj_json, e)
777            n_total_objects = max(
778                (int(c.size - 1) for c in bincount_per_frame if c.size > 1),
779                default=0,
780            )
781    else:
782        all_ids = {
783            int(i)
784            for c in bincount_per_frame
785            for i in (np.flatnonzero(c[1:]) + 1 if c.size > 1 else ())
786        }
787        n_total_objects = len(all_ids)
788
789    # ── Scene-complexity features ────────────────────────────────────────
790    feats["obj_mean"] = float(obj_counts.mean())
791    feats["obj_std"] = float(obj_counts.std())
792    feats["obj_consist"] = (
793        float((obj_counts == n_total_objects).mean()) if n_total_objects > 0 else 0.0
794    )
795
796    # ── Occlusion ────────────────────────────────────────────────────────
797    feats.update(_occlusion_from_per_frame_bboxes(bboxes_per_frame))
798
799    # ── Depth aggregates (NaN means "skip frame for this aggregate") ─────
800    feats["depth_invalid"] = float(depth_invalid_frac.mean())
801    feats["depth_invalid_mask"] = (
802        float(np.nanmean(depth_inv_mask_frac)) if not np.isnan(depth_inv_mask_frac).all() else 0.0
803    )
804    feats["depth_std"] = (
805        float(np.nanmean(depth_std_per_frame)) if not np.isnan(depth_std_per_frame).all() else 0.0
806    )
807    feats["depth_std_mask"] = (
808        float(np.nanmean(depth_std_mask_per_frame))
809        if not np.isnan(depth_std_mask_per_frame).all()
810        else 0.0
811    )
812
813    # ── Photometric ──────────────────────────────────────────────────────
814    feats["specular"] = float(spec_frac.mean())
815    feats["dark"] = float(dark_frac.mean())
816
817    # ── Image quality (D435 RGB) ─────────────────────────────────────
818    feats["rgb_blur"] = float(rgb_blur_per_frame.mean())
819    feats["rgb_texture"] = float(rgb_tex_per_frame.mean())
820
821    # ── Compute max_id once for reuse by object-size and temporal-stability ─
822    max_id = max((int(c.size - 1) for c in bincount_per_frame if c.size > 1), default=0)
823
824    # ── Object size distribution ────────────────────────────────────
825    # Per-instance mean mask area fraction (over frames where visible),
826    # then mean/std across instances. Small objects are universally harder.
827    if max_id >= 1 and T > 0:
828        _frame_pixels = bincount_per_frame[0].sum() if bincount_per_frame else 1
829        _areas_for_size = np.zeros((T, max_id), dtype=np.int64)
830        for _t, _c in enumerate(bincount_per_frame):
831            _n = min(int(_c.size) - 1, max_id)
832            if _n > 0:
833                _areas_for_size[_t, :_n] = _c[1 : 1 + _n]
834        _present = _areas_for_size > 0
835        _per_inst_mean = []
836        for _j in range(max_id):
837            _vis = _areas_for_size[:, _j][_present[:, _j]]
838            if _vis.size > 0:
839                _per_inst_mean.append(float(_vis.mean()) / max(_frame_pixels, 1))
840        if _per_inst_mean:
841            feats["mask_area_mean"] = float(np.mean(_per_inst_mean))
842            feats["mask_area_std"] = float(np.std(_per_inst_mean))
843        else:
844            feats["mask_area_mean"] = 0.0
845            feats["mask_area_std"] = 0.0
846    else:
847        feats["mask_area_mean"] = 0.0
848        feats["mask_area_std"] = 0.0
849
850    # ── Temporal stability: build dense (T, max_id) area matrix ──────────
851    if max_id >= 1:
852        areas = np.zeros((T, max_id), dtype=np.int64)
853        for t, c in enumerate(bincount_per_frame):
854            n = min(int(c.size) - 1, max_id)
855            if n > 0:
856                areas[t, :n] = c[1 : 1 + n]
857        feats.update(_temporal_stability_from_areas(areas))
858    else:
859        feats["area_cv"] = 0.0
860        feats["vis_instability"] = 0.0
861
862    # ── Camera motion ────────────────────────────────────────────────────
863    feats.update(_camera_motion_from_arrays(positions, quats))
864
865    # ── Fisheye / stereo (independent I/O — uses fisheye/, not the
866    # rgb/depth/mask/pose modalities streamed above) ─────────────────────
867    feats.update(_fisheye_features(phase_dir))
868
869    # Stable column order matching FEATURE_NAMES.
870    # Missing fisheye features → NaN (not 0) so percentile normalization
871    # can exclude them instead of biasing toward low difficulty.
872    feats = {
873        k: float(feats.get(k, float("nan") if k in _NAN_WHEN_ABSENT else 0.0))
874        for k in FEATURE_NAMES
875    }
876
877    return PhaseFeatures(
878        scene_id=scene_id,
879        phase=phase_idx,
880        n_frames_total=n_total,
881        n_frames_used=T,
882        features=feats,
883    )

Compute all 18 ESD features for one (scene, phase) directory.

phase_dir is expected to be <data_root>/<scene_dir>/<phase_idx>; e.g. test_dataset_aggregated/scene1.GDC.cse/0. The scene_id and phase fields of the result are inferred from this path.

The per-frame loop is streaming: one frame's modalities are decoded, fed into per-feature accumulators, and discarded. Peak memory is independent of phase length.

def iter_phase_dirs(data_root: pathlib.Path) -> Iterator[pathlib.Path]:
886def iter_phase_dirs(data_root: Path) -> Iterator[Path]:
887    """Yield every ``<scene>/<phase>`` directory under ``data_root``.
888
889    A phase directory is any immediate child of a scene directory whose
890    name is purely numeric and that contains an ``rgb/`` subdirectory.
891    """
892    data_root = Path(data_root)
893    if not data_root.is_dir():
894        raise DatasetError(
895            f"data_root is not a directory: {data_root}", hint="check the --data-root argument."
896        )
897    for scene_dir in sorted(p for p in data_root.iterdir() if p.is_dir()):
898        for child in sorted(scene_dir.iterdir()):
899            if child.is_dir() and child.name.isdigit() and (child / "rgb").is_dir():
900                yield child

Yield every <scene>/<phase> directory under data_root.

A phase directory is any immediate child of a scene directory whose name is purely numeric and that contains an rgb/ subdirectory.