From 06d30a0e59be7dbbd4ed0b624543c92e41615bf4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:43:57 -0400 Subject: [PATCH] =?UTF-8?q?LR-EYE-PET:=20pet=20eye=20=E2=80=94=20glow=20de?= =?UTF-8?q?tection,=20dark=20pupil,=20catchlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pet eyes are found by brightness (the tapetum glows in any colour) and the disc is replaced by a dark neutral pupil (Darken scales it); redeye.catchlight {index, on, offset} adds a soft white catchlight at an offset from the pupil centre in pupil radii. Red Eye panel: Red Eye / Pet Eye mode for new corrections and an Add Catchlight checkbox. CPU/GPU twins updated together (equivalence covers both modes and the catchlight). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/masks.rs | 22 +++++++++++++++++++++ crates/engine/src/tests_color.rs | 14 ++++++++++++++ crates/gpu/src/wgsl/map.wgsl | 4 ++-- crates/pipeline/src/redeye.rs | 31 ++++++++++++++++++++++++++++-- crates/ui-egui/src/panels/right.rs | 15 +++++++++++++++ 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 6000fedde..0db1c58c4 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -321,6 +321,28 @@ pub fn specs() -> Vec { 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; diff --git a/crates/engine/src/tests_color.rs b/crates/engine/src/tests_color.rs index 995541a78..e765e0671 100644 --- a/crates/engine/src/tests_color.rs +++ b/crates/engine/src/tests_color.rs @@ -79,6 +79,20 @@ fn red_eye_commands_and_controls() { 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(); diff --git a/crates/gpu/src/wgsl/map.wgsl b/crates/gpu/src/wgsl/map.wgsl index e571557f4..9a54e7f66 100644 --- a/crates/gpu/src/wgsl/map.wgsl +++ b/crates/gpu/src/wgsl/map.wgsl @@ -155,12 +155,12 @@ fn redeye_k(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups 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); + 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 = lum2020(c) * 0.15 * (1.0 - darken); + let k = min(lum2020(c), 0.04) * (1.0 - 0.85 * darken); c = c + (vec3(k) - c) * m; } else { let red = (c.x - max(c.y, c.z)) / max(c.x, 1e-6); diff --git a/crates/pipeline/src/redeye.rs b/crates/pipeline/src/redeye.rs index f88d18118..3bad1d634 100644 --- a/crates/pipeline/src/redeye.rs +++ b/crates/pipeline/src/redeye.rs @@ -161,10 +161,10 @@ pub fn eye_pixel(c: [f32; 3], x: f32, y: f32, eyes: &[EyeK]) -> [f32; 3] { 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) }; + 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) * 0.15 * (1.0 - e.darken); + 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); @@ -265,6 +265,33 @@ mod tests { } } + #[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 }]; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 1ae589e7a..d85c1f460 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -230,6 +230,14 @@ 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.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), ); @@ -253,6 +261,13 @@ fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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}));