Skip to main content

viser_quality/
pool.rs

1//! Pooling strategies for reducing per-frame metric scores to summary statistics.
2//!
3//! Per-frame quality varies a lot within a clip, and the arithmetic mean hides
4//! the worst moments that dominate perceived quality. [`PooledStats`] captures
5//! the whole distribution — mean, harmonic mean, spread, and low percentiles —
6//! so callers can pool with whatever strategy fits their use case (e.g. the
7//! harmonic mean Netflix uses for VMAF, or a low percentile for worst-case QoE).
8
9use serde::{Deserialize, Serialize};
10
11/// A strategy for reducing a series of per-frame scores to a single value.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum PoolStrategy {
15    /// Arithmetic mean.
16    Mean,
17    /// Harmonic mean — penalises low outliers; the convention Netflix uses for VMAF.
18    HarmonicMean,
19    /// Minimum (single worst frame for higher-is-better metrics).
20    Min,
21    /// Maximum (single best frame for higher-is-better metrics).
22    Max,
23    /// 1st percentile — worst-1% pooling, the part of the clip viewers notice most.
24    P1,
25    /// 5th percentile.
26    P5,
27    /// 10th percentile.
28    P10,
29    /// Median (50th percentile).
30    Median,
31}
32
33impl PoolStrategy {
34    /// Apply this strategy to `values`, returning `0.0` for an empty slice.
35    pub fn apply(self, values: &[f64]) -> f64 {
36        if values.is_empty() {
37            return 0.0;
38        }
39        match self {
40            PoolStrategy::Mean => mean(values),
41            PoolStrategy::HarmonicMean => harmonic_mean(values),
42            PoolStrategy::Min
43            | PoolStrategy::Max
44            | PoolStrategy::P1
45            | PoolStrategy::P5
46            | PoolStrategy::P10
47            | PoolStrategy::Median => {
48                let mut sorted = values.to_vec();
49                sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
50                match self {
51                    PoolStrategy::Min => sorted[0],
52                    PoolStrategy::Max => sorted[sorted.len() - 1],
53                    PoolStrategy::P1 => percentile_sorted(&sorted, 1.0),
54                    PoolStrategy::P5 => percentile_sorted(&sorted, 5.0),
55                    PoolStrategy::P10 => percentile_sorted(&sorted, 10.0),
56                    PoolStrategy::Median => percentile_sorted(&sorted, 50.0),
57                    PoolStrategy::Mean | PoolStrategy::HarmonicMean => unreachable!(),
58                }
59            }
60        }
61    }
62}
63
64/// The full distribution of a per-frame metric series.
65///
66/// All fields are `0.0`/`0` for an empty input. Percentiles are
67/// linearly interpolated; `std_dev` is the population standard deviation.
68#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
69#[serde(default)]
70pub struct PooledStats {
71    /// Arithmetic mean.
72    pub mean: f64,
73    /// Harmonic mean over the positive values (`0.0` if none are positive).
74    pub harmonic_mean: f64,
75    /// Smallest value.
76    pub min: f64,
77    /// Largest value.
78    pub max: f64,
79    /// 1st percentile.
80    pub p1: f64,
81    /// 5th percentile.
82    pub p5: f64,
83    /// 10th percentile.
84    pub p10: f64,
85    /// Median (50th percentile).
86    pub median: f64,
87    /// Population standard deviation.
88    pub std_dev: f64,
89    /// Number of frames pooled.
90    pub count: usize,
91}
92
93impl PooledStats {
94    /// Compute every summary statistic from a per-frame series in one pass over a sort.
95    pub fn from_values(values: &[f64]) -> Self {
96        if values.is_empty() {
97            return Self::default();
98        }
99        let mut sorted = values.to_vec();
100        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
101        Self {
102            mean: mean(values),
103            harmonic_mean: harmonic_mean(values),
104            min: sorted[0],
105            max: sorted[sorted.len() - 1],
106            p1: percentile_sorted(&sorted, 1.0),
107            p5: percentile_sorted(&sorted, 5.0),
108            p10: percentile_sorted(&sorted, 10.0),
109            median: percentile_sorted(&sorted, 50.0),
110            std_dev: std_dev(values),
111            count: values.len(),
112        }
113    }
114
115    /// Read back the value a given [`PoolStrategy`] would produce.
116    pub fn get(&self, strategy: PoolStrategy) -> f64 {
117        match strategy {
118            PoolStrategy::Mean => self.mean,
119            PoolStrategy::HarmonicMean => self.harmonic_mean,
120            PoolStrategy::Min => self.min,
121            PoolStrategy::Max => self.max,
122            PoolStrategy::P1 => self.p1,
123            PoolStrategy::P5 => self.p5,
124            PoolStrategy::P10 => self.p10,
125            PoolStrategy::Median => self.median,
126        }
127    }
128}
129
130fn mean(values: &[f64]) -> f64 {
131    if values.is_empty() {
132        return 0.0;
133    }
134    values.iter().sum::<f64>() / values.len() as f64
135}
136
137fn harmonic_mean(values: &[f64]) -> f64 {
138    let positive: Vec<f64> = values.iter().copied().filter(|x| *x > 0.0).collect();
139    if positive.is_empty() {
140        return 0.0;
141    }
142    let denom: f64 = positive.iter().map(|x| 1.0 / x).sum();
143    positive.len() as f64 / denom
144}
145
146fn std_dev(values: &[f64]) -> f64 {
147    if values.len() < 2 {
148        return 0.0;
149    }
150    let m = mean(values);
151    let var = values
152        .iter()
153        .map(|x| {
154            let d = x - m;
155            d * d
156        })
157        .sum::<f64>()
158        / values.len() as f64;
159    var.sqrt()
160}
161
162/// Linearly-interpolated percentile `p` in `[0, 100]` over an already-sorted slice.
163fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
164    match sorted.len() {
165        0 => 0.0,
166        1 => sorted[0],
167        n => {
168            let rank = (p / 100.0) * (n - 1) as f64;
169            let lo = rank.floor() as usize;
170            let hi = rank.ceil() as usize;
171            let frac = rank - lo as f64;
172            sorted[lo] + (sorted[hi] - sorted[lo]) * frac
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn empty_is_zero() {
183        let s = PooledStats::from_values(&[]);
184        assert_eq!(s, PooledStats::default());
185        assert_eq!(PoolStrategy::Mean.apply(&[]), 0.0);
186        assert_eq!(PoolStrategy::P1.apply(&[]), 0.0);
187    }
188
189    #[test]
190    fn basic_stats() {
191        let v = [10.0, 20.0, 30.0, 40.0, 50.0];
192        let s = PooledStats::from_values(&v);
193        assert!((s.mean - 30.0).abs() < 1e-9);
194        assert!((s.min - 10.0).abs() < 1e-9);
195        assert!((s.max - 50.0).abs() < 1e-9);
196        assert!((s.median - 30.0).abs() < 1e-9);
197        assert_eq!(s.count, 5);
198    }
199
200    #[test]
201    fn harmonic_mean_penalises_low_outliers() {
202        let v = [100.0, 100.0, 1.0];
203        let s = PooledStats::from_values(&v);
204        // harmonic mean is dragged far below the arithmetic mean by the low frame
205        assert!(s.harmonic_mean < s.mean);
206        assert!(s.harmonic_mean < 5.0);
207    }
208
209    #[test]
210    fn harmonic_mean_ignores_nonpositive() {
211        // zeros/negatives are skipped rather than producing inf/NaN
212        assert_eq!(harmonic_mean(&[0.0, 0.0]), 0.0);
213        let s = PooledStats::from_values(&[0.0, 2.0]);
214        assert!((s.harmonic_mean - 2.0).abs() < 1e-9);
215    }
216
217    #[test]
218    fn percentiles_interpolate() {
219        let v: Vec<f64> = (1..=100).map(|x| x as f64).collect();
220        let s = PooledStats::from_values(&v);
221        // p1 over 1..=100 (linear interp on n-1) ≈ 1.99
222        assert!((s.p1 - 1.99).abs() < 1e-6);
223        assert!((s.p10 - 10.9).abs() < 1e-6);
224        assert!((s.median - 50.5).abs() < 1e-6);
225    }
226
227    #[test]
228    fn strategy_get_matches_apply() {
229        let v = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0];
230        let s = PooledStats::from_values(&v);
231        for strat in [
232            PoolStrategy::Mean,
233            PoolStrategy::HarmonicMean,
234            PoolStrategy::Min,
235            PoolStrategy::Max,
236            PoolStrategy::P1,
237            PoolStrategy::P5,
238            PoolStrategy::P10,
239            PoolStrategy::Median,
240        ] {
241            assert!((s.get(strat) - strat.apply(&v)).abs() < 1e-9, "mismatch for {strat:?}");
242        }
243    }
244
245    #[test]
246    fn single_value() {
247        let s = PooledStats::from_values(&[42.0]);
248        assert_eq!(s.mean, 42.0);
249        assert_eq!(s.p1, 42.0);
250        assert_eq!(s.std_dev, 0.0);
251        assert_eq!(s.count, 1);
252    }
253}