codec-eval 0.3.2

Image codec comparison and evaluation library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
//! Viewing condition modeling for perceptual quality assessment.
//!
//! This module provides the [`ViewingCondition`] type which models how an image
//! will be viewed, affecting perceptual quality thresholds.
//!
//! ## Key Concepts
//!
//! - **acuity_ppd**: The viewer's visual acuity in pixels per degree. This is
//!   determined by the display's pixel density and viewing distance.
//! - **browser_dppx**: The browser/OS device pixel ratio (e.g., 2.0 for retina).
//! - **image_intrinsic_dppx**: The image's intrinsic pixels per CSS pixel (for srcset).
//! - **ppd**: The effective pixels per degree for this specific image viewing.
//!
//! ## Simulation Modes
//!
//! When simulating viewing conditions for metric calculation, there are two approaches:
//!
//! - [`SimulationMode::Accurate`]: Simulate browser behavior exactly, including upscaling
//!   undersized images. This matches real-world viewing but introduces resampling artifacts.
//!
//! - [`SimulationMode::DownsampleOnly`]: Never upsample images. For undersized images,
//!   adjust the effective PPD instead. This avoids simulation artifacts but requires
//!   metric threshold adjustment.

use serde::{Deserialize, Serialize};

/// How to handle image scaling during viewing simulation.
///
/// When calculating perceptual metrics, we need to simulate how images appear
/// on different devices. This affects whether we resample images or adjust
/// metric thresholds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SimulationMode {
    /// Simulate browser behavior exactly.
    ///
    /// - Undersized images (ratio < 1): Upsample to simulate browser upscaling
    /// - Oversized images (ratio > 1): Downsample to simulate browser downscaling
    ///
    /// This matches real-world viewing but introduces resampling artifacts
    /// that may affect metric accuracy.
    #[default]
    Accurate,

    /// Never upsample, only downsample.
    ///
    /// - Undersized images: Keep at native resolution, adjust effective PPD
    /// - Oversized images: Downsample normally
    ///
    /// This avoids introducing upsampling artifacts in the simulation.
    /// The effective PPD is adjusted to account for the missing upscale,
    /// making metric thresholds more lenient for undersized images.
    DownsampleOnly,
}

/// Viewing condition for perceptual quality assessment.
///
/// Models how an image will be viewed, which affects whether compression
/// artifacts will be perceptible.
///
/// # Example
///
/// ```
/// use codec_eval::ViewingCondition;
///
/// // Desktop viewing with 2x retina display showing a 2x srcset image
/// let condition = ViewingCondition::desktop()
///     .with_browser_dppx(2.0)
///     .with_image_intrinsic_dppx(2.0);
///
/// // The effective PPD accounts for the srcset ratio
/// let ppd = condition.effective_ppd();
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewingCondition {
    /// Viewer's visual acuity in pixels per degree.
    ///
    /// This is the baseline PPD for the display and viewing distance.
    /// Typical values:
    /// - Desktop at arm's length: ~40 PPD
    /// - Laptop: ~60 PPD
    /// - Smartphone held close: ~90+ PPD
    pub acuity_ppd: f64,

    /// Browser/OS device pixel ratio.
    ///
    /// For retina/HiDPI displays, this is typically 2.0 or 3.0.
    /// For standard displays, this is 1.0.
    pub browser_dppx: Option<f64>,

    /// Image's intrinsic pixels per CSS pixel.
    ///
    /// For srcset images:
    /// - A 1x image has `intrinsic_dppx = 1.0`
    /// - A 2x image has `intrinsic_dppx = 2.0`
    ///
    /// This affects the effective resolution at which the image is displayed.
    pub image_intrinsic_dppx: Option<f64>,

    /// Override or computed PPD for this specific viewing.
    ///
    /// If `Some`, this value is used directly instead of computing from
    /// the other fields.
    pub ppd: Option<f64>,
}

impl ViewingCondition {
    /// Create a new viewing condition with the given acuity PPD.
    ///
    /// # Arguments
    ///
    /// * `acuity_ppd` - The viewer's visual acuity in pixels per degree.
    #[must_use]
    pub fn new(acuity_ppd: f64) -> Self {
        Self {
            acuity_ppd,
            browser_dppx: None,
            image_intrinsic_dppx: None,
            ppd: None,
        }
    }

    /// Desktop viewing condition (acuity ~40 PPD).
    ///
    /// Represents viewing a standard desktop monitor at arm's length
    /// (approximately 24 inches / 60 cm).
    #[must_use]
    pub fn desktop() -> Self {
        Self::new(40.0)
    }

    /// Laptop viewing condition (acuity ~60 PPD).
    ///
    /// Represents viewing a laptop screen at a typical distance
    /// (approximately 18 inches / 45 cm).
    #[must_use]
    pub fn laptop() -> Self {
        Self::new(60.0)
    }

    /// Smartphone viewing condition (acuity ~90 PPD).
    ///
    /// Represents viewing a smartphone held at reading distance
    /// (approximately 12 inches / 30 cm).
    #[must_use]
    pub fn smartphone() -> Self {
        Self::new(90.0)
    }

    /// Set the browser/OS device pixel ratio.
    ///
    /// # Arguments
    ///
    /// * `dppx` - Device pixel ratio (e.g., 2.0 for retina).
    #[must_use]
    pub fn with_browser_dppx(mut self, dppx: f64) -> Self {
        self.browser_dppx = Some(dppx);
        self
    }

    /// Set the image's intrinsic pixels per CSS pixel.
    ///
    /// # Arguments
    ///
    /// * `dppx` - Intrinsic DPI ratio (e.g., 2.0 for a 2x srcset image).
    #[must_use]
    pub fn with_image_intrinsic_dppx(mut self, dppx: f64) -> Self {
        self.image_intrinsic_dppx = Some(dppx);
        self
    }

    /// Override the computed PPD with a specific value.
    ///
    /// # Arguments
    ///
    /// * `ppd` - The PPD value to use.
    #[must_use]
    pub fn with_ppd_override(mut self, ppd: f64) -> Self {
        self.ppd = Some(ppd);
        self
    }

    /// Compute the effective PPD for metric adjustment.
    ///
    /// If `ppd` is set, returns that value directly. Otherwise, computes
    /// the effective PPD from the acuity and dppx values.
    ///
    /// The formula is:
    /// ```text
    /// effective_ppd = acuity_ppd * (image_intrinsic_dppx / browser_dppx)
    /// ```
    ///
    /// This accounts for how srcset images are scaled on HiDPI displays.
    #[must_use]
    pub fn effective_ppd(&self) -> f64 {
        if let Some(ppd) = self.ppd {
            return ppd;
        }

        let browser = self.browser_dppx.unwrap_or(1.0);
        let intrinsic = self.image_intrinsic_dppx.unwrap_or(1.0);

        // When intrinsic > browser, image pixels are smaller than device pixels,
        // making artifacts less visible (higher effective PPD).
        // When intrinsic < browser, image pixels are larger, artifacts more visible.
        self.acuity_ppd * (intrinsic / browser)
    }

    /// Compute the srcset ratio (intrinsic / browser).
    ///
    /// - ratio < 1: Image is undersized, browser upscales
    /// - ratio = 1: Native resolution
    /// - ratio > 1: Image is oversized, browser downscales
    #[must_use]
    pub fn srcset_ratio(&self) -> f64 {
        let browser = self.browser_dppx.unwrap_or(1.0);
        let intrinsic = self.image_intrinsic_dppx.unwrap_or(1.0);
        intrinsic / browser
    }

    /// Compute simulation parameters for a given image size.
    ///
    /// Returns the scale factor to apply and the adjusted PPD for metrics.
    ///
    /// # Arguments
    ///
    /// * `image_width` - Original image width in pixels
    /// * `image_height` - Original image height in pixels
    /// * `mode` - Simulation mode (accurate or downsample-only)
    ///
    /// # Example
    ///
    /// ```
    /// use codec_eval::viewing::{ViewingCondition, SimulationMode};
    ///
    /// let condition = ViewingCondition::desktop()
    ///     .with_browser_dppx(2.0)
    ///     .with_image_intrinsic_dppx(1.0); // undersized
    ///
    /// let params = condition.simulation_params(1000, 800, SimulationMode::DownsampleOnly);
    /// assert_eq!(params.scale_factor, 1.0); // No upscaling
    /// assert!(params.adjusted_ppd < 40.0);  // Adjusted for missing upscale
    /// ```
    #[must_use]
    pub fn simulation_params(
        &self,
        image_width: u32,
        image_height: u32,
        mode: SimulationMode,
    ) -> SimulationParams {
        let ratio = self.srcset_ratio();
        let base_ppd = self.acuity_ppd;

        match mode {
            SimulationMode::Accurate => {
                // Full simulation: scale by ratio
                let scale_factor = ratio;
                let target_width = (image_width as f64 * scale_factor).round() as u32;
                let target_height = (image_height as f64 * scale_factor).round() as u32;

                SimulationParams {
                    scale_factor,
                    target_width,
                    target_height,
                    adjusted_ppd: self.effective_ppd(),
                    requires_upscale: ratio < 1.0,
                    requires_downscale: ratio > 1.0,
                }
            }
            SimulationMode::DownsampleOnly => {
                if ratio >= 1.0 {
                    // Oversized: downsample normally
                    let scale_factor = ratio;
                    let target_width = (image_width as f64 * scale_factor).round() as u32;
                    let target_height = (image_height as f64 * scale_factor).round() as u32;

                    SimulationParams {
                        scale_factor,
                        target_width,
                        target_height,
                        adjusted_ppd: self.effective_ppd(),
                        requires_upscale: false,
                        requires_downscale: ratio > 1.0,
                    }
                } else {
                    // Undersized: keep original size, adjust PPD instead
                    // The effective PPD is reduced because we're not simulating the upscale
                    // that would make artifacts more visible
                    let adjusted_ppd = base_ppd * ratio;

                    SimulationParams {
                        scale_factor: 1.0,
                        target_width: image_width,
                        target_height: image_height,
                        adjusted_ppd,
                        requires_upscale: false, // We skip upscaling
                        requires_downscale: false,
                    }
                }
            }
        }
    }
}

/// Parameters for viewing simulation.
///
/// Describes how to transform an image and adjust metrics for a viewing condition.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SimulationParams {
    /// Scale factor to apply to the image (1.0 = no scaling).
    pub scale_factor: f64,

    /// Target width after scaling.
    pub target_width: u32,

    /// Target height after scaling.
    pub target_height: u32,

    /// Adjusted PPD for metric thresholds.
    ///
    /// In downsample-only mode, this may differ from effective_ppd()
    /// to compensate for skipped upscaling.
    pub adjusted_ppd: f64,

    /// Whether the simulation requires upscaling.
    ///
    /// In downsample-only mode, this is always false.
    pub requires_upscale: bool,

    /// Whether the simulation requires downscaling.
    pub requires_downscale: bool,
}

/// Reference PPD for metric threshold normalization.
///
/// Desktop viewing at arm's length (~24"/60cm) is the most demanding
/// common viewing condition, so we use it as the baseline.
pub const REFERENCE_PPD: f64 = 40.0;

impl SimulationParams {
    /// Check if any scaling is required.
    #[must_use]
    pub fn requires_scaling(&self) -> bool {
        self.requires_upscale || self.requires_downscale
    }

    /// Get the scale factor clamped to downscale-only (max 1.0).
    #[must_use]
    pub fn downscale_only_factor(&self) -> f64 {
        self.scale_factor.min(1.0)
    }

    /// Compute threshold multiplier for metric values.
    ///
    /// This accounts for how viewing conditions affect artifact visibility.
    /// Higher PPD = smaller angular size = artifacts less visible = more lenient thresholds.
    ///
    /// The multiplier is relative to [`REFERENCE_PPD`] (40, desktop viewing).
    ///
    /// # Returns
    ///
    /// - 1.0 at reference PPD (40)
    /// - > 1.0 for higher PPD (more lenient, e.g., 1.75 at 70 PPD)
    /// - < 1.0 for lower PPD (stricter, e.g., 0.5 at 20 PPD)
    ///
    /// # Example
    ///
    /// ```
    /// use codec_eval::viewing::{ViewingCondition, SimulationMode, REFERENCE_PPD};
    ///
    /// // Desktop at reference PPD
    /// let condition = ViewingCondition::new(40.0);
    /// let params = condition.simulation_params(1000, 800, SimulationMode::Accurate);
    /// assert!((params.threshold_multiplier() - 1.0).abs() < 0.01);
    ///
    /// // Laptop at 70 PPD - more lenient
    /// let condition = ViewingCondition::new(70.0);
    /// let params = condition.simulation_params(1000, 800, SimulationMode::Accurate);
    /// assert!(params.threshold_multiplier() > 1.5);
    /// ```
    #[must_use]
    pub fn threshold_multiplier(&self) -> f64 {
        self.adjusted_ppd / REFERENCE_PPD
    }

    /// Adjust a DSSIM threshold for this viewing condition.
    ///
    /// Higher PPD allows higher DSSIM values (artifacts less visible).
    ///
    /// # Arguments
    ///
    /// * `base_threshold` - Threshold at reference PPD (e.g., 0.0003 for imperceptible)
    ///
    /// # Example
    ///
    /// ```
    /// use codec_eval::viewing::{ViewingCondition, SimulationMode};
    ///
    /// let condition = ViewingCondition::new(70.0); // laptop
    /// let params = condition.simulation_params(1000, 800, SimulationMode::Accurate);
    ///
    /// // Imperceptible threshold at reference is 0.0003
    /// let adjusted = params.adjust_dssim_threshold(0.0003);
    /// assert!(adjusted > 0.0003); // More lenient at higher PPD
    /// ```
    #[must_use]
    pub fn adjust_dssim_threshold(&self, base_threshold: f64) -> f64 {
        base_threshold * self.threshold_multiplier()
    }

    /// Adjust a Butteraugli threshold for this viewing condition.
    ///
    /// Higher PPD allows higher Butteraugli values (artifacts less visible).
    ///
    /// # Arguments
    ///
    /// * `base_threshold` - Threshold at reference PPD (e.g., 1.0 for imperceptible)
    #[must_use]
    pub fn adjust_butteraugli_threshold(&self, base_threshold: f64) -> f64 {
        base_threshold * self.threshold_multiplier()
    }

    /// Adjust a SSIMULACRA2 threshold for this viewing condition.
    ///
    /// Higher PPD allows lower SSIMULACRA2 scores (artifacts less visible).
    /// Note: SSIMULACRA2 is inverted (higher = better), so we divide.
    ///
    /// # Arguments
    ///
    /// * `base_threshold` - Threshold at reference PPD (e.g., 90.0 for imperceptible)
    #[must_use]
    pub fn adjust_ssimulacra2_threshold(&self, base_threshold: f64) -> f64 {
        // SSIMULACRA2: higher is better, so higher PPD means we can accept lower scores
        // But we need to be careful not to go below 0
        let multiplier = self.threshold_multiplier();
        if multiplier >= 1.0 {
            // Higher PPD: can accept lower scores
            // 90 at 40 PPD → ~51 at 70 PPD (90 - (90-0) * (1 - 1/1.75))
            base_threshold - (100.0 - base_threshold) * (1.0 - 1.0 / multiplier)
        } else {
            // Lower PPD: need higher scores
            // 90 at 40 PPD → 95 at 20 PPD
            base_threshold + (100.0 - base_threshold) * (1.0 / multiplier - 1.0)
        }
        .clamp(0.0, 100.0)
    }

    /// Check if a DSSIM value is acceptable for this viewing condition.
    ///
    /// # Arguments
    ///
    /// * `dssim` - Measured DSSIM value
    /// * `base_threshold` - Threshold at reference PPD
    #[must_use]
    pub fn dssim_acceptable(&self, dssim: f64, base_threshold: f64) -> bool {
        dssim < self.adjust_dssim_threshold(base_threshold)
    }

    /// Check if a Butteraugli value is acceptable for this viewing condition.
    #[must_use]
    pub fn butteraugli_acceptable(&self, butteraugli: f64, base_threshold: f64) -> bool {
        butteraugli < self.adjust_butteraugli_threshold(base_threshold)
    }

    /// Check if a SSIMULACRA2 value is acceptable for this viewing condition.
    #[must_use]
    pub fn ssimulacra2_acceptable(&self, ssimulacra2: f64, base_threshold: f64) -> bool {
        ssimulacra2 > self.adjust_ssimulacra2_threshold(base_threshold)
    }
}

impl Default for ViewingCondition {
    fn default() -> Self {
        Self::desktop()
    }
}

/// Pre-defined viewing condition presets for common scenarios.
///
/// These presets model real-world viewing scenarios including srcset
/// image delivery on various devices.
///
/// ## Terminology
///
/// - **Native**: srcset matches device DPPX (1x on 1x, 2x on 2x, etc.)
/// - **Undersized**: srcset is smaller than device (browser upscales, artifacts amplified)
/// - **Oversized**: srcset is larger than device (browser downscales, artifacts hidden)
///
/// ## Preset PPD Values
///
/// | Device | Base PPD | Typical DPPX | Viewing Distance |
/// |--------|----------|--------------|------------------|
/// | Desktop | 40 | 1.0 | ~24" / 60cm |
/// | Laptop | 70 | 2.0 | ~18" / 45cm |
/// | Phone | 95 | 3.0 | ~12" / 30cm |
pub mod presets {
    use super::ViewingCondition;

    //=========================================================================
    // Native Conditions (srcset matches device DPPX)
    //=========================================================================

    /// Desktop monitor at arm's length, 1x srcset on 1x display.
    ///
    /// This is the most demanding condition - artifacts are most visible.
    /// Effective PPD: 40
    #[must_use]
    pub fn native_desktop() -> ViewingCondition {
        ViewingCondition::new(40.0)
            .with_browser_dppx(1.0)
            .with_image_intrinsic_dppx(1.0)
    }

    /// Laptop/retina screen, 2x srcset on 2x display.
    ///
    /// Common premium laptop viewing condition.
    /// Effective PPD: 70
    #[must_use]
    pub fn native_laptop() -> ViewingCondition {
        ViewingCondition::new(70.0)
            .with_browser_dppx(2.0)
            .with_image_intrinsic_dppx(2.0)
    }

    /// Smartphone, 3x srcset on 3x display.
    ///
    /// High-DPI phone with matching srcset.
    /// Effective PPD: 95
    #[must_use]
    pub fn native_phone() -> ViewingCondition {
        ViewingCondition::new(95.0)
            .with_browser_dppx(3.0)
            .with_image_intrinsic_dppx(3.0)
    }

    //=========================================================================
    // Undersized Conditions (browser upscales, artifacts amplified)
    //=========================================================================

    /// 1x srcset shown on 3x phone display (0.33x ratio).
    ///
    /// Worst case: massive upscaling makes artifacts very visible.
    /// Effective PPD: ~32 (95 * 1/3)
    #[must_use]
    pub fn srcset_1x_on_phone() -> ViewingCondition {
        ViewingCondition::new(95.0)
            .with_browser_dppx(3.0)
            .with_image_intrinsic_dppx(1.0)
    }

    /// 1x srcset shown on 2x laptop display (0.5x ratio).
    ///
    /// Common when srcset is misconfigured or unavailable.
    /// Effective PPD: 35 (70 * 1/2)
    #[must_use]
    pub fn srcset_1x_on_laptop() -> ViewingCondition {
        ViewingCondition::new(70.0)
            .with_browser_dppx(2.0)
            .with_image_intrinsic_dppx(1.0)
    }

    /// 2x srcset shown on 3x phone display (0.67x ratio).
    ///
    /// Moderate upscaling on high-DPI phone.
    /// Effective PPD: ~63 (95 * 2/3)
    #[must_use]
    pub fn srcset_2x_on_phone() -> ViewingCondition {
        ViewingCondition::new(95.0)
            .with_browser_dppx(3.0)
            .with_image_intrinsic_dppx(2.0)
    }

    //=========================================================================
    // Oversized Conditions (browser downscales, artifacts hidden)
    //=========================================================================

    /// 2x srcset shown on 1x desktop display (2.0x ratio).
    ///
    /// Downscaling hides artifacts, but wastes bandwidth.
    /// Effective PPD: 80 (40 * 2)
    #[must_use]
    pub fn srcset_2x_on_desktop() -> ViewingCondition {
        ViewingCondition::new(40.0)
            .with_browser_dppx(1.0)
            .with_image_intrinsic_dppx(2.0)
    }

    /// 2x srcset shown on 1.5x laptop display (1.33x ratio).
    ///
    /// Slight oversizing on mid-DPI laptop.
    /// Effective PPD: ~93 (70 * 2/1.5)
    #[must_use]
    pub fn srcset_2x_on_laptop_1_5x() -> ViewingCondition {
        ViewingCondition::new(70.0)
            .with_browser_dppx(1.5)
            .with_image_intrinsic_dppx(2.0)
    }

    /// 3x srcset shown on 3x phone display.
    ///
    /// Native phone viewing, same as native_phone().
    /// Effective PPD: 95
    #[must_use]
    pub fn srcset_3x_on_phone() -> ViewingCondition {
        native_phone()
    }

    //=========================================================================
    // Preset Collections
    //=========================================================================

    /// All standard presets for comprehensive analysis.
    ///
    /// Returns conditions ordered from most demanding (lowest effective PPD)
    /// to least demanding (highest effective PPD).
    #[must_use]
    pub fn all() -> Vec<ViewingCondition> {
        vec![
            srcset_1x_on_phone(),       // ~32 PPD - most demanding
            srcset_1x_on_laptop(),      // 35 PPD
            native_desktop(),           // 40 PPD
            srcset_2x_on_phone(),       // ~63 PPD
            native_laptop(),            // 70 PPD
            srcset_2x_on_desktop(),     // 80 PPD
            srcset_2x_on_laptop_1_5x(), // ~93 PPD
            native_phone(),             // 95 PPD - least demanding
        ]
    }

    /// Key presets for compact analysis tables.
    ///
    /// Covers the main device types at native resolution.
    #[must_use]
    pub fn key() -> Vec<ViewingCondition> {
        vec![native_desktop(), native_laptop(), native_phone()]
    }

    /// Baseline condition for quality mapping (native laptop).
    ///
    /// This is a good middle-ground for quality calibration:
    /// - More forgiving than desktop (70 vs 40 PPD)
    /// - Representative of premium laptop viewing
    /// - 2x srcset is common for web images
    #[must_use]
    pub fn baseline() -> ViewingCondition {
        native_laptop()
    }

    /// Most demanding condition for diminishing returns analysis.
    ///
    /// Native desktop is where artifacts are most visible,
    /// making it ideal for determining quality upper bounds.
    #[must_use]
    pub fn demanding() -> ViewingCondition {
        native_desktop()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_desktop_defaults() {
        let v = ViewingCondition::desktop();
        assert!((v.acuity_ppd - 40.0).abs() < f64::EPSILON);
        assert!(v.browser_dppx.is_none());
        assert!(v.image_intrinsic_dppx.is_none());
        assert!(v.ppd.is_none());
    }

    #[test]
    fn test_effective_ppd_no_dppx() {
        let v = ViewingCondition::desktop();
        assert!((v.effective_ppd() - 40.0).abs() < f64::EPSILON);
    }

    #[test]
    fn test_effective_ppd_with_retina() {
        // 2x image on 2x display = same effective PPD
        let v = ViewingCondition::desktop()
            .with_browser_dppx(2.0)
            .with_image_intrinsic_dppx(2.0);
        assert!((v.effective_ppd() - 40.0).abs() < f64::EPSILON);
    }

    #[test]
    fn test_effective_ppd_1x_on_2x() {
        // 1x image on 2x display = half effective PPD (artifacts more visible)
        let v = ViewingCondition::desktop()
            .with_browser_dppx(2.0)
            .with_image_intrinsic_dppx(1.0);
        assert!((v.effective_ppd() - 20.0).abs() < f64::EPSILON);
    }

    #[test]
    fn test_effective_ppd_2x_on_1x() {
        // 2x image on 1x display = double effective PPD (artifacts less visible)
        let v = ViewingCondition::desktop()
            .with_browser_dppx(1.0)
            .with_image_intrinsic_dppx(2.0);
        assert!((v.effective_ppd() - 80.0).abs() < f64::EPSILON);
    }

    #[test]
    fn test_ppd_override() {
        let v = ViewingCondition::desktop()
            .with_browser_dppx(2.0)
            .with_image_intrinsic_dppx(1.0)
            .with_ppd_override(100.0);
        assert!((v.effective_ppd() - 100.0).abs() < f64::EPSILON);
    }

    #[test]
    fn test_presets_native() {
        let desktop = presets::native_desktop();
        assert!((desktop.effective_ppd() - 40.0).abs() < 0.1);

        let laptop = presets::native_laptop();
        assert!((laptop.effective_ppd() - 70.0).abs() < 0.1);

        let phone = presets::native_phone();
        assert!((phone.effective_ppd() - 95.0).abs() < 0.1);
    }

    #[test]
    fn test_presets_undersized() {
        // 1x on 3x phone = 95 * (1/3) ≈ 31.67
        let v = presets::srcset_1x_on_phone();
        assert!(v.effective_ppd() < 35.0);
        assert!(v.effective_ppd() > 30.0);

        // 1x on 2x laptop = 70 * (1/2) = 35
        let v = presets::srcset_1x_on_laptop();
        assert!((v.effective_ppd() - 35.0).abs() < 0.1);
    }

    #[test]
    fn test_presets_oversized() {
        // 2x on 1x desktop = 40 * (2/1) = 80
        let v = presets::srcset_2x_on_desktop();
        assert!((v.effective_ppd() - 80.0).abs() < 0.1);
    }

    #[test]
    fn test_presets_all_ordered() {
        let all = presets::all();
        assert!(all.len() >= 5);

        // Should be ordered by effective PPD (ascending)
        for i in 0..all.len() - 1 {
            assert!(
                all[i].effective_ppd() <= all[i + 1].effective_ppd(),
                "Presets should be ordered by effective PPD"
            );
        }
    }

    #[test]
    fn test_srcset_ratio() {
        // Native: ratio = 1
        let v = ViewingCondition::desktop();
        assert!((v.srcset_ratio() - 1.0).abs() < 0.001);

        // Undersized: 1x on 2x = 0.5
        let v = ViewingCondition::desktop()
            .with_browser_dppx(2.0)
            .with_image_intrinsic_dppx(1.0);
        assert!((v.srcset_ratio() - 0.5).abs() < 0.001);

        // Oversized: 2x on 1x = 2.0
        let v = ViewingCondition::desktop()
            .with_browser_dppx(1.0)
            .with_image_intrinsic_dppx(2.0);
        assert!((v.srcset_ratio() - 2.0).abs() < 0.001);
    }

    #[test]
    fn test_simulation_accurate_undersized() {
        // 1x on 2x display (undersized)
        let v = ViewingCondition::new(40.0)
            .with_browser_dppx(2.0)
            .with_image_intrinsic_dppx(1.0);

        let params = v.simulation_params(1000, 800, SimulationMode::Accurate);

        // Should upscale to simulate browser behavior
        assert!((params.scale_factor - 0.5).abs() < 0.001);
        assert_eq!(params.target_width, 500);
        assert_eq!(params.target_height, 400);
        assert!(params.requires_upscale); // ratio < 1 means browser upscales
        assert!(!params.requires_downscale);
    }

    #[test]
    fn test_simulation_accurate_oversized() {
        // 2x on 1x display (oversized)
        let v = ViewingCondition::new(40.0)
            .with_browser_dppx(1.0)
            .with_image_intrinsic_dppx(2.0);

        let params = v.simulation_params(1000, 800, SimulationMode::Accurate);

        // Should downscale
        assert!((params.scale_factor - 2.0).abs() < 0.001);
        assert_eq!(params.target_width, 2000);
        assert_eq!(params.target_height, 1600);
        assert!(!params.requires_upscale);
        assert!(params.requires_downscale);
    }

    #[test]
    fn test_simulation_downsample_only_undersized() {
        // 1x on 2x display (undersized) with downsample-only mode
        let v = ViewingCondition::new(40.0)
            .with_browser_dppx(2.0)
            .with_image_intrinsic_dppx(1.0);

        let params = v.simulation_params(1000, 800, SimulationMode::DownsampleOnly);

        // Should NOT upscale, keep original size
        assert!((params.scale_factor - 1.0).abs() < 0.001);
        assert_eq!(params.target_width, 1000);
        assert_eq!(params.target_height, 800);
        assert!(!params.requires_upscale);
        assert!(!params.requires_downscale);

        // PPD should be adjusted to compensate (reduced)
        assert!((params.adjusted_ppd - 20.0).abs() < 0.1); // 40 * 0.5 = 20
    }

    #[test]
    fn test_simulation_downsample_only_oversized() {
        // 2x on 1x display (oversized) - should still downscale
        let v = ViewingCondition::new(40.0)
            .with_browser_dppx(1.0)
            .with_image_intrinsic_dppx(2.0);

        let params = v.simulation_params(1000, 800, SimulationMode::DownsampleOnly);

        // Should downscale (oversized images are fine to downscale)
        assert!((params.scale_factor - 2.0).abs() < 0.001);
        assert_eq!(params.target_width, 2000);
        assert_eq!(params.target_height, 1600);
        assert!(!params.requires_upscale);
        assert!(params.requires_downscale);
    }

    #[test]
    fn test_simulation_params_helpers() {
        let params = SimulationParams {
            scale_factor: 0.5,
            target_width: 500,
            target_height: 400,
            adjusted_ppd: 20.0,
            requires_upscale: true,
            requires_downscale: false,
        };

        assert!(params.requires_scaling());
        assert!((params.downscale_only_factor() - 0.5).abs() < 0.001);

        let params2 = SimulationParams {
            scale_factor: 2.0,
            target_width: 2000,
            target_height: 1600,
            adjusted_ppd: 80.0,
            requires_upscale: false,
            requires_downscale: true,
        };

        assert!(params2.requires_scaling());
        assert!((params2.downscale_only_factor() - 1.0).abs() < 0.001);
    }

    #[test]
    fn test_threshold_multiplier() {
        // Reference PPD = 40
        let params_ref = SimulationParams {
            scale_factor: 1.0,
            target_width: 1000,
            target_height: 800,
            adjusted_ppd: 40.0,
            requires_upscale: false,
            requires_downscale: false,
        };
        assert!((params_ref.threshold_multiplier() - 1.0).abs() < 0.001);

        // Higher PPD = more lenient
        let params_high = SimulationParams {
            scale_factor: 1.0,
            target_width: 1000,
            target_height: 800,
            adjusted_ppd: 80.0,
            requires_upscale: false,
            requires_downscale: false,
        };
        assert!((params_high.threshold_multiplier() - 2.0).abs() < 0.001);

        // Lower PPD = stricter
        let params_low = SimulationParams {
            scale_factor: 1.0,
            target_width: 1000,
            target_height: 800,
            adjusted_ppd: 20.0,
            requires_upscale: false,
            requires_downscale: false,
        };
        assert!((params_low.threshold_multiplier() - 0.5).abs() < 0.001);
    }

    #[test]
    fn test_adjust_dssim_threshold() {
        let base_threshold = 0.0003; // imperceptible at reference

        // At reference PPD, threshold unchanged
        let params_ref = SimulationParams {
            scale_factor: 1.0,
            target_width: 1000,
            target_height: 800,
            adjusted_ppd: 40.0,
            requires_upscale: false,
            requires_downscale: false,
        };
        assert!((params_ref.adjust_dssim_threshold(base_threshold) - 0.0003).abs() < 0.00001);

        // At higher PPD (laptop), more lenient
        let params_laptop = SimulationParams {
            scale_factor: 1.0,
            target_width: 1000,
            target_height: 800,
            adjusted_ppd: 70.0,
            requires_upscale: false,
            requires_downscale: false,
        };
        let adjusted = params_laptop.adjust_dssim_threshold(base_threshold);
        assert!(adjusted > 0.0003); // More lenient
        assert!((adjusted - 0.000525).abs() < 0.0001); // 0.0003 * 1.75
    }

    #[test]
    fn test_adjust_ssimulacra2_threshold() {
        let base_threshold = 90.0; // imperceptible at reference

        // At reference PPD, threshold unchanged
        let params_ref = SimulationParams {
            scale_factor: 1.0,
            target_width: 1000,
            target_height: 800,
            adjusted_ppd: 40.0,
            requires_upscale: false,
            requires_downscale: false,
        };
        assert!((params_ref.adjust_ssimulacra2_threshold(base_threshold) - 90.0).abs() < 0.1);

        // At higher PPD, can accept lower scores
        let params_high = SimulationParams {
            scale_factor: 1.0,
            target_width: 1000,
            target_height: 800,
            adjusted_ppd: 80.0,
            requires_upscale: false,
            requires_downscale: false,
        };
        let adjusted = params_high.adjust_ssimulacra2_threshold(base_threshold);
        assert!(adjusted < 90.0); // Can accept lower score

        // At lower PPD, need higher scores
        let params_low = SimulationParams {
            scale_factor: 1.0,
            target_width: 1000,
            target_height: 800,
            adjusted_ppd: 20.0,
            requires_upscale: false,
            requires_downscale: false,
        };
        let adjusted = params_low.adjust_ssimulacra2_threshold(base_threshold);
        assert!(adjusted > 90.0); // Need higher score
    }

    #[test]
    fn test_metric_acceptable() {
        let params = SimulationParams {
            scale_factor: 1.0,
            target_width: 1000,
            target_height: 800,
            adjusted_ppd: 70.0, // laptop, more lenient
            requires_upscale: false,
            requires_downscale: false,
        };

        // DSSIM: 0.0004 would fail at reference (40 PPD) but pass at 70 PPD
        // Threshold at 70 PPD = 0.0003 * 1.75 = 0.000525
        assert!(params.dssim_acceptable(0.0004, 0.0003));
        assert!(!params.dssim_acceptable(0.0006, 0.0003));

        // Butteraugli: 1.5 would fail at reference but pass at 70 PPD
        // Threshold at 70 PPD = 1.0 * 1.75 = 1.75
        assert!(params.butteraugli_acceptable(1.5, 1.0));

        // SSIMULACRA2: at 70 PPD (multiplier 1.75), threshold is ~85.7
        // So 86 passes but 85 fails
        assert!(params.ssimulacra2_acceptable(86.0, 90.0));
        assert!(!params.ssimulacra2_acceptable(84.0, 90.0));
    }
}