Tell the shader which colour space it is encoding for
The generated shader ended with `encode_srgb` and a clamp, so every photograph leaving DarkRoom had been through sRGB's gamut whatever the settings page said. Export refused the other three spaces rather than tag clipped pixels with a gamut they did not contain — correct, and not something an encoder could fix. So the output space becomes a parameter of composition. `compose_for` emits a constant primaries matrix after the camera matrix and before the clip, and generates the transfer function to match: the sRGB curve for sRGB and Display P3, a pure 2.199 gamma for Adobe RGB, 1.8 with a linear toe for ProPhoto. The ordering the camera matrix depends on is untouched — operations still run in camera space — and sRGB emits no conversion at all, so the shader compiled on nearly every frame is byte-for-byte what it was. The numbers live in dr-types, derived from four chromaticity pairs per space rather than tabulated. That is not tidiness: the shader encodes the pixels and the ICC profile describes them, and a file whose profile disagrees with its own contents is worse than one with no profile. One derivation makes them agree by construction, and can be checked against the values the specifications publish. Profiles are generated here too — minimal v2 matrix/TRC, about 2 KB, pure Rust, no lcms to satisfy under the NDK. A JPEG carries it in APP2, a PNG in iCCP, a TIFF in tag 34675. sRGB gets one as well, because untagged does not mean sRGB, it means guess. The refusal survives in a sharper form. A `Frame` now carries the space it was rendered in, and export refuses to label it anything else. The develop session still composes for sRGB, so a P3 export from the interface fails with an accurate error instead of producing a file that lies — the frontend half is a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -350,7 +350,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 {
|
||||
|
||||
Reference in New Issue
Block a user