Merge branch 'colour-managed-export'

This commit is contained in:
2026-08-17 09:28:22 +02:00
11 changed files with 1672 additions and 66 deletions
+13 -5
View File
@@ -13,7 +13,7 @@ use std::path::PathBuf;
use dr_export::{export, Frame, NameContext};
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
use dr_pipeline::EditGraph;
use dr_types::{ExportFormat, ExportSettings, OutputSharpening, SizingMode};
use dr_types::{ColourSpace, ExportFormat, ExportSettings, OutputSharpening, SizingMode};
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info,wgpu=warn"))
@@ -72,17 +72,24 @@ fn main() {
let (fw, fh) = graph.output_size(sw, sh);
println!("source {sw}×{sh}, framed {fw}×{fh}");
// The output space is chosen *here*, before the render, because that is
// where it takes effect: the primaries conversion and the encode are the
// last two lines of the generated shader (FR-EXP-2). Asking for it at the
// encoder would be too late — the pixels would already be clipped.
let space = ColourSpace::DisplayP3;
let mut adjust = AdjustPass::new(&ctx);
let shader = graph.compose();
let shader = graph.compose_for(space);
let t = std::time::Instant::now();
adjust.render(&source, &shader, fw, fh).expect("render");
let (pixels, w, h) = adjust.export_pixels().expect("read back");
println!(
"rendered {w}×{h} in {:.0} ms",
t.elapsed().as_secs_f32() * 1000.0
"rendered {w}×{h} in {:.0} ms as {}",
t.elapsed().as_secs_f32() * 1000.0,
space.label()
);
let frame = Frame::new(w, h, pixels).expect("well-formed frame");
let frame = Frame::in_space(w, h, pixels, space).expect("well-formed frame");
let stem = input
.file_stem()
@@ -121,6 +128,7 @@ fn main() {
format,
sizing,
sharpening,
colour_space: space,
filename_template: "{name}-{dimensions}".into(),
..Default::default()
};
+225 -19
View File
@@ -1,4 +1,4 @@
//! TRACES: FR-EXP-1 | FR-EXP-8
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-8
//! The encoders.
//!
//! All four write RGB, not RGBA. The pipeline produces an opaque frame — no
@@ -7,6 +7,18 @@
//! the same value in every pixel, and a PNG that some tools then treat as
//! having meaningful transparency.
//!
//! # The colour profile
//!
//! All four embed one, in the place their container puts it: a JPEG APP2
//! segment, a PNG `iCCP` chunk, TIFF tag 34675. Encoding correctly and
//! labelling correctly are separate jobs and both are required — pixels in
//! Display P3 with no profile are read as sRGB and come out desaturated,
//! which is a worse outcome than not offering the space at all.
//!
//! sRGB gets one too, rather than relying on it being everyone's default.
//! Untagged is not the same as tagged sRGB: it means "guess", and the guess
//! differs between a browser, a phone gallery and a print shop.
//!
//! # Metadata
//!
//! Nothing is written. `strip_location` defaults to on (FR-EXP-8) and this
@@ -22,20 +34,25 @@
use dr_types::{ExportFormat, ExportSettings};
use crate::ExportError;
use crate::{icc, ExportError};
/// Encode a resized, sharpened RGBA buffer to the requested format.
///
/// The buffer is already encoded into `settings.colour_space` — that happened
/// in the shader, at the only point where the unclipped colour still existed.
/// All that is left here is to say so.
pub fn encode(
rgba: &[u8],
width: u32,
height: u32,
settings: &ExportSettings,
) -> Result<Vec<u8>, ExportError> {
let profile = icc::profile(settings.colour_space);
match settings.format {
ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality),
ExportFormat::Png => png(rgba, width, height),
ExportFormat::Tiff8 => tiff8(rgba, width, height),
ExportFormat::Tiff16 => tiff16(rgba, width, height),
ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality, &profile),
ExportFormat::Png => png(rgba, width, height, &profile),
ExportFormat::Tiff8 => tiff8(rgba, width, height, &profile),
ExportFormat::Tiff16 => tiff16(rgba, width, height, &profile),
other => Err(ExportError::FormatUnsupported(other)),
}
}
@@ -49,9 +66,20 @@ fn rgb(rgba: &[u8]) -> Vec<u8> {
out
}
fn jpeg(rgba: &[u8], width: u32, height: u32, quality: u8) -> Result<Vec<u8>, ExportError> {
fn jpeg(
rgba: &[u8],
width: u32,
height: u32,
quality: u8,
profile: &[u8],
) -> Result<Vec<u8>, ExportError> {
let mut bytes = Vec::new();
let encoder = jpeg_encoder::Encoder::new(&mut bytes, quality);
let mut encoder = jpeg_encoder::Encoder::new(&mut bytes, quality);
// Splits across APP2 segments itself if it has to. The profiles this crate
// generates fit in one, but the branch is the encoder's rather than ours.
encoder
.add_icc_profile(profile)
.map_err(|e| ExportError::Encode(e.to_string()))?;
encoder
.encode(
&rgb(rgba),
@@ -63,12 +91,21 @@ fn jpeg(rgba: &[u8], width: u32, height: u32, quality: u8) -> Result<Vec<u8>, Ex
Ok(bytes)
}
fn png(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8>, ExportError> {
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, width, height);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
// Built through `Info` rather than the setters, because the profile is
// the one field `png::Encoder` has no setter for. The `sRGB` chunk is
// deliberately left unset: the crate writes `iCCP` only in its
// absence, and an sRGB chunk beside a P3 profile is a contradiction a
// reader has to pick a side of.
let mut info = png::Info::with_size(width, height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Eight;
info.icc_profile = Some(std::borrow::Cow::Borrowed(profile));
let encoder = png::Encoder::with_info(&mut bytes, info)
.map_err(|e| ExportError::Encode(e.to_string()))?;
let mut writer = encoder
.write_header()
.map_err(|e| ExportError::Encode(e.to_string()))?;
@@ -82,18 +119,63 @@ fn png(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
Ok(bytes)
}
fn tiff8(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
/// The ICC profile as a TIFF tag value.
///
/// A newtype only because the tag's field type is `UNDEFINED` (7) and the
/// `tiff` crate maps a plain `&[u8]` to `BYTE` (1). Both are single bytes and
/// most readers do not look, but libtiff declares `TIFFTAG_ICCPROFILE` as
/// undefined and a strict reader is entitled to agree with it.
struct IccTag<'a>(&'a [u8]);
impl tiff::encoder::TiffValue for IccTag<'_> {
const BYTE_LEN: u8 = 1;
const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED;
fn count(&self) -> usize {
self.0.len()
}
fn data(&self) -> std::borrow::Cow<'_, [u8]> {
std::borrow::Cow::Borrowed(self.0)
}
}
/// Tag 34675, `InterColourProfile`. Not in the `tiff` crate's `Tag` enum.
const TAG_ICC_PROFILE: u16 = 34675;
fn tiff8(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8>, ExportError> {
use tiff::encoder::{colortype, TiffEncoder};
let mut bytes = std::io::Cursor::new(Vec::new());
let mut encoder =
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
encoder
.write_image::<colortype::RGB8>(width, height, &rgb(rgba))
let mut image = encoder
.new_image::<colortype::RGB8>(width, height)
.map_err(|e| ExportError::Encode(e.to_string()))?;
tag_profile(image.encoder(), profile)?;
image
.write_data(&rgb(rgba))
.map_err(|e| ExportError::Encode(e.to_string()))?;
Ok(bytes.into_inner())
}
/// Add the profile tag to a directory being built.
///
/// Shared by both TIFF widths, and separate from them because `write_image`
/// cannot be used once there is a tag to add — the directory has to be opened,
/// written into, and closed by hand.
fn tag_profile<W, K>(
dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>,
profile: &[u8],
) -> Result<(), ExportError>
where
W: std::io::Write + std::io::Seek,
K: tiff::encoder::TiffKind,
{
dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), IccTag(profile))
.map_err(|e| ExportError::Encode(e.to_string()))
}
/// 16-bit TIFF, for work continuing in another editor.
///
/// **Honest about what it carries.** The adjust pass renders to an 8-bit
@@ -108,7 +190,7 @@ fn tiff8(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
/// one: the composer has to be told what format to write, and export has to
/// ask for the wide one (FR-EXP-9). Until then this is a container promotion,
/// which is still the right thing to hand an editor that works in 16-bit.
fn tiff16(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
fn tiff16(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8>, ExportError> {
use tiff::encoder::{colortype, TiffEncoder};
// `x * 257` rather than `x << 8`: it maps 255 to 65535 exactly, where the
@@ -118,8 +200,12 @@ fn tiff16(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError>
let mut bytes = std::io::Cursor::new(Vec::new());
let mut encoder =
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
encoder
.write_image::<colortype::RGB16>(width, height, &wide)
let mut image = encoder
.new_image::<colortype::RGB16>(width, height)
.map_err(|e| ExportError::Encode(e.to_string()))?;
tag_profile(image.encoder(), profile)?;
image
.write_data(&wide)
.map_err(|e| ExportError::Encode(e.to_string()))?;
Ok(bytes.into_inner())
}
@@ -127,6 +213,7 @@ fn tiff16(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError>
#[cfg(test)]
mod tests {
use super::*;
use dr_types::ColourSpace;
#[test]
fn alpha_is_dropped_before_encoding() {
@@ -155,7 +242,7 @@ mod tests {
0, 0, 255, 255, // blue
10, 20, 30, 255,
];
let bytes = png(&rgba, 2, 2).unwrap();
let bytes = png(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap();
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
let mut reader = decoder.read_info().unwrap();
@@ -166,4 +253,123 @@ mod tests {
assert_eq!(info.color_type, png::ColorType::Rgb);
assert_eq!(&out[..12], &[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]);
}
/// A flat frame, for the tests that care only about what surrounds the
/// pixels.
fn flat(w: u32, h: u32) -> Vec<u8> {
vec![128; (w * h * 4) as usize]
}
#[test]
fn a_png_carries_a_profile_a_decoder_gets_back_intact() {
// Read out through the PNG decoder, so this exercises the iCCP
// chunk's deflate round-trip rather than asserting the encoder was
// called. A truncated or mis-deflated profile is one a reader
// discards silently, leaving the file to be guessed at as sRGB.
for space in ColourSpace::ALL {
let want = icc::profile(space);
let bytes = png(&flat(4, 4), 4, 4, &want).unwrap();
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
let reader = decoder.read_info().unwrap();
let got = reader
.info()
.icc_profile
.as_ref()
.unwrap_or_else(|| panic!("{space:?} PNG carries no profile"));
assert_eq!(got.as_ref(), want.as_slice(), "{space:?}");
}
}
#[test]
fn a_jpeg_carries_its_profile_in_a_well_formed_app2_segment() {
// The APP2 form is exacting: the marker, a length, the string
// "ICC_PROFILE\0", then a chunk index and count before the payload.
// A reader that does not find that header ignores the segment, so
// walking it here is the only way to know the file is really tagged.
for space in ColourSpace::ALL {
let want = icc::profile(space);
let bytes = jpeg(&flat(4, 4), 4, 4, 90, &want).unwrap();
let got = jpeg_icc(&bytes)
.unwrap_or_else(|| panic!("{space:?} JPEG has no ICC_PROFILE segment"));
assert_eq!(got, want, "{space:?}");
}
}
/// Walk a JPEG's marker segments and reassemble the ICC profile.
///
/// Hand-rolled because `zune-jpeg` decodes pixels and this is about what
/// travels beside them.
fn jpeg_icc(bytes: &[u8]) -> Option<Vec<u8>> {
const TAG: &[u8] = b"ICC_PROFILE\0";
let mut out = Vec::new();
let mut i = 2; // past the SOI
while i + 4 <= bytes.len() {
if bytes[i] != 0xFF {
return None;
}
let marker = bytes[i + 1];
// Start of scan: entropy-coded data follows and there are no more
// parseable segments.
if marker == 0xDA {
break;
}
let len = usize::from(u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]));
let payload = &bytes[i + 4..i + 2 + len];
if marker == 0xE2 && payload.starts_with(TAG) {
// Two bytes of chunk index and count follow the tag.
out.extend_from_slice(&payload[TAG.len() + 2..]);
}
i += 2 + len;
}
(!out.is_empty()).then_some(out)
}
#[test]
fn both_tiff_widths_carry_the_profile_in_tag_34675() {
// Read back through the `tiff` decoder's own tag lookup. Writing the
// tag with the wrong field type or a stale offset produces a file that
// still opens — with no profile, and therefore the wrong colours.
use tiff::decoder::Decoder;
use tiff::tags::Tag;
for space in ColourSpace::ALL {
let want = icc::profile(space);
for (label, bytes) in [
("8-bit", tiff8(&flat(4, 4), 4, 4, &want).unwrap()),
("16-bit", tiff16(&flat(4, 4), 4, 4, &want).unwrap()),
] {
let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode");
let got = d
.get_tag_u8_vec(Tag::Unknown(TAG_ICC_PROFILE))
.unwrap_or_else(|e| panic!("{space:?} {label} TIFF: {e}"));
assert_eq!(got, want, "{space:?} {label}");
}
}
}
#[test]
fn a_tiff_still_decodes_to_its_pixels_with_the_extra_tag_present() {
// Adding a tag means opening the directory by hand instead of using
// `write_image`, which is the sort of change that produces a valid
// header over unreadable strips.
use tiff::decoder::{Decoder, DecodingResult};
let rgba: Vec<u8> = vec![
255, 0, 0, 255, // red
0, 255, 0, 255, // green
0, 0, 255, 255, // blue
10, 20, 30, 255,
];
let bytes = tiff8(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap();
let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode");
assert_eq!(d.dimensions().expect("dimensions"), (2, 2));
let DecodingResult::U8(pixels) = d.read_image().expect("read") else {
panic!("expected 8-bit samples");
};
assert_eq!(
&pixels[..12],
&[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]
);
}
}
+16 -5
View File
@@ -23,12 +23,23 @@ pub enum ExportError {
#[error("{} export is not supported yet", .0.label())]
FormatUnsupported(ExportFormat),
/// Asked for a colour space the pipeline does not render to.
/// TRACES: FR-EXP-2
/// The frame was rendered into one colour space and asked to be labelled
/// another.
///
/// See the note in `export`: the shader clips to sRGB before this crate
/// sees a pixel, so a wider space could only be a mislabelling.
#[error("{} export needs a pipeline that renders to it", .0.label())]
ColourSpaceUnsupported(ColourSpace),
/// Not a limitation of the encoders — all four spaces embed a correct
/// profile. It is that the conversion happens in the shader, before the
/// clip to 0..1, so a frame is in exactly one space by the time it gets
/// here. The caller composes with `EditGraph::compose_for` to change which.
#[error(
"the frame was rendered in {} but a {} file was asked for",
.rendered.label(),
.requested.label()
)]
ColourSpaceMismatch {
rendered: ColourSpace,
requested: ColourSpace,
},
#[error("encoding failed: {0}")]
Encode(String),
+483
View File
@@ -0,0 +1,483 @@
//! TRACES: FR-EXP-2
//! Minimal ICC v2 matrix/TRC profiles, generated.
//!
//! # Why generated rather than shipped
//!
//! A profile is a description of what the pixels in a file mean, and the
//! pixels here were produced by [`dr_types::colour`]'s matrices. Embedding a
//! profile downloaded from elsewhere would mean two independent statements
//! about the same space, agreeing until one of them was revised. Deriving both
//! from the same primaries makes agreement structural.
//!
//! It is also the only pure-Rust route. Little-CMS is the obvious library and
//! it is C, which the whole workspace avoids so it cross-compiles under the
//! Android NDK — the same reasoning behind rustls, bundled SQLite and the
//! Lensfun port.
//!
//! # What "minimal" leaves out
//!
//! A matrix/TRC display profile and nothing else: three colorants, three tone
//! curves, a white point and the chromatic adaptation that got it there. No
//! A2B/B2A lookup tables, no gamut tag, no named colours. That is the whole of
//! what an RGB working space *is*, and it is what every reader — a browser, an
//! operating system compositor, Photoshop — takes from a profile like this
//! one. The tags omitted describe device behaviour these spaces do not have.
//!
//! Profiles come out around 2 KB, which matters more than it sounds: a JPEG
//! carries the profile in APP2 segments capped at 64 KB each, and one that fits
//! in a single segment avoids the chunked form that some older readers
//! mishandle.
use dr_types::{ColourSpace, Transfer};
/// The ICC profile describing `space`, ready to embed.
///
/// Deterministic — the same space always produces the same bytes. Two exports
/// of the same frame must be byte-identical files, which a creation timestamp
/// read from the clock would quietly break, along with any deduplication
/// downstream of it.
pub fn profile(space: ColourSpace) -> Vec<u8> {
let colorants = space.to_pcs_xyz();
let trc = trc_curve(space.transfer());
// Sorted by signature, as the specification asks a tag table to be. Some
// readers binary-search it.
let mut tags: Vec<(&[u8; 4], Vec<u8>)> = vec![
(b"bTRC", trc.clone()),
// Columns, not rows: a colorant tag is where one primary lands in XYZ.
(b"bXYZ", xyz_type(colorants[2], colorants[5], colorants[8])),
(b"cprt", text_type(COPYRIGHT)),
(b"desc", description_type(&description(space))),
(b"gTRC", trc.clone()),
(b"gXYZ", xyz_type(colorants[1], colorants[4], colorants[7])),
(b"rTRC", trc),
(b"rXYZ", xyz_type(colorants[0], colorants[3], colorants[6])),
// The PCS illuminant itself, not the space's own white. The space's
// white is recoverable from this and `chad`, and a profile that put
// its native white here would have every reader adapt it twice.
(b"wtpt", xyz_type(PCS_D50[0], PCS_D50[1], PCS_D50[2])),
];
// Only where there is an adaptation to declare. ProPhoto is a D50 space
// already, and an identity `chad` is a tag saying nothing.
let adaptation = space.adaptation_to_pcs();
if !is_identity(&adaptation) {
tags.push((b"chad", sf32_type(&adaptation)));
}
tags.sort_by_key(|(sig, _)| **sig);
assemble(&tags)
}
/// What a colour-management dialogue will show this profile as.
///
/// Deliberately not the canonical names. "sRGB IEC61966-2.1" is the reference
/// profile, and this is not it — it is a profile derived from the same
/// primaries, which is a different and weaker claim. "Adobe RGB (1998)" is
/// additionally a name belonging to someone else. A distinct name also tells a
/// user opening the file where the profile came from, which is the question
/// they are asking when they look.
fn description(space: ColourSpace) -> String {
format!("DarkRoom {}", space.label())
}
/// The copyright tag, which ICC requires a profile to carry.
///
/// A set of chromaticity coordinates from a published specification is not
/// something to claim rights over, and a profile nobody may redistribute would
/// make the files carrying it awkward to share — which is the whole purpose of
/// an export.
const COPYRIGHT: &str = "Generated by DarkRoom. No rights reserved.";
/// The profile connection space illuminant, as s15Fixed16 exactly.
const PCS_D50: [f32; 3] = [0.9642, 1.0, 0.8249];
/// Samples in a tabulated tone curve.
///
/// 1024 is what the reference sRGB profiles use. The curve is interpolated
/// linearly between samples, so this is far finer than the 8-bit values it
/// describes; halving it would still be adequate and would save a kilobyte
/// nobody is counting.
const TRC_SAMPLES: usize = 1024;
/// A tone reproduction curve for the space's transfer function.
///
/// ICC curves run *towards* the connection space — device value to linear —
/// which is the opposite direction from the shader's final encode. Getting it
/// backwards produces a file that looks washed out or crushed by exactly the
/// amount the curve bends.
fn trc_curve(transfer: Transfer) -> Vec<u8> {
// A pure power curve has an exact representation: a single u8Fixed8
// gamma. Adobe RGB's 563/256 lands on it precisely, where a 1024-entry
// table would be an approximation of a number the format can hold.
if let Transfer::Gamma(g) = transfer {
let mut out = tag_header(b"curv");
out.extend_from_slice(&1u32.to_be_bytes());
out.extend_from_slice(&((g * 256.0).round() as u16).to_be_bytes());
return out;
}
let mut out = tag_header(b"curv");
out.extend_from_slice(&(TRC_SAMPLES as u32).to_be_bytes());
for i in 0..TRC_SAMPLES {
let device = i as f32 / (TRC_SAMPLES - 1) as f32;
let linear = transfer.decode(device);
out.extend_from_slice(&((linear * 65535.0).round() as u16).to_be_bytes());
}
out
}
/// An `XYZType` tag: one colour in the connection space.
fn xyz_type(x: f32, y: f32, z: f32) -> Vec<u8> {
let mut out = tag_header(b"XYZ ");
for v in [x, y, z] {
out.extend_from_slice(&s15_fixed16(v).to_be_bytes());
}
out
}
/// An `s15Fixed16ArrayType` tag, which is how `chad` is stored.
fn sf32_type(m: &[f32; 9]) -> Vec<u8> {
let mut out = tag_header(b"sf32");
for v in m {
out.extend_from_slice(&s15_fixed16(*v).to_be_bytes());
}
out
}
/// A `textType` tag: ASCII with a terminating NUL.
fn text_type(s: &str) -> Vec<u8> {
let mut out = tag_header(b"text");
out.extend_from_slice(s.as_bytes());
out.push(0);
out
}
/// A `textDescriptionType` tag — the v2 profile's name field.
///
/// Baroque, and not optional: v2 has no plain `mluc`, and the ASCII string is
/// followed by empty Unicode and ScriptCode blocks that a reader will walk
/// whether or not they hold anything. The 67-byte Macintosh field is fixed
/// width by specification, so it is written out zeroed rather than omitted.
fn description_type(s: &str) -> Vec<u8> {
let ascii = s.as_bytes();
let mut out = tag_header(b"desc");
out.extend_from_slice(&(ascii.len() as u32 + 1).to_be_bytes());
out.extend_from_slice(ascii);
out.push(0);
// Unicode language code, then Unicode character count: none of either.
out.extend_from_slice(&[0; 8]);
// ScriptCode code (u16), length (u8), and the fixed 67-byte field.
out.extend_from_slice(&[0; 3]);
out.extend_from_slice(&[0; 67]);
out
}
/// Every tag element opens with its type signature and four reserved bytes.
fn tag_header(sig: &[u8; 4]) -> Vec<u8> {
let mut out = Vec::from(*sig);
out.extend_from_slice(&[0; 4]);
out
}
/// ICC's fixed-point number: 16 integer bits, 16 fractional.
fn s15_fixed16(v: f32) -> i32 {
(f64::from(v) * 65536.0).round() as i32
}
fn is_identity(m: &[f32; 9]) -> bool {
const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
// One step of s15Fixed16, the format the matrix would be stored in. Below
// that it *is* the identity — ProPhoto's own white and the PCS illuminant
// differ in the sixth decimal place, and a `chad` recording that would be
// nine copies of 1.0000 and 0.0000 dressed up as information.
const STEP: f32 = 1.0 / 65536.0;
m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < STEP)
}
/// Header, tag table, and the tag data, with the size written back in.
fn assemble(tags: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut out = header();
out.extend_from_slice(&(tags.len() as u32).to_be_bytes());
let table_at = out.len();
out.resize(table_at + tags.len() * 12, 0);
for (i, (sig, data)) in tags.iter().enumerate() {
// Identical elements share one copy, which the specification allows
// explicitly. The three tone curves of a grey-balanced space are the
// same 2 KB table, so this is two thirds of the profile.
let offset = find(&out, data).unwrap_or_else(|| {
let at = out.len();
out.extend_from_slice(data);
// Every element starts on a four-byte boundary.
while !out.len().is_multiple_of(4) {
out.push(0);
}
at
});
let entry = table_at + i * 12;
out[entry..entry + 4].copy_from_slice(*sig);
out[entry + 4..entry + 8].copy_from_slice(&(offset as u32).to_be_bytes());
out[entry + 8..entry + 12].copy_from_slice(&(data.len() as u32).to_be_bytes());
}
let size = out.len() as u32;
out[0..4].copy_from_slice(&size.to_be_bytes());
out
}
/// Where `needle` already sits in `haystack`, if it does.
///
/// Only ever called with tag elements, which begin on four-byte boundaries and
/// start with a type signature — so a match cannot be a coincidental overlap
/// of two other tags' bytes.
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|w| w == needle)
.filter(|at| at.is_multiple_of(4))
}
/// The fixed 128-byte profile header.
fn header() -> Vec<u8> {
let mut h = Vec::with_capacity(128);
// Size, filled in once the profile is complete.
h.extend_from_slice(&[0; 4]);
// Preferred CMM: no preference.
h.extend_from_slice(&[0; 4]);
// Version 2.1.0. v2 rather than v4 because it is what every reader
// handles, and because nothing here needs a v4 tag type.
h.extend_from_slice(&[0x02, 0x10, 0x00, 0x00]);
h.extend_from_slice(b"mntr");
h.extend_from_slice(b"RGB ");
h.extend_from_slice(b"XYZ ");
// Creation date. Fixed, for the determinism the module docs describe.
for field in [2025u16, 1, 1, 0, 0, 0] {
h.extend_from_slice(&field.to_be_bytes());
}
h.extend_from_slice(b"acsp");
// Primary platform, flags, manufacturer, model, attributes: unspecified.
h.extend_from_slice(&[0; 24]);
// Rendering intent: perceptual, as the reference RGB working-space
// profiles declare. For a matrix/TRC profile the field is advisory —
// there is only one transform in here to apply.
h.extend_from_slice(&[0; 4]);
for v in PCS_D50 {
h.extend_from_slice(&s15_fixed16(v).to_be_bytes());
}
// Creator, profile ID, and the reserved tail.
h.extend_from_slice(&[0; 4]);
h.extend_from_slice(&[0; 16]);
h.extend_from_slice(&[0; 28]);
debug_assert_eq!(h.len(), 128);
h
}
#[cfg(test)]
mod tests {
use super::*;
/// A tag's element data, located through the profile's own tag table —
/// so these tests read the profile the way a colour engine would rather
/// than the way it was written.
fn tag<'a>(profile: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> {
let count = u32::from_be_bytes(profile[128..132].try_into().unwrap()) as usize;
for i in 0..count {
let at = 132 + i * 12;
if &profile[at..at + 4] == want {
let off = u32::from_be_bytes(profile[at + 4..at + 8].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(profile[at + 8..at + 12].try_into().unwrap()) as usize;
return Some(&profile[off..off + len]);
}
}
None
}
fn xyz(data: &[u8]) -> [f32; 3] {
let read =
|at: usize| i32::from_be_bytes(data[at..at + 4].try_into().unwrap()) as f32 / 65536.0;
[read(8), read(12), read(16)]
}
#[test]
fn a_profile_declares_its_own_length() {
// The first field a reader trusts. A profile whose header says it is
// longer than the buffer is one a strict parser rejects outright and a
// lax one reads past the end of.
for space in ColourSpace::ALL {
let p = profile(space);
let declared = u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize;
assert_eq!(declared, p.len(), "{space:?}");
}
}
#[test]
fn a_profile_carries_the_signature_that_identifies_it_as_one() {
// `acsp` at offset 36 is how every reader recognises an ICC profile.
for space in ColourSpace::ALL {
assert_eq!(&profile(space)[36..40], b"acsp", "{space:?}");
}
}
#[test]
fn every_tag_lies_inside_the_profile_and_on_a_boundary() {
// A tag table is offsets and lengths, and nothing checks them for us.
// An off-by-four here produces a profile that parses as far as the
// tag a reader happens to want.
for space in ColourSpace::ALL {
let p = profile(space);
let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize;
for i in 0..count {
let at = 132 + i * 12;
let off = u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(p[at + 8..at + 12].try_into().unwrap()) as usize;
assert!(off.is_multiple_of(4), "{space:?} tag {i} starts at {off}");
assert!(off + len <= p.len(), "{space:?} tag {i} runs off the end");
}
}
}
#[test]
fn every_profile_carries_the_tags_a_matrix_trc_profile_requires() {
// The ICC v2 required set for a display profile. A reader missing any
// one of these falls back to assuming sRGB, which is the silent
// failure this whole feature exists to prevent.
for space in ColourSpace::ALL {
let p = profile(space);
for required in [
b"desc", b"cprt", b"wtpt", b"rXYZ", b"gXYZ", b"bXYZ", b"rTRC", b"gTRC", b"bTRC",
] {
assert!(tag(&p, required).is_some(), "{space:?} has no {required:?}");
}
}
}
#[test]
fn the_colorants_are_the_ones_the_shader_encoded_with() {
// The property the file's honesty rests on. The composer converts the
// pixels with `to_pcs_xyz`'s primaries; if the profile described any
// others the file would be a precise, confident lie.
for space in ColourSpace::ALL {
let p = profile(space);
let want = space.to_pcs_xyz();
for (i, sig) in [b"rXYZ", b"gXYZ", b"bXYZ"].into_iter().enumerate() {
let got = xyz(tag(&p, sig).expect("colorant"));
for (row, g) in got.iter().enumerate() {
let expected = want[row * 3 + i];
assert!(
(g - expected).abs() < 1e-4,
"{space:?} {sig:?} row {row}: profile says {g}, shader used {expected}"
);
}
}
}
}
#[test]
fn the_white_point_is_the_connection_space_illuminant() {
// Not the space's own white. ProPhoto's is D50 anyway, but P3's is
// D65, and a profile advertising D65 as its media white would have
// every neutral adapted a second time.
for space in ColourSpace::ALL {
let got = xyz(tag(&profile(space), b"wtpt").expect("wtpt"));
for (i, want) in PCS_D50.iter().enumerate() {
assert!((got[i] - want).abs() < 1e-4, "{space:?} white {i}: {got:?}");
}
}
}
#[test]
fn a_tabulated_curve_reproduces_the_transfer_function_it_came_from() {
// Read back out of the profile and compared against the function the
// shader encodes with. The curve runs device-to-linear, and writing it
// the other way round would still produce a monotonic curve of the
// right length — this is what catches the direction.
for space in [ColourSpace::Srgb, ColourSpace::ProPhoto] {
let p = profile(space);
let curve = tag(&p, b"rTRC").expect("rTRC");
let count = u32::from_be_bytes(curve[8..12].try_into().unwrap()) as usize;
assert_eq!(count, TRC_SAMPLES, "{space:?}");
let transfer = space.transfer();
for i in [0, 1, count / 4, count / 2, count - 1] {
let at = 12 + i * 2;
let got =
f32::from(u16::from_be_bytes(curve[at..at + 2].try_into().unwrap())) / 65535.0;
let want = transfer.decode(i as f32 / (count - 1) as f32);
assert!(
(got - want).abs() < 1e-4,
"{space:?} sample {i}: profile {got}, transfer {want}"
);
}
}
}
#[test]
fn adobe_rgb_stores_its_gamma_exactly_rather_than_sampling_it() {
// 563/256 is representable in a u8Fixed8, so the curve is one number.
// A 1024-entry table would approximate a value the format can hold
// exactly, and would round-trip through other software as 2.2.
let p = profile(ColourSpace::AdobeRgb);
let curve = tag(&p, b"rTRC").expect("rTRC");
assert_eq!(u32::from_be_bytes(curve[8..12].try_into().unwrap()), 1);
assert_eq!(u16::from_be_bytes(curve[12..14].try_into().unwrap()), 563);
}
#[test]
fn the_three_tone_curves_share_one_copy() {
// Not a size optimisation for its own sake: it keeps the profile under
// the 64 KB a single JPEG APP2 segment holds, so the chunked form that
// older readers mishandle is never needed.
let p = profile(ColourSpace::Srgb);
let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize;
let offsets: Vec<u32> = ["rTRC", "gTRC", "bTRC"]
.iter()
.map(|sig| {
(0..count)
.map(|i| 132 + i * 12)
.find(|at| &p[*at..at + 4] == sig.as_bytes())
.map(|at| u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()))
.expect("curve present")
})
.collect();
assert_eq!(offsets[0], offsets[1]);
assert_eq!(offsets[1], offsets[2]);
assert!(p.len() < 8 * 1024, "{} bytes is too large", p.len());
}
#[test]
fn a_d65_space_declares_its_adaptation_and_a_d50_space_does_not() {
// `chad` is what lets a reader recover the space's native white from
// colorants that have already been adapted. Without it, D65 primaries
// adapted to D50 and genuine D50 primaries are the same nine numbers.
assert!(tag(&profile(ColourSpace::DisplayP3), b"chad").is_some());
assert!(
tag(&profile(ColourSpace::ProPhoto), b"chad").is_none(),
"ProPhoto is a D50 space; an identity chad says nothing"
);
}
#[test]
fn the_same_space_always_produces_the_same_bytes() {
// Two exports of one frame must be identical files. A creation
// timestamp from the clock is the obvious way to lose that.
for space in ColourSpace::ALL {
assert_eq!(profile(space), profile(space), "{space:?}");
}
}
#[test]
fn each_space_is_described_by_its_own_name() {
// A file whose profile says "sRGB" while carrying P3 pixels is exactly
// as misleading as no profile at all, and harder to notice.
for space in ColourSpace::ALL {
let p = profile(space);
let desc = tag(&p, b"desc").expect("desc");
let len = u32::from_be_bytes(desc[8..12].try_into().unwrap()) as usize;
let name = std::str::from_utf8(&desc[12..12 + len - 1]).expect("ascii");
assert_eq!(name, format!("DarkRoom {}", space.label()));
}
}
}
+78 -15
View File
@@ -1,4 +1,4 @@
//! TRACES: FR-EXP-1 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9
//! Turning a rendered frame into a file's worth of bytes.
//!
//! # What this crate is, and is not
@@ -26,6 +26,7 @@ use dr_types::{ColourSpace, ExportFormat, ExportSettings};
mod encode;
mod error;
pub mod icc;
mod name;
mod sharpen;
mod size;
@@ -36,7 +37,7 @@ pub use size::target_size;
/// A rendered frame, as the adjust pass produced it.
///
/// 8-bit RGBA, display-encoded sRGB — the format
/// 8-bit RGBA, display-encoded in [`Self::space`] — the format
/// [`dr_gpu::AdjustPass`](../dr_gpu/struct.AdjustPass.html) writes. Alpha is
/// carried but never meaningful: the pipeline writes 1.0 everywhere, and no
/// operation produces transparency.
@@ -46,10 +47,42 @@ pub struct Frame {
pub height: u32,
/// Tightly packed RGBA8, `width * height * 4` bytes.
pub rgba: Vec<u8>,
/// TRACES: FR-EXP-2
/// The space the shader encoded these pixels into.
///
/// Travels with the pixels rather than being asserted at the point of
/// encoding, because it is a fact about them and not a preference. The
/// conversion happened in the generated shader, before the clip to 0..1,
/// and nothing downstream can undo or redo it — a frame clipped to sRGB
/// has already lost whatever a wider space would have carried.
///
/// Making it a field is what lets [`export`] refuse to label a frame as
/// something it is not, rather than trusting a caller to have rendered
/// what it asked for.
pub space: ColourSpace,
}
impl Frame {
/// A frame the pipeline rendered in sRGB — what
/// [`EditGraph::compose`](../dr_pipeline/struct.EditGraph.html#method.compose)
/// produces, and so what the display path hands over.
///
/// An export in a wider space must render its own frame with
/// `compose_for` and declare it through [`Self::in_space`]. Defaulting
/// here rather than demanding the space at every call site keeps the
/// common case honest by construction: a caller that has not thought
/// about colour is describing sRGB, and sRGB is what it rendered.
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Result<Self, ExportError> {
Self::in_space(width, height, rgba, ColourSpace::Srgb)
}
/// A frame rendered into a stated colour space.
pub fn in_space(
width: u32,
height: u32,
rgba: Vec<u8>,
space: ColourSpace,
) -> Result<Self, ExportError> {
let expected = width as usize * height as usize * 4;
if rgba.len() != expected {
return Err(ExportError::FrameSize {
@@ -64,6 +97,7 @@ impl Frame {
width,
height,
rgba,
space,
})
}
}
@@ -100,17 +134,22 @@ pub fn export(
settings: &ExportSettings,
name: String,
) -> Result<Encoded, ExportError> {
// Refused rather than mislabelled. The pipeline's final stage encodes to
// sRGB and clamps to its gamut (see `encode_srgb` in the generated
// shader), so the pixels arriving here have already lost anything a wider
// space could have carried. Tagging them Display P3 would produce a file
// that claims a gamut it does not contain — worse than not offering it,
// because the claim survives into everything downstream.
// TRACES: FR-EXP-2
// Refused rather than mislabelled. Every space the settings page offers
// now works, but only if the *frame* was rendered into it: the conversion
// and the clip both happen in the generated shader, so pixels that arrive
// clipped to sRGB have already lost whatever a wider space would have
// carried, and no amount of profile-writing here brings it back.
//
// Honouring the other spaces is a pipeline change, not an encoder one:
// the shader has to be told what to encode to (FR-EXP-2).
if settings.colour_space != ColourSpace::Srgb {
return Err(ExportError::ColourSpaceUnsupported(settings.colour_space));
// The caller's fix is to compose with `EditGraph::compose_for(space)`
// before rendering. Until it does, this is an accurate error where the
// alternative would be a file that claims a gamut it does not contain —
// and that claim survives into everything downstream.
if frame.space != settings.colour_space {
return Err(ExportError::ColourSpaceMismatch {
rendered: frame.space,
requested: settings.colour_space,
});
}
if matches!(settings.format, ExportFormat::Avif | ExportFormat::JpegXl) {
@@ -247,17 +286,41 @@ mod tests {
}
#[test]
fn a_colour_space_the_pipeline_cannot_produce_is_refused_not_mislabelled() {
fn a_frame_rendered_in_one_space_is_not_labelled_another() {
// A file tagged Display P3 carrying sRGB-clipped pixels is a lie that
// survives into everything downstream. Better to fail loudly.
// survives into everything downstream. The frame carries the space it
// was rendered in precisely so this cannot be waved through.
let mut s = settings(ExportFormat::Jpeg);
s.colour_space = ColourSpace::DisplayP3;
assert!(matches!(
export(&frame(8, 8), &s, "a".into()),
Err(ExportError::ColourSpaceUnsupported(_))
Err(ExportError::ColourSpaceMismatch { .. })
));
}
#[test]
fn every_colour_space_exports_when_the_frame_was_rendered_in_it() {
// The other side of the refusal above, and what FR-EXP-2 actually
// asks for: a frame the pipeline encoded into a wide space reaches a
// file, in every format that has an encoder.
for space in ColourSpace::ALL {
for format in [
ExportFormat::Jpeg,
ExportFormat::Png,
ExportFormat::Tiff8,
ExportFormat::Tiff16,
] {
let mut s = settings(format);
s.colour_space = space;
let mut f = frame(8, 8);
f.space = space;
let out = export(&f, &s, "a".into())
.unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}"));
assert!(!out.bytes.is_empty());
}
}
}
#[test]
fn the_formats_without_an_encoder_say_so() {
for format in [ExportFormat::Avif, ExportFormat::JpegXl] {
+78
View File
@@ -1260,6 +1260,84 @@ mod tests {
);
}
#[test]
fn every_output_colour_space_renders_what_the_colorimetry_predicts() {
// TRACES: FR-EXP-2
// The shader carries constants generated from `dr_types::colour`; this
// recomputes the same conversion on the CPU and demands the GPU agree.
// A transposed matrix, a transfer function applied before the
// primaries, or a clip in the wrong place all compile perfectly and
// simply produce the wrong colour — none of which a "did it compile"
// test would notice.
//
// A saturated patch, deliberately: every one of these spaces maps a
// neutral to itself, so a grey would agree with all four.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let source = [200u8, 90, 40];
let img = jpeg_image(&ctx, source);
// The JPEG path linearises with the sRGB curve, so this is the value
// reaching the output stage.
let linear: Vec<f32> = source
.iter()
.map(|&v| dr_types::Transfer::Srgb.decode(f32::from(v) / 255.0))
.collect();
for space in dr_types::ColourSpace::ALL {
let shader = EditGraph::default_chain().compose_for(space);
let t = pass.render(&img, &shader, 16, 16).expect("render");
let got = read_centre(&ctx, t);
let m = space.from_linear_srgb();
for channel in 0..3 {
let converted = m[channel * 3] * linear[0]
+ m[channel * 3 + 1] * linear[1]
+ m[channel * 3 + 2] * linear[2];
let want = space.transfer().encode(converted.clamp(0.0, 1.0)) * 255.0;
let delta = (f32::from(got[channel]) - want).abs();
// Two levels: the pipeline stores its intermediate in f16 and
// the source itself came from an 8-bit texel, so exactness is
// not on offer. A wrong matrix is out by tens.
assert!(
delta <= 2.0,
"{space:?} channel {channel}: rendered {} against a predicted {want:.1} \
(whole pixel {got:?})",
got[channel]
);
}
}
}
#[test]
fn a_wide_gamut_render_differs_from_an_srgb_one() {
// The companion to the test above, and the one that would fail if the
// output space were accepted and then ignored: predicted values that
// happened to match sRGB's would prove nothing. A saturated red is
// several tens of levels apart in P3.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = jpeg_image(&ctx, [230, 30, 20]);
let srgb = {
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::Srgb);
let t = pass.render(&img, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let p3 = {
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::DisplayP3);
let t = pass.render(&img, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
// Less red and more green: the same colour expressed against wider
// primaries needs smaller numbers to reach it.
assert!(
p3[0] < srgb[0] && p3[1] > srgb[1],
"sRGB rendered {srgb:?} and Display P3 {p3:?}"
);
}
#[test]
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
// The two producers must be interchangeable. A mid-grey that is
+17 -2
View File
@@ -285,9 +285,24 @@ impl EditGraph {
!self.ops.iter().any(|o| o.is_active()) && !self.framing.edits_image()
}
/// Generate the fused shader for the current state.
/// Generate the fused shader for the current state, encoded to sRGB.
///
/// What the display path wants. An export that has been asked for a wider
/// space wants [`Self::compose_for`] instead, and must say so: the space
/// is baked into the shader, so a frame rendered by this one is sRGB and
/// nothing downstream can make it anything else.
pub fn compose(&self) -> ComposedShader {
compose_with_framing(&self.ops, &self.framing)
self.compose_for(dr_types::ColourSpace::Srgb)
}
/// TRACES: FR-EXP-2
/// Generate the fused shader, encoded to a chosen output space.
///
/// Not stored on the graph, because it is not part of the edit: the same
/// graph renders to the screen and to a file in the same breath, and the
/// two want different answers.
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader {
compose_with_framing(&self.ops, &self.framing, output)
}
}
+208 -19
View File
@@ -22,6 +22,8 @@
use std::fmt::Write as _;
use dr_types::{ColourSpace, Transfer};
use crate::descriptor::{OpDescriptor, ParamId, Presentation};
use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS};
@@ -152,23 +154,41 @@ const BASE_UNIFORM_FIELDS: usize = 16;
/// operation's uniforms the moment either block changes size.
pub const RESERVED_UNIFORM_FIELDS: usize = BASE_UNIFORM_FIELDS + FRAMING_UNIFORM_FIELDS;
/// Compose enabled operations into a single compute shader.
/// Compose enabled operations into a single compute shader, for the display.
///
/// Inactive operations are skipped entirely — they contribute no code, no
/// uniforms, and nothing to the structure hash.
///
/// Equivalent to [`compose_with_framing`] with neutral framing.
/// Equivalent to [`compose_with_framing`] with neutral framing and an sRGB
/// output.
pub fn compose(ops: &[Box<dyn Operation>]) -> ComposedShader {
compose_with_framing(ops, &Framing::new())
compose_with_framing(ops, &Framing::new(), ColourSpace::Srgb)
}
/// TRACES: FR-EXP-2 | FR-DSP-6
/// Compose operations and framing into a single compute shader.
///
/// Framing generates the shader's **prologue** — the map from an output pixel
/// back to a source position — where [`compose`] would emit a fixed identity
/// scale. The fused-dispatch property is unaffected: a cropped, straightened
/// edit with three adjustments is still one dispatch, one read, one write.
pub fn compose_with_framing(ops: &[Box<dyn Operation>], framing: &Framing) -> ComposedShader {
///
/// # The output space is a parameter, not a constant
///
/// `output` decides the primaries and transfer function the last two lines of
/// the shader encode into. It is passed per composition rather than held
/// anywhere because it is a property of *this render*: the same edit goes to
/// the screen in the display's space and to a file in whatever the export asks
/// for, and neither is more authoritative than the other.
///
/// It also enters the structure hash, so the two do not collide in the
/// pipeline cache — a screen render and a Display P3 export are different
/// shaders, however identical their sliders.
pub fn compose_with_framing(
ops: &[Box<dyn Operation>],
framing: &Framing,
output: ColourSpace,
) -> ComposedShader {
let active: Vec<&dyn Operation> = ops
.iter()
.map(|o| o.as_ref())
@@ -271,6 +291,9 @@ pub fn compose_with_framing(ops: &[Box<dyn Operation>], framing: &Framing) -> Co
""
};
let to_output = primaries_conversion(output);
let encode_output = encode_output_fn(output);
let source = format!(
"// GENERATED — do not edit.
//
@@ -286,17 +309,8 @@ struct Params {{
@group(0) @binding(1) var<uniform> u: Params;
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
{sampler_helper}{helper_src}// Linear sRGB to the display transfer function.
//
// The one place quantisation happens: everything above runs in linear f16,
// and this is the final encode (ARCH §5.2).
fn encode_srgb(c: vec3<f32>) -> vec3<f32> {{
let lo = c * 12.92;
let hi = 1.055 * pow(max(c, vec3<f32>(0.0031308)), vec3<f32>(1.0 / 2.4)) - 0.055;
return select(hi, lo, c <= vec3<f32>(0.0031308));
}}
// The inverse, for sources that arrive already display-encoded.
{sampler_helper}{helper_src}{encode_output}
// Display-encoded sRGB back to linear, for sources that arrive that way.
//
// A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded
// where the demosaicer's are linear. Every operation below assumes linear
@@ -345,10 +359,12 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
dot(u.cam_to_srgb_1.rgb, c),
dot(u.cam_to_srgb_2.rgb, c),
);
// Clip to the display gamut and encode.
{to_output}
// Clip to the output gamut and encode. The clip is last for the reason the
// matrix above is: a colour outside sRGB is still inside a wider space, and
// clipping before the conversion would throw it away for no one's benefit.
c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(encode_srgb(c), 1.0));
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(encode_output(c), 1.0));
}}
",
active.len()
@@ -357,7 +373,15 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
// Framing enters the hash by structure only — which branches its prologue
// generated, never how far a slider moved. Dragging the crop handles must
// reuse the compiled pipeline and re-upload uniforms.
let structure_hash = mix(hash_structure(&active), framing.structure_key());
//
// The output space enters it too, and must: it changes the source, so two
// spaces sharing a hash would have the second silently rendered with the
// first's shader — a Display P3 export that came out sRGB and said
// otherwise.
let structure_hash = mix(
mix(hash_structure(&active), framing.structure_key()),
output as u64,
);
ComposedShader {
source,
@@ -366,6 +390,87 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
}
}
/// The WGSL converting linear sRGB into the output space's primaries.
///
/// A constant matrix rather than a uniform: the space is chosen when the
/// shader is composed, so the numbers are known at generation time and the
/// driver can fold them into the surrounding arithmetic.
///
/// Empty for sRGB, which is the space the pipeline already works in — the
/// camera matrix converts into it, which is what `cam_to_srgb` is named for.
/// Emitting an identity there would put nine constants and three dot products
/// into the display path's shader, the one compiled most often, to compute the
/// value it already had. The identity is detected rather than special-cased by
/// name, so a space that happens to share sRGB's primaries would be spared
/// too.
fn primaries_conversion(output: ColourSpace) -> String {
let m = output.from_linear_srgb();
const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
// A tolerance rather than equality: the matrix is an inverse multiplied by
// a product, so sRGB's own comes back a few ULP off the identity. A
// millionth of a channel is four decimal places below an 8-bit step.
if m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < 1e-6) {
return String::new();
}
let mut out = format!(
"\n // Linear sRGB -> linear {}. The last colour transform before the\n\
\x20 // encode, and the reason a colour sRGB could not hold survives\n\
\x20 // this far: it is still inside this gamut.\n\
\x20 c = vec3<f32>(\n",
output.label()
);
// Entries below the printed precision are zero as far as the shader is
// concerned, and a shared primary produces one every time. Snapping them
// avoids emitting `-0.000000`, which reads as a sign error to whoever is
// debugging a shader at the time.
let show = |v: f32| if v.abs() < 5e-7 { 0.0 } else { v };
for row in 0..3 {
let _ = writeln!(
out,
" dot(vec3<f32>({:.6}, {:.6}, {:.6}), c),",
show(m[row * 3]),
show(m[row * 3 + 1]),
show(m[row * 3 + 2])
);
}
out.push_str(" );\n");
out
}
/// The WGSL of the output space's transfer function.
///
/// Named `encode_output` whatever the space, so the call site at the end of
/// `main` does not have to know which one it got.
fn encode_output_fn(output: ColourSpace) -> String {
let body = match output.transfer() {
Transfer::Srgb => " let lo = c * 12.92;
let hi = 1.055 * pow(max(c, vec3<f32>(0.0031308)), vec3<f32>(1.0 / 2.4)) - 0.055;
return select(hi, lo, c <= vec3<f32>(0.0031308));"
.to_string(),
// No linear segment at all, so no `select`: Adobe RGB (1998) is a
// pure power curve, and inventing a toe for it would be a different
// space wearing its name.
Transfer::Gamma(g) => format!(" return pow(c, vec3<f32>(1.0 / {g:.8}));"),
Transfer::Prophoto => " let lo = c * 16.0;
let hi = pow(max(c, vec3<f32>(0.001953125)), vec3<f32>(1.0 / 1.8));
return select(hi, lo, c < vec3<f32>(0.001953125));"
.to_string(),
};
format!(
"// Linear {} to its transfer function.
//
// The one place quantisation happens: everything above runs in linear f16,
// and this is the final encode (ARCH §5.2).
fn encode_output(c: vec3<f32>) -> vec3<f32> {{
{body}
}}
",
output.label()
)
}
/// The WGSL turning the framed source position `p` into the colour `c`.
///
/// Split out because it is the join between the coordinate stage and the
@@ -766,6 +871,90 @@ mod tests {
assert!(op < matrix, "the camera matrix must come after operations");
}
/// Compose with neutral framing into a chosen output space.
fn compose_to(ops: &[Box<dyn Operation>], output: ColourSpace) -> ComposedShader {
compose_with_framing(ops, &Framing::new(), output)
}
#[test]
fn an_srgb_render_is_byte_for_byte_what_it_was_before_output_spaces_existed() {
// The display path is the shader compiled on nearly every frame, and
// it must not pick up an identity matrix multiply for the sake of
// generality. Asserted against the source rather than against timing,
// which would not fail reliably.
let ops = vec![fake(&DESC_A, 1.0, false)];
let srgb = compose_to(&ops, ColourSpace::Srgb).source;
assert!(
!srgb.contains("Linear sRGB -> linear sRGB"),
"sRGB in, sRGB out must emit no conversion:\n{srgb}"
);
assert_eq!(srgb, compose(&ops).source);
}
#[test]
fn a_wide_output_space_converts_after_the_camera_matrix_and_before_the_clip() {
// The whole point of the ordering. The camera matrix lands the colour
// in linear sRGB, the primaries conversion carries it into the wider
// space, and only then is it clipped — clipping first would discard
// exactly the colours the wider space was chosen to keep.
let source = compose_to(&[fake(&DESC_A, 1.0, false)], ColourSpace::DisplayP3).source;
let camera = source.find("u.cam_to_srgb_0").expect("camera matrix");
let convert = source
.find("Linear sRGB -> linear Display P3")
.expect("primaries conversion");
let clip = source.find("c = clamp(c,").expect("clip");
assert!(camera < convert, "the camera matrix must come first");
assert!(convert < clip, "the clip must come after the conversion");
}
#[test]
fn the_generated_matrix_is_the_one_the_profile_writer_will_use() {
// The shader encodes the pixels and `dr-export` describes them, from
// the same table in `dr-types`. If the composer ever grew its own copy
// of these numbers the file would be labelled with primaries it does
// not contain, which is the failure the whole feature exists to avoid.
let m = ColourSpace::DisplayP3.from_linear_srgb();
let first_row = format!("dot(vec3<f32>({:.6}, {:.6}, {:.6}), c)", m[0], m[1], m[2]);
let source = compose_to(&[], ColourSpace::DisplayP3).source;
assert!(
source.contains(&first_row),
"expected {first_row} in:\n{source}"
);
}
#[test]
fn every_output_space_encodes_with_its_own_transfer_function() {
// Adobe RGB's pure 2.199 gamma and ProPhoto's 1.8-with-a-toe are not
// the sRGB curve, and a file encoded with the wrong one is wrong in a
// way no amount of correct primaries repairs.
let marks = [
(ColourSpace::Srgb, "1.0 / 2.4"),
(ColourSpace::DisplayP3, "1.0 / 2.4"),
(ColourSpace::AdobeRgb, "1.0 / 2.19921875"),
(ColourSpace::ProPhoto, "1.0 / 1.8"),
];
for (space, mark) in marks {
let source = compose_to(&[], space).source;
assert!(
source.contains(mark),
"{space:?} should encode with {mark}:\n{source}"
);
}
}
#[test]
fn the_output_space_changes_the_structure_hash() {
// The pipeline cache is keyed on this hash. Two spaces sharing one
// would have the second rendered with the first's compiled shader —
// an export that came out sRGB and claimed to be Display P3.
let mut seen: Vec<u64> = Vec::new();
for space in ColourSpace::ALL {
let h = compose_to(&[fake(&DESC_A, 1.0, false)], space).structure_hash;
assert!(!seen.contains(&h), "{space:?} collides with another space");
seen.push(h);
}
}
#[test]
fn generated_source_carries_a_do_not_edit_banner() {
// Someone will eventually find this in a debugger and try to fix it
+545
View File
@@ -0,0 +1,545 @@
//! TRACES: FR-EXP-2 | FR-DSP-6
//! The numbers behind an output colour space.
//!
//! # Why this lives in the types crate
//!
//! Two crates need these numbers and they must never disagree. `dr-pipeline`
//! bakes the primaries into the generated shader, so the pixels are *encoded*
//! into the space; `dr-export` writes the same primaries into an ICC profile,
//! so the file is *described* as being in it. A drift between the two produces
//! a file whose profile lies about its own contents — precisely the failure the
//! export path refused to risk before any of this existed. Neither crate
//! depends on the other, so one shared home is the only way to make that
//! disagreement impossible rather than merely unlikely.
//!
//! # Derived, not tabulated
//!
//! Everything here comes from four chromaticity pairs per space. A table of
//! nine pre-computed matrix entries is a set of numbers nobody can check, and a
//! transposed row in one looks exactly like a correct matrix. A derivation can
//! be tested against the values the specifications publish, which is what the
//! tests at the bottom do.
//!
//! The arithmetic is `f64` throughout and only narrows on the way out. Two of
//! the three steps are matrix inversions, and an inverse amplifies whatever
//! rounding it was handed.
use crate::settings::ColourSpace;
/// A colour space's primaries and white point, as CIE xy chromaticities.
///
/// This is the whole definition of a space's *gamut*; the transfer function
/// (see [`Transfer`]) is the other, independent half. Display P3 and sRGB
/// differ only in the first, ProPhoto and Adobe RGB in both.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Chromaticities {
pub red: [f64; 2],
pub green: [f64; 2],
pub blue: [f64; 2],
pub white: [f64; 2],
}
/// How a space maps linear light onto the numbers stored in a file.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Transfer {
/// The sRGB curve: linear at slope 12.92 below 0.0031308, then gamma 2.4
/// offset to meet it. Display P3 uses this same curve — it departs from
/// sRGB in its primaries alone.
Srgb,
/// A pure power curve with no linear segment. Adobe RGB (1998)'s.
Gamma(f32),
/// ROMM RGB's curve: linear at slope 16 below 1/512, then gamma 1.8.
Prophoto,
}
impl Transfer {
/// Linear light to a stored value. What the final stage of a render does.
pub fn encode(self, v: f32) -> f32 {
// `powf` of a negative is NaN, and a negative arrives whenever a
// colour falls outside the destination gamut. Callers clamp, but a
// NaN escaping here would reach a file as a black pixel with no
// indication of where it came from.
let v = v.max(0.0);
match self {
Self::Srgb => {
if v <= 0.003_130_8 {
v * 12.92
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
}
}
Self::Gamma(g) => v.powf(1.0 / g),
Self::Prophoto => {
if v < 1.0 / 512.0 {
v * 16.0
} else {
v.powf(1.0 / 1.8)
}
}
}
}
/// A stored value back to linear light.
///
/// The direction an ICC tone reproduction curve is defined in — a `curv`
/// tag maps device values towards the profile connection space — which is
/// why this exists alongside the encoder the renderer wants.
pub fn decode(self, v: f32) -> f32 {
let v = v.max(0.0);
match self {
Self::Srgb => {
if v <= 0.040_45 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
Self::Gamma(g) => v.powf(g),
Self::Prophoto => {
if v < 16.0 / 512.0 {
v / 16.0
} else {
v.powf(1.8)
}
}
}
}
}
impl ColourSpace {
/// The primaries and white point that define this space's gamut.
pub fn chromaticities(self) -> Chromaticities {
match self {
Self::Srgb => Chromaticities {
red: [0.6400, 0.3300],
green: [0.3000, 0.6000],
blue: [0.1500, 0.0600],
white: D65,
},
// Same blue as sRGB, and a far redder red: P3's gamut is the
// cinema projector primaries on a D65 white.
Self::DisplayP3 => Chromaticities {
red: [0.6800, 0.3200],
green: [0.2650, 0.6900],
blue: [0.1500, 0.0600],
white: D65,
},
// Red and blue are sRGB's exactly. Adobe RGB (1998) widens the
// green corner alone, which is why the conversion out of sRGB
// leaves the blue channel almost untouched.
Self::AdobeRgb => Chromaticities {
red: [0.6400, 0.3300],
green: [0.2100, 0.7100],
blue: [0.1500, 0.0600],
white: D65,
},
// ROMM RGB. Two of its three primaries are imaginary — outside the
// spectral locus — which is how it encloses every real surface
// colour, and why so much of its cube is unreachable.
Self::ProPhoto => Chromaticities {
red: [0.734_699, 0.265_301],
green: [0.159_597, 0.840_403],
blue: [0.036_598, 0.000_105],
white: D50,
},
}
}
/// The transfer function a file in this space carries.
pub fn transfer(self) -> Transfer {
match self {
Self::Srgb | Self::DisplayP3 => Transfer::Srgb,
// 563/256, which is what Adobe RGB (1998) specifies and, not by
// coincidence, exactly what an ICC `curv` tag's u8Fixed8 gamma can
// hold. Writing 2.2 instead would be a different space.
Self::AdobeRgb => Transfer::Gamma(563.0 / 256.0),
Self::ProPhoto => Transfer::Prophoto,
}
}
/// Linear sRGB to this space's linear RGB, row-major.
///
/// The transform the render's final stage needs. Linear sRGB is the
/// pipeline's connection space — the camera matrix converts into it, which
/// is what `cam_to_srgb` in the generated shader is named for — so every
/// output space is reached from there.
///
/// Identity for [`ColourSpace::Srgb`], to within the rounding of an
/// inversion followed by a multiplication.
pub fn from_linear_srgb(self) -> [f32; 9] {
let src = ColourSpace::Srgb.chromaticities();
let dst = self.chromaticities();
// Through XYZ, adapting the white point on the way: ProPhoto is a D50
// space, and handing D65 white to it unadapted would tint every export
// in it warm.
let adapt = adaptation(white_xyz(&src), white_xyz(&dst));
narrow(mul(invert(rgb_to_xyz(&dst)), mul(adapt, rgb_to_xyz(&src))))
}
/// This space's linear RGB to the ICC profile connection space, row-major.
///
/// The columns are the `rXYZ`, `gXYZ` and `bXYZ` colorant tags of a
/// matrix/TRC profile. Chromatically adapted to D50 because the PCS is
/// defined at D50 and nowhere else — a profile carrying unadapted D65
/// colorants describes a space nobody asked for.
pub fn to_pcs_xyz(self) -> [f32; 9] {
let c = self.chromaticities();
narrow(mul(adaptation(white_xyz(&c), PCS_D50), rgb_to_xyz(&c)))
}
/// The white-point adaptation folded into [`Self::to_pcs_xyz`], row-major.
///
/// An ICC profile carries this separately, in its `chad` tag, so that a
/// reader can undo the adaptation and recover the space's native white.
/// Without it the colorants alone are ambiguous: D65 primaries adapted to
/// D50 and genuine D50 primaries are the same nine numbers.
pub fn adaptation_to_pcs(self) -> [f32; 9] {
narrow(adaptation(white_xyz(&self.chromaticities()), PCS_D50))
}
}
/// The D65 white point, as sRGB, Display P3 and Adobe RGB all define it.
const D65: [f64; 2] = [0.3127, 0.3290];
/// The D50 white point, as ROMM RGB defines it.
const D50: [f64; 2] = [0.345_704, 0.358_540];
/// The profile connection space illuminant, to the precision ICC fixes it at.
///
/// Written as the values a profile's header actually carries — 0x0000F6D6,
/// 0x00010000, 0x0000D32D as s15Fixed16 — rather than derived from a
/// chromaticity pair. A media white point that differs from the PCS
/// illuminant in the last bit is a profile some validators reject and some
/// readers quietly re-adapt.
const PCS_D50: [f64; 3] = [0.9642, 1.0, 0.8249];
/// The Bradford cone response matrix.
///
/// Bradford rather than the simpler von Kries or XYZ scaling: it is what ICC
/// specifies for the `chad` tag, so a profile built with anything else would
/// describe colorants that disagree with the adaptation it declares.
const BRADFORD: M3 = [
[0.8951, 0.2664, -0.1614],
[-0.7502, 1.7135, 0.0367],
[0.0389, -0.0685, 1.0296],
];
type M3 = [[f64; 3]; 3];
/// An xy chromaticity as an XYZ triple normalised to Y = 1.
fn xyz_from_xy(xy: [f64; 2]) -> [f64; 3] {
let [x, y] = xy;
[x / y, 1.0, (1.0 - x - y) / y]
}
fn white_xyz(c: &Chromaticities) -> [f64; 3] {
xyz_from_xy(c.white)
}
/// Linear RGB to XYZ for a set of primaries.
///
/// The primaries fix the *directions* of the three columns; the white point
/// fixes their lengths, by the requirement that (1, 1, 1) render as the white.
fn rgb_to_xyz(c: &Chromaticities) -> M3 {
let r = xyz_from_xy(c.red);
let g = xyz_from_xy(c.green);
let b = xyz_from_xy(c.blue);
let directions = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]];
let scale = mul_vec(invert(directions), white_xyz(c));
let mut m = directions;
for row in &mut m {
for (col, s) in row.iter_mut().zip(scale) {
*col *= s;
}
}
m
}
/// Bradford chromatic adaptation between two white points, in XYZ.
fn adaptation(from: [f64; 3], to: [f64; 3]) -> M3 {
let s = mul_vec(BRADFORD, from);
let d = mul_vec(BRADFORD, to);
let scale = [
[d[0] / s[0], 0.0, 0.0],
[0.0, d[1] / s[1], 0.0],
[0.0, 0.0, d[2] / s[2]],
];
mul(invert(BRADFORD), mul(scale, BRADFORD))
}
fn mul(a: M3, b: M3) -> M3 {
let mut out = [[0.0; 3]; 3];
for (i, row) in out.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
out
}
fn mul_vec(m: M3, v: [f64; 3]) -> [f64; 3] {
[
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
]
}
/// Invert by the adjugate.
///
/// No singular case to handle: every matrix inverted here is built from a set
/// of primaries that span a real gamut, or is the Bradford constant. A
/// degenerate one would mean a space with two identical primaries, which is
/// not a space.
fn invert(m: M3) -> M3 {
let cofactor = |a: usize, b: usize, c: usize, d: usize| m[a][b] * m[c][d] - m[a][d] * m[c][b];
let a = cofactor(1, 1, 2, 2);
let b = -cofactor(1, 0, 2, 2);
let c = cofactor(1, 0, 2, 1);
let det = m[0][0] * a + m[0][1] * b + m[0][2] * c;
let inv = 1.0 / det;
[
[
a * inv,
-cofactor(0, 1, 2, 2) * inv,
cofactor(0, 1, 1, 2) * inv,
],
[
b * inv,
cofactor(0, 0, 2, 2) * inv,
-cofactor(0, 0, 1, 2) * inv,
],
[
c * inv,
-cofactor(0, 0, 2, 1) * inv,
cofactor(0, 0, 1, 1) * inv,
],
]
}
/// Row-major `f64` matrix to the flat `f32` array the rest of the tree passes
/// around — the same shape `dr-decode` gives a camera matrix.
fn narrow(m: M3) -> [f32; 9] {
let mut out = [0.0f32; 9];
for (i, row) in m.iter().enumerate() {
for (j, v) in row.iter().enumerate() {
out[i * 3 + j] = *v as f32;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// Compare against a published matrix, entry by entry.
fn assert_close(got: [f32; 9], want: [f32; 9], tol: f32, what: &str) {
for (i, (g, w)) in got.iter().zip(want).enumerate() {
assert!(
(g - w).abs() <= tol,
"{what} entry {i}: got {g}, published {w}\n got {got:?}\n want {want:?}"
);
}
}
#[test]
fn srgb_to_srgb_is_the_identity() {
// The property the composer relies on to leave the display path's
// shader alone: asking for the space the pipeline already works in
// must cost nothing. A matrix that is merely *close* would still be
// skipped, but one that is not close means the derivation itself is
// wrong in a way every other space would inherit.
assert_close(
ColourSpace::Srgb.from_linear_srgb(),
[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
1e-5,
"sRGB -> sRGB",
);
}
#[test]
fn adobe_rgb_leaves_the_red_and_blue_primaries_where_they_were() {
// Adobe RGB (1998) shares sRGB's red and blue chromaticities exactly,
// so the conversion can only move green into red and green into blue.
// Any other non-zero entry means the primaries were mistyped or the
// matrix came out transposed — which a norm-based check would pass.
let m = ColourSpace::AdobeRgb.from_linear_srgb();
assert_close(
m,
[0.7152, 0.2848, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0412, 0.9588],
1e-3,
"sRGB -> Adobe RGB",
);
}
#[test]
fn display_p3_matches_the_published_conversion() {
// P3 shares sRGB's blue, so the third column is zero above the
// diagonal. The published matrix, to four places.
assert_close(
ColourSpace::DisplayP3.from_linear_srgb(),
[
0.8225, 0.1774, 0.0, 0.0332, 0.9669, 0.0, 0.0171, 0.0724, 0.9105,
],
1e-3,
"sRGB -> Display P3",
);
}
#[test]
fn prophoto_conversion_matches_the_published_bradford_matrix() {
// The one space whose conversion includes a white-point adaptation.
// Dropping the adaptation changes these by a percent or two — small
// enough to look plausible and large enough to tint every export.
assert_close(
ColourSpace::ProPhoto.from_linear_srgb(),
[
0.5294, 0.3300, 0.1406, 0.0983, 0.8734, 0.0283, 0.0169, 0.1178, 0.8653,
],
2e-3,
"sRGB -> ProPhoto",
);
}
#[test]
fn the_srgb_colorants_match_the_reference_profile() {
// The nine numbers in every sRGB IEC61966-2.1 profile ever shipped.
// This is what proves the D50 adaptation runs in the right direction:
// reversing it moves each entry by several percent.
//
// Row-major here, where a profile stores them column by column as
// three XYZ tags — so this also pins the orientation.
assert_close(
ColourSpace::Srgb.to_pcs_xyz(),
[
0.4360, 0.3851, 0.1431, //
0.2225, 0.7169, 0.0606, //
0.0139, 0.0971, 0.7141,
],
1e-3,
"sRGB colorants",
);
}
#[test]
fn the_prophoto_colorants_match_the_reference_profile() {
// ProPhoto is already D50, so its adaptation is a near-identity and
// its colorants are the raw primaries. Its blue Y of 0.0001 is the
// giveaway that the numbers are the real ROMM ones.
assert_close(
ColourSpace::ProPhoto.to_pcs_xyz(),
[
0.7977, 0.1352, 0.0313, //
0.2880, 0.7119, 0.0001, //
0.0000, 0.0000, 0.8251,
],
1e-3,
"ProPhoto colorants",
);
}
#[test]
fn every_space_renders_its_white_as_the_pcs_illuminant() {
// (1, 1, 1) in any RGB space is that space's white, and a profile's
// colorants must sum to D50 or every neutral in the file comes out
// tinted. The failure is invisible in a single patch and glaring
// across a grey ramp.
for space in ColourSpace::ALL {
let m = space.to_pcs_xyz();
let sum = [m[0] + m[1] + m[2], m[3] + m[4] + m[5], m[6] + m[7] + m[8]];
for (i, (got, want)) in sum.iter().zip(PCS_D50).enumerate() {
assert!(
(f64::from(*got) - want).abs() < 1e-3,
"{space:?} white component {i} is {got}, not the PCS {want}"
);
}
}
}
#[test]
fn the_wide_spaces_contain_the_whole_srgb_cube() {
// The claim a wide-gamut export makes: nothing sRGB could express is
// lost by encoding into a larger space. Every corner of the sRGB cube
// must land inside the unit cube of the destination — and a matrix
// derived in the wrong direction sends P3's red corner to 1.2, which
// is exactly the clipping this feature exists to avoid.
for space in ColourSpace::ALL {
let m = space.from_linear_srgb();
for corner in 0..8u8 {
let v = [
f32::from(corner & 1),
f32::from((corner >> 1) & 1),
f32::from((corner >> 2) & 1),
];
for row in 0..3 {
let out = m[row * 3] * v[0] + m[row * 3 + 1] * v[1] + m[row * 3 + 2] * v[2];
assert!(
(-1e-4..=1.0 + 1e-4).contains(&out),
"{space:?} sends sRGB corner {v:?} channel {row} to {out}"
);
}
}
}
}
#[test]
fn a_wider_space_leaves_headroom_where_srgb_has_none() {
// The other half of the same claim, and the reason the feature is
// worth having: saturated sRGB primaries stop short of the wide
// space's own primaries, so there is room left for colours sRGB
// could not hold. Without this, "wide gamut" would be a relabelling.
for space in [
ColourSpace::DisplayP3,
ColourSpace::AdobeRgb,
ColourSpace::ProPhoto,
] {
let m = space.from_linear_srgb();
// sRGB's fully saturated red, in the destination.
let red = [m[0], m[3], m[6]];
assert!(
red[0] < 0.99,
"{space:?} maps sRGB red to {red:?}, leaving it no headroom"
);
}
}
#[test]
fn every_transfer_function_round_trips() {
// Encode then decode must be the identity, or a file exported in a
// space and reopened in it would drift a little further every pass.
// Checked near both ends, where a mismatched breakpoint hides.
for space in ColourSpace::ALL {
let t = space.transfer();
for v in [0.0, 0.0005, 0.002, 0.05, 0.2159, 0.5, 0.9, 1.0] {
let back = t.decode(t.encode(v));
assert!(
(back - v).abs() < 1e-4,
"{space:?} at {v}: encode/decode returned {back}"
);
}
}
}
#[test]
fn every_transfer_function_maps_the_ends_to_the_ends() {
// Black must stay black and white must stay white in every space. A
// curve that maps 1.0 to 0.998 makes every white in a file slightly
// grey, which is the sort of thing nobody notices until a print.
for space in ColourSpace::ALL {
let t = space.transfer();
assert!(t.encode(0.0).abs() < 1e-6, "{space:?} black");
assert!((t.encode(1.0) - 1.0).abs() < 1e-6, "{space:?} white");
}
}
#[test]
fn encoding_a_negative_is_black_rather_than_nan() {
// Out-of-gamut colours arrive negative. `powf` of a negative is NaN,
// and a NaN reaching an 8-bit cast is a black pixel with no clue as
// to where it came from.
for space in ColourSpace::ALL {
assert_eq!(space.transfer().encode(-0.2), 0.0, "{space:?}");
}
}
}
+2
View File
@@ -8,9 +8,11 @@ use std::collections::BTreeSet;
use std::fmt;
use std::ops::Range;
pub mod colour;
pub mod selector;
pub mod settings;
pub use colour::{Chromaticities, Transfer};
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
pub use settings::{
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
+7 -1
View File
@@ -451,7 +451,13 @@ impl ExportFormat {
}
}
/// Output colour space, with its ICC profile embedded on export (FR-EXP-2).
/// TRACES: FR-EXP-2
/// Output colour space, with its ICC profile embedded on export.
///
/// The name alone. What each one *is* — its primaries, white point and
/// transfer function, and the matrices derived from them — lives in
/// [`crate::colour`], where the renderer and the profile writer read the same
/// numbers and so cannot describe a file as something it is not.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ColourSpace {