mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 03:46:50 +01:00
* Modern Brightness/Contrast: Photoshop's brightness curve The modern brightness curve was a line rolled off by a v^P term. It sat up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels off for lowered brightness. The curve is refit to the Photoshop ground truth we have: - the ±50 samples from 4759d12b; - the photoshop corpus's brightness +25 / contrast +30 files, inverted through the (exact) contrast curve. Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255, then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the exact inverse of the curve raised by as much (the -50 samples confirm this to within a level). Every measured point is within about a level. For strong values the knee moves down so the curve stays monotone across -150..150. Brightness then contrast are evaluated through one 4096-entry table, built in 55 us. The GPU uses that same table through the Levels/Curves kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree by construction. Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now pass (145 -> 151 of 258). The floor goes 133 -> 151. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CMYK: Brightness/Contrast curves each ink, as Photoshop does Photoshop applies Brightness/Contrast in a CMYK document to each ink's stored value, 1 - ink, black included. Both the modern and the legacy curve do this; the cmyk8 brightness-contrast(-legacy) oracles match it within a level on every pixel of the files' own inks. Raising brightness takes ink away. We applied it to the screen colour instead. - cmyk_channel_adjustment gains Brightness/Contrast. apply_in builds the channel curve once (brightness_contrast_lut) and runs each separated ink through it (ink_through). - Destructive Image > Adjustments on a CMYK layer curves the stored inks directly, so it is exact; a selection mixes them. adjusted_channels now takes the per-pixel operation, and works out luma only for CMYK Threshold, which compares it. - The GPU leaves CMYK Brightness/Contrast to the CPU, like the other ink rules. cmyk8/16 brightness-contrast oracles, bad pixels: - modern: 72% -> 48-57%, max error 0.19 -> 0.09 - legacy: 71% -> 51-53% The rest is our separation of the RGB composite, not the rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CMYK Threshold compares the inks' own luma, as Photoshop does Photoshop's CMYK Threshold splits on the Rec. 601 luma of the inks' plain complement, (1 - c)(1 - k) and so on, whatever the document's profile. On the files' own inks this puts every pixel of cmyk8 and cmyk16 threshold-128.psd on the right side of level 128. The profile's RGB luma missed 100. stored_channels works the luma out from the inks itself (ink_luma), so it no longer takes one; the engine's stored-channel path no longer converts each pixel to RGB at all. Through the separated RGB composite the cmyk threshold oracles drop from 1.6% to 0.2% bad pixels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CMYK legacy Brightness/Contrast pivots at 128 in ink Photoshop's legacy contrast pivots at level 128 of the ink, which is 127 of 255 of the stored value (1 - ink) that the curve runs on, not 128 as in RGB. On Photoshop's own 16-bit inks (cmyk16 brightness-contrast-legacy.psd) ours sat a constant 0.43 of a level above. That is exactly the one-level pivot shift times contrast +30's slope minus one; with the pivot moved, the inks match within 0.01. brightness_contrast_lut is now ink_brightness_contrast_lut, since its legacy curve is the CMYK one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CMYK: adjustment layers composite on the document's own inks The compositor works in display RGB. In a CMYK document, an adjustment layer that Photoshop evaluates on the inks could only act on our separation of the RGB composite, and that separation's black differs from the file's. So even exact ink rules missed: CMYK Invert was 10% off, Levels and Curves 50-65%. The ink-native compositor (compose::native, until now only for flattening plain raster stacks) now takes adjustment layers too: - Invert, Posterize, Threshold and Brightness/Contrast (modern and legacy), and Levels and Curves on CMYK channels, prepared once (InkStack, InkOp) and applied to the straight inks. - Opacity and an unfeathered layer mask mix the inks linearly, as in Photoshop; raster layers' masks scale their coverage the same way. - A CMYK stack of plain raster layers and such adjustments renders this way for display, export and reduced views (render_cmyk, tiles in parallel); flattening and merging keep the adjusted inks exactly. - Stacks of raster layers alone keep the RGB compositor, which the GPU matches. The GPU already leaves these adjustments to the CPU. Photoshop oracles: cmyk8 and cmyk16 invert, posterize-4, threshold-128, levels, curves, brightness-contrast and brightness-contrast-legacy all pass (14 files), 151 -> 165 of 258; floor 151 -> 165. psd-tools and io corpora unchanged. 24 MP CMYK8 with one adjustment renders in 286 ms on the inks, against 442 ms through the RGB compositor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CMYK ink compositing inlines open groups The ink compositor gave up on any group. Now a group at full opacity without a mask is inlined: - a pass-through group's children composite straight onto the backdrop, so an adjustment inside it reaches the layers below; - an isolated (Normal) group of raster layers alone composites as its children would, since Normal is associative. An isolated group with an adjustment, which only sees the group's content, keeps the RGB compositor. So do a translucent group and an artboard. Nesting is bounded. psd-tools' CMYK adjustment files keep their swatches in pass-through groups. With #4948's fair comparison, curves_cmyk drops from 37% to 0.3% bad pixels and levels_cmyk from 14% to 0.2%. The photoshop corpus is unchanged at 165. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CMYK: Hue/Saturation and Color Balance keep the black ink In a CMYK document Photoshop applies Hue/Saturation (not Colorize) and Color Balance to C, M and Y as the complement of an RGB colour, and leaves the black ink alone. On the photoshop corpus's own inks, cmyk16 hue-saturation.psd follows this on every pixel within half a level (cmyk8 within two), and color-balance within our RGB Color Balance's own error. We applied both to the separated screen colour, which also rewrote K. - adjust::cmy_adjustment and apply_cmy run the RGB adjustment once per band on (1 - c, 1 - m, 1 - y). - The ink compositor (InkOp::Cmy), the RGB compositor's CMYK branch and destructive Image > Adjustments (pixels::adjusted_cmy) use them. The GPU leaves these to the CPU in CMYK documents, as it does the other ink rules. - Colorize separates its colour again, black included, so it keeps the screen-colour path. cmyk8/16 oracles, bad pixels: - hue-saturation: 100% -> 0.4-0.7% - color-balance: 74% -> 25% Neither passes yet. Half a level of cyan ink is three levels of red through the profile at saturated cyan, and our RGB models leave that much. The pass count stays at 165. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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> * Modern brightness: knee at output 126, fit out to -95 and +150 psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast layers to their own columns. Read through the exact contrast curve, it gives Photoshop's brightness at -95, -31, +23 and +150, well past the +/-50 samples the first fit had. The +/-50 curve extrapolated badly there: 22 levels off at -95 and 25 at +150. Every setting puts the knee at about the same output level, not input. The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at 126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23, +25 and +50 within 1.4 levels. Past about +110 no such cubic stays below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with the slope continuous at the knee. That gives +150 within 6 levels, where every curve family tried bottoms out. Lowered curves are still the inverse of raised ones. The photoshop corpus's six brightness-contrast oracles still pass. psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and _grayscale from 0.098 to 0.016. The new measurements are pinned in modern_brightness_matches_photoshop. 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> * CMYK: Posterize works on the stored values (1 - ink) Photoshop posterizes a CMYK channel's stored value, 1 - ink, not the ink: with levels whose steps are uneven (3, 7, 13, 21) the two differ, e.g. 3 levels take an ink of 85 to 128, where posterizing the ink drops it to none. psd-tools posterize_cmyk.psd goes from 13.8 % of pixels off to exact; psd-tools 241 -> 242, floor 242. 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> * Raise corpus floors to the results of the landed adjustment fixes Photoshop-authored 165 -> 185 of 258, io 146 -> 148 of 170, psd-tools 242 -> 249 of 309, measured with the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through PRs merged together. 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>
2222 lines
110 KiB
Rust
2222 lines
110 KiB
Rust
//! Evaluation of adjustment layers on a composite buffer.
|
||
//!
|
||
//! Formulas are documented approximations of Photoshop behaviour. Exact matching is tuned in
|
||
//! milestone M7 against Photoshop-rendered PSD composites (the oracle in `testkit`).
|
||
|
||
use photocraft_color::convert::{D50, SRGB_TO_XYZ_D50, XYZ_D50_TO_SRGB, lab_to_srgb, mat3_mul, rgb_to_gray, srgb_to_lab, srgb_to_linear};
|
||
use photocraft_color::{ColorMode, SampleType};
|
||
use photocraft_doc::Adjustment;
|
||
use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, ToneSpace, lut3d_len};
|
||
|
||
use crate::Buffer;
|
||
|
||
/// The document tone curve used to linearize values for adjustments that
|
||
/// work in linear light (Exposure). Until ICC profiles are wired in (M8) this
|
||
/// approximates the Photoshop defaults: sRGB for colour documents and the
|
||
/// "Dot Gain 20%" grey profile (≈ gamma 1.73, fitted on the corpus) for
|
||
/// grayscale documents. 32-bit documents store linear light already.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub enum Transfer {
|
||
/// sRGB piecewise curve.
|
||
Srgb,
|
||
/// Pure power law with this exponent.
|
||
Gamma(f32),
|
||
}
|
||
|
||
impl Transfer {
|
||
/// Default transfer for a document's colour mode and depth. 32-bit samples are linear, and
|
||
/// Photoshop's Exposure works on them as stored (photoshop corpus rgb32 and gray32
|
||
/// exposure.psd: within 0.5/255, against 69/255 through the 2.2 curve).
|
||
pub fn for_document(mode: photocraft_color::ColorMode, depth: photocraft_color::SampleType) -> Self {
|
||
if depth == photocraft_color::SampleType::F32 {
|
||
return Transfer::Gamma(1.0);
|
||
}
|
||
match mode {
|
||
photocraft_color::ColorMode::Grayscale | photocraft_color::ColorMode::Duotone | photocraft_color::ColorMode::Bitmap => Transfer::Gamma(1.732),
|
||
_ => 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,
|
||
}
|
||
}
|
||
/// Tone-encoded value → linear light.
|
||
pub 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),
|
||
}
|
||
}
|
||
/// Linear light → tone-encoded value.
|
||
pub 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),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Applies an adjustment assuming an sRGB document.
|
||
pub fn apply(adj: &Adjustment, buf: &mut Buffer) {
|
||
apply_with(adj, buf, Transfer::Srgb);
|
||
}
|
||
|
||
/// 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, in a document of `depth` (`None`:
|
||
/// unrounded). Integer depths work on whole levels like Photoshop's (see [`levels_q`] and
|
||
/// `adjustment_quantum`), 32-bit documents get its float Levels (see [`levels_float`]).
|
||
pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>) {
|
||
match adj {
|
||
Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]),
|
||
Adjustment::Threshold { level } => map_rgb(buf, |c| [if threshold_white(c, *level, depth) { 1.0 } else { 0.0 }; 3]),
|
||
Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize_in(v, *levels, depth))),
|
||
Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => {
|
||
// Legacy: contrast scales around 128/255, with brightness added before it when
|
||
// contrast is raised and after it when contrast is lowered. Measured on Photoshop
|
||
// 27.11, brightness +40 with contrast +30 maps 128 to 185 (brightness first), while
|
||
// the corpus's brightness -23 with contrast -26 (psd-tools) needs contrast first;
|
||
// contrast 80 keeps 128 at 128 (a 0.5 pivot moved it to 130).
|
||
if depth == Some(SampleType::F32) {
|
||
let f = legacy_brightness_contrast_hdr(*brightness, *contrast);
|
||
map_rgb(buf, |px| px.map(&f));
|
||
} else {
|
||
let f = legacy_brightness_contrast(*brightness, *contrast);
|
||
map_rgb(buf, |px| px.map(&f))
|
||
}
|
||
}
|
||
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
|
||
// Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth.
|
||
// Unlike the legacy linear scale, both are smooth curves that pin pure black and white
|
||
// (see `modern_brightness` and `modern_contrast`), applied brightness then contrast
|
||
// through one lookup table.
|
||
let table = modern_lut(*brightness, *contrast);
|
||
map_rgb(buf, |px| px.map(|v| lut(&table, v)))
|
||
}
|
||
Adjustment::Exposure { exposure, offset, gamma } => {
|
||
// In linear light: (lin·2^exposure + offset)^(1/gamma), then back
|
||
// 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();
|
||
let hdr = depth == Some(SampleType::F32);
|
||
map_rgb(buf, |c| {
|
||
c.map(|v| {
|
||
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| 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)),
|
||
}
|
||
}
|
||
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
|
||
let table = (!*colorize && ranges.iter().any(|r| !r.is_neutral())).then(|| hue_range_tables(ranges));
|
||
map_rgb(buf, |c| {
|
||
let (dh, ds, dl) = match &table {
|
||
Some(t) => {
|
||
// Range edits follow the pixel's original hue, in full however dull the colour
|
||
// (Photoshop's merged images, #4089). Greys stay grey: no hue to shift, no
|
||
// saturation to scale, and range Lightness moves towards a channel they share.
|
||
let h0 = rgb_to_hsl(c).0;
|
||
(lut(&t[0], h0), lut(&t[1], h0), lut(&t[2], h0))
|
||
}
|
||
None => (0.0, 0.0, 0.0),
|
||
};
|
||
let out = hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0).clamp(-1.0, 1.0), *colorize);
|
||
range_lightness(out, dl)
|
||
})
|
||
}
|
||
Adjustment::Vibrance { vibrance, saturation } => {
|
||
let (v, s) = (*vibrance / 100.0, *saturation / 100.0);
|
||
let hi = if depth == Some(SampleType::F32) { f32::INFINITY } else { 1.0 };
|
||
map_rgb(buf, |c| vibrance_px(c, v, s, transfer, hi))
|
||
}
|
||
Adjustment::ChannelMixer { matrix, monochrome } if depth == Some(SampleType::F32) => {
|
||
// 32-bit: each source percentage is cut to 1/1024 (toward zero), the constant counts
|
||
// 255/512 of its value, and nothing clips (photoshop corpus rgb32 channel-mixer.psd:
|
||
// pure red, green, blue, black and white all exact).
|
||
let cut = |v: f32| (v * 1024.0).trunc() / 1024.0;
|
||
let m = matrix.map(|row| [cut(row[0]), cut(row[1]), cut(row[2]), row[3] * 255.0 / 512.0]);
|
||
map_rgb(buf, |c| {
|
||
let mix = |row: &[f32; 4]| row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3];
|
||
if *monochrome { [mix(&m[0]); 3] } else { [mix(&m[0]), mix(&m[1]), mix(&m[2])] }
|
||
})
|
||
}
|
||
Adjustment::ChannelMixer { matrix, monochrome } => map_rgb(buf, |c| {
|
||
let mix = |row: &[f32; 4]| (row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3]).clamp(0.0, 1.0);
|
||
if *monochrome { [mix(&matrix[0]); 3] } else { [mix(&matrix[0]), mix(&matrix[1]), mix(&matrix[2])] }
|
||
}),
|
||
Adjustment::PhotoFilter { color, density, preserve_luminosity } => {
|
||
let linear_doc = depth == Some(SampleType::F32);
|
||
let m = photo_filter_matrix(*color, *density, *preserve_luminosity && linear_doc);
|
||
let set_lum = *preserve_luminosity && !linear_doc;
|
||
map_rgb(buf, |c| {
|
||
// 32-bit samples are linear already, and may go negative.
|
||
let f = if linear_doc { mat3_mul(&m, c) } else { mat3_mul(&m, c.map(|v| transfer.decode(v))).map(|v| transfer.encode(v)) };
|
||
let f = if set_lum { photocraft_color::blend::set_lum(f, photocraft_color::blend::lum(c)) } else { f };
|
||
// 32-bit results aren't clipped (photoshop corpus rgb32 photo-filter.psd: white
|
||
// filters to 1.19 red).
|
||
if linear_doc { f } else { f.map(|v| v.clamp(0.0, 1.0)) }
|
||
})
|
||
}
|
||
Adjustment::BlackWhite { weights, tint } => {
|
||
let hdr = depth == Some(SampleType::F32);
|
||
map_rgb(buf, |c| {
|
||
let g = black_white_gray(c, weights);
|
||
match tint {
|
||
// Photoshop tints like the Color blend mode: the tint's hue and saturation at
|
||
// the grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. In
|
||
// 32-bit documents nothing clips at 1, as with the blend mode there (photoshop
|
||
// corpus rgb32 black-white-tint.psd: white tints to 1.05, 1.00, 0.87).
|
||
Some(t) if hdr => photocraft_color::blend::blend_rgb_within(photocraft_color::blend::BlendMode::Color, [g; 3], *t, f32::MAX),
|
||
Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)),
|
||
None => [g; 3],
|
||
}
|
||
})
|
||
}
|
||
Adjustment::GradientMap { stops, reverse, dither } => {
|
||
let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0);
|
||
for (i, p) in buf.px.iter_mut().enumerate() {
|
||
if p[3] <= 0.0 {
|
||
continue;
|
||
}
|
||
// Driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: photoshop
|
||
// corpus rgb8 and rgb16 gradient-map.psd match within a level (2 levels off with
|
||
// 0.299, 0.587, 0.114).
|
||
let mut t = photocraft_color::blend::lum([p[0], p[1], p[2]]);
|
||
if *reverse {
|
||
t = 1.0 - t;
|
||
}
|
||
let mut o = gradient(stops, t);
|
||
if *dither {
|
||
let d = bayer4(x0 + (i % w) as i32, y0 + (i / w) as i32) / 255.0;
|
||
o = o.map(|v| (v + d).clamp(0.0, 1.0));
|
||
}
|
||
p[..3].copy_from_slice(&o);
|
||
}
|
||
}
|
||
Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => {
|
||
map_rgb(buf, |c| std::array::from_fn(|ch| color_balance_channel([*shadows, *midtones, *highlights], *preserve_luminosity, ch, c[ch])))
|
||
}
|
||
Adjustment::SelectiveColor { relative, adjustments } => map_rgb(buf, |c| selective_color(c, *relative, adjustments)),
|
||
Adjustment::ColorLookup { lut: Some(table), size, tetrahedral, dither, .. } if lut3d_len(*size).is_some_and(|len| table.len() >= len) => {
|
||
let (n, w, x0, y0) = (*size as usize, buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0);
|
||
for (i, p) in buf.px.iter_mut().enumerate() {
|
||
if p[3] <= 0.0 {
|
||
continue;
|
||
}
|
||
let mut o = lut3d_sample(table, n, [p[0], p[1], p[2]], *tetrahedral);
|
||
if *dither {
|
||
let d = bayer4(x0 + (i % w) as i32, y0 + (i / w) as i32) / 255.0;
|
||
o = o.map(|v| (v + d).clamp(0.0, 1.0));
|
||
}
|
||
p[..3].copy_from_slice(&o);
|
||
}
|
||
}
|
||
// Not evaluated: identity (still round-trips through PSD).
|
||
Adjustment::ColorLookup { .. } | Adjustment::Unsupported { .. } => {}
|
||
}
|
||
}
|
||
|
||
/// Legacy Brightness/Contrast on a 32-bit document's samples: unclipped, with contrast pivoting at
|
||
/// 127 of 255 (photoshop corpus rgb32 and gray32 brightness-contrast-legacy.psd: every pixel
|
||
/// exact, negative and over-white values included; the 8/16-bit pivot, 128, is 0.43 of a level
|
||
/// off there).
|
||
fn legacy_brightness_contrast_hdr(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 {
|
||
const PIVOT: f32 = 127.0 / 255.0;
|
||
let b = brightness / 255.0;
|
||
let c = contrast.clamp(-100.0, 99.0);
|
||
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
|
||
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
|
||
move |v: f32| (v + pre - PIVOT) * k + PIVOT + post
|
||
}
|
||
|
||
/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]).
|
||
fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 {
|
||
legacy_brightness_contrast_at(brightness, contrast, LEGACY_PIVOT)
|
||
}
|
||
|
||
/// [`legacy_brightness_contrast`] with contrast pivoting at `pivot`.
|
||
fn legacy_brightness_contrast_at(brightness: f32, contrast: f32, pivot: f32) -> impl Fn(f32) -> f32 {
|
||
let b = brightness / 255.0;
|
||
let c = contrast.clamp(-100.0, 99.0);
|
||
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
|
||
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
|
||
move |v: f32| ((v + pre - pivot) * k + pivot + post).clamp(0.0, 1.0)
|
||
}
|
||
|
||
/// Whether Threshold at `level` turns colour `c` white in a document of `depth` (`None`: unknown,
|
||
/// treated as 8-bit). Photoshop splits on the luminosity 0.30 R + 0.59 G + 0.11 B (the blend
|
||
/// modes' `lum`), not the Rec. 601 grey, of the document's whole sample values:
|
||
/// - 8 bits: white when that luminosity, rounded to a level with halves down, reaches the level;
|
||
/// - 16 bits: white when it reaches the level converted to 16 bits (128 → 16448);
|
||
/// - 32 bits: white when it reaches the level.
|
||
///
|
||
/// Exact on every pixel of psd-tools threshold_rgb.psd, threshold_16bits_rgb.psd and
|
||
/// threshold_16bits_grayscale.psd and of the photoshop corpus's rgb16 threshold-128.psd, where the
|
||
/// Rec. 601 grey split 28 and 100 pixels of the first two the other way. The sums of
|
||
/// `30 R + 59 G + 11 B` over whole samples are exact in f32 (at most 3 276 800).
|
||
pub fn threshold_white(c: [f32; 3], level: f32, depth: Option<SampleType>) -> bool {
|
||
let t = (level * 255.0).round();
|
||
let sum = |q: f32| {
|
||
let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * q).round());
|
||
30.0 * r + 59.0 * g + 11.0 * b
|
||
};
|
||
match depth {
|
||
None | Some(SampleType::U8) => sum(255.0) > 100.0 * t - 50.0,
|
||
Some(SampleType::U16) => sum(32768.0) >= 100.0 * (t * 32768.0 / 255.0).round(),
|
||
Some(SampleType::F32) => (0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]) * 255.0 >= t,
|
||
}
|
||
}
|
||
|
||
/// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab
|
||
/// and CMYK documents the adjustments Photoshop evaluates on the document's own channels do so
|
||
/// (see [`lab_channel_adjustment`] and [`cmyk_channel_adjustment`]).
|
||
pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>, mode: ColorMode) {
|
||
if mode == ColorMode::Lab && lab_channel_adjustment(adj) {
|
||
apply_lab(adj, buf, depth);
|
||
} else if mode == ColorMode::Cmyk && cmyk_channel_adjustment(adj) {
|
||
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
|
||
if let Adjustment::BrightnessContrast { brightness, contrast, legacy } = adj {
|
||
let table = ink_brightness_contrast_lut(*brightness, *contrast, *legacy);
|
||
map_rgb(buf, |c| cmyk_to_rgb(rgb_to_cmyk(c).map(|v| ink_through(&table, v))));
|
||
} else if cmy_adjustment(adj) {
|
||
let mut inks: Vec<[f32; 4]> = buf.px.iter().map(|p| rgb_to_cmyk([p[0], p[1], p[2]])).collect();
|
||
apply_cmy(adj, &mut inks);
|
||
for (p, ink) in buf.px.iter_mut().zip(&inks) {
|
||
if p[3] > 0.0 {
|
||
let [r, g, b] = cmyk_to_rgb(*ink);
|
||
(p[0], p[1], p[2]) = (r, g, b);
|
||
}
|
||
}
|
||
} else {
|
||
map_rgb(buf, |c| {
|
||
let mut ink = rgb_to_cmyk(c);
|
||
stored_channels(adj, mode, depth, &mut ink);
|
||
cmyk_to_rgb(ink)
|
||
});
|
||
}
|
||
} else {
|
||
apply_depth(adj, buf, transfer, depth);
|
||
}
|
||
}
|
||
|
||
/// Whether a Lab document evaluates `adj` on its L, a and b channels rather than on the screen
|
||
/// colour: Invert and Posterize (L, a and b alike), Brightness/Contrast (L only), Threshold
|
||
/// (split on L, neutral result) and Gradient Map (driven by L). Levels and Curves carry their own
|
||
/// Lab records ([`ToneSpace::Lab`]); everything else stays on the screen colour.
|
||
pub fn lab_channel_adjustment(adj: &Adjustment) -> bool {
|
||
matches!(
|
||
adj,
|
||
Adjustment::Invert
|
||
| Adjustment::Posterize { .. }
|
||
| Adjustment::Threshold { .. }
|
||
| Adjustment::BrightnessContrast { .. }
|
||
| Adjustment::GradientMap { .. }
|
||
)
|
||
}
|
||
|
||
/// Whether a CMYK document evaluates `adj` on its inks rather than on the screen colour: Invert
|
||
/// and Posterize (each ink, black included, so paper inverts to 400% ink), Threshold (paper or
|
||
/// 100% black ink alone) and Brightness/Contrast (each ink's stored value, 1 - ink, through the
|
||
/// tone curve; see [`ink_through`]). Photoshop corpus cmyk8 and cmyk16 invert, posterize-4,
|
||
/// threshold-128 and brightness-contrast(-legacy) follow these rules on the files' own inks on
|
||
/// every pixel; here the inks are the screen colour separated through the document's CMYK
|
||
/// profile, so neutrals carry the black that profile generates rather than the file's.
|
||
pub fn cmyk_channel_adjustment(adj: &Adjustment) -> bool {
|
||
stored_channel_adjustment(adj) || cmy_adjustment(adj) || matches!(adj, Adjustment::BrightnessContrast { .. })
|
||
}
|
||
|
||
/// Whether a CMYK document evaluates `adj` on C, M and Y as the complement of an RGB colour,
|
||
/// black kept ([`apply_cmy`]): Hue/Saturation (not Colorize, which Photoshop separates again,
|
||
/// black and all) and Color Balance (photoshop corpus cmyk16 hue-saturation.psd follows this on
|
||
/// every pixel within half a level, cmyk8 within two; color-balance within our RGB Color
|
||
/// Balance's own error).
|
||
pub fn cmy_adjustment(adj: &Adjustment) -> bool {
|
||
matches!(adj, Adjustment::HueSaturation { colorize: false, .. } | Adjustment::ColorBalance { .. })
|
||
}
|
||
|
||
/// A [`cmy_adjustment`] on straight inks (C, M, Y, K; 0 none): the RGB adjustment on
|
||
/// `(1 - c, 1 - m, 1 - y)`, black left alone.
|
||
pub fn apply_cmy(adj: &Adjustment, inks: &mut [[f32; 4]]) {
|
||
let width = i32::try_from(inks.len()).unwrap_or(i32::MAX);
|
||
let mut buf = Buffer { rect: crate::Rect::new(0, 0, width, 1), px: inks.iter().map(|i| [1.0 - i[0], 1.0 - i[1], 1.0 - i[2], 1.0]).collect() };
|
||
apply_depth(adj, &mut buf, Transfer::Srgb, None);
|
||
for (ink, p) in inks.iter_mut().zip(&buf.px) {
|
||
for c in 0..3 {
|
||
ink[c] = (1.0 - p[c]).clamp(0.0, 1.0);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Brightness/Contrast as the lookup table (4096 samples over 0..1) a CMYK ink's stored value,
|
||
/// `1 - ink`, goes through ([`ink_through`]): [`modern_lut`], or the legacy linear curve with its
|
||
/// contrast pivoting at 128 in ink, which is 127 of 255 stored (photoshop corpus cmyk16
|
||
/// brightness-contrast-legacy.psd sits 0.43 of a level off around 128 stored, as a one-level
|
||
/// pivot shift times contrast +30's slope predicts).
|
||
pub fn ink_brightness_contrast_lut(brightness: f32, contrast: f32, legacy: bool) -> Vec<f32> {
|
||
if !legacy {
|
||
return modern_lut(brightness, contrast);
|
||
}
|
||
let curve = legacy_brightness_contrast_at(brightness, contrast, 1.0 - LEGACY_PIVOT);
|
||
(0..LUT_SIZE).map(|k| curve(k as f32 / (LUT_SIZE - 1) as f32)).collect()
|
||
}
|
||
|
||
/// One CMYK ink (0: none) through a channel tone curve the way Photoshop applies one in CMYK: to
|
||
/// the stored value, `1 - ink`, so raising brightness takes ink away.
|
||
pub fn ink_through(table: &[f32], ink: f32) -> f32 {
|
||
1.0 - lut(table, 1.0 - ink)
|
||
}
|
||
|
||
/// Whether [`stored_channels`] evaluates `adj`: Invert, Posterize and Threshold.
|
||
pub fn stored_channel_adjustment(adj: &Adjustment) -> bool {
|
||
matches!(adj, Adjustment::Invert | Adjustment::Posterize { .. } | Adjustment::Threshold { .. })
|
||
}
|
||
|
||
/// Invert, Posterize or Threshold on one pixel's own channels in a Lab or CMYK document of
|
||
/// `depth` (`None`: unknown, treated as 8-bit): `ch` holds L, a, b normalised as [`lab_norm`]
|
||
/// gives them, or the C, M, Y, K inks (0: none). Lab Invert turns 8-bit bytes `v` into `255 - v`
|
||
/// (a becomes -1 - a) and mirrors a and b around neutral at 16 bits (photoshop corpus lab16
|
||
/// invert.psd: neutral stays neutral). CMYK Threshold compares [`ink_luma`].
|
||
pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleType>, ch: &mut [f32]) {
|
||
let lab = mode == ColorMode::Lab;
|
||
match adj {
|
||
Adjustment::Invert => {
|
||
let mirror = if lab && depth.is_some_and(|d| d != SampleType::U8) { 256.0 / 255.0 } else { 1.0 };
|
||
for (i, v) in ch.iter_mut().enumerate() {
|
||
*v = if i > 0 { mirror } else { 1.0 } - v.clamp(0.0, 1.0);
|
||
}
|
||
}
|
||
// CMYK posterizes the stored values (1 - ink), which its uneven levels make differ from
|
||
// posterizing the ink: psd-tools posterize_cmyk.psd (3 to 21 levels) is exact so.
|
||
Adjustment::Posterize { levels } if mode == ColorMode::Cmyk => ch.iter_mut().for_each(|v| *v = 1.0 - posterize_in(1.0 - *v, *levels, depth)),
|
||
Adjustment::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize_in(*v, *levels, depth)),
|
||
Adjustment::Threshold { level } => {
|
||
// On the 8-bit grid, like the RGB threshold.
|
||
let t = (level * 255.0).round();
|
||
let out = if lab {
|
||
let white = ch.first().is_some_and(|l| (l.clamp(0.0, 1.0) * 255.0).round() >= t);
|
||
[if white { 1.0 } else { 0.0 }, 128.0 / 255.0, 128.0 / 255.0, 0.0]
|
||
} else {
|
||
let ink = |i: usize| ch.get(i).copied().unwrap_or(0.0);
|
||
let black = (ink_luma([ink(0), ink(1), ink(2), ink(3)]) * 255.0).round() < t;
|
||
[0.0, 0.0, 0.0, if black { 1.0 } else { 0.0 }]
|
||
};
|
||
ch.iter_mut().zip(out).for_each(|(v, o)| *v = o);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// The luma Photoshop's CMYK Threshold compares: Rec. 601 weights on the inks' plain complement,
|
||
/// `(1 - c)(1 - k)` and so on, whatever the document's profile (photoshop corpus cmyk8 and cmyk16
|
||
/// threshold-128.psd: every pixel on the right side of level 128).
|
||
pub fn ink_luma(ink: [f32; 4]) -> f32 {
|
||
let paper = 1.0 - ink[3].clamp(0.0, 1.0);
|
||
rgb_to_gray([0, 1, 2].map(|i| (1.0 - ink[i].clamp(0.0, 1.0)) * paper))
|
||
}
|
||
|
||
/// Encoded sRGB → Lab normalised the way Photoshop's channels hold it: L/100, (a+128)/255,
|
||
/// (b+128)/255.
|
||
fn lab_norm(c: [f32; 3]) -> [f32; 3] {
|
||
let l = srgb_to_lab(c);
|
||
[l[0] / 100.0, (l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0]
|
||
}
|
||
|
||
/// The inverse of [`lab_norm`].
|
||
fn lab_denorm(n: [f32; 3]) -> [f32; 3] {
|
||
lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0])
|
||
}
|
||
|
||
/// Neutral a or b as Lab surfaces store it ((0 + 128) / 255).
|
||
pub const LAB_NEUTRAL: f32 = 128.0 / 255.0;
|
||
|
||
/// An adjustment Photoshop evaluates on a Lab document's own channels, as an operation on one
|
||
/// pixel's stored L, a, b (normalised as Lab surfaces store them: L / 100, (a + 128) / 255,
|
||
/// (b + 128) / 255) in a document of `depth`: Invert and Posterize (each channel), Threshold
|
||
/// (split on L, neutral result), Brightness/Contrast (L) and Levels or Curves with Lab records.
|
||
/// `None` for any other adjustment. On the stored values colours outside the display gamut keep
|
||
/// theirs, where [`apply_in`] can only round-trip them through clipped display RGB.
|
||
pub fn lab_stored_op(adj: &Adjustment, depth: Option<SampleType>) -> Option<LabOp<'_>> {
|
||
let op: LabOp<'_> = match adj {
|
||
Adjustment::Invert | Adjustment::Posterize { .. } => Box::new(move |ch: &mut [f32; 3]| stored_channels(adj, ColorMode::Lab, depth, ch)),
|
||
Adjustment::Threshold { level } => {
|
||
// Split on L on the 8-bit grid, like the display path (`apply_lab`).
|
||
let t = (level * 255.0).round();
|
||
Box::new(move |ch: &mut [f32; 3]| {
|
||
let white = (ch[0].clamp(0.0, 1.0) * 255.0).round() >= t;
|
||
*ch = [if white { 1.0 } else { 0.0 }, LAB_NEUTRAL, LAB_NEUTRAL];
|
||
})
|
||
}
|
||
Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
|
||
let (b, c, legacy) = (*brightness, *contrast, *legacy);
|
||
let legacy_curve = legacy_brightness_contrast(b, c);
|
||
Box::new(move |ch: &mut [f32; 3]| {
|
||
ch[0] = if legacy { legacy_curve(ch[0]) } else { modern_contrast(modern_brightness(ch[0], b), c).clamp(0.0, 1.0) };
|
||
})
|
||
}
|
||
Adjustment::Levels { space: ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Lab, .. } => {
|
||
let luts = tone_luts_depth(adj, depth);
|
||
Box::new(move |ch: &mut [f32; 3]| {
|
||
for (v, t) in ch.iter_mut().zip(&luts) {
|
||
*v = lut(t, *v);
|
||
}
|
||
})
|
||
}
|
||
_ => return None,
|
||
};
|
||
Some(op)
|
||
}
|
||
|
||
/// One pixel's stored L, a, b through an adjustment ([`lab_stored_op`]).
|
||
pub type LabOp<'a> = Box<dyn Fn(&mut [f32; 3]) + Send + Sync + 'a>;
|
||
|
||
/// The Lab evaluation of a [`lab_channel_adjustment`] in a document of `depth` (no-op for any
|
||
/// other adjustment).
|
||
fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<SampleType>) {
|
||
match adj {
|
||
// L → 100 - L and a, b mirrored about neutral; each channel posterized (see `stored_channels`).
|
||
Adjustment::Invert | Adjustment::Posterize { .. } => map_rgb(buf, |c| {
|
||
let mut n = lab_norm(c);
|
||
stored_channels(adj, ColorMode::Lab, depth, &mut n);
|
||
lab_denorm(n)
|
||
}),
|
||
Adjustment::Threshold { level } => {
|
||
// Split on L alone (on the 8-bit grid); the result is neutral black or white.
|
||
let t = (level * 255.0).round();
|
||
map_rgb(buf, |c| [if (lab_norm(c)[0].clamp(0.0, 1.0) * 255.0).round() >= t { 1.0 } else { 0.0 }; 3])
|
||
}
|
||
Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
|
||
let legacy_curve = legacy_brightness_contrast(*brightness, *contrast);
|
||
let modern = (!*legacy).then(|| modern_lut(*brightness, *contrast));
|
||
let curve = |v: f32| match &modern {
|
||
Some(table) => lut(table, v),
|
||
None => legacy_curve(v),
|
||
};
|
||
// The tone curve on L only. Added as the difference of two round trips (like
|
||
// `tone_in_space`) so a and b, and colours a round trip moves, stay put.
|
||
map_rgb(buf, |c| {
|
||
let n = lab_norm(c);
|
||
let l = curve(n[0]);
|
||
if l == n[0] {
|
||
return c;
|
||
}
|
||
let (before, after) = (lab_denorm(n), lab_denorm([l, n[1], n[2]]));
|
||
std::array::from_fn(|i| (c[i] + after[i] - before[i]).clamp(0.0, 1.0))
|
||
})
|
||
}
|
||
Adjustment::GradientMap { stops, reverse, dither } => {
|
||
let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0);
|
||
for (i, p) in buf.px.iter_mut().enumerate() {
|
||
if p[3] <= 0.0 {
|
||
continue;
|
||
}
|
||
// Driven by L: equal lightness maps to the same stop colour whatever the chroma.
|
||
let l = lab_norm([p[0], p[1], p[2]])[0].clamp(0.0, 1.0);
|
||
let mut o = gradient(stops, if *reverse { 1.0 - l } else { l });
|
||
if *dither {
|
||
let d = bayer4(x0 + (i % w) as i32, y0 + (i / w) as i32) / 255.0;
|
||
o = o.map(|v| (v + d).clamp(0.0, 1.0));
|
||
}
|
||
p[..3].copy_from_slice(&o);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
/// (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_q(m, levels_q(c, 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, pencil } => {
|
||
let m = if *space == ToneSpace::Lab { curve_lut(&[]) } else { curve_lut_mode(master, *pencil) };
|
||
let row = |c: &[CurvePoint]| curve_lut_mode(c, *pencil).iter().map(|&v| lut(&m, v)).collect();
|
||
[row(&per_channel[0]), row(&per_channel[1]), row(&per_channel[2]), row(if *space == ToneSpace::Cmyk { black } else { &[] })]
|
||
}
|
||
_ => std::array::from_fn(|_| (0..LUT_SIZE).map(x).collect()),
|
||
}
|
||
}
|
||
|
||
/// [`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 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)) => {
|
||
let x = |k: usize| k as f32 / (LUT_SIZE - 1) as f32;
|
||
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels_float(master, levels_float(c, x(k))).clamp(0.0, 1.0)).collect();
|
||
[row(&per_channel[0]), row(&per_channel[1]), row(&per_channel[2]), (0..LUT_SIZE).map(x).collect()]
|
||
}
|
||
_ => tone_luts_q(adj, depth.and_then(crate::adjustment_quantum)),
|
||
}
|
||
}
|
||
|
||
/// Applies channel LUTs to a display-RGB colour in CMYK (ink brightness, 1 - ink) or Lab space,
|
||
/// through the conversions the document's surfaces use. The change is added as a difference of
|
||
/// two round trips, so channels a curve leaves alone (and out-of-gamut colours) stay exact.
|
||
fn tone_in_space(space: ToneSpace, luts: &[Vec<f32>; 4], c: [f32; 3]) -> [f32; 3] {
|
||
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
|
||
let (before, after) = match space {
|
||
ToneSpace::Cmyk => {
|
||
let ink = rgb_to_cmyk(c);
|
||
let out: [f32; 4] = std::array::from_fn(|i| 1.0 - lut(&luts[i], 1.0 - ink[i]));
|
||
if out == ink {
|
||
return c;
|
||
}
|
||
(cmyk_to_rgb(ink), cmyk_to_rgb(out))
|
||
}
|
||
ToneSpace::Lab => {
|
||
let l = srgb_to_lab(c);
|
||
let n = [l[0] / 100.0, (l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0];
|
||
let o: [f32; 3] = std::array::from_fn(|i| lut(&luts[i], n[i]));
|
||
if o == n {
|
||
return c;
|
||
}
|
||
let back = |v: [f32; 3]| lab_to_srgb([v[0] * 100.0, v[1] * 255.0 - 128.0, v[2] * 255.0 - 128.0]);
|
||
(back(n), back(o))
|
||
}
|
||
ToneSpace::Rgb => return std::array::from_fn(|i| lut(&luts[i], c[i])),
|
||
};
|
||
std::array::from_fn(|i| (c[i] + after[i] - before[i]).clamp(0.0, 1.0))
|
||
}
|
||
|
||
/// Hue/Saturation's range edits as three hue-indexed tables (hue shift in degrees, saturation and
|
||
/// lightness as fractions), sampled at hue `k / (len - 1)` turns. Ranges add up where they overlap.
|
||
pub fn hue_range_tables(ranges: &[HueRange; 6]) -> [Vec<f32>; 3] {
|
||
let at = |k: usize| {
|
||
let deg = k as f32 / (LUT_SIZE - 1) as f32 * 360.0;
|
||
ranges.iter().filter(|r| !r.is_neutral()).fold([0.0f32; 3], |acc, r| {
|
||
let w = r.weight(deg);
|
||
[acc[0] + w * r.hue, acc[1] + w * r.saturation / 100.0, acc[2] + w * r.lightness / 100.0]
|
||
})
|
||
};
|
||
let rows: Vec<[f32; 3]> = (0..LUT_SIZE).map(at).collect();
|
||
std::array::from_fn(|i| rows.iter().map(|r| r[i]).collect())
|
||
}
|
||
|
||
/// One Hue/Saturation evaluation: hue shift in degrees, saturation and lightness in -1..=1.
|
||
/// Colorize sets the hue and saturation instead of shifting them; at 0% it leaves only the
|
||
/// lightness, as in Photoshop (#2326). Ticking Colorize in the dialog starts saturation at 25%.
|
||
pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> [f32; 3] {
|
||
let lighten = |v: f32| if l > 0.0 { v + (1.0 - v) * l } else { v * (1.0 + l) };
|
||
if colorize {
|
||
// Colorize builds the colour at the lightened (or darkened) lightness, so black with
|
||
// Lightness +30 becomes a 30 % lightness colour, not grey (measured in Photoshop 27.11).
|
||
let (_, _, ll) = rgb_to_hsl(c);
|
||
return hsl_to_rgb(hue.rem_euclid(360.0) / 360.0, s.abs().min(1.0), lighten(ll));
|
||
}
|
||
// Photoshop applies Lightness first, then rotates the hue and scales the saturation of the
|
||
// result: Hue +40, Saturation +25, Lightness -10 turns (240, 160, 30) into (173, 243, 0), not
|
||
// (168, 230, 14) (Photoshop 27.11 corpus file adjustments/rgb8/hue-saturation).
|
||
let (hh, ss, ll) = rgb_to_hsl(c.map(lighten));
|
||
let hh = (hh + hue / 360.0).rem_euclid(1.0);
|
||
// Raising saturation divides by 1 - s, lowering it multiplies by 1 + s: +50 % doubles it
|
||
// (measured in Photoshop 27.11); +100 % saturates any colour that has some.
|
||
let ss = if s > 0.0 { ss / (1.0 - s).max(1.0 / 512.0) } else { ss * (1.0 + s) }.clamp(0.0, 1.0);
|
||
hsl_to_rgb(hh, ss, ll)
|
||
}
|
||
|
||
/// A colour range's Lightness, applied after the master edits. It moves each channel towards the
|
||
/// pixel's brightest channel when raised and towards its darkest when lowered, not towards white
|
||
/// or black: Blues +50 turns (0, 29, 115) into (57, 71, 115), Blues -50 turns (102, 128, 204) into
|
||
/// (102, 115, 153) (measured in Photoshop 27.11).
|
||
pub fn range_lightness(c: [f32; 3], l: f32) -> [f32; 3] {
|
||
let l = l.clamp(-1.0, 1.0);
|
||
let target = if l > 0.0 { c[0].max(c[1]).max(c[2]) } else { c[0].min(c[1]).min(c[2]) };
|
||
c.map(|v| v + (target - v) * l.abs())
|
||
}
|
||
|
||
/// Black & White grey value. Each colour is the sum of a grey part (its smallest channel), a
|
||
/// secondary-colour part (yellow, cyan or magenta: middle − smallest) and a primary part (red,
|
||
/// green or blue: largest − middle); the sliders (percent, Photoshop defaults 40 60 40 60 20 80 for
|
||
/// reds, yellows, greens, cyans, blues, magentas) weight the two colour parts.
|
||
pub fn black_white_gray(c: [f32; 3], weights: &[f32; 6]) -> f32 {
|
||
let (r, g, b) = (c[0], c[1], c[2]);
|
||
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
||
let mid = r + g + b - max - min;
|
||
// Primary: the largest channel; secondary: the two largest channels together.
|
||
let primary = if r >= g && r >= b {
|
||
0
|
||
} else if g >= b {
|
||
2
|
||
} else {
|
||
4
|
||
};
|
||
let secondary = if b <= r && b <= g {
|
||
1 // red + green = yellow
|
||
} else if r <= g {
|
||
3 // green + blue = cyan
|
||
} else {
|
||
5 // red + blue = magenta
|
||
};
|
||
(min + (mid - min) * weights[secondary] / 100.0 + (max - mid) * weights[primary] / 100.0).clamp(0.0, 1.0)
|
||
}
|
||
|
||
/// Ordered-dither offset in -0.5..0.5 (4×4 Bayer matrix) for document pixel (x, y).
|
||
pub fn bayer4(x: i32, y: i32) -> f32 {
|
||
const M: [f32; 16] = [0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0];
|
||
(M[((y & 3) * 4 + (x & 3)) as usize] + 0.5) / 16.0 - 0.5
|
||
}
|
||
|
||
/// Samples a 3D LUT (`n`³ RGB triplets, red fastest) at `c`, trilinear or tetrahedral.
|
||
pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f32; 3] {
|
||
// A degenerate or short table can't be sampled: leave the colour alone.
|
||
if n < 2 || n.checked_pow(3).and_then(|c| c.checked_mul(3)).is_none_or(|len| lut.len() < len) {
|
||
return c;
|
||
}
|
||
let m = (n - 1) as f32;
|
||
let pos = c.map(|v| v.clamp(0.0, 1.0) * m);
|
||
let i0 = pos.map(|p| (p.floor() as usize).min(n - 2));
|
||
let f: [f32; 3] = std::array::from_fn(|k| pos[k] - i0[k] as f32);
|
||
let at = |dr: usize, dg: usize, db: usize| -> [f32; 3] {
|
||
let idx = (((i0[2] + db) * n + i0[1] + dg) * n + i0[0] + dr) * 3;
|
||
[lut[idx], lut[idx + 1], lut[idx + 2]]
|
||
};
|
||
let mix = |w: &[(f32, [f32; 3])]| -> [f32; 3] { std::array::from_fn(|k| w.iter().map(|(a, v)| a * v[k]).sum()) };
|
||
let (fr, fg, fb) = (f[0], f[1], f[2]);
|
||
if tetrahedral {
|
||
let (c000, c111) = (at(0, 0, 0), at(1, 1, 1));
|
||
if fr > fg {
|
||
if fg > fb {
|
||
mix(&[(1.0 - fr, c000), (fr - fg, at(1, 0, 0)), (fg - fb, at(1, 1, 0)), (fb, c111)])
|
||
} else if fr > fb {
|
||
mix(&[(1.0 - fr, c000), (fr - fb, at(1, 0, 0)), (fb - fg, at(1, 0, 1)), (fg, c111)])
|
||
} else {
|
||
mix(&[(1.0 - fb, c000), (fb - fr, at(0, 0, 1)), (fr - fg, at(1, 0, 1)), (fg, c111)])
|
||
}
|
||
} else if fb > fg {
|
||
mix(&[(1.0 - fb, c000), (fb - fg, at(0, 0, 1)), (fg - fr, at(0, 1, 1)), (fr, c111)])
|
||
} else if fb > fr {
|
||
mix(&[(1.0 - fg, c000), (fg - fb, at(0, 1, 0)), (fb - fr, at(0, 1, 1)), (fr, c111)])
|
||
} else {
|
||
mix(&[(1.0 - fg, c000), (fg - fr, at(0, 1, 0)), (fr - fb, at(1, 1, 0)), (fb, c111)])
|
||
}
|
||
} else {
|
||
let lerp = |a: [f32; 3], b: [f32; 3], t: f32| -> [f32; 3] { std::array::from_fn(|k| a[k] + (b[k] - a[k]) * t) };
|
||
let c00 = lerp(at(0, 0, 0), at(1, 0, 0), fr);
|
||
let c10 = lerp(at(0, 1, 0), at(1, 1, 0), fr);
|
||
let c01 = lerp(at(0, 0, 1), at(1, 0, 1), fr);
|
||
let c11 = lerp(at(0, 1, 1), at(1, 1, 1), fr);
|
||
lerp(lerp(c00, c10, fg), lerp(c01, c11, fg), fb)
|
||
}
|
||
}
|
||
|
||
/// Color Balance on channel `ch` (0 red, 1 green, 2 blue) of `v` (0..1); `tones` are the
|
||
/// shadows, midtones and highlights slider triples (cyan–red, magenta–green, yellow–blue,
|
||
/// −100..100).
|
||
///
|
||
/// Reverse-engineered from Photoshop (a 16-bit gray ramp through every tone at ±10..±100, pairs of
|
||
/// tones, and random settings of all nine sliders, Preserve Luminosity on and off): the three
|
||
/// tones fold into one Levels per channel (input black point, input white point, midtone gamma),
|
||
/// within 0.8 of 255 everywhere. Folding matters: a lowered shadows and a raised highlights
|
||
/// slider cancel each other's gamma, which applying the tones one after another doesn't (#3423).
|
||
/// - Preserve Luminosity off: a slider only moves its own channel. Lowered shadows set the black
|
||
/// point to |shadows| levels, raised highlights set the white point to 255 − highlights levels,
|
||
/// and the gamma is 2^(m / 100) with m = midtones + (shadows + highlights) / 2.
|
||
/// - Preserve Luminosity on: each channel's black point is (max − own) shadows levels, its white
|
||
/// point 255 − (own − min) highlights levels, and m = own − (max + min) / 2 midtones.
|
||
///
|
||
/// A gamma > 1 starts with slope 2^gamma at black ([`balance_gamma`]: up to 18 of 255 brighter
|
||
/// near black without it). When the black point reaches the white point, input
|
||
/// at or above the white point is white and the rest black.
|
||
pub fn color_balance_channel(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 {
|
||
let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 }));
|
||
let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0);
|
||
let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]);
|
||
let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]);
|
||
let (black, white, m) = if preserve_luminosity {
|
||
(max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0)
|
||
} else {
|
||
((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0)
|
||
};
|
||
let (b, w) = (black / 255.0, 1.0 - white / 255.0);
|
||
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||
if v >= w {
|
||
return 1.0;
|
||
}
|
||
if v <= b {
|
||
return 0.0;
|
||
}
|
||
balance_gamma((v - b) / (w - b), 2f32.powf(m / 100.0))
|
||
}
|
||
|
||
/// The midtone gamma of Color Balance's per-channel Levels (`u` in 0..1): `u^(1/gamma)`, except
|
||
/// that a gamma > 1 (which would start with infinite slope) starts at slope 2^gamma and joins the
|
||
/// power curve in a cubic Hermite at twice the input where that line meets it (Photoshop,
|
||
/// measured on a 16-bit ramp at gamma 1.07..2: within 0.02 of 255).
|
||
fn balance_gamma(u: f32, gamma: f32) -> f32 {
|
||
let e = 1.0 / gamma.max(0.01);
|
||
let power = u.powf(e);
|
||
if gamma <= 1.0 {
|
||
return power;
|
||
}
|
||
let s0 = 2f32.powf(gamma);
|
||
let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0);
|
||
if u0.is_nan() || u0 <= 0.0 || u >= u0 {
|
||
return power;
|
||
}
|
||
let (p, d, t) = (u0.powf(e), e * u0.powf(e - 1.0), u / u0);
|
||
let (t2, t3) = (t * t, t * t * t);
|
||
((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0)
|
||
}
|
||
|
||
/// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans,
|
||
/// blues, magentas (by chroma within the hue sector), whites, neutrals and blacks (by lightness).
|
||
pub fn selective_color_weights(c: [f32; 3]) -> [f32; 9] {
|
||
let max = c[0].max(c[1]).max(c[2]);
|
||
let min = c[0].min(c[1]).min(c[2]);
|
||
let mid = c[0] + c[1] + c[2] - max - min;
|
||
let top = |i: usize| if c[i] >= max { max - mid } else { 0.0 };
|
||
let bottom = |i: usize| if c[i] <= min { mid - min } else { 0.0 };
|
||
[
|
||
top(0),
|
||
bottom(2),
|
||
top(1),
|
||
bottom(0),
|
||
top(2),
|
||
bottom(1),
|
||
((min - 0.5) * 2.0).max(0.0),
|
||
(1.0 - (max - 0.5).abs() - (min - 0.5).abs()).clamp(0.0, 1.0),
|
||
((0.5 - max) * 2.0).max(0.0),
|
||
]
|
||
}
|
||
|
||
/// Photoshop Selective Color: each range shifts the cyan, magenta and yellow ink (1 − R, G, B) by
|
||
/// its percentage, plus its black percentage on every ink, weighted by how much the colour belongs
|
||
/// to the range. Relative mode scales the shift by the ink already present (so it can't tint pure
|
||
/// white). Absolute mode scales it by the room the ink has: adding ink by min(1, 2 × (1 − ink)),
|
||
/// removing it by min(1, 2 × ink), so Neutrals Cyan +50 darkens red 64 by 32, not 64 (all 72
|
||
/// absolute range × ink cases within 1 level of Photoshop 27.11).
|
||
pub fn selective_color(c: [f32; 3], relative: bool, adj: &[[f32; 4]; 9]) -> [f32; 3] {
|
||
let w = selective_color_weights(c);
|
||
let mut delta = [0.0f32; 3];
|
||
for (r, wr) in w.iter().enumerate() {
|
||
if *wr <= 0.0 {
|
||
continue;
|
||
}
|
||
let a = adj[r].map(|v| v / 100.0);
|
||
for i in 0..3 {
|
||
let ink = 1.0 - c[i];
|
||
let d = a[i] + a[3];
|
||
let d = if relative {
|
||
d * ink
|
||
} else if d > 0.0 {
|
||
d * (2.0 * (1.0 - ink)).min(1.0)
|
||
} else {
|
||
d * (2.0 * ink).min(1.0)
|
||
};
|
||
delta[i] += d * wr;
|
||
}
|
||
}
|
||
std::array::from_fn(|i| (c[i] - delta[i]).clamp(0.0, 1.0))
|
||
}
|
||
|
||
const LUT_SIZE: usize = 4096;
|
||
|
||
fn map_rgb(buf: &mut Buffer, f: impl Fn([f32; 3]) -> [f32; 3]) {
|
||
for p in &mut buf.px {
|
||
if p[3] <= 0.0 {
|
||
continue;
|
||
}
|
||
let r = f([p[0], p[1], p[2]]);
|
||
p[0] = r[0];
|
||
p[1] = r[1];
|
||
p[2] = r[2];
|
||
}
|
||
}
|
||
|
||
#[inline]
|
||
fn lut(table: &[f32], v: f32) -> f32 {
|
||
let x = v.clamp(0.0, 1.0) * (table.len() - 1) as f32;
|
||
let i = x.floor() as usize;
|
||
let j = (i + 1).min(table.len() - 1);
|
||
let f = x - i as f32;
|
||
table[i] * (1.0 - f) + table[j] * f
|
||
}
|
||
|
||
/// Photo Filter's linear-light RGB matrix. Photoshop multiplies the pixel's D50 XYZ, relative to
|
||
/// white, by the filter colour's (mixed in by `density`): a fixed linear map, so it is folded into
|
||
/// one matrix. With `normalize_y` the map keeps luminance Y (32-bit Preserve Luminosity; 8/16-bit
|
||
/// documents instead restore the encoded luminosity like the Luminosity blend mode). Fitted on the
|
||
/// photoshop corpus `photo-filter.psd`: rgb16 within 0.6/255 everywhere.
|
||
pub fn photo_filter_matrix(color: [f32; 3], density: f32, normalize_y: bool) -> [[f32; 3]; 3] {
|
||
let d = if density.is_finite() { density.clamp(0.0, 1.0) } else { 0.0 };
|
||
let xyz = mat3_mul(&SRGB_TO_XYZ_D50, color.map(|v| srgb_to_linear(v.clamp(0.0, 1.0))));
|
||
let mut s: [f32; 3] = std::array::from_fn(|i| 1.0 - d + d * xyz[i] / D50[i]);
|
||
if normalize_y && s[1] > 1e-6 {
|
||
let y = s[1];
|
||
s = s.map(|v| v / y);
|
||
}
|
||
// XYZ_D50_TO_SRGB · diag(s) · SRGB_TO_XYZ_D50
|
||
std::array::from_fn(|r| std::array::from_fn(|c| (0..3).map(|k| XYZ_D50_TO_SRGB[r][k] * s[k] * SRGB_TO_XYZ_D50[k][c]).sum()))
|
||
}
|
||
|
||
/// Photoshop's Vibrance on one pixel (`v`, `s`: the sliders / 100; `hi`: the sample ceiling).
|
||
///
|
||
/// Measured black-box against Photoshop 25.4 on 7,400-colour charts (8- and 16-bit; sRGB, Adobe RGB
|
||
/// and ProPhoto documents). Both sliders work in linear light through the document's tone curve,
|
||
/// Vibrance first, then Saturation:
|
||
/// - Saturation moves each colour away from (or towards) the grey `0.288 R + 0.712 G` (blue has no
|
||
/// weight, in every profile) by `1 + s`, clipping each channel: within 0.07/255 at every `s`.
|
||
/// - Vibrance is an HSV change in linear light that keeps the hue: see [`vibrance_lin`].
|
||
pub fn vibrance_px(c: [f32; 3], v: f32, s: f32, transfer: Transfer, hi: f32) -> [f32; 3] {
|
||
let mut l = c.map(|x| transfer.decode(x));
|
||
if v != 0.0 {
|
||
l = vibrance_lin(l, v);
|
||
}
|
||
if s != 0.0 {
|
||
let g = VIBRANCE_GREY[0] * l[0] + VIBRANCE_GREY[1] * l[1];
|
||
l = l.map(|x| (g + (1.0 + s) * (x - g)).clamp(0.0, hi));
|
||
}
|
||
l.map(|x| transfer.encode(x.clamp(0.0, hi)))
|
||
}
|
||
|
||
/// Weights of the grey Photoshop's Vibrance › Saturation desaturates to (R, G; blue has none).
|
||
pub const VIBRANCE_GREY: [f32; 2] = [0.288, 0.712];
|
||
|
||
/// Positive Vibrance's saturation boost, fitted to the measurements (see [`vibrance_lin`]).
|
||
pub const VIBRANCE_BOOST: [f32; 7] = [0.5001, 0.0887, 0.4374, 0.7573, -0.8113, 0.5154, 2.8026];
|
||
|
||
/// The Vibrance slider (`v` in [-1, 1]) on a linear-light pixel. With `t = 1 - min/max` (HSV
|
||
/// saturation) and `V = max`:
|
||
/// - below 0 (`a = -v`): `t' = t (1 - a/4) (1 - a + a t (1 + t) / 2)` and
|
||
/// `V' = V - a t (1 - t) (2 - t + t²) V (1 - V)`; exact (0.2/255 mean) except in the deepest
|
||
/// shadows (`V` under 0.05), which Photoshop desaturates less.
|
||
/// - above 0: `V' = V + v/4 t (1 - t) (2 - t + t²) V (1 - V)` (exact) and a saturation boost that
|
||
/// grows as `t` and `V` fall, damped on reds and skin (HSV hue 0–30°, fading out by 45° and in
|
||
/// from 300°): a fit, 0.4 (+10) to 5 (+100) /255 off on average.
|
||
pub fn vibrance_lin(c: [f32; 3], v: f32) -> [f32; 3] {
|
||
let mx = c[0].max(c[1]).max(c[2]);
|
||
let mn = c[0].min(c[1]).min(c[2]);
|
||
if mx <= 0.0 || mx - mn <= 1e-9 {
|
||
return c;
|
||
}
|
||
let t = 1.0 - mn / mx;
|
||
let shape = t * (1.0 - t) * (2.0 - t + t * t) * mx * (1.0 - mx).max(0.0);
|
||
let (t2, v2) = if v < 0.0 {
|
||
let a = -v;
|
||
(t * (1.0 - a / 4.0) * (1.0 - a + a * t * (1.0 + t) / 2.0), mx - a * shape)
|
||
} else {
|
||
let [c0, c1, p, q, r0, ra, rt] = VIBRANCE_BOOST;
|
||
let w = vibrance_skin(hsv_hue(c, mx, mn));
|
||
let a = v * (1.0 - w) + 0.64 * v.powf(1.6) * w;
|
||
let boost = (c0 * a + c1 * a * a) * t.powf(p) * (1.0 - t).powf(q) * ((r0 + ra * a + rt * (1.0 - t)) * (1.0 - mx).max(0.0)).exp();
|
||
((t * (1.0 + boost)).min(1.0), mx + v / 4.0 * shape)
|
||
};
|
||
let m2 = v2 * (1.0 - t2);
|
||
let k = (v2 - m2) / (mx - mn);
|
||
c.map(|x| m2 + (x - mn) * k)
|
||
}
|
||
|
||
/// How much positive Vibrance treats a hue (degrees) as skin: 1 on 0–30°, fading to 0 by 45° and
|
||
/// in again from 300°.
|
||
fn vibrance_skin(h: f32) -> f32 {
|
||
if h <= 30.0 {
|
||
1.0
|
||
} else if h < 45.0 {
|
||
(45.0 - h) / 15.0
|
||
} else if h < 300.0 {
|
||
0.0
|
||
} else {
|
||
(h - 300.0) / 60.0
|
||
}
|
||
}
|
||
|
||
/// HSV hue in degrees of `c` (its max `mx` and min `mn` given, `mx > mn`).
|
||
fn hsv_hue(c: [f32; 3], mx: f32, mn: f32) -> f32 {
|
||
let d = mx - mn;
|
||
let h = if mx == c[0] {
|
||
((c[1] - c[2]) / d).rem_euclid(6.0)
|
||
} else if mx == c[1] {
|
||
(c[2] - c[0]) / d + 2.0
|
||
} else {
|
||
(c[0] - c[1]) / d + 4.0
|
||
};
|
||
h * 60.0
|
||
}
|
||
|
||
/// Modern Brightness curve (one channel, `brightness` in [-150, 150]); pins pure black and white,
|
||
/// and `b = 0` is the identity. Raised, it is a line of slope `s = 1.375^(b/50)` from the origin
|
||
/// up to a knee at output 126/255, then a cubic Hermite to (1, 1) whose end slope is
|
||
/// `1 / (1 + 0.084 b)`; past about +110, where no such cubic stays below white, the roll-off is
|
||
/// `1 - (1 - y0)(1 - u)^α` instead (continuous slope `α` at the knee, flat at white). Lowered, it
|
||
/// is the inverse of the curve raised by as much.
|
||
///
|
||
/// Fit to every Photoshop measurement we have, all within about a level and a half: its ±50
|
||
/// samples (`modern_brightness_matches_photoshop`), the photoshop corpus's brightness-contrast.psd
|
||
/// (+25 with contrast +30) and psd-tools' brightnesscontrast_rgb.psd, whose masked layers give
|
||
/// +23, -31 and -95 (+150 within 6 levels there, where every curve family tried bottoms out).
|
||
pub fn modern_brightness(v: f32, brightness: f32) -> f32 {
|
||
if brightness == 0.0 || !brightness.is_finite() {
|
||
return v;
|
||
}
|
||
Brightness::new(brightness).eval(v)
|
||
}
|
||
|
||
/// [`modern_brightness`] for one slider value, its curve's constants worked out once.
|
||
struct Brightness {
|
||
raise: bool,
|
||
s: f32,
|
||
e: f32,
|
||
x0: f32,
|
||
/// The knee's slope over the roll-off's secant (`α` of Fritsch–Carlson); a power roll-off
|
||
/// replaces the Hermite when α² + β² > 9.
|
||
alpha: f32,
|
||
hermite: bool,
|
||
}
|
||
|
||
/// Output level of the Brightness curve's knee.
|
||
const BRIGHTNESS_KNEE: f32 = 126.0 / 255.0;
|
||
|
||
impl Brightness {
|
||
fn new(brightness: f32) -> Self {
|
||
let b = brightness.abs();
|
||
let s = 1.375f32.powf(b / 50.0);
|
||
let e = 1.0 / (1.0 + 0.084 * b);
|
||
let x0 = (BRIGHTNESS_KNEE / s).min(1.0);
|
||
let secant = (1.0 - BRIGHTNESS_KNEE) / (1.0 - x0).max(1e-6);
|
||
let (alpha, beta) = (s / secant, e / secant);
|
||
Brightness { raise: brightness > 0.0, s, e, x0, alpha, hermite: alpha * alpha + beta * beta <= 9.0 }
|
||
}
|
||
|
||
fn eval(&self, v: f32) -> f32 {
|
||
let v = v.clamp(0.0, 1.0);
|
||
if self.raise {
|
||
return self.raised(v);
|
||
}
|
||
if v <= 0.0 || v >= 1.0 {
|
||
return v;
|
||
}
|
||
// The inverse of the raised curve (monotone on [0, 1]), by bisection.
|
||
let (mut lo, mut hi) = (0.0f32, 1.0f32);
|
||
for _ in 0..32 {
|
||
let mid = 0.5 * (lo + hi);
|
||
if self.raised(mid) < v {
|
||
lo = mid;
|
||
} else {
|
||
hi = mid;
|
||
}
|
||
}
|
||
0.5 * (lo + hi)
|
||
}
|
||
|
||
/// The curve raised: the line, then a monotone roll-off to (1, 1).
|
||
fn raised(&self, x: f32) -> f32 {
|
||
let Brightness { s, e, x0, alpha, hermite, .. } = *self;
|
||
let y0 = BRIGHTNESS_KNEE;
|
||
if x <= x0 {
|
||
return y0.min(s * x);
|
||
}
|
||
let h = (1.0 - x0).max(1e-6);
|
||
let t = ((x - x0) / h).clamp(0.0, 1.0);
|
||
let y = if hermite {
|
||
let (t2, t3) = (t * t, t * t * t);
|
||
(2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * h * s + (-2.0 * t3 + 3.0 * t2) + (t3 - t2) * h * e
|
||
} else {
|
||
1.0 - (1.0 - y0) * (1.0 - t).powf(alpha)
|
||
};
|
||
y.clamp(0.0, 1.0)
|
||
}
|
||
}
|
||
|
||
/// Modern Brightness/Contrast as one channel's lookup table (4096 samples over 0..1):
|
||
/// [`modern_brightness`] then [`modern_contrast`], as Photoshop applies them.
|
||
pub fn modern_lut(brightness: f32, contrast: f32) -> Vec<f32> {
|
||
let step = |k: usize, n: usize| k as f32 / (n - 1) as f32;
|
||
let bright: Vec<f32> = if brightness == 0.0 || !brightness.is_finite() {
|
||
(0..LUT_SIZE).map(|k| step(k, LUT_SIZE)).collect()
|
||
} else if brightness > 0.0 {
|
||
let curve = Brightness::new(brightness);
|
||
(0..LUT_SIZE).map(|k| curve.raised(step(k, LUT_SIZE))).collect()
|
||
} else {
|
||
// Lowered: invert the raised curve, sampled four times finer, in one monotone pass.
|
||
let curve = Brightness::new(-brightness);
|
||
let fine = 4 * LUT_SIZE;
|
||
let ys: Vec<f32> = (0..fine).map(|j| curve.raised(step(j, fine))).collect();
|
||
let mut j = 0;
|
||
(0..LUT_SIZE)
|
||
.map(|k| {
|
||
let y = step(k, LUT_SIZE);
|
||
while j + 2 < fine && ys.get(j + 1).is_some_and(|&n| n < y) {
|
||
j += 1;
|
||
}
|
||
let (y0, y1) = (ys.get(j).copied().unwrap_or(0.0), ys.get(j + 1).copied().unwrap_or(1.0));
|
||
let f = if y1 > y0 { ((y - y0) / (y1 - y0)).clamp(0.0, 1.0) } else { 0.0 };
|
||
(step(j, fine) + f / (fine - 1) as f32).clamp(0.0, 1.0)
|
||
})
|
||
.collect()
|
||
};
|
||
bright.into_iter().map(|b| modern_contrast(b, contrast).clamp(0.0, 1.0)).collect()
|
||
}
|
||
|
||
/// Modern Contrast curve (one channel, `contrast` in [-50, 100]). A symmetric cubic-Hermite S-curve
|
||
/// pivoting at 0.5: endpoint slope `1 - c/128`, centre slope `1 + c/128`. Near-exact vs Photoshop
|
||
/// (≤0.5/255). `c = 0` is the identity; pins 0, 0.5 and 1.
|
||
pub fn modern_contrast(v: f32, contrast: f32) -> f32 {
|
||
if contrast == 0.0 {
|
||
return v;
|
||
}
|
||
let k = contrast / 128.0;
|
||
let (end, mid) = (1.0 - k, 1.0 + k);
|
||
// Cubic Hermite on [0, 0.5]: pinned (0,0) slope `end`, (0.5,0.5) slope `mid`. `t` in [0,1].
|
||
let half = |t: f32| end * 0.5 * (t * t * t - 2.0 * t * t + t) + (-2.0 * t * t * t + 3.0 * t * t) * 0.5 + mid * 0.5 * (t * t * t - t * t);
|
||
let v = v.clamp(0.0, 1.0);
|
||
if v <= 0.5 { half(v / 0.5) } else { 1.0 - half((1.0 - v) / 0.5) }
|
||
}
|
||
|
||
/// Photoshop posterize: `n` equal input bins over 0..=255, output levels
|
||
/// `floor(k * 255 / (n - 1))` (exact on the corpus for 3, 7, 13 and 21 levels).
|
||
pub fn posterize(v: f32, levels: u32) -> f32 {
|
||
let n = levels.clamp(2, 255) as f32;
|
||
let x = (v.clamp(0.0, 1.0) * 255.0).round();
|
||
let bin = (x * n / 256.0).floor().min(n - 1.0);
|
||
(bin * 255.0 / (n - 1.0)).floor() / 255.0
|
||
}
|
||
|
||
/// [`posterize`] in a document of `depth` (`None`: unknown, treated as 8-bit). At 16 bits
|
||
/// Photoshop bins the 16-bit sample (`⌊v · n / 32768⌋`) and lands on 8-bit levels through a
|
||
/// 16-bit step: `⌊b · 32768 / (n - 1)⌋`, converted with `⌊(o · 255 + 16384) / 32768⌋`, so
|
||
/// 127.5 comes out as 128 but 42.5 as 42 (8 bits floor them all). Exact on every sample of
|
||
/// psd-tools posterize_16bits_rgb.psd and posterize_16bits_grayscale.psd (levels 2 to 21),
|
||
/// which the 8-bit rule missed by up to half the range.
|
||
pub fn posterize_in(v: f32, levels: u32, depth: Option<SampleType>) -> f32 {
|
||
if depth != Some(SampleType::U16) {
|
||
return posterize(v, levels);
|
||
}
|
||
let n = f64::from(levels.clamp(2, 255));
|
||
let x = (f64::from(v.clamp(0.0, 1.0)) * 32768.0).round();
|
||
let bin = (x * n / 32768.0).floor().min(n - 1.0);
|
||
let step = (bin * 32768.0 / (n - 1.0)).floor();
|
||
(((step * 255.0 + 16384.0) / 32768.0).floor() / 255.0) as 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 in three steps, each rounded half up to a whole level: the input-range stretch, the
|
||
/// midtone gamma ([`levels_gamma`]) and the output range. 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), and an output range narrower than 0..255 maps neighbouring gamma levels to one level
|
||
/// (psd-tools levels_rgb: 43..48 → 58 through a 0.47 channel gamma and the master's 48..255).
|
||
/// 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 quantum = quantum.filter(|q| q.is_finite() && *q >= 1.0);
|
||
let whole = |x: f32| match quantum {
|
||
Some(q) => (x * q + 0.5).floor() / q,
|
||
None => x,
|
||
};
|
||
let u = match quantum {
|
||
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 t = whole(levels_gamma(u, ch.gamma.max(0.01)));
|
||
whole(ch.out_black + t * (ch.out_white - ch.out_black))
|
||
}
|
||
|
||
/// Exponent of the shadow toe in [`levels_gamma`] (fitted; see there).
|
||
const LEVELS_TOE_POWER: f32 = 2.2;
|
||
|
||
/// Levels' midtone gamma `g` on `u` in 0..1: `u^(1/g)`, except that above 1, where that power
|
||
/// curve is infinitely steep at black, Photoshop starts along a toe `a·u − b·u^q` of slope
|
||
/// `a = 2^g` that joins the curve with the same value and slope. `q` = [`LEVELS_TOE_POWER`] is
|
||
/// fitted on whole-level oracles at gamma 1.16, 1.22, 1.3, 1.78 and 2.0 (psd-tools levels_rgb
|
||
/// and levels_grayscale, the photoshop corpus's rgb8 and gray8 levels.psd, a measured 0..255
|
||
/// ramp): all but 2 of their 1118 levels exact, the others within one. Below 1 the power curve is
|
||
/// exact (gamma 0.67 and 0.7: every level).
|
||
pub fn levels_gamma(u: f32, g: f32) -> f32 {
|
||
let c = 1.0 / g;
|
||
if g <= 1.0 {
|
||
return u.powf(c);
|
||
}
|
||
let (a, q) = (2f32.powf(g), LEVELS_TOE_POWER);
|
||
// Where the toe meets the curve: a·u1 − b·u1^q = u1^c and a − q·b·u1^(q−1) = c·u1^(c−1).
|
||
let u1 = ((1.0 - c / q) / (a * (1.0 - 1.0 / q))).powf(1.0 / (1.0 - c));
|
||
if u >= u1 || u1 <= 0.0 {
|
||
return u.powf(c);
|
||
}
|
||
let b = (a * u1 - u1.powf(c)) / u1.powf(q);
|
||
a * u - b * u.powf(q)
|
||
}
|
||
|
||
/// [`levels`] in a 32-bit document. Neither the input is clipped to the input range nor the result
|
||
/// to the output range there, and the midtone gamma is a plain power curve, mirrored below the
|
||
/// black point (the oracle corpus' rgb32 and gray32 levels.psd: within 0.5/255, 10/255 off with
|
||
/// 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 = 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)
|
||
}
|
||
|
||
/// The LUT of one Curves curve: [`curve_map_lut`] for a pencil-drawn map, else [`curve_lut`].
|
||
pub fn curve_lut_mode(points: &[CurvePoint], pencil: bool) -> Vec<f32> {
|
||
if pencil { curve_map_lut(points) } else { curve_lut(points) }
|
||
}
|
||
|
||
/// A pencil-drawn Curves map (see [`photocraft_doc::adjust::curve_map`]): straight segments
|
||
/// between its entries, constant outside the first/last, clamped to 0..=1, sampled into a LUT.
|
||
pub fn curve_map_lut(points: &[CurvePoint]) -> Vec<f32> {
|
||
let mut pts: Vec<(f32, f32)> = points.iter().filter(|p| p.input.is_finite() && p.output.is_finite()).map(|p| (p.input, p.output)).collect();
|
||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
|
||
let ident = |k: usize| k as f32 / (LUT_SIZE - 1) as f32;
|
||
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return (0..LUT_SIZE).map(ident).collect() };
|
||
let mut seg = 0;
|
||
(0..LUT_SIZE)
|
||
.map(|k| {
|
||
let xv = ident(k);
|
||
if xv <= first.0 {
|
||
return first.1.clamp(0.0, 1.0);
|
||
}
|
||
if xv >= last.0 {
|
||
return last.1.clamp(0.0, 1.0);
|
||
}
|
||
while pts.get(seg + 1).is_some_and(|p| p.0 < xv) {
|
||
seg += 1;
|
||
}
|
||
let (Some(&(x0, y0)), Some(&(x1, y1))) = (pts.get(seg), pts.get(seg + 1)) else { return last.1.clamp(0.0, 1.0) };
|
||
let t = if x1 > x0 { (xv - x0) / (x1 - x0) } else { 0.0 };
|
||
(y0 + (y1 - y0) * t).clamp(0.0, 1.0)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Photoshop curve: a natural cubic spline through the points (it may
|
||
/// overshoot between points, as Photoshop's does), constant outside the
|
||
/// first/last point, clamped to 0..=1, sampled into a LUT.
|
||
pub fn curve_lut(points: &[CurvePoint]) -> Vec<f32> {
|
||
let mut pts: Vec<(f32, f32)> = points.iter().map(|p| (p.input, p.output)).collect();
|
||
pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
|
||
if pts.len() < 2 {
|
||
return (0..LUT_SIZE).map(|i| i as f32 / (LUT_SIZE - 1) as f32).collect();
|
||
}
|
||
let n = pts.len();
|
||
// Second derivatives of the natural spline (tridiagonal solve).
|
||
let mut m2 = vec![0.0f64; n];
|
||
if n > 2 {
|
||
let x: Vec<f64> = pts.iter().map(|p| f64::from(p.0)).collect();
|
||
let y: Vec<f64> = pts.iter().map(|p| f64::from(p.1)).collect();
|
||
let mut c = vec![0.0f64; n];
|
||
let mut d = vec![0.0f64; n];
|
||
for i in 1..n - 1 {
|
||
let (h0, h1) = (x[i] - x[i - 1], x[i + 1] - x[i]);
|
||
let a = h0 / 6.0;
|
||
let b = (h0 + h1) / 3.0;
|
||
let cc = h1 / 6.0;
|
||
let r = (y[i + 1] - y[i]) / h1 - (y[i] - y[i - 1]) / h0;
|
||
let denom = b - a * c[i - 1];
|
||
c[i] = cc / denom;
|
||
d[i] = (r - a * d[i - 1]) / denom;
|
||
}
|
||
for i in (1..n - 1).rev() {
|
||
m2[i] = d[i] - c[i] * m2[i + 1];
|
||
}
|
||
}
|
||
(0..LUT_SIZE)
|
||
.map(|k| {
|
||
let xv = k as f32 / (LUT_SIZE - 1) as f32;
|
||
if xv <= pts[0].0 {
|
||
return pts[0].1.clamp(0.0, 1.0);
|
||
}
|
||
if xv >= pts[n - 1].0 {
|
||
return pts[n - 1].1.clamp(0.0, 1.0);
|
||
}
|
||
let i = pts.windows(2).position(|w| xv <= w[1].0).unwrap_or(n - 2);
|
||
let (x0, y0) = (f64::from(pts[i].0), f64::from(pts[i].1));
|
||
let (x1, y1) = (f64::from(pts[i + 1].0), f64::from(pts[i + 1].1));
|
||
let h = x1 - x0;
|
||
let t = f64::from(xv);
|
||
let a = (x1 - t) / h;
|
||
let b = (t - x0) / h;
|
||
let y = a * y0 + b * y1 + ((a * a * a - a) * m2[i] + (b * b * b - b) * m2[i + 1]) * h * h / 6.0;
|
||
(y as f32).clamp(0.0, 1.0)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// A layer-style contour curve: [`curve_lut`], except that the curve bends sharply at corner
|
||
/// points (`corners[i]` true; Photoshop's Contour Editor "Corner"): each run between corners is
|
||
/// its own natural spline, so all-corner points make a polyline. Missing flags are smooth.
|
||
pub fn contour_curve_lut(points: &[CurvePoint], corners: &[bool]) -> Vec<f32> {
|
||
let mut pts: Vec<(CurvePoint, bool)> = points.iter().enumerate().map(|(i, p)| (p.clone(), corners.get(i).copied().unwrap_or(false))).collect();
|
||
if !pts.iter().any(|p| p.1) {
|
||
return curve_lut(points);
|
||
}
|
||
pts.sort_by(|a, b| a.0.input.partial_cmp(&b.0.input).unwrap_or(std::cmp::Ordering::Equal));
|
||
pts.dedup_by(|a, b| (a.0.input - b.0.input).abs() < 1e-6);
|
||
let mut out: Vec<f32> = Vec::new();
|
||
let mut start = 0usize;
|
||
let last = pts.len().saturating_sub(1);
|
||
for i in 1..=last {
|
||
let corner = pts.get(i).is_some_and(|p| p.1);
|
||
if !(corner || i == last) {
|
||
continue;
|
||
}
|
||
let Some(seg) = pts.get(start..=i) else { break };
|
||
let from = seg.first().map_or(0.0, |p| p.0.input);
|
||
let lut = curve_lut(&seg.iter().map(|p| p.0.clone()).collect::<Vec<_>>());
|
||
if out.is_empty() {
|
||
out = lut;
|
||
} else {
|
||
for (k, (o, v)) in out.iter_mut().zip(lut).enumerate() {
|
||
if k as f32 / (LUT_SIZE - 1) as f32 >= from {
|
||
*o = v;
|
||
}
|
||
}
|
||
}
|
||
start = i;
|
||
}
|
||
if out.is_empty() { curve_lut(points) } else { out }
|
||
}
|
||
|
||
fn gradient(stops: &[(f32, [f32; 3])], t: f32) -> [f32; 3] {
|
||
match stops {
|
||
[] => [t; 3],
|
||
[s] => s.1,
|
||
_ => {
|
||
if t <= stops[0].0 {
|
||
return stops[0].1;
|
||
}
|
||
for w in stops.windows(2) {
|
||
if t <= w[1].0 {
|
||
let k = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 };
|
||
return std::array::from_fn(|i| w[0].1[i] + (w[1].1[i] - w[0].1[i]) * k);
|
||
}
|
||
}
|
||
stops[stops.len() - 1].1
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn rgb_to_hsl(c: [f32; 3]) -> (f32, f32, f32) {
|
||
let max = c[0].max(c[1]).max(c[2]);
|
||
let min = c[0].min(c[1]).min(c[2]);
|
||
let l = (max + min) / 2.0;
|
||
if (max - min).abs() < 1e-7 {
|
||
return (0.0, 0.0, l);
|
||
}
|
||
let d = max - min;
|
||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||
let h = if max == c[0] {
|
||
((c[1] - c[2]) / d).rem_euclid(6.0)
|
||
} else if max == c[1] {
|
||
(c[2] - c[0]) / d + 2.0
|
||
} else {
|
||
(c[0] - c[1]) / d + 4.0
|
||
};
|
||
(h / 6.0, s, l)
|
||
}
|
||
|
||
pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
|
||
if s <= 0.0 {
|
||
return [l; 3];
|
||
}
|
||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||
let p = 2.0 * l - q;
|
||
let f = |mut t: f32| {
|
||
t = t.rem_euclid(1.0);
|
||
if t < 1.0 / 6.0 {
|
||
p + (q - p) * 6.0 * t
|
||
} else if t < 0.5 {
|
||
q
|
||
} else if t < 2.0 / 3.0 {
|
||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||
} else {
|
||
p
|
||
}
|
||
};
|
||
[f(h + 1.0 / 3.0), f(h), f(h - 1.0 / 3.0)]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod lookup_tests {
|
||
use super::*;
|
||
|
||
fn identity(n: usize) -> Vec<f32> {
|
||
let m = (n - 1) as f32;
|
||
(0..n * n * n).flat_map(|i| [(i % n) as f32 / m, ((i / n) % n) as f32 / m, (i / (n * n)) as f32 / m]).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn identity_lut_is_identity_both_interpolations() {
|
||
let t = identity(5);
|
||
for c in [[0.1, 0.5, 0.9], [0.33, 0.77, 0.0], [1.0, 1.0, 1.0]] {
|
||
for tet in [false, true] {
|
||
let o = lut3d_sample(&t, 5, c, tet);
|
||
for k in 0..3 {
|
||
assert!((o[k] - c[k]).abs() < 1e-5, "{c:?} {tet} {o:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Photoshop 27.11, measured: absolute Selective Color on greys (red channel after one range's
|
||
/// cyan change).
|
||
#[test]
|
||
fn absolute_selective_color_matches_photoshop() {
|
||
// (range index, cyan %, grey, Photoshop's red)
|
||
let measured = [
|
||
(7, 50.0, 64u8, 32u8),
|
||
(7, 50.0, 192, 129),
|
||
(7, -50.0, 192, 223),
|
||
(7, -50.0, 64, 128),
|
||
(6, -50.0, 192, 224),
|
||
(6, -50.0, 240, 253),
|
||
(8, 50.0, 64, 32),
|
||
(8, 50.0, 16, 2),
|
||
];
|
||
for (range, cyan, grey, want) in measured {
|
||
let mut adj = [[0.0f32; 4]; 9];
|
||
adj[range][0] = cyan;
|
||
let g = grey as f32 / 255.0;
|
||
let got = selective_color([g; 3], false, &adj)[0] * 255.0;
|
||
assert!((got - want as f32).abs() <= 1.5, "range {range} cyan {cyan} grey {grey}: {got} vs {want}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn selective_color_zero_is_identity_and_ranges_are_local() {
|
||
let zero = [[0.0f32; 4]; 9];
|
||
for c in [[0.9, 0.1, 0.1], [0.5, 0.5, 0.5], [0.2, 0.4, 0.8]] {
|
||
assert_eq!(selective_color(c, true, &zero), c);
|
||
}
|
||
// +100 % cyan on reds only: red loses red, blue is untouched.
|
||
let mut a = zero;
|
||
a[0][0] = 100.0;
|
||
let red = selective_color([0.9, 0.1, 0.1], false, &a);
|
||
assert!(red[0] < 0.2, "{red:?}");
|
||
assert_eq!(selective_color([0.1, 0.1, 0.9], false, &a), [0.1, 0.1, 0.9]);
|
||
// Relative mode can't tint pure white; absolute can (via whites).
|
||
let mut w = zero;
|
||
w[6] = [0.0, 0.0, 50.0, 0.0];
|
||
assert_eq!(selective_color([1.0; 3], true, &w), [1.0; 3]);
|
||
assert!(selective_color([1.0; 3], false, &w)[2] < 0.6);
|
||
}
|
||
|
||
#[test]
|
||
fn dither_offsets_are_balanced() {
|
||
let sum: f32 = (0..4).flat_map(|y| (0..4).map(move |x| bayer4(x, y))).sum();
|
||
assert!(sum.abs() < 1e-5);
|
||
assert_eq!(bayer4(-4, -4), bayer4(0, 0));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tone_tests {
|
||
use super::*;
|
||
use photocraft_geom::Rect;
|
||
|
||
fn ramp() -> Buffer {
|
||
let rect = Rect::new(0, 0, 8, 8);
|
||
let px = (0..64).map(|i| [((i % 8) as f32) / 7.0, ((i / 8) as f32) / 7.0, 0.3, 1.0]).collect();
|
||
Buffer { rect, px }
|
||
}
|
||
|
||
/// Photoshop 27.11, measured: Brightness/Contrast with Use Legacy on, over a gray ramp
|
||
/// (x = 0, 16, …, 240, 255), one row per (brightness, contrast).
|
||
/// One pixel through `adj` in a 32-bit document.
|
||
fn hdr(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] {
|
||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||
apply_depth(adj, &mut b, Transfer::Gamma(1.0), Some(SampleType::F32));
|
||
[b.px[0][0], b.px[0][1], b.px[0][2]]
|
||
}
|
||
|
||
/// Photoshop's own 32-bit results (photoshop corpus rgb32): nothing clips, legacy contrast
|
||
/// pivots at 127, the mixer cuts its percentages to 1/1024 and counts 255/512 of the
|
||
/// constant, and the Black & White tint keeps values above 1.
|
||
#[test]
|
||
fn thirty_two_bit_adjustments_match_photoshop_unclipped() {
|
||
let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol);
|
||
let bc = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true };
|
||
// brightness-contrast-legacy.psd
|
||
assert!(near(hdr(&bc, [0.4910, 0.0009, 0.9301]), [0.6280, -0.0721, 1.2553], 2e-4), "{:?}", hdr(&bc, [0.4910, 0.0009, 0.9301]));
|
||
// channel-mixer.psd: pure red, green, blue and black, exactly.
|
||
let mixer = Adjustment::ChannelMixer { matrix: [[0.8, 0.3, -0.1, 0.0], [0.1, 0.9, 0.0, 0.05], [0.0, 0.2, 0.7, 0.0]], monochrome: false };
|
||
for (c, want) in [
|
||
([1.0, 0.0, 0.0], [0.7998047, 0.1245117, 0.0]),
|
||
([0.0, 1.0, 0.0], [0.2998047, 0.9243164, 0.1992188]),
|
||
([0.0, 0.0, 1.0], [-0.0996094, 0.0249023, 0.6992188]),
|
||
([0.0, 0.0, 0.0], [0.0, 0.0249023, 0.0]),
|
||
] {
|
||
assert!(near(hdr(&mixer, c), want, 1e-6), "{c:?}: {:?}", hdr(&mixer, c));
|
||
}
|
||
// black-white-tint.psd: white tints past 1.
|
||
let bw = Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: Some([0.88235295, 0.827451, 0.7019608]) };
|
||
assert!(near(hdr(&bw, [1.0; 3]), [1.0522, 0.9973, 0.8718], 2e-4), "{:?}", hdr(&bw, [1.0; 3]));
|
||
// 8/16-bit documents still clip.
|
||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [1.0, 1.0, 1.0, 1.0]);
|
||
apply_depth(&bw, &mut b, Transfer::Srgb, None);
|
||
assert!(b.px[0].iter().all(|v| *v <= 1.0));
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_brightness_contrast_matches_photoshop() {
|
||
const X: [u8; 17] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255];
|
||
let measured: [(f32, f32, [u8; 17]); 7] = [
|
||
(40.0, 0.0, [40, 56, 72, 88, 104, 120, 136, 152, 168, 184, 200, 216, 232, 248, 255, 255, 255]),
|
||
(-40.0, 0.0, [0, 0, 0, 8, 24, 40, 56, 72, 88, 104, 120, 136, 152, 168, 184, 200, 215]),
|
||
(0.0, 30.0, [0, 0, 0, 14, 37, 60, 82, 105, 128, 151, 174, 196, 219, 242, 255, 255, 255]),
|
||
(0.0, -30.0, [38, 49, 61, 72, 83, 94, 106, 117, 128, 139, 150, 162, 173, 184, 195, 207, 217]),
|
||
(40.0, 30.0, [3, 25, 48, 71, 94, 117, 139, 162, 185, 208, 231, 253, 255, 255, 255, 255, 255]),
|
||
(100.0, 0.0, [100, 116, 132, 148, 164, 180, 196, 212, 228, 244, 255, 255, 255, 255, 255, 255, 255]),
|
||
(0.0, 80.0, [0, 0, 0, 0, 0, 0, 0, 48, 128, 208, 255, 255, 255, 255, 255, 255, 255]),
|
||
];
|
||
for (brightness, contrast, want) in measured {
|
||
let rect = Rect::new(0, 0, 17, 1);
|
||
let px = X.iter().map(|x| [f32::from(*x) / 255.0; 4]).map(|p| [p[0], p[1], p[2], 1.0]).collect();
|
||
let mut b = Buffer { rect, px };
|
||
apply(&Adjustment::BrightnessContrast { brightness, contrast, legacy: true }, &mut b);
|
||
for (i, (p, w)) in b.px.iter().zip(want).enumerate() {
|
||
let got = (p[0] * 255.0).round() as i32;
|
||
assert!((got - i32::from(w)).abs() <= 1, "b {brightness} c {contrast} at {}: {got} vs Photoshop {w}", X[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Pixel → Photoshop's split. 8-bit from psd-tools threshold_rgb.psd: the first three are
|
||
/// white though their Rec. 601 grey rounds below 60, and (131, 109, 99) sits exactly on 114.5
|
||
/// at level 115 (halves round down). 16-bit from threshold_16bits_rgb.psd and the photoshop
|
||
/// corpus's rgb16 threshold-128.psd, whose mid grey 16448 is level 128 in 16 bits.
|
||
#[test]
|
||
fn threshold_splits_on_the_luminosity_like_photoshop() {
|
||
let c8 = |r: u8, g: u8, b: u8| [r, g, b].map(|v| f32::from(v) / 255.0);
|
||
for (c, level, white) in
|
||
[(c8(87, 51, 31), 60u8, true), (c8(90, 49, 33), 60, true), (c8(68, 57, 50), 60, true), (c8(48, 63, 72), 60, false), (c8(131, 109, 99), 115, false)]
|
||
{
|
||
assert_eq!(threshold_white(c, f32::from(level) / 255.0, Some(SampleType::U8)), white, "8-bit {c:?} at {level}");
|
||
}
|
||
let c16 = |r: u16, g: u16, b: u16| [r, g, b].map(|v| f32::from(v) / 32768.0);
|
||
for (c, level, white) in [
|
||
(c16(8995, 7196, 6425), 60u8, false),
|
||
(c16(11180, 6554, 3984), 60, false),
|
||
(c16(16448, 16448, 16448), 128, true),
|
||
(c16(16447, 16447, 16447), 128, false),
|
||
] {
|
||
assert_eq!(threshold_white(c, f32::from(level) / 255.0, Some(SampleType::U16)), white, "16-bit {c:?} at {level}");
|
||
}
|
||
// Through the adjustment: neutral black or white.
|
||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [87.0 / 255.0, 51.0 / 255.0, 31.0 / 255.0, 1.0]);
|
||
apply_depth(&Adjustment::Threshold { level: 60.0 / 255.0 }, &mut b, Transfer::Srgb, Some(SampleType::U8));
|
||
assert_eq!(b.px[0], [1.0, 1.0, 1.0, 1.0]);
|
||
}
|
||
|
||
/// 16-bit sample → Photoshop's 8-bit level, from psd-tools posterize_16bits_rgb.psd: bins
|
||
/// split on the 16-bit sample (10794 and 10923 straddle a third of 32768), and the middle of 3
|
||
/// levels is 128 where 8 bits give 127, while 21 levels keep 25 for 25.5.
|
||
#[test]
|
||
fn sixteen_bit_posterize_matches_photoshop() {
|
||
let points: [(u32, u16, u8); 9] =
|
||
[(3, 10794, 0), (3, 10923, 128), (3, 21845, 128), (3, 21974, 255), (13, 2504, 0), (13, 2542, 21), (13, 7453, 42), (13, 7582, 64), (21, 3341, 25)];
|
||
for (levels, x, want) in points {
|
||
let got = posterize_in(f32::from(x) / 32768.0, levels, Some(SampleType::U16));
|
||
assert!((got * 255.0 - f32::from(want)).abs() < 1e-3, "{levels} levels at {x}: {} vs Photoshop {want}", got * 255.0);
|
||
}
|
||
// 8 bits keep their rule.
|
||
assert_eq!(posterize_in(128.0 / 255.0, 3, Some(SampleType::U8)), posterize(128.0 / 255.0, 3));
|
||
}
|
||
|
||
#[test]
|
||
fn color_lookup_with_an_overflowing_stored_size_is_the_identity() {
|
||
// A stored document's size is untrusted: (2²²)³ × 3 overflows, which once wrapped past
|
||
// the table-length guard and indexed out of bounds.
|
||
for size in [1 << 22, u32::MAX] {
|
||
let a = Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false };
|
||
let mut b = ramp();
|
||
apply(&a, &mut b);
|
||
assert_eq!(b.px, ramp().px, "size {size}");
|
||
}
|
||
}
|
||
|
||
/// Photoshop 27.11, measured: Color Balance with one cyan–red slider moved, over a gray ramp
|
||
/// (x = 0, 16, …, 240, 255); red and green out (blue follows green on gray).
|
||
#[test]
|
||
fn color_balance_matches_photoshop() {
|
||
const X: [u8; 17] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255];
|
||
type Row = (&'static str, f32, bool, [u8; 17], [u8; 17]);
|
||
let measured: [Row; 6] = [
|
||
("midtones", 50.0, false, [0, 36, 59, 78, 96, 112, 128, 142, 156, 170, 183, 196, 209, 221, 233, 244, 255], X),
|
||
("shadows", -100.0, false, [0, 0, 0, 0, 0, 0, 0, 7, 23, 43, 67, 93, 122, 154, 186, 221, 255], X),
|
||
("highlights", 100.0, false, [0, 51, 84, 111, 136, 160, 182, 202, 223, 242, 255, 255, 255, 255, 255, 255, 255], X),
|
||
("shadows", 50.0, true, X, [0, 0, 0, 0, 17, 37, 57, 77, 97, 117, 137, 157, 177, 197, 216, 236, 255]),
|
||
(
|
||
"midtones",
|
||
100.0,
|
||
true,
|
||
[0, 36, 59, 78, 96, 112, 128, 142, 156, 170, 183, 196, 209, 221, 233, 244, 255],
|
||
[0, 5, 14, 24, 36, 50, 64, 80, 97, 114, 132, 151, 171, 191, 212, 234, 255],
|
||
),
|
||
("highlights", -100.0, true, X, [0, 26, 53, 79, 105, 132, 158, 184, 211, 237, 255, 255, 255, 255, 255, 255, 255]),
|
||
];
|
||
// Two sliders in one tone, Preserve Luminosity on (measured): shadows red and blue +50 darken
|
||
// only green; highlights red −50 with green +50 lift green by 100 and blue by 50.
|
||
let two =
|
||
|tones: [[f32; 3]; 3], x: f32| -> [i32; 3] { std::array::from_fn(|ch| (color_balance_channel(tones, true, ch, x / 255.0) * 255.0).round() as i32) };
|
||
assert_eq!(two([[50.0, 0.0, 50.0], [0.0; 3], [0.0; 3]], 128.0), [128, 97, 128]);
|
||
let hi = two([[0.0; 3], [0.0; 3], [-50.0, 50.0, 0.0]], 128.0);
|
||
assert!((hi[0] - 128).abs() <= 1 && (hi[1] - 211).abs() <= 1 && (hi[2] - 159).abs() <= 1, "{hi:?}");
|
||
for (tone, amount, preserve, red, green) in measured {
|
||
let mut tones = [[0.0f32; 3]; 3];
|
||
tones[["shadows", "midtones", "highlights"].iter().position(|t| *t == tone).unwrap()][0] = amount;
|
||
for (i, x) in X.iter().enumerate() {
|
||
let v = f32::from(*x) / 255.0;
|
||
let got = |ch| (color_balance_channel(tones, preserve, ch, v) * 255.0).round() as i32;
|
||
let (r, g) = (got(0), got(1));
|
||
assert!((r - i32::from(red[i])).abs() <= 1, "{tone} {amount} pl {preserve} at {x}: red {r} vs Photoshop {}", red[i]);
|
||
assert!((g - i32::from(green[i])).abs() <= 1, "{tone} {amount} pl {preserve} at {x}: green {g} vs Photoshop {}", green[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Photoshop, measured (8-bit gray ramp, every channel): whole settings, where the tones fold
|
||
/// into one Levels per channel (#3423). The issue's midtones red +100 / magenta −100 (the
|
||
/// shadow toe), its sequoia14 layer with Preserve Luminosity off and on, all nine sliders with
|
||
/// Preserve Luminosity on, a black point past the white point, and lowered shadows with raised
|
||
/// highlights (their gammas cancel: a straight line between the two points).
|
||
#[test]
|
||
fn color_balance_whole_settings_match_photoshop() {
|
||
const X: [u8; 12] = [0, 4, 8, 16, 32, 64, 96, 128, 160, 192, 224, 255];
|
||
type Case = ([[f32; 3]; 3], bool, [[u8; 12]; 3]);
|
||
let cases: [Case; 6] = [
|
||
(
|
||
[[0.0; 3], [100.0, -100.0, 0.0], [0.0; 3]],
|
||
false,
|
||
[[0, 16, 30, 55, 90, 128, 156, 181, 202, 221, 239, 255], [0, 0, 0, 1, 4, 16, 36, 64, 100, 145, 197, 255], X],
|
||
),
|
||
(
|
||
[[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]],
|
||
false,
|
||
[
|
||
[0, 0, 2, 9, 25, 56, 89, 122, 156, 188, 222, 255],
|
||
[0, 5, 9, 18, 35, 67, 100, 131, 163, 194, 225, 255],
|
||
[0, 3, 7, 14, 29, 60, 91, 123, 156, 189, 222, 255],
|
||
],
|
||
),
|
||
(
|
||
[[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]],
|
||
true,
|
||
[
|
||
[0, 0, 0, 0, 5, 42, 80, 117, 154, 189, 226, 255],
|
||
[0, 4, 8, 16, 33, 66, 98, 130, 162, 195, 226, 255],
|
||
[0, 0, 0, 0, 15, 48, 82, 116, 151, 186, 221, 255],
|
||
],
|
||
),
|
||
(
|
||
[[40.0, -30.0, 20.0], [-50.0, 25.0, 60.0], [30.0, -70.0, 10.0]],
|
||
true,
|
||
[
|
||
[0, 1, 3, 9, 25, 69, 126, 193, 255, 255, 255, 255],
|
||
[0, 0, 0, 0, 0, 0, 46, 93, 136, 177, 217, 255],
|
||
[0, 0, 0, 0, 44, 107, 156, 199, 238, 255, 255, 255],
|
||
],
|
||
),
|
||
(
|
||
[[100.0, -100.0, 0.0], [0.0; 3], [-100.0, 100.0, 0.0]],
|
||
true,
|
||
[X, [0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 0, 0, 0, 130, 255, 255, 255, 255]],
|
||
),
|
||
(
|
||
[[-76.0, -2.0, 60.0], [0.0; 3], [99.0, 0.0, -30.0]],
|
||
false,
|
||
[
|
||
[0, 0, 0, 0, 0, 0, 71, 171, 255, 255, 255, 255],
|
||
[0, 2, 6, 14, 29, 61, 94, 126, 158, 191, 224, 255],
|
||
[0, 6, 11, 21, 39, 73, 106, 137, 168, 197, 227, 255],
|
||
],
|
||
),
|
||
];
|
||
for (tones, preserve, want) in cases {
|
||
for (ch, row) in want.iter().enumerate() {
|
||
for (x, w) in X.iter().zip(row) {
|
||
let got = (color_balance_channel(tones, preserve, ch, f32::from(*x) / 255.0) * 255.0).round() as i32;
|
||
assert!((got - i32::from(*w)).abs() <= 1, "{tones:?} pl {preserve} ch {ch} at {x}: {got} vs Photoshop {w}");
|
||
}
|
||
}
|
||
}
|
||
// Hostile input stays in range.
|
||
for v in [f32::NAN, -1.0, 2.0, f32::INFINITY] {
|
||
let out = color_balance_channel([[f32::NAN, 1e9, -1e9]; 3], true, 7, v);
|
||
assert!((0.0..=1.0).contains(&out), "{v}: {out}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn color_balance_midtones_with_multiple_sliders_preserve_luminosity() {
|
||
// A shared offset depends on the two extremes, not the sum of moved sliders.
|
||
// The old per-slider product leaves red/blue unchanged for [30, 0, 30].
|
||
let tones = [[0.0; 3], [30.0, 0.0, 30.0], [0.0; 3]];
|
||
for input in [0.25_f32, 0.5, 0.75] {
|
||
for (ch, offset) in [-0.15_f32, 0.15, -0.15].into_iter().enumerate() {
|
||
let expected = input.powf(2f32.powf(offset));
|
||
let actual = color_balance_channel(tones, true, ch, input);
|
||
assert!((actual - expected).abs() < 1e-6, "channel {ch}, input {input}: {actual} vs {expected}");
|
||
}
|
||
// Preserve Luminosity off must still affect only the moved channels.
|
||
assert_eq!(color_balance_channel(tones, false, 1, input), input);
|
||
// One moved slider retains its previous half-strength compensation.
|
||
let single = [[0.0; 3], [30.0, 0.0, 0.0], [0.0; 3]];
|
||
assert!((color_balance_channel(single, true, 0, input) - input.powf(2f32.powf(-0.15))).abs() < 1e-6);
|
||
assert!((color_balance_channel(single, true, 1, input) - input.powf(2f32.powf(0.15))).abs() < 1e-6);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn levels_and_curves_apply_each_channel_before_the_master() {
|
||
// These two affine tone adjustments describe the same mapping:
|
||
// channel(x) = 1 / 2 + x / 2, then master(x) = x / 2, so 0 maps to 0.25.
|
||
// Levels used to apply the master first (0 mapped to 0.5).
|
||
let line = |a: f32, b: f32| vec![CurvePoint { input: 0.0, output: a }, CurvePoint { input: 1.0, output: b }];
|
||
let levels = Adjustment::Levels {
|
||
master: LevelsChannel { out_white: 0.5, ..Default::default() },
|
||
per_channel: std::array::from_fn(|_| LevelsChannel { out_black: 0.5, ..Default::default() }),
|
||
space: ToneSpace::Rgb,
|
||
black: Default::default(),
|
||
};
|
||
let curves = Adjustment::Curves {
|
||
master: line(0.0, 0.5),
|
||
per_channel: std::array::from_fn(|_| line(0.5, 1.0)),
|
||
space: ToneSpace::Rgb,
|
||
black: Vec::new(),
|
||
pencil: false,
|
||
};
|
||
for depth in [None, Some(SampleType::U8), Some(SampleType::U16), Some(SampleType::F32)] {
|
||
let ll = tone_luts_depth(&levels, depth);
|
||
let cl = tone_luts_depth(&curves, depth);
|
||
for channel in 0..3 {
|
||
for index in [0, LUT_SIZE / 4, LUT_SIZE / 2, 3 * LUT_SIZE / 4, LUT_SIZE - 1] {
|
||
assert!(
|
||
(ll[channel][index] - cl[channel][index]).abs() < 0.01,
|
||
"{depth:?} channel {channel} sample {index}: Levels {} vs Curves {}",
|
||
ll[channel][index],
|
||
cl[channel][index]
|
||
);
|
||
}
|
||
}
|
||
assert!((cl[0][0] - 0.25).abs() < 0.01, "{depth:?}: {}", cl[0][0]);
|
||
|
||
// Exercise the compositor's real adjustment path, not only its LUT builder.
|
||
for adj in [&levels, &curves] {
|
||
let mut pixel = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.0, 0.0, 0.0, 1.0]] };
|
||
apply_depth(adj, &mut pixel, Transfer::Srgb, depth);
|
||
for channel in 0..3 {
|
||
assert!((pixel.px[0][channel] - 0.25).abs() < 0.01, "{depth:?}: {adj:?} rendered {:?}", pixel.px[0]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Lab has no composite/master tone record.
|
||
let mut lab = curves.clone();
|
||
let mut lab_without_master = curves;
|
||
if let Adjustment::Curves { space, .. } = &mut lab {
|
||
*space = ToneSpace::Lab;
|
||
}
|
||
if let Adjustment::Curves { master, space, .. } = &mut lab_without_master {
|
||
*space = ToneSpace::Lab;
|
||
*master = line(0.0, 1.0);
|
||
}
|
||
assert_eq!(tone_luts(&lab), tone_luts(&lab_without_master));
|
||
}
|
||
|
||
#[test]
|
||
fn identity_tone_in_every_space_is_identity() {
|
||
for space in [ToneSpace::Rgb, ToneSpace::Cmyk, ToneSpace::Lab] {
|
||
let mut a = Adjustment::identity_curves();
|
||
if let Adjustment::Curves { space: s, .. } = &mut a {
|
||
*s = space;
|
||
}
|
||
let mut b = ramp();
|
||
apply(&a, &mut b);
|
||
for (x, y) in b.px.iter().zip(ramp().px.iter()) {
|
||
for k in 0..3 {
|
||
assert!((x[k] - y[k]).abs() < 0.02, "{space:?} {x:?} {y:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cmyk_black_curve_darkens_and_lab_lightness_lifts() {
|
||
let line = |a: f32, b: f32| vec![CurvePoint { input: 0.0, output: a }, CurvePoint { input: 1.0, output: b }];
|
||
// Black brightness 1 -> 0.5: more black ink everywhere.
|
||
let a = Adjustment::Curves {
|
||
master: line(0.0, 1.0),
|
||
per_channel: [line(0.0, 1.0), line(0.0, 1.0), line(0.0, 1.0)],
|
||
space: ToneSpace::Cmyk,
|
||
black: line(0.0, 0.5),
|
||
pencil: false,
|
||
};
|
||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.8, 0.8, 0.8, 1.0]] };
|
||
apply(&a, &mut b);
|
||
assert!(b.px[0][0] < 0.7, "{:?}", b.px[0]);
|
||
// Lab lightness 0..1 -> 0.2..1 lifts a dark grey; a/b untouched keeps it grey.
|
||
let a = Adjustment::Curves {
|
||
master: line(1.0, 0.0), // ignored in Lab
|
||
per_channel: [line(0.2, 1.0), line(0.0, 1.0), line(0.0, 1.0)],
|
||
space: ToneSpace::Lab,
|
||
black: Vec::new(),
|
||
pencil: false,
|
||
};
|
||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.2, 0.2, 0.2, 1.0]] };
|
||
apply(&a, &mut b);
|
||
let p = b.px[0];
|
||
assert!(p[0] > 0.3 && (p[0] - p[2]).abs() < 0.02, "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn hue_ranges_only_touch_their_colours() {
|
||
let mut ranges = HueRange::defaults();
|
||
ranges[0].saturation = -100.0; // desaturate reds
|
||
let a = Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges };
|
||
let mut b = Buffer { rect: Rect::new(0, 0, 3, 1), px: vec![[0.9, 0.1, 0.1, 1.0], [0.1, 0.1, 0.9, 1.0], [0.5, 0.5, 0.5, 1.0]] };
|
||
apply(&a, &mut b);
|
||
let red = b.px[0];
|
||
assert!((red[0] - red[1]).abs() < 0.05, "red desaturated {red:?}");
|
||
assert!((b.px[1][2] - 0.9).abs() < 1e-4 && (b.px[1][0] - 0.1).abs() < 1e-4, "blue untouched {:?}", b.px[1]);
|
||
assert_eq!(b.px[2], [0.5, 0.5, 0.5, 1.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn colorize_at_zero_saturation_is_grey() {
|
||
// Photoshop: Colorize at 0% saturation leaves no colour, only the pixel's lightness (#2326).
|
||
let px = vec![[0.9, 0.2, 0.1, 1.0], [0.1, 0.6, 0.8, 1.0], [0.5, 0.5, 0.5, 1.0]];
|
||
for depth in [None, Some(SampleType::U8), Some(SampleType::U16), Some(SampleType::F32)] {
|
||
for lightness in [0.0, 30.0, -30.0] {
|
||
let a = Adjustment::HueSaturation { hue: 200.0, saturation: 0.0, lightness, colorize: true, ranges: HueRange::defaults() };
|
||
let mut b = Buffer { rect: Rect::new(0, 0, 3, 1), px: px.clone() };
|
||
apply_depth(&a, &mut b, Transfer::Srgb, depth);
|
||
for p in &b.px {
|
||
assert!((p[0] - p[1]).abs() < 1e-4 && (p[1] - p[2]).abs() < 1e-4, "{depth:?} lightness {lightness}: {p:?}");
|
||
}
|
||
}
|
||
// Low saturations still tint: the old 25% floor no longer hides them.
|
||
let tint = |saturation: f32| {
|
||
let a = Adjustment::HueSaturation { hue: 0.0, saturation, lightness: 0.0, colorize: true, ranges: HueRange::defaults() };
|
||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.5, 0.5, 0.5, 1.0]] };
|
||
apply_depth(&a, &mut b, Transfer::Srgb, depth);
|
||
b.px[0][0] - b.px[0][2]
|
||
};
|
||
assert!(tint(10.0) > 0.05 && tint(10.0) < tint(25.0), "{depth:?}: 10% {} vs 25% {}", tint(10.0), tint(25.0));
|
||
}
|
||
}
|
||
|
||
/// Photoshop 27.11, measured: Hue/Saturation raising saturation, and Colorize with Lightness.
|
||
#[test]
|
||
fn hue_saturation_matches_photoshop() {
|
||
// (input, hue, saturation, lightness, colorize, Photoshop's output)
|
||
type Case = ([u8; 3], f32, f32, f32, bool, [u8; 3]);
|
||
let measured: [Case; 8] = [
|
||
([204, 102, 102], 0.0, 50.0, 0.0, false, [254, 51, 51]),
|
||
([204, 159, 102], 0.0, 50.0, 0.0, false, [254, 164, 51]),
|
||
([204, 102, 102], 0.0, -50.0, 0.0, false, [178, 127, 127]),
|
||
([0, 0, 0], 120.0, 100.0, 30.0, true, [0, 152, 0]),
|
||
([28, 28, 28], 120.0, 100.0, 30.0, true, [0, 192, 0]),
|
||
([160, 160, 160], 30.0, 25.0, -40.0, true, [120, 96, 73]),
|
||
([252, 252, 252], 30.0, 25.0, -40.0, true, [177, 151, 126]),
|
||
([255, 143, 0], 30.0, 25.0, -40.0, true, [95, 76, 58]),
|
||
];
|
||
for (input, hue, saturation, lightness, colorize, want) in measured {
|
||
let a = Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges: HueRange::defaults() };
|
||
let c = input.map(|v| v as f32 / 255.0);
|
||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[c[0], c[1], c[2], 1.0]] };
|
||
apply(&a, &mut b);
|
||
for (i, w) in want.iter().enumerate() {
|
||
let got = b.px[0][i] * 255.0;
|
||
assert!((got - *w as f32).abs() <= 2.0, "{input:?} h{hue} s{saturation} l{lightness} colorize {colorize}, channel {i}: {got} vs {w}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Photoshop 27.11, measured: a range's Lightness (Blues ±50) moves towards the brightest or darkest channel.
|
||
#[test]
|
||
fn range_lightness_matches_photoshop() {
|
||
let measured = [
|
||
([0u8, 29, 115], 0.5, [57u8, 71, 115]),
|
||
([0, 64, 255], 0.5, [125, 157, 255]),
|
||
([102, 128, 204], 0.5, [152, 165, 204]),
|
||
([102, 128, 204], -0.5, [102, 115, 154]),
|
||
([0, 18, 115], -0.5, [0, 9, 58]),
|
||
];
|
||
for (input, l, want) in measured {
|
||
let got = range_lightness(input.map(|v| v as f32 / 255.0), l);
|
||
for i in 0..3 {
|
||
assert!((got[i] * 255.0 - want[i] as f32).abs() <= 2.5, "{input:?} channel {i}: {} vs {}", got[i] * 255.0, want[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Photoshop's own render (merged image of psd-tools `adjustments/huesaturation_lightness_rgb.psd`,
|
||
/// layer "Hue/Saturation 1"): a dull magenta gets the whole range edit, as a vivid one does (#4089).
|
||
#[test]
|
||
fn hue_ranges_apply_in_full_to_dull_colours() {
|
||
let range = |lightness: f32, bounds: [f32; 4]| HueRange { hue: 0.0, saturation: 0.0, lightness, bounds };
|
||
let ranges = [
|
||
range(-11.0, [-57.0, 4.0, 82.0, 175.0]),
|
||
range(100.0, [12.0, 45.0, 75.0, 105.0]),
|
||
HueRange::neutral(2),
|
||
HueRange::neutral(3),
|
||
HueRange::neutral(4),
|
||
range(100.0, [-117.0, -50.0, 39.0, 104.0]),
|
||
];
|
||
let a = Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 17.0, colorize: false, ranges };
|
||
let measured = [([126u8, 97, 120], [148u8, 148, 148]), ([131, 103, 125], [152, 152, 152]), ([95, 69, 90], [122, 122, 122])];
|
||
for (input, want) in measured {
|
||
let c = input.map(|v| v as f32 / 255.0);
|
||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[c[0], c[1], c[2], 1.0]] };
|
||
apply(&a, &mut b);
|
||
for (i, w) in want.iter().enumerate() {
|
||
let got = b.px[0][i] * 255.0;
|
||
assert!((got - *w as f32).abs() <= 2.0, "{input:?} channel {i}: {got} vs {w}");
|
||
}
|
||
}
|
||
// Greys have no hue, so the ranges leave them alone: only the master Lightness moves them.
|
||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.4, 0.4, 0.4, 1.0]] };
|
||
apply(&a, &mut b);
|
||
let p = b.px[0];
|
||
assert!((p[0] - p[1]).abs() < 1e-6 && (p[1] - p[2]).abs() < 1e-6 && (p[0] - (0.4 + 0.6 * 0.17)).abs() < 1e-4, "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn black_white_weights() {
|
||
let d = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
|
||
assert!((black_white_gray([0.5; 3], &d) - 0.5).abs() < 1e-6, "greys keep their value");
|
||
assert!((black_white_gray([1.0, 0.0, 0.0], &d) - 0.4).abs() < 1e-6);
|
||
assert!((black_white_gray([1.0, 1.0, 0.0], &d) - 0.6).abs() < 1e-6);
|
||
let mut w = d;
|
||
w[0] = 100.0;
|
||
assert!(black_white_gray([1.0, 0.0, 0.0], &w) > black_white_gray([1.0, 0.0, 0.0], &d));
|
||
}
|
||
|
||
/// Photoshop 27.11, measured: Black & White (default weights) with Tint over greys. The tint
|
||
/// is blended like the Color blend mode: its hue and saturation at the grey's luminosity.
|
||
#[test]
|
||
fn black_white_tint_matches_photoshop() {
|
||
let weights = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
|
||
let measured: [([u8; 3], u8, [u8; 3]); 6] = [
|
||
([255, 0, 0], 24, [81, 1, 1]),
|
||
([255, 0, 0], 120, [255, 64, 64]),
|
||
([255, 0, 0], 240, [255, 235, 235]),
|
||
([0xe1, 0xd3, 0xb3], 120, [133, 119, 87]),
|
||
([0xe1, 0xd3, 0xb3], 24, [33, 24, 1]),
|
||
([0x40, 0x20, 0x10], 120, [144, 112, 96]),
|
||
];
|
||
for (tint, grey, want) in measured {
|
||
let tint = Some(tint.map(|v| v as f32 / 255.0));
|
||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[grey as f32 / 255.0; 4]] };
|
||
b.px[0][3] = 1.0;
|
||
apply(&Adjustment::BlackWhite { weights, tint }, &mut b);
|
||
for (i, w) in want.iter().enumerate() {
|
||
let got = b.px[0][i] * 255.0;
|
||
assert!((got - *w as f32).abs() <= 2.5, "tint {tint:?} grey {grey} channel {i}: {got} vs {w}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn gradient_map_follows_the_w3c_luminosity() {
|
||
// Photoshop's Gradient Map is driven by 0.3 R + 0.59 G + 0.11 B (photoshop corpus rgb8 and
|
||
// rgb16 gradient-map.psd), not the Rec. 601 grey.
|
||
let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])];
|
||
for (c, t) in [([0.0, 1.0, 0.0], 0.59), ([1.0, 0.0, 0.0], 0.3), ([0.0, 0.0, 1.0], 0.11), ([0.2, 0.6, 0.9], 0.513)] {
|
||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||
apply(&Adjustment::GradientMap { stops: stops.clone(), reverse: false, dither: false }, &mut b);
|
||
assert!((b.px[0][0] - t).abs() < 1e-5, "{c:?}: {}", b.px[0][0]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn gradient_map_dither_stays_close() {
|
||
let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])];
|
||
let mut a = ramp();
|
||
let mut b = ramp();
|
||
apply(&Adjustment::GradientMap { stops: stops.clone(), reverse: false, dither: false }, &mut a);
|
||
apply(&Adjustment::GradientMap { stops, reverse: false, dither: true }, &mut b);
|
||
assert!(a.px.iter().zip(&b.px).all(|(x, y)| (x[0] - y[0]).abs() <= 0.5 / 255.0 + 1e-6));
|
||
assert!(a.px.iter().zip(&b.px).any(|(x, y)| x[0] != y[0]));
|
||
}
|
||
|
||
/// `adj` applied in a Lab document to one encoded sRGB colour, returned as (L, a, b).
|
||
fn in_lab(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] {
|
||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||
apply_in(adj, &mut b, Transfer::Srgb, Some(SampleType::U8), ColorMode::Lab);
|
||
srgb_to_lab([b.px[0][0], b.px[0][1], b.px[0][2]])
|
||
}
|
||
|
||
/// A muted, in-gamut colour from (L, a, b).
|
||
fn lab(l: f32, a: f32, b: f32) -> [f32; 3] {
|
||
lab_to_srgb([l, a, b])
|
||
}
|
||
|
||
fn near(got: [f32; 3], want: [f32; 3], tol: f32) -> bool {
|
||
got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol)
|
||
}
|
||
|
||
#[test]
|
||
fn lab_invert_flips_l_a_and_b() {
|
||
let got = in_lab(&Adjustment::Invert, lab(30.0, 12.0, -18.0));
|
||
// n -> 1 - n: L -> 100 - L, a -> -a - 1, b -> -b - 1 (mirrored on the 0..255 grid).
|
||
assert!(near(got, [70.0, -13.0, 17.0], 0.6), "{got:?}");
|
||
// On the screen colour it would not be a mirror in Lab.
|
||
let mut rgb = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 1.0]);
|
||
let c = lab(30.0, 12.0, -18.0);
|
||
rgb.px[0] = [c[0], c[1], c[2], 1.0];
|
||
apply(&Adjustment::Invert, &mut rgb);
|
||
assert!(!near(srgb_to_lab([rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]]), got, 1.0));
|
||
}
|
||
|
||
#[test]
|
||
fn sixteen_bit_lab_invert_keeps_greys_neutral() {
|
||
// 16-bit Lab Invert mirrors a and b around neutral (photoshop corpus lab16 invert.psd);
|
||
// 8-bit bytes land one step off it.
|
||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 1.0]);
|
||
let c = lab(40.0, 0.0, 0.0);
|
||
b.px[0] = [c[0], c[1], c[2], 1.0];
|
||
apply_in(&Adjustment::Invert, &mut b, Transfer::Srgb, Some(SampleType::U16), ColorMode::Lab);
|
||
let got = srgb_to_lab([b.px[0][0], b.px[0][1], b.px[0][2]]);
|
||
assert!(near(got, [60.0, 0.0, 0.0], 0.05), "{got:?}");
|
||
assert!(near(in_lab(&Adjustment::Invert, c), [60.0, -1.0, -1.0], 0.1));
|
||
}
|
||
|
||
/// `adj` applied in a CMYK document to one encoded sRGB colour.
|
||
fn in_cmyk(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] {
|
||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||
apply_in(adj, &mut b, Transfer::Srgb, Some(SampleType::U8), ColorMode::Cmyk);
|
||
[b.px[0][0], b.px[0][1], b.px[0][2]]
|
||
}
|
||
|
||
#[test]
|
||
fn cmyk_invert_posterize_and_threshold_work_on_the_inks() {
|
||
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
|
||
// Paper inverts to all four inks at 100%, not to RGB black (photoshop corpus cmyk8 invert.psd).
|
||
assert!(near(in_cmyk(&Adjustment::Invert, [1.0; 3]), cmyk_to_rgb([1.0; 4]), 2e-3));
|
||
let red = [0.9, 0.2, 0.1];
|
||
assert!(near(in_cmyk(&Adjustment::Invert, red), cmyk_to_rgb(rgb_to_cmyk(red).map(|v| 1.0 - v)), 2e-3));
|
||
let two = cmyk_to_rgb(rgb_to_cmyk(red).map(|v| if v >= 0.5 { 1.0 } else { 0.0 }));
|
||
assert!(near(in_cmyk(&Adjustment::Posterize { levels: 2 }, red), two, 2e-3));
|
||
// Threshold's black is the black ink alone (threshold-128.psd), its white the paper.
|
||
let t = Adjustment::Threshold { level: 0.5 };
|
||
assert!(near(in_cmyk(&t, [0.2; 3]), cmyk_to_rgb([0.0, 0.0, 0.0, 1.0]), 2e-3));
|
||
assert!(near(in_cmyk(&t, [0.8; 3]), cmyk_to_rgb([0.0; 4]), 2e-3));
|
||
}
|
||
|
||
/// CMYK posterizes the stored value 1 - ink: with 3 levels an ink of 85 (stored 170) lands on
|
||
/// the middle step, 128 (stored 127), where posterizing the ink would drop it to none (psd-tools
|
||
/// posterize_cmyk.psd, levels 3 to 21, exact so).
|
||
#[test]
|
||
fn cmyk_posterize_works_on_the_stored_values() {
|
||
let mut ink = [85.0 / 255.0; 4];
|
||
stored_channels(&Adjustment::Posterize { levels: 3 }, ColorMode::Cmyk, Some(SampleType::U8), &mut ink);
|
||
assert!(ink.iter().all(|v| (v * 255.0 - 128.0).abs() < 1e-3), "{ink:?}");
|
||
assert_eq!(posterize(85.0 / 255.0, 3), 0.0, "posterizing the ink itself");
|
||
}
|
||
|
||
#[test]
|
||
fn cmyk_threshold_compares_the_inks_plain_luma() {
|
||
assert_eq!(ink_luma([0.0; 4]), 1.0);
|
||
assert_eq!(ink_luma([0.0, 0.0, 0.0, 1.0]), 0.0);
|
||
// Two greys either side of level 128 in photoshop corpus cmyk8 threshold-128.psd: black,
|
||
// then white (8-bit inks).
|
||
let at = |i: [f32; 4]| {
|
||
let mut ch = i.map(|v| v / 255.0);
|
||
stored_channels(&Adjustment::Threshold { level: 128.0 / 255.0 }, ColorMode::Cmyk, Some(SampleType::U8), &mut ch);
|
||
ch
|
||
};
|
||
assert_eq!(at([144.0, 122.0, 121.0, 35.0]), [0.0, 0.0, 0.0, 1.0]);
|
||
assert_eq!(at([131.0, 110.0, 110.0, 19.0]), [0.0; 4]);
|
||
}
|
||
|
||
#[test]
|
||
fn cmyk_brightness_contrast_curves_each_ink_from_paper() {
|
||
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
|
||
// The tone curve runs on each ink's stored value, 1 - ink (photoshop corpus cmyk8
|
||
// brightness-contrast.psd and -legacy.psd): raising brightness takes ink away, and paper
|
||
// and solid ink stay put.
|
||
let red = [0.9, 0.2, 0.1];
|
||
for legacy in [false, true] {
|
||
let adj = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy };
|
||
let table = ink_brightness_contrast_lut(25.0, 30.0, legacy);
|
||
let want = cmyk_to_rgb(rgb_to_cmyk(red).map(|v| 1.0 - lut(&table, 1.0 - v)));
|
||
assert!(near(in_cmyk(&adj, red), want, 2e-3), "legacy {legacy}");
|
||
assert!(near(in_cmyk(&adj, [1.0; 3]), [1.0; 3], 2e-3), "legacy {legacy}: paper");
|
||
let solid = cmyk_to_rgb([1.0; 4]);
|
||
assert!(near(in_cmyk(&adj, solid), cmyk_to_rgb(rgb_to_cmyk(solid).map(|v| ink_through(&table, v))), 2e-3));
|
||
}
|
||
// Legacy +25 / +30 on Photoshop's own 16-bit inks (photoshop corpus cmyk16
|
||
// brightness-contrast-legacy.psd): contrast pivots at 128 in ink, 127 stored.
|
||
let legacy = ink_brightness_contrast_lut(25.0, 30.0, true);
|
||
for (ink, ps) in [(139.0, 108.0), (160.0, 137.99), (172.0, 155.13), (183.0, 170.85), (196.0, 189.42), (98.0, 49.42), (128.0, 92.28), (168.0, 149.42)] {
|
||
let got = ink_through(&legacy, ink / 255.0) * 255.0;
|
||
assert!((got - ps).abs() < 0.1, "ink {ink}: {got} vs Photoshop {ps}");
|
||
}
|
||
let brighter = Adjustment::BrightnessContrast { brightness: 60.0, contrast: 0.0, legacy: false };
|
||
let ink = ink_brightness_contrast_lut(60.0, 0.0, false);
|
||
assert!(ink_through(&ink, 0.5) < 0.5 && ink_through(&ink, 0.0) == 0.0 && (ink_through(&ink, 1.0) - 1.0).abs() < 1e-6);
|
||
// On the screen colour it would land elsewhere.
|
||
let mut rgb = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [red[0], red[1], red[2], 1.0]);
|
||
apply(&brighter, &mut rgb);
|
||
assert!(!near(in_cmyk(&brighter, red), [rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]], 0.01));
|
||
}
|
||
|
||
#[test]
|
||
fn lab_posterize_steps_l_a_and_b() {
|
||
let got = in_lab(&Adjustment::Posterize { levels: 2 }, lab(70.0, 20.0, -30.0));
|
||
// Two levels: each normalised channel snaps to 0 or 1 (L 100, a and b at +127 / -128).
|
||
assert!(near(got, srgb_to_lab(lab_denorm([1.0, 1.0, 0.0])), 1e-3), "{got:?}");
|
||
let grey = in_lab(&Adjustment::Posterize { levels: 255 }, lab(50.0, 0.0, 0.0));
|
||
assert!(near(grey, [50.0, 0.0, 0.0], 1.1), "255 levels is within a step of the identity: {grey:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn lab_threshold_splits_on_l_with_a_neutral_result() {
|
||
let t = Adjustment::Threshold { level: 0.5 };
|
||
// A saturated blue (L ≈ 32) and yellow (L ≈ 97): black and white, nothing in between.
|
||
for (c, white) in [([0.0, 0.0, 1.0], false), ([1.0, 1.0, 0.0], true), (lab(52.0, 40.0, 30.0), true), (lab(48.0, -40.0, 30.0), false)] {
|
||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||
apply_in(&t, &mut b, Transfer::Srgb, None, ColorMode::Lab);
|
||
let v = if white { 1.0 } else { 0.0 };
|
||
assert_eq!(b.px[0], [v, v, v, 1.0], "{c:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lab_brightness_contrast_moves_l_only() {
|
||
for legacy in [false, true] {
|
||
let adj = Adjustment::BrightnessContrast { brightness: 40.0, contrast: 30.0, legacy };
|
||
let before = [45.0, 15.0, -20.0];
|
||
let got = in_lab(&adj, lab(before[0], before[1], before[2]));
|
||
assert!(got[0] > before[0] + 5.0, "{legacy}: L rises: {got:?}");
|
||
assert!((got[1] - before[1]).abs() < 0.5 && (got[2] - before[2]).abs() < 0.5, "{legacy}: a, b kept: {got:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lab_gradient_map_follows_l() {
|
||
let stops = vec![(0.0, [1.0, 0.0, 0.0]), (1.0, [0.0, 0.0, 1.0])];
|
||
for reverse in [false, true] {
|
||
let map = Adjustment::GradientMap { stops: stops.clone(), reverse, dither: false };
|
||
// A grey and a saturated colour with the same L map to the same colour.
|
||
let grey = in_lab(&map, lab(60.0, 0.0, 0.0));
|
||
let green = in_lab(&map, lab(60.0, -50.0, 40.0));
|
||
assert!(near(grey, green, 1e-3), "{reverse}: {grey:?} vs {green:?}");
|
||
let t = if reverse { 0.4 } else { 0.6 };
|
||
let want = srgb_to_lab([1.0 - t, 0.0, t]);
|
||
assert!(near(grey, want, 0.05), "{reverse}: {grey:?} vs {want:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn other_modes_and_adjustments_keep_the_screen_colour_path() {
|
||
let c = [0.2, 0.5, 0.7, 1.0];
|
||
let lab_only = [Adjustment::Invert, Adjustment::Threshold { level: 0.47 }, Adjustment::Posterize { levels: 4 }];
|
||
for adj in
|
||
lab_only.iter().chain([&Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }])
|
||
{
|
||
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] {
|
||
let (mut a, mut b) = (Buffer::filled(crate::Rect::new(0, 0, 1, 1), c), Buffer::filled(crate::Rect::new(0, 0, 1, 1), c));
|
||
apply_in(adj, &mut a, Transfer::Srgb, None, mode);
|
||
apply_depth(adj, &mut b, Transfer::Srgb, None);
|
||
let own = (mode == ColorMode::Lab && lab_channel_adjustment(adj)) || (mode == ColorMode::Cmyk && cmyk_channel_adjustment(adj));
|
||
assert_eq!(a.px == b.px, !own, "{adj:?} in {mode:?}");
|
||
}
|
||
}
|
||
// Transparent pixels are left alone and odd values never panic.
|
||
let mut t = Buffer::filled(crate::Rect::new(0, 0, 2, 1), [f32::NAN, 2.0, -1.0, 0.0]);
|
||
for adj in lab_only.iter().chain([&Adjustment::GradientMap { stops: vec![], reverse: true, dither: true }]) {
|
||
for mode in [ColorMode::Lab, ColorMode::Cmyk] {
|
||
apply_in(adj, &mut t, Transfer::Srgb, None, mode);
|
||
let mut odd = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [f32::NAN, f32::INFINITY, -3.0, 1.0]);
|
||
apply_in(adj, &mut odd, Transfer::Srgb, Some(SampleType::U16), mode);
|
||
}
|
||
}
|
||
assert!(t.px[0][1] == 2.0);
|
||
}
|
||
}
|
||
|
||
/// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128,
|
||
/// measured on Photoshop 27.11; a 0.5 pivot moved it to 130).
|
||
pub const LEGACY_PIVOT: f32 = 128.0 / 255.0;
|
||
|
||
#[cfg(test)]
|
||
mod lut3d_guard_tests {
|
||
use super::lut3d_sample;
|
||
|
||
#[test]
|
||
fn degenerate_or_short_tables_leave_the_colour_alone() {
|
||
let c = [0.2, 0.4, 0.6];
|
||
for tetra in [false, true] {
|
||
assert_eq!(lut3d_sample(&[], 0, c, tetra), c);
|
||
assert_eq!(lut3d_sample(&[0.0; 3], 1, c, tetra), c);
|
||
assert_eq!(lut3d_sample(&[0.0; 3], 2, c, tetra), c);
|
||
assert_eq!(lut3d_sample(&[0.0; 24], usize::MAX, c, tetra), c);
|
||
}
|
||
}
|
||
}
|