diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 010ff7ba..b415d312 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -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"); diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index b18461a2..00ae96ba 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -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()); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 59a5370a..492cb38c 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -44,6 +44,7 @@ struct Planes { clarity: Option<(u32, Arc)>, texture: Option<(u32, Arc)>, dark: Option<(u32, Arc, f32)>, + chroma: Option<(u32, Arc)>, } 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>, texture: Option>, dark: Option>, + /// Blurred chromaticity (local Moiré / Noise). + chroma: Option>, 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 { + 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, 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::>(); let mut aux: Vec = Vec::new(); + let mut own: Option = 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::() <= 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::() > 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::>(), 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)); diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index d405e949..2ddda1b6 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -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) { let l0 = l_pre + ev; // --- local (mask) contributions - var lt: array; + var lt: array; var tint_on = false; var tint_dir = vec2(0.0, 0.0); var tint_amt = 0.0; @@ -250,18 +251,42 @@ fn main(@builtin(global_invocation_id) gid: vec3) { 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(aux[t + 15u], aux[t + 16u]); - tint_amt = a * aux[t + 17u]; + tint_dir = vec2(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(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(masks[o], masks[o + 1u], masks[o + 2u]); + c = max((ch0 + (cb - ch0) * mt) * y, vec3(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) { 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); } diff --git a/crates/gpu/src/wgsl/map.wgsl b/crates/gpu/src/wgsl/map.wgsl index 9a54e7f6..fc8eebec 100644 --- a/crates/gpu/src/wgsl/map.wgsl +++ b/crates/gpu/src/wgsl/map.wgsl @@ -70,6 +70,51 @@ fn guided_apply(@builtin(global_invocation_id) g: vec3, @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, @builtin(num_workgroups) nw: vec3) { + 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, @builtin(num_workgroups) nw: vec3) { + 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, @builtin(num_workgroups) nw: vec3) { + 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, @builtin(num_workgroups) nw: vec3) { diff --git a/crates/gpu/src/wgsl/mask.wgsl b/crates/gpu/src/wgsl/mask.wgsl index 2075e186..c2d15b64 100644 --- a/crates/gpu/src/wgsl/mask.wgsl +++ b/crates/gpu/src/wgsl/mask.wgsl @@ -13,6 +13,19 @@ fn pos(x: u32, y: u32) -> vec2 { // 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 { + let c = vec3(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, rl: f32, rch: vec3) -> 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) { let w = pu(0u); @@ -64,11 +77,14 @@ fn shape(@builtin(global_invocation_id) g: vec3) { } 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) { 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]); diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 0a15922c..349c82e1 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -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; diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 0ab6199e..8eb6a637 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -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. diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index ad6b56e7..20fcbb86 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -110,6 +110,8 @@ pub(crate) struct Prepared { pub clarity_blur: Option>, pub texture_blur: Option>, pub dark: Option>, + /// Blurred chromaticity (`rgb / Y`) for local Moiré / Noise. + pub chroma_blur: Option>, /// Airlight of `dark` (before exposure). pub air: f32, pub masks: Vec, @@ -402,3 +404,5 @@ pub(crate) fn for_rows(data: &mut [T], w: usize, f: impl Fn(usize, &mut mod tests; #[cfg(test)] mod tests_geometry; +#[cfg(test)] +mod tests_local; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 8efb4126..ae26888c 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -208,6 +208,8 @@ pub(crate) struct Planes { pub texture: Option<(u32, Arc)>, /// Dark channel and its airlight. pub dark: Option<(u32, Arc, f32)>, + /// Blurred chromaticity (local Moiré / Noise). + pub chroma: Option<(u32, Arc)>, } /// 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, /// Dehaze dark channel (Gaussian). pub dark: Option, + /// Chromaticity blur for local Moiré / colour noise (Gaussian). + pub chroma: Option, } 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, 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, 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. diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 33af94d3..f02185f8 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -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)); + } } diff --git a/crates/pipeline/src/tests_local.rs b/crates/pipeline/src/tests_local.rs new file mode 100644 index 00000000..3ccf961b --- /dev/null +++ b/crates/pipeline/src/tests_local.rs @@ -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 = (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::() / v.len() as f32; + (v.iter().map(|a| (a - m).powi(2)).sum::() / 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::() / (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)); +} diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 7c4c2500..2aad39ff 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -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), diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index ca60889b..74b360f2 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -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); +} diff --git a/docs/parity.md b/docs/parity.md index 489a445e..756e3f42 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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%) | ## 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 | ⬜ | | |