diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index f85898c1..d6fdab54 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -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] diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index 05d99279..e8e47e06 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -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 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1dd7101f..87414f15 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { + // 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 { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index e77107e6..e8c6b193 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index d85c1f46..92c2f34c 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -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() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index fc71264a..83fb03c4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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,