ph-color 0.1.1

Fixed-point no_std color math for embedded targets: conversion, transfer functions, matrices, gain, and interpolation
Documentation
//! Shipped sRGB EOTF/OETF tables. Host `ph-color-bake` derived the knots;
//! this crate only applies them.

use crate::color::Color;
use crate::encoding::{Encoded, Linear};
use crate::fixed::Q0_16;
use crate::interp::InterpLut;
use crate::space::Srgb;

/// Flash sRGB OETF table. `encode` accepts only [`Color<Srgb, Linear>`].
#[derive(Clone, Copy)]
pub struct SrgbOetf {
    lut: InterpLut<257>,
}

impl SrgbOetf {
    /// Wrap a bake-emitted 257-knot table.
    #[must_use]
    pub const fn from_knots(knots: [Q0_16; 257], max_err_lsb: u16) -> Self {
        Self {
            lut: InterpLut::from_knots(knots, max_err_lsb),
        }
    }

    /// Bake-supplied maximum absolute error in UQ0.16 LSBs.
    #[must_use]
    pub const fn max_err_lsb(&self) -> u16 {
        self.lut.max_err_lsb()
    }

    /// Interpolate one [`Q0_16`] channel.
    #[must_use]
    pub const fn lookup(&self, x: Q0_16) -> Q0_16 {
        self.lut.lookup(x)
    }

    /// Encode linear sRGB with this table.
    #[must_use]
    pub const fn encode(&self, color: Color<Srgb, Linear>) -> Color<Srgb, Encoded> {
        self.lut.encode(color)
    }

    /// Encode linear sRGB with `f32` LUT interpolation. sRGB only.
    ///
    /// Oklab `f32` is out of scope.
    #[cfg(feature = "f32")]
    #[must_use]
    pub fn encode_f32(
        &self,
        color: crate::ColorF32<Srgb, Linear>,
    ) -> crate::ColorF32<Srgb, Encoded> {
        self.lut.encode_f32(color)
    }
}

/// Flash sRGB EOTF table. `decode` accepts only [`Color<Srgb, Encoded>`].
#[derive(Clone, Copy)]
pub struct SrgbEotf {
    lut: InterpLut<257>,
}

impl SrgbEotf {
    /// Wrap a bake-emitted 257-knot table.
    #[must_use]
    pub const fn from_knots(knots: [Q0_16; 257], max_err_lsb: u16) -> Self {
        Self {
            lut: InterpLut::from_knots(knots, max_err_lsb),
        }
    }

    /// Bake-supplied maximum absolute error in UQ0.16 LSBs.
    #[must_use]
    pub const fn max_err_lsb(&self) -> u16 {
        self.lut.max_err_lsb()
    }

    /// Interpolate one [`Q0_16`] channel.
    #[must_use]
    pub const fn lookup(&self, x: Q0_16) -> Q0_16 {
        self.lut.lookup(x)
    }

    /// Decode encoded sRGB with this table.
    #[must_use]
    pub const fn decode(&self, color: Color<Srgb, Encoded>) -> Color<Srgb, Linear> {
        self.lut.decode(color)
    }

    /// Decode encoded sRGB with `f32` LUT interpolation. sRGB only.
    ///
    /// Oklab `f32` is out of scope.
    #[cfg(feature = "f32")]
    #[must_use]
    pub fn decode_f32(
        &self,
        color: crate::ColorF32<Srgb, Encoded>,
    ) -> crate::ColorF32<Srgb, Linear> {
        self.lut.decode_f32(color)
    }
}

/// Bake-measured maximum absolute OETF error in UQ0.16 LSBs.
pub const SRGB_OETF_MAX_ERR_LSB: u16 = 115;

/// sRGB OETF (`Linear<Srgb>` → `Encoded<Srgb>`), 257 knots in flash.
pub const SRGB_OETF: SrgbOetf = SrgbOetf::from_knots(
    Q0_16::array_from_raw([
        0, 3255, 5552, 7237, 8618, 9809, 10867, 11827, 12710, 13531, 14300, 15025, 15713, 16368,
        16995, 17595, 18173, 18730, 19269, 19790, 20295, 20786, 21263, 21728, 22181, 22624, 23056,
        23478, 23892, 24297, 24694, 25083, 25465, 25840, 26209, 26571, 26927, 27278, 27623, 27963,
        28298, 28627, 28953, 29273, 29590, 29902, 30210, 30515, 30815, 31112, 31406, 31696, 31983,
        32266, 32547, 32824, 33099, 33370, 33639, 33906, 34169, 34430, 34689, 34945, 35199, 35450,
        35699, 35947, 36191, 36434, 36675, 36914, 37151, 37385, 37619, 37850, 38079, 38307, 38533,
        38757, 38980, 39201, 39420, 39638, 39854, 40069, 40282, 40494, 40705, 40914, 41122, 41328,
        41533, 41737, 41939, 42141, 42341, 42539, 42737, 42934, 43129, 43323, 43516, 43708, 43899,
        44089, 44277, 44465, 44652, 44837, 45022, 45206, 45388, 45570, 45751, 45931, 46110, 46288,
        46465, 46642, 46817, 46992, 47166, 47339, 47511, 47682, 47853, 48023, 48192, 48360, 48527,
        48694, 48860, 49025, 49190, 49354, 49517, 49679, 49841, 50002, 50162, 50322, 50481, 50639,
        50797, 50954, 51111, 51266, 51422, 51576, 51730, 51884, 52036, 52189, 52340, 52491, 52642,
        52792, 52941, 53090, 53238, 53386, 53533, 53680, 53826, 53972, 54117, 54262, 54406, 54549,
        54693, 54835, 54977, 55119, 55260, 55401, 55541, 55681, 55820, 55959, 56098, 56236, 56373,
        56510, 56647, 56783, 56919, 57054, 57189, 57324, 57458, 57592, 57725, 57858, 57990, 58122,
        58254, 58385, 58516, 58647, 58777, 58907, 59036, 59165, 59294, 59422, 59550, 59678, 59805,
        59932, 60058, 60184, 60310, 60435, 60561, 60685, 60810, 60934, 61058, 61181, 61304, 61427,
        61549, 61671, 61793, 61915, 62036, 62157, 62277, 62398, 62518, 62637, 62757, 62876, 62994,
        63113, 63231, 63349, 63466, 63584, 63701, 63817, 63934, 64050, 64166, 64281, 64397, 64512,
        64626, 64741, 64855, 64969, 65083, 65196, 65309, 65422, 65535,
    ]),
    SRGB_OETF_MAX_ERR_LSB,
);

/// Bake-measured maximum absolute EOTF error in UQ0.16 LSBs.
pub const SRGB_EOTF_MAX_ERR_LSB: u16 = 1;

/// sRGB EOTF (`Encoded<Srgb>` → `Linear<Srgb>`), 257 knots in flash.
pub const SRGB_EOTF: SrgbEotf = SrgbEotf::from_knots(
    Q0_16::array_from_raw([
        0, 20, 40, 59, 79, 99, 119, 139, 159, 178, 198, 218, 240, 263, 286, 312, 338, 365, 394,
        424, 456, 489, 523, 558, 595, 633, 673, 714, 756, 800, 845, 892, 940, 990, 1041, 1094,
        1148, 1204, 1262, 1320, 1381, 1443, 1507, 1572, 1639, 1707, 1778, 1849, 1923, 1998, 2075,
        2154, 2234, 2316, 2400, 2485, 2572, 2661, 2752, 2845, 2939, 3035, 3133, 3233, 3334, 3438,
        3543, 3650, 3759, 3870, 3982, 4097, 4214, 4332, 4452, 4575, 4699, 4825, 4953, 5083, 5215,
        5349, 5485, 5623, 5763, 5906, 6050, 6196, 6344, 6494, 6646, 6800, 6957, 7115, 7276, 7438,
        7603, 7770, 7939, 8110, 8283, 8458, 8636, 8816, 8997, 9181, 9367, 9556, 9746, 9939, 10134,
        10331, 10530, 10732, 10936, 11142, 11350, 11561, 11773, 11988, 12206, 12425, 12647, 12872,
        13098, 13327, 13558, 13791, 14027, 14265, 14506, 14749, 14994, 15241, 15491, 15743, 15998,
        16255, 16514, 16776, 17041, 17307, 17576, 17848, 18122, 18398, 18677, 18958, 19242, 19528,
        19816, 20108, 20401, 20697, 20996, 21297, 21600, 21906, 22215, 22526, 22840, 23156, 23474,
        23796, 24119, 24446, 24775, 25106, 25440, 25777, 26116, 26458, 26802, 27149, 27499, 27851,
        28206, 28563, 28923, 29286, 29651, 30019, 30390, 30763, 31139, 31518, 31899, 32283, 32670,
        33059, 33451, 33846, 34243, 34644, 35046, 35452, 35860, 36271, 36685, 37102, 37521, 37943,
        38368, 38795, 39226, 39659, 40095, 40533, 40975, 41419, 41866, 42316, 42768, 43224, 43682,
        44143, 44607, 45073, 45543, 46015, 46491, 46969, 47450, 47934, 48420, 48910, 49402, 49897,
        50396, 50897, 51401, 51908, 52417, 52930, 53446, 53964, 54486, 55010, 55537, 56067, 56601,
        57137, 57676, 58218, 58763, 59311, 59862, 60415, 60972, 61532, 62095, 62661, 63230, 63801,
        64376, 64954, 65535,
    ]),
    SRGB_EOTF_MAX_ERR_LSB,
);

/// Default-feature flash tables (sRGB OETF + EOTF) must stay ≤ 2 KiB.
/// Oklab `CBRT` is feature-gated and is not part of this budget.
const _: () = {
    let tables = core::mem::size_of::<SrgbOetf>().saturating_add(core::mem::size_of::<SrgbEotf>());
    assert!(tables <= 2048, "default shipped tables exceed 2 KiB");
};

#[cfg(test)]
mod tests {
    use super::*;
    use crate::color::Color;
    use crate::encoding::{Encoded, Linear};
    use crate::space::Srgb;

    #[test]
    fn default_tables_fit_2kib() {
        let n =
            core::mem::size_of_val(&SRGB_OETF).saturating_add(core::mem::size_of_val(&SRGB_EOTF));
        assert!(n <= 2048, "default shipped tables {n} bytes exceed 2 KiB");
    }

    #[test]
    fn max_err_lsb_is_the_bake_companion() {
        assert_eq!(SRGB_OETF.max_err_lsb(), SRGB_OETF_MAX_ERR_LSB);
        assert_eq!(SRGB_EOTF.max_err_lsb(), SRGB_EOTF_MAX_ERR_LSB);
    }

    #[test]
    fn endpoints_match_knots() {
        assert_eq!(SRGB_OETF.lookup(Q0_16::ZERO), Q0_16::ZERO);
        assert_eq!(SRGB_OETF.lookup(Q0_16::ONE), Q0_16::ONE);
        assert_eq!(SRGB_EOTF.lookup(Q0_16::ZERO), Q0_16::ZERO);
        assert_eq!(SRGB_EOTF.lookup(Q0_16::ONE), Q0_16::ONE);
    }

    #[test]
    fn encode_decode_are_typed_to_srgb() {
        let lin = Color::<Srgb, Linear>::new(Q0_16::array_from_raw([0, 32768, 65535]));
        let enc = SRGB_OETF.encode(lin);
        let back = SRGB_EOTF.decode(enc);
        let _: Color<Srgb, Encoded> = enc;
        let _: Color<Srgb, Linear> = back;
        let [r, _, b] = enc.ch;
        assert_eq!(r, Q0_16::ZERO);
        assert_eq!(b, Q0_16::ONE);
    }

    #[test]
    fn oetf_is_increasing() {
        let mut prev = SRGB_OETF.lookup(Q0_16::ZERO);
        for x in 1..=u16::MAX {
            let y = SRGB_OETF.lookup(Q0_16::from_raw(x));
            assert!(y >= prev, "x={x} y={y:?} prev={prev:?}");
            prev = y;
        }
    }

    #[test]
    fn knot_boundaries_and_midpoints() {
        let last = 256u32;
        for i in 0..=last {
            let x = match i.saturating_mul(65535).checked_div(last) {
                Some(v) if v <= u32::from(u16::MAX) => v as u16,
                _ => 0,
            };
            let o = SRGB_OETF.lookup(Q0_16::from_raw(x));
            let e = SRGB_EOTF.lookup(Q0_16::from_raw(x));
            if i == 0 {
                assert_eq!((o, e), (Q0_16::ZERO, Q0_16::ZERO));
            }
            if i == last {
                assert_eq!((o, e), (Q0_16::ONE, Q0_16::ONE));
            }
            if i < last {
                let next = match (i.saturating_add(1))
                    .saturating_mul(65535)
                    .checked_div(last)
                {
                    Some(v) if v <= u32::from(u16::MAX) => v as u16,
                    _ => 0,
                };
                let mid = x.saturating_add(next.saturating_sub(x) / 2);
                let om = SRGB_OETF.lookup(Q0_16::from_raw(mid));
                let em = SRGB_EOTF.lookup(Q0_16::from_raw(mid));
                let on = SRGB_OETF.lookup(Q0_16::from_raw(next));
                let en = SRGB_EOTF.lookup(Q0_16::from_raw(next));
                assert!(om >= o && om <= on);
                assert!(em >= e && em <= en);
            }
        }
    }
}