LR-MASK-FEATHER-EDGE: Refine Edges for masks

Mask.refine (0–100, mask.refine; Masking panel ▸ Refine Edges): after a mask's components are
combined, a guided filter steered by the photo's luminance (window up to ~4 % of the long
edge) snaps soft mask edges to the photo's edges. Rendered on the CPU (the GPU path has no
kernel for it yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 03:16:54 -04:00
1 parent 8d40b3e372
commit 5d864c0374
6 files changed
+78 -5

No files matched your search

+8 -1
View File
@@ -802,11 +802,18 @@ pub struct Mask {
pub invert: bool,
pub components: Vec<MaskComponent>,
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 }
}
}
+17
View File
@@ -208,6 +208,23 @@ pub fn specs() -> Vec<CommandSpec> {
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",
+1
View File
@@ -431,6 +431,7 @@ pub fn develop(src: &Arc<Rgb32f>, 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;
+35
View File
@@ -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}");
}
}
+13
View File
@@ -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;
}
+4 -4
View File
@@ -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.
<!-- /parity:summary -->
## 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 |