mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 01:57:53 -06:00
Merge #1968: Make Gaussian Blur and Fast Box Blur continuous at small values (#1939) (@MarekSpodBiedry)
This commit is contained in:
@@ -35,22 +35,44 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if !repeat && !ctx.adjustment {
|
||||
b.pad((s * 3.0).ceil() as u32);
|
||||
}
|
||||
b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment);
|
||||
let (sx, sy) = (s * kx, s * ky);
|
||||
// Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939).
|
||||
b.img = if effectcraft_raster::gaussian_is_exact(sx, sy) {
|
||||
effectcraft_raster::gaussian_blur_exact(&b.img, sx, sy, repeat || ctx.adjustment)
|
||||
} else {
|
||||
gaussian_blur(&b.img, sx, sy, repeat || ctx.adjustment)
|
||||
};
|
||||
b
|
||||
}
|
||||
|
||||
fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
|
||||
if r == 0 {
|
||||
let radius = (ctx.params.f("radius") * b.scale).max(0.0);
|
||||
let r = radius.floor() as usize;
|
||||
let frac = (radius - r as f64) as f32;
|
||||
// Fractional radius: blend the two neighbouring integer radii so the result changes continuously (#1939).
|
||||
let frac = if frac < 1e-4 { 0.0 } else { frac };
|
||||
if r == 0 && frac == 0.0 {
|
||||
return b;
|
||||
}
|
||||
let it = ctx.params.f("iterations").round().clamp(1.0, 50.0) as usize;
|
||||
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
|
||||
let repeat = ctx.params.b("repeatEdge");
|
||||
if !repeat && !ctx.adjustment {
|
||||
b.pad((r * it) as u32 + 1);
|
||||
let reach = if frac > 0.0 { r + 1 } else { r };
|
||||
b.pad((reach * it) as u32 + 1);
|
||||
}
|
||||
let edge = repeat || ctx.adjustment;
|
||||
let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge);
|
||||
if frac == 0.0 {
|
||||
b.img = lo;
|
||||
return b;
|
||||
}
|
||||
let hi = effectcraft_raster::box_blur(&b.img, (r + 1) * kx as usize, (r + 1) * ky as usize, it, edge);
|
||||
for ((l, h), o) in lo.data.iter().zip(hi.data.iter()).zip(b.img.data.iter_mut()) {
|
||||
for c in 0..4 {
|
||||
o[c] = l[c] + (h[c] - l[c]) * frac;
|
||||
}
|
||||
}
|
||||
b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment);
|
||||
b
|
||||
}
|
||||
|
||||
@@ -738,6 +760,42 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, run_fx};
|
||||
|
||||
/// White left half, black right half (one vertical edge at x = 20).
|
||||
fn edge() -> effectcraft_raster::Image {
|
||||
let mut img = effectcraft_raster::Image::filled(40, 8, [0.0, 0.0, 0.0, 1.0]);
|
||||
for y in 0..8 {
|
||||
for x in 0..20 {
|
||||
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn blurred_row(id: &str, param: &str, v: f64) -> Vec<f32> {
|
||||
let out = run_fx(id, &[(param, num(v)), ("repeatEdge", Value::Bool(true))], edge(), 0.0, EffectEnv::default());
|
||||
(16..24).map(|x| out.img.get(x, 4)[0]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_gaussian_blurriness_is_continuous() {
|
||||
// #1939: 0.9 used to be identical to 0, and 1.2 / 1.6 / 2.0 identical to each other.
|
||||
let row = |v| blurred_row("ec.blur.gaussian", "blurriness", v);
|
||||
assert_ne!(row(0.0), row(0.9));
|
||||
assert_ne!(row(0.9), row(1.2));
|
||||
assert_ne!(row(1.2), row(1.6));
|
||||
assert_ne!(row(1.6), row(2.0));
|
||||
assert_ne!(row(2.0), row(2.4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_box_blur_radius_is_continuous() {
|
||||
let row = |v| blurred_row("ec.blur.fastbox", "radius", v);
|
||||
assert_ne!(row(0.0), row(0.5));
|
||||
assert_ne!(row(0.5), row(0.9));
|
||||
assert_ne!(row(1.2), row(1.6));
|
||||
assert_ne!(row(1.6), row(2.4));
|
||||
}
|
||||
|
||||
/// A bright square on mid grey.
|
||||
fn scene() -> effectcraft_raster::Image {
|
||||
let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]);
|
||||
|
||||
@@ -99,7 +99,13 @@ fn gaussian_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if !ctx.adjustment {
|
||||
b.pad((s * 3.0).ceil() as u32);
|
||||
}
|
||||
b.img = gaussian_blur(&b.img, s * kx, s * ky, ctx.adjustment);
|
||||
let (sx, sy) = (s * kx, s * ky);
|
||||
// Small sigmas use the sampled kernel, like Gaussian Blur (#1939).
|
||||
b.img = if effectcraft_raster::gaussian_is_exact(sx, sy) {
|
||||
effectcraft_raster::gaussian_blur_exact(&b.img, sx, sy, ctx.adjustment)
|
||||
} else {
|
||||
gaussian_blur(&b.img, sx, sy, ctx.adjustment)
|
||||
};
|
||||
b
|
||||
}
|
||||
|
||||
@@ -175,6 +181,16 @@ mod tests {
|
||||
assert!(out.img.get(c as i64, c as i64)[3] < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaussian_legacy_small_blurriness_blurs() {
|
||||
// #1939: Blurriness under ~1.2 used to be a no-op.
|
||||
let mut img = Image::new(15, 15);
|
||||
img.set(7, 7, [1.0, 1.0, 1.0, 1.0]);
|
||||
let out = run_fx("ec.obsolete.gaussianlegacy", &[("blurriness", num(0.9))], img, 0.0, EffectEnv::default());
|
||||
let c = out.img.width / 2;
|
||||
assert!(out.img.get(c as i64, c as i64)[3] < 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_effects_are_obsolete() {
|
||||
for id in ["ec.key.luma", "ec.key.spill", "ec.blur.reduceflicker"] {
|
||||
|
||||
@@ -261,6 +261,10 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
return Some(b);
|
||||
}
|
||||
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
|
||||
// Small sigmas use the CPU's sampled kernel (#1939).
|
||||
if effectcraft_raster::gaussian_is_exact(s * kx, s * ky) {
|
||||
return None;
|
||||
}
|
||||
let repeat = ctx.params.b("repeatEdge");
|
||||
if !repeat && !ctx.adjustment {
|
||||
b.pad(e, (s * 3.0).ceil() as u32)?;
|
||||
@@ -270,7 +274,12 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
}
|
||||
|
||||
fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
|
||||
let radius = (ctx.params.f("radius") * b.scale).max(0.0);
|
||||
// Fractional radii blend two integer radii on the CPU (#1939).
|
||||
if radius - radius.floor() >= 1e-4 {
|
||||
return None;
|
||||
}
|
||||
let r = radius.floor() as usize;
|
||||
if r == 0 {
|
||||
return Some(b);
|
||||
}
|
||||
|
||||
@@ -188,6 +188,10 @@ fn gaussian_legacy(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
2 => (0.0, 1.0),
|
||||
_ => (1.0, 1.0),
|
||||
};
|
||||
// Small sigmas use the CPU's sampled kernel (#1939).
|
||||
if effectcraft_raster::gaussian_is_exact(s * kx, s * ky) {
|
||||
return None;
|
||||
}
|
||||
if !ctx.adjustment {
|
||||
b.pad(e, (s * 3.0).ceil() as u32)?;
|
||||
}
|
||||
|
||||
+19
-4
@@ -524,12 +524,27 @@ pub(crate) fn c(r: f64, g: f64, b: f64) -> Value {
|
||||
Value::Color([r, g, b, 1.0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_blurs_fall_back_to_the_cpu() {
|
||||
// #1939: small Gaussian sigmas and fractional box radii render on the CPU kernel.
|
||||
for (id, vals) in [("ec.blur.gaussian", [("blurriness", n(0.9))]), ("ec.blur.fastbox", [("radius", n(1.5))])] {
|
||||
let mut s = Scene::new(BitDepth::Bpc32);
|
||||
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
|
||||
s.push(bg);
|
||||
let mut l = s.footage(70, 44);
|
||||
s.effect(&mut l, id, &vals);
|
||||
s.push(l);
|
||||
check(&format!("{id} {vals:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_effects_match_cpu() {
|
||||
effect_case("ec.blur.gaussian", &[("blurriness", n(9.0))]);
|
||||
effect_case("ec.blur.gaussian", &[("blurriness", n(4.0)), ("dimensions", Value::Enum(1)), ("repeatEdge", Value::Bool(true))]);
|
||||
effect_case("ec.blur.fastbox", &[("radius", n(5.0)), ("iterations", n(2.0))]);
|
||||
effect_case("ec.blur.fastbox", &[("radius", n(3.0)), ("dimensions", Value::Enum(2)), ("repeatEdge", Value::Bool(true))]);
|
||||
// Sigma 3 px and up and even radii, so the half-scale pass stays on the GPU too (#1939).
|
||||
effect_case("ec.blur.gaussian", &[("blurriness", n(14.0))]);
|
||||
effect_case("ec.blur.gaussian", &[("blurriness", n(12.0)), ("dimensions", Value::Enum(1)), ("repeatEdge", Value::Bool(true))]);
|
||||
effect_case("ec.blur.fastbox", &[("radius", n(6.0)), ("iterations", n(2.0))]);
|
||||
effect_case("ec.blur.fastbox", &[("radius", n(4.0)), ("dimensions", Value::Enum(2)), ("repeatEdge", Value::Bool(true))]);
|
||||
effect_case("ec.blur.directional", &[("direction", n(30.0)), ("length", n(12.0))]);
|
||||
effect_case("ec.stylize.glow", &[("threshold", n(40.0)), ("radius", n(14.0)), ("intensity", n(1.5))]);
|
||||
effect_case("ec.stylize.glow", &[("colors", Value::Enum(1)), ("operation", Value::Enum(1)), ("colorA", c(1.0, 0.8, 0.1))]);
|
||||
|
||||
@@ -46,6 +46,7 @@ fn bevels_and_legacy_gaussian() {
|
||||
effect_case("ec.perspective.bevelalpha", &[("edgeThickness", n(2.0)), ("lightAngle", n(200.0)), ("lightColor", c(1.0, 0.8, 0.5))]);
|
||||
effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.1)), ("lightIntensity", n(0.5))]);
|
||||
effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.3)), ("lightAngle", n(130.0))]);
|
||||
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(7.0))]);
|
||||
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(5.0)), ("blurDimensions", e(2))]);
|
||||
// Sigma 3 px and up at half scale too: smaller ones render on the CPU (#1939).
|
||||
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(14.0))]);
|
||||
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(12.0)), ("blurDimensions", e(2))]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Blurs: separable box passes (exact running sums) approximating Gaussians, plus directional
|
||||
//! and radial (spin/zoom) blurs.
|
||||
//! Blurs: separable box passes (exact running sums) approximating Gaussians, a sampled Gaussian
|
||||
//! kernel for small sigmas, plus directional and radial (spin/zoom) blurs.
|
||||
//!
|
||||
//! Box passes run straight on row-major pixels: horizontal passes per row, vertical passes over
|
||||
//! blocks of rows with a per-column running sum (no transposes), ping-ponging between two
|
||||
@@ -157,6 +157,49 @@ pub fn gaussian_blur(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> I
|
||||
box_passes(img, &rx, &ry, repeat)
|
||||
}
|
||||
|
||||
/// Below this sigma (pixels) three integer-radius box passes are too coarse (sigma under ~0.6 is
|
||||
/// a no-op and the result steps between a few shapes), so [`gaussian_blur_exact`] is used instead.
|
||||
const EXACT_SIGMA_BELOW: f64 = 3.0;
|
||||
|
||||
/// True when a Gaussian of these per-axis sigmas should use [`gaussian_blur_exact`].
|
||||
pub fn gaussian_is_exact(sigma_x: f64, sigma_y: f64) -> bool {
|
||||
sigma_x.max(sigma_y) < EXACT_SIGMA_BELOW
|
||||
}
|
||||
|
||||
/// One separable pass of a sampled Gaussian (radius 3 sigma) along x or y.
|
||||
fn gauss_pass(img: &Image, sigma: f64, vertical: bool, repeat: bool) -> Image {
|
||||
if sigma <= 0.0 || img.is_empty() {
|
||||
return img.clone();
|
||||
}
|
||||
let r = (sigma * 3.0).ceil() as i64;
|
||||
let mut k: Vec<f64> = (-r..=r).map(|i| (-((i * i) as f64) / (2.0 * sigma * sigma)).exp()).collect();
|
||||
let total: f64 = k.iter().sum();
|
||||
k.iter_mut().for_each(|v| *v /= total);
|
||||
let w = img.width as usize;
|
||||
let mut out = Image::new(img.width, img.height);
|
||||
out.rows_mut().for_each(|(y, row)| {
|
||||
for x in 0..w {
|
||||
let mut acc = [0.0f64; 4];
|
||||
for (i, wt) in k.iter().enumerate() {
|
||||
let d = i as i64 - r;
|
||||
let (sx, sy) = if vertical { (x as i64, y as i64 + d) } else { (x as i64 + d, y as i64) };
|
||||
let p = if repeat { img.get_clamped(sx, sy) } else { img.get(sx, sy) };
|
||||
for c in 0..4 {
|
||||
acc[c] += p[c] as f64 * wt;
|
||||
}
|
||||
}
|
||||
row[x] = acc.map(|v| v as f32);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Gaussian blur as a true sampled kernel, continuous in sigma (for small sigmas, see
|
||||
/// [`gaussian_is_exact`]).
|
||||
pub fn gaussian_blur_exact(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> Image {
|
||||
gauss_pass(&gauss_pass(img, sigma_x, false, repeat), sigma_y, true, repeat)
|
||||
}
|
||||
|
||||
/// Motion blur along `angle_deg` (0 = vertical in AE's Directional Blur, measured clockwise from
|
||||
/// up) over `length` pixels.
|
||||
pub fn directional_blur(img: &Image, angle_deg: f64, length: f64) -> Image {
|
||||
|
||||
@@ -16,7 +16,7 @@ pub mod warp;
|
||||
use effectcraft_color::{BlendMode, blend_pixel};
|
||||
use rayon::prelude::*;
|
||||
|
||||
pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur};
|
||||
pub use blur::{box_blur, directional_blur, gaussian_blur, gaussian_blur_exact, gaussian_is_exact, radial_blur};
|
||||
pub use channels3d::AuxChannels;
|
||||
pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user