LR-MASK-COLORRANGE: pick colour-range samples on the photo

The Color mask tile adds an empty colour range and arms the picker: click the photo to sample
(⇧-click adds, up to five); Pick in the Masking panel re-arms it. mask.sampleColor {x, y, add?}
samples via lightcraft_pipeline::color_range_sample, which reads the exposed scene colour at
that point in the same OkLab space the mask compares in (so a click selects what was clicked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 22:03:14 -04:00
co-authored by Claude Opus 5.5
parent 140d7cbc67
commit f65c2310f2
7 changed files with 147 additions and 5 deletions
+41
View File
@@ -167,6 +167,47 @@ pub fn specs() -> Vec<CommandSpec> {
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",
+39
View File
@@ -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());
}
+28
View File
@@ -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<Plane> {
+1 -1
View File
@@ -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))
}
+14
View File
@@ -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()
+20
View File
@@ -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",
+4 -4
View File
@@ -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.
<!-- /parity:summary -->
## 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` | |