mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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:
1 parent
8b7fb306dc
commit
ebcc41825b
14 files changed
+614
-12
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user