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.<i>.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 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 10:37:02 -04:00
1 parent 8b7fb306dc
commit ebcc41825b
14 files changed
+614 -12

No files matched your search

+34 -1
View File
@@ -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<f64> {
// (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 {
+13
View File
@@ -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<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)]
+42 -1
View File
@@ -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<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.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()
}),
]
}
+26
View File
@@ -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();
+2 -1
View File
@@ -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");
+9 -1
View File
@@ -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)
}
+46
View File
@@ -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.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<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);
}
+47
View File
@@ -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));
}
+7 -1
View File
@@ -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<redeye::EyeK>,
}
/// 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`.
+283
View File
@@ -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<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.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]);
}
}
+63
View File
@@ -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) {
+1 -1
View File
@@ -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);
+36 -6
View File
@@ -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}));
}
}
});
});
}
+5
View File
@@ -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,