Merge branch 'colour-managed-export'
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user