M8.3: render heal/clone/remove spots (frequency-separation heal, auto source search)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 23:28:58 -04:00
parent ca35e7f888
commit c0655d6fe6
2 changed files with 218 additions and 1 deletions

View File

@ -23,6 +23,7 @@ pub mod geometry;
mod local;
pub mod masks;
pub mod profiles;
pub mod spots;
mod tone;
use lightcraft_develop::{DevelopSettings, Treatment};
@ -113,7 +114,8 @@ pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &Render
let px_per_long = frame.px_per_long(w);
local::scene_linear_pre(&mut img, info, s);
lap("wb/exposure", &mut t);
spots::apply(&mut img, &s.spots, &frame, px_per_long);
lap("wb/exposure/spots", &mut t);
let prep = local::prepare(img, s, &frame, px_per_long, req.quality);
lap("prepare", &mut t);
let image = finish::finish(&prep, s, &frame, info);

View File

@ -0,0 +1,215 @@
//! Remove / Heal / Clone spots, rendered at output resolution in scene-linear light.
//!
//! - **Clone** copies the source patch with a feathered edge.
//! - **Heal** copies the source's *detail* and keeps the target's *tone*: result = src + blur(target − src)
//! (frequency separation — a fast, stable approximation of gradient-domain healing).
//! - **Remove** is Heal with an automatically chosen source: candidate offsets on rings around the
//! spot are scored by how well the source's surrounding annulus matches the target's.
use lightcraft_develop::{Spot, SpotMode};
use lightcraft_geom::Point;
use lightcraft_raster::Rgb32f;
use crate::geometry::Frame;
#[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)
}
/// Mean colour of `img` in a disc/annulus.
fn ring_stats(img: &Rgb32f, c: (f32, f32), r0: f32, r1: f32) -> Option<Vec<[f32; 3]>> {
let mut out = Vec::new();
let n = 24;
for i in 0..n {
let a = i as f32 / n as f32 * std::f32::consts::TAU;
for rr in [r0, (r0 + r1) / 2.0, r1] {
let (x, y) = (c.0 + a.cos() * rr, c.1 + a.sin() * rr);
if x < 0.0 || y < 0.0 || x >= img.width as f32 || y >= img.height as f32 {
return None;
}
out.push(img.sample_bilinear(x, y));
}
}
Some(out)
}
fn score(img: &Rgb32f, target: (f32, f32), source: (f32, f32), r: f32) -> f32 {
let (Some(a), Some(b)) = (ring_stats(img, target, r * 1.05, r * 1.6), ring_stats(img, source, r * 1.05, r * 1.6)) else { return f32::MAX };
// also compare the inside of the source against the target's surroundings (texture continuity)
let inside = ring_stats(img, source, r * 0.2, r * 0.9);
let mut s = 0.0;
for (p, q) in a.iter().zip(&b) {
for c in 0..3 {
let (u, v) = (p[c].max(0.0).sqrt(), q[c].max(0.0).sqrt());
s += (u - v) * (u - v);
}
}
if let Some(ins) = inside {
let mean_a: f32 = a.iter().map(|p| p[1].max(0.0).sqrt()).sum::<f32>() / a.len() as f32;
let var: f32 = ins.iter().map(|p| (p[1].max(0.0).sqrt() - mean_a).powi(2)).sum::<f32>() / ins.len() as f32;
s += var * a.len() as f32 * 0.5;
}
s
}
/// Pick a source offset (in output pixels) for a remove/heal spot.
pub fn auto_source(img: &Rgb32f, target: (f32, f32), r: f32) -> (f32, f32) {
let mut best = (f32::MAX, (r * 2.5, 0.0));
for ring in [2.4f32, 3.2, 4.2] {
for i in 0..16 {
let a = i as f32 / 16.0 * std::f32::consts::TAU;
let d = (a.cos() * r * ring, a.sin() * r * ring);
let s = score(img, target, (target.0 + d.0, target.1 + d.1), r);
if s < best.0 {
best = (s, d);
}
}
}
best.1
}
/// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit.
pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) {
if spots.is_empty() {
return;
}
let (w, h) = (img.width, img.height);
let to_out = frame.norm_to_out(w, h);
for spot in spots {
let r = (spot.size * ppl).max(1.0) as f32;
let pts: Vec<(f32, f32)> = spot
.points
.iter()
.map(|p| {
let q = to_out.apply(*p);
(q.x as f32, q.y as f32)
})
.collect();
let Some(&first) = pts.first() else { continue };
let offset = match spot.source_offset {
Some(o) => {
let a = to_out.apply(Point::new(0.0, 0.0));
let b = to_out.apply(o);
((b.x - a.x) as f32, (b.y - a.y) as f32)
}
None => auto_source(img, first, r),
};
// Bounding box of the stroke.
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for p in &pts {
x0 = x0.min(p.0 - r);
y0 = y0.min(p.1 - r);
x1 = x1.max(p.0 + r);
y1 = y1.max(p.1 + r);
}
let pad = r;
let (bx0, by0) = (((x0 - pad).floor().max(0.0)) as usize, ((y0 - pad).floor().max(0.0)) as usize);
let (bx1, by1) = (((x1 + pad).ceil() as usize).min(w), ((y1 + pad).ceil() as usize).min(h));
if bx1 <= bx0 || by1 <= by0 {
continue;
}
let (bw, bh) = (bx1 - bx0, by1 - by0);
let feather = (spot.feather / 100.0).clamp(0.0, 1.0) as f32;
let opacity = (spot.opacity / 100.0).clamp(0.0, 1.0) as f32;
// alpha of the stroke and source patch in the box
let mut alpha = vec![0.0f32; bw * bh];
let mut src = vec![[0.0f32; 3]; bw * bh];
let mut tgt = vec![[0.0f32; 3]; bw * bh];
for y in 0..bh {
for x in 0..bw {
let (px, py) = ((bx0 + x) as f32 + 0.5, (by0 + y) as f32 + 0.5);
let d = pts.iter().map(|p| ((px - p.0).powi(2) + (py - p.1).powi(2)).sqrt()).fold(f32::MAX, f32::min);
let inner = r * (1.0 - feather * 0.8);
alpha[y * bw + x] = (1.0 - smooth(inner, r, d)) * opacity;
src[y * bw + x] = img.sample_bilinear(px + offset.0, py + offset.1);
tgt[y * bw + x] = img.get(bx0 + x, by0 + y);
}
}
let heal = spot.mode != SpotMode::Clone;
let low = if heal {
// Tone correction from the spot's *surroundings* only: normalized convolution of
// (target − source) over pixels outside the stroke (a membrane-like interpolation).
let outside: Vec<f32> = alpha.iter().map(|a| if *a < 0.02 { 1.0 } else { 0.0 }).collect();
let diff = Rgb32f {
width: bw,
height: bh,
data: tgt.iter().zip(&src).zip(&outside).map(|((t, s), m)| [(t[0] - s[0]) * m, (t[1] - s[1]) * m, (t[2] - s[2]) * m]).collect(),
};
let wts = lightcraft_raster::Plane { width: bw, height: bh, data: outside };
let sigma = (r * 0.6).max(1.0);
let (bd, bm) = (lightcraft_raster::blur::gaussian(&diff, sigma), lightcraft_raster::blur::gaussian(&wts, sigma));
Some(Rgb32f {
width: bw,
height: bh,
data: bd
.data
.iter()
.zip(&bm.data)
.map(|(d, m)| {
let m = m.max(1e-4);
[d[0] / m, d[1] / m, d[2] / m]
})
.collect(),
})
} else {
None
};
for y in 0..bh {
for x in 0..bw {
let i = y * bw + x;
let a = alpha[i];
if a <= 0.0 {
continue;
}
let mut v = src[i];
if let Some(l) = &low {
let d = l.data[i];
v = [v[0] + d[0], v[1] + d[1], v[2] + d[2]];
}
let t = tgt[i];
img.set(bx0 + x, by0 + y, [t[0] + (v[0] - t[0]) * a, t[1] + (v[1] - t[1]) * a, t[2] + (v[2] - t[2]) * a].map(|c| c.max(0.0)));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_develop::DevelopSettings;
#[test]
fn remove_erases_a_dot() {
// flat grey with a gentle gradient and a black dot in the middle
let mut img = Rgb32f::from_fn(120, 80, |x, _| [0.2 + x as f32 * 0.001; 3]);
for y in 36..44 {
for x in 56..64 {
img.set(x, y, [0.0; 3]);
}
}
let frame = Frame::new(120, 80, &DevelopSettings::default(), true);
let spot = Spot { points: vec![Point::new(0.5, 0.5)], size: 8.0 / 120.0, feather: 30.0, ..Default::default() };
apply(&mut img, &[spot], &frame, frame.px_per_long(120));
let c = img.get(60, 40);
assert!((c[0] - 0.26).abs() < 0.03, "{c:?}");
}
#[test]
fn clone_copies_source() {
let mut img = Rgb32f::from_fn(100, 100, |x, _| if x < 50 { [0.1; 3] } else { [0.9; 3] });
let frame = Frame::new(100, 100, &DevelopSettings::default(), true);
let spot = Spot {
mode: SpotMode::Clone,
points: vec![Point::new(0.25, 0.5)],
size: 0.05,
feather: 0.0,
source_offset: Some(Point::new(0.5, 0.0)),
..Default::default()
};
apply(&mut img, &[spot], &frame, frame.px_per_long(100));
assert!(img.get(25, 50)[0] > 0.8);
assert!(img.get(5, 50)[0] < 0.2);
}
}