Fix #2919: Match Grain takes no sample box beside transparent pixels

A box's own pixels had to be opaque, but the high-pass that measures its
grain reads 3 px around it, and a transparent pixel there is black once
unpremultiplied. A blur earlier in the stack pads the layer with
transparent pixels, so boxes along that border measured the edge as
strong, smooth colour grain, and Match Grain drew 12 px colour cells
from a clean source on the frames that picked them. Boxes now need their
3 px surround opaque too (a summed-area table keeps the check O(1) per
candidate). Remove Grain picks its boxes the same way.
This commit is contained in:
JonRC committed 2026-11-02 22:49:52 -03:00
1 parent 7870b5916a
commit c6ab02b83a
2 files changed
+57 -5

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();
+30
View File
@@ -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]);