From 3ad5eadab2c7852c69fa70069e6a3f5bd318f813 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:51:56 +0300 Subject: [PATCH] LR-REM-HEAL: checkpoint zero-feather spot edge correction Treat a zero-width feather transition as a hard edge so exact-radius pixels remain unchanged, including at zero opacity. Add actual image regressions for all spot modes, image corners and two clone output sizes. Validation: both new regressions failed on the original implementation and pass here. Formatting, workspace Clippy, HEIF and a generated-photo app smoke test pass. The unfiltered workspace gate remains blocked by the existing all-masks overlay test tracked by #1599; later parity, layers, assets and WASM stages are pending. This is a source checkpoint, not a green release candidate. Contributor: @eliets2 --- crates/pipeline/src/spots.rs | 50 ++++++++++++++++++++++++++++++++++++ docs/parity-checklist.md | 7 ++--- docs/whats-new.md | 6 +++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 3bb775df4..d3ecb4f26 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -18,6 +18,11 @@ use crate::geometry::Frame; #[inline] fn smooth(e0: f32, e1: f32, x: f32) -> f32 { + // Zero feather is a hard edge, including when the pixel lies exactly on the radius. + // Dividing 0 by 0 here would give that pixel a NaN alpha, even at zero opacity. + if e0 >= e1 { + return if x < e1 { 0.0 } else { 1.0 }; + } let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); t * t * (3.0 - 2.0 * t) } @@ -369,6 +374,51 @@ mod tests { use super::*; use lightcraft_develop::DevelopSettings; + #[test] + fn hard_edge_spots_at_zero_opacity_leave_every_pixel_unchanged() { + for mode in [SpotMode::Clone, SpotMode::Heal, SpotMode::Remove, SpotMode::Fill] { + for (cx, cy) in [(32.5, 24.5), (0.5, 0.5)] { + let original = Rgb32f::from_fn(64, 48, |x, y| [0.2 + x as f32 / 128.0, 0.3 + y as f32 / 128.0, 0.4]); + let mut img = original.clone(); + let frame = Frame::new(64, 48, &DevelopSettings::default(), true); + let spot = Spot { + mode, + points: vec![Point::new(cx / 64.0, cy / 48.0)], + size: 5.0 / 64.0, + feather: 0.0, + opacity: 0.0, + source_offset: Some(Point::new(8.0 / 64.0, 0.0)), + }; + apply(&mut img, &[spot], &frame, frame.px_per_long(64)); + for (i, (actual, expected)) in img.data.iter().zip(&original.data).enumerate() { + assert_eq!(actual, expected, "zero-opacity {mode:?} at ({cx}, {cy}), pixel {i}"); + } + } + } + } + + #[test] + fn hard_edge_clone_copies_inside_and_preserves_pixels_on_the_radius() { + for (w, h, cx, cy, radius) in [(64, 48, 32.5, 24.5, 5.0), (128, 96, 64.5, 48.5, 10.0)] { + let original = Rgb32f::from_fn(w, h, |x, y| [0.2 + x as f32 / w as f32, y as f32 / h as f32, 0.4]); + let mut img = original.clone(); + let frame = Frame::new(w, h, &DevelopSettings::default(), true); + let spot = Spot { + mode: SpotMode::Clone, + points: vec![Point::new(cx / w as f64, cy / h as f64)], + size: radius / w as f64, + feather: 0.0, + opacity: 100.0, + source_offset: Some(Point::new(0.125, 0.0)), + }; + apply(&mut img, &[spot], &frame, frame.px_per_long(w)); + let (x, y) = (cx as usize, cy as usize); + assert_eq!(img.get(x, y), original.get(x + w / 8, y), "inside copies the source at {w}px"); + assert_eq!(img.get(x + radius as usize, y), original.get(x + radius as usize, y), "radius stays unchanged at {w}px"); + assert!(img.data.iter().flatten().all(|v| v.is_finite())); + } + } + #[test] fn remove_erases_a_dot() { // flat grey with a gentle gradient and a black dot in the middle diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..1bb9ba9b6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -261,8 +261,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-REM-CONTENTAWARE | Content-aware remove | P1 | 🟡 | `cmd:spot.add` (`mode: remove` / `fill`), `crates/pipeline/src/spots.rs`, `crates/inpaint/src/lib.rs` | Remove heals with an automatic source; Fill synthesises the area from its surroundings (PatchMatch completion over a membrane-blended start, `crates/inpaint`, ported from PhotoCraft; bounded by the spot size limit); no generative fill (M8.4) | -| LR-REM-HEAL | Heal | P0 | ✅ | `cmd:spot.add` (`mode: heal`) | | -| LR-REM-CLONE | Clone | P0 | ✅ | `cmd:spot.add` (`mode: clone`) | | +| LR-REM-HEAL | Heal | P0 | ✅ | `cmd:spot.add` (`mode: heal`), `crates/pipeline/src/spots.rs` | Zero-feather edges use a hard step; zero-opacity spots preserve all pixels, including exact-radius pixels and image corners | +| LR-REM-CLONE | Clone | P0 | ✅ | `cmd:spot.add` (`mode: clone`), `crates/pipeline/src/spots.rs` | Hard-edge source copying and unchanged radius pixels regression-tested at two output sizes | | LR-REM-GEN | Generative remove | OOS | 🚫 | | | | LR-REM-DETECT | Object detection for remove | P2 | ⬜ | | | | LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | ✅ | `cmd:spot.add` (`size`, `feather`, `opacity`), `cmd:brush.smaller`, `cmd:brush.larger`, `cmd:brush.featherLess`, `cmd:brush.featherMore`, `crates/ui-egui/src/panels/right.rs` | Size / Feather / Opacity sliders (for new spots and the selected one); `[` `]` size, ⇧`[` ⇧`]` feather (also the Masking brush) | @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-14 | patch | LR-REM-HEAL, LR-REM-CLONE: zero-feather edges preserve exact-radius pixels; zero-opacity regression covers all spot modes and image corners. Statuses and feature counts unchanged. | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index b5c2ac68c..7c7328276 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,11 @@ # What's new in LightCraft +## 2026-10-14 + +### Hard-edged Heal and Clone spots +- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity + changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. + ## 2026-10-13 ### Slider drags on large raws