mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
@@ -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]]];
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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 } => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user