Files
effectcraft/crates/effects/src/blur2.rs
T

968 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Blur & Sharpen, batch 2: edge-preserving blurs (Bilateral, Smart), per-channel and map-driven
//! blurs (Channel, Compound, CC Vector), bokeh (Camera Lens Blur), radial streaks and small
//! utilities (Reduce Interlace Flicker, CC Cross Blur).
use effectcraft_color::luminance;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px, gaussian_blur};
use rayon::prelude::*;
use crate::util::{Plane, gauss_plane, premul, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Blur & Sharpen", params, render, gpu: false, float: true }
}
#[inline]
fn plum(p: Px) -> f32 {
luminance(p[0], p[1], p[2])
}
// ---------------------------------------------------------------------------------------------
// Bilateral / Smart Blur
/// Edge-preserving neighbourhood average. `hard` = box spatial kernel + hard luminance range test
/// (Smart Blur); otherwise Gaussian spatial × Gaussian range weights (bilateral filter, Tomasi &
/// Manduchi 1998). Large radii are sampled on a stride to stay fast.
fn edge_preserving(src: &Image, r: f64, range: f32, hard: bool) -> Image {
let ri = r.ceil() as i64;
let stride = ((r / 6.0).ceil() as i64).max(1);
let sig_s2 = 2.0 * (r / 2.0).max(0.5).powi(2);
let sig_r2 = 2.0 * range.max(1e-3).powi(2);
let mut out = Image::new(src.width, src.height);
out.rows_mut().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let c = src.data[y * src.width as usize + x];
let cl = plum(c);
let mut acc = [0.0f32; 4];
let mut wsum = 0.0f32;
let mut dy = -ri;
while dy <= ri {
let mut dx = -ri;
while dx <= ri {
let d2 = (dx * dx + dy * dy) as f64;
if d2 <= r * r + 0.5 {
let q = src.get_clamped(x as i64 + dx, y as i64 + dy);
let w = if hard {
if (plum(q) - cl).abs() <= range { 1.0 } else { 0.0 }
} else {
let dc = (q[0] - c[0]).powi(2) + (q[1] - c[1]).powi(2) + (q[2] - c[2]).powi(2) + (q[3] - c[3]).powi(2);
((-d2 / sig_s2) as f32 - dc / sig_r2).exp()
};
if w > 0.0 {
for k in 0..4 {
acc[k] += q[k] * w;
}
wsum += w;
}
}
dx += stride;
}
dy += stride;
}
*o = if wsum > 0.0 { acc.map(|v| v / wsum) } else { c };
}
});
out
}
fn bilateral(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = ctx.params.f("radius") * b.scale;
if r < 0.5 {
return b;
}
let range = ctx.params.f("threshold") as f32 / 100.0;
b.img = edge_preserving(&b.img, r, range, false);
if !ctx.params.b("colorize") {
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]);
[l, l, l]
});
}
b
}
/// Sobel gradient magnitude of premultiplied luminance.
fn sobel(img: &Image) -> Plane {
let l = Plane::from_image(img, plum);
let mut out = Plane::new(l.w, l.h);
if l.w == 0 {
return out;
}
out.data.par_chunks_mut(l.w).enumerate().for_each(|(y, row)| {
let y = y as i64;
for (x, o) in row.iter_mut().enumerate() {
let x = x as i64;
let g = |dx: i64, dy: i64| l.get_clamped(x + dx, y + dy);
let gx = g(1, -1) + 2.0 * g(1, 0) + g(1, 1) - g(-1, -1) - 2.0 * g(-1, 0) - g(-1, 1);
let gy = g(-1, 1) + 2.0 * g(0, 1) + g(1, 1) - g(-1, -1) - 2.0 * g(0, -1) - g(1, -1);
*o = (gx * gx + gy * gy).sqrt() * 0.25;
}
});
out
}
fn smart_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = ctx.params.f("radius") * b.scale;
let thr = ctx.params.f("threshold") as f32 / 100.0;
let mode = ctx.params.e("mode");
let blurred = if r >= 0.5 { edge_preserving(&b.img, r, thr, true) } else { b.img.clone() };
if mode == 0 {
b.img = blurred;
return b;
}
let edges = sobel(&blurred);
let et = thr.max(0.01) * 0.5;
let mut out = blurred;
out.data.par_iter_mut().zip(edges.data.par_iter()).for_each(|(px, &e)| {
let a = px[3];
let on = e > et;
if mode == 1 {
let v = if on { 1.0 } else { 0.0 };
*px = premul([v; 3], a);
} else if on {
*px = premul([1.0; 3], a);
}
});
b.img = out;
b
}
// ---------------------------------------------------------------------------------------------
// Channel Blur
fn dims_xy(dim: u32) -> (f64, f64) {
match dim {
1 => (1.0, 0.0),
2 => (0.0, 1.0),
_ => (1.0, 1.0),
}
}
fn channel_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let sig = ["redBlurriness", "greenBlurriness", "blueBlurriness", "alphaBlurriness"].map(|id| ctx.params.f(id).max(0.0) * 0.5 * b.scale);
if sig.iter().all(|&s| s < 0.05) {
return b;
}
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
let repeat = ctx.params.b("repeatEdge") || ctx.adjustment;
let maxs = sig.iter().cloned().fold(0.0, f64::max);
if !repeat {
b.pad((maxs * 3.0).ceil() as u32 + 1);
}
// The buffer is padded with transparency (or edges repeat), so an edge-clamped plane blur fits both.
let blur = |pl: &Plane, s: f64| -> Plane { gauss_plane(pl, s * kx, s * ky) };
let chans: [Plane; 4] = crate::util::split(&b.img);
let alpha = &chans[3];
let mut alpha_cache: Vec<(u64, Plane)> = Vec::new();
let mut get_alpha = |s: f64| -> Plane {
let key = s.to_bits();
if let Some((_, pl)) = alpha_cache.iter().find(|(k, _)| *k == key) {
return pl.clone();
}
let pl = if s < 0.05 { alpha.clone() } else { blur(alpha, s) };
alpha_cache.push((key, pl.clone()));
pl
};
let new_a = get_alpha(sig[3]);
let mut straight: Vec<Plane> = Vec::with_capacity(3);
for c in 0..3 {
if sig[c] < 0.05 {
straight.push(chans[c].zip_map(alpha, |v, a| if a > 1e-6 { v / a } else { 0.0 }));
} else {
let bc = blur(&chans[c], sig[c]);
let ba = get_alpha(sig[c]);
straight.push(bc.zip_map(&ba, |v, a| if a > 1e-6 { v / a } else { 0.0 }));
}
}
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let a = new_a.data[i].clamp(0.0, 1.0);
*px = premul([straight[0].data[i], straight[1].data[i], straight[2].data[i]], a);
});
b
}
// ---------------------------------------------------------------------------------------------
// Compound Blur
fn compound_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let max_sigma = ctx.params.f("maximumBlur").max(0.0) * 0.5 * b.scale;
if max_sigma < 0.05 {
return b;
}
let invert = ctx.params.b("invertBlur");
if !ctx.adjustment {
b.pad((max_sigma * 3.0).ceil() as u32 + 1);
}
// The blur map: the Blur Layer's luminance (stretched to fit or centred), else the layer's own.
let map_img = crate::util::layer_or_self(ctx, &b, "blurLayer", true, ctx.params.b("stretchMapToFit"));
let map = Plane::from_image(&map_img, |p| {
let (c, a) = unpremul(p);
let l = luminance(c[0], c[1], c[2]) * a;
if invert { 1.0 - l } else { l }
});
const N: usize = 5;
let levels: Vec<Image> = (0..N)
.into_par_iter()
.map(|k| {
if k == 0 {
b.img.clone()
} else {
gaussian_blur(&b.img, max_sigma * k as f64 / (N - 1) as f64, max_sigma * k as f64 / (N - 1) as f64, ctx.adjustment)
}
})
.collect();
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let t = map.data[i].clamp(0.0, 1.0) * (N - 1) as f32;
let k = (t.floor() as usize).min(N - 2);
let f = t - k as f32;
let a = levels[k].data[i];
let c = levels[k + 1].data[i];
*px = crate::util::lerp4(a, c, f);
});
b
}
// ---------------------------------------------------------------------------------------------
// Camera Lens Blur
/// Half-open horizontal spans (dy, x_left, x_right) of a polygonal / round iris of radius `r`.
fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64, i64, i64)> {
let ri = r.ceil() as i64;
let rot = rotation_deg.to_radians();
let verts: Vec<(f64, f64)> = (0..sides)
.map(|k| {
let a = rot - std::f64::consts::FRAC_PI_2 + k as f64 * std::f64::consts::TAU / sides.max(3) as f64;
(r * a.cos(), r * a.sin())
})
.collect();
let mut spans = Vec::new();
for dy in -ri..=ri {
let y = dy as f64;
let circ = if y.abs() <= r { Some((r * r - y * y).max(0.0).sqrt()) } else { None };
let poly = if sides >= 3 {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for k in 0..verts.len() {
let (x0, y0) = verts[k];
let (x1, y1) = verts[(k + 1) % verts.len()];
if (y0 - y) * (y1 - y) <= 0.0 && (y1 - y0).abs() > 1e-12 {
let x = x0 + (y - y0) / (y1 - y0) * (x1 - x0);
lo = lo.min(x);
hi = hi.max(x);
}
}
if lo <= hi { Some((lo, hi)) } else { None }
} else {
circ.map(|c| (-c, c))
};
let span = match (poly, circ) {
(Some((lo, hi)), Some(c)) => Some((lo + (-c - lo) * roundness, hi + (c - hi) * roundness)),
(Some(s), None) => Some(s),
(None, Some(c)) if roundness > 0.5 => Some((-c, c)),
_ => None,
};
if let Some((lo, hi)) = span {
let (l, h) = (lo.round() as i64, hi.round() as i64);
if l <= h {
spans.push((dy, l, h));
}
}
}
if spans.is_empty() {
spans.push((0, 0, 0));
}
spans
}
/// Camera Lens Blur's iris spans at blur radius `r` pixels (shape, rotation, roundness and
/// aspect ratio applied) and the iris' horizontal reach (shared with the GPU kernel).
pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) {
let shape = ctx.params.e("irisProperties/irisShape");
let sides = if shape >= 8 { 0 } else { shape + 3 };
let roundness = ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0;
// Aspect Ratio stretches the iris horizontally (> 1) or squeezes it (< 1).
let aspect = ctx.params.f("irisProperties/irisAspectRatio");
let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), roundness);
if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 {
for s in spans.iter_mut() {
s.1 = (s.1 as f64 * aspect).round() as i64;
s.2 = (s.2 as f64 * aspect).round() as i64;
}
}
(spans, r * aspect.max(1.0))
}
/// Whether Camera Lens Blur needs more than plain iris spans (Diffraction Fringe or a Blur
/// Map): the GPU kernel handles the plain blur only.
pub fn camera_lens_plain(ctx: &EffectCtx) -> bool {
ctx.params.f("irisProperties/diffractionFringe") <= 0.0 && ctx.params.get("blurMap/blurMapLayer").and_then(|v| v.as_layer()).is_none()
}
fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = ctx.params.f("blurRadius").max(0.0) * b.scale;
if r < 0.5 {
return b;
}
let shape = ctx.params.e("irisProperties/irisShape");
let sides = if shape >= 8 { 0 } else { shape + 3 };
let roundness = ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0;
// Aspect Ratio stretches the iris horizontally (> 1) or squeezes it (< 1).
let aspect = ctx.params.f("irisProperties/irisAspectRatio");
let reach = r * aspect.max(1.0);
let repeat = ctx.params.b("repeatEdge") || ctx.adjustment;
if !repeat {
b.pad(reach.ceil() as u32 + 1);
}
let linear = ctx.params.b("useLinear");
let to_lin = |px: &mut [f32; 4]| {
let (c, a) = unpremul(*px);
*px = premul(c.map(effectcraft_color::srgb_to_linear), a);
};
// Highlights: boost bright pixels before the blur so they bloom into bokeh shapes; Saturation
// keeps their colour (100) or pushes the added energy towards white (0).
let thr = ctx.params.f("highlight/specularThreshold") as f32 / 255.0;
let boost = 1.0 + ctx.params.f("highlight/specularBrightness").max(0.0) as f32 / 100.0 * 4.0;
let sat = (ctx.params.f("highlight/highlightSaturation") / 100.0).clamp(0.0, 1.0) as f32;
let mut src = b.img.clone();
if linear {
src.data.par_iter_mut().for_each(to_lin);
}
if boost > 1.0 {
src.data.par_iter_mut().for_each(|px| {
let (c, a) = unpremul(*px);
if a > 0.0 && luminance(c[0], c[1], c[2]) >= thr {
let m = c[0].max(c[1]).max(c[2]);
for k in 0..3 {
let tinted = c[k] + (m - c[k]) * (1.0 - sat);
px[k] = (c[k] + tinted * (boost - 1.0)) * a;
}
}
});
}
let (w, h) = (src.width as usize, src.height as usize);
// Per-row prefix sums (f64 for precision).
let prefix: Vec<Vec<[f64; 4]>> = src
.data
.par_chunks(w.max(1))
.map(|row| {
let mut v = Vec::with_capacity(w + 1);
let mut acc = [0.0f64; 4];
v.push(acc);
for px in row {
for c in 0..4 {
acc[c] += px[c] as f64;
}
v.push(acc);
}
v
})
.collect();
// Sum of the source over the iris spans centred on (x, y).
let span_sum = |spans: &[(i64, i64, i64)], x: usize, y: usize| -> [f64; 4] {
let mut acc = [0.0f64; 4];
for &(dy, l, rr) in spans {
let mut yy = y as i64 + dy;
if yy < 0 || yy >= h as i64 {
if !repeat {
continue;
}
yy = yy.clamp(0, h as i64 - 1);
}
let pre = &prefix[yy as usize];
let (mut a, mut bnd) = (x as i64 + l, x as i64 + rr);
if repeat {
if a < 0 {
let n = (-a).min(bnd - a + 1) as f64;
let first = src.data[yy as usize * w];
for c in 0..4 {
acc[c] += first[c] as f64 * n;
}
a = 0;
}
if bnd >= w as i64 {
let n = (bnd - (w as i64 - 1)).min(bnd - a + 1) as f64;
let last = src.data[yy as usize * w + w - 1];
for c in 0..4 {
acc[c] += last[c] as f64 * n;
}
bnd = w as i64 - 1;
}
} else {
a = a.max(0);
bnd = bnd.min(w as i64 - 1);
}
if a <= bnd {
for c in 0..4 {
acc[c] += pre[bnd as usize + 1][c] - pre[a as usize][c];
}
}
}
acc
};
// Diffraction Fringe: the iris edge (outer 20 % of the radius) gets more of the energy:
// 0 = an even disc, 100 = a natural halo, 500 = all of it in the ring.
let fringe = (ctx.params.f("irisProperties/diffractionFringe") / 500.0).clamp(0.0, 1.0);
let (w_in, w_ring) = (1.0 - fringe, 1.0 + 4.0 * fringe);
let iris = |radius: f64| -> (Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>) {
let stretch = |mut s: Vec<(i64, i64, i64)>| {
if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 {
for v in s.iter_mut() {
v.1 = (v.1 as f64 * aspect).round() as i64;
v.2 = (v.2 as f64 * aspect).round() as i64;
}
}
s
};
let outer = stretch(iris_spans(radius, sides, ctx.params.f("irisProperties/irisRotation"), roundness));
let inner = if fringe > 0.0 && radius * 0.8 >= 1.0 {
stretch(iris_spans(radius * 0.8, sides, ctx.params.f("irisProperties/irisRotation"), roundness))
} else {
vec![]
};
(outer, inner)
};
let count = |s: &[(i64, i64, i64)]| s.iter().map(|(_, l, r)| (r - l + 1) as f64).sum::<f64>();
// One blurred pixel with the given iris.
let blur_px = |sp: &(Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>), x: usize, y: usize| -> [f32; 4] {
let (outer, inner) = sp;
let acc = if inner.is_empty() {
let n = count(outer).max(1.0);
span_sum(outer, x, y).map(|v| v / n)
} else {
let (so, si) = (span_sum(outer, x, y), span_sum(inner, x, y));
let (no, ni) = (count(outer), count(inner));
let norm = (w_in * ni + w_ring * (no - ni)).max(1e-9);
std::array::from_fn(|c| (w_in * si[c] + w_ring * (so[c] - si[c])) / norm)
};
let mut px = acc.map(|v| v as f32);
px[3] = px[3].clamp(0.0, 1.0);
if linear {
let (c, a) = unpremul(px);
px = premul(c.map(|v| effectcraft_color::linear_to_srgb(v.max(0.0))), a);
}
px
};
// Blur Map: per-pixel radius from a map layer (no blur at Blur Focal Distance, full radius
// at the farthest value).
let map = ctx.layer_param("blurMap/blurMapLayer", true).map(|o| {
let stretch = ctx.params.e("blurMap/placement") == 1;
let img = crate::util::fit_layer(ctx, &b, &o, stretch);
let ch = ctx.params.e("blurMap/channel");
let invert = ctx.params.b("blurMap/invertBlurMap");
let focal = (ctx.params.f("blurMap/blurFocalDistance") / 255.0).clamp(0.0, 1.0) as f32;
let span = focal.max(1.0 - focal).max(1e-3);
img.data
.iter()
.map(|p| {
let (c, a) = unpremul(*p);
let mut v = match ch {
1 => a,
2 => c[0].max(c[1]).max(c[2]) * a,
_ => luminance(c[0], c[1], c[2]) * a,
};
if invert {
v = 1.0 - v;
}
((v - focal).abs() / span).clamp(0.0, 1.0)
})
.collect::<Vec<f32>>()
});
let mut out = Image::new(src.width, src.height);
match &map {
None => {
let sp = iris(r);
out.rows_mut().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
*o = blur_px(&sp, x, y);
}
});
}
Some(m) => {
// Radii quantised to levels; each pixel blends the two nearest.
let levels = (r.ceil() as usize).clamp(1, 8);
let irises: Vec<_> = (0..=levels).map(|k| iris(r * k as f64 / levels as f64)).collect();
let srcd = &b.img.data;
out.rows_mut().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let f = m[y * w + x] * levels as f32;
let k0 = (f.floor() as usize).min(levels);
let t = f - k0 as f32;
let at = |k: usize| if k == 0 || r * k as f64 / (levels as f64) < 0.5 { srcd[y * w + x] } else { blur_px(&irises[k], x, y) };
let a = at(k0);
*o = if t > 1e-4 && k0 < levels {
let bq = at(k0 + 1);
std::array::from_fn(|c| a[c] + (bq[c] - a[c]) * t)
} else {
a
};
}
});
}
}
b.img = out;
b
}
// ---------------------------------------------------------------------------------------------
// CC Radial Fast Blur
fn radial_fast_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5;
if s <= 0.0 {
return b;
}
let c = b.to_px(ctx.params.v2("center"));
let mode = ctx.params.e("zoom");
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1);
let len = (dx * dx + dy * dy).sqrt() * s;
let n = (len.ceil() as usize).clamp(2, 64);
let mut acc = match mode {
1 => [f32::NEG_INFINITY; 4],
2 => [f32::INFINITY; 4],
_ => [0.0; 4],
};
for i in 0..n {
let k = 1.0 - s * i as f64 / (n - 1) as f64;
let q = src.sample_bilinear(c.0 + dx * k, c.1 + dy * k);
for ch in 0..4 {
acc[ch] = match mode {
1 => acc[ch].max(q[ch]),
2 => acc[ch].min(q[ch]),
_ => acc[ch] + q[ch] / n as f32,
};
}
}
for ch in 0..3 {
acc[ch] = acc[ch].min(acc[3]).max(0.0);
}
acc[3] = acc[3].clamp(0.0, 1.0);
*o = acc;
}
});
b
}
// ---------------------------------------------------------------------------------------------
// CC Vector Blur
fn vector_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amount = ctx.params.f("amount").max(0.0) * b.scale;
if amount < 0.5 {
return b;
}
let kind = ctx.params.e("type");
let off = ctx.params.f("angleOffset").to_radians();
let soft = ctx.params.f("mapSoftness").max(0.0) * 0.5 * b.scale;
let map = gauss_plane(&Plane::from_image(&b.img, plum), soft, soft);
let src = b.img.clone();
let (w, h) = (map.w as i64, map.h as i64);
b.img.rows_mut().for_each(|(y, row)| {
let y = y as i64;
for (x, o) in row.iter_mut().enumerate() {
let x = x as i64;
let gx = (map.get_clamped((x + 1).min(w - 1), y) - map.get_clamped((x - 1).max(0), y)) * 0.5;
let gy = (map.get_clamped(x, (y + 1).min(h - 1)) - map.get_clamped(x, (y - 1).max(0))) * 0.5;
let mag = (gx * gx + gy * gy).sqrt() as f64;
let m = map.get_clamped(x, y) as f64;
let (ang, len) = match kind {
0..=2 => {
if mag < 1e-6 {
continue;
}
let g = (gy as f64).atan2(gx as f64);
let base = if kind == 2 { g } else { g + std::f64::consts::FRAC_PI_2 };
let len = if kind == 1 { amount } else { amount * (mag * 10.0).min(1.0) };
(base + off, len)
}
3 => (m * std::f64::consts::TAU + off, amount),
_ => (m * std::f64::consts::TAU + off, amount * m),
};
if len < 0.5 {
continue;
}
let (dx, dy) = (ang.cos() * len, ang.sin() * len);
let n = ((len * 2.0).ceil() as usize).clamp(2, 48);
let mut acc = [0.0f32; 4];
for i in 0..n {
let t = i as f64 / (n - 1) as f64 - 0.5;
let q = src.sample_bilinear_clamped(x as f64 + 0.5 + dx * t, y as f64 + 0.5 + dy * t);
for c in 0..4 {
acc[c] += q[c] / n as f32;
}
}
*o = acc;
}
});
b
}
// ---------------------------------------------------------------------------------------------
// Reduce Interlace Flicker / CC Cross Blur
fn reduce_flicker(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = ctx.params.f("softness").max(0.0) * b.scale;
if s < 0.05 {
return b;
}
b.img = gaussian_blur(&b.img, 0.0, s, true);
b
}
fn cross_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let rx = (ctx.params.f("radiusX").max(0.0) * b.scale).round() as usize;
let ry = (ctx.params.f("radiusY").max(0.0) * b.scale).round() as usize;
if rx == 0 && ry == 0 {
return b;
}
if !ctx.adjustment {
b.pad((rx.max(ry) * 3) as u32 + 1);
}
let repeat = ctx.adjustment;
let hz = effectcraft_raster::box_blur(&b.img, rx, 0, 3, repeat);
let vt = effectcraft_raster::box_blur(&b.img, 0, ry, 3, repeat);
let mode = ctx.params.e("transferMode");
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (a, v) = (hz.data[i], vt.data[i]);
let mut o = [0.0f32; 4];
for c in 0..4 {
o[c] = match mode {
1 => a[c] + v[c],
2 => a[c] + v[c] - a[c] * v[c],
3 => a[c].max(v[c]),
_ => (a[c] + v[c]) * 0.5,
};
}
o[3] = o[3].clamp(0.0, 1.0);
*px = o;
});
b
}
pub fn specs() -> Vec<EffectSpec> {
let dims = || popup(&["Horizontal and Vertical", "Horizontal", "Vertical"]);
let blur_s = || slider(0.0, 3000.0, 0.0, 50.0, 1);
vec![
spec(
"ec.blur.bilateral",
"Bilateral Blur",
vec![
p("radius", "Radius", num(5.0), slider(0.0, 200.0, 0.0, 50.0, 1)),
p("threshold", "Threshold", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("colorize", "Colorize", Value::Bool(true), ParamUi::Checkbox),
],
bilateral,
),
spec(
"ec.blur.smart",
"Smart Blur",
vec![
p("radius", "Radius", num(3.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("threshold", "Threshold", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("mode", "Mode", Value::Enum(0), popup(&["Normal", "Edge Only", "Overlay Edge"])),
],
smart_blur,
),
spec(
"ec.blur.channel",
"Channel Blur",
vec![
p("redBlurriness", "Red Blurriness", num(0.0), blur_s()),
p("greenBlurriness", "Green Blurriness", num(0.0), blur_s()),
p("blueBlurriness", "Blue Blurriness", num(0.0), blur_s()),
p("alphaBlurriness", "Alpha Blurriness", num(0.0), blur_s()),
p("repeatEdge", "Repeat Edge Pixels", Value::Bool(false), ParamUi::Checkbox),
p("dimensions", "Blur Dimensions", Value::Enum(0), dims()),
],
channel_blur,
),
spec(
"ec.blur.compound",
"Compound Blur",
vec![
p("blurLayer", "Blur Layer", Value::Layer(None), ParamUi::Layer),
p("maximumBlur", "Maximum Blur", num(20.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("stretchMapToFit", "Stretch Map to Fit", Value::Bool(true), ParamUi::Checkbox),
p("invertBlur", "Invert Blur", Value::Bool(false), ParamUi::Checkbox),
],
compound_blur,
),
spec(
"ec.blur.cameralens",
"Camera Lens Blur",
vec![
p("blurRadius", "Blur Radius", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
p(
"irisProperties/irisShape",
"Shape",
Value::Enum(3),
popup(&["Triangle", "Square", "Pentagon", "Hexagon", "Heptagon", "Octagon", "Nonagon", "Decagon", "Circle"]),
),
p("irisProperties/irisRoundness", "Roundness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("irisProperties/irisAspectRatio", "Aspect Ratio", num(1.0), slider(0.01, 100.0, 0.25, 4.0, 2)),
p("irisProperties/irisRotation", "Rotation", num(0.0), ParamUi::Angle),
p("irisProperties/diffractionFringe", "Diffraction Fringe", num(0.0), slider(0.0, 500.0, 0.0, 500.0, 1)),
p("blurMap/blurMapLayer", "Layer", Value::Layer(None), ParamUi::Layer),
p("blurMap/channel", "Channel", Value::Enum(0), popup(&["Luminance", "Alpha", "Color"])),
p("blurMap/placement", "Placement", Value::Enum(1), popup(&["Center", "Stretch"])),
p("blurMap/blurFocalDistance", "Blur Focal Distance", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
p("blurMap/invertBlurMap", "Invert Blur Map", Value::Bool(false), ParamUi::Checkbox),
p("highlight/specularBrightness", "Gain", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("highlight/specularThreshold", "Threshold", num(255.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
p("highlight/highlightSaturation", "Saturation", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("repeatEdge", "Repeat Edge Pixels", Value::Bool(false), ParamUi::Checkbox),
p("useLinear", "Use Linear Working Space", Value::Bool(false), ParamUi::Checkbox),
],
camera_lens_blur,
),
spec(
"ec.blur.ccradialfast",
"CC Radial Fast Blur",
vec![
p("center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
p("amount", "Amount", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("zoom", "Zoom", Value::Enum(0), popup(&["Standard", "Brightest", "Darkest"])),
],
radial_fast_blur,
),
spec(
"ec.blur.ccvector",
"CC Vector Blur",
vec![
p("type", "Type", Value::Enum(0), popup(&["Natural", "Constant Length", "Perpendicular", "Direction Center", "Direction Fading"])),
p("amount", "Amount", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
p("angleOffset", "Angle Offset", num(0.0), ParamUi::Angle),
p("mapSoftness", "Map Softness", num(15.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
vector_blur,
),
EffectSpec {
category: "Obsolete",
..spec(
"ec.blur.reduceflicker",
"Reduce Interlace Flicker",
vec![p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 2))],
reduce_flicker,
)
},
spec(
"ec.blur.cccross",
"CC Cross Blur",
vec![
p("radiusX", "Radius X", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
p("radiusY", "Radius Y", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
p("transferMode", "Transfer Mode", Value::Enum(0), popup(&["Blend", "Add", "Screen", "Lighten"])),
],
cross_blur,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Params, find};
fn run(id: &str, img: &Image, over: &[(&str, Value)]) -> Buf {
let s = find(id).unwrap();
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 })
}
fn edge_img() -> Image {
let mut img = Image::new(32, 16);
for y in 0..16 {
for x in 0..32 {
let v = if x < 16 { 0.1 } else { 0.9 };
img.set(x, y, [v, v, v, 1.0]);
}
}
img
}
#[test]
fn bilateral_keeps_edge_sharper_than_gaussian() {
let img = edge_img();
let bl = run("ec.blur.bilateral", &img, &[("radius", num(4.0))]).img;
let g = gaussian_blur(&img, 2.0, 2.0, true);
let jump_b = bl.get(16, 8)[0] - bl.get(15, 8)[0];
let jump_g = g.get(16, 8)[0] - g.get(15, 8)[0];
assert!(jump_b > 0.7, "{jump_b}");
assert!(jump_b > jump_g + 0.3);
}
#[test]
fn channel_blur_zero_is_identity() {
let img = edge_img();
let out = run("ec.blur.channel", &img, &[]);
assert_eq!(out.img, img);
}
#[test]
fn channel_blur_only_touches_selected_channel() {
let img = edge_img();
let out = run("ec.blur.channel", &img, &[("redBlurriness", num(6.0)), ("repeatEdge", Value::Bool(true))]).img;
let (l, r) = (out.get(15, 8), out.get(16, 8));
assert!(r[0] - l[0] < 0.6);
assert!((l[1] - 0.1).abs() < 1e-5 && (r[1] - 0.9).abs() < 1e-5);
}
#[test]
fn lens_blur_preserves_constant_and_energy() {
let img = Image::filled(24, 20, [0.4, 0.3, 0.2, 1.0]);
let out = run("ec.blur.cameralens", &img, &[("repeatEdge", Value::Bool(true))]).img;
assert!(out.data.iter().all(|p| (p[0] - 0.4).abs() < 1e-4 && (p[3] - 1.0).abs() < 1e-4));
let mut dot = Image::new(9, 9);
dot.set(4, 4, [1.0, 1.0, 1.0, 1.0]);
let out = run("ec.blur.cameralens", &dot, &[("blurRadius", num(5.0))]);
let total: f32 = out.img.data.iter().map(|p| p[3]).sum();
assert!((total - 1.0).abs() < 1e-3, "{total}");
assert!(out.img.width > 9);
}
#[test]
fn lens_blur_aspect_ratio_widens_the_bokeh() {
let mut dot = Image::new(41, 41);
dot.set(20, 20, [1.0, 1.0, 1.0, 1.0]);
let wide = run("ec.blur.cameralens", &dot, &[("blurRadius", num(5.0)), ("irisProperties/irisAspectRatio", num(2.0))]);
let (o, d) = (wide.offset[0] as i64, wide.offset[1] as i64);
// Lit 8 px to the side but not 8 px above.
assert!(wide.img.get(20 + o + 8, 20 + d)[3] > 0.0);
assert_eq!(wide.img.get(20 + o, 20 + d - 8)[3], 0.0);
}
#[test]
fn lens_blur_highlight_saturation_whitens_boosted_colour() {
let mut img = Image::new(21, 21);
img.set(10, 10, [1.0, 0.2, 0.2, 1.0]);
let over = |s: f64| {
[
("blurRadius", num(3.0)),
("highlight/specularBrightness", num(100.0)),
("highlight/specularThreshold", num(0.0)),
("highlight/highlightSaturation", num(s)),
]
};
let keep = run("ec.blur.cameralens", &img, &over(100.0));
let white = run("ec.blur.cameralens", &img, &over(0.0));
let c = |b: &Buf| b.img.get(10 + b.offset[0] as i64, 10 + b.offset[1] as i64);
// Same red energy, more green with Saturation 0.
assert!((c(&keep)[0] - c(&white)[0]).abs() < 1e-5);
assert!(c(&white)[1] > c(&keep)[1] + 1e-4);
}
#[test]
fn compound_blur_reads_the_blur_layer() {
use crate::{EffectEnv, EffectHost, LayerPixels};
struct Map;
impl EffectHost for Map {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
// Left half black (sharp), right half white (full blur).
let mut m = Image::new(20, 20);
for y in 0..20 {
for x in 10..20 {
m.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
Some(LayerPixels { buf: Buf { img: m, offset: [0.0; 2], scale: 1.0 }, size: [20.0, 20.0] })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
let mut img = Image::filled(20, 20, [0.0, 0.0, 0.0, 1.0]);
for y in 0..20 {
img.set(4, y, [1.0, 1.0, 1.0, 1.0]);
img.set(15, y, [1.0, 1.0, 1.0, 1.0]);
}
let out = crate::run_fx(
"ec.blur.compound",
&[("blurLayer", Value::Layer(Some(1))), ("maximumBlur", num(6.0))],
img,
0.0,
EffectEnv { host: Some(&Map), ..Default::default() },
);
let (o, d) = (out.offset[0] as i64, out.offset[1] as i64);
assert!((out.img.get(4 + o, 10 + d)[0] - 1.0).abs() < 1e-4, "dark map keeps the line sharp");
assert!(out.img.get(15 + o, 10 + d)[0] < 0.9, "bright map blurs");
}
#[test]
fn iris_spans_hexagon_is_symmetric() {
let s = iris_spans(10.0, 6, 0.0, 0.0);
let s0 = s.iter().find(|t| t.0 == 0).unwrap();
assert_eq!(s0.1, -s0.2);
let circle = iris_spans(10.0, 0, 0.0, 0.0);
assert_eq!(circle.len(), 21);
}
#[test]
fn reduce_flicker_blurs_vertically_only() {
let mut img = Image::filled(8, 8, [0.0, 0.0, 0.0, 1.0]);
for x in 0..8 {
img.set(x, 4, [1.0, 1.0, 1.0, 1.0]);
}
let out = run("ec.blur.reduceflicker", &img, &[("softness", num(1.0))]).img;
assert!(out.get(3, 4)[0] < 0.9 && out.get(3, 3)[0] > 0.05);
assert!((out.get(0, 4)[0] - out.get(7, 4)[0]).abs() < 1e-6);
}
#[test]
fn compound_blur_dark_pixels_stay_sharp() {
let mut img = Image::new(16, 16);
for y in 0..16 {
for x in 0..16 {
img.set(x, y, if (x + y) % 2 == 0 { [0.0, 0.0, 0.0, 1.0] } else { [0.02, 0.02, 0.02, 1.0] });
}
}
let out = run("ec.blur.compound", &img, &[("maximumBlur", num(10.0))]);
// Luminance ≈ 0 → (almost) no blur.
let pad = (out.img.width - 16) / 2;
assert!(out.img.get(pad as i64, pad as i64)[0] < 0.005);
}
#[test]
fn camera_lens_blur_diffraction_fringe_and_blur_map() {
use crate::{EffectEnv, EffectHost, LayerPixels};
// A single bright point spreads into a disc; with Diffraction Fringe its rim is brighter.
let mut dot = Image::filled(41, 41, [0.0, 0.0, 0.0, 1.0]);
dot.set(20, 20, [1.0, 1.0, 1.0, 1.0]);
let v = [("blurRadius", num(10.0)), ("irisProperties/irisShape", Value::Enum(8)), ("repeatEdge", Value::Bool(true))];
let plain = run("ec.blur.cameralens", &dot, &v).img;
let ring = run("ec.blur.cameralens", &dot, &[v.as_slice(), &[("irisProperties/diffractionFringe", num(300.0))]].concat()).img;
assert!((plain.get(20, 20)[0] - plain.get(29, 20)[0]).abs() < 1e-5, "even disc");
assert!(ring.get(29, 20)[0] > ring.get(20, 20)[0] * 2.0, "{:?} {:?}", ring.get(29, 20), ring.get(20, 20));
// Blur Map: black (focal distance 0) stays sharp, white gets the full radius.
struct Half;
impl EffectHost for Half {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
let mut m = Image::filled(40, 20, [0.0, 0.0, 0.0, 1.0]);
for y in 0..20 {
for x in 20..40 {
m.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
Some(LayerPixels { buf: Buf { img: m, offset: [0.0; 2], scale: 1.0 }, size: [40.0, 20.0] })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
let stripes = crate::util::gen_image(40, 20, |x, _| if x % 4 < 2 { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] });
let vals = [("blurRadius", num(6.0)), ("repeatEdge", Value::Bool(true)), ("blurMap/blurMapLayer", Value::Layer(Some(1)))];
let out = crate::run_fx("ec.blur.cameralens", &vals, stripes.clone(), 0.0, EffectEnv { host: Some(&Half), ..Default::default() }).img;
assert_eq!(out.get(4, 10), stripes.get(4, 10), "sharp where the map is at the focal distance");
assert!((out.get(30, 10)[0] - 0.5).abs() < 0.2, "{:?}", out.get(30, 10));
}
}