mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-05 18:18:49 +00:00
Merge PR #2922
This commit is contained in:
commit
5720043d32
2 files changed
+81
-12
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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", ¶ms, 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]);
|
||||
|
||||
Reference in new issue
Block a user