mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
co-authored by
Claude Opus 5.5
parent
140d7cbc67
commit
f65c2310f2
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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` | |
|
||||
|
||||
Reference in New Issue
Block a user