lightcraft/crates/codecs/src/space.rs

322 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Source colour spaces, transfer curves and the conversion to the working space.
use lightcraft_color::{ADOBE_RGB, D50, DISPLAY_P3, Mat3, PROPHOTO, REC2020, RgbSpace, SRGB, WORKING, bradford};
use serde::{Deserialize, Serialize};
/// A transfer curve (encoded value → linear light), as found in ICC `curv`/`para` tags and container
/// colour descriptions.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Trc {
/// Identity (already linear).
Linear,
/// IEC 61966-2-1 sRGB curve (also used by Display P3).
Srgb,
/// Pure power law `y = x^g`.
Gamma(f32),
/// ITU-R BT.709 / BT.2020 OETF inverse.
Rec709,
/// ICC parametric curve parameters `[g, a, b, c, d, e, f]` (1, 3, 4, 5 or 7 values; ICC function types 0–4).
Parametric(Vec<f32>),
/// Sampled curve over `[0, 1]` (ICC `curv` with ≥ 2 entries), linearly interpolated.
Table(Vec<u16>),
}
impl Trc {
/// Encoded value → linear. Negative values are mirrored and values above 1 are extrapolated,
/// so extended-range float data survives.
pub fn to_linear(&self, v: f32) -> f32 {
if v < 0.0 {
return -self.to_linear(-v);
}
match self {
Trc::Linear => v,
Trc::Srgb => {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
Trc::Gamma(g) => v.powf(*g),
Trc::Rec709 => {
if v < 0.081 {
v / 4.5
} else {
((v + 0.099) / 1.099).powf(1.0 / 0.45)
}
}
Trc::Parametric(p) => eval_parametric(p, v),
Trc::Table(t) => eval_table(t, v),
}
}
/// Linear → encoded value (inverse of [`Trc::to_linear`]); tables are inverted numerically.
pub fn from_linear(&self, v: f32) -> f32 {
if v < 0.0 {
return -self.from_linear(-v);
}
match self {
Trc::Linear => v,
Trc::Srgb => lightcraft_color::transfer::linear_to_srgb(v),
Trc::Gamma(g) => {
if *g > 0.0 {
v.powf(1.0 / g)
} else {
v
}
}
Trc::Rec709 => {
if v < 0.018 {
v * 4.5
} else {
1.099 * v.powf(0.45) - 0.099
}
}
Trc::Parametric(_) | Trc::Table(_) => {
// Monotone curves: bisection on [0, max(1, v)].
let (mut lo, mut hi) = (0.0f32, 1.0f32);
while self.to_linear(hi) < v && hi < 1e6 {
hi *= 2.0;
}
for _ in 0..40 {
let mid = 0.5 * (lo + hi);
if self.to_linear(mid) < v {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
}
}
/// True for the identity curve (including degenerate ICC encodings of it).
pub fn is_linear(&self) -> bool {
match self {
Trc::Linear => true,
Trc::Gamma(g) => (*g - 1.0).abs() < 1e-4,
Trc::Parametric(p) => p.len() == 1 && (p[0] - 1.0).abs() < 1e-4,
Trc::Table(t) => t.is_empty(),
_ => false,
}
}
/// Whether two curves agree to within `tol` on a sample grid.
pub fn approx_eq(&self, other: &Trc, tol: f32) -> bool {
(0..=32).all(|i| {
let x = i as f32 / 32.0;
(self.to_linear(x) - other.to_linear(x)).abs() <= tol
})
}
/// 256-entry linearization table for 8-bit data.
pub(crate) fn lut8(&self) -> Vec<f32> {
(0..256).map(|i| self.to_linear(i as f32 / 255.0)).collect()
}
/// 65536-entry linearization table for 16-bit data.
pub(crate) fn lut16(&self) -> Vec<f32> {
if self.is_linear() {
return (0..65536).map(|i| i as f32 / 65535.0).collect();
}
(0..65536).map(|i| self.to_linear(i as f32 / 65535.0)).collect()
}
}
fn eval_parametric(p: &[f32], x: f32) -> f32 {
let pw = |b: f32, g: f32| if b > 0.0 { b.powf(g) } else { 0.0 };
match p.len() {
1 => pw(x, p[0]),
3 => {
let (g, a, b) = (p[0], p[1], p[2]);
if a != 0.0 && x >= -b / a { pw(a * x + b, g) } else { 0.0 }
}
4 => {
let (g, a, b, c) = (p[0], p[1], p[2], p[3]);
if a != 0.0 && x >= -b / a { pw(a * x + b, g) + c } else { c }
}
5 => {
let (g, a, b, c, d) = (p[0], p[1], p[2], p[3], p[4]);
if x >= d { pw(a * x + b, g) } else { c * x }
}
7 => {
let (g, a, b, c, d, e, f) = (p[0], p[1], p[2], p[3], p[4], p[5], p[6]);
if x >= d { pw(a * x + b, g) + e } else { c * x + f }
}
_ => x,
}
}
fn eval_table(t: &[u16], x: f32) -> f32 {
match t.len() {
0 => x,
1 => x.powf(t[0] as f32 / 256.0),
n => {
let pos = x * (n - 1) as f32;
let i = (pos.floor() as usize).min(n - 2);
let f = pos - i as f32;
let a = t[i] as f32 / 65535.0;
let b = t[i + 1] as f32 / 65535.0;
a + (b - a) * f
}
}
}
/// Well-known RGB spaces we recognise (from ICC colorants or container tags).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum NamedSpace {
Srgb,
DisplayP3,
AdobeRgb,
ProPhoto,
Rec2020,
}
impl NamedSpace {
pub const ALL: [NamedSpace; 5] = [NamedSpace::Srgb, NamedSpace::DisplayP3, NamedSpace::AdobeRgb, NamedSpace::ProPhoto, NamedSpace::Rec2020];
pub fn rgb_space(self) -> RgbSpace {
match self {
NamedSpace::Srgb => SRGB,
NamedSpace::DisplayP3 => DISPLAY_P3,
NamedSpace::AdobeRgb => ADOBE_RGB,
NamedSpace::ProPhoto => PROPHOTO,
NamedSpace::Rec2020 => REC2020,
}
}
/// The space's standard encoding curve.
pub fn trc(self) -> Trc {
match self {
NamedSpace::Srgb | NamedSpace::DisplayP3 => Trc::Srgb,
NamedSpace::AdobeRgb => Trc::Gamma(563.0 / 256.0),
NamedSpace::ProPhoto => Trc::Gamma(1.8),
NamedSpace::Rec2020 => Trc::Rec709,
}
}
/// Linear RGB → XYZ (D50 PCS, Bradford-adapted), as an ICC matrix/TRC profile would store it.
pub fn to_xyz_d50(self) -> Mat3 {
rgb_to_xyz_d50(&self.rgb_space())
}
pub fn name(self) -> &'static str {
self.rgb_space().name
}
/// Recognise a space from its D50-adapted colorant matrix (tolerant to s15Fixed16 rounding and
/// the small variations between vendors' profiles).
pub fn recognize(to_xyz_d50: &Mat3) -> Option<NamedSpace> {
NamedSpace::ALL.into_iter().find(|n| {
let m = n.to_xyz_d50();
(0..3).all(|r| (0..3).all(|c| (m.0[r][c] - to_xyz_d50.0[r][c]).abs() < 0.004))
})
}
}
/// `space`'s linear RGB → XYZ relative to D50 (Bradford), the ICC PCS.
pub fn rgb_to_xyz_d50(space: &RgbSpace) -> Mat3 {
bradford(space.white, D50).mul(&space.to_xyz())
}
/// Where a decoded image's colour interpretation came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpaceOrigin {
/// No colour information in the file: assumed sRGB.
Untagged,
/// A container-level description (PNG `sRGB`/`cHRM`/`gAMA`, JPEG XL enum colour encoding, …).
Container,
/// An embedded ICC matrix/TRC profile, applied exactly.
IccMatrixTrc,
/// An embedded LUT-based (or CMYK/Lab) ICC profile, converted by the CMS to linear Rec.2020.
IccCms,
/// An embedded ICC profile we could not apply (malformed/unsupported): **fell back to sRGB**.
IccUnsupported,
/// A naive conversion without a profile (e.g. CMYK → RGB without an ICC profile), then sRGB.
Naive,
}
/// The colour space of [`crate::Decoded::image`]: linear RGB with these primaries.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SourceSpace {
/// Recognised well-known space, if the primaries match one.
pub named: Option<NamedSpace>,
/// Linear image RGB → CIE XYZ relative to D50 (ICC PCS; white Y = 1).
pub to_xyz_d50: Mat3,
/// The encoding curves that were removed during decode (R, G, B), if known.
pub trc: Option<[Trc; 3]>,
pub origin: SpaceOrigin,
}
impl SourceSpace {
pub fn named(n: NamedSpace, origin: SpaceOrigin) -> SourceSpace {
let t = n.trc();
SourceSpace { named: Some(n), to_xyz_d50: n.to_xyz_d50(), trc: Some([t.clone(), t.clone(), t]), origin }
}
/// Linear source RGB → linear `dst` RGB (Bradford from D50 to `dst`'s white).
pub fn to_space(&self, dst: &RgbSpace) -> Mat3 {
if let Some(n) = self.named {
let s = n.rgb_space();
if s == *dst {
return Mat3::IDENTITY;
}
return s.to_space(dst);
}
dst.from_xyz().mul(&bradford(D50, dst.white)).mul(&self.to_xyz_d50)
}
/// Linear source RGB → linear Rec.2020 D65 (the pipeline working space).
pub fn to_working(&self) -> Mat3 {
self.to_space(&WORKING)
}
/// True when the pixels need a matrix to reach the working space.
pub fn is_working(&self) -> bool {
self.named == Some(NamedSpace::Rec2020)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn named_recognition_roundtrip() {
for n in NamedSpace::ALL {
assert_eq!(NamedSpace::recognize(&n.to_xyz_d50()), Some(n));
}
}
#[test]
fn trc_inverse() {
let curves = [
Trc::Srgb,
Trc::Gamma(2.2),
Trc::Rec709,
Trc::Parametric(vec![2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045]),
Trc::Table((0..1024).map(|i| ((i as f32 / 1023.0).powf(2.2) * 65535.0) as u16).collect()),
];
for c in curves {
for i in 0..=20 {
let x = i as f32 / 20.0;
let y = c.to_linear(x);
assert!((c.from_linear(y) - x).abs() < 2e-3, "{c:?} {x}");
}
}
assert!(Trc::Srgb.approx_eq(&Trc::Parametric(vec![2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045]), 1e-5));
}
#[test]
fn working_matrix_identity_for_rec2020() {
let s = SourceSpace::named(NamedSpace::Rec2020, SpaceOrigin::Container);
assert_eq!(s.to_working(), Mat3::IDENTITY);
let p3 = SourceSpace::named(NamedSpace::DisplayP3, SpaceOrigin::Container);
let w = p3.to_working().apply([1.0, 1.0, 1.0]);
for c in w {
assert!((c - 1.0).abs() < 1e-6);
}
}
}