M9.13: Add / Match / Remove Grain preview region, presets, channel balance, sampling and temporal filtering
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b97b44cac
commit
aae00dfaca
|
|
@ -8,9 +8,6 @@ use crate::{CATEGORIES, registry};
|
|||
/// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far
|
||||
/// as the public behaviour documentation describes it.
|
||||
pub const PARTIAL: &[(&str, &str)] = &[
|
||||
("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"),
|
||||
("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"),
|
||||
("ec.noise.removegrain", "no preview region, sampling or temporal filtering"),
|
||||
("ec.vr.digitalglitch", "reduced control set"),
|
||||
("ec.vr.converter", "common layouts only"),
|
||||
("ec.sim.caustics", "no Sky group or light type"),
|
||||
|
|
|
|||
|
|
@ -566,6 +566,9 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|||
("fineTuning", "Fine Tuning"),
|
||||
("unsharpMask", "Unsharp Mask"),
|
||||
("noiseOptions", "Noise Options (Animation)"),
|
||||
("previewRegion", "Preview Region"),
|
||||
("sampling", "Sampling"),
|
||||
("temporalFiltering", "Temporal Filtering"),
|
||||
// Time: Timewarp.
|
||||
("tuning", "Tuning"),
|
||||
("motionBlur", "Motion Blur"),
|
||||
|
|
@ -733,6 +736,8 @@ pub const TIME_DEPENDENT: &[&str] = &[
|
|||
"ec.stylize.strobe",
|
||||
"ec.noise.noise",
|
||||
"ec.noise.addgrain",
|
||||
// Remove Grain's temporal filtering reads neighbouring frames.
|
||||
"ec.noise.removegrain",
|
||||
"ec.noise.noisealpha",
|
||||
"ec.noise.noisehlsauto",
|
||||
"ec.obsolete.lightning",
|
||||
|
|
|
|||
|
|
@ -455,4 +455,16 @@ mod tests {
|
|||
assert!(g.get("additionalForeground").is_none());
|
||||
assert_eq!(prop_at(&mut g, "additionalForeground/foreground1").unwrap().value, Value::Enum(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_grain_viewing_modes_remap_by_label() {
|
||||
let spec = find("ec.noise.matchgrain").unwrap();
|
||||
let mut next = 1;
|
||||
let mut g = instantiate(spec, &mut Ids(&mut next), "Match Grain", [100.0, 50.0]);
|
||||
let pr = g.get_mut("viewingMode").unwrap();
|
||||
pr.ui = ParamUi::Popup { options: vec!["Final Output".into(), "Noise Samples".into(), "Blending Matte".into()] };
|
||||
pr.value = Value::Enum(0);
|
||||
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
|
||||
assert_eq!(g.get("viewingMode").unwrap().value, Value::Enum(4), "Final Output");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ pub(crate) struct GrainLook {
|
|||
/// Grain time (frames of grain at the animation speed) and whether it interpolates.
|
||||
pub frame: f32,
|
||||
pub seed: u32,
|
||||
/// Color ▸ Red / Green / Blue Balance.
|
||||
pub balance: [f32; 3],
|
||||
}
|
||||
|
||||
impl GrainLook {
|
||||
|
|
@ -84,7 +86,28 @@ impl GrainLook {
|
|||
midpoint: pr.get("application/midpoint").map(Value::as_f64).unwrap_or(0.5).clamp(0.01, 0.99) as f32,
|
||||
frame: if smooth && ft.fract() != 0.0 { ft as f32 } else { ft.floor() as f32 },
|
||||
seed: (pr.f("animation/randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37),
|
||||
balance: ch("color/", "redBalance", "greenBalance", "blueBalance"),
|
||||
}
|
||||
.with_preset(pr.e("preset"))
|
||||
}
|
||||
|
||||
/// Apply Add Grain's Preset (a film-stock look on top of the controls).
|
||||
fn with_preset(mut self, preset: u32) -> GrainLook {
|
||||
// (intensity ×, size ×, softness +, saturation ×, monochrome)
|
||||
let (ki, ks, soft, ksat, mono) = match preset {
|
||||
1 => (0.6, 0.7, 0.2, 0.6, false),
|
||||
2 => (1.3, 1.8, 0.4, 0.8, false),
|
||||
3 => (1.8, 2.5, 0.8, 0.5, false),
|
||||
4 => (1.0, 0.6, 0.0, 1.0, false),
|
||||
5 => (1.6, 1.4, 0.3, 1.0, true),
|
||||
_ => return self,
|
||||
};
|
||||
self.intensity *= ki;
|
||||
self.size *= ks;
|
||||
self.softness += soft;
|
||||
self.saturation *= ksat;
|
||||
self.mono |= mono;
|
||||
self
|
||||
}
|
||||
|
||||
/// Zero-mean noise planes (one, or one per channel) of grain `size` pixels (times each
|
||||
|
|
@ -129,7 +152,7 @@ impl GrainLook {
|
|||
let l = luminance(self.tint[0], self.tint[1], self.tint[2]).max(1e-3);
|
||||
g = [0, 1, 2].map(|k| g[k] * ((1.0 - self.tint_amount) + self.tint_amount * self.tint[k] / l));
|
||||
}
|
||||
g
|
||||
[g[0] * self.balance[0], g[1] * self.balance[1], g[2] * self.balance[2]]
|
||||
}
|
||||
|
||||
/// Combine source channel `c` with signed grain `n` by the Blending Mode.
|
||||
|
|
@ -171,6 +194,9 @@ pub(crate) fn grain_params() -> (Vec<crate::ParamSpec>, Vec<crate::ParamSpec>, V
|
|||
p("color/saturation", "Saturation", num(1.0), mult()),
|
||||
p("color/tintAmount", "Tint Amount", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("color/tintColor", "Tint Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("color/redBalance", "Red Balance", num(1.0), mult()),
|
||||
p("color/greenBalance", "Green Balance", num(1.0), mult()),
|
||||
p("color/blueBalance", "Blue Balance", num(1.0), mult()),
|
||||
];
|
||||
let application = vec![
|
||||
p("application/blendingMode", "Blending Mode", Value::Enum(0), popup(&GRAIN_BLEND_MODES)),
|
||||
|
|
@ -187,27 +213,124 @@ pub(crate) fn grain_params() -> (Vec<crate::ParamSpec>, Vec<crate::ParamSpec>, V
|
|||
(tweaking, color, application, animation)
|
||||
}
|
||||
|
||||
/// Add Grain's Viewing Mode options.
|
||||
pub(crate) const ADD_GRAIN_VIEWS: [&str; 3] = ["Preview", "Blending Matte", "Final Output"];
|
||||
/// Add Grain's Preset options (our own film-stock looks).
|
||||
pub(crate) const GRAIN_PRESETS: [&str; 6] = ["None", "Fine 35mm", "Coarse 16mm", "Vintage 8mm", "Video Noise", "Monochrome High Speed"];
|
||||
|
||||
/// The Preview Region controls of the grain effects (centre, size, box display).
|
||||
pub(crate) fn preview_params() -> Vec<crate::ParamSpec> {
|
||||
vec![
|
||||
p("previewRegion/center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
|
||||
p("previewRegion/width", "Width", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)),
|
||||
p("previewRegion/height", "Height", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)),
|
||||
p("previewRegion/showBox", "Show Box", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("previewRegion/boxColor", "Box Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
]
|
||||
}
|
||||
|
||||
/// Preview viewing mode: the processed pixels inside the Preview Region, the original
|
||||
/// outside, and (Show Box) the region's outline.
|
||||
pub(crate) fn preview_compose(ctx: &EffectCtx, b: &mut Buf, orig: &Image) {
|
||||
let c = b.to_px(ctx.params.v2("previewRegion/center"));
|
||||
let (hw, hh) = (ctx.params.f("previewRegion/width") * b.scale * 0.5, ctx.params.f("previewRegion/height") * b.scale * 0.5);
|
||||
let (x0, x1, y0, y1) = (c.0 - hw, c.0 + hw, c.1 - hh, c.1 + hh);
|
||||
let show = ctx.params.b("previewRegion/showBox");
|
||||
let col = ctx.params.color("previewRegion/boxColor");
|
||||
let w = b.img.width as usize;
|
||||
b.img.data.par_iter_mut().zip(orig.data.par_iter()).enumerate().for_each(|(i, (px, o))| {
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
let inside = x >= x0 && x < x1 && y >= y0 && y < y1;
|
||||
if !inside {
|
||||
*px = *o;
|
||||
}
|
||||
if show {
|
||||
let near = |v: f64, e: f64| (v - e).abs() < 0.75;
|
||||
let on = ((near(x, x0) || near(x, x1)) && y >= y0 - 0.75 && y < y1 + 0.75) || ((near(y, y0) || near(y, y1)) && x >= x0 - 0.75 && x < x1 + 0.75);
|
||||
if on {
|
||||
*px = [col[0], col[1], col[2], 1.0];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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).
|
||||
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));
|
||||
if w < size || h < size || count == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let low = Plane::luma(img);
|
||||
let step = (size / 2).max(1);
|
||||
let mut cands: Vec<(f32, usize, usize)> = Vec::new();
|
||||
for y in (0..=h - size).step_by(step) {
|
||||
for x in (0..=w - size).step_by(step) {
|
||||
// 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);
|
||||
for yy in y..y + size {
|
||||
for xx in x..x + size {
|
||||
let v = low.data[yy * w + xx];
|
||||
s += v;
|
||||
sq += v * v;
|
||||
n += 1.0;
|
||||
opaque &= img.data[yy * w + xx][3] > 0.5;
|
||||
}
|
||||
}
|
||||
if opaque {
|
||||
cands.push((sq / n - (s / n).powi(2), x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1)));
|
||||
cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect()
|
||||
}
|
||||
|
||||
/// Draw sample boxes' outlines (Noise Samples viewing mode).
|
||||
pub(crate) fn draw_boxes(img: &mut Image, boxes: &[(usize, usize, usize)], c: [f32; 4]) {
|
||||
for &(x, y, s) in boxes {
|
||||
for k in 0..s {
|
||||
for (px, py) in [(x + k, y), (x + k, y + s - 1), (x, y + k), (x + s - 1, y + k)] {
|
||||
img.set(px as u32, py as u32, [c[0], c[1], c[2], 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Add Grain ----
|
||||
|
||||
fn add_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let look = GrainLook::from_params(ctx);
|
||||
if look.intensity <= 0.0 {
|
||||
// Viewing Mode (Final Output for projects saved before it existed).
|
||||
let view = ctx.params.get("viewingMode").map(|v| v.as_enum()).unwrap_or(2);
|
||||
if look.intensity <= 0.0 && view != 1 {
|
||||
return b;
|
||||
}
|
||||
let orig = b.img.clone();
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let planes = look.planes(w, h, look.size * b.scale as f32, b.scale, 101);
|
||||
let amp = look.intensity * 0.25;
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let (c, a) = unpremul(*px);
|
||||
let weight = look.weight(c);
|
||||
if view == 1 {
|
||||
// Blending Matte: where (and how strongly) the grain applies.
|
||||
let v = weight.clamp(0.0, 1.0);
|
||||
*px = [v, v, v, 1.0];
|
||||
return;
|
||||
}
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let raw = if look.mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] };
|
||||
let g = look.color(raw);
|
||||
let weight = look.weight(c);
|
||||
let o = [0, 1, 2].map(|k| look.blend(c[k], g[k] * amp * look.channel_intensity[k] * weight));
|
||||
*px = premul(o, a);
|
||||
});
|
||||
if view == 0 {
|
||||
preview_compose(ctx, &mut b, &orig);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
|
|
@ -333,18 +456,104 @@ fn dust_scratches(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
b
|
||||
}
|
||||
|
||||
/// Remove Grain's Viewing Mode options.
|
||||
pub(crate) const REMOVE_GRAIN_VIEWS: [&str; 4] = ["Preview", "Noise Samples", "Blending Matte", "Final Output"];
|
||||
|
||||
/// Grain sampling controls (Match Grain, Remove Grain): how many flat boxes of what size the
|
||||
/// grain is measured in.
|
||||
pub(crate) fn sampling_params() -> Vec<crate::ParamSpec> {
|
||||
vec![
|
||||
p("sampling/samplingProcess", "Sampling Process", Value::Enum(0), popup(&["Automatic"])),
|
||||
p("sampling/numberOfSamples", "Number of Samples", num(80.0), slider(1.0, 500.0, 1.0, 200.0, 0)),
|
||||
p("sampling/sampleSize", "Sample Size", num(9.0), slider(3.0, 64.0, 3.0, 32.0, 0)),
|
||||
p("sampling/sampleBoxColor", "Sample Box Color", crate::col(1.0, 1.0, 0.0), ParamUi::Color),
|
||||
]
|
||||
}
|
||||
|
||||
/// The sample boxes for the effect's Sampling settings.
|
||||
pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(usize, usize, usize)> {
|
||||
let n = ctx.params.get("sampling/numberOfSamples").map(Value::as_f64).unwrap_or(80.0).clamp(1.0, 500.0) as usize;
|
||||
let s = (ctx.params.get("sampling/sampleSize").map(Value::as_f64).unwrap_or(9.0) * scale).round().max(3.0) as usize;
|
||||
sample_boxes(img, n, s)
|
||||
}
|
||||
|
||||
fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let amt = pr.f("noiseReductionSettings/noiseReduction") as f32;
|
||||
let um_amount = pr.f("unsharpMask/amount") as f32 / 100.0;
|
||||
if amt <= 0.0 && um_amount <= 0.0 {
|
||||
let view = pr.get("viewingMode").map(|v| v.as_enum()).unwrap_or(3);
|
||||
let boxes = sampling_boxes(ctx, &b.img, b.scale);
|
||||
if view == 1 {
|
||||
let c = pr.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]);
|
||||
draw_boxes(&mut b.img, &boxes, c);
|
||||
return b;
|
||||
}
|
||||
if view == 2 {
|
||||
b.img.data.iter_mut().for_each(|p| *p = [1.0, 1.0, 1.0, 1.0]);
|
||||
return b;
|
||||
}
|
||||
let temporal = pr.b("temporalFiltering/enabled");
|
||||
if amt <= 0.0 && um_amount <= 0.0 && !temporal {
|
||||
return b;
|
||||
}
|
||||
let before = b.img.clone();
|
||||
// Temporal Filtering: steady pixels average with the neighbouring frames (grain changes
|
||||
// every frame, the picture does not); Motion Sensitivity keeps moving areas out of it.
|
||||
if temporal {
|
||||
let fps = ctx.fps();
|
||||
let host = ctx.env.host;
|
||||
let at = |t: f64| host.and_then(|h| h.self_at(t, ctx.env.effect_index)).filter(|o| o.img.width == b.img.width && o.img.height == b.img.height);
|
||||
let (prev, next) = (at(ctx.time - 1.0 / fps), at(ctx.time + 1.0 / fps));
|
||||
let k = (pr.get("temporalFiltering/amount").map(Value::as_f64).unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let sens = pr.get("temporalFiltering/motionSensitivity").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0) as f32;
|
||||
let thr = 0.02 + (1.0 - sens) * 0.2;
|
||||
if prev.is_some() || next.is_some() {
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let mut acc = *px;
|
||||
let mut n = 1.0;
|
||||
for o in [&prev, &next].into_iter().flatten() {
|
||||
let q = o.img.data[i];
|
||||
let d = (0..3).map(|c| (q[c] - px[c]).abs()).fold(0.0f32, f32::max);
|
||||
if d < thr {
|
||||
let wgt = 1.0 - d / thr;
|
||||
for c in 0..4 {
|
||||
acc[c] += q[c] * wgt;
|
||||
}
|
||||
n += wgt;
|
||||
}
|
||||
}
|
||||
for c in 0..4 {
|
||||
px[c] += (acc[c] / n - px[c]) * k;
|
||||
}
|
||||
});
|
||||
}
|
||||
if amt <= 0.0 && um_amount <= 0.0 {
|
||||
if view == 0 {
|
||||
preview_compose(ctx, &mut b, &before);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
// The grain level measured in the sample boxes scales the smoothing.
|
||||
let level = {
|
||||
let mut m = vec![false; b.img.data.len()];
|
||||
let w = b.img.width as usize;
|
||||
for &(x, y, s) in &boxes {
|
||||
for yy in y..y + s {
|
||||
for xx in x..x + s {
|
||||
m[yy * w + xx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let st = crate::noise2::grain_stats_masked(&b.img, Some(&m));
|
||||
let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0;
|
||||
if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) }
|
||||
};
|
||||
let passes = pr.f("noiseReductionSettings/passes").round().clamp(1.0, 8.0) as usize;
|
||||
let multichannel = pr.e("noiseReductionSettings/mode") == 0;
|
||||
let texture = (pr.f("fineTuning/texture") as f32).clamp(0.0, 1.0);
|
||||
let r = (2.0 * b.scale).round().max(1.0) as usize;
|
||||
let eps = (0.02 * amt).powi(2);
|
||||
let eps = (0.02 * amt * level).powi(2);
|
||||
let orig = split(&b.img);
|
||||
let mut ch = orig.clone();
|
||||
if amt > 0.0 {
|
||||
|
|
@ -387,6 +596,9 @@ fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
p[3] = a;
|
||||
});
|
||||
b.img = out;
|
||||
if view == 0 {
|
||||
preview_compose(ctx, &mut b, &before);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
|
|
@ -533,7 +745,9 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
vec![
|
||||
{
|
||||
let (tweaking, color, application, animation) = grain_params();
|
||||
spec("ec.noise.addgrain", "Add Grain", [tweaking, color, application, animation].into_iter().flatten().collect(), add_grain)
|
||||
let head =
|
||||
vec![p("viewingMode", "Viewing Mode", Value::Enum(2), popup(&ADD_GRAIN_VIEWS)), p("preset", "Preset", Value::Enum(0), popup(&GRAIN_PRESETS))];
|
||||
spec("ec.noise.addgrain", "Add Grain", [head, preview_params(), tweaking, color, application, animation].into_iter().flatten().collect(), add_grain)
|
||||
},
|
||||
spec(
|
||||
"ec.noise.median",
|
||||
|
|
@ -557,15 +771,26 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
spec(
|
||||
"ec.noise.removegrain",
|
||||
"Remove Grain",
|
||||
vec![
|
||||
p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)),
|
||||
p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)),
|
||||
p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])),
|
||||
p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)),
|
||||
p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)),
|
||||
p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
|
||||
],
|
||||
[
|
||||
vec![p("viewingMode", "Viewing Mode", Value::Enum(3), popup(&REMOVE_GRAIN_VIEWS))],
|
||||
preview_params(),
|
||||
vec![
|
||||
p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)),
|
||||
p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)),
|
||||
p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])),
|
||||
p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)),
|
||||
p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)),
|
||||
p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
|
||||
p("temporalFiltering/enabled", "Enabled", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("temporalFiltering/amount", "Amount", num(100.0), pct()),
|
||||
p("temporalFiltering/motionSensitivity", "Motion Sensitivity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
],
|
||||
sampling_params(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
remove_grain,
|
||||
),
|
||||
spec(
|
||||
|
|
@ -768,6 +993,71 @@ mod tests {
|
|||
assert!(tinted.img.data.iter().any(|p| (p[0] - 0.4).abs() > 1e-3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grain_preview_presets_balance_and_views() {
|
||||
let base = gray(64, 64, 0.5);
|
||||
let grain = [("tweaking/intensity", num(3.0))];
|
||||
let with = |extra: &[(&str, Value)]| run("ec.noise.addgrain", &[grain.as_slice(), extra].concat(), base.clone(), 0.0).img;
|
||||
// Preview: grain only inside the region; its box outline drawn.
|
||||
let prev = with(&[
|
||||
("viewingMode", Value::Enum(0)),
|
||||
("previewRegion/center", Value::Vec2([32.0, 32.0])),
|
||||
("previewRegion/width", num(20.0)),
|
||||
("previewRegion/height", num(20.0)),
|
||||
]);
|
||||
assert_eq!(prev.get(2, 2), base.get(2, 2));
|
||||
assert_ne!(prev.get(32, 32), base.get(32, 32));
|
||||
assert_eq!(prev.get(22, 32), [1.0, 1.0, 1.0, 1.0], "box outline");
|
||||
// Blending Matte shows the tonal weight (1 at mid grey with the defaults).
|
||||
assert_eq!(with(&[("viewingMode", Value::Enum(1))]).get(5, 5), [1.0, 1.0, 1.0, 1.0]);
|
||||
// Presets and channel balance change the grain.
|
||||
let plain = with(&[]);
|
||||
assert_ne!(with(&[("preset", Value::Enum(3))]), plain);
|
||||
let no_red = with(&[("color/redBalance", num(0.0))]);
|
||||
assert!(no_red.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-6));
|
||||
// Sample boxes sit in the flat part of a picture.
|
||||
let mut half = gray(64, 64, 0.5);
|
||||
for y in 0..64 {
|
||||
for x in 32..64 {
|
||||
half.set(x, y, if (x + y) % 2 == 0 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] });
|
||||
}
|
||||
}
|
||||
let boxes = sample_boxes(&half, 4, 8);
|
||||
assert_eq!(boxes.len(), 4);
|
||||
assert!(boxes.iter().all(|(x, _, s)| x + s <= 32), "{boxes:?}");
|
||||
// Remove Grain's Noise Samples view draws them.
|
||||
let shown = run("ec.noise.removegrain", &[("viewingMode", Value::Enum(1))], gray(64, 64, 0.5), 0.0).img;
|
||||
assert!(shown.data.iter().any(|p| p == &[1.0, 1.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_grain_temporal_filtering_averages_steady_frames() {
|
||||
/// Flat grey with per-frame grain.
|
||||
struct Grainy;
|
||||
impl crate::EffectHost for Grainy {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
|
||||
Some(Buf { img: frame((t * 10.0).round() as u32), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
fn frame(f: u32) -> Image {
|
||||
crate::util::gen_image(32, 32, |x, y| {
|
||||
let n = (hash1(x as u32, y as u32, f.wrapping_mul(977)) - 0.5) * 0.06;
|
||||
[0.5 + n, 0.5 + n, 0.5 + n, 1.0]
|
||||
})
|
||||
}
|
||||
let env = crate::EffectEnv { host: Some(&Grainy), frame_rate: 10.0, ..Default::default() };
|
||||
let vals = [("noiseReductionSettings/noiseReduction", num(0.0)), ("temporalFiltering/enabled", Value::Bool(true))];
|
||||
let out = crate::run_fx("ec.noise.removegrain", &vals, frame(5), 0.5, env).img;
|
||||
let dev = |i: &Image| i.data.iter().map(|p| (p[0] - 0.5).abs()).sum::<f32>();
|
||||
assert!(dev(&out) < dev(&frame(5)) * 0.75, "{} vs {}", dev(&out), dev(&frame(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_alpha_cycle_repeats() {
|
||||
let at = |phase: f64| {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use effectcraft_project::ParamUi;
|
|||
use effectcraft_raster::Image;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::noise::{GrainLook, fbm, grain_params, median_image};
|
||||
use crate::noise::{GrainLook, draw_boxes, fbm, grain_params, median_image, preview_params, sampling_boxes, sampling_params};
|
||||
use crate::util::{Plane, gauss_plane, layer_or_self, premul, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
|
||||
|
||||
|
|
@ -29,7 +29,14 @@ pub(crate) struct GrainStats {
|
|||
pub size: f32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn grain_stats(img: &Image) -> GrainStats {
|
||||
grain_stats_masked(img, None)
|
||||
}
|
||||
|
||||
/// [`grain_stats`] over the pixels `mask` selects (all opaque pixels without one).
|
||||
pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainStats {
|
||||
let sel = |i: usize| mask.is_none_or(|m| m[i]);
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if w < 3 || h < 3 {
|
||||
return GrainStats::default();
|
||||
|
|
@ -44,7 +51,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats {
|
|||
let hp: Vec<f32> = pl.data.iter().zip(low.data.iter()).map(|(a, b)| a - b).collect();
|
||||
let (mut sum, mut sq, mut n) = (0.0f64, 0.0f64, 0.0f64);
|
||||
for (i, v) in hp.iter().enumerate() {
|
||||
if straight[i].1 > 0.5 {
|
||||
if straight[i].1 > 0.5 && sel(i) {
|
||||
sum += *v as f64;
|
||||
sq += (*v as f64) * (*v as f64);
|
||||
n += 1.0;
|
||||
|
|
@ -57,7 +64,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats {
|
|||
for y in 0..h {
|
||||
for x in 0..w - 1 {
|
||||
let i = y * w + x;
|
||||
if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 {
|
||||
if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 && sel(i) && sel(i + 1) {
|
||||
rho_num += (hp[i] * hp[i + 1]) as f64;
|
||||
rho_den += (hp[i] * hp[i]) as f64;
|
||||
}
|
||||
|
|
@ -68,15 +75,45 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats {
|
|||
GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 }
|
||||
}
|
||||
|
||||
/// Match Grain's Viewing Mode options.
|
||||
const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"];
|
||||
|
||||
fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if ctx.params.get("noiseSourceLayer").and_then(Value::as_layer).is_none() {
|
||||
return b;
|
||||
}
|
||||
// MATCH_GRAIN_VIEWS order.
|
||||
let view = ctx.params.e("viewingMode");
|
||||
let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false);
|
||||
let src_stats = grain_stats(&source);
|
||||
let src_boxes = sampling_boxes(ctx, &source, b.scale);
|
||||
let own_boxes = sampling_boxes(ctx, &b.img, b.scale);
|
||||
let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]);
|
||||
if view == 1 || view == 2 {
|
||||
// Noise Samples (on the source) / Compensation Samples (on this layer).
|
||||
let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) };
|
||||
draw_boxes(&mut img, &boxes, box_col);
|
||||
b.img = img;
|
||||
return b;
|
||||
}
|
||||
let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option<Vec<bool>> {
|
||||
if boxes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let w = img.width as usize;
|
||||
let mut m = vec![false; img.data.len()];
|
||||
for &(x, y, s) in boxes {
|
||||
for yy in y..y + s {
|
||||
for xx in x..x + s {
|
||||
m[yy * w + xx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
};
|
||||
let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref());
|
||||
let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32;
|
||||
let own = if comp > 0.0 { grain_stats(&b.img) } else { GrainStats::default() };
|
||||
let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() };
|
||||
let orig = b.img.clone();
|
||||
let look = GrainLook::from_params(ctx);
|
||||
let amp: [f32; 3] = [0, 1, 2].map(|k| {
|
||||
let s = src_stats.std[k];
|
||||
|
|
@ -100,11 +137,7 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let g = look.color(raw);
|
||||
let weight = look.weight(c);
|
||||
match view {
|
||||
1 => {
|
||||
let o = [0, 1, 2].map(|k| (0.5 + g[k] * amp[k]).clamp(0.0, 1.0));
|
||||
*px = [o[0], o[1], o[2], 1.0];
|
||||
}
|
||||
2 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0],
|
||||
3 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0],
|
||||
_ => {
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
|
|
@ -114,6 +147,9 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
}
|
||||
}
|
||||
});
|
||||
if view == 0 {
|
||||
crate::noise::preview_compose(ctx, &mut b, &orig);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
|
|
@ -214,13 +250,13 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
"ec.noise.matchgrain",
|
||||
"Match Grain",
|
||||
vec![
|
||||
p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])),
|
||||
p("viewingMode", "Viewing Mode", Value::Enum(4), popup(&MATCH_GRAIN_VIEWS)),
|
||||
p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("noiseSourceLayerSource", "Noise Source Layer Source", Value::Enum(0), ParamUi::Hidden),
|
||||
p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()),
|
||||
]
|
||||
.into_iter()
|
||||
.chain([tweaking, color, application, animation].into_iter().flatten())
|
||||
.chain([preview_params(), sampling_params(), tweaking, color, application, animation].into_iter().flatten())
|
||||
.collect(),
|
||||
match_grain,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
|
||||
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
|
||||
|
||||
EffectCraft ships 303 effects with 2892 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
EffectCraft ships 303 effects with 2927 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
|
||||
- **GPU**: 24 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
- **Status**: 296 implemented in full, 7 partial (what is missing is listed).
|
||||
- **Status**: 299 implemented in full, 4 partial (what is missing is listed).
|
||||
|
||||
Effects are addressed by id (`ec.<category>.<name>`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`).
|
||||
|
||||
|
|
@ -263,18 +263,18 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
|
||||
| Effect | Id | Params | GPU | 32 | Status |
|
||||
|---|---|---:|:-:|:-:|---|
|
||||
| Add Grain | `ec.noise.addgrain` | 22 | | 32 | Partial: no preview region, presets, channel balance or temporal controls |
|
||||
| Add Grain | `ec.noise.addgrain` | 32 | | 32 | Implemented |
|
||||
| Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented |
|
||||
| Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented |
|
||||
| Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented |
|
||||
| Match Grain | `ec.noise.matchgrain` | 25 | | 32 | Partial: no preview region, presets, sampling or temporal controls |
|
||||
| Match Grain | `ec.noise.matchgrain` | 37 | | 32 | Implemented |
|
||||
| Median | `ec.noise.median` | 2 | | 32 | Implemented |
|
||||
| Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented |
|
||||
| Noise | `ec.noise.noise` | 3 | | 32 | Implemented |
|
||||
| Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented |
|
||||
| Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented |
|
||||
| Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented |
|
||||
| Remove Grain | `ec.noise.removegrain` | 7 | | 32 | Partial: no preview region, sampling or temporal filtering |
|
||||
| Remove Grain | `ec.noise.removegrain` | 20 | | 32 | Implemented |
|
||||
| Turbulent Noise | `ec.noise.turbulent` | 22 | | 32 | Implemented |
|
||||
|
||||
## Obsolete
|
||||
|
|
|
|||
Loading…
Reference in New Issue