codec_eval/
viewing.rs

1//! Viewing condition modeling for perceptual quality assessment.
2//!
3//! This module provides the [`ViewingCondition`] type which models how an image
4//! will be viewed, affecting perceptual quality thresholds.
5//!
6//! ## Key Concepts
7//!
8//! - **acuity_ppd**: The viewer's visual acuity in pixels per degree. This is
9//!   determined by the display's pixel density and viewing distance.
10//! - **browser_dppx**: The browser/OS device pixel ratio (e.g., 2.0 for retina).
11//! - **image_intrinsic_dppx**: The image's intrinsic pixels per CSS pixel (for srcset).
12//! - **ppd**: The effective pixels per degree for this specific image viewing.
13
14use serde::{Deserialize, Serialize};
15
16/// Viewing condition for perceptual quality assessment.
17///
18/// Models how an image will be viewed, which affects whether compression
19/// artifacts will be perceptible.
20///
21/// # Example
22///
23/// ```
24/// use codec_eval::ViewingCondition;
25///
26/// // Desktop viewing with 2x retina display showing a 2x srcset image
27/// let condition = ViewingCondition::desktop()
28///     .with_browser_dppx(2.0)
29///     .with_image_intrinsic_dppx(2.0);
30///
31/// // The effective PPD accounts for the srcset ratio
32/// let ppd = condition.effective_ppd();
33/// ```
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct ViewingCondition {
36    /// Viewer's visual acuity in pixels per degree.
37    ///
38    /// This is the baseline PPD for the display and viewing distance.
39    /// Typical values:
40    /// - Desktop at arm's length: ~40 PPD
41    /// - Laptop: ~60 PPD
42    /// - Smartphone held close: ~90+ PPD
43    pub acuity_ppd: f64,
44
45    /// Browser/OS device pixel ratio.
46    ///
47    /// For retina/HiDPI displays, this is typically 2.0 or 3.0.
48    /// For standard displays, this is 1.0.
49    pub browser_dppx: Option<f64>,
50
51    /// Image's intrinsic pixels per CSS pixel.
52    ///
53    /// For srcset images:
54    /// - A 1x image has `intrinsic_dppx = 1.0`
55    /// - A 2x image has `intrinsic_dppx = 2.0`
56    ///
57    /// This affects the effective resolution at which the image is displayed.
58    pub image_intrinsic_dppx: Option<f64>,
59
60    /// Override or computed PPD for this specific viewing.
61    ///
62    /// If `Some`, this value is used directly instead of computing from
63    /// the other fields.
64    pub ppd: Option<f64>,
65}
66
67impl ViewingCondition {
68    /// Create a new viewing condition with the given acuity PPD.
69    ///
70    /// # Arguments
71    ///
72    /// * `acuity_ppd` - The viewer's visual acuity in pixels per degree.
73    #[must_use]
74    pub fn new(acuity_ppd: f64) -> Self {
75        Self {
76            acuity_ppd,
77            browser_dppx: None,
78            image_intrinsic_dppx: None,
79            ppd: None,
80        }
81    }
82
83    /// Desktop viewing condition (acuity ~40 PPD).
84    ///
85    /// Represents viewing a standard desktop monitor at arm's length
86    /// (approximately 24 inches / 60 cm).
87    #[must_use]
88    pub fn desktop() -> Self {
89        Self::new(40.0)
90    }
91
92    /// Laptop viewing condition (acuity ~60 PPD).
93    ///
94    /// Represents viewing a laptop screen at a typical distance
95    /// (approximately 18 inches / 45 cm).
96    #[must_use]
97    pub fn laptop() -> Self {
98        Self::new(60.0)
99    }
100
101    /// Smartphone viewing condition (acuity ~90 PPD).
102    ///
103    /// Represents viewing a smartphone held at reading distance
104    /// (approximately 12 inches / 30 cm).
105    #[must_use]
106    pub fn smartphone() -> Self {
107        Self::new(90.0)
108    }
109
110    /// Set the browser/OS device pixel ratio.
111    ///
112    /// # Arguments
113    ///
114    /// * `dppx` - Device pixel ratio (e.g., 2.0 for retina).
115    #[must_use]
116    pub fn with_browser_dppx(mut self, dppx: f64) -> Self {
117        self.browser_dppx = Some(dppx);
118        self
119    }
120
121    /// Set the image's intrinsic pixels per CSS pixel.
122    ///
123    /// # Arguments
124    ///
125    /// * `dppx` - Intrinsic DPI ratio (e.g., 2.0 for a 2x srcset image).
126    #[must_use]
127    pub fn with_image_intrinsic_dppx(mut self, dppx: f64) -> Self {
128        self.image_intrinsic_dppx = Some(dppx);
129        self
130    }
131
132    /// Override the computed PPD with a specific value.
133    ///
134    /// # Arguments
135    ///
136    /// * `ppd` - The PPD value to use.
137    #[must_use]
138    pub fn with_ppd_override(mut self, ppd: f64) -> Self {
139        self.ppd = Some(ppd);
140        self
141    }
142
143    /// Compute the effective PPD for metric adjustment.
144    ///
145    /// If `ppd` is set, returns that value directly. Otherwise, computes
146    /// the effective PPD from the acuity and dppx values.
147    ///
148    /// The formula is:
149    /// ```text
150    /// effective_ppd = acuity_ppd * (image_intrinsic_dppx / browser_dppx)
151    /// ```
152    ///
153    /// This accounts for how srcset images are scaled on HiDPI displays.
154    #[must_use]
155    pub fn effective_ppd(&self) -> f64 {
156        if let Some(ppd) = self.ppd {
157            return ppd;
158        }
159
160        let browser = self.browser_dppx.unwrap_or(1.0);
161        let intrinsic = self.image_intrinsic_dppx.unwrap_or(1.0);
162
163        // When intrinsic > browser, image pixels are smaller than device pixels,
164        // making artifacts less visible (higher effective PPD).
165        // When intrinsic < browser, image pixels are larger, artifacts more visible.
166        self.acuity_ppd * (intrinsic / browser)
167    }
168}
169
170impl Default for ViewingCondition {
171    fn default() -> Self {
172        Self::desktop()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_desktop_defaults() {
182        let v = ViewingCondition::desktop();
183        assert!((v.acuity_ppd - 40.0).abs() < f64::EPSILON);
184        assert!(v.browser_dppx.is_none());
185        assert!(v.image_intrinsic_dppx.is_none());
186        assert!(v.ppd.is_none());
187    }
188
189    #[test]
190    fn test_effective_ppd_no_dppx() {
191        let v = ViewingCondition::desktop();
192        assert!((v.effective_ppd() - 40.0).abs() < f64::EPSILON);
193    }
194
195    #[test]
196    fn test_effective_ppd_with_retina() {
197        // 2x image on 2x display = same effective PPD
198        let v = ViewingCondition::desktop()
199            .with_browser_dppx(2.0)
200            .with_image_intrinsic_dppx(2.0);
201        assert!((v.effective_ppd() - 40.0).abs() < f64::EPSILON);
202    }
203
204    #[test]
205    fn test_effective_ppd_1x_on_2x() {
206        // 1x image on 2x display = half effective PPD (artifacts more visible)
207        let v = ViewingCondition::desktop()
208            .with_browser_dppx(2.0)
209            .with_image_intrinsic_dppx(1.0);
210        assert!((v.effective_ppd() - 20.0).abs() < f64::EPSILON);
211    }
212
213    #[test]
214    fn test_effective_ppd_2x_on_1x() {
215        // 2x image on 1x display = double effective PPD (artifacts less visible)
216        let v = ViewingCondition::desktop()
217            .with_browser_dppx(1.0)
218            .with_image_intrinsic_dppx(2.0);
219        assert!((v.effective_ppd() - 80.0).abs() < f64::EPSILON);
220    }
221
222    #[test]
223    fn test_ppd_override() {
224        let v = ViewingCondition::desktop()
225            .with_browser_dppx(2.0)
226            .with_image_intrinsic_dppx(1.0)
227            .with_ppd_override(100.0);
228        assert!((v.effective_ppd() - 100.0).abs() < f64::EPSILON);
229    }
230}