Skip to main content

ff_filter/analysis/
quality_metrics.rs

1//! Video quality metrics (SSIM, PSNR).
2
3#![allow(unsafe_code)]
4
5use std::path::Path;
6
7use crate::FilterError;
8
9/// Computes video quality metrics between a reference and a distorted video.
10///
11/// All methods are static — there is no state to configure.
12pub struct QualityMetrics;
13
14impl QualityMetrics {
15    /// Computes the mean SSIM (Structural Similarity Index Measure) over all
16    /// frames between `reference` and `distorted`.
17    ///
18    /// Returns a value in `[0.0, 1.0]`:
19    /// - `1.0` — the inputs are frame-identical.
20    /// - `0.0` — no structural similarity.
21    ///
22    /// Uses `FFmpeg`'s `ssim` filter internally.  Both inputs must have the
23    /// same frame count; if they differ the function returns an error rather
24    /// than silently comparing only the overlapping portion.
25    ///
26    /// # Errors
27    ///
28    /// - [`FilterError::AnalysisFailed`] — either input file is not found, the
29    ///   inputs have different frame counts, or the internal filter graph fails.
30    ///
31    /// # Examples
32    ///
33    /// ```ignore
34    /// use ff_filter::QualityMetrics;
35    ///
36    /// // Compare a video against itself — should return ≈ 1.0.
37    /// let ssim = QualityMetrics::ssim("reference.mp4", "reference.mp4")?;
38    /// assert!(ssim > 0.9999);
39    /// ```
40    pub fn ssim(
41        reference: impl AsRef<Path>,
42        distorted: impl AsRef<Path>,
43    ) -> Result<f32, FilterError> {
44        let reference = reference.as_ref();
45        let distorted = distorted.as_ref();
46
47        if !reference.exists() {
48            return Err(FilterError::AnalysisFailed {
49                reason: format!("reference file not found: {}", reference.display()),
50            });
51        }
52        if !distorted.exists() {
53            return Err(FilterError::AnalysisFailed {
54                reason: format!("distorted file not found: {}", distorted.display()),
55            });
56        }
57        // SAFETY: compute_ssim_unsafe manages all raw pointer lifetimes according
58        // to the avfilter ownership rules: every allocated object is freed before
59        // returning, either in the bail! macro or in the normal cleanup path.
60        unsafe { super::analysis_inner::compute_ssim_unsafe(reference, distorted) }
61    }
62
63    /// Computes the mean PSNR (Peak Signal-to-Noise Ratio, in dB) over all
64    /// frames between `reference` and `distorted`.
65    ///
66    /// Uses the luminance (Y-plane) PSNR as the representative value.
67    ///
68    /// - Identical inputs → `f32::INFINITY` (MSE = 0).
69    /// - Lightly compressed → typically > 40 dB.
70    /// - Heavy degradation → typically < 30 dB.
71    ///
72    /// Uses `FFmpeg`'s `psnr` filter internally.  Both inputs must have the
73    /// same frame count; if they differ the function returns an error.
74    ///
75    /// # Errors
76    ///
77    /// - [`FilterError::AnalysisFailed`] — either input file is not found, the
78    ///   inputs have different frame counts, or the internal filter graph fails.
79    ///
80    /// # Examples
81    ///
82    /// ```ignore
83    /// use ff_filter::QualityMetrics;
84    ///
85    /// // Compare a video against itself — should return infinity.
86    /// let psnr = QualityMetrics::psnr("reference.mp4", "reference.mp4")?;
87    /// assert!(psnr > 100.0 || psnr == f32::INFINITY);
88    /// ```
89    pub fn psnr(
90        reference: impl AsRef<Path>,
91        distorted: impl AsRef<Path>,
92    ) -> Result<f32, FilterError> {
93        let reference = reference.as_ref();
94        let distorted = distorted.as_ref();
95
96        if !reference.exists() {
97            return Err(FilterError::AnalysisFailed {
98                reason: format!("reference file not found: {}", reference.display()),
99            });
100        }
101        if !distorted.exists() {
102            return Err(FilterError::AnalysisFailed {
103                reason: format!("distorted file not found: {}", distorted.display()),
104            });
105        }
106        // SAFETY: compute_psnr_unsafe manages all raw pointer lifetimes according
107        // to the avfilter ownership rules: every allocated object is freed before
108        // returning, either in the bail! macro or in the normal cleanup path.
109        unsafe { super::analysis_inner::compute_psnr_unsafe(reference, distorted) }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn quality_metrics_ssim_missing_reference_should_return_analysis_failed() {
119        let result = QualityMetrics::ssim("does_not_exist_ref.mp4", "does_not_exist_dist.mp4");
120        assert!(
121            matches!(result, Err(FilterError::AnalysisFailed { .. })),
122            "expected AnalysisFailed for missing reference, got {result:?}"
123        );
124    }
125
126    #[test]
127    fn quality_metrics_ssim_missing_distorted_should_return_analysis_failed() {
128        // Reference exists (any existing file), distorted does not.
129        // Use a path that is guaranteed to exist: the Cargo.toml for this crate.
130        let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
131        let result = QualityMetrics::ssim(&manifest, "does_not_exist_dist_99999.mp4");
132        assert!(
133            matches!(result, Err(FilterError::AnalysisFailed { .. })),
134            "expected AnalysisFailed for missing distorted, got {result:?}"
135        );
136    }
137
138    #[test]
139    fn quality_metrics_psnr_missing_reference_should_return_analysis_failed() {
140        let result = QualityMetrics::psnr("does_not_exist_ref.mp4", "does_not_exist_dist.mp4");
141        assert!(
142            matches!(result, Err(FilterError::AnalysisFailed { .. })),
143            "expected AnalysisFailed for missing reference, got {result:?}"
144        );
145    }
146
147    #[test]
148    fn quality_metrics_psnr_missing_distorted_should_return_analysis_failed() {
149        let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
150        let result = QualityMetrics::psnr(&manifest, "does_not_exist_dist_99999.mp4");
151        assert!(
152            matches!(result, Err(FilterError::AnalysisFailed { .. })),
153            "expected AnalysisFailed for missing distorted, got {result:?}"
154        );
155    }
156}