diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 9d21e77a7..b6f76a268 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -254,8 +254,14 @@ pub(crate) fn preview_compose(ctx: &EffectCtx, b: &mut Buf, orig: &Image) { }); } +/// How far the grain statistics read around a sample box: their high-pass subtracts a Gaussian +/// of sigma 1.5, three box passes of radius 1 (`noise2::grain_stats_boxes`). +const SAMPLE_REACH: usize = 3; + /// 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). +/// candidate boxes (real grain shows best where the picture has no detail). A box and the +/// [`SAMPLE_REACH`] px around it are opaque: a transparent pixel there is black once +/// unpremultiplied, and the high-pass would measure that edge as grain. 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)); @@ -264,6 +270,19 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize } let low = Plane::luma(img); let step = (size / 2).max(1); + // Transparent pixels (alpha 0.5 or less) in a summed-area table: `sat[y * (w + 1) + x]` + // counts them above and left of (x, y). + let sw = w + 1; + let mut sat = vec![0usize; sw * (h + 1)]; + for y in 0..h { + let mut run = 0; + for x in 0..w { + let opaque = img.data[y * w + x][3] > 0.5; + run += usize::from(!opaque); + sat[(y + 1) * sw + x + 1] = sat[y * sw + x + 1] + run; + } + } + let sat = sat.as_slice(); // Candidate rows in parallel; the sort below is a total order (variance, y, x), so the // result does not depend on the collection order. let rows: Vec = (0..=h - size).step_by(step).collect(); @@ -271,20 +290,23 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize .par_iter() .flat_map_iter(|&y| { let data = low.data.as_slice(); - let alpha = img.data.as_slice(); (0..=w - size).step_by(step).filter_map(move |x| { + let (x0, y0) = (x.saturating_sub(SAMPLE_REACH), y.saturating_sub(SAMPLE_REACH)); + let (x1, y1) = ((x + size + SAMPLE_REACH).min(w), (y + size + SAMPLE_REACH).min(h)); + if sat[y1 * sw + x1] + sat[y0 * sw + x0] != sat[y0 * sw + x1] + sat[y1 * sw + x0] { + return None; + } // 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); + let (mut s, mut sq, mut n) = (0.0f32, 0.0f32, 0.0f32); for yy in y..y + size { for xx in x..x + size { let v = data[yy * w + xx]; s += v; sq += v * v; n += 1.0; - opaque &= alpha[yy * w + xx][3] > 0.5; } } - opaque.then(|| (sq / n - (s / n).powi(2), x, y)) + Some((sq / n - (s / n).powi(2), x, y)) }) }) .collect(); diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index 48d840416..5ffdce5f5 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -464,6 +464,36 @@ mod tests { assert_eq!(out.img.data, again.img.data); } + /// #2919: a sample box beside transparent pixels measured the edge as grain. Its own + /// pixels are opaque, but the high-pass blur reads 3 px around it, where transparent pixels + /// are black once unpremultiplied. An earlier blur pads the layer with transparent pixels, + /// so a clean plate got blocky colour grain on the frames whose flattest boxes sat at the + /// edge, and fine grain on the others. + #[test] + fn match_grain_ignores_the_edge_of_a_transparent_border() { + // A flat, noiseless sky inside the 2 px transparent frame of a padded buffer. + let (w, h) = (64u32, 48u32); + let mut sky = Image::filled(w, h, [0.4, 0.6, 0.9, 1.0]); + for y in 0..h { + for x in 0..w { + if x < 2 || y < 2 || x >= w - 2 || y >= h - 2 { + sky.set(x, y, [0.0; 4]); + } + } + } + let boxes = crate::noise::sample_boxes(&sky, 80, 9); + assert!(!boxes.is_empty()); + let st = grain_stats_boxes(&sky, &boxes); + assert!(st.std.iter().all(|s| *s < 1e-5), "{st:?} from {boxes:?}"); + // So Match Grain finds no grain to add. + let host = FakeHost(sky); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let target = Image::filled(w, h, [0.2, 0.3, 0.5, 1.0]); + let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], target.clone(), 0.5, env); + let most = out.img.data.iter().zip(&target.data).flat_map(|(a, b)| (0..3).map(move |k| (a[k] - b[k]).abs())).fold(0.0f32, f32::max); + assert!(most < 1e-5, "Match Grain added {most}"); + } + #[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]);