From ebcc41825b94291633ee893f9b102bd427665373 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:37:02 -0400 Subject: [PATCH] LR-EYE-RED: red eye correction with automatic pupil detection pipeline::redeye: inside the user's ellipse the pupil is found on the source image (a fixed grid in normalized coordinates, so previews and exports agree): weighted centroid and area of red-dominant pixels (or, for pet eyes, of the brightest ones). Pupil Size scales the detected disc; rendering in scene-linear light after white balance neutralizes the red where it is red (iris and catchlights stay) and darkens it. CPU (lin stage) and GPU kernel redeye_k with an equivalence test (both the GPU and the CPU scene-linear paths). Indexed controls redEye..pupilSize/darken, redeye.add/delete commands, the Red Eye panel (selected eye's sliders, delete) and the loupe tool (drag an ellipse, click to select). Co-Authored-By: Claude Opus 5.5 --- crates/develop/src/controls.rs | 35 +++- crates/develop/src/settings.rs | 13 ++ crates/engine/src/cmd/masks.rs | 43 ++++- crates/engine/src/tests_color.rs | 26 +++ crates/gpu/src/ctx.rs | 3 +- crates/gpu/src/render.rs | 10 +- crates/gpu/src/wgsl/map.wgsl | 46 +++++ crates/gpu/tests/equivalence.rs | 47 +++++ crates/pipeline/src/lib.rs | 8 +- crates/pipeline/src/redeye.rs | 283 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 63 +++++++ crates/ui-egui/src/panels/edit.rs | 2 +- crates/ui-egui/src/panels/right.rs | 42 ++++- crates/ui-egui/src/state.rs | 5 + 14 files changed, 614 insertions(+), 12 deletions(-) create mode 100644 crates/pipeline/src/redeye.rs diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index 35d4097e9..5696d6706 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -27,6 +27,7 @@ pub enum Section { Profile, Calibration, PointColor, + RedEye, } impl Section { @@ -47,6 +48,7 @@ impl Section { Section::Profile => "Profile", Section::Calibration => "Calibration", Section::PointColor => "Point Color", + Section::RedEye => "Red Eye", } } } @@ -335,6 +337,28 @@ pub static INDEXED: &[ControlSpec] = &[ 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`). @@ -361,13 +385,21 @@ fn indexed_field<'a>(s: &'a mut DevelopSettings, id: &str) -> Option<&'a mut f64 _ => return None, }) } + "redEye" => { + let e = s.red_eye.get_mut(i)?; + Some(match field { + "pupilSize" => &mut e.pupil_size, + "darken" => &mut e.darken, + _ => return None, + }) + } _ => None, } } fn indexed_get(s: &DevelopSettings, id: &str) -> Option { // (a clone keeps one accessor for get and set; these lists are tiny) - let mut c = DevelopSettings { point_colors: s.point_colors.clone(), ..DevelopSettings::default() }; + 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) } @@ -398,6 +430,7 @@ pub fn indexed_instances(s: &DevelopSettings) -> Vec<(String, &'static ControlSp 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 { diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index ab7d098e8..080a574e6 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -842,14 +842,27 @@ impl Default for Spot { } } +/// A red eye / pet eye correction: the user's ellipse (centre normalized, radii as fractions of +/// the long edge); the pupil inside it is found automatically. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct RedEye { pub center: Point, pub rx: f64, pub ry: f64, + /// 0..100 (50 = the detected pupil). pub pupil_size: f64, + /// 0..100. pub darken: f64, pub pet: bool, + /// Pet eye catchlight: offset of its centre from the pupil centre, in pupil radii. + #[serde(default)] + pub catchlight: Option, +} + +impl Default for RedEye { + fn default() -> Self { + Self { center: Point::new(0.5, 0.5), rx: 0.02, ry: 0.015, pupil_size: 50.0, darken: 50.0, pet: false, catchlight: None } + } } #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 70e1f759a..6000fedde 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -1,6 +1,6 @@ //! Masking and Remove (spot) commands on the active photo. -use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, Spot, SpotMode}; +use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode}; use lightcraft_geom::Point; use serde_json::{Value, json}; @@ -292,6 +292,47 @@ pub fn specs() -> Vec { s.set_develop(id, dd, "Delete Spot")?; ok() }), + // ---- Red eye / pet eye + cmd!( + "redeye.add", + "Add Red Eye Correction", + [], + None, + "{center: [x,y] normalized, rx, ry: radii as fractions of the long edge, pet?: bool, pupilSize?: 0..100, darken?: 0..100} — the pupil inside is found automatically; returns {index}", + has_active, + |s, p| { + let c = "redeye.add"; + let center = point(p, "center").ok_or_else(|| bad(c, "missing center"))?; + let d = RedEye::default(); + let eye = RedEye { + center, + rx: f64_or(p, "rx", d.rx).clamp(1e-4, 0.5), + ry: f64_or(p, "ry", d.ry).clamp(1e-4, 0.5), + pupil_size: f64_or(p, "pupilSize", d.pupil_size).clamp(0.0, 100.0), + darken: f64_or(p, "darken", d.darken).clamp(0.0, 100.0), + pet: bool_or(p, "pet", false), + catchlight: None, + }; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + dd.red_eye.push(eye); + let n = dd.red_eye.len(); + s.set_develop(id, dd, if eye.pet { "Pet Eye" } else { "Red Eye" })?; + Ok(json!({"index": n - 1})) + } + ), + cmd!("redeye.delete", "Delete Red Eye Correction", [], None, "{index}", has_active, |s, p| { + let c = "redeye.delete"; + let i = super::f64_req(p, "index", c)? as usize; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + if i >= dd.red_eye.len() { + return Err(bad(c, "no such eye")); + } + dd.red_eye.remove(i); + s.set_develop(id, dd, "Delete Red Eye")?; + ok() + }), ] } diff --git a/crates/engine/src/tests_color.rs b/crates/engine/src/tests_color.rs index 5cc58869e..995541a78 100644 --- a/crates/engine/src/tests_color.rs +++ b/crates/engine/src/tests_color.rs @@ -53,6 +53,32 @@ fn point_color_pick_and_adjust() { 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 targeted_adjustment_on_curve_and_mixer() { let mut s = demo(); diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 0d12e6caa..07072d2c7 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -37,7 +37,7 @@ const MODULES: &[Module] = &[ Module { src: include_str!("wgsl/map.wgsl"), bindings: &[("a", false, "f32"), ("b", false, "f32"), ("c", false, "f32"), ("dst", true, "f32")], - entries: &["log_lum_k", "dark_k", "guided_pre", "guided_ab", "guided_apply", "wb_k", "nr_lum", "chroma_k", "nr_col", "subsample"], + entries: &["log_lum_k", "dark_k", "guided_pre", "guided_ab", "guided_apply", "wb_k", "nr_lum", "chroma_k", "nr_col", "subsample", "redeye_k"], }, Module { src: include_str!("wgsl/mask.wgsl"), @@ -119,6 +119,7 @@ fn constants() -> String { s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]); s += &format!("const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\n", crate::params::CURVE_N); s += &format!("const POINT_WORDS: u32 = {}u;\n", lightcraft_pipeline::colorops::POINT_WORDS); + s += &format!("const EYE_WORDS: u32 = {}u;\n", lightcraft_pipeline::redeye::EYE_WORDS); s += &format!("const SHADOW_TINT_K: f32 = {:?};\n", lightcraft_pipeline::colorops::SHADOW_TINT); for (i, h) in GRAIN_HASH.iter().enumerate() { s += &format!("const GRAIN_H{i}: u32 = {h}u;\n"); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 7e0a75b0c..0174da93e 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -510,7 +510,15 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho p.extend(m.unwrap_or_default().iter().flatten().map(|v| v.to_bits())); let out = cx.gpu.buffer(n * 3); map(cx, "wb_k", n, &p, [Some(sampled), None, None], &out); - out + if plan.eyes.is_empty() { + out + } else { + let mut p = vec![w as u32, plan.eyes.len() as u32]; + p.extend(plan.eyes.iter().flat_map(|e| e.words()).map(f32::to_bits)); + let eyed = cx.gpu.buffer(n * 3); + map(cx, "redeye_k", n, &p, [Some(&out), None, None], &eyed); + eyed + } }; denoise(cx, img, plan) } diff --git a/crates/gpu/src/wgsl/map.wgsl b/crates/gpu/src/wgsl/map.wgsl index c0a4419a9..e571557f4 100644 --- a/crates/gpu/src/wgsl/map.wgsl +++ b/crates/gpu/src/wgsl/map.wgsl @@ -135,3 +135,49 @@ fn subsample(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroup } dst[i] = a[i * pu(1u)]; } + +// `redeye::eye_pixel` (a: image). P[1] = width, P[2] = eye count, then EYE_WORDS per eye: +// cx, cy, r, darken, pet, has catchlight, catchlight x, y, r (output pixels). +@compute @workgroup_size(256) +fn redeye_k(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { + let i = lin_index(g, nw); + if (i >= pu(0u)) { + return; + } + let w = pu(1u); + let x = f32(i % w) + 0.5; + let y = f32(i / w) + 0.5; + var c = rgb_a(i); + for (var e = 0u; e < pu(2u); e++) { + let o = 3u + e * EYE_WORDS; + let ex = x - pf(o); + let ey = y - pf(o + 1u); + let d = sqrt(ex * ex + ey * ey) / pf(o + 2u); + var m = 1.0 - sstep(1.0, 1.35, d); + if (pf(o + 4u) != 0.0) { + m = 1.0 - sstep(0.85, 1.1, d); + } + let darken = pf(o + 3u); + if (m > 0.0) { + if (pf(o + 4u) != 0.0) { + let k = lum2020(c) * 0.15 * (1.0 - darken); + c = c + (vec3(k) - c) * m; + } else { + let red = (c.x - max(c.y, c.z)) / max(c.x, 1e-6); + let a = m * sstep(0.25, 0.5, red); + let n = 0.5 * (c.y + c.z) * (1.0 - 0.9 * darken); + c = c + (vec3(n) - c) * a; + } + } + if (pf(o + 5u) != 0.0) { + let lx = x - pf(o + 6u); + let ly = y - pf(o + 7u); + let dc = sqrt(lx * lx + ly * ly) / pf(o + 8u); + let mc = 1.0 - sstep(0.5, 1.0, dc); + if (mc > 0.0) { + c = c + (vec3(0.9) - c) * mc; + } + } + } + put_rgb(i, c); +} diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index d4a8e21f8..f85898c18 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -403,3 +403,50 @@ fn overlays_match() { let req = RenderRequest { overlay: lightcraft_pipeline::Overlay::PointColorRange(0), ..RenderRequest::fit(640, 640) }; check("point color range overlay", &src, &raw, &s, &req); } + +#[test] +fn red_eye_matches() { + if !gpu() { + return; + } + // A face-like patch with a red pupil (left) and a glowing pet pupil (right). + let src = Arc::new(Rgb32f::from_fn(800, 400, |x, y| { + let d = |cx: f32, cy: f32| ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt(); + if d(250.0, 200.0) < 20.0 { + [0.55, 0.05, 0.04] + } else if d(560.0, 190.0) < 24.0 { + [0.5, 0.7, 0.3] + } else if d(250.0, 200.0) < 40.0 || d(560.0, 190.0) < 45.0 { + [0.12, 0.1, 0.08] + } else { + [0.45, 0.3, 0.22] + } + })); + let info = SourceInfo::default(); + let eyes = vec![ + lightcraft_develop::RedEye { center: Point::new(0.31, 0.5), rx: 0.06, ry: 0.05, darken: 70.0, ..Default::default() }, + lightcraft_develop::RedEye { + center: Point::new(0.7, 0.48), + rx: 0.06, + ry: 0.06, + pupil_size: 60.0, + pet: true, + catchlight: Some(Point::new(-0.35, -0.35)), + ..Default::default() + }, + ]; + let s = DevelopSettings { red_eye: eyes.clone(), ..Default::default() }; + check("red + pet eye", &src, &info, &s, &RenderRequest::fit(800, 800)); + let g = lightcraft_gpu::render(&src, &info, &s, &RenderRequest::fit(800, 800), None).expect("gpu").image; + let (red, pet) = (g.data[200 * 800 + 250], g.data[190 * 800 + 560]); + assert!(red[0] < 80 && red[0].abs_diff(red[1]) < 10, "red pupil fixed on the GPU: {red:?}"); + assert!(pet[1] < 80 && pet[0].abs_diff(pet[1]) < 5, "pet pupil darkened on the GPU: {pet:?}"); + check("red + pet eye (small)", &src, &info, &s, &RenderRequest::fit(300, 300)); + // with a spot the scene-linear stage runs on the CPU (eyes included) + let s = DevelopSettings { + red_eye: eyes, + spots: vec![Spot { points: vec![Point::new(0.1, 0.2)], size: 0.02, source_offset: Some(Point::new(0.05, 0.0)), ..Default::default() }], + ..Default::default() + }; + check("red eye + spot (cpu stage)", &src, &info, &s, &RenderRequest::fit(600, 600)); +} diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index cf71eb874..97b724dca 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -30,6 +30,7 @@ pub mod local; pub mod masks; pub mod optics; pub mod profiles; +pub mod redeye; pub mod spots; pub mod tone; pub mod transform; @@ -229,6 +230,8 @@ pub struct Plan<'a> { pub geo: u64, /// Key of the white-balanced, retouched, denoised image (and of the planes computed from it). pub lin_key: u64, + /// Red eye / pet eye corrections with their detected pupils, in output pixels. + pub eyes: Vec, } /// Resolve `s` against `src` for `req` (see [`Plan`]). @@ -245,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). @@ -272,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`. diff --git a/crates/pipeline/src/redeye.rs b/crates/pipeline/src/redeye.rs new file mode 100644 index 000000000..f88d18118 --- /dev/null +++ b/crates/pipeline/src/redeye.rs @@ -0,0 +1,283 @@ +//! Red eye and pet eye correction, in scene-linear light after white balance. +//! +//! The user draws an ellipse over the eye; inside it the pupil is found automatically on the +//! source image (resolution independent: a fixed grid of samples in normalized coordinates): +//! +//! - **red eye:** red-dominant pixels (`(R − max(G, B)) / (R + G + B)`) — flash light reflected by +//! the retina; +//! - **pet eye:** the brightest pixels relative to the ellipse (the tapetum's glow, any colour). +//! +//! Their weighted centroid and area give the pupil disc, scaled by Pupil Size. Rendering (shared +//! by the CPU and the GPU kernel `redeye_k`): red eye neutralizes the red inside the disc (only +//! where it is red, so iris and catchlights stay) and darkens it; pet eye replaces the disc with a +//! dark pupil and can add a catchlight. + +use lightcraft_develop::RedEye; +use lightcraft_geom::{Orientation, Point}; +use lightcraft_raster::Rgb32f; + +use crate::for_rows; +use crate::geometry::Frame; + +/// `f32`s per eye in [`EyeK::words`] (the GPU parameter layout). +pub const EYE_WORDS: usize = 9; + +/// Samples per axis of the detection grid over the ellipse's bounding box. +const GRID: usize = 40; + +/// One eye resolved for rendering, in output pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EyeK { + pub cx: f32, + pub cy: f32, + /// Pupil radius. + pub r: f32, + /// Darken 0..1. + pub darken: f32, + pub pet: bool, + /// Catchlight disc (centre, radius), pet eye only. + pub catchlight: Option<(f32, f32, f32)>, +} + +impl EyeK { + pub fn words(&self) -> [f32; EYE_WORDS] { + let (lx, ly, lr) = self.catchlight.unwrap_or((0.0, 0.0, 0.0)); + [self.cx, self.cy, self.r, self.darken, self.pet as u8 as f32, self.catchlight.is_some() as u8 as f32, lx, ly, lr] + } +} + +/// A detected pupil: centre (normalized coordinates) and radius (long-edge units). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Pupil { + pub center: Point, + pub radius: f64, + /// Whether anything was found (else: the ellipse centre and a default radius). + pub found: bool, +} + +/// Normalized point of the user-oriented image → normalized point of the source (inverse of +/// `Rgb32f::oriented`: undo the quarter turns, then the flip). +pub fn oriented_to_source(o: Orientation, p: Point) -> Point { + let (flip, turns) = o.to_parts(); + let (mut u, mut v) = (p.x, p.y); + for _ in 0..turns { + // a clockwise turn maps source (x, y) to (1 − y, x) + (u, v) = (v, 1.0 - u); + } + if flip { + u = 1.0 - u; + } + Point::new(u, v) +} + +#[inline] +fn smooth(e0: f32, e1: f32, x: f32) -> f32 { + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Find the pupil inside `eye`'s ellipse on `src` (the EXIF-oriented source; `o` = the user +/// orientation, `ow × oh` = the oriented image size in any unit). +pub fn detect(src: &Rgb32f, o: Orientation, ow: f64, oh: f64, eye: &RedEye) -> Pupil { + let long = ow.max(oh); + let (rx, ry) = (eye.rx.max(1e-4), eye.ry.max(1e-4)); + let fallback = Pupil { center: eye.center, radius: 0.5 * rx.min(ry), found: false }; + if src.width == 0 || src.height == 0 { + return fallback; + } + // samples inside the ellipse: (normalized point, colour) + let mut samples = Vec::with_capacity(GRID * GRID); + for j in 0..GRID { + for i in 0..GRID { + let (dx, dy) = ((i as f64 + 0.5) / GRID as f64 * 2.0 - 1.0, (j as f64 + 0.5) / GRID as f64 * 2.0 - 1.0); + if dx * dx + dy * dy > 1.0 { + continue; + } + let n = Point::new(eye.center.x + dx * rx * long / ow, eye.center.y + dy * ry * long / oh); + if !(0.0..1.0).contains(&n.x) || !(0.0..1.0).contains(&n.y) { + continue; + } + let s = oriented_to_source(o, n); + let (px, py) = ((s.x * src.width as f64) as usize, (s.y * src.height as f64) as usize); + samples.push((n, src.data[py.min(src.height - 1) * src.width + px.min(src.width - 1)])); + } + } + if samples.is_empty() { + return fallback; + } + let lum = |c: [f32; 3]| lightcraft_color::luminance_2020(c); + let weights: Vec = if eye.pet { + let ymax = samples.iter().map(|(_, c)| lum(*c)).fold(0.0f32, f32::max); + let mean = samples.iter().map(|(_, c)| lum(*c)).sum::() / samples.len() as f32; + if ymax <= 1e-6 || ymax < 1.5 * mean { + return fallback; + } + samples.iter().map(|(_, c)| smooth(0.5, 0.8, lum(*c) / ymax)).collect() + } else { + samples + .iter() + .map(|(_, c)| { + let s = (c[0] - c[1].max(c[2])) / (c[0] + c[1] + c[2] + 1e-6); + if c[0] > 1e-3 { smooth(0.22, 0.42, s) } else { 0.0 } + }) + .collect() + }; + let total: f32 = weights.iter().sum(); + if total < 3.0 { + return fallback; + } + let (mut x, mut y) = (0.0, 0.0); + for ((n, _), w) in samples.iter().zip(&weights) { + x += n.x * *w as f64; + y += n.y * *w as f64; + } + let frac = total as f64 / samples.len() as f64; + Pupil { center: Point::new(x / total as f64, y / total as f64), radius: (frac * rx * ry).sqrt(), found: true } +} + +/// Resolve the eyes for a `w × h` render of `frame` (`ppl` = output pixels per long-edge unit). +pub fn resolve(src: &Rgb32f, eyes: &[RedEye], orientation: Orientation, frame: &Frame, w: usize, h: usize, ppl: f64) -> Vec { + if eyes.is_empty() { + return Vec::new(); + } + let to_out = frame.norm_to_out(w, h); + eyes.iter() + .map(|e| { + let p = detect(src, orientation, frame.ow, frame.oh, e); + let r = (p.radius * (0.5 + e.pupil_size.clamp(0.0, 100.0) / 100.0)).min(e.rx.max(e.ry) * 1.1); + let c = to_out.apply(p.center); + let r_px = (r * ppl) as f32; + let catchlight = + e.catchlight.filter(|_| e.pet).map(|off| ((c.x + off.x * r * ppl) as f32, (c.y + off.y * r * ppl) as f32, (0.22 * r_px).max(0.5))); + EyeK { cx: c.x as f32, cy: c.y as f32, r: r_px.max(0.5), darken: (e.darken.clamp(0.0, 100.0) / 100.0) as f32, pet: e.pet, catchlight } + }) + .collect() +} + +/// One pixel (pixel centre `(x, y)` in output pixels) through every eye. +#[inline] +pub fn eye_pixel(c: [f32; 3], x: f32, y: f32, eyes: &[EyeK]) -> [f32; 3] { + let mut c = c; + for e in eyes { + let d = ((x - e.cx) * (x - e.cx) + (y - e.cy) * (y - e.cy)).sqrt() / e.r; + // red: generous disc (the red gate protects the iris); pet: the disc itself is replaced + let m = if e.pet { 1.0 - smooth(0.85, 1.1, d) } else { 1.0 - smooth(1.0, 1.35, d) }; + if m > 0.0 { + if e.pet { + let k = lightcraft_color::luminance_2020(c) * 0.15 * (1.0 - 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 red_pupil_is_neutralized_and_darkened_iris_kept() { + let eyes = [EyeK { cx: 50.0, cy: 50.0, r: 10.0, darken: 0.5, pet: false, catchlight: None }]; + let red = eye_pixel([0.5, 0.04, 0.05], 50.0, 50.0, &eyes); + assert!((red[0] - red[1]).abs() < 0.01 && red[0] < 0.05, "{red:?}"); + let iris = [0.12, 0.1, 0.08]; + let o = eye_pixel(iris, 52.0, 50.0, &eyes); + assert!(o.iter().zip(&iris).all(|(a, b)| (a - b).abs() < 0.02), "not red: barely touched {o:?}"); + let far = eye_pixel([0.5, 0.04, 0.05], 80.0, 50.0, &eyes); + assert_eq!(far, [0.5, 0.04, 0.05]); + // darker with more Darken + let dark = eye_pixel([0.5, 0.2, 0.2], 50.0, 50.0, &[EyeK { darken: 1.0, ..eyes[0] }]); + let light = eye_pixel([0.5, 0.2, 0.2], 50.0, 50.0, &[EyeK { darken: 0.0, ..eyes[0] }]); + assert!(dark[1] < light[1]); + } +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 3833bc2b9..e77107e60 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -44,6 +44,10 @@ 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, @@ -200,6 +204,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)); @@ -715,6 +720,64 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo } } +// ------------------------------------------------------------------------ red eye + +/// Red Eye tool: drag an ellipse over an eye (a new correction), click one to select it. +fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { + // screen points per long-edge unit + let o = map.screen(Point::new(0.0, 0.0)); + let (sx, sy) = (map.screen(Point::new(1.0, 0.0)).distance(o), map.screen(Point::new(0.0, 1.0)).distance(o)); + let per_long = sx.max(sy); + let p = ui.painter(); + for (i, e) in d.red_eye.iter().enumerate() { + let c = map.screen(e.center); + let r = vec2(e.rx as f32 * per_long, e.ry as f32 * per_long); + let sel = i == app.ui.eye; + let col = if sel { Color32::WHITE } else { Color32::from_white_alpha(150) }; + p.add(egui::Shape::ellipse_stroke(c, r, Stroke::new(if sel { 1.5 } else { 1.0 }, col))); + p.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.0, col)); + p.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.0, col)); + } + if resp.hover_pos().is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + // from where the button went down (a drag is recognized only after some movement) + app.gesture = Some(Gesture::Eye { a: map.norm(q) }); + } + if let Some(Gesture::Eye { a }) = app.gesture + && let Some(q) = resp.interact_pointer_pos() + { + let (a, b) = (map.screen(a), q); + p.add(egui::Shape::ellipse_stroke(a.lerp(b, 0.5), ((b - a) / 2.0).abs(), Stroke::new(1.0, Color32::WHITE))); + } + if resp.drag_stopped() + && let Some(Gesture::Eye { a }) = app.gesture.take() + && let Some(q) = resp.interact_pointer_pos() + { + let (sa, sb) = (map.screen(a), q); + let half = ((sb - sa) / 2.0).abs() / per_long; + if half.x > 0.002 && half.y > 0.002 { + let c = map.norm(sa.lerp(sb, 0.5)); + let r = app.run("redeye.add", json!({"center": [c.x, c.y], "rx": half.x, "ry": half.y, "pet": app.ui.eye_pet})); + if let Ok(v) = r { + app.ui.eye = v["index"].as_u64().unwrap_or(0) as usize; + } + } + } else if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + && let Some(i) = d.red_eye.iter().position(|e| { + let c = map.screen(e.center); + let (dx, dy) = ((q.x - c.x) / (e.rx as f32 * per_long).max(4.0), (q.y - c.y) / (e.ry as f32 * per_long).max(4.0)); + dx * dx + dy * dy <= 1.0 + }) + { + app.ui.eye = i; + } +} + // ------------------------------------------------------------------------ filmstrip fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 2b0190c45..12201d1ab 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -284,7 +284,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 45e652910..1ae589e7a 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -30,7 +30,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::Crop => crop(app, ui, id), RightPanel::Remove => remove(app, ui, id), RightPanel::Masking => super::masking::show(app, ui, id), - RightPanel::RedEye => red_eye(app, ui), + RightPanel::RedEye => red_eye(app, ui, id), RightPanel::Info => info(app, ui, id), RightPanel::Keywords => keywords(app, ui, id), RightPanel::Versions => versions(app, ui, id), @@ -226,13 +226,43 @@ 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.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| { + ui.horizontal(|ui| { + if text_button(ui, "eyeDelete", "Delete", false).clicked() { + let _ = app.run("redeye.delete", json!({"index": i})); + } + if n > 1 && text_button(ui, "eyeDeleteAll", "Delete all", false).clicked() { + for k in (0..n).rev() { + let _ = app.run("redeye.delete", json!({"index": k})); + } + } + }); }); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3907fb212..fc71264a7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -111,6 +111,9 @@ pub struct UiState { 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, /// Transient toast text and its expiry (seconds of app time). #[serde(skip)] pub toast: Option<(String, f64)>, @@ -181,6 +184,8 @@ impl Default for UiState { remove_size: 0.02, point_color: 0, point_color_visualize: false, + eye: 0, + eye_pet: false, toast: None, status: String::new(), dialog: None,