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}