M9.13: Rolling Shutter Repair Pixel Motion Detail, Reshape correspondence points

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 05:20:40 -04:00
parent e44af1f7e8
commit 05e3861b8b
3 changed files with 103 additions and 6 deletions

View File

@ -8,8 +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.distort.reshape", "no correspondence points"),
("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"),
("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"),
("ec.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"),
("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"),

View File

@ -428,6 +428,42 @@ fn smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---------------------------------------------------------------- Reshape
/// Reshape's correspondence points: "s,d s,d …" (outline fractions, sorted by destination).
fn parse_correspondence(s: &str) -> Vec<(f64, f64)> {
let mut v: Vec<(f64, f64)> = s
.split_whitespace()
.filter_map(|t| {
let (a, b) = t.split_once(',')?;
Some((a.trim().parse::<f64>().ok()?.rem_euclid(1.0), b.trim().parse::<f64>().ok()?.rem_euclid(1.0)))
})
.collect();
v.sort_by(|a, b| a.1.total_cmp(&b.1));
v
}
/// The source outline fraction matching destination fraction `u` (piecewise linear between
/// correspondence pairs; around the loop for closed shapes).
fn map_fraction(pairs: &[(f64, f64)], u: f64, closed: bool) -> f64 {
let n = pairs.len();
if n == 1 {
return (pairs[0].0 + (u - pairs[0].1)).rem_euclid(1.0);
}
if !closed {
// Open paths: clamp beyond the first / last pair (ends map to ends).
let ext: Vec<(f64, f64)> = std::iter::once((0.0, 0.0)).chain(pairs.iter().copied()).chain(std::iter::once((1.0, 1.0))).collect();
let k = ext.iter().rposition(|p| p.1 <= u).unwrap_or(0).min(ext.len() - 2);
let (a, b) = (ext[k], ext[k + 1]);
let t = if b.1 > a.1 { (u - a.1) / (b.1 - a.1) } else { 0.0 };
return (a.0 + (b.0 - a.0) * t).clamp(0.0, 1.0);
}
let k = pairs.iter().rposition(|p| p.1 <= u).unwrap_or(n - 1);
let (a, b) = (pairs[k], pairs[(k + 1) % n]);
let du = (b.1 - a.1).rem_euclid(1.0).max(1e-9);
let ds = (b.0 - a.0).rem_euclid(1.0);
let t = (u - a.1).rem_euclid(1.0) / du;
(a.0 + ds * t).rem_euclid(1.0)
}
fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (Some((sm, sc)), Some((dm, dc))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "destinationMask")) else { return b };
let pct = ctx.params.f("percent") / 100.0;
@ -450,7 +486,25 @@ fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf {
} else {
0
};
let disp: Vec<[f64; 2]> = (0..N).map(|i| [s[(i + rot) % N][0] - d[i][0], s[(i + rot) % N][1] - d[i][1]]).collect();
// Correspondence points ("source,destination" pairs of outline fractions 0..1) pin which
// point of the source outline goes to which point of the destination outline; in between
// the outlines are matched proportionally by length. Without them the starting point is
// chosen automatically (above).
let pairs = parse_correspondence(ctx.params.s("correspondencePoints"));
let src_at = |i: usize| -> [f64; 2] {
if pairs.is_empty() {
return s[(i + rot) % N];
}
let u = map_fraction(&pairs, i as f64 / N as f64, sc && dc);
let len = crate::util::poly_length(&sm, sc);
crate::util::poly_point_at(&sm, sc, u * len).0
};
let disp: Vec<[f64; 2]> = (0..N)
.map(|i| {
let q = src_at(i);
[q[0] - d[i][0], q[1] - d[i][1]]
})
.collect();
let smooth = ctx.params.e("interpolation") == 2;
b.img = remap(&b.img, false, |x, y| {
if let Some(bd) = &bound
@ -839,6 +893,8 @@ pub fn specs() -> Vec<EffectSpec> {
p("percent", "Percent", num(100.0), pct()),
p("elasticity", "Elasticity", Value::Enum(0), popup(&ELASTICITY)),
p("interpolation", "Interpolation Method", Value::Enum(1), popup(&["Discrete", "Linear", "Smooth"])),
// Correspondence points: "source,destination" outline fractions (0..1) per point.
p("correspondencePoints", "Correspondence Points", Value::Str(String::new()), ParamUi::Hidden),
],
reshape,
),
@ -1029,6 +1085,22 @@ mod tests {
assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15));
}
#[test]
fn reshape_correspondence_points_steer_the_match() {
// Matching outlines with a zero pair change nothing; a quarter-turn offset twists.
let im = img(40, 40);
let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))];
let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks);
assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change");
let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks);
assert!(maxd(&twisted.img, &im) > 0.1);
// Fraction mapping.
assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9);
assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9);
assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9);
}
#[test]
fn upscale_grows_about_centre() {
let im = img(20, 10);

View File

@ -435,12 +435,15 @@ fn median(v: &mut [f64]) -> f64 {
/// Measure motion around the current frame (`cur`) from `prev` to `next` (all in the same
/// buffer grid). `per_row`: Warp method (one velocity per scanline band).
fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool) -> Option<Flow> {
/// `detail` (Pixel Motion Detail, 0..1; 0.2 = the standard grid) scales the number of motion
/// vectors the Pixel Motion method measures: more vectors follow finer local motion.
fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool, detail: f64) -> Option<Flow> {
use effectcraft_track::klt::{GrayPyramid, LkOpts, analysis_factor, track};
let factor = analysis_factor(cur.width, cur.height, if detailed { 640 } else { 320 });
let pc = GrayPyramid::from_image(cur, [0.0; 2], factor, 4);
let (w, h) = (pc.width(), pc.height());
let n = if detailed { 24 } else { 12 };
let base = if detailed { 24.0 } else { 12.0 };
let n = if per_row { base as usize } else { (base * (0.5 + detail.clamp(0.0, 1.0) * 2.5)).round().clamp(4.0, 96.0) as usize };
let cell_w = w as f64 / n as f64;
let rows = ((n as f64) * h as f64 / w.max(1) as f64).round().max(2.0) as usize;
let cell_h = h as f64 / rows as f64;
@ -520,7 +523,17 @@ fn rolling_shutter(ctx: &EffectCtx, mut b: Buf) -> Buf {
return b;
}
let per_row = ctx.params.e("advanced/method") == 0;
let Some(flow) = measure(&b.img, prev.as_ref(), next.as_ref(), 1.0, ctx.params.b("advanced/detailedAnalysis"), per_row) else { return b };
let Some(flow) = measure(
&b.img,
prev.as_ref(),
next.as_ref(),
1.0,
ctx.params.b("advanced/detailedAnalysis"),
per_row,
ctx.params.f("advanced/pixelMotionDetail") / 100.0,
) else {
return b;
};
let dir = ctx.params.e("scanDirection");
let src = b.img.clone();
let (w, h) = (b.img.width as f64, b.img.height as f64);
@ -740,6 +753,20 @@ mod tests {
);
assert_eq!(out.img.data, again.img.data);
}
// Pixel Motion Detail changes how many vectors Pixel Motion measures (and still repairs).
let detail = |d: f64| {
run_fx(
"ec.distort.rollingshutterrepair",
&[("rollingShutterRate", num(100.0)), ("advanced/method", Value::Enum(1)), ("advanced/pixelMotionDetail", num(d))],
skewed.clone(),
t,
env,
)
.img
};
let (fine, coarse) = (detail(100.0), detail(0.0));
assert_ne!(fine.data, coarse.data);
assert!(err(&fine) < err(&skewed) * 0.5 && err(&coarse) < err(&skewed) * 0.5);
let none = run_fx("ec.distort.rollingshutterrepair", &[], skewed.clone(), t, EffectEnv::default());
assert_eq!(none.img.data, skewed.data);
}