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 697a71489..5696d6706 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}; @@ -22,6 +25,9 @@ pub enum Section { Optics, Geometry, Profile, + Calibration, + PointColor, + RedEye, } impl Section { @@ -40,6 +46,9 @@ impl Section { Section::Optics => "Optics", Section::Geometry => "Geometry", Section::Profile => "Profile", + Section::Calibration => "Calibration", + Section::PointColor => "Point Color", + Section::RedEye => "Red Eye", } } } @@ -103,7 +112,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), } } @@ -113,7 +122,7 @@ macro_rules! controls { let v = spec.clamp(v); match id { $( $id => { s.$($field).+ = v as _; true } )* - _ => false, + _ => indexed_set(s, id, v), } } }; @@ -138,6 +147,7 @@ controls! { "curve.splitShadows" => curve.split_shadows, "Shadows split", Curve, 10, 70, 25, 1, 0, Plain; "curve.splitMid" => curve.split_mid, "Midtones split", Curve, 20, 80, 50, 1, 0, Plain; "curve.splitHighlights" => curve.split_highlights, "Highlights split", Curve, 30, 90, 75, 1, 0, Plain; + "curve.refineSaturation" => curve.refine_saturation, "Refine Saturation", Curve, 0, 100, 100, 1, 0, Gradient { from: "#7a7a7a", to: "#d85a3a" }; "color.vibrance" => color.vibrance, "Vibrance", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#d8406a" }; "color.saturation" => color.saturation, "Saturation", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e04a3a" }; "mixer.red.hue" => mixer.red.hue, "Red Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 0 }; @@ -227,11 +237,207 @@ controls! { "geometry.scale" => geometry.scale, "Scale", Geometry, 50, 150, 100, 1, 0, Plain; "geometry.offsetX" => geometry.offset_x, "Offset X", Geometry, -100, 100, 0, 0.1, 1, Centered; "geometry.offsetY" => geometry.offset_y, "Offset Y", Geometry, -100, 100, 0, 0.1, 1, Centered; + "calibration.shadowsTint" => calibration.shadows_tint, "Shadows Tint", Calibration, -100, 100, 0, 1, 0, Tint; + "calibration.redHue" => calibration.red_hue, "Red Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#e0306a", to: "#e08a30" }; + "calibration.redSat" => calibration.red_sat, "Red Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e03a3a" }; + "calibration.greenHue" => calibration.green_hue, "Green Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#b0c030", to: "#30c08a" }; + "calibration.greenSat" => calibration.green_sat, "Green Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40b050" }; + "calibration.blueHue" => calibration.blue_hue, "Blue Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#30a0d0", to: "#8040d0" }; + "calibration.blueSat" => calibration.blue_sat, "Blue Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#3a60e0" }; "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" }, + }, + ControlSpec { + id: "redEye.pupilSize", + label: "Pupil Size", + section: Section::RedEye, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Plain, + }, + ControlSpec { + id: "redEye.darken", + label: "Darken", + section: Section::RedEye, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Gradient { from: "#d0d0d0", to: "#101010" }, + }, +]; + +/// `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, + }) + } + "redEye" => { + let e = s.red_eye.get_mut(i)?; + Some(match field { + "pupilSize" => &mut e.pupil_size, + "darken" => &mut e.darken, + _ => 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(), red_eye: s.red_eye.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(), + "redEye" => s.red_eye.len(), + _ => 0, + }; + for i in 0..n { + out.push((format!("{fam}.{i}.{field}"), spec)); + } + } + out } pub fn in_section(section: Section) -> impl Iterator { @@ -267,6 +473,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 349d700b6..f00e7bca6 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -32,10 +32,11 @@ pub enum SettingsGroup { Spots, RedEye, LensBlur, + Calibration, } impl SettingsGroup { - pub const ALL: [SettingsGroup; 19] = [ + pub const ALL: [SettingsGroup; 20] = [ SettingsGroup::Profile, SettingsGroup::Treatment, SettingsGroup::WhiteBalance, @@ -55,6 +56,7 @@ impl SettingsGroup { SettingsGroup::Spots, SettingsGroup::RedEye, SettingsGroup::LensBlur, + SettingsGroup::Calibration, ]; /// Groups included by default when copying (Lightroom excludes crop, masks and spots by default). @@ -86,6 +88,7 @@ impl SettingsGroup { SettingsGroup::Spots => "Remove", SettingsGroup::RedEye => "Red Eye", SettingsGroup::LensBlur => "Lens Blur", + SettingsGroup::Calibration => "Calibration", } } @@ -98,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"], @@ -111,6 +114,7 @@ impl SettingsGroup { SettingsGroup::Spots => &["spots"], SettingsGroup::RedEye => &["red_eye"], SettingsGroup::LensBlur => &["lens_blur"], + SettingsGroup::Calibration => &["calibration"], } } } diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index fa257c367..080a574e6 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, @@ -32,6 +34,8 @@ pub struct DevelopSettings { pub red_eye: Vec, pub lens_blur: LensBlur, pub enhance: Enhance, + /// Camera calibration: shadows tint and primary hue/saturation (applied before tone mapping). + pub calibration: Calibration, /// Section on/off toggles (the "eye" buttons on panel headers): section id → enabled. pub disabled_sections: Vec, } @@ -47,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(), @@ -62,6 +67,7 @@ impl Default for DevelopSettings { red_eye: Vec::new(), lens_blur: LensBlur::default(), enhance: Enhance::default(), + calibration: Calibration::default(), disabled_sections: Vec::new(), } } @@ -188,6 +194,9 @@ pub struct ToneCurve { pub red: Vec, pub green: Vec, pub blue: Vec, + /// Refine Saturation 0..100: 100 keeps the saturation a curve produces, lower values pull it + /// back towards the saturation before the curve (strong contrast curves oversaturate). + pub refine_saturation: f64, } impl Default for ToneCurve { @@ -204,6 +213,7 @@ impl Default for ToneCurve { red: Vec::new(), green: Vec::new(), blue: Vec::new(), + refine_saturation: 100.0, } } } @@ -256,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)] @@ -319,6 +386,31 @@ impl ColorGrading { } } +/// Camera calibration (Lightroom Classic's Calibration panel): a green/magenta tint of the shadows, +/// and a hue rotation / saturation scale of each working-space primary (−100..100), i.e. a +/// white-preserving 3×3 matrix applied in scene-linear light before tone mapping. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Calibration { + pub shadows_tint: f64, + pub red_hue: f64, + pub red_sat: f64, + pub green_hue: f64, + pub green_sat: f64, + pub blue_hue: f64, + pub blue_sat: f64, +} + +impl Calibration { + /// (hue, saturation) of the red, green and blue primaries. + pub fn primaries(&self) -> [(f64, f64); 3] { + [(self.red_hue, self.red_sat), (self.green_hue, self.green_sat), (self.blue_hue, self.blue_sat)] + } + pub fn is_neutral(&self) -> bool { + *self == Calibration::default() + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Effects { @@ -750,14 +842,27 @@ impl Default for Spot { } } +/// A red eye / pet eye correction: the user's ellipse (centre normalized, radii as fractions of +/// the long edge); the pupil inside it is found automatically. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct RedEye { pub center: Point, pub rx: f64, pub ry: f64, + /// 0..100 (50 = the detected pupil). pub pupil_size: f64, + /// 0..100. pub darken: f64, pub pet: bool, + /// Pet eye catchlight: offset of its centre from the pupil centre, in pupil radii. + #[serde(default)] + pub catchlight: Option, +} + +impl Default for RedEye { + fn default() -> Self { + Self { center: Point::new(0.5, 0.5), rx: 0.02, ry: 0.015, pupil_size: 50.0, darken: 50.0, pet: false, catchlight: None } + } } #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index c1cb9040f..ac6a9a60d 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -158,7 +158,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..65a9a709e --- /dev/null +++ b/crates/engine/src/cmd/color.rs @@ -0,0 +1,209 @@ +//! Commands that sample the developed image: Point Color samples and the targeted adjustment tool +//! (`develop.targeted`: a vertical drag on the photo raises/lowers the tone-curve region or the +//! colour-mixer bands under the pointer; the UI sends one call per drag step inside an interaction). +//! +//! 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::spline::MonotoneCurve; +use lightcraft_color::transfer::srgb_to_linear; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_develop::{DevelopSettings, MAX_POINT_COLORS, MIXER_BANDS, PointColor, ToneCurve, Treatment, controls}; +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(); +} + +/// The targeted adjustment tool: the controls (and their new values) a vertical drag of `delta` +/// at `(x, y)` changes, computed on `d` (applied by the caller). +fn targeted(s: &mut Session, p: &Value) -> Result { + let c = "develop.targeted"; + let (x, y, delta) = (f64_req(p, "x", c)?, f64_req(p, "y", c)?, f64_req(p, "delta", c)?); + let target = p.get("target").and_then(Value::as_str).unwrap_or("curve").to_string(); + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + let mut changed = serde_json::Map::new(); + fn nudge(changed: &mut serde_json::Map, d: &mut DevelopSettings, ctl: &str, by: f64) { + let v = controls::get(d, ctl).unwrap_or(0.0) + by; + controls::set(d, ctl, v); + changed.insert(ctl.to_string(), json!(controls::get(d, ctl))); + } + let label; + match target.as_str() { + "curve" => { + // the encoded value the tone curve sees there (curves and grain neutralized) + let e = probe(s, c, x, y, |d| { + d.curve = ToneCurve { refine_saturation: d.curve.refine_saturation, ..Default::default() }; + d.grain.amount = 0.0; + })?; + let channel = p.get("channel").and_then(Value::as_str).unwrap_or("parametric"); + label = "Tone Curve"; + if channel == "parametric" { + let v = (0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]) as f64 * 100.0; + let cv = &d.curve; + let region = if v < cv.split_shadows { + "curve.shadows" + } else if v < cv.split_mid { + "curve.darks" + } else if v < cv.split_highlights { + "curve.lights" + } else { + "curve.highlights" + }; + nudge(&mut changed, &mut d, region, delta); + } else { + // point curve: move the point at the sampled input up/down (or add one there) + let (pts, v) = match channel { + "red" => (&mut d.curve.red, e[0]), + "green" => (&mut d.curve.green, e[1]), + "blue" => (&mut d.curve.blue, e[2]), + _ => (&mut d.curve.master, 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]), + }; + let v = v as f64; + if pts.is_empty() { + *pts = vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)]; + } + let curve = MonotoneCurve::new(&pts.iter().map(|q| (q.x, q.y)).collect::>()); + let dy = delta / 255.0; + match pts.iter().position(|q| (q.x - v).abs() < 0.03) { + Some(i) => pts[i].y = (pts[i].y + dy).clamp(0.0, 1.0), + None => { + let i = pts.iter().position(|q| q.x > v).unwrap_or(pts.len()); + pts.insert(i, Point::new(v, (curve.eval(v) + dy).clamp(0.0, 1.0))); + } + } + changed.insert(format!("curve.{channel}"), json!(pts.iter().map(|q| [q.x, q.y]).collect::>())); + } + } + "hue" | "sat" | "lum" => { + // the hue the colour mixer sees there (the mixer and everything after it neutralized) + let e = probe(s, c, x, y, |d| { + d.mixer = Default::default(); + d.bw_mix = Default::default(); + after_mixer_neutral(d); + })?; + let [_, _, h] = encoded_to_oklch(e); + let w = lightcraft_pipeline::colorops::band_weights(h); + let top = w.iter().cloned().fold(0.0f32, f32::max).max(1e-6); + let bw = d.treatment == Treatment::Bw || d.profile.id == "lc.mono"; + label = if bw { "B&W Mix" } else { "Color Mixer" }; + for (i, band) in MIXER_BANDS.iter().enumerate() { + let k = (w[i] / top) as f64; + if k < 0.05 { + continue; + } + let ctl = if bw { format!("bw.{band}") } else { format!("mixer.{band}.{target}") }; + nudge(&mut changed, &mut d, &ctl, delta * k); + } + } + _ => return Err(bad(c, "target must be curve, hue, sat or lum")), + } + s.set_develop(id, d, label)?; + Ok(Value::Object(changed)) +} + +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!( + "develop.targeted", + "Targeted Adjustment", + [], + None, + "{target: curve|hue|sat|lum, x, y (normalized image coords), delta (slider units; point curves: output levels 0..255), channel?: parametric|master|red|green|blue (curve)} — adjusts the curve region / colour-mixer bands under the point; returns the changed controls", + has_active, + targeted + ), + 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/masks.rs b/crates/engine/src/cmd/masks.rs index 70e1f759a..0db1c58c4 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -1,6 +1,6 @@ //! Masking and Remove (spot) commands on the active photo. -use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, Spot, SpotMode}; +use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode}; use lightcraft_geom::Point; use serde_json::{Value, json}; @@ -292,6 +292,69 @@ pub fn specs() -> Vec { s.set_develop(id, dd, "Delete Spot")?; ok() }), + // ---- Red eye / pet eye + cmd!( + "redeye.add", + "Add Red Eye Correction", + [], + None, + "{center: [x,y] normalized, rx, ry: radii as fractions of the long edge, pet?: bool, pupilSize?: 0..100, darken?: 0..100} — the pupil inside is found automatically; returns {index}", + has_active, + |s, p| { + let c = "redeye.add"; + let center = point(p, "center").ok_or_else(|| bad(c, "missing center"))?; + let d = RedEye::default(); + let eye = RedEye { + center, + rx: f64_or(p, "rx", d.rx).clamp(1e-4, 0.5), + ry: f64_or(p, "ry", d.ry).clamp(1e-4, 0.5), + pupil_size: f64_or(p, "pupilSize", d.pupil_size).clamp(0.0, 100.0), + darken: f64_or(p, "darken", d.darken).clamp(0.0, 100.0), + pet: bool_or(p, "pet", false), + catchlight: None, + }; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + dd.red_eye.push(eye); + let n = dd.red_eye.len(); + s.set_develop(id, dd, if eye.pet { "Pet Eye" } else { "Red Eye" })?; + Ok(json!({"index": n - 1})) + } + ), + cmd!( + "redeye.catchlight", + "Pet Eye Catchlight", + [], + None, + "{index, on?: bool (default true), offset?: [dx, dy] from the pupil centre in pupil radii (default [-0.35, -0.35])}", + has_active, + |s, p| { + let c = "redeye.catchlight"; + let i = super::f64_req(p, "index", c)? as usize; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + let eye = dd.red_eye.get_mut(i).ok_or_else(|| bad(c, "no such eye"))?; + if !eye.pet { + return Err(bad(c, "catchlights are for pet eyes")); + } + let off = point(p, "offset").or(eye.catchlight).unwrap_or(Point::new(-0.35, -0.35)); + eye.catchlight = bool_or(p, "on", true).then(|| Point::new(off.x.clamp(-1.0, 1.0), off.y.clamp(-1.0, 1.0))); + s.set_develop(id, dd, "Catchlight")?; + ok() + } + ), + cmd!("redeye.delete", "Delete Red Eye Correction", [], None, "{index}", has_active, |s, p| { + let c = "redeye.delete"; + let i = super::f64_req(p, "index", c)? as usize; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + if i >= dd.red_eye.len() { + return Err(bad(c, "no such eye")); + } + dd.red_eye.remove(i); + s.set_develop(id, dd, "Delete Red Eye")?; + ok() + }), ] } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 623313490..5d48e7d88 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; @@ -102,6 +103,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(merge::specs()); v.extend(query::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/crs.rs b/crates/engine/src/crs.rs index d35641e7e..6dfaaa93c 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -190,6 +190,7 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { n(o, "ParametricShadowSplit", "curve.split_shadows"); n(o, "ParametricMidtoneSplit", "curve.split_mid"); n(o, "ParametricHighlightSplit", "curve.split_highlights"); + n(o, "CurveRefineSaturation", "curve.refine_saturation"); for (crs, ch) in [("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] { @@ -261,6 +262,15 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { n(o, "DefringeGreenHueLo", "optics.defringe_green_hue_lo"); n(o, "DefringeGreenHueHi", "optics.defringe_green_hue_hi"); + // ---- Calibration + n(o, "ShadowTint", "calibration.shadows_tint"); + n(o, "RedHue", "calibration.red_hue"); + n(o, "RedSaturation", "calibration.red_sat"); + n(o, "GreenHue", "calibration.green_hue"); + n(o, "GreenSaturation", "calibration.green_sat"); + n(o, "BlueHue", "calibration.blue_hue"); + n(o, "BlueSaturation", "calibration.blue_sat"); + // ---- Geometry n(o, "PerspectiveVertical", "geometry.vertical"); n(o, "PerspectiveHorizontal", "geometry.horizontal"); @@ -330,13 +340,15 @@ mod tests { crs:Whites2012="+9" crs:Blacks2012="-17" crs:Texture="+6" crs:Clarity2012="+11" crs:Dehaze="+4" crs:Vibrance="+19" crs:Saturation="-3" crs:HueAdjustmentOrange="-7" crs:SaturationAdjustmentBlue="-22" crs:LuminanceAdjustmentAqua="+13" crs:ParametricShadows="+5" crs:ParametricHighlights="-8" - crs:ParametricMidtoneSplit="55" crs:SplitToningShadowHue="210" crs:SplitToningShadowSaturation="14" + crs:ParametricMidtoneSplit="55" crs:CurveRefineSaturation="40" crs:SplitToningShadowHue="210" crs:SplitToningShadowSaturation="14" crs:SplitToningHighlightHue="40" crs:SplitToningHighlightSaturation="9" crs:SplitToningBalance="+20" crs:ColorGradeMidtoneHue="120" crs:ColorGradeMidtoneSat="6" crs:ColorGradeBlending="70" crs:Sharpness="55" crs:SharpenRadius="+1.2" crs:SharpenDetail="30" crs:SharpenEdgeMasking="12" crs:LuminanceSmoothing="18" crs:ColorNoiseReduction="25" crs:PostCropVignetteAmount="-21" crs:PostCropVignetteStyle="2" crs:GrainAmount="15" crs:GrainSize="30" crs:GrainFrequency="60" crs:PerspectiveUpright="2" crs:PerspectiveVertical="-10" crs:AutoLateralCA="1" crs:ConvertToGrayscale="False" + crs:ShadowTint="-7" crs:RedHue="+12" crs:RedSaturation="+20" crs:GreenHue="-5" crs:GreenSaturation="+3" + crs:BlueHue="-15" crs:BlueSaturation="+25" crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.05" crs:CropBottom="0.9" crs:CropRight="0.8" crs:CropAngle="1.5"> 0, 064, 52192, 205255, 255 @@ -369,6 +381,7 @@ mod tests { assert_eq!(s.mixer.aqua.lum, 13.0); assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0)); assert_eq!(s.curve.master.len(), 4); + assert_eq!(s.curve.refine_saturation, 40.0); assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); @@ -384,6 +397,11 @@ mod tests { assert_eq!(s.geometry.vertical, -10.0); assert!(s.optics.remove_ca); assert_eq!(s.treatment, lightcraft_develop::Treatment::Color); + let cal = s.calibration; + assert_eq!( + (cal.shadows_tint, cal.red_hue, cal.red_sat, cal.green_hue, cal.green_sat, cal.blue_hue, cal.blue_sat), + (-7.0, 12.0, 20.0, -5.0, 3.0, -15.0, 25.0) + ); let c = s.crop.geometry; assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5)); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 89b28fef9..4e39d872d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -321,6 +321,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 91e13b3aa..f0b579c03 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -246,6 +246,21 @@ 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); + } + // a diagnostic view (visualized range / spots) must never become the photo's cached preview + if overlay != lightcraft_pipeline::Overlay::None { + self.view_cache = None; + } + self + } + /// Reuse `stages` across this view's renders. pub fn with_stages(mut self, stages: Arc) -> Self { self.stages = Some(stages); @@ -408,7 +423,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/merge.rs b/crates/engine/src/merge.rs index 608928edc..f12ff9408 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -260,7 +260,7 @@ fn render_preview( if let Some(c) = crop { s.crop.geometry.rect = lightcraft_geom::Rect::from_xywh(c[0], c[1], c[2], c[3]); } - let req = RenderRequest { max_w: edge, max_h: edge, quality: Quality::Full, apply_crop: false }; + let req = RenderRequest { quality: Quality::Full, apply_crop: false, ..RenderRequest::fit(edge, edge) }; let mut img = lightcraft_pipeline::render(&src, &info, &s, &req).image; if let Some(g) = ghost { let (w, h) = (img.width, img.height); diff --git a/crates/engine/src/tests_color.rs b/crates/engine/src/tests_color.rs new file mode 100644 index 000000000..e765e0671 --- /dev/null +++ b/crates/engine/src/tests_color.rs @@ -0,0 +1,123 @@ +//! 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); +} + +#[test] +fn red_eye_commands_and_controls() { + let mut s = demo(); + let r = s.execute("redeye.add", &json!({"center": [0.3, 0.4], "rx": 0.03, "ry": 0.02})).unwrap(); + assert_eq!(r["index"], 0); + s.execute("redeye.add", &json!({"center": [0.6, 0.4], "rx": 0.03, "ry": 0.02, "darken": 80})).unwrap(); + let d = active_dev(&s); + assert_eq!(d.red_eye.len(), 2); + assert_eq!((d.red_eye[0].pupil_size, d.red_eye[0].darken, d.red_eye[1].darken), (50.0, 50.0, 80.0)); + // per-eye sliders are develop controls + s.execute("develop.set", &json!({"values": {"redEye.1.pupilSize": 70, "redEye.0.darken": 140}})).unwrap(); + let d = active_dev(&s); + assert_eq!((d.red_eye[1].pupil_size, d.red_eye[0].darken), (70.0, 100.0)); + let ctl = s.execute("develop.controls", &json!({"section": "redEye"})).unwrap(); + assert_eq!(ctl.as_array().unwrap().len(), 4, "{ctl}"); + // the render runs with eyes (detection falls back gracefully on a photo without red pupils) + let id = s.active().unwrap(); + assert!(s.render_now(id, 200, 200).is_ok()); + s.execute("redeye.delete", &json!({"index": 0})).unwrap(); + assert_eq!(active_dev(&s).red_eye.len(), 1); + assert!(s.execute("redeye.delete", &json!({"index": 5})).is_err()); + assert!(s.execute("redeye.add", &json!({"rx": 0.03})).is_err()); + let d = active_dev(&s); + assert_eq!(lightcraft_develop::DevelopSettings::from_json(&d.to_json()).unwrap(), d); +} + +#[test] +fn pet_eye_catchlight_command() { + let mut s = demo(); + s.execute("redeye.add", &json!({"center": [0.3, 0.4], "rx": 0.03, "ry": 0.03})).unwrap(); + s.execute("redeye.add", &json!({"center": [0.6, 0.4], "rx": 0.03, "ry": 0.03, "pet": true})).unwrap(); + assert!(s.execute("redeye.catchlight", &json!({"index": 0})).is_err(), "red eyes have no catchlight"); + s.execute("redeye.catchlight", &json!({"index": 1})).unwrap(); + assert_eq!(active_dev(&s).red_eye[1].catchlight, Some(lightcraft_geom::Point::new(-0.35, -0.35))); + s.execute("redeye.catchlight", &json!({"index": 1, "offset": [0.2, -3.0]})).unwrap(); + assert_eq!(active_dev(&s).red_eye[1].catchlight, Some(lightcraft_geom::Point::new(0.2, -1.0))); + s.execute("redeye.catchlight", &json!({"index": 1, "on": false})).unwrap(); + assert_eq!(active_dev(&s).red_eye[1].catchlight, None); +} + +#[test] +fn targeted_adjustment_on_curve_and_mixer() { + let mut s = demo(); + // the bright sky lies in the upper regions of the parametric curve + let r = s.execute("develop.targeted", &json!({"target": "curve", "x": 0.5, "y": 0.35, "delta": 20})).unwrap(); + let (k, v) = r.as_object().unwrap().iter().next().map(|(k, v)| (k.clone(), v.clone())).unwrap(); + assert!(k == "curve.lights" || k == "curve.highlights", "{r}"); + assert_eq!(v, 20.0); + assert_eq!(lightcraft_develop::controls::get(&active_dev(&s), &k), Some(20.0)); + // the dark trees in the lower ones + let r = s.execute("develop.targeted", &json!({"target": "curve", "x": 0.4, "y": 0.62, "delta": -10})).unwrap(); + assert!(r.get("curve.shadows").is_some() || r.get("curve.darks").is_some(), "{r}"); + // point curve: a point appears at the sampled input, raised by delta levels + s.execute("develop.targeted", &json!({"target": "curve", "channel": "master", "x": 0.5, "y": 0.35, "delta": 12.75})).unwrap(); + let m = active_dev(&s).curve.master; + assert_eq!(m.len(), 3); + assert!((m[1].y - m[1].x - 0.05).abs() < 1e-6, "{m:?}"); + // a second step moves the same point + s.execute("develop.targeted", &json!({"target": "curve", "channel": "master", "x": 0.5, "y": 0.35, "delta": 12.75})).unwrap(); + assert_eq!(active_dev(&s).curve.master.len(), 3); + // colour mixer: the purple sky's bands lose saturation, the dominant one by the full delta + let r = s.execute("develop.targeted", &json!({"target": "sat", "x": 0.5, "y": 0.1, "delta": -30})).unwrap(); + let o = r.as_object().unwrap(); + assert!(!o.is_empty() && o.keys().all(|k| k.starts_with("mixer.") && k.ends_with(".sat")), "{r}"); + assert!(o.values().any(|v| v.as_f64() == Some(-30.0)), "{r}"); + assert!(o.values().all(|v| v.as_f64().unwrap() < 0.0)); + assert!(s.execute("develop.targeted", &json!({"target": "nope", "x": 0.5, "y": 0.5, "delta": 1})).is_err()); +} diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index b71471f60..07072d2c7 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -37,7 +37,7 @@ const MODULES: &[Module] = &[ Module { src: include_str!("wgsl/map.wgsl"), bindings: &[("a", false, "f32"), ("b", false, "f32"), ("c", false, "f32"), ("dst", true, "f32")], - entries: &["log_lum_k", "dark_k", "guided_pre", "guided_ab", "guided_apply", "wb_k", "nr_lum", "chroma_k", "nr_col", "subsample"], + entries: &["log_lum_k", "dark_k", "guided_pre", "guided_ab", "guided_apply", "wb_k", "nr_lum", "chroma_k", "nr_col", "subsample", "redeye_k"], }, Module { src: include_str!("wgsl/mask.wgsl"), @@ -118,6 +118,9 @@ 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 EYE_WORDS: u32 = {}u;\n", lightcraft_pipeline::redeye::EYE_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 64d722d7e..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), @@ -59,6 +62,10 @@ const FIELDS: &[(&str, usize)] = &[ ("GRAIN_ROUGH", 1), ("GRAIN_SEED", 1), ("GRAIN_AFF", 6), + ("REFINE_SAT", 1), + ("CALIB", 1), + ("CALIB_M", 9), + ("SHADOW_TINT", 1), ]; /// `(name, index)` of every field (for the WGSL constants). @@ -144,6 +151,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.u("SRGB_OFF", srgb_off as u32); p.u("CURVE_OFF", curve_off as u32); p.b("CURVES", fp.curves.is_some()); + p.f("REFINE_SAT", fp.refine_sat); p.f("GAIN", fp.gain); p.f("EV", fp.ev); p.f("AIR", fp.air); @@ -167,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 { @@ -196,6 +207,11 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr }, ); } + if let Some(m) = &fp.calib { + p.b("CALIB", true); + p.fs("CALIB_M", m.as_flattened()); + } + p.f("SHADOW_TINT", fp.shadow_tint); if let Some((amt, cell, rough, seed)) = fp.grain { p.b("GRAIN", true); p.f("GRAIN_AMT", amt); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 72c51f5fb..0174da93e 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 }) } @@ -508,7 +510,15 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho p.extend(m.unwrap_or_default().iter().flatten().map(|v| v.to_bits())); let out = cx.gpu.buffer(n * 3); map(cx, "wb_k", n, &p, [Some(sampled), None, None], &out); - out + if plan.eyes.is_empty() { + out + } else { + let mut p = vec![w as u32, plan.eyes.len() as u32]; + p.extend(plan.eyes.iter().flat_map(|e| e.words()).map(f32::to_bits)); + let eyed = cx.gpu.buffer(n * 3); + map(cx, "redeye_k", n, &p, [Some(&out), None, None], &eyed); + eyed + } }; denoise(cx, img, plan) } diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index 0548ab5db..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); @@ -117,6 +156,42 @@ fn color_ops(rgb: vec3, local_sat: f32, local_hue: f32) -> vec3 { return oklab_inv(lab); } +// `colorops::calibrate`: primaries matrix, then the shadows tint (luminance kept). +fn calibrate(c0: vec3) -> vec3 { + var c = c0; + if (pu(F_CALIB) != 0u) { + let m = array, 3>( + vec3(pf(F_CALIB_M), pf(F_CALIB_M + 1u), pf(F_CALIB_M + 2u)), + vec3(pf(F_CALIB_M + 3u), pf(F_CALIB_M + 4u), pf(F_CALIB_M + 5u)), + vec3(pf(F_CALIB_M + 6u), pf(F_CALIB_M + 7u), pf(F_CALIB_M + 8u)), + ); + c = max(mul3(m, c), vec3(0.0)); + } + let st = pf(F_SHADOW_TINT); + if (st != 0.0) { + let y0 = lum2020(c); + let w = 1.0 - sstep(-5.0, -0.5, log2(max(y0, 1e-7) / 0.18)); + c.y *= max(1.0 - SHADOW_TINT_K * st * w, 0.0); + let y1 = max(lum2020(c), 1e-9); + c = c * y0 / y1; + } + return c; +} + +// `finish::refine_saturation`. +fn refine_saturation(before: vec3, after: vec3, refine: f32) -> vec3 { + let lw = vec3(0.2126, 0.7152, 0.0722); + let y0 = dot(before, lw); + let y1 = dot(after, lw); + let s0 = (max(before.x, max(before.y, before.z)) - min(before.x, min(before.y, before.z))) / max(y0, 1e-4); + let s1 = (max(after.x, max(after.y, after.z)) - min(after.x, min(after.y, after.z))) / max(y1, 1e-4); + if (s1 <= 1e-6 || s0 <= 1e-6) { + return after; + } + let k = clamp(pow(s0 / s1, 1.0 - refine), 0.0, 4.0); + return y1 + (after - y1) * k; +} + fn ghash(i: i32, j: i32, seed: u32) -> f32 { var v = (bitcast(i) * GRAIN_H0) ^ (bitcast(j) * GRAIN_H1) ^ (seed * GRAIN_H2); v ^= v >> 13u; @@ -263,6 +338,11 @@ fn main(@builtin(global_invocation_id) gid: vec3) { c = c * exp2(delta); } + // --- calibration (scene linear, before the tone map) + if (pu(F_CALIB) != 0u || pf(F_SHADOW_TINT) != 0.0) { + c = calibrate(c); + } + // --- tone map on luminance, highlight desaturation let yl = lum2020(c); let o = tone_apply(yl); @@ -338,7 +418,12 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // --- encode, curves, grain var e = vec3(encode_srgb(r.x), encode_srgb(r.y), encode_srgb(r.z)); if (pu(F_CURVES) != 0u) { + let e0 = e; e = vec3(curve(0u, e.x), curve(1u, e.y), curve(2u, e.z)); + let rs = pf(F_REFINE_SAT); + if (rs < 1.0) { + e = refine_saturation(e0, e, rs); + } } if (pu(F_GRAIN) != 0u) { let px = f32(x) + 0.5; diff --git a/crates/gpu/src/wgsl/map.wgsl b/crates/gpu/src/wgsl/map.wgsl index c0a4419a9..9a54e7f66 100644 --- a/crates/gpu/src/wgsl/map.wgsl +++ b/crates/gpu/src/wgsl/map.wgsl @@ -135,3 +135,49 @@ fn subsample(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroup } dst[i] = a[i * pu(1u)]; } + +// `redeye::eye_pixel` (a: image). P[1] = width, P[2] = eye count, then EYE_WORDS per eye: +// cx, cy, r, darken, pet, has catchlight, catchlight x, y, r (output pixels). +@compute @workgroup_size(256) +fn redeye_k(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { + let i = lin_index(g, nw); + if (i >= pu(0u)) { + return; + } + let w = pu(1u); + let x = f32(i % w) + 0.5; + let y = f32(i / w) + 0.5; + var c = rgb_a(i); + for (var e = 0u; e < pu(2u); e++) { + let o = 3u + e * EYE_WORDS; + let ex = x - pf(o); + let ey = y - pf(o + 1u); + let d = sqrt(ex * ex + ey * ey) / pf(o + 2u); + var m = 1.0 - sstep(1.0, 1.35, d); + if (pf(o + 4u) != 0.0) { + m = 1.0 - sstep(0.95, 1.2, d); + } + let darken = pf(o + 3u); + if (m > 0.0) { + if (pf(o + 4u) != 0.0) { + let k = min(lum2020(c), 0.04) * (1.0 - 0.85 * darken); + c = c + (vec3(k) - c) * m; + } else { + let red = (c.x - max(c.y, c.z)) / max(c.x, 1e-6); + let a = m * sstep(0.25, 0.5, red); + let n = 0.5 * (c.y + c.z) * (1.0 - 0.9 * darken); + c = c + (vec3(n) - c) * a; + } + } + if (pf(o + 5u) != 0.0) { + let lx = x - pf(o + 6u); + let ly = y - pf(o + 7u); + let dc = sqrt(lx * lx + ly * ly) / pf(o + 8u); + let mc = 1.0 - sstep(0.5, 1.0, dc); + if (mc > 0.0) { + c = c + (vec3(0.9) - c) * mc; + } + } + } + put_rgb(i, c); +} diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 474d34043..d6fdab543 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; @@ -102,6 +119,11 @@ fn cases() -> Vec<(&'static str, Edit)> { s.curve.master = vec![Point::new(0.0, 0.05), Point::new(0.5, 0.55), Point::new(1.0, 0.95)]; s.curve.blue = vec![Point::new(0.0, 0.0), Point::new(0.5, 0.45), Point::new(1.0, 1.0)]; }), + ("tone curves + refine saturation", |s| { + s.curve.master = vec![Point::new(0.0, 0.0), Point::new(0.25, 0.15), Point::new(0.75, 0.88), Point::new(1.0, 1.0)]; + s.curve.lights = 30.0; + s.curve.refine_saturation = 20.0; + }), ("vignette (highlight)", |s| { s.vignette.amount = -60.0; s.vignette.highlights = 50.0; @@ -253,6 +275,14 @@ fn cases() -> Vec<(&'static str, Edit)> { }, ]; }), + ("calibration", |s| { + s.calibration.shadows_tint = 40.0; + s.calibration.red_hue = 50.0; + s.calibration.red_sat = -30.0; + s.calibration.green_hue = -40.0; + s.calibration.blue_sat = 60.0; + s.calibration.blue_hue = 25.0; + }), ("spots + defringe (cpu stage)", |s| { s.spots = vec![Spot { points: vec![Point::new(0.3, 0.3)], size: 0.03, source_offset: Some(Point::new(0.1, 0.0)), ..Default::default() }]; s.optics.defringe_purple_amount = 5.0; @@ -357,3 +387,78 @@ 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); + // Visualize Spots is a binary threshold of a high-pass: a 1-LSB difference near the threshold + // flips a pixel, so compare the share of differing pixels instead of LSBs. + let s = DevelopSettings::default(); + for t in [20u8, 50, 90] { + let req = RenderRequest { overlay: lightcraft_pipeline::Overlay::Spots(t), ..RenderRequest::fit(640, 640) }; + let cpu = render(&src, &raw, &s, &req).image; + let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image; + let differ = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a != b).count() as f64 / cpu.data.len() as f64; + let white = cpu.data.iter().filter(|p| p[0] == 255).count() as f64 / cpu.data.len() as f64; + eprintln!("visualize spots t={t:<3} white {:.3}% differing {:.4}%", white * 100.0, differ * 100.0); + assert!(differ < 0.002, "spots t={t}: {differ}"); + } +} + +#[test] +fn red_eye_matches() { + if !gpu() { + return; + } + // A face-like patch with a red pupil (left) and a glowing pet pupil (right). + let src = Arc::new(Rgb32f::from_fn(800, 400, |x, y| { + let d = |cx: f32, cy: f32| ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt(); + if d(250.0, 200.0) < 20.0 { + [0.55, 0.05, 0.04] + } else if d(560.0, 190.0) < 24.0 { + [0.5, 0.7, 0.3] + } else if d(250.0, 200.0) < 40.0 || d(560.0, 190.0) < 45.0 { + [0.12, 0.1, 0.08] + } else { + [0.45, 0.3, 0.22] + } + })); + let info = SourceInfo::default(); + let eyes = vec![ + lightcraft_develop::RedEye { center: Point::new(0.31, 0.5), rx: 0.06, ry: 0.05, darken: 70.0, ..Default::default() }, + lightcraft_develop::RedEye { + center: Point::new(0.7, 0.48), + rx: 0.06, + ry: 0.06, + pupil_size: 60.0, + pet: true, + catchlight: Some(Point::new(-0.35, -0.35)), + ..Default::default() + }, + ]; + let s = DevelopSettings { red_eye: eyes.clone(), ..Default::default() }; + check("red + pet eye", &src, &info, &s, &RenderRequest::fit(800, 800)); + let g = lightcraft_gpu::render(&src, &info, &s, &RenderRequest::fit(800, 800), None).expect("gpu").image; + let (red, pet) = (g.data[200 * 800 + 250], g.data[190 * 800 + 560]); + assert!(red[0] < 80 && red[0].abs_diff(red[1]) < 10, "red pupil fixed on the GPU: {red:?}"); + assert!(pet[1] < 80 && pet[0].abs_diff(pet[1]) < 5, "pet pupil darkened on the GPU: {pet:?}"); + check("red + pet eye (small)", &src, &info, &s, &RenderRequest::fit(300, 300)); + // with a spot the scene-linear stage runs on the CPU (eyes included) + let s = DevelopSettings { + red_eye: eyes, + spots: vec![Spot { points: vec![Point::new(0.1, 0.2)], size: 0.02, source_offset: Some(Point::new(0.05, 0.0)), ..Default::default() }], + ..Default::default() + }; + check("red eye + spot (cpu stage)", &src, &info, &s, &RenderRequest::fit(600, 600)); +} diff --git a/crates/pipeline/src/colorops.rs b/crates/pipeline/src/colorops.rs index 5d378d19a..987afa285 100644 --- a/crates/pipeline/src/colorops.rs +++ b/crates/pipeline/src/colorops.rs @@ -1,12 +1,12 @@ //! Colour tools in OkLCh on display-linear Rec.2020 values: vibrance, saturation, the 8-band colour -//! mixer, B&W mix, and 3-way colour grading. +//! mixer, B&W mix, and 3-way colour grading; plus the camera-calibration matrix (scene linear). use std::f32::consts::{PI, TAU}; 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::{REC2020, SRGB}; -use lightcraft_develop::{DevelopSettings, MIXER_HUES}; +use lightcraft_color::{Mat3, REC2020, SRGB}; +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 }; @@ -158,6 +240,57 @@ impl ColorOps { } } +/// Hue rotation (OkLCh radians) of a calibration primary at ±100. +pub const CALIB_HUE: f32 = 0.5; +/// Chroma scale of a calibration primary at ±100 (`1 ± CALIB_SAT`). +pub const CALIB_SAT: f32 = 0.6; + +/// The calibration panel's primaries as a white-preserving 3×3 matrix on linear Rec.2020 (row-major): +/// each primary is rotated in OkLCh hue and scaled in chroma at constant OkLab lightness, then the +/// columns are rescaled so that neutral (1, 1, 1) maps to itself. `None` when neutral. +pub fn calibration_matrix(c: &Calibration) -> Option<[[f32; 3]; 3]> { + let prim = c.primaries(); + if prim.iter().all(|(h, s)| *h == 0.0 && *s == 0.0) { + return None; + } + let mut p = [[0.0f64; 3]; 3]; + for (i, (hue, sat)) in prim.iter().enumerate() { + let mut e = [0.0f32; 3]; + e[i] = 1.0; + let [l, ch, h] = lab_to_lch(oklab_from_2020(e)); + let h2 = h + (hue.clamp(-100.0, 100.0) / 100.0) as f32 * CALIB_HUE; + let c2 = ch * (1.0 + (sat.clamp(-100.0, 100.0) / 100.0) as f32 * CALIB_SAT); + let q = oklab_to_2020(lch_to_lab([l, c2, h2])); + for (r, row) in p.iter_mut().enumerate() { + row[i] = q[r] as f64; + } + } + let m = Mat3(p); + let k = m.inverse()?.apply([1.0, 1.0, 1.0]); + Some(std::array::from_fn(|r| std::array::from_fn(|col| (p[r][col] * k[col]) as f32))) +} + +/// Shadows-tint strength at ±100: the green channel's relative change in deep shadows. +pub const SHADOW_TINT: f32 = 0.3; + +/// Calibration in scene-linear light (before tone mapping): the primaries matrix, then the shadows +/// tint (green ↔ magenta, weighted towards dark tones, luminance kept). +#[inline] +pub fn calibrate(c: [f32; 3], m: Option<&[[f32; 3]; 3]>, shadow_tint: f32) -> [f32; 3] { + let mut c = c; + if let Some(m) = m { + c = std::array::from_fn(|r| (m[r][0] * c[0] + m[r][1] * c[1] + m[r][2] * c[2]).max(0.0)); + } + if shadow_tint != 0.0 { + let y0 = lightcraft_color::luminance_2020(c); + let w = 1.0 - smooth(-5.0, -0.5, (y0.max(1e-7) / 0.18).log2()); + c[1] *= (1.0 - SHADOW_TINT * shadow_tint * w).max(0.0); + let y1 = lightcraft_color::luminance_2020(c).max(1e-9); + c = c.map(|v| v * y0 / y1); + } + c +} + #[inline] fn smooth(e0: f32, e1: f32, x: f32) -> f32 { let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); @@ -219,6 +352,73 @@ mod tests { assert!((cr - cr0).abs() < 0.01); } + #[test] + fn calibration_keeps_white_and_moves_primaries() { + assert!(calibration_matrix(&Calibration::default()).is_none()); + let cal = Calibration { red_hue: 60.0, blue_sat: -50.0, ..Default::default() }; + let m = calibration_matrix(&cal).unwrap(); + let grey = calibrate([0.3, 0.3, 0.3], Some(&m), 0.0); + assert!(grey.iter().all(|v| (v - 0.3).abs() < 1e-4), "{grey:?}"); + // red rotates in hue, blue loses chroma + let red = [0.4, 0.05, 0.03]; + let h0 = lab_to_lch(oklab_from_2020(red))[2]; + let h1 = lab_to_lch(oklab_from_2020(calibrate(red, Some(&m), 0.0)))[2]; + assert!(h1 - h0 > 0.05, "{h0} -> {h1}"); + let blue = [0.03, 0.05, 0.4]; + let c0 = lab_to_lch(oklab_from_2020(blue))[1]; + let c1 = lab_to_lch(oklab_from_2020(calibrate(blue, Some(&m), 0.0)))[1]; + assert!(c1 < c0 * 0.97, "{c0} -> {c1}"); + } + + #[test] + fn shadow_tint_targets_shadows() { + let dark = calibrate([0.01, 0.01, 0.01], None, 1.0); + let bright = calibrate([0.8, 0.8, 0.8], None, 1.0); + assert!(dark[1] < dark[0] * 0.85, "magenta shadows: {dark:?}"); + assert!((bright[1] - bright[0]).abs() < 1e-3, "{bright:?}"); + let y = |c: [f32; 3]| lightcraft_color::luminance_2020(c); + assert!((y(dark) - 0.01).abs() < 1e-5); + let green = calibrate([0.01, 0.01, 0.01], None, -1.0); + 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/finish.rs b/crates/pipeline/src/finish.rs index 5a79a2ded..0ab6199e4 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -145,8 +145,13 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] { pub struct FinishParams { pub tone: ToneMap, pub ops: ColorOps, + /// Calibration: primaries matrix (row-major, linear Rec.2020) and shadows tint (−1..1). + pub calib: Option<[[f32; 3]; 3]>, + pub shadow_tint: f32, /// Tone curves (parametric ∘ point, per channel) on encoded values, 1024 entries each. pub curves: Option<[Lut1; 3]>, + /// Refine Saturation as 0..1 (1 = the curves' own saturation). + pub refine_sat: f32, pub vig: Option, pub to_srgb: [[f32; 3]; 3], pub hl: f32, @@ -187,7 +192,10 @@ impl FinishParams { let cell = (0.0006 + (s.grain.size / 100.0) as f32 * 0.0024) * px_per_long as f32; ((s.grain.amount / 100.0) as f32 * 0.13, cell.max(0.6), (s.grain.roughness / 100.0) as f32, s.grain.seed) }); + let calibration = s.section_enabled("calibration"); FinishParams { + calib: if calibration { crate::colorops::calibration_matrix(&s.calibration) } else { None }, + shadow_tint: if calibration { (s.calibration.shadows_tint / 100.0) as f32 } else { 0.0 }, tone: if info.raw { ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks) } else { @@ -195,6 +203,7 @@ impl FinishParams { }, ops: ColorOps::new(s), curves: curve_luts(&s.curve), + refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32, vig: if effects { vignette(s) } else { None }, to_srgb: REC2020.to_space(&SRGB).to_f32(), hl: (s.light.highlights / 100.0) as f32, @@ -330,6 +339,11 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So c = c.map(|v| v * g); } + // --- calibration (scene linear, before the tone map) + if fp.calib.is_some() || fp.shadow_tint != 0.0 { + c = crate::colorops::calibrate(c, fp.calib.as_ref(), fp.shadow_tint); + } + // --- tone map on luminance, highlight desaturation let yl = luminance_2020(c); let o = tone.apply(yl); @@ -397,7 +411,11 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So // --- encode, curves, grain let mut e = r.map(|v| encode_srgb(srgb, v)); if let Some(l) = curves { + let e0 = e; e = [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])]; + if fp.refine_sat < 1.0 { + e = refine_saturation(e0, e, fp.refine_sat); + } } if let Some((amt, cell, rough, seed)) = *grain { let n = out_to_norm.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); @@ -415,6 +433,23 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So out } +/// Refine Saturation: scale the curved colour's chroma (around its luma, encoded values) so its +/// saturation (chroma / luma) moves from the curve's towards the pre-curve one: the ratio is +/// `(s_curve / s_before)^refine`, so 1 keeps the curve, 0 restores the original saturation. +#[inline] +pub fn refine_saturation(before: [f32; 3], after: [f32; 3], refine: f32) -> [f32; 3] { + let luma = |e: [f32; 3]| 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + let chroma = |e: [f32; 3]| e[0].max(e[1]).max(e[2]) - e[0].min(e[1]).min(e[2]); + let (y0, y1) = (luma(before), luma(after)); + let s0 = chroma(before) / y0.max(1e-4); + let s1 = chroma(after) / y1.max(1e-4); + if s1 <= 1e-6 || s0 <= 1e-6 { + return after; + } + let k = (s0 / s1).powf(1.0 - refine).clamp(0.0, 4.0); + after.map(|v| y1 + (v - y1) * k) +} + pub const SRGB_LUT_N: usize = 4096; /// Linear → sRGB-encoded table (`SRGB_LUT_N + 1` entries over 0..1), interpolated linearly. @@ -449,6 +484,21 @@ fn enc(v: f32) -> u8 { mod tests { use super::*; + #[test] + fn refine_saturation_restores_the_pre_curve_saturation() { + let before = [0.5, 0.3, 0.2]; + let after = [0.7, 0.35, 0.15]; // a contrasty curve: more saturated + assert_eq!(refine_saturation(before, after, 1.0), after); + let r = refine_saturation(before, after, 0.0); + let sat = |e: [f32; 3]| (e[0] - e[2]) / (0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]); + assert!((sat(r) - sat(before)).abs() < 1e-4, "{r:?}"); + let half = refine_saturation(before, after, 0.5); + assert!(sat(half) > sat(before) && sat(half) < sat(after)); + // luma is kept + let y = |e: [f32; 3]| 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + assert!((y(r) - y(after)).abs() < 1e-5); + } + #[test] fn srgb_table_matches_the_exact_curve() { let lut = srgb_lut(); diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 4ba0d886f..548febbd2 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -30,10 +30,14 @@ pub mod local; pub mod masks; pub mod optics; pub mod profiles; +pub mod redeye; 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 +82,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 } } } @@ -224,14 +230,17 @@ pub struct Plan<'a> { pub geo: u64, /// Key of the white-balanced, retouched, denoised image (and of the planes computed from it). pub lin_key: u64, + /// Red eye / pet eye corrections with their detected pupils, in output pixels. + pub eyes: Vec, } /// 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); @@ -239,17 +248,19 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R let src_long = src.width.max(src.height); let geo = hash_of((format!("{frame:?}"), w, h)); let (wb_t, wb_tint) = local::effective_wb(info, s); + let eyes = redeye::resolve(src, &s.red_eye, s.orientation, &frame, w, h, px_per_long); let d = &s.detail; let lin_key = hash_of(( geo, [wb_t, wb_tint, info.as_shot_temp, info.as_shot_tint].map(f64::to_bits), format!("{:?}", s.spots), + format!("{eyes:?}"), // defringe runs in this stage format!("{:?}", s.optics), [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits), src_long, )); - Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key } + Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes } } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). @@ -266,6 +277,7 @@ pub fn lin_cpu(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) { local::white_balance(img, info, s); optics::defringe(img, s, p.px_per_long / optics::DEFRINGE_REF_LONG); spots::apply(img, &s.spots, &p.frame, p.px_per_long); + redeye::apply(img, &p.eyes); } /// Render `src` with settings `s`. @@ -336,6 +348,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/redeye.rs b/crates/pipeline/src/redeye.rs new file mode 100644 index 000000000..3bad1d634 --- /dev/null +++ b/crates/pipeline/src/redeye.rs @@ -0,0 +1,310 @@ +//! Red eye and pet eye correction, in scene-linear light after white balance. +//! +//! The user draws an ellipse over the eye; inside it the pupil is found automatically on the +//! source image (resolution independent: a fixed grid of samples in normalized coordinates): +//! +//! - **red eye:** red-dominant pixels (`(R − max(G, B)) / (R + G + B)`) — flash light reflected by +//! the retina; +//! - **pet eye:** the brightest pixels relative to the ellipse (the tapetum's glow, any colour). +//! +//! Their weighted centroid and area give the pupil disc, scaled by Pupil Size. Rendering (shared +//! by the CPU and the GPU kernel `redeye_k`): red eye neutralizes the red inside the disc (only +//! where it is red, so iris and catchlights stay) and darkens it; pet eye replaces the disc with a +//! dark pupil and can add a catchlight. + +use lightcraft_develop::RedEye; +use lightcraft_geom::{Orientation, Point}; +use lightcraft_raster::Rgb32f; + +use crate::for_rows; +use crate::geometry::Frame; + +/// `f32`s per eye in [`EyeK::words`] (the GPU parameter layout). +pub const EYE_WORDS: usize = 9; + +/// Samples per axis of the detection grid over the ellipse's bounding box. +const GRID: usize = 40; + +/// One eye resolved for rendering, in output pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EyeK { + pub cx: f32, + pub cy: f32, + /// Pupil radius. + pub r: f32, + /// Darken 0..1. + pub darken: f32, + pub pet: bool, + /// Catchlight disc (centre, radius), pet eye only. + pub catchlight: Option<(f32, f32, f32)>, +} + +impl EyeK { + pub fn words(&self) -> [f32; EYE_WORDS] { + let (lx, ly, lr) = self.catchlight.unwrap_or((0.0, 0.0, 0.0)); + [self.cx, self.cy, self.r, self.darken, self.pet as u8 as f32, self.catchlight.is_some() as u8 as f32, lx, ly, lr] + } +} + +/// A detected pupil: centre (normalized coordinates) and radius (long-edge units). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Pupil { + pub center: Point, + pub radius: f64, + /// Whether anything was found (else: the ellipse centre and a default radius). + pub found: bool, +} + +/// Normalized point of the user-oriented image → normalized point of the source (inverse of +/// `Rgb32f::oriented`: undo the quarter turns, then the flip). +pub fn oriented_to_source(o: Orientation, p: Point) -> Point { + let (flip, turns) = o.to_parts(); + let (mut u, mut v) = (p.x, p.y); + for _ in 0..turns { + // a clockwise turn maps source (x, y) to (1 − y, x) + (u, v) = (v, 1.0 - u); + } + if flip { + u = 1.0 - u; + } + Point::new(u, v) +} + +#[inline] +fn smooth(e0: f32, e1: f32, x: f32) -> f32 { + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Find the pupil inside `eye`'s ellipse on `src` (the EXIF-oriented source; `o` = the user +/// orientation, `ow × oh` = the oriented image size in any unit). +pub fn detect(src: &Rgb32f, o: Orientation, ow: f64, oh: f64, eye: &RedEye) -> Pupil { + let long = ow.max(oh); + let (rx, ry) = (eye.rx.max(1e-4), eye.ry.max(1e-4)); + let fallback = Pupil { center: eye.center, radius: 0.5 * rx.min(ry), found: false }; + if src.width == 0 || src.height == 0 { + return fallback; + } + // samples inside the ellipse: (normalized point, colour) + let mut samples = Vec::with_capacity(GRID * GRID); + for j in 0..GRID { + for i in 0..GRID { + let (dx, dy) = ((i as f64 + 0.5) / GRID as f64 * 2.0 - 1.0, (j as f64 + 0.5) / GRID as f64 * 2.0 - 1.0); + if dx * dx + dy * dy > 1.0 { + continue; + } + let n = Point::new(eye.center.x + dx * rx * long / ow, eye.center.y + dy * ry * long / oh); + if !(0.0..1.0).contains(&n.x) || !(0.0..1.0).contains(&n.y) { + continue; + } + let s = oriented_to_source(o, n); + let (px, py) = ((s.x * src.width as f64) as usize, (s.y * src.height as f64) as usize); + samples.push((n, src.data[py.min(src.height - 1) * src.width + px.min(src.width - 1)])); + } + } + if samples.is_empty() { + return fallback; + } + let lum = |c: [f32; 3]| lightcraft_color::luminance_2020(c); + let weights: Vec = if eye.pet { + let ymax = samples.iter().map(|(_, c)| lum(*c)).fold(0.0f32, f32::max); + let mean = samples.iter().map(|(_, c)| lum(*c)).sum::() / samples.len() as f32; + if ymax <= 1e-6 || ymax < 1.5 * mean { + return fallback; + } + samples.iter().map(|(_, c)| smooth(0.5, 0.8, lum(*c) / ymax)).collect() + } else { + samples + .iter() + .map(|(_, c)| { + let s = (c[0] - c[1].max(c[2])) / (c[0] + c[1] + c[2] + 1e-6); + if c[0] > 1e-3 { smooth(0.22, 0.42, s) } else { 0.0 } + }) + .collect() + }; + let total: f32 = weights.iter().sum(); + if total < 3.0 { + return fallback; + } + let (mut x, mut y) = (0.0, 0.0); + for ((n, _), w) in samples.iter().zip(&weights) { + x += n.x * *w as f64; + y += n.y * *w as f64; + } + let frac = total as f64 / samples.len() as f64; + Pupil { center: Point::new(x / total as f64, y / total as f64), radius: (frac * rx * ry).sqrt(), found: true } +} + +/// Resolve the eyes for a `w × h` render of `frame` (`ppl` = output pixels per long-edge unit). +pub fn resolve(src: &Rgb32f, eyes: &[RedEye], orientation: Orientation, frame: &Frame, w: usize, h: usize, ppl: f64) -> Vec { + if eyes.is_empty() { + return Vec::new(); + } + let to_out = frame.norm_to_out(w, h); + eyes.iter() + .map(|e| { + let p = detect(src, orientation, frame.ow, frame.oh, e); + let r = (p.radius * (0.5 + e.pupil_size.clamp(0.0, 100.0) / 100.0)).min(e.rx.max(e.ry) * 1.1); + let c = to_out.apply(p.center); + let r_px = (r * ppl) as f32; + let catchlight = + e.catchlight.filter(|_| e.pet).map(|off| ((c.x + off.x * r * ppl) as f32, (c.y + off.y * r * ppl) as f32, (0.22 * r_px).max(0.5))); + EyeK { cx: c.x as f32, cy: c.y as f32, r: r_px.max(0.5), darken: (e.darken.clamp(0.0, 100.0) / 100.0) as f32, pet: e.pet, catchlight } + }) + .collect() +} + +/// One pixel (pixel centre `(x, y)` in output pixels) through every eye. +#[inline] +pub fn eye_pixel(c: [f32; 3], x: f32, y: f32, eyes: &[EyeK]) -> [f32; 3] { + let mut c = c; + for e in eyes { + let d = ((x - e.cx) * (x - e.cx) + (y - e.cy) * (y - e.cy)).sqrt() / e.r; + // red: generous disc (the red gate protects the iris); pet: the disc itself is replaced + let m = if e.pet { 1.0 - smooth(0.95, 1.2, d) } else { 1.0 - smooth(1.0, 1.35, d) }; + if m > 0.0 { + if e.pet { + let k = lightcraft_color::luminance_2020(c).min(0.04) * (1.0 - 0.85 * e.darken); + c = c.map(|v| v + (k - v) * m); + } else { + let red = (c[0] - c[1].max(c[2])) / c[0].max(1e-6); + let a = m * smooth(0.25, 0.5, red); + let n = 0.5 * (c[1] + c[2]) * (1.0 - 0.9 * e.darken); + c = c.map(|v| v + (n - v) * a); + } + } + if let Some((lx, ly, lr)) = e.catchlight { + let dc = ((x - lx) * (x - lx) + (y - ly) * (y - ly)).sqrt() / lr; + let mc = 1.0 - smooth(0.5, 1.0, dc); + if mc > 0.0 { + c = c.map(|v| v + (0.9 - v) * mc); + } + } + } + c +} + +/// Apply the eyes to `img` (output resolution), in place. +pub fn apply(img: &mut Rgb32f, eyes: &[EyeK]) { + if eyes.is_empty() { + return; + } + let w = img.width; + for_rows(&mut img.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + *px = eye_pixel(*px, x as f32 + 0.5, y as f32 + 0.5, eyes); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn orientation_inverse_matches_raster() { + let img = Rgb32f::from_fn(5, 3, |x, y| [x as f32, y as f32, 0.0]); + use Orientation::*; + for o in [Normal, Rotate90, Rotate180, Rotate270, FlipH, Transverse, FlipV, Transpose] { + let r = img.oriented(o); + for y in 0..r.height { + for x in 0..r.width { + let n = Point::new((x as f64 + 0.5) / r.width as f64, (y as f64 + 0.5) / r.height as f64); + let s = oriented_to_source(o, n); + let (sx, sy) = ((s.x * 5.0) as usize, (s.y * 3.0) as usize); + assert_eq!(r.get(x, y), img.get(sx, sy), "{o:?} at {x},{y}"); + } + } + } + } + + /// A skin-coloured patch with a red pupil of radius `pr` at (cx, cy) (pixels). + fn eye_image(w: usize, h: usize, cx: f32, cy: f32, pr: f32, pupil: [f32; 3]) -> Rgb32f { + Rgb32f::from_fn(w, h, |x, y| { + let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt(); + if d < pr { + pupil + } else if d < pr * 2.0 { + [0.12, 0.1, 0.08] // iris + } else { + [0.45, 0.3, 0.22] + } + }) + } + + #[test] + fn detects_the_red_pupil_off_centre() { + let img = eye_image(200, 100, 90.0, 52.0, 6.0, [0.5, 0.04, 0.04]); + // ellipse centred off the pupil (long edge 200: rx 0.1 = 20 px) + let eye = RedEye { center: Point::new(0.48, 0.5), rx: 0.1, ry: 0.08, pupil_size: 50.0, darken: 50.0, pet: false, catchlight: None }; + let p = detect(&img, Orientation::Normal, 200.0, 100.0, &eye); + assert!(p.found); + assert!((p.center.x - 0.45).abs() < 0.01 && (p.center.y - 0.52).abs() < 0.02, "{p:?}"); + assert!((p.radius * 200.0 - 6.0).abs() < 1.5, "{}", p.radius * 200.0); + // nothing red: fallback + let plain = eye_image(200, 100, 90.0, 52.0, 6.0, [0.03, 0.03, 0.03]); + assert!(!detect(&plain, Orientation::Normal, 200.0, 100.0, &eye).found); + } + + #[test] + fn renders_fix_the_red_pupil_at_any_size() { + use crate::{RenderRequest, SourceInfo, render}; + let src = eye_image(400, 200, 180.0, 104.0, 12.0, [0.5, 0.04, 0.04]); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.red_eye.push(RedEye { center: Point::new(0.47, 0.5), rx: 0.1, ry: 0.08, ..Default::default() }); + let info = SourceInfo::default(); + for size in [400, 160] { + let img = render(&src, &info, &s, &RenderRequest::fit(size, size)).image; + let (x, y) = ((0.45 * img.width as f64) as usize, (0.52 * img.height as f64) as usize); + let p = img.data[y * img.width + x]; + assert!(p[0] < 90 && p[0].abs_diff(p[1]) < 12, "size {size}: pupil {p:?}"); + // skin outside the ellipse is untouched + let q = img.data[(img.height / 2) * img.width + img.width / 10]; + let plain = render(&src, &info, &Default::default(), &RenderRequest::fit(size, size)).image; + assert_eq!(q, plain.data[(img.height / 2) * img.width + img.width / 10]); + } + } + + #[test] + fn pet_eye_finds_the_glow_darkens_it_and_adds_a_catchlight() { + use crate::{RenderRequest, SourceInfo, render}; + // a green-yellow tapetum glow (any colour: pet eyes are found by brightness) + let src = eye_image(400, 200, 210.0, 96.0, 14.0, [0.5, 0.7, 0.3]); + let eye = RedEye { center: Point::new(0.52, 0.5), rx: 0.1, ry: 0.09, pet: true, ..Default::default() }; + let p = detect(&src, Orientation::Normal, 400.0, 200.0, &eye); + assert!(p.found && (p.center.x - 0.525).abs() < 0.01 && (p.center.y - 0.48).abs() < 0.02, "{p:?}"); + assert!((p.radius * 400.0 - 14.0).abs() < 3.0, "{}", p.radius * 400.0); + // a red pupil is not a glow candidate for red-eye detection when it's green + assert!(!detect(&src, Orientation::Normal, 400.0, 200.0, &RedEye { pet: false, ..eye }).found); + let info = SourceInfo::default(); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.red_eye.push(eye); + let img = render(&src, &info, &s, &RenderRequest::fit(400, 400)).image; + let at = |img: &lightcraft_raster::Rgba8, x: f64, y: f64| img.data[(y * 200.0) as usize * 400 + (x * 400.0) as usize]; + let c = at(&img, 0.525, 0.48); + assert!(c[1] < 70 && c[0].abs_diff(c[1]) < 4, "dark neutral pupil: {c:?}"); + // catchlight up-left of the centre + s.red_eye[0].catchlight = Some(Point::new(-0.35, -0.35)); + let img = render(&src, &info, &s, &RenderRequest::fit(400, 400)).image; + let r = 14.0 / 400.0; + let l = at(&img, 0.525 - 0.35 * r, 0.48 - 0.35 * r * 2.0); + assert!(l[0] > 200 && l[1] > 200 && l[2] > 200, "catchlight {l:?}"); + assert!(at(&img, 0.525 + 0.4 * r, 0.48 + 0.4 * r * 2.0)[1] < 70, "rest of the pupil stays dark"); + } + + #[test] + fn red_pupil_is_neutralized_and_darkened_iris_kept() { + let eyes = [EyeK { cx: 50.0, cy: 50.0, r: 10.0, darken: 0.5, pet: false, catchlight: None }]; + let red = eye_pixel([0.5, 0.04, 0.05], 50.0, 50.0, &eyes); + assert!((red[0] - red[1]).abs() < 0.01 && red[0] < 0.05, "{red:?}"); + let iris = [0.12, 0.1, 0.08]; + let o = eye_pixel(iris, 52.0, 50.0, &eyes); + assert!(o.iter().zip(&iris).all(|(a, b)| (a - b).abs() < 0.02), "not red: barely touched {o:?}"); + let far = eye_pixel([0.5, 0.04, 0.05], 80.0, 50.0, &eyes); + assert_eq!(far, [0.5, 0.04, 0.05]); + // darker with more Darken + let dark = eye_pixel([0.5, 0.2, 0.2], 50.0, 50.0, &[EyeK { darken: 1.0, ..eyes[0] }]); + let light = eye_pixel([0.5, 0.2, 0.2], 50.0, 50.0, &[EyeK { darken: 0.0, ..eyes[0] }]); + assert!(dark[1] < light[1]); + } +} diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 0361cb95d..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![ @@ -230,3 +230,55 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() { let d = max_diff(&a, &b); assert!(d <= 2, "max difference {d}"); } + +#[test] +fn calibration_shifts_colours_but_keeps_greys() { + let info = SourceInfo { raw: true, ..Default::default() }; + let req = RenderRequest::fit(64, 8); + // a grey ramp is untouched by the primaries + let grey = Rgb32f::from_fn(64, 8, |x, _| [0.002 * 1.12f32.powi(x as i32); 3]); + let mut s = DevelopSettings::default(); + s.calibration.red_hue = 80.0; + s.calibration.green_sat = -60.0; + let a = render(&grey, &info, &DevelopSettings::default(), &req).image; + let b = render(&grey, &info, &s, &req).image; + assert!(max_diff(&a, &b) <= 1); + // a red patch rotates towards yellow (green rises relative to blue) + let red = Rgb32f::filled(16, 16, [0.3, 0.04, 0.03]); + let r0 = render(&red, &info, &DevelopSettings::default(), &RenderRequest::fit(16, 16)).image.data[0]; + let r1 = render(&red, &info, &s, &RenderRequest::fit(16, 16)).image.data[0]; + assert!(r1[1] as i32 - r1[2] as i32 > r0[1] as i32 - r0[2] as i32 + 5, "{r0:?} -> {r1:?}"); + // shadows tint: magenta (+) lowers green in a dark grey + let dark = Rgb32f::filled(16, 16, [0.01, 0.01, 0.01]); + s = DevelopSettings::default(); + s.calibration.shadows_tint = 100.0; + let d = render(&dark, &info, &s, &RenderRequest::fit(16, 16)).image.data[0]; + assert!(d[1] + 3 < d[0], "{d:?}"); + // the section toggle disables it + s.set_section_enabled("calibration", false); + let off = render(&dark, &info, &s, &RenderRequest::fit(16, 16)).image.data[0]; + assert!(off[1].abs_diff(off[0]) <= 1, "{off:?}"); +} + +#[test] +fn refine_saturation_tames_a_contrast_curve() { + let info = SourceInfo { raw: true, ..Default::default() }; + let src = Rgb32f::filled(16, 16, [0.12, 0.05, 0.03]); + let req = RenderRequest::fit(16, 16); + let sat = |p: [u8; 4]| p[0] as i32 - p[2] as i32; + let mut s = DevelopSettings::default(); + let flat = render(&src, &info, &s, &req).image.data[0]; + s.curve.master = vec![ + lightcraft_geom::Point::new(0.0, 0.0), + lightcraft_geom::Point::new(0.3, 0.15), + lightcraft_geom::Point::new(0.7, 0.85), + lightcraft_geom::Point::new(1.0, 1.0), + ]; + let full = render(&src, &info, &s, &req).image.data[0]; + s.curve.refine_saturation = 0.0; + let refined = render(&src, &info, &s, &req).image.data[0]; + assert!(sat(full) > sat(refined), "{flat:?} {full:?} {refined:?}"); + // the curve's tone change stays + let y = |p: [u8; 4]| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32; + assert!((y(full) - y(refined)).abs() < 2.0); +} diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs new file mode 100644 index 000000000..e8e47e062 --- /dev/null +++ b/crates/pipeline/src/visualize.rs @@ -0,0 +1,187 @@ +//! 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). +//! - **Visualize Spots** (Remove tool): a high-pass of the luminance thresholded to black/white, +//! so dust spots and specks stand out as white dots. The scale is relative to the image (the +//! same spots show at any preview size); the threshold slider (0..100) raises sensitivity. + +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), + /// Visualize Spots with a threshold 0..100 (higher = more sensitive). + Spots(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), + Overlay::Spots(t) => (2, t 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), + 2 => Overlay::Spots(v.clamp(0.0, 100.0) 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, + Overlay::Spots(t) => 0x2000 + t 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)); + } + } + Overlay::Spots(t) => spots(img, t, plan.px_per_long), + } +} + +/// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge. +const SPOT_SIGMA: f64 = 0.006; + +/// High-pass threshold (encoded luminance) for slider value `t` (0..100): 100 shows the faintest +/// specks, 0 only strong ones. +pub fn spot_threshold(t: u8) -> f32 { + let k = 1.0 - t.min(100) as f32 / 100.0; + 0.01 + 0.2 * k * k +} + +fn spots(img: &mut Rgba8, t: u8, ppl: f64) { + let (w, h) = (img.width, img.height); + if w == 0 || h == 0 { + return; + } + let l = lightcraft_raster::Plane::from_fn(w, h, |x, y| { + let p = img.data[y * w + x]; + (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) / 255.0 + }); + let b = lightcraft_raster::blur::gaussian(&l, (SPOT_SIGMA * ppl).max(0.8) as f32); + let thr = spot_threshold(t); + for (i, px) in img.data.iter_mut().enumerate() { + let v = if (l.data[i] - b.data[i]).abs() > thr { 255 } else { 0 }; + *px = [v, v, v, 255]; + } +} + +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 spots_view_marks_specks_at_any_size() { + // a smooth gradient with two small dark specks + let src = Rgb32f::from_fn(400, 300, |x, y| { + let d = |cx: f32, cy: f32| ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt(); + let base = 0.1 + 0.3 * x as f32 / 400.0; + if d(100.0, 100.0) < 2.5 || d(300.0, 200.0) < 2.5 { [base * 0.5; 3] } else { [base; 3] } + }); + let info = SourceInfo::default(); + let s = DevelopSettings::default(); + for size in [400, 200] { + let req = RenderRequest { overlay: Overlay::Spots(50), ..RenderRequest::fit(size, size) }; + let v = render(&src, &info, &s, &req).image; + let at = |x: f64, y: f64| v.data[(y * v.height as f64) as usize * v.width + (x * v.width as f64) as usize]; + assert_eq!(at(0.25, 1.0 / 3.0), [255, 255, 255, 255], "speck at size {size}"); + assert_eq!(at(0.75, 2.0 / 3.0), [255, 255, 255, 255]); + assert_eq!(at(0.5, 0.5), [0, 0, 0, 255], "smooth gradient is black"); + let white = v.data.iter().filter(|p| p[0] == 255).count() as f64 / v.data.len() as f64; + assert!(white < 0.01, "{white}"); + } + assert!(spot_threshold(0) > spot_threshold(50) && spot_threshold(50) > spot_threshold(100)); + let (k, v) = Overlay::Spots(37).to_parts(); + assert_eq!(Overlay::from_parts(k, v), Overlay::Spots(37)); + } + + #[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/icons.rs b/crates/ui-egui/src/icons.rs index a8656c6f9..10426bc2e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -66,6 +66,8 @@ pub enum Icon { Video, Heart, Edited, + /// Targeted adjustment: a ring with a centre dot and up/down arrows (drag vertically). + Target, } struct Pen<'a> { @@ -402,5 +404,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.circle(10.0, 10.0, 7.0); pen.line(&[(7.0, 13.0), (13.0, 7.0)]); } + Target => { + pen.circle(8.0, 10.0, 5.0); + pen.dot(8.0, 10.0, 1.3); + pen.line(&[(15.5, 3.5), (15.5, 16.5)]); + pen.line(&[(13.5, 5.5), (15.5, 3.5), (17.5, 5.5)]); + pen.line(&[(13.5, 14.5), (15.5, 16.5), (17.5, 14.5)]); + } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b11cd6eda..9cebcea25 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -29,6 +29,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.clipping", "Show Clipping", Some("J"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), + ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), ("view.back", "Back to Grid", Some("Escape"), "View"), ("view.filterBar", "Filter", None, "View"), @@ -180,6 +181,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // like Lightroom's A: opens the Remove tool with the view on, or toggles it there + if app.ui.right == RightPanel::Remove { + app.ui.visualize_spots = !app.ui.visualize_spots; + } else { + app.ui.right = RightPanel::Remove; + app.ui.visualize_spots = true; + } + Ok(Value::Null) + } "view.cropOverlay" => { use crate::state::CropOverlay::*; app.ui.crop_overlay = match app.ui.crop_overlay { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 012c4479c..dc3aaad8f 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -48,6 +48,15 @@ pub enum Gesture { Guide { a: Point, }, + /// Red Eye: an ellipse being dragged from corner `a` (normalized). + Eye { + a: Point, + }, + /// Targeted adjustment drag from `at` (normalized); `acc` = vertical travel not yet applied. + Targeted { + at: Point, + acc: f32, + }, } /// Screen ↔ normalized-image mapping for the displayed frame. @@ -158,6 +167,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.loupe_job(id, rw.max(8), rh.max(8), !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); } // once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and @@ -253,6 +263,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id), RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d), RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d), + RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d), _ => general_interaction(app, ui, &resp, &map, img_rect, canvas, native, aspect), } resp.context_menu(|ui| super::grid::context_menu(app, ui, id)); @@ -270,6 +281,51 @@ fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { } } +/// Targeted adjustment tool: dragging up/down on the photo raises/lowers the tone-curve region or +/// the colour-mixer bands under the press point (`develop.targeted`, one call per whole step, all +/// in one interaction = one undo step). +fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, target: &str) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + if resp.drag_started() + && let Some(q) = resp.interact_pointer_pos() + { + let _ = app.run("develop.beginInteraction", json!({"label": "Targeted Adjustment"})); + app.gesture = Some(Gesture::Targeted { at: map.norm(q), acc: 0.0 }); + } + if resp.dragged() + && let Some(Gesture::Targeted { at, acc }) = &mut app.gesture + { + *acc -= resp.drag_delta().y * 0.5; + let step = acc.trunc(); + if step != 0.0 { + *acc -= step; + let at = *at; + let mut p = json!({"target": target, "x": at.x, "y": at.y, "delta": step}); + if target == "curve" && app.ui.curve_channel != "parametric" { + p["channel"] = json!(app.ui.curve_channel); + } + let _ = app.run("develop.targeted", p); + } + } + if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::Targeted { .. })) { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } +} + +/// 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; + if app.ui.right == RightPanel::Remove && app.ui.visualize_spots { + return Overlay::Spots(app.ui.spots_threshold.clamp(0.0, 100.0).round() as u8); + } + 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()) { @@ -304,6 +360,23 @@ fn general_interaction( } return; } + if let Some(target) = app.ui.tool.strip_prefix("tat:").map(str::to_string) { + targeted_drag(app, ui, resp, map, &target); + 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) { @@ -725,6 +798,64 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo } } +// ------------------------------------------------------------------------ red eye + +/// Red Eye tool: drag an ellipse over an eye (a new correction), click one to select it. +fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { + // screen points per long-edge unit + let o = map.screen(Point::new(0.0, 0.0)); + let (sx, sy) = (map.screen(Point::new(1.0, 0.0)).distance(o), map.screen(Point::new(0.0, 1.0)).distance(o)); + let per_long = sx.max(sy); + let p = ui.painter(); + for (i, e) in d.red_eye.iter().enumerate() { + let c = map.screen(e.center); + let r = vec2(e.rx as f32 * per_long, e.ry as f32 * per_long); + let sel = i == app.ui.eye; + let col = if sel { Color32::WHITE } else { Color32::from_white_alpha(150) }; + p.add(egui::Shape::ellipse_stroke(c, r, Stroke::new(if sel { 1.5 } else { 1.0 }, col))); + p.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.0, col)); + p.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.0, col)); + } + if resp.hover_pos().is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + // from where the button went down (a drag is recognized only after some movement) + app.gesture = Some(Gesture::Eye { a: map.norm(q) }); + } + if let Some(Gesture::Eye { a }) = app.gesture + && let Some(q) = resp.interact_pointer_pos() + { + let (a, b) = (map.screen(a), q); + p.add(egui::Shape::ellipse_stroke(a.lerp(b, 0.5), ((b - a) / 2.0).abs(), Stroke::new(1.0, Color32::WHITE))); + } + if resp.drag_stopped() + && let Some(Gesture::Eye { a }) = app.gesture.take() + && let Some(q) = resp.interact_pointer_pos() + { + let (sa, sb) = (map.screen(a), q); + let half = ((sb - sa) / 2.0).abs() / per_long; + if half.x > 0.002 && half.y > 0.002 { + let c = map.norm(sa.lerp(sb, 0.5)); + let r = app.run("redeye.add", json!({"center": [c.x, c.y], "rx": half.x, "ry": half.y, "pet": app.ui.eye_pet})); + if let Ok(v) = r { + app.ui.eye = v["index"].as_u64().unwrap_or(0) as usize; + } + } + } else if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + && let Some(i) = d.red_eye.iter().position(|e| { + let c = map.screen(e.center); + let (dx, dy) = ((q.x - c.x) / (e.rx as f32 * per_long).max(4.0), (q.y - c.y) / (e.ry as f32 * per_long).max(4.0)); + dx * dx + dy * dy <= 1.0 + }) + { + app.ui.eye = i; + } +} + // ------------------------------------------------------------------------ filmstrip fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 20d63a35c..794dc5fbd 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"); @@ -281,10 +290,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } ui.add_space(8.0); }); + // Lightroom Classic's Calibration panel (the cloud app hides it): last, like there. + section(app, ui, &d, "calibration", "Calibration", |app, ui, d| { + sub_title(ui, "Shadows"); + control(app, ui, d, "calibration.shadowsTint", true); + for (title, k) in [("Red Primary", "red"), ("Green Primary", "green"), ("Blue Primary", "blue")] { + sub_title(ui, title); + control(app, ui, d, &format!("calibration.{k}Hue"), true); + control(app, ui, d, &format!("calibration.{k}Sat"), true); + } + ui.add_space(8.0); + }); ui.add_space(40.0); } -fn sub_title(ui: &mut egui::Ui, title: &str) { +pub fn sub_title(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label); @@ -422,6 +442,9 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev app.ui.curve_channel = ch.into(); } } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + tat_button(app, ui, "tat:curve", "Targeted adjustment: drag up/down on the photo to adjust the curve there"); + }); }); }); let ch = app.ui.curve_channel.clone(); @@ -472,6 +495,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev for c in ["curve.highlights", "curve.lights", "curve.darks", "curve.shadows"] { control(app, ui, d, c, true); } + control(app, ui, d, "curve.refineSaturation", true); return; } let pts_of = |d: &DevelopSettings| -> Vec { @@ -563,9 +587,19 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let _ = app.run("develop.endInteraction", json!({})); } ui.data_mut(|dd| dd.insert_temp(drag_id, dragging)); + control(app, ui, d, "curve.refineSaturation", true); let _ = id; } +/// Toggle for a targeted-adjustment tool (`tool` = `tat:`). +fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) { + let active = app.ui.tool == tool; + let id = tool.replace(':', "-"); + if crate::widgets::icon_button(ui, &id, Icon::Target, vec2(26.0, 26.0), active, true, tip).clicked() { + app.ui.tool = if active { String::new() } else { tool.to_string() }; + } +} + // ------------------------------------------------------------------------------ colour mixer fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { @@ -589,6 +623,26 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { } }); }); + // targeted adjustment: pick the attribute, then drag on the photo + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover()); + paint(ui.painter(), r, Icon::Target, t.icon); + let items: &[(&str, &str)] = + if crate::is_bw(d) { &[("lum", "B&W Mix")] } else { &[("hue", "Hue"), ("sat", "Saturation"), ("lum", "Luminance")] }; + for (k, label) in items { + let tool = format!("tat:{k}"); + let active = app.ui.tool == tool; + if text_button(ui, &format!("tatMixer-{k}"), label, active) + .on_hover_text("Targeted adjustment: drag up/down on the photo to adjust the colours there") + .clicked() + { + app.ui.tool = if active { String::new() } else { tool }; + } + } + }); + }); let b = bands[sel]; if crate::is_bw(d) { control(app, ui, d, &format!("bw.{b}"), true); @@ -599,6 +653,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/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 097d9b8b5..01f88b9af 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -30,7 +30,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::Crop => crop(app, ui, id), RightPanel::Remove => remove(app, ui, id), RightPanel::Masking => super::masking::show(app, ui, id), - RightPanel::RedEye => red_eye(app, ui), + RightPanel::RedEye => red_eye(app, ui, id), RightPanel::Info => info(app, ui, id), RightPanel::Keywords => keywords(app, ui, id), RightPanel::Versions => versions(app, ui, id), @@ -228,6 +228,28 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if let Some(v) = out.value { app.ui.remove_size = (v / 1000.0) as f32; } + // Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out + padded(ui, |ui| { + let mut v = app.ui.visualize_spots; + if ui.checkbox(&mut v, "Visualize Spots (A)").changed() { + app.ui.visualize_spots = v; + } + }); + let spec = lightcraft_develop::ControlSpec { + id: "ui.spotsThreshold", + label: "Threshold", + section: lightcraft_develop::Section::Detail, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: lightcraft_develop::Track::Plain, + }; + let out = slider(ui, &spec, app.ui.spots_threshold as f64, app.ui.visualize_spots, None); + if let Some(v) = out.value { + app.ui.spots_threshold = v as f32; + } padded(ui, |ui| { if !d.spots.is_empty() && text_button(ui, "removeClear", "Delete all spots", false).clicked() { for i in (0..d.spots.len()).rev() { @@ -237,13 +259,58 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); } -fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui) { +fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let d = app.session.develop_of(id).unwrap_or_default(); header(ui, "Red Eye"); padded(ui, |ui| { - ui.label("Click and drag over an eye to correct it."); - if text_button(ui, "redeyeAuto", "Auto Correct", false).clicked() { - app.ui.status = "Red eye correction arrives in M8".into(); + ui.horizontal_wrapped(|ui| { + for (label, pet) in [("Red Eye", false), ("Pet Eye", true)] { + if text_button(ui, &format!("eyeMode-{}", if pet { "pet" } else { "red" }), label, app.ui.eye_pet == pet).clicked() { + app.ui.eye_pet = pet; + } + } + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Drag over an eye on the photo; the pupil inside is found automatically.").color(Tokens::get(ui.ctx()).text_dim), + ); + }); + let n = d.red_eye.len(); + if n == 0 { + return; + } + if app.ui.eye >= n { + app.ui.eye = n - 1; + } + let i = app.ui.eye; + let eye = d.red_eye[i]; + divider(ui); + super::edit::sub_title(ui, &format!("{} {} of {n}", if eye.pet { "Pet Eye" } else { "Red Eye" }, i + 1)); + for k in ["pupilSize", "darken"] { + let cid = format!("redEye.{i}.{k}"); + let Some(spec) = lightcraft_develop::controls::find(&cid) else { continue }; + let v = lightcraft_develop::controls::get(&d, &cid).unwrap_or(spec.default); + let out = slider(ui, spec, v, true, None); + super::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": cid, "value": v}))); + } + padded(ui, |ui| { + if eye.pet { + let mut on = eye.catchlight.is_some(); + if ui.checkbox(&mut on, "Add Catchlight").changed() { + let _ = app.run("redeye.catchlight", json!({"index": i, "on": on})); + } + ui.add_space(4.0); } + ui.horizontal(|ui| { + if text_button(ui, "eyeDelete", "Delete", false).clicked() { + let _ = app.run("redeye.delete", json!({"index": i})); + } + if n > 1 && text_button(ui, "eyeDeleteAll", "Delete all", false).clicked() { + for k in (0..n).rev() { + let _ = app.run("redeye.delete", json!({"index": k})); + } + } + }); }); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 7cbd41de4..bd0afed90 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -111,6 +111,16 @@ 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, + /// Red Eye panel: selected correction, and whether new ones are pet eyes. + pub eye: usize, + pub eye_pet: bool, + /// Remove tool: Visualize Spots (high-pass black/white view) and its threshold 0..100. + pub visualize_spots: bool, + pub spots_threshold: f32, /// Transient toast text and its expiry (seconds of app time). #[serde(skip)] pub toast: Option<(String, f64)>, @@ -183,6 +193,12 @@ impl Default for UiState { brush_flow: 60.0, brush_erase: false, remove_size: 0.02, + point_color: 0, + point_color_visualize: false, + eye: 0, + eye_pet: false, + visualize_spots: false, + spots_threshold: 50.0, toast: None, status: String::new(), dialog: None, diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index cfdc6ac3e..28867685d 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -58,6 +58,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`). | `GrayMixer` | `bw_mix.` | | | `ParametricShadows`, `ParametricDarks`, `ParametricLights`, `ParametricHighlights` | `curve.shadows/darks/lights/highlights` | | | `ParametricShadowSplit`, `ParametricMidtoneSplit`, `ParametricHighlightSplit` | `curve.split_shadows/split_mid/split_highlights` | | +| `CurveRefineSaturation` | `curve.refine_saturation` | 0..100 (100 = curve saturation unchanged) | | `ToneCurvePV2012`, `ToneCurvePV2012Red/Green/Blue` | `curve.master/red/green/blue` | `"x, y"` points in 0..255 → 0..1; a straight 0→255 line = no curve | | `SplitToningShadowHue/Saturation`, `SplitToningHighlightHue/Saturation` | `grading.shadows/highlights.hue/sat` | | | `ColorGradeShadowLum`, `ColorGradeHighlightLum` | `grading.shadows/highlights.lum` | | @@ -72,6 +73,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`). | `LensProfileEnable`, `AutoLateralCA` | `optics.lens_profile`, `optics.remove_ca` | the switch only; lens profiles are our own | | `LensManualDistortionAmount`, `VignetteAmount`, `VignetteMidpoint` | `optics.distortion`, `optics.vignetting`, `optics.vignetting_midpoint` | | | `DefringePurple/GreenAmount/HueLo/HueHi` | `optics.defringe_*` | | +| `ShadowTint`, `RedHue/Saturation`, `GreenHue/Saturation`, `BlueHue/Saturation` | `calibration.shadows_tint`, `calibration.red_hue/red_sat`, … | Calibration panel, 1:1 | | `PerspectiveVertical/Horizontal/Rotate/Scale/Aspect/X/Y` | `geometry.vertical/horizontal/rotate/scale/aspect/offset_x/offset_y` | | | `PerspectiveUpright` | `geometry.upright` | 0 off, 1 auto, 2 level, 3 vertical, 4 full, 5 guided | | `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | normalized edges → rect; angle in degrees; `HasCrop="False"` → no crop |