LR-MASK-BRUSH + LR-MASK-SLIDERS: Auto Mask brush and local Noise / Moiré / Defringe on CPU and GPU

Auto Mask strokes weight each dab by similarity (log luminance + chromaticity) to the pixel under
its centre and are refined by a cross guided filter on log luminance (max-combined, never removing
paint). Local Noise smooths small-amplitude luminance detail (negative boosts it) and calms colour
noise; Moiré blends chromaticity towards a blurred plane (appended to the GPU mask buffer);
Defringe desaturates purple/green casts on edges. Pixel tests per slider and for the edge
constraint, CPU/GPU equivalence cases, Auto Mask checkbox and Moiré/Defringe sliders in the panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 13:09:14 -04:00
parent ca8db6e028
commit 8c1c7282b2
15 changed files with 610 additions and 60 deletions

View File

@ -37,7 +37,22 @@ 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", "redeye_k"],
entries: &[
"log_lum_k",
"dark_k",
"guided_pre",
"guided_ab",
"guided_apply",
"wb_k",
"nr_lum",
"chroma_k",
"nr_col",
"subsample",
"redeye_k",
"xguided_pre",
"xguided_ab",
"xguided_apply",
],
},
Module {
src: include_str!("wgsl/mask.wgsl"),
@ -121,7 +136,7 @@ pub struct Gpu {
/// WGSL constants shared by every module (generated from the CPU pipeline's values).
fn constants() -> String {
use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_TERMS, SRGB_LUT_N};
use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_SUMS, MASK_TERMS, SRGB_LUT_N};
use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N};
let mut s = String::new();
let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices();
@ -134,9 +149,14 @@ fn constants() -> String {
s += &format!("const TONE_MIN_GAIN: f32 = {:?};\n", 2f32.powf(LUT_MIN_EV));
let b = lightcraft_pipeline::geometry::BLANK;
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 SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\nconst MASK_SUMS: u32 = {MASK_SUMS}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);
use lightcraft_pipeline::masks::{AUTO_TOL_CHROMA, AUTO_TOL_EV};
s += &format!("const AUTO_TOL_EV: f32 = {AUTO_TOL_EV:?};\nconst AUTO_TOL_CHROMA: f32 = {AUTO_TOL_CHROMA:?};\n");
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");

View File

@ -31,6 +31,7 @@ const FIELDS: &[(&str, usize)] = &[
("HAS_CLAR", 1),
("HAS_TEX", 1),
("HAS_DARK", 1),
("HAS_CHROMA", 1),
("OPS_IDENTITY", 1),
("VIBRANCE", 1),
("SATURATION", 1),
@ -123,6 +124,8 @@ pub struct Present {
pub clarity: bool,
pub texture: bool,
pub dark: bool,
/// The blurred chromaticity follows the mask planes in the `masks` buffer.
pub chroma: bool,
}
/// The `finish` kernel's parameter block and auxiliary table (tone LUT | sRGB LUT | curve LUTs |
@ -166,6 +169,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
p.b("HAS_CLAR", present.clarity);
p.b("HAS_TEX", present.texture);
p.b("HAS_DARK", present.dark);
p.b("HAS_CHROMA", present.chroma);
let ops = &fp.ops;
p.b("OPS_IDENTITY", ops.is_identity());

View File

@ -44,6 +44,7 @@ struct Planes {
clarity: Option<(u32, Arc<Buf>)>,
texture: Option<(u32, Arc<Buf>)>,
dark: Option<(u32, Arc<Buf>, f32)>,
chroma: Option<(u32, Arc<Buf>)>,
}
impl GpuStages {
@ -143,6 +144,12 @@ impl<'a> Cx<'a> {
out
}
/// Copy all of `src` into `dst` at `offset` (32-bit words; recorded).
pub fn copy_into(&mut self, src: &Buf, dst: &Buf, offset: usize) {
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
enc.copy_buffer_to_buffer(src.raw(), 0, dst.raw(), (offset * 4) as u64, (src.len * 4) as u64);
}
pub fn read_rgb(&mut self, b: &Buf, w: usize, h: usize) -> Rgb32f {
Rgb32f { width: w, height: h, data: self.read(b, w * h * 3) }
}
@ -469,7 +476,12 @@ pub fn render(
// 5. per-pixel stage
let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air);
let present = Present { clarity: prep.clarity.is_some(), texture: prep.texture.is_some(), dark: prep.dark.is_some() };
let present = Present {
clarity: prep.clarity.is_some(),
texture: prep.texture.is_some(),
dark: prep.dark.is_some(),
chroma: masks.is_some() && prep.chroma.is_some(),
};
let (p, aux) = finish_block(&fp, &terms, &present);
let aux = gpu.upload(&aux);
let out = gpu.buffer(n);
@ -580,6 +592,8 @@ struct Prep {
clarity: Option<Arc<Buf>>,
texture: Option<Arc<Buf>>,
dark: Option<Arc<Buf>>,
/// Blurred chromaticity (local Moiré / Noise).
chroma: Option<Arc<Buf>>,
air: f32,
}
@ -634,14 +648,58 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla
}
},
};
Prep { log_l, base, clarity, texture, dark, air }
let chroma = sig.chroma.map(|sg| {
plane_at(&mut planes.chroma, sg, || {
let ch = cx.gpu.buffer(n * 3);
map(cx, "chroma_k", n, &[], [Some(lin), None, None], &ch);
gaussian(cx, &ch, w, h, 3, sg)
})
});
Prep { log_l, base, clarity, texture, dark, chroma, air }
}
/// Most brush dabs the mask kernel evaluates per pixel (more: the CPU rasterizes the brush).
const MAX_DABS: usize = 4096;
/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated)
/// and their adjustment terms. Shapes without a kernel (Sky, Subject, …) run on the CPU.
/// The `shape` kernel's brush data for `dabs`: stroke records (12 words: dab offset, dab count,
/// r, hard, flow, density, erase, bbox x0 y0 x1 y1, auto) then the dab centres.
fn brush_aux(dabs: &[&lightcraft_pipeline::masks::BrushDabs], erase: bool) -> Vec<f32> {
let mut aux = Vec::new();
let mut off = 12 * dabs.len();
for d in dabs {
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for q in &d.dabs {
(x0, y0, x1, y1) = (x0.min(q.x - d.r), y0.min(q.y - d.r), x1.max(q.x + d.r), y1.max(q.y + d.r));
}
aux.extend([off as f32, d.dabs.len() as f32, d.r as f32, d.hard as f32, d.flow, d.density, (erase && d.erase) as u8 as f32]);
aux.extend([x0, y0, x1, y1].map(|v| v as f32));
aux.push(d.auto as u8 as f32);
off += 2 * d.dabs.len();
}
for d in dabs {
aux.extend(d.dabs.iter().flat_map(|q| [q.x as f32, q.y as f32]));
}
aux
}
/// Cross guided filter (`masks::guided_cross`) of `p` steered by `guide`, max-combined with `p`.
fn guided_cross_max(cx: &mut Cx<'_>, guide: &Buf, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf {
let n = w * h;
let (m0, m1) = (cx.gpu.buffer(2 * n), cx.gpu.buffer(2 * n));
map(cx, "xguided_pre", n, &[0], [Some(guide), Some(p), None], &m0);
map(cx, "xguided_pre", n, &[1], [Some(guide), Some(p), None], &m1);
let (b0, b1) = (gaussian(cx, &m0, w, h, 2, sigma), gaussian(cx, &m1, w, h, 2, sigma));
let ab = cx.gpu.buffer(2 * n);
map(cx, "xguided_ab", n, &[eps.to_bits()], [Some(&b0), Some(&b1), None], &ab);
let ab = gaussian(cx, &ab, w, h, 2, sigma);
let q = cx.gpu.buffer(n);
map(cx, "xguided_apply", n, &[], [Some(guide), Some(&ab), Some(p)], &q);
q
}
/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated,
/// followed by the blurred chromaticity when local Moiré / Noise need it) and their adjustment
/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU.
fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Host) -> (Option<Buf>, Vec<[f32; MASK_TERMS]>) {
use lightcraft_develop::{MaskOp, MaskShape};
let s = &*plan.settings;
@ -657,7 +715,10 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
let [a, b, c, d, e, f] = frame.out_to_norm(w, h).0;
let (kx, ky) = (frame.ow / long, frame.oh / long);
let aff = [a * kx, b * ky, c * kx, d * ky, e * kx, f * ky].map(|v| (v as f32).to_bits());
let alpha = cx.zeroed(n * list.len());
let alpha = cx.zeroed(n * list.len() + if prep.chroma.is_some() { 3 * n } else { 0 });
if let Some(ch) = &prep.chroma {
cx.copy_into(ch, &alpha, n * list.len());
}
let comp_plane = cx.gpu.buffer(n);
for (mi, m) in list.iter().enumerate() {
let mut first = true;
@ -673,6 +734,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
p.extend_from_slice(&aff);
let f32s = |v: &[f64]| v.iter().map(|x| (*x as f32).to_bits()).collect::<Vec<u32>>();
let mut aux: Vec<f32> = Vec::new();
let mut own: Option<Buf> = None;
let kind = match &comp.shape {
MaskShape::Linear { start, end } => {
let (a, b) = (frame.norm_to_long(*start), frame.norm_to_long(*end));
@ -700,29 +762,46 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
}
MaskShape::Brush { strokes } => {
let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect();
if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() <= MAX_DABS {
p.push(dabs.len() as u32);
let mut off = 11 * dabs.len();
for d in &dabs {
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for q in &d.dabs {
(x0, y0, x1, y1) = (x0.min(q.x - d.r), y0.min(q.y - d.r), x1.max(q.x + d.r), y1.max(q.y + d.r));
}
aux.extend([off as f32, d.dabs.len() as f32, d.r as f32, d.hard as f32, d.flow, d.density, d.erase as u8 as f32]);
aux.extend([x0, y0, x1, y1].map(|v| v as f32));
off += 2 * d.dabs.len();
}
for d in &dabs {
aux.extend(d.dabs.iter().flat_map(|q| [q.x as f32, q.y as f32]));
}
Some(4)
} else {
if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() > MAX_DABS {
None
} else if dabs.iter().any(|d| d.auto) {
// Auto Mask: stroke by stroke (each one refined before it's combined)
let brush = cx.zeroed(n);
let stroke = cx.gpu.buffer(n);
for d in &dabs {
let mut q = p.clone();
q[2] = 4;
q[3] = 0;
q.push(1);
let aux = cx.gpu.upload(&brush_aux(&[d], false));
cx.run("shape", &q, &[Some(lin), Some(&prep.log_l), Some(&aux), Some(&stroke), Some(&brush)], groups2(w, h, [16, 16]));
let refined = d.auto.then(|| {
let (sigma, eps) = lightcraft_pipeline::masks::auto_refine(d.r);
guided_cross_max(cx, &prep.log_l, &stroke, w, h, sigma, eps)
});
let op = if d.erase { 2 } else { 1 };
cx.run(
"combine",
&[n as u32, 0, 0, op],
&[None, None, None, Some(refined.as_ref().unwrap_or(&stroke)), Some(&brush)],
groups1(n),
);
}
if comp.invert {
cx.run("finalize", &[n as u32, 0, 0, 1, 1f32.to_bits()], &[None, None, None, Some(&stroke), Some(&brush)], groups1(n));
}
own = Some(brush);
None
} else {
p.push(dabs.len() as u32);
aux = brush_aux(&dabs.iter().collect::<Vec<_>>(), true);
Some(4)
}
}
_ => None,
};
let plane = match kind {
_ if own.is_some() => own,
Some(k) => {
p[2] = k;
let aux = (!aux.is_empty()).then(|| cx.gpu.upload(&aux));

View File

@ -1,5 +1,6 @@
// The per-pixel stage: a straight port of `lightcraft_pipeline::finish` (keep in step with it).
// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes), aux
// Bindings: img (rgb, pre-exposure), log_l, base, clar, tex, dark, masks (NMASK planes, then the
// blurred chromaticity when HAS_CHROMA), aux
// (tone LUT | sRGB LUT | curve LUTs | mask terms), out (packed RGBA8).
fn tone_apply(y: f32) -> f32 {
@ -239,7 +240,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let l0 = l_pre + ev;
// --- local (mask) contributions
var lt: array<f32, 14>;
var lt: array<f32, MASK_SUMS>;
var tint_on = false;
var tint_dir = vec2<f32>(0.0, 0.0);
var tint_amt = 0.0;
@ -250,18 +251,42 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
continue;
}
let t = pu(F_MASK_OFF) + m * MASK_TERMS;
for (var k = 0u; k < 14u; k++) {
for (var k = 0u; k < MASK_SUMS; k++) {
lt[k] += a * aux[t + k];
}
if (aux[t + 14u] > 0.0) {
if (aux[t + MASK_SUMS] > 0.0) {
tint_on = true;
tint_dir = vec2<f32>(aux[t + 15u], aux[t + 16u]);
tint_amt = a * aux[t + 17u];
tint_dir = vec2<f32>(aux[t + MASK_SUMS + 1u], aux[t + MASK_SUMS + 2u]);
tint_amt = a * aux[t + MASK_SUMS + 3u];
}
}
let l_exp = lt[0];
let l_temp = lt[1];
let l_tint = lt[2];
let l_noise = lt[14];
// --- local Moiré (and the colour part of Noise): chromaticity towards its blur
let mt = clamp(lt[15] + 0.5 * max(l_noise, 0.0), -1.0, 1.0);
if (mt != 0.0 && pu(F_HAS_CHROMA) != 0u) {
let raw = vec3<f32>(img[3u * i], img[3u * i + 1u], img[3u * i + 2u]);
let y = lum2020(c);
let ch0 = raw / max(lum2020(raw), 1e-6);
let o = nm * n + 3u * i;
let cb = vec3<f32>(masks[o], masks[o + 1u], masks[o + 2u]);
c = max((ch0 + (cb - ch0) * mt) * y, vec3<f32>(0.0));
}
// --- local Defringe: desaturate purple / green fringes along edges
let df = clamp(lt[16], 0.0, 1.0);
if (df > 0.0 && pu(F_HAS_TEX) != 0u) {
let yd = max(lum2020(c), 1e-6);
let purple = (min(c.x, c.z) - c.y) / yd;
let green = (c.y - max(c.x, c.z)) / yd;
let k = df * sstep(0.04, 0.3, abs(l_pre - tex[i])) * max(sstep(0.02, 0.2, purple), sstep(0.02, 0.2, green));
if (k > 0.0) {
let y = lum2020(c);
c = c + (y - c) * k;
}
}
// --- dehaze (scene linear)
let dz = pf(F_DEHAZE) + lt[10];
@ -334,6 +359,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
delta += sp * 1.3 * clamp(det, -0.8, 0.8) * mk;
}
}
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
if (l_noise != 0.0 && pu(F_HAS_TEX) != 0u) {
let det = l_pre - tex[i];
delta -= clamp(l_noise, -1.0, 1.0) * 0.9 * det * (1.0 - sstep(0.1, 0.5, abs(det)));
}
if (delta != 0.0) {
c = c * exp2(delta);
}

View File

@ -70,6 +70,51 @@ fn guided_apply(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgr
dst[i] = b[2u * i] * a[i] + b[2u * i + 1u];
}
// Cross guided filter (`masks::guided_cross`), a: guide, b: input. P[1] = 0: (guide, input);
// 1: (guide·input, guide²).
@compute @workgroup_size(256)
fn xguided_pre(@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 gi = a[i];
let p = b[i];
if (pu(1u) == 0u) {
dst[2u * i] = gi;
dst[2u * i + 1u] = p;
} else {
dst[2u * i] = gi * p;
dst[2u * i + 1u] = gi * gi;
}
}
// Coefficients (a, b) from blurred means (a: guide, input) and correlations (b: guide·input,
// guide²). P[1] = eps.
@compute @workgroup_size(256)
fn xguided_ab(@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 mi = a[2u * i];
let mp = a[2u * i + 1u];
let v = max(b[2u * i + 1u] - mi * mi, 0.0);
let k = (b[2u * i] - mi * mp) / (v + pf(1u));
dst[2u * i] = k;
dst[2u * i + 1u] = mp - k * mi;
}
// `max(p, clamp(a·guide + b, 0, 1))` (a: guide, b: blurred coefficients, c: the input p).
@compute @workgroup_size(256)
fn xguided_apply(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>) {
let i = lin_index(g, nw);
if (i >= pu(0u)) {
return;
}
dst[i] = max(c[i], clamp(b[2u * i] * a[i] + b[2u * i + 1u], 0.0, 1.0));
}
// White balance: 3×3 matrix (P[1] = has matrix, P[2..11] row-major), clamped at 0.
@compute @workgroup_size(256)
fn wb_k(@builtin(global_invocation_id) g: vec3<u32>, @builtin(num_workgroups) nw: vec3<u32>) {

View File

@ -13,6 +13,19 @@ fn pos(x: u32, y: u32) -> vec2<f32> {
// 2 luminance range: lo, hi, lo feather, hi feather, ev
// 3 colour range: tol, gain, sample count (OkLab triples in aux)
// 4 brush: stroke count (stroke records + dabs in aux)
// `masks::chromaticity` of pixel i.
fn chroma_at(i: u32) -> vec3<f32> {
let c = vec3<f32>(img[3u * i], img[3u * i + 1u], img[3u * i + 2u]);
return c / max(lum2020(c), 1e-6);
}
// `masks::auto_similarity`.
fn auto_similarity(l: f32, ch: vec3<f32>, rl: f32, rch: vec3<f32>) -> f32 {
let dl = (l - rl) / AUTO_TOL_EV;
let dc = length(ch - rch) / AUTO_TOL_CHROMA;
return 1.0 - sstep(0.5, 1.0, sqrt(dl * dl + dc * dc));
}
@compute @workgroup_size(16, 16)
fn shape(@builtin(global_invocation_id) g: vec3<u32>) {
let w = pu(0u);
@ -64,11 +77,14 @@ fn shape(@builtin(global_invocation_id) g: vec3<u32>) {
}
v = 1.0 - sstep(tol * 0.5, tol, best);
} else if (kind == 4u) {
// stroke record: dab offset, dab count, r, hard, flow, density, erase, bbox x0 y0 x1 y1
// stroke record: dab offset, dab count, r, hard, flow, density, erase, bbox x0 y0 x1 y1, auto
let px = f32(g.x) + 0.5;
let py = f32(g.y) + 0.5;
let lp = log_l[i];
let chp = chroma_at(i);
for (var k = 0u; k < pu(10u); k++) {
let o = 11u * k;
let o = 12u * k;
let auto_mask = aux[o + 11u] != 0.0;
if (px < aux[o + 7u] || py < aux[o + 8u] || px > aux[o + 9u] || py > aux[o + 10u]) {
continue;
}
@ -89,6 +105,12 @@ fn shape(@builtin(global_invocation_id) g: vec3<u32>) {
if (dd > hard) {
a = 1.0 - sstep(hard, r, dd);
}
if (auto_mask) {
let rx = min(u32(max(floor(aux[off + 2u * j]), 0.0)), w - 1u);
let ry = min(u32(max(floor(aux[off + 2u * j + 1u]), 0.0)), h - 1u);
let ri = ry * w + rx;
a *= auto_similarity(lp, chp, log_l[ri], chroma_at(ri));
}
keep *= 1.0 - a * flow;
}
let sa = min(1.0 - keep, aux[o + 5u]);

View File

@ -275,6 +275,37 @@ fn cases() -> Vec<(&'static str, Edit)> {
},
];
}),
("masks (local noise, moiré, defringe)", |s| {
let radial = MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.3, ry: 0.25, angle: 0.0, feather: 50.0, invert: false };
let linear = MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.7) };
let m = |shape, adjust| Mask { components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() };
use lightcraft_develop::LocalAdjustments as L;
s.masks = vec![
m(radial, L { noise: 80.0, moire: 60.0, defringe: 100.0, ..Default::default() }),
m(linear, L { noise: -60.0, moire: -40.0, ..Default::default() }),
];
}),
("masks (auto mask brush)", |s| {
let st = |pts: &[(f64, f64)], auto_mask, erase| BrushStroke {
points: pts.iter().map(|p| Point::new(p.0, p.1)).collect(),
size: 0.06,
feather: 40.0,
flow: 80.0,
auto_mask,
erase,
..Default::default()
};
let strokes = vec![
st(&[(0.2, 0.55), (0.5, 0.6), (0.8, 0.5)], true, false),
st(&[(0.3, 0.2), (0.6, 0.25)], false, false),
st(&[(0.5, 0.58)], true, true),
];
s.masks = vec![Mask {
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }],
adjust: lightcraft_develop::LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() },
..Default::default()
}];
}),
("calibration", |s| {
s.calibration.shadows_tint = 40.0;
s.calibration.red_hue = 50.0;

View File

@ -105,12 +105,15 @@ fn grain_noise(x: f32, y: f32, seed: u32) -> f32 {
}
/// Number of per-mask terms in [`mask_terms`].
pub const MASK_TERMS: usize = 18;
pub const MASK_TERMS: usize = 21;
/// Number of alpha-weighted terms at the start of [`mask_terms`].
pub const MASK_SUMS: usize = 17;
/// A mask's local adjustments as the per-pixel stage uses them (alpha-weighted sums), in order:
/// exposure, temp, tint, contrast, highlights, shadows, whites, blacks, texture, clarity, dehaze,
/// saturation, hue, sharpness, then the colour overlay: on (0/1), cos and sin of its OkLCh hue, and
/// its strength.
/// saturation, hue, sharpness, noise, moiré, defringe, then the colour overlay: on (0/1), cos and
/// sin of its OkLCh hue, and its strength.
pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] {
let (on, cos, sin, amt) = if j.color_sat > 0.0 {
let hue = crate::colorops::oklch_hue_of_srgb_hue(j.color_hue);
@ -133,6 +136,9 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] {
(j.saturation / 100.0) as f32,
(j.hue / 100.0) as f32 * 0.6,
(j.sharpness / 100.0) as f32,
(j.noise / 100.0) as f32,
(j.moire / 100.0) as f32,
(j.defringe / 100.0) as f32,
on,
cos,
sin,
@ -250,21 +256,42 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
let l0 = l_pre + ev;
// --- local (mask) contributions: alpha-weighted sums of the masks' terms
let mut l = [0.0f32; 14];
let mut l = [0.0f32; MASK_SUMS];
let mut tint_col: Option<([f32; 3], f32)> = None;
for (m, t) in p.masks.iter().zip(&terms) {
let a = m.alpha.data[i];
if a <= 0.0 {
continue;
}
for k in 0..14 {
for k in 0..MASK_SUMS {
l[k] += a * t[k];
}
if t[14] > 0.0 {
tint_col = Some(([t[15], t[16], 0.0], a * t[17]));
if t[MASK_SUMS] > 0.0 {
tint_col = Some(([t[MASK_SUMS + 1], t[MASK_SUMS + 2], 0.0], a * t[MASK_SUMS + 3]));
}
}
let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp, l_noise, l_moire, l_defringe] =
l;
// --- local Moiré (and the colour part of Noise): chromaticity towards its blur
let mt = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0);
if mt != 0.0
&& let Some(cb) = &p.chroma_blur
{
let (y, ch0, cb) = (luminance_2020(c), crate::masks::chromaticity(raw), cb.data[i]);
c = std::array::from_fn(|k| ((ch0[k] + (cb[k] - ch0[k]) * mt) * y).max(0.0));
}
// --- local Defringe: desaturate purple / green fringes along edges
let df = l_defringe.clamp(0.0, 1.0);
if df > 0.0
&& let Some(b) = &p.texture_blur
{
let k = df * defringe_weight(c, p.log_l.data[i] - b.data[i]);
if k > 0.0 {
let y = luminance_2020(c);
c = c.map(|v| v + (y - v) * k);
}
}
let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp] = l;
// --- dehaze (scene linear)
let dz = dehaze + l_dehaze;
@ -334,6 +361,13 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m;
}
}
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
if l_noise != 0.0
&& let Some(b) = &p.texture_blur
{
let det = l_pre - b.data[i];
delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * det * (1.0 - smooth(0.1, 0.5, det.abs()));
}
if delta != 0.0 {
let g = delta.exp2();
c = c.map(|v| v * g);
@ -433,6 +467,16 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
out
}
/// Local Defringe weight of a scene-linear colour `c` whose log luminance differs by `det` from
/// its fine blur: high on strong edges with a purple or green cast.
#[inline]
pub fn defringe_weight(c: [f32; 3], det: f32) -> f32 {
let y = luminance_2020(c).max(1e-6);
let purple = (c[0].min(c[2]) - c[1]) / y;
let green = (c[1] - c[0].max(c[2])) / y;
smooth(0.04, 0.3, det.abs()) * smooth(0.02, 0.2, purple).max(smooth(0.02, 0.2, green))
}
/// Refine Saturation: scale the curved colour's chroma (around its luma, encoded values) so its
/// saturation (chroma / luma) moves from the curve's towards the pre-curve one: the ratio is
/// `(s_curve / s_before)^refine`, so 1 keeps the curve, 0 restores the original saturation.

View File

@ -110,6 +110,8 @@ pub(crate) struct Prepared {
pub clarity_blur: Option<Arc<Plane>>,
pub texture_blur: Option<Arc<Plane>>,
pub dark: Option<Arc<Plane>>,
/// Blurred chromaticity (`rgb / Y`) for local Moiré / Noise.
pub chroma_blur: Option<Arc<Rgb32f>>,
/// Airlight of `dark` (before exposure).
pub air: f32,
pub masks: Vec<masks::Evaluated>,
@ -402,3 +404,5 @@ pub(crate) fn for_rows<T: Send>(data: &mut [T], w: usize, f: impl Fn(usize, &mut
mod tests;
#[cfg(test)]
mod tests_geometry;
#[cfg(test)]
mod tests_local;

View File

@ -208,6 +208,8 @@ pub(crate) struct Planes {
pub texture: Option<(u32, Arc<Plane>)>,
/// Dark channel and its airlight.
pub dark: Option<(u32, Arc<Plane>, f32)>,
/// Blurred chromaticity (local Moiré / Noise).
pub chroma: Option<(u32, Arc<Rgb32f>)>,
}
/// Reuse `slot` if it was computed at `sigma`, else compute and store it.
@ -232,6 +234,9 @@ pub fn dark_of(c: [f32; 3]) -> f32 {
c[0].min(c[1]).min(c[2])
}
/// Radius of the local Moiré / colour-noise chromaticity blur, as a fraction of the long edge.
pub const CHROMA_SIGMA: f32 = 0.004;
/// Radii (px) of the spatial planes the settings need (`None` = not needed).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlaneSigmas {
@ -243,6 +248,8 @@ pub struct PlaneSigmas {
pub texture: Option<f32>,
/// Dehaze dark channel (Gaussian).
pub dark: Option<f32>,
/// Chromaticity blur for local Moiré / colour noise (Gaussian).
pub chroma: Option<f32>,
}
pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas {
@ -256,10 +263,17 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
if q == Quality::Draft { sigma.min(24.0) } else { sigma }
});
let clarity = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| (0.012 * ppl).max(1.0));
let texture = (s.effects.texture != 0.0 || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) || local_any(|a| a.sharpness))
.then(|| (0.0018 * ppl).max(0.6));
// local Noise and Defringe read the fine detail band too
let texture = (s.effects.texture != 0.0
|| s.detail.sharpen_amount != 0.0
|| local_any(|a| a.texture)
|| local_any(|a| a.sharpness)
|| local_any(|a| a.noise)
|| s.masks.iter().any(|m| m.adjust.defringe > 0.0))
.then(|| (0.0018 * ppl).max(0.6));
let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0));
PlaneSigmas { base, clarity, texture, dark }
let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0));
PlaneSigmas { base, clarity, texture, dark, chroma }
}
/// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure),
@ -267,9 +281,18 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
/// one alone scales poorly: the guided filters work on small subsampled grids).
pub(crate) fn prepare(img: Arc<Rgb32f>, s: &DevelopSettings, frame: &Frame, px_per_long: f64, q: Quality, planes: &mut Planes) -> Prepared {
let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone();
let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma } = plane_sigmas(s, px_per_long, q);
let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } =
plane_sigmas(s, px_per_long, q);
let Planes { base: sb, clarity: sc, texture: st, dark: sd, .. } = planes;
let Planes { base: sb, clarity: sc, texture: st, dark: sd, chroma: sch, .. } = planes;
let chroma_blur = chroma_sigma.map(|sg| match sch {
Some((k, c)) if *k == sg.to_bits() => c.clone(),
_ => {
let c = timed("chroma", || Arc::new(gaussian(&img.map(masks::chromaticity), sg)));
*sch = Some((sg.to_bits(), c.clone()));
c
}
});
let l = &log_l;
let (base, (clarity_blur, (texture_blur, dark))) = par_join(
|| base_sigma.map(|sg| plane_at(sb, sg, || timed("base", || guided_fast(l, sg, BASE_EPS)))),
@ -302,7 +325,7 @@ pub(crate) fn prepare(img: Arc<Rgb32f>, s: &DevelopSettings, frame: &Frame, px_p
};
let ev = s.light.exposure as f32;
let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev));
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, air, masks, px_per_long }
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long }
}
/// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel.

View File

@ -107,7 +107,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
if inv { 1.0 - a } else { a }
});
}
MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, &mut out),
MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, img, log_l, &mut out),
MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => {
let (lo, hi, lf, hf) = (*lo as f32, *hi as f32, (*lo_feather as f32).max(1e-3), (*hi_feather as f32).max(1e-3));
for (v, l) in out.data.iter_mut().zip(&log_l.data) {
@ -188,6 +188,8 @@ pub struct BrushDabs {
pub flow: f32,
pub density: f32,
pub erase: bool,
/// Auto Mask: paint only pixels like the one under each dab, then snap to edges.
pub auto: bool,
}
/// The dabs of stroke `s` in a `w × h` output.
@ -210,15 +212,90 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa
}
dabs.push(q);
}
BrushDabs { dabs, r, hard, flow: (s.flow / 100.0).clamp(0.0, 1.0) as f32, density: (s.density / 100.0).clamp(0.0, 1.0) as f32, erase: s.erase }
BrushDabs {
dabs,
r,
hard,
flow: (s.flow / 100.0).clamp(0.0, 1.0) as f32,
density: (s.density / 100.0).clamp(0.0, 1.0) as f32,
erase: s.erase,
auto: s.auto_mask,
}
}
/// Stamp brush strokes into `out` (max-combine; erase strokes subtract).
fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, out: &mut Plane) {
/// Auto Mask tolerances: a pixel takes a dab's paint fully up to half of these from the colour
/// under the dab centre, none beyond them (log2 luminance, chromaticity `rgb / Y`).
pub const AUTO_TOL_EV: f32 = 0.5;
pub const AUTO_TOL_CHROMA: f32 = 0.25;
/// Chromaticity `rgb / Y` (as colour noise reduction uses it).
#[inline]
pub fn chromaticity(c: [f32; 3]) -> [f32; 3] {
let y = lightcraft_color::luminance_2020(c).max(1e-6);
[c[0] / y, c[1] / y, c[2] / y]
}
/// Auto Mask: how much a pixel (log luminance `l`, chromaticity `ch`) is like the reference.
#[inline]
pub fn auto_similarity(l: f32, ch: [f32; 3], rl: f32, rch: [f32; 3]) -> f32 {
let dl = (l - rl) / AUTO_TOL_EV;
let dc = ((ch[0] - rch[0]).powi(2) + (ch[1] - rch[1]).powi(2) + (ch[2] - rch[2]).powi(2)).sqrt() / AUTO_TOL_CHROMA;
1.0 - smooth(0.5, 1.0, (dl * dl + dc * dc).sqrt())
}
/// The output pixel a dab at `d` samples its reference colour from.
#[inline]
pub fn dab_pixel(d: Point, w: usize, h: usize) -> usize {
let x = (d.x.floor().max(0.0) as usize).min(w - 1);
let y = (d.y.floor().max(0.0) as usize).min(h - 1);
y * w + x
}
/// Guided-filter refinement of an Auto Mask stroke of radius `r` px: (sigma px, epsilon EV²). The
/// stroke keeps the larger of its own and the refined alpha.
pub fn auto_refine(r: f64) -> (f32, f32) {
((0.25 * r).max(1.0) as f32, 0.02)
}
/// Guided filter of `p` steered by `guide` (He et al.), clamped to 0..1: `p`'s edges snap to the
/// guide's.
pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane {
use lightcraft_raster::blur::gaussian;
let mi = gaussian(guide, sigma);
let mp = gaussian(p, sigma);
let cip = gaussian(&guide.zip_map(p, |a, b| a * b), sigma);
let cii = gaussian(&guide.map(|a| a * a), sigma);
let mut a = Plane::new(p.width, p.height);
let mut b = Plane::new(p.width, p.height);
for i in 0..p.data.len() {
let var = (cii.data[i] - mi.data[i] * mi.data[i]).max(0.0);
let k = (cip.data[i] - mi.data[i] * mp.data[i]) / (var + eps);
a.data[i] = k;
b.data[i] = mp.data[i] - k * mi.data[i];
}
let (ma, mb) = (gaussian(&a, sigma), gaussian(&b, sigma));
let mut q = Plane::new(p.width, p.height);
for i in 0..q.data.len() {
q.data[i] = (ma.data[i] * guide.data[i] + mb.data[i]).clamp(0.0, 1.0);
}
q
}
/// Stamp brush strokes into `out` (max-combine; erase strokes subtract). Auto Mask strokes weight
/// each dab by [`auto_similarity`] to the pixel under its centre and are refined with
/// [`guided_cross`] on log luminance.
fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, out: &mut Plane) {
if w == 0 || h == 0 {
return;
}
for s in strokes {
let BrushDabs { dabs, r, hard, flow, density: dens, erase } = brush_dabs(s, frame, w, h);
let BrushDabs { dabs, r, hard, flow, density: dens, erase, auto } = brush_dabs(s, frame, w, h);
let mut stroke_alpha = Plane::new(w, h);
for d in &dabs {
let refc = auto.then(|| {
let j = dab_pixel(*d, w, h);
(log_l.data[j], chromaticity(img.data[j]))
});
let (x0, x1) = (((d.x - r).floor().max(0.0)) as usize, ((d.x + r).ceil().max(0.0) as usize).min(w));
let (y0, y1) = (((d.y - r).floor().max(0.0)) as usize, ((d.y + r).ceil().max(0.0) as usize).min(h));
for y in y0..y1 {
@ -227,13 +304,23 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, o
if dd > r {
continue;
}
let a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) };
let v = &mut stroke_alpha.data[y * w + x];
let mut a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) };
let i = y * w + x;
if let Some((rl, rch)) = refc {
a *= auto_similarity(log_l.data[i], chromaticity(img.data[i]), rl, rch);
}
let v = &mut stroke_alpha.data[i];
// flow accumulates within a stroke up to density
*v = (*v + a * flow * (1.0 - *v)).min(dens);
}
}
}
if auto {
let (sigma, eps) = auto_refine(r);
let q = guided_cross(log_l, &stroke_alpha, sigma, eps);
// the refinement fills gaps along edges; it never takes paint away
stroke_alpha = stroke_alpha.zip_map(&q, f32::max);
}
for (o, a) in out.data.iter_mut().zip(&stroke_alpha.data) {
*o = if erase { *o * (1.0 - a) } else { o.max(*a) };
}
@ -289,4 +376,35 @@ mod tests {
assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50));
assert!(a.get(40, 5) < 0.01);
}
#[test]
fn auto_mask_stops_at_edges() {
// dark left half, bright right half; a stroke along the boundary, centred on the dark side
let (w, h) = (200, 100);
let f = frame(w, h);
let img = Rgb32f::from_fn(w, h, |x, _| if x < 100 { [0.03; 3] } else { [0.6, 0.5, 0.4] });
let l = img.map(crate::local::log_lum);
let stroke = |auto_mask| BrushStroke {
points: vec![Point::new(0.47, 0.2), Point::new(0.47, 0.8)],
size: 0.08,
feather: 0.0,
auto_mask,
..Default::default()
};
let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] };
let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0);
let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0);
// without Auto Mask the brush spills over the edge; with it, it stays on the dark side
assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50));
assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50));
assert!(auto.get(110, 50) < 0.02);
assert!(auto.get(90, 50) > 0.9, "painted side {}", auto.get(90, 50));
assert!(auto.get(99, 50) > 0.8, "up to the edge {}", auto.get(99, 50));
// on a flat area Auto Mask paints like the plain brush
let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]);
let fl = flat.map(crate::local::log_lum);
let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0);
let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0);
assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50));
}
}

View File

@ -0,0 +1,109 @@
//! Pixel tests of the local (mask) Noise, Moiré and Defringe sliders.
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
use lightcraft_raster::{Rgb32f, Rgba8};
use crate::{RenderRequest, SourceInfo, render};
const W: usize = 240;
const H: usize = 120;
/// A deterministic hash in −1..1.
fn noise(x: usize, y: usize, k: u32) -> f32 {
let mut v = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ k.wrapping_mul(0xcb1a_b31f);
v ^= v >> 13;
v = v.wrapping_mul(0x5bd1_e995);
v ^= v >> 15;
(v & 0xffff) as f32 / 32768.0 - 1.0
}
/// Settings with one mask covering the left half (x < 0.45), with `adjust`.
fn left_mask(adjust: LocalAdjustments) -> DevelopSettings {
let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.46, 0.5) };
let mut s = DevelopSettings::default();
s.masks.push(Mask { id: 1, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() });
s
}
fn shot(src: &Rgb32f, s: &DevelopSettings) -> Rgba8 {
render(src, &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image
}
/// Standard deviation of `f(pixel)` over columns `x0..x1` (rows 4..H-4).
fn spread(img: &Rgba8, x0: usize, x1: usize, f: impl Fn([u8; 4]) -> f32) -> f32 {
let v: Vec<f32> = (4..H - 4).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| f(img.data[y * W + x])).collect();
let m = v.iter().sum::<f32>() / v.len() as f32;
(v.iter().map(|a| (a - m).powi(2)).sum::<f32>() / v.len() as f32).sqrt()
}
fn luma(p: [u8; 4]) -> f32 {
0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32
}
fn chroma(p: [u8; 4]) -> f32 {
p[0] as f32 - p[2] as f32
}
#[test]
fn local_noise_smooths_luminance_noise_inside_the_mask() {
let src = Rgb32f::from_fn(W, H, |x, y| [0.18 * (1.0 + 0.12 * noise(x, y, 1)); 3]);
let plain = shot(&src, &DevelopSettings::default());
let base = spread(&plain, 10, 90, luma);
let on = shot(&src, &left_mask(LocalAdjustments { noise: 100.0, ..Default::default() }));
let off = shot(&src, &left_mask(LocalAdjustments { noise: -100.0, ..Default::default() }));
let (smoothed, boosted) = (spread(&on, 10, 90, luma), spread(&off, 10, 90, luma));
assert!(smoothed < 0.6 * base, "noise +100: {smoothed} vs {base}");
assert!(boosted > 1.2 * base, "noise −100: {boosted} vs {base}");
// outside the mask nothing changes
assert_eq!(spread(&on, 150, 230, luma), spread(&plain, 150, 230, luma));
// the mean brightness is kept
let mean = |img: &Rgba8| (4..H - 4).flat_map(|y| (10..90).map(move |x| luma(img.data[y * W + x]))).sum::<f32>() / (80 * (H - 8)) as f32;
assert!((mean(&on) - mean(&plain)).abs() < 2.0);
}
#[test]
fn local_moire_removes_colour_speckle_inside_the_mask() {
let src = Rgb32f::from_fn(W, H, |x, y| {
let n = 0.25 * noise(x, y, 2);
[0.18 * (1.0 + n), 0.18, 0.18 * (1.0 - n)]
});
let plain = shot(&src, &DevelopSettings::default());
let base = spread(&plain, 10, 90, chroma);
let on = shot(&src, &left_mask(LocalAdjustments { moire: 100.0, ..Default::default() }));
let reduced = spread(&on, 10, 90, chroma);
assert!(reduced < 0.4 * base, "moiré +100: {reduced} vs {base}");
assert_eq!(spread(&on, 150, 230, chroma), spread(&plain, 150, 230, chroma));
// luminance is kept
assert!((spread(&on, 10, 90, luma) - spread(&plain, 10, 90, luma)).abs() < 1.0);
// positive Noise also calms colour noise (less than Moiré)
let n = spread(&shot(&src, &left_mask(LocalAdjustments { noise: 100.0, ..Default::default() })), 10, 90, chroma);
assert!(n < 0.8 * base && n > reduced, "{n}");
}
#[test]
fn local_defringe_desaturates_purple_edges_only() {
// dark | bright edge at x = 40 (inside the mask) and x = 200 (outside), each with a purple
// fringe column; a flat purple patch (no edge) at x 60..80
let src = Rgb32f::from_fn(W, H, |x, _| {
let fringe = x == 39 || x == 40 || x == 199 || x == 200;
if fringe {
[0.25, 0.04, 0.3]
} else if (60..80).contains(&x) {
[0.2, 0.08, 0.22]
} else if (40..120).contains(&x) || x >= 200 {
[0.7; 3]
} else {
[0.02; 3]
}
});
let s = left_mask(LocalAdjustments { defringe: 100.0, ..Default::default() });
let (plain, on) = (shot(&src, &DevelopSettings::default()), shot(&src, &s));
let sat = |p: [u8; 4]| p[0].max(p[2]) as f32 - p[1] as f32;
let at = |img: &Rgba8, x: usize| img.data[60 * W + x];
assert!(sat(at(&plain, 40)) > 40.0, "a purple fringe to start with: {:?}", at(&plain, 40));
assert!(sat(at(&on, 40)) < 0.5 * sat(at(&plain, 40)), "fringe in the mask: {:?} vs {:?}", at(&on, 40), at(&plain, 40));
assert_eq!(at(&on, 200), at(&plain, 200), "outside the mask");
let flat = |img: &Rgba8| sat(at(img, 70));
assert!((flat(&on) - flat(&plain)).abs() <= 2.0, "flat purple kept: {} vs {}", flat(&on), flat(&plain));
}

View File

@ -33,6 +33,8 @@ pub const LOCAL: &[ControlSpec] = &[
spec("saturation", "Saturation", -100.0, 100.0, 1.0, 0, Track::Gradient { from: "#7a7a7a", to: "#e04a3a" }),
spec("sharpness", "Sharpness", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("noise", "Noise", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("moire", "Moiré", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("defringe", "Defringe", -100.0, 100.0, 1.0, 0, Track::Centered),
];
pub fn local_get(a: &LocalAdjustments, key: &str) -> f64 {
@ -294,6 +296,10 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.ui.brush_erase = true;
}
});
let mut auto = app.ui.brush_auto_mask;
if ui.checkbox(&mut auto, "Auto Mask").on_hover_text("Paint only areas like the one under the brush").changed() {
app.ui.brush_auto_mask = auto;
}
});
for (id, label, min, max, get) in [
("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64),

View File

@ -89,3 +89,20 @@ fn mask_overlay_keys_and_pins() {
assert_eq!(h.app.session.active_mask, Some(2));
h.settle(SETTLE);
}
#[test]
fn brush_strokes_carry_auto_mask() {
let mut h = detail("panel.masking");
let r = h.request("ui.set", json!({"brushAutoMask": true}), T);
assert_eq!(r["ok"], true, "{r}");
exec(&mut h, "tool.brush", json!({}));
assert_eq!(h.app.ui.tool, "brush");
pointer(
&mut h,
json!([{"kind": "down", "x": 0.3, "y": 0.5}, {"kind": "drag", "x": 0.4, "y": 0.5}, {"kind": "drag", "x": 0.5, "y": 0.5}, {"kind": "up", "x": 0.5, "y": 0.5}]),
);
let d = develop(&h);
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
assert!(strokes[0].auto_mask && strokes[0].points.len() >= 2, "{strokes:?}");
h.settle(SETTLE);
}

View File

@ -31,7 +31,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) |
| I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) |
| J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) |
| K. Masking (MASK) | 8 | 8 | 7 | 0 | 5/8 (63%) | 3/5 (60%) |
| K. Masking (MASK) | 10 | 6 | 7 | 0 | 7/8 (88%) | 3/5 (60%) |
| L. Presets (PRE) | 1 | 4 | 2 | 1 | 0/2 (0%) | 1/2 (50%) |
| M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) |
| N. Copy / paste / sync (SYNC) | 4 | 0 | 1 | 0 | 3/3 (100%) | 1/1 (100%) |
@ -48,7 +48,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) |
| Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) |
| Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) |
| **Total** | 210 | 101 | 189 | 37 | 153/198 (77%) | 49/144 (34%) |
| **Total** | 212 | 99 | 189 | 37 | 155/198 (78%) | 49/144 (34%) |
<!-- /parity:summary -->
## Top gaps
@ -57,8 +57,6 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i
1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible /
Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort.
3. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local
Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort.
4. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫,
feather/opacity sliders, `[`/`]` size keys. Medium effort.
5. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort.
@ -335,7 +333,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-MASK-OBJECTS | Object selection | P2 | ⬜ | | shape exists (falls back to the subject heuristic); no UI |
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | |
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` | size/feather/flow/density/erase; Auto Mask flag not applied; no A/B brushes, no pressure |
| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | |
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | |
| LR-MASK-COLORRANGE | Colour range | P1 | 🟡 | `cmd:mask.add` (`colorRange`) | renders; sampling UX unverified |
@ -345,7 +343,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | |
| LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | |
| LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ⬜ | | |
| LR-MASK-SLIDERS | Local adjustment sliders | P0 | 🟡 | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | noise / moiré / defringe stored but not rendered; no local curve or effect presets |
| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets |
| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; no auto-show on hover |
| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode |
| LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | |