From aae00dfaca8a3b65c26669fdc64157bfa15b4ce7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:38:40 -0400 Subject: [PATCH] M9.13: Add / Match / Remove Grain preview region, presets, channel balance, sampling and temporal filtering Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 3 - crates/effects/src/lib.rs | 5 + crates/effects/src/migrate.rs | 12 ++ crates/effects/src/noise.rs | 320 ++++++++++++++++++++++++++++++++-- crates/effects/src/noise2.rs | 60 +++++-- docs/effects.md | 10 +- 6 files changed, 375 insertions(+), 35 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index c243559a..2828080c 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -8,9 +8,6 @@ use crate::{CATEGORIES, registry}; /// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far /// as the public behaviour documentation describes it. pub const PARTIAL: &[(&str, &str)] = &[ - ("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"), - ("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"), - ("ec.noise.removegrain", "no preview region, sampling or temporal filtering"), ("ec.vr.digitalglitch", "reduced control set"), ("ec.vr.converter", "common layouts only"), ("ec.sim.caustics", "no Sky group or light type"), diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index bd6e035a..d4ea8b81 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -566,6 +566,9 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("fineTuning", "Fine Tuning"), ("unsharpMask", "Unsharp Mask"), ("noiseOptions", "Noise Options (Animation)"), + ("previewRegion", "Preview Region"), + ("sampling", "Sampling"), + ("temporalFiltering", "Temporal Filtering"), // Time: Timewarp. ("tuning", "Tuning"), ("motionBlur", "Motion Blur"), @@ -733,6 +736,8 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.stylize.strobe", "ec.noise.noise", "ec.noise.addgrain", + // Remove Grain's temporal filtering reads neighbouring frames. + "ec.noise.removegrain", "ec.noise.noisealpha", "ec.noise.noisehlsauto", "ec.obsolete.lightning", diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 02b51dfb..e5dc30ba 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -455,4 +455,16 @@ mod tests { assert!(g.get("additionalForeground").is_none()); assert_eq!(prop_at(&mut g, "additionalForeground/foreground1").unwrap().value, Value::Enum(3)); } + + #[test] + fn match_grain_viewing_modes_remap_by_label() { + let spec = find("ec.noise.matchgrain").unwrap(); + let mut next = 1; + let mut g = instantiate(spec, &mut Ids(&mut next), "Match Grain", [100.0, 50.0]); + let pr = g.get_mut("viewingMode").unwrap(); + pr.ui = ParamUi::Popup { options: vec!["Final Output".into(), "Noise Samples".into(), "Blending Matte".into()] }; + pr.value = Value::Enum(0); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert_eq!(g.get("viewingMode").unwrap().value, Value::Enum(4), "Final Output"); + } } diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 4555d16a..a543bf4e 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -59,6 +59,8 @@ pub(crate) struct GrainLook { /// Grain time (frames of grain at the animation speed) and whether it interpolates. pub frame: f32, pub seed: u32, + /// Color ▸ Red / Green / Blue Balance. + pub balance: [f32; 3], } impl GrainLook { @@ -84,7 +86,28 @@ impl GrainLook { midpoint: pr.get("application/midpoint").map(Value::as_f64).unwrap_or(0.5).clamp(0.01, 0.99) as f32, frame: if smooth && ft.fract() != 0.0 { ft as f32 } else { ft.floor() as f32 }, seed: (pr.f("animation/randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37), + balance: ch("color/", "redBalance", "greenBalance", "blueBalance"), } + .with_preset(pr.e("preset")) + } + + /// Apply Add Grain's Preset (a film-stock look on top of the controls). + fn with_preset(mut self, preset: u32) -> GrainLook { + // (intensity ×, size ×, softness +, saturation ×, monochrome) + let (ki, ks, soft, ksat, mono) = match preset { + 1 => (0.6, 0.7, 0.2, 0.6, false), + 2 => (1.3, 1.8, 0.4, 0.8, false), + 3 => (1.8, 2.5, 0.8, 0.5, false), + 4 => (1.0, 0.6, 0.0, 1.0, false), + 5 => (1.6, 1.4, 0.3, 1.0, true), + _ => return self, + }; + self.intensity *= ki; + self.size *= ks; + self.softness += soft; + self.saturation *= ksat; + self.mono |= mono; + self } /// Zero-mean noise planes (one, or one per channel) of grain `size` pixels (times each @@ -129,7 +152,7 @@ impl GrainLook { let l = luminance(self.tint[0], self.tint[1], self.tint[2]).max(1e-3); g = [0, 1, 2].map(|k| g[k] * ((1.0 - self.tint_amount) + self.tint_amount * self.tint[k] / l)); } - g + [g[0] * self.balance[0], g[1] * self.balance[1], g[2] * self.balance[2]] } /// Combine source channel `c` with signed grain `n` by the Blending Mode. @@ -171,6 +194,9 @@ pub(crate) fn grain_params() -> (Vec, Vec, V p("color/saturation", "Saturation", num(1.0), mult()), p("color/tintAmount", "Tint Amount", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)), p("color/tintColor", "Tint Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color), + p("color/redBalance", "Red Balance", num(1.0), mult()), + p("color/greenBalance", "Green Balance", num(1.0), mult()), + p("color/blueBalance", "Blue Balance", num(1.0), mult()), ]; let application = vec![ p("application/blendingMode", "Blending Mode", Value::Enum(0), popup(&GRAIN_BLEND_MODES)), @@ -187,27 +213,124 @@ pub(crate) fn grain_params() -> (Vec, Vec, V (tweaking, color, application, animation) } +/// Add Grain's Viewing Mode options. +pub(crate) const ADD_GRAIN_VIEWS: [&str; 3] = ["Preview", "Blending Matte", "Final Output"]; +/// Add Grain's Preset options (our own film-stock looks). +pub(crate) const GRAIN_PRESETS: [&str; 6] = ["None", "Fine 35mm", "Coarse 16mm", "Vintage 8mm", "Video Noise", "Monochrome High Speed"]; + +/// The Preview Region controls of the grain effects (centre, size, box display). +pub(crate) fn preview_params() -> Vec { + vec![ + p("previewRegion/center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point), + p("previewRegion/width", "Width", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)), + p("previewRegion/height", "Height", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)), + p("previewRegion/showBox", "Show Box", Value::Bool(true), ParamUi::Checkbox), + p("previewRegion/boxColor", "Box Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color), + ] +} + +/// Preview viewing mode: the processed pixels inside the Preview Region, the original +/// outside, and (Show Box) the region's outline. +pub(crate) fn preview_compose(ctx: &EffectCtx, b: &mut Buf, orig: &Image) { + let c = b.to_px(ctx.params.v2("previewRegion/center")); + let (hw, hh) = (ctx.params.f("previewRegion/width") * b.scale * 0.5, ctx.params.f("previewRegion/height") * b.scale * 0.5); + let (x0, x1, y0, y1) = (c.0 - hw, c.0 + hw, c.1 - hh, c.1 + hh); + let show = ctx.params.b("previewRegion/showBox"); + let col = ctx.params.color("previewRegion/boxColor"); + let w = b.img.width as usize; + b.img.data.par_iter_mut().zip(orig.data.par_iter()).enumerate().for_each(|(i, (px, o))| { + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let inside = x >= x0 && x < x1 && y >= y0 && y < y1; + if !inside { + *px = *o; + } + if show { + let near = |v: f64, e: f64| (v - e).abs() < 0.75; + let on = ((near(x, x0) || near(x, x1)) && y >= y0 - 0.75 && y < y1 + 0.75) || ((near(y, y0) || near(y, y1)) && x >= x0 - 0.75 && x < x1 + 0.75); + if on { + *px = [col[0], col[1], col[2], 1.0]; + } + } + }); +} + +/// Boxes (x, y, size in buffer px) where grain is sampled: the flattest of a grid of +/// candidate boxes (real grain shows best where the picture has no detail). +pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize, usize, usize)> { + let (w, h) = (img.width as usize, img.height as usize); + let size = size.clamp(3, w.min(h).max(3)); + if w < size || h < size || count == 0 { + return vec![]; + } + let low = Plane::luma(img); + let step = (size / 2).max(1); + let mut cands: Vec<(f32, usize, usize)> = Vec::new(); + for y in (0..=h - size).step_by(step) { + for x in (0..=w - size).step_by(step) { + // Variance of the picture inside the box (the flattest boxes hold the least detail). + let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); + for yy in y..y + size { + for xx in x..x + size { + let v = low.data[yy * w + xx]; + s += v; + sq += v * v; + n += 1.0; + opaque &= img.data[yy * w + xx][3] > 0.5; + } + } + if opaque { + cands.push((sq / n - (s / n).powi(2), x, y)); + } + } + } + cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1))); + cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect() +} + +/// Draw sample boxes' outlines (Noise Samples viewing mode). +pub(crate) fn draw_boxes(img: &mut Image, boxes: &[(usize, usize, usize)], c: [f32; 4]) { + for &(x, y, s) in boxes { + for k in 0..s { + for (px, py) in [(x + k, y), (x + k, y + s - 1), (x, y + k), (x + s - 1, y + k)] { + img.set(px as u32, py as u32, [c[0], c[1], c[2], 1.0]); + } + } + } +} + // ---- Add Grain ---- fn add_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let look = GrainLook::from_params(ctx); - if look.intensity <= 0.0 { + // Viewing Mode (Final Output for projects saved before it existed). + let view = ctx.params.get("viewingMode").map(|v| v.as_enum()).unwrap_or(2); + if look.intensity <= 0.0 && view != 1 { return b; } + let orig = b.img.clone(); let (w, h) = (b.img.width as usize, b.img.height as usize); let planes = look.planes(w, h, look.size * b.scale as f32, b.scale, 101); let amp = look.intensity * 0.25; b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { let (c, a) = unpremul(*px); + let weight = look.weight(c); + if view == 1 { + // Blending Matte: where (and how strongly) the grain applies. + let v = weight.clamp(0.0, 1.0); + *px = [v, v, v, 1.0]; + return; + } if a <= 0.0 { return; } let raw = if look.mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] }; let g = look.color(raw); - let weight = look.weight(c); let o = [0, 1, 2].map(|k| look.blend(c[k], g[k] * amp * look.channel_intensity[k] * weight)); *px = premul(o, a); }); + if view == 0 { + preview_compose(ctx, &mut b, &orig); + } b } @@ -333,18 +456,104 @@ fn dust_scratches(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Remove Grain's Viewing Mode options. +pub(crate) const REMOVE_GRAIN_VIEWS: [&str; 4] = ["Preview", "Noise Samples", "Blending Matte", "Final Output"]; + +/// Grain sampling controls (Match Grain, Remove Grain): how many flat boxes of what size the +/// grain is measured in. +pub(crate) fn sampling_params() -> Vec { + vec![ + p("sampling/samplingProcess", "Sampling Process", Value::Enum(0), popup(&["Automatic"])), + p("sampling/numberOfSamples", "Number of Samples", num(80.0), slider(1.0, 500.0, 1.0, 200.0, 0)), + p("sampling/sampleSize", "Sample Size", num(9.0), slider(3.0, 64.0, 3.0, 32.0, 0)), + p("sampling/sampleBoxColor", "Sample Box Color", crate::col(1.0, 1.0, 0.0), ParamUi::Color), + ] +} + +/// The sample boxes for the effect's Sampling settings. +pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(usize, usize, usize)> { + let n = ctx.params.get("sampling/numberOfSamples").map(Value::as_f64).unwrap_or(80.0).clamp(1.0, 500.0) as usize; + let s = (ctx.params.get("sampling/sampleSize").map(Value::as_f64).unwrap_or(9.0) * scale).round().max(3.0) as usize; + sample_boxes(img, n, s) +} + fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let amt = pr.f("noiseReductionSettings/noiseReduction") as f32; let um_amount = pr.f("unsharpMask/amount") as f32 / 100.0; - if amt <= 0.0 && um_amount <= 0.0 { + let view = pr.get("viewingMode").map(|v| v.as_enum()).unwrap_or(3); + let boxes = sampling_boxes(ctx, &b.img, b.scale); + if view == 1 { + let c = pr.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]); + draw_boxes(&mut b.img, &boxes, c); return b; } + if view == 2 { + b.img.data.iter_mut().for_each(|p| *p = [1.0, 1.0, 1.0, 1.0]); + return b; + } + let temporal = pr.b("temporalFiltering/enabled"); + if amt <= 0.0 && um_amount <= 0.0 && !temporal { + return b; + } + let before = b.img.clone(); + // Temporal Filtering: steady pixels average with the neighbouring frames (grain changes + // every frame, the picture does not); Motion Sensitivity keeps moving areas out of it. + if temporal { + let fps = ctx.fps(); + let host = ctx.env.host; + let at = |t: f64| host.and_then(|h| h.self_at(t, ctx.env.effect_index)).filter(|o| o.img.width == b.img.width && o.img.height == b.img.height); + let (prev, next) = (at(ctx.time - 1.0 / fps), at(ctx.time + 1.0 / fps)); + let k = (pr.get("temporalFiltering/amount").map(Value::as_f64).unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32; + let sens = pr.get("temporalFiltering/motionSensitivity").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0) as f32; + let thr = 0.02 + (1.0 - sens) * 0.2; + if prev.is_some() || next.is_some() { + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let mut acc = *px; + let mut n = 1.0; + for o in [&prev, &next].into_iter().flatten() { + let q = o.img.data[i]; + let d = (0..3).map(|c| (q[c] - px[c]).abs()).fold(0.0f32, f32::max); + if d < thr { + let wgt = 1.0 - d / thr; + for c in 0..4 { + acc[c] += q[c] * wgt; + } + n += wgt; + } + } + for c in 0..4 { + px[c] += (acc[c] / n - px[c]) * k; + } + }); + } + if amt <= 0.0 && um_amount <= 0.0 { + if view == 0 { + preview_compose(ctx, &mut b, &before); + } + return b; + } + } + // The grain level measured in the sample boxes scales the smoothing. + let level = { + let mut m = vec![false; b.img.data.len()]; + let w = b.img.width as usize; + for &(x, y, s) in &boxes { + for yy in y..y + s { + for xx in x..x + s { + m[yy * w + xx] = true; + } + } + } + let st = crate::noise2::grain_stats_masked(&b.img, Some(&m)); + let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0; + if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) } + }; let passes = pr.f("noiseReductionSettings/passes").round().clamp(1.0, 8.0) as usize; let multichannel = pr.e("noiseReductionSettings/mode") == 0; let texture = (pr.f("fineTuning/texture") as f32).clamp(0.0, 1.0); let r = (2.0 * b.scale).round().max(1.0) as usize; - let eps = (0.02 * amt).powi(2); + let eps = (0.02 * amt * level).powi(2); let orig = split(&b.img); let mut ch = orig.clone(); if amt > 0.0 { @@ -387,6 +596,9 @@ fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { p[3] = a; }); b.img = out; + if view == 0 { + preview_compose(ctx, &mut b, &before); + } b } @@ -533,7 +745,9 @@ pub fn specs() -> Vec { vec![ { let (tweaking, color, application, animation) = grain_params(); - spec("ec.noise.addgrain", "Add Grain", [tweaking, color, application, animation].into_iter().flatten().collect(), add_grain) + let head = + vec![p("viewingMode", "Viewing Mode", Value::Enum(2), popup(&ADD_GRAIN_VIEWS)), p("preset", "Preset", Value::Enum(0), popup(&GRAIN_PRESETS))]; + spec("ec.noise.addgrain", "Add Grain", [head, preview_params(), tweaking, color, application, animation].into_iter().flatten().collect(), add_grain) }, spec( "ec.noise.median", @@ -557,15 +771,26 @@ pub fn specs() -> Vec { spec( "ec.noise.removegrain", "Remove Grain", - vec![ - p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)), - p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)), - p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])), - p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)), - p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)), - p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)), - p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)), - ], + [ + vec![p("viewingMode", "Viewing Mode", Value::Enum(3), popup(&REMOVE_GRAIN_VIEWS))], + preview_params(), + vec![ + p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)), + p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)), + p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])), + p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)), + p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)), + p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)), + p("temporalFiltering/enabled", "Enabled", Value::Bool(false), ParamUi::Checkbox), + p("temporalFiltering/amount", "Amount", num(100.0), pct()), + p("temporalFiltering/motionSensitivity", "Motion Sensitivity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)), + ], + sampling_params(), + ] + .into_iter() + .flatten() + .collect(), remove_grain, ), spec( @@ -768,6 +993,71 @@ mod tests { assert!(tinted.img.data.iter().any(|p| (p[0] - 0.4).abs() > 1e-3)); } + #[test] + fn grain_preview_presets_balance_and_views() { + let base = gray(64, 64, 0.5); + let grain = [("tweaking/intensity", num(3.0))]; + let with = |extra: &[(&str, Value)]| run("ec.noise.addgrain", &[grain.as_slice(), extra].concat(), base.clone(), 0.0).img; + // Preview: grain only inside the region; its box outline drawn. + let prev = with(&[ + ("viewingMode", Value::Enum(0)), + ("previewRegion/center", Value::Vec2([32.0, 32.0])), + ("previewRegion/width", num(20.0)), + ("previewRegion/height", num(20.0)), + ]); + assert_eq!(prev.get(2, 2), base.get(2, 2)); + assert_ne!(prev.get(32, 32), base.get(32, 32)); + assert_eq!(prev.get(22, 32), [1.0, 1.0, 1.0, 1.0], "box outline"); + // Blending Matte shows the tonal weight (1 at mid grey with the defaults). + assert_eq!(with(&[("viewingMode", Value::Enum(1))]).get(5, 5), [1.0, 1.0, 1.0, 1.0]); + // Presets and channel balance change the grain. + let plain = with(&[]); + assert_ne!(with(&[("preset", Value::Enum(3))]), plain); + let no_red = with(&[("color/redBalance", num(0.0))]); + assert!(no_red.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-6)); + // Sample boxes sit in the flat part of a picture. + let mut half = gray(64, 64, 0.5); + for y in 0..64 { + for x in 32..64 { + half.set(x, y, if (x + y) % 2 == 0 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); + } + } + let boxes = sample_boxes(&half, 4, 8); + assert_eq!(boxes.len(), 4); + assert!(boxes.iter().all(|(x, _, s)| x + s <= 32), "{boxes:?}"); + // Remove Grain's Noise Samples view draws them. + let shown = run("ec.noise.removegrain", &[("viewingMode", Value::Enum(1))], gray(64, 64, 0.5), 0.0).img; + assert!(shown.data.iter().any(|p| p == &[1.0, 1.0, 0.0, 1.0])); + } + + #[test] + fn remove_grain_temporal_filtering_averages_steady_frames() { + /// Flat grey with per-frame grain. + struct Grainy; + impl crate::EffectHost for Grainy { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + Some(Buf { img: frame((t * 10.0).round() as u32), offset: [0.0; 2], scale: 1.0 }) + } + } + fn frame(f: u32) -> Image { + crate::util::gen_image(32, 32, |x, y| { + let n = (hash1(x as u32, y as u32, f.wrapping_mul(977)) - 0.5) * 0.06; + [0.5 + n, 0.5 + n, 0.5 + n, 1.0] + }) + } + let env = crate::EffectEnv { host: Some(&Grainy), frame_rate: 10.0, ..Default::default() }; + let vals = [("noiseReductionSettings/noiseReduction", num(0.0)), ("temporalFiltering/enabled", Value::Bool(true))]; + let out = crate::run_fx("ec.noise.removegrain", &vals, frame(5), 0.5, env).img; + let dev = |i: &Image| i.data.iter().map(|p| (p[0] - 0.5).abs()).sum::(); + assert!(dev(&out) < dev(&frame(5)) * 0.75, "{} vs {}", dev(&out), dev(&frame(5))); + } + #[test] fn noise_alpha_cycle_repeats() { let at = |phase: f64| { diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index cc7667a6..4c22f6d8 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -7,7 +7,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::Image; use rayon::prelude::*; -use crate::noise::{GrainLook, fbm, grain_params, median_image}; +use crate::noise::{GrainLook, draw_boxes, fbm, grain_params, median_image, preview_params, sampling_boxes, sampling_params}; use crate::util::{Plane, gauss_plane, layer_or_self, premul, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; @@ -29,7 +29,14 @@ pub(crate) struct GrainStats { pub size: f32, } +#[cfg(test)] pub(crate) fn grain_stats(img: &Image) -> GrainStats { + grain_stats_masked(img, None) +} + +/// [`grain_stats`] over the pixels `mask` selects (all opaque pixels without one). +pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainStats { + let sel = |i: usize| mask.is_none_or(|m| m[i]); let (w, h) = (img.width as usize, img.height as usize); if w < 3 || h < 3 { return GrainStats::default(); @@ -44,7 +51,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats { let hp: Vec = pl.data.iter().zip(low.data.iter()).map(|(a, b)| a - b).collect(); let (mut sum, mut sq, mut n) = (0.0f64, 0.0f64, 0.0f64); for (i, v) in hp.iter().enumerate() { - if straight[i].1 > 0.5 { + if straight[i].1 > 0.5 && sel(i) { sum += *v as f64; sq += (*v as f64) * (*v as f64); n += 1.0; @@ -57,7 +64,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats { for y in 0..h { for x in 0..w - 1 { let i = y * w + x; - if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 { + if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 && sel(i) && sel(i + 1) { rho_num += (hp[i] * hp[i + 1]) as f64; rho_den += (hp[i] * hp[i]) as f64; } @@ -68,15 +75,45 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats { GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } } +/// Match Grain's Viewing Mode options. +const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"]; + fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { if ctx.params.get("noiseSourceLayer").and_then(Value::as_layer).is_none() { return b; } + // MATCH_GRAIN_VIEWS order. let view = ctx.params.e("viewingMode"); let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false); - let src_stats = grain_stats(&source); + let src_boxes = sampling_boxes(ctx, &source, b.scale); + let own_boxes = sampling_boxes(ctx, &b.img, b.scale); + let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]); + if view == 1 || view == 2 { + // Noise Samples (on the source) / Compensation Samples (on this layer). + let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) }; + draw_boxes(&mut img, &boxes, box_col); + b.img = img; + return b; + } + let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option> { + if boxes.is_empty() { + return None; + } + let w = img.width as usize; + let mut m = vec![false; img.data.len()]; + for &(x, y, s) in boxes { + for yy in y..y + s { + for xx in x..x + s { + m[yy * w + xx] = true; + } + } + } + Some(m) + }; + let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref()); let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; - let own = if comp > 0.0 { grain_stats(&b.img) } else { GrainStats::default() }; + let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() }; + let orig = b.img.clone(); let look = GrainLook::from_params(ctx); let amp: [f32; 3] = [0, 1, 2].map(|k| { let s = src_stats.std[k]; @@ -100,11 +137,7 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let g = look.color(raw); let weight = look.weight(c); match view { - 1 => { - let o = [0, 1, 2].map(|k| (0.5 + g[k] * amp[k]).clamp(0.0, 1.0)); - *px = [o[0], o[1], o[2], 1.0]; - } - 2 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0], + 3 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0], _ => { if a <= 0.0 { return; @@ -114,6 +147,9 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { } } }); + if view == 0 { + crate::noise::preview_compose(ctx, &mut b, &orig); + } b } @@ -214,13 +250,13 @@ pub fn specs() -> Vec { "ec.noise.matchgrain", "Match Grain", vec![ - p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])), + p("viewingMode", "Viewing Mode", Value::Enum(4), popup(&MATCH_GRAIN_VIEWS)), p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer), p("noiseSourceLayerSource", "Noise Source Layer Source", Value::Enum(0), ParamUi::Hidden), p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()), ] .into_iter() - .chain([tweaking, color, application, animation].into_iter().flatten()) + .chain([preview_params(), sampling_params(), tweaking, color, application, animation].into_iter().flatten()) .collect(), match_grain, ), diff --git a/docs/effects.md b/docs/effects.md index eb0ab1ed..001a4320 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2892 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 303 effects with 2927 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 24 effects also run on the GPU compositor with identical results. - **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping. -- **Status**: 296 implemented in full, 7 partial (what is missing is listed). +- **Status**: 299 implemented in full, 4 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -263,18 +263,18 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Add Grain | `ec.noise.addgrain` | 22 | | 32 | Partial: no preview region, presets, channel balance or temporal controls | +| Add Grain | `ec.noise.addgrain` | 32 | | 32 | Implemented | | Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented | | Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented | | Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented | -| Match Grain | `ec.noise.matchgrain` | 25 | | 32 | Partial: no preview region, presets, sampling or temporal controls | +| Match Grain | `ec.noise.matchgrain` | 37 | | 32 | Implemented | | Median | `ec.noise.median` | 2 | | 32 | Implemented | | Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented | | Noise | `ec.noise.noise` | 3 | | 32 | Implemented | | Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented | | Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented | | Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented | -| Remove Grain | `ec.noise.removegrain` | 7 | | 32 | Partial: no preview region, sampling or temporal filtering | +| Remove Grain | `ec.noise.removegrain` | 20 | | 32 | Implemented | | Turbulent Noise | `ec.noise.turbulent` | 22 | | 32 | Implemented | ## Obsolete