fishxinyu's picture
download
raw
10.4 kB
"""Evaluate camera-motion adherence of generated videos against a camera reference clip.
Both videos are reduced to a 3D camera trajectory (``traj.py``), canonicalized to frame 0 and
scale-normalized, then compared (``metric.py``):
rot_err mean geodesic angle between the two rotation sequences, degrees (lower better)
trans_err mean L2 between scale-normalized camera centres (lower better)
cam_mc mean Frobenius over [R | C], rotation and translation together (lower better)
plus ``rpe_rot`` / ``rpe_trans`` (windowed), ``traj_cos`` (scale-free direction agreement) and
``ate`` (Sim(3)-aligned) as diagnostics, and the legacy 2D ``zoom`` / ``trans`` for continuity
with pre-2026-08 runs.
Two controls, both emitted per sample:
<metric>_chance the static camera -- identity rotation, zero translation -- scored against
the same reference. Analytic, exact and free. This is the chance level, and
reading raw errors without it is a mistake this metric invites: the four v1
references differ enormously in motion magnitude, so a raw error mostly
reports which camera the sample drew. <metric>_rel puts them on one scale;
< 1 means the generation beat a locked-off camera.
--shuffle-refs scores each generation against a DIFFERENT sample's reference. With four
camera clips a derangement always draws one of the other three, so this is
a direct test of whether the metric can tell the references apart at all.
If shuffled ~= matched, the number is not measuring camera adherence.
Known limitations:
* VGGT is a static-scene model; its accuracy degrades where a moving subject dominates the
frame. ``ref_motion_scale`` / ``gen_motion_scale`` are emitted so a trajectory the
estimator saw as motionless is visible rather than silently scored.
* Camera motion is all this sees -- not scene coherence, temporal flicker, or quality.
Usage:
python eval_camera/eval.py \\
--generated-dir /path/to/generated/videos \\
--dataset-dir /path/to/dataset/camera \\
--output-dir /path/to/output \\
--backend vggt
"""
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
import numpy as np
from tqdm import tqdm
from metric import SCORED_METRICS, eval_metrics, legacy_2d_score, score
from traj import BACKENDS, MAX_FRAMES_DEFAULT, extract_trajectory, limit_threads
# Columns written to results.csv, in order: every metric with its control and ratio.
_NO_RATIO = {"traj_cos"}
def _columns(include_legacy: bool) -> list[str]:
cols: list[str] = []
for metric in eval_metrics:
if metric in ("zoom", "trans"):
continue
cols += [metric, f"{metric}_chance"]
if metric not in _NO_RATIO:
cols.append(f"{metric}_rel")
if include_legacy:
for metric in ("zoom", "trans"):
cols += [metric, f"{metric}_chance", f"{metric}_rel"]
return cols
def evaluate_camera(
generated_dir: Path,
dataset_dir: Path,
output_dir: Path | None = None,
shuffle_refs: bool = False,
backend: str = "vggt",
max_frames: int = MAX_FRAMES_DEFAULT,
cache_dir: Path | None = None,
device: str = "cuda",
include_legacy: bool = True,
) -> None:
generated_files = {f.stem: f for f in sorted(generated_dir.glob("*.mp4"))}
reference_files = {f.stem: f for f in sorted(dataset_dir.glob("*.mp4"))}
sample_ids = sorted(set(generated_files) & set(reference_files))
if not sample_ids:
print(f"No matching samples between {generated_dir} and {dataset_dir}")
return
pairing = dict(zip(sample_ids, sample_ids))
if shuffle_refs:
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from derange import deranged_pairing # noqa: E402
pairing = deranged_pairing(sample_ids)
if cache_dir is None and output_dir is not None:
# Default the cache beside the results so a re-score after a metric change is free,
# and so the four reference trajectories are extracted once per run rather than
# once per sample.
cache_dir = output_dir / "traj_cache"
print(f"Evaluating {len(sample_ids)} sample(s) with backend={backend}...")
per_sample: dict[str, dict] = {}
columns = _columns(include_legacy)
for sid in tqdm(sample_ids):
ref_id = pairing[sid]
try:
gen_traj = extract_trajectory(
generated_files[sid], backend, max_frames, cache_dir, device
)
ref_traj = extract_trajectory(
reference_files[ref_id], backend, max_frames, cache_dir, device
)
except (ValueError, RuntimeError) as exc:
print(f" {sid}: skipped ({exc})")
continue
row = score(gen_traj, ref_traj)
if include_legacy:
try:
row.update(
legacy_2d_score(
generated_files[sid], reference_files[ref_id], cache_dir
)
)
except (ValueError, ImportError) as exc:
print(f" {sid}: legacy 2D metrics unavailable ({exc})")
row["ref_id"] = ref_id
per_sample[sid] = row
if not per_sample:
print("No samples scored.")
return
scored_ids = sorted(per_sample)
mean = {}
for key in columns:
vals = [
per_sample[s][key]
for s in scored_ids
if per_sample[s].get(key) is not None and np.isfinite(per_sample[s].get(key, np.nan))
]
mean[key] = float(np.mean(vals)) if vals else float("nan")
beats = {
m: sum(1 for s in scored_ids if per_sample[s][m] < per_sample[s][f"{m}_chance"])
for m in SCORED_METRICS
}
print(f"\n{'=' * 60}")
print(f"Mean metrics over {len(scored_ids)} sample(s)"
f"{' [SHUFFLED REFS]' if shuffle_refs else ''}:")
for key in columns:
print(f" {key}: {mean[key]:.6f}")
print(" beats static control:")
for m in SCORED_METRICS:
print(f" {m}: {beats[m]}/{len(scored_ids)}")
if output_dir is None:
return
output_dir.mkdir(parents=True, exist_ok=True)
result = {
"backend": backend,
"max_frames": max_frames,
"shuffled_refs": shuffle_refs,
"mean": {k: (None if v != v else round(v, 6)) for k, v in mean.items()},
"beats_control": {m: f"{beats[m]}/{len(scored_ids)}" for m in SCORED_METRICS},
"per_sample": {
sid: {
k: (round(v, 6) if isinstance(v, float) and v == v else v)
for k, v in per_sample[sid].items()
}
for sid in scored_ids
},
}
(output_dir / "results.json").write_text(json.dumps(result, indent=2))
with (output_dir / "results.csv").open("w", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(["sample_id"] + columns)
writer.writerow(["average"] + [f"{mean[k]:.6f}" for k in columns])
for sid in scored_ids:
writer.writerow(
[sid]
+ [
""
if per_sample[sid].get(k) is None or per_sample[sid].get(k) != per_sample[sid].get(k)
else f"{per_sample[sid][k]:.6f}"
for k in columns
]
)
print(f"Results saved to {output_dir}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Evaluate camera-trajectory adherence against a reference camera clip.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--generated-dir",
required=True,
help="Directory containing generated .mp4 files (one per sample ID).",
)
parser.add_argument(
"--dataset-dir",
required=True,
help=(
"Directory of camera reference videos, i.e. <dataset>/camera. Expects "
"<id>.mp4 whose stems match the generated video stems."
),
)
parser.add_argument(
"--output-dir",
default=None,
help="Optional folder to write results.json and results.csv.",
)
parser.add_argument(
"--shuffle-refs",
action="store_true",
help=(
"Score each generation against a different sample's reference. The static control "
"is always emitted as <metric>_chance regardless; this is the stronger test of "
"whether the metric can distinguish the reference clips from one another."
),
)
parser.add_argument("--backend", default="vggt", choices=BACKENDS)
parser.add_argument("--max-frames", type=int, default=MAX_FRAMES_DEFAULT)
parser.add_argument(
"--cache-dir",
default=None,
help="Trajectory cache. Defaults to <output-dir>/traj_cache.",
)
parser.add_argument("--device", default="cuda")
parser.add_argument(
"--cpu-threads",
type=int,
default=2,
help="Cap on OpenCV/torch CPU threads. The GPU is the bottleneck; leaving these "
"uncapped makes sharded workers fight for cores instead.",
)
parser.add_argument(
"--no-legacy-2d",
action="store_true",
help="Skip the legacy zoom/trans diagnostics (saves a CPU pass over both videos).",
)
args = parser.parse_args()
limit_threads(args.cpu_threads)
generated_dir = Path(args.generated_dir)
dataset_dir = Path(args.dataset_dir)
if not generated_dir.exists():
parser.error(f"Generated video directory not found: {generated_dir}")
if not dataset_dir.exists():
parser.error(f"Camera reference directory not found: {dataset_dir}")
evaluate_camera(
generated_dir=generated_dir,
dataset_dir=dataset_dir,
output_dir=Path(args.output_dir) if args.output_dir else None,
shuffle_refs=args.shuffle_refs,
backend=args.backend,
max_frames=args.max_frames,
cache_dir=Path(args.cache_dir) if args.cache_dir else None,
device=args.device,
include_legacy=not args.no_legacy_2d,
)
if __name__ == "__main__":
main()

Xet Storage Details

Size:
10.4 kB
·
Xet hash:
bfcf6439c277a02f6d600decc2b7903cfac23bf42e2acdb16c65cd71415eac16

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.