diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 080a574e6..78125bbb0 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -802,11 +802,18 @@ pub struct Mask { pub invert: bool, pub components: Vec, pub adjust: LocalAdjustments, + /// Refine Edges 0..100: the mask's edges snap to the photo's (guided filter). + #[serde(skip_serializing_if = "is_zero")] + pub refine: f64, +} + +fn is_zero(v: &f64) -> bool { + *v == 0.0 } impl Default for Mask { fn default() -> Self { - Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default() } + Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default(), refine: 0.0 } } } diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index a0a05f521..0b0f2288c 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -208,6 +208,23 @@ pub fn specs() -> Vec { Ok(json!({"samples": out})) } ), + cmd!( + "mask.refine", + "Refine Mask Edges", + [], + None, + "{id?, value: 0..100} — the mask's edges snap to the photo's (guided filter; rendered on the CPU)", + has_active, + |s, p| { + let v = f64_or(p, "value", 0.0).clamp(0.0, 100.0); + let mid = mask_id(p, s.active_mask, "mask.refine")?; + masks_edit(s, "mask.refine", "Refine Edges", |masks, _| { + let i = find(masks, mid, "mask.refine")?; + masks[i].refine = v; + Ok(()) + }) + } + ), cmd!( "mask.adjust", "Set Mask Adjustments", diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 444203bd2..c12819853 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -431,6 +431,7 @@ pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: & // LUT profiles have no GPU stage: they render on the CPU if gpu && !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id) + && !s.masks.iter().any(|m| m.visible && m.refine > 0.0) && let Some(r) = lightcraft_gpu::render(src, info, s, req, stages) { return r; diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 4f8865595..4accbcd74 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -56,6 +56,16 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l } first = false; } + if m.refine > 0.0 { + // Refine Edges: the mask's edges follow the photo's (window up to ~4 % of the long edge, + // the width of a soft brush edge or gradient) + let k = (m.refine / 100.0).clamp(0.0, 1.0) as f32; + let sigma = (0.04 * w.max(h) as f32 * k).max(1.0); + let refined = guided_cross(log_l, &alpha, sigma, 0.02); + for (a, r) in alpha.data.iter_mut().zip(&refined.data) { + *a += (r - *a) * k.sqrt(); + } + } if m.invert { alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v); } @@ -407,4 +417,29 @@ mod tests { let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0); assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50)); } + + /// Refine Edges: a soft mask over a hard edge in the photo snaps to that edge. + #[test] + fn refine_edges_follows_the_photo() { + let (w, h) = (120usize, 80usize); + // left half dark, right half bright + let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { [0.05; 3] } else { [0.6; 3] }); + let log_l = Plane::from_fn(w, h, |x, _| if x < 60 { (0.05f32).log2() } else { (0.6f32).log2() }); + let f = Frame::new(w, h, &Default::default(), true); + let soft = Mask { + components: vec![MaskComponent { + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.75, 0.5), end: Point::new(0.25, 0.5) }, + }], + ..Default::default() + }; + let refined = Mask { refine: 100.0, ..soft.clone() }; + let step = |m: &Mask| { + let a = evaluate_one(m, &f, w, h, &img, &log_l, 0.0); + a.data[40 * w + 63] - a.data[40 * w + 56] + }; + let (plain, sharp) = (step(&soft), step(&refined)); + assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}"); + } } diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 56c3dcc62..ce7837b56 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -250,6 +250,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }; let out = slider(ui, &amt, m.adjust.amount, true, None); apply_slider_out(app, &amt, out, |app, v| app.run("mask.adjust", json!({"values": {"amount": v}}))); + let refine = ControlSpec { + id: "refine", + label: "Refine Edges", + section: Section::Light, + min: 0.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Track::Plain, + }; + let out = slider(ui, &refine, m.refine, true, None); + apply_slider_out(app, &refine, out, |app, v| app.run("mask.refine", json!({"value": v}))); ui.add_space(30.0); let _ = Stroke::NONE; } diff --git a/docs/parity.md b/docs/parity.md index 2c764796d..f8ba92c01 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -31,7 +31,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 3 | 7 | 0 | 8/8 (100%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 3 | 6 | 0 | 8/8 (100%) | 5/5 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 80 | 1 | 4 | 8 | 46/47 (98%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 24 | 8 | 47 | 9 | — | 20/21 (95%) | -| **Total** | 376 | 25 | 99 | 37 | 193/198 (97%) | 136/144 (94%) | +| **Total** | 377 | 25 | 98 | 37 | 193/198 (97%) | 136/144 (94%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **77.7%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 34.5% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **77.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 35.1% of 158. ## Top gaps @@ -324,7 +324,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-COMBINE | Add / subtract / intersect | P0 | ✅ | `cmd:mask.addComponent` | | | LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | | | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | -| LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ⬜ | | | +| LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | | LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; no auto-show on hover | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode |