From a6435737fb3b4db389813c2c43f54ad402f0b0cc Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Tue, 4 Feb 2025 14:40:39 +0100 Subject: [PATCH 01/10] Implement absolute color conversions and chromatic adaptation --- color/README.md | 2 +- color/src/chromaticity.rs | 127 ++++++++++++++++++++ color/src/colorspace.rs | 239 +++++++++++++++++++++++++++++++------- color/src/lib.rs | 43 ++++++- 4 files changed, 367 insertions(+), 44 deletions(-) create mode 100644 color/src/chromaticity.rs diff --git a/color/README.md b/color/README.md index 8696fc1..2fdbe47 100644 --- a/color/README.md +++ b/color/README.md @@ -70,7 +70,7 @@ Simplifications include: * Only handling 3-component color spaces (plus optional alpha). * Choosing a fixed, curated set of color spaces for dynamic color types. * Choosing linear sRGB as the central color space. - * Keeping white point implicit. + * Keeping white point implicit in the general conversion operations. A number of other tasks are out of scope for this crate: * Print color spaces (CMYK). diff --git a/color/src/chromaticity.rs b/color/src/chromaticity.rs new file mode 100644 index 0000000..178664d --- /dev/null +++ b/color/src/chromaticity.rs @@ -0,0 +1,127 @@ +// Copyright 2024 the Color Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::{matdiagmatmul, matmatmul, matvecmul}; + +/// CIE `xy` chromaticity, specifying a color in the XYZ color space, but not its luminosity. +/// +/// An absolute color can be specified by adding a luminosity coordinate `Y` as in `xyY`. An `XYZ` +/// color can be calculated from `xyY` as follows. +/// +/// ```text +/// X = Y/y * x +/// Y = Y +/// Z = Y/y * (1 - x - y) +/// ``` +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Chromaticity { + /// The x-coordinate of the CIE `xy` chromaticity. + pub x: f32, + + /// The y-coordinate of the CIE `xy` chromaticity. + pub y: f32, +} + +impl Chromaticity { + /// The CIE D65 white point under the standard 2° observer. + /// + /// This is a common white point for color spaces targeting monitors. + /// + /// The white point's chromaticities are truncated to four digits here, as specified by the + /// CSS Color 4 specification, and following most color spaces using this white point. + pub const D65: Self = Self { + x: 0.3127, + y: 0.3290, + }; + + /// The CIE D50 white point under the standard 2° observer. + /// + /// The white point's chromaticities are truncated to four digits here, as specified by the + /// CSS Color 4 specification, and following most color spaces using this white point. + pub const D50: Self = Self { + x: 0.3457, + y: 0.3585, + }; + + /// The [ACES white point][aceswp]. + /// + /// This is the reference white of [ACEScg](crate::AcesCg) and [ACES2065-1](crate::Aces2065_1). + /// The white point is near the D60 white point under the standard 2° observer. + /// + /// [aceswp]: https://docs.acescentral.com/tb/white-point + pub const ACES: Self = Self { + x: 0.32168, + y: 0.33767, + }; + + /// Convert the `xy` chromaticities to XYZ, assuming `xyY` with `Y=1`. + pub(crate) const fn to_xyz(self) -> [f32; 3] { + let y_recip = 1. / self.y; + [self.x * y_recip, 1., (1. - self.x - self.y) * y_recip] + } + + /// Calculate the 3x3 linear Bradford chromatic adaptation matrix from linear sRGB space. + /// + /// This calculates the matrix going from a reference white of `self` to a reference white of + /// `to`. + pub(crate) const fn linear_srgb_chromatic_adaptation_matrix(self, to: Self) -> [[f32; 3]; 3] { + let bradford_source = matvecmul(&Self::XYZ_TO_BRADFORD, self.to_xyz()); + let bradford_dest = matvecmul(&Self::XYZ_TO_BRADFORD, to.to_xyz()); + + matmatmul( + &matdiagmatmul( + &Self::BRADFORD_TO_SRGB, + [ + bradford_dest[0] / bradford_source[0], + bradford_dest[1] / bradford_source[1], + bradford_dest[2] / bradford_source[2], + ], + ), + &Self::SRGB_TO_BRADFORD, + ) + } + + /// `XYZ_to_Bradford * lin_sRGB_to_XYZ` + const SRGB_TO_BRADFORD: [[f32; 3]; 3] = [ + [ + 1_298_421_353. / 3_072_037_500., + 172_510_403. / 351_090_000., + 32_024_671. / 1_170_300_000., + ], + [ + 85_542_113. / 1_536_018_750., + 7_089_448_151. / 7_372_890_000., + 244_246_729. / 10_532_700_000., + ], + [ + 131_355_661. / 614_4075_000., + 71_798_777. / 819_210_000., + 3_443_292_119. / 3_510_900_000., + ], + ]; + + /// `XYZ_to_lin_sRGB * Bradford_to_XYZ` + const BRADFORD_TO_SRGB: [[f32; 3]; 3] = [ + [ + 3_597_831_250_055_000. / 1_417_335_035_684_489., + -1_833_298_161_702_000. / 1_417_335_035_684_489., + -57_038_163_791_000. / 1_417_335_035_684_489., + ], + [ + -4_593_417_841_453_000. / 31_461_687_363_220_151., + 35_130_825_086_032_200. / 31_461_687_363_220_151., + -702_492_905_752_400. / 31_461_687_363_220_151., + ], + [ + -191_861_334_350_000. / 4_536_975_728_019_583., + -3_248_024_097_900_00. / 4_536_975_728_019_583., + 4_639_090_845_380_000. / 4_536_975_728_019_583., + ], + ]; + + const XYZ_TO_BRADFORD: [[f32; 3]; 3] = [ + [0.8951, 0.2664, -0.1614], + [-0.7502, 1.7135, 0.0367], + [0.0389, -0.0685, 1.0296], + ]; +} diff --git a/color/src/colorspace.rs b/color/src/colorspace.rs index f7d1570..7ec9321 100644 --- a/color/src/colorspace.rs +++ b/color/src/colorspace.rs @@ -3,7 +3,7 @@ use core::{any::TypeId, f32}; -use crate::{matmul, tag::ColorSpaceTag}; +use crate::{matvecmul, tag::ColorSpaceTag, Chromaticity}; #[cfg(all(not(feature = "std"), not(test)))] use crate::floatfuncs::FloatFuncs; @@ -18,9 +18,10 @@ use crate::floatfuncs::FloatFuncs; /// space does not explicitly define a gamut, so generally conversions /// will succeed and round-trip, subject to numerical precision. /// -/// White point is not explicitly represented. For color spaces with a -/// white point other than D65 (the native white point for sRGB), use -/// a linear Bradford chromatic adaptation, following CSS Color 4. +/// White point is handled implicitly in the general conversion methods. For color spaces with a +/// white point other than D65 (the native white point for sRGB), use a linear Bradford chromatic +/// adaptation, following CSS Color 4. The conversion methods suffixed with `_absolute` do not +/// perform chromatic adaptation. /// /// See the [XYZ-D65 color space](`XyzD65`) documentation for some /// background information on color spaces. @@ -86,12 +87,23 @@ pub trait ColorSpace: Clone + Copy + 'static { /// The tag corresponding to this color space, if a matching tag exists. const TAG: Option = None; + /// The white point of the color space. + /// + /// See the [XYZ-D65 color space](`XyzD65`) documentation for some background information on + /// the meaning of "white point." + const WHITE_POINT: Chromaticity = Chromaticity::D65; + /// The component values for the color white within this color space. const WHITE_COMPONENTS: [f32; 3]; /// Convert an opaque color to linear sRGB. /// /// Values are likely to exceed [0, 1] for wide-gamut and HDR colors. + /// + /// This performs chromatic adaptation from the source color space's reference white to the + /// target color space's reference white; see the [XYZ-D65 color space](`XyzD65`) documentation + /// for some background information on the meaning of "reference white." Use + /// [`ColorSpace::to_linear_srgb_absolute`] to convert the absolute color instead. fn to_linear_srgb(src: [f32; 3]) -> [f32; 3]; /// Convert an opaque color from linear sRGB. @@ -99,17 +111,6 @@ pub trait ColorSpace: Clone + Copy + 'static { /// In general, this method should not do any gamut clipping. fn from_linear_srgb(src: [f32; 3]) -> [f32; 3]; - /// Scale the chroma by the given amount. - /// - /// In color spaces with a natural representation of chroma, scale - /// directly. In other color spaces, equivalent results as scaling - /// chroma in Oklab. - fn scale_chroma(src: [f32; 3], scale: f32) -> [f32; 3] { - let rgb = Self::to_linear_srgb(src); - let scaled = LinearSrgb::scale_chroma(rgb, scale); - Self::from_linear_srgb(scaled) - } - /// Convert to a different color space. /// /// The default implementation is a no-op if the color spaces @@ -127,6 +128,132 @@ pub trait ColorSpace: Clone + Copy + 'static { } } + /// Convert an opaque color to linear sRGB, without chromatic adaptation. + /// + /// For most use-cases you should consider using the chromatically-adapting + /// [`ColorSpace::to_linear_srgb`] instead. + /// + /// Values are likely to exceed [0, 1] for wide-gamut and HDR colors. + /// + /// This does not perform chromatic adaptation from the source color space's reference white to + /// sRGB's standard reference white; thereby representing the same absolute color in sRGB. See + /// the [XYZ-D65 color space](`XyzD65`) documentation for some background information on the + /// meaning of "reference white." + /// + /// # Note to implementers + /// + /// The default implementation undoes the chromatic adaptation performed by + /// [`ColorSpace::to_linear_srgb`]. This can be overriden for better performance and greater + /// calculation accuracy. + fn to_linear_srgb_absolute(src: [f32; 3]) -> [f32; 3] { + let lin_srgb = Self::to_linear_srgb(src); + if Self::WHITE_POINT == Chromaticity::D65 { + lin_srgb + } else if Self::WHITE_POINT == Chromaticity::D50 { + // NOTE: we can't use Self::WHITE_POINT in `const` contexts (yet?). We branch on the + // known values instead. + const LIN_SRGB_ADAPTATION_MATRIX: [[f32; 3]; 3] = + Chromaticity::D65.linear_srgb_chromatic_adaptation_matrix(Chromaticity::D50); + matvecmul(&LIN_SRGB_ADAPTATION_MATRIX, lin_srgb) + } else { + let lin_srgb_adaptation_matrix = + Chromaticity::D65.linear_srgb_chromatic_adaptation_matrix(Self::WHITE_POINT); + matvecmul(&lin_srgb_adaptation_matrix, lin_srgb) + } + } + + /// Convert an opaque color from linear sRGB, without chromatic adaptation. + /// + /// For most use-cases you should consider using the chromatically-adapting + /// [`ColorSpace::from_linear_srgb`] instead. + /// + /// In general, this method should not do any gamut clipping. + /// + /// This does not perform chromatic adaptation to the destination color space's reference white + /// from sRGB's standard reference white; thereby representing the same absolute color in the + /// target color space. See the [XYZ-D65 color space](`XyzD65`) documentation for some + /// background information on the meaning of "reference white." + /// + /// # Note to implementers + /// + /// The default implementation undoes the chromatic adaptation performed by + /// [`ColorSpace::from_linear_srgb`]. This can be overriden for better performance and greater + /// calculation accuracy. + fn from_linear_srgb_absolute(src: [f32; 3]) -> [f32; 3] { + if Self::WHITE_POINT == Chromaticity::D65 { + Self::from_linear_srgb(src) + } else if Self::WHITE_POINT == Chromaticity::D50 { + // NOTE: we can't use Self::WHITE_POINT in `const` contexts (yet?). We branch on the + // known values instead. + const LIN_SRGB_ADAPTATION_MATRIX: [[f32; 3]; 3] = + Chromaticity::D50.linear_srgb_chromatic_adaptation_matrix(Chromaticity::D65); + Self::from_linear_srgb(matvecmul(&LIN_SRGB_ADAPTATION_MATRIX, src)) + } else { + let lin_srgb_adaptation_matrix = + Chromaticity::ACES.linear_srgb_chromatic_adaptation_matrix(Chromaticity::D65); + Self::from_linear_srgb(matvecmul(&lin_srgb_adaptation_matrix, src)) + } + } + + /// Convert to a different color space, without chromatic adaptation. + /// + /// For most use-cases you should consider using the chromatically-adapting + /// [`ColorSpace::convert`] instead. + /// + /// This does not perform chromatic adaptation from the source color space's reference white to + /// the destination color space's reference white; thereby representing the same absolute color + /// in the destination color space. See the [XYZ-D65 color space](`XyzD65`) documentation for + /// some background information on the meaning of "reference white." + /// + /// The default implementation is a no-op if the color spaces are the same, otherwise converts + /// from the source to linear sRGB, then from that to the target, without chromatic adaptation. + /// Implementations are encouraged to specialize further (using the [`TypeId`] of the color + /// spaces), effectively finding a shortest path in the conversion graph. + fn convert_absolute(src: [f32; 3]) -> [f32; 3] { + if TypeId::of::() == TypeId::of::() { + src + } else { + let lin_rgb = Self::to_linear_srgb_absolute(src); + TargetCS::from_linear_srgb_absolute(lin_rgb) + } + } + + /// Chromatically adapt the color between the given white point chromaticities. + /// + /// The color is assumed to be under a reference white point of `from` and is chromatically + /// adapted to the given white point `to`. The linear Bradford transform is used to perform the + /// chromatic adaptation. + fn chromatically_adapt(src: [f32; 3], from: Chromaticity, to: Chromaticity) -> [f32; 3] { + if from == to { + return src; + } + + let lin_srgb_adaptation_matrix = if from == Chromaticity::D65 && to == Chromaticity::D50 { + Chromaticity::D65.linear_srgb_chromatic_adaptation_matrix(Chromaticity::D50) + } else if from == Chromaticity::D50 && to == Chromaticity::D65 { + Chromaticity::D50.linear_srgb_chromatic_adaptation_matrix(Chromaticity::D65) + } else { + from.linear_srgb_chromatic_adaptation_matrix(to) + }; + + let lin_srgb_adapted = matvecmul( + &lin_srgb_adaptation_matrix, + Self::to_linear_srgb_absolute(src), + ); + Self::from_linear_srgb_absolute(lin_srgb_adapted) + } + + /// Scale the chroma by the given amount. + /// + /// In color spaces with a natural representation of chroma, scale + /// directly. In other color spaces, equivalent results as scaling + /// chroma in Oklab. + fn scale_chroma(src: [f32; 3], scale: f32) -> [f32; 3] { + let rgb = Self::to_linear_srgb(src); + let scaled = LinearSrgb::scale_chroma(rgb, scale); + Self::from_linear_srgb(scaled) + } + /// Clip the color's components to fit within the natural gamut of the color space. /// /// There are many possible ways to map colors outside of a color space's gamut to colors @@ -216,7 +343,7 @@ impl ColorSpace for LinearSrgb { } fn scale_chroma(src: [f32; 3], scale: f32) -> [f32; 3] { - let lms = matmul(&OKLAB_SRGB_TO_LMS, src).map(f32::cbrt); + let lms = matvecmul(&OKLAB_SRGB_TO_LMS, src).map(f32::cbrt); let l = OKLAB_LMS_TO_LAB[0]; let lightness = l[0] * lms[0] + l[1] * lms[1] + l[2] * lms[2]; let lms_scaled = [ @@ -224,7 +351,7 @@ impl ColorSpace for LinearSrgb { lightness + scale * (lms[1] - lightness), lightness + scale * (lms[2] - lightness), ]; - matmul(&OKLAB_LMS_TO_SRGB, lms_scaled.map(|x| x * x * x)) + matvecmul(&OKLAB_LMS_TO_SRGB, lms_scaled.map(|x| x * x * x)) } fn clip([r, g, b]: [f32; 3]) -> [f32; 3] { @@ -320,7 +447,7 @@ impl ColorSpace for DisplayP3 { [-0.042_056_955, 1.042_056_9, 0.0], [-0.019_637_555, -0.078_636_04, 1.098_273_6], ]; - matmul(&LINEAR_DISPLAYP3_TO_SRGB, src.map(srgb_to_lin)) + matvecmul(&LINEAR_DISPLAYP3_TO_SRGB, src.map(srgb_to_lin)) } fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { @@ -329,7 +456,7 @@ impl ColorSpace for DisplayP3 { [0.033_194_2, 0.966_805_8, 0.0], [0.017_082_632, 0.072_397_44, 0.910_519_96], ]; - matmul(&LINEAR_SRGB_TO_DISPLAYP3, src).map(lin_to_srgb) + matvecmul(&LINEAR_SRGB_TO_DISPLAYP3, src).map(lin_to_srgb) } fn clip([r, g, b]: [f32; 3]) -> [f32; 3] { @@ -378,7 +505,7 @@ impl ColorSpace for A98Rgb { (279_685_764. / 268_173_353.) as f32, ], ]; - matmul( + matvecmul( &LINEAR_A98RGB_TO_SRGB, [r, g, b].map(|x| x.abs().powf(563. / 256.).copysign(x)), ) @@ -403,7 +530,7 @@ impl ColorSpace for A98Rgb { (268_173_353. / 279_685_764.) as f32, ], ]; - matmul(&LINEAR_SRGB_TO_A98RGB, [r, g, b]).map(|x| x.abs().powf(256. / 563.).copysign(x)) + matvecmul(&LINEAR_SRGB_TO_A98RGB, [r, g, b]).map(|x| x.abs().powf(256. / 563.).copysign(x)) } fn clip([r, g, b]: [f32; 3]) -> [f32; 3] { @@ -434,6 +561,7 @@ pub struct ProphotoRgb; impl ColorSpace for ProphotoRgb { const TAG: Option = Some(ColorSpaceTag::ProphotoRgb); + const WHITE_POINT: Chromaticity = Chromaticity::D50; const WHITE_COMPONENTS: [f32; 3] = [1., 1., 1.]; fn to_linear_srgb([r, g, b]: [f32; 3]) -> [f32; 3] { @@ -452,7 +580,7 @@ impl ColorSpace for ProphotoRgb { } } - matmul(&LINEAR_PROPHOTORGB_TO_SRGB, [r, g, b].map(transfer)) + matvecmul(&LINEAR_PROPHOTORGB_TO_SRGB, [r, g, b].map(transfer)) } fn from_linear_srgb([r, g, b]: [f32; 3]) -> [f32; 3] { @@ -471,7 +599,7 @@ impl ColorSpace for ProphotoRgb { } } - matmul(&LINEAR_SRGB_TO_PROPHOTORGB, [r, g, b]).map(transfer) + matvecmul(&LINEAR_SRGB_TO_PROPHOTORGB, [r, g, b]).map(transfer) } fn clip([r, g, b]: [f32; 3]) -> [f32; 3] { @@ -543,7 +671,7 @@ impl ColorSpace for Rec2020 { } } - matmul(&LINEAR_REC2020_TO_SRGB, [r, g, b].map(transfer)) + matvecmul(&LINEAR_REC2020_TO_SRGB, [r, g, b].map(transfer)) } fn from_linear_srgb([r, g, b]: [f32; 3]) -> [f32; 3] { @@ -577,7 +705,7 @@ impl ColorSpace for Rec2020 { (Rec2020::A * x.abs().powf(0.45) - (Rec2020::A - 1.)).copysign(x) } } - matmul(&LINEAR_SRGB_TO_REC2020, [r, g, b]).map(transfer) + matvecmul(&LINEAR_SRGB_TO_REC2020, [r, g, b]).map(transfer) } fn clip([r, g, b]: [f32; 3]) -> [f32; 3] { @@ -613,6 +741,7 @@ impl ColorSpace for Aces2065_1 { const TAG: Option = Some(ColorSpaceTag::Aces2065_1); + const WHITE_POINT: Chromaticity = Chromaticity::ACES; const WHITE_COMPONENTS: [f32; 3] = [1.0, 1.0, 1.0]; fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] { @@ -622,7 +751,7 @@ impl ColorSpace for Aces2065_1 { [-0.276_479_9, 1.372_719, -0.096_239_17], [-0.015_378_065, -0.152_975_34, 1.168_353_4], ]; - matmul(&ACES2065_1_TO_LINEAR_SRGB, src) + matvecmul(&ACES2065_1_TO_LINEAR_SRGB, src) } fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { @@ -632,7 +761,7 @@ impl ColorSpace for Aces2065_1 { [0.089_776_44, 0.813_439_4, 0.096_784_13], [0.017_541_17, 0.111_546_55, 0.870_912_25], ]; - matmul(&LINEAR_SRGB_TO_ACES2065_1, src) + matvecmul(&LINEAR_SRGB_TO_ACES2065_1, src) } fn clip([r, g, b]: [f32; 3]) -> [f32; 3] { @@ -670,6 +799,7 @@ impl ColorSpace for AcesCg { const TAG: Option = Some(ColorSpaceTag::AcesCg); + const WHITE_POINT: Chromaticity = Chromaticity::ACES; const WHITE_COMPONENTS: [f32; 3] = [1.0, 1.0, 1.0]; fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] { @@ -679,7 +809,7 @@ impl ColorSpace for AcesCg { [-0.130_256_41, 1.140_804_8, -0.010_548_319], [-0.024_003_357, -0.128_968_97, 1.152_972_3], ]; - matmul(&ACESCG_TO_LINEAR_SRGB, src) + matvecmul(&ACESCG_TO_LINEAR_SRGB, src) } fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { @@ -689,7 +819,7 @@ impl ColorSpace for AcesCg { [0.070_193_72, 0.916_353_9, 0.013_452_399], [0.020_615_593, 0.109_569_77, 0.869_814_63], ]; - matmul(&LINEAR_SRGB_TO_ACESCG, src) + matvecmul(&LINEAR_SRGB_TO_ACESCG, src) } fn clip([r, g, b]: [f32; 3]) -> [f32; 3] { @@ -724,6 +854,7 @@ impl ColorSpace for XyzD50 { const TAG: Option = Some(ColorSpaceTag::XyzD50); + const WHITE_POINT: Chromaticity = Chromaticity::D50; const WHITE_COMPONENTS: [f32; 3] = [3457. / 3585., 1., 986. / 1195.]; fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] { @@ -733,7 +864,7 @@ impl ColorSpace for XyzD50 { [-0.978_795_47, 1.916_254_4, 0.033_442_874], [0.071_955_39, -0.228_976_76, 1.405_386_1], ]; - matmul(&XYZ_TO_LINEAR_SRGB, src) + matvecmul(&XYZ_TO_LINEAR_SRGB, src) } fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { @@ -743,7 +874,7 @@ impl ColorSpace for XyzD50 { [0.222_493_17, 0.716_887, 0.060_619_81], [0.013_923_922, 0.097_081_326, 0.714_099_35], ]; - matmul(&LINEAR_SRGB_TO_XYZ, src) + matvecmul(&LINEAR_SRGB_TO_XYZ, src) } fn clip([x, y, z]: [f32; 3]) -> [f32; 3] { @@ -817,7 +948,7 @@ impl ColorSpace for XyzD65 { [-0.969_243_65, 1.875_967_5, 0.041_555_06], [0.055_630_08, -0.203_976_96, 1.056_971_5], ]; - matmul(&XYZ_TO_LINEAR_SRGB, src) + matvecmul(&XYZ_TO_LINEAR_SRGB, src) } fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { @@ -826,7 +957,7 @@ impl ColorSpace for XyzD65 { [0.212_639, 0.715_168_65, 0.072_192_32], [0.019_330_818, 0.119_194_78, 0.950_532_14], ]; - matmul(&LINEAR_SRGB_TO_XYZ, src) + matvecmul(&LINEAR_SRGB_TO_XYZ, src) } fn clip([x, y, z]: [f32; 3]) -> [f32; 3] { @@ -889,13 +1020,13 @@ impl ColorSpace for Oklab { const WHITE_COMPONENTS: [f32; 3] = [1., 0., 0.]; fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] { - let lms = matmul(&OKLAB_LAB_TO_LMS, src).map(|x| x * x * x); - matmul(&OKLAB_LMS_TO_SRGB, lms) + let lms = matvecmul(&OKLAB_LAB_TO_LMS, src).map(|x| x * x * x); + matvecmul(&OKLAB_LMS_TO_SRGB, lms) } fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { - let lms = matmul(&OKLAB_SRGB_TO_LMS, src).map(f32::cbrt); - matmul(&OKLAB_LMS_TO_LAB, lms) + let lms = matvecmul(&OKLAB_SRGB_TO_LMS, src).map(f32::cbrt); + matvecmul(&OKLAB_LMS_TO_LAB, lms) } fn scale_chroma([l, a, b]: [f32; 3], scale: f32) -> [f32; 3] { @@ -1051,11 +1182,11 @@ impl ColorSpace for Lab { (116. / KAPPA) * value - (16. / KAPPA) } }); - matmul(&LAB_XYZ_TO_SRGB, xyz) + matvecmul(&LAB_XYZ_TO_SRGB, xyz) } fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { - let xyz = matmul(&LAB_SRGB_TO_XYZ, src); + let xyz = matvecmul(&LAB_SRGB_TO_XYZ, src); let f = xyz.map(|value| { if value > EPSILON { value.cbrt() @@ -1332,8 +1463,8 @@ impl ColorSpace for Hwb { #[cfg(test)] mod tests { use crate::{ - A98Rgb, Aces2065_1, AcesCg, ColorSpace, DisplayP3, Hsl, Hwb, Lab, Lch, LinearSrgb, Oklab, - Oklch, OpaqueColor, ProphotoRgb, Rec2020, Srgb, XyzD50, XyzD65, + A98Rgb, Aces2065_1, AcesCg, Chromaticity, ColorSpace, DisplayP3, Hsl, Hwb, Lab, Lch, + LinearSrgb, Oklab, Oklch, OpaqueColor, ProphotoRgb, Rec2020, Srgb, XyzD50, XyzD65, }; #[must_use] @@ -1515,4 +1646,30 @@ mod tests { )); } } + + #[test] + fn absolute_conversion() { + assert!(almost_equal::( + Srgb::convert_absolute::([0.5, 0.2, 0.4]), + // Calculated using colour-science (https://github.com/colour-science/colour) with + // `chromatic_adaptation_transform=None` + [0.14628284, 0.04714393, 0.13361104], + 1e-4, + )); + + assert!(almost_equal::( + Srgb::convert_absolute::([0.5, 0.2, 0.4]), + Srgb::convert::([0.5, 0.2, 0.4]), + 1e-4, + )); + } + + #[test] + fn chromatic_adaptation() { + assert!(almost_equal::( + XyzD50::convert_absolute::(Srgb::convert::([0.5, 0.2, 0.4])), + Srgb::chromatically_adapt([0.5, 0.2, 0.4], Chromaticity::D65, Chromaticity::D50), + 1e-4, + )); + } } diff --git a/color/src/lib.rs b/color/src/lib.rs index 2fc170d..208314c 100644 --- a/color/src/lib.rs +++ b/color/src/lib.rs @@ -38,7 +38,7 @@ //! * Only handling 3-component color spaces (plus optional alpha). //! * Choosing a fixed, curated set of color spaces for dynamic color types. //! * Choosing linear sRGB as the central color space. -//! * Keeping white point implicit. +//! * Keeping white point implicit in the general conversion operations. //! //! A number of other tasks are out of scope for this crate: //! * Print color spaces (CMYK). @@ -85,6 +85,7 @@ #![cfg_attr(all(not(feature = "std"), not(test)), no_std)] pub mod cache_key; +mod chromaticity; mod color; mod colorspace; mod dynamic; @@ -106,6 +107,7 @@ mod impl_bytemuck; #[cfg(all(not(feature = "std"), not(test)))] mod floatfuncs; +pub use chromaticity::Chromaticity; pub use color::{AlphaColor, HueDirection, OpaqueColor, PremulColor}; pub use colorspace::{ A98Rgb, Aces2065_1, AcesCg, ColorSpace, ColorSpaceLayout, DisplayP3, Hsl, Hwb, Lab, Lch, @@ -122,7 +124,8 @@ const fn u8_to_f32(x: u8) -> f32 { x as f32 * (1.0 / 255.0) } -fn matmul(m: &[[f32; 3]; 3], x: [f32; 3]) -> [f32; 3] { +/// Multiplication `m * x` of a 3x3-matrix `m` and a 3-vector `x`. +const fn matvecmul(m: &[[f32; 3]; 3], x: [f32; 3]) -> [f32; 3] { [ m[0][0] * x[0] + m[0][1] * x[1] + m[0][2] * x[2], m[1][0] * x[0] + m[1][1] * x[1] + m[1][2] * x[2], @@ -130,6 +133,42 @@ fn matmul(m: &[[f32; 3]; 3], x: [f32; 3]) -> [f32; 3] { ] } +/// Multiplication `ma * mb` of two 3x3-matrices `ma` and `mb`. +const fn matmatmul(ma: &[[f32; 3]; 3], mb: &[[f32; 3]; 3]) -> [[f32; 3]; 3] { + [ + [ + ma[0][0] * mb[0][0] + ma[0][1] * mb[1][0] + ma[0][2] * mb[2][0], + ma[0][0] * mb[0][1] + ma[0][1] * mb[1][1] + ma[0][2] * mb[2][1], + ma[0][0] * mb[0][2] + ma[0][1] * mb[1][2] + ma[0][2] * mb[2][2], + ], + [ + ma[1][0] * mb[0][0] + ma[1][1] * mb[1][0] + ma[1][2] * mb[2][0], + ma[1][0] * mb[0][1] + ma[1][1] * mb[1][1] + ma[1][2] * mb[2][1], + ma[1][0] * mb[0][2] + ma[1][1] * mb[1][2] + ma[1][2] * mb[2][2], + ], + [ + ma[2][0] * mb[0][0] + ma[2][1] * mb[1][0] + ma[2][2] * mb[2][0], + ma[2][0] * mb[0][1] + ma[2][1] * mb[1][1] + ma[2][2] * mb[2][1], + ma[2][0] * mb[0][2] + ma[2][1] * mb[1][2] + ma[2][2] * mb[2][2], + ], + ] +} + +/// Multiplication `ma * mb` of a 3x3-matrix `ma` by a 3x3-diagonal matrix `mb`. +/// +/// Diagonal matrix `mb` is given by +/// +/// [ mb[0] 0 0 ] +/// [ 0 mb[1] 0 ] +/// [ 0 0 mb[2] ] +const fn matdiagmatmul(ma: &[[f32; 3]; 3], mb: [f32; 3]) -> [[f32; 3]; 3] { + [ + [ma[0][0] * mb[0], ma[0][1] * mb[1], ma[0][2] * mb[2]], + [ma[1][0] * mb[0], ma[1][1] * mb[1], ma[1][2] * mb[2]], + [ma[2][0] * mb[0], ma[2][1] * mb[1], ma[2][2] * mb[2]], + ] +} + impl AlphaColor { /// Create a color from 8-bit rgba values. /// From bbdfdca35408b2e9779637b86d69907c3f99dc16 Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Tue, 4 Feb 2025 15:17:04 +0100 Subject: [PATCH 02/10] Formatting, clippy --- color/src/chromaticity.rs | 4 ++-- color/src/colorspace.rs | 4 ++-- color/src/lib.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/color/src/chromaticity.rs b/color/src/chromaticity.rs index 178664d..d6528f5 100644 --- a/color/src/chromaticity.rs +++ b/color/src/chromaticity.rs @@ -94,7 +94,7 @@ impl Chromaticity { 244_246_729. / 10_532_700_000., ], [ - 131_355_661. / 614_4075_000., + 131_355_661. / 6_144_075_000., 71_798_777. / 819_210_000., 3_443_292_119. / 3_510_900_000., ], @@ -114,7 +114,7 @@ impl Chromaticity { ], [ -191_861_334_350_000. / 4_536_975_728_019_583., - -3_248_024_097_900_00. / 4_536_975_728_019_583., + -324_802_409_790_000. / 4_536_975_728_019_583., 4_639_090_845_380_000. / 4_536_975_728_019_583., ], ]; diff --git a/color/src/colorspace.rs b/color/src/colorspace.rs index 7ec9321..f7c8944 100644 --- a/color/src/colorspace.rs +++ b/color/src/colorspace.rs @@ -143,7 +143,7 @@ pub trait ColorSpace: Clone + Copy + 'static { /// # Note to implementers /// /// The default implementation undoes the chromatic adaptation performed by - /// [`ColorSpace::to_linear_srgb`]. This can be overriden for better performance and greater + /// [`ColorSpace::to_linear_srgb`]. This can be overridden for better performance and greater /// calculation accuracy. fn to_linear_srgb_absolute(src: [f32; 3]) -> [f32; 3] { let lin_srgb = Self::to_linear_srgb(src); @@ -177,7 +177,7 @@ pub trait ColorSpace: Clone + Copy + 'static { /// # Note to implementers /// /// The default implementation undoes the chromatic adaptation performed by - /// [`ColorSpace::from_linear_srgb`]. This can be overriden for better performance and greater + /// [`ColorSpace::from_linear_srgb`]. This can be overridden for better performance and greater /// calculation accuracy. fn from_linear_srgb_absolute(src: [f32; 3]) -> [f32; 3] { if Self::WHITE_POINT == Chromaticity::D65 { diff --git a/color/src/lib.rs b/color/src/lib.rs index 208314c..6e803fc 100644 --- a/color/src/lib.rs +++ b/color/src/lib.rs @@ -156,7 +156,7 @@ const fn matmatmul(ma: &[[f32; 3]; 3], mb: &[[f32; 3]; 3]) -> [[f32; 3]; 3] { /// Multiplication `ma * mb` of a 3x3-matrix `ma` by a 3x3-diagonal matrix `mb`. /// -/// Diagonal matrix `mb` is given by +/// Diagonal matrix `mb` is given by /// /// [ mb[0] 0 0 ] /// [ 0 mb[1] 0 ] From f1119bca901bf5a05fcc216390f98781132851e6 Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Tue, 4 Feb 2025 15:28:36 +0100 Subject: [PATCH 03/10] Fix doc --- color/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/color/src/lib.rs b/color/src/lib.rs index 6e803fc..b4775c2 100644 --- a/color/src/lib.rs +++ b/color/src/lib.rs @@ -158,9 +158,11 @@ const fn matmatmul(ma: &[[f32; 3]; 3], mb: &[[f32; 3]; 3]) -> [[f32; 3]; 3] { /// /// Diagonal matrix `mb` is given by /// +/// ```text /// [ mb[0] 0 0 ] /// [ 0 mb[1] 0 ] /// [ 0 0 mb[2] ] +/// ``` const fn matdiagmatmul(ma: &[[f32; 3]; 3], mb: [f32; 3]) -> [[f32; 3]; 3] { [ [ma[0][0] * mb[0], ma[0][1] * mb[1], ma[0][2] * mb[2]], From 65ae80fbfbb93f07d9b3d4a42fe6b885f470705b Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Fri, 7 Feb 2025 12:44:19 +0100 Subject: [PATCH 04/10] Use inline const blocks to allow referring to associated const --- color/src/colorspace.rs | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/color/src/colorspace.rs b/color/src/colorspace.rs index f7c8944..a0a518a 100644 --- a/color/src/colorspace.rs +++ b/color/src/colorspace.rs @@ -149,15 +149,10 @@ pub trait ColorSpace: Clone + Copy + 'static { let lin_srgb = Self::to_linear_srgb(src); if Self::WHITE_POINT == Chromaticity::D65 { lin_srgb - } else if Self::WHITE_POINT == Chromaticity::D50 { - // NOTE: we can't use Self::WHITE_POINT in `const` contexts (yet?). We branch on the - // known values instead. - const LIN_SRGB_ADAPTATION_MATRIX: [[f32; 3]; 3] = - Chromaticity::D65.linear_srgb_chromatic_adaptation_matrix(Chromaticity::D50); - matvecmul(&LIN_SRGB_ADAPTATION_MATRIX, lin_srgb) } else { - let lin_srgb_adaptation_matrix = - Chromaticity::D65.linear_srgb_chromatic_adaptation_matrix(Self::WHITE_POINT); + let lin_srgb_adaptation_matrix = const { + Chromaticity::D65.linear_srgb_chromatic_adaptation_matrix(Self::WHITE_POINT) + }; matvecmul(&lin_srgb_adaptation_matrix, lin_srgb) } } @@ -180,19 +175,15 @@ pub trait ColorSpace: Clone + Copy + 'static { /// [`ColorSpace::from_linear_srgb`]. This can be overridden for better performance and greater /// calculation accuracy. fn from_linear_srgb_absolute(src: [f32; 3]) -> [f32; 3] { - if Self::WHITE_POINT == Chromaticity::D65 { - Self::from_linear_srgb(src) - } else if Self::WHITE_POINT == Chromaticity::D50 { - // NOTE: we can't use Self::WHITE_POINT in `const` contexts (yet?). We branch on the - // known values instead. - const LIN_SRGB_ADAPTATION_MATRIX: [[f32; 3]; 3] = - Chromaticity::D50.linear_srgb_chromatic_adaptation_matrix(Chromaticity::D65); - Self::from_linear_srgb(matvecmul(&LIN_SRGB_ADAPTATION_MATRIX, src)) + let lin_srgb_adapted = if Self::WHITE_POINT == Chromaticity::D65 { + src } else { - let lin_srgb_adaptation_matrix = - Chromaticity::ACES.linear_srgb_chromatic_adaptation_matrix(Chromaticity::D65); - Self::from_linear_srgb(matvecmul(&lin_srgb_adaptation_matrix, src)) - } + let lin_srgb_adaptation_matrix = const { + Self::WHITE_POINT.linear_srgb_chromatic_adaptation_matrix(Chromaticity::D65) + }; + matvecmul(&lin_srgb_adaptation_matrix, src) + }; + Self::from_linear_srgb(lin_srgb_adapted) } /// Convert to a different color space, without chromatic adaptation. From 8ed7c6a1c806392684b7be032d30dc099e87ff09 Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Fri, 7 Feb 2025 12:52:50 +0100 Subject: [PATCH 05/10] Add 'colour-science' name to ignored typos' --- .typos.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.typos.toml b/.typos.toml index e4a0f9b..3dba607 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,6 +1,11 @@ # See the configuration reference at # https://github.com/crate-ci/typos/blob/master/docs/reference.md +[default] +extend-ignore-re = [ + "colour-science" +] + # Corrections take the form of a key/value pair. The key is the incorrect word # and the value is the correct word. If the key and value are the same, the # word is treated as always correct. If the value is an empty string, the word From 22f7ffc26102a822295622e61ced4ad8909a9274 Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Fri, 7 Feb 2025 12:55:42 +0100 Subject: [PATCH 06/10] TOML formatting --- .typos.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.typos.toml b/.typos.toml index 3dba607..ac633ef 100644 --- a/.typos.toml +++ b/.typos.toml @@ -2,9 +2,7 @@ # https://github.com/crate-ci/typos/blob/master/docs/reference.md [default] -extend-ignore-re = [ - "colour-science" -] +extend-ignore-re = ["colour-science"] # Corrections take the form of a key/value pair. The key is the incorrect word # and the value is the correct word. If the key and value are the same, the From 9d44aef5d2a07db7f893f8568a594e38dcfd3f69 Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Sat, 8 Feb 2025 12:02:41 +0100 Subject: [PATCH 07/10] Implement conversion methods for tags / dynamic colors --- color/src/dynamic.rs | 29 +++++++++++++--- color/src/tag.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/color/src/dynamic.rs b/color/src/dynamic.rs index f1c1f91..3215c05 100644 --- a/color/src/dynamic.rs +++ b/color/src/dynamic.rs @@ -89,9 +89,9 @@ impl DynamicColor { } } - #[must_use] - /// Convert to a different color space. - pub fn convert(self, cs: ColorSpaceTag) -> Self { + /// The const-generic parameter `ABSOLUTE` indicates whether the conversion performs chromatic + /// adaptation. When `ABSOLUTE` is `true`, no chromatic adaptation is performed. + fn convert_impl(self, cs: ColorSpaceTag) -> Self { if self.cs == cs { // Note: §12 suggests that changing powerless to missing happens // even when the color is already in the interpolation color space, @@ -99,7 +99,11 @@ impl DynamicColor { self } else { let (opaque, alpha) = split_alpha(self.components); - let mut components = add_alpha(self.cs.convert(cs, opaque), alpha); + let mut components = if ABSOLUTE { + add_alpha(self.cs.convert_absolute(cs, opaque), alpha) + } else { + add_alpha(self.cs.convert(cs, opaque), alpha) + }; // Reference: §12.2 of Color 4 spec let missing = if !self.flags.missing().is_empty() { if self.cs.same_analogous(cs) { @@ -135,6 +139,23 @@ impl DynamicColor { } } + #[must_use] + /// Convert to a different color space. + pub fn convert(self, cs: ColorSpaceTag) -> Self { + self.convert_impl::(cs) + } + + #[must_use] + /// Convert to a different color space, without chromatic adaptation. + /// + /// For most use-cases you should consider using the chromatically-adapting + /// [`DynamicColor::convert`] instead. + /// + /// See the documentation on [`ColorSpace::convert_absolute`] for more information. + pub fn convert_absolute(self, cs: ColorSpaceTag) -> Self { + self.convert_impl::(cs) + } + /// Set any missing components to zero. /// /// We have a soft invariant that any bit set in the missing bitflag has diff --git a/color/src/tag.rs b/color/src/tag.rs index 22b2d56..95ab1c2 100644 --- a/color/src/tag.rs +++ b/color/src/tag.rs @@ -221,6 +221,86 @@ impl ColorSpaceTag { } } + /// Convert an opaque color from linear sRGB, without chromatic adaptation. + /// + /// For most use-cases you should consider using the chromatically-adapting + /// [`ColorSpaceTag::from_linear_srgb`] instead. + /// + /// This is the tagged counterpart of [`ColorSpace::from_linear_srgb_absolute`]. + pub fn from_linear_srgb_absolute(self, rgb: [f32; 3]) -> [f32; 3] { + match self { + Self::Srgb => Srgb::from_linear_srgb_absolute(rgb), + Self::LinearSrgb => rgb, + Self::Lab => Lab::from_linear_srgb_absolute(rgb), + Self::Lch => Lch::from_linear_srgb_absolute(rgb), + Self::Oklab => Oklab::from_linear_srgb_absolute(rgb), + Self::Oklch => Oklch::from_linear_srgb_absolute(rgb), + Self::DisplayP3 => DisplayP3::from_linear_srgb_absolute(rgb), + Self::A98Rgb => A98Rgb::from_linear_srgb_absolute(rgb), + Self::ProphotoRgb => ProphotoRgb::from_linear_srgb_absolute(rgb), + Self::Rec2020 => Rec2020::from_linear_srgb_absolute(rgb), + Self::Aces2065_1 => Aces2065_1::from_linear_srgb_absolute(rgb), + Self::AcesCg => AcesCg::from_linear_srgb_absolute(rgb), + Self::XyzD50 => XyzD50::from_linear_srgb_absolute(rgb), + Self::XyzD65 => XyzD65::from_linear_srgb_absolute(rgb), + Self::Hsl => Hsl::from_linear_srgb_absolute(rgb), + Self::Hwb => Hwb::from_linear_srgb_absolute(rgb), + } + } + + /// Convert an opaque color to linear sRGB, without chromatic adaptation. + /// + /// For most use-cases you should consider using the chromatically-adapting + /// [`ColorSpaceTag::to_linear_srgb`] instead. + /// + /// This is the tagged counterpart of [`ColorSpace::to_linear_srgb_absolute`]. + pub fn to_linear_srgb_absolute(self, src: [f32; 3]) -> [f32; 3] { + match self { + Self::Srgb => Srgb::to_linear_srgb_absolute(src), + Self::LinearSrgb => src, + Self::Lab => Lab::to_linear_srgb_absolute(src), + Self::Lch => Lch::to_linear_srgb_absolute(src), + Self::Oklab => Oklab::to_linear_srgb_absolute(src), + Self::Oklch => Oklch::to_linear_srgb_absolute(src), + Self::DisplayP3 => DisplayP3::to_linear_srgb_absolute(src), + Self::A98Rgb => A98Rgb::to_linear_srgb_absolute(src), + Self::ProphotoRgb => ProphotoRgb::to_linear_srgb_absolute(src), + Self::Rec2020 => Rec2020::to_linear_srgb_absolute(src), + Self::Aces2065_1 => Aces2065_1::to_linear_srgb_absolute(src), + Self::AcesCg => AcesCg::to_linear_srgb_absolute(src), + Self::XyzD50 => XyzD50::to_linear_srgb_absolute(src), + Self::XyzD65 => XyzD65::to_linear_srgb_absolute(src), + Self::Hsl => Hsl::to_linear_srgb_absolute(src), + Self::Hwb => Hwb::to_linear_srgb_absolute(src), + } + } + + /// Convert the color components into the target color space, without chromatic adaptation. + /// + /// For most use-cases you should consider using the chromatically-adapting + /// [`ColorSpaceTag::convert`] instead. + /// + /// This is the tagged counterpart of [`ColorSpace::convert_absolute`]. See the documentation + /// on [`ColorSpace::convert_absolute`] for more information. + pub fn convert_absolute(self, target: Self, src: [f32; 3]) -> [f32; 3] { + match (self, target) { + _ if self == target => src, + (Self::Oklab, Self::Oklch) | (Self::Lab, Self::Lch) => { + Oklab::convert_absolute::(src) + } + (Self::Oklch, Self::Oklab) | (Self::Lch, Self::Lab) => { + Oklch::convert_absolute::(src) + } + (Self::Srgb, Self::Hsl) => Srgb::convert_absolute::(src), + (Self::Hsl, Self::Srgb) => Hsl::convert_absolute::(src), + (Self::Srgb, Self::Hwb) => Srgb::convert_absolute::(src), + (Self::Hwb, Self::Srgb) => Hwb::convert_absolute::(src), + (Self::Hsl, Self::Hwb) => Hsl::convert_absolute::(src), + (Self::Hwb, Self::Hsl) => Hwb::convert_absolute::(src), + _ => target.from_linear_srgb_absolute(self.to_linear_srgb_absolute(src)), + } + } + /// Scale the chroma by the given amount. /// /// This is the tagged counterpart of [`ColorSpace::scale_chroma`]. From ea7fde21b4b2c3efa92466d4144ebb0d238d2c81 Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Sat, 8 Feb 2025 12:21:49 +0100 Subject: [PATCH 08/10] Implement chromatic adaptation for tags / dynamic colors --- color/src/dynamic.rs | 32 +++++++++++++++++++++++++++----- color/src/tag.rs | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/color/src/dynamic.rs b/color/src/dynamic.rs index 3215c05..cbd2e14 100644 --- a/color/src/dynamic.rs +++ b/color/src/dynamic.rs @@ -6,8 +6,8 @@ use crate::{ cache_key::{BitEq, BitHash}, color::{add_alpha, fixup_hues_for_interpolate, split_alpha}, - AlphaColor, ColorSpace, ColorSpaceLayout, ColorSpaceTag, Flags, HueDirection, LinearSrgb, - Missing, + AlphaColor, Chromaticity, ColorSpace, ColorSpaceLayout, ColorSpaceTag, Flags, HueDirection, + LinearSrgb, Missing, }; use core::hash::{Hash, Hasher}; @@ -149,13 +149,35 @@ impl DynamicColor { /// Convert to a different color space, without chromatic adaptation. /// /// For most use-cases you should consider using the chromatically-adapting - /// [`DynamicColor::convert`] instead. - /// - /// See the documentation on [`ColorSpace::convert_absolute`] for more information. + /// [`DynamicColor::convert`] instead. See the documentation on + /// [`ColorSpace::convert_absolute`] for more information. pub fn convert_absolute(self, cs: ColorSpaceTag) -> Self { self.convert_impl::(cs) } + #[must_use] + /// Chromatically adapt the color between the given white point chromaticities. + /// + /// The color is assumed to be under a reference white point of `from` and is chromatically + /// adapted to the given white point `to`. The linear Bradford transform is used to perform the + /// chromatic adaptation. + pub fn chromatically_adapt(self, from: Chromaticity, to: Chromaticity) -> Self { + if from == to { + return self; + } + + // Treat missing components as zero, as per CSS Color Module Level 4 § 4.4. + let (opaque, alpha) = split_alpha(self.zero_missing_components().components); + let components = add_alpha(self.cs.chromatically_adapt(opaque, from, to), alpha); + Self { + cs: self.cs, + // After chromatically adapting the color, components may no longer be missing. Don't + // forward the flags. + flags: Flags::default(), + components, + } + } + /// Set any missing components to zero. /// /// We have a soft invariant that any bit set in the missing bitflag has diff --git a/color/src/tag.rs b/color/src/tag.rs index 95ab1c2..ebd5f91 100644 --- a/color/src/tag.rs +++ b/color/src/tag.rs @@ -4,8 +4,8 @@ //! The color space tag enum. use crate::{ - A98Rgb, Aces2065_1, AcesCg, ColorSpace, ColorSpaceLayout, DisplayP3, Hsl, Hwb, Lab, Lch, - LinearSrgb, Missing, Oklab, Oklch, ProphotoRgb, Rec2020, Srgb, XyzD50, XyzD65, + A98Rgb, Aces2065_1, AcesCg, Chromaticity, ColorSpace, ColorSpaceLayout, DisplayP3, Hsl, Hwb, + Lab, Lch, LinearSrgb, Missing, Oklab, Oklch, ProphotoRgb, Rec2020, Srgb, XyzD50, XyzD65, }; /// The color space tag for [dynamic colors]. @@ -301,6 +301,34 @@ impl ColorSpaceTag { } } + /// Chromatically adapt the color between the given white point chromaticities. + /// + /// This is the tagged counterpart of [`ColorSpace::chromatically_adapt`]. + /// + /// The color is assumed to be under a reference white point of `from` and is chromatically + /// adapted to the given white point `to`. The linear Bradford transform is used to perform the + /// chromatic adaptation. + pub fn chromatically_adapt(self, src: [f32; 3], from: Chromaticity, to: Chromaticity) -> [f32; 3] { + match self { + Self::Srgb => Srgb::chromatically_adapt(src, from, to), + Self::LinearSrgb => LinearSrgb::chromatically_adapt(src, from, to), + Self::Lab => Lab::chromatically_adapt(src, from, to), + Self::Lch => Lch::chromatically_adapt(src, from, to), + Self::Oklab => Oklab::chromatically_adapt(src, from, to), + Self::Oklch => Oklch::chromatically_adapt(src, from, to), + Self::DisplayP3 => DisplayP3::chromatically_adapt(src, from, to), + Self::A98Rgb => A98Rgb::chromatically_adapt(src, from, to), + Self::ProphotoRgb => ProphotoRgb::chromatically_adapt(src, from, to), + Self::Rec2020 => Rec2020::chromatically_adapt(src, from, to), + Self::Aces2065_1 => Aces2065_1::chromatically_adapt(src, from, to), + Self::AcesCg => AcesCg::chromatically_adapt(src, from, to), + Self::XyzD50 => XyzD50::chromatically_adapt(src, from, to), + Self::XyzD65 => XyzD65::chromatically_adapt(src, from, to), + Self::Hsl => Hsl::chromatically_adapt(src, from, to), + Self::Hwb => Hwb::chromatically_adapt(src, from, to), + } + } + /// Scale the chroma by the given amount. /// /// This is the tagged counterpart of [`ColorSpace::scale_chroma`]. From 6293a8e8ae53b5ec62b4feaf3175e68429c12b96 Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Sun, 9 Feb 2025 11:13:18 +0100 Subject: [PATCH 09/10] Fmt --- color/src/tag.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/color/src/tag.rs b/color/src/tag.rs index ebd5f91..da5d409 100644 --- a/color/src/tag.rs +++ b/color/src/tag.rs @@ -308,7 +308,12 @@ impl ColorSpaceTag { /// The color is assumed to be under a reference white point of `from` and is chromatically /// adapted to the given white point `to`. The linear Bradford transform is used to perform the /// chromatic adaptation. - pub fn chromatically_adapt(self, src: [f32; 3], from: Chromaticity, to: Chromaticity) -> [f32; 3] { + pub fn chromatically_adapt( + self, + src: [f32; 3], + from: Chromaticity, + to: Chromaticity, + ) -> [f32; 3] { match self { Self::Srgb => Srgb::chromatically_adapt(src, from, to), Self::LinearSrgb => LinearSrgb::chromatically_adapt(src, from, to), From ff14bdfc418f61e501a974938f13e25e57c092cb Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Thu, 13 Feb 2025 10:07:40 +0100 Subject: [PATCH 10/10] Changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e3df5..ced248e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ You can find its changes [documented below](#023-2025-01-20). This release has an [MSRV][] of 1.82. +### Added + +* Support converting between color spaces without chromatic adaptation, thereby representing the same absolute color in the destination color space as in the source color space. ([#139][] by [@tomcur][]) + + **Note to `ColorSpace` implementers:** the `WHITE_POINT` associated constant is added to `ColorSpace`, defaulting to D65. + Implementations with a non-D65 white point should set this constant to get correct default absolute conversion behavior. +* Support manual chromatic adaptation of colors between arbitrary white point chromaticities. ([#139][] by [@tomcur][]) + ## [0.2.3][] (2025-01-20) This release has an [MSRV][] of 1.82. @@ -127,6 +135,7 @@ This is the initial release. [#130]: https://github.com/linebender/color/pull/130 [#135]: https://github.com/linebender/color/pull/135 [#136]: https://github.com/linebender/color/pull/136 +[#139]: https://github.com/linebender/color/pull/139 [Unreleased]: https://github.com/linebender/color/compare/v0.2.3...HEAD [0.2.3]: https://github.com/linebender/color/releases/tag/v0.2.3