LR-EYE-PET: pet eye — glow detection, dark pupil, catchlight

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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 10:43:57 -04:00
co-authored by Claude Opus 5.5
parent ebcc41825b
commit 06d30a0e59
5 changed files with 82 additions and 4 deletions
+22
View File
@@ -321,6 +321,28 @@ pub fn specs() -> Vec<CommandSpec> {
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;
+14
View File
@@ -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();
+2 -2
View File
@@ -155,12 +155,12 @@ fn redeye_k(@builtin(global_invocation_id) g: vec3<u32>, @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<f32>(k) - c) * m;
} else {
let red = (c.x - max(c.y, c.z)) / max(c.x, 1e-6);
+29 -2
View File
@@ -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 }];
+15
View File
@@ -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}));