mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-17 20:56:50 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10bd7ad47c |
No files matched your search
@@ -30,6 +30,15 @@ impl Transfer {
|
||||
_ => Transfer::Srgb,
|
||||
}
|
||||
}
|
||||
/// The tone curve Exposure linearises through: RGB documents use a pure 2.2 power, not the
|
||||
/// sRGB curve (psd-tools adjustment_nested_composition_4: an offset of 0.1738 lifts 76 to 134,
|
||||
/// not 136; ag-psd masks), grey documents their dot-gain gamma.
|
||||
pub fn for_exposure(self) -> Self {
|
||||
match self {
|
||||
Transfer::Srgb => Transfer::Gamma(2.2),
|
||||
t => t,
|
||||
}
|
||||
}
|
||||
fn decode(self, v: f32) -> f32 {
|
||||
match self {
|
||||
Transfer::Srgb => photocraft_color::convert::srgb_to_linear(v.max(0.0)),
|
||||
@@ -51,6 +60,13 @@ pub fn apply(adj: &Adjustment, buf: &mut Buffer) {
|
||||
|
||||
/// Applies an adjustment with the document's tone transfer.
|
||||
pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
apply_depth(adj, buf, transfer, None);
|
||||
}
|
||||
|
||||
/// Applies an adjustment with the document's tone transfer, on samples with `quantum` steps
|
||||
/// per unit (the document's integer depth, see `adjustment_quantum`): Levels then works on
|
||||
/// whole levels like Photoshop's (see [`levels_q`]).
|
||||
pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, quantum: Option<f32>) {
|
||||
match adj {
|
||||
Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]),
|
||||
Adjustment::Threshold { level } => {
|
||||
@@ -89,6 +105,7 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
// through the document tone curve (fitted on the corpus).
|
||||
let m = 2f32.powf(*exposure);
|
||||
let g = gamma.max(0.01);
|
||||
let transfer = transfer.for_exposure();
|
||||
map_rgb(buf, |c| {
|
||||
c.map(|v| {
|
||||
let lin = (transfer.decode(v) * m + offset).max(0.0).powf(1.0 / g);
|
||||
@@ -97,7 +114,7 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
})
|
||||
}
|
||||
Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => {
|
||||
let luts = tone_luts(adj);
|
||||
let luts = tone_luts_q(adj, quantum);
|
||||
match space {
|
||||
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i]))),
|
||||
ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(*space, &luts, c)),
|
||||
@@ -203,12 +220,18 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
/// `per_channel` channels then black (identity unless the space is CMYK). In Lab there is no
|
||||
/// composite record, so the master is ignored.
|
||||
pub fn tone_luts(adj: &Adjustment) -> [Vec<f32>; 4] {
|
||||
tone_luts_q(adj, None)
|
||||
}
|
||||
|
||||
/// [`tone_luts`] for samples with `quantum` steps per unit (Levels works on whole levels; see
|
||||
/// [`levels_q`]).
|
||||
pub fn tone_luts_q(adj: &Adjustment, quantum: Option<f32>) -> [Vec<f32>; 4] {
|
||||
let x = |k: usize| k as f32 / (LUT_SIZE - 1) as f32;
|
||||
match adj {
|
||||
Adjustment::Levels { master, per_channel, space, black } => {
|
||||
let ident = LevelsChannel::default();
|
||||
let m = if *space == ToneSpace::Lab { &ident } else { master };
|
||||
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels(c, levels(m, x(k)))).collect();
|
||||
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels_q(c, levels_q(m, x(k), quantum), quantum)).collect();
|
||||
[row(&per_channel[0]), row(&per_channel[1]), row(&per_channel[2]), row(if *space == ToneSpace::Cmyk { black } else { &ident })]
|
||||
}
|
||||
Adjustment::Curves { master, per_channel, space, black } => {
|
||||
@@ -458,20 +481,37 @@ pub fn posterize(v: f32, levels: u32) -> f32 {
|
||||
}
|
||||
|
||||
pub fn levels(ch: &LevelsChannel, v: f32) -> f32 {
|
||||
levels_q(ch, v, None)
|
||||
}
|
||||
|
||||
/// [`levels`] on samples with `quantum` steps per unit (8-bit: 255). Photoshop works on whole
|
||||
/// levels: the input is a level, and the input-range stretch is rounded (half up) to a whole
|
||||
/// level before the midtone gamma, so a 44..214 range maps 45, 46, 47, 48 → 1.5, 3, 4.5, 6 →
|
||||
/// 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the steps). In a LUT
|
||||
/// the result is a staircase that is exact at every level.
|
||||
pub fn levels_q(ch: &LevelsChannel, v: f32, quantum: Option<f32>) -> f32 {
|
||||
let range = (ch.in_white - ch.in_black).max(1e-6);
|
||||
let u = ((v - ch.in_black) / range).clamp(0.0, 1.0);
|
||||
let u = match quantum.filter(|q| q.is_finite() && *q >= 1.0) {
|
||||
Some(q) => {
|
||||
let v = (v * q).round() / q;
|
||||
(((v - ch.in_black) / range).clamp(0.0, 1.0) * q + 0.5 + 1e-3).floor() / q
|
||||
}
|
||||
None => ((v - ch.in_black) / range).clamp(0.0, 1.0),
|
||||
};
|
||||
let g = ch.gamma.max(0.01);
|
||||
let t = if g > 1.0 {
|
||||
// A midtone gamma > 1 lifts shadows, and a pure `u^(1/g)` has infinite slope at black —
|
||||
// which Photoshop bounds, giving a soft shadow toe (initial slope 2^gamma, verified against
|
||||
// the real app). Soft-min (p-norm) of the power curve with the slope-2^gamma line; reduces
|
||||
// to the exact power curve in the body, so highlights are unchanged. gamma <= 1 is untouched.
|
||||
// which Photoshop bounds, giving a soft shadow toe (initial slope ≈ 2^gamma, verified against
|
||||
// the real app). Soft-min (p-norm) of the power curve with that line; reduces to the exact
|
||||
// power curve in the body, so highlights are unchanged. gamma <= 1 is untouched. Slope and
|
||||
// sharpness fitted on whole-level input (`levels_q`): a 0..255 ramp at gamma 2.0 and
|
||||
// psd-tools levels_grayscale's gamma 1.78 (within one level of Photoshop on both).
|
||||
let power = u.powf(1.0 / g);
|
||||
let line = 2.0f32.powf(g) * u;
|
||||
let line = 0.93 * 2.0f32.powf(g) * u;
|
||||
if power <= 1e-6 || line <= 1e-6 {
|
||||
power.min(line)
|
||||
} else {
|
||||
let p = 1.8 + 8.8 / g; // p-norm sharpness, fit to Photoshop ground truth
|
||||
let p = 10.0; // p-norm sharpness, fit to Photoshop ground truth
|
||||
(power.powf(-p) + line.powf(-p)).powf(-1.0 / p)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Gradient fill layer geometry.
|
||||
//!
|
||||
//! Photoshop lays a gradient fill layer's Linear and Reflected gradients out between whole-pixel
|
||||
//! end points: the chord through the frame's centre along the angle (as [`effects::gradient_t`])
|
||||
//! ends at points truncated to the pixel grid. On large frames that is invisible, on small ones it
|
||||
//! changes the effective angle: a 30° Reflected fill on a 4 × 4 canvas ends at the corner
|
||||
//! `(4, 0)` instead of `(4, 0.845)` and renders as a 45° one (fitted on the psd-tools `colormodes`
|
||||
//! files, whose 16/32-bit versions store no fill pixels).
|
||||
//!
|
||||
//! The snapped end points are expressed as the angle / scale / centre offset that
|
||||
//! [`effects::gradient_t`] (and the GPU's `gradient_t`) already take, so both compositors share
|
||||
//! one layout.
|
||||
|
||||
use photocraft_doc::GradientStyle;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::effects;
|
||||
|
||||
/// Effective `(angle, scale, centre offset)` for a gradient fill with `style`, `angle` (degrees)
|
||||
/// and `scale` laid out in `frame`.
|
||||
pub fn fill_gradient_layout(style: GradientStyle, angle: f32, scale: f32, frame: Rect) -> (f32, f32, (f32, f32)) {
|
||||
let unchanged = (angle, scale, (0.0, 0.0));
|
||||
if !matches!(style, GradientStyle::Linear | GradientStyle::Reflected) || !angle.is_finite() || !scale.is_finite() {
|
||||
return unchanged;
|
||||
}
|
||||
let w = f64::from(frame.width().max(1));
|
||||
let h = f64::from(frame.height().max(1));
|
||||
let (cx, cy) = (f64::from(frame.x0) + w / 2.0, f64::from(frame.y0) + h / 2.0);
|
||||
// Unscaled chord length along `a` (radians), as `effects::gradient_t`.
|
||||
let chord_of = |a: f64| {
|
||||
let (s, c) = a.sin_cos();
|
||||
(w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0)
|
||||
};
|
||||
let a = f64::from(angle).to_radians();
|
||||
let scale64 = f64::from(scale.max(1e-3));
|
||||
let half = chord_of(a) * scale64 / 2.0;
|
||||
let (s, c) = a.sin_cos();
|
||||
let (dx, dy) = (c * half, -s * half);
|
||||
// Truncate to the pixel grid (with a little slack for rounding error in exact cases).
|
||||
let snap = |v: f64| (v + 1e-4).floor();
|
||||
let end = (snap(cx + dx), snap(cy + dy));
|
||||
let (start, mid) = match style {
|
||||
GradientStyle::Reflected => ((cx, cy), (cx, cy)),
|
||||
_ => {
|
||||
let s0 = (snap(cx - dx), snap(cy - dy));
|
||||
(s0, ((s0.0 + end.0) / 2.0, (s0.1 + end.1) / 2.0))
|
||||
}
|
||||
};
|
||||
let v = (end.0 - start.0, end.1 - start.1);
|
||||
let len = v.0.hypot(v.1);
|
||||
if len < 0.5 {
|
||||
return unchanged;
|
||||
}
|
||||
let a2 = (-v.1).atan2(v.0);
|
||||
// Reflected spans half the chord from the centre; Linear the whole chord.
|
||||
let span = if style == GradientStyle::Reflected { 2.0 * len } else { len };
|
||||
let scale2 = span / chord_of(a2);
|
||||
let offset = (((mid.0 - cx) / w) as f32, ((mid.1 - cy) / h) as f32);
|
||||
(a2.to_degrees() as f32, scale2 as f32, offset)
|
||||
}
|
||||
|
||||
/// Gradient parameter `t` of a gradient fill at pixel centre `(x, y)` (see [`fill_gradient_layout`]).
|
||||
pub fn fill_gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, frame: Rect, x: f32, y: f32) -> f32 {
|
||||
let (a, s, o) = fill_gradient_layout(style, angle, scale, frame);
|
||||
effects::gradient_t(style, a, s, reverse, o, frame, x, y)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn grid(style: GradientStyle, angle: f32, frame: Rect) -> Vec<f32> {
|
||||
let mut v = Vec::new();
|
||||
for y in frame.y0..frame.y1 {
|
||||
for x in frame.x0..frame.x1 {
|
||||
v.push(fill_gradient_t(style, angle, 1.0, false, frame, x as f32 + 0.5, y as f32 + 0.5));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_reflected_30_degrees_snaps_to_the_corner() {
|
||||
// Photoshop's 4 × 4 30° Reflected fill: t = |x − y| / 4.
|
||||
let t = grid(GradientStyle::Reflected, 30.0, Rect::new(0, 0, 4, 4));
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
let want = (x as f32 - y as f32).abs() / 4.0;
|
||||
assert!((t[y * 4 + x] - want).abs() < 1e-4, "({x},{y}) {} vs {want}", t[y * 4 + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_layouts_are_unchanged() {
|
||||
for (style, angle, frame) in [
|
||||
(GradientStyle::Linear, 90.0, Rect::new(0, 0, 33, 20)),
|
||||
(GradientStyle::Linear, 0.0, Rect::new(-5, 3, 40, 17)),
|
||||
(GradientStyle::Reflected, 45.0, Rect::new(0, 0, 32, 32)),
|
||||
(GradientStyle::Linear, -90.0, Rect::new(10, 10, 210, 210)),
|
||||
] {
|
||||
for y in [frame.y0, (frame.y0 + frame.y1) / 2, frame.y1 - 1] {
|
||||
for x in [frame.x0, (frame.x0 + frame.x1) / 2, frame.x1 - 1] {
|
||||
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||
let a = fill_gradient_t(style, angle, 1.0, false, frame, px, py);
|
||||
let b = effects::gradient_t(style, angle, 1.0, false, (0.0, 0.0), frame, px, py);
|
||||
assert!((a - b).abs() < 1e-3, "{style:?} {angle} {frame:?} ({x},{y}): {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_frames_barely_move_and_bad_input_is_harmless() {
|
||||
let f = Rect::new(0, 0, 3000, 2000);
|
||||
let (a, s, o) = fill_gradient_layout(GradientStyle::Linear, 30.0, 1.0, f);
|
||||
assert!((a - 30.0).abs() < 0.05 && (s - 1.0).abs() < 1e-3 && o.0.abs() < 1e-3 && o.1.abs() < 1e-3);
|
||||
for (ang, sc) in [(f32::NAN, 1.0), (30.0, f32::INFINITY), (30.0, 0.0), (1e30, 1.0)] {
|
||||
let t = fill_gradient_t(GradientStyle::Linear, ang, sc, false, Rect::new(0, 0, 4, 4), 1.5, 1.5);
|
||||
assert!(t.is_nan() || (0.0..=1.0).contains(&t));
|
||||
}
|
||||
assert_eq!(fill_gradient_layout(GradientStyle::Radial, 30.0, 1.0, f), (30.0, 1.0, (0.0, 0.0)));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
pub mod adjust;
|
||||
pub mod bounds;
|
||||
pub mod effects;
|
||||
pub mod fill_layout;
|
||||
pub mod masks;
|
||||
pub mod multichannel;
|
||||
pub mod pattern;
|
||||
@@ -645,10 +646,11 @@ fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedP
|
||||
})
|
||||
.collect();
|
||||
// Gradient geometry relative to the layer's frame, independent of the render rect.
|
||||
let (angle, scale, offset) = fill_layout::fill_gradient_layout(*style, *angle, *scale, canvas);
|
||||
let mut b = Buffer::transparent(rect);
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
let t = effects::gradient_t(*style, *angle, *scale, *reverse, (0.0, 0.0), canvas, x as f32 + 0.5, y as f32 + 0.5);
|
||||
let t = effects::gradient_t(*style, angle, scale, *reverse, offset, canvas, x as f32 + 0.5, y as f32 + 0.5);
|
||||
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
|
||||
b.px[i] = sample_stops(&stops, t);
|
||||
}
|
||||
@@ -935,7 +937,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
// Adjustment layers transform the backdrop, then blend the result back in.
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = backdrop.clone();
|
||||
adjust::apply_with(adj, &mut adjusted, cx.transfer);
|
||||
adjust::apply_depth(adj, &mut adjusted, cx.transfer, adjustment_quantum(cx.depth));
|
||||
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut adjusted, cx);
|
||||
@@ -1200,7 +1202,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
let rect = base.rect;
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = base.clone();
|
||||
adjust::apply_with(adj, &mut adjusted, cx.transfer);
|
||||
adjust::apply_depth(adj, &mut adjusted, cx.transfer, adjustment_quantum(cx.depth));
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for (i, p) in base.px.iter_mut().enumerate() {
|
||||
let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
|
||||
|
||||
@@ -1077,6 +1077,70 @@ fn levels_matches_photoshop() {
|
||||
assert!((adjust::levels(&ident(1.0), 0.37) - 0.37).abs() < 1e-6);
|
||||
}
|
||||
|
||||
// Photoshop (psd-tools levels_grayscale, 8-bit): input range 44..214, gamma 1.78. The stretch
|
||||
// is rounded to whole levels before the gamma (45 → 1.5 → 2, 47 → 4.5 → 5), so the shadows
|
||||
// step unevenly: 45, 46, 47, 48, 52, 69 → 7, 10, 17, 20, 37, 88.
|
||||
#[test]
|
||||
fn levels_work_on_whole_levels() {
|
||||
let ch = LevelsChannel { in_black: 44.0 / 255.0, in_white: 214.0 / 255.0, gamma: 1.78, out_black: 0.0, out_white: 1.0 };
|
||||
let adj = Adjustment::Levels {
|
||||
master: LevelsChannel::default(),
|
||||
per_channel: [ch.clone(), ch.clone(), ch.clone()],
|
||||
space: Default::default(),
|
||||
black: LevelsChannel::default(),
|
||||
};
|
||||
let luts = adjust::tone_luts_q(&adj, Some(255.0));
|
||||
let at = |v: u8| {
|
||||
let x = f32::from(v) / 255.0 * 4095.0;
|
||||
let (i, f) = (x.floor() as usize, x.fract());
|
||||
let y = luts[0][i] * (1.0 - f) + luts[0][(i + 1).min(4095)] * f;
|
||||
(y * 255.0).round()
|
||||
};
|
||||
for (v, ps) in [(44u8, 0.0f32), (45, 7.0), (46, 10.0), (47, 17.0), (48, 20.0), (52, 37.0), (69, 88.0), (100, 137.0), (214, 255.0)] {
|
||||
assert!((at(v) - ps).abs() <= 1.0, "levels {v}: got {} want {ps}", at(v));
|
||||
}
|
||||
// 16-bit and float stay smooth (no 8-bit staircase).
|
||||
for q in [Some(32768.0), None] {
|
||||
let a = adjust::levels_q(&ch, 0.2, q);
|
||||
let b = adjust::levels_q(&ch, 0.2 + 1.0 / 2048.0, q);
|
||||
assert!(b > a && b - a < 2.0 / 255.0, "{q:?}: {a} {b}");
|
||||
}
|
||||
// Hostile quantum values fall back to the continuous curve.
|
||||
for q in [Some(0.0), Some(f32::NAN), Some(-3.0)] {
|
||||
assert_eq!(adjust::levels_q(&ch, 0.4, q), adjust::levels(&ch, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
// Exposure linearises RGB documents through a 2.2 power, not the sRGB curve: Photoshop lifts 76
|
||||
// to 134 with an offset of 0.1738 (psd-tools adjustment_nested_composition_4).
|
||||
#[test]
|
||||
fn exposure_offset_uses_gamma_2_2_in_rgb() {
|
||||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [76.0 / 255.0, 76.0 / 255.0, 76.0 / 255.0, 1.0]);
|
||||
adjust::apply_with(&Adjustment::Exposure { exposure: 0.0, offset: 0.1738, gamma: 1.0 }, &mut b, adjust::Transfer::Srgb);
|
||||
assert_eq!((b.px[0][0] * 255.0).round(), 134.0);
|
||||
assert_eq!(adjust::Transfer::Gamma(1.732).for_exposure(), adjust::Transfer::Gamma(1.732));
|
||||
}
|
||||
|
||||
// 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]
|
||||
fn small_gradient_fill_matches_photoshop_at_all_depths() {
|
||||
let stops = vec![(0.0, Color::rgb(0.0, 0.0, 0.0)), (1.0, Color::rgb(1.0, 1.0, 1.0))];
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut d = Document::new("g", Size::new(4, 4), ColorMode::Rgb, depth);
|
||||
let fill = Fill::Gradient { stops: stops.clone(), angle: 30.0, scale: 1.0, style: photocraft_doc::GradientStyle::Reflected, reverse: false };
|
||||
d.layers.push(Layer::new("g", LayerContent::Fill(fill)));
|
||||
let out = flatten(&d);
|
||||
for y in 0..4usize {
|
||||
for x in 0..4usize {
|
||||
let want = (x as f32 - y as f32).abs() / 4.0;
|
||||
let got = out.px[y * 4 + x][0];
|
||||
assert!((got - want).abs() < 1.0 / 255.0, "{depth:?} ({x},{y}): {got} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tall document with soft content, a translucent region and an adjustment.
|
||||
fn tall_doc(w: u32, h: u32) -> Document {
|
||||
let mut d = doc_white(w, h);
|
||||
|
||||
@@ -726,9 +726,13 @@ impl<'a> Planner<'a> {
|
||||
}
|
||||
Fill::Gradient { stops, angle, scale, style, reverse } => {
|
||||
p.gradient = true;
|
||||
p.params[0] = [*angle, *scale, if *reverse { 1.0 } else { 0.0 }, style_index(*style)];
|
||||
// compose::render_fill: whole-pixel end points (fill_layout).
|
||||
let (angle, scale, offset) = photocraft_compose::fill_layout::fill_gradient_layout(*style, *angle, *scale, frame);
|
||||
p.params[0] = [angle, scale, if *reverse { 1.0 } else { 0.0 }, style_index(*style)];
|
||||
let c = frame;
|
||||
p.params[1] = [c.x0 as f32, c.y0 as f32, c.width() as f32, c.height() as f32];
|
||||
p.params[2][0] = offset.0;
|
||||
p.params[2][1] = offset.1;
|
||||
let conv: Vec<(f32, [f32; 4])> = stops
|
||||
.iter()
|
||||
.map(|(t, c)| {
|
||||
@@ -798,7 +802,7 @@ impl<'a> Planner<'a> {
|
||||
}
|
||||
let mut p = Pass::new(Kernel::Adjust, 0);
|
||||
p.a = Some(src);
|
||||
let (kind, params, lut) = adjustment_program(adj, self.cx.transfer);
|
||||
let (kind, params, lut) = adjustment_program(adj, self.cx.transfer, photocraft_compose::adjustment_quantum(self.cx.depth));
|
||||
p.adjust_kind = kind;
|
||||
p.params = params;
|
||||
p.lut = lut;
|
||||
@@ -1237,7 +1241,7 @@ pub fn adjustment_on_gpu(adj: &Adjustment) -> bool {
|
||||
}
|
||||
|
||||
/// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`.
|
||||
pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
|
||||
pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, quantum: Option<f32>) -> Program {
|
||||
let mut p = [[0.0f32; 4]; 4];
|
||||
match adj {
|
||||
Adjustment::Invert => (1, p, None),
|
||||
@@ -1262,7 +1266,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
|
||||
(5, p, None)
|
||||
}
|
||||
Adjustment::Exposure { exposure, offset, gamma } => {
|
||||
let g = match transfer {
|
||||
let g = match transfer.for_exposure() {
|
||||
Transfer::Srgb => 0.0,
|
||||
Transfer::Gamma(g) => g,
|
||||
};
|
||||
@@ -1270,7 +1274,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
|
||||
(6, p, None)
|
||||
}
|
||||
// RGB space only (see `adjustment_on_gpu`); the rows are the CPU's channel∘master LUTs.
|
||||
Adjustment::Levels { .. } | Adjustment::Curves { .. } => (7, p, Some(adjust::tone_luts(adj).iter().take(3).map(|t| to_row(t)).collect())),
|
||||
Adjustment::Levels { .. } | Adjustment::Curves { .. } => (7, p, Some(adjust::tone_luts_q(adj, quantum).iter().take(3).map(|t| to_row(t)).collect())),
|
||||
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
|
||||
p[0] = [*hue, saturation / 100.0, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }];
|
||||
if !*colorize && ranges.iter().any(|r| !r.is_neutral()) {
|
||||
|
||||
@@ -230,6 +230,11 @@ fn adjustment_layers() {
|
||||
d.layers.push(noise_layer("bg", fmt, Rect::from_xywh(0, 0, 40, 30), 71, 0.5));
|
||||
d.layers.push(Layer::new("ex", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.01, gamma: 1.1 })));
|
||||
check(&mut g, &d, &format!("adjustment {depth:?}"));
|
||||
// Levels on whole levels of the document's depth (compose::adjust::levels_q).
|
||||
let lc = LevelsChannel { in_black: 0.17, in_white: 0.84, gamma: 1.78, out_black: 0.0, out_white: 1.0 };
|
||||
let lv = Adjustment::Levels { master: lc, per_channel: Default::default(), space: Default::default(), black: LevelsChannel::default() };
|
||||
d.layers.push(Layer::new("lv", LayerContent::Adjustment(lv)));
|
||||
check(&mut g, &d, &format!("levels {depth:?}"));
|
||||
}
|
||||
for adj in adjustments() {
|
||||
for (mode, opacity, masked) in [(BlendMode::Normal, 1.0, false), (BlendMode::Multiply, 0.7, true)] {
|
||||
@@ -334,6 +339,14 @@ fn fills_and_dissolve() {
|
||||
d.layers.push(l);
|
||||
check(&mut g, &d, &format!("gradient {style:?}"));
|
||||
}
|
||||
// Tiny frames, where the whole-pixel end points (compose::fill_layout) change the angle
|
||||
// and centre.
|
||||
for (w, h, style, angle) in [(4, 4, GradientStyle::Reflected, 30.0), (7, 5, GradientStyle::Linear, 30.0), (9, 4, GradientStyle::Linear, -60.0)] {
|
||||
let mut d = base_doc(w, h);
|
||||
let stops = vec![(0.0, Color::rgb(0.0, 0.0, 0.7)), (0.5, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(1.0, 1.0, 0.0))];
|
||||
d.layers.push(Layer::new("grad", LayerContent::Fill(Fill::Gradient { stops, angle, scale: 1.0, style, reverse: false })));
|
||||
check(&mut g, &d, &format!("small gradient {w}x{h} {style:?} {angle}"));
|
||||
}
|
||||
let mut d = base_doc(64, 48);
|
||||
let mut l = noise_layer("dis", PixelFormat::RGBA8, Rect::new(0, 0, 64, 48), 41, 0.2);
|
||||
l.blend = BlendMode::Dissolve;
|
||||
|
||||
@@ -104,6 +104,8 @@ pub fn color_from_desc(d: &Descriptor) -> Option<Color> {
|
||||
b"RGBC" => Color::rgb(g("Rd ")? / 255.0, g("Grn ")? / 255.0, g("Bl ")? / 255.0),
|
||||
b"CMYC" => Color { mode: ColorMode::Cmyk, c: [g("Cyn ")? / 100.0, g("Mgnt")? / 100.0, g("Ylw ")? / 100.0, g("Blck")? / 100.0], alpha: 1.0 },
|
||||
b"Grsc" => Color::gray(1.0 - g("Gry ")? / 100.0),
|
||||
// CIE L*a*b*: L 0..100, a / b −128..127 (stored like Lab pixels: offset by 128).
|
||||
b"LbCl" => Color { mode: ColorMode::Lab, c: [g("Lmnc")? / 100.0, (g("A ")? + 128.0) / 255.0, (g("B ")? + 128.0) / 255.0, 0.0], alpha: 1.0 },
|
||||
// Newer Photoshop writes 0..1 floats.
|
||||
_ if d.get("redFloat").is_some() => Color::rgb(g("redFloat")?, g("greenFloat")?, g("blueFloat")?),
|
||||
_ => {
|
||||
@@ -123,6 +125,9 @@ pub fn color_to_desc(c: &Color) -> Descriptor {
|
||||
.with("Ylw ", d(c.c[2] * 100.0))
|
||||
.with("Blck", d(c.c[3] * 100.0)),
|
||||
ColorMode::Grayscale => Descriptor::new("Grsc").with("Gry ", d((1.0 - c.c[0]) * 100.0)),
|
||||
ColorMode::Lab => {
|
||||
Descriptor::new("LbCl").with("Lmnc", d(c.c[0] * 100.0)).with("A ", d(c.c[1] * 255.0 - 128.0)).with("B ", d(c.c[2] * 255.0 - 128.0))
|
||||
}
|
||||
_ => {
|
||||
let rgb = c.to_rgb();
|
||||
Descriptor::new("RGBC").with("Rd ", d(rgb[0] * 255.0)).with("Grn ", d(rgb[1] * 255.0)).with("Bl ", d(rgb[2] * 255.0))
|
||||
@@ -522,6 +527,23 @@ mod tests {
|
||||
assert_eq!(opaque, vec![(0.0, red), (1.0, Color::WHITE)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_descriptor_colours_round_trip() {
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_psd::descriptor::{Descriptor, Value};
|
||||
let d = Descriptor::new("LbCl").with("Lmnc", Value::Double(19.07)).with("A ", Value::Double(52.29)).with("B ", Value::Double(-85.08));
|
||||
let c = super::color_from_desc(&d).unwrap();
|
||||
assert_eq!(c.mode, ColorMode::Lab);
|
||||
assert!((c.c[0] - 0.1907).abs() < 1e-5 && (c.c[1] * 255.0 - 128.0 - 52.29).abs() < 1e-3 && (c.c[2] * 255.0 - 128.0 + 85.08).abs() < 1e-3);
|
||||
let back = super::color_from_desc(&super::color_to_desc(&c)).unwrap();
|
||||
assert_eq!(back.mode, ColorMode::Lab);
|
||||
for i in 0..3 {
|
||||
assert!((back.c[i] - c.c[i]).abs() < 1e-6);
|
||||
}
|
||||
// Missing components are rejected, not defaulted.
|
||||
assert!(super::color_from_desc(&Descriptor::new("LbCl").with("Lmnc", Value::Double(50.0))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_ranges_map_to_blend_if() {
|
||||
use photocraft_doc::{BlendIf, BlendRange};
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! colours (end tangents at half the chord slope); Perceptual interpolates in Oklab, Linear in
|
||||
//! linear light, Classic in sRGB. Stop midpoints remap each segment piecewise-linearly.
|
||||
|
||||
use photocraft_color::Color;
|
||||
use photocraft_color::{Color, ColorMode};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Method {
|
||||
@@ -117,7 +117,10 @@ fn eval(stops: &[(f32, [f32; 3])], mids: &[f32], smooth: f32, t: f32) -> [f32; 3
|
||||
/// already interpolates linearly in sRGB.
|
||||
pub(crate) fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, Color)> {
|
||||
let plain_mids = midpoints.iter().all(|m| (m - 0.5).abs() < 1e-3);
|
||||
if stops.len() < 2 || (smoothness <= 0.0 && method == Method::Classic && plain_mids) {
|
||||
// Classic interpolates in the stops' own model: Lab stops (Lab documents) blend in L*a*b*, which the
|
||||
// compositors' sRGB interpolation can't reproduce (psd-tools 4x4_16bit_lab).
|
||||
let lab = method == Method::Classic && stops.iter().all(|(_, c)| c.mode == ColorMode::Lab);
|
||||
if stops.len() < 2 || (smoothness <= 0.0 && method == Method::Classic && plain_mids && !lab) {
|
||||
return stops;
|
||||
}
|
||||
let mut s = stops;
|
||||
@@ -126,12 +129,16 @@ pub(crate) fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32,
|
||||
// Stop colours carry no alpha in Photoshop (opacity has its own stops); keep the nearest.
|
||||
s.iter().min_by(|a, b| (a.0 - t).abs().total_cmp(&(b.0 - t).abs())).map_or(1.0, |x| x.1.alpha)
|
||||
};
|
||||
let pts: Vec<(f32, [f32; 3])> = s.iter().map(|(t, c)| (*t, into_space(method, c.to_rgb()))).collect();
|
||||
let pts: Vec<(f32, [f32; 3])> = s.iter().map(|(t, c)| (*t, if lab { [c.c[0], c.c[1], c.c[2]] } else { into_space(method, c.to_rgb()) })).collect();
|
||||
const N: usize = 96;
|
||||
let mut out: Vec<(f32, Color)> = (0..=N)
|
||||
.map(|k| {
|
||||
let t = k as f32 / N as f32;
|
||||
let rgb = out_of_space(method, eval(&pts, midpoints, smoothness.clamp(0.0, 1.0), t));
|
||||
let v = eval(&pts, midpoints, smoothness.clamp(0.0, 1.0), t);
|
||||
if lab {
|
||||
return (t, Color { mode: ColorMode::Lab, c: [v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0), v[2].clamp(0.0, 1.0), 0.0], alpha: alpha(t) });
|
||||
}
|
||||
let rgb = out_of_space(method, v);
|
||||
(t, Color::rgba(rgb[0], rgb[1], rgb[2], alpha(t)))
|
||||
})
|
||||
.collect();
|
||||
@@ -184,6 +191,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_lab_stops_interpolate_in_lab() {
|
||||
// Lab documents blend Classic gradients in L*a*b* (psd-tools 4x4_16bit_lab): the half-way
|
||||
// colour is the Lab average, even without smoothness.
|
||||
let lab = |l: f32, a: f32, b: f32| Color { mode: ColorMode::Lab, c: [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0, 0.0], alpha: 1.0 };
|
||||
let s = bake(vec![(0.0, lab(19.07, 52.29, -85.08)), (1.0, lab(54.29, 80.81, 69.91))], &[0.5], 0.0, Method::Classic);
|
||||
assert!(s.len() > 2 && s.iter().all(|(_, c)| c.mode == ColorMode::Lab));
|
||||
let mid = s.iter().find(|(t, _)| (t - 0.5).abs() < 1e-6).map(|(_, c)| *c).unwrap();
|
||||
let want = lab(36.68, 66.55, -7.585);
|
||||
for i in 0..3 {
|
||||
assert!((mid.c[i] - want.c[i]).abs() < 1e-4, "{mid:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_shifts_the_half_way_colour() {
|
||||
let s = bake(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], &[0.25], 0.0, Method::Classic);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, Pattern};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_psd::patterns::{PsdPattern, block_key, mode_channels, parse_pattern_block, write_pattern_block};
|
||||
@@ -69,7 +69,10 @@ pub fn from_psd(p: &PsdPattern) -> Option<Pattern> {
|
||||
fill.push(max_sample(s));
|
||||
invert.push(false);
|
||||
}
|
||||
let bytes = interleave(&refs, &fill, w * h, s, &invert);
|
||||
let mut bytes = interleave(&refs, &fill, w * h, s, &invert);
|
||||
if mode == ColorMode::Lab && s == SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, refs.len(), true);
|
||||
}
|
||||
let surface = Surface::from_interleaved(fmt, Rect::new(0, 0, w as i32, h as i32), &bytes);
|
||||
Some(Pattern { id: p.id.clone(), name: p.name.clone(), width: p.width, height: p.height, surface })
|
||||
}
|
||||
@@ -83,8 +86,11 @@ pub fn to_psd(p: &Pattern) -> PsdPattern {
|
||||
};
|
||||
let fmt = PixelFormat::new(mode, f.sample, f.alpha);
|
||||
let surf = if fmt == f { p.surface.clone() } else { p.surface.convert(fmt) };
|
||||
let bytes = surf.to_interleaved(p.rect());
|
||||
let mut bytes = surf.to_interleaved(p.rect());
|
||||
let ch = fmt.channels();
|
||||
if mode == ColorMode::Lab && fmt.sample == SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, ch, false);
|
||||
}
|
||||
let mut invert = vec![mode == ColorMode::Cmyk; ch];
|
||||
if fmt.alpha {
|
||||
invert[ch - 1] = false;
|
||||
|
||||
@@ -104,6 +104,27 @@ pub fn max_sample(s: SampleType) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Top of Photoshop's 16-bit Lab a*/b* scale: it stores `32768 + 256·a` (0..65280 spans −128..127,
|
||||
/// fitted on psd-tools `4x4_16bit_lab`: the merged image holds a stop's `LbCl` a* = 52.29 as 46154),
|
||||
/// while documents keep a*/b* on the 8-bit scale `(a + 128) / 255` at every depth.
|
||||
pub const LAB16_CHROMA_MAX: f32 = 65280.0;
|
||||
|
||||
/// Rescales the a*/b* channels (1 and 2) of interleaved native-endian 16-bit Lab pixels with
|
||||
/// `ch` channels: PSD → document when `to_doc`, else document → PSD. Round-trips exactly.
|
||||
pub fn lab16_chroma(bytes: &mut [u8], ch: usize, to_doc: bool) {
|
||||
if ch < 3 {
|
||||
return;
|
||||
}
|
||||
let k = if to_doc { 65535.0 / f64::from(LAB16_CHROMA_MAX) } else { f64::from(LAB16_CHROMA_MAX) / 65535.0 };
|
||||
for px in bytes.chunks_exact_mut(ch * 2) {
|
||||
for c in 1..3 {
|
||||
let v = u16::from_ne_bytes([px[2 * c], px[2 * c + 1]]);
|
||||
let w = (f64::from(v) * k).round().min(65535.0) as u16;
|
||||
px[2 * c..2 * c + 2].copy_from_slice(&w.to_ne_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native-endian encoding of zero.
|
||||
pub fn zero_sample(s: SampleType) -> Vec<u8> {
|
||||
vec![0; s.bytes()]
|
||||
@@ -198,6 +219,34 @@ mod tests {
|
||||
assert_eq!(il, vec![9, 5, 9, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab16_chroma_scale_round_trips() {
|
||||
// Two pixels of three channels: L is untouched, a*/b* rescale, and every PSD value
|
||||
// comes back exactly.
|
||||
let mut all = Vec::new();
|
||||
for v in 0..=u16::MAX {
|
||||
all.extend_from_slice(&v.to_ne_bytes());
|
||||
all.extend_from_slice(&v.to_ne_bytes());
|
||||
all.extend_from_slice(&v.to_ne_bytes());
|
||||
}
|
||||
let orig = all.clone();
|
||||
lab16_chroma(&mut all, 3, true);
|
||||
let px = |b: &[u8], i: usize, c: usize| u16::from_ne_bytes([b[(i * 3 + c) * 2], b[(i * 3 + c) * 2 + 1]]);
|
||||
// Neutral (32768 in PSD) is 128 / 255 of the document scale; 65280 (a* = 127) is the top.
|
||||
assert_eq!(px(&all, 32768, 0), 32768);
|
||||
assert_eq!(px(&all, 32768, 1), (32768.0f64 * 65535.0 / 65280.0).round() as u16);
|
||||
assert_eq!(px(&all, 65280, 2), 65535);
|
||||
lab16_chroma(&mut all, 3, false);
|
||||
for i in 0..=65280usize {
|
||||
assert_eq!(px(&all, i, 1), px(&orig, i, 1), "{i}");
|
||||
}
|
||||
// Short or odd buffers are left alone.
|
||||
let mut short = vec![1u8, 2, 3];
|
||||
lab16_chroma(&mut short, 3, true);
|
||||
lab16_chroma(&mut short, 1, true);
|
||||
assert_eq!(short, [1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn be_codec() {
|
||||
let mut v = Vec::new();
|
||||
|
||||
@@ -88,7 +88,10 @@ impl Ex {
|
||||
if r.is_empty() {
|
||||
return (PsdRect::default(), self.empty_channels());
|
||||
}
|
||||
let bytes = s.to_interleaved(r);
|
||||
let mut bytes = s.to_interleaved(r);
|
||||
if self.fmt.mode == ColorMode::Lab && self.fmt.sample == SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false);
|
||||
}
|
||||
let mut invert = vec![self.cmyk; self.cc];
|
||||
invert.push(false);
|
||||
let planes = deinterleave(&bytes, self.cc + 1, self.fmt.sample, &invert);
|
||||
@@ -502,6 +505,7 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) ->
|
||||
let canvas = doc.bounds();
|
||||
let w = canvas.width() as usize;
|
||||
let space = photocraft_compose::cmyk_space(doc);
|
||||
let lab16 = fmt.mode == ColorMode::Lab && sample == SampleType::U16;
|
||||
let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> {
|
||||
// Retain alpha whenever it differs from opaque at the stored precision.
|
||||
has_alpha |= band.px.iter().any(|p| match sample {
|
||||
@@ -523,6 +527,8 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) ->
|
||||
// Matte against white like Photoshop (see `pixels::matte`).
|
||||
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
|
||||
let x = if cmyk && c < cc { 1.0 - m } else { m };
|
||||
// 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`).
|
||||
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
|
||||
encode_be(x, sample, &mut out[c]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,10 @@ impl Ctx<'_> {
|
||||
fill.push(max_sample(s));
|
||||
let mut invert = vec![self.cmyk; self.cc];
|
||||
invert.push(false);
|
||||
let bytes = interleave(&refs, &fill, w * h, s, &invert);
|
||||
let mut bytes = interleave(&refs, &fill, w * h, s, &invert);
|
||||
if self.fmt.mode == ColorMode::Lab && s == SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, true);
|
||||
}
|
||||
let mut surf = Surface::from_interleaved(self.fmt, Rect::new(r.left, r.top, r.right, r.bottom), &bytes);
|
||||
surf.prune();
|
||||
surf
|
||||
@@ -527,7 +530,10 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec<String>) {
|
||||
fill.push(max_sample(depth));
|
||||
let mut inv = vec![cx.cmyk; cc];
|
||||
inv.push(false);
|
||||
let bytes = interleave(&planes, &fill, n, depth, &inv);
|
||||
let mut bytes = interleave(&planes, &fill, n, depth, &inv);
|
||||
if fmt.mode == ColorMode::Lab && depth == SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, cc + 1, true);
|
||||
}
|
||||
let mut s = Surface::from_interleaved(fmt, canvas, &bytes);
|
||||
if alpha_idx.is_some() {
|
||||
// Undo Photoshop's white matting of the merged image.
|
||||
|
||||
@@ -64,6 +64,63 @@ const PASS_TOL: f32 = 2.0 / 255.0;
|
||||
/// Export → re-import must render within one 8-bit step of the imported document.
|
||||
const ROUNDTRIP_TOL: f32 = 1.0 / 255.0 + 1e-5;
|
||||
|
||||
/// Dissolve block size and tolerance (see [`dissolve_matches`]).
|
||||
const DISSOLVE_BLOCK: i32 = 16;
|
||||
const DISSOLVE_TOL: f32 = 0.1;
|
||||
|
||||
/// Oracle for documents with Dissolve layers. Photoshop's dissolve decides each pixel with a
|
||||
/// position-only pseudo-random threshold (observed on psd-tools dissolve.psd: where dissolved
|
||||
/// layers of equal opacity overlap, a pixel shows the top layer or nothing, never a lower one),
|
||||
/// but its generator is not public and can't be recovered from one binary pattern; ours uses
|
||||
/// another hash with the same rule. So outside the dissolve layers' bounds pixels must match
|
||||
/// as usual (≤ 2/255), and inside them the premultiplied colour averaged over 16 × 16 blocks
|
||||
/// (density and colour) must agree within 0.1 (binomial noise of a 50 % dissolve is about
|
||||
/// 0.044 per block difference).
|
||||
fn dissolve_matches(doc: &photocraft_doc::Document, ours: &[[f32; 4]], ps: &[[f32; 4]]) -> bool {
|
||||
let canvas = doc.bounds();
|
||||
let regions: Vec<_> = doc
|
||||
.walk()
|
||||
.into_iter()
|
||||
.filter(|(_, _, l)| l.visible && l.blend == photocraft_color::BlendMode::Dissolve)
|
||||
.map(|(_, _, l)| photocraft_compose::layer_bounds(l, canvas).intersect(&canvas))
|
||||
.filter(|r| !r.is_empty())
|
||||
.collect();
|
||||
let (w, h) = (canvas.width() as i32, canvas.height() as i32);
|
||||
if regions.is_empty() || ours.len() != ps.len() || ours.len() != (w as usize) * (h as usize) {
|
||||
return false;
|
||||
}
|
||||
let pm = |p: &[f32; 4], c: usize| if c < 3 { p[c] * p[3] } else { p[3] };
|
||||
let inside = |x: i32, y: i32| regions.iter().any(|r| r.contains(x + canvas.x0, y + canvas.y0));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = (y * w + x) as usize;
|
||||
if !inside(x, y) && (0..4).any(|c| (pm(&ours[i], c) - pm(&ps[i], c)).abs() > PASS_TOL) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for by in (0..h).step_by(DISSOLVE_BLOCK as usize) {
|
||||
for bx in (0..w).step_by(DISSOLVE_BLOCK as usize) {
|
||||
let (x1, y1) = ((bx + DISSOLVE_BLOCK).min(w), (by + DISSOLVE_BLOCK).min(h));
|
||||
let n = ((x1 - bx) * (y1 - by)) as f32;
|
||||
for c in 0..4 {
|
||||
let (mut a, mut b) = (0.0, 0.0);
|
||||
for y in by..y1 {
|
||||
for x in bx..x1 {
|
||||
let i = (y * w + x) as usize;
|
||||
a += pm(&ours[i], c);
|
||||
b += pm(&ps[i], c);
|
||||
}
|
||||
}
|
||||
if ((a - b) / n).abs() > DISSOLVE_TOL {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Files known to be invalid upstream: their parse error is expected.
|
||||
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
|
||||
|
||||
@@ -80,13 +137,13 @@ struct Source {
|
||||
roundtrip_floor: usize,
|
||||
}
|
||||
|
||||
/// Hand-picked mix in `corpus/psd` (170 files: 121 vs the merged image + 12 vs the thumbnail).
|
||||
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 133, roundtrip_floor: 169 };
|
||||
/// Hand-picked mix in `corpus/psd` (170 files: 128 vs the merged image + 12 vs the thumbnail).
|
||||
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 140, roundtrip_floor: 169 };
|
||||
|
||||
/// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`):
|
||||
/// 202 vs the merged image + 10 vs the thumbnail.
|
||||
/// 212 vs the merged image + 10 vs the thumbnail.
|
||||
const PSD_TOOLS: Source =
|
||||
Source { label: "psd-tools corpus", env: "PHOTOCRAFT_PSDTOOLS_CORPUS", default_dir: "corpus/psd-tools", pass_floor: 212, roundtrip_floor: 307 };
|
||||
Source { label: "psd-tools corpus", env: "PHOTOCRAFT_PSDTOOLS_CORPUS", default_dir: "corpus/psd-tools", pass_floor: 222, roundtrip_floor: 307 };
|
||||
|
||||
/// The corpus directory and whether floors are enforced; `None` when absent.
|
||||
fn locate(src: &Source) -> Option<(PathBuf, bool)> {
|
||||
@@ -280,7 +337,13 @@ fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option<f32>) {
|
||||
let m = common::max_diff(&ours, &merged);
|
||||
let bad = ours.iter().zip(&merged).filter(|(a, b)| (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > PASS_TOL)).count();
|
||||
let pct = 100.0 * bad as f32 / ours.len().max(1) as f32;
|
||||
let (outcome, status) = if m <= PASS_TOL { (Outcome::Pass, "PASS") } else { (Outcome::Diff, "DIFF") };
|
||||
let (outcome, status) = if m <= PASS_TOL {
|
||||
(Outcome::Pass, "PASS")
|
||||
} else if dissolve_matches(doc, &ours, &merged) {
|
||||
(Outcome::Pass, "PASS (dissolve metric)")
|
||||
} else {
|
||||
(Outcome::Diff, "DIFF")
|
||||
};
|
||||
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
|
||||
eprintln!("{name:<60} {layers:>6} {:>9.4} {:>7.2}% {status} {}", m, pct, notes.join(" | "));
|
||||
if std::env::var_os("CORPUS_THUMB_CALIBRATE").is_some()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! 16-bit Lab PSDs: Photoshop stores a*/b* as `32768 + 256·a` (0..65280 spans −128..127), not
|
||||
//! on the full 0..65535 range. Layer pixels, the merged image and patterns use that scale, and
|
||||
//! the documents' a*/b* stay on the 8-bit scale `(a + 128) / 255` at every depth.
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::PsdFile;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// a* = 52.29, b* = −85.08 (a gradient stop of psd-tools 4x4_16bit_lab) on the document scale.
|
||||
const A: f32 = (52.29 + 128.0) / 255.0;
|
||||
const B: f32 = (-85.08 + 128.0) / 255.0;
|
||||
|
||||
fn lab_doc(depth: SampleType) -> Document {
|
||||
let mut d = Document::new("lab", Size::new(3, 2), ColorMode::Lab, depth);
|
||||
let fmt = PixelFormat::new(ColorMode::Lab, depth, true);
|
||||
let mut s = Surface::new(fmt);
|
||||
let px: Vec<f32> = (0..6).flat_map(|_| [0.1907, A, B, 1.0]).collect();
|
||||
s.write_region(Rect::new(0, 0, 3, 2), &px);
|
||||
d.layers.push(Layer::new("px", LayerContent::Raster(s)));
|
||||
d
|
||||
}
|
||||
|
||||
fn be16(b: &[u8], i: usize) -> u16 {
|
||||
u16::from_be_bytes([b[2 * i], b[2 * i + 1]])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab16_chroma_uses_photoshops_scale() {
|
||||
let doc = lab_doc(SampleType::U16);
|
||||
let out = export(&doc, "x.psd", &ExportOptions::default()).unwrap();
|
||||
let file = PsdFile::from_bytes(&out.bytes).unwrap();
|
||||
assert_eq!(file.header.depth, 16);
|
||||
// Merged image: planes L, a, b.
|
||||
let m = file.decode_merged().unwrap();
|
||||
let n = 6;
|
||||
let a = be16(&m, n);
|
||||
let b = be16(&m, 2 * n);
|
||||
assert!((i32::from(a) - (32768 + (256.0f32 * 52.29).round() as i32)).abs() <= 1, "a = {a}");
|
||||
assert!((i32::from(b) - (32768 - (256.0f32 * 85.08).round() as i32)).abs() <= 1, "b = {b}");
|
||||
// Layer channel 1 (a*) the same.
|
||||
let rec = &file.layers()[0];
|
||||
let plane = rec.decode_channel(1, 16, file.header.version).unwrap();
|
||||
assert!((i32::from(be16(&plane, 0)) - i32::from(a)).abs() <= 1);
|
||||
// And back: the document values survive.
|
||||
let back = import("x.psd", &out.bytes).unwrap().document;
|
||||
let s = back.layers[0].surface().unwrap();
|
||||
let p = s.pixel(1, 1);
|
||||
assert!((p[1] - A).abs() < 2e-5 && (p[2] - B).abs() < 2e-5, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_round_trips_at_every_depth() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let doc = lab_doc(depth);
|
||||
let out = export(&doc, "x.psd", &ExportOptions::default()).unwrap();
|
||||
let back = import("x.psd", &out.bytes).unwrap().document;
|
||||
let p = back.layers[0].surface().unwrap().pixel(2, 0);
|
||||
let tol = if depth == SampleType::U8 { 0.5 / 255.0 + 1e-6 } else { 2e-5 };
|
||||
assert!((p[1] - A).abs() <= tol && (p[2] - B).abs() <= tol, "{depth:?}: {p:?}");
|
||||
let a = photocraft_compose::flatten(&doc).px;
|
||||
let b = photocraft_compose::flatten(&back).px;
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
for c in 0..4 {
|
||||
assert!((x[c] - y[c]).abs() <= 1.0 / 255.0, "{depth:?}: {x:?} vs {y:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user