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:
parent
06d30a0e59
commit
82cecec366
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue