mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-19 02:10:25 -05:00
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:
1 parent
8d40b3e372
commit
5d864c0374
6 files changed
+78
-5
No files matched your search
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user