mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-28 10:11:58 +01:00
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped In 32-bit documents Photoshop keeps these adjustments' results above 1 and below 0. On the photoshop corpus's rgb32/gray32 files: - Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit: 128) and is exact on every pixel. - Channel Mixer cuts each source percentage to 1/1024 toward zero, counts 255/512 of the constant and is exact on pure red, green, blue, black and white. - Photo Filter works on the linear samples, negative results included. - The Black & White tint is the Color blend mode without the clip at 1, as the blend mode is there; white tints to (1.05, 1.00, 0.87). We clipped all four to 0..1. The GPU kernels follow suit under F_HDR (the legacy pivot and the mixer's steps come from the planner), and the HDR parity test covers them. Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32 channel-mixer and black-white-tint pass (145 -> 149 of 258); floor 145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is its colour (#3790). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Levels: whole levels at every step and Photoshop's shadow toe In 8 bits Photoshop rounds each of the three Levels steps to a whole level (half up): the input-range stretch (already modelled), the midtone gamma and the output range. A narrow output range therefore merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to 58 through a 0.47 channel gamma and the master's 48..255, where the unrounded curve gave 56..59 (3 levels off in blue). The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma that joins the power curve with the same value and slope (q = 2.2, fitted). With the rounding, that is exact on 1116 of the 1118 levels of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7: every level exact). Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors: photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Masks: feathers blur with exactly the feather's variance A mask feather is a Gaussian with sigma = feather, which we approximate with three box passes per axis. Whole-pixel boxes rounded the variance: a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so feathered edges were too soft by up to 4 levels against Photoshop (psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px pixel feather). Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on the whole radius and a fractional weight on the two samples beyond it, so three passes have variance sigma^2 exactly. It stays O(n), reusing the window's leaving sample for the low end weight (50 MP at a 1000 px feather: 180 ms parallel, was 172). psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS), layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %). Corpus test 239 -> 240; floor 236 -> 240. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Lab: adjustment layers composite on the stored L, a and b The compositor reads a Lab document's layers through display RGB, which clips colours outside its gamut, so the adjustments Photoshop evaluates on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels and Curves with Lab records) only saw the clipped colours: the photoshop corpus's Lab oracles were up to 11 levels off on saturated colours. compose::native_lab works a Lab stack of plain layers out on the stored values instead, like the CMYK ink path: Normal mixes L, a and b linearly, an opaque layer keeps them exactly, a layer mask scales the mix, and adjustment layers run adjust::lab_stored_op on each pixel. Stacks with such adjustments render for display this way; flattening a plain Lab stack keeps its stored values (exports no longer clip). Anything else (other blend modes, effects, other adjustments) keeps the RGB compositor. Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves and brightness-contrast-legacy; lab8 invert and posterize-4. lab8 levels and legacy B/C come within a level (their 8-bit rules are #5143 and #5132). psd-tools and io unchanged; every round trip unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Threshold: split on Photoshop's luminosity of whole samples Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B (the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey, taken over the document's whole sample values: - 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity rounded to a level with halves down reaches the level; - 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits (round(level x 32768 / 255): mid grey 16448 is level 128); - 32 bits: the luminosity reaches the level. Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split 28 the other way), threshold_16bits_rgb (100) and threshold_16bits_ grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on the level) split the same way on both. psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test 239 -> 241, floor 236 -> 241. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does At 16 bits Photoshop doesn't round to 8-bit levels before binning. It bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels through a 16-bit step: floor(b * 32768 / (n - 1)), converted with floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where 8 bits give 127, while 42.5 and 25.5 stay 42 and 25. Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7, 13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule missed up to half the range at bin edges. The same rule applies to CMYK and Lab channels at 16 bits: on the stored inks (#4945's native path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of pixels off. 8-bit Posterize is unchanged. The GPU kernel runs the same rule; 16-bit layers reach it as half floats, so a sample within their precision of a bin edge can land in the neighbouring bin there (the CPU, which exports, is exact). psd-tools 239 -> 241; floor 236 -> 241. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Groups: a pass-through clipping base with an adjustment renders isolated A pass-through group composites its children straight into the backdrop, so an adjustment layer inside reaches the layers beneath. When the group is the base of a clipping group and holds an adjustment layer, Photoshop renders it isolated instead: in psd-tools passthrough_clipping_mask_adjustment an Exposure in such a group leaves the layers beneath alone (we darkened them: half the image 170 levels off). The same group with a Linear Dodge child and no adjustment keeps reaching the backdrop (passthrough_clipping_mask_blendmode), and so do clipping-mask3/4/5, so the rule is the adjustment, not the clipping alone (isolating every pass-through clipping base broke those two). CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
254 lines
11 KiB
Rust
254 lines
11 KiB
Rust
//! Lab-native compositing of plain Lab layer stacks.
|
||
//!
|
||
//! The compositor works in display RGB, so a Lab document's layers are read through `lab_to_srgb`,
|
||
//! which clips colours outside the display gamut, and adjustments Photoshop evaluates on L, a and
|
||
//! b ([`adjust::lab_stored_op`]) can then only see those clipped colours: an inverted or levelled
|
||
//! saturated colour comes out several levels off. When every visible layer is a plain Lab raster
|
||
//! layer in Normal mode or such an adjustment layer, the stack is worked out on the stored values
|
||
//! instead: Normal mixes L, a and b linearly, as Photoshop does in Lab, and an opaque layer keeps
|
||
//! them exactly. A layer mask (unfeathered) scales the mix.
|
||
//!
|
||
//! Stacks with such adjustment layers render for display this way ([`render_lab`]); flattening
|
||
//! ([`flatten_lab`]) keeps the stored values for any plain stack.
|
||
|
||
use photocraft_color::blend::BlendMode;
|
||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||
use photocraft_doc::{Document, Layer, LayerContent, LayerMask};
|
||
use photocraft_geom::Rect;
|
||
use photocraft_raster::Surface;
|
||
|
||
use crate::Buffer;
|
||
use crate::adjust::{self, LabOp};
|
||
|
||
/// Colour channels of a Lab pixel.
|
||
const LAB: usize = 3;
|
||
|
||
/// Whether the compositor would only blend `layer` Normally at its opacity through its layer mask:
|
||
/// no clipping, vector mask, feathered mask, effects, excluded channels, Blend If or advanced
|
||
/// blending.
|
||
fn simple(layer: &Layer) -> bool {
|
||
layer.blend == BlendMode::Normal
|
||
&& !layer.clipped
|
||
&& !crate::masks::has_feather(layer)
|
||
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
|
||
&& !crate::effects::has_effects(layer)
|
||
&& layer.excluded_channels == 0
|
||
&& layer.blend_if.is_default()
|
||
&& layer.advanced.is_default()
|
||
&& layer.video.is_none()
|
||
}
|
||
|
||
/// One step of a native stack, at the layer's opacity × fill, through its mask.
|
||
enum Item<'a> {
|
||
Layer(&'a Surface, f32, Option<&'a LayerMask>),
|
||
Adjust(LabOp<'a>, f32, Option<&'a LayerMask>),
|
||
}
|
||
|
||
/// Groups nested deeper than this keep the RGB compositor.
|
||
const MAX_DEPTH: usize = 64;
|
||
|
||
/// `layers`' visible steps, appended to `items`; `None` when one needs the RGB compositor. A group
|
||
/// at full opacity without a mask is inlined: pass-through groups composite their children straight
|
||
/// onto the backdrop, and so do isolated (Normal) groups of raster layers alone, since Normal
|
||
/// composites associatively; an adjustment inside an isolated group would only see the group's
|
||
/// own content, so such a group keeps the RGB compositor (`adjustments` false).
|
||
fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: &mut Vec<Item<'a>>, level: usize) -> Option<()> {
|
||
if level > MAX_DEPTH {
|
||
return None;
|
||
}
|
||
for layer in layers.iter().filter(|l| l.visible) {
|
||
let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0);
|
||
let mask = layer.mask.as_ref().filter(|m| m.enabled);
|
||
let item = match &layer.content {
|
||
LayerContent::Group(g) => {
|
||
let open = g.artboard.is_none()
|
||
&& alpha >= 1.0
|
||
&& mask.is_none()
|
||
&& !layer.clipped
|
||
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
|
||
&& !crate::effects::has_effects(layer)
|
||
&& layer.excluded_channels == 0
|
||
&& layer.blend_if.is_default()
|
||
&& layer.advanced.is_default();
|
||
match layer.blend {
|
||
BlendMode::PassThrough if open => collect(&g.children, depth, adjustments, items, level + 1)?,
|
||
BlendMode::Normal if open => collect(&g.children, depth, false, items, level + 1)?,
|
||
_ => return None,
|
||
}
|
||
continue;
|
||
}
|
||
LayerContent::Raster(s) if simple(layer) && s.format().mode == ColorMode::Lab => Item::Layer(s, alpha, mask),
|
||
LayerContent::Adjustment(adj) if adjustments && simple(layer) => Item::Adjust(adjust::lab_stored_op(adj, Some(depth))?, alpha, mask),
|
||
_ => return None,
|
||
};
|
||
items.push(item);
|
||
}
|
||
Some(())
|
||
}
|
||
|
||
/// A Lab document's visible layers, prepared for compositing on their stored L, a and b.
|
||
pub struct LabStack<'a> {
|
||
items: Vec<Item<'a>>,
|
||
}
|
||
|
||
impl<'a> LabStack<'a> {
|
||
/// `None` unless `doc` is Lab and every visible layer is a plain Lab raster layer, an
|
||
/// adjustment layer [`adjust::lab_stored_op`] evaluates, or a group that can be inlined (see
|
||
/// the module docs and [`collect`]).
|
||
pub fn new(doc: &'a Document) -> Option<Self> {
|
||
if doc.mode != ColorMode::Lab {
|
||
return None;
|
||
}
|
||
let mut items = Vec::new();
|
||
collect(&doc.layers, doc.depth, true, &mut items, 0)?;
|
||
Some(LabStack { items })
|
||
}
|
||
|
||
/// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly).
|
||
pub fn adjusts(&self) -> bool {
|
||
self.items.iter().any(|i| matches!(i, Item::Adjust(..)))
|
||
}
|
||
|
||
/// Straight L, a, b and alpha over `rect`, row-major, into `acc` (`src` and `mv` are scratch).
|
||
fn composite(&self, rect: Rect, acc: &mut Vec<[f32; LAB + 1]>, src: &mut Vec<f32>, mv: &mut Vec<f32>) {
|
||
let n = rect.width() as usize * rect.height() as usize;
|
||
acc.clear();
|
||
acc.resize(n, [0.0, adjust::LAB_NEUTRAL, adjust::LAB_NEUTRAL, 0.0]);
|
||
// The mask's value at each pixel (1 without one).
|
||
let masked = |mask: Option<&LayerMask>, mv: &mut Vec<f32>| match mask {
|
||
Some(m) => m.values_into(rect, mv),
|
||
None => {
|
||
mv.clear();
|
||
mv.resize(n, 1.0);
|
||
}
|
||
};
|
||
for item in &self.items {
|
||
match item {
|
||
Item::Layer(surf, opacity, mask) => {
|
||
let opacity = *opacity;
|
||
if opacity <= 0.0 || (surf.content_bounds().intersect(&rect).is_empty() && surf.default_pixel().get(LAB).is_none_or(|a| *a <= 0.0)) {
|
||
continue;
|
||
}
|
||
let sf = surf.format();
|
||
let sn = sf.channels();
|
||
surf.read_region_into(rect, src);
|
||
masked(*mask, mv);
|
||
for ((d, s), k) in acc.iter_mut().zip(src.chunks_exact(sn)).zip(mv.iter()) {
|
||
let sa = if sf.alpha { s.get(LAB).copied().unwrap_or(1.0) } else { 1.0 } * opacity * k.clamp(0.0, 1.0);
|
||
if sa <= 0.0 {
|
||
continue;
|
||
}
|
||
let oa = sa + d[LAB] * (1.0 - sa);
|
||
let keep = d[LAB] * (1.0 - sa);
|
||
for (c, v) in s.iter().take(LAB).enumerate() {
|
||
// An opaque pixel replaces what is below exactly (no rounding through the mix).
|
||
d[c] = if sa >= 1.0 { *v } else { (v * sa + d[c] * keep) / oa };
|
||
}
|
||
d[LAB] = oa;
|
||
}
|
||
}
|
||
Item::Adjust(op, opacity, mask) => {
|
||
if *opacity <= 0.0 {
|
||
continue;
|
||
}
|
||
masked(*mask, mv);
|
||
for (d, k) in acc.iter_mut().zip(mv.iter()) {
|
||
let mix = opacity * k.clamp(0.0, 1.0);
|
||
if d[LAB] <= 0.0 || mix <= 0.0 {
|
||
continue;
|
||
}
|
||
let mut lab = [d[0], d[1], d[2]];
|
||
op(&mut lab);
|
||
for (c, v) in lab.iter().enumerate() {
|
||
d[c] += (v - d[c]) * mix;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The composite of `doc` over `rect` in `fmt`, worked out on stored L, a and b, or `None` when
|
||
/// the document isn't Lab, `fmt` isn't, or a visible layer needs the full compositor (see the
|
||
/// module docs). `white` flattens onto white (L 100, neutral a and b) and makes the result opaque.
|
||
pub fn flatten_lab(doc: &Document, rect: Rect, fmt: PixelFormat, white: bool) -> Option<Surface> {
|
||
if fmt.mode != ColorMode::Lab {
|
||
return None;
|
||
}
|
||
let stack = LabStack::new(doc)?;
|
||
let mut out = Surface::new(fmt);
|
||
if rect.is_empty() {
|
||
return Some(out);
|
||
}
|
||
let n = fmt.channels();
|
||
let empty = out.default_pixel();
|
||
let rows = crate::band_rows_for(rect.width(), 0);
|
||
let (mut acc, mut src, mut mv, mut data) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||
let mut y = rect.y0;
|
||
while y < rect.y1 {
|
||
let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1));
|
||
stack.composite(band, &mut acc, &mut src, &mut mv);
|
||
data.clear();
|
||
data.resize(acc.len() * n, 0.0);
|
||
for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) {
|
||
let a = p[LAB];
|
||
if white {
|
||
let paper = [1.0, adjust::LAB_NEUTRAL, adjust::LAB_NEUTRAL];
|
||
for c in 0..LAB {
|
||
o[c] = p[c] * a + paper[c] * (1.0 - a);
|
||
}
|
||
if fmt.alpha {
|
||
o[LAB] = 1.0;
|
||
}
|
||
} else if a <= 0.0 {
|
||
o.copy_from_slice(&empty[..n]);
|
||
} else {
|
||
o[..LAB].copy_from_slice(&p[..LAB]);
|
||
if fmt.alpha {
|
||
o[LAB] = a;
|
||
}
|
||
}
|
||
}
|
||
out.write_region(band, &data);
|
||
y = band.y1;
|
||
}
|
||
out.prune();
|
||
Some(out)
|
||
}
|
||
|
||
/// The display composite (straight RGBA) of a Lab document whose stack has adjustment layers that
|
||
/// work on L, a and b, worked out on the stored values; `None` for any other document, which the
|
||
/// RGB compositor renders. Rows of `tile` render in parallel.
|
||
pub fn render_lab(doc: &Document, rect: Rect, tile: i32) -> Option<Buffer> {
|
||
let stack = LabStack::new(doc).filter(LabStack::adjusts)?;
|
||
let w = rect.width() as usize;
|
||
let mut out = Buffer::transparent(rect);
|
||
if w == 0 || rect.is_empty() {
|
||
return Some(out);
|
||
}
|
||
let rows = tile.max(1) as usize;
|
||
let band = |i: usize, px: &mut [[f32; 4]]| {
|
||
let y0 = rect.y0.saturating_add((i * rows) as i32);
|
||
let r = Rect::new(rect.x0, y0, rect.x1, y0.saturating_add((px.len() / w) as i32));
|
||
let (mut acc, mut src, mut mv) = (Vec::new(), Vec::new(), Vec::new());
|
||
stack.composite(r, &mut acc, &mut src, &mut mv);
|
||
for (o, p) in px.iter_mut().zip(&acc) {
|
||
if p[LAB] > 0.0 {
|
||
let [r, g, b] = photocraft_color::convert::lab_to_srgb([p[0] * 100.0, p[1] * 255.0 - 128.0, p[2] * 255.0 - 128.0]);
|
||
*o = [r, g, b, p[LAB]];
|
||
}
|
||
}
|
||
};
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
use rayon::prelude::*;
|
||
out.px.par_chunks_mut(w * rows).enumerate().for_each(|(i, px)| band(i, px));
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
for (i, px) in out.px.chunks_mut(w * rows).enumerate() {
|
||
band(i, px);
|
||
}
|
||
Some(out)
|
||
}
|