LR-EDIT-COLOR-POINTCOLOR: Point Color — sampled colours with shift, variance, range and visualize

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.<i>.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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 10:18:28 -04:00
co-authored by Claude Opus 5.5
parent 37d9ecf0e9
commit 490fe943cd
23 changed files with 890 additions and 12 deletions
+6
View File
@@ -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<String>,
/// 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()));
+186 -3
View File
@@ -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.<i>.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<f64> {
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 (`<family>.<field>`; addressed as `<family>.<i>.<field>`).
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<f64> {
// (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<Item = &'static ControlSpec> {
@@ -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<String> = 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();
+1 -1
View File
@@ -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"],
+60
View File
@@ -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<PointColor>,
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)]
+1 -1
View File
@@ -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;
+109
View File
@@ -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<usize> {
Ok(f64_req(p, "index", c)? as usize)
}
pub fn specs() -> Vec<CommandSpec> {
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)
}),
]
}
+2
View File
@@ -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());
+7
View File
@@ -114,6 +114,13 @@ pub fn specs() -> Vec<CommandSpec> {
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(),
))
}),
+2
View File
@@ -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;
+12 -1
View File
@@ -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<StageCache>) -> 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();
+54
View File
@@ -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);
}
+1
View File
@@ -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");
+6
View File
@@ -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<f32> = 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 {
+2
View File
@@ -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 })
}
+39
View File
@@ -51,6 +51,39 @@ fn band_weights(h: f32) -> array<f32, 8> {
return w;
}
// `PointK::apply` for Point Color sample `k` (OkLCh in, OkLCh out).
fn point_color(k: u32, lch: vec3<f32>) -> vec3<f32> {
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<f32>(l, c, h);
}
// `ColorOps::apply`.
fn color_ops(rgb: vec3<f32>, local_sat: f32, local_hue: f32) -> vec3<f32> {
if (pu(F_OPS_IDENTITY) != 0u && local_sat == 0.0 && local_hue == 0.0) {
@@ -75,6 +108,12 @@ fn color_ops(rgb: vec3<f32>, local_sat: f32, local_hue: f32) -> vec3<f32> {
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<f32>(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);
+33
View File
@@ -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);
}
+121 -2
View File
@@ -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<PointK>,
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();
+10 -2
View File
@@ -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 }
}
+1 -1
View File
@@ -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<Box<dyn Fn(&mut DevelopSettings)>> = vec![
+126
View File
@@ -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<f32> = (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:?}");
}
}
+24
View File
@@ -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) {
+81 -1
View File
@@ -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) {
+6
View File
@@ -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,