From 8d40b3e37270b82927289ea6219e5de8553030d3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:07:49 -0400 Subject: [PATCH] LR-REM-DUST: Find Dust Spots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lightcraft-pipeline dust.rs: sensor-dust detection on a rendered image — pixels darker than their blurred surroundings by more than 3× the local texture, grouped into small (0.15–2 % of the long edge), roughly round blobs. spot.findDust {sensitivity?, add?} renders the uncropped photo, detects and adds a heal spot on each (auto source; one undo step). Remove panel ▸ Find Dust Spots. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/masks.rs | 36 ++++++++ crates/engine/src/tests_import.rs | 39 +++++++++ crates/pipeline/src/dust.rs | 128 +++++++++++++++++++++++++++++ crates/pipeline/src/lib.rs | 1 + crates/ui-egui/src/panels/right.rs | 16 ++++ docs/parity.md | 8 +- 6 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 crates/pipeline/src/dust.rs diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 860c14331..a0a05f521 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -320,6 +320,42 @@ pub fn specs() -> Vec { }) }), // ---- Remove tool (spots) + cmd!( + "spot.findDust", + "Find Dust Spots", + [], + None, + "{sensitivity?: 0..100 (50), add?: bool (true)} — find sensor-dust spots (small soft dark spots on smooth areas) and add a heal spot on each (one undo step) → {spots: [{x, y, size}], added}", + has_active, + |s, p| { + let c = "spot.findDust"; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + // the uncropped photo, so positions are the spots' own coordinates + let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?; + let img = job.run().rendered.map_err(|e| bad(c, e))?.image; + let found = lightcraft_pipeline::dust::detect(&img, f64_or(p, "sensitivity", 50.0) as f32); + let out: Vec = found.iter().map(|d| json!({"x": d.x, "y": d.y, "size": d.radius})).collect(); + if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() { + return Ok(json!({"spots": out, "added": 0})); + } + let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + let base = Spot::default(); + for d in &found { + let mut spot = Spot { + mode: SpotMode::Heal, + points: vec![Point::new(d.x, d.y)], + size: d.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1), + feather: base.feather, + opacity: 100.0, + source_offset: None, + }; + spot.source_offset = pick_source(s, id, &dd, &spot, None); + dd.spots.push(spot); + } + s.set_develop(id, dd, "Find Dust Spots")?; + Ok(json!({"spots": out, "added": found.len()})) + } + ), cmd!( "spot.add", "Add Remove Spot", diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 0d8ee7e7c..bfdacd1f1 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -482,3 +482,42 @@ fn assisted_culling_groups_bursts_and_scores_focus() { assert!(vis.contains(&by_name(&s, "b_sharp.png")) && !vis.contains(&by_name(&s, "a_soft.png")) && vis.contains(&other)); let _ = std::fs::remove_dir_all(&dir); } + +/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step). +#[test] +fn dust_spots_are_found_and_healed() { + let dir = temp_dir("dust"); + let (w, h) = (900usize, 600usize); + let spots = [(180.0f32, 140.0f32), (600.0, 300.0)]; + let data: Vec<[u8; 4]> = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let mut v = 0.5 + 0.3 * (y / h as f32); + for (cx, cy) in spots { + v -= 0.07 * (-((x - cx).powi(2) + (y - cy).powi(2)) / (2.0 * 25.0)).exp(); + } + let b = (v * 255.0) as u8; + [b, b, (b as f32 * 1.15).min(255.0) as u8, 255] + }) + .collect(); + let png = lightcraft_codecs::encode_png( + &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: w, height: h, data }), + &Default::default(), + ) + .unwrap(); + std::fs::write(dir.join("sky.png"), png).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [dir.join("sky.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let before = s.render_now(id, 900, 600).unwrap().image; + let r = s.execute("spot.findDust", &json!({})).unwrap(); + assert_eq!(r["added"], 2, "{r}"); + let after = s.render_now(id, 900, 600).unwrap().image; + let at = |img: &lightcraft_raster::Rgba8, x: usize, y: usize| img.data[y * 900 + x][0] as i32; + for (cx, cy) in spots { + assert!(at(&after, cx as usize, cy as usize) > at(&before, cx as usize, cy as usize) + 6, "the spot at {cx},{cy} is lifted"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.develop_of(id).unwrap().spots.is_empty(), "one undo step"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/pipeline/src/dust.rs b/crates/pipeline/src/dust.rs new file mode 100644 index 000000000..165e94b60 --- /dev/null +++ b/crates/pipeline/src/dust.rs @@ -0,0 +1,128 @@ +//! Dust-spot detection: sensor dust shows as small, soft, darker-than-surroundings round spots +//! on smooth areas (sky, walls). Classical: a blurred copy gives the surroundings, the darkening +//! is compared with the local texture so detail doesn't trigger it, and each candidate blob +//! must be small and roughly round. + +use lightcraft_raster::{Plane, Rgba8, blur::gaussian}; + +/// A found spot: centre (0..1 of the image's width / height) and radius (fraction of the long +/// edge), with how much darker it is (0..1). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DustSpot { + pub x: f64, + pub y: f64, + pub radius: f64, + pub strength: f32, +} + +/// Dust spots in a rendered image; `sensitivity` 0..100 (higher finds fainter spots). +pub fn detect(img: &Rgba8, sensitivity: f32) -> Vec { + let (w, h) = (img.width, img.height); + if w < 32 || h < 32 { + return Vec::new(); + } + let long = w.max(h) as f32; + let l = Plane::from_fn(w, h, |x, y| { + let p = img.data[y * w + x]; + (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) / 255.0 + }); + let around = gaussian(&l, (0.012 * long).max(2.0)); + // texture: local mean of the fine detail (what isn't explained by the surroundings) + let detail = Plane::from_fn(w, h, |x, y| (l.data[y * w + x] - around.data[y * w + x]).abs()); + let texture = gaussian(&detail, (0.02 * long).max(3.0)); + let min_dark = 0.03 - 0.022 * (sensitivity / 100.0).clamp(0.0, 1.0); + let dark: Vec = (0..w * h).map(|i| (around.data[i] - l.data[i]).max(0.0)).collect(); + let candidate = |i: usize| dark[i] > min_dark && dark[i] > 3.0 * texture.data[i] && l.data[i] > 0.08; + // blobs of candidate pixels (4-connected) + let mut seen = vec![false; w * h]; + let mut out = Vec::new(); + let (rmin, rmax) = (0.0015 * long, 0.02 * long); + for start in 0..w * h { + if seen[start] || !candidate(start) { + continue; + } + let mut stack = vec![start]; + seen[start] = true; + let (mut n, mut sx, mut sy, mut peak) = (0usize, 0.0f64, 0.0f64, 0.0f32); + let (mut x0, mut x1, mut y0, mut y1) = (w, 0, h, 0); + while let Some(i) = stack.pop() { + let (x, y) = (i % w, i / w); + n += 1; + sx += x as f64; + sy += y as f64; + peak = peak.max(dark[i]); + (x0, x1, y0, y1) = (x0.min(x), x1.max(x), y0.min(y), y1.max(y)); + if n > (rmax * rmax * 4.0) as usize { + break; // too big to be dust: stop growing + } + for j in [i.wrapping_sub(1), i + 1, i.wrapping_sub(w), i + w] { + let ok = j < w * h && !seen[j] && ((j % w).abs_diff(x) <= 1); + if ok && candidate(j) { + seen[j] = true; + stack.push(j); + } + } + } + let (bw, bh) = ((x1 - x0 + 1) as f32, (y1 - y0 + 1) as f32); + let r = (bw.max(bh)) / 2.0; + let round = bw.max(bh) / bw.min(bh) <= 1.8 && n as f32 >= 0.45 * bw * bh; + if r >= rmin && r <= rmax && round { + out.push(DustSpot { + x: (sx / n as f64 + 0.5) / w as f64, + y: (sy / n as f64 + 0.5) / h as f64, + radius: (r / long) as f64 * 1.6, + strength: peak, + }); + } + } + out.sort_by(|a, b| b.strength.total_cmp(&a.strength)); + out.truncate(60); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A smooth sky gradient with three soft dark spots: all found, nothing else. + #[test] + fn finds_spots_on_smooth_areas() { + let (w, h) = (600usize, 400usize); + let spots = [(120.0f32, 90.0f32, 4.0f32), (400.0, 200.0, 6.0), (500.0, 330.0, 3.0)]; + let data: Vec<[u8; 4]> = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let mut v = 0.55 + 0.25 * (y / h as f32) + 0.05 * (x / w as f32); + for (cx, cy, r) in spots { + let d2 = (x - cx).powi(2) + (y - cy).powi(2); + v -= 0.06 * (-d2 / (2.0 * r * r)).exp(); + } + let b = (v * 255.0) as u8; + [b, b, (b as f32 * 1.1).min(255.0) as u8, 255] + }) + .collect(); + let found = detect(&Rgba8 { width: w, height: h, data }, 50.0); + assert_eq!(found.len(), 3, "{found:?}"); + for (cx, cy, _) in spots { + assert!( + found.iter().any(|s| (s.x * w as f64 - cx as f64).abs() < 3.0 && (s.y * h as f64 - cy as f64).abs() < 3.0), + "{cx},{cy}: {found:?}" + ); + } + } + + #[test] + fn textured_scenes_give_few_false_spots() { + let img = lightcraft_scenes::demo_library()[2].render(600, 400); + let data = img + .data + .iter() + .map(|c| { + let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0) as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let found = detect(&Rgba8 { width: 600, height: 400, data }, 50.0); + assert!(found.len() <= 3, "{} false spots", found.len()); + } +} diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 8c1289b19..d34a9843a 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -25,6 +25,7 @@ pub mod auto; pub mod colorops; pub mod cull; +pub mod dust; pub mod finish; pub mod geometry; pub mod local; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 99deb9a8b..3748aacf9 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -224,6 +224,22 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(8.0); ui.label(format!("{} spot(s) on this photo", d.spots.len())); ui.add_space(4.0); + if text_button(ui, "findDust", "Find Dust Spots", false) + .on_hover_text("Heal the small, soft dark spots sensor dust leaves on smooth areas") + .clicked() + { + match app.run("spot.findDust", json!({})) { + Ok(r) => { + let n = r["added"].as_u64().unwrap_or(0); + app.toast( + ui.ctx(), + if n == 0 { "No dust spots found".to_string() } else { format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }) }, + ); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + ui.add_space(4.0); ui.label(egui::RichText::new("Paint over a distraction on the photo to remove it.").color(Tokens::get(ui.ctx()).text_dim)); }); // brush settings; with a spot selected they edit that spot too diff --git a/docs/parity.md b/docs/parity.md index abaa1a571..2c764796d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -29,7 +29,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 6 | 1 | 3 | 0 | 3/3 (100%) | 2/3 (67%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | -| I. Remove / healing (REM) | 6 | 1 | 3 | 2 | 4/4 (100%) | 2/3 (67%) | +| 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%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (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** | 375 | 25 | 100 | 37 | 193/198 (97%) | 136/144 (94%) | +| **Total** | 376 | 25 | 99 | 37 | 193/198 (97%) | 136/144 (94%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **77.5%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 33.9% of 158. +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. ## Top gaps @@ -294,7 +294,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-REM-VISUALIZE | Visualize spots | P1 | ✅ | `cmd:view.visualizeSpots` | | | LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | | | | LR-REM-REFLECT | Remove reflections | P2 | ⬜ | | | -| LR-REM-DUST | Dust detection | P2 | ⬜ | | | +| LR-REM-DUST | Dust detection | P2 | ✅ | `cmd:spot.findDust`, `crates/pipeline/src/dust.rs` | Remove panel ▸ Find Dust Spots: small, soft, round dark spots on smooth areas become heal spots (sensitivity; one undo step); Visualize Spots for checking by eye | | LR-REM-SYNC | Sync spots | P1 | ✅ | `cmd:develop.copy` (`groups`), `crates/develop/src/presets.rs` | | ## J. Red eye (EYE)