Files
photocraft/crates/cms/src/builtin.rs
F4LCONandBrandon Thomas ae03e7cd66 OpenEXR/HDR export: linearize grayscale documents too (#2386) (#2448)
* OpenEXR/HDR export: linearize grayscale documents too

Fixes #2386. The EXR/HDR branch of export_flat converted RGB images to
linear sRGB but left gray ones as stored, so an sGray #808080 was written
as 0.502 instead of 0.216. Gray images are now converted from their
profile (sGray when untagged) to linear gray. An untagged grayscale
OpenEXR opens tagged linear gray (an EXR Y channel is linear luminance),
and the open policy keeps that profile without a mismatch prompt, as it
already does for linear sRGB.

* contributors: add itsF4LCON

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 13:13:40 -04:00

277 lines
11 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Built-in profiles. All are generated by this crate from published colorimetric definitions
//! (primaries, white points, transfer functions) and dedicated to the public domain (CC0-1.0),
//! so documents that embed them carry no licence obligations. See the crate README.
use std::sync::OnceLock;
use crate::Intent;
use crate::curve::{Curve, srgb_trc};
use crate::math::{self, Mat3};
use crate::pipeline::Stage;
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
/// Copyright tag text of every built-in profile.
pub const COPYRIGHT: &str = "No copyright: CC0-1.0 public domain dedication. Generated by photocraft-cms.";
/// Identifiers of the built-in profiles.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Builtin {
/// sRGB IEC 61966-2.1 (Rec. 709 primaries, D65, sRGB transfer function).
Srgb,
/// Display P3 (DCI-P3 primaries, D65, sRGB transfer function).
DisplayP3,
/// Adobe RGB (1998)-compatible: its published primaries, D65, gamma 563/256.
AdobeRgbCompat,
/// ProPhoto-compatible: ROMM RGB primaries, D50, gamma 1.8.
ProPhotoCompat,
/// sRGB primaries with a linear transfer function.
LinearSrgb,
/// Rec. 2020 primaries, D65, Rec. 709 transfer function.
Rec2020,
/// Gray, gamma 2.2.
GrayGamma22,
/// Gray with the sRGB transfer function (sGray): gray `v` looks like RGB `(v, v, v)` in sRGB.
SGray,
/// CIE L*a*b* D50 identity (device Lab = PCS Lab, ICC v4 encoding).
LabD50,
/// Synthetic coated-offset CMYK (300 % total ink, medium GCR); see [`crate::synth`].
CoatedCmyk,
}
impl Builtin {
pub const ALL: [Builtin; 10] = [
Builtin::Srgb,
Builtin::DisplayP3,
Builtin::AdobeRgbCompat,
Builtin::ProPhotoCompat,
Builtin::LinearSrgb,
Builtin::Rec2020,
Builtin::GrayGamma22,
Builtin::SGray,
Builtin::LabD50,
Builtin::CoatedCmyk,
];
/// Stable identifier used by commands and settings.
pub fn id(self) -> &'static str {
match self {
Builtin::Srgb => "srgb",
Builtin::DisplayP3 => "display-p3",
Builtin::AdobeRgbCompat => "adobe-rgb-compat",
Builtin::ProPhotoCompat => "prophoto-compat",
Builtin::LinearSrgb => "linear-srgb",
Builtin::Rec2020 => "rec2020",
Builtin::GrayGamma22 => "gray-gamma-2.2",
Builtin::SGray => "sgray",
Builtin::LabD50 => "lab-d50",
Builtin::CoatedCmyk => "coated-cmyk",
}
}
/// Profile description (the `desc` tag).
pub fn description(self) -> &'static str {
match self {
Builtin::Srgb => "sRGB IEC61966-2.1",
Builtin::DisplayP3 => "Display P3",
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)",
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)",
Builtin::LinearSrgb => "Linear sRGB (Photocraft)",
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)",
Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)",
Builtin::SGray => "sGray (sRGB tone curve, Photocraft)",
Builtin::LabD50 => "Lab D50 identity (Photocraft)",
Builtin::CoatedCmyk => crate::synth::DESCRIPTION,
}
}
/// Looks up an id (case-insensitive; a few aliases and the descriptions are accepted).
pub fn from_id(s: &str) -> Option<Builtin> {
let n: String = s.to_ascii_lowercase().chars().filter(|c| c.is_ascii_alphanumeric()).collect();
let alias = match n.as_str() {
"srgb" | "srgbiec6196621" | "srgbiec61966" => Some(Builtin::Srgb),
"p3" | "displayp3" => Some(Builtin::DisplayP3),
"adobergb" | "adobergb1998" | "adobergbcompat" | "clayrgb" => Some(Builtin::AdobeRgbCompat),
"prophoto" | "prophotorgb" | "prophotocompat" | "romm" | "rommrgb" => Some(Builtin::ProPhotoCompat),
"linearsrgb" | "srgblinear" | "linear" => Some(Builtin::LinearSrgb),
"rec2020" | "bt2020" => Some(Builtin::Rec2020),
"gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22),
"sgray" | "graysrgb" => Some(Builtin::SGray),
"lab" | "labd50" | "cielab" => Some(Builtin::LabD50),
"cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk),
_ => None,
};
alias.or_else(|| Builtin::ALL.into_iter().find(|b| b.description().eq_ignore_ascii_case(s)))
}
/// The profile (built once per process).
pub fn profile(self) -> &'static Profile {
static CELLS: [OnceLock<Profile>; 10] = [const { OnceLock::new() }; 10];
// `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`),
// so the discriminant is the cell index.
CELLS[self as usize].get_or_init(|| build(self))
}
}
/// The default profile for a colour space: sRGB, sGray, the coated CMYK profile, Lab D50.
pub fn default_for(cs: ColorSpace) -> Option<&'static Profile> {
Some(match cs {
ColorSpace::Rgb => Builtin::Srgb.profile(),
ColorSpace::Gray => Builtin::SGray.profile(),
ColorSpace::Cmyk => Builtin::CoatedCmyk.profile(),
ColorSpace::Lab => Builtin::LabD50.profile(),
_ => return None,
})
}
pub fn srgb() -> &'static Profile {
Builtin::Srgb.profile()
}
/// Gray with a linear tone curve: what a grayscale OpenEXR holds. Not a [`Builtin`], as it is
/// only assigned to such files, never offered as a working space.
pub fn linear_gray() -> &'static Profile {
static PROFILE: OnceLock<Profile> = OnceLock::new();
PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0)))
}
/// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290).
fn d65() -> [f64; 3] {
math::xy_to_xyz(math::D65_XY)
}
/// Colorant matrix adapted to D50 and quantized to s15Fixed16, with each row nudged so that
/// RGB (1, 1, 1) maps exactly to the stored D50 white (keeps neutrals neutral).
fn colorants(r: [f64; 2], g: [f64; 2], b: [f64; 2], white: [f64; 3]) -> (Mat3, Option<Mat3>) {
// Only called with the constant primaries of the built-in spaces, which are independent
// (every built-in profile is built in the tests).
#[allow(clippy::expect_used)]
let m = math::rgb_to_xyz_matrix(r, g, b, white).expect("built-in primaries are independent");
let d50 = math::d50_quantized();
let adapt = if (white[0] - math::D50[0]).abs() < 1e-3 && (white[2] - math::D50[2]).abs() < 1e-3 { None } else { Some(math::bradford(white, math::D50)) };
let m50 = match &adapt {
Some(a) => math::mul(a, &m),
None => {
// White already (nearly) D50: scale rows onto the exact PCS white.
let s = math::apply(&m, [1.0, 1.0, 1.0]);
let mut o = m;
for (i, row) in o.iter_mut().enumerate() {
for v in row.iter_mut() {
*v *= math::D50[i] / s[i];
}
}
o
}
};
let mut q = m50.map(|row| row.map(math::s15f16_round));
for (i, row) in q.iter_mut().enumerate() {
let err = d50[i] - row.iter().sum::<f64>();
let k = (0..3).max_by(|a, b| row[*a].abs().total_cmp(&row[*b].abs())).unwrap_or(0);
row[k] = math::s15f16_round(row[k] + err);
}
(q, adapt.map(|a| a.map(|row| row.map(math::s15f16_round))))
}
fn rgb(desc: &str, prim: [[f64; 2]; 3], white: [f64; 3], trc: Curve) -> Profile {
let (m, chad) = colorants(prim[0], prim[1], prim[2], white);
Profile {
version: (4, 0x30),
class: ProfileClass::Display,
color_space: ColorSpace::Rgb,
pcs: Pcs::Xyz,
rendering_intent: Intent::Perceptual,
description: desc.into(),
copyright: COPYRIGHT.into(),
white_point: math::d50_quantized(),
black_point: None,
chad,
matrix: Some(m),
trc: Some([trc.clone(), trc.clone(), trc]),
gray_trc: None,
a2b: [None, None, None],
b2a: [None, None, None],
bytes: None,
hash: Default::default(),
}
.with_encoded_bytes()
}
fn gray(desc: &str, trc: Curve) -> Profile {
Profile {
version: (4, 0x30),
class: ProfileClass::Display,
color_space: ColorSpace::Gray,
pcs: Pcs::Xyz,
rendering_intent: Intent::Perceptual,
description: desc.into(),
copyright: COPYRIGHT.into(),
white_point: math::d50_quantized(),
black_point: None,
chad: None,
matrix: None,
trc: None,
gray_trc: Some(trc),
a2b: [None, None, None],
b2a: [None, None, None],
bytes: None,
hash: Default::default(),
}
.with_encoded_bytes()
}
fn lab() -> Profile {
let ident = || Lut { kind: LutKind::Ab, inputs: 3, outputs: 3, stages: vec![Stage::Curves(vec![Curve::Identity; 3])] };
Profile {
version: (4, 0x30),
class: ProfileClass::ColorSpace,
color_space: ColorSpace::Lab,
pcs: Pcs::Lab,
rendering_intent: Intent::Perceptual,
description: Builtin::LabD50.description().into(),
copyright: COPYRIGHT.into(),
white_point: math::d50_quantized(),
black_point: None,
chad: None,
matrix: None,
trc: None,
gray_trc: None,
a2b: [Some(ident()), None, None],
b2a: [Some(ident()), None, None],
bytes: None,
hash: Default::default(),
}
.with_encoded_bytes()
}
/// The shipped synthetic CMYK profile (generated by [`crate::synth::coated_cmyk`]; regenerate
/// with `PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release regen`).
pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc");
fn build(b: Builtin) -> Profile {
let (srgb_p, p3, adobe, romm, bt2020) = (
[[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]],
[[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
[[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]],
[[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]],
[[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
);
match b {
Builtin::Srgb => rgb(b.description(), srgb_p, d65(), srgb_trc()),
Builtin::DisplayP3 => rgb(b.description(), p3, d65(), srgb_trc()),
Builtin::AdobeRgbCompat => rgb(b.description(), adobe, d65(), Curve::Gamma(563.0 / 256.0)),
Builtin::ProPhotoCompat => rgb(b.description(), romm, math::D50, Curve::Gamma(1.8)),
Builtin::LinearSrgb => rgb(b.description(), srgb_p, d65(), Curve::Gamma(1.0)),
Builtin::Rec2020 => {
// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
let a = 1.099;
let trc = Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] };
rgb(b.description(), bt2020, d65(), trc)
}
Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)),
Builtin::SGray => gray(b.description(), srgb_trc()),
Builtin::LabD50 => lab(),
// The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test); rebuild
// them rather than crash if they ever fail to parse.
Builtin::CoatedCmyk => Profile::parse(COATED_CMYK_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()),
}
}