From 490fe943cd3d2b4557009d9c90d190956eceab1c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:18:28 -0400 Subject: [PATCH] =?UTF-8?q?LR-EDIT-COLOR-POINTCOLOR:=20Point=20Color=20?= =?UTF-8?q?=E2=80=94=20sampled=20colours=20with=20shift,=20variance,=20ran?= =?UTF-8?q?ge=20and=20visualize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Up to 8 OkLCh samples (pointColor.pick samples the colour after the mixer on a proxy render); per sample hue/saturation/luminance shift, variance (compress/expand the spread around the sample) and an overall + per-axis range with soft edges, applied after the colour mixer in ColorOps and its WGSL twin (equivalence case). Indexed control specs (pointColor..hueShift …) so develop.set/controls/MCP address each sample. Visualize range is a render overlay (RenderRequest::overlay) drawn after either renderer: the sample's own edit is left out and colours outside the range turn grey. Point Color flyout in the Color section with swatches, eyedropper, sliders, visualize and delete. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/wire.rs | 6 + crates/develop/src/controls.rs | 189 ++++++++++++++++++++++++- crates/develop/src/presets.rs | 2 +- crates/develop/src/settings.rs | 60 ++++++++ crates/engine/examples/render_bench.rs | 2 +- crates/engine/src/cmd/color.rs | 109 ++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/query.rs | 7 + crates/engine/src/lib.rs | 2 + crates/engine/src/media.rs | 13 +- crates/engine/src/tests_color.rs | 54 +++++++ crates/gpu/src/ctx.rs | 1 + crates/gpu/src/params.rs | 6 + crates/gpu/src/render.rs | 2 + crates/gpu/src/wgsl/finish.wgsl | 39 +++++ crates/gpu/tests/equivalence.rs | 33 +++++ crates/pipeline/src/colorops.rs | 123 +++++++++++++++- crates/pipeline/src/lib.rs | 12 +- crates/pipeline/src/tests.rs | 2 +- crates/pipeline/src/visualize.rs | 126 +++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 24 ++++ crates/ui-egui/src/panels/edit.rs | 82 ++++++++++- crates/ui-egui/src/state.rs | 6 + 23 files changed, 890 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/cmd/color.rs create mode 100644 crates/engine/src/tests_color.rs create mode 100644 crates/pipeline/src/visualize.rs diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 95e75ffd5..6dbe81b2c 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -45,6 +45,9 @@ pub struct WireJob { /// Reuse the worker's intermediate results for this view (the loupe), like the desktop's /// per-slot stage cache: slider drags then only redo what changed. pub stages: Option, + /// The request's diagnostic overlay (`Overlay::to_parts`). + #[serde(default)] + pub overlay: (u8, f32), } impl WireJob { @@ -70,6 +73,7 @@ impl WireJob { thumb: job.cache.as_ref().map(|(_, k)| k.to_string()), thumb_cached, stages: job.stages.is_some().then(|| view.to_string()), + overlay: job.request.overlay.to_parts(), } } @@ -83,6 +87,7 @@ impl WireJob { max_h: self.max_h, quality: if self.draft { Quality::Draft } else { Quality::Full }, apply_crop: self.apply_crop, + overlay: lightcraft_engine::pipeline::Overlay::from_parts(self.overlay.0, self.overlay.1), } } @@ -296,6 +301,7 @@ mod tests { thumb: None, thumb_cached: false, stages: Some("Main".into()), + overlay: (0, 0.0), }; let mut core = WorkerCore::default(); assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str())); diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index ba456260c..35d4097e9 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -1,6 +1,9 @@ //! Control specs: one table describes every numeric develop slider — id, label, section, range, //! default, step, display precision and track style. The UI builds panels from it, MCP exposes it as //! a schema, and `get`/`set` address settings by id (e.g. `light.exposure`, `mixer.red.hue`). +//! +//! Settings that come in lists (Point Color samples) are *indexed* controls: one template spec in +//! [`INDEXED`] (`pointColor.hueShift`) addresses every element as `pointColor..hueShift`. use serde::{Deserialize, Serialize}; @@ -23,6 +26,7 @@ pub enum Section { Geometry, Profile, Calibration, + PointColor, } impl Section { @@ -42,6 +46,7 @@ impl Section { Section::Geometry => "Geometry", Section::Profile => "Profile", Section::Calibration => "Calibration", + Section::PointColor => "Point Color", } } } @@ -105,7 +110,7 @@ macro_rules! controls { pub fn get(s: &DevelopSettings, id: &str) -> Option { match id { $( $id => Some(s.$($field).+ as f64), )* - _ => None, + _ => indexed_get(s, id), } } @@ -115,7 +120,7 @@ macro_rules! controls { let v = spec.clamp(v); match id { $( $id => { s.$($field).+ = v as _; true } )* - _ => false, + _ => indexed_set(s, id, v), } } }; @@ -240,8 +245,166 @@ controls! { "crop.angle" => crop.geometry.angle, "Straighten", Geometry, -45, 45, 0, 0.01, 2, Centered; } +/// Template specs of the indexed controls (`.`; addressed as `..`). +pub static INDEXED: &[ControlSpec] = &[ + ControlSpec { + id: "pointColor.hueShift", + label: "Hue", + section: Section::PointColor, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Rainbow, + }, + ControlSpec { + id: "pointColor.satShift", + label: "Saturation", + section: Section::PointColor, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Gradient { from: "#7a7a7a", to: "#e04a3a" }, + }, + ControlSpec { + id: "pointColor.lumShift", + label: "Luminance", + section: Section::PointColor, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Gradient { from: "#202020", to: "#f0f0f0" }, + }, + ControlSpec { + id: "pointColor.variance", + label: "Variance", + section: Section::PointColor, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Centered, + }, + ControlSpec { + id: "pointColor.range", + label: "Range", + section: Section::PointColor, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Plain, + }, + ControlSpec { + id: "pointColor.hueRange", + label: "Hue Range", + section: Section::PointColor, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Rainbow, + }, + ControlSpec { + id: "pointColor.satRange", + label: "Saturation Range", + section: Section::PointColor, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Gradient { from: "#7a7a7a", to: "#e04a3a" }, + }, + ControlSpec { + id: "pointColor.lumRange", + label: "Luminance Range", + section: Section::PointColor, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Gradient { from: "#202020", to: "#f0f0f0" }, + }, +]; + +/// `pointColor.3.hueShift` → (`pointColor`, 3, `hueShift`). +fn split_indexed(id: &str) -> Option<(&str, usize, &str)> { + let mut it = id.splitn(3, '.'); + let (fam, i, field) = (it.next()?, it.next()?, it.next()?); + Some((fam, i.parse().ok()?, field)) +} + +fn indexed_field<'a>(s: &'a mut DevelopSettings, id: &str) -> Option<&'a mut f64> { + let (fam, i, field) = split_indexed(id)?; + match fam { + "pointColor" => { + let p = s.point_colors.get_mut(i)?; + Some(match field { + "hueShift" => &mut p.hue_shift, + "satShift" => &mut p.sat_shift, + "lumShift" => &mut p.lum_shift, + "variance" => &mut p.variance, + "range" => &mut p.range, + "hueRange" => &mut p.hue_range, + "satRange" => &mut p.sat_range, + "lumRange" => &mut p.lum_range, + _ => return None, + }) + } + _ => None, + } +} + +fn indexed_get(s: &DevelopSettings, id: &str) -> Option { + // (a clone keeps one accessor for get and set; these lists are tiny) + let mut c = DevelopSettings { point_colors: s.point_colors.clone(), ..DevelopSettings::default() }; + indexed_field(&mut c, id).map(|v| *v) +} + +fn indexed_set(s: &mut DevelopSettings, id: &str, v: f64) -> bool { + match indexed_field(s, id) { + Some(f) => { + *f = v; + true + } + None => false, + } +} + +/// The spec of a control id, including indexed ids (`pointColor.0.hueShift` → the +/// `pointColor.hueShift` template). pub fn find(id: &str) -> Option<&'static ControlSpec> { - CONTROLS.iter().find(|c| c.id == id) + if let Some(c) = CONTROLS.iter().find(|c| c.id == id) { + return Some(c); + } + let (fam, _, field) = split_indexed(id)?; + INDEXED.iter().find(|c| c.id.strip_prefix(fam).and_then(|r| r.strip_prefix('.')) == Some(field)) +} + +/// Every indexed control that exists in `s` (id, template spec), e.g. `pointColor.0.hueShift`. +pub fn indexed_instances(s: &DevelopSettings) -> Vec<(String, &'static ControlSpec)> { + let mut out = Vec::new(); + for spec in INDEXED { + let Some((fam, field)) = spec.id.split_once('.') else { continue }; + let n = match fam { + "pointColor" => s.point_colors.len(), + _ => 0, + }; + for i in 0..n { + out.push((format!("{fam}.{i}.{field}"), spec)); + } + } + out } pub fn in_section(section: Section) -> impl Iterator { @@ -277,6 +440,26 @@ mod tests { assert_eq!(get(&s, "nope"), None); } + #[test] + fn indexed_controls_address_list_elements() { + let mut s = DevelopSettings::default(); + assert_eq!(get(&s, "pointColor.0.hueShift"), None); + assert!(!set(&mut s, "pointColor.0.hueShift", 10.0), "no sample yet"); + s.point_colors.push(Default::default()); + s.point_colors.push(Default::default()); + assert!(set(&mut s, "pointColor.1.hueShift", 250.0)); + assert_eq!(s.point_colors[1].hue_shift, 100.0, "clamped by the template spec"); + assert_eq!(get(&s, "pointColor.1.range"), Some(50.0)); + assert_eq!(find("pointColor.7.lumRange").map(|c| c.id), Some("pointColor.lumRange")); + assert!(find("pointColor.x.lumRange").is_none() && find("pointColor.0.nope").is_none()); + let ids: Vec = indexed_instances(&s).into_iter().map(|(id, _)| id).collect(); + assert_eq!(ids.len(), 2 * INDEXED.iter().filter(|c| c.id.starts_with("pointColor.")).count()); + for (id, spec) in indexed_instances(&s) { + assert!(get(&s, &id).is_some(), "{id}"); + assert!(spec.min <= spec.default && spec.default <= spec.max); + } + } + #[test] fn formatting() { let e = find("light.exposure").unwrap(); diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index e4ebc7555..f00e7bca6 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -101,7 +101,7 @@ impl SettingsGroup { SettingsGroup::Light => &["light"], SettingsGroup::ToneCurve => &["curve"], SettingsGroup::Color => &["color"], - SettingsGroup::ColorMixer => &["mixer", "bw_mix"], + SettingsGroup::ColorMixer => &["mixer", "bw_mix", "point_colors"], SettingsGroup::ColorGrading => &["grading"], SettingsGroup::Effects => &["effects"], SettingsGroup::Vignette => &["vignette"], diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index ee625e1a5..ab7d098e8 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -17,6 +17,8 @@ pub struct DevelopSettings { pub curve: ToneCurve, pub color: ColorAdj, pub mixer: Mixer, + /// Point Color: up to [`MAX_POINT_COLORS`] sampled colours, each with its own adjustment. + pub point_colors: Vec, pub bw_mix: BwMix, pub grading: ColorGrading, pub effects: Effects, @@ -49,6 +51,7 @@ impl Default for DevelopSettings { curve: ToneCurve::default(), color: ColorAdj::default(), mixer: Mixer::default(), + point_colors: Vec::new(), bw_mix: BwMix::default(), grading: ColorGrading::default(), effects: Effects::default(), @@ -263,6 +266,63 @@ impl Mixer { } } +/// Most Point Color samples per photo (Lightroom's limit). +pub const MAX_POINT_COLORS: usize = 8; + +/// One Point Color sample: a colour picked on the image (OkLCh, at the stage where Point Color +/// applies: after the colour mixer) and the adjustment of the colours around it. The range is a +/// box in OkLCh around the sample, widened by `range` (overall) and the per-axis widths, with a +/// soft edge. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PointColor { + /// Sampled OkLab lightness (0..1). + pub lum: f64, + /// Sampled OkLCh chroma (0..~0.37). + pub chroma: f64, + /// Sampled OkLCh hue, degrees. + pub hue: f64, + /// Hue shift −100..100 (±~29° at the sample). + pub hue_shift: f64, + /// Saturation shift −100..100 (chroma scale). + pub sat_shift: f64, + /// Luminance shift −100..100. + pub lum_shift: f64, + /// −100 compresses the colour spread within the range towards the sample (evens out e.g. + /// skin), +100 expands it. + pub variance: f64, + /// Overall range 0..100 (scales all three widths). + pub range: f64, + pub hue_range: f64, + pub sat_range: f64, + pub lum_range: f64, +} + +impl Default for PointColor { + fn default() -> Self { + Self { + lum: 0.5, + chroma: 0.1, + hue: 0.0, + hue_shift: 0.0, + sat_shift: 0.0, + lum_shift: 0.0, + variance: 0.0, + range: 50.0, + hue_range: 50.0, + sat_range: 50.0, + lum_range: 50.0, + } + } +} + +impl PointColor { + /// True when the sample changes nothing. + pub fn is_neutral(&self) -> bool { + self.hue_shift == 0.0 && self.sat_shift == 0.0 && self.lum_shift == 0.0 && self.variance == 0.0 + } +} + /// B&W mix (used when treatment = Bw): per-band luminance contribution −100..100. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index 8e0027f8c..cb1b1142f 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -121,7 +121,7 @@ fn main() { let preview = Arc::new(fit(&full, 2560, 2560, Filter::Box)); let s = typical(); let view = RenderRequest::fit(1920, 1280); - let draft = RenderRequest { max_w: 1152, max_h: 768, quality: Quality::Draft, apply_crop: true }; + let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(1152, 768) }; if gpu { let c = develop(&preview, &info, &s, &view, None, false).image; let g = develop(&preview, &info, &s, &view, None, true).image; diff --git a/crates/engine/src/cmd/color.rs b/crates/engine/src/cmd/color.rs new file mode 100644 index 000000000..fbf551cb7 --- /dev/null +++ b/crates/engine/src/cmd/color.rs @@ -0,0 +1,109 @@ +//! Commands that sample the developed image: Point Color samples and the targeted adjustment tool. +//! +//! Sampling renders a small proxy with every stage *after* the sampled one neutralized, so the +//! picked colour is the one the adjustment will see (e.g. Point Color samples after the colour +//! mixer, before vibrance, grading, vignette and curves). + +use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; +use lightcraft_color::transfer::srgb_to_linear; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_develop::{DevelopSettings, MAX_POINT_COLORS, PointColor, Treatment}; +use lightcraft_geom::Point; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, f64_req, has_active}; +use crate::media::SourceLevel; +use crate::{Result, Session}; + +/// Long edge of the proxy rendered for sampling. +const PROBE_EDGE: usize = 384; + +/// The sRGB-encoded colour (0..1) at normalized image point `(x, y)` of the active photo rendered +/// with its settings changed by `neutral`, averaged over 3 × 3 proxy pixels. +pub(crate) fn probe(s: &mut Session, c: &str, x: f64, y: f64, neutral: impl FnOnce(&mut DevelopSettings)) -> Result<[f32; 3]> { + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let src = s.source_now(id, 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 mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + neutral(&mut d); + let req = lightcraft_pipeline::RenderRequest::fit(PROBE_EDGE, PROBE_EDGE); + let plan = lightcraft_pipeline::plan(&src, &info, &d, &req); + let q = plan.frame.norm_to_out(plan.w, plan.h).apply(Point::new(x.clamp(0.0, 1.0), y.clamp(0.0, 1.0))); + let img = lightcraft_pipeline::render(&src, &info, &d, &req).image; + let (cx, cy) = (q.x.floor() as isize, q.y.floor() as isize); + let mut acc = [0.0f32; 3]; + let mut n = 0.0; + for dy in -1..=1 { + for dx in -1..=1 { + let (px, py) = ((cx + dx).clamp(0, img.width as isize - 1) as usize, (cy + dy).clamp(0, img.height as isize - 1) as usize); + let p = img.data[py * img.width + px]; + for k in 0..3 { + acc[k] += p[k] as f32 / 255.0; + } + n += 1.0; + } + } + Ok(acc.map(|v| v / n)) +} + +/// sRGB-encoded → OkLCh (lightness, chroma, hue in radians) via linear Rec.2020. +pub(crate) fn encoded_to_oklch(e: [f32; 3]) -> [f32; 3] { + let lin = SRGB.to_space(&REC2020).apply_f32(e.map(srgb_to_linear)); + lab_to_lch(oklab_from_2020(lin)) +} + +/// Everything after the colour mixer, neutralized (the colour Point Color sees). +pub(crate) fn after_mixer_neutral(d: &mut DevelopSettings) { + d.color.vibrance = 0.0; + d.color.saturation = 0.0; + d.point_colors.clear(); + d.treatment = Treatment::Color; + d.grading = Default::default(); + d.vignette.amount = 0.0; + d.grain.amount = 0.0; + d.curve = Default::default(); +} + +fn index(p: &Value, c: &str) -> Result { + Ok(f64_req(p, "index", c)? as usize) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "pointColor.pick", + "Add Point Color Sample", + [], + None, + "{x, y} normalized image coords — samples the colour there (max 8); returns {index, lum, chroma, hue}", + has_active, + |s, p| { + let c = "pointColor.pick"; + let (x, y) = (f64_req(p, "x", c)?, f64_req(p, "y", c)?); + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + if s.develop_of(id).is_some_and(|d| d.point_colors.len() >= MAX_POINT_COLORS) { + return Err(bad(c, format!("at most {MAX_POINT_COLORS} point colours"))); + } + let [l, ch, h] = encoded_to_oklch(probe(s, c, x, y, after_mixer_neutral)?); + let sample = PointColor { lum: l as f64, chroma: ch as f64, hue: (h as f64).to_degrees().rem_euclid(360.0), ..Default::default() }; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + d.point_colors.push(sample); + let i = d.point_colors.len() - 1; + s.set_develop(id, d, "Point Color")?; + Ok(json!({"index": i, "lum": sample.lum, "chroma": sample.chroma, "hue": sample.hue})) + } + ), + cmd!("pointColor.delete", "Delete Point Color Sample", [], None, "{index}", has_active, |s, p| { + let c = "pointColor.delete"; + let i = index(p, c)?; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + if i >= d.point_colors.len() { + return Err(bad(c, "no such sample")); + } + d.point_colors.remove(i); + s.set_develop(id, d, "Delete Point Color")?; + Ok(Value::Null) + }), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3e1b8c540..d4c811bf0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -4,6 +4,7 @@ //! delete), `album.*`, `develop.*` (settings), `crop.*`, `mask.*`, `preset.*`, `version.*`, //! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…). +mod color; mod develop; mod edit; mod library; @@ -101,6 +102,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(edit::specs()); v.extend(library::specs()); v.extend(develop::specs()); + v.extend(color::specs()); v.extend(masks::specs()); v.extend(query::specs()); v.extend(xmp::specs()); diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 5348c3519..dcb1a5111 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -114,6 +114,13 @@ pub fn specs() -> Vec { v["value"] = json!(controls::get(&d, c.id)); v }) + // indexed controls of the list elements that exist (`pointColor.0.hueShift`, …) + .chain(controls::indexed_instances(&d).into_iter().filter(|(_, c)| sec.is_none_or(|x| x == c.section)).map(|(id, c)| { + let mut v = serde_json::to_value(c).unwrap_or_default(); + v["value"] = json!(controls::get(&d, &id)); + v["id"] = json!(id); + v + })) .collect(), )) }), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2f971c97e..b42f5eb05 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -317,6 +317,8 @@ impl Session { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_color; +#[cfg(test)] mod tests_import; #[cfg(test)] mod tests_library; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 1c0a40704..99fd35214 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -228,6 +228,17 @@ impl RenderJob { self } + /// Draw a diagnostic overlay over the result (e.g. Point Color's visualized range). Gets its + /// own result key. + pub fn with_overlay(mut self, overlay: lightcraft_pipeline::Overlay) -> Self { + if self.request.overlay != overlay { + self.key ^= self.request.overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); + self.request.overlay = overlay; + self.key ^= overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); + } + self + } + /// Reuse `stages` across this view's renders. pub fn with_stages(mut self, stages: Arc) -> Self { self.stages = Some(stages); @@ -319,7 +330,7 @@ impl crate::Session { let level = SourceLevel::for_size(max_w.max(max_h)); let source = self.media.source_ref(&p, level); let settings = if before { Arc::new(lightcraft_pipeline::before_settings(&p.develop)) } else { p.develop.clone() }; - let request = RenderRequest { max_w, max_h, quality: Quality::Full, apply_crop }; + let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) }; let key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (level as u64) << 60; let cache = thumb_bucket.map(|b| { let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish(); diff --git a/crates/engine/src/tests_color.rs b/crates/engine/src/tests_color.rs new file mode 100644 index 000000000..a6b8eb2a7 --- /dev/null +++ b/crates/engine/src/tests_color.rs @@ -0,0 +1,54 @@ +//! Sampling commands: Point Color, the targeted adjustment tool, red eye. + +use serde_json::json; + +use crate::Session; + +fn demo() -> Session { + Session::with_demo() +} + +fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings { + (*s.develop_of(s.active().unwrap()).unwrap()).clone() +} + +/// Spread of the channels (a saturation proxy) at normalized `(x, y)`. +fn chroma_at(img: &lightcraft_raster::Rgba8, x: f64, y: f64) -> i32 { + let p = img.data[(img.height as f64 * y) as usize * img.width + (img.width as f64 * x) as usize]; + p[0].max(p[1]).max(p[2]) as i32 - p[0].min(p[1]).min(p[2]) as i32 +} + +#[test] +fn point_color_pick_and_adjust() { + let mut s = demo(); + let id = s.active().unwrap(); + let before = s.render_now(id, 384, 384).unwrap().image; + let r = s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap(); + assert_eq!(r["index"], 0); + let d = active_dev(&s); + assert_eq!(d.point_colors.len(), 1); + assert!(d.point_colors[0].chroma > 0.01, "the sky has colour: {r}"); + // the sample's sliders are develop controls (and listed as such) + s.execute("develop.set", &json!({"control": "pointColor.0.satShift", "value": -100})).unwrap(); + assert_eq!(active_dev(&s).point_colors[0].sat_shift, -100.0); + let ctl = s.execute("develop.controls", &json!({"section": "pointColor"})).unwrap(); + assert!(ctl.as_array().unwrap().iter().any(|c| c["id"] == "pointColor.0.satShift" && c["value"] == -100.0), "{ctl}"); + // the picked colour is desaturated in the render + let after = s.render_now(id, 384, 384).unwrap().image; + assert!( + chroma_at(&after, 0.5, 0.08) + 8 < chroma_at(&before, 0.5, 0.08), + "{} vs {}", + chroma_at(&after, 0.5, 0.08), + chroma_at(&before, 0.5, 0.08) + ); + // at most 8 samples; delete + for _ in 0..7 { + s.execute("pointColor.pick", &json!({"x": 0.3, "y": 0.5})).unwrap(); + } + assert!(s.execute("pointColor.pick", &json!({"x": 0.3, "y": 0.5})).is_err()); + s.execute("pointColor.delete", &json!({"index": 3})).unwrap(); + assert_eq!(active_dev(&s).point_colors.len(), 7); + // survives the settings JSON (what the catalog and XMP sidecars store) + let d = active_dev(&s); + assert_eq!(lightcraft_develop::DevelopSettings::from_json(&d.to_json()).unwrap(), d); +} diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index f05f22f14..0d12e6caa 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -118,6 +118,7 @@ fn constants() -> String { let b = lightcraft_pipeline::geometry::BLANK; s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]); s += &format!("const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\n", crate::params::CURVE_N); + s += &format!("const POINT_WORDS: u32 = {}u;\n", lightcraft_pipeline::colorops::POINT_WORDS); s += &format!("const SHADOW_TINT_K: f32 = {:?};\n", lightcraft_pipeline::colorops::SHADOW_TINT); for (i, h) in GRAIN_HASH.iter().enumerate() { s += &format!("const GRAIN_H{i}: u32 = {h}u;\n"); diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 18ad1639c..b18461a2e 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -2,6 +2,7 @@ //! `finish` kernel reads (`F_*` constants, generated from [`FIELDS`]), plus its auxiliary tables. use lightcraft_develop::VignetteStyle; +use lightcraft_pipeline::colorops::POINT_WORDS; use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, srgb_lut}; /// Entries per tone-curve table (`lightcraft_pipeline::finish` builds them at this size). @@ -37,6 +38,8 @@ const FIELDS: &[(&str, usize)] = &[ ("MIX_HUE", 8), ("MIX_SAT", 8), ("MIX_LUM", 8), + ("NPC", 1), + ("PC", 8 * POINT_WORDS), ("BW", 1), ("BW_MIX", 8), ("GRADING", 1), @@ -172,6 +175,9 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.fs("MIX_HUE", &ops.hue); p.fs("MIX_SAT", &ops.sat); p.fs("MIX_LUM", &ops.lum); + p.u("NPC", ops.points.len().min(8) as u32); + let pc: Vec = ops.points.iter().take(8).flat_map(|k| k.words()).collect(); + p.fs("PC", &pc); p.b("BW", ops.bw.is_some()); p.fs("BW_MIX", &ops.bw.unwrap_or([0.0; 8])); if let Some((wheels, blending, balance)) = &ops.grading { diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 72c51f5fb..7e0a75b0c 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -486,6 +486,8 @@ pub fn render( lap("finish + readback", &mut t, &mut cx); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t, &mut cx); + let mut image = image; + lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan); Some(Rendered { image, histogram }) } diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index 6b1a22a25..d405e9495 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -51,6 +51,39 @@ fn band_weights(h: f32) -> array { return w; } +// `PointK::apply` for Point Color sample `k` (OkLCh in, OkLCh out). +fn point_color(k: u32, lch: vec3) -> vec3 { + let o = F_PC + k * POINT_WORDS; + let sl = pf(o); + let sc = pf(o + 1u); + let sh = pf(o + 2u); + let var_k = pf(o + 6u); + let wh_ = pf(o + 7u); + let wc_ = pf(o + 8u); + let wl_ = pf(o + 9u); + var l = lch.x; + var c = lch.y; + var h = lch.z; + var wh = 1.0; + if (sc >= 0.02) { + wh = (1.0 - sstep(0.5 * wh_, wh_, abs(wrap_angle(h - sh)))) * sstep(0.005, 0.025, c); + } + if (wh <= 0.0) { + return lch; + } + let wc = 1.0 - sstep(0.5 * wc_, wc_, abs(c - sc)); + let wl = 1.0 - sstep(0.5 * wl_, wl_, abs(l - sl)); + let w = wh * wc * wl; + if (w <= 0.0) { + return lch; + } + h += w * (var_k * wrap_angle(h - sh) + pf(o + 3u)); + c += w * var_k * (c - sc); + c = max(c * (1.0 + w * pf(o + 4u)), 0.0); + l += w * (var_k * (l - sl) + pf(o + 5u)); + return vec3(l, c, h); +} + // `ColorOps::apply`. fn color_ops(rgb: vec3, local_sat: f32, local_hue: f32) -> vec3 { if (pu(F_OPS_IDENTITY) != 0u && local_sat == 0.0 && local_hue == 0.0) { @@ -75,6 +108,12 @@ fn color_ops(rgb: vec3, local_sat: f32, local_hue: f32) -> vec3 { c *= max(1.0 + ds, 0.0); l += dl * chroma_w * sqrt(max(l, 0.05)); } + for (var k = 0u; k < pu(F_NPC); k++) { + let r = point_color(k, vec3(l, c, h)); + l = r.x; + c = r.y; + h = r.z; + } let vib = pf(F_VIBRANCE); if (vib != 0.0) { let low = 1.0 - clamp(c / 0.22, 0.0, 1.0); diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 12c94be49..d4a8e21f8 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -84,6 +84,23 @@ fn cases() -> Vec<(&'static str, Edit)> { s.mixer.orange.hue = 30.0; s.mixer.green.lum = 40.0; }), + ("point color", |s| { + use lightcraft_develop::PointColor; + s.point_colors = vec![ + PointColor { + lum: 0.62, + chroma: 0.09, + hue: 60.0, + hue_shift: 40.0, + sat_shift: -30.0, + lum_shift: 20.0, + range: 80.0, + ..Default::default() + }, + PointColor { lum: 0.5, chroma: 0.12, hue: 270.0, variance: -60.0, sat_shift: 40.0, hue_range: 80.0, ..Default::default() }, + PointColor { lum: 0.4, chroma: 0.01, hue: 0.0, lum_shift: -30.0, ..Default::default() }, + ]; + }), ("b&w mix", |s| { s.treatment = Treatment::Bw; s.bw_mix.blue = 60.0; @@ -370,3 +387,19 @@ fn cached_renders_match_uncached() { assert_eq!(warm.image, fresh.image, "step {k}"); } } + +#[test] +fn overlays_match() { + if !gpu() { + return; + } + // Diagnostic overlays run on the finished 8-bit image, after either renderer. + let src = scene(0, 900, 600); + let raw = SourceInfo { raw: true, ..Default::default() }; + let s = DevelopSettings { + point_colors: vec![lightcraft_develop::PointColor { lum: 0.6, chroma: 0.05, hue: 300.0, hue_shift: 50.0, range: 90.0, ..Default::default() }], + ..Default::default() + }; + let req = RenderRequest { overlay: lightcraft_pipeline::Overlay::PointColorRange(0), ..RenderRequest::fit(640, 640) }; + check("point color range overlay", &src, &raw, &s, &req); +} diff --git a/crates/pipeline/src/colorops.rs b/crates/pipeline/src/colorops.rs index 96f67e87e..987afa285 100644 --- a/crates/pipeline/src/colorops.rs +++ b/crates/pipeline/src/colorops.rs @@ -6,7 +6,7 @@ use std::sync::OnceLock; use lightcraft_color::perceptual::{hsv_to_rgb, lab_to_lch, lch_to_lab, oklab_from_2020, oklab_to_2020}; use lightcraft_color::{Mat3, REC2020, SRGB}; -use lightcraft_develop::{Calibration, DevelopSettings, MIXER_HUES}; +use lightcraft_develop::{Calibration, DevelopSettings, MIXER_HUES, PointColor}; /// OkLCh hue angle (radians) of a pure sRGB colour with HSV hue `deg`. pub fn oklch_hue_of_srgb_hue(deg: f64) -> f32 { @@ -56,6 +56,78 @@ pub struct WheelK { pub lum: f32, } +/// Number of `f32`s per Point Color sample in [`PointK::words`] (the GPU parameter layout). +pub const POINT_WORDS: usize = 10; + +/// A Point Color sample resolved for the per-pixel stage: the sample (OkLab lightness, chroma, hue +/// in radians), the adjustment (hue rotation in radians, chroma scale − 1, lightness shift, +/// variance factor − 1) and the half-widths of the range box (hue radians, chroma, lightness). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PointK { + pub l: f32, + pub c: f32, + pub h: f32, + pub dh: f32, + pub sat: f32, + pub dl: f32, + pub var: f32, + pub wh: f32, + pub wc: f32, + pub wl: f32, +} + +impl PointK { + pub fn new(p: &PointColor) -> PointK { + let k = (p.range.clamp(0.0, 100.0) / 50.0).max(0.05) as f32; + let f = |v: f64| (v.clamp(0.0, 100.0) / 100.0) as f32; + PointK { + l: p.lum as f32, + c: p.chroma.max(0.0) as f32, + h: (p.hue as f32).to_radians(), + dh: (p.hue_shift.clamp(-100.0, 100.0) / 100.0) as f32 * 0.5, + sat: (p.sat_shift.clamp(-100.0, 100.0) / 100.0) as f32, + dl: (p.lum_shift.clamp(-100.0, 100.0) / 100.0) as f32 * 0.2, + var: (p.variance.clamp(-100.0, 100.0) / 100.0) as f32 * 0.8, + wh: (0.1 + 0.6 * f(p.hue_range)) * k, + wc: (0.02 + 0.12 * f(p.sat_range)) * k, + wl: (0.05 + 0.4 * f(p.lum_range)) * k, + } + } + + pub fn words(&self) -> [f32; POINT_WORDS] { + [self.l, self.c, self.h, self.dh, self.sat, self.dl, self.var, self.wh, self.wc, self.wl] + } + + /// How much an OkLCh colour belongs to this sample's range (0..1, soft edges). + #[inline] + pub fn weight(&self, l: f32, c: f32, h: f32) -> f32 { + // hue is meaningless for near-neutral colours: a chromatic sample ignores them, a + // near-neutral sample selects by chroma and lightness only + let wh = if self.c < 0.02 { 1.0 } else { (1.0 - smooth(0.5 * self.wh, self.wh, wrap(h - self.h).abs())) * smooth(0.005, 0.025, c) }; + if wh <= 0.0 { + return 0.0; + } + let wc = 1.0 - smooth(0.5 * self.wc, self.wc, (c - self.c).abs()); + let wl = 1.0 - smooth(0.5 * self.wl, self.wl, (l - self.l).abs()); + wh * wc * wl + } + + /// Apply this sample's adjustment to an OkLCh colour. + #[inline] + pub fn apply(&self, lch: [f32; 3]) -> [f32; 3] { + let [mut l, mut c, mut h] = lch; + let w = self.weight(l, c, h); + if w <= 0.0 { + return lch; + } + h += w * (self.var * wrap(h - self.h) + self.dh); + c += w * self.var * (c - self.c); + c = (c * (1.0 + w * self.sat)).max(0.0); + l += w * (self.var * (l - self.l) + self.dl); + [l, c, h] + } +} + /// The colour tools' parameters, resolved once per render (fields are read by the GPU kernel). #[derive(Clone, Debug)] pub struct ColorOps { @@ -65,6 +137,8 @@ pub struct ColorOps { pub sat: [f32; 8], pub lum: [f32; 8], pub mixer: bool, + /// Point Color samples that change something (applied after the mixer). + pub points: Vec, pub bw: Option<[f32; 8]>, /// Wheels (shadows, midtones, highlights, global), blending, balance. pub grading: Option<([WheelK; 4], f32, f32)>, @@ -89,6 +163,11 @@ impl ColorOps { sat: bands.map(|b| (b.sat / 100.0) as f32), lum: bands.map(|b| (b.lum / 100.0) as f32 * 0.18), mixer: !s.mixer.is_neutral(), + points: if crate::is_bw(s) { + Vec::new() + } else { + s.point_colors.iter().take(lightcraft_develop::MAX_POINT_COLORS).filter(|p| !p.is_neutral()).map(PointK::new).collect() + }, bw: crate::is_bw(s).then(|| s.bw_mix.bands().map(|v| (v / 100.0) as f32)), grading: (!g.is_neutral()).then(|| { ( @@ -102,7 +181,7 @@ impl ColorOps { } pub fn is_identity(&self) -> bool { - self.vibrance == 0.0 && self.saturation == 0.0 && !self.mixer && self.bw.is_none() && self.grading.is_none() + self.vibrance == 0.0 && self.saturation == 0.0 && !self.mixer && self.points.is_empty() && self.bw.is_none() && self.grading.is_none() } /// `local_sat` (−1..1) and `local_hue` (radians) come from masks. @@ -126,6 +205,9 @@ impl ColorOps { c *= (1.0 + ds).max(0.0); l += dl * chroma_w * l.max(0.05).sqrt(); } + for p in &self.points { + [l, c, h] = p.apply([l, c, h]); + } if self.vibrance != 0.0 { let low = 1.0 - (c / 0.22).clamp(0.0, 1.0); let skin = if self.vibrance > 0.0 { 1.0 - 0.6 * (-(wrap(h - self.skin) / 0.35).powi(2)).exp() } else { 1.0 }; @@ -300,6 +382,43 @@ mod tests { assert!(green[1] > green[0]); } + #[test] + fn point_color_targets_its_range() { + let skin = [0.35, 0.16, 0.09]; + let [l, c, h] = lab_to_lch(oklab_from_2020(skin)); + let sample = + PointColor { lum: l as f64, chroma: c as f64, hue: (h as f64).to_degrees(), hue_shift: 60.0, sat_shift: -50.0, ..Default::default() }; + let mut s = DevelopSettings::default(); + s.point_colors.push(sample); + let ops = ColorOps::new(&s); + let out = lab_to_lch(oklab_from_2020(ops.apply(skin, 0.0, 0.0))); + assert!(out[1] < c * 0.7, "chroma {c} -> {}", out[1]); + assert!(wrap(out[2] - h) > 0.2, "hue {h} -> {}", out[2]); + // far colours are untouched + for far in [[0.02, 0.05, 0.4], [0.05, 0.3, 0.05], [0.3, 0.3, 0.3]] { + let o = ops.apply(far, 0.0, 0.0); + assert!(far.iter().zip(&o).all(|(a, b)| (a - b).abs() < 1e-4), "{far:?} -> {o:?}"); + } + // weight: 1 at the sample, falling to 0 outside the range; a wider range reaches further + let k = PointK::new(&sample); + assert!((k.weight(l, c, h) - 1.0).abs() < 1e-6); + assert_eq!(k.weight(l, c, h + 1.0), 0.0); + let wide = PointK::new(&PointColor { range: 100.0, ..sample }); + assert!(wide.weight(l, c, h + 0.45) > k.weight(l, c, h + 0.45)); + } + + #[test] + fn point_color_variance_compresses_towards_the_sample() { + let p = PointColor { lum: 0.6, chroma: 0.1, hue: 40.0, variance: -100.0, range: 100.0, ..Default::default() }; + let k = PointK::new(&p); + let (h0, near) = (40f32.to_radians(), 40f32.to_radians() + 0.1); + let o = k.apply([0.62, 0.11, near]); + assert!((o[2] - h0).abs() < 0.1 && (o[1] - 0.1).abs() < 0.01 && (o[0] - 0.6).abs() < 0.02, "{o:?}"); + let k = PointK::new(&PointColor { variance: 100.0, ..p }); + let o = k.apply([0.62, 0.11, near]); + assert!(o[2] - h0 > 0.1, "{o:?}"); + } + #[test] fn grading_tints_shadows_only() { let mut s = DevelopSettings::default(); diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 3ccee51ba..cf71eb874 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -34,6 +34,9 @@ pub mod spots; pub mod tone; pub mod transform; pub mod upright; +pub mod visualize; + +pub use visualize::Overlay; use lightcraft_develop::{DevelopSettings, Treatment}; use std::any::Any; @@ -78,11 +81,13 @@ pub struct RenderRequest { pub quality: Quality, /// Show the crop frame's content only (true) or the whole uncropped image (crop tool active). pub apply_crop: bool, + /// A diagnostic view drawn over the result (e.g. Point Color's "visualize range"). + pub overlay: Overlay, } impl RenderRequest { pub fn fit(max_w: usize, max_h: usize) -> Self { - Self { max_w, max_h, quality: Quality::Full, apply_crop: true } + Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None } } } @@ -228,10 +233,11 @@ pub struct Plan<'a> { /// Resolve `s` against `src` for `req` (see [`Plan`]). pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &RenderRequest) -> Plan<'a> { - let settings: Cow<'a, DevelopSettings> = match profiles::effective(s) { + let mut settings: Cow<'a, DevelopSettings> = match profiles::effective(s) { Cow::Borrowed(b) => upright::resolve(src, info, b), Cow::Owned(o) => Cow::Owned(upright::resolve(src, info, &o).into_owned()), }; + visualize::adjust_settings(req.overlay, &mut settings); let s = &*settings; let frame = frame_for(src, info, s, req.apply_crop); let (w, h) = frame.fit(req.max_w, req.max_h); @@ -336,6 +342,8 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende lap("finish", &mut t); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t); + let mut image = image; + visualize::apply(&mut image, req.overlay, &plan); Rendered { image, histogram } } diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 93158164b..9aa5a93b0 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -177,7 +177,7 @@ fn stage_cache_matches_uncached_render_through_a_slider_session() { let info = SourceInfo { raw: true, ..Default::default() }; let cache = StageCache::default(); let full = RenderRequest::fit(200, 200); - let draft = RenderRequest { max_w: 120, max_h: 120, quality: Quality::Draft, apply_crop: true }; + let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(120, 120) }; let mut s = typical_edits(); // every kind of edit: tone, exposure, spatial amounts, NR, WB, crop, colour — at two sizes let steps: Vec> = vec![ diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs new file mode 100644 index 000000000..05d99279b --- /dev/null +++ b/crates/pipeline/src/visualize.rs @@ -0,0 +1,126 @@ +//! Diagnostic views drawn over a finished render ([`Overlay`]), shared by the CPU and GPU renderers +//! (they run on the 8-bit result, after the histogram is taken). +//! +//! - **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). + +use std::borrow::Cow; + +use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; +use lightcraft_color::transfer::srgb_to_linear; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_develop::DevelopSettings; +use lightcraft_raster::Rgba8; + +use crate::colorops::PointK; +use crate::{Plan, for_rows}; + +/// A diagnostic view of a render. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum Overlay { + #[default] + None, + /// Point Color sample `i`: its range in colour, everything else grey. + PointColorRange(u8), +} + +impl Overlay { + /// A plain (kind, value) pair, e.g. for sending a request to a web worker. + pub fn to_parts(self) -> (u8, f32) { + match self { + Overlay::None => (0, 0.0), + Overlay::PointColorRange(i) => (1, i as f32), + } + } + + /// Inverse of [`Overlay::to_parts`] (unknown kinds: no overlay). + pub fn from_parts(kind: u8, v: f32) -> Overlay { + match kind { + 1 => Overlay::PointColorRange(v as u8), + _ => Overlay::None, + } + } + + /// A stable value for render-cache keys (0 = no overlay). + pub fn key(self) -> u64 { + match self { + Overlay::None => 0, + Overlay::PointColorRange(i) => 0x1000 + i as u64, + } + } +} + +/// Settings changes an overlay needs before rendering (e.g. the visualized sample's adjustment +/// is left out, so the range is shown on the colours it selects). +pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { + if let Overlay::PointColorRange(i) = o + && let Some(p) = s.point_colors.get(i as usize) + && !p.is_neutral() + { + let p = &mut s.to_mut().point_colors[i as usize]; + (p.hue_shift, p.sat_shift, p.lum_shift, p.variance) = (0.0, 0.0, 0.0, 0.0); + } +} + +/// Draw overlay `o` over `img` (rendered with `plan`). +pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) { + match o { + Overlay::None => {} + Overlay::PointColorRange(i) => { + if let Some(p) = plan.settings.point_colors.get(i as usize) { + point_color_range(img, &PointK::new(p)); + } + } + } +} + +fn point_color_range(img: &mut Rgba8, k: &PointK) { + let m = SRGB.to_space(&REC2020).to_f32(); + let lut: Vec = (0..256).map(|v| srgb_to_linear(v as f32 / 255.0)).collect(); + let w = img.width; + for_rows(&mut img.data, w, |_, row| { + for px in row.iter_mut() { + let lin = [lut[px[0] as usize], lut[px[1] as usize], lut[px[2] as usize]]; + let c = std::array::from_fn(|r| m[r][0] * lin[0] + m[r][1] * lin[1] + m[r][2] * lin[2]); + let [l, ch, h] = lab_to_lch(oklab_from_2020(c)); + let a = k.weight(l, ch, h); + let grey = 0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32; + for c in 0..3 { + px[c] = (grey + (px[c] as f32 - grey) * a).round().clamp(0.0, 255.0) as u8; + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RenderRequest, SourceInfo, render}; + use lightcraft_develop::PointColor; + use lightcraft_raster::Rgb32f; + + #[test] + fn point_color_range_keeps_the_selected_colour_only() { + // left half orange, right half blue + let src = Rgb32f::from_fn(32, 16, |x, _| if x < 16 { [0.4, 0.15, 0.04] } else { [0.03, 0.06, 0.35] }); + let info = SourceInfo::default(); + let plain = render(&src, &info, &DevelopSettings::default(), &RenderRequest::fit(32, 16)).image; + // sample the orange as it renders + let px = plain.data[8 * 32 + 4]; + let lin = [px[0], px[1], px[2]].map(|v| srgb_to_linear(v as f32 / 255.0)); + let c = SRGB.to_space(&REC2020).apply_f32(lin); + let [l, ch, h] = lab_to_lch(oklab_from_2020(c)); + let mut s = DevelopSettings::default(); + s.point_colors.push(PointColor { lum: l as f64, chroma: ch as f64, hue: (h as f64).to_degrees(), hue_shift: 80.0, ..Default::default() }); + let req = RenderRequest { overlay: Overlay::PointColorRange(0), ..RenderRequest::fit(32, 16) }; + let v = render(&src, &info, &s, &req).image; + let (o, b) = (v.data[8 * 32 + 4], v.data[8 * 32 + 28]); + // the orange stays orange (and unshifted: the overlay shows the selection, not the edit) + assert!(o[0].abs_diff(px[0]) <= 2 && o[2].abs_diff(px[2]) <= 2, "{o:?} vs {px:?}"); + // the blue turns grey + assert!(b[0].abs_diff(b[2]) <= 1, "{b:?}"); + // without the overlay the edit shows + let e = render(&src, &info, &s, &RenderRequest::fit(32, 16)).image.data[8 * 32 + 4]; + assert!(e[1].abs_diff(px[1]) > 10, "{e:?} vs {px:?}"); + } +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 32cc8ea13..ff9426334 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -144,6 +144,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, !crop_tool) { let job = if interacting { job.draft() } else { job }; + let job = job.with_overlay(view_overlay(app, &d)); app.renderer.request(Slot::Main, job, 100); } let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash)); @@ -199,6 +200,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { resp.context_menu(|ui| super::grid::context_menu(app, ui, id)); } +/// 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; + 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); + } + Overlay::None +} + fn clipping_overlay(app: &LightcraftApp, p: &egui::Painter, r: Rect) { // Highlight clipped regions using the histogram's extremes isn't spatial; show a subtle frame hint. if let Some(h) = app.renderer.textures.get(&Slot::Main).and_then(|t| t.histogram.as_ref()) { @@ -233,6 +244,19 @@ fn general_interaction( } return; } + if app.ui.tool == "pointColor" { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let n = map.norm(q); + if let Ok(r) = app.run("pointColor.pick", json!({"x": n.x, "y": n.y})) { + app.ui.point_color = r["index"].as_u64().unwrap_or(0) as usize; + } + app.ui.tool.clear(); + } + return; + } // click toggles Fit ↔ 100 % at the clicked point; drag pans when zoomed let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0; if resp.double_clicked() || (resp.clicked() && !zoomed) { diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index a9c443a28..57fd35ceb 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -36,7 +36,7 @@ fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: let Some(spec) = controls::find(id) else { return }; let v = controls::get(d, id).unwrap_or(spec.default); let out = slider(ui, spec, v, enabled, None); - apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": spec.id, "value": v}))); + apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": id, "value": v}))); } /// Relative temperature scale for rendered (non-raw) files: −100..100 ↔ Kelvin via mired shift. @@ -177,6 +177,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if open { mixer(app, ui, d); } + if !crate::is_bw(d) { + let open = app.ui.flyout_open("pointColor"); + if flyout_row(ui, "pointColor", "Point Color", Icon::Picker, open).clicked() { + app.ui.toggle_flyout("pointColor"); + } + if open { + point_color(app, ui, d); + } + } let open = app.ui.flyout_open("grading"); if flyout_row(ui, "grading", "Color Grading", Icon::Presets, open).clicked() { app.ui.toggle_flyout("grading"); @@ -592,6 +601,77 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { } } +// ------------------------------------------------------------------------------ point color + +/// Display colour of an OkLCh sample (lightness, chroma, hue in degrees). +fn oklch_color(l: f64, c: f64, h_deg: f64) -> Color32 { + use lightcraft_color::perceptual::{lch_to_lab, oklab_to_2020}; + let lin = oklab_to_2020(lch_to_lab([l as f32, c as f32, (h_deg as f32).to_radians()])); + let s = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).apply_f32(lin); + let e = s.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8); + Color32::from_rgb(e[0], e[1], e[2]) +} + +/// Point Color: swatches of the samples (+ the eyedropper), the selected sample's shifts and range, +/// and "Visualize range". +fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + let t = Tokens::get(ui.ctx()); + let n = d.point_colors.len(); + if app.ui.point_color >= n && n > 0 { + app.ui.point_color = n - 1; + } + let sel = app.ui.point_color; + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + let active = app.ui.tool == "pointColor"; + let full = n >= lightcraft_develop::MAX_POINT_COLORS; + if crate::widgets::icon_button(ui, "pointColorPicker", Icon::Picker, vec2(26.0, 26.0), active, !full, "Sample a colour on the photo") + .clicked() + { + app.ui.tool = if active { String::new() } else { "pointColor".into() }; + } + for (i, p) in d.point_colors.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + register(ui.ctx(), format!("pointColor:{i}"), r); + ui.painter().rect_filled(r.shrink(2.0), 3.0, oklch_color(p.lum, p.chroma, p.hue)); + if i == sel { + ui.painter().rect_stroke(r, 4.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside); + } + if resp.clicked() { + app.ui.point_color = i; + } + } + }); + }); + if n == 0 { + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 8 }).show(ui, |ui| { + ui.label(egui::RichText::new("Pick a colour on the photo with the eyedropper.").size(12.0).color(t.text_dim)); + }); + return; + } + for k in ["hueShift", "satShift", "lumShift", "variance"] { + control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); + } + sub_title(ui, "Range"); + for k in ["range", "hueRange", "satRange", "lumRange"] { + control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); + } + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 8 }).show(ui, |ui| { + ui.horizontal(|ui| { + let mut v = app.ui.point_color_visualize; + if ui.checkbox(&mut v, "Visualize range").changed() { + app.ui.point_color_visualize = v; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if text_button(ui, "pointColorDelete", "Delete", false).clicked() { + let _ = app.run("pointColor.delete", json!({"index": sel})); + } + }); + }); + }); +} + // ------------------------------------------------------------------------------ colour grading fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 34b3b15a7..3907fb212 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -107,6 +107,10 @@ pub struct UiState { pub brush_flow: f32, pub brush_erase: bool, pub remove_size: f32, + /// Selected Point Color sample. + pub point_color: usize, + /// Point Color "Visualize range": the selected sample's range in colour, the rest grey. + pub point_color_visualize: bool, /// Transient toast text and its expiry (seconds of app time). #[serde(skip)] pub toast: Option<(String, f64)>, @@ -175,6 +179,8 @@ impl Default for UiState { brush_flow: 60.0, brush_erase: false, remove_size: 0.02, + point_color: 0, + point_color_visualize: false, toast: None, status: String::new(), dialog: None,