mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
co-authored by
Claude Opus 5.5
parent
26b00014a8
commit
8d40b3e372
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user