Skip to main content

ff_filter/analysis/
quality_metrics.rs

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