perceive-cvd 0.1.0

Colorblind simulation using Brettel and Viénot models
Documentation
/// Reference value tests for CVD simulation against DaltonLens-derived Brettel
/// matrices and Vienot 1999 matrices.
///
/// Expected sRGB8 values were generated by applying the same matrices to linear
/// RGB in Python and converting back through the sRGB transfer function.
/// Tolerance: +/- 2 per channel to account for floating-point rounding.
use perceive_color::Color;
use perceive_cvd::types::{CvdType, Severity};
use perceive_cvd::{simulate, simulate_fast};

/// Assert that simulated sRGB8 output matches expected values within tolerance.
fn assert_srgb8_close(actual: Color, expected: (u8, u8, u8), tolerance: i16, label: &str) {
    let (ar, ag, ab) = actual.to_srgb8();
    let (er, eg, eb) = expected;
    let dr = (i16::from(ar) - i16::from(er)).abs();
    let dg = (i16::from(ag) - i16::from(eg)).abs();
    let db = (i16::from(ab) - i16::from(eb)).abs();
    assert!(
        dr <= tolerance && dg <= tolerance && db <= tolerance,
        "{label}: expected ({er}, {eg}, {eb}), got ({ar}, {ag}, {ab}), \
         delta=({dr}, {dg}, {db}), tolerance={tolerance}"
    );
}

// ---------------------------------------------------------------------------
// Brettel protan reference values
// ---------------------------------------------------------------------------

#[test]
fn brettel_protan_red() {
    let sim = simulate(
        Color::from_srgb8(255, 0, 0),
        CvdType::Protan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (108, 92, 12), 2, "brettel protan red");
}

#[test]
fn brettel_protan_green() {
    let sim = simulate(
        Color::from_srgb8(0, 255, 0),
        CvdType::Protan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (255, 237, 0), 2, "brettel protan green");
}

#[test]
fn brettel_protan_blue() {
    let sim = simulate(
        Color::from_srgb8(0, 0, 255),
        CvdType::Protan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (0, 56, 255), 2, "brettel protan blue");
}

#[test]
fn brettel_protan_orange() {
    let sim = simulate(
        Color::from_srgb8(255, 128, 0),
        CvdType::Protan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (171, 147, 9), 2, "brettel protan orange");
}

#[test]
fn brettel_protan_skin() {
    let sim = simulate(
        Color::from_srgb8(200, 150, 120),
        CvdType::Protan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (167, 155, 120), 2, "brettel protan skin");
}

// ---------------------------------------------------------------------------
// Brettel deutan reference values
// ---------------------------------------------------------------------------

#[test]
fn brettel_deutan_red() {
    let sim = simulate(
        Color::from_srgb8(255, 0, 0),
        CvdType::Deutan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (164, 139, 0), 2, "brettel deutan red");
}

#[test]
fn brettel_deutan_green() {
    let sim = simulate(
        Color::from_srgb8(0, 255, 0),
        CvdType::Deutan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (241, 209, 46), 2, "brettel deutan green");
}

#[test]
fn brettel_deutan_blue() {
    let sim = simulate(
        Color::from_srgb8(0, 0, 255),
        CvdType::Deutan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (0, 87, 254), 2, "brettel deutan blue");
}

#[test]
fn brettel_deutan_orange() {
    let sim = simulate(
        Color::from_srgb8(255, 128, 0),
        CvdType::Deutan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (198, 169, 0), 2, "brettel deutan orange");
}

#[test]
fn brettel_deutan_skin() {
    let sim = simulate(
        Color::from_srgb8(200, 150, 120),
        CvdType::Deutan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (176, 162, 119), 2, "brettel deutan skin");
}

// ---------------------------------------------------------------------------
// Brettel tritan reference values
// ---------------------------------------------------------------------------

#[test]
fn brettel_tritan_red() {
    let sim = simulate(
        Color::from_srgb8(255, 0, 0),
        CvdType::Tritan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (255, 0, 78), 2, "brettel tritan red");
}

#[test]
fn brettel_tritan_green() {
    let sim = simulate(
        Color::from_srgb8(0, 255, 0),
        CvdType::Tritan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (121, 233, 255), 2, "brettel tritan green");
}

#[test]
fn brettel_tritan_blue() {
    let sim = simulate(
        Color::from_srgb8(0, 0, 255),
        CvdType::Tritan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (0, 98, 136), 2, "brettel tritan blue");
}

#[test]
fn brettel_tritan_yellow() {
    let sim = simulate(
        Color::from_srgb8(255, 255, 0),
        CvdType::Tritan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (255, 238, 241), 2, "brettel tritan yellow");
}

#[test]
fn brettel_tritan_skin() {
    let sim = simulate(
        Color::from_srgb8(200, 150, 120),
        CvdType::Tritan,
        Severity::FULL,
    );
    assert_srgb8_close(sim, (203, 145, 151), 2, "brettel tritan skin");
}

// ---------------------------------------------------------------------------
// Vienot protan reference values
// ---------------------------------------------------------------------------

#[test]
fn vienot_protan_red() {
    let sim = simulate_fast(Color::from_srgb8(255, 0, 0), CvdType::Protan);
    assert_srgb8_close(sim, (94, 94, 13), 2, "vienot protan red");
}

#[test]
fn vienot_protan_green() {
    let sim = simulate_fast(Color::from_srgb8(0, 255, 0), CvdType::Protan);
    assert_srgb8_close(sim, (242, 242, 0), 2, "vienot protan green");
}

#[test]
fn vienot_protan_blue() {
    let sim = simulate_fast(Color::from_srgb8(0, 0, 255), CvdType::Protan);
    assert_srgb8_close(sim, (0, 0, 255), 2, "vienot protan blue");
}

#[test]
fn vienot_protan_skin() {
    let sim = simulate_fast(Color::from_srgb8(200, 150, 120), CvdType::Protan);
    assert_srgb8_close(sim, (157, 157, 120), 2, "vienot protan skin");
}

// ---------------------------------------------------------------------------
// Vienot deutan reference values
// ---------------------------------------------------------------------------

#[test]
fn vienot_deutan_red() {
    let sim = simulate_fast(Color::from_srgb8(255, 0, 0), CvdType::Deutan);
    assert_srgb8_close(sim, (147, 147, 0), 2, "vienot deutan red");
}

#[test]
fn vienot_deutan_green() {
    let sim = simulate_fast(Color::from_srgb8(0, 255, 0), CvdType::Deutan);
    assert_srgb8_close(sim, (219, 219, 41), 2, "vienot deutan green");
}

#[test]
fn vienot_deutan_skin() {
    let sim = simulate_fast(Color::from_srgb8(200, 150, 120), CvdType::Deutan);
    assert_srgb8_close(sim, (167, 167, 118), 2, "vienot deutan skin");
}

// ---------------------------------------------------------------------------
// Vienot tritan reference values
// ---------------------------------------------------------------------------

#[test]
fn vienot_tritan_green() {
    let sim = simulate_fast(Color::from_srgb8(0, 255, 0), CvdType::Tritan);
    assert_srgb8_close(sim, (106, 239, 239), 2, "vienot tritan green");
}

#[test]
fn vienot_tritan_blue() {
    let sim = simulate_fast(Color::from_srgb8(0, 0, 255), CvdType::Tritan);
    assert_srgb8_close(sim, (0, 105, 105), 2, "vienot tritan blue");
}

#[test]
fn vienot_tritan_skin() {
    let sim = simulate_fast(Color::from_srgb8(200, 150, 120), CvdType::Tritan);
    assert_srgb8_close(sim, (203, 146, 146), 2, "vienot tritan skin");
}

// ---------------------------------------------------------------------------
// Cross-model consistency: Brettel and Vienot agree within reasonable tolerance
// ---------------------------------------------------------------------------

#[test]
fn cross_model_protan_agreement() {
    let colors = [
        Color::from_srgb8(255, 128, 0),
        Color::from_srgb8(200, 150, 120),
        Color::from_srgb8(100, 200, 50),
    ];
    for c in &colors {
        let brettel = simulate(*c, CvdType::Protan, Severity::FULL);
        let vienot = simulate_fast(*c, CvdType::Protan);
        let (br, bg, bb) = brettel.to_srgb8();
        let (vr, vg, vb) = vienot.to_srgb8();
        // Models use different approaches; allow up to 30 sRGB8 units difference
        let dr = (i16::from(br) - i16::from(vr)).abs();
        let dg = (i16::from(bg) - i16::from(vg)).abs();
        let db = (i16::from(bb) - i16::from(vb)).abs();
        assert!(
            dr <= 30 && dg <= 30 && db <= 30,
            "protan cross-model divergence too large: \
             brettel=({br},{bg},{bb}), vienot=({vr},{vg},{vb})"
        );
    }
}

#[test]
fn cross_model_deutan_agreement() {
    let colors = [
        Color::from_srgb8(255, 128, 0),
        Color::from_srgb8(200, 150, 120),
        Color::from_srgb8(100, 200, 50),
    ];
    for c in &colors {
        let brettel = simulate(*c, CvdType::Deutan, Severity::FULL);
        let vienot = simulate_fast(*c, CvdType::Deutan);
        let (br, bg, bb) = brettel.to_srgb8();
        let (vr, vg, vb) = vienot.to_srgb8();
        let dr = (i16::from(br) - i16::from(vr)).abs();
        let dg = (i16::from(bg) - i16::from(vg)).abs();
        let db = (i16::from(bb) - i16::from(vb)).abs();
        assert!(
            dr <= 30 && dg <= 30 && db <= 30,
            "deutan cross-model divergence too large: \
             brettel=({br},{bg},{bb}), vienot=({vr},{vg},{vb})"
        );
    }
}

#[test]
fn cross_model_tritan_agreement() {
    let colors = [
        Color::from_srgb8(200, 150, 120),
        Color::from_srgb8(100, 200, 50),
    ];
    for c in &colors {
        let brettel = simulate(*c, CvdType::Tritan, Severity::FULL);
        let vienot = simulate_fast(*c, CvdType::Tritan);
        let (br, bg, bb) = brettel.to_srgb8();
        let (vr, vg, vb) = vienot.to_srgb8();
        let dr = (i16::from(br) - i16::from(vr)).abs();
        let dg = (i16::from(bg) - i16::from(vg)).abs();
        let db = (i16::from(bb) - i16::from(vb)).abs();
        assert!(
            dr <= 30 && dg <= 30 && db <= 30,
            "tritan cross-model divergence too large: \
             brettel=({br},{bg},{bb}), vienot=({vr},{vg},{vb})"
        );
    }
}

// ---------------------------------------------------------------------------
// Half-plane differentiation: colors on opposite sides of the separator
// produce measurably different results through A vs B matrices.
// ---------------------------------------------------------------------------

#[test]
fn brettel_protan_half_plane_differentiation() {
    // Separator: [0.00048, 0.00393, -0.00441]
    // Pure green has positive dot product (plane A).
    // Pure blue has negative dot product (plane B).
    let green = Color::from_srgb8(0, 255, 0);
    let blue = Color::from_srgb8(0, 0, 255);
    let sim_green = simulate(green, CvdType::Protan, Severity::FULL);
    let sim_blue = simulate(blue, CvdType::Protan, Severity::FULL);
    // They should produce visibly different outputs (not just scaled versions)
    let (gr, gg, gb) = sim_green.to_srgb8();
    let (br, bg, bb) = sim_blue.to_srgb8();
    let diff = (i16::from(gr) - i16::from(br)).abs()
        + (i16::from(gg) - i16::from(bg)).abs()
        + (i16::from(gb) - i16::from(bb)).abs();
    assert!(
        diff > 100,
        "half-plane differentiation too small: green=({gr},{gg},{gb}), blue=({br},{bg},{bb})"
    );
}

#[test]
fn brettel_tritan_half_plane_differentiation() {
    // Separator: [0.03901, -0.02788, -0.01113]
    // Red has positive dot product (plane A).
    // Green has negative dot product (plane B).
    let red = Color::from_srgb8(255, 0, 0);
    let green = Color::from_srgb8(0, 255, 0);
    let sim_red = simulate(red, CvdType::Tritan, Severity::FULL);
    let sim_green = simulate(green, CvdType::Tritan, Severity::FULL);
    let (rr, rg, rb) = sim_red.to_srgb8();
    let (gr, gg, gb) = sim_green.to_srgb8();
    let diff = (i16::from(rr) - i16::from(gr)).abs()
        + (i16::from(rg) - i16::from(gg)).abs()
        + (i16::from(rb) - i16::from(gb)).abs();
    assert!(
        diff > 100,
        "half-plane differentiation too small: red=({rr},{rg},{rb}), green=({gr},{gg},{gb})"
    );
}

#[test]
fn brettel_deutan_half_plane_differentiation() {
    // Separator: [-0.00281, -0.00611, 0.00892]
    // Blue has positive dot product (plane A).
    // Red has negative dot product (plane B).
    let blue = Color::from_srgb8(0, 0, 255);
    let red = Color::from_srgb8(255, 0, 0);
    let sim_blue = simulate(blue, CvdType::Deutan, Severity::FULL);
    let sim_red = simulate(red, CvdType::Deutan, Severity::FULL);
    let (br, bg, bb) = sim_blue.to_srgb8();
    let (rr, rg, rb) = sim_red.to_srgb8();
    let diff = (i16::from(br) - i16::from(rr)).abs()
        + (i16::from(bg) - i16::from(rg)).abs()
        + (i16::from(bb) - i16::from(rb)).abs();
    assert!(
        diff > 100,
        "half-plane differentiation too small: blue=({br},{bg},{bb}), red=({rr},{rg},{rb})"
    );
}