Preserve HDR range in float adjustments and stack statistics (#2592)

* Preserve HDR range in float adjustments and stack statistics

* Cover HDR exposure round trips and Levels record order

* Report the backend used by GPU parity tests

* Compare float PSD oracles at native depth and preserve signed Exposure

* Retain merged alpha metadata when decoding float references

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Lessa
2026-10-11 10:44:55 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 989644cabd
commit 53d09f5850
8 changed files with 286 additions and 14 deletions
+29 -3
View File
@@ -73,7 +73,8 @@ fn stat(v: &mut [f32], s: Stat) -> f32 {
}
}
/// Combines `layers` (each `len` pixels) with statistic `s`.
/// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float
/// range; quantisation/clipping belongs to the destination surface, not the statistic.
pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
let len = layers.iter().map(Vec::len).max().unwrap_or(0);
let mut out = vec![[0.0f32; 4]; len];
@@ -97,7 +98,7 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
continue;
}
for c in 0..3 {
o[c] = stat(&mut samples[c], s).clamp(0.0, 1.0);
o[c] = stat(&mut samples[c], s);
}
o[3] = alpha;
}
@@ -121,7 +122,7 @@ mod tests {
assert_eq!(at(Stat::Minimum), 0.1);
assert_eq!(at(Stat::Maximum), 0.9);
assert!((at(Stat::Range) - 0.8).abs() < 1e-6);
assert_eq!(at(Stat::Summation), 1.0); // clamped
assert!((at(Stat::Summation) - 1.6).abs() < 1e-6);
let var = at(Stat::Variance);
assert!((at(Stat::StandardDeviation) - var.sqrt()).abs() < 1e-6);
assert!(at(Stat::Skewness) > 0.5, "right-skewed");
@@ -129,6 +130,31 @@ mod tests {
assert!(at(Stat::Kurtosis) > 0.0);
}
#[test]
fn statistics_preserve_hdr_and_signed_samples() {
let layers = vec![px(-2.0), px(4.0)];
for (mode, expected) in [
(Stat::Mean, 1.0),
(Stat::Median, 1.0),
(Stat::Minimum, -2.0),
(Stat::Maximum, 4.0),
(Stat::Range, 6.0),
(Stat::Summation, 2.0),
(Stat::Variance, 9.0),
(Stat::StandardDeviation, 3.0),
] {
assert_eq!(combine(&layers, mode)[0], [expected, expected, expected, 1.0], "{mode:?}");
}
for mode in [Stat::Mean, Stat::Median] {
assert_eq!(combine(&[px(2.0), px(4.0)], mode)[0], [3.0, 3.0, 3.0, 1.0]);
assert_eq!(combine(&[px(-2.0), px(-4.0)], mode)[0], [-3.0, -3.0, -3.0, 1.0]);
}
// These display-oriented statistics still apply their own normalisation.
for mode in [Stat::Entropy, Stat::Skewness, Stat::Kurtosis] {
assert!((0.0..=1.0).contains(&combine(&layers, mode)[0][0]), "{mode:?}");
}
}
#[test]
fn transparent_samples_are_ignored() {
let layers = vec![vec![[0.5, 0.5, 0.5, 1.0]], vec![[1.0, 1.0, 1.0, 0.0]]];
+40 -4
View File
@@ -48,12 +48,14 @@ impl Transfer {
fn decode(self, v: f32) -> f32 {
match self {
Transfer::Srgb => photocraft_color::convert::srgb_to_linear(v.max(0.0)),
Transfer::Gamma(1.0) => v.max(0.0),
Transfer::Gamma(g) => v.max(0.0).powf(g),
}
}
fn encode(self, v: f32) -> f32 {
match self {
Transfer::Srgb => photocraft_color::convert::linear_to_srgb(v.max(0.0)),
Transfer::Gamma(1.0) => v.max(0.0),
Transfer::Gamma(g) => v.max(0.0).powf(1.0 / g),
}
}
@@ -116,17 +118,39 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
let m = 2f32.powf(*exposure);
let g = gamma.max(0.01);
let transfer = transfer.for_exposure();
let hdr = depth == Some(SampleType::F32);
map_rgb(buf, |c| {
c.map(|v| {
let lin = (transfer.decode(v) * m + offset).max(0.0).powf(1.0 / g);
if hdr {
// Float documents store linear samples. Photoshop's 32-bit merged
// Exposure oracle also retains negative results with signed gamma.
let lin = v * m + offset;
return if g == 1.0 { lin } else { lin.signum() * lin.abs().powf(1.0 / g) };
}
let lin = (transfer.decode(v) * m + offset).max(0.0);
let lin = if g == 1.0 { lin } else { lin.powf(1.0 / g) };
transfer.encode(lin).clamp(0.0, 1.0)
})
})
}
Adjustment::Levels { master, per_channel, space: ToneSpace::Rgb, .. } if depth == Some(SampleType::F32) => {
// A 0..1 lookup table cannot represent signed, unbounded float samples.
let master_identity = *master == LevelsChannel::default();
let channel_identity = per_channel.each_ref().map(|ch| *ch == LevelsChannel::default());
if !master_identity || channel_identity.iter().any(|&v| !v) {
map_rgb(buf, |c| {
std::array::from_fn(|i| {
let v = if channel_identity[i] { c[i] } else { levels_float(&per_channel[i], c[i]) };
if master_identity { v } else { levels_float(master, v) }
})
});
}
}
Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => {
let luts = tone_luts_depth(adj, depth);
let identity = float_tone_identity(adj, depth);
match space {
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i]))),
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { lut(&luts[i], c[i]) })),
ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(*space, &luts, c)),
}
}
@@ -222,6 +246,17 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
}
}
/// Float RGB curve channels with no edit must preserve samples outside the LUT domain.
/// Edited curves retain the existing spline/LUT behaviour until HDR extrapolation has an oracle.
pub fn float_tone_identity(adj: &Adjustment, depth: Option<SampleType>) -> [bool; 3] {
match adj {
Adjustment::Curves { master, per_channel, space: ToneSpace::Rgb, .. } if depth == Some(SampleType::F32) => {
std::array::from_fn(|i| photocraft_doc::adjust::is_identity_curve(master) && photocraft_doc::adjust::is_identity_curve(&per_channel[i]))
}
_ => [false; 3],
}
}
/// Per-channel LUTs of a Levels or Curves adjustment: each channel's record first, then the
/// master (composite) record, as Photoshop does (psd-tools levels_rgb.psd and curves_rgb.psd,
/// #975). Four rows: the three `per_channel` channels then black
@@ -251,7 +286,8 @@ pub fn tone_luts_q(adj: &Adjustment, quantum: Option<f32>) -> [Vec<f32>; 4] {
}
/// [`tone_luts_q`] in a document of `depth` (`None`: unrounded). 32-bit Levels use
/// [`levels_float`], sampled on 0..1 and kept in 0..1 like every adjustment result here.
/// [`levels_float`], sampled on 0..1 and kept in 0..1 for LUT consumers. Float RGB rendering
/// evaluates the formula directly instead, so samples and results outside that domain survive.
pub fn tone_luts_depth(adj: &Adjustment, depth: Option<SampleType>) -> [Vec<f32>; 4] {
match (adj, depth) {
(Adjustment::Levels { master, per_channel, space: ToneSpace::Rgb, .. }, Some(SampleType::F32)) => {
@@ -648,7 +684,7 @@ pub fn levels_q(ch: &LevelsChannel, v: f32, quantum: Option<f32>) -> f32 {
/// the clipped curve).
pub fn levels_float(ch: &LevelsChannel, v: f32) -> f32 {
let t = (v - ch.in_black) / (ch.in_white - ch.in_black).max(1e-6);
let t = t.signum() * t.abs().powf(1.0 / ch.gamma.max(0.01));
let t = if ch.gamma == 1.0 { t } else { t.signum() * t.abs().powf(1.0 / ch.gamma.max(0.01)) };
ch.out_black + t * (ch.out_white - ch.out_black)
}
+96 -1
View File
@@ -1529,9 +1529,11 @@ fn levels_dont_clip_in_32_bit() {
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Srgb, Some(depth));
(b.px[0][0] * 255.0).round()
};
for (v, want) in [(0.0, 0.0), (0.0497, 3.0), (0.0976, 32.0), (0.4508, 140.0), (0.9473, 255.0), (1.0, 255.0)] {
for (v, want) in [(0.0497, 3.0), (0.0976, 32.0), (0.4508, 140.0), (0.9473, 255.0)] {
assert_eq!(level(SampleType::F32, v), want, "{v}");
}
assert!(level(SampleType::F32, 0.0) < 0.0);
assert!(level(SampleType::F32, 1.0) > 255.0);
assert_eq!(level(SampleType::U8, 0.0), 10.0);
assert_eq!(level(SampleType::U8, 1.0), 245.0);
}
@@ -1564,6 +1566,99 @@ fn exposure_scales_32_bit_samples_as_stored() {
}
}
#[test]
fn float_adjustments_preserve_hdr_range() {
let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }];
let levels = LevelsChannel { in_black: 0.25, in_white: 0.75, gamma: 2.0, out_black: -0.1, out_white: 1.5 };
let adjustments = [
(Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 }, [4.0, 2.0, 0.5]),
(Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 }, [8.0, 4.0, 1.0]),
(
Adjustment::Levels { master: Default::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() },
[4.0, 2.0, 0.5],
),
(
Adjustment::Levels { master: levels, per_channel: Default::default(), space: Default::default(), black: Default::default() },
[4.0, 2.0, 0.5].map(|v| -0.1 + ((v - 0.25_f32) / 0.5).sqrt() * 1.6),
),
(
Adjustment::Curves {
master: identity.clone(),
per_channel: std::array::from_fn(|_| identity.clone()),
space: Default::default(),
black: Vec::new(),
},
[4.0, 2.0, 0.5],
),
(Adjustment::Curves { master: Vec::new(), per_channel: Default::default(), space: Default::default(), black: Vec::new() }, [4.0, 2.0, 0.5]),
];
for (adj, want) in adjustments {
let mut d = Document::new("HDR", Size::new(1, 1), ColorMode::Rgb, SampleType::F32);
let mut l = Layer::raster("pixels", PixelFormat::RGBA32F);
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[4.0, 2.0, 0.5, 0.75]);
d.layers = vec![l, Layer::new("adjustment", LayerContent::Adjustment(adj.clone()))];
let got = px(&d, 0, 0);
for (a, b) in got[..3].iter().zip(want) {
assert!((a - b).abs() < 1e-5, "{adj:?}: {got:?} vs {want:?}");
}
assert_eq!(got[3], 0.75);
}
}
#[test]
fn float_tone_adjustments_preserve_negative_and_untouched_channels() {
let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }];
let edited = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.5 }];
for (adj, want) in [
(
Adjustment::Levels { master: Default::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() },
[-0.1, 4.0, 0.5],
),
(
Adjustment::Curves { master: identity.clone(), per_channel: [identity.clone(), Vec::new(), edited], space: Default::default(), black: Vec::new() },
[-0.1, 4.0, 0.25],
),
] {
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [-0.1, 4.0, 0.5, 0.75]);
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
for (a, b) in b.px[0][..3].iter().zip(want) {
assert!((a - b).abs() < 1e-5, "{adj:?}: {:?} vs {want:?}", b);
}
assert_eq!(b.px[0][3], 0.75);
}
}
#[test]
fn float_exposure_stops_round_trip_without_losing_highlights() {
let input = [4.0, -2.0, 0.5, 0.75];
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), input);
for exposure in [1.0, -1.0] {
adjust::apply_depth(&Adjustment::Exposure { exposure, offset: 0.0, gamma: 1.0 }, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
}
assert_eq!(b.px[0], input);
}
#[test]
fn float_exposure_preserves_signed_gamma_and_offset() {
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [-0.5, 0.0, 4.0, 0.75]);
adjust::apply_depth(&Adjustment::Exposure { exposure: 1.0, offset: -0.25, gamma: 2.0 }, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
for (got, want) in b.px[0].iter().zip([-1.25_f32.sqrt(), -0.5, 7.75_f32.sqrt(), 0.75]) {
assert!((got - want).abs() < 1e-6, "{:?}", b.px[0]);
}
}
#[test]
fn float_levels_apply_channel_before_master_outside_unit_range() {
let channel = LevelsChannel { in_black: 0.5, in_white: 1.5, ..Default::default() };
let master = LevelsChannel { out_black: -0.1, out_white: 1.5, ..Default::default() };
let adj = Adjustment::Levels { master, per_channel: std::array::from_fn(|_| channel.clone()), space: Default::default(), black: Default::default() };
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [4.0, 2.0, -0.1, 1.0]);
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
for (got, want) in b.px[0].iter().zip([5.5, 2.3, -1.06, 1.0]) {
assert!((got - want).abs() < 1e-6, "{:?}", b.px[0]);
}
}
// A 30° Reflected gradient fill on a 4 × 4 canvas renders as Photoshop's (its end point snaps to
// the corner: t = |x − y| / 4) at every depth.
#[test]
+21 -2
View File
@@ -48,7 +48,7 @@ const F_STROKE_OUT: u32 = 1024u; // effect paint: outside stroke band
const F_FIRST: u32 = 2048u; // outside strokes: nothing accumulated yet
const F_CHANNELS: u32 = 4096u; // lerp: per-channel weights in p0 (channel restrictions)
const F_LAB: u32 = 65536u; // Lab document: Normal mixes in CIELAB
const F_HDR: u32 = 262144u; // 32-bit float document: Add / Divide and Hue/Saturation/Color/Luminosity don't clip at 1
const F_HDR: u32 = 262144u; // 32-bit float document: HDR blends and Exposure retain values above 1
const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment results, integer docs)
const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups)
const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w
@@ -552,10 +552,28 @@ fn selective_color(c: vec3<f32>, relative: bool) -> vec3<f32> {
// Exposure on one channel (p = exposure scale, offset, gamma, transfer gamma). Per channel, not a
// loop over `c[i]`: FXC aborts on that inside `adjust`'s switch.
fn exposure(v: f32, p: vec4<f32>) -> f32 {
if ((op.flags & F_HDR) != 0u) {
let lin = v * p.x + p.y;
if (p.z == 1.0) { return lin; }
return sign(lin) * pow(abs(lin), 1.0 / p.z);
}
let lin = pow(max(t_decode(v, p.w) * p.x + p.y, 0.0), 1.0 / p.z);
return clamp(t_encode(lin, p.w), 0.0, 1.0);
}
// Mirrors compose::adjust::levels_float; coefficients are master, R, G, B in LUT row 0.
fn levels_float(record: i32, v: f32) -> f32 {
let base = record * 5;
let t = (v - lut_at(base)) / max(lut_at(base + 1) - lut_at(base), 0.000001);
let mapped = sign(t) * pow(abs(t), 1.0 / lut_at(base + 2));
return lut_at(base + 3) + mapped * (lut_at(base + 4) - lut_at(base + 3));
}
fn tone_channel(row: i32, v: f32, identity: f32) -> f32 {
if (identity > 0.5) { return v; }
return lut(row, v);
}
// Modern Brightness curve: line of slope 1.375^(b/50) rolled off to (1,1) by a v^P white anchor.
// Mirrors compose::adjust::modern_brightness.
fn mbright(v: f32, b: f32) -> f32 {
@@ -598,7 +616,8 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {
return vec3(mcontrast(mbright(c.r, b), ct), mcontrast(mbright(c.g, b), ct), mcontrast(mbright(c.b, b), ct));
}
case 6: { return vec3(exposure(c.r, p0), exposure(c.g, p0), exposure(c.b, p0)); } // Exposure
case 7: { return vec3(lut(0, c.r), lut(1, c.g), lut(2, c.b)); } // Levels / Curves
case 7: { return vec3(tone_channel(0, c.r, p0.x), tone_channel(1, c.g, p0.y), tone_channel(2, c.b, p0.z)); } // Levels / Curves
case 17: { return vec3(levels_float(0, levels_float(1, c.r)), levels_float(0, levels_float(2, c.g)), levels_float(0, levels_float(3, c.b))); }
case 8: { // Hue/Saturation
let hsl = rgb_to_hsl(c);
var hh: f32;
+15 -2
View File
@@ -380,8 +380,8 @@ pub const F_FIRST: u32 = 2048;
pub const F_CHANNELS: u32 = 4096;
/// Lab document: Normal blending mixes in CIELAB (`psblend::LAB_MIX`).
pub const F_LAB: u32 = 65536;
/// 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1, and the non-separable
/// modes clip only below 0 (`psblend::HDR`).
/// 32-bit float document: Add, Divide and Exposure retain values above 1; the non-separable
/// blend modes clip only below 0 (`psblend::HDR`).
pub const F_HDR: u32 = 262144;
/// `Lerp`: A rounded to `p0.x` steps per unit (adjustment results on integer documents).
pub const F_QUANT: u32 = 32768;
@@ -1557,8 +1557,21 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf
p[0] = [2f32.powf(*exposure), *offset, gamma.max(0.01), transfer_exponent(transfer.for_exposure())];
(6, p, None)
}
Adjustment::Levels { master, per_channel, space: ToneSpace::Rgb, .. } if depth == photocraft_color::SampleType::F32 => {
// Twenty coefficients exceed the sixteen uniform parameters. Store four records
// in LUT row 0, read texel-exact by the analytic float Levels kernel.
let mut row = [0.0f32; 4096];
for (i, ch) in std::iter::once(master).chain(per_channel).enumerate() {
let coefficients = [ch.in_black, ch.in_white, ch.gamma.max(0.01), ch.out_black, ch.out_white];
row[i * 5..i * 5 + 5].copy_from_slice(&coefficients);
}
(17, p, Some(vec![row]))
}
// RGB space only (see `adjustment_on_gpu`); the rows are the CPU's channel∘master LUTs.
Adjustment::Levels { .. } | Adjustment::Curves { .. } => {
for (v, identity) in p[0].iter_mut().zip(adjust::float_tone_identity(adj, Some(depth))) {
*v = if identity { 1.0 } else { 0.0 };
}
(7, p, Some(adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect()))
}
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
+35
View File
@@ -29,6 +29,40 @@ struct Gpu {
/// Simulated texture limit (pages and chunks of 256 px).
const PAGED_LIMIT: u32 = 256;
#[test]
fn hdr_adjustments_preserve_range_and_cpu_parity() {
let Some(mut g) = gpu() else { return };
let identity = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }];
let channel = LevelsChannel { in_black: 0.25, in_white: 0.75, gamma: 2.0, out_black: -0.1, out_white: 1.5 };
for adj in [
Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 },
Adjustment::Exposure { exposure: 1.0, offset: 0.1, gamma: 1.3 },
Adjustment::Exposure { exposure: 0.8, offset: -0.02, gamma: 1.2 },
Adjustment::Levels { master: Default::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() },
Adjustment::Levels {
master: channel.clone(),
per_channel: [channel.clone(), Default::default(), channel],
space: Default::default(),
black: Default::default(),
},
Adjustment::Curves { master: identity.clone(), per_channel: std::array::from_fn(|_| identity.clone()), space: Default::default(), black: Vec::new() },
Adjustment::Curves {
master: Vec::new(),
per_channel: [Vec::new(), identity.clone(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 0.5 }]],
space: Default::default(),
black: Vec::new(),
},
] {
let mut d = Document::new("HDR adjustments", Size::new(3, 1), ColorMode::Rgb, SampleType::F32);
let mut layer = Layer::raster("pixels", PixelFormat::RGBA32F);
layer.surface_mut().unwrap().write_region(Rect::new(0, 0, 3, 1), &[4.0, 2.0, 0.5, 1.0, -0.1, 3.0, 0.25, 0.75, 0.0, 0.0, 0.0, 1.0]);
d.layers = vec![layer, Layer::new("adjustment", LayerContent::Adjustment(adj.clone()))];
let cpu = photocraft_compose::flatten(&d);
assert!(cpu.px[0][0] > 1.0, "HDR reference unexpectedly clipped: {adj:?}");
check(&mut g, &d, &format!("HDR {adj:?}"));
}
}
fn gpu() -> Option<Gpu> {
let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let instance = wgpu::Instance::default();
@@ -40,6 +74,7 @@ fn gpu() -> Option<Gpu> {
}
};
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
eprintln!("GPU parity backend: {:?}", adapter.get_info().backend);
// Exact parity needs 32-bit float targets. Adapters without them (e.g. GL software
// rasterizers) use the CPU compositor in the app, so there is nothing to compare there.
if Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
+7 -2
View File
@@ -376,7 +376,9 @@ pub fn merged_composite(file: &PsdFile) -> Result<Vec<[f32; 4]>, IoError> {
})
.collect());
}
let img = file.composite_rgba8().ok();
// The RGBA8 preview clips signed/HDR values. Decode float merged images at their
// original depth through the model path below; they are the rendering oracle.
let img = if h.depth == 32 { None } else { file.composite_rgba8().ok() };
// CMYK goes through the colour-managed model conversion (the PSD crate's RGBA preview is a
// naive, profile-free conversion).
if let (Some(img), false) = (img, matches!(h.color_mode, photocraft_psd::ColorMode::Lab | photocraft_psd::ColorMode::Cmyk)) {
@@ -398,7 +400,10 @@ pub fn merged_composite(file: &PsdFile) -> Result<Vec<[f32; 4]>, IoError> {
.collect());
}
// Generic path (Lab, CMYK and others) via the raster model conversion.
let (doc, _) = psd_to_document(&PsdFile { layer_info: None, ..file.clone() });
// Keep the merged-alpha marker while discarding layers: otherwise an extra
// spot/alpha channel in an opaque composite is mistaken for transparency.
let layer_info = Some(photocraft_psd::LayerInfo { merged_alpha: file.merged_has_alpha(), layers: Vec::new(), padding: None });
let (doc, _) = psd_to_document(&PsdFile { layer_info, ..file.clone() });
// Multichannel documents keep their channels apart (no layer): composite them.
if doc.layers.is_empty() && doc.mode == photocraft_color::ColorMode::Multichannel {
return Ok(photocraft_compose::flatten(&doc).px);
+43
View File
@@ -160,3 +160,46 @@ fn photoshop_32bit_corpus_round_trip() {
assert!(d <= 1e-4, "{name}: render differs by {d}");
}
}
// Supply independent float merged planes: using the RGBA8 preview as the oracle
// silently clips these samples, even though Photoshop stores the full range.
#[test]
fn merged_oracle_preserves_signed_hdr_samples() {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for alpha in [1.0, 0.5] {
let doc = Document::new("float merged", Size::new(3, 1), mode, SampleType::F32);
let mut file = PsdFile::from_bytes(&export_psd(&doc)).unwrap();
let color_channels = if mode == ColorMode::Rgb { 3 } else { 1 };
file.layer_info = None;
file.header.channels = color_channels + u16::from(alpha != 1.0);
file.image_data.compression = Compression::Raw;
// Photoshop's merged pixels are matted against white when alpha is present.
file.image_data.data =
(0..color_channels).flat_map(|_| [-0.125_f32, 0.123456, 4.0].into_iter().flat_map(|v| (v * alpha + 1.0 - alpha).to_be_bytes())).collect();
if alpha != 1.0 {
file.image_data.data.extend((0..3).flat_map(|_| alpha.to_be_bytes()));
}
let pixels = merged_composite(&file).unwrap();
for (p, value) in pixels.iter().zip([-0.125, 0.123456, 4.0]) {
for (got, want) in p.iter().zip([value, value, value, alpha]) {
assert!((got - want).abs() < 1e-6, "{mode:?}, alpha {alpha}: {p:?}");
}
}
}
}
}
#[test]
fn merged_oracle_does_not_treat_extra_channels_as_transparency() {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
let doc = Document::new("extra channel", Size::new(1, 1), mode, SampleType::F32);
let mut file = PsdFile::from_bytes(&export_psd(&doc)).unwrap();
let color_channels = if mode == ColorMode::Rgb { 3 } else { 1 };
file.header.channels = color_channels + 1;
file.layer_info = Some(photocraft_psd::LayerInfo { merged_alpha: false, layers: Vec::new(), padding: None });
file.image_data.compression = Compression::Raw;
file.image_data.data = (0..color_channels).flat_map(|_| 2.0_f32.to_be_bytes()).chain(0.0_f32.to_be_bytes()).collect();
assert!(!file.merged_has_alpha());
assert_eq!(merged_composite(&file).unwrap(), vec![[2.0, 2.0, 2.0, 1.0]], "{mode:?}");
}
}