LR-REM-VISUALIZE: Visualize Spots — high-pass black/white view with threshold

Overlay::Spots(threshold): luminance minus its Gaussian blur (sigma relative to the long edge,
so the same specks show at any preview size), thresholded to white/black; the threshold slider
(0..100) raises sensitivity. Runs after either renderer like the other overlays (equivalence:
share of differing pixels, since the view is binary). Remove panel: Visualize Spots checkbox +
Threshold slider; UI command view.visualizeSpots (A) opens the Remove tool with the view on or
toggles it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 10:52:06 -04:00
parent 06d30a0e59
commit 82cecec366
6 changed files with 114 additions and 0 deletions

View File

@ -402,6 +402,18 @@ fn overlays_match() {
};
let req = RenderRequest { overlay: lightcraft_pipeline::Overlay::PointColorRange(0), ..RenderRequest::fit(640, 640) };
check("point color range overlay", &src, &raw, &s, &req);
// Visualize Spots is a binary threshold of a high-pass: a 1-LSB difference near the threshold
// flips a pixel, so compare the share of differing pixels instead of LSBs.
let s = DevelopSettings::default();
for t in [20u8, 50, 90] {
let req = RenderRequest { overlay: lightcraft_pipeline::Overlay::Spots(t), ..RenderRequest::fit(640, 640) };
let cpu = render(&src, &raw, &s, &req).image;
let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image;
let differ = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a != b).count() as f64 / cpu.data.len() as f64;
let white = cpu.data.iter().filter(|p| p[0] == 255).count() as f64 / cpu.data.len() as f64;
eprintln!("visualize spots t={t:<3} white {:.3}% differing {:.4}%", white * 100.0, differ * 100.0);
assert!(differ < 0.002, "spots t={t}: {differ}");
}
}
#[test]

View File

@ -3,6 +3,9 @@
//!
//! - **Point Color range:** the photo is rendered without that sample's own adjustment; pixels
//! inside the sample's range keep their colour, the rest turn grey (weighted by the range).
//! - **Visualize Spots** (Remove tool): a high-pass of the luminance thresholded to black/white,
//! so dust spots and specks stand out as white dots. The scale is relative to the image (the
//! same spots show at any preview size); the threshold slider (0..100) raises sensitivity.
use std::borrow::Cow;
@ -22,6 +25,8 @@ pub enum Overlay {
None,
/// Point Color sample `i`: its range in colour, everything else grey.
PointColorRange(u8),
/// Visualize Spots with a threshold 0..100 (higher = more sensitive).
Spots(u8),
}
impl Overlay {
@ -30,6 +35,7 @@ impl Overlay {
match self {
Overlay::None => (0, 0.0),
Overlay::PointColorRange(i) => (1, i as f32),
Overlay::Spots(t) => (2, t as f32),
}
}
@ -37,6 +43,7 @@ impl Overlay {
pub fn from_parts(kind: u8, v: f32) -> Overlay {
match kind {
1 => Overlay::PointColorRange(v as u8),
2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8),
_ => Overlay::None,
}
}
@ -46,6 +53,7 @@ impl Overlay {
match self {
Overlay::None => 0,
Overlay::PointColorRange(i) => 0x1000 + i as u64,
Overlay::Spots(t) => 0x2000 + t as u64,
}
}
}
@ -71,6 +79,34 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) {
point_color_range(img, &PointK::new(p));
}
}
Overlay::Spots(t) => spots(img, t, plan.px_per_long),
}
}
/// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge.
const SPOT_SIGMA: f64 = 0.006;
/// High-pass threshold (encoded luminance) for slider value `t` (0..100): 100 shows the faintest
/// specks, 0 only strong ones.
pub fn spot_threshold(t: u8) -> f32 {
let k = 1.0 - t.min(100) as f32 / 100.0;
0.01 + 0.2 * k * k
}
fn spots(img: &mut Rgba8, t: u8, ppl: f64) {
let (w, h) = (img.width, img.height);
if w == 0 || h == 0 {
return;
}
let l = lightcraft_raster::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 b = lightcraft_raster::blur::gaussian(&l, (SPOT_SIGMA * ppl).max(0.8) as f32);
let thr = spot_threshold(t);
for (i, px) in img.data.iter_mut().enumerate() {
let v = if (l.data[i] - b.data[i]).abs() > thr { 255 } else { 0 };
*px = [v, v, v, 255];
}
}
@ -99,6 +135,31 @@ mod tests {
use lightcraft_develop::PointColor;
use lightcraft_raster::Rgb32f;
#[test]
fn spots_view_marks_specks_at_any_size() {
// a smooth gradient with two small dark specks
let src = Rgb32f::from_fn(400, 300, |x, y| {
let d = |cx: f32, cy: f32| ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt();
let base = 0.1 + 0.3 * x as f32 / 400.0;
if d(100.0, 100.0) < 2.5 || d(300.0, 200.0) < 2.5 { [base * 0.5; 3] } else { [base; 3] }
});
let info = SourceInfo::default();
let s = DevelopSettings::default();
for size in [400, 200] {
let req = RenderRequest { overlay: Overlay::Spots(50), ..RenderRequest::fit(size, size) };
let v = render(&src, &info, &s, &req).image;
let at = |x: f64, y: f64| v.data[(y * v.height as f64) as usize * v.width + (x * v.width as f64) as usize];
assert_eq!(at(0.25, 1.0 / 3.0), [255, 255, 255, 255], "speck at size {size}");
assert_eq!(at(0.75, 2.0 / 3.0), [255, 255, 255, 255]);
assert_eq!(at(0.5, 0.5), [0, 0, 0, 255], "smooth gradient is black");
let white = v.data.iter().filter(|p| p[0] == 255).count() as f64 / v.data.len() as f64;
assert!(white < 0.01, "{white}");
}
assert!(spot_threshold(0) > spot_threshold(50) && spot_threshold(50) > spot_threshold(100));
let (k, v) = Overlay::Spots(37).to_parts();
assert_eq!(Overlay::from_parts(k, v), Overlay::Spots(37));
}
#[test]
fn point_color_range_keeps_the_selected_colour_only() {
// left half orange, right half blue

View File

@ -27,6 +27,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.clipping", "Show Clipping", Some("J"), "View"),
("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"),
("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"),
("view.visualizeSpots", "Visualize Spots", Some("A"), "View"),
("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"),
("view.back", "Back to Grid", Some("Escape"), "View"),
("view.filterBar", "Filter", None, "View"),
@ -164,6 +165,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.mask_overlay = !app.ui.mask_overlay;
Ok(Value::Null)
}
"view.visualizeSpots" => {
// like Lightroom's A: opens the Remove tool with the view on, or toggles it there
if app.ui.right == RightPanel::Remove {
app.ui.visualize_spots = !app.ui.visualize_spots;
} else {
app.ui.right = RightPanel::Remove;
app.ui.visualize_spots = true;
}
Ok(Value::Null)
}
"view.cropOverlay" => {
use crate::state::CropOverlay::*;
app.ui.crop_overlay = match app.ui.crop_overlay {

View File

@ -245,6 +245,9 @@ fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respon
/// The diagnostic overlay the loupe shows (Point Color's visualized range).
fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay {
use lightcraft_pipeline::Overlay;
if app.ui.right == RightPanel::Remove && app.ui.visualize_spots {
return Overlay::Spots(app.ui.spots_threshold.clamp(0.0, 100.0).round() as u8);
}
let edit = app.ui.right == RightPanel::Edit;
if edit && app.ui.point_color_visualize && app.ui.flyout_open("pointColor") && app.ui.point_color < d.point_colors.len() {
return Overlay::PointColorRange(app.ui.point_color as u8);

View File

@ -217,6 +217,28 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if let Some(v) = out.value {
app.ui.remove_size = (v / 1000.0) as f32;
}
// Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out
padded(ui, |ui| {
let mut v = app.ui.visualize_spots;
if ui.checkbox(&mut v, "Visualize Spots (A)").changed() {
app.ui.visualize_spots = v;
}
});
let spec = lightcraft_develop::ControlSpec {
id: "ui.spotsThreshold",
label: "Threshold",
section: lightcraft_develop::Section::Detail,
min: 0.0,
max: 100.0,
default: 50.0,
step: 1.0,
decimals: 0,
track: lightcraft_develop::Track::Plain,
};
let out = slider(ui, &spec, app.ui.spots_threshold as f64, app.ui.visualize_spots, None);
if let Some(v) = out.value {
app.ui.spots_threshold = v as f32;
}
padded(ui, |ui| {
if !d.spots.is_empty() && text_button(ui, "removeClear", "Delete all spots", false).clicked() {
for i in (0..d.spots.len()).rev() {

View File

@ -114,6 +114,9 @@ pub struct UiState {
/// Red Eye panel: selected correction, and whether new ones are pet eyes.
pub eye: usize,
pub eye_pet: bool,
/// Remove tool: Visualize Spots (high-pass black/white view) and its threshold 0..100.
pub visualize_spots: bool,
pub spots_threshold: f32,
/// Transient toast text and its expiry (seconds of app time).
#[serde(skip)]
pub toast: Option<(String, f64)>,
@ -186,6 +189,8 @@ impl Default for UiState {
point_color_visualize: false,
eye: 0,
eye_pet: false,
visualize_spots: false,
spots_threshold: 50.0,
toast: None,
status: String::new(),
dialog: None,