From 667d140802d813919c7f1c12594f5afe4eef885c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:52:27 -0400 Subject: [PATCH 01/31] feat(icc): build ICC profiles from named colour spaces and CICP Add `IccProfile::builtin`, `gray_with_gamma`, `from_cicp` and `from_source_profile`: spec-valid v4 matrix/TRC display profiles for the colour spaces gamut-color already carries colorimetry for, and for the H.273 code-point triple AVIF/HEIC/JXL usually signal instead of embedding a profile. gamut-icc gains a normal dependency on gamut-color. The constructors need gamut-color's colorimetry and gamut-icc's serializer, and only one of the two can own that edge: gamut-color is the primitive (fan-in 8) with no need of a serializer, so pointing it the other way would invert the layering. Primaries, white point and transfer are read from gamut-color rather than restated, so the buildable set is exactly what it can express on the two CICP axes. Adobe RGB and ProPhoto RGB have no code point on either axis and their chromaticities are private to gamut-color, so they are declined rather than approximated. --- Cargo.lock | 1 + crates/gamut-icc/Cargo.toml | 5 + crates/gamut-icc/src/builtin.rs | 744 ++++++++++++++++++++++++++++++++ crates/gamut-icc/src/lib.rs | 21 +- 4 files changed, 767 insertions(+), 4 deletions(-) create mode 100644 crates/gamut-icc/src/builtin.rs diff --git a/Cargo.lock b/Cargo.lock index fcdd8e15..84e147d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -746,6 +746,7 @@ dependencies = [ name = "gamut-icc" version = "1.0.0" dependencies = [ + "gamut-color", "gamut-core", "lcms2-oracle", "md-5", diff --git a/crates/gamut-icc/Cargo.toml b/crates/gamut-icc/Cargo.toml index bcd1d678..44b7af6b 100644 --- a/crates/gamut-icc/Cargo.toml +++ b/crates/gamut-icc/Cargo.toml @@ -16,6 +16,11 @@ categories.workspace = true workspace = true [dependencies] +# Colorimetry for the built-in profile constructors (`src/builtin.rs`): the CICP code-point +# chromaticity tables, the RGB->XYZ / Bradford derivations, and the ST 2084 transfer. The edge +# points this way because gamut-color is the primitive (fan-in 8) and has no need of a profile +# serializer; see `STATUS.md`, "Built-in profiles". +gamut-color.workspace = true gamut-core.workspace = true md-5.workspace = true thiserror.workspace = true diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs new file mode 100644 index 00000000..437fe084 --- /dev/null +++ b/crates/gamut-icc/src/builtin.rs @@ -0,0 +1,744 @@ +//! Built-in profile constructors: a spec-valid [`IccProfile`] for a named colour space, for a +//! [`SourceProfile`], or for a CICP signalling triple. +//! +//! Every profile built here is a **v4 three-component matrix/TRC display profile** (ICC.1:2022 +//! §8.4) — or, for [`IccProfile::gray_with_gamma`], the monochrome model of the same class. The +//! colorimetry is never written down twice: the primaries and white point come from +//! [`gamut_color::cicp::ColourPrimaries::chromaticities`], the RGB→XYZ construction and the +//! Bradford adaptation to the D50 PCS from [`gamut_color::matrix`], and the ST 2084 curve from +//! [`gamut_color::transfer`]. This crate contributes the ICC encoding, not the numbers. +//! +//! # Which spaces +//! +//! The buildable set is exactly what `gamut-color` can express on the two CICP axes — because a +//! matrix/TRC profile *is* a (primaries, transfer) pair. [`gamut_color::SourceProfile`]'s +//! `ADOBE_RGB` and `PROPHOTO_RGB` return `None` from both +//! [`colour_primaries`](gamut_color::SourceProfile::colour_primaries) and +//! [`transfer_characteristics`](gamut_color::SourceProfile::transfer_characteristics), and their +//! chromaticities are private to `gamut-color`, so [`IccProfile::from_source_profile`] returns +//! `None` for them rather than this module retyping the tables. +//! +//! # Determinism +//! +//! A constructor is a pure function of its arguments: the creation date is +//! [`DateTime::ZERO`](crate::DateTime::ZERO), the profile ID is left unset, and every +//! open-registry header field is zero, so the same call always serializes to the same bytes. Stamp +//! an ID with [`IccWriter::recompute_profile_id`](crate::IccWriter::recompute_profile_id) if one +//! is wanted. + +use gamut_color::SourceProfile; +use gamut_color::cicp::{ColourPrimaries, TransferCharacteristics}; +use gamut_color::linalg::mat_mul3; +use gamut_color::matrix::{D50, bradford_adapt, rgb_to_xyz_matrix}; +use gamut_color::transfer::pq_eotf; + +use crate::cicp::Cicp; +use crate::curve::{Curve, ParametricCurve}; +use crate::header::{ColorSpace, DeviceClass, ProfileHeader}; +use crate::mluc::{Mluc, MlucRecord}; +use crate::primitives::{S15Fixed16, XyzNumber}; +use crate::profile::IccProfile; +use crate::tag_types::TagData; +use crate::tags::KnownTag; + +/// The `copyrightTag` text every built-in profile carries. The profiles encode published +/// colorimetry and carry no rights of their own. +const COPYRIGHT: &str = "Public domain — no rights reserved."; + +/// Samples in a `curveType` TRC for a transfer with no `parametricCurveType` closed form. +/// +/// 1024 points keep the linear-interpolation error of the steepest such curve (ST 2084 near its +/// peak) below one `uInt16` quantum, so the table is as exact as the encoding it is written in. +const SAMPLED_TRC_POINTS: usize = 1024; + +/// The 3×3 identity, used as the total fallback in [`colorants_d50`]. +const IDENTITY_3X3: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + +/// A colour space [`IccProfile::builtin`] can construct without any further input. +/// +/// `#[non_exhaustive]` with explicit, permanently assigned discriminants: variants are appended as +/// `gamut-color` gains the colorimetry for further spaces, and an existing discriminant never +/// changes. +#[non_exhaustive] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BuiltinProfile { + /// sRGB: BT.709 primaries, D65 white, the IEC 61966-2-1 transfer. + Srgb = 0, + /// Linear sRGB: BT.709 primaries, D65 white, the identity transfer — the scene-linear + /// working space of sRGB. + LinearSrgb = 1, + /// Display P3: SMPTE EG 432-1 primaries, D65 white, the sRGB transfer. + DisplayP3 = 2, + /// BT.2100 PQ: BT.2020 primaries, D65 white, the SMPTE ST 2084 transfer normalized to its + /// 10 000 cd/m² peak. + Bt2020Pq = 3, +} + +impl BuiltinProfile { + /// The two CICP axes this space is defined by, the tone curve that encodes its transfer, and + /// the text of its `profileDescriptionTag`. + /// + /// The curve is named here rather than derived through [`Trc::from_cicp`] so that + /// [`IccProfile::builtin`] is total with no unreachable fallback; that the two agree is + /// pinned by `each_builtin_space_names_the_curve_for_its_own_transfer`. + fn parts(self) -> (ColourPrimaries, TransferCharacteristics, Trc, &'static str) { + match self { + BuiltinProfile::Srgb => ( + ColourPrimaries::Bt709, + TransferCharacteristics::Srgb, + Trc::Srgb, + "sRGB", + ), + BuiltinProfile::LinearSrgb => ( + ColourPrimaries::Bt709, + TransferCharacteristics::Linear, + Trc::Gamma(1.0), + "Linear sRGB", + ), + BuiltinProfile::DisplayP3 => ( + ColourPrimaries::DisplayP3, + TransferCharacteristics::Srgb, + Trc::Srgb, + "Display P3", + ), + BuiltinProfile::Bt2020Pq => ( + ColourPrimaries::Bt2020, + TransferCharacteristics::Pq, + Trc::Pq, + "BT.2100 PQ", + ), + } + } + + /// Every space, for the exhaustive sweeps that must not miss one. + const ALL: [BuiltinProfile; 4] = [ + BuiltinProfile::Srgb, + BuiltinProfile::LinearSrgb, + BuiltinProfile::DisplayP3, + BuiltinProfile::Bt2020Pq, + ]; + + /// The `cicpType` value this space signals: its two axes, the identity matrix coefficients + /// (H.273 code point 0 — the profile describes RGB, not a luma–chroma encoding), and the + /// full-range flag. + /// + /// # Examples + /// + /// ``` + /// use gamut_icc::BuiltinProfile; + /// let cicp = BuiltinProfile::Bt2020Pq.cicp(); + /// assert_eq!((cicp.colour_primaries, cicp.transfer_characteristics), (9, 16)); + /// ``` + #[must_use] + pub fn cicp(self) -> Cicp { + let (primaries, transfer, _, _) = self.parts(); + cicp_of(primaries, transfer) + } + + /// The space whose axes are exactly `primaries` and `transfer`, if there is one. Lets + /// [`IccProfile::from_cicp`] name a profile it recognizes instead of describing it by code + /// point. + fn for_axes(primaries: ColourPrimaries, transfer: TransferCharacteristics) -> Option { + Self::ALL.into_iter().find(|space| { + let (p, t, _, _) = space.parts(); + (p, t) == (primaries, transfer) + }) + } +} + +/// The `cicpType` value for a pair of CICP axes, with identity matrix coefficients and the +/// full-range flag set. +fn cicp_of(primaries: ColourPrimaries, transfer: TransferCharacteristics) -> Cicp { + Cicp { + colour_primaries: cicp_byte(primaries.code_point()), + transfer_characteristics: cicp_byte(transfer.code_point()), + matrix_coefficients: 0, + video_full_range_flag: 1, + } +} + +/// A CICP code point as the byte `cicpType` stores it (ICC.1:2022 §10.7 — four `uInt8`s). +/// +/// ITU-T H.273 defines every code point in `0..=255`, so this is total for any modelled value; a +/// value that does not fit a byte cannot be signalled at all and becomes `2` (Unspecified). +fn cicp_byte(code_point: u16) -> u8 { + u8::try_from(code_point).unwrap_or(2) +} + +/// A tone-response curve this module can encode. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Trc { + /// `Y = X^g`, a `parametricCurveType` function type 0 (ICC.1:2022 §10.18). + Gamma(f64), + /// IEC 61966-2-1 (sRGB), a `parametricCurveType` function type 3 — the spec's own piecewise + /// closed form, so the encoding is exact rather than sampled. + Srgb, + /// SMPTE ST 2084 (PQ) normalized to its peak. ICC.1:2022 §10.18 defines no closed form for + /// it, so it is a sampled `curveType` (§10.6). + Pq, +} + +impl Trc { + /// The curve for a CICP transfer code point, or `None` where `gamut-color` implements no + /// curve for it ([`TransferCharacteristics::Bt709`], [`TransferCharacteristics::Hlg`] and + /// [`TransferCharacteristics::Unspecified`] — see + /// [`gamut_color::transfer::eotf_for`]). + fn from_cicp(transfer: TransferCharacteristics) -> Option { + match transfer { + TransferCharacteristics::Linear => Some(Trc::Gamma(1.0)), + TransferCharacteristics::Srgb => Some(Trc::Srgb), + TransferCharacteristics::Pq | TransferCharacteristics::Bt2020_10 => Some(Trc::Pq), + TransferCharacteristics::Bt709 + | TransferCharacteristics::Hlg + | TransferCharacteristics::Unspecified => None, + // `TransferCharacteristics` is `#[non_exhaustive]`: a code point gamut-color models + // later has no curve here until this match names it. + _ => None, + } + } + + /// The tag element encoding this curve. + fn tag(self) -> TagData { + match self { + Trc::Gamma(g) => TagData::ParametricCurve(ParametricCurve { + function_type: 0, + params: vec![S15Fixed16::from_f64(g)], + }), + // IEC 61966-2-1: `Y = ((X + 0.055) / 1.055)^2.4` for `X >= 0.04045`, else `X / 12.92` + // — ICC.1:2022 §10.18 function type 3 in its `(g, a, b, c, d)` order. Pinned against + // `gamut_color::transfer::srgb_eotf` by `srgb_parametric_curve_matches_gamut_color`. + Trc::Srgb => TagData::ParametricCurve(ParametricCurve { + function_type: 3, + params: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045] + .into_iter() + .map(S15Fixed16::from_f64) + .collect(), + }), + Trc::Pq => TagData::Curve(Curve::Sampled(pq_samples())), + } + } +} + +/// [`SAMPLED_TRC_POINTS`] uniform samples of the ST 2084 EOTF normalized to its own peak. +/// +/// [`pq_eotf`] returns absolute luminance in cd/m², so it is divided by `pq_eotf(1.0)` — the +/// 10 000 cd/m² peak, read back from the same function rather than restated here — to land in the +/// `[0, 1]` range a `curveType` encodes. +fn pq_samples() -> Vec { + let peak = pq_eotf(1.0); + let last = (SAMPLED_TRC_POINTS - 1) as f64; + (0..SAMPLED_TRC_POINTS) + .map(|i| { + let signal = i as f64 / last; + let light = (pq_eotf(signal) / peak).clamp(0.0, 1.0); + (light * 65535.0).round() as u16 + }) + .collect() +} + +/// The D50-adapted colorant columns for `primaries`, and the chromatic-adaptation matrix that took +/// them there — the `rXYZ`/`gXYZ`/`bXYZ` (§9.2.10) and `chad` (§9.2.35) tag contents. +/// +/// Total by construction. A code point that names no chromaticities +/// ([`ColourPrimaries::Unspecified`]) — or, were one ever added, chromaticities with no RGB→XYZ +/// matrix — yields the identity for both, i.e. colorants equal to the PCS axes. Every public entry +/// point rejects `Unspecified` before reaching here, so that result is not observable through them. +fn colorants_d50(primaries: ColourPrimaries) -> ([[f64; 3]; 3], [[f64; 3]; 3]) { + let (rgb_to_xyz, chad) = primaries + .chromaticities() + .and_then(|(rgb, white)| { + rgb_to_xyz_matrix(&rgb, white).zip(bradford_adapt(white, D50)) + }) + .unwrap_or((IDENTITY_3X3, IDENTITY_3X3)); + (mat_mul3(&chad, &rgb_to_xyz), chad) +} + +/// A `multiLocalizedUnicodeType` element carrying one `en-US` record — the v4 form of the +/// description and copyright tags. +fn mluc(text: &str) -> TagData { + TagData::MultiLocalizedUnicode(Mluc { + records: vec![MlucRecord { + language: *b"en", + country: *b"US", + text: text.to_owned(), + }], + }) +} + +/// The three-component matrix/TRC display profile for a pair of CICP axes (ICC.1:2022 §8.4). +/// +/// `cicp` is the `cicpType` element to record the signalling the profile was built from. +fn rgb_matrix_trc( + primaries: ColourPrimaries, + trc: Trc, + description: &str, + cicp: Cicp, +) -> IccProfile { + let (colorants, chad) = colorants_d50(primaries); + // A colorant tag is a *column* of the RGB→XYZ matrix: the XYZ of that primary at full scale. + let column = |j: usize| { + TagData::Xyz(vec![XyzNumber::from_f64([ + colorants[0][j], + colorants[1][j], + colorants[2][j], + ])]) + }; + let chad_tag = TagData::S15Fixed16Array( + chad.iter() + .flatten() + .map(|&v| S15Fixed16::from_f64(v)) + .collect(), + ); + let curve = trc.tag(); + IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), + tags: vec![ + (KnownTag::ProfileDescription.into(), mluc(description)), + (KnownTag::Copyright.into(), mluc(COPYRIGHT)), + (KnownTag::MediaWhitePoint.into(), TagData::Xyz(vec![XyzNumber::D50])), + (KnownTag::ChromaticAdaptation.into(), chad_tag), + (KnownTag::RedColorant.into(), column(0)), + (KnownTag::GreenColorant.into(), column(1)), + (KnownTag::BlueColorant.into(), column(2)), + (KnownTag::RedTrc.into(), curve.clone()), + (KnownTag::GreenTrc.into(), curve.clone()), + (KnownTag::BlueTrc.into(), curve), + (KnownTag::Cicp.into(), TagData::Cicp(cicp)), + ], + } +} + +impl IccProfile { + /// A v4 matrix/TRC display profile for a named colour space. + /// + /// The media white point is the D50 the PCS mandates and the colorants are adapted to it with + /// the Bradford matrix recorded in `chad`, so the result satisfies + /// [`validate`](IccProfile::validate) for the Display class with no further setup. + /// + /// # Examples + /// + /// ``` + /// use gamut_icc::{BuiltinProfile, IccProfile, KnownTag, TagData}; + /// + /// let profile = IccProfile::builtin(BuiltinProfile::DisplayP3); + /// assert!(profile.validate().is_empty()); + /// assert!(matches!(profile.get(KnownTag::RedColorant), Some(TagData::Xyz(_)))); + /// ``` + #[must_use] + pub fn builtin(space: BuiltinProfile) -> Self { + let (primaries, _, trc, description) = space.parts(); + rgb_matrix_trc(primaries, trc, description, space.cicp()) + } + + /// A v4 monochrome display profile with a pure-gamma grey tone curve (`Y = X^gamma`). + /// + /// The white point is D50, so no chromatic adaptation is needed and no `chad` tag is written. + /// + /// # Examples + /// + /// ``` + /// use gamut_icc::{IccProfile, KnownTag, TagData}; + /// + /// let profile = IccProfile::gray_with_gamma(2.2); + /// assert!(profile.validate().is_empty()); + /// assert!(matches!(profile.get(KnownTag::GrayTrc), Some(TagData::ParametricCurve(_)))); + /// ``` + #[must_use] + pub fn gray_with_gamma(gamma: f64) -> Self { + IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Gray), + tags: vec![ + ( + KnownTag::ProfileDescription.into(), + mluc(&format!("Grey gamma {gamma}")), + ), + (KnownTag::Copyright.into(), mluc(COPYRIGHT)), + (KnownTag::MediaWhitePoint.into(), TagData::Xyz(vec![XyzNumber::D50])), + (KnownTag::GrayTrc.into(), Trc::Gamma(gamma).tag()), + ], + } + } + + /// A v4 matrix/TRC display profile for a CICP signalling triple — the path from what AVIF, + /// HEIC and JXL usually carry (a `colr`/`nclx` code-point trio) to an embeddable profile. + /// + /// The `cicpType` tag records `cicp` verbatim, including its matrix coefficients and range + /// flag: those describe a luma–chroma *encoding*, which a matrix/TRC profile does not model, + /// so the profile's own pipeline always describes the RGB signal after any de-matrixing. + /// + /// Returns `None` when the profile cannot describe the signalling: an unmodelled or + /// [`Unspecified`](ColourPrimaries::Unspecified) primaries code point, or a transfer + /// characteristic for which `gamut-color` implements no curve (BT.709 and HLG today). + /// + /// # Examples + /// + /// ``` + /// use gamut_icc::{BuiltinProfile, Cicp, IccProfile}; + /// + /// // BT.709 primaries + sRGB transfer is sRGB, and is built as such. + /// let signalled = Cicp { colour_primaries: 1, transfer_characteristics: 13, + /// matrix_coefficients: 0, video_full_range_flag: 1 }; + /// assert_eq!(IccProfile::from_cicp(signalled), Some(IccProfile::builtin(BuiltinProfile::Srgb))); + /// + /// // "Unspecified" primaries name no chromaticities, so no profile can be built. + /// assert!(IccProfile::from_cicp(Cicp { colour_primaries: 2, ..signalled }).is_none()); + /// ``` + #[must_use] + pub fn from_cicp(cicp: Cicp) -> Option { + let primaries = ColourPrimaries::from_code_point(u16::from(cicp.colour_primaries))?; + if primaries == ColourPrimaries::Unspecified { + return None; + } + let transfer = + TransferCharacteristics::from_code_point(u16::from(cicp.transfer_characteristics))?; + let trc = Trc::from_cicp(transfer)?; + let named = BuiltinProfile::for_axes(primaries, transfer); + let description = match named { + Some(space) => space.parts().3.to_owned(), + None => format!( + "CICP {}/{}", + cicp.colour_primaries, cicp.transfer_characteristics + ), + }; + Some(rgb_matrix_trc(primaries, trc, &description, cicp)) + } + + /// A v4 matrix/TRC display profile for a [`SourceProfile`] — `gamut-color`'s + /// `(gamut, transfer)` bundle. + /// + /// Returns `None` for a bundle with no CICP axes: `ADOBE_RGB` and `PROPHOTO_RGB` have neither + /// a primaries nor a transfer code point, and their chromaticities are private to + /// `gamut-color`, so this crate cannot describe them without restating tables it does not own. + /// + /// # Examples + /// + /// ``` + /// use gamut_color::SourceProfile; + /// use gamut_icc::{BuiltinProfile, IccProfile}; + /// + /// assert_eq!( + /// IccProfile::from_source_profile(SourceProfile::SRGB), + /// Some(IccProfile::builtin(BuiltinProfile::Srgb)), + /// ); + /// assert!(IccProfile::from_source_profile(SourceProfile::ADOBE_RGB).is_none()); + /// ``` + #[must_use] + pub fn from_source_profile(source: SourceProfile) -> Option { + let primaries = source.colour_primaries()?; + let transfer = source.transfer_characteristics()?; + Self::from_cicp(cicp_of(primaries, transfer)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tags::KnownTag; + use gamut_color::transfer::srgb_eotf; + use lcms2_oracle::tag; + + /// Every [`BuiltinProfile`] and the grey constructor satisfy ICC.1:2022 §8's required-tag set + /// for the Display class, and serialize. `validate` is gamut-icc's own §8 checker, so this + /// pins the *tag set* each constructor emits, not its colorimetry. + #[test] + fn every_constructor_satisfies_the_section_8_display_model() { + let profiles = [ + ("srgb", IccProfile::builtin(BuiltinProfile::Srgb)), + ("linear", IccProfile::builtin(BuiltinProfile::LinearSrgb)), + ("p3", IccProfile::builtin(BuiltinProfile::DisplayP3)), + ("pq", IccProfile::builtin(BuiltinProfile::Bt2020Pq)), + ("gray", IccProfile::gray_with_gamma(2.2)), + ]; + for (label, profile) in profiles { + assert_eq!(profile.validate(), vec![], "{label}: §8 conformance"); + assert!(profile.to_bytes().is_ok(), "{label}: serializes"); + } + } + + /// `colorants_d50` is total: a code point naming no chromaticities yields the identity for + /// both the colorants and the adaptation matrix. This is the arm the public constructors can + /// never reach, so it is pinned here at the only input that reaches it. + #[test] + fn colorants_of_unspecified_primaries_are_the_identity() { + let (colorants, chad) = colorants_d50(ColourPrimaries::Unspecified); + assert_eq!(colorants, IDENTITY_3X3); + assert_eq!(chad, IDENTITY_3X3); + } + + /// A code point too large for `cicpType`'s `uInt8` becomes Unspecified. Unreachable from any + /// modelled CICP value — H.273 code points are bytes — so it is pinned at that input directly. + #[test] + fn an_oversized_code_point_signals_unspecified() { + assert_eq!(cicp_byte(255), 255); + assert_eq!(cicp_byte(256), 2); + } + + /// The `parametricCurveType` type 3 parameters this module writes for sRGB reproduce + /// `gamut-color`'s own `srgb_eotf`. Without this the coefficients would be a second, + /// unguarded copy of the IEC 61966-2-1 curve that could drift from the crate that owns it. + #[test] + fn srgb_parametric_curve_matches_gamut_color() { + let TagData::ParametricCurve(curve) = Trc::Srgb.tag() else { + panic!("sRGB is encoded as a parametricCurveType"); + }; + // The `s15Fixed16` quantization of (g, a, b, c, d) is the whole of the error budget: + // one quantum is 1/65536, and the largest derivative of the curve with respect to a + // parameter is `g` = 2.4. + for step in 0..=100 { + let x = f64::from(step) / 100.0; + let (got, want) = (curve.eval(x), srgb_eotf(x)); + assert!((got - want).abs() < 1.0e-4, "sRGB TRC at {x}: {got} vs {want}"); + } + } + + /// The sampled PQ curve reproduces `gamut-color`'s `pq_eotf` normalized to its peak, to within + /// the `uInt16` quantum the table is written in. This is what justifies + /// [`SAMPLED_TRC_POINTS`]: it pins that the sampling density is fine enough to make the + /// encoding, not the table, the limit on accuracy. + #[test] + fn sampled_pq_curve_matches_gamut_color() { + let TagData::Curve(curve) = Trc::Pq.tag() else { + panic!("PQ is encoded as a sampled curveType"); + }; + let peak = pq_eotf(1.0); + for step in 0..=100 { + let x = f64::from(step) / 100.0; + let (got, want) = (curve.eval(x), pq_eotf(x) / peak); + assert!( + (got - want).abs() < 2.0 / 65535.0, + "PQ TRC at {x}: {got} vs {want}" + ); + } + } + + /// The four built-in spaces have four distinct sets of axes, and each round-trips through its + /// `cicpType` value back to itself. A space whose `parts` named the wrong code point would + /// collide with another or fail to round-trip. + #[test] + fn each_builtin_space_round_trips_through_its_cicp_value() { + for space in BuiltinProfile::ALL { + let (primaries, transfer, _, _) = space.parts(); + assert_eq!( + BuiltinProfile::for_axes(primaries, transfer), + Some(space), + "{space:?} is the space for its own axes" + ); + assert_eq!( + IccProfile::from_cicp(space.cicp()), + Some(IccProfile::builtin(space)), + "{space:?} builds the same profile from its own CICP value" + ); + } + } + + /// The curve each space names is the one its own transfer code point maps to. `parts` states + /// the curve directly so that `builtin` needs no fallback, and this is what stops the two + /// halves of that statement drifting apart. + #[test] + fn each_builtin_space_names_the_curve_for_its_own_transfer() { + for space in BuiltinProfile::ALL { + let (_, transfer, trc, _) = space.parts(); + assert_eq!(Trc::from_cicp(transfer), Some(trc), "{space:?}"); + } + } + + /// A transfer `gamut-color` implements no curve for cannot be built, and neither can + /// unmodelled or unspecified primaries. Each rejection is asserted at an input that isolates + /// it: the primaries are valid when the transfer is the reason, and vice versa. + #[test] + fn unrepresentable_signalling_is_rejected() { + let srgb = Cicp { + colour_primaries: 1, + transfer_characteristics: 13, + matrix_coefficients: 0, + video_full_range_flag: 1, + }; + assert!(IccProfile::from_cicp(srgb).is_some(), "the control builds"); + for (label, cicp) in [ + ( + "unmodelled primaries", + Cicp { + colour_primaries: 11, + ..srgb + }, + ), + ( + "unspecified primaries", + Cicp { + colour_primaries: 2, + ..srgb + }, + ), + ( + "unmodelled transfer", + Cicp { + transfer_characteristics: 17, + ..srgb + }, + ), + ( + "BT.709 transfer, no curve in gamut-color", + Cicp { + transfer_characteristics: 1, + ..srgb + }, + ), + ( + "HLG transfer, no curve in gamut-color", + Cicp { + transfer_characteristics: 18, + ..srgb + }, + ), + ] { + assert_eq!(IccProfile::from_cicp(cicp), None, "{label}"); + } + } + + /// `SourceProfile`'s bundles map onto the built-in spaces, and the two with no CICP axes are + /// declined rather than approximated. + #[test] + fn source_profiles_map_onto_the_builtin_spaces() { + let cases = [ + (SourceProfile::SRGB, Some(BuiltinProfile::Srgb)), + (SourceProfile::LINEAR_SRGB, Some(BuiltinProfile::LinearSrgb)), + (SourceProfile::DISPLAY_P3, Some(BuiltinProfile::DisplayP3)), + (SourceProfile::BT2020, Some(BuiltinProfile::Bt2020Pq)), + (SourceProfile::ADOBE_RGB, None), + (SourceProfile::PROPHOTO_RGB, None), + ]; + for (source, expected) in cases { + let want = expected.map(IccProfile::builtin); + assert_eq!( + IccProfile::from_source_profile(source), + want, + "{source:?} → {expected:?}" + ); + } + } + + /// A constructor is a pure function of its arguments: the same call serializes to the same + /// bytes. Guards the "no timestamp, no ID, no entropy" property the module doc promises, which + /// a later `DateTime::now()` would silently break. + #[test] + fn constructors_are_byte_deterministic() { + let first = IccProfile::builtin(BuiltinProfile::Srgb).to_bytes(); + let second = IccProfile::builtin(BuiltinProfile::Srgb).to_bytes(); + assert_eq!(first.ok(), second.ok()); + } + + // --- differential: Little-CMS re-opens what we built ------------------------------------ + + /// lcms2 re-opens each built-in profile and reports the same colorants as it computes for the + /// same primaries itself. This is the colorimetric acceptance gate: the D50 adaptation, the + /// column-vs-row orientation of the colorant tags and the `s15Fixed16` encoding are all only + /// checked against an independent implementation. + #[test] + fn oracle_colorants_match_lcms_for_the_same_primaries() { + for space in [ + BuiltinProfile::Srgb, + BuiltinProfile::DisplayP3, + BuiltinProfile::Bt2020Pq, + ] { + let (primaries, _, _, label) = space.parts(); + let (rgb, white) = primaries.chromaticities().expect("a modelled code point"); + // lcms2 builds its own matrix/TRC profile from the same chromaticities; the gamma is + // irrelevant to the colorants. + let reference = lcms2_oracle::rgb_matrix_shaper(white, rgb, [2.2, 2.2, 2.2]); + let ours = lcms2_oracle::Profile::from_bytes( + &IccProfile::builtin(space).to_bytes().expect("serializes"), + ) + .expect("lcms2 re-opens the profile"); + + for (name, sig) in [ + ("red", tag::RED_COLORANT), + ("green", tag::GREEN_COLORANT), + ("blue", tag::BLUE_COLORANT), + ] { + let got = ours.read_xyz(sig).expect("colorant present"); + let want = reference.read_xyz(sig).expect("colorant present"); + for axis in 0..3 { + assert!( + (got[axis] - want[axis]).abs() < 1.0e-3, + "{label} {name} colorant [{axis}]: {got:?} vs {want:?}" + ); + } + } + } + } + + /// lcms2 evaluates our sRGB tone curve to the same values as it evaluates the sRGB curve in + /// the profile it synthesizes itself. Two independent constructions of IEC 61966-2-1, read by + /// the same reference CMM. + #[test] + fn oracle_srgb_tone_curve_matches_lcms() { + let ours = lcms2_oracle::Profile::from_bytes( + &IccProfile::builtin(BuiltinProfile::Srgb) + .to_bytes() + .expect("serializes"), + ) + .expect("lcms2 re-opens the profile"); + let reference = lcms2_oracle::srgb(); + + for step in 0..=20 { + let x = step as f32 / 20.0; + let got = ours.eval_tone_curve(tag::RED_TRC, x).expect("rTRC present"); + let want = reference + .eval_tone_curve(tag::RED_TRC, x) + .expect("rTRC present"); + assert!( + (got - want).abs() < 1.0e-4, + "sRGB rTRC at {x}: {got} vs {want}" + ); + } + } + + /// A transform from our sRGB profile to lcms2's own sRGB profile is the identity, to within a + /// code. This is the end-to-end acceptance: colorants, white point, adaptation and TRC all + /// have to be right together for a full round trip through the PCS to come back unchanged. + #[test] + fn oracle_transform_through_our_srgb_is_the_identity() { + let ours = lcms2_oracle::Profile::from_bytes( + &IccProfile::builtin(BuiltinProfile::Srgb) + .to_bytes() + .expect("serializes"), + ) + .expect("lcms2 re-opens the profile"); + let reference = lcms2_oracle::srgb(); + + let pixels: Vec = (0..=255u8) + .flat_map(|v| [v, 255 - v, v.wrapping_mul(3)]) + .collect(); + // Intent 1 is media-relative colorimetric: the intent under which two profiles for the + // same space must agree exactly. + let out = lcms2_oracle::transform_rgb8(&ours, &reference, 1, &pixels); + assert_eq!(out.len(), pixels.len()); + for (i, (&got, &want)) in out.iter().zip(pixels.iter()).enumerate() { + assert!( + got.abs_diff(want) <= 1, + "channel {i}: {got} vs {want} through our sRGB → lcms2 sRGB" + ); + } + } + + /// lcms2 reports the grey profile's gamma as the one asked for. Pins the monochrome + /// constructor's `kTRC` against an independent reader rather than against our own encoder. + #[test] + fn oracle_gray_gamma_matches_lcms() { + for gamma in [1.0, 1.8, 2.2] { + let bytes = IccProfile::gray_with_gamma(gamma) + .to_bytes() + .expect("serializes"); + let profile = + lcms2_oracle::Profile::from_bytes(&bytes).expect("lcms2 re-opens the profile"); + let estimated = profile + .estimate_gamma(tag::GRAY_TRC, 0.01) + .expect("kTRC present"); + assert!( + (estimated - gamma).abs() < 0.01, + "grey gamma {gamma}: lcms2 estimates {estimated}" + ); + } + } +} diff --git a/crates/gamut-icc/src/lib.rs b/crates/gamut-icc/src/lib.rs index 5a0cf659..20d2dcff 100644 --- a/crates/gamut-icc/src/lib.rs +++ b/crates/gamut-icc/src/lib.rs @@ -3,8 +3,9 @@ //! An ICC profile is the self-describing colour-characterization blob embedded in images (the WebP //! `ICCP` chunk, the AVIF/HEIF `colr` box of type `prof`, a JPEG `APP2` segment): a 128-byte header, //! a tag table, then the tag element data the table points at. It is a flat, offset-indexed binary -//! format that needs neither the TIFF/IFD machinery nor XML, so this crate depends only on -//! [`gamut_core`] (plus `md-5`, for the §7.2.18 profile ID). +//! format that needs neither the TIFF/IFD machinery nor XML, so this crate's only dependencies are +//! [`gamut_core`], [`gamut_color`] (the colorimetry behind the built-in profile constructors below) +//! and `md-5` (for the §7.2.18 profile ID). //! //! Layouts follow **ICC.1:2022** (profile version 4.4, equivalent to ISO 15076-1; see //! `references/icc`). Profile **v2** — still the most common version in real images — is supported, @@ -18,6 +19,14 @@ //! [`IccProfile::validate`] reports any ICC.1:2022 §8 required tags missing for the profile's //! device class. //! +//! # Built-in profiles +//! +//! [`IccProfile::builtin`] constructs a spec-valid v4 matrix/TRC profile for a named +//! [`BuiltinProfile`] space, [`IccProfile::gray_with_gamma`] the monochrome equivalent, and +//! [`IccProfile::from_cicp`] / [`IccProfile::from_source_profile`] the same from what a codec +//! actually signals — an H.273 code-point triple, or a [`gamut_color::SourceProfile`]. The +//! colorimetry behind them is [`gamut_color`]'s, never restated here; see `src/builtin.rs`. +//! //! ```no_run //! use gamut_icc::{IccProfile, KnownTag, TagData}; //! @@ -38,8 +47,10 @@ //! defined in §10 — iccMAX's `multiProcessElementsType`, or private/unregistered types — is //! preserved verbatim as [`TagData::Raw`], so every profile round-trips losslessly regardless of //! what it carries. Applying a profile's transform (a CMM), and building transforms from -//! [`gamut_color`](https://docs.rs/gamut-color), are out of scope — the `to_f64`/`eval` accessors -//! are the seam for that — as is **iccMAX** (`ICC.2`), a separate next-generation profile format. +//! [`gamut_color`], are out of scope — the `to_f64`/`eval` accessors are the seam for that — as is +//! **iccMAX** (`ICC.2`), a separate next-generation profile format. Note the distinction from the +//! built-in constructors above: this crate reads `gamut-color`'s colorimetry to *describe* a +//! colour space, and still applies nothing. //! //! # Stability //! @@ -62,6 +73,7 @@ //! always serializes. The format's `u32` sizes and offsets cap a serialized profile at 4 GiB. #![forbid(unsafe_code)] +mod builtin; mod bytes; mod cicp; mod colorant; @@ -83,6 +95,7 @@ mod tags; mod validate; mod writer; +pub use builtin::BuiltinProfile; pub use cicp::Cicp; pub use colorant::{Colorant, ColorantOrder, ColorantTable}; pub use curve::{Curve, CurveOrParametric, ParametricCurve}; From 2771400fa83fa6de753e0426cdc42928ef972bea Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:55:48 -0400 Subject: [PATCH 02/31] fix(icc): adapt built-in colorants to the PCS D50 the profile declares Bradford-adapting to gamut-color's CIE D50 chromaticity while writing ICC's mandated D50 encoding as the mediaWhitePointTag left the colorants disagreeing with the white point they sum to by 2e-4 in Z, and put the disagreement with lcms2's own colorants at 1.8e-4. Deriving the adaptation target from `XyzNumber::D50` instead brings both inside four s15Fixed16 quanta. --- crates/gamut-icc/src/builtin.rs | 46 ++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 437fe084..88fce86c 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -29,7 +29,7 @@ use gamut_color::SourceProfile; use gamut_color::cicp::{ColourPrimaries, TransferCharacteristics}; use gamut_color::linalg::mat_mul3; -use gamut_color::matrix::{D50, bradford_adapt, rgb_to_xyz_matrix}; +use gamut_color::matrix::{bradford_adapt, rgb_to_xyz_matrix}; use gamut_color::transfer::pq_eotf; use crate::cicp::Cicp; @@ -237,6 +237,20 @@ fn pq_samples() -> Vec { .collect() } +/// The PCS D50 white as a CIE 1931 chromaticity, derived from the exact `XYZNumber` ICC.1:2022 +/// §7.2.16 mandates for the PCS illuminant. +/// +/// Deliberately *not* [`gamut_color::matrix::D50`]: that is the CIE-published chromaticity, whose +/// tristimulus Z is 0.82521, while ICC's rounded encoding is 0.8249. Adapting to the CIE one while +/// writing the ICC one as the `mediaWhitePointTag` would leave the colorants disagreeing with the +/// white point they are supposed to sum to, by 2e-4 in Z. The PCS illuminant is an ICC fact, so +/// this crate owns it. +fn pcs_d50_chromaticity() -> [f64; 2] { + let [x, y, z] = XyzNumber::D50.to_f64(); + let sum = x + y + z; + [x / sum, y / sum] +} + /// The D50-adapted colorant columns for `primaries`, and the chromatic-adaptation matrix that took /// them there — the `rXYZ`/`gXYZ`/`bXYZ` (§9.2.10) and `chad` (§9.2.35) tag contents. /// @@ -248,7 +262,7 @@ fn colorants_d50(primaries: ColourPrimaries) -> ([[f64; 3]; 3], [[f64; 3]; 3]) { let (rgb_to_xyz, chad) = primaries .chromaticities() .and_then(|(rgb, white)| { - rgb_to_xyz_matrix(&rgb, white).zip(bradford_adapt(white, D50)) + rgb_to_xyz_matrix(&rgb, white).zip(bradford_adapt(white, pcs_d50_chromaticity())) }) .unwrap_or((IDENTITY_3X3, IDENTITY_3X3)); (mat_mul3(&chad, &rgb_to_xyz), chad) @@ -434,7 +448,6 @@ impl IccProfile { #[cfg(test)] mod tests { use super::*; - use crate::tags::KnownTag; use gamut_color::transfer::srgb_eotf; use lcms2_oracle::tag; @@ -618,6 +631,26 @@ mod tests { } } + /// Each space's colorants sum to the media white point the same profile declares — the law a + /// matrix/TRC profile has to satisfy for full-scale RGB to land on the PCS white. It is what + /// forces the Bradford adaptation to target [`pcs_d50_chromaticity`] rather than the CIE D50. + #[test] + fn colorants_sum_to_the_declared_media_white_point() { + let want = XyzNumber::D50.to_f64(); + for space in BuiltinProfile::ALL { + let (primaries, _, _, label) = space.parts(); + let (colorants, _) = colorants_d50(primaries); + for axis in 0..3 { + let sum: f64 = colorants[axis].iter().sum(); + assert!( + (sum - want[axis]).abs() < 1.0e-6, + "{label} white [{axis}]: {sum} vs {}", + want[axis] + ); + } + } + } + /// A constructor is a pure function of its arguments: the same call serializes to the same /// bytes. Guards the "no timestamp, no ID, no entropy" property the module doc promises, which /// a later `DateTime::now()` would silently break. @@ -634,6 +667,11 @@ mod tests { /// same primaries itself. This is the colorimetric acceptance gate: the D50 adaptation, the /// column-vs-row orientation of the colorant tags and the `s15Fixed16` encoding are all only /// checked against an independent implementation. + /// + /// The tolerance is four `s15Fixed16` quanta, so it is the tag encoding — not the derivation — + /// that sets it: the largest observed disagreement is 2.4e-5, under two quanta. A transposed + /// matrix, a missing Bradford adaptation or a wrong white point all move a colorant by more + /// than 1e-2. #[test] fn oracle_colorants_match_lcms_for_the_same_primaries() { for space in [ @@ -660,7 +698,7 @@ mod tests { let want = reference.read_xyz(sig).expect("colorant present"); for axis in 0..3 { assert!( - (got[axis] - want[axis]).abs() < 1.0e-3, + (got[axis] - want[axis]).abs() < 4.0 / 65536.0, "{label} {name} colorant [{axis}]: {got:?} vs {want:?}" ); } From f5624524e947ce9c23798063d5c90c9c6ae1c348 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:59:03 -0400 Subject: [PATCH 03/31] docs(icc): record the built-in profile constructors and their curve choices STATUS gains the dependency direction and the evidence for it, the buildable set and why Adobe RGB / ProPhoto are declined, the parametric-vs-sampled tone-curve choice per transfer with the clause that decides it, the PCS white point, and the lcms2 acceptance criteria. --- crates/gamut-icc/README.md | 36 ++++++++++++++++++++++--- crates/gamut-icc/STATUS.md | 47 +++++++++++++++++++++++++++++++++ crates/gamut-icc/src/builtin.rs | 18 ++++++++++--- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/crates/gamut-icc/README.md b/crates/gamut-icc/README.md index b80a189a..0cb7068f 100644 --- a/crates/gamut-icc/README.md +++ b/crates/gamut-icc/README.md @@ -14,9 +14,9 @@ the format crates can read, preserve, and embed accurate color characterization. ISO 15076-1; [`../../references/icc`](../../references/icc)), with v2 read support since most embedded profiles are still v2. - **Dependency-light.** An ICC profile needs neither IFD nor XML machinery, so this crate builds - only on [`gamut-core`](../gamut-core) plus [`md-5`](https://crates.io/crates/md-5) (the §7.2.18 - profile-ID digest) — distinct from CICP color signaling, which lives in - [`gamut-color`](../gamut-color). + only on [`gamut-core`](../gamut-core), [`gamut-color`](../gamut-color) (the colorimetry behind + the built-in profile constructors below) and [`md-5`](https://crates.io/crates/md-5) (the + §7.2.18 profile-ID digest). ## Usage @@ -32,6 +32,36 @@ let serialized = profile.to_bytes()?; // spec-valid bytes, ready to re-embed # Ok(()) } ``` +### Built-in profiles, and CICP → profile + +Constructing a profile is the other direction. `IccProfile::builtin` emits a spec-valid v4 +matrix/TRC display profile for a named space, and `IccProfile::from_cicp` does the same from the +H.273 code-point triple AVIF, HEIC and JXL usually signal instead of embedding a profile: + +```rust +use gamut_icc::{BuiltinProfile, Cicp, IccProfile}; + +let p3 = IccProfile::builtin(BuiltinProfile::DisplayP3); +assert!(p3.validate().is_empty()); +let bytes = p3.to_bytes()?; // ready to embed + +// BT.2020 primaries + PQ, as an AVIF `colr` box would signal them. +let hdr = IccProfile::from_cicp(Cicp { + colour_primaries: 9, + transfer_characteristics: 16, + matrix_coefficients: 0, + video_full_range_flag: 1, +}); +assert!(hdr.is_some()); +# Ok::<_, gamut_icc::IccError>(()) +``` + +The buildable spaces are sRGB, linear sRGB, Display P3 and BT.2100 PQ, plus +`IccProfile::gray_with_gamma` for a monochrome profile — exactly the set +[`gamut-color`](../gamut-color) can express on the two CICP axes. Their primaries, white point and +transfer are read from that crate rather than restated here, so the two cannot drift; Adobe RGB and +ProPhoto RGB have no code point on either axis and are declined rather than approximated. + **Every ICC.1:2022 §10 element type decodes semantically** — the `XYZType`, curve, and text types; the `lut8`/`lut16`/`lutAToB`/`lutBToA` transforms; `namedColor2Type`; the measurement/signalling types (`chromaticityType`, `cicpType`, `measurementType`, `viewingConditionsType`, `dataType`); the diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index 8e4a2562..c52ae1a8 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -26,6 +26,7 @@ serialization as an equivalent profile. | P9 | §10 | **Full §10 coverage** — every remaining element type decoded (see below) | ✅ | | P10 | §8 | Profile-class conformance validation (`IccProfile::validate`) | ✅ | | P11 | — | **v1 stabilization** (issue #180): API-surface hardening (private modules, `Eq` model, std conversion traits, validated fallible writes, `ProfileHeader::new`) + spec-citation audit | ✅ | +| P12 | §8.4, §9.2 | **Built-in profiles** (issue #424): `IccProfile::builtin` / `gray_with_gamma` / `from_cicp` / `from_source_profile` | ✅ | ## Modelled element types @@ -41,6 +42,52 @@ Any element type *not* defined in ICC.1:2022 §10 (e.g. iccMAX's `multiProcessEl private/unregistered types) is preserved verbatim as `TagData::Raw` and round-trips byte-for-byte, so no profile is rejected for carrying an unmodelled tag. +## Built-in profiles + +`IccProfile::builtin`, `gray_with_gamma`, `from_cicp` and `from_source_profile` construct +spec-valid v4 three-component matrix/TRC display profiles (§8.4). The colorimetry is **gamut-color's +and is never restated here**: primaries and white point come from +`ColourPrimaries::chromaticities`, the RGB→XYZ construction and Bradford adaptation from +`gamut_color::matrix`, the ST 2084 curve from `gamut_color::transfer`. + +**Dependency direction: `gamut-icc → gamut-color`.** The constructors need gamut-color's +colorimetry and gamut-icc's serializer, and only one of the two can own that edge. gamut-color is +the primitive — fan-in 8, no ICC dependency — so pointing it the other way would invert the +layering and give a widely-depended-on crate a profile serializer it has no use for. (Contrast +`gamut-cmm → gamut-icc` below: applying a profile is a layer above parsing one; *describing* a +colour space is a layer below.) + +**The buildable set is exactly what gamut-color can express on the two CICP axes**, because a +matrix/TRC profile *is* a (primaries, transfer) pair: sRGB, linear sRGB, Display P3 and BT.2100 PQ. +`SourceProfile::ADOBE_RGB` and `PROPHOTO_RGB` return `None` from both `colour_primaries()` and +`transfer_characteristics()`, and their chromaticities are private to gamut-color, so +`from_source_profile` declines them rather than restating tables this crate does not own. Adding +them needs a public chromaticity accessor in gamut-color first — tracked separately. + +**Tone-curve shape per transfer.** A `parametricCurveType` (§10.18) is used wherever ITU-T H.273 +gives the transfer a closed form ICC also defines, and a sampled `curveType` (§10.6) otherwise: + +| Transfer (H.273) | ICC encoding | Deciding clause | +| ---------------- | ------------ | --------------- | +| Linear (code 8) | `parametricCurveType` type 0, `g = 1` | §10.18 type 0 is `Y = X^g` exactly | +| sRGB / IEC 61966-2-1 (code 13) | `parametricCurveType` type 3, `(g, a, b, c, d)` | §10.18 type 3 is the spec's own piecewise form | +| Grey gamma (`gray_with_gamma`) | `parametricCurveType` type 0 | §10.18 type 0; `s15Fixed16` beats `curveType`'s single `u8Fixed8` entry | +| PQ / ST 2084 (codes 16, 14) | `curveType`, 1024 `uInt16` samples | §10.18 defines no closed form for PQ; 1024 points keep interpolation error under one `uInt16` quantum | + +**PCS white.** Colorants are Bradford-adapted to the D50 that `XYZNumber::D50` encodes (§7.2.16), +not to `gamut_color::matrix::D50`. The two differ by 2e-4 in Z — the CIE chromaticity against ICC's +rounded tristimulus — and adapting to the CIE one while writing the ICC one as the +`mediaWhitePointTag` leaves the colorants disagreeing with the white point they sum to. The PCS +illuminant is an ICC fact, so this crate owns it. + +**Determinism.** A constructor is a pure function of its arguments: no creation timestamp, no +profile ID, no other entropy, so the same call always serializes to the same bytes. + +**Acceptance.** lcms2 re-opens every constructed profile and reports the same colorants as it +derives from the same primaries itself (within four `s15Fixed16` quanta), evaluates our sRGB TRC to +the same values as the sRGB profile it synthesizes itself, estimates the grey gamma we asked for, +and transforms through our sRGB into its own sRGB as the identity to within one 8-bit code. + ## Deferred / intentional leniencies - **Applying transforms** (a CMM): gamut-icc parses and serializes profiles; evaluating a profile's diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 88fce86c..fadcf873 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -310,7 +310,10 @@ fn rgb_matrix_trc( tags: vec![ (KnownTag::ProfileDescription.into(), mluc(description)), (KnownTag::Copyright.into(), mluc(COPYRIGHT)), - (KnownTag::MediaWhitePoint.into(), TagData::Xyz(vec![XyzNumber::D50])), + ( + KnownTag::MediaWhitePoint.into(), + TagData::Xyz(vec![XyzNumber::D50]), + ), (KnownTag::ChromaticAdaptation.into(), chad_tag), (KnownTag::RedColorant.into(), column(0)), (KnownTag::GreenColorant.into(), column(1)), @@ -368,7 +371,10 @@ impl IccProfile { mluc(&format!("Grey gamma {gamma}")), ), (KnownTag::Copyright.into(), mluc(COPYRIGHT)), - (KnownTag::MediaWhitePoint.into(), TagData::Xyz(vec![XyzNumber::D50])), + ( + KnownTag::MediaWhitePoint.into(), + TagData::Xyz(vec![XyzNumber::D50]), + ), (KnownTag::GrayTrc.into(), Trc::Gamma(gamma).tag()), ], } @@ -447,10 +453,11 @@ impl IccProfile { #[cfg(test)] mod tests { - use super::*; use gamut_color::transfer::srgb_eotf; use lcms2_oracle::tag; + use super::*; + /// Every [`BuiltinProfile`] and the grey constructor satisfy ICC.1:2022 §8's required-tag set /// for the Display class, and serialize. `validate` is gamut-icc's own §8 checker, so this /// pins the *tag set* each constructor emits, not its colorimetry. @@ -501,7 +508,10 @@ mod tests { for step in 0..=100 { let x = f64::from(step) / 100.0; let (got, want) = (curve.eval(x), srgb_eotf(x)); - assert!((got - want).abs() < 1.0e-4, "sRGB TRC at {x}: {got} vs {want}"); + assert!( + (got - want).abs() < 1.0e-4, + "sRGB TRC at {x}: {got} vs {want}" + ); } } From 9b12bba12452dd5ac195a8b73f9bd843c7888f86 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 23:54:58 -0400 Subject: [PATCH 04/31] refactor(icc): fold the curveless transfer arm into the wildcard The explicit `Bt709 | Hlg | Unspecified => None` arm in `Trc::from_cicp` sat in front of a `_ => None` wildcard that `#[non_exhaustive]` makes mandatory, so deleting it changed no behaviour: an equivalent mutant no test can kill. Fold it into the wildcard's comment, which still names the three code points and why gamut-color supplies no curve for them. `unrepresentable_signalling_is_rejected` already pins BT.709 and HLG to `None` through the public entry point, so the behaviour stays covered. --- crates/gamut-icc/src/builtin.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index fadcf873..3b2c899e 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -189,11 +189,11 @@ impl Trc { TransferCharacteristics::Linear => Some(Trc::Gamma(1.0)), TransferCharacteristics::Srgb => Some(Trc::Srgb), TransferCharacteristics::Pq | TransferCharacteristics::Bt2020_10 => Some(Trc::Pq), - TransferCharacteristics::Bt709 - | TransferCharacteristics::Hlg - | TransferCharacteristics::Unspecified => None, - // `TransferCharacteristics` is `#[non_exhaustive]`: a code point gamut-color models - // later has no curve here until this match names it. + // Every other code point has no curve here. That covers the three gamut-color models + // but supplies no EOTF for (`Bt709`, `Hlg`, `Unspecified`) and, since + // `TransferCharacteristics` is `#[non_exhaustive]`, any code point gamut-color models + // later until this match names it. Listing the first three explicitly would be a + // second, behaviourally identical arm. _ => None, } } From 6592e655070042834eb3853f7c0abbbeb7f247e3 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:56:59 -0400 Subject: [PATCH 05/31] fix(icc)!: build CICP profiles from the transfer H.273 defines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `from_cicp` gave H.273 transfer code point 14 the PQ curve. Table 3 defines 14 as the BT.709 curve — its own remark calls it "functionally the same as the values 1, 6 and 15" — so a BT.2020 10-bit signal produced an `rTRC` reading 0.009224 at signal 0.5 where the spec gives 0.259719, a factor of 28. The same match rejected code point 1 outright while accepting 14. Codes 1, 6, 14 and 15 are one curve, and its inverse is exactly `parametricCurveType` function type 3 (ICC.1:2022 §10.18), so all four are now encoded from Table 3's own α = 1 + 5.5β and β = 0.018053968510807. The transfer axis is keyed on the raw code point rather than on `gamut_color::cicp::TransferCharacteristics`, because what an ICC tag can encode is not what gamut-color can evaluate: codes 6 and 15 have no variant there and none of the four has an EOTF, yet all four are exactly encodable. Three further conformance fixes on the same surface: * §10.3 requires `MatrixCoefficients` to be 0 when the data colour space is RGB, so the caller's value — 1, 5, 6 or 9 in a typical AVIF or HEIC `nclx` box — is no longer written into the `cicpType` tag, and `VideoFullRangeFlag` is normalized to 1 to match the full-scale RGB the profile's matrix and curves are defined over. Nothing is lost: both describe an encoding the caller de-matrixes before the profile applies, and both stay in the container signalling a decoder reads them from. * `gray_with_gamma` declines a gamma no `kTRC` can carry — non-finite, non-positive, or at or above the 32 768 `s15Fixed16` cannot hold, which `S15Fixed16::from_f64` would otherwise saturate silently. * `colorants_d50` returns the option instead of falling back to the identity, so primaries with no chromaticities are declined structurally rather than by naming `Unspecified`, and no profile can claim the PCS axes as its colorants. `constructors_are_byte_deterministic` compared `first.ok() == second.ok()`, so two serialization failures compared equal and it passed vacuously. BREAKING CHANGE: `IccProfile::builtin` and `IccProfile::gray_with_gamma` now return `Option`. `IccProfile::from_cicp` no longer records the caller's `MatrixCoefficients` or `VideoFullRangeFlag`, and builds a BT.709 tone curve — not a PQ one — for transfer code point 14. --- crates/gamut-icc/src/builtin.rs | 433 +++++++++++++++++++++++++------- 1 file changed, 346 insertions(+), 87 deletions(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 3b2c899e..5495e230 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -10,13 +10,32 @@ //! //! # Which spaces //! -//! The buildable set is exactly what `gamut-color` can express on the two CICP axes — because a -//! matrix/TRC profile *is* a (primaries, transfer) pair. [`gamut_color::SourceProfile`]'s -//! `ADOBE_RGB` and `PROPHOTO_RGB` return `None` from both +//! A matrix/TRC profile *is* a (primaries, transfer) pair, and the two axes are resolved from +//! different places. **Primaries** come from `gamut-color`: a code point it gives no +//! chromaticities for cannot be described, which is why [`gamut_color::SourceProfile`]'s +//! `ADOBE_RGB` and `PROPHOTO_RGB` — `None` from both //! [`colour_primaries`](gamut_color::SourceProfile::colour_primaries) and -//! [`transfer_characteristics`](gamut_color::SourceProfile::transfer_characteristics), and their -//! chromaticities are private to `gamut-color`, so [`IccProfile::from_source_profile`] returns -//! `None` for them rather than this module retyping the tables. +//! [`transfer_characteristics`](gamut_color::SourceProfile::transfer_characteristics), with +//! chromaticities private to `gamut-color` — are declined by +//! [`IccProfile::from_source_profile`] rather than having their tables retyped here. +//! **Transfers** are keyed on the raw ITU-T H.273 code point, because the set of curves an ICC +//! tag can *encode* is not the set `gamut-color` can *evaluate*: H.273 Table 3's code points 1, +//! 6, 14 and 15 are one curve with a closed form ICC.1:2022 §10.18 defines exactly, so this +//! module encodes all four even though `gamut_color::transfer::eotf_for` supplies no EOTF for +//! them and its `TransferCharacteristics` models only two of the four. +//! +//! # What a CICP triple contributes, and what it does not +//! +//! [`IccProfile::from_cicp`] takes all four H.273 fields but builds from two of them. ICC.1:2022 +//! §10.3 states that *"when the data colour space in the profile header is RGB or XYZ, +//! MatrixCoefficients shall be 0 (zero)"*, so the caller's `MatrixCoefficients` is **not** +//! carried into the `cicpType` tag: an AVIF or HEIC `nclx` box routinely signals 1, 5, 6 or 9, +//! and writing any of those into an RGB profile would make it non-conforming. Nothing is lost by +//! that — the coefficients describe a luma–chroma *encoding* the caller de-matrixes before the +//! profile applies, and they remain in the container's own signalling where a decoder reads +//! them. `VideoFullRangeFlag` is normalized to `1` for the same reason: the profile's matrix and +//! tone curves are defined over full-scale RGB, and §10.3's own RGB examples note the flag "is +//! often 1" there. //! //! # Determinism //! @@ -51,8 +70,24 @@ const COPYRIGHT: &str = "Public domain — no rights reserved."; /// peak) below one `uInt16` quantum, so the table is as exact as the encoding it is written in. const SAMPLED_TRC_POINTS: usize = 1024; -/// The 3×3 identity, used as the total fallback in [`colorants_d50`]. -const IDENTITY_3X3: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; +/// A 3×3 matrix in row-major order — the shape `gamut_color::matrix` produces and consumes. +type Matrix3 = [[f64; 3]; 3]; + +/// The largest gamma a `parametricCurveType` parameter can carry: `s15Fixed16` (ICC.1:2022 §4.6) +/// is a signed 16.16 fixed-point number, so 32 768 is the first value it cannot encode. +/// +/// [`S15Fixed16::from_f64`] saturates rather than failing, so a gamma at or above this would be +/// silently written as 32 767.99998. [`IccProfile::gray_with_gamma`] declines it instead. +const GAMMA_ENCODING_LIMIT: f64 = 32_768.0; + +/// ITU-T H.273 (07/2024) §8.2: for `TransferCharacteristics` 1, 6, 14 and 15, β is "the positive +/// constant necessary for the curve segments that meet at the value β to have continuity of both +/// value and slope", stated there as `0.018053968510807...`. +const BT709_BETA: f64 = 0.018_053_968_510_807; + +/// ITU-T H.273 (07/2024) §8.2: `α = 1 + 5.5 * β = 1.099296826809442...`, derived from +/// [`BT709_BETA`] rather than restated so the two cannot disagree. +const BT709_ALPHA: f64 = 1.0 + 5.5 * BT709_BETA; /// A colour space [`IccProfile::builtin`] can construct without any further input. /// @@ -136,25 +171,38 @@ impl BuiltinProfile { cicp_of(primaries, transfer) } - /// The space whose axes are exactly `primaries` and `transfer`, if there is one. Lets - /// [`IccProfile::from_cicp`] name a profile it recognizes instead of describing it by code - /// point. - fn for_axes(primaries: ColourPrimaries, transfer: TransferCharacteristics) -> Option { + /// The space whose axes are exactly `primaries` and the H.273 transfer code point + /// `transfer`, if there is one. Lets [`IccProfile::from_cicp`] name a profile it recognizes + /// instead of describing it by code point. + fn for_axes(primaries: ColourPrimaries, transfer: u8) -> Option { Self::ALL.into_iter().find(|space| { let (p, t, _, _) = space.parts(); - (p, t) == (primaries, transfer) + p == primaries && cicp_byte(t.code_point()) == transfer }) } } -/// The `cicpType` value for a pair of CICP axes, with identity matrix coefficients and the -/// full-range flag set. +/// The `cicpType` value for a pair of CICP axes. fn cicp_of(primaries: ColourPrimaries, transfer: TransferCharacteristics) -> Cicp { - Cicp { + normalized_cicp(Cicp { colour_primaries: cicp_byte(primaries.code_point()), transfer_characteristics: cicp_byte(transfer.code_point()), + // Both set by `normalized_cicp`, which owns the §10.3 rule for every constructor. + matrix_coefficients: 0, + video_full_range_flag: 0, + }) +} + +/// `cicp` as ICC.1:2022 §10.3 requires it inside an RGB profile: `MatrixCoefficients` **shall** +/// be zero, and `VideoFullRangeFlag` is set to `1` because the profile's matrix and tone curves +/// are defined over full-scale RGB. +/// +/// The two axes the profile is actually built from pass through untouched. +fn normalized_cicp(cicp: Cicp) -> Cicp { + Cicp { matrix_coefficients: 0, video_full_range_flag: 1, + ..cicp } } @@ -174,26 +222,33 @@ enum Trc { /// IEC 61966-2-1 (sRGB), a `parametricCurveType` function type 3 — the spec's own piecewise /// closed form, so the encoding is exact rather than sampled. Srgb, + /// The ITU-T H.273 Table 3 curve shared by transfer code points 1 (BT.709), 6 (BT.601), + /// 14 (BT.2020 10-bit) and 15 (BT.2020 12-bit) — one function, as Table 3's own informative + /// remark says ("functionally the same as the values 1, 6 and 15"). Also a + /// `parametricCurveType` function type 3. + Bt709, /// SMPTE ST 2084 (PQ) normalized to its peak. ICC.1:2022 §10.18 defines no closed form for /// it, so it is a sampled `curveType` (§10.6). Pq, } impl Trc { - /// The curve for a CICP transfer code point, or `None` where `gamut-color` implements no - /// curve for it ([`TransferCharacteristics::Bt709`], [`TransferCharacteristics::Hlg`] and - /// [`TransferCharacteristics::Unspecified`] — see - /// [`gamut_color::transfer::eotf_for`]). - fn from_cicp(transfer: TransferCharacteristics) -> Option { + /// The curve for an ITU-T H.273 `TransferCharacteristics` code point, or `None` for a code + /// point this module cannot encode as an ICC tone curve. + /// + /// Keyed on the raw code point, not on [`TransferCharacteristics`]: the encodable set and the + /// set `gamut-color` models are different sets (see the module docs). Code points 1, 6, 14 + /// and 15 are one curve — H.273 Table 3 gives them identical segments and one pair of α/β + /// constants — so all four map to [`Trc::Bt709`]. + /// + /// Everything else is declined, including HLG (18) and Unspecified (2), which have no + /// `parametricCurveType` form and no `gamut-color` EOTF to sample. + fn for_code_point(transfer: u8) -> Option { match transfer { - TransferCharacteristics::Linear => Some(Trc::Gamma(1.0)), - TransferCharacteristics::Srgb => Some(Trc::Srgb), - TransferCharacteristics::Pq | TransferCharacteristics::Bt2020_10 => Some(Trc::Pq), - // Every other code point has no curve here. That covers the three gamut-color models - // but supplies no EOTF for (`Bt709`, `Hlg`, `Unspecified`) and, since - // `TransferCharacteristics` is `#[non_exhaustive]`, any code point gamut-color models - // later until this match names it. Listing the first three explicitly would be a - // second, behaviourally identical arm. + 1 | 6 | 14 | 15 => Some(Trc::Bt709), + 8 => Some(Trc::Gamma(1.0)), + 13 => Some(Trc::Srgb), + 16 => Some(Trc::Pq), _ => None, } } @@ -215,6 +270,23 @@ impl Trc { .map(S15Fixed16::from_f64) .collect(), }), + // The inverse of H.273 Table 3's opto-electronic function, which is what an ICC tone + // curve encodes: `Lc = ((V + α − 1) / α)^(1/0.45)` for `V >= 4.5 β`, else `V / 4.5`. + // In §10.18 type 3's `(g, a, b, c, d)` order. Pinned against an independent + // transcription of Table 3 by `bt709_curve_inverts_the_h273_transfer`. + Trc::Bt709 => TagData::ParametricCurve(ParametricCurve { + function_type: 3, + params: [ + 1.0 / 0.45, + 1.0 / BT709_ALPHA, + (BT709_ALPHA - 1.0) / BT709_ALPHA, + 1.0 / 4.5, + 4.5 * BT709_BETA, + ] + .into_iter() + .map(S15Fixed16::from_f64) + .collect(), + }), Trc::Pq => TagData::Curve(Curve::Sampled(pq_samples())), } } @@ -254,18 +326,16 @@ fn pcs_d50_chromaticity() -> [f64; 2] { /// The D50-adapted colorant columns for `primaries`, and the chromatic-adaptation matrix that took /// them there — the `rXYZ`/`gXYZ`/`bXYZ` (§9.2.10) and `chad` (§9.2.35) tag contents. /// -/// Total by construction. A code point that names no chromaticities -/// ([`ColourPrimaries::Unspecified`]) — or, were one ever added, chromaticities with no RGB→XYZ -/// matrix — yields the identity for both, i.e. colorants equal to the PCS axes. Every public entry -/// point rejects `Unspecified` before reaching here, so that result is not observable through them. -fn colorants_d50(primaries: ColourPrimaries) -> ([[f64; 3]; 3], [[f64; 3]; 3]) { - let (rgb_to_xyz, chad) = primaries - .chromaticities() - .and_then(|(rgb, white)| { - rgb_to_xyz_matrix(&rgb, white).zip(bradford_adapt(white, pcs_d50_chromaticity())) - }) - .unwrap_or((IDENTITY_3X3, IDENTITY_3X3)); - (mat_mul3(&chad, &rgb_to_xyz), chad) +/// `None` when the primaries cannot be turned into colorants at all: a code point that names no +/// chromaticities ([`ColourPrimaries::Unspecified`], and any later variant `gamut-color` adds +/// without them), or chromaticities degenerate enough to have no RGB→XYZ or adaptation matrix. +/// Declining here is what stops a profile claiming the PCS axes as its primaries, so the guard is +/// structural: no caller has to know which code points are colorimetrically empty. +fn colorants_d50(primaries: ColourPrimaries) -> Option<(Matrix3, Matrix3)> { + let (rgb, white) = primaries.chromaticities()?; + let rgb_to_xyz = rgb_to_xyz_matrix(&rgb, white)?; + let chad = bradford_adapt(white, pcs_d50_chromaticity())?; + Some((mat_mul3(&chad, &rgb_to_xyz), chad)) } /// A `multiLocalizedUnicodeType` element carrying one `en-US` record — the v4 form of the @@ -282,14 +352,15 @@ fn mluc(text: &str) -> TagData { /// The three-component matrix/TRC display profile for a pair of CICP axes (ICC.1:2022 §8.4). /// -/// `cicp` is the `cicpType` element to record the signalling the profile was built from. +/// `cicp` is the `cicpType` element to record the signalling the profile was built from, already +/// normalized for §10.3. `None` when [`colorants_d50`] declines the primaries. fn rgb_matrix_trc( primaries: ColourPrimaries, trc: Trc, description: &str, cicp: Cicp, -) -> IccProfile { - let (colorants, chad) = colorants_d50(primaries); +) -> Option { + let (colorants, chad) = colorants_d50(primaries)?; // A colorant tag is a *column* of the RGB→XYZ matrix: the XYZ of that primary at full scale. let column = |j: usize| { TagData::Xyz(vec![XyzNumber::from_f64([ @@ -305,7 +376,7 @@ fn rgb_matrix_trc( .collect(), ); let curve = trc.tag(); - IccProfile { + Some(IccProfile { header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), tags: vec![ (KnownTag::ProfileDescription.into(), mluc(description)), @@ -323,7 +394,7 @@ fn rgb_matrix_trc( (KnownTag::BlueTrc.into(), curve), (KnownTag::Cicp.into(), TagData::Cicp(cicp)), ], - } + }) } impl IccProfile { @@ -333,17 +404,23 @@ impl IccProfile { /// the Bradford matrix recorded in `chad`, so the result satisfies /// [`validate`](IccProfile::validate) for the Display class with no further setup. /// + /// Every [`BuiltinProfile`] in this release yields `Some`, and + /// `every_builtin_space_is_buildable` pins that. The result is still an `Option` because the + /// colorimetry is `gamut-color`'s and its chromaticity and matrix constructors are themselves + /// fallible: a variant added here whose primaries that crate cannot supply must decline, not + /// fall back to a profile whose colorants are silently the PCS axes. + /// /// # Examples /// /// ``` /// use gamut_icc::{BuiltinProfile, IccProfile, KnownTag, TagData}; /// - /// let profile = IccProfile::builtin(BuiltinProfile::DisplayP3); + /// let profile = IccProfile::builtin(BuiltinProfile::DisplayP3).expect("a modelled space"); /// assert!(profile.validate().is_empty()); /// assert!(matches!(profile.get(KnownTag::RedColorant), Some(TagData::Xyz(_)))); /// ``` #[must_use] - pub fn builtin(space: BuiltinProfile) -> Self { + pub fn builtin(space: BuiltinProfile) -> Option { let (primaries, _, trc, description) = space.parts(); rgb_matrix_trc(primaries, trc, description, space.cicp()) } @@ -352,18 +429,30 @@ impl IccProfile { /// /// The white point is D50, so no chromatic adaptation is needed and no `chad` tag is written. /// + /// Returns `None` for a `gamma` no `kTRC` can carry: `gamma` must be finite, strictly + /// positive, and below [`GAMMA_ENCODING_LIMIT`] — the first magnitude the `s15Fixed16` + /// parameter cannot hold. `f64` is open input, and the alternatives are worse than declining: + /// `NaN` would be written as a description string and a saturated `s15Fixed16`, and a gamma of + /// `0.0` describes a profile that maps every input to white. + /// /// # Examples /// /// ``` /// use gamut_icc::{IccProfile, KnownTag, TagData}; /// - /// let profile = IccProfile::gray_with_gamma(2.2); + /// let profile = IccProfile::gray_with_gamma(2.2).expect("2.2 is encodable"); /// assert!(profile.validate().is_empty()); /// assert!(matches!(profile.get(KnownTag::GrayTrc), Some(TagData::ParametricCurve(_)))); + /// + /// assert!(IccProfile::gray_with_gamma(f64::NAN).is_none()); + /// assert!(IccProfile::gray_with_gamma(0.0).is_none()); /// ``` #[must_use] - pub fn gray_with_gamma(gamma: f64) -> Self { - IccProfile { + pub fn gray_with_gamma(gamma: f64) -> Option { + if !gamma.is_finite() || gamma <= 0.0 || gamma >= GAMMA_ENCODING_LIMIT { + return None; + } + Some(IccProfile { header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Gray), tags: vec![ ( @@ -377,19 +466,24 @@ impl IccProfile { ), (KnownTag::GrayTrc.into(), Trc::Gamma(gamma).tag()), ], - } + }) } /// A v4 matrix/TRC display profile for a CICP signalling triple — the path from what AVIF, /// HEIC and JXL usually carry (a `colr`/`nclx` code-point trio) to an embeddable profile. /// - /// The `cicpType` tag records `cicp` verbatim, including its matrix coefficients and range - /// flag: those describe a luma–chroma *encoding*, which a matrix/TRC profile does not model, - /// so the profile's own pipeline always describes the RGB signal after any de-matrixing. + /// Only the primaries and transfer code points shape the profile. The `cicpType` tag records + /// them together with the `MatrixCoefficients` and `VideoFullRangeFlag` **ICC.1:2022 §10.3 + /// requires of an RGB profile** — zero and one — not the caller's: §10.3 states that when the + /// data colour space is RGB or XYZ, `MatrixCoefficients` *shall* be 0. That is not a loss of + /// information. The coefficients describe a luma–chroma encoding the caller de-matrixes + /// before this profile applies, and they stay in the container signalling (`nclx`, AV1 + /// sequence header) that a decoder actually reads them from. /// - /// Returns `None` when the profile cannot describe the signalling: an unmodelled or - /// [`Unspecified`](ColourPrimaries::Unspecified) primaries code point, or a transfer - /// characteristic for which `gamut-color` implements no curve (BT.709 and HLG today). + /// Returns `None` when the profile cannot describe the signalling: a primaries code point + /// with no chromaticities, whether unmodelled or + /// [`Unspecified`](ColourPrimaries::Unspecified); or a transfer code point with no ICC tone + /// curve, such as HLG (18) or Unspecified (2). /// /// # Examples /// @@ -399,7 +493,12 @@ impl IccProfile { /// // BT.709 primaries + sRGB transfer is sRGB, and is built as such. /// let signalled = Cicp { colour_primaries: 1, transfer_characteristics: 13, /// matrix_coefficients: 0, video_full_range_flag: 1 }; - /// assert_eq!(IccProfile::from_cicp(signalled), Some(IccProfile::builtin(BuiltinProfile::Srgb))); + /// assert_eq!(IccProfile::from_cicp(signalled), IccProfile::builtin(BuiltinProfile::Srgb)); + /// + /// // The BT.601 matrix an AVIF `nclx` box may carry does not change the profile, and is not + /// // written into it. + /// let matrixed = Cicp { matrix_coefficients: 6, video_full_range_flag: 0, ..signalled }; + /// assert_eq!(IccProfile::from_cicp(matrixed), IccProfile::from_cicp(signalled)); /// /// // "Unspecified" primaries name no chromaticities, so no profile can be built. /// assert!(IccProfile::from_cicp(Cicp { colour_primaries: 2, ..signalled }).is_none()); @@ -407,13 +506,8 @@ impl IccProfile { #[must_use] pub fn from_cicp(cicp: Cicp) -> Option { let primaries = ColourPrimaries::from_code_point(u16::from(cicp.colour_primaries))?; - if primaries == ColourPrimaries::Unspecified { - return None; - } - let transfer = - TransferCharacteristics::from_code_point(u16::from(cicp.transfer_characteristics))?; - let trc = Trc::from_cicp(transfer)?; - let named = BuiltinProfile::for_axes(primaries, transfer); + let trc = Trc::for_code_point(cicp.transfer_characteristics)?; + let named = BuiltinProfile::for_axes(primaries, cicp.transfer_characteristics); let description = match named { Some(space) => space.parts().3.to_owned(), None => format!( @@ -421,7 +515,7 @@ impl IccProfile { cicp.colour_primaries, cicp.transfer_characteristics ), }; - Some(rgb_matrix_trc(primaries, trc, &description, cicp)) + rgb_matrix_trc(primaries, trc, &description, normalized_cicp(cicp)) } /// A v4 matrix/TRC display profile for a [`SourceProfile`] — `gamut-color`'s @@ -439,7 +533,7 @@ impl IccProfile { /// /// assert_eq!( /// IccProfile::from_source_profile(SourceProfile::SRGB), - /// Some(IccProfile::builtin(BuiltinProfile::Srgb)), + /// IccProfile::builtin(BuiltinProfile::Srgb), /// ); /// assert!(IccProfile::from_source_profile(SourceProfile::ADOBE_RGB).is_none()); /// ``` @@ -471,19 +565,20 @@ mod tests { ("gray", IccProfile::gray_with_gamma(2.2)), ]; for (label, profile) in profiles { + let profile = profile.unwrap_or_else(|| panic!("{label}: constructed")); assert_eq!(profile.validate(), vec![], "{label}: §8 conformance"); assert!(profile.to_bytes().is_ok(), "{label}: serializes"); } } - /// `colorants_d50` is total: a code point naming no chromaticities yields the identity for - /// both the colorants and the adaptation matrix. This is the arm the public constructors can - /// never reach, so it is pinned here at the only input that reaches it. + /// No [`BuiltinProfile`] declines. `builtin` returns an `Option` only because the colorimetry + /// it resolves is `gamut-color`'s and that crate's constructors are fallible; this is what + /// makes a variant added without chromaticities fail here instead of shipping. #[test] - fn colorants_of_unspecified_primaries_are_the_identity() { - let (colorants, chad) = colorants_d50(ColourPrimaries::Unspecified); - assert_eq!(colorants, IDENTITY_3X3); - assert_eq!(chad, IDENTITY_3X3); + fn every_builtin_space_is_buildable() { + for space in BuiltinProfile::ALL { + assert!(IccProfile::builtin(space).is_some(), "{space:?}"); + } } /// A code point too large for `cicpType`'s `uInt8` becomes Unspecified. Unreachable from any @@ -515,6 +610,67 @@ mod tests { } } + /// ITU-T H.273 (07/2024) Table 3, transfer code point 1, transcribed **forward** exactly as + /// the spec states it: `V = α Lc^0.45 − (α − 1)` for `1 >= Lc >= β`, and `V = 4.500 Lc` for + /// `β > Lc >= 0`. + /// + /// Its α and β are written out again here on purpose. A test that reused [`BT709_ALPHA`] and + /// [`BT709_BETA`] would share any mistyped digit with the code it is checking. + fn h273_bt709_oetf(light: f64) -> f64 { + let (alpha, beta) = (1.099_296_826_809_442, 0.018_053_968_510_807); + if light >= beta { + alpha * light.powf(0.45) - (alpha - 1.0) + } else { + 4.500 * light + } + } + + /// The `parametricCurveType` written for the BT.709 family inverts H.273 Table 3's + /// opto-electronic function, on both segments and across the β knee. An ICC tone curve + /// encodes signal → light, so this is the law that fixes the direction as well as the + /// constants: writing the forward function, or `α` where `α − 1` belongs, moves the curve by + /// far more than the tolerance. + /// + /// The tolerance is the `s15Fixed16` quantization of `(g, a, b, c, d)` — measured at most + /// 1.4e-6 over the whole domain — with an order of magnitude of headroom. + #[test] + fn bt709_curve_inverts_the_h273_transfer() { + let TagData::ParametricCurve(curve) = Trc::Bt709.tag() else { + panic!("the BT.709 family is encoded as a parametricCurveType"); + }; + for step in 0..=1000 { + let light = f64::from(step) / 1000.0; + let got = curve.eval(h273_bt709_oetf(light)); + assert!( + (got - light).abs() < 1.0e-5, + "BT.709 TRC round trip at Lc = {light}: {got}" + ); + } + } + + /// H.273 Table 3 gives code points 1, 6, 14 and 15 the same two segments and the same α/β — + /// its own remark calls 14 "functionally the same as the values 1, 6 and 15" — so all four + /// build one curve. Each is asserted separately: the code maps them in a single or-pattern, + /// whose alternatives no mutation of the match itself would exercise. + #[test] + fn every_bt709_family_code_point_builds_the_same_curve() { + let want = Trc::Bt709.tag(); + for code in [1_u8, 6, 14, 15] { + let profile = IccProfile::from_cicp(Cicp { + colour_primaries: 1, + transfer_characteristics: code, + matrix_coefficients: 0, + video_full_range_flag: 1, + }) + .unwrap_or_else(|| panic!("H.273 transfer {code} is encodable")); + assert_eq!( + profile.get(KnownTag::RedTrc), + Some(&want), + "transfer {code}" + ); + } + } + /// The sampled PQ curve reproduces `gamut-color`'s `pq_eotf` normalized to its peak, to within /// the `uInt16` quantum the table is written in. This is what justifies /// [`SAMPLED_TRC_POINTS`]: it pins that the sampling density is fine enough to make the @@ -543,13 +699,15 @@ mod tests { for space in BuiltinProfile::ALL { let (primaries, transfer, _, _) = space.parts(); assert_eq!( - BuiltinProfile::for_axes(primaries, transfer), + BuiltinProfile::for_axes(primaries, cicp_byte(transfer.code_point())), Some(space), "{space:?} is the space for its own axes" ); + let built = IccProfile::builtin(space); + assert!(built.is_some(), "{space:?} builds"); assert_eq!( IccProfile::from_cicp(space.cicp()), - Some(IccProfile::builtin(space)), + built, "{space:?} builds the same profile from its own CICP value" ); } @@ -562,7 +720,8 @@ mod tests { fn each_builtin_space_names_the_curve_for_its_own_transfer() { for space in BuiltinProfile::ALL { let (_, transfer, trc, _) = space.parts(); - assert_eq!(Trc::from_cicp(transfer), Some(trc), "{space:?}"); + let code = cicp_byte(transfer.code_point()); + assert_eq!(Trc::for_code_point(code), Some(trc), "{space:?}"); } } @@ -601,14 +760,14 @@ mod tests { }, ), ( - "BT.709 transfer, no curve in gamut-color", + "unspecified transfer", Cicp { - transfer_characteristics: 1, + transfer_characteristics: 2, ..srgb }, ), ( - "HLG transfer, no curve in gamut-color", + "HLG transfer, no ICC tone curve", Cicp { transfer_characteristics: 18, ..srgb @@ -619,6 +778,65 @@ mod tests { } } + /// ICC.1:2022 §10.3: "when the data colour space in the profile header is RGB or XYZ, + /// MatrixCoefficients shall be 0 (zero)". The coefficients an AVIF or HEIC `nclx` box carries + /// (1, 5, 6, 9 …) therefore do not reach the `cicpType` tag, and neither does the range flag, + /// which is fixed at full range to match the full-scale RGB the profile's own matrix and + /// curves are defined over. Two profiles differing only in those two fields are one profile. + #[test] + fn from_cicp_normalizes_the_matrix_coefficients_and_range_flag() { + let conforming = Cicp { + colour_primaries: 9, + transfer_characteristics: 16, + matrix_coefficients: 0, + video_full_range_flag: 1, + }; + let reference = IccProfile::from_cicp(conforming).expect("the control builds"); + for matrix in [1_u8, 5, 6, 9] { + for range in [0_u8, 1] { + let signalled = Cicp { + matrix_coefficients: matrix, + video_full_range_flag: range, + ..conforming + }; + let profile = + IccProfile::from_cicp(signalled).expect("signalling with a matrix builds"); + assert_eq!(profile, reference, "matrix {matrix}, range {range}"); + assert_eq!( + profile.get(KnownTag::Cicp), + Some(&TagData::Cicp(conforming)), + "matrix {matrix}, range {range}: §10.3 fixes both fields for an RGB profile" + ); + } + } + } + + /// A grey gamma is open `f64` input, and one the `kTRC` cannot carry is declined rather than + /// written as a saturated or non-finite `s15Fixed16`. The limit itself is asserted from both + /// sides, because only a value exactly at it separates `>=` from `>`. + #[test] + fn an_unencodable_grey_gamma_is_declined() { + assert!( + IccProfile::gray_with_gamma(2.2).is_some(), + "the control builds" + ); + assert!( + IccProfile::gray_with_gamma(GAMMA_ENCODING_LIMIT - 1.0).is_some(), + "the largest encodable magnitude builds" + ); + for gamma in [ + 0.0, + -1.0, + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + GAMMA_ENCODING_LIMIT, + 40_000.0, + ] { + assert_eq!(IccProfile::gray_with_gamma(gamma), None, "gamma {gamma}"); + } + } + /// `SourceProfile`'s bundles map onto the built-in spaces, and the two with no CICP axes are /// declined rather than approximated. #[test] @@ -632,7 +850,7 @@ mod tests { (SourceProfile::PROPHOTO_RGB, None), ]; for (source, expected) in cases { - let want = expected.map(IccProfile::builtin); + let want = expected.and_then(IccProfile::builtin); assert_eq!( IccProfile::from_source_profile(source), want, @@ -649,7 +867,7 @@ mod tests { let want = XyzNumber::D50.to_f64(); for space in BuiltinProfile::ALL { let (primaries, _, _, label) = space.parts(); - let (colorants, _) = colorants_d50(primaries); + let (colorants, _) = colorants_d50(primaries).expect("a modelled code point"); for axis in 0..3 { let sum: f64 = colorants[axis].iter().sum(); assert!( @@ -666,9 +884,13 @@ mod tests { /// a later `DateTime::now()` would silently break. #[test] fn constructors_are_byte_deterministic() { - let first = IccProfile::builtin(BuiltinProfile::Srgb).to_bytes(); - let second = IccProfile::builtin(BuiltinProfile::Srgb).to_bytes(); - assert_eq!(first.ok(), second.ok()); + let serialize = || { + IccProfile::builtin(BuiltinProfile::Srgb) + .expect("sRGB is buildable") + .to_bytes() + .expect("sRGB serializes") + }; + assert_eq!(serialize(), serialize()); } // --- differential: Little-CMS re-opens what we built ------------------------------------ @@ -695,7 +917,10 @@ mod tests { // irrelevant to the colorants. let reference = lcms2_oracle::rgb_matrix_shaper(white, rgb, [2.2, 2.2, 2.2]); let ours = lcms2_oracle::Profile::from_bytes( - &IccProfile::builtin(space).to_bytes().expect("serializes"), + &IccProfile::builtin(space) + .expect("a modelled space") + .to_bytes() + .expect("serializes"), ) .expect("lcms2 re-opens the profile"); @@ -723,6 +948,7 @@ mod tests { fn oracle_srgb_tone_curve_matches_lcms() { let ours = lcms2_oracle::Profile::from_bytes( &IccProfile::builtin(BuiltinProfile::Srgb) + .expect("sRGB is buildable") .to_bytes() .expect("serializes"), ) @@ -749,6 +975,7 @@ mod tests { fn oracle_transform_through_our_srgb_is_the_identity() { let ours = lcms2_oracle::Profile::from_bytes( &IccProfile::builtin(BuiltinProfile::Srgb) + .expect("sRGB is buildable") .to_bytes() .expect("serializes"), ) @@ -770,12 +997,44 @@ mod tests { } } + /// lcms2 re-opens the BT.709 profile from our serialized bytes and evaluates its `rTRC` to + /// the light H.273 Table 3's forward function started from. An independent CMM reading an + /// independent transcription of the spec: this is what says the `parametricCurveType` we + /// *wrote* — function type, parameter order and `s15Fixed16` encoding — is the curve, not + /// just that our own evaluator agrees with itself. + #[test] + fn oracle_bt709_tone_curve_matches_the_h273_transfer() { + let bytes = IccProfile::from_cicp(Cicp { + colour_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 0, + video_full_range_flag: 1, + }) + .expect("BT.709 signalling builds") + .to_bytes() + .expect("serializes"); + let opened = lcms2_oracle::Profile::from_bytes(&bytes).expect("lcms2 re-opens the profile"); + + for step in 0..=20 { + let light = f64::from(step) / 20.0; + let signal = h273_bt709_oetf(light); + let got = opened + .eval_tone_curve(tag::RED_TRC, signal as f32) + .expect("rTRC present"); + assert!( + (f64::from(got) - light).abs() < 1.0e-4, + "BT.709 rTRC at V = {signal}: {got} vs Lc = {light}" + ); + } + } + /// lcms2 reports the grey profile's gamma as the one asked for. Pins the monochrome /// constructor's `kTRC` against an independent reader rather than against our own encoder. #[test] fn oracle_gray_gamma_matches_lcms() { for gamma in [1.0, 1.8, 2.2] { let bytes = IccProfile::gray_with_gamma(gamma) + .expect("an encodable gamma") .to_bytes() .expect("serializes"); let profile = From d250db5b82db8393e10c4f4e0d71cfc8a07f351a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:57:10 -0400 Subject: [PATCH 06/31] docs(icc): correct the transfer table, the fan-in figure and the PQ limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STATUS.md's tone-curve table codified the defect the previous commit fixed, listing a sampled PQ `curveType` as the encoding for transfer code points "16, 14". It now names the BT.709 family separately, states which code points are declined and why, and records that `from_cicp` builds from two of the four H.273 fields because §10.3 fixes the other two. The dependency paragraph claimed gamut-color has fan-in 8. It is 10 before this crate's edge and 11 with it: gamut, av1, av2, avif, cmm, dng, heic, jpeg, vvc, webp. The frozen record elsewhere stands as written — this is new prose, and new prose must not restate a figure known to be wrong. Also records, as a known limit, that the BT.2100 PQ profile is peak-referred: its curve is normalized to ST 2084's own 10 000 cd/m² peak, so a diffuse-white signal evaluates to roughly 0.02 media-relative and a CMM renders such content through it near black. That is correct for a peak-referred profile and may still surprise a caller embedding it, so the alternative normalization is tracked rather than taken silently. `AGENTS.md`'s architecture table listed gamut-icc with no dependency edge. This branch adds `gamut-icc ← color`. --- AGENTS.md | 2 +- crates/gamut-icc/Cargo.toml | 3 ++- crates/gamut-icc/README.md | 22 ++++++++------- crates/gamut-icc/STATUS.md | 53 ++++++++++++++++++++++++++++++------- crates/gamut-icc/src/lib.rs | 5 ++-- 5 files changed, 63 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c30149d5..d90fcfa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Dependency edges (a crate depends on those to its right): - **gamut-ifd** — TIFF/IFD container core (byte order, field types, IFD read/write); a low-level container primitive (sibling to bitstream), shared by `gamut-tiff` and EXIF metadata. ← core. Optional `bigtiff` feature adds 64-bit BigTIFF. Per-format metadata - crates (**gamut-exif** ← ifd; **gamut-icc**; **gamut-xmp**; **gamut-iptc** ← xmp) and the + crates (**gamut-exif** ← ifd; **gamut-icc** ← color; **gamut-xmp**; **gamut-iptc** ← xmp) and the **gamut-metadata** facade (← exif/xmp/icc/iptc) layer on top under the `metadata` feature; format crates consume the facade for embedded metadata. - **gamut-cmm** — ICC colour management module (epic #323): the transform engine — a diff --git a/crates/gamut-icc/Cargo.toml b/crates/gamut-icc/Cargo.toml index 44b7af6b..a45e8bf4 100644 --- a/crates/gamut-icc/Cargo.toml +++ b/crates/gamut-icc/Cargo.toml @@ -18,7 +18,8 @@ workspace = true [dependencies] # Colorimetry for the built-in profile constructors (`src/builtin.rs`): the CICP code-point # chromaticity tables, the RGB->XYZ / Bradford derivations, and the ST 2084 transfer. The edge -# points this way because gamut-color is the primitive (fan-in 8) and has no need of a profile +# points this way because gamut-color is the primitive (fan-in 10 without this edge) and has no +# need of a profile # serializer; see `STATUS.md`, "Built-in profiles". gamut-color.workspace = true gamut-core.workspace = true diff --git a/crates/gamut-icc/README.md b/crates/gamut-icc/README.md index 0cb7068f..c27c71e2 100644 --- a/crates/gamut-icc/README.md +++ b/crates/gamut-icc/README.md @@ -41,26 +41,30 @@ H.273 code-point triple AVIF, HEIC and JXL usually signal instead of embedding a ```rust use gamut_icc::{BuiltinProfile, Cicp, IccProfile}; -let p3 = IccProfile::builtin(BuiltinProfile::DisplayP3); +let p3 = IccProfile::builtin(BuiltinProfile::DisplayP3).expect("a modelled space"); assert!(p3.validate().is_empty()); let bytes = p3.to_bytes()?; // ready to embed -// BT.2020 primaries + PQ, as an AVIF `colr` box would signal them. +// BT.2020 primaries + PQ, as an AVIF `colr` box would signal them. The matrix coefficients are +// deliberately not carried into the profile: ICC.1:2022 §10.3 requires zero for an RGB profile. let hdr = IccProfile::from_cicp(Cicp { colour_primaries: 9, transfer_characteristics: 16, - matrix_coefficients: 0, - video_full_range_flag: 1, + matrix_coefficients: 9, + video_full_range_flag: 0, }); assert!(hdr.is_some()); # Ok::<_, gamut_icc::IccError>(()) ``` -The buildable spaces are sRGB, linear sRGB, Display P3 and BT.2100 PQ, plus -`IccProfile::gray_with_gamma` for a monochrome profile — exactly the set -[`gamut-color`](../gamut-color) can express on the two CICP axes. Their primaries, white point and -transfer are read from that crate rather than restated here, so the two cannot drift; Adobe RGB and -ProPhoto RGB have no code point on either axis and are declined rather than approximated. +The named spaces are sRGB, linear sRGB, Display P3 and BT.2100 PQ, plus +`IccProfile::gray_with_gamma` for a monochrome profile. Their primaries and white point are read +from [`gamut-color`](../gamut-color) rather than restated here, so the two cannot drift; Adobe RGB +and ProPhoto RGB have no code point on either CICP axis and are declined rather than approximated. +`from_cicp` reaches further on the transfer axis — the BT.709 family (H.273 code points 1, 6, 14 +and 15), linear, sRGB and PQ all have an ICC tone-curve encoding — because what an ICC tag can +encode is not the same set as what gamut-color can evaluate. Every constructor returns an `Option` +and declines signalling it cannot describe; `STATUS.md` tabulates the curve chosen per transfer. **Every ICC.1:2022 §10 element type decodes semantically** — the `XYZType`, curve, and text types; the `lut8`/`lut16`/`lutAToB`/`lutBToA` transforms; `namedColor2Type`; the measurement/signalling diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index c52ae1a8..5c03fcca 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -45,17 +45,20 @@ so no profile is rejected for carrying an unmodelled tag. ## Built-in profiles `IccProfile::builtin`, `gray_with_gamma`, `from_cicp` and `from_source_profile` construct -spec-valid v4 three-component matrix/TRC display profiles (§8.4). The colorimetry is **gamut-color's -and is never restated here**: primaries and white point come from -`ColourPrimaries::chromaticities`, the RGB→XYZ construction and Bradford adaptation from -`gamut_color::matrix`, the ST 2084 curve from `gamut_color::transfer`. +spec-valid v4 three-component matrix/TRC display profiles (§8.4). All four return `Option`: the +colorimetry they resolve is **gamut-color's and is never restated here** — primaries and white +point come from `ColourPrimaries::chromaticities`, the RGB→XYZ construction and Bradford +adaptation from `gamut_color::matrix`, the ST 2084 curve from `gamut_color::transfer` — and those +constructors are themselves fallible, so an input whose colorimetry cannot be resolved is declined +rather than given a profile whose colorants are silently the PCS axes. `builtin` yields `Some` for +every `BuiltinProfile` in this release and a test pins that. **Dependency direction: `gamut-icc → gamut-color`.** The constructors need gamut-color's colorimetry and gamut-icc's serializer, and only one of the two can own that edge. gamut-color is -the primitive — fan-in 8, no ICC dependency — so pointing it the other way would invert the -layering and give a widely-depended-on crate a profile serializer it has no use for. (Contrast -`gamut-cmm → gamut-icc` below: applying a profile is a layer above parsing one; *describing* a -colour space is a layer below.) +the primitive — fan-in 10 before this edge, 11 with it, and no ICC dependency — so pointing it the +other way would invert the layering and give a widely-depended-on crate a profile serializer it +has no use for. (Contrast `gamut-cmm → gamut-icc` below: applying a profile is a layer above +parsing one; *describing* a colour space is a layer below.) **The buildable set is exactly what gamut-color can express on the two CICP axes**, because a matrix/TRC profile *is* a (primaries, transfer) pair: sRGB, linear sRGB, Display P3 and BT.2100 PQ. @@ -70,9 +73,32 @@ gives the transfer a closed form ICC also defines, and a sampled `curveType` (§ | Transfer (H.273) | ICC encoding | Deciding clause | | ---------------- | ------------ | --------------- | | Linear (code 8) | `parametricCurveType` type 0, `g = 1` | §10.18 type 0 is `Y = X^g` exactly | +| BT.709 family (codes 1, 6, 14, 15) | `parametricCurveType` type 3, `(g, a, b, c, d)` | Table 3 gives all four one curve — "functionally the same as the values 1, 6 and 15" — with α = 1 + 5.5β and β = 0.018053968510807…; its inverse is §10.18 type 3 exactly | | sRGB / IEC 61966-2-1 (code 13) | `parametricCurveType` type 3, `(g, a, b, c, d)` | §10.18 type 3 is the spec's own piecewise form | | Grey gamma (`gray_with_gamma`) | `parametricCurveType` type 0 | §10.18 type 0; `s15Fixed16` beats `curveType`'s single `u8Fixed8` entry | -| PQ / ST 2084 (codes 16, 14) | `curveType`, 1024 `uInt16` samples | §10.18 defines no closed form for PQ; 1024 points keep interpolation error under one `uInt16` quantum | +| PQ / ST 2084 (code 16) | `curveType`, 1024 `uInt16` samples | §10.18 defines no closed form for PQ; 1024 points keep interpolation error under one `uInt16` quantum | + +Declined: HLG (code 18) and Unspecified (code 2) have neither a §10.18 closed form nor a +gamut-color EOTF to sample from, and every other H.273 code point is unmodelled here. The transfer +axis is keyed on the **raw code point**, not on `gamut_color::cicp::TransferCharacteristics`, +because the set of curves an ICC tag can encode is not the set gamut-color can evaluate: codes 6 +and 15 have no `TransferCharacteristics` variant, and none of the four BT.709-family codes has a +gamut-color EOTF, yet all four are exactly encodable. + +**CICP fields the profile does not carry.** `from_cicp` builds from the primaries and transfer +code points only. §10.3 states that "when the data colour space in the profile header is RGB or +XYZ, MatrixCoefficients shall be 0 (zero)", so the caller's `MatrixCoefficients` — routinely 1, 5, +6 or 9 in an AVIF/HEIC `nclx` box — is **not** written into the `cicpType` tag; writing it would +make the profile non-conforming for the most common input there is. `VideoFullRangeFlag` is +normalized to `1` alongside it, because the profile's matrix and tone curves are defined over +full-scale RGB. Neither is a loss of information: both describe a luma–chroma encoding the caller +de-matrixes *before* this profile applies, and both remain in the container signalling a decoder +reads them from. + +**Grey gamma domain.** `gray_with_gamma` takes open `f64` input and declines anything a `kTRC` +cannot carry: non-finite, non-positive, or ≥ 32 768, the first magnitude `s15Fixed16` (§4.6) cannot +hold. `S15Fixed16::from_f64` saturates rather than failing, so accepting those would silently write +a gamma nobody asked for. **PCS white.** Colorants are Bradford-adapted to the D50 that `XYZNumber::D50` encodes (§7.2.16), not to `gamut_color::matrix::D50`. The two differ by 2e-4 in Z — the CIE chromaticity against ICC's @@ -80,6 +106,15 @@ rounded tristimulus — and adapting to the CIE one while writing the ICC one as `mediaWhitePointTag` leaves the colorants disagreeing with the white point they sum to. The PCS illuminant is an ICC fact, so this crate owns it. +**Known limit: the BT.2100 PQ profile is peak-referred.** Its `curveType` samples ST 2084 +normalized to the transfer's own 10 000 cd/m² peak, so signal maps to media-relative luminance as a +fraction of that peak. A diffuse-white signal (the ~203 cd/m² BT.2408 reference white) therefore +evaluates to roughly 0.02 media-relative, and a CMM rendering such content through this profile +puts it near black. That is mathematically correct for a peak-referred profile and it is not what a +caller embedding the profile alongside SDR-referred content necessarily expects; choosing a +diffuse-white-referred normalization instead is a colour-appearance decision with its own +consequences, so it is tracked separately rather than changed silently here. + **Determinism.** A constructor is a pure function of its arguments: no creation timestamp, no profile ID, no other entropy, so the same call always serializes to the same bytes. diff --git a/crates/gamut-icc/src/lib.rs b/crates/gamut-icc/src/lib.rs index 20d2dcff..88549fb1 100644 --- a/crates/gamut-icc/src/lib.rs +++ b/crates/gamut-icc/src/lib.rs @@ -24,8 +24,9 @@ //! [`IccProfile::builtin`] constructs a spec-valid v4 matrix/TRC profile for a named //! [`BuiltinProfile`] space, [`IccProfile::gray_with_gamma`] the monochrome equivalent, and //! [`IccProfile::from_cicp`] / [`IccProfile::from_source_profile`] the same from what a codec -//! actually signals — an H.273 code-point triple, or a [`gamut_color::SourceProfile`]. The -//! colorimetry behind them is [`gamut_color`]'s, never restated here; see `src/builtin.rs`. +//! actually signals — an H.273 code-point triple, or a [`gamut_color::SourceProfile`]. Each +//! returns `None` for signalling no matrix/TRC profile can describe. The colorimetry behind them +//! is [`gamut_color`]'s, never restated here; see `src/builtin.rs`. //! //! ```no_run //! use gamut_icc::{IccProfile, KnownTag, TagData}; From f868da15e8a8ba24641e6f4047fa7575dc8488c1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:09:09 -0400 Subject: [PATCH 07/31] docs(icc): state the grey gamma limit instead of linking a private const `gray_with_gamma` is public but `GAMMA_ENCODING_LIMIT` is not, so rustdoc reported "public documentation for `gray_with_gamma` links to private item GAMMA_ENCODING_LIMIT" and rendered no link at all. The magnitude is short enough to state. --- crates/gamut-icc/src/builtin.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 5495e230..369095e0 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -430,8 +430,8 @@ impl IccProfile { /// The white point is D50, so no chromatic adaptation is needed and no `chad` tag is written. /// /// Returns `None` for a `gamma` no `kTRC` can carry: `gamma` must be finite, strictly - /// positive, and below [`GAMMA_ENCODING_LIMIT`] — the first magnitude the `s15Fixed16` - /// parameter cannot hold. `f64` is open input, and the alternatives are worse than declining: + /// positive, and below 32 768 — the first magnitude the `s15Fixed16` parameter cannot hold. + /// `f64` is open input, and the alternatives are worse than declining: /// `NaN` would be written as a description string and a saturated `s15Fixed16`, and a gamma of /// `0.0` describes a profile that maps every input to white. /// From ae43874326f4e50a5c3d0f82b597e58287255941 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:20:25 -0400 Subject: [PATCH 08/31] fix(icc): bound the grey gamma by what the kTRC actually encodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gray_with_gamma` refused a gamma at or above 32 768 because `S15Fixed16::from_f64` saturates there, but the same conversion degenerates at the low end too: it rounds, so any gamma below 0.5 / 65 536 = 7.62939453125e-6 is written as raw 0. `Y = X^0` maps every input, black included, to white — exactly the profile the doc comment gives as the reason for refusing a literal 0.0 — and the constructor accepted it, serialized it, and `validate` reported it clean. Verified by re-opening the written bytes: gamma 1e-6 gave a `kTRC` of raw 0 and a transfer that is identically white. The guard now tests the encoding rather than the value, at both ends: `(gamma * 65 536).round()` must land in `1 ..= i32::MAX`, which is the same arithmetic `from_f64` performs, so the two cannot disagree about where rounding stops carrying the caller's number. That also corrects where the top bound sits. Saturation does not begin at 32 768 but at (2^31 − 0.5) / 65 536 = 32 767.999992370605468750, half a quantum above the largest representable value; gammas in between were accepted and silently written as 32 767.99998474121. The boundary test now reads each accepted gamma back out of the tag it was written into, so a value that survives the guard but not the encoding fails it. --- crates/gamut-icc/src/builtin.rs | 101 ++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 369095e0..817f4f4d 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -73,13 +73,6 @@ const SAMPLED_TRC_POINTS: usize = 1024; /// A 3×3 matrix in row-major order — the shape `gamut_color::matrix` produces and consumes. type Matrix3 = [[f64; 3]; 3]; -/// The largest gamma a `parametricCurveType` parameter can carry: `s15Fixed16` (ICC.1:2022 §4.6) -/// is a signed 16.16 fixed-point number, so 32 768 is the first value it cannot encode. -/// -/// [`S15Fixed16::from_f64`] saturates rather than failing, so a gamma at or above this would be -/// silently written as 32 767.99998. [`IccProfile::gray_with_gamma`] declines it instead. -const GAMMA_ENCODING_LIMIT: f64 = 32_768.0; - /// ITU-T H.273 (07/2024) §8.2: for `TransferCharacteristics` 1, 6, 14 and 15, β is "the positive /// constant necessary for the curve segments that meet at the value β to have continuity of both /// value and slope", stated there as `0.018053968510807...`. @@ -214,6 +207,30 @@ fn cicp_byte(code_point: u16) -> u8 { u8::try_from(code_point).unwrap_or(2) } +/// Whether `gamma` survives the `s15Fixed16` encoding of a `parametricCurveType` function type 0 +/// parameter (ICC.1:2022 §10.18) with the value the caller asked for. +/// +/// The guard is on the **encoding**, not on the number, because [`S15Fixed16::from_f64`] rounds +/// `gamma × 65 536` and then *clamps*: it never fails, and it degenerates at both ends. +/// +/// * Below `0.5 / 65 536 = 7.629 394 531 25e-6` the product rounds to raw `0`, and `Y = X^0` maps +/// every input — black included — to white. That is the same profile a literal `gamma` of `0.0` +/// would give, and [`IccProfile::validate`] cannot see it: a `kTRC` holding raw zero is a +/// structurally well-formed tag. +/// * From `(2^31 − 0.5) / 65 536 = 32 767.999 992 370 605 468 75` upward the product exceeds +/// `i32::MAX` and the clamp writes `32 767.999 984 741 21` instead — a gamma chosen by the +/// encoder rather than by the caller. (The clamp, not the fixed-point width, is where this +/// starts: the *largest representable* value, `32 767.999 984 741 21`, is half a quantum lower +/// still, and every gamma between the two merely rounds to it.) +/// +/// Between the two the encoding is a rounding of at most half a quantum, which is what a +/// fixed-point tag is for. Non-finite input satisfies neither comparison and is declined with the +/// rest. +fn gamma_is_encodable(gamma: f64) -> bool { + let raw = (gamma * 65_536.0).round(); + (1.0..=f64::from(i32::MAX)).contains(&raw) +} + /// A tone-response curve this module can encode. #[derive(Debug, Clone, Copy, PartialEq)] enum Trc { @@ -429,11 +446,20 @@ impl IccProfile { /// /// The white point is D50, so no chromatic adaptation is needed and no `chad` tag is written. /// - /// Returns `None` for a `gamma` no `kTRC` can carry: `gamma` must be finite, strictly - /// positive, and below 32 768 — the first magnitude the `s15Fixed16` parameter cannot hold. - /// `f64` is open input, and the alternatives are worse than declining: - /// `NaN` would be written as a description string and a saturated `s15Fixed16`, and a gamma of - /// `0.0` describes a profile that maps every input to white. + /// Returns `None` for a `gamma` the `kTRC` cannot carry *as the caller wrote it*. The bound + /// is the `s15Fixed16` encoding of the curve parameter rather than the number: `gamma` is + /// accepted from `0.5 / 65 536 = 7.629 394 531 25e-6` up to, but not including, + /// `(2^31 − 0.5) / 65 536 = 32 767.999 992 370 605 468 75`. Below that the parameter rounds to + /// zero and `Y = X^0` maps every input to white — the same degenerate profile a literal `0.0` + /// would give, which [`validate`](IccProfile::validate) cannot see, since a `kTRC` holding raw + /// zero is a well-formed tag. At or above it the parameter saturates to a different gamma. + /// Non-finite input is declined with the rest: `NaN` would otherwise reach both the + /// description string and the tag. + /// + /// That range is what the format can encode, not what is colorimetrically sensible — a gamma + /// of `1e-5` is encodable and useless. Whether the constructor should also refuse a + /// meaningless gamma is deliberately left open; see + /// . /// /// # Examples /// @@ -449,7 +475,7 @@ impl IccProfile { /// ``` #[must_use] pub fn gray_with_gamma(gamma: f64) -> Option { - if !gamma.is_finite() || gamma <= 0.0 || gamma >= GAMMA_ENCODING_LIMIT { + if !gamma_is_encodable(gamma) { return None; } Some(IccProfile { @@ -811,26 +837,49 @@ mod tests { } } - /// A grey gamma is open `f64` input, and one the `kTRC` cannot carry is declined rather than - /// written as a saturated or non-finite `s15Fixed16`. The limit itself is asserted from both - /// sides, because only a value exactly at it separates `>=` from `>`. + /// A grey gamma is open `f64` input, and the constructor accepts exactly those the `kTRC` + /// parameter carries back. Each accepted gamma is read out of the tag it was written into, so + /// the test fails if the value survives the guard but not the encoding; each rejected one is + /// the neighbour of an accepted one, so both bounds are pinned from both sides. + /// + /// The `s15Fixed16` conversion degenerates at both ends and never fails, which is why the + /// bound is on the encoding: above the top it clamps to `i32::MAX`, and below the bottom it + /// rounds to raw zero — a `Y = X^0` curve mapping every input to white, which serializes and + /// which `validate` reports clean. + /// + /// The two bounds are restated as literal decimals instead of being reused from + /// [`gamma_is_encodable`], so a wrong scale factor there cannot agree with the test. #[test] - fn an_unencodable_grey_gamma_is_declined() { - assert!( - IccProfile::gray_with_gamma(2.2).is_some(), - "the control builds" - ); - assert!( - IccProfile::gray_with_gamma(GAMMA_ENCODING_LIMIT - 1.0).is_some(), - "the largest encodable magnitude builds" - ); + fn a_grey_gamma_the_ktrc_cannot_carry_is_declined() { + /// `0.5 / 65 536` — one half of a `s15Fixed16` quantum, which is both the smallest gamma + /// whose parameter rounds to raw 1 rather than raw 0 and the largest rounding error the + /// encoding may introduce. + const HALF_QUANTUM: f64 = 7.629_394_531_25e-6; + /// `(2 ^ 31 − 0.5) / 65 536` — the smallest gamma whose parameter clamps to `i32::MAX`. + const SATURATING: f64 = 32_767.999_992_370_605_468_75; + + for gamma in [HALF_QUANTUM, 1.0, 2.2, SATURATING.next_down()] { + let profile = IccProfile::gray_with_gamma(gamma) + .unwrap_or_else(|| panic!("gamma {gamma} is encodable")); + let Some(TagData::ParametricCurve(curve)) = profile.get(KnownTag::GrayTrc) else { + panic!("gamma {gamma}: the grey TRC is a parametricCurveType"); + }; + let written = curve.params[0].to_f64(); + assert!( + written > 0.0 && (written - gamma).abs() <= HALF_QUANTUM, + "gamma {gamma} reached the kTRC as {written}" + ); + } + for gamma in [ 0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, - GAMMA_ENCODING_LIMIT, + HALF_QUANTUM.next_down(), + 1.0e-6, + SATURATING, 40_000.0, ] { assert_eq!(IccProfile::gray_with_gamma(gamma), None, "gamma {gamma}"); From 5b3735b916970dd707887fe8cff5f5fca652782c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:20:34 -0400 Subject: [PATCH 09/31] test(icc): pin the complement of the encodable transfer code points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Trc::for_code_point` maps seven H.273 code points to a tone curve and declines the rest, but every test of it started from a code point it already believed in. That catches a member sent to the wrong curve; it cannot catch a non-member let in. Adding an eighth arm — one H.273 Table 3 gives a different curve, with its own toe and its own constants — left all 161 tests passing, and no mutation of a match expression produces an extra arm, so the mutation gate is blind to it as well. The new sweep asserts `None` for every byte in 0..=255 outside the accepted set, with that set restated rather than read back from the function under test. --- crates/gamut-icc/src/builtin.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 817f4f4d..cd2c232c 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -751,6 +751,30 @@ mod tests { } } + /// No transfer code point outside the encodable set builds a tone curve. + /// + /// The set is restated here rather than read back from [`Trc::for_code_point`], which is what + /// makes this the only test that fails when a code point is wrongly *admitted*. Every other + /// test of the mapping starts from a code point it already believes in, so it can see a + /// member sent to the wrong curve but not a non-member let in — and no mutation of a match + /// expression produces an extra arm, so the mutation gate cannot see it either. + #[test] + fn no_unlisted_transfer_code_point_is_encodable() { + // ICC.1:2022 §10.18 gives a closed form, or gamut-color an EOTF to sample, for exactly + // these ITU-T H.273 Table 3 code points: the BT.709 family (1, 6, 14, 15), linear (8), + // sRGB (13) and PQ (16). Every other byte — reserved, unspecified, the two logarithmic + // curves, ST 240, the BT.470-6 display gammas, HLG, and everything H.273 has not + // assigned — has no ICC tone curve here. + const ENCODABLE: [u8; 7] = [1, 6, 8, 13, 14, 15, 16]; + + for code in 0..=u8::MAX { + if ENCODABLE.contains(&code) { + continue; + } + assert_eq!(Trc::for_code_point(code), None, "transfer code point {code}"); + } + } + /// A transfer `gamut-color` implements no curve for cannot be built, and neither can /// unmodelled or unspecified primaries. Each rejection is asserted at an input that isolates /// it: the primaries are valid when the transfer is the reason, and vice versa. From 569cc5470c1ce1373b315558c0bb33dbf183fc34 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:21:09 -0400 Subject: [PATCH 10/31] docs(icc): stop calling the full-range flag a conformance requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ICC.1:2022 §10.3 carries one `shall` about a `cicpType` tag in an RGB profile: `MatrixCoefficients` shall be 0. It says nothing of the kind about `VideoFullRangeFlag` — only that it "is often 1" for RGB — and its own RGB examples include 1-1-0-0 and 9-16-0-0 with the flag at zero. This module wrote both fields and documented both as §10.3 requirements, which is true of one and false of the other. Fixing the flag at 1 is a deliberate normalisation: the colorants, the `chad` and the tone curves written alongside it are all defined over full-scale RGB, so a profile of this shape signalling narrow range would describe a scaling it does not perform. That is now documented as this crate's choice, together with its consequence — the caller's flag is discarded, not preserved, and a caller that needs the original value must read it from the container signalling it came from. Also repairs a doc link left behind by an earlier rename. Both items are private, so `cargo doc` without `--document-private-items` never resolved it and reported the crate clean. --- crates/gamut-icc/src/builtin.rs | 76 ++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index cd2c232c..58ed9d33 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -26,17 +26,30 @@ //! //! # What a CICP triple contributes, and what it does not //! -//! [`IccProfile::from_cicp`] takes all four H.273 fields but builds from two of them. ICC.1:2022 -//! §10.3 states that *"when the data colour space in the profile header is RGB or XYZ, -//! MatrixCoefficients shall be 0 (zero)"*, so the caller's `MatrixCoefficients` is **not** -//! carried into the `cicpType` tag: an AVIF or HEIC `nclx` box routinely signals 1, 5, 6 or 9, -//! and writing any of those into an RGB profile would make it non-conforming. Nothing is lost by -//! that — the coefficients describe a luma–chroma *encoding* the caller de-matrixes before the -//! profile applies, and they remain in the container's own signalling where a decoder reads -//! them. `VideoFullRangeFlag` is normalized to `1` for the same reason: the profile's matrix and -//! tone curves are defined over full-scale RGB, and §10.3's own RGB examples note the flag "is -//! often 1" there. +//! [`IccProfile::from_cicp`] takes all four H.273 fields but builds from two of them, and +//! rewrites the other two. The two rewrites do **not** rest on the same authority, and are +//! documented apart on purpose. //! +//! `MatrixCoefficients` is **conformance**. ICC.1:2022 §10.3 states that *"when the data colour +//! space in the profile header is RGB or XYZ, MatrixCoefficients shall be 0 (zero)"* — a `shall` +//! — so the caller's value cannot be carried: an AVIF or HEIC `nclx` box routinely signals 1, 5, +//! 6 or 9, and writing any of those into an RGB profile would make it non-conforming. Nothing is +//! lost by that. The coefficients describe a luma–chroma *encoding* the caller de-matrixes before +//! the profile applies, and they remain in the container's own signalling where a decoder reads +//! them. +//! +//! `VideoFullRangeFlag` is **not** conformance. §10.3 says only that the flag "is often 1" for an +//! RGB profile, and its own RGB examples include `1-1-0-0` and `9-16-0-0` with the flag at zero, +//! so a narrow-range RGB `cicpType` is a legal tag this module chooses not to write. Fixing it at +//! `1` is a deliberate normalisation: everything the profile actually contains — the colorant +//! matrix, the `chad` and the tone curves — is defined over full-scale RGB, so a profile of this +//! shape signalling narrow range would describe a scaling it does not perform. The consequence is +//! that the caller's flag is **discarded, not preserved**: two triples differing only in it build +//! one profile, and a caller that needs the original value must keep it in the container +//! signalling it came from. +//! +//! + //! # Determinism //! //! A constructor is a pure function of its arguments: the creation date is @@ -107,7 +120,7 @@ impl BuiltinProfile { /// The two CICP axes this space is defined by, the tone curve that encodes its transfer, and /// the text of its `profileDescriptionTag`. /// - /// The curve is named here rather than derived through [`Trc::from_cicp`] so that + /// The curve is named here rather than derived through [`Trc::for_code_point`] so that /// [`IccProfile::builtin`] is total with no unreachable fallback; that the two agree is /// pinned by `each_builtin_space_names_the_curve_for_its_own_transfer`. fn parts(self) -> (ColourPrimaries, TransferCharacteristics, Trc, &'static str) { @@ -186,9 +199,13 @@ fn cicp_of(primaries: ColourPrimaries, transfer: TransferCharacteristics) -> Cic }) } -/// `cicp` as ICC.1:2022 §10.3 requires it inside an RGB profile: `MatrixCoefficients` **shall** -/// be zero, and `VideoFullRangeFlag` is set to `1` because the profile's matrix and tone curves -/// are defined over full-scale RGB. +/// `cicp` with the two fields this module does not take from the caller replaced. +/// +/// `MatrixCoefficients` is zero because ICC.1:2022 §10.3 **requires** it of an RGB or XYZ profile. +/// `VideoFullRangeFlag` is `1` by this module's own choice, not by that clause: §10.3 only remarks +/// that the flag "is often 1" for RGB, and lists RGB examples with it at zero. Full range is what +/// the colorants and tone curves written alongside it are defined over, so the caller's flag is +/// normalised away rather than carried — see the module docs. /// /// The two axes the profile is actually built from pass through untouched. fn normalized_cicp(cicp: Cicp) -> Cicp { @@ -499,12 +516,18 @@ impl IccProfile { /// HEIC and JXL usually carry (a `colr`/`nclx` code-point trio) to an embeddable profile. /// /// Only the primaries and transfer code points shape the profile. The `cicpType` tag records - /// them together with the `MatrixCoefficients` and `VideoFullRangeFlag` **ICC.1:2022 §10.3 - /// requires of an RGB profile** — zero and one — not the caller's: §10.3 states that when the - /// data colour space is RGB or XYZ, `MatrixCoefficients` *shall* be 0. That is not a loss of - /// information. The coefficients describe a luma–chroma encoding the caller de-matrixes - /// before this profile applies, and they stay in the container signalling (`nclx`, AV1 - /// sequence header) that a decoder actually reads them from. + /// those two verbatim, and replaces the other two with `0` and `1`. + /// + /// `MatrixCoefficients` is zero because ICC.1:2022 §10.3 states that when the data colour + /// space is RGB or XYZ it *shall* be. No information is lost: the coefficients describe a + /// luma–chroma encoding the caller de-matrixes before this profile applies, and they stay in + /// the container signalling (`nclx`, AV1 sequence header) a decoder actually reads them from. + /// + /// `VideoFullRangeFlag` is `1` by this crate's choice, **not** by §10.3, which only remarks + /// that the flag "is often 1" for RGB and gives RGB examples with it at zero. The profile's + /// colorants and tone curves are defined over full-scale RGB, so that is what it signals. The + /// caller's flag is therefore **not carried**: triples differing only in it build one profile, + /// and a caller that needs the original value must read it from the container signalling. /// /// Returns `None` when the profile cannot describe the signalling: a primaries code point /// with no chromaticities, whether unmodelled or @@ -828,11 +851,12 @@ mod tests { } } - /// ICC.1:2022 §10.3: "when the data colour space in the profile header is RGB or XYZ, - /// MatrixCoefficients shall be 0 (zero)". The coefficients an AVIF or HEIC `nclx` box carries - /// (1, 5, 6, 9 …) therefore do not reach the `cicpType` tag, and neither does the range flag, - /// which is fixed at full range to match the full-scale RGB the profile's own matrix and - /// curves are defined over. Two profiles differing only in those two fields are one profile. + /// Neither of the two fields `from_cicp` does not build from reaches the `cicpType` tag, so + /// two triples differing only in them are one profile. The coefficients an AVIF or HEIC `nclx` + /// box carries (1, 5, 6, 9 …) are dropped because ICC.1:2022 §10.3 says they *shall* be zero + /// in an RGB profile; the range flag is dropped by this crate's own normalisation to the + /// full-scale RGB its matrix and curves are defined over. The assertion is the same either + /// way — that the caller's value is not carried — which is the part a caller can observe. #[test] fn from_cicp_normalizes_the_matrix_coefficients_and_range_flag() { let conforming = Cicp { @@ -855,7 +879,7 @@ mod tests { assert_eq!( profile.get(KnownTag::Cicp), Some(&TagData::Cicp(conforming)), - "matrix {matrix}, range {range}: §10.3 fixes both fields for an RGB profile" + "matrix {matrix}, range {range}: neither field is taken from the caller" ); } } From 5b7fde526fda158a1f53cba94c9afc374c8a2f73 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:21:19 -0400 Subject: [PATCH 11/31] docs(icc): quantify the second sanctioned reading of the BT.709 curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H.273 Table 3 defines transfer code points 1, 6, 14 and 15 as an opto-electronic function, and an ICC tone curve encodes signal to light, so this module writes the exact inverse. That literal reading stays: it is what the reference implementations write, and changing it would make gamut-icc disagree with every other tool that reads the same signalling. It is not the only reading H.273 sanctions. §8.2 NOTE 1 points at BT.1886-0 as the corresponding electro-optical function for flat-panel displays, which at reference black zero is a pure gamma of 2.4 — and every profile built here is a display-class profile, so that reading has a claim. The two are far apart, and the docs now say so with numbers: at mid-grey the literal reading gives Y = 0.259719 against BT.1886's 0.189465, a factor of 1.371. The same table records that two code points this module already encodes, 1 and 13, differ by 0.0457 at mid-grey — 21 % of the sRGB value — so a caller treating "BT.709 primaries" and "sRGB" as interchangeable sees that much shift from the transfer alone. Offering the BT.1886 reading as an option is filed separately. --- crates/gamut-icc/src/builtin.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 58ed9d33..b83b87b2 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -48,8 +48,33 @@ //! one profile, and a caller that needs the original value must keep it in the container //! signalling it came from. //! +//! # Which reading of transfer 1, 6, 14 and 15 +//! +//! H.273 Table 3 defines those four code points as an **opto-electronic** function, and an ICC +//! tone curve encodes signal → light, so this module writes its exact inverse: +//! `Lc = ((V + α − 1) / α)^(1/0.45)` above the knee. That is the literal reading, and it is what +//! the reference implementations write, so a profile built here agrees with them. +//! +//! It is not the only sanctioned reading. H.273 §8.2 NOTE 1 observes that for these code points +//! "a suggested corresponding reference electro-optical transfer characteristic function for flat +//! panel displays used in HDTV studio production has been specified in Rec. ITU-R BT.1886-0" — +//! which, at reference black zero, is a pure gamma of 2.4. Since every profile built here is a +//! *display*-class profile, that reading has a real claim, and the two are far apart. At mid-grey +//! `V = 0.5` the PCS `Y` is: +//! +//! | reading | `Y` at `V = 0.5` | +//! | --- | --- | +//! | inverse OETF — what this module writes | 0.259719 | +//! | BT.1886 EOTF, `V^2.4` | 0.189465 | +//! | the sRGB code point (13), for scale | 0.214041 | +//! +//! The literal reading is 1.371× the BT.1886 one — 0.0703 in absolute `Y`. Note the third row: +//! two code points this module *does* encode, 1 and 13, already differ by 0.0457 at mid-grey +//! (21 % of the sRGB value), so a caller that treats "BT.709 primaries" and "sRGB" as +//! interchangeable will see that much of a shift from the transfer alone. Offering the BT.1886 +//! reading as an option is tracked at +//! . //! - //! # Determinism //! //! A constructor is a pure function of its arguments: the creation date is @@ -260,6 +285,10 @@ enum Trc { /// 14 (BT.2020 10-bit) and 15 (BT.2020 12-bit) — one function, as Table 3's own informative /// remark says ("functionally the same as the values 1, 6 and 15"). Also a /// `parametricCurveType` function type 3. + /// + /// This is the *inverse of Table 3's opto-electronic function*, the literal reading; H.273 + /// §8.2 NOTE 1 sanctions a second one for display profiles, which the module docs quantify + /// against this one. Bt709, /// SMPTE ST 2084 (PQ) normalized to its peak. ICC.1:2022 §10.18 defines no closed form for /// it, so it is a sampled `curveType` (§10.6). From 6d6a2347b547e3aafa90ff0140b65c8af3ebef5a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:21:40 -0400 Subject: [PATCH 12/31] style(icc): reformat the transfer code-point sweep assertion --- crates/gamut-icc/src/builtin.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index b83b87b2..402fb17c 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -823,7 +823,11 @@ mod tests { if ENCODABLE.contains(&code) { continue; } - assert_eq!(Trc::for_code_point(code), None, "transfer code point {code}"); + assert_eq!( + Trc::for_code_point(code), + None, + "transfer code point {code}" + ); } } From 3d56d3d5b0d51443986dbcb0531976ea31b74a47 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:22:23 -0400 Subject: [PATCH 13/31] style(icc): write the saturation bound at its shortest exact decimal --- crates/gamut-icc/src/builtin.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 402fb17c..e10925e0 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -937,7 +937,9 @@ mod tests { /// encoding may introduce. const HALF_QUANTUM: f64 = 7.629_394_531_25e-6; /// `(2 ^ 31 − 0.5) / 65 536` — the smallest gamma whose parameter clamps to `i32::MAX`. - const SATURATING: f64 = 32_767.999_992_370_605_468_75; + /// Exactly 32 767.999 992 370 605 468 75, written at the shortest decimal that names + /// that `f64` and no other. + const SATURATING: f64 = 32_767.999_992_370_605; for gamma in [HALF_QUANTUM, 1.0, 2.2, SATURATING.next_down()] { let profile = IccProfile::gray_with_gamma(gamma) From 5fb03d3161adeb41e9bad7c008ef54ae30e46bca Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 06:19:21 -0400 Subject: [PATCH 14/31] fix(icc)!: decline a CICP triple that does not signal full range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `from_cicp` rewrote `VideoFullRangeFlag` to 1 and called the loss harmless, on the reading that the flag — like `MatrixCoefficients` beside it — describes a luma-chroma encoding the caller de-matrixes before an RGB profile applies. ICC.1:2022 §10.3's own RGB examples refute that. `1-1-0-0` is listed as "RGB narrow range representation specified in Recommendation ITU-R BT.709-6, Item 3.4", and its `MatrixCoefficients` is already zero: the narrow range is on the RGB samples themselves, and no de-matrixing removes it. So a caller holding genuine narrow-range signalling was not merely losing a piece of metadata; it was handed a profile whose colorants, `chad` and tone curves are all defined over full-scale RGB, and its colour rendered wrongly. The flag is now a precondition rather than a rewrite: anything but 1 is declined, which is what this module already does for primaries it has no chromaticities for and for a transfer with no ICC tone curve. Narrow-range callers scale to full range and pass 1. `normalized_cicp` becomes `rgb_conforming_cicp` — it now carries only the §10.3 `shall`, which is the one field that is still rewritten — and the accepted flag is stated once as `FULL_RANGE`, read by both the tag `cicp_of` writes and the precondition `from_cicp` enforces, so the two cannot drift. The complement is swept over all 256 flag bytes rather than sampled at 0: the guard is a comparison, and an ordering mutation of it leaves one side of 1 admitted. BREAKING CHANGE: `IccProfile::from_cicp` returns `None` for a `Cicp` whose `video_full_range_flag` is not 1, where it previously built a full-range profile from it. --- crates/gamut-icc/README.md | 15 ++- crates/gamut-icc/STATUS.md | 34 ++++-- crates/gamut-icc/src/builtin.rs | 177 ++++++++++++++++++++------------ 3 files changed, 147 insertions(+), 79 deletions(-) diff --git a/crates/gamut-icc/README.md b/crates/gamut-icc/README.md index c27c71e2..e603b8e6 100644 --- a/crates/gamut-icc/README.md +++ b/crates/gamut-icc/README.md @@ -47,13 +47,15 @@ let bytes = p3.to_bytes()?; // ready to embed // BT.2020 primaries + PQ, as an AVIF `colr` box would signal them. The matrix coefficients are // deliberately not carried into the profile: ICC.1:2022 §10.3 requires zero for an RGB profile. -let hdr = IccProfile::from_cicp(Cicp { +// The range flag, by contrast, is a precondition: only full range (1) can be described. +let signalled = Cicp { colour_primaries: 9, transfer_characteristics: 16, matrix_coefficients: 9, - video_full_range_flag: 0, -}); -assert!(hdr.is_some()); + video_full_range_flag: 1, +}; +assert!(IccProfile::from_cicp(signalled).is_some()); +assert!(IccProfile::from_cicp(Cicp { video_full_range_flag: 0, ..signalled }).is_none()); # Ok::<_, gamut_icc::IccError>(()) ``` @@ -64,7 +66,10 @@ and ProPhoto RGB have no code point on either CICP axis and are declined rather `from_cicp` reaches further on the transfer axis — the BT.709 family (H.273 code points 1, 6, 14 and 15), linear, sRGB and PQ all have an ICC tone-curve encoding — because what an ICC tag can encode is not the same set as what gamut-color can evaluate. Every constructor returns an `Option` -and declines signalling it cannot describe; `STATUS.md` tabulates the curve chosen per transfer. +and declines signalling it cannot describe — including a narrow-range `VideoFullRangeFlag`, which +is a scaling of the RGB samples that a full-scale matrix/TRC profile does not perform and that +de-matrixing does not remove. `STATUS.md` tabulates the curve chosen per transfer and records why +each field is rewritten or refused. **Every ICC.1:2022 §10 element type decodes semantically** — the `XYZType`, curve, and text types; the `lut8`/`lut16`/`lutAToB`/`lutBToA` transforms; `namedColor2Type`; the measurement/signalling diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index 5c03fcca..e57b09a7 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -50,8 +50,10 @@ colorimetry they resolve is **gamut-color's and is never restated here** — pri point come from `ColourPrimaries::chromaticities`, the RGB→XYZ construction and Bradford adaptation from `gamut_color::matrix`, the ST 2084 curve from `gamut_color::transfer` — and those constructors are themselves fallible, so an input whose colorimetry cannot be resolved is declined -rather than given a profile whose colorants are silently the PCS axes. `builtin` yields `Some` for -every `BuiltinProfile` in this release and a test pins that. +rather than given a profile whose colorants are silently the PCS axes. Colorimetry is not the only +reason to decline: `from_cicp` also refuses signalling this profile *shape* cannot carry, which is +where narrow range lands — see "CICP fields the profile does not build from" below. `builtin` +yields `Some` for every `BuiltinProfile` in this release and a test pins that. **Dependency direction: `gamut-icc → gamut-color`.** The constructors need gamut-color's colorimetry and gamut-icc's serializer, and only one of the two can own that edge. gamut-color is @@ -85,15 +87,25 @@ because the set of curves an ICC tag can encode is not the set gamut-color can e and 15 have no `TransferCharacteristics` variant, and none of the four BT.709-family codes has a gamut-color EOTF, yet all four are exactly encodable. -**CICP fields the profile does not carry.** `from_cicp` builds from the primaries and transfer -code points only. §10.3 states that "when the data colour space in the profile header is RGB or -XYZ, MatrixCoefficients shall be 0 (zero)", so the caller's `MatrixCoefficients` — routinely 1, 5, -6 or 9 in an AVIF/HEIC `nclx` box — is **not** written into the `cicpType` tag; writing it would -make the profile non-conforming for the most common input there is. `VideoFullRangeFlag` is -normalized to `1` alongside it, because the profile's matrix and tone curves are defined over -full-scale RGB. Neither is a loss of information: both describe a luma–chroma encoding the caller -de-matrixes *before* this profile applies, and both remain in the container signalling a decoder -reads them from. +**CICP fields the profile does not build from.** `from_cicp` builds from the primaries and transfer +code points only, and treats the other two fields differently on purpose — one is rewritten, one is +a precondition. + +`MatrixCoefficients` is **rewritten to zero**. §10.3 states that "when the data colour space in the +profile header is RGB or XYZ, MatrixCoefficients shall be 0 (zero)", so the caller's value — +routinely 1, 5, 6 or 9 in an AVIF/HEIC `nclx` box — is **not** written into the `cicpType` tag; +writing it would make the profile non-conforming for the most common input there is. Nothing is +lost: the coefficients describe a luma–chroma encoding the caller de-matrixes *before* this profile +applies, and they remain in the container signalling a decoder reads them from. + +`VideoFullRangeFlag` is **not** rewritten. A triple carrying anything but full range (`1`) is +**declined**. §10.3's own RGB examples put the flag at zero (`1-1-0-0`, `9-16-0-0`), and read +`1-1-0-0` closely: with `MatrixCoefficients` already zero it is a narrow range on the *RGB samples +themselves*, which de-matrixing does not remove. This profile's colorants, `chad` and tone curves +are all defined over full-scale RGB, so normalising the flag to `1` would return a profile that +renders the caller's colour **wrongly**, not one that merely dropped metadata. Declining is what the +crate already does for primaries it has no chromaticities for and for a transfer with no ICC tone +curve. Callers holding narrow-range samples scale them to full range and pass `1`. **Grey gamma domain.** `gray_with_gamma` takes open `f64` input and declines anything a `kTRC` cannot carry: non-finite, non-positive, or ≥ 32 768, the first magnitude `s15Fixed16` (§4.6) cannot diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index e10925e0..50b3deee 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -26,27 +26,32 @@ //! //! # What a CICP triple contributes, and what it does not //! -//! [`IccProfile::from_cicp`] takes all four H.273 fields but builds from two of them, and -//! rewrites the other two. The two rewrites do **not** rest on the same authority, and are -//! documented apart on purpose. +//! [`IccProfile::from_cicp`] takes all four H.273 fields but builds from two of them. Of the other +//! two, one is rewritten and one is a precondition. They do **not** rest on the same authority, and +//! are documented apart on purpose. //! -//! `MatrixCoefficients` is **conformance**. ICC.1:2022 §10.3 states that *"when the data colour -//! space in the profile header is RGB or XYZ, MatrixCoefficients shall be 0 (zero)"* — a `shall` -//! — so the caller's value cannot be carried: an AVIF or HEIC `nclx` box routinely signals 1, 5, -//! 6 or 9, and writing any of those into an RGB profile would make it non-conforming. Nothing is -//! lost by that. The coefficients describe a luma–chroma *encoding* the caller de-matrixes before -//! the profile applies, and they remain in the container's own signalling where a decoder reads -//! them. +//! `MatrixCoefficients` is **conformance**, and is rewritten. ICC.1:2022 §10.3 states that *"when +//! the data colour space in the profile header is RGB or XYZ, MatrixCoefficients shall be 0 +//! (zero)"* — a `shall` — so the caller's value cannot be carried: an AVIF or HEIC `nclx` box +//! routinely signals 1, 5, 6 or 9, and writing any of those into an RGB profile would make it +//! non-conforming. Nothing is lost by that. The coefficients describe a luma–chroma *encoding* the +//! caller de-matrixes before the profile applies, and they remain in the container's own +//! signalling where a decoder reads them. The sample *range* that accompanies such an encoding is +//! not theirs to describe — that is the next field, and it is guarded, not rewritten. //! -//! `VideoFullRangeFlag` is **not** conformance. §10.3 says only that the flag "is often 1" for an -//! RGB profile, and its own RGB examples include `1-1-0-0` and `9-16-0-0` with the flag at zero, -//! so a narrow-range RGB `cicpType` is a legal tag this module chooses not to write. Fixing it at -//! `1` is a deliberate normalisation: everything the profile actually contains — the colorant -//! matrix, the `chad` and the tone curves — is defined over full-scale RGB, so a profile of this -//! shape signalling narrow range would describe a scaling it does not perform. The consequence is -//! that the caller's flag is **discarded, not preserved**: two triples differing only in it build -//! one profile, and a caller that needs the original value must keep it in the container -//! signalling it came from. +//! `VideoFullRangeFlag` is **not** conformance, and is **not** rewritten either: a triple carrying +//! anything but full range (`1`) is **declined**. §10.3 says only that the flag "is often 1" for an +//! RGB profile, and its own RGB examples include `1-1-0-0` and `9-16-0-0` with the flag at zero, so +//! a narrow-range RGB `cicpType` is a legal tag — it is simply not one this module can build. +//! Everything the profile contains — the colorant matrix, the `chad` and the tone curves — is +//! defined over full-scale RGB, and read §10.3's `1-1-0-0` example closely: with +//! `MatrixCoefficients` already zero it is a narrow range on the **RGB samples themselves**, so +//! de-matrixing does not remove it. Normalising the flag to `1` would hand back a profile that +//! renders the caller's colour *wrongly*, not one that merely dropped a piece of metadata. +//! Declining is what this module already does for primaries it has no chromaticities for and for a +//! transfer with no ICC tone curve, and narrow range is the same case: signalling this profile +//! shape cannot describe. A caller holding narrow-range samples scales them to full range first, +//! and then the triple it passes is one this module builds. //! //! # Which reading of transfer 1, 6, 14 and 15 //! @@ -213,30 +218,37 @@ impl BuiltinProfile { } } +/// The one `VideoFullRangeFlag` a profile built here can describe (ITU-T H.273 signals full range +/// as `1`). +/// +/// Stated once and read from both ends of the module: it is what [`cicp_of`] writes and what +/// [`IccProfile::from_cicp`] requires of the caller, so the tag and the precondition cannot drift. +/// Every colorant, `chad` and tone curve written here is defined over full-scale RGB; see the +/// module docs for why narrow range is declined rather than normalised. +const FULL_RANGE: u8 = 1; + /// The `cicpType` value for a pair of CICP axes. fn cicp_of(primaries: ColourPrimaries, transfer: TransferCharacteristics) -> Cicp { - normalized_cicp(Cicp { + rgb_conforming_cicp(Cicp { colour_primaries: cicp_byte(primaries.code_point()), transfer_characteristics: cicp_byte(transfer.code_point()), - // Both set by `normalized_cicp`, which owns the §10.3 rule for every constructor. + // Set by `rgb_conforming_cicp`, which owns the §10.3 rule for every constructor. matrix_coefficients: 0, - video_full_range_flag: 0, + video_full_range_flag: FULL_RANGE, }) } -/// `cicp` with the two fields this module does not take from the caller replaced. -/// -/// `MatrixCoefficients` is zero because ICC.1:2022 §10.3 **requires** it of an RGB or XYZ profile. -/// `VideoFullRangeFlag` is `1` by this module's own choice, not by that clause: §10.3 only remarks -/// that the flag "is often 1" for RGB, and lists RGB examples with it at zero. Full range is what -/// the colorants and tone curves written alongside it are defined over, so the caller's flag is -/// normalised away rather than carried — see the module docs. +/// `cicp` with `MatrixCoefficients` replaced by the zero ICC.1:2022 §10.3 **requires** of an RGB or +/// XYZ profile. /// -/// The two axes the profile is actually built from pass through untouched. -fn normalized_cicp(cicp: Cicp) -> Cicp { +/// That is the only field this module rewrites. `VideoFullRangeFlag` is not normalised here: a +/// triple that does not already carry [`FULL_RANGE`] is declined by +/// [`IccProfile::from_cicp`] instead, so nothing reaching this function can disagree with the +/// colorants and curves written alongside it. The two axes the profile is built from pass through +/// untouched. +fn rgb_conforming_cicp(cicp: Cicp) -> Cicp { Cicp { matrix_coefficients: 0, - video_full_range_flag: 1, ..cicp } } @@ -545,23 +557,27 @@ impl IccProfile { /// HEIC and JXL usually carry (a `colr`/`nclx` code-point trio) to an embeddable profile. /// /// Only the primaries and transfer code points shape the profile. The `cicpType` tag records - /// those two verbatim, and replaces the other two with `0` and `1`. + /// those two verbatim, replaces `MatrixCoefficients` with `0`, and passes + /// `VideoFullRangeFlag` through — because the only value it accepts is `1`. /// /// `MatrixCoefficients` is zero because ICC.1:2022 §10.3 states that when the data colour /// space is RGB or XYZ it *shall* be. No information is lost: the coefficients describe a /// luma–chroma encoding the caller de-matrixes before this profile applies, and they stay in /// the container signalling (`nclx`, AV1 sequence header) a decoder actually reads them from. /// - /// `VideoFullRangeFlag` is `1` by this crate's choice, **not** by §10.3, which only remarks - /// that the flag "is often 1" for RGB and gives RGB examples with it at zero. The profile's - /// colorants and tone curves are defined over full-scale RGB, so that is what it signals. The - /// caller's flag is therefore **not carried**: triples differing only in it build one profile, - /// and a caller that needs the original value must read it from the container signalling. + /// `VideoFullRangeFlag` is **not** rewritten. §10.3 does not require `1` — it only remarks + /// that the flag "is often 1" for RGB, and gives RGB examples with it at zero — but this + /// profile's colorants, `chad` and tone curves are all defined over full-scale RGB, and §10.3's + /// own `1-1-0-0` example is a narrow range on the **RGB samples themselves** (its + /// `MatrixCoefficients` is already zero), which no de-matrixing removes. Rewriting the flag + /// would therefore return a profile that renders the caller's colour wrongly, so a triple that + /// does not signal full range is declined instead. Scale narrow-range samples to full range and + /// pass `1`. /// /// Returns `None` when the profile cannot describe the signalling: a primaries code point /// with no chromaticities, whether unmodelled or - /// [`Unspecified`](ColourPrimaries::Unspecified); or a transfer code point with no ICC tone - /// curve, such as HLG (18) or Unspecified (2). + /// [`Unspecified`](ColourPrimaries::Unspecified); a transfer code point with no ICC tone + /// curve, such as HLG (18) or Unspecified (2); or any `VideoFullRangeFlag` other than `1`. /// /// # Examples /// @@ -575,14 +591,21 @@ impl IccProfile { /// /// // The BT.601 matrix an AVIF `nclx` box may carry does not change the profile, and is not /// // written into it. - /// let matrixed = Cicp { matrix_coefficients: 6, video_full_range_flag: 0, ..signalled }; + /// let matrixed = Cicp { matrix_coefficients: 6, ..signalled }; /// assert_eq!(IccProfile::from_cicp(matrixed), IccProfile::from_cicp(signalled)); /// + /// // Narrow range is a scaling of the RGB samples this profile shape does not perform. + /// let narrow = Cicp { video_full_range_flag: 0, ..signalled }; + /// assert!(IccProfile::from_cicp(narrow).is_none()); + /// /// // "Unspecified" primaries name no chromaticities, so no profile can be built. /// assert!(IccProfile::from_cicp(Cicp { colour_primaries: 2, ..signalled }).is_none()); /// ``` #[must_use] pub fn from_cicp(cicp: Cicp) -> Option { + if cicp.video_full_range_flag != FULL_RANGE { + return None; + } let primaries = ColourPrimaries::from_code_point(u16::from(cicp.colour_primaries))?; let trc = Trc::for_code_point(cicp.transfer_characteristics)?; let named = BuiltinProfile::for_axes(primaries, cicp.transfer_characteristics); @@ -593,7 +616,7 @@ impl IccProfile { cicp.colour_primaries, cicp.transfer_characteristics ), }; - rgb_matrix_trc(primaries, trc, &description, normalized_cicp(cicp)) + rgb_matrix_trc(primaries, trc, &description, rgb_conforming_cicp(cicp)) } /// A v4 matrix/TRC display profile for a [`SourceProfile`] — `gamut-color`'s @@ -884,14 +907,12 @@ mod tests { } } - /// Neither of the two fields `from_cicp` does not build from reaches the `cicpType` tag, so - /// two triples differing only in them are one profile. The coefficients an AVIF or HEIC `nclx` - /// box carries (1, 5, 6, 9 …) are dropped because ICC.1:2022 §10.3 says they *shall* be zero - /// in an RGB profile; the range flag is dropped by this crate's own normalisation to the - /// full-scale RGB its matrix and curves are defined over. The assertion is the same either - /// way — that the caller's value is not carried — which is the part a caller can observe. + /// The one field `from_cicp` rewrites does not reach the `cicpType` tag, so two triples + /// differing only in it are one profile. The coefficients an AVIF or HEIC `nclx` box carries + /// (1, 5, 6, 9 …) are dropped because ICC.1:2022 §10.3 says they *shall* be zero in an RGB + /// profile, and what a caller can observe is exactly that: the value it passed is not carried. #[test] - fn from_cicp_normalizes_the_matrix_coefficients_and_range_flag() { + fn from_cicp_zeroes_the_matrix_coefficients() { let conforming = Cicp { colour_primaries: 9, transfer_characteristics: 16, @@ -900,21 +921,51 @@ mod tests { }; let reference = IccProfile::from_cicp(conforming).expect("the control builds"); for matrix in [1_u8, 5, 6, 9] { - for range in [0_u8, 1] { - let signalled = Cicp { - matrix_coefficients: matrix, - video_full_range_flag: range, - ..conforming - }; - let profile = - IccProfile::from_cicp(signalled).expect("signalling with a matrix builds"); - assert_eq!(profile, reference, "matrix {matrix}, range {range}"); - assert_eq!( - profile.get(KnownTag::Cicp), - Some(&TagData::Cicp(conforming)), - "matrix {matrix}, range {range}: neither field is taken from the caller" - ); + let signalled = Cicp { + matrix_coefficients: matrix, + ..conforming + }; + let profile = + IccProfile::from_cicp(signalled).expect("signalling with a matrix builds"); + assert_eq!(profile, reference, "matrix {matrix}"); + assert_eq!( + profile.get(KnownTag::Cicp), + Some(&TagData::Cicp(conforming)), + "matrix {matrix}: the coefficients are not taken from the caller" + ); + } + } + + /// Full range is a precondition of `from_cicp`, not a field it normalises: every other + /// `VideoFullRangeFlag` byte is declined. + /// + /// The complement is swept rather than sampled at `0`, because the guard is a comparison and + /// an ordering mutation of it (`<`, `>`) leaves one side of `1` still admitted. The control at + /// `1` builds from the same axes, so the axes cannot be what any rejection is about. + #[test] + fn a_cicp_triple_that_is_not_full_range_is_declined() { + let full = Cicp { + colour_primaries: 1, + transfer_characteristics: 13, + matrix_coefficients: 0, + video_full_range_flag: FULL_RANGE, + }; + assert!( + IccProfile::from_cicp(full).is_some(), + "the full-range control builds" + ); + for flag in 0..=u8::MAX { + if flag == FULL_RANGE { + continue; } + assert_eq!( + IccProfile::from_cicp(Cicp { + video_full_range_flag: flag, + ..full + }), + None, + "video_full_range_flag {flag}" + ); } } From c4020c74f59bfc8e6e6ee6ddddcceabcc2ccd37d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 06:19:43 -0400 Subject: [PATCH 15/31] fix(icc): describe a grey profile by the gamma its kTRC holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gray_with_gamma` wrote the `profileDescriptionTag` from the value the caller passed and the `kTRC` from that value encoded as `s15Fixed16`, so the two disagreed by up to half a quantum — and at the smallest accepted gamma, `0.5 / 65 536`, the description read one number while the tag carried exactly twice it. A profile contradicting its own tag is the same class of defect this branch has been closing: one fact written down in two places. The guard now returns the encoded parameter instead of a bool, and `gray_with_gamma` shadows its argument with it. The requested value is out of scope from that line on, so nothing below can be written from it — the description names what the tag holds because there is nothing else left to name. `gray_with_gamma(2.2)` is therefore described as `Grey gamma 2.1999969482421875`, which is the parameter a reader inspecting the tag will find. Two statements of the encoding bound are corrected while they are in hand. The saturating parameter's value was written truncated (`32 767.999 984 741 21`) beside a neighbour written exact, and is now exact: `32 767.999 984 741 210 937 5`. And the two ends of the domain are named for what they are — the bottom refuses a degenerate all-white curve, while the top is fidelity only, since the first rejected gamma and the last accepted one are one f64 ulp apart (2^-38) and evaluate identically. It is kept for the symmetry of a closed domain, and now says so instead of implying both ends refuse harm. STATUS.md carried the superseded bound — "non-finite, non-positive, or ≥ 32 768" — which was wrong in both directions once the guard moved onto the encoding: it refused values the constructor accepts and admitted the whole low interval the constructor refuses. --- crates/gamut-icc/STATUS.md | 22 ++++++++++--- crates/gamut-icc/src/builtin.rs | 55 +++++++++++++++++++++++---------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index e57b09a7..df98e9f1 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -107,10 +107,24 @@ renders the caller's colour **wrongly**, not one that merely dropped metadata. D crate already does for primaries it has no chromaticities for and for a transfer with no ICC tone curve. Callers holding narrow-range samples scale them to full range and pass `1`. -**Grey gamma domain.** `gray_with_gamma` takes open `f64` input and declines anything a `kTRC` -cannot carry: non-finite, non-positive, or ≥ 32 768, the first magnitude `s15Fixed16` (§4.6) cannot -hold. `S15Fixed16::from_f64` saturates rather than failing, so accepting those would silently write -a gamma nobody asked for. +**Grey gamma domain.** `gray_with_gamma` takes open `f64` input and declines anything the `kTRC` +cannot carry *non-degenerately*. The bound is on the encoding of the §10.18 type 0 parameter, not +on the number: `S15Fixed16::from_f64` (§4.6) rounds `gamma × 65 536` and then clamps, so it never +fails and it degenerates at both ends. Accepted is `0.5 / 65 536 = 7.629 394 531 25e-6` up to but +not including `(2^31 − 0.5) / 65 536 = 32 767.999 992 370 605 468 75`; below that the parameter +rounds to raw zero, which is a `Y = X^0` curve mapping every input to white and a tag `validate` +reports clean, and at or above it the parameter clamps to a gamma the encoder chose rather than the +caller. Non-finite input is declined with the rest. The two bounds are not the same kind of thing: +the bottom one refuses a **degenerate** profile, while the top one is **fidelity only** — the first +rejected gamma and the last accepted one are one `f64` ulp apart (`2^-38`, under four parts in a +trillion) and evaluate identically, so nothing is saved from misrendering there. It is kept because +a domain stated at both ends is easier to reason about than one open at the top, and the crate says +which kind each end is rather than implying both refuse harm. + +The `profileDescriptionTag` names the gamma the tag **holds**, not the one requested: +`gray_with_gamma(2.2)` is described as `Grey gamma 2.1999969482421875`. At the smallest accepted +gamma the two differ by a factor of two, and a profile whose description contradicts its own tag is +worse than a long number. **PCS white.** Colorants are Bradford-adapted to the D50 that `XYZNumber::D50` encodes (§7.2.16), not to `gamut_color::matrix::D50`. The two differ by 2e-4 in Z — the CIE chromaticity against ICC's diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 50b3deee..ae523ac6 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -261,28 +261,40 @@ fn cicp_byte(code_point: u16) -> u8 { u8::try_from(code_point).unwrap_or(2) } -/// Whether `gamma` survives the `s15Fixed16` encoding of a `parametricCurveType` function type 0 -/// parameter (ICC.1:2022 §10.18) with the value the caller asked for. +/// The `s15Fixed16` a `parametricCurveType` function type 0 parameter (ICC.1:2022 §10.18) carries +/// for `gamma`, or `None` when the encoding cannot hold it non-degenerately. /// /// The guard is on the **encoding**, not on the number, because [`S15Fixed16::from_f64`] rounds -/// `gamma × 65 536` and then *clamps*: it never fails, and it degenerates at both ends. +/// `gamma × 65 536` and then *clamps*: it never fails, and it degenerates at both ends. The +/// comparison is against the product *before* the clamp — afterwards a saturated parameter is +/// indistinguishable from one the caller asked for. /// /// * Below `0.5 / 65 536 = 7.629 394 531 25e-6` the product rounds to raw `0`, and `Y = X^0` maps /// every input — black included — to white. That is the same profile a literal `gamma` of `0.0` /// would give, and [`IccProfile::validate`] cannot see it: a `kTRC` holding raw zero is a /// structurally well-formed tag. /// * From `(2^31 − 0.5) / 65 536 = 32 767.999 992 370 605 468 75` upward the product exceeds -/// `i32::MAX` and the clamp writes `32 767.999 984 741 21` instead — a gamma chosen by the -/// encoder rather than by the caller. (The clamp, not the fixed-point width, is where this -/// starts: the *largest representable* value, `32 767.999 984 741 21`, is half a quantum lower -/// still, and every gamma between the two merely rounds to it.) +/// `i32::MAX` and the clamp writes `32 767.999 984 741 210 937 5` instead — a gamma chosen by +/// the encoder rather than by the caller. (The clamp, not the fixed-point width, is where this +/// starts: the *largest representable* value, `32 767.999 984 741 210 937 5`, is half a quantum +/// lower still, and every gamma between the two merely rounds to it.) /// /// Between the two the encoding is a rounding of at most half a quantum, which is what a -/// fixed-point tag is for. Non-finite input satisfies neither comparison and is declined with the -/// rest. -fn gamma_is_encodable(gamma: f64) -> bool { +/// fixed-point tag is for; inside that range the clamp in [`S15Fixed16::from_f64`] is inert, so +/// the value returned is the same product this guard tested. Non-finite input satisfies neither +/// comparison and is declined with the rest. +/// +/// The two ends are not the same kind of refusal. The bottom one refuses a **degenerate** profile: +/// an all-white curve nothing downstream can detect. The top one is **fidelity only** — the first +/// rejected gamma and the last accepted one are one `f64` ulp apart (`2^-38`, under four parts in +/// a trillion) and evaluate identically, so no caller is saved from a misrendering by it. It is +/// kept for the symmetry of a domain closed at both ends, and named here so the symmetry is not +/// mistaken for a second correctness claim. +fn encodable_gamma(gamma: f64) -> Option { let raw = (gamma * 65_536.0).round(); - (1.0..=f64::from(i32::MAX)).contains(&raw) + (1.0..=f64::from(i32::MAX)) + .contains(&raw) + .then(|| S15Fixed16::from_f64(gamma)) } /// A tone-response curve this module can encode. @@ -510,7 +522,9 @@ impl IccProfile { /// `(2^31 − 0.5) / 65 536 = 32 767.999 992 370 605 468 75`. Below that the parameter rounds to /// zero and `Y = X^0` maps every input to white — the same degenerate profile a literal `0.0` /// would give, which [`validate`](IccProfile::validate) cannot see, since a `kTRC` holding raw - /// zero is a well-formed tag. At or above it the parameter saturates to a different gamma. + /// zero is a well-formed tag. At or above it the parameter saturates to a different gamma — + /// though only just: that bound is fidelity, not degeneracy, since the first rejected gamma + /// and the last accepted one are one `f64` ulp apart and evaluate identically. /// Non-finite input is declined with the rest: `NaN` would otherwise reach both the /// description string and the tag. /// @@ -519,6 +533,13 @@ impl IccProfile { /// meaningless gamma is deliberately left open; see /// . /// + /// The `profileDescriptionTag` names the gamma the `kTRC` **holds**, not the one that was + /// asked for: an accepted `gamma` is rounded to the tag's `s15Fixed16` parameter first and + /// every use below reads that, so `gray_with_gamma(2.2)` is described as + /// `Grey gamma 2.1999969482421875`. A profile whose own description contradicted its own tag + /// would be worse than a long number — at the smallest accepted gamma the two differ by a + /// factor of two. + /// /// # Examples /// /// ``` @@ -533,9 +554,11 @@ impl IccProfile { /// ``` #[must_use] pub fn gray_with_gamma(gamma: f64) -> Option { - if !gamma_is_encodable(gamma) { - return None; - } + // Shadowing, not a second binding: from here on the value the caller asked for is out of + // scope, so neither the tag nor the description below can be written from it. That is the + // structural form of "the description names what the tag holds" — there is nothing else + // left to name. + let gamma = encodable_gamma(gamma)?.to_f64(); Some(IccProfile { header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Gray), tags: vec![ @@ -980,7 +1003,7 @@ mod tests { /// which `validate` reports clean. /// /// The two bounds are restated as literal decimals instead of being reused from - /// [`gamma_is_encodable`], so a wrong scale factor there cannot agree with the test. + /// [`encodable_gamma`], so a wrong scale factor there cannot agree with the test. #[test] fn a_grey_gamma_the_ktrc_cannot_carry_is_declined() { /// `0.5 / 65 536` — one half of a `s15Fixed16` quantum, which is both the smallest gamma From 1b45aa4601ecda44cd8b47618f6ee4953d1f682c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 06:20:02 -0400 Subject: [PATCH 16/31] docs(icc): cite cicpType's own clause and quote the tag's mid-grey values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two documentation statements in this module were about something other than what they named. `cicp_byte` cited ICC.1:2022 §10.7 for the four-byte `cicpType` layout. §10.7 is `dataType`; `cicpType` is §10.3 — the clause this branch spent a round reading correctly for `MatrixCoefficients` and `VideoFullRangeFlag`. Every other citation in the module checks out. The mid-grey comparison table labelled a row "what this module writes" but carried the value of the exact closed form, which is not what the tag holds: the `parametricCurveType` parameters are rounded to `s15Fixed16` first. Both rows this module actually writes are now quoted as the tag evaluates — 0.259721 for the BT.709-family inverse OETF and 0.214045 for sRGB, against 0.259719 and 0.214041 before rounding — and the BT.1886 row is labelled as the closed form, because no tag here holds it. The divergence figures the paragraph draws from the table (1.371x, 0.0703, 0.0457, 21 %) are unchanged at their stated precision. --- crates/gamut-icc/src/builtin.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index ae523ac6..bd0cd553 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -64,14 +64,18 @@ //! "a suggested corresponding reference electro-optical transfer characteristic function for flat //! panel displays used in HDTV studio production has been specified in Rec. ITU-R BT.1886-0" — //! which, at reference black zero, is a pure gamma of 2.4. Since every profile built here is a -//! *display*-class profile, that reading has a real claim, and the two are far apart. At mid-grey -//! `V = 0.5` the PCS `Y` is: +//! *display*-class profile, that reading has a real claim, and the two are far apart. +//! +//! At mid-grey `V = 0.5` the PCS `Y` is as follows. The two rows this module writes are quoted as +//! the **tag evaluates**, with `(g, a, b, c, d)` already rounded to `s15Fixed16` — not as the +//! closed form before rounding, which is a different number in the sixth decimal. The BT.1886 row +//! is the closed form, because no tag here holds it. //! //! | reading | `Y` at `V = 0.5` | //! | --- | --- | -//! | inverse OETF — what this module writes | 0.259719 | -//! | BT.1886 EOTF, `V^2.4` | 0.189465 | -//! | the sRGB code point (13), for scale | 0.214041 | +//! | inverse OETF — the `parametricCurveType` this module writes | 0.259721 | +//! | BT.1886 EOTF, `V^2.4` — closed form; not written here | 0.189465 | +//! | the sRGB code point (13) as this module writes it, for scale | 0.214045 | //! //! The literal reading is 1.371× the BT.1886 one — 0.0703 in absolute `Y`. Note the third row: //! two code points this module *does* encode, 1 and 13, already differ by 0.0457 at mid-grey @@ -253,7 +257,7 @@ fn rgb_conforming_cicp(cicp: Cicp) -> Cicp { } } -/// A CICP code point as the byte `cicpType` stores it (ICC.1:2022 §10.7 — four `uInt8`s). +/// A CICP code point as the byte `cicpType` stores it (ICC.1:2022 §10.3 — four `uInt8`s). /// /// ITU-T H.273 defines every code point in `0..=255`, so this is total for any modelled value; a /// value that does not fit a byte cannot be signalled at all and becomes `2` (Unspecified). From 5a9cc0c5c1df649043100c9dc27ab14b7720c361 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:18:41 -0400 Subject: [PATCH 17/31] docs(icc): cite the clauses ICC.1:2022 gives the colorant and chad tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `§9.2.10` is BToD1Tag and `§9.2.35` is metadataTag in the vendored ICC.1:2022; the MatrixColumn tags are `§9.2.46`/`§9.2.31`/`§9.2.4` and chromaticAdaptationTag is `§9.2.15`. Every `§`-citation in the crate was resolved against a clause index extracted from the vendored PDF rather than read by hand, and these two were the only members that resolved false. Names the numbering used, since the standard's own §8.4.3 cross-references disagree with its headings for two of the four. --- crates/gamut-icc/src/builtin.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index bd0cd553..c157c2f2 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -415,7 +415,14 @@ fn pcs_d50_chromaticity() -> [f64; 2] { } /// The D50-adapted colorant columns for `primaries`, and the chromatic-adaptation matrix that took -/// them there — the `rXYZ`/`gXYZ`/`bXYZ` (§9.2.10) and `chad` (§9.2.35) tag contents. +/// them there — the `rXYZ`/`gXYZ`/`bXYZ` (ICC.1:2022 §9.2.46, §9.2.31, §9.2.4) and `chad` +/// (§9.2.15) tag contents. +/// +/// Those are the numbers the standard's clause **headings** carry. ICC.1:2022 numbers two of the +/// same clauses differently where §8.4.3 cross-references them (9.2.44 for `redMatrixColumnTag`, +/// 9.2.30 for `greenMatrixColumnTag`) — an erratum in the published document, not two editions. +/// Every §9.2.x citation in this crate uses the heading numbering. No gate checks that yet; +/// is the one that would. /// /// `None` when the primaries cannot be turned into colorants at all: a code point that names no /// chromaticities ([`ColourPrimaries::Unspecified`], and any later variant `gamut-color` adds From a5c88739d13aca9de22aab78d3aa0864cfa2b282 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:19:35 -0400 Subject: [PATCH 18/31] docs(icc): check the transfer-axis claim about gamut-color in a doctest The module docs and STATUS.md both said `gamut_color::transfer::eotf_for` supplies no EOTF for any of H.273's four BT.709-family code points. It supplies one for code point 14: `bt2020_pq_to_sdr`, a PQ EOTF plus a tone map, which at `V = 0.5` evaluates 20.3 % above the curve Table 3 gives that code point. The sentence was the stated justification for keying transfers on the raw code point, so a reader who believed it would key on `TransferCharacteristics` and write a tone map into an ICC tag. Both statements now say what the crate returns, and the claim itself is a doctest beside them, so it cannot drift from the crate it describes. That the two crates read code point 14 differently is gamut-color's question and is filed as #605, not fixed here. Refs #605 --- crates/gamut-icc/STATUS.md | 10 ++++++++-- crates/gamut-icc/src/builtin.rs | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index df98e9f1..58fe46ca 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -84,8 +84,14 @@ Declined: HLG (code 18) and Unspecified (code 2) have neither a §10.18 closed f gamut-color EOTF to sample from, and every other H.273 code point is unmodelled here. The transfer axis is keyed on the **raw code point**, not on `gamut_color::cicp::TransferCharacteristics`, because the set of curves an ICC tag can encode is not the set gamut-color can evaluate: codes 6 -and 15 have no `TransferCharacteristics` variant, and none of the four BT.709-family codes has a -gamut-color EOTF, yet all four are exactly encodable. +and 15 have no `TransferCharacteristics` variant at all; code 1 has one but no `eotf_for` curve; +and code 14 has an `eotf_for` curve that is **not this one** — `bt2020_pq_to_sdr`, a PQ EOTF plus +a tone map to SDR, which at `V = 0.5` is 20.3 % above what Table 3 gives code 14. All four are +exactly encodable here as the one curve Table 3 defines. That the two crates read code point 14 +differently is a `gamut-color` question, filed as +[#605](https://github.com/visualcommons/gamut/issues/605) rather than resolved here; the doctest +on `src/builtin.rs`'s module documentation pins every clause of this paragraph that is a claim +about gamut-color, so it cannot drift from the crate it describes. **CICP fields the profile does not build from.** `from_cicp` builds from the primaries and transfer code points only, and treats the other two fields differently on purpose — one is rewritten, one is diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index c157c2f2..f06b199d 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -21,8 +21,36 @@ //! **Transfers** are keyed on the raw ITU-T H.273 code point, because the set of curves an ICC //! tag can *encode* is not the set `gamut-color` can *evaluate*: H.273 Table 3's code points 1, //! 6, 14 and 15 are one curve with a closed form ICC.1:2022 §10.18 defines exactly, so this -//! module encodes all four even though `gamut_color::transfer::eotf_for` supplies no EOTF for -//! them and its `TransferCharacteristics` models only two of the four. +//! module encodes all four, while `gamut_color::cicp::TransferCharacteristics` models only two of +//! them (1 and 14) and `gamut_color::transfer::eotf_for` returns a curve for only one — code +//! point 14, which it maps to `bt2020_pq_to_sdr`, a PQ EOTF followed by a tone map to SDR rather +//! than Table 3's curve. Keying on the code point is what keeps that tone map out of an ICC tag: +//! at `V = 0.5` it evaluates 20.3 % above the curve this module writes for the same code point. +//! Which of the two readings of code point 14 is right is `gamut-color`'s question, not this +//! crate's, and is tracked at . +//! +//! ``` +//! use gamut_color::cicp::TransferCharacteristics as Tc; +//! use gamut_color::transfer::eotf_for; +//! use gamut_icc::{Cicp, IccProfile}; +//! +//! // Two of the four BT.709-family code points have a `TransferCharacteristics` variant … +//! assert_eq!(Tc::from_code_point(1), Some(Tc::Bt709)); +//! assert_eq!(Tc::from_code_point(14), Some(Tc::Bt2020_10)); +//! assert_eq!(Tc::from_code_point(6), None); +//! assert_eq!(Tc::from_code_point(15), None); +//! // … and exactly one of those two has an EOTF, which is not this curve. +//! assert!(eotf_for(Tc::Bt709).is_none()); +//! assert!(eotf_for(Tc::Bt2020_10).is_some()); +//! +//! // All four are encodable here regardless; that they encode one curve is pinned by +//! // `every_bt709_family_code_point_builds_the_same_curve`. +//! let axes = Cicp { colour_primaries: 1, transfer_characteristics: 1, +//! matrix_coefficients: 0, video_full_range_flag: 1 }; +//! for code in [1, 6, 14, 15] { +//! assert!(IccProfile::from_cicp(Cicp { transfer_characteristics: code, ..axes }).is_some()); +//! } +//! ``` //! //! # What a CICP triple contributes, and what it does not //! From d0e32113c4a9c0dce720d41d4b1dc2e033c12c33 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:19:58 -0400 Subject: [PATCH 19/31] docs(icc): correct the CIE D50 tristimulus this crate deliberately avoids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pcs_d50_chromaticity` gave the CIE-published D50's Z as 0.82521. Derived from `gamut_color::matrix::D50` — the chromaticity the sentence names — it is 0.825105, against ICC's 0.824905. The gap the paragraph is about is unchanged at 2.0e-4; only the number naming the other crate's constant was wrong, and it is a third statement of an external value this crate restated without checking. --- crates/gamut-icc/src/builtin.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index f06b199d..5d43865c 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -431,11 +431,11 @@ fn pq_samples() -> Vec { /// The PCS D50 white as a CIE 1931 chromaticity, derived from the exact `XYZNumber` ICC.1:2022 /// §7.2.16 mandates for the PCS illuminant. /// -/// Deliberately *not* [`gamut_color::matrix::D50`]: that is the CIE-published chromaticity, whose -/// tristimulus Z is 0.82521, while ICC's rounded encoding is 0.8249. Adapting to the CIE one while -/// writing the ICC one as the `mediaWhitePointTag` would leave the colorants disagreeing with the -/// white point they are supposed to sum to, by 2e-4 in Z. The PCS illuminant is an ICC fact, so -/// this crate owns it. +/// Deliberately *not* [`gamut_color::matrix::D50`]: that is the CIE-published chromaticity +/// `(0.3457, 0.3585)`, whose tristimulus `Z` at `Y = 1` is 0.825105, while ICC's rounded +/// `XYZNumber` encoding carries 0.824905. Adapting to the CIE one while writing the ICC one as +/// the `mediaWhitePointTag` would leave the colorants disagreeing with the white point they are +/// supposed to sum to, by 2.0e-4 in Z. The PCS illuminant is an ICC fact, so this crate owns it. fn pcs_d50_chromaticity() -> [f64; 2] { let [x, y, z] = XyzNumber::D50.to_f64(); let sum = x + y + z; From ce65dd7ce217769852ce56e1b9004f05ff233584 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:20:46 -0400 Subject: [PATCH 20/31] test(icc): guard the sampled PQ curve at the quantum it claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SAMPLED_TRC_POINTS` claims 1024 points hold the linear-interpolation error below one `uInt16` quantum, and the guarding test admitted two — so the claim was stated twice and gated nowhere. Its 101-point sweep also missed the peak, which sits mid-interval at `V` about 0.9956: it measured 0.962 quanta where the true worst case is 0.986. The sweep now visits every interval at both ends and at its midpoint, and the tolerance is one quantum. Measured 0.9854 on that grid, 0.9861 over a 200 001-point sweep; both figures are published on the constant. --- crates/gamut-icc/STATUS.md | 2 +- crates/gamut-icc/src/builtin.rs | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index 58fe46ca..05ffffc6 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -78,7 +78,7 @@ gives the transfer a closed form ICC also defines, and a sampled `curveType` (§ | BT.709 family (codes 1, 6, 14, 15) | `parametricCurveType` type 3, `(g, a, b, c, d)` | Table 3 gives all four one curve — "functionally the same as the values 1, 6 and 15" — with α = 1 + 5.5β and β = 0.018053968510807…; its inverse is §10.18 type 3 exactly | | sRGB / IEC 61966-2-1 (code 13) | `parametricCurveType` type 3, `(g, a, b, c, d)` | §10.18 type 3 is the spec's own piecewise form | | Grey gamma (`gray_with_gamma`) | `parametricCurveType` type 0 | §10.18 type 0; `s15Fixed16` beats `curveType`'s single `u8Fixed8` entry | -| PQ / ST 2084 (code 16) | `curveType`, 1024 `uInt16` samples | §10.18 defines no closed form for PQ; 1024 points keep interpolation error under one `uInt16` quantum | +| PQ / ST 2084 (code 16) | `curveType`, 1024 `uInt16` samples | §10.18 defines no closed form for PQ; 1024 points keep interpolation error under one `uInt16` quantum — measured 0.986, guarded at 1 | Declined: HLG (code 18) and Unspecified (code 2) have neither a §10.18 closed form nor a gamut-color EOTF to sample from, and every other H.273 code point is unmodelled here. The transfer diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 5d43865c..cbdf3d71 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -143,6 +143,9 @@ const COPYRIGHT: &str = "Public domain — no rights reserved."; /// /// 1024 points keep the linear-interpolation error of the steepest such curve (ST 2084 near its /// peak) below one `uInt16` quantum, so the table is as exact as the encoding it is written in. +/// Measured **0.986** quanta at `V ≈ 0.9956` over a 200 001-point sweep, and 0.9854 over the +/// midpoint sweep `sampled_pq_curve_matches_gamut_color` runs — which is why that test's +/// tolerance is one quantum: a guard at twice the claimed bound is not a guard on the claim. const SAMPLED_TRC_POINTS: usize = 1024; /// A 3×3 matrix in row-major order — the shape `gamut_color::matrix` produces and consumes. @@ -838,17 +841,26 @@ mod tests { /// the `uInt16` quantum the table is written in. This is what justifies /// [`SAMPLED_TRC_POINTS`]: it pins that the sampling density is fine enough to make the /// encoding, not the table, the limit on accuracy. + /// + /// The sweep visits every one of the table's intervals at both ends *and at its midpoint*, + /// which is where linear interpolation is furthest from the curve it interpolates. A uniform + /// 101-point sweep misses the peak: it measures 0.962 quanta where the true worst case is + /// 0.986, at `V ≈ 0.9956`. The tolerance is therefore **one** quantum — the bound + /// [`SAMPLED_TRC_POINTS`] claims — not two; the arithmetic is deterministic, so a guard that + /// tight cannot flake. #[test] fn sampled_pq_curve_matches_gamut_color() { let TagData::Curve(curve) = Trc::Pq.tag() else { panic!("PQ is encoded as a sampled curveType"); }; let peak = pq_eotf(1.0); - for step in 0..=100 { - let x = f64::from(step) / 100.0; + // Twice the interval count: an even `step` lands on a sample, an odd one on a midpoint. + let steps = 2 * (SAMPLED_TRC_POINTS - 1); + for step in 0..=steps { + let x = step as f64 / steps as f64; let (got, want) = (curve.eval(x), pq_eotf(x) / peak); assert!( - (got - want).abs() < 2.0 / 65535.0, + (got - want).abs() < 1.0 / 65535.0, "PQ TRC at {x}: {got} vs {want}" ); } From 96bddf08e2c4cd0276a9e468822656fd5fec1dc7 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:21:21 -0400 Subject: [PATCH 21/31] docs(icc): say where a SourceProfile bundle and its profile diverge `from_source_profile(SourceProfile::BT2020)` builds a peak-referred ST 2084 profile, while the bundle's own `eotf` is that curve plus a Reinhard tone map to SDR: measured up to 0.735 absolute apart over the signal domain, 52x at V = 0.1, against 4.2e-6 for the sRGB bundle. The reasoning for building it anyway was on the pull request and nowhere a consumer reads, so it is now on the constructor, in STATUS.md and beside the test that asserts the mapping, with the measured divergence and the distinction that carries it: a sample range is a property of the samples, a tone map is a rendering choice. --- crates/gamut-icc/STATUS.md | 13 +++++++++++++ crates/gamut-icc/src/builtin.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index 05ffffc6..d257af32 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -93,6 +93,19 @@ differently is a `gamut-color` question, filed as on `src/builtin.rs`'s module documentation pins every clause of this paragraph that is a claim about gamut-color, so it cannot drift from the crate it describes. +**A `SourceProfile` bundle's own curve is not always the profile's curve.** `from_source_profile` +projects the bundle onto its two CICP code points and builds from those. For `SourceProfile::SRGB` +that is a distinction without a difference — the bundle's transfer *is* code point 13, and profile +and bundle agree to 4.2e-6, the `s15Fixed16` rounding of the tag. For `SourceProfile::BT2020` it is +not: that bundle's transfer is ST 2084 **plus a Reinhard tone map to SDR**, while its code point is +16, which is ST 2084 alone. The profile encodes 16, peak-referred, and over a 100 001-point sweep +of the signal domain the two curves diverge by up to **0.735** absolute — 52× at `V = 0.1`. This is +the opposite call from narrow range above, and deliberately so: a sample range is a property of the +*samples*, which a full-scale profile genuinely cannot describe, whereas a tone map is gamut-color's +choice about how to *render* an HDR transfer, and §9.2.17 requires the `cicpType` tag to be +equivalent to the encoding the profile represents. A caller wanting the tone-mapped rendering +applies it to its samples and embeds an SDR profile. + **CICP fields the profile does not build from.** `from_cicp` builds from the primaries and transfer code points only, and treats the other two fields differently on purpose — one is rewritten, one is a precondition. diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index cbdf3d71..d0f0b5ed 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -691,6 +691,27 @@ impl IccProfile { /// a primaries nor a transfer code point, and their chromaticities are private to /// `gamut-color`, so this crate cannot describe them without restating tables it does not own. /// + /// # The profile is the code point, not the bundle's own curve + /// + /// A [`SourceProfile`] is a *rendering* bundle, and for one of them the profile built here and + /// the bundle's own [`eotf`](SourceProfile::eotf) are not the same curve. + /// `SourceProfile::BT2020` pairs BT.2020 primaries with `SourceTransfer::Bt2020Pq`, which is + /// the ST 2084 EOTF **followed by a Reinhard tone map to SDR**. Its CICP transfer code point + /// is 16, and 16 is ST 2084 — so this profile encodes ST 2084, peak-referred, without the tone + /// map. Swept over the signal domain at 100 001 points the two diverge by up to **0.735** + /// absolute, 52× at `V = 0.1`; `SourceProfile::SRGB`, whose transfer *is* its code point, + /// agrees with its profile to 4.2e-6 — the `s15Fixed16` rounding of the tag, and nothing else. + /// + /// That is deliberate, and it is the difference between the two axes. A narrow sample range is + /// a property of the samples, which is why [`from_cicp`](IccProfile::from_cicp) declines one it + /// cannot describe. A tone map is not: it is `gamut-color`'s choice about how to *render* an + /// HDR transfer into SDR, and an ICC transfer tag is the encoding the code point names. + /// Writing the tone map into the tag would hand a CMM a curve no other reader of the same CICP + /// triple would produce, and would contradict the `cicpType` tag written beside it (ICC.1:2022 + /// §9.2.17: the CICP tag content "shall be equivalent to the data colour space encoding + /// represented by this ICC profile"). A caller that wants the tone-mapped rendering applies it + /// to its samples and embeds an SDR profile. + /// /// # Examples /// /// ``` @@ -1096,6 +1117,11 @@ mod tests { /// `SourceProfile`'s bundles map onto the built-in spaces, and the two with no CICP axes are /// declined rather than approximated. + /// + /// `BT2020` maps onto [`BuiltinProfile::Bt2020Pq`] even though the bundle's own `eotf` is a + /// tone map and the profile's `rTRC` is not: the mapping is on the **code points** the bundle + /// projects onto, which is what `from_source_profile` is defined over. The divergence that + /// follows is documented on the constructor and measured there (up to 0.735 absolute). #[test] fn source_profiles_map_onto_the_builtin_spaces() { let cases = [ From bfc41a3648e92a40c30897a7906c5973e82a2482 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:22:14 -0400 Subject: [PATCH 22/31] docs(icc): name the colorimetry this crate does restate, and its gates Both the module docs and STATUS.md said the colorimetry is never written down twice. Three published constants are written here: H.273's beta, the IEC 61966-2-1 parametric set, and the PCS D50. All three are correctly gated, so the handling was right and only the blanket sentence was wrong - but a sentence that overstates is what four rounds of review have been about. Each restatement is now named with why it cannot be borrowed and which test pins it. --- crates/gamut-icc/STATUS.md | 21 ++++++++++++++++----- crates/gamut-icc/src/builtin.rs | 23 ++++++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index d257af32..74f5ee9c 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -46,11 +46,22 @@ so no profile is rejected for carrying an unmodelled tag. `IccProfile::builtin`, `gray_with_gamma`, `from_cicp` and `from_source_profile` construct spec-valid v4 three-component matrix/TRC display profiles (§8.4). All four return `Option`: the -colorimetry they resolve is **gamut-color's and is never restated here** — primaries and white -point come from `ColourPrimaries::chromaticities`, the RGB→XYZ construction and Bradford -adaptation from `gamut_color::matrix`, the ST 2084 curve from `gamut_color::transfer` — and those -constructors are themselves fallible, so an input whose colorimetry cannot be resolved is declined -rather than given a profile whose colorants are silently the PCS axes. Colorimetry is not the only +colorimetry they resolve is **gamut-color's, and whatever gamut-color can supply is never retyped +here** — primaries and white point come from `ColourPrimaries::chromaticities`, the RGB→XYZ +construction and Bradford adaptation from `gamut_color::matrix`, the ST 2084 curve from +`gamut_color::transfer` — and those constructors are themselves fallible, so an input whose +colorimetry cannot be resolved is declined rather than given a profile whose colorants are silently +the PCS axes. + +Three published constants gamut-color does *not* supply are written out here, because an ICC tag +needs them in a form no gamut-color function returns, and each is gated rather than trusted: +H.273 §8.2's β (`BT709_BETA`, from which α is derived rather than restated), pinned by +`bt709_curve_inverts_the_h273_transfer` against an independent forward transcription of Table 3 and +by the lcms2 oracle; the IEC 61966-2-1 `(g, a, b, c, d)` parameter set for §10.18 type 3, pinned by +`srgb_parametric_curve_matches_gamut_color` against `gamut_color::transfer::srgb_eotf` and by +`oracle_srgb_tone_curve_matches_lcms`; and the PCS D50 of §7.2.16, which is an ICC fact rather than +a CIE one (see "PCS white" below), pinned by `colorants_sum_to_the_declared_media_white_point` and +the lcms2 colorant oracle. `src/builtin.rs`'s module documentation tabulates the three. Colorimetry is not the only reason to decline: `from_cicp` also refuses signalling this profile *shape* cannot carry, which is where narrow range lands — see "CICP fields the profile does not build from" below. `builtin` yields `Some` for every `BuiltinProfile` in this release and a test pins that. diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index d0f0b5ed..e4840b67 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -2,11 +2,24 @@ //! [`SourceProfile`], or for a CICP signalling triple. //! //! Every profile built here is a **v4 three-component matrix/TRC display profile** (ICC.1:2022 -//! §8.4) — or, for [`IccProfile::gray_with_gamma`], the monochrome model of the same class. The -//! colorimetry is never written down twice: the primaries and white point come from -//! [`gamut_color::cicp::ColourPrimaries::chromaticities`], the RGB→XYZ construction and the -//! Bradford adaptation to the D50 PCS from [`gamut_color::matrix`], and the ST 2084 curve from -//! [`gamut_color::transfer`]. This crate contributes the ICC encoding, not the numbers. +//! §8.4) — or, for [`IccProfile::gray_with_gamma`], the monochrome model of the same class. +//! +//! **Colorimetry `gamut-color` can supply is never retyped here**: the primaries and white point +//! come from [`gamut_color::cicp::ColourPrimaries::chromaticities`], the RGB→XYZ construction and +//! the Bradford adaptation to the D50 PCS from [`gamut_color::matrix`], and the ST 2084 curve from +//! [`gamut_color::transfer`]. +//! +//! Three published constants it does not supply *are* written here, because an ICC tag needs them +//! in a form no `gamut-color` function returns. Each is gated against something outside this +//! module rather than trusted: +//! +//! | Restated here | Why it cannot be borrowed | What gates it | +//! | --- | --- | --- | +//! | [`BT709_BETA`] (H.273 §8.2); [`BT709_ALPHA`] is *derived* from it, not restated | `gamut-color` has no BT.709-family curve at all — see "Which spaces" | `bt709_curve_inverts_the_h273_transfer`, against `h273_bt709_oetf`, a forward transcription of Table 3 that restates α and β on purpose so no mistyped digit is shared; and `oracle_bt709_tone_curve_matches_the_h273_transfer` through lcms2 | +//! | The IEC 61966-2-1 `(g, a, b, c, d)` set for [`Trc::Srgb`] | `gamut_color::transfer::srgb_eotf` is a *function*; §10.18 type 3 needs its five parameters | `srgb_parametric_curve_matches_gamut_color` against that function, and `oracle_srgb_tone_curve_matches_lcms` | +//! | The PCS D50, via [`XyzNumber::D50`] | It is an **ICC** fact (§7.2.16), not a CIE one; see [`pcs_d50_chromaticity`] | `colorants_sum_to_the_declared_media_white_point`, and the lcms2 colorant oracle | +//! +//! Everything else this module contributes is ICC encoding, not colour science. //! //! # Which spaces //! From 288b2a74ea1e30b30440cbd79eab9a12ac420e95 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:22:49 -0400 Subject: [PATCH 23/31] docs(icc): cite cicpTag's own clause for the rule that decides the flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record justified declining a narrow-range triple by argument: the tag would contradict the colorants beside it. ICC.1:2022 §9.2.17 settles it outright - the CICP tag content "shall be equivalent to the data colour space encoding represented by this ICC profile" - so carrying the flag through is non-conforming, not merely inconsistent. The same clause is why `gray_with_gamma` writes no `cicpType`: it is permitted only for RGB, YCbCr and XYZ, which read as an omission until it is cited. --- crates/gamut-icc/STATUS.md | 9 ++++++++- crates/gamut-icc/src/builtin.rs | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index 74f5ee9c..5108b7e7 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -129,7 +129,10 @@ lost: the coefficients describe a luma–chroma encoding the caller de-matrixes applies, and they remain in the container signalling a decoder reads them from. `VideoFullRangeFlag` is **not** rewritten. A triple carrying anything but full range (`1`) is -**declined**. §10.3's own RGB examples put the flag at zero (`1-1-0-0`, `9-16-0-0`), and read +**declined**, and carrying it through unchanged instead would be non-conforming rather than merely +inconsistent: §9.2.17 requires that "the colour encoding specified by the CICP tag content shall be +equivalent to the data colour space encoding represented by this ICC profile", which a narrow-range +triple beside full-scale colorants is not. §10.3's own RGB examples put the flag at zero (`1-1-0-0`, `9-16-0-0`), and read `1-1-0-0` closely: with `MatrixCoefficients` already zero it is a narrow range on the *RGB samples themselves*, which de-matrixing does not remove. This profile's colorants, `chad` and tone curves are all defined over full-scale RGB, so normalising the flag to `1` would return a profile that @@ -137,6 +140,10 @@ renders the caller's colour **wrongly**, not one that merely dropped metadata. D crate already does for primaries it has no chromaticities for and for a transfer with no ICC tone curve. Callers holding narrow-range samples scale them to full range and pass `1`. +`gray_with_gamma` writes no `cicpType` tag at all: §9.2.17 permits the tag only for an RGB, YCbCr +or XYZ data colour space and says it shall not be present otherwise, and a monochrome profile's +space is `GRAY`. + **Grey gamma domain.** `gray_with_gamma` takes open `f64` input and declines anything the `kTRC` cannot carry *non-degenerately*. The bound is on the encoding of the §10.18 type 0 parameter, not on the number: `S15Fixed16::from_f64` (§4.6) rounds `gamma × 65 536` and then clamps, so it never diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index e4840b67..71327595 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -94,6 +94,14 @@ //! shape cannot describe. A caller holding narrow-range samples scales them to full range first, //! and then the triple it passes is one this module builds. //! +//! Nor is carrying the flag through unchanged — the option that looks most conservative, since it +//! discards nothing — merely inconsistent. §9.2.17 makes it **non-conforming**: the colour +//! encoding a `cicpType` tag specifies *"shall be equivalent to the data colour space encoding +//! represented by this ICC profile"*, and a narrow-range triple sitting beside full-scale +//! colorants and tone curves is not equivalent to what the profile represents. There is no +//! reading of the tag under which all three of "keep the flag", "keep the colorimetry" and +//! "conform" hold together. +//! //! # Which reading of transfer 1, 6, 14 and 15 //! //! H.273 Table 3 defines those four code points as an **opto-electronic** function, and an ICC @@ -570,6 +578,10 @@ impl IccProfile { /// A v4 monochrome display profile with a pure-gamma grey tone curve (`Y = X^gamma`). /// /// The white point is D50, so no chromatic adaptation is needed and no `chad` tag is written. + /// Nor is a `cicpType` tag: §9.2.17 permits it only when the data colour space in the header is + /// RGB, YCbCr or XYZ, and states that it *"shall not be present for other data colour spaces"*. + /// A monochrome profile's space is `GRAY`, so the tag's absence here is required rather than an + /// omission — and the CICP axes have nothing to say about a grey ramp anyway. /// /// Returns `None` for a `gamma` the `kTRC` cannot carry *as the caller wrote it*. The bound /// is the `s15Fixed16` encoding of the curve parameter rather than the number: `gamma` is From 9baf3997e1031478584c85a6dbd620b2ef8cd461 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:23:13 -0400 Subject: [PATCH 24/31] docs(icc): disclose the rendering intent every constructor writes `Perceptual` is `ProfileHeader::new`'s default and a surprising one on a colorimetrically exact matrix/TRC profile, where a caller most likely means media-relative colorimetric. It costs one section to say so, and a doctest to keep the statement true; the field is a preference a CMM may override, so nothing about the colorimetry turns on it. --- crates/gamut-icc/STATUS.md | 5 +++++ crates/gamut-icc/src/builtin.rs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index 5108b7e7..150c77f2 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -178,6 +178,11 @@ caller embedding the profile alongside SDR-referred content necessarily expects; diffuse-white-referred normalization instead is a colour-appearance decision with its own consequences, so it is tracked separately rather than changed silently here. +**Rendering intent.** Every constructor writes `Perceptual` in the header's rendering-intent field +(§7.2.15), `ProfileHeader::new`'s default. It is a surprising default on a colorimetrically exact +matrix/TRC profile, so it is disclosed rather than left to be discovered; the field is a preference +a CMM may override, and a caller wanting another sets `header.rendering_intent` before serializing. + **Determinism.** A constructor is a pure function of its arguments: no creation timestamp, no profile ID, no other entropy, so the same call always serializes to the same bytes. diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 71327595..db54f0ac 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -133,6 +133,25 @@ //! reading as an option is tracked at //! . //! +//! # Rendering intent +//! +//! Every profile built here carries `Perceptual` in the header's rendering-intent field (§7.2.15), +//! which is [`ProfileHeader::new`]'s default. That is worth stating because it is surprising on a +//! colorimetrically exact matrix/TRC profile, where `MediaRelativeColorimetric` is what most +//! callers mean. The field is a *preference* a CMM may override — the profile describes one +//! colour space either way, and the intent that actually renders is chosen at transform time — so +//! it changes nothing about the colorimetry, and a caller who wants a different default sets +//! `profile.header.rendering_intent` before serializing: +//! +//! ``` +//! use gamut_icc::{BuiltinProfile, IccProfile, RenderingIntent}; +//! +//! let mut p = IccProfile::builtin(BuiltinProfile::Srgb).expect("a modelled space"); +//! assert_eq!(p.header.rendering_intent, RenderingIntent::Perceptual); +//! p.header.rendering_intent = RenderingIntent::MediaRelativeColorimetric; +//! assert!(p.validate().is_empty()); +//! ``` +//! //! # Determinism //! //! A constructor is a pure function of its arguments: the creation date is From 4379973e5989fb29b1df379a1a5891d5010ec5dd Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:23:37 -0400 Subject: [PATCH 25/31] docs(icc): list thiserror among the crate's dependencies Both the crate docs and the README enumerated the dependency list and both omitted `thiserror`, which `error.rs` derives its error type from. Found by the same sweep that derived the cross-crate claims, and false for the same reason: a list that says "only" is a completeness claim. --- crates/gamut-icc/README.md | 9 +++++---- crates/gamut-icc/src/lib.rs | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/gamut-icc/README.md b/crates/gamut-icc/README.md index e603b8e6..cb651cc1 100644 --- a/crates/gamut-icc/README.md +++ b/crates/gamut-icc/README.md @@ -13,10 +13,11 @@ the format crates can read, preserve, and embed accurate color characterization. - **Clean-slate from the spec.** Implemented from **ICC.1:2022** (profile v4.4, equivalent to ISO 15076-1; [`../../references/icc`](../../references/icc)), with v2 read support since most embedded profiles are still v2. -- **Dependency-light.** An ICC profile needs neither IFD nor XML machinery, so this crate builds - only on [`gamut-core`](../gamut-core), [`gamut-color`](../gamut-color) (the colorimetry behind - the built-in profile constructors below) and [`md-5`](https://crates.io/crates/md-5) (the - §7.2.18 profile-ID digest). +- **Dependency-light.** An ICC profile needs neither IFD nor XML machinery, so the whole + dependency list is [`gamut-core`](../gamut-core), [`gamut-color`](../gamut-color) (the + colorimetry behind the built-in profile constructors below), + [`md-5`](https://crates.io/crates/md-5) (the §7.2.18 profile-ID digest) and + [`thiserror`](https://crates.io/crates/thiserror). ## Usage diff --git a/crates/gamut-icc/src/lib.rs b/crates/gamut-icc/src/lib.rs index 88549fb1..0a1f357e 100644 --- a/crates/gamut-icc/src/lib.rs +++ b/crates/gamut-icc/src/lib.rs @@ -3,9 +3,9 @@ //! An ICC profile is the self-describing colour-characterization blob embedded in images (the WebP //! `ICCP` chunk, the AVIF/HEIF `colr` box of type `prof`, a JPEG `APP2` segment): a 128-byte header, //! a tag table, then the tag element data the table points at. It is a flat, offset-indexed binary -//! format that needs neither the TIFF/IFD machinery nor XML, so this crate's only dependencies are -//! [`gamut_core`], [`gamut_color`] (the colorimetry behind the built-in profile constructors below) -//! and `md-5` (for the §7.2.18 profile ID). +//! format that needs neither the TIFF/IFD machinery nor XML, so this crate's whole dependency list +//! is [`gamut_core`], [`gamut_color`] (the colorimetry behind the built-in profile constructors +//! below), `md-5` (for the §7.2.18 profile ID) and `thiserror`. //! //! Layouts follow **ICC.1:2022** (profile version 4.4, equivalent to ISO 15076-1; see //! `references/icc`). Profile **v2** — still the most common version in real images — is supported, From a34093d1fee2aa0dc5518390bae1966d3019ed07 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 09:28:48 -0400 Subject: [PATCH 26/31] test(icc): gate the two cross-crate figures the docs still quoted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The derivation of every doc sentence asserting what a function outside this crate returns left two members ungated: the 20.3 % by which `gamut_color::transfer::eotf_for`'s curve for code point 14 exceeds the one this module writes, and the tristimuli separating `gamut_color::matrix::D50` from the PCS D50. Both resolve true — 20.298 % and a 1.99e-4 gap — and both were stated twice with nothing to hold them. The first joins the doctest that already pins the sentences beside it; the second is a drift guard on the two constants, since the arithmetic it describes is what decides the adaptation target. STATUS.md's claim that the doctest pins "every clause" of that paragraph was itself an ungated completeness claim, and is replaced by the list of what it asserts. --- crates/gamut-icc/STATUS.md | 11 +++++++---- crates/gamut-icc/src/builtin.rs | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index 150c77f2..87eca94d 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -100,9 +100,10 @@ and code 14 has an `eotf_for` curve that is **not this one** — `bt2020_pq_to_s a tone map to SDR, which at `V = 0.5` is 20.3 % above what Table 3 gives code 14. All four are exactly encodable here as the one curve Table 3 defines. That the two crates read code point 14 differently is a `gamut-color` question, filed as -[#605](https://github.com/visualcommons/gamut/issues/605) rather than resolved here; the doctest -on `src/builtin.rs`'s module documentation pins every clause of this paragraph that is a claim -about gamut-color, so it cannot drift from the crate it describes. +[#605](https://github.com/visualcommons/gamut/issues/605) rather than resolved here. Each claim +about gamut-color above is an assertion in the doctest on `src/builtin.rs`'s module documentation: +which of the four code points `TransferCharacteristics::from_code_point` models, which of those +`eotf_for` returns a curve for, and the 20.3 % by which that curve exceeds Table 3's at `V = 0.5`. **A `SourceProfile` bundle's own curve is not always the profile's curve.** `from_source_profile` projects the bundle onto its two CICP code points and builds from those. For `SourceProfile::SRGB` @@ -167,7 +168,9 @@ worse than a long number. not to `gamut_color::matrix::D50`. The two differ by 2e-4 in Z — the CIE chromaticity against ICC's rounded tristimulus — and adapting to the CIE one while writing the ICC one as the `mediaWhitePointTag` leaves the colorants disagreeing with the white point they sum to. The PCS -illuminant is an ICC fact, so this crate owns it. +illuminant is an ICC fact, so this crate owns it. Both tristimuli and the 2e-4 gap are pinned by +`the_two_d50_tristimuli_the_doc_names_are_what_the_constants_hold`, so this figure cannot drift +from the constants it describes. **Known limit: the BT.2100 PQ profile is peak-referred.** Its `curveType` samples ST 2084 normalized to the transfer's own 10 000 cd/m² peak, so signal maps to media-relative luminance as a diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index db54f0ac..7d5f1cb0 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -52,9 +52,12 @@ //! assert_eq!(Tc::from_code_point(14), Some(Tc::Bt2020_10)); //! assert_eq!(Tc::from_code_point(6), None); //! assert_eq!(Tc::from_code_point(15), None); -//! // … and exactly one of those two has an EOTF, which is not this curve. +//! // … and exactly one of those two has an EOTF, which is not this curve: at V = 0.5 it sits +//! // 20.3 % above Table 3's 0.259719, the light this module writes for the same code point +//! // (pinned by `bt709_curve_inverts_the_h273_transfer`). //! assert!(eotf_for(Tc::Bt709).is_none()); -//! assert!(eotf_for(Tc::Bt2020_10).is_some()); +//! let tone_mapped = eotf_for(Tc::Bt2020_10).expect("code point 14 has an EOTF")(0.5); +//! assert!(((tone_mapped / 0.259_719 - 1.0) * 100.0 - 20.3).abs() < 0.05); //! //! // All four are encodable here regardless; that they encode one curve is pinned by //! // `every_bt709_family_code_point_builds_the_same_curve`. @@ -479,6 +482,10 @@ fn pq_samples() -> Vec { /// `XYZNumber` encoding carries 0.824905. Adapting to the CIE one while writing the ICC one as /// the `mediaWhitePointTag` would leave the colorants disagreeing with the white point they are /// supposed to sum to, by 2.0e-4 in Z. The PCS illuminant is an ICC fact, so this crate owns it. +/// +/// Both tristimuli and the gap between them are pinned by +/// `the_two_d50_tristimuli_the_doc_names_are_what_the_constants_hold`, so this paragraph cannot +/// drift from the two constants it quotes. fn pcs_d50_chromaticity() -> [f64; 2] { let [x, y, z] = XyzNumber::D50.to_f64(); let sum = x + y + z; @@ -1206,6 +1213,27 @@ mod tests { } } + /// The two D50s [`pcs_d50_chromaticity`] distinguishes hold the tristimuli its doc quotes: + /// `gamut_color::matrix::D50` gives `Z = 0.825105` at `Y = 1`, ICC's encoded + /// [`XyzNumber::D50`] carries `0.824905`, and the gap is the documented 2.0e-4. A drift guard + /// on two constants this crate does not own — the doc sentence naming them is otherwise + /// unchecked, and its arithmetic is what decides the adaptation target. + #[test] + fn the_two_d50_tristimuli_the_doc_names_are_what_the_constants_hold() { + let [x, y] = gamut_color::matrix::D50; + let cie_z = (1.0 - x - y) / y; + let icc_z = XyzNumber::D50.to_f64()[2]; + assert!((cie_z - 0.825_105).abs() < 5.0e-7, "CIE D50 Z: {cie_z}"); + assert!((icc_z - 0.824_905).abs() < 5.0e-7, "ICC D50 Z: {icc_z}"); + // Each bound is half the last digit the doc states: six decimals on the two tristimuli, + // two significant figures on the gap they differ by. + assert!( + (cie_z - icc_z - 2.0e-4).abs() < 5.0e-6, + "gap: {}", + cie_z - icc_z + ); + } + /// A constructor is a pure function of its arguments: the same call serializes to the same /// bytes. Guards the "no timestamp, no ID, no entropy" property the module doc promises, which /// a later `DateTime::now()` would silently break. From a8ad48ce541db1d84596773f23a4a584fc56ce46 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 24 Sep 2026 23:23:51 -0400 Subject: [PATCH 27/31] test(icc): gate the SourceProfile divergence figures the docs quote from_source_profile's doc, STATUS.md and the mapping test's doc state that SourceProfile::BT2020's own eotf diverges from its profile by up to 0.735 absolute and 52x at V = 0.1, against 4.2e-6 for SRGB. Nothing checked them. Assert all three at half the last digit stated, over the 100 001-point sweep the prose names, measured through the rTRC tag this module writes. --- crates/gamut-icc/STATUS.md | 4 ++- crates/gamut-icc/src/builtin.rs | 45 ++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index 87eca94d..fd932e73 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -111,7 +111,9 @@ that is a distinction without a difference — the bundle's transfer *is* code p and bundle agree to 4.2e-6, the `s15Fixed16` rounding of the tag. For `SourceProfile::BT2020` it is not: that bundle's transfer is ST 2084 **plus a Reinhard tone map to SDR**, while its code point is 16, which is ST 2084 alone. The profile encodes 16, peak-referred, and over a 100 001-point sweep -of the signal domain the two curves diverge by up to **0.735** absolute — 52× at `V = 0.1`. This is +of the signal domain the two curves diverge by up to **0.735** absolute — 52× at `V = 0.1`. All +three figures in this paragraph are asserted at the precision written here by +`source_profile_divergence_is_what_the_docs_quote`, beside the constructor they describe. This is the opposite call from narrow range above, and deliberately so: a sample range is a property of the *samples*, which a full-scale profile genuinely cannot describe, whereas a tone map is gamut-color's choice about how to *render* an HDR transfer, and §9.2.17 requires the `cicpType` tag to be diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 7d5f1cb0..ef029e0b 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -752,6 +752,8 @@ impl IccProfile { /// map. Swept over the signal domain at 100 001 points the two diverge by up to **0.735** /// absolute, 52× at `V = 0.1`; `SourceProfile::SRGB`, whose transfer *is* its code point, /// agrees with its profile to 4.2e-6 — the `s15Fixed16` rounding of the tag, and nothing else. + /// All three figures are asserted, at the precision written here, by + /// `source_profile_divergence_is_what_the_docs_quote`. /// /// That is deliberate, and it is the difference between the two axes. A narrow sample range is /// a property of the samples, which is why [`from_cicp`](IccProfile::from_cicp) declines one it @@ -1172,7 +1174,8 @@ mod tests { /// `BT2020` maps onto [`BuiltinProfile::Bt2020Pq`] even though the bundle's own `eotf` is a /// tone map and the profile's `rTRC` is not: the mapping is on the **code points** the bundle /// projects onto, which is what `from_source_profile` is defined over. The divergence that - /// follows is documented on the constructor and measured there (up to 0.735 absolute). + /// follows is documented on the constructor (up to 0.735 absolute), and that figure is gated + /// by `source_profile_divergence_is_what_the_docs_quote`. #[test] fn source_profiles_map_onto_the_builtin_spaces() { let cases = [ @@ -1193,6 +1196,46 @@ mod tests { } } + /// The `rTRC` of `profile` evaluated by this crate's own curve evaluator — the tag as written, + /// `s15Fixed16` rounding and sampling included. + fn red_trc(profile: &IccProfile) -> impl Fn(f64) -> f64 + '_ { + move |x| match profile.get(KnownTag::RedTrc) { + Some(TagData::ParametricCurve(curve)) => curve.eval(x), + Some(TagData::Curve(curve)) => curve.eval(x), + other => panic!("an rTRC tone curve, found {other:?}"), + } + } + + /// The figures `from_source_profile`'s doc and `STATUS.md` quote for how far a + /// [`SourceProfile`] bundle's own `eotf` sits from the profile built for it: up to **0.735** + /// absolute and **52×** at `V = 0.1` for `BT2020`, against **4.2e-6** for the `SRGB` control. + /// Both sides are measured as they ship — the bundle through `gamut-color`'s `eotf`, the + /// profile through the `rTRC` tag this module writes — over the 100 001-point sweep the prose + /// names. Each bound is half the last digit the prose states, so the gate is exactly as tight + /// as the claim; the figures otherwise restate another crate's curve with nothing checking it. + #[test] + fn source_profile_divergence_is_what_the_docs_quote() { + let largest_divergence = |source: SourceProfile| { + let profile = IccProfile::from_source_profile(source).expect("a buildable bundle"); + let trc = red_trc(&profile); + (0..=100_000) + .map(|step| { + let x = f64::from(step) / 100_000.0; + (source.eotf(x) - trc(x)).abs() + }) + .fold(0.0, f64::max) + }; + + let bt2020 = largest_divergence(SourceProfile::BT2020); + assert!((bt2020 - 0.735).abs() < 5.0e-4, "BT2020: {bt2020}"); + let srgb = largest_divergence(SourceProfile::SRGB); + assert!((srgb - 4.2e-6).abs() < 5.0e-8, "SRGB: {srgb}"); + + let profile = IccProfile::from_source_profile(SourceProfile::BT2020).expect("buildable"); + let ratio = SourceProfile::BT2020.eotf(0.1) / red_trc(&profile)(0.1); + assert!((ratio - 52.0).abs() < 0.5, "BT2020 at V = 0.1: {ratio}×"); + } + /// Each space's colorants sum to the media white point the same profile declares — the law a /// matrix/TRC profile has to satisfy for full-scale RGB to land on the PCS white. It is what /// forces the Bradford adaptation to target [`pcs_d50_chromaticity`] rather than the CIE D50. From b4dda221d7c7d474aa0cbbaaf9ee25c4daf0bb87 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 24 Sep 2026 23:27:03 -0400 Subject: [PATCH 28/31] test(icc): have lcms2 check every built profile's white point, curves and cicp tag Decision 5 promised that lcms2 re-opens each constructed profile and checks its primaries, white point and TRC, and that the CICP path is compared with the oracle's cicp synthesiser. It did not: lcms2 never evaluated the PQ or linear TRC, never read the white point, opened no from_cicp profile but the BT.709 one, and the synthesiser went unused. - colorants: swept over every primaries code point from_cicp builds (adds BT.470 BG and SMPTE 170M), superseding the three-space loop; - mediaWhitePointTag: read by lcms2 for every built-in space and grey; - TRCs: every encodable transfer code point, all three channels, against an independent curve; supersedes the sRGB and BT.709 single-curve tests; - cicpType: equal to the tag lcms2's own synthesiser writes for the same fields, for every buildable pair. STATUS.md's acceptance paragraph now lists what each oracle checks and what none does. --- crates/gamut-icc/STATUS.md | 20 ++- crates/gamut-icc/src/builtin.rs | 274 ++++++++++++++++++++------------ 2 files changed, 190 insertions(+), 104 deletions(-) diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index fd932e73..93a87dc8 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -59,7 +59,7 @@ H.273 §8.2's β (`BT709_BETA`, from which α is derived rather than restated), `bt709_curve_inverts_the_h273_transfer` against an independent forward transcription of Table 3 and by the lcms2 oracle; the IEC 61966-2-1 `(g, a, b, c, d)` parameter set for §10.18 type 3, pinned by `srgb_parametric_curve_matches_gamut_color` against `gamut_color::transfer::srgb_eotf` and by -`oracle_srgb_tone_curve_matches_lcms`; and the PCS D50 of §7.2.16, which is an ICC fact rather than +`oracle_every_encodable_transfer_matches_its_curve_through_lcms`; and the PCS D50 of §7.2.16, which is an ICC fact rather than a CIE one (see "PCS white" below), pinned by `colorants_sum_to_the_declared_media_white_point` and the lcms2 colorant oracle. `src/builtin.rs`'s module documentation tabulates the three. Colorimetry is not the only reason to decline: `from_cicp` also refuses signalling this profile *shape* cannot carry, which is @@ -191,10 +191,20 @@ a CMM may override, and a caller wanting another sets `header.rendering_intent` **Determinism.** A constructor is a pure function of its arguments: no creation timestamp, no profile ID, no other entropy, so the same call always serializes to the same bytes. -**Acceptance.** lcms2 re-opens every constructed profile and reports the same colorants as it -derives from the same primaries itself (within four `s15Fixed16` quanta), evaluates our sRGB TRC to -the same values as the sRGB profile it synthesizes itself, estimates the grey gamma we asked for, -and transforms through our sRGB into its own sRGB as the identity to within one 8-bit code. +**Acceptance.** Every check below is lcms2 (`tooling/lcms2-oracle`) reading bytes this crate +serialized, compared against something this crate did not compute: + +| What lcms2 checks | Over which profiles | Test | +| --- | --- | --- | +| Colorants equal the ones lcms2 derives from the same chromaticities (four `s15Fixed16` quanta) | `from_cicp` for **every** primaries code point it builds — 1, 5, 6, 9, 12 — which covers the built-in spaces' three | `oracle_colorants_match_lcms_for_every_buildable_primaries` | +| `mediaWhitePointTag` equals the D50 lcms2 writes itself | every `BuiltinProfile` and the grey constructor | `oracle_media_white_point_matches_lcms` | +| All three TRCs evaluate to the transfer's own curve (two `uInt16` quanta) | `from_cicp` for **every** encodable transfer — 1, 6, 8, 13, 14, 15, 16 — against H.273 Table 3, the identity, lcms2's own sRGB and `gamut-color`'s `pq_eotf` | `oracle_every_encodable_transfer_matches_its_curve_through_lcms` | +| The `cicpType` tag equals the one lcms2's own `cicp` synthesiser writes | every buildable (primaries, transfer) pair, built from an `nclx`-style matrix 9 | `oracle_cicp_tag_matches_the_lcms_synthesiser` | +| The grey gamma lcms2 estimates is the one asked for | `gray_with_gamma` at 1.0, 1.8, 2.2 | `oracle_gray_gamma_matches_lcms` | +| A transform through our sRGB into lcms2's own sRGB is the identity to one 8-bit code | sRGB | `oracle_transform_through_our_srgb_is_the_identity` | + +Not checked by lcms2: the `chad` tag's contents (it is read only through the colorants it +produced), and any `from_cicp` profile's end-to-end transform other than sRGB's. ## Deferred / intentional leniencies diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index ef029e0b..e1b5ccd0 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -15,8 +15,8 @@ //! //! | Restated here | Why it cannot be borrowed | What gates it | //! | --- | --- | --- | -//! | [`BT709_BETA`] (H.273 §8.2); [`BT709_ALPHA`] is *derived* from it, not restated | `gamut-color` has no BT.709-family curve at all — see "Which spaces" | `bt709_curve_inverts_the_h273_transfer`, against `h273_bt709_oetf`, a forward transcription of Table 3 that restates α and β on purpose so no mistyped digit is shared; and `oracle_bt709_tone_curve_matches_the_h273_transfer` through lcms2 | -//! | The IEC 61966-2-1 `(g, a, b, c, d)` set for [`Trc::Srgb`] | `gamut_color::transfer::srgb_eotf` is a *function*; §10.18 type 3 needs its five parameters | `srgb_parametric_curve_matches_gamut_color` against that function, and `oracle_srgb_tone_curve_matches_lcms` | +//! | [`BT709_BETA`] (H.273 §8.2); [`BT709_ALPHA`] is *derived* from it, not restated | `gamut-color` has no BT.709-family curve at all — see "Which spaces" | `bt709_curve_inverts_the_h273_transfer`, against `h273_bt709_oetf`, a forward transcription of Table 3 that restates α and β on purpose so no mistyped digit is shared; and `oracle_every_encodable_transfer_matches_its_curve_through_lcms` through lcms2 | +//! | The IEC 61966-2-1 `(g, a, b, c, d)` set for [`Trc::Srgb`] | `gamut_color::transfer::srgb_eotf` is a *function*; §10.18 type 3 needs its five parameters | `srgb_parametric_curve_matches_gamut_color` against that function, and `oracle_every_encodable_transfer_matches_its_curve_through_lcms` against lcms2's own sRGB | //! | The PCS D50, via [`XyzNumber::D50`] | It is an **ICC** fact (§7.2.16), not a CIE one; see [`pcs_d50_chromaticity`] | `colorants_sum_to_the_declared_media_white_point`, and the lcms2 colorant oracle | //! //! Everything else this module contributes is ICC encoding, not colour science. @@ -1293,35 +1293,85 @@ mod tests { // --- differential: Little-CMS re-opens what we built ------------------------------------ - /// lcms2 re-opens each built-in profile and reports the same colorants as it computes for the - /// same primaries itself. This is the colorimetric acceptance gate: the D50 adaptation, the + /// A transform from our sRGB profile to lcms2's own sRGB profile is the identity, to within a + /// code. This is the end-to-end acceptance: colorants, white point, adaptation and TRC all + /// have to be right together for a full round trip through the PCS to come back unchanged. + #[test] + fn oracle_transform_through_our_srgb_is_the_identity() { + let ours = lcms2_oracle::Profile::from_bytes( + &IccProfile::builtin(BuiltinProfile::Srgb) + .expect("sRGB is buildable") + .to_bytes() + .expect("serializes"), + ) + .expect("lcms2 re-opens the profile"); + let reference = lcms2_oracle::srgb(); + + let pixels: Vec = (0..=255u8) + .flat_map(|v| [v, 255 - v, v.wrapping_mul(3)]) + .collect(); + // Intent 1 is media-relative colorimetric: the intent under which two profiles for the + // same space must agree exactly. + let out = lcms2_oracle::transform_rgb8(&ours, &reference, 1, &pixels); + assert_eq!(out.len(), pixels.len()); + for (i, (&got, &want)) in out.iter().zip(pixels.iter()).enumerate() { + assert!( + got.abs_diff(want) <= 1, + "channel {i}: {got} vs {want} through our sRGB → lcms2 sRGB" + ); + } + } + + /// The serialized bytes of `profile`, re-opened by lcms2. + fn opened_by_lcms(profile: &IccProfile) -> lcms2_oracle::Profile { + lcms2_oracle::Profile::from_bytes(&profile.to_bytes().expect("serializes")) + .expect("lcms2 re-opens the profile") + } + + /// The profile `from_cicp` builds for full-range RGB signalling of `primaries` and `transfer`. + fn rgb_cicp(primaries: u8, transfer: u8) -> Option { + IccProfile::from_cicp(Cicp { + colour_primaries: primaries, + transfer_characteristics: transfer, + matrix_coefficients: 0, + video_full_range_flag: 1, + }) + } + + /// Every primaries code point `from_cicp` builds, found by asking it rather than restated. + fn buildable_primaries() -> Vec { + (0..=u8::MAX) + .filter(|&code| rgb_cicp(code, 13).is_some()) + .collect() + } + + /// lcms2 re-opens a profile for **every** primaries code point `from_cicp` builds — not only + /// the three the built-in spaces use — and reports the colorants it derives from the same + /// chromaticities itself. This is the colorimetric acceptance gate: the D50 adaptation, the /// column-vs-row orientation of the colorant tags and the `s15Fixed16` encoding are all only - /// checked against an independent implementation. + /// checked against an independent implementation. The code points BT.470 BG (5) and SMPTE + /// 170M (6) are reachable only through `from_cicp`; the built-in spaces' three are reached + /// here through the same path, which `each_builtin_space_round_trips_through_its_cicp_value` + /// pins to be the profile `builtin` returns. /// /// The tolerance is four `s15Fixed16` quanta, so it is the tag encoding — not the derivation — - /// that sets it: the largest observed disagreement is 2.4e-5, under two quanta. A transposed + /// that sets it: the largest observed disagreement is 1.2e-5 (BT.470 BG), under one quantum; + /// over the three built-in primaries alone it is 9.1e-6 (BT.2020). A transposed /// matrix, a missing Bradford adaptation or a wrong white point all move a colorant by more - /// than 1e-2. + /// than 1e-2. lcms2 builds its reference from the same chromaticities; its gamma is + /// irrelevant to the colorants. #[test] - fn oracle_colorants_match_lcms_for_the_same_primaries() { - for space in [ - BuiltinProfile::Srgb, - BuiltinProfile::DisplayP3, - BuiltinProfile::Bt2020Pq, - ] { - let (primaries, _, _, label) = space.parts(); + fn oracle_colorants_match_lcms_for_every_buildable_primaries() { + let codes = buildable_primaries(); + for code in [1, 5, 6, 9, 12] { + assert!(codes.contains(&code), "primaries {code} build"); + } + for code in codes { + let primaries = + ColourPrimaries::from_code_point(u16::from(code)).expect("a modelled code point"); let (rgb, white) = primaries.chromaticities().expect("a modelled code point"); - // lcms2 builds its own matrix/TRC profile from the same chromaticities; the gamma is - // irrelevant to the colorants. let reference = lcms2_oracle::rgb_matrix_shaper(white, rgb, [2.2, 2.2, 2.2]); - let ours = lcms2_oracle::Profile::from_bytes( - &IccProfile::builtin(space) - .expect("a modelled space") - .to_bytes() - .expect("serializes"), - ) - .expect("lcms2 re-opens the profile"); - + let ours = opened_by_lcms(&rgb_cicp(code, 13).expect("buildable")); for (name, sig) in [ ("red", tag::RED_COLORANT), ("green", tag::GREEN_COLORANT), @@ -1332,97 +1382,123 @@ mod tests { for axis in 0..3 { assert!( (got[axis] - want[axis]).abs() < 4.0 / 65536.0, - "{label} {name} colorant [{axis}]: {got:?} vs {want:?}" + "primaries {code} {name} colorant [{axis}]: {got:?} vs {want:?}" ); } } } } - /// lcms2 evaluates our sRGB tone curve to the same values as it evaluates the sRGB curve in - /// the profile it synthesizes itself. Two independent constructions of IEC 61966-2-1, read by - /// the same reference CMM. + /// lcms2 reads the `mediaWhitePointTag` of every profile built here — each built-in space and + /// the grey constructor — as the D50 it writes into a matrix/TRC profile it builds itself. + /// The colorant oracle only sees the white point through the colorants; this reads it. #[test] - fn oracle_srgb_tone_curve_matches_lcms() { - let ours = lcms2_oracle::Profile::from_bytes( - &IccProfile::builtin(BuiltinProfile::Srgb) - .expect("sRGB is buildable") - .to_bytes() - .expect("serializes"), + fn oracle_media_white_point_matches_lcms() { + let reference = lcms2_oracle::rgb_matrix_shaper( + [0.3127, 0.3290], + [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]], + [2.2, 2.2, 2.2], ) - .expect("lcms2 re-opens the profile"); - let reference = lcms2_oracle::srgb(); - - for step in 0..=20 { - let x = step as f32 / 20.0; - let got = ours.eval_tone_curve(tag::RED_TRC, x).expect("rTRC present"); - let want = reference - .eval_tone_curve(tag::RED_TRC, x) - .expect("rTRC present"); - assert!( - (got - want).abs() < 1.0e-4, - "sRGB rTRC at {x}: {got} vs {want}" - ); + .read_xyz(tag::MEDIA_WHITE_POINT) + .expect("lcms2 writes a white point"); + let profiles = BuiltinProfile::ALL + .into_iter() + .map(|space| (format!("{space:?}"), IccProfile::builtin(space))) + .chain([("grey".to_owned(), IccProfile::gray_with_gamma(2.2))]); + for (label, profile) in profiles { + let got = opened_by_lcms(&profile.expect("buildable")) + .read_xyz(tag::MEDIA_WHITE_POINT) + .expect("wtpt present"); + for axis in 0..3 { + assert!( + (got[axis] - reference[axis]).abs() < 1.0 / 65536.0, + "{label} white point [{axis}]: {got:?} vs {reference:?}" + ); + } } } - /// A transform from our sRGB profile to lcms2's own sRGB profile is the identity, to within a - /// code. This is the end-to-end acceptance: colorants, white point, adaptation and TRC all - /// have to be right together for a full round trip through the PCS to come back unchanged. + /// lcms2 evaluates the three TRCs of the profile built for **every** encodable transfer code + /// point to that transfer's own curve, taken from a source independent of the tag: H.273 + /// Table 3's forward function for the BT.709 family, the identity for linear, lcms2's own sRGB + /// for sRGB, and `gamut-color`'s `pq_eotf` for PQ. The linear and PQ tags are otherwise only + /// checked by this crate's own evaluator. An independent CMM reading an independent + /// transcription of each curve is what says the tag this module *wrote* — function type, + /// parameter order, `s15Fixed16` encoding, sampling — is the curve, not just that this + /// crate's evaluator agrees with itself. + /// + /// The tolerance is two `uInt16` quanta: the sampled PQ table's own interpolation error is + /// under one (see [`SAMPLED_TRC_POINTS`]), and lcms2 adds its 16-bit evaluation of the table + /// on top. Measured worst cases on this grid: 1.08e-5 for PQ, 4.2e-6 for sRGB, 1.3e-6 for the + /// BT.709 family, zero for linear. A wrong curve, direction or normalization moves a sample + /// by orders of magnitude more. #[test] - fn oracle_transform_through_our_srgb_is_the_identity() { - let ours = lcms2_oracle::Profile::from_bytes( - &IccProfile::builtin(BuiltinProfile::Srgb) - .expect("sRGB is buildable") - .to_bytes() - .expect("serializes"), - ) - .expect("lcms2 re-opens the profile"); - let reference = lcms2_oracle::srgb(); - - let pixels: Vec = (0..=255u8) - .flat_map(|v| [v, 255 - v, v.wrapping_mul(3)]) - .collect(); - // Intent 1 is media-relative colorimetric: the intent under which two profiles for the - // same space must agree exactly. - let out = lcms2_oracle::transform_rgb8(&ours, &reference, 1, &pixels); - assert_eq!(out.len(), pixels.len()); - for (i, (&got, &want)) in out.iter().zip(pixels.iter()).enumerate() { - assert!( - got.abs_diff(want) <= 1, - "channel {i}: {got} vs {want} through our sRGB → lcms2 sRGB" - ); + fn oracle_every_encodable_transfer_matches_its_curve_through_lcms() { + let srgb = lcms2_oracle::srgb(); + let peak = pq_eotf(1.0); + for code in [1_u8, 6, 8, 13, 14, 15, 16] { + let ours = opened_by_lcms(&rgb_cicp(1, code).expect("an encodable transfer")); + for step in 0..=64 { + let light_or_signal = f64::from(step) / 64.0; + // (signal, the light that signal must decode to) + let (signal, want) = match code { + 1 | 6 | 14 | 15 => (h273_bt709_oetf(light_or_signal), light_or_signal), + 8 => (light_or_signal, light_or_signal), + 13 => ( + light_or_signal, + f64::from( + srgb.eval_tone_curve(tag::RED_TRC, light_or_signal as f32) + .expect("lcms2 sRGB rTRC"), + ), + ), + _ => (light_or_signal, pq_eotf(light_or_signal) / peak), + }; + for (channel, sig) in [ + ("r", tag::RED_TRC), + ("g", tag::GREEN_TRC), + ("b", tag::BLUE_TRC), + ] { + let got = f64::from( + ours.eval_tone_curve(sig, signal as f32) + .expect("TRC present"), + ); + assert!( + (got - want).abs() < 2.0 / 65535.0, + "transfer {code} {channel}TRC at V = {signal}: {got} vs {want}" + ); + } + } } } - /// lcms2 re-opens the BT.709 profile from our serialized bytes and evaluates its `rTRC` to - /// the light H.273 Table 3's forward function started from. An independent CMM reading an - /// independent transcription of the spec: this is what says the `parametricCurveType` we - /// *wrote* — function type, parameter order and `s15Fixed16` encoding — is the curve, not - /// just that our own evaluator agrees with itself. + /// The `cicpType` tag every buildable signalling writes is the one lcms2's own `cicp` + /// synthesiser writes for the same four fields, read back through this crate's parser. + /// The two inputs coincide once §10.3 has zeroed `MatrixCoefficients`, so each triple is + /// built from a caller's `nclx`-style matrix 9 and compared with lcms2's tag for matrix 0: + /// a field written at the wrong offset, or the matrix passed through, shows up as a mismatch + /// against bytes this crate did not produce. #[test] - fn oracle_bt709_tone_curve_matches_the_h273_transfer() { - let bytes = IccProfile::from_cicp(Cicp { - colour_primaries: 1, - transfer_characteristics: 1, - matrix_coefficients: 0, - video_full_range_flag: 1, - }) - .expect("BT.709 signalling builds") - .to_bytes() - .expect("serializes"); - let opened = lcms2_oracle::Profile::from_bytes(&bytes).expect("lcms2 re-opens the profile"); - - for step in 0..=20 { - let light = f64::from(step) / 20.0; - let signal = h273_bt709_oetf(light); - let got = opened - .eval_tone_curve(tag::RED_TRC, signal as f32) - .expect("rTRC present"); - assert!( - (f64::from(got) - light).abs() < 1.0e-4, - "BT.709 rTRC at V = {signal}: {got} vs Lc = {light}" - ); + fn oracle_cicp_tag_matches_the_lcms_synthesiser() { + for primaries in buildable_primaries() { + for transfer in [1_u8, 6, 8, 13, 14, 15, 16] { + let ours = IccProfile::from_cicp(Cicp { + colour_primaries: primaries, + transfer_characteristics: transfer, + matrix_coefficients: 9, + video_full_range_flag: 1, + }) + .expect("buildable signalling"); + let lcms = IccProfile::parse( + &lcms2_oracle::cicp(primaries, transfer, 0, 1).to_bytes(), + ) + .expect("lcms2's profile parses"); + assert_eq!( + ours.get(KnownTag::Cicp), + lcms.get(KnownTag::Cicp), + "cicp {primaries}/{transfer}" + ); + assert!(ours.get(KnownTag::Cicp).is_some(), "cicp tag written"); + } } } From 047903b74c2b49356bbd8f31c5921f42f5076ecc Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 24 Sep 2026 23:29:48 -0400 Subject: [PATCH 29/31] fix(icc): build narrow-range CICP signalling whose range is on Y/Cb/Cr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit from_cicp declined every VideoFullRangeFlag of 0. ITU-T H.273 §8.3 applies that range to Y, Cb and Cr under luma-chroma matrix coefficients (1, 4-7, 9-15; equations 30 to 32), so the RGB they de-matrix to is full scale and common nclx triples such as 9/16/9/0 and 1/13/6/0 got None for a profile this crate builds exactly. Admit narrow range under those coefficients and write the tag's flag as 1, the range of the RGB the profile describes, as MatrixCoefficients is already written as 0. Narrow range under RGB coefficients (0, 8, 16, 17) - ICC.1:2022 §10.3's 1-1-0-0 - stays declined, as do coefficient 2, reserved ones and flag bytes other than 0 and 1. --- crates/gamut-icc/README.md | 15 ++- crates/gamut-icc/STATUS.md | 40 ++++--- crates/gamut-icc/src/builtin.rs | 192 +++++++++++++++++++++++--------- 3 files changed, 171 insertions(+), 76 deletions(-) diff --git a/crates/gamut-icc/README.md b/crates/gamut-icc/README.md index cb651cc1..a68e14b0 100644 --- a/crates/gamut-icc/README.md +++ b/crates/gamut-icc/README.md @@ -48,15 +48,16 @@ let bytes = p3.to_bytes()?; // ready to embed // BT.2020 primaries + PQ, as an AVIF `colr` box would signal them. The matrix coefficients are // deliberately not carried into the profile: ICC.1:2022 §10.3 requires zero for an RGB profile. -// The range flag, by contrast, is a precondition: only full range (1) can be described. +// The range flag is read against the matrix: narrow range on Y/Cb/Cr de-matrixes to full-scale +// RGB and builds, while narrow range on the RGB samples themselves (matrix 0) is declined. let signalled = Cicp { colour_primaries: 9, transfer_characteristics: 16, matrix_coefficients: 9, - video_full_range_flag: 1, + video_full_range_flag: 0, }; assert!(IccProfile::from_cicp(signalled).is_some()); -assert!(IccProfile::from_cicp(Cicp { video_full_range_flag: 0, ..signalled }).is_none()); +assert!(IccProfile::from_cicp(Cicp { matrix_coefficients: 0, ..signalled }).is_none()); # Ok::<_, gamut_icc::IccError>(()) ``` @@ -67,9 +68,11 @@ and ProPhoto RGB have no code point on either CICP axis and are declined rather `from_cicp` reaches further on the transfer axis — the BT.709 family (H.273 code points 1, 6, 14 and 15), linear, sRGB and PQ all have an ICC tone-curve encoding — because what an ICC tag can encode is not the same set as what gamut-color can evaluate. Every constructor returns an `Option` -and declines signalling it cannot describe — including a narrow-range `VideoFullRangeFlag`, which -is a scaling of the RGB samples that a full-scale matrix/TRC profile does not perform and that -de-matrixing does not remove. `STATUS.md` tabulates the curve chosen per transfer and records why +and declines signalling it cannot describe — including a narrow-range `VideoFullRangeFlag` under +RGB matrix coefficients (0, 8, 16, 17), which is a scaling of the RGB samples that a full-scale +matrix/TRC profile does not perform and that de-matrixing does not remove. Under luma–chroma +coefficients the narrow range is on Y/Cb/Cr, de-matrixed RGB is full scale, and the profile is +built with a full-range `cicpType`. `STATUS.md` tabulates the curve chosen per transfer and records why each field is rewritten or refused. **Every ICC.1:2022 §10 element type decodes semantically** — the `XYZType`, curve, and text types; diff --git a/crates/gamut-icc/STATUS.md b/crates/gamut-icc/STATUS.md index 93a87dc8..44707cc7 100644 --- a/crates/gamut-icc/STATUS.md +++ b/crates/gamut-icc/STATUS.md @@ -114,15 +114,15 @@ not: that bundle's transfer is ST 2084 **plus a Reinhard tone map to SDR**, whil of the signal domain the two curves diverge by up to **0.735** absolute — 52× at `V = 0.1`. All three figures in this paragraph are asserted at the precision written here by `source_profile_divergence_is_what_the_docs_quote`, beside the constructor they describe. This is -the opposite call from narrow range above, and deliberately so: a sample range is a property of the -*samples*, which a full-scale profile genuinely cannot describe, whereas a tone map is gamut-color's +the opposite call from narrow-range RGB below, and deliberately so: a sample range is a property of +the *samples*, which a full-scale profile genuinely cannot describe, whereas a tone map is gamut-color's choice about how to *render* an HDR transfer, and §9.2.17 requires the `cicpType` tag to be equivalent to the encoding the profile represents. A caller wanting the tone-mapped rendering applies it to its samples and embeds an SDR profile. **CICP fields the profile does not build from.** `from_cicp` builds from the primaries and transfer -code points only, and treats the other two fields differently on purpose — one is rewritten, one is -a precondition. +code points only, and treats the other two fields differently on purpose — one is always +rewritten, the other is a precondition on the RGB samples, read against the first. `MatrixCoefficients` is **rewritten to zero**. §10.3 states that "when the data colour space in the profile header is RGB or XYZ, MatrixCoefficients shall be 0 (zero)", so the caller's value — @@ -131,17 +131,27 @@ writing it would make the profile non-conforming for the most common input there lost: the coefficients describe a luma–chroma encoding the caller de-matrixes *before* this profile applies, and they remain in the container signalling a decoder reads them from. -`VideoFullRangeFlag` is **not** rewritten. A triple carrying anything but full range (`1`) is -**declined**, and carrying it through unchanged instead would be non-conforming rather than merely -inconsistent: §9.2.17 requires that "the colour encoding specified by the CICP tag content shall be -equivalent to the data colour space encoding represented by this ICC profile", which a narrow-range -triple beside full-scale colorants is not. §10.3's own RGB examples put the flag at zero (`1-1-0-0`, `9-16-0-0`), and read -`1-1-0-0` closely: with `MatrixCoefficients` already zero it is a narrow range on the *RGB samples -themselves*, which de-matrixing does not remove. This profile's colorants, `chad` and tone curves -are all defined over full-scale RGB, so normalising the flag to `1` would return a profile that -renders the caller's colour **wrongly**, not one that merely dropped metadata. Declining is what the -crate already does for primaries it has no chromaticities for and for a transfer with no ICC tone -curve. Callers holding narrow-range samples scale them to full range and pass `1`. +`VideoFullRangeFlag` is **read against the matrix coefficients**, because ITU-T H.273 §8.3 applies +it to Y, Cb and Cr under luma–chroma coefficients (1, 4–7, 9–15; equations 30 to 32) and to R, G +and B under the others (0, 8, 16, 17; equations 27 to 29): + +| Signalled | What is narrow | Result | +| --- | --- | --- | +| flag `1`, any coefficients | nothing | built; tag flag `1` | +| flag `0`, luma–chroma coefficients (`9-16-9-0`, `1-13-6-0`) | Y, Cb, Cr — de-matrixed RGB is full scale | built, the same profile as flag `1`; tag flag `1` | +| flag `0`, RGB coefficients (§10.3's `1-1-0-0`, `9-16-0-0`) | the RGB samples themselves | **declined** | +| flag `0` under coefficient 2 (unknown) or a reserved one; any flag byte but `0`/`1` | unknown | **declined** | + +The tag writes `1` for the admitted narrow-range case because it describes the RGB the profile +applies to, the same reason it writes `MatrixCoefficients` `0`: once the coefficients are `0`, a +flag of `0` would read as narrow-range *RGB*, and §9.2.17 requires that "the colour encoding +specified by the CICP tag content shall be equivalent to the data colour space encoding represented +by this ICC profile". The declined RGB case is the one no de-matrixing removes: this profile's +colorants, `chad` and tone curves are all defined over full-scale RGB, so normalising the flag to +`1` would return a profile that renders the caller's colour **wrongly**, not one that merely dropped +metadata. Declining is what the crate already does for primaries it has no chromaticities for and +for a transfer with no ICC tone curve. Callers holding narrow-range RGB samples scale them to full +range and pass `1`. `gray_with_gamma` writes no `cicpType` tag at all: §9.2.17 permits the tag only for an RGB, YCbCr or XYZ data colour space and says it shall not be present otherwise, and a monochrome profile's diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index e1b5ccd0..1bc54dfa 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -71,8 +71,8 @@ //! # What a CICP triple contributes, and what it does not //! //! [`IccProfile::from_cicp`] takes all four H.273 fields but builds from two of them. Of the other -//! two, one is rewritten and one is a precondition. They do **not** rest on the same authority, and -//! are documented apart on purpose. +//! two, one is always rewritten and one is a precondition on the RGB samples. They do **not** rest +//! on the same authority, and are documented apart on purpose. //! //! `MatrixCoefficients` is **conformance**, and is rewritten. ICC.1:2022 §10.3 states that *"when //! the data colour space in the profile header is RGB or XYZ, MatrixCoefficients shall be 0 @@ -81,29 +81,36 @@ //! non-conforming. Nothing is lost by that. The coefficients describe a luma–chroma *encoding* the //! caller de-matrixes before the profile applies, and they remain in the container's own //! signalling where a decoder reads them. The sample *range* that accompanies such an encoding is -//! not theirs to describe — that is the next field, and it is guarded, not rewritten. +//! the next field, and whether it is theirs depends on which coefficients they are. //! -//! `VideoFullRangeFlag` is **not** conformance, and is **not** rewritten either: a triple carrying -//! anything but full range (`1`) is **declined**. §10.3 says only that the flag "is often 1" for an -//! RGB profile, and its own RGB examples include `1-1-0-0` and `9-16-0-0` with the flag at zero, so -//! a narrow-range RGB `cicpType` is a legal tag — it is simply not one this module can build. -//! Everything the profile contains — the colorant matrix, the `chad` and the tone curves — is -//! defined over full-scale RGB, and read §10.3's `1-1-0-0` example closely: with -//! `MatrixCoefficients` already zero it is a narrow range on the **RGB samples themselves**, so -//! de-matrixing does not remove it. Normalising the flag to `1` would hand back a profile that -//! renders the caller's colour *wrongly*, not one that merely dropped a piece of metadata. -//! Declining is what this module already does for primaries it has no chromaticities for and for a -//! transfer with no ICC tone curve, and narrow range is the same case: signalling this profile -//! shape cannot describe. A caller holding narrow-range samples scales them to full range first, -//! and then the triple it passes is one this module builds. +//! `VideoFullRangeFlag` is **not** conformance. What it scales is fixed by the coefficients beside +//! it — H.273 §8.3 applies it to **R, G and B** when `MatrixCoefficients` is 0, 8, 16 or 17 +//! (equations 27 to 29, and 33 to 35), and to **Y, Cb and Cr** when it is 1, 4, 5, 6, 7 or 9 to +//! 15 (equations 30 to 32, and 36 to 38) — so the flag is read as a statement about the RGB +//! samples the profile will see, and the triple is kept only if they are full range: //! -//! Nor is carrying the flag through unchanged — the option that looks most conservative, since it -//! discards nothing — merely inconsistent. §9.2.17 makes it **non-conforming**: the colour +//! * **Full range (`1`)** under any coefficients: the RGB is full scale, and the flag is kept. +//! * **Narrow range (`0`) under luma–chroma coefficients** — `9-16-9-0`, `1-13-6-0`, what an AVIF +//! or HEIC `nclx` box most often carries. The narrow range is on Y, Cb and Cr, and E′R, E′G and +//! E′B come out of de-matrixing in the range 0 to 1: the RGB this profile applies to *is* full +//! scale. So the profile is built, and its tag carries `1` — the range of the RGB it describes, +//! exactly as `MatrixCoefficients` is written as the `0` of the RGB it describes. +//! * **Narrow range under RGB coefficients** (0, 8, 16, 17) — §10.3's own `1-1-0-0` and `9-16-0-0` +//! examples. The narrow range is on the **RGB samples themselves**, de-matrixing does not remove +//! it, and every colorant, `chad` and tone curve here is defined over full-scale RGB. Declined: +//! normalising the flag would hand back a profile that renders the caller's colour *wrongly*. +//! A narrow-range RGB `cicpType` is a legal tag — it is simply not one this module can build, +//! the same case as primaries with no chromaticities or a transfer with no ICC tone curve. A +//! caller holding such samples scales them to full range and passes `1`. +//! * **Anything else** — any other flag byte, or narrow range under coefficients H.273 leaves +//! unknown (2) or reserved — says nothing this module can rely on about the RGB, and is +//! declined. +//! +//! Carrying the caller's `0` through unchanged — the option that looks most conservative, since it +//! discards nothing — is not merely inconsistent. §9.2.17 makes it **non-conforming**: the colour //! encoding a `cicpType` tag specifies *"shall be equivalent to the data colour space encoding -//! represented by this ICC profile"*, and a narrow-range triple sitting beside full-scale -//! colorants and tone curves is not equivalent to what the profile represents. There is no -//! reading of the tag under which all three of "keep the flag", "keep the colorimetry" and -//! "conform" hold together. +//! represented by this ICC profile"*, and with `MatrixCoefficients` at `0` a flag of `0` reads as +//! narrow-range RGB, which is not what full-scale colorants and tone curves represent. //! //! # Which reading of transfer 1, 6, 14 and 15 //! @@ -299,34 +306,53 @@ impl BuiltinProfile { /// The one `VideoFullRangeFlag` a profile built here can describe (ITU-T H.273 signals full range /// as `1`). /// -/// Stated once and read from both ends of the module: it is what [`cicp_of`] writes and what -/// [`IccProfile::from_cicp`] requires of the caller, so the tag and the precondition cannot drift. -/// Every colorant, `chad` and tone curve written here is defined over full-scale RGB; see the -/// module docs for why narrow range is declined rather than normalised. +/// Stated once and read from both ends of the module: it is what [`rgb_conforming_cicp`] writes +/// and what [`rgb_is_full_range`] accepts, so the tag and the precondition cannot drift. Every +/// colorant, `chad` and tone curve written here is defined over full-scale RGB; see the module +/// docs for which narrow-range signalling that still admits. const FULL_RANGE: u8 = 1; +/// ITU-T H.273 (07/2024) §8.3: the `MatrixCoefficients` code points for which +/// `VideoFullRangeFlag` scales Y, Cb and Cr (equations 30 to 32) rather than R, G and B — so a +/// narrow range under them leaves de-matrixed RGB full scale. Transcribed from the clause's own +/// list; every other code point is an RGB-range one (0, 8, 16, 17), unknown (2) or reserved. +const LUMA_CHROMA_MATRICES: [u8; 12] = [1, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15]; + +/// Whether the RGB samples a profile built from `cicp` applies to are full range — full range +/// signalled outright, or narrow range on luma–chroma signals the caller de-matrixes first. +fn rgb_is_full_range(cicp: Cicp) -> bool { + match cicp.video_full_range_flag { + FULL_RANGE => true, + 0 => LUMA_CHROMA_MATRICES.contains(&cicp.matrix_coefficients), + _ => false, + } +} + /// The `cicpType` value for a pair of CICP axes. fn cicp_of(primaries: ColourPrimaries, transfer: TransferCharacteristics) -> Cicp { rgb_conforming_cicp(Cicp { colour_primaries: cicp_byte(primaries.code_point()), transfer_characteristics: cicp_byte(transfer.code_point()), - // Set by `rgb_conforming_cicp`, which owns the §10.3 rule for every constructor. + // Both set by `rgb_conforming_cicp`, which owns the §10.3 and range rules for every + // constructor. matrix_coefficients: 0, video_full_range_flag: FULL_RANGE, }) } -/// `cicp` with `MatrixCoefficients` replaced by the zero ICC.1:2022 §10.3 **requires** of an RGB or -/// XYZ profile. +/// `cicp` as it describes the RGB the profile applies to: `MatrixCoefficients` replaced by the +/// zero ICC.1:2022 §10.3 **requires** of an RGB or XYZ profile, and `VideoFullRangeFlag` by +/// [`FULL_RANGE`]. /// -/// That is the only field this module rewrites. `VideoFullRangeFlag` is not normalised here: a -/// triple that does not already carry [`FULL_RANGE`] is declined by -/// [`IccProfile::from_cicp`] instead, so nothing reaching this function can disagree with the -/// colorants and curves written alongside it. The two axes the profile is built from pass through -/// untouched. +/// The flag is written, not passed through, because once the coefficients are `0` a flag of `0` +/// would read as narrow-range *RGB* — which is not what a caller's narrow-range luma–chroma +/// signalling decodes to. Only triples [`rgb_is_full_range`] admits reach here, so the value +/// written is always the range of the samples the colorants and curves are defined over. The two +/// axes the profile is built from pass through untouched. fn rgb_conforming_cicp(cicp: Cicp) -> Cicp { Cicp { matrix_coefficients: 0, + video_full_range_flag: FULL_RANGE, ..cicp } } @@ -673,27 +699,28 @@ impl IccProfile { /// HEIC and JXL usually carry (a `colr`/`nclx` code-point trio) to an embeddable profile. /// /// Only the primaries and transfer code points shape the profile. The `cicpType` tag records - /// those two verbatim, replaces `MatrixCoefficients` with `0`, and passes - /// `VideoFullRangeFlag` through — because the only value it accepts is `1`. + /// those two verbatim and describes the RGB the profile applies to: `MatrixCoefficients` `0` + /// and `VideoFullRangeFlag` `1`. /// /// `MatrixCoefficients` is zero because ICC.1:2022 §10.3 states that when the data colour /// space is RGB or XYZ it *shall* be. No information is lost: the coefficients describe a /// luma–chroma encoding the caller de-matrixes before this profile applies, and they stay in /// the container signalling (`nclx`, AV1 sequence header) a decoder actually reads them from. /// - /// `VideoFullRangeFlag` is **not** rewritten. §10.3 does not require `1` — it only remarks - /// that the flag "is often 1" for RGB, and gives RGB examples with it at zero — but this - /// profile's colorants, `chad` and tone curves are all defined over full-scale RGB, and §10.3's - /// own `1-1-0-0` example is a narrow range on the **RGB samples themselves** (its - /// `MatrixCoefficients` is already zero), which no de-matrixing removes. Rewriting the flag - /// would therefore return a profile that renders the caller's colour wrongly, so a triple that - /// does not signal full range is declined instead. Scale narrow-range samples to full range and - /// pass `1`. + /// `VideoFullRangeFlag` is read against those coefficients, because ITU-T H.273 §8.3 applies + /// it to Y, Cb and Cr under luma–chroma coefficients (1, 4–7, 9–15) and to R, G and B under + /// the others. Narrow range on luma–chroma signals — `9-16-9-0`, `1-13-6-0` — de-matrixes to + /// full-scale RGB, so it is built, and the tag says `1`. Narrow range under RGB coefficients + /// (0, 8, 16, 17) — §10.3's own `1-1-0-0` example — is a narrow range on the **RGB samples + /// themselves**, which no de-matrixing removes and which this profile's full-scale colorants, + /// `chad` and tone curves cannot describe, so it is declined. Scale such samples to full range + /// and pass `1`. /// /// Returns `None` when the profile cannot describe the signalling: a primaries code point /// with no chromaticities, whether unmodelled or /// [`Unspecified`](ColourPrimaries::Unspecified); a transfer code point with no ICC tone - /// curve, such as HLG (18) or Unspecified (2); or any `VideoFullRangeFlag` other than `1`. + /// curve, such as HLG (18) or Unspecified (2); narrow range under coefficients that are not + /// luma–chroma ones; or a `VideoFullRangeFlag` other than `0` or `1`. /// /// # Examples /// @@ -710,16 +737,21 @@ impl IccProfile { /// let matrixed = Cicp { matrix_coefficients: 6, ..signalled }; /// assert_eq!(IccProfile::from_cicp(matrixed), IccProfile::from_cicp(signalled)); /// - /// // Narrow range is a scaling of the RGB samples this profile shape does not perform. - /// let narrow = Cicp { video_full_range_flag: 0, ..signalled }; - /// assert!(IccProfile::from_cicp(narrow).is_none()); + /// // Narrow range on Y/Cb/Cr de-matrixes to full-scale RGB: the same profile … + /// let narrow_ycbcr = Cicp { video_full_range_flag: 0, ..matrixed }; + /// assert_eq!(IccProfile::from_cicp(narrow_ycbcr), IccProfile::from_cicp(signalled)); + /// + /// // … but narrow range on the RGB samples themselves is a scaling this profile does not + /// // perform. + /// let narrow_rgb = Cicp { video_full_range_flag: 0, ..signalled }; + /// assert!(IccProfile::from_cicp(narrow_rgb).is_none()); /// /// // "Unspecified" primaries name no chromaticities, so no profile can be built. /// assert!(IccProfile::from_cicp(Cicp { colour_primaries: 2, ..signalled }).is_none()); /// ``` #[must_use] pub fn from_cicp(cicp: Cicp) -> Option { - if cicp.video_full_range_flag != FULL_RANGE { + if !rgb_is_full_range(cicp) { return None; } let primaries = ColourPrimaries::from_code_point(u16::from(cicp.colour_primaries))?; @@ -1084,12 +1116,13 @@ mod tests { } } - /// Full range is a precondition of `from_cicp`, not a field it normalises: every other - /// `VideoFullRangeFlag` byte is declined. + /// Under RGB coefficients, full range is a precondition of `from_cicp`, not a field it + /// normalises: every other `VideoFullRangeFlag` byte is declined. Under luma–chroma + /// coefficients every byte but `0` and `1` is declined too. /// - /// The complement is swept rather than sampled at `0`, because the guard is a comparison and - /// an ordering mutation of it (`<`, `>`) leaves one side of `1` still admitted. The control at - /// `1` builds from the same axes, so the axes cannot be what any rejection is about. + /// The complement is swept rather than sampled at `0`, so a guard that admitted any byte + /// beyond the two H.273 defines is seen. The control at `1` builds from the same axes, so the + /// axes cannot be what any rejection is about. #[test] fn a_cicp_triple_that_is_not_full_range_is_declined() { let full = Cicp { @@ -1112,8 +1145,57 @@ mod tests { ..full }), None, - "video_full_range_flag {flag}" + "RGB coefficients, video_full_range_flag {flag}" ); + if flag != 0 { + assert_eq!( + IccProfile::from_cicp(Cicp { + matrix_coefficients: 9, + video_full_range_flag: flag, + ..full + }), + None, + "luma–chroma coefficients, video_full_range_flag {flag}" + ); + } + } + } + + /// Narrow range is built exactly when H.273 §8.3 puts it on Y, Cb and Cr — equations 30 to + /// 32's list, 1, 4–7 and 9–15 — and then yields the full-range profile, whose `cicpType` + /// says `1`: de-matrixing a narrow-range luma–chroma signal gives full-scale RGB, and that is + /// what the profile describes. Every other coefficient is declined: 0, 8, 16 and 17 put the + /// narrow range on the RGB samples, 2 leaves it unknown, and the rest are reserved. + /// + /// The list is restated here rather than read back from [`LUMA_CHROMA_MATRICES`], and the + /// whole byte range is swept, so a coefficient wrongly admitted or wrongly left out fails + /// here — the or-pattern-like membership test has no mutant for either. + #[test] + fn narrow_range_builds_only_under_luma_chroma_coefficients() { + const EQUATIONS_30_TO_32: [u8; 12] = [1, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15]; + let full = Cicp { + colour_primaries: 9, + transfer_characteristics: 16, + matrix_coefficients: 0, + video_full_range_flag: FULL_RANGE, + }; + let want = IccProfile::from_cicp(full).expect("the full-range control builds"); + for matrix in 0..=u8::MAX { + let got = IccProfile::from_cicp(Cicp { + matrix_coefficients: matrix, + video_full_range_flag: 0, + ..full + }); + if EQUATIONS_30_TO_32.contains(&matrix) { + assert_eq!(got.as_ref(), Some(&want), "matrix {matrix}, narrow range"); + assert_eq!( + got.as_ref().and_then(|p| p.get(KnownTag::Cicp)), + Some(&TagData::Cicp(full)), + "matrix {matrix}: the tag describes the full-range RGB" + ); + } else { + assert_eq!(got, None, "matrix {matrix}, narrow range"); + } } } From fda06ac9a79e2921a14346ac6588ffe7cfee52b2 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 24 Sep 2026 23:30:02 -0400 Subject: [PATCH 30/31] docs(icc): say what actually declines a transfer code point unrepresentable_signalling_is_rejected said a transfer gamut-color implements no curve for cannot be built. Transfers are keyed on the raw code point, and code point 1 has no gamut-color curve yet builds; what declines one is having no ICC tone curve here. --- crates/gamut-icc/src/builtin.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 1bc54dfa..3abc91af 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -1034,8 +1034,9 @@ mod tests { } } - /// A transfer `gamut-color` implements no curve for cannot be built, and neither can - /// unmodelled or unspecified primaries. Each rejection is asserted at an input that isolates + /// A transfer code point this module has no ICC tone curve for cannot be built — whether or + /// not `gamut-color` evaluates it, since transfers are keyed on the raw code point — and + /// neither can primaries with no chromaticities, unmodelled or unspecified. Each rejection is asserted at an input that isolates /// it: the primaries are valid when the transfer is the reason, and vice versa. #[test] fn unrepresentable_signalling_is_rejected() { From c3af92b2a07cd1f20e10cb57747c789eb803914d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 24 Sep 2026 23:32:03 -0400 Subject: [PATCH 31/31] style(icc): format the lcms2 cicp synthesiser comparison --- crates/gamut-icc/src/builtin.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/gamut-icc/src/builtin.rs b/crates/gamut-icc/src/builtin.rs index 3abc91af..ae93ab39 100644 --- a/crates/gamut-icc/src/builtin.rs +++ b/crates/gamut-icc/src/builtin.rs @@ -1571,10 +1571,9 @@ mod tests { video_full_range_flag: 1, }) .expect("buildable signalling"); - let lcms = IccProfile::parse( - &lcms2_oracle::cicp(primaries, transfer, 0, 1).to_bytes(), - ) - .expect("lcms2's profile parses"); + let lcms = + IccProfile::parse(&lcms2_oracle::cicp(primaries, transfer, 0, 1).to_bytes()) + .expect("lcms2's profile parses"); assert_eq!( ours.get(KnownTag::Cicp), lcms.get(KnownTag::Cicp),