use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tracing::warn;
use viser_ffmpeg::{ProbeCache, ffmpeg_path};
pub mod noref;
pub mod pool;
pub use noref::{NoRefOpts, NoRefResult, measure_noref};
pub use pool::{PoolStrategy, PooledStats};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Metric {
#[default]
Vmaf,
Psnr,
Ssim,
Ssimulacra2,
Butteraugli,
MsSsim,
Vif,
Cambi,
Xpsnr,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Result {
pub vmaf: f64,
pub psnr: f64,
pub psnr_u: f64,
pub psnr_v: f64,
pub psnr_avg: f64,
pub ssim: f64,
pub ssimulacra2: f64,
pub butteraugli: f64,
pub ms_ssim: f64,
pub vif: f64,
pub cambi: f64,
pub xpsnr: f64,
pub pooled: Pooled,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub frames: Vec<FrameResult>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Pooled {
pub vmaf: PooledStats,
pub psnr: PooledStats,
pub ssim: PooledStats,
pub ssimulacra2: PooledStats,
pub butteraugli: PooledStats,
pub ms_ssim: PooledStats,
pub vif: PooledStats,
pub cambi: PooledStats,
pub xpsnr: PooledStats,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FrameResult {
pub frame_num: i32,
pub vmaf: f64,
pub psnr: f64,
#[serde(default)]
pub psnr_u: f64,
#[serde(default)]
pub psnr_v: f64,
pub ssim: f64,
pub ssimulacra2: f64,
pub butteraugli: f64,
#[serde(default)]
pub ms_ssim: f64,
#[serde(default)]
pub vif: f64,
#[serde(default)]
pub cambi: f64,
#[serde(default)]
pub xpsnr: f64,
}
#[derive(Debug, Clone)]
pub struct MeasureOpts {
pub metrics: Vec<Metric>,
pub subsample: i32,
pub model: String,
pub per_frame: bool,
pub frame_samples: usize,
pub probe_cache: Option<ProbeCache>,
}
impl Default for MeasureOpts {
fn default() -> Self {
Self {
metrics: vec![
Metric::Vmaf,
Metric::Psnr,
Metric::Ssim,
Metric::Ssimulacra2,
Metric::Butteraugli,
],
subsample: 0,
model: "vmaf_v0.6.1".into(),
per_frame: false,
frame_samples: 0,
probe_cache: None,
}
}
}
pub async fn measure(
reference: &str,
distorted: &str,
opts: MeasureOpts,
) -> anyhow::Result<Result> {
let model_name = if opts.model.is_empty() { "vmaf_v0.6.1" } else { &opts.model };
let metrics = if opts.metrics.is_empty() {
vec![Metric::Vmaf, Metric::Psnr, Metric::Ssim]
} else {
opts.metrics.clone()
};
if metrics.iter().all(|m| matches!(m, Metric::Psnr | Metric::Ssim)) {
return measure_native(reference, distorted, &metrics, &opts).await;
}
let tmp = tempfile::Builder::new().prefix("viser-vmaf-").suffix(".json").tempfile()?;
let log_path = tmp.path().to_string_lossy().to_string();
let mut vmaf_opts = format!("log_fmt=json:log_path={log_path}:model=version={model_name}");
let mut features: Vec<&str> = Vec::new();
for m in &metrics {
match m {
Metric::Psnr => features.push("name=psnr"),
Metric::Ssim => features.push("name=float_ssim"),
Metric::MsSsim => features.push("name=float_ms_ssim"),
Metric::Cambi => features.push("name=cambi"),
Metric::Vmaf | Metric::Vif => {}
Metric::Xpsnr | Metric::Ssimulacra2 | Metric::Butteraugli => {}
}
}
if !features.is_empty() {
vmaf_opts.push_str(&format!(":feature={}", features.join("|")));
}
if opts.subsample > 0 {
vmaf_opts.push_str(&format!(":n_subsample={}", opts.subsample));
}
let ref_info = if let Some(ref cache) = opts.probe_cache {
cache.probe(reference).await?
} else {
viser_ffmpeg::probe(reference).await?
};
let ref_video =
ref_info.video_stream().ok_or_else(|| anyhow::anyhow!("no video stream in reference"))?;
if ref_video.bits_per_raw_sample > 8 {
warn!(
bits_per_sample = ref_video.bits_per_raw_sample,
reference = reference,
"10-bit content detected; VMAF scores calibrated for 8-bit may differ"
);
}
let filtergraph = format!(
"[0:v]scale={}:{}:flags=bicubic[dist];[dist][1:v]libvmaf={}",
ref_video.width, ref_video.height, vmaf_opts
);
let args = ["-i", distorted, "-i", reference, "-lavfi", &filtergraph, "-f", "null", "-"];
let output = Command::new(ffmpeg_path())
.args(args)
.stderr(std::process::Stdio::piped())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("ffmpeg quality measurement failed: {stderr}");
}
let data = std::fs::read(&log_path)?;
let mut result = parse_vmaf_log(&data, opts.per_frame)?;
if metrics.contains(&Metric::Ssimulacra2) {
let scores = measure_ssimulacra2(reference, distorted, &opts).await?;
result.ssimulacra2 = pool::PoolStrategy::Mean.apply(&scores);
result.pooled.ssimulacra2 = PooledStats::from_values(&scores);
}
if metrics.contains(&Metric::Butteraugli) {
let scores = measure_butteraugli(reference, distorted, &opts).await?;
result.butteraugli = pool::PoolStrategy::Mean.apply(&scores);
result.pooled.butteraugli = PooledStats::from_values(&scores);
}
if metrics.contains(&Metric::Xpsnr) {
let scores = measure_xpsnr(reference, distorted, &opts).await?;
result.xpsnr = pool::PoolStrategy::Mean.apply(&scores);
result.pooled.xpsnr = PooledStats::from_values(&scores);
if opts.per_frame && scores.len() == result.frames.len() {
for (fr, s) in result.frames.iter_mut().zip(scores) {
fr.xpsnr = s;
}
}
}
Ok(result)
}
async fn measure_native(
reference: &str,
distorted: &str,
metrics: &[Metric],
opts: &MeasureOpts,
) -> anyhow::Result<Result> {
let ref_info = if let Some(ref cache) = opts.probe_cache {
cache.probe(reference).await?
} else {
viser_ffmpeg::probe(reference).await?
};
let ref_video =
ref_info.video_stream().ok_or_else(|| anyhow::anyhow!("no video stream in reference"))?;
let sel = if opts.subsample > 1 {
format!("select=not(mod(n\\,{}))", opts.subsample)
} else {
"null".to_string()
};
let mut result = Result::default();
for m in metrics {
let filter_name = match m {
Metric::Psnr => "psnr",
Metric::Ssim => "ssim",
_ => continue,
};
let filtergraph = format!(
"[0:v]scale={}:{}:flags=bicubic,{sel}[dist];[1:v]{sel}[ref];[dist][ref]{filter_name}",
ref_video.width, ref_video.height
);
let args = ["-i", distorted, "-i", reference, "-lavfi", &filtergraph, "-f", "null", "-"];
let output = Command::new(ffmpeg_path())
.args(args)
.stderr(std::process::Stdio::piped())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("ffmpeg {filter_name} measurement failed: {stderr}");
}
let stderr = String::from_utf8_lossy(&output.stderr);
match m {
Metric::Psnr => {
let line = stderr
.lines()
.rev()
.find(|l| l.contains("PSNR") && l.contains("average:"))
.ok_or_else(|| anyhow::anyhow!("could not parse PSNR from ffmpeg output"))?;
result.psnr = parse_metric_kv(line, "y:").unwrap_or(0.0);
result.psnr_u = parse_metric_kv(line, "u:").unwrap_or(0.0);
result.psnr_v = parse_metric_kv(line, "v:").unwrap_or(0.0);
result.psnr_avg = parse_metric_kv(line, "average:").unwrap_or(result.psnr);
}
Metric::Ssim => {
let line = stderr
.lines()
.rev()
.find(|l| l.contains("SSIM") && l.contains("All:"))
.ok_or_else(|| anyhow::anyhow!("could not parse SSIM from ffmpeg output"))?;
result.ssim = parse_metric_kv(line, "All:").unwrap_or(0.0);
}
_ => {}
}
}
Ok(result)
}
fn parse_metric_kv(line: &str, key: &str) -> Option<f64> {
let start = line.find(key)? + key.len();
let rest = &line[start..];
let end = rest
.find(|c: char| !matches!(c, '0'..='9' | '.' | '-' | '+' | 'e' | 'E'))
.unwrap_or(rest.len());
rest[..end].parse().ok()
}
#[derive(Deserialize)]
struct VmafLog {
frames: Vec<VmafFrame>,
#[serde(default)]
pooled_metrics: std::collections::HashMap<String, PooledMetric>,
}
#[derive(Deserialize)]
struct VmafFrame {
#[serde(rename = "frameNum")]
frame_num: i32,
metrics: std::collections::HashMap<String, f64>,
}
#[derive(Deserialize)]
struct PooledMetric {
mean: f64,
}
fn parse_vmaf_log(data: &[u8], per_frame: bool) -> anyhow::Result<Result> {
let log: VmafLog = serde_json::from_slice(data)?;
let mut result = Result::default();
result.vmaf = pooled_mean(&log, &["vmaf"]);
result.psnr = pooled_mean(&log, &["psnr_y", "psnr"]);
result.psnr_u = pooled_mean(&log, &["psnr_cb", "psnr_u"]);
result.psnr_v = pooled_mean(&log, &["psnr_cr", "psnr_v"]);
result.psnr_avg = if result.psnr_u > 0.0 && result.psnr_v > 0.0 {
(6.0 * result.psnr + result.psnr_u + result.psnr_v) / 8.0
} else {
result.psnr
};
result.ssim = pooled_mean(&log, &["float_ssim", "ssim"]);
let mut vmaf_series = Vec::with_capacity(log.frames.len());
let mut psnr_series = Vec::with_capacity(log.frames.len());
let mut ssim_series = Vec::with_capacity(log.frames.len());
let mut ms_ssim_series = Vec::with_capacity(log.frames.len());
let mut vif_series = Vec::with_capacity(log.frames.len());
let mut cambi_series = Vec::with_capacity(log.frames.len());
for f in &log.frames {
if let Some(v) = f.metrics.get("vmaf") {
vmaf_series.push(*v);
}
if let Some(v) = frame_metric(&f.metrics, &["psnr_y", "psnr"]) {
psnr_series.push(v);
}
if let Some(v) = frame_metric(&f.metrics, &["float_ssim", "ssim"]) {
ssim_series.push(v);
}
if let Some(v) = frame_metric(&f.metrics, &["float_ms_ssim", "ms_ssim"]) {
ms_ssim_series.push(v);
}
if let Some(v) = vif_mean(&f.metrics) {
vif_series.push(v);
}
if let Some(v) = f.metrics.get("cambi") {
cambi_series.push(*v);
}
}
result.pooled.vmaf = PooledStats::from_values(&vmaf_series);
result.pooled.psnr = PooledStats::from_values(&psnr_series);
result.pooled.ssim = PooledStats::from_values(&ssim_series);
result.pooled.ms_ssim = PooledStats::from_values(&ms_ssim_series);
result.pooled.vif = PooledStats::from_values(&vif_series);
result.pooled.cambi = PooledStats::from_values(&cambi_series);
result.ms_ssim = result.pooled.ms_ssim.mean;
result.vif = result.pooled.vif.mean;
result.cambi = result.pooled.cambi.mean;
if result.vmaf == 0.0 {
result.vmaf = result.pooled.vmaf.mean;
}
if result.psnr == 0.0 {
result.psnr = result.pooled.psnr.mean;
if result.psnr_avg == 0.0 {
result.psnr_avg = result.psnr;
}
}
if result.ssim == 0.0 {
result.ssim = result.pooled.ssim.mean;
}
if per_frame {
for f in &log.frames {
result.frames.push(FrameResult {
frame_num: f.frame_num,
vmaf: f.metrics.get("vmaf").copied().unwrap_or(0.0),
psnr: frame_metric(&f.metrics, &["psnr_y", "psnr"]).unwrap_or(0.0),
psnr_u: frame_metric(&f.metrics, &["psnr_cb", "psnr_u"]).unwrap_or(0.0),
psnr_v: frame_metric(&f.metrics, &["psnr_cr", "psnr_v"]).unwrap_or(0.0),
ssim: frame_metric(&f.metrics, &["float_ssim", "ssim"]).unwrap_or(0.0),
ssimulacra2: f.metrics.get("ssimulacra2").copied().unwrap_or(0.0),
butteraugli: f.metrics.get("butteraugli").copied().unwrap_or(0.0),
ms_ssim: frame_metric(&f.metrics, &["float_ms_ssim", "ms_ssim"]).unwrap_or(0.0),
vif: vif_mean(&f.metrics).unwrap_or(0.0),
cambi: f.metrics.get("cambi").copied().unwrap_or(0.0),
xpsnr: 0.0,
});
}
}
Ok(result)
}
fn pooled_mean(log: &VmafLog, keys: &[&str]) -> f64 {
for k in keys {
if let Some(m) = log.pooled_metrics.get(*k) {
return m.mean;
}
}
0.0
}
fn frame_metric(metrics: &std::collections::HashMap<String, f64>, keys: &[&str]) -> Option<f64> {
for k in keys {
if let Some(v) = metrics.get(*k) {
return Some(*v);
}
}
None
}
fn vif_mean(metrics: &std::collections::HashMap<String, f64>) -> Option<f64> {
let mut sum = 0.0;
let mut n = 0;
for s in 0..4 {
if let Some(v) = frame_metric(
metrics,
&[
&format!("integer_vif_scale{s}"),
&format!("float_vif_scale{s}"),
&format!("vif_scale{s}"),
],
) {
sum += v;
n += 1;
}
}
if n > 0 { Some(sum / n as f64) } else { None }
}
fn sample_indices(nb_frames: i32, samples: usize) -> Vec<i32> {
if samples <= 1 || nb_frames <= 1 {
return vec![0];
}
let count = samples.min(nb_frames as usize);
if count <= 1 {
return vec![0];
}
(0..count)
.map(|i| ((i as f64) * (nb_frames as f64 - 1.0) / (count as f64 - 1.0)).round() as i32)
.collect()
}
async fn reference_dims(reference: &str, opts: &MeasureOpts) -> anyhow::Result<(i32, i32, i32)> {
let ref_info = if let Some(ref cache) = opts.probe_cache {
cache.probe(reference).await?
} else {
viser_ffmpeg::probe(reference).await?
};
let ref_video =
ref_info.video_stream().ok_or_else(|| anyhow::anyhow!("no video stream in reference"))?;
Ok((ref_video.width, ref_video.height, ref_video.nb_frames))
}
async fn extract_frames_png(
input: &str,
selection: Option<&[i32]>,
width: i32,
height: i32,
dir: &Path,
) -> anyhow::Result<Vec<PathBuf>> {
let scale = format!("scale={width}:{height}:flags=bicubic");
let vf = match selection {
None => scale,
Some(indices) => {
let sel = indices.iter().map(|i| format!("eq(n\\,{i})")).collect::<Vec<_>>().join("+");
format!("select='{sel}',{scale}")
}
};
let pattern = dir.join("%06d.png");
let output = Command::new(ffmpeg_path())
.args(["-i", input, "-vf", &vf, "-fps_mode", "passthrough", "-c:v", "png"])
.arg(&pattern)
.stderr(std::process::Stdio::piped())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("failed to extract frames from {input}: {stderr}");
}
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "png"))
.collect();
paths.sort();
Ok(paths)
}
struct FramePairs {
_ref_dir: tempfile::TempDir,
_dist_dir: tempfile::TempDir,
pairs: Vec<(PathBuf, PathBuf)>,
}
async fn extract_frame_pairs(
reference: &str,
distorted: &str,
opts: &MeasureOpts,
) -> anyhow::Result<FramePairs> {
let (width, height, nb_frames) = reference_dims(reference, opts).await?;
let (_, _, dist_nb_frames) = reference_dims(distorted, opts).await?;
if dist_nb_frames != nb_frames {
warn!(
reference_frames = nb_frames,
distorted_frames = dist_nb_frames,
"reference and distorted frame counts differ; sampled perceptual metrics may be misaligned"
);
}
let selection: Option<Vec<i32>> = if opts.frame_samples == 0 {
None
} else {
Some(sample_indices(nb_frames, opts.frame_samples))
};
let sel = selection.as_deref();
let ref_dir = tempfile::Builder::new().prefix("viser-q-ref-").tempdir()?;
let dist_dir = tempfile::Builder::new().prefix("viser-q-dist-").tempdir()?;
let ref_paths = extract_frames_png(reference, sel, width, height, ref_dir.path()).await?;
let dist_paths = extract_frames_png(distorted, sel, width, height, dist_dir.path()).await?;
let n = ref_paths.len().min(dist_paths.len());
let pairs =
ref_paths.into_iter().take(n).zip(dist_paths.into_iter().take(n)).collect::<Vec<_>>();
Ok(FramePairs { _ref_dir: ref_dir, _dist_dir: dist_dir, pairs })
}
async fn measure_ssimulacra2(
reference: &str,
distorted: &str,
opts: &MeasureOpts,
) -> anyhow::Result<Vec<f64>> {
let frames = extract_frame_pairs(reference, distorted, opts).await?;
let mut scores = Vec::with_capacity(frames.pairs.len());
for (ref_png, dist_png) in &frames.pairs {
let s2_output = Command::new("ssimulacra2")
.arg(ref_png)
.arg(dist_png)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await?;
if !s2_output.status.success() {
anyhow::bail!("ssimulacra2 failed: {}", String::from_utf8_lossy(&s2_output.stderr));
}
let stdout_str = String::from_utf8_lossy(&s2_output.stdout);
let score: f64 = stdout_str
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("ssimulacra2: could not parse score: {stdout_str}"))?;
scores.push(score);
}
Ok(scores)
}
async fn measure_butteraugli(
reference: &str,
distorted: &str,
opts: &MeasureOpts,
) -> anyhow::Result<Vec<f64>> {
let frames = extract_frame_pairs(reference, distorted, opts).await?;
let mut scores = Vec::with_capacity(frames.pairs.len());
for (i, (ref_png, dist_png)) in frames.pairs.iter().enumerate() {
let ba_output = Command::new("butteraugli")
.arg(ref_png)
.arg(dist_png)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await;
let mut score = 0.0;
let mut parsed = false;
if let Ok(out) = ba_output
&& out.status.success()
{
let stdout_str = String::from_utf8_lossy(&out.stdout);
if let Ok(s) = stdout_str.trim().parse::<f64>() {
score = s;
parsed = true;
} else if let Some(last_line) = stdout_str.lines().last() {
if let Ok(s) = last_line.trim().parse::<f64>() {
score = s;
parsed = true;
}
}
}
if !parsed {
warn!(frame = i, "butteraugli not available or failed; recording 0.0");
}
scores.push(score);
}
Ok(scores)
}
fn parse_xpsnr_component(line: &str, tag: &str) -> Option<f64> {
let idx = line.find(tag)?;
let token = line[idx + tag.len()..].split_whitespace().next()?;
match token {
"inf" | "-inf" => Some(100.0),
t => t.parse::<f64>().ok().map(|x| if x.is_finite() { x } else { 100.0 }),
}
}
async fn measure_xpsnr(
reference: &str,
distorted: &str,
opts: &MeasureOpts,
) -> anyhow::Result<Vec<f64>> {
let (width, height, _nb) = reference_dims(reference, opts).await?;
let stats = tempfile::Builder::new().prefix("viser-xpsnr-").suffix(".log").tempfile()?;
let stats_path = stats.path().to_string_lossy().to_string();
let filtergraph = format!(
"[0:v]scale={width}:{height}:flags=bicubic[dist];[dist][1:v]xpsnr=stats_file={stats_path}"
);
let output = Command::new(ffmpeg_path())
.args(["-i", distorted, "-i", reference, "-lavfi", &filtergraph, "-f", "null", "-"])
.stderr(std::process::Stdio::piped())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("xpsnr measurement failed: {stderr}");
}
let log = std::fs::read_to_string(stats.path())?;
let mut scores = Vec::new();
for line in log.lines() {
if let Some(y) = parse_xpsnr_component(line, "y:") {
let u = parse_xpsnr_component(line, "u:").unwrap_or(y);
let v = parse_xpsnr_component(line, "v:").unwrap_or(y);
scores.push((6.0 * y + u + v) / 8.0);
}
}
Ok(scores)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metric_serde_roundtrip() {
for m in
&[Metric::Vmaf, Metric::Psnr, Metric::Ssim, Metric::Ssimulacra2, Metric::Butteraugli]
{
let json = serde_json::to_string(m).unwrap();
let back: Metric = serde_json::from_str(&json).unwrap();
assert_eq!(*m, back);
}
}
#[test]
fn test_metric_serde_names() {
assert_eq!(serde_json::to_string(&Metric::Vmaf).unwrap(), "\"vmaf\"");
assert_eq!(serde_json::to_string(&Metric::Psnr).unwrap(), "\"psnr\"");
assert_eq!(serde_json::to_string(&Metric::Ssim).unwrap(), "\"ssim\"");
assert_eq!(serde_json::to_string(&Metric::Ssimulacra2).unwrap(), "\"ssimulacra2\"");
assert_eq!(serde_json::to_string(&Metric::Butteraugli).unwrap(), "\"butteraugli\"");
}
#[test]
fn test_metric_eq() {
assert_eq!(Metric::Vmaf, Metric::Vmaf);
assert_ne!(Metric::Vmaf, Metric::Psnr);
assert_eq!(Metric::Ssimulacra2, Metric::Ssimulacra2);
assert_ne!(Metric::Ssimulacra2, Metric::Butteraugli);
}
#[test]
fn test_result_default() {
let r = Result::default();
assert!((r.vmaf - 0.0).abs() < 1e-9);
assert!((r.psnr - 0.0).abs() < 1e-9);
assert!((r.ssim - 0.0).abs() < 1e-9);
assert!((r.ssimulacra2 - 0.0).abs() < 1e-9);
assert!((r.butteraugli - 0.0).abs() < 1e-9);
assert!(r.frames.is_empty());
}
#[test]
fn test_parse_vmaf_log_basic() {
let json = br#"{
"frames": [
{"frameNum": 0, "metrics": {"vmaf": 85.0, "psnr_y": 38.5, "float_ssim": 0.95}}
],
"pooled_metrics": {
"vmaf": {"mean": 86.5},
"psnr_y": {"mean": 39.2},
"float_ssim": {"mean": 0.96}
}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.vmaf - 86.5).abs() < 1e-9);
assert!((result.psnr - 39.2).abs() < 1e-9);
assert!((result.ssim - 0.96).abs() < 1e-9);
assert!(result.frames.is_empty());
}
#[test]
fn test_parse_vmaf_log_per_frame() {
let json = br#"{
"frames": [
{"frameNum": 0, "metrics": {"vmaf": 80.0, "psnr_y": 37.0, "float_ssim": 0.93}},
{"frameNum": 1, "metrics": {"vmaf": 90.0, "psnr_y": 40.0, "float_ssim": 0.97}}
],
"pooled_metrics": {
"vmaf": {"mean": 85.0},
"psnr_y": {"mean": 38.5},
"float_ssim": {"mean": 0.95}
}
}"#;
let result = parse_vmaf_log(json, true).unwrap();
assert_eq!(result.frames.len(), 2);
assert_eq!(result.frames[0].frame_num, 0);
assert!((result.frames[0].vmaf - 80.0).abs() < 1e-9);
assert_eq!(result.frames[1].frame_num, 1);
assert!((result.frames[1].vmaf - 90.0).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_fallback_psnr() {
let json = br#"{
"frames": [],
"pooled_metrics": {
"vmaf": {"mean": 85.0},
"psnr": {"mean": 39.0},
"ssim": {"mean": 0.94}
}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.psnr - 39.0).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_missing_metrics() {
let json = br#"{
"frames": [],
"pooled_metrics": {}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.vmaf - 0.0).abs() < 1e-9);
assert!((result.psnr - 0.0).abs() < 1e-9);
assert!((result.ssim - 0.0).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_invalid_json() {
assert!(parse_vmaf_log(b"not json", false).is_err());
}
#[test]
fn test_result_serde_roundtrip() {
let r = Result {
vmaf: 85.0,
psnr: 38.5,
ssim: 0.95,
ssimulacra2: 70.0,
butteraugli: 0.5,
..Default::default()
};
let json = serde_json::to_string(&r).unwrap();
let back: Result = serde_json::from_str(&json).unwrap();
assert!((back.vmaf - 85.0).abs() < 1e-9);
assert!((back.ssimulacra2 - 70.0).abs() < 1e-9);
assert!((back.butteraugli - 0.5).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_per_component_psnr() {
let json = br#"{
"frames": [],
"pooled_metrics": {
"vmaf": {"mean": 85.0},
"psnr_y": {"mean": 40.0},
"psnr_cb": {"mean": 44.0},
"psnr_cr": {"mean": 46.0},
"float_ssim": {"mean": 0.95}
}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.psnr - 40.0).abs() < 1e-9, "luma");
assert!((result.psnr_u - 44.0).abs() < 1e-9, "Cb");
assert!((result.psnr_v - 46.0).abs() < 1e-9, "Cr");
assert!((result.psnr_avg - 41.25).abs() < 1e-9, "weighted avg");
}
#[test]
fn test_parse_vmaf_log_psnr_avg_falls_back_to_luma() {
let json = br#"{
"frames": [],
"pooled_metrics": {"psnr_y": {"mean": 39.0}}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.psnr_avg - 39.0).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_pooled_distribution() {
let json = br#"{
"frames": [
{"frameNum": 0, "metrics": {"vmaf": 80.0, "psnr_y": 37.0, "float_ssim": 0.93}},
{"frameNum": 1, "metrics": {"vmaf": 90.0, "psnr_y": 41.0, "float_ssim": 0.97}}
],
"pooled_metrics": {"vmaf": {"mean": 85.0}}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert_eq!(result.pooled.vmaf.count, 2);
assert!((result.pooled.vmaf.min - 80.0).abs() < 1e-9);
assert!((result.pooled.vmaf.max - 90.0).abs() < 1e-9);
assert!((result.pooled.vmaf.mean - 85.0).abs() < 1e-9);
assert!((result.pooled.psnr.min - 37.0).abs() < 1e-9);
assert!((result.psnr - 39.0).abs() < 1e-9, "psnr falls back to frame mean");
}
#[test]
fn test_sample_indices() {
assert_eq!(sample_indices(100, 0), vec![0]);
assert_eq!(sample_indices(100, 1), vec![0]);
assert_eq!(sample_indices(0, 5), vec![0]);
assert_eq!(sample_indices(1, 5), vec![0]);
assert_eq!(sample_indices(101, 3), vec![0, 50, 100]);
assert_eq!(sample_indices(2, 10), vec![0, 1]);
}
#[test]
fn test_result_serde_omits_zero_frames() {
let r = Result::default();
let json = serde_json::to_string(&r).unwrap();
assert!(!json.contains("frames"));
}
#[test]
fn test_measure_opts_default() {
let opts = MeasureOpts::default();
assert_eq!(opts.metrics.len(), 5);
assert_eq!(opts.subsample, 0);
assert_eq!(opts.model, "vmaf_v0.6.1");
assert!(!opts.per_frame);
assert_eq!(opts.frame_samples, 0);
assert!(opts.probe_cache.is_none());
}
#[test]
fn test_vif_mean() {
let mut m = std::collections::HashMap::new();
m.insert("integer_vif_scale0".to_string(), 0.2);
m.insert("integer_vif_scale1".to_string(), 0.4);
m.insert("integer_vif_scale2".to_string(), 0.6);
m.insert("integer_vif_scale3".to_string(), 0.8);
assert!((vif_mean(&m).unwrap() - 0.5).abs() < 1e-9);
let mut m2 = std::collections::HashMap::new();
m2.insert("vif_scale0".to_string(), 1.0);
m2.insert("float_vif_scale1".to_string(), 0.0);
assert!((vif_mean(&m2).unwrap() - 0.5).abs() < 1e-9);
assert!(vif_mean(&std::collections::HashMap::new()).is_none());
}
#[test]
fn test_parse_xpsnr_component() {
let line = "n: 1 XPSNR y: 46.9714 XPSNR u: 45.1188 XPSNR v: 45.0873";
assert!((parse_xpsnr_component(line, "y:").unwrap() - 46.9714).abs() < 1e-9);
assert!((parse_xpsnr_component(line, "u:").unwrap() - 45.1188).abs() < 1e-9);
assert!((parse_xpsnr_component(line, "v:").unwrap() - 45.0873).abs() < 1e-9);
assert_eq!(parse_xpsnr_component("XPSNR y: inf", "y:"), Some(100.0));
assert_eq!(parse_xpsnr_component("nothing here", "y:"), None);
}
#[test]
fn test_parse_vmaf_log_extended_metrics() {
let json = br#"{
"frames": [
{"frameNum": 0, "metrics": {"vmaf": 80.0, "float_ms_ssim": 0.90, "cambi": 2.0,
"integer_vif_scale0": 0.2, "integer_vif_scale1": 0.4,
"integer_vif_scale2": 0.6, "integer_vif_scale3": 0.8}},
{"frameNum": 1, "metrics": {"vmaf": 90.0, "float_ms_ssim": 1.00, "cambi": 0.0,
"integer_vif_scale0": 0.4, "integer_vif_scale1": 0.6,
"integer_vif_scale2": 0.8, "integer_vif_scale3": 1.0}}
],
"pooled_metrics": {"vmaf": {"mean": 85.0}}
}"#;
let result = parse_vmaf_log(json, true).unwrap();
assert!((result.ms_ssim - 0.95).abs() < 1e-9);
assert!((result.cambi - 1.0).abs() < 1e-9);
assert!((result.vif - 0.6).abs() < 1e-9);
assert!((result.frames[0].ms_ssim - 0.90).abs() < 1e-9);
assert!((result.frames[0].vif - 0.5).abs() < 1e-9);
assert!((result.frames[1].cambi - 0.0).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_ssim_no_float_prefix() {
let json = br#"{
"frames": [{"frameNum": 0, "metrics": {"ssim": 0.92}}],
"pooled_metrics": {"ssim": {"mean": 0.92}}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.ssim - 0.92).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_ms_ssim_fallback_name() {
let json = br#"{
"frames": [{"frameNum": 0, "metrics": {"ms_ssim": 0.88}}],
"pooled_metrics": {}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.ms_ssim - 0.88).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_psnr_cb_cr_fallback_names() {
let json = br#"{
"frames": [],
"pooled_metrics": {
"psnr_y": {"mean": 40.0},
"psnr_cb": {"mean": 44.0},
"psnr_cr": {"mean": 46.0}
}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.psnr_u - 44.0).abs() < 1e-9, "Cb via psnr_cb");
assert!((result.psnr_v - 46.0).abs() < 1e-9, "Cr via psnr_cr");
}
#[test]
fn test_parse_vmaf_log_psnr_u_v_fallback_names() {
let json = br#"{
"frames": [],
"pooled_metrics": {
"psnr_y": {"mean": 40.0},
"psnr_u": {"mean": 43.0},
"psnr_v": {"mean": 45.0}
}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.psnr_u - 43.0).abs() < 1e-9, "Cb via psnr_u");
assert!((result.psnr_v - 45.0).abs() < 1e-9, "Cr via psnr_v");
}
#[test]
fn test_parse_vmaf_log_pooled_missing_fallback_to_frame_mean() {
let json = br#"{
"frames": [
{"frameNum": 0, "metrics": {"vmaf": 80.0, "psnr_y": 36.0, "float_ssim": 0.90}},
{"frameNum": 1, "metrics": {"vmaf": 90.0, "psnr_y": 42.0, "float_ssim": 0.96}}
],
"pooled_metrics": {}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.vmaf - 85.0).abs() < 1e-9);
assert!((result.psnr - 39.0).abs() < 1e-9);
assert!((result.ssim - 0.93).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_empty_frames_and_pooled() {
let json = br#"{
"frames": [],
"pooled_metrics": {}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.vmaf - 0.0).abs() < 1e-9);
assert!((result.psnr - 0.0).abs() < 1e-9);
assert!((result.ssim - 0.0).abs() < 1e-9);
assert!((result.ms_ssim - 0.0).abs() < 1e-9);
assert!((result.vif - 0.0).abs() < 1e-9);
assert!((result.cambi - 0.0).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_single_frame_with_pooled() {
let json = br#"{
"frames": [{"frameNum": 0, "metrics": {"vmaf": 95.0}}],
"pooled_metrics": {"vmaf": {"mean": 95.0}}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.vmaf - 95.0).abs() < 1e-9);
assert_eq!(result.pooled.vmaf.count, 1);
}
#[test]
fn test_parse_vmaf_log_vif_mixed_naming() {
let json = br#"{
"frames": [{"frameNum": 0, "metrics": {
"integer_vif_scale0": 0.5,
"float_vif_scale0": 0.4,
"vif_scale1": 0.6,
"integer_vif_scale1": 0.6
}}],
"pooled_metrics": {}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert!((result.vif - 0.55).abs() < 1e-9, "mean of 2 scales with naming variants");
}
#[test]
fn test_parse_vmaf_log_xpsnr_per_frame_propagation() {
let json = br#"{
"frames": [
{"frameNum": 0, "metrics": {"vmaf": 85.0}}
],
"pooled_metrics": {"vmaf": {"mean": 85.0}}
}"#;
let mut result = parse_vmaf_log(json, true).unwrap();
result.xpsnr = 0.0;
result.frames[0].xpsnr = 45.5;
assert!((result.frames[0].xpsnr - 45.5).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_pooled_distribution_single_frame() {
let json = br#"{
"frames": [{"frameNum": 0, "metrics": {"vmaf": 88.0}}],
"pooled_metrics": {"vmaf": {"mean": 88.0}}
}"#;
let result = parse_vmaf_log(json, false).unwrap();
assert_eq!(result.pooled.vmaf.count, 1);
assert!((result.pooled.vmaf.min - 88.0).abs() < 1e-9);
assert!((result.pooled.vmaf.max - 88.0).abs() < 1e-9);
assert!((result.pooled.vmaf.mean - 88.0).abs() < 1e-9);
}
#[test]
fn test_parse_vmaf_log_per_frame_with_missing_metrics() {
let json = br#"{
"frames": [
{"frameNum": 0, "metrics": {"vmaf": 85.0}},
{"frameNum": 1, "metrics": {}}
],
"pooled_metrics": {"vmaf": {"mean": 85.0}}
}"#;
let result = parse_vmaf_log(json, true).unwrap();
assert_eq!(result.frames.len(), 2);
assert!((result.frames[0].vmaf - 85.0).abs() < 1e-9);
assert!((result.frames[1].vmaf - 0.0).abs() < 1e-9);
}
#[test]
fn test_parse_xpsnr_component_negative_inf() {
assert_eq!(parse_xpsnr_component("XPSNR y: -inf", "y:"), Some(100.0));
}
#[test]
fn test_parse_xpsnr_component_nan() {
assert_eq!(parse_xpsnr_component("XPSNR y: NaN", "y:"), Some(100.0));
}
#[test]
fn test_parse_xpsnr_component_regular() {
assert!((parse_xpsnr_component("XPSNR u: 44.5678", "u:").unwrap() - 44.5678).abs() < 1e-4);
}
#[test]
fn test_parse_xpsnr_component_bad_format() {
assert_eq!(parse_xpsnr_component("n: 1 XPSNR", "y:"), None);
}
#[test]
fn test_sample_indices_uneven() {
assert_eq!(sample_indices(5, 3), vec![0, 2, 4]);
}
#[test]
fn test_sample_indices_more_samples_than_frames() {
assert_eq!(sample_indices(2, 10), vec![0, 1]);
}
#[test]
fn test_sample_indices_single_frame_input() {
assert_eq!(sample_indices(1, 5), vec![0]);
}
#[test]
fn test_sample_indices_large_values() {
let indices = sample_indices(1000, 5);
assert_eq!(indices.len(), 5);
assert_eq!(indices[0], 0);
assert_eq!(indices[4], 999);
}
#[test]
fn test_pooled_mean_first_match_wins() {
let mut map = std::collections::HashMap::new();
map.insert("psnr_y".to_string(), PooledMetric { mean: 40.0 });
map.insert("psnr".to_string(), PooledMetric { mean: 39.0 });
assert_eq!(
pooled_mean(&VmafLog { frames: vec![], pooled_metrics: map }, &["psnr_y", "psnr"]),
40.0
);
}
#[test]
fn test_pooled_mean_fallback() {
let mut map = std::collections::HashMap::new();
map.insert("psnr".to_string(), PooledMetric { mean: 39.0 });
assert_eq!(
pooled_mean(&VmafLog { frames: vec![], pooled_metrics: map }, &["psnr_y", "psnr"]),
39.0
);
}
#[test]
fn test_pooled_mean_missing_all() {
assert_eq!(
pooled_mean(
&VmafLog { frames: vec![], pooled_metrics: std::collections::HashMap::new() },
&["psnr_y", "psnr"]
),
0.0
);
}
#[test]
fn test_frame_metric_first_match() {
let mut map = std::collections::HashMap::new();
map.insert("psnr_y".to_string(), 40.0);
map.insert("psnr".to_string(), 39.0);
assert_eq!(frame_metric(&map, &["psnr_y", "psnr"]), Some(40.0));
}
#[test]
fn test_frame_metric_fallback() {
let mut map = std::collections::HashMap::new();
map.insert("psnr".to_string(), 39.0);
assert_eq!(frame_metric(&map, &["psnr_y", "psnr"]), Some(39.0));
}
#[test]
fn test_frame_metric_missing() {
assert_eq!(frame_metric(&std::collections::HashMap::new(), &["psnr_y"]), None);
}
}