Files
photocraft/crates/compose/src/native_lab.rs
T
4c450b3879 Lab: adjustment layers composite on the stored L, a and b (seven Lab oracles pass) (#5171)
* 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>
2026-10-14 14:10:36 -04:00

254 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)
}