colorimetry 0.0.9

Rust Spectral Colorimetry library with JavaScript/WASM interfaces
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
// SPDX-License-Identifier: Apache-2.0 OR MIT
// Copyright (c) 2024-2025, Harbers Bik LLC

//! # Device-dependent Red-Green-Blue (RGB) Colors
//!
//! The `rgb` module provides types and functions for working with device-dependent
//! Red-Green-Blue colors.
//!
//! ## Submodules
//!
//! - **`gamma`**  
//!   Encoding and decoding gamma curves (e.g. sRGB, AdobeRGB) for nonlinear ↔ linear conversions.  
//! - **`rgbspace`**  
//!   Definitions of `RgbSpace` (primaries, white point, conversion matrices) and helpers  
//!   to transform between RGB and CIE XYZ.  
//! - **`widergb`**  
//!   The `WideRgb` type, which allows out-of-gamut values (outside `[0.0,1.0]`) for HDR or extended-gamut workflows.
//!
//! ## Core Features
//!
//! - **Strict Validation**  
//!   Creating an `Rgb` with `Rgb::new(r, g, b, …)` returns an error if any component lies
//!   outside `[0.0, 1.0]`.  
//! - **Multiple Constructors**  
//!   - `Rgb::new(r, g, b, observer, space)` — fully specified  
//!   - `Rgb::from_u8(r, g, b, …)` / `Rgb::from_u16(r, g, b, …)` — byte- or word-based input  
//! - **Color Space & Observer**  
//!   Optionally supply a standard observer (e.g. CIE1931 or CIE2015) and an RGB color
//!   space (e.g. sRGB, AdobeRGB). Defaults are Cie1931 + sRGB.
//!
//! ## Conversions
//!
//! - **To XYZ**  
//!   Call `.xyz()` to convert into CIE XYZ tristimulus values (scaled so Y = 100 for white).
//! - **To Device Values**  
//!   Use `Into<[u8; 3]>` or `Into<[f64; 3]>` for byte or float arrays, automatically applying
//!   the color space’s gamma curve.
//!
//! ## Error Handling
//!
//! - `Rgb::new(...)` returns `Err(CmtError::InvalidRgbValue)` if any component ∉ `[0.0,1.0]`.  
//! - `from_u8` / `from_u16` automatically clamp inputs into range and never error.
//!
//! ## Notes
//!
//! - Use `Rgb` when you need **strict gamut compliance**. For HDR or out-of-gamut workflows,
//!   consider `WideRgb`.  
//! - Supplying an alternate `observer` or `space` customizes the `.xyz()` conversion for specialized
//!   viewing conditions or display profiles.
//!
//! ## Testing
//!
//! The module includes unit tests for:
//! - Value validation and clamping  
//! - Byte and float conversions  
//! - XYZ tristimulus output under default settings  

mod gamma;
mod rgbspace;
mod widergb;

pub use gamma::GammaCurve;
pub use rgbspace::RgbSpace;
pub use widergb::WideRgb;

use crate::{
    colorant::Colorant,
    error::Error,
    observer::Observer::{self, Cie1931},
    spectrum::Spectrum,
    stimulus::Stimulus,
    traits::{Filter, Light},
    xyz::XYZ,
};
use approx::AbsDiffEq;
use nalgebra::Vector3;
use std::borrow::Cow;

/// Represents a color stimulus using Red, Green, and Blue (RGB) values constrained to the `[0.0, 1.0]` range.
/// Each component is a floating-point value representing the relative intensity of the respective primary color
/// within a defined RGB color space.
///
/// Unlike the CIE XYZ tristimulus values, which use imaginary primaries, RGB values are defined using real primaries
/// based on a specific color space. These primaries typically form a triangular area within a CIE (x,y) chromaticity
/// diagram, representing the gamut of colors the device can reproduce.
///
/// # Usage
/// The `Rgb` struct is used to encapsulate color information in a device-independent manner, allowing for accurate color
/// representation, conversion, and manipulation within defined RGB spaces. It is particularly useful for applications
/// involving color management, digital imaging, and rendering where strict adherence to gamut boundaries is required.
///
/// # Example
/// ```rust
/// # use colorimetry::rgb::Rgb;
/// # use approx::assert_abs_diff_eq;
///
/// // Create an sRGB color with normalized RGB values
/// let rgb = Rgb::new(0.5, 0.25, 0.75, None, None).unwrap();
/// assert_abs_diff_eq!(rgb.to_array().as_ref(), [0.5, 0.25, 0.75].as_ref(), epsilon = 1e-6);
/// ```
///
/// # Notes
/// - The `Rgb` struct strictly enforces the `[0.0, 1.0]` range for each component. Any attempt to create values
///   outside this range will result in an error.
/// - The `observer` field allows for color conversion accuracy under different lighting and viewing conditions,
///   enhancing the reliability of transformations to other color spaces such as XYZ.
#[cfg_attr(target_arch = "wasm32", wasm_bindgen::prelude::wasm_bindgen)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rgb {
    /// The RGB color space the color values are using. Often this is the _sRGB_
    /// color space, which is rather small.
    pub(crate) space: RgbSpace,

    /// Reference to the colorimetric observer being used. This is almost always
    /// the CIE 1931 standard observer, which has been known to represent the
    /// deep blue region of humen vision sensitivity incorrectly. Here we allow
    /// other standard observers, such as the CIE 2015 cone fundamentals based
    /// observer, to improve color management quality.
    pub(crate) observer: Observer,
    pub(crate) rgb: Vector3<f64>,
}

impl Rgb {
    /// Creates a new `Rgb` instance with the specified red, green, and blue values.
    /// # Arguments
    /// - `r`: Red component, in the range from 0.0 to 1.0
    /// - `g`: Green component, in the range from 0.0 to 1.0
    /// - `b`: Blue component, in the range from 0.0 to 1.0
    /// - `observer`: Optional observer, defaults to `Observer::Cie1931`
    /// - `space`: Optional RGB color space, defaults to `RgbSpace::SRGB`
    /// # Returns
    /// A new `Rgb` instance with the specified RGB values and color space.
    /// # Errors
    /// - InvalidRgbValue: at least one of the RGB values is outside the range from 0.0 to 1.0.
    ///     
    pub fn new(
        r: f64,
        g: f64,
        b: f64,
        opt_observer: Option<Observer>,
        opt_rgbspace: Option<RgbSpace>,
    ) -> Result<Self, Error> {
        if (0.0..=1.0).contains(&r) && (0.0..=1.0).contains(&g) && (0.0..=1.0).contains(&b) {
            let observer = opt_observer.unwrap_or_default();
            let space = opt_rgbspace.unwrap_or_default();
            Ok(Rgb {
                rgb: Vector3::new(r, g, b),
                observer,
                space,
            })
        } else {
            Err(Error::InvalidRgbValue)
        }
    }

    /// Construct a RGB instance from red, green, and blue u8 values in the range from 0 to 255.
    ///
    /// When using online RGB data, when observer and color space or color profile are not explicititely specfied,
    /// `Observer::Cie1931`, and `RgbSpace::SRGB` are implied, and those are the defaults here too.
    pub fn from_u8(
        r_u8: u8,
        g_u8: u8,
        b_u8: u8,
        observer: Option<Observer>,
        space: Option<RgbSpace>,
    ) -> Self {
        let space = space.unwrap_or_default();
        let [r, g, b] = [r_u8, g_u8, b_u8]
            .map(|v| (v as f64 / 255.0).clamp(0.0, 1.0))
            .map(|v| space.gamma().decode(v));
        // unwrap OK as derived from u8 values and clamped to 0.0..1.0
        Rgb::new(r, g, b, observer, Some(space)).unwrap()
    }

    /// Construct a RGB instance from red, green, and blue u16 values in the range from 0 to 1.
    ///
    /// When using online RGB data, when observer and color space or color profile are not explicititely specfied,
    /// `Observer::Cie1931`, and `RgbSpace::SRGB` are implied, and those are the defaults here too.
    pub fn from_u16(
        r_u16: u16,
        g_u16: u16,
        b_u16: u16,
        observer: Option<Observer>,
        space: Option<RgbSpace>,
    ) -> Self {
        let space = space.unwrap_or_default();
        let [r, g, b] = [r_u16, g_u16, b_u16]
            .map(|v| (v as f64 / 65_535.0).clamp(0.0, 1.0))
            .map(|v| space.gamma().decode(v));
        // unwrap OK as derived from u16 values and clamped to 0.0..1.0
        Rgb::new(r, g, b, observer, Some(space)).unwrap()
    }

    /// Returns the value of the red channel.
    pub fn r(&self) -> f64 {
        self.rgb.x
    }

    /// Returns the value of the green channel.
    pub fn g(&self) -> f64 {
        self.rgb.y
    }

    /// Returns the value of the blue channel.
    pub fn b(&self) -> f64 {
        self.rgb.z
    }

    /// Returns the RGB values as an array with the red, green, and blue values respectively
    ///
    /// ```rust
    /// # use colorimetry::rgb::Rgb;
    /// let rgb = Rgb::new(0.1, 0.2, 0.3, None, None).unwrap();
    /// let [r, g, b] = rgb.to_array();
    /// assert_eq!([r, g, b], [0.1, 0.2, 0.3]);
    /// ```
    pub fn to_array(&self) -> [f64; 3] {
        *self.rgb.as_ref()
    }

    /// Converts the RGB value to a tri-stimulus XYZ value
    pub fn xyz(&self) -> XYZ {
        const YW: f64 = 100.0;
        let xyz = self.observer.rgb2xyz_matrix(self.space) * self.rgb;
        XYZ {
            observer: self.observer,
            xyz: xyz.map(|v| v * YW),
        }
    }
}

impl Light for Rgb {
    /// Implements the `Light` trait for the `Rgb` struct, allowing an `Rgb` color to be interpreted
    /// as a spectral power distribution (`Spectrum`).
    ///
    /// The `spectrum` method converts the RGB values into a spectral representation based on the
    /// primaries defined by the associated RGB color space and the current observer.
    ///
    /// # Method: `spectrum()`
    /// - This method calculates the spectral power distribution of the `Rgb` instance by combining the
    ///   RGB values with the respective primary spectra.
    /// - Each RGB component is weighted by its corresponding luminance factor (`yrgb`) and primary spectrum,
    ///   effectively converting the RGB values into a continuous spectrum representation.
    ///
    /// # Implementation Details
    /// - The method iterates over the RGB components and the respective primary spectra.
    /// - Each component value is scaled by its corresponding luminance weight (`yrgb`) and then combined
    ///   with the primary spectrum using a weighted sum.
    /// - The resulting `Spectrum` is returned as an owned `Cow<'_,Spectrum>`.
    ///
    /// # Notes
    /// - The spectral representation is device-dependent and based on the primaries defined by the `RgbSpace`.
    /// - The observer's data is used to apply luminance scaling, enhancing perceptual accuracy.
    fn spectrum(&self) -> Cow<'_, Spectrum> {
        let prim = self.space.primaries();
        let rgb2xyz = self.observer.rgb2xyz_matrix(self.space);
        let yrgb = rgb2xyz.row(1);
        //        self.rgb.iter().zip(yrgb.iter()).zip(prim.iter()).map(|((v,w),s)|*v * *w * &s.0).sum()
        let s = self
            .rgb
            .iter()
            .zip(yrgb.iter())
            .zip(prim.iter())
            .fold(Spectrum::default(), |acc, ((&v, &w), s)| acc + v * w * s.0);
        Cow::Owned(s)
    }
}

impl Filter for Rgb {
    /// Implements the `Filter` trait for the `Rgb` struct, treating an RGB color as a spectral filter function.
    ///
    /// The `spectrum` method interprets the RGB values as a filter, excluding the reference illuminant.
    /// The filter function is then used in combination with a reference illuminant to simulate the resulting
    /// stimulus within the defined RGB color space.
    ///
    /// # Method: `spectrum()`
    /// - This method calculates the spectral power distribution of the `Rgb` instance by treating each RGB
    ///   component as a filter over its respective primary spectrum.
    /// - The resulting spectrum represents the relative transmittance of each primary, scaled by its respective
    ///   luminance weight (`yrgb`), without applying any reference illuminant.
    ///
    /// # Example
    /// ```rust
    /// use colorimetry::{xyz::XYZ, observer::Observer::Cie1931, illuminant::CieIlluminant, rgb::{Rgb, RgbSpace}, traits::{Light, Filter}};
    /// use approx::assert_ulps_eq;
    ///
    /// // Define an sRGB white color using the CIE 1931 observer
    /// let rgb = Rgb::from_u8(255, 255, 255, None, None);
    ///
    /// // Retrieve the spectral representation
    /// let spectrum = Filter::spectrum(&rgb);
    ///
    /// // Compare with the CIE D65 reference white point
    /// let d65: XYZ = Cie1931.xyz(&CieIlluminant::D65, Some(&rgb));
    /// let xyz_d65 = Cie1931.xyz_d65();
    /// approx::assert_ulps_eq!(d65, xyz_d65, epsilon = 1e-2);
    /// ```
    ///
    /// # Implementation Details
    /// - The method iterates over the RGB components and their respective primary spectra, treating each
    ///   component as a filter function.
    /// - Each component is scaled by its luminance factor (`yrgb`) to accurately reflect the relative
    ///   contribution of each primary to the resulting spectrum.
    /// - The resulting spectrum is returned as an owned `Cow<'_,Spectrum>`.
    ///
    /// # Notes
    /// - The spectral representation is device-dependent, relying on the primary spectra defined in the
    ///   associated `RgbSpace`.
    /// - This implementation excludes the reference illuminant, making it suitable for use as a relative filter
    ///   that can be combined with any illuminant to produce a specific stimulus.
    fn spectrum(&self) -> Cow<'_, Spectrum> {
        let prim = self.space.primaries_as_colorants();
        let rgb2xyz = self.observer.rgb2xyz_matrix(self.space);
        let yrgb = rgb2xyz.row(1);
        let s = self
            .rgb
            .iter()
            .zip(yrgb.iter())
            .zip(prim.iter())
            .fold(Spectrum::default(), |acc, ((&v, &w), s)| acc + v * w * s.0);
        Cow::Owned(s)
    }
}

impl AsRef<Vector3<f64>> for Rgb {
    fn as_ref(&self) -> &Vector3<f64> {
        &self.rgb
    }
}

/// Clamped RGB values as a u8 array. Uses gamma function.
impl From<Rgb> for [u8; 3] {
    fn from(rgb: Rgb) -> Self {
        let data: &[f64; 3] = rgb.rgb.as_ref();
        data.map(|v| (rgb.space.gamma().encode(v.clamp(0.0, 1.0)) * 255.0).round() as u8)
    }
}

impl From<Rgb> for [f64; 3] {
    fn from(rgb: Rgb) -> Self {
        rgb.to_array()
    }
}

impl AbsDiffEq for Rgb {
    type Epsilon = f64;

    fn default_epsilon() -> Self::Epsilon {
        f64::default_epsilon()
    }

    fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool {
        self.observer == other.observer && self.rgb.abs_diff_eq(&other.rgb, epsilon)
    }
}

impl approx::UlpsEq for Rgb {
    fn default_max_ulps() -> u32 {
        f64::default_max_ulps()
    }

    fn ulps_eq(&self, other: &Self, epsilon: Self::Epsilon, max_ulps: u32) -> bool {
        self.observer == other.observer && self.rgb.ulps_eq(&other.rgb, epsilon, max_ulps)
    }
}

pub fn gaussian_filtered_primaries(
    white: &Spectrum,
    red: [f64; 3],
    green: [f64; 2],
    blue: [f64; 2],
) -> [Stimulus; 3] {
    let [rc, rw, f] = red;
    let [gc, gw] = green;
    let [bc, bw] = blue;
    [
        Stimulus(
            Stimulus(&*Colorant::gaussian(bc, bw).spectrum() * white)
                .set_luminance(Cie1931, 100.0)
                .0
                * f
                + Stimulus(&*Colorant::gaussian(rc, rw).spectrum() * white)
                    .set_luminance(Cie1931, 100.0)
                    .0
                    * (1.0 - f),
        ),
        Stimulus(&*Colorant::gaussian(gc, gw).spectrum() * white).set_luminance(Cie1931, 100.0),
        Stimulus(&*Colorant::gaussian(bc, bw).spectrum() * white).set_luminance(Cie1931, 100.0),
    ]
}

#[cfg(test)]
mod rgb_tests {
    use crate::rgb::Rgb;

    #[test]
    fn get_values_f64() {
        let rgb = Rgb::new(0.1, 0.2, 0.3, None, None).unwrap();
        let [r, g, b] = <[f64; 3]>::from(rgb);
        assert_eq!(r, 0.1);
        assert_eq!(g, 0.2);
        assert_eq!(b, 0.3);
    }

    #[test]
    fn get_values_u8() {
        let rgb = Rgb::new(0.1, 0.2, 0.3, None, None).unwrap();
        let [r, g, b] = <[u8; 3]>::from(rgb);
        assert_eq!(r, 89);
        assert_eq!(g, 124);
        assert_eq!(b, 149);
    }
}