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
This commit is contained in:
eliets2 committed 2026-10-14 00:51:56 +03:00
1 parent b3d392b499
commit 3ad5eadab2
3 files changed
+60 -3

No files matched your search

+50
View File
@@ -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
+4 -3
View File
@@ -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 (🟡) |
+6
View File
@@ -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