#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GammaTarget {
Gamma22,
Gamma24,
Gamma26,
Srgb,
LinRec709,
}
impl GammaTarget {
#[must_use]
pub fn encode(&self, linear: f32) -> f32 {
let v = linear.clamp(0.0, 1.0);
match self {
Self::Gamma22 => v.powf(1.0 / 2.2),
Self::Gamma24 => v.powf(1.0 / 2.4),
Self::Gamma26 => v.powf(1.0 / 2.6),
Self::Srgb => {
if v <= 0.003_130_8 {
v * 12.92
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
}
}
Self::LinRec709 => v,
}
}
#[must_use]
pub fn nominal_gamma(&self) -> f32 {
match self {
Self::Gamma22 => 2.2,
Self::Gamma24 => 2.4,
Self::Gamma26 => 2.6,
Self::Srgb => 2.2, Self::LinRec709 => 1.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum WhitePointTarget {
D50,
D55,
D65,
D75,
Native,
}
impl WhitePointTarget {
#[must_use]
pub fn xy(&self) -> (f32, f32) {
match self {
Self::D50 => (0.3457, 0.3585),
Self::D55 => (0.3324, 0.3474),
Self::D65 => (0.3127, 0.3290),
Self::D75 => (0.2990, 0.3149),
Self::Native => (0.3127, 0.3290), }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LuminanceTarget {
pub min_nits: f32,
pub max_nits: f32,
}
impl LuminanceTarget {
#[must_use]
pub fn sdr() -> Self {
Self {
min_nits: 0.1,
max_nits: 100.0,
}
}
#[must_use]
pub fn hdr400() -> Self {
Self {
min_nits: 0.02,
max_nits: 400.0,
}
}
#[must_use]
pub fn hdr1000() -> Self {
Self {
min_nits: 0.005,
max_nits: 1000.0,
}
}
}
#[derive(Debug, Clone)]
pub struct DisplayCalibrationSpec {
pub gamma: GammaTarget,
pub white_point: WhitePointTarget,
pub luminance: LuminanceTarget,
pub color_space: String,
}
impl DisplayCalibrationSpec {
#[must_use]
pub fn srgb_sdr() -> Self {
Self {
gamma: GammaTarget::Srgb,
white_point: WhitePointTarget::D65,
luminance: LuminanceTarget::sdr(),
color_space: "sRGB".to_string(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct MeasuredDisplayParams {
pub actual_gamma: f32,
pub actual_white_xy: (f32, f32),
pub actual_max_nits: f32,
pub actual_min_nits: f32,
}
#[derive(Debug, Clone, Copy)]
pub struct CalibrationResult {
pub error_gamma: f32,
pub error_white_de: f32,
pub error_max_luminance_pct: f32,
pub passes: bool,
}
const TOLERANCE_GAMMA: f32 = 0.1;
const TOLERANCE_WHITE_DE: f32 = 3.0;
const TOLERANCE_LUMINANCE_PCT: f32 = 10.0;
impl CalibrationResult {
#[must_use]
pub fn evaluate(spec: &DisplayCalibrationSpec, measured: &MeasuredDisplayParams) -> Self {
let target_gamma = spec.gamma.nominal_gamma();
let error_gamma = (target_gamma - measured.actual_gamma).abs();
let (tx, ty) = spec.white_point.xy();
let (mx, my) = measured.actual_white_xy;
let error_white_de = cie_uv_delta_e(tx, ty, mx, my);
let error_max_luminance_pct = if spec.luminance.max_nits > 0.0 {
((measured.actual_max_nits - spec.luminance.max_nits) / spec.luminance.max_nits * 100.0)
.abs()
} else {
0.0
};
let passes = error_gamma <= TOLERANCE_GAMMA
&& error_white_de <= TOLERANCE_WHITE_DE
&& error_max_luminance_pct <= TOLERANCE_LUMINANCE_PCT;
Self {
error_gamma,
error_white_de,
error_max_luminance_pct,
passes,
}
}
}
fn cie_uv_delta_e(x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
let u1 = 4.0 * x1 / (-2.0 * x1 + 12.0 * y1 + 3.0);
let v1 = 9.0 * y1 / (-2.0 * x1 + 12.0 * y1 + 3.0);
let u2 = 4.0 * x2 / (-2.0 * x2 + 12.0 * y2 + 3.0);
let v2 = 9.0 * y2 / (-2.0 * x2 + 12.0 * y2 + 3.0);
let du = u1 - u2;
let dv = v1 - v2;
((du * du + dv * dv).sqrt()) * 100.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gamma22_encode_midtone() {
let encoded = GammaTarget::Gamma22.encode(0.5);
let expected = 0.5f32.powf(1.0 / 2.2);
assert!((encoded - expected).abs() < 1e-6);
}
#[test]
fn test_gamma24_encode_zero_one() {
assert!((GammaTarget::Gamma24.encode(0.0) - 0.0).abs() < 1e-6);
assert!((GammaTarget::Gamma24.encode(1.0) - 1.0).abs() < 1e-6);
}
#[test]
fn test_srgb_encode_low() {
let v = 0.001f32;
let encoded = GammaTarget::Srgb.encode(v);
assert!((encoded - v * 12.92).abs() < 1e-6);
}
#[test]
fn test_srgb_encode_high() {
let v = 0.5f32;
let encoded = GammaTarget::Srgb.encode(v);
let expected = 1.055 * v.powf(1.0 / 2.4) - 0.055;
assert!((encoded - expected).abs() < 1e-6);
}
#[test]
fn test_linear_rec709_is_identity() {
assert!((GammaTarget::LinRec709.encode(0.75) - 0.75).abs() < 1e-6);
}
#[test]
fn test_white_point_d65_xy() {
let (x, y) = WhitePointTarget::D65.xy();
assert!((x - 0.3127).abs() < 1e-4);
assert!((y - 0.3290).abs() < 1e-4);
}
#[test]
fn test_luminance_target_sdr() {
let t = LuminanceTarget::sdr();
assert!((t.max_nits - 100.0).abs() < 1e-6);
assert!(t.min_nits < 1.0);
}
#[test]
fn test_luminance_target_hdr1000() {
let t = LuminanceTarget::hdr1000();
assert!((t.max_nits - 1000.0).abs() < 1e-6);
}
#[test]
fn test_calibration_result_pass() {
let spec = DisplayCalibrationSpec::srgb_sdr();
let measured = MeasuredDisplayParams {
actual_gamma: 2.2, actual_white_xy: (0.3127, 0.3290),
actual_max_nits: 100.0,
actual_min_nits: 0.1,
};
let result = CalibrationResult::evaluate(&spec, &measured);
assert!(
result.passes,
"Expected pass but gamma_err={} white_de={} lum_err={}",
result.error_gamma, result.error_white_de, result.error_max_luminance_pct
);
}
#[test]
fn test_calibration_result_fail_gamma() {
let spec = DisplayCalibrationSpec::srgb_sdr();
let measured = MeasuredDisplayParams {
actual_gamma: 1.8, actual_white_xy: (0.3127, 0.3290),
actual_max_nits: 100.0,
actual_min_nits: 0.1,
};
let result = CalibrationResult::evaluate(&spec, &measured);
assert!(!result.passes);
assert!(result.error_gamma > TOLERANCE_GAMMA);
}
#[test]
fn test_calibration_result_fail_luminance() {
let spec = DisplayCalibrationSpec::srgb_sdr();
let measured = MeasuredDisplayParams {
actual_gamma: 2.2,
actual_white_xy: (0.3127, 0.3290),
actual_max_nits: 200.0, actual_min_nits: 0.1,
};
let result = CalibrationResult::evaluate(&spec, &measured);
assert!(!result.passes);
}
#[test]
fn test_gamma_nominal_values() {
assert!((GammaTarget::Gamma22.nominal_gamma() - 2.2).abs() < 1e-6);
assert!((GammaTarget::Gamma26.nominal_gamma() - 2.6).abs() < 1e-6);
assert!((GammaTarget::LinRec709.nominal_gamma() - 1.0).abs() < 1e-6);
}
#[test]
fn test_hdr400_preset() {
let t = LuminanceTarget::hdr400();
assert!((t.max_nits - 400.0).abs() < 1e-6);
}
}