M9.3: Noise & Grain effects (Match Grain, Median (Legacy), Curl Noise)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 22:03:26 -04:00
co-authored by Claude Opus 5.5
parent 5ac5560df9
commit b58167d0c7
+365 -3
View File
@@ -1,7 +1,369 @@
//! Noise & Grain effects, batch 2.
//! Noise & Grain effects, batch 2: Match Grain (grain statistics measured from another layer
//! and synthesised onto this one), Median (Legacy) and Curl Noise (divergence-free flow
//! distortion from the curl of a fractal noise potential).
use crate::EffectSpec;
use effectcraft_color::luminance;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::noise::{fbm, median_image};
use crate::util::{Plane, gauss_plane, layer_or_self, premul, smoothstep, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true }
}
fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
// ---------------------------------------------------------------- Match Grain
/// Grain statistics of an image: per-channel standard deviation of the high-pass residual
/// (over opaque pixels) and a grain size from the lag-1 autocorrelation.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct GrainStats {
pub std: [f32; 3],
pub size: f32,
}
pub(crate) fn grain_stats(img: &Image) -> GrainStats {
let (w, h) = (img.width as usize, img.height as usize);
if w < 3 || h < 3 {
return GrainStats::default();
}
let straight: Vec<([f32; 3], f32)> = img.data.par_iter().map(|&p| unpremul(p)).collect();
let mut std = [0.0f32; 3];
let mut rho_num = 0.0f64;
let mut rho_den = 0.0f64;
for (k, s) in std.iter_mut().enumerate() {
let pl = Plane { w, h, data: straight.iter().map(|(c, _)| c[k]).collect() };
let low = gauss_plane(&pl, 1.5, 1.5);
let hp: Vec<f32> = 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 {
sum += *v as f64;
sq += (*v as f64) * (*v as f64);
n += 1.0;
}
}
if n > 1.0 {
let mean = sum / n;
*s = ((sq / n - mean * mean).max(0.0)).sqrt() as f32;
}
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 {
rho_num += (hp[i] * hp[i + 1]) as f64;
rho_den += (hp[i] * hp[i]) as f64;
}
}
}
}
let rho = if rho_den > 1e-12 { (rho_num / rho_den).clamp(0.0, 0.95) } else { 0.0 };
GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 }
}
fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
if ctx.params.get("noiseSourceLayer").and_then(Value::as_layer).is_none() {
return b;
}
let view = ctx.params.e("viewingMode");
let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false);
let src_stats = grain_stats(&source);
let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32;
let own = if comp > 0.0 { grain_stats(&b.img) } else { GrainStats::default() };
let intensity = ctx.params.f("intensity") as f32;
let chan = [ctx.params.f("redIntensity") as f32, ctx.params.f("greenIntensity") as f32, ctx.params.f("blueIntensity") as f32];
let amp: [f32; 3] = [0, 1, 2].map(|k| {
let s = src_stats.std[k];
let e = own.std[k] * comp;
(s * s - e * e).max(0.0).sqrt() * intensity * chan[k]
});
let size = (src_stats.size.max(0.5) * ctx.params.f("size") as f32 * b.scale as f32).max(0.05);
let aspect = ctx.params.f("aspectRatio").max(0.05) as f32;
let soft = ctx.params.f("softness") * b.scale;
let mono = ctx.params.b("monochromatic");
let sat = ctx.params.f("saturation") as f32;
let blend_mode = ctx.params.e("blendingMode");
let (ws, wm, wh) = (ctx.params.f("shadows") as f32, ctx.params.f("midtones") as f32, ctx.params.f("highlights") as f32);
let seed = (ctx.params.f("randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37);
let ft = ctx.time * 24.0 * ctx.params.f("animationSpeed");
let frame = if ctx.params.b("animateSmoothly") { ft as f32 } else { ft.floor() as f32 };
let (w, h) = (b.img.width as usize, b.img.height as usize);
let nch = if mono { 1 } else { 3 };
let mut planes: Vec<Plane> = (0..nch)
.map(|k| {
let mut pl = Plane::new(w, h);
pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, v) in row.iter_mut().enumerate() {
*v = value_noise(x as f32 / size, y as f32 / (size * aspect), frame, seed.wrapping_add(k as u32 * 131)) - 0.5;
}
});
pl
})
.collect();
if soft > 0.05 {
planes = planes.iter().map(|pl| gauss_plane(pl, soft, soft * aspect as f64)).collect();
}
// Normalise each synthetic plane to unit standard deviation so the measured stats transfer.
for pl in planes.iter_mut() {
let n = pl.data.len().max(1) as f64;
let mean = pl.data.iter().map(|&v| v as f64).sum::<f64>() / n;
let var = pl.data.iter().map(|&v| (v as f64 - mean).powi(2)).sum::<f64>() / n;
let k = if var > 1e-12 { 1.0 / var.sqrt() } else { 0.0 };
*pl = pl.map(|v| ((v as f64 - mean) * k) as f32);
}
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (c, a) = unpremul(*px);
let mut g = if mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] };
let gm = (g[0] + g[1] + g[2]) / 3.0;
g = g.map(|v| gm + (v - gm) * sat);
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let s = 1.0 - smoothstep(0.0, 0.5, l);
let hi = smoothstep(0.5, 1.0, l);
let weight = ws * s + wm * (1.0 - s - hi) + wh * hi;
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],
_ => {
if a <= 0.0 {
return;
}
let o = [0, 1, 2].map(|k| {
let n = g[k] * amp[k] * weight;
let v = match blend_mode {
1 => c[k] * (1.0 + n * 2.0),
2 => 1.0 - (1.0 - c[k]) * (1.0 - n * 2.0),
_ => c[k] + n,
};
v.max(0.0)
});
*px = premul(o, a);
}
}
});
b
}
// ---------------------------------------------------------------- Median (Legacy)
fn median_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
if r == 0 {
return b;
}
if ctx.params.b("operateOnAlphaChannel") {
b.img = median_image(&b.img, r);
return b;
}
// Colour only: median of straight colour, alpha kept.
let mut straight = b.img.clone();
straight.data.par_iter_mut().for_each(|px| {
let (c, _) = unpremul(*px);
*px = [c[0], c[1], c[2], 1.0];
});
let m = median_image(&straight, r);
b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, q)| {
*px = premul([q[0], q[1], q[2]], px[3]);
});
b
}
// ---------------------------------------------------------------- Curl Noise
fn curl_noise(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amount = ctx.params.f("displacementAmount") * b.scale;
let view = ctx.params.e("view");
if amount.abs() < 1e-6 && view == 0 {
return b;
}
let scale = (ctx.params.f("scale").max(1.0) * b.scale) as f32;
let complexity = ctx.params.f("complexity") as f32;
let rot = ctx.params.f("rotation").to_radians() as f32;
let off = ctx.params.v2("offset");
let (ox, oy) = ((off[0] * b.scale + b.offset[0]) as f32, (off[1] * b.scale + b.offset[1]) as f32);
let evo = (ctx.params.f("evolution") / 360.0) as f32;
let seed = ctx.params.f("randomSeed") as i64 as u32 ^ 0x51_7cc1;
let steps = ctx.params.f("steps").clamp(1.0, 16.0) as usize;
let edge_wrap = ctx.params.e("edgeBehavior") == 1;
let (w, h) = (b.img.width as usize, b.img.height as usize);
let (cr, sr) = (rot.cos(), rot.sin());
// Potential field on the pixel grid.
let mut pot = Plane::new(w, h);
pot.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, v) in row.iter_mut().enumerate() {
let (dx, dy) = (x as f32 + 0.5 - ox, y as f32 + 0.5 - oy);
let u = (dx * cr + dy * sr) / scale;
let vv = (-dx * sr + dy * cr) / scale;
*v = fbm(u, vv, evo * 4.0, seed, complexity);
}
});
// Curl: v = (dψ/dy, -dψ/dx), normalised so the typical speed is ~1 px per unit amount.
let mut vx = Plane::new(w, h);
let mut vy = Plane::new(w, h);
vx.data.par_chunks_mut(w.max(1)).zip(vy.data.par_chunks_mut(w.max(1))).enumerate().for_each(|(y, (rx, ry))| {
for x in 0..w {
let (xi, yi) = (x as i64, y as i64);
let gx = (pot.get_clamped(xi + 1, yi) - pot.get_clamped(xi - 1, yi)) * 0.5;
let gy = (pot.get_clamped(xi, yi + 1) - pot.get_clamped(xi, yi - 1)) * 0.5;
rx[x] = gy * scale;
ry[x] = -gx * scale;
}
});
if view == 1 {
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (a, c) = (vx.data[i], vy.data[i]);
*px = [(0.5 + a * 0.5).clamp(0.0, 1.0), (0.5 + c * 0.5).clamp(0.0, 1.0), 0.5, 1.0];
});
return b;
}
let src = b.img.clone();
let dt = amount as f32 / steps as f32;
let (wf, hf) = (w as f64, h as f64);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (mut sx, mut sy) = (x as f64 + 0.5, y as f64 + 0.5);
for _ in 0..steps {
// Trace the streamline backwards (semi-Lagrangian advection).
let (u, v) = (vx.sample(sx, sy), vy.sample(sx, sy));
sx -= (u * dt) as f64;
sy -= (v * dt) as f64;
}
*px = if edge_wrap { src.sample_bilinear_clamped(sx.rem_euclid(wf), sy.rem_euclid(hf)) } else { src.sample_bilinear_clamped(sx, sy) };
}
});
b
}
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
let mult = || slider(0.0, 10.0, 0.0, 2.0, 2);
vec![
spec(
"ec.noise.matchgrain",
"Match Grain",
vec![
p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])),
p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer),
p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()),
p("intensity", "Intensity", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("size", "Size", num(1.0), slider(0.05, 100.0, 0.1, 10.0, 2)),
p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
p("aspectRatio", "Aspect Ratio", num(1.0), slider(0.05, 20.0, 0.25, 4.0, 2)),
p("redIntensity", "Red Intensity", num(1.0), mult()),
p("greenIntensity", "Green Intensity", num(1.0), mult()),
p("blueIntensity", "Blue Intensity", num(1.0), mult()),
p("monochromatic", "Monochromatic", Value::Bool(false), ParamUi::Checkbox),
p("saturation", "Saturation", num(1.0), mult()),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&["Normal", "Multiply", "Screen"])),
p("shadows", "Shadows", num(1.0), mult()),
p("midtones", "Midtones", num(1.0), mult()),
p("highlights", "Highlights", num(1.0), mult()),
p("animationSpeed", "Animation Speed", num(1.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
p("animateSmoothly", "Animate Smoothly", Value::Bool(true), ParamUi::Checkbox),
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)),
],
match_grain,
),
spec(
"ec.noise.medianlegacy",
"Median (Legacy)",
vec![
p("radius", "Radius", num(1.0), slider(0.0, 255.0, 0.0, 20.0, 0)),
p("operateOnAlphaChannel", "Operate On Alpha Channel", Value::Bool(false), ParamUi::Checkbox),
],
median_legacy,
),
spec(
"ec.noise.curlnoise",
"Curl Noise",
vec![
p("view", "View", Value::Enum(0), popup(&["Final Output", "Flow Field"])),
p("displacementAmount", "Displacement Amount", num(20.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
p("scale", "Scale", num(100.0), slider(1.0, 10000.0, 10.0, 600.0, 1)),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("offset", "Offset Turbulence", Value::Vec2([0.5, 0.5]), ParamUi::Point),
p("complexity", "Complexity", num(3.0), slider(1.0, 20.0, 1.0, 10.0, 1)),
p("evolution", "Evolution", num(0.0), ParamUi::Angle),
p("steps", "Flow Steps", num(4.0), slider(1.0, 16.0, 1.0, 16.0, 0)),
p("edgeBehavior", "Edge Behavior", Value::Enum(0), popup(&["Clamp", "Wrap"])),
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)),
],
curl_noise,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channel2::tests::FakeHost;
use crate::{EffectEnv, run_fx};
fn noisy(w: u32, h: u32, amp: f32, seed: u32) -> Image {
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
let n = (crate::util::hash1(x, y, seed) - 0.5) * 2.0 * amp;
img.set(x, y, [0.5 + n, 0.5 + n, 0.5 + n, 1.0]);
}
}
img
}
#[test]
fn match_grain_transfers_grain_strength() {
let flat = Image::filled(48, 48, [0.5, 0.5, 0.5, 1.0]);
let none = run_fx("ec.noise.matchgrain", &[], flat.clone(), 0.0, EffectEnv::default());
assert_eq!(none.img.data, flat.data);
let host = FakeHost(noisy(48, 48, 0.1, 3));
let env = EffectEnv { host: Some(&host), ..Default::default() };
let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], flat.clone(), 0.5, env);
let target = grain_stats(&host.0).std[0];
let got = grain_stats(&out.img).std[0];
assert!(got > target * 0.4 && got < target * 2.5, "target {target} got {got}");
let again = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], flat, 0.5, env);
assert_eq!(out.img.data, again.img.data);
}
#[test]
fn median_legacy_radius_zero_identity_and_removes_speck() {
let mut img = Image::filled(9, 9, [0.2, 0.2, 0.2, 1.0]);
img.set(4, 4, [1.0, 1.0, 1.0, 1.0]);
let id = run_fx("ec.noise.medianlegacy", &[("radius", num(0.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(id.img.data, img.data);
let out = run_fx("ec.noise.medianlegacy", &[("radius", num(1.0))], img.clone(), 0.0, EffectEnv::default());
assert!((out.img.get(4, 4)[0] - 0.2).abs() < 0.01);
let again = run_fx("ec.noise.medianlegacy", &[("radius", num(1.0))], img, 0.0, EffectEnv::default());
assert_eq!(out.img.data, again.img.data);
}
#[test]
fn curl_noise_zero_is_identity_and_flow_preserves_flat() {
let img = crate::channel2::tests::sample();
let id = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(0.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(id.img.data, img.data);
let flat = Image::filled(16, 16, [0.3, 0.6, 0.9, 1.0]);
let out = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], flat.clone(), 0.0, EffectEnv::default());
assert!(out.img.data.iter().all(|p| (p[1] - 0.6).abs() < 1e-5));
let mut grad = Image::new(32, 32);
for y in 0..32 {
for x in 0..32 {
grad.set(x, y, [x as f32 / 32.0, y as f32 / 32.0, 0.0, 1.0]);
}
}
let a = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], grad.clone(), 0.0, EffectEnv::default());
let b = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], grad.clone(), 0.0, EffectEnv::default());
assert_eq!(a.img.data, b.img.data);
assert!(a.img.data.iter().zip(grad.data.iter()).any(|(p, q)| (p[0] - q[0]).abs() > 0.01));
}
}