LR-REM-DUST: Find Dust Spots

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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 03:07:49 -04:00
co-authored by Claude Opus 5.5
parent 26b00014a8
commit 8d40b3e372
6 changed files with 224 additions and 4 deletions
+36
View File
@@ -320,6 +320,42 @@ pub fn specs() -> Vec<CommandSpec> {
})
}),
// ---- 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<Value> = 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",
+39
View File
@@ -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);
}
+128
View File
@@ -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<DustSpot> {
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<f32> = (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());
}
}
+1
View File
@@ -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;
+16
View File
@@ -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
+4 -4
View File
@@ -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.
<!-- /parity:summary -->
## 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)