This commit is contained in:
Brandon Thomas committed 2026-11-03 09:04:45 -05:00
commit 5720043d32
2 files changed
+81 -12

No files matched your search

+27 -5
View File
@@ -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<usize> = (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();
+54 -7
View File
@@ -190,15 +190,14 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
b.img = img;
return b;
}
let src_stats = grain_stats_boxes(&source, &src_boxes);
// No box with an opaque surround (a small layer inside transparent pixels) means no clean
// sample: no grain to match, as After Effects applies none when its samples hold transparent
// areas. The whole-image fallback of `grain_stats_boxes` would measure the edge again.
let clean_stats = |img: &Image, boxes: &[(usize, usize, usize)]| if boxes.is_empty() { GrainStats::default() } else { grain_stats_boxes(img, boxes) };
let src_stats = clean_stats(&source, &src_boxes);
let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32;
// The own-layer boxes only matter for the compensation term; skip them when it is off.
let own = if comp > 0.0 {
let own_boxes = sampling_boxes(ctx, &b.img, b.scale);
grain_stats_boxes(&b.img, &own_boxes)
} else {
GrainStats::default()
};
let own = if comp > 0.0 { clean_stats(&b.img, &sampling_boxes(ctx, &b.img, b.scale)) } else { GrainStats::default() };
let orig = (view == 0).then(|| b.img.clone());
let look = GrainLook::from_params(ctx);
let amp: [f32; 3] = [0, 1, 2].map(|k| {
@@ -464,6 +463,54 @@ 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 colour inside the 2 px transparent frame of a padded buffer.
let framed = |w: u32, h: u32, c: [f32; 4]| {
let mut img = Image::filled(w, h, c);
for y in 0..h {
for x in 0..w {
if x < 2 || y < 2 || x >= w - 2 || y >= h - 2 {
img.set(x, y, [0.0; 4]);
}
}
}
img
};
let most_change = |a: &Image, b: &Image| a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |k| (p[k] - q[k]).abs())).fold(0.0f32, f32::max);
// A noiseless sky. At 16 px no 9 px box has an opaque 3 px surround, so there is no
// clean sample at all (the whole-image fallback would measure the edge).
for (w, h, clean_boxes) in [(64u32, 48u32, true), (16, 16, false)] {
let sky = framed(w, h, [0.4, 0.6, 0.9, 1.0]);
let boxes = crate::noise::sample_boxes(&sky, 80, 9);
assert_eq!(!boxes.is_empty(), clean_boxes, "{w}x{h}: {boxes:?}");
if clean_boxes {
let st = grain_stats_boxes(&sky, &boxes);
assert!(st.std.iter().all(|s| *s < 1e-5), "{w}x{h}: {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 = most_change(&out.img, &target);
assert!(most < 1e-5, "{w}x{h}: Match Grain added {most}");
}
// Compensate for Existing Noise has no clean sample of such a layer either, so its edge
// doesn't cancel the source's grain.
let host = FakeHost(noisy(16, 16, 0.1, 3));
let env = EffectEnv { host: Some(&host), ..Default::default() };
let target = framed(16, 16, [0.9, 0.9, 0.9, 1.0]);
let params = [("noiseSourceLayer", Value::Layer(Some(7))), ("compensateForExistingNoise", crate::num(100.0))];
let out = run_fx("ec.noise.matchgrain", &params, target.clone(), 0.5, env);
assert!(most_change(&out.img, &target) > 0.01, "the layer's own edge cancelled the grain");
}
#[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]);