rpx_benchmark.tasks._video_pipeline

Per-clip pipeline for video tasks (Video Depth today, future per-clip tasks later).

The sibling of rpx_benchmark.tasks._pipeline, which handles per-frame tasks. The two pipelines are deliberately split rather than overloaded onto a single run_pipeline: the iteration unit is different (frame vs. clip), the dataset class is different (~rpx_benchmark.loader.RPXDataset vs. ~rpx_benchmark.video_loader.VideoDepthDataset), and per-clip tasks have an explicit alignment hook between model.predict and the metric suite that per-frame tasks don't need. Mixing both into one function obscured both code paths during the Video Depth prototype; the split is the result of that prototype's lesson.

The contract for a video model is exactly what ~rpx_benchmark.api.BenchmarkModel describes: setup() loads weights, predict(batch[VideoSample]) -> list[VideoDepthPrediction] runs one inference per clip. The model declares depth_output_kind = "metric" (default) or "relative" on the class; the pipeline applies the adapter's declared per-clip alignment to the prediction before metrics are computed when the model is relative. This matches the community convention for evaluating affine-invariant video-depth models (DepthCrafter, RollingDepth, MoGe-2 video runs).

  1"""Per-clip pipeline for video tasks (Video Depth today, future per-clip tasks
  2later).
  3
  4The sibling of :mod:`rpx_benchmark.tasks._pipeline`, which handles
  5per-frame tasks. The two pipelines are deliberately split rather than
  6overloaded onto a single ``run_pipeline``: the iteration unit is
  7different (frame vs. clip), the dataset class is different
  8(:class:`~rpx_benchmark.loader.RPXDataset` vs.
  9:class:`~rpx_benchmark.video_loader.VideoDepthDataset`), and per-clip tasks
 10have an explicit alignment hook between ``model.predict`` and the
 11metric suite that per-frame tasks don't need. Mixing both into one
 12function obscured both code paths during the Video Depth prototype; the
 13split is the result of that prototype's lesson.
 14
 15The contract for a video model is exactly what
 16:class:`~rpx_benchmark.api.BenchmarkModel` describes: ``setup()``
 17loads weights, ``predict(batch[VideoSample]) -> list[VideoDepthPrediction]``
 18runs one inference per clip. The model declares
 19``depth_output_kind = "metric"`` (default) or ``"relative"`` on the
 20class; the pipeline applies the adapter's declared per-clip alignment
 21to the prediction *before* metrics are computed when the model is relative.
 22This matches the community convention for evaluating affine-invariant
 23video-depth models (DepthCrafter, RollingDepth, MoGe-2 video runs).
 24"""
 25
 26from __future__ import annotations
 27
 28import json
 29import os
 30import tempfile
 31import time
 32import zipfile
 33from dataclasses import dataclass
 34from pathlib import Path
 35from typing import Dict, List, Optional
 36
 37import numpy as np
 38
 39from ..api import TaskType, VideoDepthPrediction, VideoSample
 40from ..cell_log import cells_from_per_sample, write_cells
 41from ..exceptions import AdapterError, ConfigError
 42from ..hub import DEFAULT_REPO_ID, download_split
 43from ..logging_utils import get_logger
 44from ..metrics.depth_alignment import (
 45    DEPTH_MAX_M,
 46    DEPTH_MIN_M,
 47    align_pred_to_gt_pooled,
 48)
 49from ..metrics.registry import MetricSuite
 50from ..reports import format_markdown_summary, write_json
 51from ..runner import _make_cuda_sync
 52from ..video_loader import VideoDepthDataset
 53from ._pipeline import PipelineResult, TaskRunConfig, resolve_device
 54
 55log = get_logger(__name__)
 56
 57
 58# --------------------------------------------------------------------------- #
 59# Config
 60# --------------------------------------------------------------------------- #
 61
 62
 63@dataclass
 64class VideoTaskRunConfig(TaskRunConfig):
 65    """Adds Video Depth-specific knobs to :class:`TaskRunConfig`.
 66
 67    ``frame_budget`` and ``sampling`` are the temporal-resolution
 68    ablation knobs from the paper (\\S5.2). Defaults mean "use every
 69    frame on disk", which is the headline run.
 70    """
 71
 72    frame_budget: Optional[int] = None
 73    sampling: str = "all"
 74    save_predictions: bool = False
 75    resume_predictions: bool = False
 76    compute_fscore: bool = False
 77
 78
 79# --------------------------------------------------------------------------- #
 80# Alignment helper
 81# --------------------------------------------------------------------------- #
 82
 83
 84def _apply_clip_alignment(
 85    pred: VideoDepthPrediction,
 86    sample: VideoSample,
 87    *,
 88    mode: str = "ls_affine",
 89) -> VideoDepthPrediction:
 90    """Solve one ``(s, t)`` over the whole clip and apply it.
 91
 92    Relative-depth video models produce ``pred.depth_map_seq`` up to an
 93    affine transform per clip. Per the model's official convention
 94    (depth-space for most models, disparity-space for GemDepth), we fit
 95    a single ``(s, t)``
 96    over every valid pixel in the clip and apply it uniformly across
 97    all ``T`` frames. Per-frame fitting would let the model cheat
 98    temporal-inconsistency punishments; per-scene fitting (pooling all
 99    three phases) would conflate phase-stability with calibration drift.
100    """
101    gt = sample.ground_truth
102    fit_mask = (
103        gt.valid_mask_seq.astype(bool)
104        & np.isfinite(pred.depth_map_seq)
105        & np.isfinite(gt.depth_map_seq)
106        & (gt.depth_map_seq > DEPTH_MIN_M)
107        & (gt.depth_map_seq < DEPTH_MAX_M)
108    )
109    raw = pred.depth_map_seq.astype(np.float32)
110    target = gt.depth_map_seq.astype(np.float32)
111    if mode == "ls_disparity":
112        # GemDepth's official evaluator clips raw inverse depth, fits it
113        # to GT inverse depth over the entire clip, and only then takes
114        # the reciprocal.
115        raw = np.clip(raw, 1e-3, None)
116        target_inverse = np.zeros_like(target)
117        np.reciprocal(target, out=target_inverse, where=fit_mask)
118        target = target_inverse
119        aligned_inverse = align_pred_to_gt_pooled(
120            pred_seq=raw,
121            gt_seq=target,
122            mode="ls_affine",
123            valid_seq=fit_mask,
124        )
125        aligned_seq = np.reciprocal(np.clip(aligned_inverse, 1e-3, None))
126    elif mode == "ls_affine":
127        aligned_seq = align_pred_to_gt_pooled(
128            pred_seq=raw,
129            gt_seq=target,
130            mode="ls_affine",
131            valid_seq=fit_mask,
132        )
133    else:
134        raise ConfigError(
135            f"unsupported video-depth native alignment mode: {mode!r}"
136        )
137    return VideoDepthPrediction(depth_map_seq=aligned_seq)
138
139
140def _prediction_path(predictions_dir: Path, sample: VideoSample) -> Path:
141    meta = sample.metadata or {}
142    scene = meta.get("scene_id") or str(sample.id)
143    phase = meta.get("phase_idx")
144    if phase is None:
145        phase = sample.phase.value if sample.phase is not None else "unknown"
146    return predictions_dir / str(scene) / str(phase) / "depth.npz"
147
148
149def _paper_valid_mask(sample: VideoSample) -> np.ndarray:
150    gt = np.asarray(sample.ground_truth.depth_map_seq)
151    return (
152        np.asarray(sample.ground_truth.valid_mask_seq, dtype=bool)
153        & np.isfinite(gt)
154        & (gt > DEPTH_MIN_M)
155        & (gt < DEPTH_MAX_M)
156    )
157
158
159def _validate_prediction(
160    depth: np.ndarray,
161    sample: VideoSample,
162    *,
163    raise_on_error: bool,
164) -> np.ndarray | None:
165    expected_shape = np.asarray(sample.ground_truth.depth_map_seq).shape
166    error: str | None = None
167    if depth.dtype != np.dtype(np.float32):
168        error = f"dtype {depth.dtype} is not float32"
169    elif depth.ndim != 3 or depth.shape != expected_shape:
170        error = f"shape {depth.shape} does not match {expected_shape}"
171    elif not np.isfinite(depth).all():
172        error = "prediction contains non-finite values"
173    else:
174        valid = _paper_valid_mask(sample)
175        if not valid.any():
176            error = "clip has no GT pixels in 0.3 < depth < 5.0 m"
177        elif float(np.ptp(depth[valid])) <= 1e-6:
178            error = "prediction is degenerate (constant on paper-valid pixels)"
179    if error is None:
180        return depth
181    if raise_on_error:
182        raise AdapterError(
183            f"Refusing invalid Video Depth prediction for {sample.id!r}: {error}",
184            hint=(
185                "Expected one finite, non-degenerate float32 depth sequence "
186                "matching GT. Evaluation applies the documented [0.3, 5.0] "
187                "prediction clip on the strict paper-valid GT mask."
188            ),
189        )
190    return None
191
192
193def _load_cached_prediction(
194    path: Path,
195    sample: VideoSample,
196) -> tuple[VideoDepthPrediction, float] | None:
197    try:
198        with np.load(path, allow_pickle=False) as payload:
199            if set(payload.files) != {"depth", "frame_indices", "latency_ms"}:
200                return None
201            depth = np.asarray(payload["depth"])
202            frame_indices = np.asarray(payload["frame_indices"])
203            latency_raw = np.asarray(payload["latency_ms"])
204        expected_frames = np.asarray(sample.ground_truth.frame_indices, dtype=np.int32)
205        if frame_indices.dtype != np.dtype(np.int32):
206            return None
207        if not np.array_equal(frame_indices, expected_frames):
208            return None
209        if latency_raw.size != 1:
210            return None
211        latency_ms = float(latency_raw.reshape(-1)[0])
212        if not np.isfinite(latency_ms) or latency_ms < 0:
213            return None
214        depth = _validate_prediction(depth, sample, raise_on_error=False)
215        if depth is None:
216            return None
217        return VideoDepthPrediction(depth_map_seq=depth), latency_ms
218    except (OSError, ValueError, KeyError, EOFError, zipfile.BadZipFile):
219        return None
220
221
222def _atomic_save_prediction(
223    path: Path,
224    prediction: VideoDepthPrediction,
225    sample: VideoSample,
226    latency_ms: float,
227) -> None:
228    depth = np.asarray(prediction.depth_map_seq, dtype=np.float32)
229    _validate_prediction(depth, sample, raise_on_error=True)
230    path.parent.mkdir(parents=True, exist_ok=True)
231    temp_path: Path | None = None
232    try:
233        with tempfile.NamedTemporaryFile(
234            mode="w+b",
235            suffix=".npz.part",
236            dir=path.parent,
237            delete=False,
238        ) as handle:
239            temp_path = Path(handle.name)
240            np.savez_compressed(
241                handle,
242                depth=depth,
243                frame_indices=np.asarray(
244                    sample.ground_truth.frame_indices,
245                    dtype=np.int32,
246                ),
247                latency_ms=np.asarray(latency_ms, dtype=np.float64),
248            )
249            handle.flush()
250            os.fsync(handle.fileno())
251        os.replace(temp_path, path)
252    finally:
253        if temp_path is not None and temp_path.exists():
254            temp_path.unlink()
255
256
257def _atomic_write_json(path: Path, payload: dict) -> None:
258    path.parent.mkdir(parents=True, exist_ok=True)
259    temp_path: Path | None = None
260    try:
261        with tempfile.NamedTemporaryFile(
262            mode="w",
263            suffix=".json.part",
264            dir=path.parent,
265            encoding="utf-8",
266            delete=False,
267        ) as handle:
268            temp_path = Path(handle.name)
269            json.dump(payload, handle, indent=2, sort_keys=True)
270            handle.write("\n")
271            handle.flush()
272            os.fsync(handle.fileno())
273        os.replace(temp_path, path)
274    finally:
275        if temp_path is not None and temp_path.exists():
276            temp_path.unlink()
277
278
279# --------------------------------------------------------------------------- #
280# Pipeline
281# --------------------------------------------------------------------------- #
282
283
284def run_video_pipeline(
285    *,
286    task: TaskType,
287    primary_metric: str,
288    cfg: VideoTaskRunConfig,
289) -> PipelineResult:
290    """End-to-end per-clip pipeline: download → load → predict (+ align)
291    → metric suite → cell-log row → JSON / markdown summary.
292
293    The only Video Depth-specific knobs are ``frame_budget`` and ``sampling``;
294    everything else lines up with the per-frame
295    :func:`~rpx_benchmark.tasks._pipeline.run_pipeline` so the team
296    doesn't have to learn two contracts.
297    """
298    cfg.device = resolve_device(cfg.device, require_cuda=cfg.require_cuda)
299    if cfg.resume_predictions and not cfg.save_predictions:
300        raise ConfigError(
301            "resume_predictions requires save_predictions",
302            hint="Enable both flags so missing or invalid cache entries can be replaced.",
303        )
304    split_name = cfg.split.value if hasattr(cfg.split, "value") else str(cfg.split)
305    repo_id = cfg.repo_id or DEFAULT_REPO_ID
306
307    log.info(
308        "video pipeline: task=%s split=%s device=%s frame_budget=%s sampling=%s",
309        task.value,
310        split_name,
311        cfg.device,
312        cfg.frame_budget,
313        cfg.sampling,
314    )
315
316    if cfg.manifest_path:
317        manifest_path = Path(cfg.manifest_path)
318        if not manifest_path.exists():
319            raise ConfigError(
320                f"manifest_path does not exist: {manifest_path}",
321                hint="Generate per-task manifests for a local v2-webp "
322                "tree with `python -m rpx_benchmark.dataset_hub.cli "
323                "manifest ...` and pass the resulting "
324                "<staging>/manifests/video_depth/<split>.json path.",
325            )
326        log.info("using local manifest: %s", manifest_path)
327    else:
328        manifest_path = download_split(
329            task=task,
330            split=cfg.split,
331            repo_id=repo_id,
332            cache_dir=cfg.cache_dir,
333            revision=cfg.revision,
334            max_samples=cfg.max_samples,
335        )
336    dataset = VideoDepthDataset.from_manifest(
337        manifest_path,
338        batch_size=cfg.batch_size,
339        frame_budget=cfg.frame_budget,
340        sampling=cfg.sampling,
341        max_samples=cfg.max_samples,
342        compute_fscore=cfg.compute_fscore,
343    )
344    log.info("loaded %d clips from %s", len(dataset), manifest_path)
345
346    model = cfg.model
347    name = getattr(model, "name", "model")
348    is_relative = getattr(model, "depth_output_kind", "metric") == "relative"
349
350    alignment_mode = getattr(model, "native_alignment", "ls_affine")
351    if is_relative:
352        log.info(
353            "model %s is relative-depth → per-clip %s alignment will be applied",
354            name,
355            alignment_mode,
356        )
357
358    safe_name = name.replace("/", "__")
359    out_dir = Path(cfg.output_dir or f"./rpx_results/{safe_name}/{split_name}")
360    predictions_dir = out_dir / "predictions"
361    prediction_stats = {
362        "cache_hits": 0,
363        "inferred_new": 0,
364        "invalid_recomputed": 0,
365    }
366
367    # Auto-detect SystemCard (GPU / precision / batch_size) so every
368    # cell row carries the host hardware context. Required for
369    # downstream cross-host aggregation per
370    # benchmark/README.md publication gate.
371    from ..profiler import SystemCard
372
373    system_card = SystemCard.auto_detect(
374        precision=getattr(model, "native_precision", "fp32"),
375        batch_size=cfg.batch_size,
376    )
377
378    metric_suite = MetricSuite.for_task(task)
379
380    per_sample: List[dict] = []
381    n_clips = len(dataset)
382    cuda_sync = _make_cuda_sync()
383    model_is_setup = False
384    for clip_idx, batch in enumerate(dataset, start=1):
385        if not batch:
386            continue
387        predictions: List[VideoDepthPrediction | None] = [None] * len(batch)
388        latencies_ms: List[float | None] = [None] * len(batch)
389        missing_indices: List[int] = []
390        invalid_existing: set[int] = set()
391        for index, sample in enumerate(batch):
392            path = _prediction_path(predictions_dir, sample)
393            if cfg.resume_predictions and path.exists():
394                cached = _load_cached_prediction(path, sample)
395                if cached is not None:
396                    predictions[index], latencies_ms[index] = cached
397                    prediction_stats["cache_hits"] += 1
398                    continue
399                invalid_existing.add(index)
400            missing_indices.append(index)
401
402        if missing_indices:
403            if not model_is_setup:
404                model.setup()
405                model_is_setup = True
406            missing_batch = [batch[index] for index in missing_indices]
407            cuda_sync()
408            t0 = time.perf_counter()
409            inferred = model.predict(missing_batch)
410            cuda_sync()
411            elapsed_ms = (time.perf_counter() - t0) * 1000.0
412            if len(inferred) != len(missing_batch):
413                raise ConfigError(
414                    f"model returned {len(inferred)} predictions for "
415                    f"{len(missing_batch)} clips; expected one per clip",
416                )
417            per_clip_latency_ms = elapsed_ms / len(missing_batch)
418            for index, pred in zip(missing_indices, inferred, strict=True):
419                sample = batch[index]
420                if is_relative:
421                    pred = _apply_clip_alignment(
422                        pred,
423                        sample,
424                        mode=alignment_mode,
425                    )
426                canonical = np.asarray(pred.depth_map_seq, dtype=np.float32)
427                _validate_prediction(canonical, sample, raise_on_error=True)
428                pred = VideoDepthPrediction(depth_map_seq=canonical)
429                predictions[index] = pred
430                latencies_ms[index] = per_clip_latency_ms
431                if index in invalid_existing:
432                    prediction_stats["invalid_recomputed"] += 1
433                else:
434                    prediction_stats["inferred_new"] += 1
435                if cfg.save_predictions:
436                    _atomic_save_prediction(
437                        _prediction_path(predictions_dir, sample),
438                        pred,
439                        sample,
440                        per_clip_latency_ms,
441                    )
442
443        for sample, pred, latency_ms in zip(
444            batch,
445            predictions,
446            latencies_ms,
447            strict=True,
448        ):
449            if pred is None or latency_ms is None:  # pragma: no cover - invariant
450                raise ConfigError("internal error: unresolved video prediction")
451            evaluation_pred = VideoDepthPrediction(
452                depth_map_seq=np.clip(
453                    pred.depth_map_seq,
454                    DEPTH_MIN_M,
455                    DEPTH_MAX_M,
456                ).astype(np.float32, copy=False),
457            )
458            metrics: Dict[str, float] = dict(
459                metric_suite.evaluate(evaluation_pred, sample.ground_truth)
460            )
461            meta = sample.metadata or {}
462            metrics.update(
463                id=sample.id,
464                scene=meta.get("scene_id"),
465                phase=sample.phase,
466                difficulty=sample.difficulty,
467                frame_budget=int(meta.get("frame_budget", 0)),
468                latency_ms=latency_ms,
469            )
470            per_sample.append(metrics)
471
472        if clip_idx % 5 == 0 or clip_idx == n_clips:
473            log.info("video pipeline: %d/%d clips processed", clip_idx, n_clips)
474
475    result = metric_suite.build_result(per_sample)
476
477    out_dir.mkdir(parents=True, exist_ok=True)
478    json_path = out_dir / "result.json"
479    md_path = out_dir / "summary.md"
480    cells_path = out_dir / "cells.parquet"
481    metadata_path = out_dir / "run_metadata.json"
482
483    # Auto-detected metric keys exclude obvious metadata (id/scene/phase/
484    # difficulty/latency_ms via the cell_log defaults) but ``frame_budget``
485    # looks numeric, so list metrics explicitly to keep it out.
486    auto_metric_keys = sorted(
487        k for k, v in (per_sample[0].items() if per_sample else [])
488        if isinstance(v, (int, float)) and not isinstance(v, bool)
489        and k not in {"frame_budget", "latency_ms"}
490    )
491    cells = cells_from_per_sample(
492        per_sample,
493        model_name=name,
494        task=task.value,
495        metric_keys=auto_metric_keys,
496        system_card=system_card,
497    )
498    write_cells(cells, cells_path)
499    log.info("wrote %d cell rows to %s", len(cells), cells_path)
500
501    write_json(
502        json_path,
503        task=task.value,
504        model_name=name,
505        split=split_name,
506        repo_id=repo_id,
507        result=result,
508        dr_report=None,
509    )
510    md_path.write_text(
511        format_markdown_summary(
512            task=task.value,
513            model_name=name,
514            split=split_name,
515            repo_id=repo_id,
516            result=result,
517            dr_report=None,
518        ),
519        encoding="utf-8",
520    )
521    log.info("wrote %s and %s", json_path, md_path)
522    _atomic_write_json(
523        metadata_path,
524        {
525            "task": task.value,
526            "model_name": name,
527            "split": split_name,
528            "save_predictions": cfg.save_predictions,
529            "resume_predictions": cfg.resume_predictions,
530            "compute_fscore": cfg.compute_fscore,
531            "prediction_evaluation_policy": {
532                "operation": "clip",
533                "minimum_m": DEPTH_MIN_M,
534                "maximum_m": DEPTH_MAX_M,
535                "raw_cache_preserved": True,
536            },
537            "prediction_stats": prediction_stats,
538        },
539    )
540
541    paths: Dict[str, Path] = {
542        "json": json_path,
543        "markdown": md_path,
544        "cells": cells_path,
545        "out_dir": out_dir,
546        "run_metadata": metadata_path,
547    }
548    if cfg.save_predictions:
549        paths["predictions_dir"] = predictions_dir
550        paths["prediction_stats"] = prediction_stats
551
552    if cfg.upload_to_box:
553        from ..box_upload import DEFAULT_BOX_FOLDER_ID, upload_run_dir
554
555        try:
556            summary = upload_run_dir(
557                out_dir,
558                task=task.value,
559                model_name=name,
560                split=split_name,
561                root_folder_id=cfg.box_folder_id or DEFAULT_BOX_FOLDER_ID,
562            )
563            log.info(
564                "box: %d uploaded, %d skipped → box:%s",
565                summary["uploaded"],
566                summary["skipped"],
567                summary["remote_path"],
568            )
569            paths["box_remote"] = summary["remote_path"]
570        except Exception as e:  # noqa: BLE001
571            log.error(
572                "Box upload failed (%s: %s) — local artefacts at %s are intact",
573                type(e).__name__,
574                e,
575                out_dir,
576            )
577
578    return result, None, paths  # type: ignore[return-value]
579
580
581__all__ = ["VideoTaskRunConfig", "run_video_pipeline"]
@dataclass
class VideoTaskRunConfig(rpx_benchmark.tasks._pipeline.TaskRunConfig):
64@dataclass
65class VideoTaskRunConfig(TaskRunConfig):
66    """Adds Video Depth-specific knobs to :class:`TaskRunConfig`.
67
68    ``frame_budget`` and ``sampling`` are the temporal-resolution
69    ablation knobs from the paper (\\S5.2). Defaults mean "use every
70    frame on disk", which is the headline run.
71    """
72
73    frame_budget: Optional[int] = None
74    sampling: str = "all"
75    save_predictions: bool = False
76    resume_predictions: bool = False
77    compute_fscore: bool = False

Adds Video Depth-specific knobs to TaskRunConfig.

frame_budget and sampling are the temporal-resolution ablation knobs from the paper (\S5.2). Defaults mean "use every frame on disk", which is the headline run.

VideoTaskRunConfig( model: Optional[rpx_benchmark.adapters.BenchmarkableModel] = None, split: rpx_benchmark.api.Difficulty | str = <Difficulty.HARD: 'hard'>, repo_id: Optional[str] = None, cache_dir: Optional[str] = None, revision: Optional[str] = '51432e5d48895bbafb490ae7c5e34e0f6b7da7d6', batch_size: int = 1, require_cuda: bool = False, skip_flops: bool = False, max_samples: Optional[int] = None, manifest_path: Optional[str] = None, device: str = 'cuda', output_dir: Optional[str] = None, progress: Optional[Callable[[int, Optional[int], str], NoneType]] = None, metric_suite: Optional[rpx_benchmark.metrics.MetricSuite] = None, upload_to_box: bool = False, box_folder_id: Optional[str] = None, frame_budget: Optional[int] = None, sampling: str = 'all', save_predictions: bool = False, resume_predictions: bool = False, compute_fscore: bool = False)
frame_budget: Optional[int] = None
sampling: str = 'all'
save_predictions: bool = False
resume_predictions: bool = False
compute_fscore: bool = False
def run_video_pipeline( *, task: rpx_benchmark.api.TaskType, primary_metric: str, cfg: VideoTaskRunConfig) -> Tuple[rpx_benchmark.metrics.registry.BenchmarkResult, rpx_benchmark.deployment.DeploymentReadinessReport, Dict[str, pathlib.Path]]:
285def run_video_pipeline(
286    *,
287    task: TaskType,
288    primary_metric: str,
289    cfg: VideoTaskRunConfig,
290) -> PipelineResult:
291    """End-to-end per-clip pipeline: download → load → predict (+ align)
292    → metric suite → cell-log row → JSON / markdown summary.
293
294    The only Video Depth-specific knobs are ``frame_budget`` and ``sampling``;
295    everything else lines up with the per-frame
296    :func:`~rpx_benchmark.tasks._pipeline.run_pipeline` so the team
297    doesn't have to learn two contracts.
298    """
299    cfg.device = resolve_device(cfg.device, require_cuda=cfg.require_cuda)
300    if cfg.resume_predictions and not cfg.save_predictions:
301        raise ConfigError(
302            "resume_predictions requires save_predictions",
303            hint="Enable both flags so missing or invalid cache entries can be replaced.",
304        )
305    split_name = cfg.split.value if hasattr(cfg.split, "value") else str(cfg.split)
306    repo_id = cfg.repo_id or DEFAULT_REPO_ID
307
308    log.info(
309        "video pipeline: task=%s split=%s device=%s frame_budget=%s sampling=%s",
310        task.value,
311        split_name,
312        cfg.device,
313        cfg.frame_budget,
314        cfg.sampling,
315    )
316
317    if cfg.manifest_path:
318        manifest_path = Path(cfg.manifest_path)
319        if not manifest_path.exists():
320            raise ConfigError(
321                f"manifest_path does not exist: {manifest_path}",
322                hint="Generate per-task manifests for a local v2-webp "
323                "tree with `python -m rpx_benchmark.dataset_hub.cli "
324                "manifest ...` and pass the resulting "
325                "<staging>/manifests/video_depth/<split>.json path.",
326            )
327        log.info("using local manifest: %s", manifest_path)
328    else:
329        manifest_path = download_split(
330            task=task,
331            split=cfg.split,
332            repo_id=repo_id,
333            cache_dir=cfg.cache_dir,
334            revision=cfg.revision,
335            max_samples=cfg.max_samples,
336        )
337    dataset = VideoDepthDataset.from_manifest(
338        manifest_path,
339        batch_size=cfg.batch_size,
340        frame_budget=cfg.frame_budget,
341        sampling=cfg.sampling,
342        max_samples=cfg.max_samples,
343        compute_fscore=cfg.compute_fscore,
344    )
345    log.info("loaded %d clips from %s", len(dataset), manifest_path)
346
347    model = cfg.model
348    name = getattr(model, "name", "model")
349    is_relative = getattr(model, "depth_output_kind", "metric") == "relative"
350
351    alignment_mode = getattr(model, "native_alignment", "ls_affine")
352    if is_relative:
353        log.info(
354            "model %s is relative-depth → per-clip %s alignment will be applied",
355            name,
356            alignment_mode,
357        )
358
359    safe_name = name.replace("/", "__")
360    out_dir = Path(cfg.output_dir or f"./rpx_results/{safe_name}/{split_name}")
361    predictions_dir = out_dir / "predictions"
362    prediction_stats = {
363        "cache_hits": 0,
364        "inferred_new": 0,
365        "invalid_recomputed": 0,
366    }
367
368    # Auto-detect SystemCard (GPU / precision / batch_size) so every
369    # cell row carries the host hardware context. Required for
370    # downstream cross-host aggregation per
371    # benchmark/README.md publication gate.
372    from ..profiler import SystemCard
373
374    system_card = SystemCard.auto_detect(
375        precision=getattr(model, "native_precision", "fp32"),
376        batch_size=cfg.batch_size,
377    )
378
379    metric_suite = MetricSuite.for_task(task)
380
381    per_sample: List[dict] = []
382    n_clips = len(dataset)
383    cuda_sync = _make_cuda_sync()
384    model_is_setup = False
385    for clip_idx, batch in enumerate(dataset, start=1):
386        if not batch:
387            continue
388        predictions: List[VideoDepthPrediction | None] = [None] * len(batch)
389        latencies_ms: List[float | None] = [None] * len(batch)
390        missing_indices: List[int] = []
391        invalid_existing: set[int] = set()
392        for index, sample in enumerate(batch):
393            path = _prediction_path(predictions_dir, sample)
394            if cfg.resume_predictions and path.exists():
395                cached = _load_cached_prediction(path, sample)
396                if cached is not None:
397                    predictions[index], latencies_ms[index] = cached
398                    prediction_stats["cache_hits"] += 1
399                    continue
400                invalid_existing.add(index)
401            missing_indices.append(index)
402
403        if missing_indices:
404            if not model_is_setup:
405                model.setup()
406                model_is_setup = True
407            missing_batch = [batch[index] for index in missing_indices]
408            cuda_sync()
409            t0 = time.perf_counter()
410            inferred = model.predict(missing_batch)
411            cuda_sync()
412            elapsed_ms = (time.perf_counter() - t0) * 1000.0
413            if len(inferred) != len(missing_batch):
414                raise ConfigError(
415                    f"model returned {len(inferred)} predictions for "
416                    f"{len(missing_batch)} clips; expected one per clip",
417                )
418            per_clip_latency_ms = elapsed_ms / len(missing_batch)
419            for index, pred in zip(missing_indices, inferred, strict=True):
420                sample = batch[index]
421                if is_relative:
422                    pred = _apply_clip_alignment(
423                        pred,
424                        sample,
425                        mode=alignment_mode,
426                    )
427                canonical = np.asarray(pred.depth_map_seq, dtype=np.float32)
428                _validate_prediction(canonical, sample, raise_on_error=True)
429                pred = VideoDepthPrediction(depth_map_seq=canonical)
430                predictions[index] = pred
431                latencies_ms[index] = per_clip_latency_ms
432                if index in invalid_existing:
433                    prediction_stats["invalid_recomputed"] += 1
434                else:
435                    prediction_stats["inferred_new"] += 1
436                if cfg.save_predictions:
437                    _atomic_save_prediction(
438                        _prediction_path(predictions_dir, sample),
439                        pred,
440                        sample,
441                        per_clip_latency_ms,
442                    )
443
444        for sample, pred, latency_ms in zip(
445            batch,
446            predictions,
447            latencies_ms,
448            strict=True,
449        ):
450            if pred is None or latency_ms is None:  # pragma: no cover - invariant
451                raise ConfigError("internal error: unresolved video prediction")
452            evaluation_pred = VideoDepthPrediction(
453                depth_map_seq=np.clip(
454                    pred.depth_map_seq,
455                    DEPTH_MIN_M,
456                    DEPTH_MAX_M,
457                ).astype(np.float32, copy=False),
458            )
459            metrics: Dict[str, float] = dict(
460                metric_suite.evaluate(evaluation_pred, sample.ground_truth)
461            )
462            meta = sample.metadata or {}
463            metrics.update(
464                id=sample.id,
465                scene=meta.get("scene_id"),
466                phase=sample.phase,
467                difficulty=sample.difficulty,
468                frame_budget=int(meta.get("frame_budget", 0)),
469                latency_ms=latency_ms,
470            )
471            per_sample.append(metrics)
472
473        if clip_idx % 5 == 0 or clip_idx == n_clips:
474            log.info("video pipeline: %d/%d clips processed", clip_idx, n_clips)
475
476    result = metric_suite.build_result(per_sample)
477
478    out_dir.mkdir(parents=True, exist_ok=True)
479    json_path = out_dir / "result.json"
480    md_path = out_dir / "summary.md"
481    cells_path = out_dir / "cells.parquet"
482    metadata_path = out_dir / "run_metadata.json"
483
484    # Auto-detected metric keys exclude obvious metadata (id/scene/phase/
485    # difficulty/latency_ms via the cell_log defaults) but ``frame_budget``
486    # looks numeric, so list metrics explicitly to keep it out.
487    auto_metric_keys = sorted(
488        k for k, v in (per_sample[0].items() if per_sample else [])
489        if isinstance(v, (int, float)) and not isinstance(v, bool)
490        and k not in {"frame_budget", "latency_ms"}
491    )
492    cells = cells_from_per_sample(
493        per_sample,
494        model_name=name,
495        task=task.value,
496        metric_keys=auto_metric_keys,
497        system_card=system_card,
498    )
499    write_cells(cells, cells_path)
500    log.info("wrote %d cell rows to %s", len(cells), cells_path)
501
502    write_json(
503        json_path,
504        task=task.value,
505        model_name=name,
506        split=split_name,
507        repo_id=repo_id,
508        result=result,
509        dr_report=None,
510    )
511    md_path.write_text(
512        format_markdown_summary(
513            task=task.value,
514            model_name=name,
515            split=split_name,
516            repo_id=repo_id,
517            result=result,
518            dr_report=None,
519        ),
520        encoding="utf-8",
521    )
522    log.info("wrote %s and %s", json_path, md_path)
523    _atomic_write_json(
524        metadata_path,
525        {
526            "task": task.value,
527            "model_name": name,
528            "split": split_name,
529            "save_predictions": cfg.save_predictions,
530            "resume_predictions": cfg.resume_predictions,
531            "compute_fscore": cfg.compute_fscore,
532            "prediction_evaluation_policy": {
533                "operation": "clip",
534                "minimum_m": DEPTH_MIN_M,
535                "maximum_m": DEPTH_MAX_M,
536                "raw_cache_preserved": True,
537            },
538            "prediction_stats": prediction_stats,
539        },
540    )
541
542    paths: Dict[str, Path] = {
543        "json": json_path,
544        "markdown": md_path,
545        "cells": cells_path,
546        "out_dir": out_dir,
547        "run_metadata": metadata_path,
548    }
549    if cfg.save_predictions:
550        paths["predictions_dir"] = predictions_dir
551        paths["prediction_stats"] = prediction_stats
552
553    if cfg.upload_to_box:
554        from ..box_upload import DEFAULT_BOX_FOLDER_ID, upload_run_dir
555
556        try:
557            summary = upload_run_dir(
558                out_dir,
559                task=task.value,
560                model_name=name,
561                split=split_name,
562                root_folder_id=cfg.box_folder_id or DEFAULT_BOX_FOLDER_ID,
563            )
564            log.info(
565                "box: %d uploaded, %d skipped → box:%s",
566                summary["uploaded"],
567                summary["skipped"],
568                summary["remote_path"],
569            )
570            paths["box_remote"] = summary["remote_path"]
571        except Exception as e:  # noqa: BLE001
572            log.error(
573                "Box upload failed (%s: %s) — local artefacts at %s are intact",
574                type(e).__name__,
575                e,
576                out_dir,
577            )
578
579    return result, None, paths  # type: ignore[return-value]

End-to-end per-clip pipeline: download → load → predict (+ align) → metric suite → cell-log row → JSON / markdown summary.

The only Video Depth-specific knobs are frame_budget and sampling; everything else lines up with the per-frame ~rpx_benchmark.tasks._pipeline.run_pipeline() so the team doesn't have to learn two contracts.