mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 12:08:10 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ebcc41825b
commit
06d30a0e59
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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}));
|
||||
|
||||
Reference in New Issue
Block a user