M8: spot.findBlemishes — heal small blemishes on detected faces

A sibling of spot.findDust for portraits: with the YuNet detector installed, small
round spots on each face's skin that are darker or redder than the surrounding skin
become heal spots (one undo step). The detector's box and landmarks define the skin
(ellipse, skin-colour gate, eye band / brows / nostrils / mouth cut out); a candidate
must stand out from a blurred copy beyond the sensitivity threshold and 2.5x the local
texture, then grow into a small roundish blob. Conservative: faces under 160 px get
nothing, faces with more than 12 candidates (stubble, freckles, pores) get nothing, at
most 6 per face (maxPerFace). Classical, no model beyond the detector.

Tests (synthetic, no model): cheek spots found at their positions, clean face empty,
spots on features ignored, small face ignored, speckled face treated as texture,
hostile boxes and an empty image do not panic. New LR-REM-BLEMISH parity row, Find
Blemishes section in docs/faces.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
aayush113002andClaude Fable 5.1 committed 2026-10-10 15:48:31 +05:30
1 parent b70ae1741e
commit ec1e26545e
5 files changed
+361 -5

No files matched your search

+57
View File
@@ -690,6 +690,63 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"spots": out, "added": found.len()}))
}
),
cmd!(
"spot.findBlemishes",
"Find Blemishes",
[],
None,
"{sensitivity?: 0..100 (50), add?: bool (true), maxPerFace?: 1..20 (6)} — find small blemishes (darker or redder round spots on skin) on the faces of the active photo and add a heal spot on each (one undo step); needs the YuNet face detector (`faces.models.download {id: \"yunet-2023mar\"}`). Faces narrower than 160 px at 1600 px, and faces with more than 12 candidates (stubble, freckles, pores), get none → {faces, spots: [{x, y, size, face}], added}",
has_active,
|s, p| {
let c = "spot.findBlemishes";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let detector = super::face_detect::detector(s).map_err(|e| bad(c, e))?;
// 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 rgb: Vec<u8> = img.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();
let faces =
detector.detect(&rgb, img.width, img.height, &lightcraft_faces::yunet::Options::default()).map_err(|e| bad(c, e.to_string()))?;
let boxes: Vec<lightcraft_pipeline::blemish::FaceBox> = faces
.iter()
.map(|f| lightcraft_pipeline::blemish::FaceBox {
x0: f64::from(f.x0),
y0: f64::from(f.y0),
x1: f64::from(f.x1),
y1: f64::from(f.y1),
landmarks: f.landmarks.map(|(x, y)| (f64::from(x), f64::from(y))),
})
.collect();
let max_per_face = (f64_or(p, "maxPerFace", 6.0) as usize).clamp(1, 20);
let mut found = lightcraft_pipeline::blemish::detect(&img, &boxes, f64_or(p, "sensitivity", 50.0) as f32);
let mut per_face = std::collections::BTreeMap::new();
found.retain(|b| {
let n = per_face.entry(b.face).or_insert(0usize);
*n += 1;
*n <= max_per_face
});
let out: Vec<Value> = found.iter().map(|b| json!({"x": b.x, "y": b.y, "size": b.radius, "face": b.face})).collect();
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
return Ok(json!({"faces": boxes.len(), "spots": out, "added": 0}));
}
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
let base = Spot::default();
for b in &found {
let mut spot = Spot {
mode: SpotMode::Heal,
points: vec![Point::new(b.x, b.y)],
size: b.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 Blemishes")?;
Ok(json!({"faces": boxes.len(), "spots": out, "added": found.len()}))
}
),
cmd!(
"spot.add",
"Add Remove Spot",
+288
View File
@@ -0,0 +1,288 @@
//! Blemish detection on faces: small, round spots that are darker or redder than the skin around
//! them. Classical, in the spirit of [`crate::dust`]: a blurred copy gives the surrounding skin,
//! a spot has to stand out from both the surroundings and the local texture, and each candidate
//! blob must be small and roughly round. The face boxes and landmarks come from a face detector
//! (YuNet) so eyes, brows, nostrils and lips are never taken for blemishes.
//!
//! Deliberately conservative: a face with many candidates has stubble, freckles or pores, not
//! blemishes, and gets none; the rest are capped per face. Removing freckles is a different
//! feature and never a default.
use lightcraft_raster::{Plane, Rgba8, blur::gaussian};
/// A face in fractions of the image (0..1, y down): its box and the five landmarks a YuNet
/// detector gives, in its order (right eye, left eye, nose tip, right and left mouth corner,
/// from the subject's point of view).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FaceBox {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
pub landmarks: [(f64, f64); 5],
}
/// A found blemish: which face, centre (0..1 of the image's width / height), radius (fraction
/// of the long edge) and how much it stands out (0..1).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Blemish {
pub face: usize,
pub x: f64,
pub y: f64,
pub radius: f64,
pub strength: f32,
}
/// Faces narrower than this (in pixels of the analysed image) are left alone: a blemish is not
/// resolved below it, and what is found there is lash shadow and nostril edges.
pub const MIN_FACE_PX: f64 = 160.0;
/// More candidates than this on one face means texture (stubble, freckles), not blemishes.
pub const MAX_CANDIDATES_PER_FACE: usize = 12;
/// Most blemishes kept per face, strongest first.
pub const MAX_PER_FACE: usize = 6;
/// Pixels of `img` inside the box that are skin: an ellipse over the face, gated by skin colour,
/// with the eyes, brows, nostrils and mouth cut out around the landmarks.
fn skin_mask(img: &Rgba8, f: &FaceBox, x0: usize, y0: usize, cw: usize, ch: usize) -> Vec<bool> {
let (w, h) = (img.width as f64, img.height as f64);
let (fx, fy, fw, fh) = (f.x0 * w, f.y0 * h, (f.x1 - f.x0) * w, (f.y1 - f.y0) * h);
let (cx, cy) = (fx + fw / 2.0, fy + fh * 0.55);
let (rx, ry) = (fw * 0.55, fh * 0.70);
let lm = |i: usize| (f.landmarks[i].0 * w, f.landmarks[i].1 * h);
let (re, le, nose, mr, ml) = (lm(0), lm(1), lm(2), lm(3), lm(4));
let mouth = ((mr.0 + ml.0) / 2.0, (mr.1 + ml.1) / 2.0);
let eye_y = (re.1 + le.1) / 2.0;
let mut out = vec![false; cw * ch];
for yy in 0..ch {
for xx in 0..cw {
let (px, py) = ((x0 + xx) as f64 + 0.5, (y0 + yy) as f64 + 0.5);
let in_ellipse = ((px - cx) / rx).powi(2) + ((py - cy) / ry).powi(2) <= 1.0;
if !in_ellipse {
continue;
}
// brows and eyes: a band from above the brows to just below the eyes
if py > eye_y - fh * 0.32 && py < eye_y + fh * 0.05 {
continue;
}
let near = |(lx, ly): (f64, f64), r: f64| (px - lx).powi(2) + (py - ly).powi(2) <= r * r;
if near(re, fw * 0.12) || near(le, fw * 0.12) || near(mouth, fw * 0.16) {
continue;
}
// nostrils: an ellipse at and below the nose tip
if ((px - nose.0) / (fw * 0.20)).powi(2) + ((py - nose.1 - fw * 0.02) / (fw * 0.16)).powi(2) <= 1.0 {
continue;
}
let Some(p) = img.data.get((y0 + yy) * img.width + x0 + xx) else { continue };
let (r, g, b) = (f64::from(p[0]), f64::from(p[1]), f64::from(p[2]));
let cr = 128.0 + 0.5 * r - 0.4187 * g - 0.0813 * b;
let cb = 128.0 - 0.1687 * r - 0.3313 * g + 0.5 * b;
if let Some(o) = out.get_mut(yy * cw + xx) {
*o = (133.0..180.0).contains(&cr) && (77.0..130.0).contains(&cb);
}
}
}
out
}
/// Blemishes on the given faces of a rendered image; `sensitivity` 0..100 (higher finds fainter
/// spots). Faces narrower than [`MIN_FACE_PX`] get none; a face with more than
/// [`MAX_CANDIDATES_PER_FACE`] candidates gets none; at most [`MAX_PER_FACE`] per face.
pub fn detect(img: &Rgba8, faces: &[FaceBox], sensitivity: f32) -> Vec<Blemish> {
let (w, h) = (img.width, img.height);
if w < 32 || h < 32 {
return Vec::new();
}
let long = w.max(h) as f64;
let thr = 0.09 - 0.05 * (sensitivity / 100.0).clamp(0.0, 1.0);
let mut out = Vec::new();
for (fi, f) in faces.iter().enumerate() {
let fw = (f.x1 - f.x0) * w as f64;
let fh = (f.y1 - f.y0) * h as f64;
if !(fw.is_finite() && fh.is_finite()) || fw < MIN_FACE_PX || fh <= 0.0 {
continue;
}
// the face with a margin, clipped to the image
let clamp = |v: f64, hi: usize| (v.max(0.0) as usize).min(hi);
let x0 = clamp(f.x0 * w as f64 - fw * 0.15, w);
let y0 = clamp(f.y0 * h as f64 - fh * 0.15, h);
let x1 = clamp(f.x1 * w as f64 + fw * 0.15, w);
let y1 = clamp(f.y1 * h as f64 + fh * 0.15, h);
let (cw, ch) = (x1.saturating_sub(x0), y1.saturating_sub(y0));
if cw < 16 || ch < 16 {
continue;
}
let skin = skin_mask(img, f, x0, y0, cw, ch);
let px = |xx: usize, yy: usize| img.data.get((y0 + yy) * w + x0 + xx).copied().unwrap_or([0; 4]);
let lum = Plane::from_fn(cw, ch, |xx, yy| {
let p = px(xx, yy);
(0.2126 * f32::from(p[0]) + 0.7152 * f32::from(p[1]) + 0.0722 * f32::from(p[2])) / 255.0
});
// redness: how much red exceeds green, which a pimple does and even skin does not
let red = Plane::from_fn(cw, ch, |xx, yy| {
let p = px(xx, yy);
(f32::from(p[0]) - f32::from(p[1])) / 255.0
});
let sigma = (0.035 * fw as f32).max(2.0);
let around_l = gaussian(&lum, sigma);
let around_r = gaussian(&red, sigma);
let detail = Plane::from_fn(cw, ch, |xx, yy| {
let i = yy * cw + xx;
(lum.data.get(i).copied().unwrap_or(0.0) - around_l.data.get(i).copied().unwrap_or(0.0)).abs()
});
let texture = gaussian(&detail, (0.06 * fw as f32).max(3.0));
let resp: Vec<f32> = (0..cw * ch)
.map(|i| {
let dark = around_l.data.get(i).copied().unwrap_or(0.0) - lum.data.get(i).copied().unwrap_or(0.0);
let redder = red.data.get(i).copied().unwrap_or(0.0) - around_r.data.get(i).copied().unwrap_or(0.0);
dark.max(redder).max(0.0)
})
.collect();
let candidate = |i: usize| {
skin.get(i).copied().unwrap_or(false)
&& resp.get(i).copied().unwrap_or(0.0) > thr
&& resp.get(i).copied().unwrap_or(0.0) > 2.5 * texture.data.get(i).copied().unwrap_or(0.0)
};
// blobs of candidate pixels (4-connected), small and roundish
let mut seen = vec![false; cw * ch];
let mut found: Vec<Blemish> = Vec::new();
let (rmin, rmax) = ((0.004 * fw).max(1.5), 0.05 * fw);
for start in 0..cw * ch {
if seen.get(start).copied().unwrap_or(true) || !candidate(start) {
continue;
}
let mut stack = vec![start];
if let Some(s) = seen.get_mut(start) {
*s = true;
}
let (mut n, mut sx, mut sy, mut peak) = (0usize, 0.0f64, 0.0f64, 0.0f32);
let (mut bx0, mut bx1, mut by0, mut by1) = (cw, 0, ch, 0);
while let Some(i) = stack.pop() {
let (xx, yy) = (i % cw, i / cw);
n += 1;
sx += xx as f64;
sy += yy as f64;
peak = peak.max(resp.get(i).copied().unwrap_or(0.0));
(bx0, bx1, by0, by1) = (bx0.min(xx), bx1.max(xx), by0.min(yy), by1.max(yy));
if n as f64 > rmax * rmax * 4.0 {
break; // too big to be a blemish: a shadow or a feature
}
for j in [i.wrapping_sub(1), i + 1, i.wrapping_sub(cw), i + cw] {
let ok = j < cw * ch && !seen.get(j).copied().unwrap_or(true) && (j % cw).abs_diff(xx) <= 1;
if ok && candidate(j) {
if let Some(s) = seen.get_mut(j) {
*s = true;
}
stack.push(j);
}
}
}
let (bw, bh) = ((bx1 - bx0 + 1) as f64, (by1 - by0 + 1) as f64);
let r = bw.max(bh) / 2.0;
let round = bw.max(bh) / bw.min(bh) < 3.0 && n as f64 >= 0.35 * bw * bh && n >= 3;
if r >= rmin && r <= rmax && round {
found.push(Blemish {
face: fi,
x: (x0 as f64 + sx / n as f64 + 0.5) / w as f64,
y: (y0 as f64 + sy / n as f64 + 0.5) / h as f64,
radius: (r / long * 1.6).max(fw * 0.012 / long),
strength: peak,
});
}
}
if found.len() > MAX_CANDIDATES_PER_FACE {
continue; // texture, not blemishes
}
found.sort_by(|a, b| b.strength.total_cmp(&a.strength));
found.truncate(MAX_PER_FACE);
out.extend(found);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A grey frame with one skin-coloured face ellipse `fw` wide at the centre, landmarks where
/// a face has them, and `dots` dark spots on the cheeks.
fn scene(w: usize, h: usize, fw: f64, dots: &[(f64, f64)]) -> (Rgba8, FaceBox) {
let (cx, cy, fh) = (w as f64 / 2.0, h as f64 / 2.0, fw * 1.3);
let mut img = Rgba8::filled(w, h, [120, 120, 120, 255]);
for y in 0..h {
for x in 0..w {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
if ((px - cx) / (fw * 0.5)).powi(2) + ((py - cy) / (fh * 0.5)).powi(2) <= 1.0 {
img.data[y * w + x] = [222, 180, 160, 255]; // skin
}
}
}
for &(dx, dy) in dots {
let (sx, sy) = (cx + dx * fw, cy + dy * fh);
for y in 0..h {
for x in 0..w {
if (x as f64 + 0.5 - sx).powi(2) + (y as f64 + 0.5 - sy).powi(2) <= (0.012 * fw).max(1.5).powi(2) {
img.data[y * w + x] = [150, 90, 85, 255]; // a dark red spot
}
}
}
}
let n = |x: f64, y: f64| (x / w as f64, y / h as f64);
let face = FaceBox {
x0: (cx - fw / 2.0) / w as f64,
y0: (cy - fh / 2.0) / h as f64,
x1: (cx + fw / 2.0) / w as f64,
y1: (cy + fh / 2.0) / h as f64,
landmarks: [
n(cx - fw * 0.2, cy - fh * 0.12),
n(cx + fw * 0.2, cy - fh * 0.12),
n(cx, cy + fh * 0.08),
n(cx - fw * 0.15, cy + fh * 0.28),
n(cx + fw * 0.15, cy + fh * 0.28),
],
};
(img, face)
}
#[test]
fn finds_spots_on_the_cheeks_and_nothing_else() {
let dots = [(-0.28, 0.08), (0.3, 0.12), (-0.2, 0.3)];
let (img, face) = scene(800, 600, 300.0, &dots);
let found = detect(&img, &[face], 50.0);
assert_eq!(found.len(), 3, "{found:?}");
for (dx, dy) in dots {
let (ex, ey) = (0.5 + dx * 300.0 / 800.0, 0.5 + dy * 390.0 / 600.0);
assert!(found.iter().any(|b| (b.x - ex).abs() < 0.01 && (b.y - ey).abs() < 0.01), "{found:?} lacks {ex},{ey}");
}
assert!(found.iter().all(|b| b.face == 0 && b.radius > 0.0 && b.radius < 0.02 && b.strength > 0.0));
let (clean, face) = scene(800, 600, 300.0, &[]);
assert!(detect(&clean, &[face], 50.0).is_empty(), "a clean face has no blemishes");
}
#[test]
fn features_and_small_faces_are_left_alone() {
// spots on the eyes, the nose tip and the mouth: all inside the cut-outs
let (img, face) = scene(800, 600, 300.0, &[(-0.2, -0.12), (0.2, -0.12), (0.0, 0.1), (0.0, 0.28)]);
assert!(detect(&img, &[face], 80.0).is_empty(), "{:?}", detect(&img, &[face], 80.0));
// the same cheek spots on a face below the size floor
let (img, face) = scene(800, 600, 120.0, &[(-0.28, 0.08), (0.3, 0.12)]);
assert!(detect(&img, &[face], 50.0).is_empty());
}
#[test]
fn a_speckled_face_is_texture_not_blemishes() {
// 24 spots on the cheeks, all outside the eye, nose and mouth cut-outs
let dots: Vec<(f64, f64)> =
[-0.42, -0.32, -0.22, 0.22, 0.32, 0.42].iter().flat_map(|&dx| (0..4).map(move |j| (dx, 0.0 + 0.08 * j as f64))).collect();
let (img, face) = scene(800, 600, 300.0, &dots);
assert!(detect(&img, &[face], 50.0).is_empty(), "more than {MAX_CANDIDATES_PER_FACE} candidates means stubble or freckles");
}
#[test]
fn hostile_faces_do_not_panic() {
let (img, _) = scene(64, 48, 20.0, &[]);
let weird = FaceBox { x0: -2.0, y0: 0.5, x1: 9.0, y1: f64::NAN, landmarks: [(f64::INFINITY, 0.0); 5] };
let inverted = FaceBox { x0: 0.9, y0: 0.9, x1: 0.1, y1: 0.1, landmarks: [(0.5, 0.5); 5] };
assert!(detect(&img, &[weird, inverted], 100.0).is_empty());
assert!(detect(&Rgba8::new(0, 0), &[inverted], 50.0).is_empty());
}
}
+1
View File
@@ -24,6 +24,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod auto;
pub mod blemish;
pub mod colorops;
pub mod cull;
pub mod dust;
+9
View File
@@ -296,3 +296,12 @@ suggestions it would make and how many were right.
All of this is reachable from the control channel, the CLI and MCP: `faces.models.list`, `faces.models.inspect {path}`,
`faces.models.install {path, acknowledged: true, activate?}`, `faces.models.download {id, acknowledged: true}` (then `faces.models.downloads`, which also installs what has arrived, and `faces.models.downloadCancel {id}`), `faces.models.remove {id}`, `faces.models.select {id}`,
`faces.enable {enabled?}`, `faces.detect {ids?, apply?}`, `faces.index {budgetMs?, ids?}`, `faces.pump` (what the app calls every frame), `faces.suggest {ids?, threshold?, margin?}`, `faces.person {name, more?}` (a person's faces and the unnamed faces that look like them), `faces.unnamed {limit?}` (every unnamed face, look-alikes together, with suggested names), `faces.setName {id?, index, name}`, `faces.nameFaces {faces: [{photo, index}], name}` (name many at once, one undo step) and `faces.evaluate`. `acknowledged` must be `true`: the caller has shown the user the terms and the user agreed. Installing makes the model the one in use and switches recognition on unless `activate` is `false`.
## Find Blemishes
Photo ▸ Find Blemishes (`spot.findBlemishes`) uses the installed detector's boxes and landmarks to look for small,
round, darker-or-redder spots on each face's skin and adds a heal spot on each, the way Find Dust Spots does for
sensor dust. Eyes, brows, nostrils and the mouth are cut out around the landmarks; faces narrower than 160 px in
the 1600 px analysis image get nothing, as do faces with more than 12 candidates (stubble, freckles and pores are
texture, not blemishes); at most six per face. It needs no model beyond the detector and never changes anything
without a spot you can see and delete (`crates/pipeline/src/blemish.rs`).
+6 -5
View File
@@ -29,7 +29,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| F. Edit panel — global adjustments (EDIT) | 43 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) |
| G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) |
| 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%) |
| I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) |
| J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) |
| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) |
| L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) |
@@ -42,15 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) |
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 7 | 1 | 2 | 2 | 1/1 (100%) | 5/5 (100%) |
| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) |
| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) |
| **Total** | 393 | 39 | 84 | 36 | 192/200 (96%) | 142/152 (93%) |
| Lightroom Classic extras | 33 | 15 | 35 | 9 | — | 23/25 (92%) |
| **Total** | 398 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.9%** of 516 in-scope rows — P0 98.0% of 200 · P1 95.7% of 152 · P2 42.9% of 163.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 520 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.6% of 164.
<!-- /parity:summary -->
## Top gaps
@@ -336,6 +336,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | | |
| LR-REM-REFLECT | Remove reflections | 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-BLEMISH | Find blemishes on faces (beyond Lightroom) | P2 | ✅ | `cmd:spot.findBlemishes`, `crates/pipeline/src/blemish.rs`, `crates/engine/src/cmd/face_detect.rs` | Remove panel ▸ Find Blemishes: with the YuNet detector installed, small darker-or-redder round spots on each face's skin (eyes, brows, nostrils and mouth cut out around the landmarks) become heal spots (one undo step); faces under 160 px and faces with more than 12 candidates (stubble, freckles) get none, at most 6 per face; sensitivity 0–100. Classical, no learned model beyond the detector; not a Lightroom feature |
| LR-REM-SYNC | Sync spots | P1 | ✅ | `cmd:develop.copy` (`groups`), `crates/develop/src/presets.rs` | |
## J. Red eye (EYE)