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:
parent
e44af1f7e8
commit
05e3861b8b
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue