From 06d38bce12ff5e80eb77bcc0fe3cbd64fd48052c Mon Sep 17 00:00:00 2001 From: timothydaly1 Date: Wed, 14 Oct 2026 16:36:27 -0400 Subject: [PATCH] Lab: adjustment results are whole samples, as Photoshop stores them The native Lab path (compose::native_lab) worked a stack out on the stored L, a and b but kept each adjustment layer's result unrounded, so the screen showed values between two samples where Photoshop, which stores every result at the document's depth, has whole bytes (or 1/32768 steps). On saturated colours half a byte of L is a couple of RGB levels. Each adjustment's result is now rounded to the depth's samples (adjustment_quantum: 8 and 16 bits; 32-bit stays unrounded), as the RGB compositor already does. photoshop lab8 curves.psd goes from 0.0084 to exact (PASS); Photoshop oracles 185 -> 186, floor 186. lab8 brightness-contrast (modern) stays DIFF: our modern curve is within half a byte of Photoshop's on L, not exact, so rounding now shows its misses (0.02 % -> 1.05 % of pixels). Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/native_lab.rs | 8 +++++++- crates/compose/src/tests.rs | 26 ++++++++++++++++++++++++++ crates/io/tests/corpus.rs | 2 +- docs/file-format-parity.md | 5 +++-- docs/gaps.md | 5 +++-- docs/scorecard.md | 4 ++-- 6 files changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/compose/src/native_lab.rs b/crates/compose/src/native_lab.rs index 0db59499de..771240a46b 100644 --- a/crates/compose/src/native_lab.rs +++ b/crates/compose/src/native_lab.rs @@ -89,6 +89,9 @@ fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: /// A Lab document's visible layers, prepared for compositing on their stored L, a and b. pub struct LabStack<'a> { items: Vec>, + /// Steps per unit of the document's samples (`None` at 32 bits): adjustment results are + /// whole samples, as Photoshop stores them. + quantum: Option, } impl<'a> LabStack<'a> { @@ -101,7 +104,7 @@ impl<'a> LabStack<'a> { } let mut items = Vec::new(); collect(&doc.layers, doc.depth, true, &mut items, 0)?; - Some(LabStack { items }) + Some(LabStack { items, quantum: crate::adjustment_quantum(doc.depth) }) } /// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly). @@ -161,6 +164,9 @@ impl<'a> LabStack<'a> { op(&mut lab); for (c, v) in lab.iter().enumerate() { d[c] += (v - d[c]) * mix; + if let Some(q) = self.quantum { + d[c] = (d[c] * q + 0.5).floor() / q; + } } } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index d8842a24fd..24ffd8316d 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2884,6 +2884,32 @@ fn lab_adjustments_work_on_the_stored_values() { near3([p[0], p[1], p[2]], lab_display([l, byte(218), byte(38)]), 1e-3, "levels on L at half opacity"); } +// An adjustment's result in a Lab document is whole samples, as Photoshop stores it: Curves +// taking L 100 to 120 of 255 shows L 120 exactly (the photoshop corpus's lab8 curves.psd is exact +// so, where the unrounded result left saturated colours a level off on screen), at 8 bits; 16 bits +// round to 1/32768. +#[test] +fn lab_adjustment_results_are_whole_samples() { + let pts = |v: &[(f32, f32)]| v.iter().map(|&(input, output)| CurvePoint { input, output }).collect::>(); + let curves = |l: &[(f32, f32)]| Adjustment::Curves { + master: Vec::new(), + per_channel: [pts(l), Vec::new(), Vec::new()], + space: ToneSpace::Lab, + black: Vec::new(), + pencil: false, + }; + // A straight line from (0, 0) through (100, 120.4): L 100 lands between two bytes. + let adj = curves(&[(0.0, 0.0), (100.0 / 255.0, 120.4 / 255.0), (1.0, 1.0)]); + for (depth, q) in [(SampleType::U8, 255.0f32), (SampleType::U16, 32768.0)] { + let doc = lab_stack([100.0 / 255.0, 0.5, 0.5], depth, vec![adj.clone()]); + let p = px(&doc, 1, 1); + let stack = native_lab::LabStack::new(&doc); + assert!(stack.is_some_and(|s| s.adjusts()), "{depth:?}: the native path"); + let l = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])[0] / 100.0; + assert!((l * q - (l * q).round()).abs() < 0.02, "{depth:?}: L {} is between two samples", l * q); + } +} + // Flattening a Lab document keeps its stored values, out-of-gamut colours included (the RGB // compositor clipped them on the way through display RGB). #[test] diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index a8d81a1600..8c853f9d37 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -156,7 +156,7 @@ const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-t /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 185, roundtrip_floor: 258 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 186, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2651f4c4d1..7d078bcd51 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (Lab adjustment results are whole samples; Photoshop-oracle floor 186/258; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 185/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 186/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Lab documents round each adjustment layer's result on the stored L, a, b to whole samples, as Photoshop stores them (the native Lab path): photoshop lab8 curves.psd passes; Photoshop-oracle floor 185 → 186 of 258 | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | CMYK Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with black kept, on the inks for display, export and destructive edits; cmyk8/16 hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25% | | 2026-10-13 | minor | CMYK Posterize works on the stored values (1 − ink), as Photoshop does: psd-tools posterize_cmyk passes (13.8 % → 0 of pixels off on the ink path); psd-tools floor → 242 of 309 | diff --git a/docs/gaps.md b/docs/gaps.md index 52b467b2dd..5ff91a77ff 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: Lab adjustment results are whole samples, Photoshop-oracle floor 186/258; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -43,7 +43,7 @@ what an agent can't finish alone. - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored - oracles 185/258 (72%; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% + oracles 186/258 (72%; Lab adjustment results on whole samples 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object @@ -263,6 +263,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G1: Lab adjustment layers' results are whole samples (native Lab path); photoshop lab8 curves passes; Photoshop-oracle floor 185 → 186 of 258 | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged | | 2026-10-13 | minor | G1: CMYK Posterize works on the stored values (1 − ink); psd-tools posterize_cmyk passes; psd-tools floor → 242 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index fe9c144ba8..56fa36340e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 185/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 186/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 164 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -94,7 +94,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 148 | 169 | | psd-tools corpus | `corpus/psd-tools` | 309 | 249 | 308 | -| photoshop oracles | `corpus/photoshop` | 256 | 185 | 258 | +| photoshop oracles | `corpus/photoshop` | 256 | 186 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---|