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}