Skip to main content

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//!
14//! ## Simulation Modes
15//!
16//! When simulating viewing conditions for metric calculation, there are two approaches:
17//!
18//! - [`SimulationMode::Accurate`]: Simulate browser behavior exactly, including upscaling
19//!   undersized images. This matches real-world viewing but introduces resampling artifacts.
20//!
21//! - [`SimulationMode::DownsampleOnly`]: Never upsample images. For undersized images,
22//!   adjust the effective PPD instead. This avoids simulation artifacts but requires
23//!   metric threshold adjustment.
24
25use serde::{Deserialize, Serialize};
26
27/// How to handle image scaling during viewing simulation.
28///
29/// When calculating perceptual metrics, we need to simulate how images appear
30/// on different devices. This affects whether we resample images or adjust
31/// metric thresholds.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
33pub enum SimulationMode {
34    /// Simulate browser behavior exactly.
35    ///
36    /// - Undersized images (ratio < 1): Upsample to simulate browser upscaling
37    /// - Oversized images (ratio > 1): Downsample to simulate browser downscaling
38    ///
39    /// This matches real-world viewing but introduces resampling artifacts
40    /// that may affect metric accuracy.
41    #[default]
42    Accurate,
43
44    /// Never upsample, only downsample.
45    ///
46    /// - Undersized images: Keep at native resolution, adjust effective PPD
47    /// - Oversized images: Downsample normally
48    ///
49    /// This avoids introducing upsampling artifacts in the simulation.
50    /// The effective PPD is adjusted to account for the missing upscale,
51    /// making metric thresholds more lenient for undersized images.
52    DownsampleOnly,
53}
54
55/// Viewing condition for perceptual quality assessment.
56///
57/// Models how an image will be viewed, which affects whether compression
58/// artifacts will be perceptible.
59///
60/// # Example
61///
62/// ```
63/// use codec_eval::ViewingCondition;
64///
65/// // Desktop viewing with 2x retina display showing a 2x srcset image
66/// let condition = ViewingCondition::desktop()
67///     .with_browser_dppx(2.0)
68///     .with_image_intrinsic_dppx(2.0);
69///
70/// // The effective PPD accounts for the srcset ratio
71/// let ppd = condition.effective_ppd();
72/// ```
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct ViewingCondition {
75    /// Viewer's visual acuity in pixels per degree.
76    ///
77    /// This is the baseline PPD for the display and viewing distance.
78    /// Typical values:
79    /// - Desktop at arm's length: ~40 PPD
80    /// - Laptop: ~60 PPD
81    /// - Smartphone held close: ~90+ PPD
82    pub acuity_ppd: f64,
83
84    /// Browser/OS device pixel ratio.
85    ///
86    /// For retina/HiDPI displays, this is typically 2.0 or 3.0.
87    /// For standard displays, this is 1.0.
88    pub browser_dppx: Option<f64>,
89
90    /// Image's intrinsic pixels per CSS pixel.
91    ///
92    /// For srcset images:
93    /// - A 1x image has `intrinsic_dppx = 1.0`
94    /// - A 2x image has `intrinsic_dppx = 2.0`
95    ///
96    /// This affects the effective resolution at which the image is displayed.
97    pub image_intrinsic_dppx: Option<f64>,
98
99    /// Override or computed PPD for this specific viewing.
100    ///
101    /// If `Some`, this value is used directly instead of computing from
102    /// the other fields.
103    pub ppd: Option<f64>,
104}
105
106impl ViewingCondition {
107    /// Create a new viewing condition with the given acuity PPD.
108    ///
109    /// # Arguments
110    ///
111    /// * `acuity_ppd` - The viewer's visual acuity in pixels per degree.
112    #[must_use]
113    pub fn new(acuity_ppd: f64) -> Self {
114        Self {
115            acuity_ppd,
116            browser_dppx: None,
117            image_intrinsic_dppx: None,
118            ppd: None,
119        }
120    }
121
122    /// Desktop viewing condition (acuity ~40 PPD).
123    ///
124    /// Represents viewing a standard desktop monitor at arm's length
125    /// (approximately 24 inches / 60 cm).
126    #[must_use]
127    pub fn desktop() -> Self {
128        Self::new(40.0)
129    }
130
131    /// Laptop viewing condition (acuity ~60 PPD).
132    ///
133    /// Represents viewing a laptop screen at a typical distance
134    /// (approximately 18 inches / 45 cm).
135    #[must_use]
136    pub fn laptop() -> Self {
137        Self::new(60.0)
138    }
139
140    /// Smartphone viewing condition (acuity ~90 PPD).
141    ///
142    /// Represents viewing a smartphone held at reading distance
143    /// (approximately 12 inches / 30 cm).
144    #[must_use]
145    pub fn smartphone() -> Self {
146        Self::new(90.0)
147    }
148
149    /// Set the browser/OS device pixel ratio.
150    ///
151    /// # Arguments
152    ///
153    /// * `dppx` - Device pixel ratio (e.g., 2.0 for retina).
154    #[must_use]
155    pub fn with_browser_dppx(mut self, dppx: f64) -> Self {
156        self.browser_dppx = Some(dppx);
157        self
158    }
159
160    /// Set the image's intrinsic pixels per CSS pixel.
161    ///
162    /// # Arguments
163    ///
164    /// * `dppx` - Intrinsic DPI ratio (e.g., 2.0 for a 2x srcset image).
165    #[must_use]
166    pub fn with_image_intrinsic_dppx(mut self, dppx: f64) -> Self {
167        self.image_intrinsic_dppx = Some(dppx);
168        self
169    }
170
171    /// Override the computed PPD with a specific value.
172    ///
173    /// # Arguments
174    ///
175    /// * `ppd` - The PPD value to use.
176    #[must_use]
177    pub fn with_ppd_override(mut self, ppd: f64) -> Self {
178        self.ppd = Some(ppd);
179        self
180    }
181
182    /// Compute the effective PPD for metric adjustment.
183    ///
184    /// If `ppd` is set, returns that value directly. Otherwise, computes
185    /// the effective PPD from the acuity and dppx values.
186    ///
187    /// The formula is:
188    /// ```text
189    /// effective_ppd = acuity_ppd * (image_intrinsic_dppx / browser_dppx)
190    /// ```
191    ///
192    /// This accounts for how srcset images are scaled on HiDPI displays.
193    #[must_use]
194    pub fn effective_ppd(&self) -> f64 {
195        if let Some(ppd) = self.ppd {
196            return ppd;
197        }
198
199        let browser = self.browser_dppx.unwrap_or(1.0);
200        let intrinsic = self.image_intrinsic_dppx.unwrap_or(1.0);
201
202        // When intrinsic > browser, image pixels are smaller than device pixels,
203        // making artifacts less visible (higher effective PPD).
204        // When intrinsic < browser, image pixels are larger, artifacts more visible.
205        self.acuity_ppd * (intrinsic / browser)
206    }
207
208    /// Compute the srcset ratio (intrinsic / browser).
209    ///
210    /// - ratio < 1: Image is undersized, browser upscales
211    /// - ratio = 1: Native resolution
212    /// - ratio > 1: Image is oversized, browser downscales
213    #[must_use]
214    pub fn srcset_ratio(&self) -> f64 {
215        let browser = self.browser_dppx.unwrap_or(1.0);
216        let intrinsic = self.image_intrinsic_dppx.unwrap_or(1.0);
217        intrinsic / browser
218    }
219
220    /// Compute simulation parameters for a given image size.
221    ///
222    /// Returns the scale factor to apply and the adjusted PPD for metrics.
223    ///
224    /// # Arguments
225    ///
226    /// * `image_width` - Original image width in pixels
227    /// * `image_height` - Original image height in pixels
228    /// * `mode` - Simulation mode (accurate or downsample-only)
229    ///
230    /// # Example
231    ///
232    /// ```
233    /// use codec_eval::viewing::{ViewingCondition, SimulationMode};
234    ///
235    /// let condition = ViewingCondition::desktop()
236    ///     .with_browser_dppx(2.0)
237    ///     .with_image_intrinsic_dppx(1.0); // undersized
238    ///
239    /// let params = condition.simulation_params(1000, 800, SimulationMode::DownsampleOnly);
240    /// assert_eq!(params.scale_factor, 1.0); // No upscaling
241    /// assert!(params.adjusted_ppd < 40.0);  // Adjusted for missing upscale
242    /// ```
243    #[must_use]
244    pub fn simulation_params(
245        &self,
246        image_width: u32,
247        image_height: u32,
248        mode: SimulationMode,
249    ) -> SimulationParams {
250        let ratio = self.srcset_ratio();
251        let base_ppd = self.acuity_ppd;
252
253        match mode {
254            SimulationMode::Accurate => {
255                // Full simulation: scale by ratio
256                let scale_factor = ratio;
257                let target_width = (image_width as f64 * scale_factor).round() as u32;
258                let target_height = (image_height as f64 * scale_factor).round() as u32;
259
260                SimulationParams {
261                    scale_factor,
262                    target_width,
263                    target_height,
264                    adjusted_ppd: self.effective_ppd(),
265                    requires_upscale: ratio < 1.0,
266                    requires_downscale: ratio > 1.0,
267                }
268            }
269            SimulationMode::DownsampleOnly => {
270                if ratio >= 1.0 {
271                    // Oversized: downsample normally
272                    let scale_factor = ratio;
273                    let target_width = (image_width as f64 * scale_factor).round() as u32;
274                    let target_height = (image_height as f64 * scale_factor).round() as u32;
275
276                    SimulationParams {
277                        scale_factor,
278                        target_width,
279                        target_height,
280                        adjusted_ppd: self.effective_ppd(),
281                        requires_upscale: false,
282                        requires_downscale: ratio > 1.0,
283                    }
284                } else {
285                    // Undersized: keep original size, adjust PPD instead
286                    // The effective PPD is reduced because we're not simulating the upscale
287                    // that would make artifacts more visible
288                    let adjusted_ppd = base_ppd * ratio;
289
290                    SimulationParams {
291                        scale_factor: 1.0,
292                        target_width: image_width,
293                        target_height: image_height,
294                        adjusted_ppd,
295                        requires_upscale: false, // We skip upscaling
296                        requires_downscale: false,
297                    }
298                }
299            }
300        }
301    }
302}
303
304/// Parameters for viewing simulation.
305///
306/// Describes how to transform an image and adjust metrics for a viewing condition.
307#[derive(Debug, Clone, Copy, PartialEq)]
308pub struct SimulationParams {
309    /// Scale factor to apply to the image (1.0 = no scaling).
310    pub scale_factor: f64,
311
312    /// Target width after scaling.
313    pub target_width: u32,
314
315    /// Target height after scaling.
316    pub target_height: u32,
317
318    /// Adjusted PPD for metric thresholds.
319    ///
320    /// In downsample-only mode, this may differ from effective_ppd()
321    /// to compensate for skipped upscaling.
322    pub adjusted_ppd: f64,
323
324    /// Whether the simulation requires upscaling.
325    ///
326    /// In downsample-only mode, this is always false.
327    pub requires_upscale: bool,
328
329    /// Whether the simulation requires downscaling.
330    pub requires_downscale: bool,
331}
332
333/// Reference PPD for metric threshold normalization.
334///
335/// Desktop viewing at arm's length (~24"/60cm) is the most demanding
336/// common viewing condition, so we use it as the baseline.
337pub const REFERENCE_PPD: f64 = 40.0;
338
339impl SimulationParams {
340    /// Check if any scaling is required.
341    #[must_use]
342    pub fn requires_scaling(&self) -> bool {
343        self.requires_upscale || self.requires_downscale
344    }
345
346    /// Get the scale factor clamped to downscale-only (max 1.0).
347    #[must_use]
348    pub fn downscale_only_factor(&self) -> f64 {
349        self.scale_factor.min(1.0)
350    }
351
352    /// Compute threshold multiplier for metric values.
353    ///
354    /// This accounts for how viewing conditions affect artifact visibility.
355    /// Higher PPD = smaller angular size = artifacts less visible = more lenient thresholds.
356    ///
357    /// The multiplier is relative to [`REFERENCE_PPD`] (40, desktop viewing).
358    ///
359    /// # Returns
360    ///
361    /// - 1.0 at reference PPD (40)
362    /// - > 1.0 for higher PPD (more lenient, e.g., 1.75 at 70 PPD)
363    /// - < 1.0 for lower PPD (stricter, e.g., 0.5 at 20 PPD)
364    ///
365    /// # Example
366    ///
367    /// ```
368    /// use codec_eval::viewing::{ViewingCondition, SimulationMode, REFERENCE_PPD};
369    ///
370    /// // Desktop at reference PPD
371    /// let condition = ViewingCondition::new(40.0);
372    /// let params = condition.simulation_params(1000, 800, SimulationMode::Accurate);
373    /// assert!((params.threshold_multiplier() - 1.0).abs() < 0.01);
374    ///
375    /// // Laptop at 70 PPD - more lenient
376    /// let condition = ViewingCondition::new(70.0);
377    /// let params = condition.simulation_params(1000, 800, SimulationMode::Accurate);
378    /// assert!(params.threshold_multiplier() > 1.5);
379    /// ```
380    #[must_use]
381    pub fn threshold_multiplier(&self) -> f64 {
382        self.adjusted_ppd / REFERENCE_PPD
383    }
384
385    /// Adjust a DSSIM threshold for this viewing condition.
386    ///
387    /// Higher PPD allows higher DSSIM values (artifacts less visible).
388    ///
389    /// # Arguments
390    ///
391    /// * `base_threshold` - Threshold at reference PPD (e.g., 0.0003 for imperceptible)
392    ///
393    /// # Example
394    ///
395    /// ```
396    /// use codec_eval::viewing::{ViewingCondition, SimulationMode};
397    ///
398    /// let condition = ViewingCondition::new(70.0); // laptop
399    /// let params = condition.simulation_params(1000, 800, SimulationMode::Accurate);
400    ///
401    /// // Imperceptible threshold at reference is 0.0003
402    /// let adjusted = params.adjust_dssim_threshold(0.0003);
403    /// assert!(adjusted > 0.0003); // More lenient at higher PPD
404    /// ```
405    #[must_use]
406    pub fn adjust_dssim_threshold(&self, base_threshold: f64) -> f64 {
407        base_threshold * self.threshold_multiplier()
408    }
409
410    /// Adjust a Butteraugli threshold for this viewing condition.
411    ///
412    /// Higher PPD allows higher Butteraugli values (artifacts less visible).
413    ///
414    /// # Arguments
415    ///
416    /// * `base_threshold` - Threshold at reference PPD (e.g., 1.0 for imperceptible)
417    #[must_use]
418    pub fn adjust_butteraugli_threshold(&self, base_threshold: f64) -> f64 {
419        base_threshold * self.threshold_multiplier()
420    }
421
422    /// Adjust a SSIMULACRA2 threshold for this viewing condition.
423    ///
424    /// Higher PPD allows lower SSIMULACRA2 scores (artifacts less visible).
425    /// Note: SSIMULACRA2 is inverted (higher = better), so we divide.
426    ///
427    /// # Arguments
428    ///
429    /// * `base_threshold` - Threshold at reference PPD (e.g., 90.0 for imperceptible)
430    #[must_use]
431    pub fn adjust_ssimulacra2_threshold(&self, base_threshold: f64) -> f64 {
432        // SSIMULACRA2: higher is better, so higher PPD means we can accept lower scores
433        // But we need to be careful not to go below 0
434        let multiplier = self.threshold_multiplier();
435        if multiplier >= 1.0 {
436            // Higher PPD: can accept lower scores
437            // 90 at 40 PPD → ~51 at 70 PPD (90 - (90-0) * (1 - 1/1.75))
438            base_threshold - (100.0 - base_threshold) * (1.0 - 1.0 / multiplier)
439        } else {
440            // Lower PPD: need higher scores
441            // 90 at 40 PPD → 95 at 20 PPD
442            base_threshold + (100.0 - base_threshold) * (1.0 / multiplier - 1.0)
443        }
444        .clamp(0.0, 100.0)
445    }
446
447    /// Check if a DSSIM value is acceptable for this viewing condition.
448    ///
449    /// # Arguments
450    ///
451    /// * `dssim` - Measured DSSIM value
452    /// * `base_threshold` - Threshold at reference PPD
453    #[must_use]
454    pub fn dssim_acceptable(&self, dssim: f64, base_threshold: f64) -> bool {
455        dssim < self.adjust_dssim_threshold(base_threshold)
456    }
457
458    /// Check if a Butteraugli value is acceptable for this viewing condition.
459    #[must_use]
460    pub fn butteraugli_acceptable(&self, butteraugli: f64, base_threshold: f64) -> bool {
461        butteraugli < self.adjust_butteraugli_threshold(base_threshold)
462    }
463
464    /// Check if a SSIMULACRA2 value is acceptable for this viewing condition.
465    #[must_use]
466    pub fn ssimulacra2_acceptable(&self, ssimulacra2: f64, base_threshold: f64) -> bool {
467        ssimulacra2 > self.adjust_ssimulacra2_threshold(base_threshold)
468    }
469}
470
471impl Default for ViewingCondition {
472    fn default() -> Self {
473        Self::desktop()
474    }
475}
476
477/// Pre-defined viewing condition presets for common scenarios.
478///
479/// These presets model real-world viewing scenarios including srcset
480/// image delivery on various devices.
481///
482/// ## Terminology
483///
484/// - **Native**: srcset matches device DPPX (1x on 1x, 2x on 2x, etc.)
485/// - **Undersized**: srcset is smaller than device (browser upscales, artifacts amplified)
486/// - **Oversized**: srcset is larger than device (browser downscales, artifacts hidden)
487///
488/// ## Preset PPD Values
489///
490/// | Device | Base PPD | Typical DPPX | Viewing Distance |
491/// |--------|----------|--------------|------------------|
492/// | Desktop | 40 | 1.0 | ~24" / 60cm |
493/// | Laptop | 70 | 2.0 | ~18" / 45cm |
494/// | Phone | 95 | 3.0 | ~12" / 30cm |
495pub mod presets {
496    use super::ViewingCondition;
497
498    //=========================================================================
499    // Native Conditions (srcset matches device DPPX)
500    //=========================================================================
501
502    /// Desktop monitor at arm's length, 1x srcset on 1x display.
503    ///
504    /// This is the most demanding condition - artifacts are most visible.
505    /// Effective PPD: 40
506    #[must_use]
507    pub fn native_desktop() -> ViewingCondition {
508        ViewingCondition::new(40.0)
509            .with_browser_dppx(1.0)
510            .with_image_intrinsic_dppx(1.0)
511    }
512
513    /// Laptop/retina screen, 2x srcset on 2x display.
514    ///
515    /// Common premium laptop viewing condition.
516    /// Effective PPD: 70
517    #[must_use]
518    pub fn native_laptop() -> ViewingCondition {
519        ViewingCondition::new(70.0)
520            .with_browser_dppx(2.0)
521            .with_image_intrinsic_dppx(2.0)
522    }
523
524    /// Smartphone, 3x srcset on 3x display.
525    ///
526    /// High-DPI phone with matching srcset.
527    /// Effective PPD: 95
528    #[must_use]
529    pub fn native_phone() -> ViewingCondition {
530        ViewingCondition::new(95.0)
531            .with_browser_dppx(3.0)
532            .with_image_intrinsic_dppx(3.0)
533    }
534
535    //=========================================================================
536    // Undersized Conditions (browser upscales, artifacts amplified)
537    //=========================================================================
538
539    /// 1x srcset shown on 3x phone display (0.33x ratio).
540    ///
541    /// Worst case: massive upscaling makes artifacts very visible.
542    /// Effective PPD: ~32 (95 * 1/3)
543    #[must_use]
544    pub fn srcset_1x_on_phone() -> ViewingCondition {
545        ViewingCondition::new(95.0)
546            .with_browser_dppx(3.0)
547            .with_image_intrinsic_dppx(1.0)
548    }
549
550    /// 1x srcset shown on 2x laptop display (0.5x ratio).
551    ///
552    /// Common when srcset is misconfigured or unavailable.
553    /// Effective PPD: 35 (70 * 1/2)
554    #[must_use]
555    pub fn srcset_1x_on_laptop() -> ViewingCondition {
556        ViewingCondition::new(70.0)
557            .with_browser_dppx(2.0)
558            .with_image_intrinsic_dppx(1.0)
559    }
560
561    /// 2x srcset shown on 3x phone display (0.67x ratio).
562    ///
563    /// Moderate upscaling on high-DPI phone.
564    /// Effective PPD: ~63 (95 * 2/3)
565    #[must_use]
566    pub fn srcset_2x_on_phone() -> ViewingCondition {
567        ViewingCondition::new(95.0)
568            .with_browser_dppx(3.0)
569            .with_image_intrinsic_dppx(2.0)
570    }
571
572    //=========================================================================
573    // Oversized Conditions (browser downscales, artifacts hidden)
574    //=========================================================================
575
576    /// 2x srcset shown on 1x desktop display (2.0x ratio).
577    ///
578    /// Downscaling hides artifacts, but wastes bandwidth.
579    /// Effective PPD: 80 (40 * 2)
580    #[must_use]
581    pub fn srcset_2x_on_desktop() -> ViewingCondition {
582        ViewingCondition::new(40.0)
583            .with_browser_dppx(1.0)
584            .with_image_intrinsic_dppx(2.0)
585    }
586
587    /// 2x srcset shown on 1.5x laptop display (1.33x ratio).
588    ///
589    /// Slight oversizing on mid-DPI laptop.
590    /// Effective PPD: ~93 (70 * 2/1.5)
591    #[must_use]
592    pub fn srcset_2x_on_laptop_1_5x() -> ViewingCondition {
593        ViewingCondition::new(70.0)
594            .with_browser_dppx(1.5)
595            .with_image_intrinsic_dppx(2.0)
596    }
597
598    /// 3x srcset shown on 3x phone display.
599    ///
600    /// Native phone viewing, same as native_phone().
601    /// Effective PPD: 95
602    #[must_use]
603    pub fn srcset_3x_on_phone() -> ViewingCondition {
604        native_phone()
605    }
606
607    //=========================================================================
608    // Preset Collections
609    //=========================================================================
610
611    /// All standard presets for comprehensive analysis.
612    ///
613    /// Returns conditions ordered from most demanding (lowest effective PPD)
614    /// to least demanding (highest effective PPD).
615    #[must_use]
616    pub fn all() -> Vec<ViewingCondition> {
617        vec![
618            srcset_1x_on_phone(),       // ~32 PPD - most demanding
619            srcset_1x_on_laptop(),      // 35 PPD
620            native_desktop(),           // 40 PPD
621            srcset_2x_on_phone(),       // ~63 PPD
622            native_laptop(),            // 70 PPD
623            srcset_2x_on_desktop(),     // 80 PPD
624            srcset_2x_on_laptop_1_5x(), // ~93 PPD
625            native_phone(),             // 95 PPD - least demanding
626        ]
627    }
628
629    /// Key presets for compact analysis tables.
630    ///
631    /// Covers the main device types at native resolution.
632    #[must_use]
633    pub fn key() -> Vec<ViewingCondition> {
634        vec![native_desktop(), native_laptop(), native_phone()]
635    }
636
637    /// Baseline condition for quality mapping (native laptop).
638    ///
639    /// This is a good middle-ground for quality calibration:
640    /// - More forgiving than desktop (70 vs 40 PPD)
641    /// - Representative of premium laptop viewing
642    /// - 2x srcset is common for web images
643    #[must_use]
644    pub fn baseline() -> ViewingCondition {
645        native_laptop()
646    }
647
648    /// Most demanding condition for diminishing returns analysis.
649    ///
650    /// Native desktop is where artifacts are most visible,
651    /// making it ideal for determining quality upper bounds.
652    #[must_use]
653    pub fn demanding() -> ViewingCondition {
654        native_desktop()
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn test_desktop_defaults() {
664        let v = ViewingCondition::desktop();
665        assert!((v.acuity_ppd - 40.0).abs() < f64::EPSILON);
666        assert!(v.browser_dppx.is_none());
667        assert!(v.image_intrinsic_dppx.is_none());
668        assert!(v.ppd.is_none());
669    }
670
671    #[test]
672    fn test_effective_ppd_no_dppx() {
673        let v = ViewingCondition::desktop();
674        assert!((v.effective_ppd() - 40.0).abs() < f64::EPSILON);
675    }
676
677    #[test]
678    fn test_effective_ppd_with_retina() {
679        // 2x image on 2x display = same effective PPD
680        let v = ViewingCondition::desktop()
681            .with_browser_dppx(2.0)
682            .with_image_intrinsic_dppx(2.0);
683        assert!((v.effective_ppd() - 40.0).abs() < f64::EPSILON);
684    }
685
686    #[test]
687    fn test_effective_ppd_1x_on_2x() {
688        // 1x image on 2x display = half effective PPD (artifacts more visible)
689        let v = ViewingCondition::desktop()
690            .with_browser_dppx(2.0)
691            .with_image_intrinsic_dppx(1.0);
692        assert!((v.effective_ppd() - 20.0).abs() < f64::EPSILON);
693    }
694
695    #[test]
696    fn test_effective_ppd_2x_on_1x() {
697        // 2x image on 1x display = double effective PPD (artifacts less visible)
698        let v = ViewingCondition::desktop()
699            .with_browser_dppx(1.0)
700            .with_image_intrinsic_dppx(2.0);
701        assert!((v.effective_ppd() - 80.0).abs() < f64::EPSILON);
702    }
703
704    #[test]
705    fn test_ppd_override() {
706        let v = ViewingCondition::desktop()
707            .with_browser_dppx(2.0)
708            .with_image_intrinsic_dppx(1.0)
709            .with_ppd_override(100.0);
710        assert!((v.effective_ppd() - 100.0).abs() < f64::EPSILON);
711    }
712
713    #[test]
714    fn test_presets_native() {
715        let desktop = presets::native_desktop();
716        assert!((desktop.effective_ppd() - 40.0).abs() < 0.1);
717
718        let laptop = presets::native_laptop();
719        assert!((laptop.effective_ppd() - 70.0).abs() < 0.1);
720
721        let phone = presets::native_phone();
722        assert!((phone.effective_ppd() - 95.0).abs() < 0.1);
723    }
724
725    #[test]
726    fn test_presets_undersized() {
727        // 1x on 3x phone = 95 * (1/3) ≈ 31.67
728        let v = presets::srcset_1x_on_phone();
729        assert!(v.effective_ppd() < 35.0);
730        assert!(v.effective_ppd() > 30.0);
731
732        // 1x on 2x laptop = 70 * (1/2) = 35
733        let v = presets::srcset_1x_on_laptop();
734        assert!((v.effective_ppd() - 35.0).abs() < 0.1);
735    }
736
737    #[test]
738    fn test_presets_oversized() {
739        // 2x on 1x desktop = 40 * (2/1) = 80
740        let v = presets::srcset_2x_on_desktop();
741        assert!((v.effective_ppd() - 80.0).abs() < 0.1);
742    }
743
744    #[test]
745    fn test_presets_all_ordered() {
746        let all = presets::all();
747        assert!(all.len() >= 5);
748
749        // Should be ordered by effective PPD (ascending)
750        for i in 0..all.len() - 1 {
751            assert!(
752                all[i].effective_ppd() <= all[i + 1].effective_ppd(),
753                "Presets should be ordered by effective PPD"
754            );
755        }
756    }
757
758    #[test]
759    fn test_srcset_ratio() {
760        // Native: ratio = 1
761        let v = ViewingCondition::desktop();
762        assert!((v.srcset_ratio() - 1.0).abs() < 0.001);
763
764        // Undersized: 1x on 2x = 0.5
765        let v = ViewingCondition::desktop()
766            .with_browser_dppx(2.0)
767            .with_image_intrinsic_dppx(1.0);
768        assert!((v.srcset_ratio() - 0.5).abs() < 0.001);
769
770        // Oversized: 2x on 1x = 2.0
771        let v = ViewingCondition::desktop()
772            .with_browser_dppx(1.0)
773            .with_image_intrinsic_dppx(2.0);
774        assert!((v.srcset_ratio() - 2.0).abs() < 0.001);
775    }
776
777    #[test]
778    fn test_simulation_accurate_undersized() {
779        // 1x on 2x display (undersized)
780        let v = ViewingCondition::new(40.0)
781            .with_browser_dppx(2.0)
782            .with_image_intrinsic_dppx(1.0);
783
784        let params = v.simulation_params(1000, 800, SimulationMode::Accurate);
785
786        // Should upscale to simulate browser behavior
787        assert!((params.scale_factor - 0.5).abs() < 0.001);
788        assert_eq!(params.target_width, 500);
789        assert_eq!(params.target_height, 400);
790        assert!(params.requires_upscale); // ratio < 1 means browser upscales
791        assert!(!params.requires_downscale);
792    }
793
794    #[test]
795    fn test_simulation_accurate_oversized() {
796        // 2x on 1x display (oversized)
797        let v = ViewingCondition::new(40.0)
798            .with_browser_dppx(1.0)
799            .with_image_intrinsic_dppx(2.0);
800
801        let params = v.simulation_params(1000, 800, SimulationMode::Accurate);
802
803        // Should downscale
804        assert!((params.scale_factor - 2.0).abs() < 0.001);
805        assert_eq!(params.target_width, 2000);
806        assert_eq!(params.target_height, 1600);
807        assert!(!params.requires_upscale);
808        assert!(params.requires_downscale);
809    }
810
811    #[test]
812    fn test_simulation_downsample_only_undersized() {
813        // 1x on 2x display (undersized) with downsample-only mode
814        let v = ViewingCondition::new(40.0)
815            .with_browser_dppx(2.0)
816            .with_image_intrinsic_dppx(1.0);
817
818        let params = v.simulation_params(1000, 800, SimulationMode::DownsampleOnly);
819
820        // Should NOT upscale, keep original size
821        assert!((params.scale_factor - 1.0).abs() < 0.001);
822        assert_eq!(params.target_width, 1000);
823        assert_eq!(params.target_height, 800);
824        assert!(!params.requires_upscale);
825        assert!(!params.requires_downscale);
826
827        // PPD should be adjusted to compensate (reduced)
828        assert!((params.adjusted_ppd - 20.0).abs() < 0.1); // 40 * 0.5 = 20
829    }
830
831    #[test]
832    fn test_simulation_downsample_only_oversized() {
833        // 2x on 1x display (oversized) - should still downscale
834        let v = ViewingCondition::new(40.0)
835            .with_browser_dppx(1.0)
836            .with_image_intrinsic_dppx(2.0);
837
838        let params = v.simulation_params(1000, 800, SimulationMode::DownsampleOnly);
839
840        // Should downscale (oversized images are fine to downscale)
841        assert!((params.scale_factor - 2.0).abs() < 0.001);
842        assert_eq!(params.target_width, 2000);
843        assert_eq!(params.target_height, 1600);
844        assert!(!params.requires_upscale);
845        assert!(params.requires_downscale);
846    }
847
848    #[test]
849    fn test_simulation_params_helpers() {
850        let params = SimulationParams {
851            scale_factor: 0.5,
852            target_width: 500,
853            target_height: 400,
854            adjusted_ppd: 20.0,
855            requires_upscale: true,
856            requires_downscale: false,
857        };
858
859        assert!(params.requires_scaling());
860        assert!((params.downscale_only_factor() - 0.5).abs() < 0.001);
861
862        let params2 = SimulationParams {
863            scale_factor: 2.0,
864            target_width: 2000,
865            target_height: 1600,
866            adjusted_ppd: 80.0,
867            requires_upscale: false,
868            requires_downscale: true,
869        };
870
871        assert!(params2.requires_scaling());
872        assert!((params2.downscale_only_factor() - 1.0).abs() < 0.001);
873    }
874
875    #[test]
876    fn test_threshold_multiplier() {
877        // Reference PPD = 40
878        let params_ref = SimulationParams {
879            scale_factor: 1.0,
880            target_width: 1000,
881            target_height: 800,
882            adjusted_ppd: 40.0,
883            requires_upscale: false,
884            requires_downscale: false,
885        };
886        assert!((params_ref.threshold_multiplier() - 1.0).abs() < 0.001);
887
888        // Higher PPD = more lenient
889        let params_high = SimulationParams {
890            scale_factor: 1.0,
891            target_width: 1000,
892            target_height: 800,
893            adjusted_ppd: 80.0,
894            requires_upscale: false,
895            requires_downscale: false,
896        };
897        assert!((params_high.threshold_multiplier() - 2.0).abs() < 0.001);
898
899        // Lower PPD = stricter
900        let params_low = SimulationParams {
901            scale_factor: 1.0,
902            target_width: 1000,
903            target_height: 800,
904            adjusted_ppd: 20.0,
905            requires_upscale: false,
906            requires_downscale: false,
907        };
908        assert!((params_low.threshold_multiplier() - 0.5).abs() < 0.001);
909    }
910
911    #[test]
912    fn test_adjust_dssim_threshold() {
913        let base_threshold = 0.0003; // imperceptible at reference
914
915        // At reference PPD, threshold unchanged
916        let params_ref = SimulationParams {
917            scale_factor: 1.0,
918            target_width: 1000,
919            target_height: 800,
920            adjusted_ppd: 40.0,
921            requires_upscale: false,
922            requires_downscale: false,
923        };
924        assert!((params_ref.adjust_dssim_threshold(base_threshold) - 0.0003).abs() < 0.00001);
925
926        // At higher PPD (laptop), more lenient
927        let params_laptop = SimulationParams {
928            scale_factor: 1.0,
929            target_width: 1000,
930            target_height: 800,
931            adjusted_ppd: 70.0,
932            requires_upscale: false,
933            requires_downscale: false,
934        };
935        let adjusted = params_laptop.adjust_dssim_threshold(base_threshold);
936        assert!(adjusted > 0.0003); // More lenient
937        assert!((adjusted - 0.000525).abs() < 0.0001); // 0.0003 * 1.75
938    }
939
940    #[test]
941    fn test_adjust_ssimulacra2_threshold() {
942        let base_threshold = 90.0; // imperceptible at reference
943
944        // At reference PPD, threshold unchanged
945        let params_ref = SimulationParams {
946            scale_factor: 1.0,
947            target_width: 1000,
948            target_height: 800,
949            adjusted_ppd: 40.0,
950            requires_upscale: false,
951            requires_downscale: false,
952        };
953        assert!((params_ref.adjust_ssimulacra2_threshold(base_threshold) - 90.0).abs() < 0.1);
954
955        // At higher PPD, can accept lower scores
956        let params_high = SimulationParams {
957            scale_factor: 1.0,
958            target_width: 1000,
959            target_height: 800,
960            adjusted_ppd: 80.0,
961            requires_upscale: false,
962            requires_downscale: false,
963        };
964        let adjusted = params_high.adjust_ssimulacra2_threshold(base_threshold);
965        assert!(adjusted < 90.0); // Can accept lower score
966
967        // At lower PPD, need higher scores
968        let params_low = SimulationParams {
969            scale_factor: 1.0,
970            target_width: 1000,
971            target_height: 800,
972            adjusted_ppd: 20.0,
973            requires_upscale: false,
974            requires_downscale: false,
975        };
976        let adjusted = params_low.adjust_ssimulacra2_threshold(base_threshold);
977        assert!(adjusted > 90.0); // Need higher score
978    }
979
980    #[test]
981    fn test_metric_acceptable() {
982        let params = SimulationParams {
983            scale_factor: 1.0,
984            target_width: 1000,
985            target_height: 800,
986            adjusted_ppd: 70.0, // laptop, more lenient
987            requires_upscale: false,
988            requires_downscale: false,
989        };
990
991        // DSSIM: 0.0004 would fail at reference (40 PPD) but pass at 70 PPD
992        // Threshold at 70 PPD = 0.0003 * 1.75 = 0.000525
993        assert!(params.dssim_acceptable(0.0004, 0.0003));
994        assert!(!params.dssim_acceptable(0.0006, 0.0003));
995
996        // Butteraugli: 1.5 would fail at reference but pass at 70 PPD
997        // Threshold at 70 PPD = 1.0 * 1.75 = 1.75
998        assert!(params.butteraugli_acceptable(1.5, 1.0));
999
1000        // SSIMULACRA2: at 70 PPD (multiplier 1.75), threshold is ~85.7
1001        // So 86 passes but 85 fails
1002        assert!(params.ssimulacra2_acceptable(86.0, 90.0));
1003        assert!(!params.ssimulacra2_acceptable(84.0, 90.0));
1004    }
1005}