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:
parent
ca8db6e028
commit
8c1c7282b2
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 | ⬜ | | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue