# colr
[![Latest Version]][crates.io]
[![docs]][docs.rs]
[![Minimum Supported Rust Version]][Rust 1.85]
A general purpose, extensible color type unifying storage, channel layouts, and color spaces at the type level.
Color spaces, transfer functions, chromatic adaptation, and channel layouts are encoded in the type system. Mixing an sRGB color with a Display P3 color is a compile error unless you convert explicitly. All colorimetric matrices are derived at compile time. Colors are generic over their storage representation, which is itself generic over its channel layout.
## Usage
```rust
use colr::{Color, LinearSrgb, Oklab, Oklch, Srgb, Xyz};
// Construct an sRGB color. Defaults to RGBA layout.
let srgb: Color<[f32; 4], Srgb> = Color::new([0.8, 0.4, 0.2, 1.0]);
// Decode to linear light before converting. The transfer function must be
// removed before the RGB-to-XYZ matrix is applied.
let linear: Color<[f32; 4], LinearSrgb> = srgb.decode();
// Convert to Oklab for perceptual work. Each step is an explicit From.
// All matrices are compile-time constants. Xyz defaults to D65.
// Alpha is carried through each conversion unchanged.
let xyz: Color<[f32; 4], Xyz> = linear.into();
let lab: Color<[f32; 4], Oklab> = xyz.into();
// Pull out chroma and hue, reduce chroma, put it back.
let lch: Color<[f32; 4], Oklch> = lab.into();
let [l, c, h, a] = lch.inner();
let muted: Color<[f32; 4], Oklch> = Color::new([l, c * 0.5, h, a]);
// Convert back. XYZ-to-RGB may produce out-of-range values for wide-gamut
// colors; clamp if targeting a display, or pass through for tone mapping.
let oklab_out: Color<[f32; 4], Oklab> = muted.into();
let xyz_out: Color<[f32; 4], Xyz> = oklab_out.into();
let linear_out: Color<[f32; 4], LinearSrgb> = xyz_out.into();
let final_color: Color<[f32; 4], Srgb> = linear_out.encode();
```
## Color models and spaces
### Perceptual spaces
| `Lab` | CIE 1976 L\*a\*b\* parameterized by reference white W |
| `LCh` | Polar form of L\*a\*b\* |
| `Oklab` | Oklab (Ottosson 2020); improved hue linearity over Lab |
| `Oklch` | Polar form of Oklab |
`Lab` and `LCh` are parameterized by illuminant: `Lab<D65>`, `Lab<D50>`, and so on, with `D65` as the default. `Oklab` and `Oklch` have D65 baked into the specification.
All four types accept a const `OFFSET` parameter that controls where the color channels begin within a four-channel array. The default `OFFSET = 0` places color channels at indices 0, 1, 2 and alpha at 3. `OFFSET = 1` places alpha at 0 and color channels at 1, 2, 3. This allows in-place processing of ARGB-layout buffers without swizzling: reinterpret the buffer as `Oklab<1>`, operate, reinterpret back.
### RGB spaces
| `Srgb` | sRGB/Rec709 | sRGB | D65 |
| `LinearSrgb` | sRGB/Rec709 | Linear | D65 |
| `Rec709` | sRGB/Rec709 | Rec. 709 | D65 |
| `DisplayP3` | P3 | sRGB | D65 |
| `LinearP3` | P3 | Linear | D65 |
| `Hdr10` | Rec. 2020 | PQ (ST 2084) | D65 |
| `Hlg` | Rec. 2020 | HLG (BT.2100) | D65 |
| `LinearRec2020` | Rec. 2020 | Linear | D65 |
| `AcesCg` | AP1 | Linear | ACES |
| `Aces2065` | AP0 | Linear | ACES |
| `AcesCc` | AP1 | ACEScc log | ACES |
| `AcesCct` | AP1 | ACEScct log+toe | ACES |
| `ProPhoto` | ProPhoto | gamma 1.8 | D50 |
| `LinearProPhoto` | ProPhoto | Linear | D50 |
| `DciP3` | DCI-P3 | gamma 2.6 | DCI |
Each alias defaults to RGBA layout. Alternate layouts are selected with the type parameter: `Srgb<Rgb>`, `Srgb<Bgra>`, and so on.
### XYZ
`Xyz<W>` is the CIE XYZ tristimulus space parameterized by illuminant `W`, defaulting to `Xyz<D65>`. It serves as the connection space for all RGB and perceptual conversions.
Conversions between `Xyz<W1>` and `Xyz<W2>` apply Bradford chromatic adaptation by default, computed at compile time. The adaptation method can be overridden with `.adapt::<W2, CAT>()`.
### Luma
`Luma<P, TF>` is a single-channel luminance model derived from primary set `P` with transfer function `TF`. `LumaAlpha<P, TF, A>` adds an alpha channel with alpha state `A` (defaults to `Straight`). The luma weights are the Y row of the primaries' RGB-to-XYZ matrix, so they vary by primary set.
### YCbCr
`YCbCr<P, TF, L>` is Y'CbCr derived from RGB primaries `P` and transfer function `TF`. The conversion matrix is derived from the primaries' luma weights. Used in JPEG, H.264, H.265, and broadcast video. Layout defaults to `Ycbcr`.
### Spectral
`Spectral<N, G, K>` carries N spectral samples over wavelength grid `G` with physical kind `K`. Three kinds are defined:
| `IsRadiance` | Spectral radiance | SPD dot CMF |
| `IsReflectance` | Spectral reflectance \[0,1\] | (illuminant \* reflectance) dot CMF |
| `IsTransmittance` | Spectral transmittance \[0,1\] | same as reflectance |
`IsBispectral` is defined as a kind marker to close the taxonomy but does not implement `BackingStore`; bispectral (fluorescent) storage is reserved for a future explicit design.
Standard grids include Grid380_780_10nm (41 bands), Grid400_700_10nm (31 band ICC standard), and Grid380_780_5nm (81 bands), all evaluated against the CIE 1931 observer.
Blanket alias traits `Radiance`, `Reflectance`, `Transmittance`, and `Bispectral` are provided for use in bounds.
### Device-dependent
| `Cmyk` | Device CMYK. Profile required for XYZ conversion |
| `DeviceRgb` | Device RGB. Profile required for XYZ conversion |
Conversions for these types use `FromDevice` rather than `From`. See the [Device-dependent colors](#device-dependent-colors) section.
If a color space or model from an open standard is not listed above, please open an issue.
## Conversions
All conversions use `From`/`Into`. XYZ-to-RGB is infallible and may return out-of-range channel values for wide-gamut colors. Clamp with `.clamp(0.0, 1.0)` for display output, or pass the values directly to a tone mapper.
```rust
# use colr::{Color, LinearSrgb, Xyz};
# let src: Color<[f32; 4], Xyz> = Color::new([2.0, 1.0, 1.0, 1.0]);
let linear: Color<[f32; 4], LinearSrgb> = src.into();
let display_ready: Color<[f32; 4], LinearSrgb> = linear.clamp(0.0, 1.0);
```
Encode and decode transfer functions with `.encode()` and `.decode()`. Adapt a color between XYZ white points with `.adapt::<W2, CAT>()` on a `Color<_, Xyz<W1>>`.
## Device-dependent colors
`Cmyk` and `DeviceRgb<L>` are device-dependent color models whose relationship to XYZ is defined by a runtime ICC profile. Because no compile-time matrix describes them, they do not implement `From`. Conversions use `FromDevice` from `colr::device`:
```rust
pub trait FromDevice<Src>: Sized {
type Profile;
fn from_device(src: Src, profile: &Self::Profile) -> Self;
}
```
The `Profile` associated type is set by each impl. A profile type defined externally can add `FromDevice` impls for `Cmyk` or `DeviceRgb` without changes to this crate.
## Channel layouts
Layout is a type parameter, not a runtime tag. The compiler distinguishes RGBA from BGRA and will not accept one where the other is expected.
| `Rgba` | R G B A |
| `Bgra` | B G R A |
| `Argb` | A R G B |
| `Abgr` | A B G R |
| `Rgb` | R G B |
| `Bgr` | B G R |
Layout reorders are infallible and compile to a shuffle with no arithmetic:
```rust
# use colr::{Color, Srgb};
# use colr::layout::{Bgra, Rgba};
# let rgba: Color<[f32; 4], Srgb<Rgba>> = Color::new([1.0, 0.0, 0.0, 1.0]);
let bgra: Color<[f32; 4], Srgb<Bgra>> = rgba.swizzle();
```
## Chromatic adaptation
Four methods are provided. Bradford is used automatically by all built-in conversions and is the correct default for standard RGB work.
| `Bradford` | ICC, ACES, CSS Color Level 4 |
| `Cat02` | CIECAM02, ICC v4 appearance models |
| `Cat16` | CAM16 |
| `VonKries` | Legacy and reference |
To adapt a color explicitly:
```rust
# use colr::{Color, Xyz};
# use colr::chromatic_adaptation::Bradford;
# use colr::illuminant::{D50, D65};
# let xyz_d65: Color<[f32; 3], Xyz<D65>> = Color::new([0.95, 1.0, 1.09]);
let xyz_d50: Color<[f32; 3], Xyz<D50>> = xyz_d65.adapt::<D50, Bradford>();
```
Custom adaptation matrices can be computed at compile time with `operations::adapt::adapt`.
## Color difference
| `delta_e76` | `Lab` | CIE 1976 (Euclidean) |
| `delta_e2000` | `Lab` | CIEDE2000 (CIE 142-2001) |
| `delta_e_ok` | `Oklab` | Euclidean in Oklab |
```rust
# use colr::{Color, Oklab};
# let a: Color<[f32; 3], Oklab> = Color::new([0.5, 0.1, 0.0]);
# let b: Color<[f32; 3], Oklab> = Color::new([0.5, 0.0, 0.1]);
let delta: f32 = a.delta_e_ok(b);
```
## Tone mapping
Tone mapping compresses unbounded scene-linear light `[0, inf)` down to bounded display-linear light `[0, 1]`.
| `AcesNarkowicz` | ACES filmic curve approximation (per-channel) |
| `KhronosPbrNeutral` | glTF 3D Commerce standard, hue-preserving (RGB-coupled) |
| `Reinhard` | Classic `x / (x + 1)` curve (per-channel) |
```rust
# use colr::{Color, LinearSrgb};
# use colr::tonemap::KhronosPbrNeutral;
# let scene_linear: Color<[f32; 4], LinearSrgb> = Color::new([2.0, 1.0, 1.0, 1.0]);
let display_linear = scene_linear.tonemap::<KhronosPbrNeutral>();
```
## Quantization and dithering
Converting continuous `f32` signals to 8-bit `u8` integers without dithering causes visible banding. `colr` provides a way to convert with a dithering offset as well as straight. The `to_u8` method performs naive quantization while `to_u8_dithered` method dithers the color channels leaving the alpha channel untouched.
```rust
# use colr::{Color, Srgb};
# let color: Color<[f32; 4], Srgb> = Color::new([0.5, 0.5, 0.5, 1.0]);
// Generate two independent uniform random floats in [0, 1)
# let u1 = 0.2; let u2 = 0.7;
let dither = (u1 - u2) / 255.0;
let pixel: Color<[u8; 4], Srgb> = color.to_u8_dithered(dither);
```
## Alpha compositing
Blend operations are defined on `Color<[f32; 4], Rgb<P, Linear, L>>` and require premultiplied alpha. Compositing in non-linear space is incorrect.
```rust
# use colr::{Color, LinearSrgb};
# let src: Color<[f32; 4], LinearSrgb> = Color::new([1.0, 0.0, 0.0, 0.5]);
# let dst: Color<[f32; 4], LinearSrgb> = Color::new([0.0, 0.0, 1.0, 1.0]);
let result = src.premultiply().over(dst.premultiply()).unpremultiply();
```
| `premultiply` | Multiply color channels by alpha |
| `unpremultiply` | Divide color channels by alpha |
| `over` | Porter-Duff over (both premultiplied) |
## no_std support
`no_std` is available with the `libm` feature. Either `std` or `libm` must be enabled.
```toml
[dependencies]
colr = { version = "0.2", default-features = false, features = ["libm"] }
```
To support both `std` and `no_std` builds in a library crate:
```toml
[features]
default = ["std"]
std = ["colr/std"]
libm = ["colr/libm"]
[dependencies]
colr = { version = "0.2", default-features = false }
```
## Optional features
`std` is enabled by default and selects standard library math functions. The remaining features are opt-in.
| `glam` | Adds `VecN` types as valid color storage |
| `libm` | Uses `libm` math functions for `no_std` targets |
## Adding a custom primary set
Implement `Primaries` for your type. All matrices are derived at compile time from the chromaticity coordinates and white point you supply.
```rust
use colr::primaries::{Primaries, derive_rgb_to_xyz};
use colr::illuminant::{D65, Illuminant};
use colr::math::Mat3;
pub struct CustomPrimaries;
const CUSTOM_TO_XYZ: Mat3 = derive_rgb_to_xyz(
[0.680, 0.320], // red
[0.265, 0.690], // green
[0.150, 0.060], // blue
<D65 as Illuminant>::WHITE_POINT_XYZ,
);
impl Primaries for CustomPrimaries {
type Native = D65;
const R: [f32; 2] = [0.680, 0.320];
const G: [f32; 2] = [0.265, 0.690];
const B: [f32; 2] = [0.150, 0.060];
const TO_XYZ_NATIVE: Mat3 = CUSTOM_TO_XYZ;
const FROM_XYZ_NATIVE: Mat3 = Mat3::invert(&CUSTOM_TO_XYZ);
}
```
## colr-types
The [`colr-types`](https://crates.io/crates/colr-types) crate provides the color model ZSTs and marker traits without `Color<S, M>` or its operations. Depend on it directly when you need to parameterize on color model at the type level without pulling in the full operation set, for example in a GPU backend that manages its own storage.
## Design notes
All colorimetric matrices are derived in `const fn` at compile time, including RGB-to-XYZ, chromatic adaptation, and composed primaries-to-primaries transforms. There is no matrix inversion or multiplication at runtime.
`Color<S, M>` is `#[repr(transparent)]` over its storage type. The color model is carried only as `PhantomData`. There is no runtime memory overhead from the type-level bookkeeping.
## Minimum Supported Rust Version
The minimum supported version of Rust is `1.85`. Edition 2024 requires Rust 1.85. Inline `const {}` blocks, used to evaluate transform matrices at compile time in generic contexts, stabilized in Rust 1.82.
## Contributing
This crate is early in development. Consider raising an issue if it doesn't fit your needs, is missing functionality, or is unergonomic in any way.
Contributions are welcome for any color space or transfer function defined in an open standard. If a standard space is missing, please open an issue before implementing it so the approach can be agreed on first. Spaces from proprietary or closed formats belong in a separate crate.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work, as defined in the Apache-2.0 license, shall be dual licensed as below, without any additional terms or conditions.
## License
Licensed under either of
- Apache License, Version 2.0 ([LICENSE.APACHE](LICENSE.APACHE)
or <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE.MIT](LICENSE.MIT)
or <http://opensource.org/licenses/MIT>)
at your option.
[Latest Version]: https://img.shields.io/crates/v/colr.svg
[crates.io]: https://crates.io/crates/colr/
[docs]: https://docs.rs/colr/badge.svg
[docs.rs]: https://docs.rs/colr/
[Minimum Supported Rust Version]: https://img.shields.io/badge/Rust-1.85-blue?color=fc8d62&logo=rust
[Rust 1.85]: https://github.com/rust-lang/rust/blob/master/RELEASES.md