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}