use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SimulationMode {
#[default]
Accurate,
DownsampleOnly,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewingCondition {
pub acuity_ppd: f64,
pub browser_dppx: Option<f64>,
pub image_intrinsic_dppx: Option<f64>,
pub ppd: Option<f64>,
}
impl ViewingCondition {
#[must_use]
pub fn new(acuity_ppd: f64) -> Self {
Self {
acuity_ppd,
browser_dppx: None,
image_intrinsic_dppx: None,
ppd: None,
}
}
#[must_use]
pub fn desktop() -> Self {
Self::new(40.0)
}
#[must_use]
pub fn laptop() -> Self {
Self::new(60.0)
}
#[must_use]
pub fn smartphone() -> Self {
Self::new(90.0)
}
#[must_use]
pub fn with_browser_dppx(mut self, dppx: f64) -> Self {
self.browser_dppx = Some(dppx);
self
}
#[must_use]
pub fn with_image_intrinsic_dppx(mut self, dppx: f64) -> Self {
self.image_intrinsic_dppx = Some(dppx);
self
}
#[must_use]
pub fn with_ppd_override(mut self, ppd: f64) -> Self {
self.ppd = Some(ppd);
self
}
#[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);
self.acuity_ppd * (intrinsic / browser)
}
#[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
}
#[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 => {
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 {
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 {
let adjusted_ppd = base_ppd * ratio;
SimulationParams {
scale_factor: 1.0,
target_width: image_width,
target_height: image_height,
adjusted_ppd,
requires_upscale: false, requires_downscale: false,
}
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SimulationParams {
pub scale_factor: f64,
pub target_width: u32,
pub target_height: u32,
pub adjusted_ppd: f64,
pub requires_upscale: bool,
pub requires_downscale: bool,
}
pub const REFERENCE_PPD: f64 = 40.0;
impl SimulationParams {
#[must_use]
pub fn requires_scaling(&self) -> bool {
self.requires_upscale || self.requires_downscale
}
#[must_use]
pub fn downscale_only_factor(&self) -> f64 {
self.scale_factor.min(1.0)
}
#[must_use]
pub fn threshold_multiplier(&self) -> f64 {
self.adjusted_ppd / REFERENCE_PPD
}
#[must_use]
pub fn adjust_dssim_threshold(&self, base_threshold: f64) -> f64 {
base_threshold * self.threshold_multiplier()
}
#[must_use]
pub fn adjust_butteraugli_threshold(&self, base_threshold: f64) -> f64 {
base_threshold * self.threshold_multiplier()
}
#[must_use]
pub fn adjust_ssimulacra2_threshold(&self, base_threshold: f64) -> f64 {
let multiplier = self.threshold_multiplier();
if multiplier >= 1.0 {
base_threshold - (100.0 - base_threshold) * (1.0 - 1.0 / multiplier)
} else {
base_threshold + (100.0 - base_threshold) * (1.0 / multiplier - 1.0)
}
.clamp(0.0, 100.0)
}
#[must_use]
pub fn dssim_acceptable(&self, dssim: f64, base_threshold: f64) -> bool {
dssim < self.adjust_dssim_threshold(base_threshold)
}
#[must_use]
pub fn butteraugli_acceptable(&self, butteraugli: f64, base_threshold: f64) -> bool {
butteraugli < self.adjust_butteraugli_threshold(base_threshold)
}
#[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()
}
}
pub mod presets {
use super::ViewingCondition;
#[must_use]
pub fn native_desktop() -> ViewingCondition {
ViewingCondition::new(40.0)
.with_browser_dppx(1.0)
.with_image_intrinsic_dppx(1.0)
}
#[must_use]
pub fn native_laptop() -> ViewingCondition {
ViewingCondition::new(70.0)
.with_browser_dppx(2.0)
.with_image_intrinsic_dppx(2.0)
}
#[must_use]
pub fn native_phone() -> ViewingCondition {
ViewingCondition::new(95.0)
.with_browser_dppx(3.0)
.with_image_intrinsic_dppx(3.0)
}
#[must_use]
pub fn srcset_1x_on_phone() -> ViewingCondition {
ViewingCondition::new(95.0)
.with_browser_dppx(3.0)
.with_image_intrinsic_dppx(1.0)
}
#[must_use]
pub fn srcset_1x_on_laptop() -> ViewingCondition {
ViewingCondition::new(70.0)
.with_browser_dppx(2.0)
.with_image_intrinsic_dppx(1.0)
}
#[must_use]
pub fn srcset_2x_on_phone() -> ViewingCondition {
ViewingCondition::new(95.0)
.with_browser_dppx(3.0)
.with_image_intrinsic_dppx(2.0)
}
#[must_use]
pub fn srcset_2x_on_desktop() -> ViewingCondition {
ViewingCondition::new(40.0)
.with_browser_dppx(1.0)
.with_image_intrinsic_dppx(2.0)
}
#[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)
}
#[must_use]
pub fn srcset_3x_on_phone() -> ViewingCondition {
native_phone()
}
#[must_use]
pub fn all() -> Vec<ViewingCondition> {
vec![
srcset_1x_on_phone(), srcset_1x_on_laptop(), native_desktop(), srcset_2x_on_phone(), native_laptop(), srcset_2x_on_desktop(), srcset_2x_on_laptop_1_5x(), native_phone(), ]
}
#[must_use]
pub fn key() -> Vec<ViewingCondition> {
vec![native_desktop(), native_laptop(), native_phone()]
}
#[must_use]
pub fn baseline() -> ViewingCondition {
native_laptop()
}
#[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() {
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() {
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() {
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() {
let v = presets::srcset_1x_on_phone();
assert!(v.effective_ppd() < 35.0);
assert!(v.effective_ppd() > 30.0);
let v = presets::srcset_1x_on_laptop();
assert!((v.effective_ppd() - 35.0).abs() < 0.1);
}
#[test]
fn test_presets_oversized() {
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);
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() {
let v = ViewingCondition::desktop();
assert!((v.srcset_ratio() - 1.0).abs() < 0.001);
let v = ViewingCondition::desktop()
.with_browser_dppx(2.0)
.with_image_intrinsic_dppx(1.0);
assert!((v.srcset_ratio() - 0.5).abs() < 0.001);
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() {
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);
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); assert!(!params.requires_downscale);
}
#[test]
fn test_simulation_accurate_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);
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() {
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);
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);
assert!((params.adjusted_ppd - 20.0).abs() < 0.1); }
#[test]
fn test_simulation_downsample_only_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::DownsampleOnly);
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() {
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);
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);
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;
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);
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); assert!((adjusted - 0.000525).abs() < 0.0001); }
#[test]
fn test_adjust_ssimulacra2_threshold() {
let base_threshold = 90.0;
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);
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);
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); }
#[test]
fn test_metric_acceptable() {
let params = SimulationParams {
scale_factor: 1.0,
target_width: 1000,
target_height: 800,
adjusted_ppd: 70.0, requires_upscale: false,
requires_downscale: false,
};
assert!(params.dssim_acceptable(0.0004, 0.0003));
assert!(!params.dssim_acceptable(0.0006, 0.0003));
assert!(params.butteraugli_acceptable(1.5, 1.0));
assert!(params.ssimulacra2_acceptable(86.0, 90.0));
assert!(!params.ssimulacra2_acceptable(84.0, 90.0));
}
}