OpenEXR/HDR export: linearize grayscale documents too (#2386) (#2448)

* OpenEXR/HDR export: linearize grayscale documents too

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

* contributors: add itsF4LCON

---------

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
F4LCONandBrandon Thomas authored and GitHub committed 2026-10-10 13:13:40 -04:00
1 parent f8efd089ad
commit ae03e7cd66
7 files changed
+122 -10

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP,
Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_export`, Layers to Files, Layer › Export As) flattens it the same way:
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
## Implemented decode limits
+5
View File
@@ -75,5 +75,10 @@ real_name = "Ahmed Moussa"
display_name = "Ahmed Moussa"
sync_github_name = false
[people.itsF4LCON]
real_name = "Ran Geerts"
display_name = "Ran Geerts"
sync_github_name = false
[people.vangel76]
sync_github_name = true
+7
View File
@@ -127,6 +127,13 @@ pub fn srgb() -> &'static Profile {
Builtin::Srgb.profile()
}
/// Gray with a linear tone curve: what a grayscale OpenEXR holds. Not a [`Builtin`], as it is
/// only assigned to such files, never offered as a working space.
pub fn linear_gray() -> &'static Profile {
static PROFILE: OnceLock<Profile> = OnceLock::new();
PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0)))
}
/// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290).
fn d65() -> [f64; 3] {
math::xy_to_xyz(math::D65_XY)
+5 -3
View File
@@ -251,9 +251,11 @@ impl ColorState {
// Compared by colour, not bytes: Photoshop's sRGB IEC61966-2.1 is our working sRGB.
let mismatch = !emb.same_colors(&working);
let base = json!({"embedded": emb.description, "working": working.description, "mismatch": mismatch, "policy": policy.id()});
// 32-bit linear images (EXR/HDR are tagged linear sRGB on import) stay linear, as
// Photoshop keeps 32-bit documents in a linear version of the working space.
let linear_hdr = doc.depth == SampleType::F32 && emb.same_colors(Builtin::LinearSrgb.profile());
// 32-bit linear images (EXR/HDR are tagged linear sRGB or linear gray on import)
// stay linear, as Photoshop keeps 32-bit documents in a linear version of the
// working space.
let linear_hdr =
doc.depth == SampleType::F32 && (emb.same_colors(Builtin::LinearSrgb.profile()) || emb.same_colors(photocraft_cms::builtin::linear_gray()));
if !mismatch || linear_hdr {
return merge(base, json!({"action": "kept"}));
}
+19
View File
@@ -190,6 +190,25 @@ fn exr_round_trip_is_linear() {
assert!(report.get("ask").is_none());
}
#[test]
fn gray_exr_round_trip_is_linear() {
let mut s = Session::new();
// An sGray document exported to EXR is linearised (#2386); re-opened it is tagged linear gray
// and displays as before.
let d = Document::with_background("t", Size::new(8, 8), ColorMode::Grayscale, SampleType::U8, Color::gray(128.0 / 255.0));
let r = photocraft_io::export(&d, "x.exr", &photocraft_io::ExportOptions::default()).unwrap();
let back = photocraft_io::import("x.exr", &r.bytes).unwrap().document;
let icc = back.icc_profile.clone().expect("tagged");
assert_eq!(Profile::parse(&icc).unwrap().content_hash(), photocraft_cms::builtin::linear_gray().content_hash());
let v = back.layers[0].surface().unwrap().pixel(1, 1);
assert!((v[0] - 0.2159).abs() < 1e-3, "stored linear: {v:?}");
assert!(close(shown(&s, &back), shown(&s, &d), 1.0), "{:?} vs {:?}", shown(&s, &back), shown(&s, &d));
// Opening keeps it linear without a mismatch prompt.
let (_, report) = s.open_document(back, None);
assert_eq!(report["action"], "kept");
assert!(report.get("ask").is_none());
}
#[test]
fn monitor_profile_setting() {
let mut s = Session::new();
+38 -6
View File
@@ -22,10 +22,15 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
let img = codecs::decode(bytes)?;
let mut r = image_to_document(name, &img)?;
// OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless
// stated otherwise): tag them linear sRGB so they display and convert correctly.
// stated otherwise, and an OpenEXR `Y` is linear luminance): tag them linear sRGB or linear
// gray so they display and convert correctly.
let d = &mut r.document;
if d.icc_profile.is_none() && d.mode == ColorMode::Rgb && matches!(codecs::detect(bytes), Some(Format::OpenExr | Format::Hdr)) {
d.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes());
if d.icc_profile.is_none() && matches!(codecs::detect(bytes), Some(Format::OpenExr | Format::Hdr)) {
d.icc_profile = match d.mode {
ColorMode::Rgb => Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()),
ColorMode::Grayscale => Some(photocraft_cms::builtin::linear_gray().to_bytes()),
_ => None,
};
}
Ok(r)
}
@@ -311,9 +316,12 @@ pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Resu
warnings.push(format!("CMYK converted to sRGB for {format:?} through the document's colour profile"));
}
if matches!(format, Format::OpenExr | Format::Hdr) {
// OpenEXR and Radiance HDR store linear light (read back as linear sRGB, see [`import_flat`]).
// OpenEXR and Radiance HDR store linear light (read back as linear sRGB or linear gray,
// see [`import_flat`]).
if let Some(linear) = convert_rgb(&img, Builtin::LinearSrgb.profile(), Intent::RelativeColorimetric, false, CSample::F32)? {
img = linear;
} else if let Some(linear) = convert_gray(&img, photocraft_cms::builtin::linear_gray(), CSample::F32)? {
img = linear;
}
} else if !format.caps().icc {
// Untagged files read back as sRGB: convert to it (as Quick Export does) rather than
@@ -429,16 +437,40 @@ fn matte_over_white(img: &Image) -> Result<Image, IoError> {
/// RGB pixels converted from their profile (sRGB when untagged) to `dst`, unclamped, untagged and
/// stored as `sample`. `None` when the image isn't RGB or already holds `dst`'s colours.
fn convert_rgb(img: &Image, dst: &photocraft_cms::Profile, intent: photocraft_cms::Intent, bpc: bool, sample: CSample) -> Result<Option<Image>, IoError> {
use photocraft_cms::{Builtin, ColorSpace, Profile, Transform};
use photocraft_cms::{Builtin, ColorSpace, Profile};
if !img.layout().is_rgb() {
return Ok(None);
}
let src =
img.icc.as_ref().and_then(|b| Profile::parse(b).ok()).filter(|p| p.color_space == ColorSpace::Rgb).unwrap_or_else(|| Builtin::Srgb.profile().clone());
convert_from(img, &src, dst, intent, bpc, sample)
}
/// Gray pixels converted from their profile (sGray when untagged) to `dst`, unclamped, untagged
/// and stored as `sample`. `None` when the image isn't gray or already holds `dst`'s colours.
fn convert_gray(img: &Image, dst: &photocraft_cms::Profile, sample: CSample) -> Result<Option<Image>, IoError> {
use photocraft_cms::{Builtin, ColorSpace, Intent, Profile};
if !matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA) {
return Ok(None);
}
let src =
img.icc.as_ref().and_then(|b| Profile::parse(b).ok()).filter(|p| p.color_space == ColorSpace::Gray).unwrap_or_else(|| Builtin::SGray.profile().clone());
convert_from(img, &src, dst, Intent::RelativeColorimetric, false, sample)
}
/// `img`'s pixels converted from `src` to `dst` (see [`convert_rgb`]).
fn convert_from(
img: &Image,
src: &photocraft_cms::Profile,
dst: &photocraft_cms::Profile,
intent: photocraft_cms::Intent,
bpc: bool,
sample: CSample,
) -> Result<Option<Image>, IoError> {
if src.same_colors(dst) {
return Ok(None);
}
let t = Transform::new(&src, dst, intent, bpc).map_err(|e| IoError::Unsupported(e.to_string()))?;
let t = photocraft_cms::Transform::new(src, dst, intent, bpc).map_err(|e| IoError::Unsupported(e.to_string()))?;
let stride = img.layout().channels();
let out = map_bands(img, img.layout(), sample, |mut vals| {
t.apply(&mut vals, stride);
+47
View File
@@ -74,6 +74,53 @@ fn exr_rgba32() {
assert!((a[3] - b[3]).abs() < 1e-6, "alpha kept");
}
/// An opaque document filled with `#808080`.
fn mid_gray(mode: ColorMode, depth: SampleType) -> photocraft_doc::Document {
let mut d = photocraft_doc::Document::new("g", photocraft_geom::Size::new(4, 4), mode, depth);
let fmt = d.pixel_format();
let mut s = photocraft_raster::Surface::new(fmt);
let v = 128.0 / 255.0;
s.fill_rect(d.bounds(), &photocraft_raster::from_rgba(&fmt, [v, v, v, 1.0]));
d.layers.push(photocraft_doc::Layer::new("Background", photocraft_doc::LayerContent::Raster(s)));
d
}
/// OpenEXR and Radiance HDR store linear sRGB in every mode: an sGray `#808080` is written as
/// its linear value, as an RGB `#808080` is, rather than as the display-encoded 0.5 (#2386).
#[test]
fn gray_exr_and_hdr_store_linear_light() {
let linear = photocraft_color::convert::srgb_to_linear(128.0 / 255.0);
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for (ext, tol) in [("exr", 2e-5), ("hdr", 2e-3)] {
for mode in [ColorMode::Grayscale, ColorMode::Rgb] {
let r = export(&mid_gray(mode, depth), ext, &ExportOptions::default()).expect("export");
let img = photocraft_codecs::decode(&r.bytes).expect("decode");
let samples = img.to_normalized();
let worst = samples.iter().map(|v| (v - linear).abs()).fold(0.0f32, f32::max);
assert!(worst <= tol, "{mode:?} {depth:?} .{ext}: stored samples differ from {linear} by {worst}");
}
}
}
}
/// A grayscale OpenEXR holds linear luminance, so it opens as grayscale tagged with a linear gray
/// profile, in which its samples are the colours saved.
#[test]
fn gray_exr_round_trips() {
use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform};
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let r = export(&mid_gray(ColorMode::Grayscale, depth), "exr", &ExportOptions::default()).expect("export");
let back = import("x.exr", &r.bytes).expect("import").document;
assert_eq!(back.mode, ColorMode::Grayscale, "{depth:?}");
let profile = Profile::parse(back.icc_profile.as_deref().expect("tagged")).expect("profile");
assert_eq!(profile.color_space, ColorSpace::Gray, "{depth:?}");
let t = Transform::new(&profile, Builtin::SGray.profile(), Intent::RelativeColorimetric, false).expect("transform");
let mut v = [back.layers[0].surface().expect("raster").pixel(1, 1)[0]];
t.apply(&mut v, 1);
assert!((v[0] - 128.0 / 255.0).abs() < 1e-4, "{depth:?}: reopened as {} in sGray", v[0]);
}
}
fn smooth(mode: ColorMode) -> photocraft_doc::Document {
let mut d = photocraft_doc::Document::new("s", photocraft_geom::Size::new(32, 16), mode, SampleType::U8);
let fmt = d.pixel_format();