cozy-eval 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cozy_eval/__init__.py +106 -0
- cozy_eval/__main__.py +3 -0
- cozy_eval/_stats.py +99 -0
- cozy_eval/backends/__init__.py +24 -0
- cozy_eval/backends/distributional.py +95 -0
- cozy_eval/backends/perceptual.py +76 -0
- cozy_eval/backends/reference.py +171 -0
- cozy_eval/backends/signal.py +245 -0
- cozy_eval/bench/__init__.py +120 -0
- cozy_eval/bench/catalog.py +52 -0
- cozy_eval/bench/checklists/cozy_hard_eval_v1.json +1479 -0
- cozy_eval/bench/checklists/hard_video_v1.json +235 -0
- cozy_eval/bench/checklists/magicbrush_edit_v1.json +658 -0
- cozy_eval/bench/decompose/__init__.py +76 -0
- cozy_eval/bench/decompose/engine.py +312 -0
- cozy_eval/bench/decompose/sets/compositional-v1_cases.json +1986 -0
- cozy_eval/bench/decompose/sets/compositional-v1_checklists.json +3045 -0
- cozy_eval/bench/decompose/sets/compositional-v1_prompts.json +872 -0
- cozy_eval/bench/decompose/vocab.py +75 -0
- cozy_eval/bench/device.py +29 -0
- cozy_eval/bench/errors.py +47 -0
- cozy_eval/bench/judge.py +70 -0
- cozy_eval/bench/metrics/__init__.py +25 -0
- cozy_eval/bench/metrics/adherence.py +560 -0
- cozy_eval/bench/metrics/data/niqe_pristine.npz +0 -0
- cozy_eval/bench/metrics/geneval.py +491 -0
- cozy_eval/bench/metrics/hpsv3.py +213 -0
- cozy_eval/bench/metrics/iqa.py +257 -0
- cozy_eval/bench/metrics/musiq.py +427 -0
- cozy_eval/bench/metrics/ocr.py +116 -0
- cozy_eval/bench/metrics/preference.py +262 -0
- cozy_eval/bench/metrics/similarity.py +192 -0
- cozy_eval/bench/metrics/temporal.py +177 -0
- cozy_eval/bench/metrics/vqascore.py +162 -0
- cozy_eval/bench/promptset.py +182 -0
- cozy_eval/bench/promptsets/hard_eval_v1.json +295 -0
- cozy_eval/bench/promptsets/hard_video_v1.json +185 -0
- cozy_eval/bench/registry.py +446 -0
- cozy_eval/bench/suite.py +449 -0
- cozy_eval/bench/verdict.py +216 -0
- cozy_eval/bench/video.py +460 -0
- cozy_eval/benchmarks.py +340 -0
- cozy_eval/cli.py +121 -0
- cozy_eval/control.py +164 -0
- cozy_eval/frames.py +126 -0
- cozy_eval/gate.py +256 -0
- cozy_eval/image.py +80 -0
- cozy_eval/protocol.py +248 -0
- cozy_eval-0.1.0.dist-info/METADATA +413 -0
- cozy_eval-0.1.0.dist-info/RECORD +53 -0
- cozy_eval-0.1.0.dist-info/WHEEL +4 -0
- cozy_eval-0.1.0.dist-info/entry_points.txt +2 -0
- cozy_eval-0.1.0.dist-info/licenses/LICENSE +21 -0
cozy_eval/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""cozy-eval — quality benchmarking for generative image and video models,
|
|
2
|
+
with the validity rules built into the API.
|
|
3
|
+
|
|
4
|
+
The measurements here are ordinary. The opinion is not:
|
|
5
|
+
|
|
6
|
+
* **Two lanes, enforced.** Reference metrics (PSNR/SSIM/LPIPS/VMAF) are valid only
|
|
7
|
+
when the compared change leaves the sampling trajectory intact. Ask for them on
|
|
8
|
+
a quantization or compile comparison and the library raises.
|
|
9
|
+
* **Protocol stamping.** Every result carries resolution, frame count, steps,
|
|
10
|
+
seeds, prompt set, execution lane and hardware. A number without those is not
|
|
11
|
+
a result.
|
|
12
|
+
* **Population semantics.** Trajectory-perturbing comparisons are gated over
|
|
13
|
+
n>=8 paired prompts, because a single prompt measures the take, not the recipe.
|
|
14
|
+
* **Thresholds with provenance.** Every budget names the known-good and
|
|
15
|
+
known-bad populations that fixed it, and its separation margin.
|
|
16
|
+
* **Null controls.** A budget only holds where a zero-change arm (same weights,
|
|
17
|
+
different seeds) sits inside it. ``measure_null_control`` measures that per
|
|
18
|
+
family; budgets its control trips are disregarded, and a run with disregarded
|
|
19
|
+
budgets cannot rise above INDETERMINATE.
|
|
20
|
+
* **No signal is not a failure.** An arm of constant frames returns DEGENERATE,
|
|
21
|
+
never a confident FAIL with an imaging index of 0.0.
|
|
22
|
+
|
|
23
|
+
Quick start::
|
|
24
|
+
|
|
25
|
+
from cozy_eval import (
|
|
26
|
+
ChangeKind, Protocol, score_pairs, run_population_gate,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
proto = Protocol(
|
|
30
|
+
family="ltx-2.3-distilled",
|
|
31
|
+
reference_arm="bf16 compiled", candidate_arm="w8a8-pcs compiled",
|
|
32
|
+
changes=(ChangeKind.WEIGHT_QUANTIZATION, ChangeKind.ACTIVATION_QUANTIZATION),
|
|
33
|
+
width=1280, height=704, frames=121, steps=8,
|
|
34
|
+
seeds=(8,), prompts=("forge", "chef", "market", "portrait",
|
|
35
|
+
"fabric", "street", "water", "forest"),
|
|
36
|
+
execution_lane="compiled", hardware="NVIDIA H100 80GB HBM3",
|
|
37
|
+
)
|
|
38
|
+
pairs = score_pairs(reference_clips, candidate_clips)
|
|
39
|
+
print(run_population_gate(pairs, proto).summary())
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from .backends.signal import ClipScore
|
|
43
|
+
from .backends.signal import score as score_clip
|
|
44
|
+
from .benchmarks import (
|
|
45
|
+
MIN_PROMPTS,
|
|
46
|
+
REFERENCE_BUDGETS,
|
|
47
|
+
VIDEO_BUDGETS,
|
|
48
|
+
BenchmarkResult,
|
|
49
|
+
Budget,
|
|
50
|
+
distributional,
|
|
51
|
+
imaging,
|
|
52
|
+
temporal,
|
|
53
|
+
)
|
|
54
|
+
from .control import NullControl, measure_null_control
|
|
55
|
+
from .frames import iter_frames, probe
|
|
56
|
+
from .gate import (
|
|
57
|
+
GateReport,
|
|
58
|
+
run_population_gate,
|
|
59
|
+
run_reference_gate,
|
|
60
|
+
score_pairs,
|
|
61
|
+
)
|
|
62
|
+
from .image import IMAGE_BUDGETS, run_image_population_gate, score_image
|
|
63
|
+
from .protocol import (
|
|
64
|
+
ChangeKind,
|
|
65
|
+
Lane,
|
|
66
|
+
Protocol,
|
|
67
|
+
ProtocolError,
|
|
68
|
+
TrajectoryPerturbingError,
|
|
69
|
+
Verdict,
|
|
70
|
+
classify_change,
|
|
71
|
+
require_reference_lane,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
__version__ = "0.1.0"
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"IMAGE_BUDGETS",
|
|
78
|
+
"MIN_PROMPTS",
|
|
79
|
+
"REFERENCE_BUDGETS",
|
|
80
|
+
"VIDEO_BUDGETS",
|
|
81
|
+
"BenchmarkResult",
|
|
82
|
+
"Budget",
|
|
83
|
+
"ChangeKind",
|
|
84
|
+
"ClipScore",
|
|
85
|
+
"GateReport",
|
|
86
|
+
"Lane",
|
|
87
|
+
"NullControl",
|
|
88
|
+
"Protocol",
|
|
89
|
+
"ProtocolError",
|
|
90
|
+
"TrajectoryPerturbingError",
|
|
91
|
+
"Verdict",
|
|
92
|
+
"classify_change",
|
|
93
|
+
"distributional",
|
|
94
|
+
"imaging",
|
|
95
|
+
"iter_frames",
|
|
96
|
+
"measure_null_control",
|
|
97
|
+
"probe",
|
|
98
|
+
"require_reference_lane",
|
|
99
|
+
"run_image_population_gate",
|
|
100
|
+
"run_population_gate",
|
|
101
|
+
"run_reference_gate",
|
|
102
|
+
"score_clip",
|
|
103
|
+
"score_image",
|
|
104
|
+
"score_pairs",
|
|
105
|
+
"temporal",
|
|
106
|
+
]
|
cozy_eval/__main__.py
ADDED
cozy_eval/_stats.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The two statistics the population gate needs, with no SciPy in the core.
|
|
2
|
+
|
|
3
|
+
A paired t-test and Holm-Bonferroni. SciPy would do both, but pulling a 40 MB
|
|
4
|
+
compiled dependency into the base install for one continued fraction is not a
|
|
5
|
+
trade worth making — the base install has to be importable inside a conversion
|
|
6
|
+
worker image that already carries torch and diffusers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _betacf(a: float, b: float, x: float) -> float:
|
|
15
|
+
"""Continued fraction for the incomplete beta function (modified Lentz)."""
|
|
16
|
+
tiny = 1e-300
|
|
17
|
+
qab, qap, qam = a + b, a + 1.0, a - 1.0
|
|
18
|
+
c = 1.0
|
|
19
|
+
d = 1.0 - qab * x / qap
|
|
20
|
+
if abs(d) < tiny:
|
|
21
|
+
d = tiny
|
|
22
|
+
d = 1.0 / d
|
|
23
|
+
h = d
|
|
24
|
+
for m in range(1, 300):
|
|
25
|
+
m2 = 2 * m
|
|
26
|
+
aa = m * (b - m) * x / ((qam + m2) * (a + m2))
|
|
27
|
+
d = 1.0 + aa * d
|
|
28
|
+
c = 1.0 + aa / c
|
|
29
|
+
if abs(d) < tiny:
|
|
30
|
+
d = tiny
|
|
31
|
+
if abs(c) < tiny:
|
|
32
|
+
c = tiny
|
|
33
|
+
d = 1.0 / d
|
|
34
|
+
h *= d * c
|
|
35
|
+
aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
|
|
36
|
+
d = 1.0 + aa * d
|
|
37
|
+
c = 1.0 + aa / c
|
|
38
|
+
if abs(d) < tiny:
|
|
39
|
+
d = tiny
|
|
40
|
+
if abs(c) < tiny:
|
|
41
|
+
c = tiny
|
|
42
|
+
d = 1.0 / d
|
|
43
|
+
delta = d * c
|
|
44
|
+
h *= delta
|
|
45
|
+
if abs(delta - 1.0) < 3e-16:
|
|
46
|
+
break
|
|
47
|
+
return h
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def betainc(a: float, b: float, x: float) -> float:
|
|
51
|
+
"""Regularized incomplete beta I_x(a, b)."""
|
|
52
|
+
if x <= 0.0:
|
|
53
|
+
return 0.0
|
|
54
|
+
if x >= 1.0:
|
|
55
|
+
return 1.0
|
|
56
|
+
lbeta = (math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b)
|
|
57
|
+
+ a * math.log(x) + b * math.log1p(-x))
|
|
58
|
+
front = math.exp(lbeta)
|
|
59
|
+
if x < (a + 1.0) / (a + b + 2.0):
|
|
60
|
+
return front * _betacf(a, b, x) / a
|
|
61
|
+
return 1.0 - math.exp(
|
|
62
|
+
math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b)
|
|
63
|
+
+ b * math.log1p(-x) + a * math.log(x)
|
|
64
|
+
) * _betacf(b, a, 1.0 - x) / b
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def t_two_sided_p(t: float, df: float) -> float:
|
|
68
|
+
"""Two-sided p-value of a t statistic."""
|
|
69
|
+
if df <= 0:
|
|
70
|
+
return 1.0
|
|
71
|
+
if not math.isfinite(t):
|
|
72
|
+
return 0.0
|
|
73
|
+
return float(betainc(df / 2.0, 0.5, df / (df + t * t)))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def paired_t(diffs) -> tuple[float, float]:
|
|
77
|
+
"""(t, two-sided p) for a paired sample of differences."""
|
|
78
|
+
n = len(diffs)
|
|
79
|
+
if n < 2:
|
|
80
|
+
return 0.0, 1.0
|
|
81
|
+
mean = sum(diffs) / n
|
|
82
|
+
var = sum((d - mean) ** 2 for d in diffs) / (n - 1)
|
|
83
|
+
se = math.sqrt(var / n)
|
|
84
|
+
if se <= 0.0:
|
|
85
|
+
return (0.0, 1.0) if mean == 0.0 else (math.inf, 0.0)
|
|
86
|
+
t = mean / se
|
|
87
|
+
return t, t_two_sided_p(t, n - 1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def holm(pvalues: dict[str, float]) -> dict[str, float]:
|
|
91
|
+
"""Holm-Bonferroni step-down adjusted p-values."""
|
|
92
|
+
order = sorted(pvalues, key=lambda k: pvalues[k])
|
|
93
|
+
k = len(order)
|
|
94
|
+
out: dict[str, float] = {}
|
|
95
|
+
running = 0.0
|
|
96
|
+
for i, name in enumerate(order):
|
|
97
|
+
running = max(running, min(1.0, pvalues[name] * (k - i)))
|
|
98
|
+
out[name] = running
|
|
99
|
+
return out
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Measurement backends.
|
|
2
|
+
|
|
3
|
+
The library's opinion is about *protocol*, not about who computes the numbers.
|
|
4
|
+
Backends are therefore pluggable and, wherever a maintained package already owns
|
|
5
|
+
a metric, we call it rather than reimplement it:
|
|
6
|
+
|
|
7
|
+
* :mod:`.signal` — the always-available core. Elementary signal statistics
|
|
8
|
+
(Laplacian variance, spectral high-frequency ratio, histogram entropy, first-
|
|
9
|
+
and second-order temporal differences). These are primitives, not somebody's
|
|
10
|
+
library; the core keeps them so the base install is numpy + ffmpeg.
|
|
11
|
+
* :mod:`.reference` — reference metrics for the same-trajectory lane. Uses
|
|
12
|
+
``libvmaf`` through ffmpeg when present and scikit-image's SSIM when present,
|
|
13
|
+
falling back to the built-in windowed SSIM.
|
|
14
|
+
* :mod:`.perceptual` — ``pip install cozy-eval[perceptual]``. Wraps
|
|
15
|
+
``pyiqa`` (MUSIQ, CLIP-IQA, MANIQA, NIQE, BRISQUE) and, for video, DOVER-class
|
|
16
|
+
scorers. Never reimplemented here.
|
|
17
|
+
* :mod:`.distributional` — ``pip install cozy-eval[distributional]``.
|
|
18
|
+
Deep-feature Fréchet distances (I3D-FVD, V-JEPA/JEDi). Opt-in and explicitly
|
|
19
|
+
NOT validated at the sample sizes this library's gate runs at; see GATE.md.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from . import reference, signal
|
|
23
|
+
|
|
24
|
+
__all__ = ["reference", "signal"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Optional deep-feature Fréchet distances — ``pip install cozy-eval[distributional]``.
|
|
2
|
+
|
|
3
|
+
**Read this before using it.** FVD-class distances are population statistics with
|
|
4
|
+
a well-known small-sample bias: published FVD is computed over thousands of
|
|
5
|
+
clips, and at the n=8-16 a per-artifact quality gate can afford, the estimator's
|
|
6
|
+
bias dominates the effect you are trying to measure. This library's *gate* does
|
|
7
|
+
not use a deep-feature Fréchet distance for that reason — its distributional
|
|
8
|
+
benchmark is a paired test over the no-reference score distributions, which is
|
|
9
|
+
valid at n=8 because the prompt set is identical between arms.
|
|
10
|
+
|
|
11
|
+
This module exists so a lane that CAN afford n in the hundreds gets the stronger
|
|
12
|
+
statistic without leaving the protocol machinery behind. It reports the sample
|
|
13
|
+
size next to every number and refuses below ``min_samples``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SampleSizeError(RuntimeError):
|
|
22
|
+
"""Raised when a Fréchet-class statistic is requested at an unusable n."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def frechet(feat_a: np.ndarray, feat_b: np.ndarray, *, min_samples: int = 64) -> float:
|
|
26
|
+
"""Fréchet distance between two Gaussian-fitted feature populations.
|
|
27
|
+
|
|
28
|
+
Full covariance; requires ``n >= min_samples`` on both sides. Uses SciPy's
|
|
29
|
+
matrix square root when available and an eigendecomposition otherwise.
|
|
30
|
+
"""
|
|
31
|
+
a, b = np.asarray(feat_a, np.float64), np.asarray(feat_b, np.float64)
|
|
32
|
+
if min(len(a), len(b)) < min_samples:
|
|
33
|
+
raise SampleSizeError(
|
|
34
|
+
f"Frechet distance over {min(len(a), len(b))} samples is dominated by "
|
|
35
|
+
f"estimator bias (need >= {min_samples}). Use the paired population "
|
|
36
|
+
"benchmark instead — see GATE.md."
|
|
37
|
+
)
|
|
38
|
+
mu_a, mu_b = a.mean(0), b.mean(0)
|
|
39
|
+
ca = np.cov(a, rowvar=False)
|
|
40
|
+
cb = np.cov(b, rowvar=False)
|
|
41
|
+
diff = mu_a - mu_b
|
|
42
|
+
try:
|
|
43
|
+
from scipy.linalg import sqrtm
|
|
44
|
+
covmean = sqrtm(ca @ cb)
|
|
45
|
+
if np.iscomplexobj(covmean):
|
|
46
|
+
covmean = covmean.real
|
|
47
|
+
except ImportError: # pragma: no cover
|
|
48
|
+
w, v = np.linalg.eigh(ca)
|
|
49
|
+
root = v @ np.diag(np.sqrt(np.clip(w, 0, None))) @ v.T
|
|
50
|
+
w2, v2 = np.linalg.eigh(root @ cb @ root)
|
|
51
|
+
covmean = v2 @ np.diag(np.sqrt(np.clip(w2, 0, None))) @ v2.T
|
|
52
|
+
return float(diff @ diff + np.trace(ca + cb - 2.0 * covmean))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fvd(real_dir, generated_dir, *, model: str = "i3d", device: str = "cuda") -> float:
|
|
56
|
+
"""Content-debiased FVD via ``cd-fvd`` (MIT). Not reimplemented here.
|
|
57
|
+
|
|
58
|
+
Vendoring yet another I3D checkpoint loader is how FVD numbers stopped being
|
|
59
|
+
comparable between papers in the first place.
|
|
60
|
+
|
|
61
|
+
Note (verified 2026-07-26): cd-fvd's PyPI release points ``model="videomae"``
|
|
62
|
+
at a dead weights URL; the HuggingFace fix landed on ``main`` after the last
|
|
63
|
+
upload. Use ``model="i3d"`` from PyPI, or install cd-fvd from git for
|
|
64
|
+
VideoMAEv2 features.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
from cdfvd import fvd as _fvd
|
|
68
|
+
except ImportError as exc: # pragma: no cover
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
"needs cd-fvd: pip install 'cozy-eval[distributional]'"
|
|
71
|
+
) from exc
|
|
72
|
+
ev = _fvd.cdfvd(model, device=device)
|
|
73
|
+
ev.compute_real_stats(ev.load_videos(str(real_dir), data_type="video_folder"))
|
|
74
|
+
ev.compute_fake_stats(ev.load_videos(str(generated_dir), data_type="video_folder"))
|
|
75
|
+
return float(ev.compute_fvd_from_stats())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fvmd(real_dir, generated_dir, log_dir) -> float: # pragma: no cover
|
|
79
|
+
"""Fréchet Video Motion Distance via ``fvmd`` (Apache-2.0). Not reimplemented.
|
|
80
|
+
|
|
81
|
+
Motion-statistics distance over PIPs++ keypoint tracks. Reported to correlate
|
|
82
|
+
with human judgement better than FVD or VBench on motion artifacts, which is
|
|
83
|
+
the failure mode this library's temporal benchmark also targets — run both if
|
|
84
|
+
you can afford the keypoint tracker.
|
|
85
|
+
|
|
86
|
+
NOT declared as a dependency: fvmd 1.0.0 pins ``scipy==1.10.1``, which is
|
|
87
|
+
unsatisfiable alongside anything modern. Install it into its own environment.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
from fvmd import fvmd as _fvmd
|
|
91
|
+
except ImportError as exc:
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
"needs fvmd: pip install 'cozy-eval[distributional]'"
|
|
94
|
+
) from exc
|
|
95
|
+
return float(_fvmd(str(log_dir), str(generated_dir), str(real_dir)))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Optional learned no-reference backends — ``pip install cozy-eval[perceptual]``.
|
|
2
|
+
|
|
3
|
+
We do not implement MUSIQ, CLIP-IQA, MANIQA, NIQE or BRISQUE; ``pyiqa`` already
|
|
4
|
+
maintains all of them behind one factory, so this module is a ~40-line adapter
|
|
5
|
+
that (a) makes them frame sources like every other backend, (b) aggregates a
|
|
6
|
+
per-frame score into a clip score, and (c) records which model and weights were
|
|
7
|
+
used inside the protocol stamp — an unstamped MUSIQ number is as unusable as an
|
|
8
|
+
unstamped LPIPS number.
|
|
9
|
+
|
|
10
|
+
**Licence, verified 2026-07-26.** ``pyiqa`` relicensed Apache-2.0 ->
|
|
11
|
+
PolyForm-Noncommercial-1.0.0 at **0.1.16** (released 2026-07-08). The extra pins
|
|
12
|
+
``<0.1.16`` to stay on the Apache-2.0 line; raising that ceiling is a licensing
|
|
13
|
+
decision, not a version bump. DOVER and FAST-VQA are deliberately absent: both
|
|
14
|
+
are S-Lab-1.0 (non-commercial) despite ``setup.py`` files that still declare
|
|
15
|
+
MIT/Apache.
|
|
16
|
+
|
|
17
|
+
These models download weights on first use and want a GPU to be quick, which is
|
|
18
|
+
why they are an extra rather than the core. The gate's calibrated thresholds are
|
|
19
|
+
on the signal backend; a perceptual score joins the report as an additional,
|
|
20
|
+
uncalibrated observation until somebody banks a clean/degraded population for it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import functools
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from ..frames import iter_frames
|
|
30
|
+
|
|
31
|
+
#: Models known to work as a per-frame no-reference score through pyiqa.
|
|
32
|
+
FRAME_MODELS = ("musiq", "clipiqa", "maniqa", "niqe", "brisque", "topiq_nr")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def available() -> bool:
|
|
36
|
+
try:
|
|
37
|
+
import pyiqa # noqa: F401
|
|
38
|
+
return True
|
|
39
|
+
except ImportError:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@functools.cache
|
|
44
|
+
def _metric(name: str, device: str):
|
|
45
|
+
import pyiqa
|
|
46
|
+
return pyiqa.create_metric(name, device=device)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def score_frames(source, *, model: str = "musiq", device: str = "cpu",
|
|
50
|
+
stride: int = 8) -> dict[str, float]:
|
|
51
|
+
"""Aggregate a pyiqa per-frame no-reference score over a clip.
|
|
52
|
+
|
|
53
|
+
``stride`` subsamples frames: these models are two orders of magnitude more
|
|
54
|
+
expensive than the signal backend and per-frame scores are highly correlated
|
|
55
|
+
between neighbours.
|
|
56
|
+
"""
|
|
57
|
+
if not available(): # pragma: no cover
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
"perceptual backend needs pyiqa: pip install 'cozy-eval[perceptual]'"
|
|
60
|
+
)
|
|
61
|
+
import torch
|
|
62
|
+
m = _metric(model, device)
|
|
63
|
+
vals: list[float] = []
|
|
64
|
+
for i, f in enumerate(iter_frames(source)):
|
|
65
|
+
if i % stride:
|
|
66
|
+
continue
|
|
67
|
+
t = torch.from_numpy(f.transpose(2, 0, 1)[None]).float().to(device)
|
|
68
|
+
with torch.no_grad():
|
|
69
|
+
vals.append(float(m(t).item()))
|
|
70
|
+
if not vals:
|
|
71
|
+
raise ValueError("no frames scored")
|
|
72
|
+
return {
|
|
73
|
+
f"{model}_mean": float(np.mean(vals)),
|
|
74
|
+
f"{model}_p10": float(np.percentile(vals, 10)),
|
|
75
|
+
f"{model}_frames": float(len(vals)),
|
|
76
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Reference metrics for the same-trajectory lane.
|
|
2
|
+
|
|
3
|
+
Composed, not reimplemented, wherever a maintained implementation exists:
|
|
4
|
+
|
|
5
|
+
* **VMAF** via ``ffmpeg_quality_metrics`` (MIT, actively maintained) when it is
|
|
6
|
+
installed, falling back to a direct ``ffmpeg -lavfi libvmaf`` call. Netflix's
|
|
7
|
+
model is the right instrument for post-decode changes and there is no reason to
|
|
8
|
+
have an opinion of our own about it.
|
|
9
|
+
* **ColorVideoVDP** via ``cvvdp`` (MIT) when installed — a colour- and HDR-aware
|
|
10
|
+
spatiotemporal full-reference video metric, strictly a better instrument than
|
|
11
|
+
VMAF for this lane. Optional because it is heavier.
|
|
12
|
+
* **SSIM** via ``skimage.metrics.structural_similarity`` when scikit-image is
|
|
13
|
+
installed; otherwise a built-in 8x8 block-windowed SSIM (global SSIM is far too
|
|
14
|
+
insensitive on video — it stays at 0.998 through a visibly blocky re-encode).
|
|
15
|
+
* **LPIPS** via the ``lpips`` package when installed.
|
|
16
|
+
* **PSNR** is one line of numpy and has no library worth depending on.
|
|
17
|
+
|
|
18
|
+
Nothing in this module may be called without passing the
|
|
19
|
+
:func:`~cozy_eval.protocol.require_reference_lane` guard first; the gate
|
|
20
|
+
does that for you.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import functools
|
|
26
|
+
import json
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import tempfile
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
from ..frames import LUMA, iter_frames
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def psnr(a: np.ndarray, b: np.ndarray) -> float:
|
|
38
|
+
mse = float(np.mean((a - b) ** 2))
|
|
39
|
+
return 99.0 if mse < 1e-12 else float(10.0 * np.log10(1.0 / mse))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ssim_blocks(a: np.ndarray, b: np.ndarray, block: int = 8) -> float:
|
|
43
|
+
la, lb = a @ LUMA, b @ LUMA
|
|
44
|
+
h, w = la.shape
|
|
45
|
+
h, w = h - h % block, w - w % block
|
|
46
|
+
|
|
47
|
+
def tiles(x):
|
|
48
|
+
x = x[:h, :w].reshape(h // block, block, w // block, block)
|
|
49
|
+
return x.transpose(0, 2, 1, 3).reshape(-1, block * block)
|
|
50
|
+
|
|
51
|
+
ta, tb = tiles(la), tiles(lb)
|
|
52
|
+
m1, m2 = ta.mean(1), tb.mean(1)
|
|
53
|
+
v1, v2 = ta.var(1), tb.var(1)
|
|
54
|
+
cov = ((ta - m1[:, None]) * (tb - m2[:, None])).mean(1)
|
|
55
|
+
c1, c2 = 0.01 ** 2, 0.03 ** 2
|
|
56
|
+
s = ((2 * m1 * m2 + c1) * (2 * cov + c2)) / ((m1 ** 2 + m2 ** 2 + c1) * (v1 + v2 + c2))
|
|
57
|
+
return float(s.mean())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@functools.cache
|
|
61
|
+
def _have_skimage() -> bool:
|
|
62
|
+
try:
|
|
63
|
+
import skimage.metrics # noqa: F401
|
|
64
|
+
return True
|
|
65
|
+
except ImportError:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ssim(a: np.ndarray, b: np.ndarray) -> float:
|
|
70
|
+
if _have_skimage():
|
|
71
|
+
from skimage.metrics import structural_similarity
|
|
72
|
+
return float(structural_similarity(a @ LUMA, b @ LUMA, data_range=1.0))
|
|
73
|
+
return _ssim_blocks(a, b)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@functools.cache
|
|
77
|
+
def have_vmaf() -> bool:
|
|
78
|
+
"""Is the local ffmpeg built with libvmaf?"""
|
|
79
|
+
if not shutil.which("ffmpeg"):
|
|
80
|
+
return False
|
|
81
|
+
out = subprocess.run(["ffmpeg", "-hide_banner", "-filters"],
|
|
82
|
+
capture_output=True, text=True, check=False).stdout
|
|
83
|
+
return " libvmaf " in out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def vmaf(reference: str | Path, candidate: str | Path) -> dict[str, float]:
|
|
87
|
+
"""Netflix VMAF. Prefers ffmpeg_quality_metrics; falls back to raw ffmpeg."""
|
|
88
|
+
try:
|
|
89
|
+
from ffmpeg_quality_metrics import FfmpegQualityMetrics
|
|
90
|
+
except ImportError:
|
|
91
|
+
pass
|
|
92
|
+
else:
|
|
93
|
+
res = FfmpegQualityMetrics(str(reference), str(candidate)).calculate(["vmaf"])
|
|
94
|
+
vals = [row["vmaf"] for row in res["vmaf"]]
|
|
95
|
+
return {"vmaf_mean": float(np.mean(vals)), "vmaf_min": float(np.min(vals))}
|
|
96
|
+
if not have_vmaf():
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
"this ffmpeg has no libvmaf filter; install an ffmpeg built with "
|
|
99
|
+
"--enable-libvmaf, or omit VMAF from the reference metric set"
|
|
100
|
+
)
|
|
101
|
+
with tempfile.NamedTemporaryFile("r", suffix=".json") as out:
|
|
102
|
+
subprocess.run(
|
|
103
|
+
["ffmpeg", "-v", "error", "-i", str(candidate), "-i", str(reference),
|
|
104
|
+
"-lavfi", f"libvmaf=log_fmt=json:log_path={out.name}", "-f", "null", "-"],
|
|
105
|
+
check=True, capture_output=True,
|
|
106
|
+
)
|
|
107
|
+
data = json.loads(Path(out.name).read_text())
|
|
108
|
+
pooled = data["pooled_metrics"]["vmaf"]
|
|
109
|
+
return {"vmaf_mean": float(pooled["mean"]), "vmaf_min": float(pooled["min"])}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@functools.cache
|
|
113
|
+
def _lpips_model():
|
|
114
|
+
import lpips as _l
|
|
115
|
+
return _l.LPIPS(net="alex")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def lpips_pair(a: np.ndarray, b: np.ndarray) -> float:
|
|
119
|
+
"""Per-frame LPIPS via the ``lpips`` package (optional dependency)."""
|
|
120
|
+
import torch
|
|
121
|
+
model = _lpips_model()
|
|
122
|
+
def t(x):
|
|
123
|
+
return torch.from_numpy(x.transpose(2, 0, 1)[None] * 2.0 - 1.0).float()
|
|
124
|
+
with torch.no_grad():
|
|
125
|
+
return float(model(t(a), t(b)).item())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cvvdp(reference: str | Path, candidate: str | Path,
|
|
129
|
+
display: str = "standard_4k") -> dict[str, float]:
|
|
130
|
+
"""ColorVideoVDP (JOD units, higher is better). Needs ``[reference]`` extra."""
|
|
131
|
+
try:
|
|
132
|
+
import pycvvdp
|
|
133
|
+
except ImportError as exc: # pragma: no cover
|
|
134
|
+
raise RuntimeError(
|
|
135
|
+
"cvvdp not installed: pip install 'cozy-eval[reference]'"
|
|
136
|
+
) from exc
|
|
137
|
+
metric = pycvvdp.cvvdp(display_name=display)
|
|
138
|
+
jod, _ = metric.predict(str(candidate), str(reference), dim_order="FHWC")
|
|
139
|
+
return {"cvvdp_jod": float(jod)}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def compare(reference, candidate, *, with_lpips: bool = False) -> dict[str, float]:
|
|
143
|
+
"""Per-frame reference metrics between two frame sources of the same take."""
|
|
144
|
+
ps: list[float] = []
|
|
145
|
+
ss: list[float] = []
|
|
146
|
+
lp: list[float] = []
|
|
147
|
+
n = 0
|
|
148
|
+
for a, b in zip(iter_frames(reference), iter_frames(candidate), strict=True):
|
|
149
|
+
if a.shape != b.shape:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"frame shape mismatch {a.shape} vs {b.shape}: a resize is itself a "
|
|
152
|
+
"change, declare ChangeKind.OUTPUT_RESIZE and compare at one size"
|
|
153
|
+
)
|
|
154
|
+
n += 1
|
|
155
|
+
ps.append(psnr(a, b))
|
|
156
|
+
ss.append(ssim(a, b))
|
|
157
|
+
if with_lpips:
|
|
158
|
+
lp.append(lpips_pair(a, b))
|
|
159
|
+
if not n:
|
|
160
|
+
raise ValueError("no overlapping frames")
|
|
161
|
+
out = {
|
|
162
|
+
"frames": float(n),
|
|
163
|
+
"psnr_mean": float(np.mean(ps)),
|
|
164
|
+
"psnr_worst": float(np.min(ps)),
|
|
165
|
+
"ssim_mean": float(np.mean(ss)),
|
|
166
|
+
"ssim_worst": float(np.min(ss)),
|
|
167
|
+
}
|
|
168
|
+
if lp:
|
|
169
|
+
out["lpips_mean"] = float(np.mean(lp))
|
|
170
|
+
out["lpips_worst"] = float(np.max(lp))
|
|
171
|
+
return out
|