mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 12:37:08 -06:00
Merge pull request #695 from vbasky/fix/535-pixel-motion-vectors
Pixel Motion Blur: synthesize from motion vectors, honour Vector Detail (#535)
This commit is contained in:
@@ -105,7 +105,7 @@ pub use noise::remove_grain_level;
|
||||
pub use noise3::{FractalGpu, fractal_gpu};
|
||||
pub use stylize3::{BumpLight, bump_layer_id};
|
||||
pub use textfx::{TextLayer, text_layer};
|
||||
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, posterized_time, time_frames, timewarp_crop, timewarp_plan};
|
||||
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan};
|
||||
// effectcraft-gpu fx_tone.
|
||||
pub use ocio::color_stabilizer_maps;
|
||||
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
|
||||
|
||||
@@ -583,6 +583,13 @@ fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) {
|
||||
|
||||
/// The motion field from frame `a` to frame `b` (or from the warp layer's frames).
|
||||
pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
|
||||
timewarp_flow_search(a, b, pm, 4)
|
||||
}
|
||||
|
||||
/// [`timewarp_flow`] with an explicit coarse search radius: Timewarp's own resampling needs
|
||||
/// little (motion between adjacent source frames at near-100% speed), but Pixel Motion Blur
|
||||
/// must find fast full-frame displacements (#535).
|
||||
fn timewarp_flow_search(a: &Image, b: &Image, pm: &PixelMotion, radius: i32) -> Flow {
|
||||
let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0);
|
||||
let gain = if pm.correct_luminance {
|
||||
let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0)));
|
||||
@@ -591,7 +598,7 @@ pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
|
||||
1.0
|
||||
};
|
||||
let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain);
|
||||
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4);
|
||||
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, radius);
|
||||
smooth_flow(&mut flow, pm);
|
||||
flow
|
||||
}
|
||||
@@ -923,12 +930,71 @@ fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
mean_of(ctx, b, ×)
|
||||
}
|
||||
|
||||
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
fn shutter_times(ctx: &EffectCtx) -> Vec<f64> {
|
||||
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
|
||||
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
|
||||
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
|
||||
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
|
||||
mean_of(ctx, b, ×)
|
||||
(0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect()
|
||||
}
|
||||
|
||||
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
match pmb_plan(ctx).as_ref().map(TimewarpPlan::render_cpu) {
|
||||
Some(blur) => blur,
|
||||
None => mean_of(ctx, b, &shutter_times(ctx)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel Motion Blur as a [`TimewarpPlan`]: each shutter instant is decoded between the two
|
||||
/// source frames it falls between along a measured motion field (the same synthesis Timewarp
|
||||
/// uses), and the instants average into the blur. Vector Detail sets the spacing of the motion
|
||||
/// vectors, so a coarse grid follows one big motion and a fine grid separates neighbouring
|
||||
/// ones (#535). `None` without a host — the caller then falls back. Shared by the CPU effect
|
||||
/// and the GPU compositor (`fx_time`), which renders the plan like Timewarp's.
|
||||
pub fn pmb_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
|
||||
ctx.env.host?;
|
||||
let fps = ctx.fps();
|
||||
let samples = shutter_times(ctx);
|
||||
let n = samples.len();
|
||||
let mut times = Vec::new();
|
||||
for &s in &samples {
|
||||
let (f0, _) = tw_split(s, fps);
|
||||
times.push(f0 / fps);
|
||||
times.push((f0 + 1.0) / fps);
|
||||
}
|
||||
let fr = fetch(ctx, ×)?;
|
||||
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
|
||||
let mut pm = PixelMotion::from(ctx, fr.grid.scale);
|
||||
// PMB's own Vector Detail: 0 → one vector per 32 px, 100 → one per 2 px.
|
||||
let detail = (ctx.params.f("vectorDetail") / 100.0).clamp(0.0, 1.0);
|
||||
pm.tile = (32.0 * (1.0f64 / 16.0).powf(detail) * fr.grid.scale).round().max(2.0) as u32;
|
||||
let mut flows: Vec<FlowEntry> = vec![];
|
||||
let mut steps = vec![];
|
||||
let w = 1.0 / n as f32;
|
||||
for &s in &samples {
|
||||
let (f0, frac) = tw_split(s, fps);
|
||||
let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps));
|
||||
let step = if frac < 1e-4 {
|
||||
TwStep::Whole(i0)
|
||||
} else {
|
||||
let flow = match flows.iter().position(|fl| fl.key == f0 as i64) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let reach = (fr.grid.img.width.max(fr.grid.img.height) / 4).clamp(8, 64);
|
||||
flows.push(FlowEntry { key: f0 as i64, flow: timewarp_flow_search(&fr.imgs[i0], &fr.imgs[i1], &pm, reach as i32) });
|
||||
flows.len() - 1
|
||||
}
|
||||
};
|
||||
TwStep::Motion { a: i0, b: i1, w: frac, flow }
|
||||
};
|
||||
steps.push((step, w));
|
||||
}
|
||||
Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|f| f.flow).collect(), pm })
|
||||
}
|
||||
|
||||
/// A measured flow with the source frame pair it came from (plans cache one per pair).
|
||||
struct FlowEntry {
|
||||
key: i64,
|
||||
flow: Flow,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -45,13 +45,12 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
|
||||
let fd = 1.0 / ctx.fps();
|
||||
mean_of(e, ctx, b, &(-back..=fwd).map(|k| ctx.time + k as f64 * fd).collect::<Vec<_>>())
|
||||
}
|
||||
"ec.time.pixelmotionblur" => {
|
||||
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
|
||||
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
|
||||
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
|
||||
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
|
||||
mean_of(e, ctx, b, ×)
|
||||
}
|
||||
// Pixel Motion Blur synthesizes along measured motion vectors (#535); a plan that
|
||||
// cannot be built (no host) declines to the CPU effect.
|
||||
"ec.time.pixelmotionblur" => match effectcraft_effects::pmb_plan(ctx) {
|
||||
Some(plan) => render_plan(e, &plan),
|
||||
None => None,
|
||||
},
|
||||
"ec.time.timewarp" => timewarp(e, ctx, b),
|
||||
_ => None,
|
||||
}
|
||||
@@ -179,6 +178,12 @@ fn timewarp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
return Some(b);
|
||||
}
|
||||
let Some(plan) = effectcraft_effects::timewarp_plan(ctx) else { return Some(b) };
|
||||
render_plan(e, &plan)
|
||||
}
|
||||
|
||||
/// Render a prepared Timewarp plan (also Pixel Motion Blur's, `effectcraft_effects::pmb_plan`):
|
||||
/// every step to an image on the GPU, then the weighted average.
|
||||
fn render_plan(e: &mut Enc, plan: &effectcraft_effects::TimewarpPlan) -> Option<GBuf> {
|
||||
// Pixel Motion's in-between frame of frames a, b along motion field `flow`.
|
||||
let motion = |e: &mut Enc, a: usize, bi: usize, w: f32, flow: usize| -> Option<GpuImage> {
|
||||
let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Pixel Motion Blur must synthesize blur from motion vectors, not average decoded frames
|
||||
//! (#535). Two bars move in opposite directions between discrete source frames; the gap
|
||||
//! pixels between their old and new positions must fill in, and Vector Detail must matter.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, FrameBlend, ItemId, ItemKind, LayerSource, Project};
|
||||
use effectcraft_render::{FootageSource, Image, RenderOpts, Renderer};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use std::sync::Arc;
|
||||
|
||||
// A 12×8 block with a horizontal brightness gradient slides right by 12 px between source
|
||||
// frames 9 and 10. The gradient gives the block matcher real texture, so Pixel Motion can find
|
||||
// the +12 vector and smear the block across the gap — unlike a flat bar, whose motion is
|
||||
// undefined (the aperture problem) and which no synthesizer can invent coverage for.
|
||||
struct Clip;
|
||||
impl FootageSource for Clip {
|
||||
fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
let k = f.frame_rate.frame_at(t).clamp(0, 19);
|
||||
let dx = 12 * (k - 10);
|
||||
let mut image = Image::filled(64, 48, [0.0, 0.0, 0.0, 1.0]);
|
||||
for y in 0..48 {
|
||||
for x in 0..64 {
|
||||
if (16..32).contains(&y) && (20 + dx..32 + dx).contains(&x) {
|
||||
let g = (x - (20 + dx)) as f32 / 12.0;
|
||||
image.set(x as u32, y, [0.4 + 0.6 * g, 0.3, 0.2, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Arc::new(image))
|
||||
}
|
||||
}
|
||||
|
||||
fn scene(detail: Option<f64>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
let comp = Comp::new(64, 48, FrameRate::FPS_25, Tick::from_seconds_f64(0.8));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let footage = Footage {
|
||||
path: "generated.mov".into(),
|
||||
kind: FootageKind::Video,
|
||||
width: 64,
|
||||
height: 48,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: FrameRate::FPS_25,
|
||||
duration: Tick::from_seconds_f64(0.8),
|
||||
has_video: true,
|
||||
has_audio: false,
|
||||
alpha: AlphaMode::Ignore,
|
||||
missing: false,
|
||||
..Default::default()
|
||||
};
|
||||
let fid = p.add_item("Clip", Label::Aqua, None, ItemKind::Footage(footage));
|
||||
let mut layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: fid }, (64, 48), None);
|
||||
layer.switches.frame_blend = FrameBlend::Off;
|
||||
layer.switches.motion_blur = false;
|
||||
if let Some(detail) = detail {
|
||||
let spec = effectcraft_effects::find("ec.time.pixelmotionblur").unwrap();
|
||||
let mut next = p.next_id;
|
||||
let mut effect = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 48.0]);
|
||||
for (name, value) in [
|
||||
("shutterControl", Value::Enum(1)),
|
||||
("shutterAngle", Value::Scalar(180.0)),
|
||||
("shutterSamples", Value::Scalar(16.0)),
|
||||
("vectorDetail", Value::Scalar(detail)),
|
||||
] {
|
||||
effect.prop_mut(name).unwrap().value = value;
|
||||
}
|
||||
p.next_id = next;
|
||||
layer.props.sub_mut("effects").unwrap().children.push(effect.into());
|
||||
}
|
||||
p.comp_mut(cid).unwrap().layers.push(layer);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn render(p: &Project, cid: ItemId, time: f64) -> Image {
|
||||
Renderer::new(p, &Clip, RenderOpts::default()).comp_frame(cid, Tick::from_seconds_f64(time))
|
||||
}
|
||||
|
||||
fn difference(a: &Image, b: &Image) -> f64 {
|
||||
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs() as f64)).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_motion_blur_synthesizes_from_motion_vectors() {
|
||||
let (plain, cid) = scene(None);
|
||||
let still = render(&plain, cid, 0.4);
|
||||
let average = average_of(&plain, cid);
|
||||
let (low, low_id) = scene(Some(0.0));
|
||||
let (high, high_id) = scene(Some(100.0));
|
||||
let a = render(&low, low_id, 0.4);
|
||||
let b = render(&high, high_id, 0.4);
|
||||
assert!(difference(&b, &still) > 1.0, "the blur equals the still frame");
|
||||
assert!(difference(&b, &average) > 1.0, "the blur is just the plain overlay of the two decoded positions");
|
||||
assert!((24..32).any(|x| b.get(x, 24)[0] > 0.2), "no motion trail behind the moving block: {:?}", (18..46).map(|x| b.get(x, 24)[0]).collect::<Vec<f32>>());
|
||||
assert!(difference(&a, &b) > 0.5, "Vector Detail is inert: the motion field does not change with it");
|
||||
}
|
||||
|
||||
fn average_of(p: &Project, cid: ItemId) -> Image {
|
||||
let mut average = Image::new(64, 48);
|
||||
for i in 0..16 {
|
||||
let time = ((0.39 + 0.02 * i as f64 / 15.0) * 1e6).round() / 1e6;
|
||||
let frame = render(p, cid, time);
|
||||
for (p, q) in average.data.iter_mut().zip(&frame.data) {
|
||||
for c in 0..4 {
|
||||
p[c] += q[c] / 16.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
average
|
||||
}
|
||||
Reference in New Issue
Block a user