diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index a9daf676e..860c14331 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -167,6 +167,47 @@ pub fn specs() -> Vec { Ok(()) }) }), + cmd!( + "mask.sampleColor", + "Sample Mask Color", + [], + None, + "{x, y: normalized image point, add?: bool (⇧: add to the samples, max 5), id?, component?} — the colour range of the selected mask (its first colour-range component) samples the colour there → {samples}", + has_active, + |s, p| { + let c = "mask.sampleColor"; + let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?); + let add = p.get("add").and_then(Value::as_bool).unwrap_or(false); + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let src = s.source_now(id, crate::media::SourceLevel::Thumb).map_err(|e| bad(c, e))?; + let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default(); + let d = s.develop_of(id).unwrap_or_default(); + let req = lightcraft_pipeline::RenderRequest::fit(384, 384); + let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y)) + .ok_or_else(|| bad(c, "the point is outside the photo"))?; + let mid = mask_id(p, s.active_mask, c)?; + let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize); + let mut out = Vec::new(); + masks_edit(s, c, "Color Range", |masks, _| { + let i = find(masks, mid, c)?; + let k = comp + .or_else(|| masks[i].components.iter().position(|x| matches!(x.shape, MaskShape::ColorRange { .. }))) + .ok_or_else(|| bad(c, "the mask has no colour range"))?; + let Some(MaskShape::ColorRange { samples, .. }) = masks[i].components.get_mut(k).map(|x| &mut x.shape) else { + return Err(bad(c, "not a colour range component")); + }; + if !add { + samples.clear(); + } + if samples.len() < 5 { + samples.push(lab); + } + out = samples.clone(); + Ok(()) + })?; + Ok(json!({"samples": out})) + } + ), cmd!( "mask.adjust", "Set Mask Adjustments", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b1fff44c4..877fa2e2a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -519,3 +519,42 @@ fn build_previews_fills_the_cache() { } assert!(s.execute("library.buildPreviews", &json!({"size": "huge"})).is_err()); } + +/// Colour range: a click samples the colour under it, so the mask selects that colour (white +/// in the mask view) and not a different one; ⇧ adds samples, up to five. +#[test] +fn color_range_sampling_selects_the_clicked_colour() { + use lightcraft_pipeline::{MaskView, Overlay}; + let mut s = demo(); + // a flower: magenta petals at the centre, green around them + let id = s.catalog.photos().find(|p| p.meta.keywords.iter().any(|k| k == "flower")).unwrap().id; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("mask.add", &json!({"kind": "colorRange"})).unwrap(); + let mask = |s: &mut Session| { + let m = s.develop_of(id).unwrap().masks[0].clone(); + let job = s.render_job(id, 200, 200, false, true).unwrap().with_overlay(Overlay::Mask { + id: m.id as u16, + view: MaskView::WhiteOnBlack, + color: [255, 0, 0], + opacity: 100, + }); + job.run().rendered.unwrap().image + }; + let at = + |img: &lightcraft_raster::Rgba8, x: f64, y: f64| img.data[(y * img.height as f64) as usize * img.width + (x * img.width as f64) as usize][0]; + let r = s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.5})).unwrap(); + assert_eq!(r["samples"].as_array().unwrap().len(), 1); + let img = mask(&mut s); + let (centre, corner) = (at(&img, 0.5, 0.5), at(&img, 0.03, 0.03)); + assert!(centre > 200 && corner < centre / 2, "the sampled colour is selected: centre {centre}, corner {corner}"); + // ⇧-click adds, at most five + for _ in 0..6 { + s.execute("mask.sampleColor", &json!({"x": 0.03, "y": 0.03, "add": true})).unwrap(); + } + let n = s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.52, "add": true})).unwrap()["samples"].as_array().unwrap().len(); + assert_eq!(n, 5); + assert!(at(&mask(&mut s), 0.03, 0.03) > 200, "the added colour is selected too"); + // a plain click starts over + assert_eq!(s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.5})).unwrap()["samples"].as_array().unwrap().len(), 1); + assert!(s.execute("mask.sampleColor", &json!({"x": 1.5, "y": 0.5})).is_err()); +} diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 587466879..6623b8190 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -411,6 +411,34 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende Rendered { image, histogram, deep: None } } +/// The colour a colour-range mask would sample at normalized image point `p` (OkLab of the +/// exposed scene colours, as [`MaskShape::ColorRange`](lightcraft_develop::MaskShape) compares +/// them), averaged over 3 × 3 pixels of a render fitting `req`. `None` outside the image. +pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, p: lightcraft_geom::Point) -> Option<[f64; 3]> { + let plan = plan(src, info, s, req); + let mut img = plan.frame.sample(src, plan.w, plan.h); + lin_cpu(&mut img, info, &plan); + let o = plan.frame.norm_to_out(plan.w, plan.h).apply(p); + let (cx, cy) = (o.x.floor() as i64, o.y.floor() as i64); + if cx < 0 || cy < 0 || cx >= plan.w as i64 || cy >= plan.h as i64 { + return None; + } + let gain = (s.light.exposure as f32).exp2(); + let mut acc = [0f64; 3]; + let mut n = 0.0; + for y in (cy - 1).max(0)..=(cy + 1).min(plan.h as i64 - 1) { + for x in (cx - 1).max(0)..=(cx + 1).min(plan.w as i64 - 1) { + let c = img.data[y as usize * plan.w + x as usize].map(|v| v * gain); + let lab = lightcraft_color::perceptual::oklab_from_2020(masks::tonemap_for_select(c)); + for k in 0..3 { + acc[k] += lab[k] as f64; + } + n += 1.0; + } + } + Some(acc.map(|v| v / n)) +} + /// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a /// fresh evaluation. fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option { diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index f02185f8a..4f8865595 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -175,7 +175,7 @@ fn smooth_plane(p: &mut Plane, sigma: f32) { } /// A rough display mapping for colour picking (so samples taken on screen match). -fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] { +pub(crate) fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] { c.map(|v| v / (1.0 + v)) } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 1490997a1..476cc43a7 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -533,6 +533,20 @@ fn general_interaction( targeted_drag(app, ui, resp, map, &target); return; } + if app.ui.tool == "colorRange" { + // click: sample the colour for the selected mask's colour range; ⇧-click adds (up to 5) + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let n = map.norm(q); + let add = ui.input(|i| i.modifiers.shift); + if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) { + app.toast(ui.ctx(), e); + } + } + return; + } if app.ui.tool == "pointColor" { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.clicked() diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index dafa36a9b..56c3dcc62 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -84,6 +84,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.painter().text(pos2(r.center().x, r.bottom() - 9.0), Align2::CENTER_CENTER, *label, t.font(10.5), t.text_dim); if resp.clicked() { match *kind { + "colorRange" => { + // an empty colour range; clicking the photo samples it + let _ = app.run("mask.add", json!({"kind": "colorRange"})); + app.ui.tool = "colorRange".into(); + app.toast(ui.ctx(), "Click the photo to pick a colour · ⇧-click adds more"); + } "brush" | "linear" | "radial" => { app.ui.tool = kind.to_string(); if *kind != "brush" { @@ -416,6 +422,20 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape } } MaskShape::ColorRange { samples, refine } => { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("{} sample{}", samples.len(), if samples.len() == 1 { "" } else { "s" })) + .color(t.text_dim) + .size(11.5), + ); + let picking = app.ui.tool == "colorRange"; + if text_button(ui, &format!("colorPick{comp}"), "Pick", picking) + .on_hover_text("Click the photo to pick a colour; ⇧-click adds more (up to 5)") + .clicked() + { + app.ui.tool = if picking { String::new() } else { "colorRange".into() }; + } + }); let spec = ControlSpec { id: "refine", label: "Refine", diff --git a/docs/parity.md b/docs/parity.md index 18ea3d812..315ce2299 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -31,7 +31,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | 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%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 12 | 4 | 7 | 0 | 8/8 (100%) | 4/5 (80%) | +| K. Masking (MASK) | 13 | 3 | 7 | 0 | 8/8 (100%) | 5/5 (100%) | | L. Presets (PRE) | 5 | 1 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 4 | 0 | 1 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 4 | 1 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) | | Z. Keyboard shortcuts (desktop) | 69 | 7 | 4 | 1 | 49/52 (94%) | 18/23 (78%) | | Lightroom Classic extras | 18 | 12 | 49 | 9 | — | 17/21 (81%) | -| **Total** | 335 | 41 | 124 | 37 | 193/198 (97%) | 122/144 (85%) | +| **Total** | 336 | 40 | 124 | 37 | 193/198 (97%) | 123/144 (85%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **71.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 91.0% of 144 · P2 18.4% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **71.2%** of 500 in-scope rows — P0 98.7% of 198 · P1 91.3% of 144 · P2 18.4% of 158. ## Top gaps @@ -318,7 +318,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | | -| LR-MASK-COLORRANGE | Colour range | P1 | 🟡 | `cmd:mask.add` (`colorRange`), `cmd:mask.update` | renders; Refine slider in the Masking panel; sampling UX (click / drag on the photo) unverified | +| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider | | LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) | | LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data | | LR-MASK-COMBINE | Add / subtract / intersect | P0 | ✅ | `cmd:mask.addComponent` | |