mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 03:57:57 -06:00
Merge edit-panel parity (Point Color, Calibration, curve refine saturation + targeted adjustment, red/pet eye, visualize spots)
Fix-ups: diagnostic overlays never populate the view-preview cache; Photo Merge's RenderRequest gets the new overlay field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,9 @@ pub struct WireJob {
|
||||
/// Reuse the worker's intermediate results for this view (the loupe), like the desktop's
|
||||
/// per-slot stage cache: slider drags then only redo what changed.
|
||||
pub stages: Option<String>,
|
||||
/// The request's diagnostic overlay (`Overlay::to_parts`).
|
||||
#[serde(default)]
|
||||
pub overlay: (u8, f32),
|
||||
}
|
||||
|
||||
impl WireJob {
|
||||
@@ -70,6 +73,7 @@ impl WireJob {
|
||||
thumb: job.cache.as_ref().map(|(_, k)| k.to_string()),
|
||||
thumb_cached,
|
||||
stages: job.stages.is_some().then(|| view.to_string()),
|
||||
overlay: job.request.overlay.to_parts(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +87,7 @@ impl WireJob {
|
||||
max_h: self.max_h,
|
||||
quality: if self.draft { Quality::Draft } else { Quality::Full },
|
||||
apply_crop: self.apply_crop,
|
||||
overlay: lightcraft_engine::pipeline::Overlay::from_parts(self.overlay.0, self.overlay.1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +301,7 @@ mod tests {
|
||||
thumb: None,
|
||||
thumb_cached: false,
|
||||
stages: Some("Main".into()),
|
||||
overlay: (0, 0.0),
|
||||
};
|
||||
let mut core = WorkerCore::default();
|
||||
assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str()));
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Control specs: one table describes every numeric develop slider — id, label, section, range,
|
||||
//! default, step, display precision and track style. The UI builds panels from it, MCP exposes it as
|
||||
//! a schema, and `get`/`set` address settings by id (e.g. `light.exposure`, `mixer.red.hue`).
|
||||
//!
|
||||
//! Settings that come in lists (Point Color samples) are *indexed* controls: one template spec in
|
||||
//! [`INDEXED`] (`pointColor.hueShift`) addresses every element as `pointColor.<i>.hueShift`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -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<f64> {
|
||||
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 (`<family>.<field>`; addressed as `<family>.<i>.<field>`).
|
||||
pub static INDEXED: &[ControlSpec] = &[
|
||||
ControlSpec {
|
||||
id: "pointColor.hueShift",
|
||||
label: "Hue",
|
||||
section: Section::PointColor,
|
||||
min: -100.0,
|
||||
max: 100.0,
|
||||
default: 0.0,
|
||||
step: 1.0,
|
||||
decimals: 0,
|
||||
track: Rainbow,
|
||||
},
|
||||
ControlSpec {
|
||||
id: "pointColor.satShift",
|
||||
label: "Saturation",
|
||||
section: Section::PointColor,
|
||||
min: -100.0,
|
||||
max: 100.0,
|
||||
default: 0.0,
|
||||
step: 1.0,
|
||||
decimals: 0,
|
||||
track: Gradient { from: "#7a7a7a", to: "#e04a3a" },
|
||||
},
|
||||
ControlSpec {
|
||||
id: "pointColor.lumShift",
|
||||
label: "Luminance",
|
||||
section: Section::PointColor,
|
||||
min: -100.0,
|
||||
max: 100.0,
|
||||
default: 0.0,
|
||||
step: 1.0,
|
||||
decimals: 0,
|
||||
track: Gradient { from: "#202020", to: "#f0f0f0" },
|
||||
},
|
||||
ControlSpec {
|
||||
id: "pointColor.variance",
|
||||
label: "Variance",
|
||||
section: Section::PointColor,
|
||||
min: -100.0,
|
||||
max: 100.0,
|
||||
default: 0.0,
|
||||
step: 1.0,
|
||||
decimals: 0,
|
||||
track: Centered,
|
||||
},
|
||||
ControlSpec {
|
||||
id: "pointColor.range",
|
||||
label: "Range",
|
||||
section: Section::PointColor,
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
default: 50.0,
|
||||
step: 1.0,
|
||||
decimals: 0,
|
||||
track: Plain,
|
||||
},
|
||||
ControlSpec {
|
||||
id: "pointColor.hueRange",
|
||||
label: "Hue Range",
|
||||
section: Section::PointColor,
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
default: 50.0,
|
||||
step: 1.0,
|
||||
decimals: 0,
|
||||
track: Rainbow,
|
||||
},
|
||||
ControlSpec {
|
||||
id: "pointColor.satRange",
|
||||
label: "Saturation Range",
|
||||
section: Section::PointColor,
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
default: 50.0,
|
||||
step: 1.0,
|
||||
decimals: 0,
|
||||
track: Gradient { from: "#7a7a7a", to: "#e04a3a" },
|
||||
},
|
||||
ControlSpec {
|
||||
id: "pointColor.lumRange",
|
||||
label: "Luminance Range",
|
||||
section: Section::PointColor,
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
default: 50.0,
|
||||
step: 1.0,
|
||||
decimals: 0,
|
||||
track: Gradient { from: "#202020", to: "#f0f0f0" },
|
||||
},
|
||||
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<f64> {
|
||||
// (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<Item = &'static ControlSpec> {
|
||||
@@ -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<String> = indexed_instances(&s).into_iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(ids.len(), 2 * INDEXED.iter().filter(|c| c.id.starts_with("pointColor.")).count());
|
||||
for (id, spec) in indexed_instances(&s) {
|
||||
assert!(get(&s, &id).is_some(), "{id}");
|
||||
assert!(spec.min <= spec.default && spec.default <= spec.max);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting() {
|
||||
let e = find("light.exposure").unwrap();
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ pub struct DevelopSettings {
|
||||
pub curve: ToneCurve,
|
||||
pub color: ColorAdj,
|
||||
pub mixer: Mixer,
|
||||
/// Point Color: up to [`MAX_POINT_COLORS`] sampled colours, each with its own adjustment.
|
||||
pub point_colors: Vec<PointColor>,
|
||||
pub bw_mix: BwMix,
|
||||
pub grading: ColorGrading,
|
||||
pub effects: Effects,
|
||||
@@ -32,6 +34,8 @@ pub struct DevelopSettings {
|
||||
pub red_eye: Vec<RedEye>,
|
||||
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<String>,
|
||||
}
|
||||
@@ -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<Point>,
|
||||
pub green: Vec<Point>,
|
||||
pub blue: Vec<Point>,
|
||||
/// 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<Point>,
|
||||
}
|
||||
|
||||
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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Value> {
|
||||
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<String, Value>, 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::<Vec<_>>());
|
||||
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::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
"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<usize> {
|
||||
Ok(f64_req(p, "index", c)? as usize)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"pointColor.pick",
|
||||
"Add Point Color Sample",
|
||||
[],
|
||||
None,
|
||||
"{x, y} normalized image coords — samples the colour there (max 8); returns {index, lum, chroma, hue}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "pointColor.pick";
|
||||
let (x, y) = (f64_req(p, "x", c)?, f64_req(p, "y", c)?);
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
if s.develop_of(id).is_some_and(|d| d.point_colors.len() >= MAX_POINT_COLORS) {
|
||||
return Err(bad(c, format!("at most {MAX_POINT_COLORS} point colours")));
|
||||
}
|
||||
let [l, ch, h] = encoded_to_oklch(probe(s, c, x, y, after_mixer_neutral)?);
|
||||
let sample = PointColor { lum: l as f64, chroma: ch as f64, hue: (h as f64).to_degrees().rem_euclid(360.0), ..Default::default() };
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
d.point_colors.push(sample);
|
||||
let i = d.point_colors.len() - 1;
|
||||
s.set_develop(id, d, "Point Color")?;
|
||||
Ok(json!({"index": i, "lum": sample.lum, "chroma": sample.chroma, "hue": sample.hue}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"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)
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -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<CommandSpec> {
|
||||
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()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -114,6 +114,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
v["value"] = json!(controls::get(&d, c.id));
|
||||
v
|
||||
})
|
||||
// indexed controls of the list elements that exist (`pointColor.0.hueShift`, …)
|
||||
.chain(controls::indexed_instances(&d).into_iter().filter(|(_, c)| sec.is_none_or(|x| x == c.section)).map(|(id, c)| {
|
||||
let mut v = serde_json::to_value(c).unwrap_or_default();
|
||||
v["value"] = json!(controls::get(&d, &id));
|
||||
v["id"] = json!(id);
|
||||
v
|
||||
}))
|
||||
.collect(),
|
||||
))
|
||||
}),
|
||||
|
||||
@@ -190,6 +190,7 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> 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<bool>) -> 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">
|
||||
<crs:ToneCurvePV2012>
|
||||
<rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>64, 52</rdf:li><rdf:li>192, 205</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq>
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<StageCache>) -> 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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<f32> = ops.points.iter().take(8).flat_map(|k| k.words()).collect();
|
||||
p.fs("PC", &pc);
|
||||
p.b("BW", ops.bw.is_some());
|
||||
p.fs("BW_MIX", &ops.bw.unwrap_or([0.0; 8]));
|
||||
if let Some((wheels, blending, balance)) = &ops.grading {
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,39 @@ fn band_weights(h: f32) -> array<f32, 8> {
|
||||
return w;
|
||||
}
|
||||
|
||||
// `PointK::apply` for Point Color sample `k` (OkLCh in, OkLCh out).
|
||||
fn point_color(k: u32, lch: vec3<f32>) -> vec3<f32> {
|
||||
let o = F_PC + k * POINT_WORDS;
|
||||
let sl = pf(o);
|
||||
let sc = pf(o + 1u);
|
||||
let sh = pf(o + 2u);
|
||||
let var_k = pf(o + 6u);
|
||||
let wh_ = pf(o + 7u);
|
||||
let wc_ = pf(o + 8u);
|
||||
let wl_ = pf(o + 9u);
|
||||
var l = lch.x;
|
||||
var c = lch.y;
|
||||
var h = lch.z;
|
||||
var wh = 1.0;
|
||||
if (sc >= 0.02) {
|
||||
wh = (1.0 - sstep(0.5 * wh_, wh_, abs(wrap_angle(h - sh)))) * sstep(0.005, 0.025, c);
|
||||
}
|
||||
if (wh <= 0.0) {
|
||||
return lch;
|
||||
}
|
||||
let wc = 1.0 - sstep(0.5 * wc_, wc_, abs(c - sc));
|
||||
let wl = 1.0 - sstep(0.5 * wl_, wl_, abs(l - sl));
|
||||
let w = wh * wc * wl;
|
||||
if (w <= 0.0) {
|
||||
return lch;
|
||||
}
|
||||
h += w * (var_k * wrap_angle(h - sh) + pf(o + 3u));
|
||||
c += w * var_k * (c - sc);
|
||||
c = max(c * (1.0 + w * pf(o + 4u)), 0.0);
|
||||
l += w * (var_k * (l - sl) + pf(o + 5u));
|
||||
return vec3<f32>(l, c, h);
|
||||
}
|
||||
|
||||
// `ColorOps::apply`.
|
||||
fn color_ops(rgb: vec3<f32>, local_sat: f32, local_hue: f32) -> vec3<f32> {
|
||||
if (pu(F_OPS_IDENTITY) != 0u && local_sat == 0.0 && local_hue == 0.0) {
|
||||
@@ -75,6 +108,12 @@ fn color_ops(rgb: vec3<f32>, local_sat: f32, local_hue: f32) -> vec3<f32> {
|
||||
c *= max(1.0 + ds, 0.0);
|
||||
l += dl * chroma_w * sqrt(max(l, 0.05));
|
||||
}
|
||||
for (var k = 0u; k < pu(F_NPC); k++) {
|
||||
let r = point_color(k, vec3<f32>(l, c, h));
|
||||
l = r.x;
|
||||
c = r.y;
|
||||
h = r.z;
|
||||
}
|
||||
let vib = pf(F_VIBRANCE);
|
||||
if (vib != 0.0) {
|
||||
let low = 1.0 - clamp(c / 0.22, 0.0, 1.0);
|
||||
@@ -117,6 +156,42 @@ fn color_ops(rgb: vec3<f32>, local_sat: f32, local_hue: f32) -> vec3<f32> {
|
||||
return oklab_inv(lab);
|
||||
}
|
||||
|
||||
// `colorops::calibrate`: primaries matrix, then the shadows tint (luminance kept).
|
||||
fn calibrate(c0: vec3<f32>) -> vec3<f32> {
|
||||
var c = c0;
|
||||
if (pu(F_CALIB) != 0u) {
|
||||
let m = array<vec3<f32>, 3>(
|
||||
vec3<f32>(pf(F_CALIB_M), pf(F_CALIB_M + 1u), pf(F_CALIB_M + 2u)),
|
||||
vec3<f32>(pf(F_CALIB_M + 3u), pf(F_CALIB_M + 4u), pf(F_CALIB_M + 5u)),
|
||||
vec3<f32>(pf(F_CALIB_M + 6u), pf(F_CALIB_M + 7u), pf(F_CALIB_M + 8u)),
|
||||
);
|
||||
c = max(mul3(m, c), vec3<f32>(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<f32>, after: vec3<f32>, refine: f32) -> vec3<f32> {
|
||||
let lw = vec3<f32>(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<u32>(i) * GRAIN_H0) ^ (bitcast<u32>(j) * GRAIN_H1) ^ (seed * GRAIN_H2);
|
||||
v ^= v >> 13u;
|
||||
@@ -263,6 +338,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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<u32>) {
|
||||
// --- encode, curves, grain
|
||||
var e = vec3<f32>(encode_srgb(r.x), encode_srgb(r.y), encode_srgb(r.z));
|
||||
if (pu(F_CURVES) != 0u) {
|
||||
let e0 = e;
|
||||
e = vec3<f32>(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;
|
||||
|
||||
@@ -135,3 +135,49 @@ fn subsample(@builtin(global_invocation_id) g: vec3<u32>, @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<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
|
||||
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<f32>(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<f32>(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<f32>(0.9) - c) * mc;
|
||||
}
|
||||
}
|
||||
}
|
||||
put_rgb(i, c);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<PointK>,
|
||||
pub bw: Option<[f32; 8]>,
|
||||
/// Wheels (shadows, midtones, highlights, global), blending, balance.
|
||||
pub grading: Option<([WheelK; 4], f32, f32)>,
|
||||
@@ -89,6 +163,11 @@ impl ColorOps {
|
||||
sat: bands.map(|b| (b.sat / 100.0) as f32),
|
||||
lum: bands.map(|b| (b.lum / 100.0) as f32 * 0.18),
|
||||
mixer: !s.mixer.is_neutral(),
|
||||
points: if crate::is_bw(s) {
|
||||
Vec::new()
|
||||
} else {
|
||||
s.point_colors.iter().take(lightcraft_develop::MAX_POINT_COLORS).filter(|p| !p.is_neutral()).map(PointK::new).collect()
|
||||
},
|
||||
bw: crate::is_bw(s).then(|| s.bw_mix.bands().map(|v| (v / 100.0) as f32)),
|
||||
grading: (!g.is_neutral()).then(|| {
|
||||
(
|
||||
@@ -102,7 +181,7 @@ impl ColorOps {
|
||||
}
|
||||
|
||||
pub fn is_identity(&self) -> bool {
|
||||
self.vibrance == 0.0 && self.saturation == 0.0 && !self.mixer && self.bw.is_none() && self.grading.is_none()
|
||||
self.vibrance == 0.0 && self.saturation == 0.0 && !self.mixer && self.points.is_empty() && self.bw.is_none() && self.grading.is_none()
|
||||
}
|
||||
|
||||
/// `local_sat` (−1..1) and `local_hue` (radians) come from masks.
|
||||
@@ -126,6 +205,9 @@ impl ColorOps {
|
||||
c *= (1.0 + ds).max(0.0);
|
||||
l += dl * chroma_w * l.max(0.05).sqrt();
|
||||
}
|
||||
for p in &self.points {
|
||||
[l, c, h] = p.apply([l, c, h]);
|
||||
}
|
||||
if self.vibrance != 0.0 {
|
||||
let low = 1.0 - (c / 0.22).clamp(0.0, 1.0);
|
||||
let skin = if self.vibrance > 0.0 { 1.0 - 0.6 * (-(wrap(h - self.skin) / 0.35).powi(2)).exp() } else { 1.0 };
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Vig>,
|
||||
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();
|
||||
|
||||
@@ -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<redeye::EyeK>,
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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<f32> = 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::<f32>() / 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<EyeK> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ fn stage_cache_matches_uncached_render_through_a_slider_session() {
|
||||
let info = SourceInfo { raw: true, ..Default::default() };
|
||||
let cache = StageCache::default();
|
||||
let full = RenderRequest::fit(200, 200);
|
||||
let draft = RenderRequest { max_w: 120, max_h: 120, quality: Quality::Draft, apply_crop: true };
|
||||
let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(120, 120) };
|
||||
let mut s = typical_edits();
|
||||
// every kind of edit: tone, exposure, spatial amounts, NR, WB, crop, colour — at two sizes
|
||||
let steps: Vec<Box<dyn Fn(&mut DevelopSettings)>> = vec![
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<f32> = (0..256).map(|v| srgb_to_linear(v as f32 / 255.0)).collect();
|
||||
let w = img.width;
|
||||
for_rows(&mut img.data, w, |_, row| {
|
||||
for px in row.iter_mut() {
|
||||
let lin = [lut[px[0] as usize], lut[px[1] as usize], lut[px[2] as usize]];
|
||||
let c = std::array::from_fn(|r| m[r][0] * lin[0] + m[r][1] * lin[1] + m[r][2] * lin[2]);
|
||||
let [l, ch, h] = lab_to_lch(oklab_from_2020(c));
|
||||
let a = k.weight(l, ch, h);
|
||||
let grey = 0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32;
|
||||
for c in 0..3 {
|
||||
px[c] = (grey + (px[c] as f32 - grey) * a).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{RenderRequest, SourceInfo, render};
|
||||
use lightcraft_develop::PointColor;
|
||||
use lightcraft_raster::Rgb32f;
|
||||
|
||||
#[test]
|
||||
fn 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Re
|
||||
app.ui.mask_overlay = !app.ui.mask_overlay;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.visualizeSpots" => {
|
||||
// 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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Point> {
|
||||
@@ -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:<target>`).
|
||||
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) {
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -58,6 +58,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`).
|
||||
| `GrayMixer<Band>` | `bw_mix.<band>` | |
|
||||
| `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 |
|
||||
|
||||
Reference in New Issue
Block a user