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}