Merge branch 'worktree-agent-aa8aca30b220ff569'

This commit is contained in:
Brandon Thomas 2026-10-02 23:29:41 -04:00
commit fa26ad95eb
30 changed files with 5800 additions and 15 deletions

1
Cargo.lock generated
View File

@ -1504,6 +1504,7 @@ dependencies = [
"rayon",
"serde",
"serde_json",
"web-time",
]
[[package]]

View File

@ -28,10 +28,10 @@ once.
Measured feature by feature in [docs/parity.md](docs/parity.md) (end of 2 October 2026):
- **≈ 79% of After Effects' features, weighted by importance** (87% of the essentials, 67% of
professional daily-use features, 21% of the long tail); 257 of 298 effects, 16 of them on the GPU.
professional daily-use features, 21% of the long tail); 298 of 298 effects, 16 of them on the GPU.
- **≈ 100 agent-hours of work remain by the audit's estimate.** With five Claude Opus 5.5 agents in
parallel that is **≈ 8–10 hours of wall-clock time at the pace measured so far, up to ≈ 25 hours**
by the audit's conservative per-feature figures.
- Hardest remaining pieces: the 3D camera tracker, Advanced 3D, a scripting object model (Roto Brush & Refine Edge landed in M6.7).
- Hardest remaining pieces: Advanced 3D, a scripting object model (Roto Brush & Refine Edge landed in M6.7, the 3D Camera Tracker in M12.6).
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).

View File

@ -0,0 +1,291 @@
//! 3D Camera Tracker (Effect ▸ Perspective ▸ 3D Camera Tracker; Animation ▸ Track Camera).
//!
//! The analysis runs as a background job in the engine (`camera.analyze`): step 1 tracks
//! features through the clip ([`effectcraft_track::camtrack::TrackAnalyzer`]), step 2 solves
//! the camera ([`effectcraft_track::camtrack::solve`]). Both results are stored as JSON in the
//! instance's hidden parameters — **Tracks** (with **Tracks Key**: what the frames were made
//! from, so source / In-Out / time edits invalidate them) and **Solve** (with **Solve Key**: the
//! Shot Type, Angle of View, Solve Method and deleted points it was solved with, so changing
//! those re-solves without re-tracking). **Method Used** and **Average Error** report the solve.
//!
//! The effect passes its input through; with **Render Track Points** on it draws the solved
//! points (as the viewer shows them) into the frame. The viewer overlay, target and the Create
//! commands live in the engine and the UI.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use effectcraft_track::camtrack::{CameraSolve, CameraTracks, ShotType, SolveMethod, SolveSettings, linalg, point_color};
use crate::warp_stab::param;
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
/// The effect's id.
pub const ID: &str = "ec.perspective.cameratracker";
/// Hidden parameters (see the module docs).
pub const TRACKS: &str = "tracks";
pub const TRACKS_KEY: &str = "tracksKey";
pub const SOLVE: &str = "solve";
pub const SOLVE_KEY: &str = "solveKey";
/// Deleted track point ids (JSON array).
pub const DELETED: &str = "deleted";
/// The "3D Tracker Camera" layer the instance created (layer id, 0 = none).
pub const CAMERA: &str = "camera";
pub const METHOD_USED: &str = "advanced/methodUsed";
pub const AVERAGE_ERROR: &str = "advanced/averageError";
pub const SHOT_TYPES: [&str; 3] = ["Fixed Angle of View", "Variable Zoom", "Specify Angle of View"];
pub const SHOW_POINTS: [&str; 2] = ["2D Source", "3D Solved"];
pub const SOLVE_METHODS: [&str; 4] = ["Auto Detect", "Typical", "Mostly Flat Scene", "Tripod Pan"];
pub fn specs() -> Vec<EffectSpec> {
vec![EffectSpec {
id: ID,
name: "3D Camera Tracker",
category: "Perspective",
params: vec![
p("shotType", "Shot Type", Value::Enum(0), popup(&SHOT_TYPES)),
p("horizontalAngleOfView", "Horizontal Angle of View", num(40.0), slider(1.0, 170.0, 10.0, 120.0, 1)),
p("showTrackPoints", "Show Track Points", Value::Enum(1), popup(&SHOW_POINTS)),
p("renderTrackPoints", "Render Track Points", Value::Bool(false), ParamUi::Checkbox),
p("trackPointSize", "Track Point Size", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 0)),
p("targetSize", "Target Size", num(100.0), slider(1.0, 1000.0, 10.0, 300.0, 0)),
p("advanced/solveMethod", "Solve Method", Value::Enum(0), popup(&SOLVE_METHODS)),
p(METHOD_USED, "Method Used", Value::Str(String::new()), ParamUi::Hidden),
p(AVERAGE_ERROR, "Average Error", num(0.0), ParamUi::Hidden),
p("advanced/detailedAnalysis", "Detailed Analysis", Value::Bool(false), ParamUi::Checkbox),
p("advanced/autoDeletePoints", "Auto-delete Points Across Time", Value::Bool(true), ParamUi::Checkbox),
p("advanced/hideWarningBanner", "Hide Warning Banner", Value::Bool(false), ParamUi::Checkbox),
p(TRACKS, "Tracks", Value::Str(String::new()), ParamUi::Hidden),
p(TRACKS_KEY, "Tracks Key", Value::Str(String::new()), ParamUi::Hidden),
p(SOLVE, "Solve", Value::Str(String::new()), ParamUi::Hidden),
p(SOLVE_KEY, "Solve Key", Value::Str(String::new()), ParamUi::Hidden),
p(DELETED, "Deleted Points", Value::Str(String::new()), ParamUi::Hidden),
p(CAMERA, "Camera Layer", num(0.0), ParamUi::Hidden),
],
render,
gpu: false,
float: true,
}]
}
fn f(params: &Params, id: &str) -> f64 {
param(params, id).map(Value::as_f64).unwrap_or(0.0)
}
fn e(params: &Params, id: &str) -> u32 {
param(params, id).map(Value::as_enum).unwrap_or(0)
}
fn b(params: &Params, id: &str) -> bool {
param(params, id).map(Value::as_bool).unwrap_or(false)
}
fn s<'a>(params: &'a Params, id: &str) -> &'a str {
match param(params, id) {
Some(Value::Str(s)) => s,
_ => "",
}
}
/// Deleted point ids of an instance (stored as a JSON array of numbers).
pub fn deleted(params: &Params) -> Vec<u32> {
s(params, DELETED).trim().trim_start_matches('[').trim_end_matches(']').split(',').filter_map(|v| v.trim().parse().ok()).collect()
}
/// The solve settings of an instance.
pub fn settings(params: &Params) -> SolveSettings {
let mut del = deleted(params);
del.sort_unstable();
del.dedup();
SolveSettings {
shot: ShotType::ALL.get(e(params, "shotType") as usize).copied().unwrap_or_default(),
hfov: f(params, "horizontalAngleOfView"),
method: SolveMethod::ALL.get(e(params, "advanced/solveMethod") as usize).copied().unwrap_or_default(),
deleted: del,
}
}
/// What a solve depends on besides the tracks.
pub fn solve_key(st: &SolveSettings) -> String {
let hfov = if st.shot == ShotType::SpecifyAngle { format!("{:.4}", st.hfov) } else { String::new() };
format!("{:?}|{hfov}|{:?}|{:?}", st.shot, st.method, st.deleted)
}
fn fnv(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
type Cache<T> = Mutex<HashMap<u64, Arc<T>>>;
fn cached<T>(c: &'static OnceLock<Cache<T>>, json: &str, parse: impl Fn(&str) -> Option<T>) -> Option<Arc<T>> {
let key = fnv(json.as_bytes());
let c = c.get_or_init(Default::default);
if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return Some(a.clone());
}
let a = Arc::new(parse(json)?);
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 8 {
m.clear();
}
m.insert(key, a.clone());
Some(a)
}
/// The stored feature tracks of an instance (`None` when not analysed).
pub fn tracks(params: &Params) -> Option<Arc<CameraTracks>> {
static C: OnceLock<Cache<CameraTracks>> = OnceLock::new();
let j = s(params, TRACKS);
if j.is_empty() {
return None;
}
cached(&C, j, CameraTracks::from_json).filter(|t| !t.is_empty())
}
/// The stored camera solve of an instance (`None` when not solved).
pub fn solve(params: &Params) -> Option<Arc<CameraSolve>> {
static C: OnceLock<Cache<CameraSolve>> = OnceLock::new();
let j = s(params, SOLVE);
if j.is_empty() {
return None;
}
cached(&C, j, CameraSolve::from_json).filter(|t| !t.is_empty())
}
/// Screen size (layer pixels) of a target drawn for a point at depth `z`, relative to the
/// median depth of the frame's points.
pub fn target_radius(size_pct: f64, z: f64, median_z: f64) -> f64 {
(6.0 * size_pct / 100.0 * (median_z / z.max(1e-9))).clamp(1.5, 40.0)
}
/// The solved points visible on frame `k`: (id, layer pixel position, camera depth).
pub fn projected(solve: &CameraSolve, k: usize) -> Vec<(u32, [f64; 2], f64)> {
let Some(cam) = solve.frames.get(k) else { return vec![] };
solve
.visible(k)
.filter_map(|p| {
let c = cam.to_cam(p.pos);
cam.project(solve.size, p.pos).map(|q| (p.id, q, c[2]))
})
.collect()
}
/// Median of the depths.
pub fn median_depth(v: &[(u32, [f64; 2], f64)]) -> f64 {
let mut z: Vec<f64> = v.iter().map(|x| x.2).collect();
if z.is_empty() {
return 1.0;
}
z.sort_by(f64::total_cmp);
z[z.len() / 2]
}
fn render(ctx: &EffectCtx, mut buf: Buf) -> Buf {
if !b(ctx.params, "renderTrackPoints") {
return buf;
}
let Some(sol) = solve(ctx.params) else { return buf };
if (sol.size[0] - ctx.layer_size[0]).abs() > 0.5 || (sol.size[1] - ctx.layer_size[1]).abs() > 0.5 {
return buf;
}
let Some(k) = sol.frame_at(ctx.time) else { return buf };
let pts = projected(&sol, k);
let med = median_depth(&pts);
let size = f(ctx.params, "trackPointSize");
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
for (id, q, z) in pts {
let c = point_color(id);
let r = target_radius(size, z, med) * sc;
let at = [q[0] * sc + buf.offset[0], q[1] * sc + buf.offset[1]];
ring(&mut buf.img, at, r, (r * 0.25).max(1.0), [c[0], c[1], c[2], 1.0]);
disc(&mut buf.img, at, (r * 0.2).max(0.8), [c[0], c[1], c[2], 1.0]);
}
buf
}
fn lerp_px(a: Px, b: Px, t: f32) -> Px {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t]
}
fn cover(img: &mut Image, c: [f64; 2], r_out: f64, cov: impl Fn(f64) -> f64, col: Px) {
let (x0, x1) = ((c[0] - r_out - 1.0).floor().max(0.0) as i64, (c[0] + r_out + 1.0).ceil() as i64);
let (y0, y1) = ((c[1] - r_out - 1.0).floor().max(0.0) as i64, (c[1] + r_out + 1.0).ceil() as i64);
for y in y0..y1.min(img.height as i64) {
for x in x0..x1.min(img.width as i64) {
let d = (x as f64 + 0.5 - c[0]).hypot(y as f64 + 0.5 - c[1]);
let a = cov(d).clamp(0.0, 1.0) as f32;
if a > 0.0 {
let i = img.idx(x as u32, y as u32);
img.data[i] = lerp_px(img.data[i], col, a);
}
}
}
}
fn disc(img: &mut Image, c: [f64; 2], r: f64, col: Px) {
cover(img, c, r, |d| r + 0.5 - d, col);
}
fn ring(img: &mut Image, c: [f64; 2], r: f64, w: f64, col: Px) {
cover(img, c, r + w, |d| (w * 0.5 + 0.5 - (d - r).abs()).min(1.0), col);
}
/// Points of a target disc (canonical coordinates) for drawing: `n` points on the circle of
/// radius `size / 2` in the plane through `center` with normal `normal`.
pub fn target_circle(center: linalg::V3, normal: linalg::V3, radius: f64, n: usize) -> Vec<linalg::V3> {
let a = if normal[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
let u = linalg::normalize(linalg::cross(normal, a));
let v = linalg::cross(normal, u);
(0..n)
.map(|i| {
let t = i as f64 / n as f64 * std::f64::consts::TAU;
linalg::add(center, linalg::add(linalg::scale(u, radius * t.cos()), linalg::scale(v, radius * t.sin())))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::EffectEnv;
use effectcraft_track::camtrack::{SolvedFrame, SolvedPoint};
fn params_with(solve: &CameraSolve, render: bool) -> Params {
let mut p = Params::default();
p.values.insert(SOLVE.into(), Value::Str(solve.to_json()));
p.values.insert("renderTrackPoints".into(), Value::Bool(render));
p.values.insert("trackPointSize".into(), Value::Scalar(100.0));
p
}
#[test]
fn render_track_points_draws_targets_and_passes_through_otherwise() {
let sol = CameraSolve {
version: 1,
size: [64.0, 48.0],
start: 0.0,
frame_duration: 1.0 / 30.0,
frames: vec![SolvedFrame { rot: [0.0; 3], center: [0.0; 3], focal: 50.0, solved: true }],
points: vec![SolvedPoint { id: 3, pos: [0.0, 0.0, 1.0], error: 0.1, first: 0, last: 0 }],
..Default::default()
};
let img = Image::new(64, 48);
for on in [false, true] {
let params = params_with(&sol, on);
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [64.0, 48.0], seed: 0, adjustment: false, env: EffectEnv::default() };
let out = render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 });
let px = out.img.data[out.img.idx(32, 24)];
assert_eq!(px[3] > 0.5, on);
}
let st = settings(&Params::default());
assert_eq!(st.shot, ShotType::FixedAngle);
assert!(solve_key(&st).contains("FixedAngle"));
}
}

View File

@ -9,6 +9,7 @@
pub mod audio_fx;
mod blur2;
mod blur3;
pub mod camera_tracker;
mod channel;
mod channel2;
mod channel3d;
@ -382,6 +383,7 @@ pub fn registry() -> &'static [EffectSpec] {
v.extend(paint::specs());
v.extend(puppet::specs());
v.extend(warp_stab::specs());
v.extend(camera_tracker::specs());
v.extend(roto::specs());
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
for s in v.iter_mut() {
@ -530,6 +532,8 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.obsolete.mochashape",
// Each frame gets its own stabilizing warp.
warp_stab::ID,
// Render Track Points draws each frame's solved points.
camera_tracker::ID,
// Each frame has its own segmentation.
roto::ID,
];

View File

@ -0,0 +1,640 @@
//! 3D Camera Tracker runtime: the analysis job (`camera.analyze`, `track.camera`), keeping
//! analyses valid, and turning a solve into composition layers (`camera.createFromSolve`).
//!
//! The job renders the layer's input to the effect (`Renderer::layer_input`) for every frame
//! between the layer's In and Out points on a background thread and tracks features through
//! them (step 1 of 2, "Analyzing in background"), then solves the camera (step 2, "Solving
//! camera"). When only the solve settings changed (Shot Type, Angle of View, Solve Method,
//! deleted points) the stored tracks are re-solved without re-tracking. Results are written into
//! the effect's hidden parameters as one undo step; edits that change the layer's frames clear
//! them and queue the effect again ([`invalidate`], run after every edit), like After Effects.
//!
//! Composition space: the solve's canonical frame maps to comp world by
//! [`CameraSolve::world`] with the zoom of the first solved frame (focal length × the layer's
//! scale in the comp), so the "3D Tracker Camera" reproduces the footage's view and created
//! layers sit on the selected points.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_effects::camera_tracker::{self as ct, AVERAGE_ERROR, METHOD_USED, SOLVE, SOLVE_KEY, TRACKS, TRACKS_KEY};
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::{AutoOrient, GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid};
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use effectcraft_track::camtrack::linalg::{self, Similarity, V3};
use effectcraft_track::camtrack::{AnalyzeOpts, CameraSolve, CameraTracks, SolveSettings, Target, TrackAnalyzer};
use serde::Serialize;
use crate::tracking::lock;
use crate::warp::{analysis_times, signature};
use crate::{Event, Session};
/// Live analysis progress (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CameraProgress {
/// 1 = tracking features, 2 = solving the camera.
pub step: u8,
pub done: u64,
pub total: u64,
pub elapsed: f64,
pub fps: f64,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
/// Re-solving stored tracks (no tracking step).
pub solve_only: bool,
}
impl CameraProgress {
/// The viewer banner text, as After Effects words it.
pub fn banner(&self) -> String {
match self.step {
2 => "Solving camera".into(),
_ => {
let pc = (100 * self.done).checked_div(self.total).unwrap_or(0);
format!("Analyzing in background (step 1 of 2): {pc}%")
}
}
}
}
struct CamResult {
/// New tracks (None for a re-solve).
tracks: Option<CameraTracks>,
solve: Result<CameraSolve, String>,
}
#[derive(Default)]
pub struct CameraShared {
pub cancel: AtomicBool,
pub state: Mutex<CameraProgress>,
result: Mutex<Option<CamResult>>,
}
/// A running (or finished, not yet polled) camera analysis.
pub struct CameraJob {
pub shared: Arc<CameraShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub effect: Uid,
/// The layer signature the tracks are made from, and the solve settings key.
key: String,
solve_key: String,
}
impl CameraJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> CameraProgress {
lock(&self.shared.state).clone()
}
}
struct CamWork {
project: Arc<Project>,
footage: Arc<dyn FootageSource>,
expr: Option<Arc<dyn ExprHost>>,
cache: Arc<LayerCache>,
comp: ItemId,
layer: LayerId,
effect_index: usize,
size: [f64; 2],
detailed: bool,
times: Vec<Tick>,
/// Stored tracks to re-solve (skips step 1).
tracks: Option<Arc<CameraTracks>>,
settings: SolveSettings,
}
pub(crate) fn is_tracker(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == ct::ID)
}
fn str_of(g: &PropGroup, m: &str) -> String {
match g.prop(m).map(|p| &p.value) {
Some(Value::Str(s)) => s.clone(),
_ => String::new(),
}
}
/// What a tracker's tracks are made from: the layer's frames and Detailed Analysis.
fn tracks_key(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> String {
let detailed = effectcraft_effects::warp_stab::param(&static_params(g), "advanced/detailedAnalysis").is_some_and(Value::as_bool);
format!("{}{}", signature(project, cid, l), if detailed { "+detailed" } else { "" })
}
/// Parameters of an instance from static values (the tracker's settings are not animated).
pub fn static_params(g: &PropGroup) -> effectcraft_effects::Params {
effectcraft_effects::flatten_params(g, &mut |p| p.value.clone())
}
fn clear(g: &mut PropGroup, all: bool) {
let mut ids = vec![SOLVE, SOLVE_KEY, METHOD_USED];
if all {
ids.extend([TRACKS, TRACKS_KEY]);
}
for m in ids {
if let Some(p) = g.prop_mut(m) {
p.value = Value::Str(String::new());
p.keys.clear();
}
}
if let Some(p) = g.prop_mut(AVERAGE_ERROR) {
p.value = Value::Scalar(0.0);
}
}
/// Clear analyses that no longer match, after an edit: tracks made from other frames (and their
/// solve), and solves made with other settings. Returns the trackers that need (re-)analysis:
/// cleared ones and newly applied, unanalysed ones.
pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> {
let mut todo = vec![];
let mut clears = vec![];
for (cid, it) in &after.items {
let ItemKind::Comp(c) = &it.kind else { continue };
for l in &c.layers {
let Some(fx) = l.effects() else { continue };
if !fx.groups().any(is_tracker) {
continue;
}
let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id));
for g in fx.groups().filter(|g| is_tracker(g)) {
let tracked = !str_of(g, TRACKS).is_empty();
if tracked && str_of(g, TRACKS_KEY) != tracks_key(after, *cid, l, g) {
clears.push((*cid, l.id, g.uid, true));
todo.push((*cid, l.id, g.uid));
} else if tracked && str_of(g, SOLVE_KEY) != ct::solve_key(&ct::settings(&static_params(g))) {
clears.push((*cid, l.id, g.uid, false));
todo.push((*cid, l.id, g.uid));
} else if !tracked && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() {
todo.push((*cid, l.id, g.uid));
}
}
}
}
for (cid, lid, uid, all) in clears {
if let Some(g) = after.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) {
clear(g, all);
}
}
todo
}
fn input_frame(r: &Renderer, ctx: &EvalCtx, layer: &Layer, effect_index: usize) -> Option<(Arc<effectcraft_raster::Image>, [f64; 2])> {
let buf = r.layer_input(ctx, layer, effect_index)?;
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
let w = (buf.img.width as f64 / buf.scale).round().max(1.0) as u32;
let h = (buf.img.height as f64 / buf.scale).round().max(1.0) as u32;
let off = [buf.offset[0] / buf.scale, buf.offset[1] / buf.scale];
return Some((Arc::new(effectcraft_raster::resample(&buf.img, w, h)), off));
}
Some((Arc::new(buf.img.clone()), buf.offset))
}
fn run_work(w: CamWork, shared: &CameraShared) {
let t0 = web_time::Instant::now();
let fail = |msg: &str| {
let mut s = lock(&shared.state);
s.error = Some(msg.to_string());
s.finished = true;
};
let cancelled = || {
let mut s = lock(&shared.state);
s.cancelled = true;
s.finished = true;
};
let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") };
let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") };
let lt0 = layer.layer_time(w.times[0]).seconds();
let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() };
let (tracks, fresh) = match w.tracks.clone() {
Some(t) => (t, false),
None => {
let mut r = Renderer::new(&w.project, w.footage.as_ref(), RenderOpts { scale: 1.0, ..Default::default() });
r.expr = w.expr.as_deref();
r.cache = Some(&w.cache);
let r = &r;
let ctx0 = EvalCtx::new(&w.project, w.comp, comp, Tick::ZERO);
let ctx0 = EvalCtx { expr: w.expr.as_deref(), ..ctx0 };
let frame_at = |t: Tick| input_frame(r, &ctx0.at(t), layer, w.effect_index);
{
let mut s = lock(&shared.state);
s.step = 1;
s.total = w.times.len() as u64;
}
let mut an = TrackAnalyzer::new(w.size, AnalyzeOpts { detailed: w.detailed });
let mut next = frame_at(w.times[0]);
for k in 0..w.times.len() {
if shared.cancel.load(Ordering::Relaxed) {
return cancelled();
}
let Some(cur) = next.take() else { return fail(&format!("no frame at {:.3} s", w.times[k].seconds())) };
let ((), nf) = rayon::join(
|| an.push(&effectcraft_track::Frame { img: &cur.0, offset: cur.1 }),
|| if k + 1 < w.times.len() { frame_at(w.times[k + 1]) } else { None },
);
next = nf;
let mut s = lock(&shared.state);
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
}
(Arc::new(an.finish(lt0, fd)), true)
}
};
lock(&shared.state).step = 2;
let solve = effectcraft_track::camtrack::solve(&tracks, &w.settings, Some(&shared.cancel)).map_err(|e| e.0);
if shared.cancel.load(Ordering::Relaxed) {
return cancelled();
}
*lock(&shared.result) = Some(CamResult { tracks: fresh.then(|| (*tracks).clone()), solve });
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
impl Session {
pub fn is_camera_analyzing(&self) -> bool {
self.camera_job.as_ref().is_some_and(|j| !j.is_finished())
}
/// Progress of the running (or just finished) analysis.
pub fn camera_progress(&self) -> Option<CameraProgress> {
self.camera_job.as_ref().map(|j| j.progress())
}
/// The tracker being analysed: (comp, layer, effect uid).
pub fn camera_target(&self) -> Option<(ItemId, LayerId, Uid)> {
self.camera_job.as_ref().map(|j| (j.comp, j.layer, j.effect))
}
/// Cancel the running analysis (nothing is written).
pub fn stop_camera(&mut self) -> bool {
match &self.camera_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
/// Start analysing 3D Camera Tracker `effect` on `layer` (re-solving the stored tracks when
/// they are still valid). `wait`: block until done (wasm32 always does). Returns the number
/// of frames.
pub fn start_camera(&mut self, comp: ItemId, layer: LayerId, effect: Uid, wait: bool) -> Result<usize, String> {
if self.is_camera_analyzing() {
return Err("a 3D Camera Tracker analysis is already running".into());
}
self.poll_camera_job();
self.camera_pending.retain(|x| *x != (comp, layer, effect));
let c = self.project.comp(comp).ok_or("no composition")?;
let l = c.layer(layer).ok_or("no layer")?;
let fx = l.effects().ok_or("the layer has no effects")?;
let index = fx.groups().position(|g| g.uid == effect).ok_or("no such effect")?;
let g = fx.groups().nth(index).filter(|g| is_tracker(g)).ok_or("not a 3D Camera Tracker")?;
if l.switches.adjustment {
return Err("3D Camera Tracker needs footage, not an adjustment layer".into());
}
let times = analysis_times(c, l);
if times.len() < 2 {
return Err("the layer must be at least two frames long".into());
}
let (w, h) = effectcraft_render::source_size(&self.project, l);
let size = if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] };
let params = static_params(g);
let key = tracks_key(&self.project, comp, l, g);
let settings = ct::settings(&params);
let solve_key = ct::solve_key(&settings);
let tracks = ct::tracks(&params).filter(|_| str_of(g, TRACKS_KEY) == key);
let n = times.len();
let work = CamWork {
project: self.project.clone(),
footage: self.footage.clone(),
expr: self.expr.clone(),
cache: self.layer_cache.clone(),
comp,
layer,
effect_index: index,
size,
detailed: params.values.iter().any(|(k, v)| k.ends_with("detailedAnalysis") && v.as_bool()),
times,
tracks: tracks.clone(),
settings,
};
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(CameraShared::default());
{
let mut st = lock(&shared.state);
st.solve_only = tracks.is_some();
st.step = if tracks.is_some() { 2 } else { 1 };
st.total = n as u64;
}
let thread = if wait {
run_work(work, &shared);
None
} else {
let sh = shared.clone();
Some(std::thread::Builder::new().name("camera-track".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?)
};
self.camera_job = Some(CameraJob { shared, thread, comp, layer, effect, key, solve_key });
self.events.push(Event::ProjectChanged { revision: self.revision });
self.poll_camera_job();
Ok(n)
}
/// Write a finished analysis into its effect (one undo step) and drop the job.
fn poll_camera_job(&mut self) -> bool {
let Some(job) = &self.camera_job else { return false };
if !job.is_finished() {
return false;
}
let mut job = self.camera_job.take().expect("job");
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.progress();
let result = lock(&job.shared.result).take();
match (result, &st.error) {
(Some(res), None) if !st.cancelled => {
let (cid, lid, uid) = (job.comp, job.layer, job.effect);
let (key, skey) = (job.key.clone(), job.solve_key.clone());
let tracks_json = res.tracks.as_ref().map(CameraTracks::to_json);
let (solve_json, method, avg, msg, failed) = match &res.solve {
Ok(s) => (
s.to_json(),
s.method_used.label().to_string(),
s.average_error,
format!(
"3D Camera Tracker: solved {} frame(s), {} points, average error {:.2} px ({:.1} s)",
s.frames.len(),
s.points.len(),
s.average_error,
st.elapsed
),
false,
),
Err(e) => (String::new(), String::new(), 0.0, format!("3D Camera Tracker: {e}"), true),
};
let r = self.edit(if st.solve_only { "3D Camera Tracker Solve" } else { "3D Camera Tracker Analysis" }, None, |p, _| {
let g = p
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| crate::EngineError::Other("the 3D Camera Tracker was removed during analysis".into()))?;
let mut set = |m: &str, v: Value| {
if let Some(pr) = g.prop_mut(m) {
pr.value = v;
}
};
if let Some(t) = &tracks_json {
set(TRACKS, Value::Str(t.clone()));
set(TRACKS_KEY, Value::Str(key.clone()));
}
set(SOLVE, Value::Str(solve_json.clone()));
set(SOLVE_KEY, Value::Str(skey.clone()));
set(METHOD_USED, Value::Str(method.clone()));
set(AVERAGE_ERROR, Value::Scalar(avg));
Ok(())
});
let (msg, failed) = match r {
Ok(()) => (msg, failed),
Err(e) => (format!("3D Camera Tracker: {e}"), true),
};
self.events.push(Event::Toast { message: msg, error: failed });
}
_ => {
let msg = match &st.error {
Some(e) => format!("3D Camera Tracker analysis failed: {e}"),
None => "3D Camera Tracker analysis cancelled".into(),
};
self.events.push(Event::Toast { message: msg, error: st.error.is_some() });
self.bump();
}
}
true
}
/// Frontends call this every frame: finishes analyses and (with `auto`) starts queued
/// (re-)analyses in the background, like After Effects.
pub fn poll_camera(&mut self, auto: bool) -> bool {
let changed = self.poll_camera_job();
if auto && self.camera_job.is_none() {
while let Some((c, l, e)) = self.camera_pending.first().copied() {
self.camera_pending.remove(0);
if self.start_camera(c, l, e, false).is_ok() {
return true;
}
}
}
changed
}
}
// ---------------------------------------------------------------- solve → composition
/// A tracker's solve placed in its composition.
pub struct Placed {
pub solve: Arc<CameraSolve>,
/// Canonical → comp world.
pub world: Similarity,
/// Comp pixels per layer pixel.
pub scale: f64,
/// Comp times of the analysed frames.
pub times: Vec<Tick>,
pub comp_size: [f64; 2],
}
impl Placed {
/// Comp-world position of a canonical point.
pub fn to_world(&self, x: V3) -> V3 {
self.world.apply(x)
}
/// Canonical position of a comp-world point.
pub fn from_world(&self, x: V3) -> V3 {
let r = &self.world;
linalg::scale(linalg::mtv(&r.r, linalg::sub(x, r.t)), 1.0 / r.s)
}
/// The 3D Tracker Camera on frame `k`: (position, orientation degrees, zoom).
pub fn camera(&self, k: usize) -> (V3, V3, f64) {
let c = &self.solve.frames[k];
let m = linalg::mm(&self.world.r, &linalg::mt(&c.r()));
(self.to_world(c.center), linalg::euler_xyz(&m), c.focal * self.scale)
}
/// Analysed frame at comp time `t`.
pub fn frame_at(&self, t: Tick) -> usize {
self.times.iter().enumerate().min_by_key(|(_, x)| (x.0 - t.0).abs()).map(|(i, _)| i).unwrap_or(0).min(self.solve.frames.len().saturating_sub(1))
}
}
/// Comp pixels per layer pixel of `layer` (its 2D transform at its In point).
fn layer_scale(project: &Project, cid: ItemId, layer: &Layer) -> f64 {
let Some(comp) = project.comp(cid) else { return 1.0 };
let ctx = EvalCtx::new(project, cid, comp, layer.in_point.max(Tick::ZERO));
let m = ctx.world_matrix(layer).0;
let s = m[0][0].hypot(m[1][0]);
if s.is_finite() && s > 1e-9 { s } else { 1.0 }
}
/// The solve of tracker `uid` on `lid`, placed in the comp.
pub fn placed(project: &Project, cid: ItemId, lid: LayerId, uid: Uid) -> Option<Placed> {
let comp = project.comp(cid)?;
let layer = comp.layer(lid)?;
let g = layer.props.find_group(uid).filter(|g| is_tracker(g))?;
let solve = ct::solve(&static_params(g))?;
let scale = layer_scale(project, cid, layer);
let k0 = solve.first_solved()?;
let comp_size = [comp.width as f64, comp.height as f64];
let world = solve.world(comp_size, solve.frames[k0].focal * scale);
let mut times = analysis_times(comp, layer);
times.truncate(solve.frames.len());
Some(Placed { solve, world, scale, times, comp_size })
}
/// What to create from a target (`camera.createFromSolve`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CreateKind {
Text,
Solid,
Null,
ShadowCatcher,
/// Only the camera.
Camera,
}
impl CreateKind {
pub fn from_name(s: &str) -> Option<CreateKind> {
match s.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"text" => Some(CreateKind::Text),
"solid" => Some(CreateKind::Solid),
"null" => Some(CreateKind::Null),
"shadowcatcher" | "shadow" => Some(CreateKind::ShadowCatcher),
"camera" => Some(CreateKind::Camera),
_ => None,
}
}
}
/// Rotation (layer → world) of a layer lying on a plane with unit `normal` (towards the camera),
/// its x axis along `right` projected on the plane: the layer's front (−z) faces the camera.
pub fn plane_orientation(normal: V3, right: V3) -> V3 {
let z = linalg::scale(normal, -1.0);
let mut x = linalg::sub(right, linalg::scale(z, linalg::dot(right, z)));
if linalg::norm(x) < 1e-9 {
x = if z[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
x = linalg::sub(x, linalg::scale(z, linalg::dot(x, z)));
}
let x = linalg::normalize(x);
let y = linalg::cross(z, x);
let m = linalg::from_cols(x, y, z);
norm_angles(linalg::euler_xyz(&m))
}
fn norm_angles(a: V3) -> V3 {
a.map(|v| {
let r = v.rem_euclid(360.0);
if (r - 360.0).abs() < 1e-9 { 0.0 } else { r }
})
}
/// Key `path` on `l` at every `(time, value)`.
pub(crate) fn key_all(l: &mut Layer, path: &str, keys: &[(Tick, Value)]) {
if let Some(pr) = l.props.prop_mut(path) {
pr.keys = keys.iter().map(|(t, v)| Keyframe::new(*t, v.clone())).collect();
if let Some((_, v)) = keys.first() {
pr.value = v.clone();
}
}
}
/// Write the solved camera path into camera layer `l` (one-node, keyed on every analysed frame;
/// Zoom keyed for Variable Zoom).
pub(crate) fn write_camera(l: &mut Layer, pl: &Placed) {
l.auto_orient = AutoOrient::Off;
let n = pl.times.len().min(pl.solve.frames.len());
let mut pos = Vec::with_capacity(n);
let mut ori = Vec::with_capacity(n);
let mut zoom = Vec::with_capacity(n);
let mut prev: Option<V3> = None;
for k in 0..n {
let (p, o, z) = pl.camera(k);
// Unwrap the angles so per-frame keys interpolate the short way.
let o = match prev {
None => norm_angles(o),
Some(q) => [0, 1, 2].map(|i| q[i] + (o[i] - q[i] + 180.0).rem_euclid(360.0) - 180.0),
};
prev = Some(o);
let t = l.layer_time(pl.times[k]);
pos.push((t, Value::Vec3(p)));
ori.push((t, Value::Vec3(o)));
zoom.push((t, Value::Scalar(z)));
}
key_all(l, "transform/position", &pos);
key_all(l, "transform/orientation", &ori);
let variable = pl.solve.frames.iter().any(|f| (f.focal - pl.solve.frames[0].focal).abs() > 1e-6 * f.focal);
if variable {
key_all(l, "cameraOptions/zoom", &zoom);
} else if let Some((_, z)) = zoom.first()
&& let Some(pr) = l.props.prop_mut("cameraOptions/zoom")
{
pr.keys.clear();
pr.value = z.clone();
}
if let Some((_, z)) = zoom.first()
&& let Some(pr) = l.props.prop_mut("cameraOptions/focusDistance")
{
pr.value = z.clone();
}
// A one-node camera has no Point of Interest.
if let Some(tr) = l.props.sub_mut("transform") {
tr.children.retain(|c| !matches!(c, effectcraft_project::Node::Prop(p) if p.match_id == "poi"));
}
for rot in ["transform/rotationX", "transform/rotationY", "transform/rotation"] {
if let Some(pr) = l.props.prop_mut(rot) {
pr.keys.clear();
pr.value = Value::Scalar(0.0);
}
}
}
/// The target for `ids` on frame `k`: when fewer than three points are given, the nearest
/// solved neighbours (in 3D) complete the plane.
pub fn target_for(solve: &CameraSolve, ids: &[u32], k: usize) -> Option<Target> {
let mut use_ids: Vec<u32> = ids.iter().copied().filter(|i| solve.point(*i).is_some()).collect();
if use_ids.is_empty() {
return None;
}
if use_ids.len() < 3 {
let c = solve.point(use_ids[0])?.pos;
let mut others: Vec<(f64, u32)> = solve.visible(k).filter(|p| !use_ids.contains(&p.id)).map(|p| (linalg::norm(linalg::sub(p.pos, c)), p.id)).collect();
others.sort_by(|a, b| a.0.total_cmp(&b.0));
for (_, id) in others.into_iter().take(6usize.saturating_sub(use_ids.len())) {
use_ids.push(id);
}
let mut t = solve.target(&use_ids, k)?;
// The target sits on the chosen point(s).
let pts: Vec<V3> = ids.iter().filter_map(|i| solve.point(*i)).map(|p| p.pos).collect();
t.center = linalg::scale(pts.iter().fold([0.0; 3], |a, b| linalg::add(a, *b)), 1.0 / pts.len() as f64);
return Some(t);
}
solve.target(&use_ids, k)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plane_orientation_faces_the_normal() {
let n = linalg::normalize([0.2, -0.9, -0.3]);
let o = plane_orientation(n, [1.0, 0.0, 0.0]);
let m = linalg::from_euler_xyz(o);
// Layer −z (its front) is the normal.
let front = linalg::mv(&m, [0.0, 0.0, -1.0]);
assert!(linalg::norm(linalg::sub(front, n)) < 1e-9);
}
}

View File

@ -0,0 +1,574 @@
//! 3D Camera Tracker commands: Animation ▸ Track Camera (`track.camera`), the effect's Analyze /
//! Cancel / Create Camera buttons, the viewer's track point selection and right-click menu
//! (Set Ground Plane and Origin, Create Text / Solid / Null / Shadow Catcher … and Camera,
//! Create Multiple …, Delete Selected Points) and the status / points queries.
use effectcraft_color::Label;
use effectcraft_effects::camera_tracker::{self as ct, CAMERA, DELETED, SOLVE};
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build;
use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Project, Solid, Uid};
use effectcraft_track::camtrack::linalg::{self, V3};
use effectcraft_track::camtrack::{ShotType, SolveMethod, Target, point_color};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, layer_p, str_p};
use crate::camera_track::{CreateKind, Placed, is_tracker, placed, plane_orientation, static_params, target_for, write_camera};
use crate::{EngineError, Result, Session, cmd, query};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"track.camera",
"Track Camera",
["Animation"],
None,
"{layer?, shotType?: fixed|variable|specify, aov?: degrees, solveMethod?: auto|typical|flat|tripod, detailed?, wait?}",
can_track,
track_camera
),
cmd!("camera.analyze", "Analyze", [], None, "{layer?, effect?: uid|name|index, wait?: block until done}", has_comp, analyze),
cmd!("camera.cancel", "Cancel", [], None, "{}", is_analyzing, |s, _| Ok(json!({"stopped": s.stop_camera()}))),
query!("camera.solveStatus", "3D Camera Tracker Status", "{layer?, effect?}", status),
query!("camera.points", "3D Camera Tracker Points", "{layer?, effect?, time?: comp seconds} → visible solved points", points),
cmd!("camera.selectPoints", "Select Track Points", [], None, "{points: [id], add?, toggle?}", has_comp, select_points),
cmd!(
"camera.createFromSolve",
"Create from Camera Solve",
[],
None,
"{kind: text|solid|null|shadowCatcher|camera, points?: [id] (default: selected), target?: {center, normal, size?} (comp world), multiple?, layer?, effect?}",
has_comp,
create_from_solve
),
cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))),
cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground),
cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points),
]
}
fn with_kind(p: &Value, kind: &str) -> Value {
let mut q = p.clone();
if !q.is_object() {
q = json!({});
}
q["kind"] = json!(kind);
q
}
fn can_track(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
let c = s.active_comp().ok_or("no composition is open")?;
match s.state.selected_layers.first().and_then(|l| c.layer(*l)) {
Some(l) if l.has_video() && !l.switches.adjustment && !l.is_camera() && !l.is_light() => Ok(()),
Some(_) => Err("Track Camera needs a footage or composition layer".into()),
None => Err("select a layer to track".into()),
}
}
fn is_analyzing(s: &Session) -> std::result::Result<(), String> {
if s.is_camera_analyzing() { Ok(()) } else { Err("no 3D Camera Tracker analysis is running".into()) }
}
/// (comp, layer, effect uid) of a 3D Camera Tracker: `layer` / `effect` (uid, name or 1-based
/// index), else a selected one, else the first in the comp.
fn ct_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let candidates: Vec<LayerId> = match p.get("layer") {
Some(v) => vec![super::resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?],
None => {
let mut v: Vec<LayerId> = s.state.selected_props.iter().map(|(l, _)| *l).collect();
v.extend(s.state.selected_layers.iter().copied());
v.extend(comp.layers.iter().map(|l| l.id));
v
}
};
for lid in candidates {
let Some(fx) = comp.layer(lid).and_then(Layer::effects) else { continue };
let g = match p.get("effect") {
Some(Value::Number(n)) => {
let n = n.as_u64().unwrap_or(0);
fx.groups().find(|g| g.uid == n).or_else(|| fx.groups().nth((n as usize).saturating_sub(1)))
}
Some(Value::String(name)) => fx.groups().find(|g| &g.name == name),
_ => s
.state
.selected_props
.iter()
.find_map(|(l, u)| (*l == lid).then(|| fx.groups().find(|g| g.uid == *u && is_tracker(g))).flatten())
.or_else(|| fx.groups().find(|g| is_tracker(g))),
};
if let Some(g) = g.filter(|g| is_tracker(g)) {
return Ok((cid, lid, g.uid));
}
if p.get("layer").is_some() {
break;
}
}
Err(bad(cmd, "no 3D Camera Tracker found (apply Animation ▸ Track Camera first)"))
}
fn shot_p(p: &Value) -> Result<Option<ShotType>> {
match p.get("shotType") {
None => Ok(None),
Some(Value::Number(n)) => ShotType::ALL.get(n.as_u64().unwrap_or(99) as usize).copied().map(Some).ok_or_else(|| bad("track.camera", "shotType: 0..2")),
Some(Value::String(v)) => ShotType::from_name(v).map(Some).ok_or_else(|| bad("track.camera", "shotType: fixed|variable|specify")),
Some(_) => Err(bad("track.camera", "shotType: fixed|variable|specify")),
}
}
fn method_p(p: &Value) -> Result<Option<SolveMethod>> {
match p.get("solveMethod") {
None => Ok(None),
Some(Value::Number(n)) => {
SolveMethod::ALL.get(n.as_u64().unwrap_or(99) as usize).copied().map(Some).ok_or_else(|| bad("track.camera", "solveMethod: 0..3"))
}
Some(Value::String(v)) => SolveMethod::from_name(v).map(Some).ok_or_else(|| bad("track.camera", "solveMethod: auto|typical|flat|tripod")),
Some(_) => Err(bad("track.camera", "solveMethod: auto|typical|flat|tripod")),
}
}
/// Animation ▸ Track Camera / the Tracker panel button: apply the 3D Camera Tracker to the layer
/// (reusing one it already has), set the given options and start the analysis.
fn track_camera(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "track.camera")?;
let shot = shot_p(p)?;
let method = method_p(p)?;
let aov = f_p(p, "aov").or_else(|| f_p(p, "angleOfView"));
if aov.is_some_and(|a| !(1.0..=170.0).contains(&a)) {
return Err(bad("track.camera", "aov: 1..170 degrees"));
}
let detailed = b_p(p, "detailed");
let existing = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(Layer::effects).and_then(|fx| fx.groups().find(|g| is_tracker(g)).map(|g| g.uid));
let uid = match existing {
Some(u) => u,
None => {
let r = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [lid.0], "comp": cid.0}))?;
r["effects"].get(0).and_then(Value::as_u64).ok_or_else(|| EngineError::Other("the effect could not be applied".into()))?
}
};
if shot.is_some() || method.is_some() || aov.is_some() || detailed.is_some() {
s.edit("3D Camera Tracker Settings", None, |proj, _| {
let g = proj
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
let mut set = |m: &str, v: KV| {
if let Some(pr) = g.prop_mut(m) {
pr.keys.clear();
pr.value = v;
}
};
if let Some(sh) = shot {
set("shotType", KV::Enum(ShotType::ALL.iter().position(|x| *x == sh).unwrap_or(0) as u32));
}
if let Some(a) = aov {
set("horizontalAngleOfView", KV::Scalar(a));
if shot.is_none() {
set("shotType", KV::Enum(2));
}
}
if let Some(m) = method {
set("advanced/solveMethod", KV::Enum(SolveMethod::ALL.iter().position(|x| *x == m).unwrap_or(0) as u32));
}
if let Some(d) = detailed {
set("advanced/detailedAnalysis", KV::Bool(d));
}
Ok(())
})?;
}
// Select the effect so the viewer shows its track points.
s.state.selected_props = vec![(lid, uid)];
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_camera_analyzing(), "progress": s.camera_progress()});
if wait {
out["status"] = status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
}
Ok(out)
}
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = ct_p(s, p, "camera.analyze")?;
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_camera_analyzing(), "progress": s.camera_progress()});
if wait {
out["status"] = status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
}
Ok(out)
}
fn status(s: &mut Session, p: &Value) -> Result<Value> {
s.poll_camera(false);
let mut out = json!({"running": s.is_camera_analyzing(), "progress": s.camera_progress(), "banner": s.camera_progress().map(|p| p.banner())});
let Ok((cid, lid, uid)) = ct_p(s, p, "camera.solveStatus") else { return Ok(out) };
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let g = layer.props.find_group(uid).ok_or(EngineError::NoComp)?;
let params = static_params(g);
out["layer"] = json!(lid.0);
out["effect"] = json!(uid);
out["pending"] = json!(s.camera_pending.contains(&(cid, lid, uid)));
let tracks = ct::tracks(&params);
out["analyzed"] = json!(tracks.is_some());
if let Some(t) = &tracks {
out["tracks"] = json!(t.tracks.len());
out["frames"] = json!(t.frames);
}
let settings = ct::settings(&params);
out["shotType"] = json!(settings.shot.label());
out["solveMethod"] = json!(settings.method.label());
out["deleted"] = json!(settings.deleted.len());
let solve = ct::solve(&params);
out["solved"] = json!(solve.is_some());
if let Some(sv) = &solve {
let f = sv.focal();
out["methodUsed"] = json!(sv.method_used.label());
out["averageError"] = json!(sv.average_error);
out["points"] = json!(sv.points.len());
out["solvedFrames"] = json!(sv.frames.iter().filter(|c| c.solved).count());
out["focalLength"] = json!(f);
out["horizontalAngleOfView"] = json!(sv.hfov(f));
out["groundPlane"] = json!(sv.ground.is_some());
if let Some(pl) = placed(&s.project, cid, lid, uid) {
let k = pl.frame_at(s.time_of(cid));
let (pos, ori, zoom) = pl.camera(k);
out["frame"] = json!(k);
out["camera"] = json!({"position": pos, "orientation": ori, "zoom": zoom});
out["visible"] = json!(sv.visible(k).count());
}
}
let cam = params.get(CAMERA).map(|v| v.as_f64() as u64).unwrap_or(0);
out["cameraLayer"] = if cam > 0 && comp.layer(LayerId(cam)).is_some_and(Layer::is_camera) { json!(cam) } else { Value::Null };
out["selected"] = json!(s.state.camera_points);
Ok(out)
}
/// The visible solved points at the current (or given) time, with their comp positions.
fn points(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = ct_p(s, p, "camera.points")?;
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad("camera.points", "the camera is not solved yet"))?;
let t = f_p(p, "time").map(effectcraft_time::Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let k = pl.frame_at(t);
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
let m = ctx.world_matrix(layer);
let list: Vec<Value> = ct::projected(&pl.solve, k)
.into_iter()
.map(|(id, q, z)| {
let c = m.apply(effectcraft_geom::vec3(q[0], q[1], 0.0));
let sp = pl.solve.point(id).copied().unwrap_or_default();
let col = point_color(id);
json!({"id": id, "comp": [c.x, c.y], "layer": q, "depth": z, "world": pl.to_world(sp.pos), "error": sp.error, "color": col, "selected": s.state.camera_points.contains(&id)})
})
.collect();
Ok(json!({"frame": k, "points": list}))
}
fn ids_p(s: &Session, p: &Value) -> Vec<u32> {
match p.get("points") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(|v| v as u32).collect(),
Some(Value::Number(n)) => n.as_u64().map(|v| vec![v as u32]).unwrap_or_default(),
_ => s.state.camera_points.clone(),
}
}
fn select_points(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<u32> = match p.get("points") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(|v| v as u32).collect(),
_ => vec![],
};
if b_p(p, "toggle").unwrap_or(false) {
for id in ids {
match s.state.camera_points.iter().position(|x| *x == id) {
Some(i) => {
s.state.camera_points.remove(i);
}
None => s.state.camera_points.push(id),
}
}
} else if b_p(p, "add").unwrap_or(false) {
for id in ids {
if !s.state.camera_points.contains(&id) {
s.state.camera_points.push(id);
}
}
} else {
s.state.camera_points = ids;
}
s.bump();
Ok(json!({"selected": s.state.camera_points}))
}
/// The target of a create / ground command in canonical solve coordinates.
fn target_p(s: &Session, p: &Value, pl: &Placed, k: usize, cmd: &str) -> Result<Target> {
if let Some(t) = p.get("target") {
let v3 = |k: &str| -> Option<V3> {
let a = t.get(k)?.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)])
};
let (c, n) = (v3("center").ok_or_else(|| bad(cmd, "target.center: [x, y, z]"))?, v3("normal").unwrap_or([0.0, -1.0, 0.0]));
let size = t.get("size").and_then(Value::as_f64).unwrap_or(100.0);
let nc = linalg::normalize(linalg::mtv(&pl.world.r, n));
return Ok(Target { center: pl.from_world(c), normal: nc, size: size / pl.world.s });
}
let ids = ids_p(s, p);
if ids.is_empty() {
return Err(bad(cmd, "select track points (or give `points` / `target`)"));
}
target_for(&pl.solve, &ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))
}
fn next_name(c: &effectcraft_project::Comp, base: &str) -> String {
let n = (1..).find(|i| !c.layers.iter().any(|l| l.name == format!("{base} {i}"))).unwrap_or(1);
format!("{base} {n}")
}
fn place(l: &mut Layer, pos: V3, ori: V3, src: &Layer) {
l.switches.three_d = true;
l.in_point = src.in_point;
l.out_point = src.out_point;
if let Some(pr) = l.props.prop_mut("transform/position") {
pr.value = KV::Vec3(pos);
}
if let Some(pr) = l.props.prop_mut("transform/orientation") {
pr.value = KV::Vec3(ori);
}
}
/// Insert `l` above the tracked layer.
fn insert_above(proj: &mut Project, cid: ItemId, above: LayerId, l: Layer) -> Result<LayerId> {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = comp.layers.iter().position(|x| x.id == above).unwrap_or(0);
let id = l.id;
comp.layers.insert(at, l);
Ok(id)
}
fn add_solid(proj: &mut Project, comp: &effectcraft_project::Comp, name: &str, size: u32, color: [f32; 3]) -> Layer {
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
let sid = proj.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: size, height: size, pixel_aspect: 1.0 }));
build::layer(proj, comp, name, LayerSource::Solid { item: sid }, (size, size), None)
}
/// Create Text / Solid / Null / Shadow Catcher (and Light) on the target, plus the 3D Tracker
/// Camera when the tracker has none yet — one undo step.
fn create_from_solve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.createFromSolve";
let (cid, lid, uid) = ct_p(s, p, cmd)?;
let kind = CreateKind::from_name(str_p(p, "kind").unwrap_or("null")).ok_or_else(|| bad(cmd, "kind: text|solid|null|shadowCatcher|camera"))?;
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad(cmd, "the camera is not solved yet (analysis still running or failed)"))?;
let k = pl.frame_at(s.time_of(cid));
let multiple = b_p(p, "multiple").unwrap_or(false);
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let src = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
let g = src.props.find_group(uid).ok_or(EngineError::NoComp)?;
let params = static_params(g);
let size_pct = effectcraft_effects::warp_stab::param(&params, "targetSize").map(|v| v.as_f64()).unwrap_or(100.0) / 100.0;
let existing_cam = params.get(CAMERA).map(|v| v.as_f64() as u64).filter(|id| *id > 0 && comp.layer(LayerId(*id)).is_some_and(Layer::is_camera));
// Targets: one, or one per selected point (Create Multiple …) sharing the selection's plane.
let targets: Vec<Target> = if kind == CreateKind::Camera {
vec![]
} else {
let t = target_p(s, p, &pl, k, cmd)?;
if multiple {
let ids = ids_p(s, p);
ids.iter().filter_map(|i| pl.solve.point(*i)).map(|sp| Target { center: sp.pos, ..t }).collect()
} else {
vec![t]
}
};
// Camera right vector on this frame (world), to align created layers with the view.
let cam = &pl.solve.frames[k];
let right = linalg::mv(&pl.world.r, linalg::mtv(&cam.r(), [1.0, 0.0, 0.0]));
let label = match (kind, multiple) {
(CreateKind::Text, false) => "Create Text and Camera",
(CreateKind::Solid, false) => "Create Solid and Camera",
(CreateKind::Null, false) => "Create Null and Camera",
(CreateKind::ShadowCatcher, _) => "Create Shadow Catcher, Camera and Light",
(CreateKind::Text, true) => "Create Multiple Text Layers and Camera",
(CreateKind::Solid, true) => "Create Multiple Solids and Camera",
(CreateKind::Null, true) => "Create Multiple Nulls and Camera",
(CreateKind::Camera, _) => "Create Camera",
};
let (camera, created) = s.edit(label, None, |proj, st| {
let mut created = vec![];
// The camera: reuse the tracker's, else create "3D Tracker Camera" at the top.
let camera = match existing_cam {
Some(id) if kind != CreateKind::Camera => LayerId(id),
Some(id) => {
let l = proj.comp_mut(cid).and_then(|c| c.layer_mut(LayerId(id))).ok_or(EngineError::NoComp)?;
write_camera(l, &pl);
LayerId(id)
}
None => {
let mut l = build::layer(proj, &comp, "3D Tracker Camera", LayerSource::Camera, (comp.width, comp.height), None);
l.in_point = src.in_point;
l.out_point = src.out_point;
write_camera(&mut l, &pl);
let id = l.id;
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.insert(0, l);
if let Some(pr) = proj.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)).and_then(|g| g.prop_mut(CAMERA)) {
pr.value = KV::Scalar(id.0 as f64);
}
id
}
};
for t in &targets {
let center = pl.to_world(t.center);
let normal = linalg::normalize(linalg::mv(&pl.world.r, t.normal));
let ori = plane_orientation(normal, right);
let size = (t.size * pl.world.s * size_pct).max(1.0);
let cur = proj.comp(cid).ok_or(EngineError::NoComp)?.clone();
match kind {
CreateKind::Text => {
let mut l = build::layer(proj, &cur, "Text", LayerSource::Text, (comp.width, comp.height), None);
let doc = TextDoc { text: "Text".into(), justify: Justify::Center, size: (size * 0.35).clamp(4.0, 2000.0), ..Default::default() };
if let Some(pr) = l.props.prop_mut("text/sourceText") {
pr.value = KV::Text(Box::new(doc));
}
place(&mut l, center, ori, &src);
created.push(insert_above(proj, cid, lid, l)?);
}
CreateKind::Solid => {
let name = next_name(&cur, "Track Solid");
let c = point_color((created.len() as u32).wrapping_add(7));
let mut l = add_solid(proj, &cur, &name, size.round().max(1.0) as u32, c);
place(&mut l, center, ori, &src);
created.push(insert_above(proj, cid, lid, l)?);
}
CreateKind::Null => {
let name = next_name(&cur, "Track Null");
let mut l = build::layer(proj, &cur, &name, LayerSource::Null, (comp.width, comp.height), None);
place(&mut l, center, ori, &src);
if let Some(pr) = l.props.prop_mut("transform/scale") {
pr.value = KV::Vec3([size, size, size]);
}
created.push(insert_above(proj, cid, lid, l)?);
}
CreateKind::ShadowCatcher => {
let name = next_name(&cur, "Shadow Catcher");
let mut l = add_solid(proj, &cur, &name, (size * 4.0).round().max(1.0) as u32, [1.0, 1.0, 1.0]);
place(&mut l, center, ori, &src);
if let Some(pr) = l.props.prop_mut("materialOptions/acceptsShadows") {
pr.value = KV::Enum(2);
}
if let Some(pr) = l.props.prop_mut("materialOptions/castsShadows") {
pr.value = KV::Enum(0);
}
let sc = created.len();
created.push(insert_above(proj, cid, lid, l)?);
let cur = proj.comp(cid).ok_or(EngineError::NoComp)?.clone();
let lname = next_name(&cur, "Light");
let mut light = build::layer(proj, &cur, &lname, LayerSource::Light { kind: LightKind::Point }, (comp.width, comp.height), None);
light.in_point = src.in_point;
light.out_point = src.out_point;
let lp = linalg::add(center, linalg::add(linalg::scale(normal, size * 2.0), linalg::scale(right, size)));
if let Some(pr) = light.props.prop_mut("transform/position") {
pr.value = KV::Vec3(lp);
}
if let Some(pr) = light.props.prop_mut("lightOptions/castsShadows") {
pr.value = KV::Bool(true);
}
let _ = sc;
created.push(insert_above(proj, cid, created[created.len() - 1], light)?);
}
CreateKind::Camera => {}
}
}
st.selected_layers = if created.is_empty() { vec![camera] } else { created.clone() };
st.selected_props.clear();
Ok((camera, created))
})?;
let t0 = targets.first().map(
|t| json!({"center": pl.to_world(t.center), "normal": linalg::normalize(linalg::mv(&pl.world.r, t.normal)), "size": t.size * pl.world.s * size_pct}),
);
Ok(json!({"camera": camera.0, "layers": created.iter().map(|l| l.0).collect::<Vec<_>>(), "target": t0}))
}
fn set_ground(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.setGroundPlane";
let (cid, lid, uid) = ct_p(s, p, cmd)?;
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad(cmd, "the camera is not solved yet"))?;
let k = pl.frame_at(s.time_of(cid));
let ids = ids_p(s, p);
if ids.is_empty() {
return Err(bad(cmd, "select track points (or give `points`)"));
}
let t = target_for(&pl.solve, &ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))?;
let mut sv = (*pl.solve).clone();
let mut g = sv.ground_from(&ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))?;
g.origin = t.center;
sv.ground = Some(g);
let json = sv.to_json();
s.edit("Set Ground Plane and Origin", None, |proj, _| {
let pr = proj
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.and_then(|g| g.prop_mut(SOLVE))
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
pr.value = KV::Str(json);
Ok(())
})?;
Ok(json!({"origin": g.origin, "normal": g.normal}))
}
/// Delete points (and, with Auto-delete Points Across Time, the points of other tracks on the
/// same 3D feature), then re-solve.
fn delete_points(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.deletePoints";
let (cid, lid, uid) = ct_p(s, p, cmd)?;
let ids = ids_p(s, p);
if ids.is_empty() {
return Err(bad(cmd, "select track points (or give `points`)"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let g = comp.layer(lid).and_then(|l| l.props.find_group(uid)).ok_or(EngineError::NoComp)?;
let params = static_params(g);
let mut del = ct::deleted(&params);
let auto = effectcraft_effects::warp_stab::param(&params, "advanced/autoDeletePoints").is_some_and(KV::as_bool);
let mut add = ids.clone();
if auto && let Some(sv) = ct::solve(&params) {
// Same feature tracked again at other times: solved within 1 % of its depth.
let cam0 = sv.first_solved().map(|k| sv.frames[k].center).unwrap_or([0.0; 3]);
for id in &ids {
let Some(pt) = sv.point(*id) else { continue };
let r = linalg::norm(linalg::sub(pt.pos, cam0)) * 0.01;
for q in &sv.points {
let disjoint = q.last < pt.first || q.first > pt.last;
if q.id != *id && disjoint && linalg::norm(linalg::sub(q.pos, pt.pos)) < r {
add.push(q.id);
}
}
}
}
for id in add {
if !del.contains(&id) {
del.push(id);
}
}
del.sort_unstable();
let json = serde_json::to_string(&del).unwrap_or_default();
s.edit("Delete Track Points", None, |proj, _| {
let pr = proj
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.and_then(|g| g.prop_mut(DELETED))
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
pr.value = KV::Str(json);
Ok(())
})?;
s.state.camera_points.retain(|x| !del.contains(x));
// Re-solve now (After Effects re-solves after deleting points).
let wait = b_p(p, "wait").unwrap_or(false);
if !s.is_camera_analyzing() {
s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
}
Ok(json!({"deleted": del, "running": s.is_camera_analyzing()}))
}

View File

@ -4,6 +4,7 @@
mod anim;
pub mod anim_tools;
mod animation;
mod camera_cmds;
mod comp;
mod comp_more;
mod create;
@ -137,6 +138,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(track::specs());
v.extend(mask_interp::specs());
v.extend(warp_cmds::specs());
v.extend(camera_cmds::specs());
v.extend(roto_cmds::specs());
v.extend(paint::specs());
v.extend(puppet::specs());

View File

@ -50,7 +50,6 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"),
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
stub!("text.animatorFontAxes", "Variable Font Axes", ["Animation", "Animate Text"], "{}"),
stub!("track.camera", "Track Camera", ["Animation"], "{}"),
// Workspaces and panels that don't exist yet.
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
]

View File

@ -9,6 +9,7 @@
//! (compositions are `Arc`s, so untouched comps are shared).
pub mod autosave;
pub mod camera_track;
pub mod commands;
pub mod config;
pub mod demo;
@ -174,6 +175,9 @@ pub struct EditorState {
/// Tracker panel ▸ Method in mask mode (Track Mask).
#[serde(default)]
pub mask_track_method: mask_track::MaskMethod,
/// 3D Camera Tracker: the selected track points (ids) in the viewer.
#[serde(default)]
pub camera_points: Vec<u32>,
/// Mask Interpolation panel options.
#[serde(default)]
pub mask_interp: commands::mask_interp::MaskInterpOptions,
@ -270,6 +274,10 @@ pub struct Session {
pub warp_job: Option<warp::WarpJob>,
/// Warp Stabilizers waiting for (re-)analysis: (comp, layer, effect uid).
pub warp_pending: Vec<(ItemId, LayerId, Uid)>,
/// The running (or finished, not yet polled) 3D Camera Tracker analysis.
pub camera_job: Option<camera_track::CameraJob>,
/// 3D Camera Trackers waiting for (re-)analysis: (comp, layer, effect uid).
pub camera_pending: Vec<(ItemId, LayerId, Uid)>,
/// The running (or finished, not yet polled) Roto Brush propagation / Freeze.
pub roto_job: Option<roto::RotoJob>,
/// Roto Brush instances edited since their last propagation: (comp, layer, effect uid).
@ -338,6 +346,8 @@ impl Default for Session {
mask_job: None,
warp_job: None,
warp_pending: vec![],
camera_job: None,
camera_pending: vec![],
roto_job: None,
roto_pending: vec![],
events: vec![],
@ -408,6 +418,12 @@ impl Session {
self.warp_pending.push(w);
}
}
// So are 3D Camera Tracker tracks (and solves made with other settings).
for w in camera_track::invalidate(&before, &mut p) {
if !self.camera_pending.contains(&w) {
self.camera_pending.push(w);
}
}
// Roto Brush Input Keys follow the frames; edited instances propagate again.
for r in roto::sync(&before, &mut p) {
if !self.roto_pending.contains(&r) {
@ -594,6 +610,9 @@ impl Session {
self.stop_warp();
self.warp_job = None;
self.warp_pending.clear();
self.stop_camera();
self.camera_job = None;
self.camera_pending.clear();
self.stop_roto();
self.roto_job = None;
self.roto_pending.clear();
@ -620,6 +639,8 @@ mod tests_3d;
#[cfg(test)]
mod tests_anim_tools;
#[cfg(test)]
mod tests_camera_track;
#[cfg(test)]
mod tests_disk_cache;
#[cfg(test)]
mod tests_effects;

View File

@ -0,0 +1,307 @@
//! The 3D Camera Tracker end to end through the commands, on synthetic footage rendered in-test:
//! textured planes (a floor, a back wall and a box face) seen through a known moving camera.
use effectcraft_effects::camera_tracker as ct;
use effectcraft_geom::vec3;
use effectcraft_project::LayerId;
use effectcraft_raster::Image;
use effectcraft_render::EvalCtx;
use effectcraft_render::three_d::camera::active_camera;
use effectcraft_track::camtrack::linalg::{self, M3, V3};
use serde_json::{Value, json};
use crate::Session;
use crate::tests_track::{H, W, frame_time, setup, texture};
const F: f64 = 260.0;
/// The true camera on frame `f`: (world → camera rotation, centre).
fn true_camera(f: u32) -> (M3, V3) {
let t = f as f64 / 24.0;
let c = [-1.0 + 2.0 * t, -0.3 + 0.2 * (t * 3.0).sin(), 0.5 * t];
let fwd = linalg::normalize(linalg::sub([0.2 * t, 0.4, 8.0], c));
let right = linalg::normalize(linalg::cross([0.0, 1.0, 0.0], fwd));
([right, linalg::cross(fwd, right), fwd], c)
}
/// Planes: (point, normal, u axis, v axis, half extent or 0 = infinite).
const PLANES: [(V3, V3, V3, V3, f64); 3] = [
([0.0, 1.5, 0.0], [0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 0.0),
([0.0, 0.0, 10.0], [0.0, 0.0, -1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.0),
([-1.2, 0.2, 6.0], [0.3, 0.0, -1.0], [0.957_826, 0.0, 0.287_348], [0.0, 1.0, 0.0], 1.1),
];
fn scene_frame(f: u32) -> Image {
let (r, c) = true_camera(f);
let mut img = Image::new(W, H);
for y in 0..H {
for x in 0..W {
let d = linalg::mtv(&r, [(x as f64 + 0.5 - W as f64 / 2.0) / F, (y as f64 + 0.5 - H as f64 / 2.0) / F, 1.0]);
let mut best = (f64::INFINITY, 0.2f32);
for (i, (p0, n, ua, va, ext)) in PLANES.iter().enumerate() {
let den = linalg::dot(*n, d);
if den.abs() < 1e-9 {
continue;
}
let tt = linalg::dot(*n, linalg::sub(*p0, c)) / den;
if tt <= 0.1 || tt >= best.0 {
continue;
}
let hit = linalg::add(c, linalg::scale(d, tt));
let (u, v) = (linalg::dot(linalg::sub(hit, *p0), *ua), linalg::dot(linalg::sub(hit, *p0), *va));
if *ext > 0.0 && (u.abs() > *ext || v.abs() > *ext) {
continue;
}
best = (tt, texture(u * 40.0, v * 40.0, 11 + i as u32));
}
img.set(x, y, [best.1, best.1, best.1, 1.0]);
}
}
img
}
/// The comp with the clip tracked (blocking) with `params`.
fn tracked(params: Value) -> (Session, LayerId) {
let (mut s, clip, _) = setup(scene_frame);
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
assert!(s.is_enabled("track.camera"));
let mut p = params;
p["wait"] = json!(true);
let r = s.execute_checked("track.camera", p).unwrap();
assert_eq!(r["frames"], 25, "{r}");
assert!(s.camera_job.is_none());
(s, clip)
}
fn status(s: &mut Session) -> Value {
s.execute("camera.solveStatus", json!({})).unwrap()
}
#[test]
fn track_camera_solves_and_creates_layers_on_the_plane() {
let (mut s, clip) = tracked(json!({}));
let st = status(&mut s);
assert_eq!(st["solved"], true, "{st}");
let err = st["averageError"].as_f64().unwrap();
assert!(err < 0.5, "average error {err}");
let f = st["focalLength"].as_f64().unwrap();
assert!((f - F).abs() / F < 0.05, "focal {f}");
assert_ne!(st["methodUsed"], "Tripod Pan");
// Method Used / Average Error are reported in the effect.
let g = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(g.prop(ct::METHOD_USED).map(|p| &p.value), Some(effectcraft_keyframe::Value::Str(m)) if !m.is_empty()));
// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the
// ones seen below the horizon at the bottom of the frame).
s.execute("time.set", json!({"time": frame_time(12).seconds()})).unwrap();
let pts = s.execute("camera.points", json!({})).unwrap();
let list = pts["points"].as_array().unwrap();
assert!(list.len() > 30, "{}", list.len());
let floor: Vec<u64> = list.iter().filter(|p| p["comp"][1].as_f64().unwrap() > H as f64 * 0.8).map(|p| p["id"].as_u64().unwrap()).take(8).collect();
assert!(floor.len() >= 3, "{floor:?}");
s.execute("camera.selectPoints", json!({"points": floor})).unwrap();
assert_eq!(s.state.camera_points.len(), floor.len());
// Create Solid and Camera: one undo step, camera + solid.
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
let cam = LayerId(r["camera"].as_u64().unwrap());
let solid = LayerId(r["layers"][0].as_u64().unwrap());
let comp = s.active_comp().unwrap().clone();
assert!(comp.layer(cam).unwrap().is_camera());
assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera");
assert_eq!(comp.layers[0].id, cam);
let sl = comp.layer(solid).unwrap();
assert!(sl.switches.three_d);
assert!(sl.name.starts_with("Track Solid"));
// The camera is keyed on every frame.
assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25);
// Every solved point projects through the created camera exactly where the solve puts it,
// and onto its tracked position (the footage fills the comp, so layer pixels are comp
// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers:
// features on occlusion edges), 0.5 px on average.
let cid = s.active_comp_id().unwrap();
let uid = g.uid;
let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap();
let tracks = ct::tracks(&crate::camera_track::static_params(&g)).unwrap();
let mut errs = vec![];
for k in [0u32, 6, 12, 18, 24] {
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(k));
let camst = active_camera(&ctx);
for sp in pl.solve.visible(k as usize) {
let Some(obs) = tracks.track(sp.id).and_then(|t| t.at(k)) else { continue };
let w = pl.to_world(sp.pos);
let q = camst.project(W as f64, H as f64, vec3(w[0], w[1], w[2])).unwrap();
let own = pl.solve.frames[k as usize].project(pl.solve.size, sp.pos).unwrap();
assert!((q.x - own[0]).hypot(q.y - own[1]) < 0.01, "camera layer disagrees with the solve");
errs.push((q.x - obs[0]).hypot(q.y - obs[1]));
}
}
assert!(errs.len() > 100, "{}", errs.len());
errs.sort_by(f64::total_cmp);
let p95 = errs[errs.len() * 95 / 100];
let mean = errs.iter().sum::<f64>() / errs.len() as f64;
assert!(p95 < 1.0 && mean < 0.5, "p95 {p95} px, mean {mean} px");
// The solid lies on the selected points' plane.
let tr = sl.props.sub("transform").unwrap();
let pos = tr.get("position").unwrap().value.as_vec3();
let ori = tr.get("orientation").unwrap().value.as_vec3();
let normal = linalg::mv(&linalg::from_euler_xyz(ori), [0.0, 0.0, -1.0]);
let size = r["target"]["size"].as_f64().unwrap();
for id in &s.state.camera_points {
let w = pl.to_world(pl.solve.point(*id).unwrap().pos);
let d = linalg::dot(linalg::sub(w, pos), normal).abs();
assert!(d < 0.05 * size.max(1.0) + 1.0, "point {id} is {d} px off the plane");
}
// The solid faces the camera.
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(12));
let eye = active_camera(&ctx).eye;
assert!(linalg::dot(normal, linalg::sub([eye.x, eye.y, eye.z], pos)) > 0.0);
// Undo removes both layers; redo brings them back.
let before = comp.layers.len();
assert!(s.undo());
assert_eq!(s.active_comp().unwrap().layers.len(), before - 2);
assert!(s.redo());
assert_eq!(s.active_comp().unwrap().layers.len(), before);
// A second create reuses the camera: Create Multiple Nulls and Camera.
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null", "multiple": true})).unwrap();
assert_eq!(r["camera"].as_u64().unwrap(), cam.0);
assert_eq!(r["layers"].as_array().unwrap().len(), floor.len());
// Text and Shadow Catcher (+ light).
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "text"})).unwrap();
assert_eq!(r["layers"].as_array().unwrap().len(), 1);
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "shadowCatcher"})).unwrap();
let ids: Vec<LayerId> = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect();
let comp = s.active_comp().unwrap();
assert!(comp.layer(ids[0]).unwrap().name.starts_with("Shadow Catcher"));
assert_eq!(comp.layer(ids[0]).unwrap().props.prop("materialOptions/acceptsShadows").unwrap().value.as_enum(), 2);
assert!(comp.layer(ids[1]).unwrap().is_light());
assert!(s.execute_checked("camera.createFromSolve", json!({"kind": "teapot"})).is_err());
// Set Ground Plane and Origin: the target becomes the origin; a null created there sits at
// (0, 0, 0) lying on the X-Z plane (its front facing up, −y).
s.execute_checked("camera.setGroundPlane", json!({})).unwrap();
assert_eq!(status(&mut s)["groundPlane"], true);
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null"})).unwrap();
let null = LayerId(r["layers"][0].as_u64().unwrap());
let tr = s.active_comp().unwrap().layer(null).unwrap().props.sub("transform").unwrap().clone();
let pos = tr.get("position").unwrap().value.as_vec3();
assert!(linalg::norm(pos) < 1e-6, "{pos:?}");
let up = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]);
assert!(linalg::norm(linalg::sub(up, [0.0, -1.0, 0.0])) < 1e-6, "{up:?}");
// An explicit target (comp world) places the layer there, its front facing the normal.
let r = s
.execute_checked("camera.createFromSolve", json!({"kind": "solid", "target": {"center": [10.0, 0.0, 20.0], "normal": [0.0, 0.0, -1.0], "size": 80.0}}))
.unwrap();
let l = s.active_comp().unwrap().layer(LayerId(r["layers"][0].as_u64().unwrap())).unwrap().clone();
let tr = l.props.sub("transform").unwrap();
let pos = tr.get("position").unwrap().value.as_vec3();
assert!(linalg::norm(linalg::sub(pos, [10.0, 0.0, 20.0])) < 1e-6, "{pos:?}");
let front = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]);
assert!(linalg::norm(linalg::sub(front, [0.0, 0.0, -1.0])) < 1e-6, "{front:?}");
// Serde keeps the analysis.
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
let g2 = back.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap();
let sv = ct::solve(&crate::camera_track::static_params(g2)).unwrap();
assert!(sv.ground.is_some());
assert_eq!(sv.points.len(), pl.solve.points.len());
}
#[test]
fn invalidation_resolve_cancel_and_undo() {
let (mut s, clip) = tracked(json!({"shotType": "specify", "aov": 2.0 * (W as f64 / 2.0 / F).atan().to_degrees()}));
let st = status(&mut s);
assert_eq!(st["solved"], true, "{st}");
assert_eq!(st["shotType"], "Specify Angle of View");
assert!((st["focalLength"].as_f64().unwrap() - F).abs() < 0.01 * F);
let cid = s.active_comp_id().unwrap();
let uid = st["effect"].as_u64().unwrap();
// Undo removes the analysis (one step); redo restores it.
assert!(s.undo());
assert_eq!(status(&mut s)["analyzed"], false);
assert!(s.redo());
assert_eq!(status(&mut s)["solved"], true);
// Changing the Solve Method keeps the tracks and only re-solves.
s.execute_checked("prop.set", json!({"layer": clip.0, "path": "effects/#1/advanced/solveMethod", "value": 1})).unwrap();
let st = status(&mut s);
assert_eq!(st["analyzed"], true);
assert_eq!(st["solved"], false);
assert_eq!(st["pending"], true);
s.execute_checked("camera.analyze", json!({"wait": true})).unwrap();
let st = status(&mut s);
assert_eq!(st["solved"], true, "{st}");
assert_eq!(st["methodUsed"], "Typical");
// Deleting points re-solves without them.
let pts = s.execute("camera.points", json!({})).unwrap();
let del: Vec<u64> = pts["points"].as_array().unwrap().iter().take(3).map(|p| p["id"].as_u64().unwrap()).collect();
s.execute_checked("camera.deletePoints", json!({"points": del, "wait": true})).unwrap();
s.poll_camera(false);
let st = status(&mut s);
assert_eq!(st["solved"], true, "{st}");
assert!(st["deleted"].as_u64().unwrap() >= 3);
let g = s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap().clone();
let sv = ct::solve(&crate::camera_track::static_params(&g)).unwrap();
assert!(sv.points.iter().all(|p| !del.contains(&(p.id as u64))));
// Trimming the layer changes its frames: tracks and solve are cleared and queued.
s.edit("trim", None, |p, _| {
p.comp_mut(cid).unwrap().layer_mut(clip).unwrap().in_point = frame_time(2);
Ok(())
})
.unwrap();
let st = status(&mut s);
assert_eq!(st["analyzed"], false);
assert_eq!(st["pending"], true);
// A background analysis can be cancelled: nothing is written.
s.execute_checked("camera.analyze", json!({})).unwrap();
assert!(s.is_camera_analyzing() || s.camera_job.is_some());
let banner = status(&mut s)["banner"].as_str().map(str::to_string);
if s.is_camera_analyzing() {
assert!(banner.unwrap().starts_with("Analyzing in background (step 1 of 2)"));
}
s.execute("camera.cancel", json!({})).ok();
let t0 = std::time::Instant::now();
while s.camera_job.is_some() {
assert!(t0.elapsed().as_secs() < 120);
std::thread::sleep(std::time::Duration::from_millis(5));
s.poll_camera(false);
}
// (When the job finished before the cancel landed it wrote a valid analysis.)
let st = status(&mut s);
assert_eq!(st["running"], false);
assert!(s.execute_checked("track.camera", json!({"layer": clip.0, "shotType": "bogus"})).is_err());
assert!(s.execute_checked("track.camera", json!({"layer": clip.0, "aov": 500})).is_err());
}
#[test]
fn effect_parameters_match_after_effects() {
let spec = effectcraft_effects::registry().iter().find(|e| e.id == ct::ID).unwrap();
assert_eq!(spec.name, "3D Camera Tracker");
assert_eq!(spec.category, "Perspective");
let names: Vec<&str> = spec.params.iter().filter(|p| !matches!(p.ui, effectcraft_project::ParamUi::Hidden)).map(|p| p.name).collect();
for n in [
"Shot Type",
"Horizontal Angle of View",
"Show Track Points",
"Render Track Points",
"Track Point Size",
"Target Size",
"Solve Method",
"Detailed Analysis",
"Auto-delete Points Across Time",
"Hide Warning Banner",
] {
assert!(names.contains(&n), "{n}");
}
// The Animation menu entry is a real command now.
let c = crate::find_command("track.camera").unwrap();
assert_eq!(c.label, "Track Camera");
}

View File

@ -332,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id)));
}
// What remains a stub is still disabled.
for id in ["view.displayColorManagement", "file.createProxy", "track.camera"] {
for id in ["view.displayColorManagement", "file.createProxy"] {
assert!(!s.is_enabled(id), "{id}");
}
}

View File

@ -1,6 +1,6 @@
[package]
name = "effectcraft-track"
description = "Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation + Lucas-Kanade refinement), confidence, corner-pin / transform solves, stabilization and Roto Brush segmentation (graph cut, matting)"
description = "Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation + Lucas-Kanade refinement), confidence, corner-pin / transform solves, stabilization, Roto Brush segmentation (graph cut, matting) and 3D camera solving (structure from motion)"
version.workspace = true
edition.workspace = true
license.workspace = true
@ -14,3 +14,4 @@ effectcraft-raster = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
web-time = { workspace = true }

View File

@ -0,0 +1,470 @@
//! Sparse bundle adjustment: Levenberg–Marquardt over cameras and points with the **Schur
//! complement** eliminating the points (Triggs, McLauchlan, Hartley & Fitzgibbon, "Bundle
//! Adjustment — A Modern Synthesis", 2000, §6; H&Z Appendix 6). Residuals are reprojection
//! errors in pixels with a **Huber** robust loss (applied by iteratively reweighted least
//! squares, Triggs et al. §3.3).
//!
//! Cameras are `(R, C, f)`; rotations are updated multiplicatively (`R ← exp([δ]×) R`), focal
//! lengths in log space. The focal length is held fixed, shared by all cameras, or per camera
//! (with a smoothness prior between consecutive cameras). Tripod solves hold the centres fixed.
use rayon::prelude::*;
use super::geometry::Pose;
use super::linalg::*;
/// A camera in the adjustment.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BaCam {
pub pose: Pose,
/// Focal length (pixels).
pub f: f64,
}
/// One observation: camera `cam` sees point `pt` at `uv` (pixels relative to the principal
/// point).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BaObs {
pub cam: u32,
pub pt: u32,
pub uv: [f64; 2],
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FocalMode {
Fixed,
Shared,
/// Per camera, with prior weight `k` on the log-focal difference of consecutive cameras.
PerCamera(f64),
}
#[derive(Clone, Debug)]
pub struct BaOpts {
pub focal: FocalMode,
/// Motion only: the points stay fixed.
pub fix_points: bool,
/// The camera centres stay fixed (tripod).
pub fix_centers: bool,
/// Cameras that stay fixed (gauge), by index; missing entries are free.
pub fixed: Vec<bool>,
/// Huber threshold (pixels).
pub huber: f64,
pub max_iter: usize,
}
impl Default for BaOpts {
fn default() -> Self {
BaOpts { focal: FocalMode::Fixed, fix_points: false, fix_centers: false, fixed: vec![], huber: 2.0, max_iter: 30 }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct BaReport {
pub initial_cost: f64,
pub cost: f64,
pub iterations: usize,
}
const BEHIND: f64 = 1e6;
fn huber(e2: f64, k: f64) -> f64 {
if e2 <= k * k { e2 } else { 2.0 * k * e2.sqrt() - k * k }
}
/// Residual (pixels) of one observation, `None` behind the camera.
pub fn residual(c: &BaCam, x: V3, uv: [f64; 2]) -> Option<[f64; 2]> {
let p = c.pose.to_cam(x);
if p[2] <= 1e-9 {
return None;
}
Some([c.f * p[0] / p[2] - uv[0], c.f * p[1] / p[2] - uv[1]])
}
/// Total robust cost.
pub fn cost(cams: &[BaCam], pts: &[V3], obs: &[BaObs], o: &BaOpts) -> f64 {
let c: f64 = obs
.par_iter()
.map(|ob| match residual(&cams[ob.cam as usize], pts[ob.pt as usize], ob.uv) {
Some(r) => huber(r[0] * r[0] + r[1] * r[1], o.huber),
None => BEHIND,
})
.sum();
c + prior_cost(cams, o)
}
fn prior_cost(cams: &[BaCam], o: &BaOpts) -> f64 {
match o.focal {
FocalMode::PerCamera(k) => cams.windows(2).map(|w| (k * (w[0].f.ln() - w[1].f.ln())).powi(2)).sum(),
_ => 0.0,
}
}
/// Parameter layout.
struct Layout {
/// First pose parameter of each camera (`usize::MAX` when its pose is fixed).
start: Vec<usize>,
/// Per-camera focal parameter (`usize::MAX` when none). Gauge-fixed cameras keep a free
/// focal length.
fidx: Vec<usize>,
/// Index of the shared focal parameter.
shared: Option<usize>,
n: usize,
rot_only: bool,
}
fn layout(ncam: usize, o: &BaOpts) -> Layout {
let per_cam_f = matches!(o.focal, FocalMode::PerCamera(_));
let d = if o.fix_centers { 3 } else { 6 };
let mut start = vec![usize::MAX; ncam];
let mut fidx = vec![usize::MAX; ncam];
let mut n = 0;
for i in 0..ncam {
if !o.fixed.get(i).copied().unwrap_or(false) {
start[i] = n;
n += d;
}
if per_cam_f {
fidx[i] = n;
n += 1;
}
}
let shared = matches!(o.focal, FocalMode::Shared).then(|| {
n += 1;
n - 1
});
Layout { start, fidx, shared, n, rot_only: o.fix_centers }
}
/// Camera-side Jacobian entries of one observation: (parameter index, d r / d param).
fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; 2], out: &mut Vec<(usize, [f64; 2])>) {
out.clear();
let s = l.start[ci];
if s != usize::MAX {
// dXc/dδ = −[Xc]×
let sk = skew(xc);
for k in 0..3 {
let g = [-sk[0][k], -sk[1][k], -sk[2][k]];
out.push((s + k, [dot(a[0], g), dot(a[1], g)]));
}
if !l.rot_only {
// dXc/dC = −R
for k in 0..3 {
let g = [-c.pose.r[0][k], -c.pose.r[1][k], -c.pose.r[2][k]];
out.push((s + 3 + k, [dot(a[0], g), dot(a[1], g)]));
}
}
}
if l.fidx[ci] != usize::MAX {
out.push((l.fidx[ci], p));
}
if let Some(g) = l.shared {
out.push((g, p));
}
}
/// Run Levenberg–Marquardt. `cams` and `pts` are updated in place.
pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> BaReport {
let l = layout(cams.len(), o);
let npts = pts.len();
// Observations by point.
let mut by_pt: Vec<Vec<u32>> = vec![vec![]; npts];
for (i, ob) in obs.iter().enumerate() {
by_pt[ob.pt as usize].push(i as u32);
}
let mut cur = cost(cams, pts, obs, o);
let initial = cur;
let t0 = web_time::Instant::now();
let mut tries = 0usize;
let dbg = std::env::var_os("EC_CAMTRACK_DEBUG").is_some() && l.n > 200;
let mut lambda = 1e-4;
let mut iters = 0;
if l.n == 0 && o.fix_points {
return BaReport { initial_cost: initial, cost: cur, iterations: 0 };
}
let n = l.n;
let parallel = n <= 700;
for _ in 0..o.max_iter {
iters += 1;
// ---- Normal equations (Gauss–Newton with IRLS weights).
// Per point: V (3×3), bp (3), W entries (cam-side index → 3).
struct PtSys {
v: [[f64; 3]; 3],
bp: V3,
w: Vec<(usize, V3)>,
}
let build_point = |j: usize, s_acc: &mut Vec<f64>, bc: &mut Vec<f64>| -> PtSys {
let mut ps = PtSys { v: [[0.0; 3]; 3], bp: [0.0; 3], w: vec![] };
let mut jc: Vec<(usize, [f64; 2])> = Vec::with_capacity(8);
for &oi in &by_pt[j] {
let ob = &obs[oi as usize];
let ci = ob.cam as usize;
let c = &cams[ci];
let xc = c.pose.to_cam(pts[j]);
if xc[2] <= 1e-9 {
continue;
}
let iz = 1.0 / xc[2];
let p = [c.f * xc[0] * iz, c.f * xc[1] * iz];
let r = [p[0] - ob.uv[0], p[1] - ob.uv[1]];
let e = (r[0] * r[0] + r[1] * r[1]).sqrt();
let w = if e <= o.huber { 1.0 } else { o.huber / e };
let a = [[c.f * iz, 0.0, -c.f * xc[0] * iz * iz], [0.0, c.f * iz, -c.f * xc[1] * iz * iz]];
cam_jac(&l, ci, c, xc, &a, p, &mut jc);
// Point Jacobian: A R.
let jx = if o.fix_points {
[[0.0; 3]; 2]
} else {
let r0 = mtv(&c.pose.r, a[0]);
let r1 = mtv(&c.pose.r, a[1]);
[r0, r1]
};
for (ia, ja) in &jc {
bc[*ia] -= w * (ja[0] * r[0] + ja[1] * r[1]);
for (ib, jb) in &jc {
s_acc[ia * n + ib] += w * (ja[0] * jb[0] + ja[1] * jb[1]);
}
}
if !o.fix_points {
for a1 in 0..3 {
ps.bp[a1] -= w * (jx[0][a1] * r[0] + jx[1][a1] * r[1]);
for b1 in 0..3 {
ps.v[a1][b1] += w * (jx[0][a1] * jx[0][b1] + jx[1][a1] * jx[1][b1]);
}
}
for (ia, ja) in &jc {
let wv =
[w * (ja[0] * jx[0][0] + ja[1] * jx[1][0]), w * (ja[0] * jx[0][1] + ja[1] * jx[1][1]), w * (ja[0] * jx[0][2] + ja[1] * jx[1][2])];
// Each observation of a point is on another camera: only the shared focal
// length repeats.
let found = if Some(*ia) == l.shared { ps.w.iter_mut().find(|e| e.0 == *ia) } else { None };
match found {
Some(e) => e.1 = add(e.1, wv),
None => ps.w.push((*ia, wv)),
}
}
}
}
ps
};
let (mut s_mat, mut bc, systems): (Vec<f64>, Vec<f64>, Vec<PtSys>) = if parallel && npts > 64 {
let chunks = rayon::current_num_threads().clamp(1, 16);
let per = npts.div_ceil(chunks);
let parts: Vec<(Vec<f64>, Vec<f64>, Vec<PtSys>)> = (0..chunks)
.into_par_iter()
.map(|t| {
let (mut s, mut b) = (vec![0.0; n * n], vec![0.0; n]);
let v = (t * per..((t + 1) * per).min(npts)).map(|j| build_point(j, &mut s, &mut b)).collect();
(s, b, v)
})
.collect();
let mut s = vec![0.0; n * n];
let mut b = vec![0.0; n];
let mut sys = Vec::with_capacity(npts);
for (ps, pb, pv) in parts {
for (x, y) in s.iter_mut().zip(ps) {
*x += y;
}
for (x, y) in b.iter_mut().zip(pb) {
*x += y;
}
sys.extend(pv);
}
(s, b, sys)
} else {
let mut s = vec![0.0; n * n];
let mut b = vec![0.0; n];
let sys = (0..npts).map(|j| build_point(j, &mut s, &mut b)).collect();
(s, b, sys)
};
// Focal prior.
if let FocalMode::PerCamera(k) = o.focal {
for i in 0..cams.len().saturating_sub(1) {
let r = k * (cams[i].f.ln() - cams[i + 1].f.ln());
let (a, b) = (l.fidx[i], l.fidx[i + 1]);
let ia = (a != usize::MAX).then_some(a);
let ib = (b != usize::MAX).then_some(b);
for (idx, g) in [(ia, k), (ib, -k)] {
let Some(x) = idx else { continue };
bc[x] -= g * r;
for (idy, h) in [(ia, k), (ib, -k)] {
if let Some(y) = idy {
s_mat[x * n + y] += g * h;
}
}
}
}
}
let base_s = s_mat.clone();
let base_bc = bc.clone();
let mean_diag = ((0..n).map(|i| base_s[i * n + i]).sum::<f64>() / n.max(1) as f64).max(1e-12);
// ---- Try steps with increasing damping.
let mut accepted = false;
for _try in 0..12 {
tries += 1;
let mut s = base_s.clone();
let mut rhs = base_bc.clone();
for i in 0..n {
let d = s[i * n + i];
s[i * n + i] = d + lambda * d.max(1e-6 * mean_diag);
}
// Schur complement over the points: S −= W V⁻¹ Wᵀ, rhs −= W V⁻¹ bp (in parallel
// chunks of points when the reduced system is small enough to copy per thread).
let vinv: Vec<Option<M3>> = if o.fix_points {
vec![None; npts]
} else {
systems
.par_iter()
.map(|ps| {
let mut v = ps.v;
let vd = (v[0][0] + v[1][1] + v[2][2]) / 3.0;
for k in 0..3 {
v[k][k] += lambda * v[k][k].max(1e-6 * vd.max(1e-12)) + 1e-12;
}
inv3(&v)
})
.collect()
};
if !o.fix_points {
let reduce = |range: std::ops::Range<usize>, s: &mut [f64], rhs: &mut [f64]| {
for j in range {
let (Some(vi), ps) = (&vinv[j], &systems[j]) else { continue };
let vb = mv(vi, ps.bp);
let wv: Vec<V3> = ps.w.iter().map(|(_, w)| mv(vi, *w)).collect();
for (a, (ia, wa)) in ps.w.iter().enumerate() {
rhs[*ia] -= dot(*wa, vb);
for (ib, wb) in &ps.w[a..] {
let d = dot(wv[a], *wb);
s[ia * n + ib] -= d;
if ia != ib {
s[ib * n + ia] -= d;
}
}
}
}
};
let chunks = rayon::current_num_threads().clamp(1, 8);
if n <= 1200 && npts >= 64 && chunks > 1 {
let per = npts.div_ceil(chunks);
let parts: Vec<(Vec<f64>, Vec<f64>)> = (0..chunks)
.into_par_iter()
.map(|t| {
let (mut ds, mut dr) = (vec![0.0; n * n], vec![0.0; n]);
reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr);
(ds, dr)
})
.collect();
for (ds, dr) in parts {
s.par_iter_mut().zip(ds.par_iter()).for_each(|(x, y)| *x += y);
for (x, y) in rhs.iter_mut().zip(dr) {
*x += y;
}
}
} else {
reduce(0..npts, &mut s, &mut rhs);
}
}
let mut dc = rhs;
if n > 0 && !cholesky_solve(&mut s, n, &mut dc) {
lambda *= 10.0;
continue;
}
// Apply.
let mut nc: Vec<BaCam> = cams.to_vec();
for (i, c) in nc.iter_mut().enumerate() {
let st = l.start[i];
if st != usize::MAX {
let dr = [dc[st], dc[st + 1], dc[st + 2]];
c.pose.r = mm(&rodrigues(dr), &c.pose.r);
if !l.rot_only {
c.pose.c = add(c.pose.c, [dc[st + 3], dc[st + 4], dc[st + 5]]);
}
}
if l.fidx[i] != usize::MAX {
c.f *= dc[l.fidx[i]].clamp(-0.5, 0.5).exp();
}
if let Some(g) = l.shared {
c.f *= dc[g].clamp(-0.5, 0.5).exp();
}
}
let mut np: Vec<V3> = pts.to_vec();
if !o.fix_points {
np.par_iter_mut().enumerate().for_each(|(j, x)| {
let Some(vi) = &vinv[j] else { return };
let ps = &systems[j];
let mut b = ps.bp;
for (ia, w) in &ps.w {
b = sub(b, scale(*w, dc[*ia]));
}
*x = add(*x, mv(vi, b));
});
}
let c2 = cost(&nc, &np, obs, o);
if c2.is_finite() && c2 < cur {
cams.copy_from_slice(&nc);
pts.copy_from_slice(&np);
let gain = cur - c2;
cur = c2;
lambda = (lambda / 3.0).max(1e-9);
accepted = gain > 1e-6 * cur.max(1e-12);
if !accepted {
// Converged (tiny improvement).
if dbg {
eprintln!(
"ba n={} pts={} obs={} iters={iters} tries={tries} {:.2}s cost {initial:.1} -> {cur:.1}",
l.n,
npts,
obs.len(),
t0.elapsed().as_secs_f64()
);
}
return BaReport { initial_cost: initial, cost: cur, iterations: iters };
}
break;
}
lambda *= 4.0;
}
if !accepted {
break;
}
}
if dbg {
eprintln!("ba n={} pts={} obs={} iters={iters} tries={tries} {:.2}s cost {initial:.1} -> {cur:.1}", l.n, npts, obs.len(), t0.elapsed().as_secs_f64());
}
BaReport { initial_cost: initial, cost: cur, iterations: iters }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::camtrack::geometry::Rng;
#[test]
fn bundle_adjustment_converges_from_noise() {
let mut rng = Rng(99);
let mut u = || (rng.next_u64() % 100_000) as f64 / 100_000.0 - 0.5;
let truth: Vec<V3> = (0..80).map(|_| [u() * 6.0, u() * 4.0, 6.0 + u() * 4.0]).collect();
let f = 900.0;
let cams_true: Vec<BaCam> = (0..6)
.map(|i| BaCam { pose: Pose { r: rodrigues([0.0, -0.05 * i as f64, 0.01 * i as f64]), c: [0.3 * i as f64, 0.05 * i as f64, 0.0] }, f })
.collect();
let mut obs = vec![];
for (ci, c) in cams_true.iter().enumerate() {
for (pi, x) in truth.iter().enumerate() {
let r = residual(c, *x, [0.0, 0.0]).unwrap();
obs.push(BaObs { cam: ci as u32, pt: pi as u32, uv: r });
}
}
let mut cams = cams_true.clone();
for (i, c) in cams.iter_mut().enumerate().skip(2) {
c.pose.r = mm(&rodrigues([0.01, -0.01 * i as f64 * 0.3, 0.005]), &c.pose.r);
c.pose.c = add(c.pose.c, [0.02, -0.03, 0.01]);
}
let mut pts: Vec<V3> = truth.iter().map(|x| add(*x, [u() * 0.1, u() * 0.1, u() * 0.2])).collect();
let mut fixed = vec![false; 6];
fixed[0] = true;
fixed[1] = true;
let rep = adjust(&mut cams, &mut pts, &obs, &BaOpts { fixed, max_iter: 50, ..Default::default() });
let rms = (rep.cost / obs.len() as f64).sqrt();
assert!(rms < 1e-4, "{rep:?}");
}
}

View File

@ -0,0 +1,115 @@
//! Step 1 of the camera analysis: long feature tracks through the clip.
//!
//! Every frame, the active features are followed from the previous frame with pyramidal
//! Lucas–Kanade and a forward–backward check ([`crate::klt::track`]); a feature that fails ends
//! its track. When fewer than ~85 % of the wanted features remain, new Shi–Tomasi corners are
//! detected away from the active ones and start new tracks. Tracks shorter than three frames are
//! dropped at the end.
use crate::Frame;
use crate::klt::{CornerOpts, GrayPyramid, LkOpts, analysis_factor, good_features, track};
use super::{CameraTracks, Track2D};
/// Analysis options.
#[derive(Clone, Copy, Debug, Default)]
pub struct AnalyzeOpts {
/// Detailed Analysis: twice the resolution and more features.
pub detailed: bool,
}
/// Incremental feature tracking: push the clip's frames in order.
pub struct TrackAnalyzer {
opts: AnalyzeOpts,
prev: Option<GrayPyramid>,
/// (track index, level-0 plane position) of the live features.
active: Vec<(usize, [f64; 2])>,
tracks: Vec<Track2D>,
frame: u32,
size: [f64; 2],
factor: f64,
}
impl TrackAnalyzer {
pub fn new(size: [f64; 2], opts: AnalyzeOpts) -> TrackAnalyzer {
TrackAnalyzer { opts, prev: None, active: vec![], tracks: vec![], frame: 0, size, factor: 1.0 }
}
pub fn frames(&self) -> u32 {
self.frame
}
/// Features currently followed.
pub fn active(&self) -> usize {
self.active.len()
}
fn params(&self) -> (u32, usize) {
if self.opts.detailed { (1280, 900) } else { (720, 500) }
}
/// Analyse the next frame.
pub fn push(&mut self, frame: &Frame) {
let (max_side, want) = self.params();
let factor = analysis_factor(frame.img.width, frame.img.height, max_side);
self.factor = factor as f64;
let pyr = GrayPyramid::from_image(frame.img, frame.offset, factor, 4);
let k = self.frame;
match &self.prev {
Some(prev) if prev.width() == pyr.width() && prev.height() == pyr.height() && prev.offset == pyr.offset => {
let pts: Vec<[f64; 2]> = self.active.iter().map(|a| a.1).collect();
let q = track(prev, &pyr, &pts, None, &LkOpts { radius: 6, iterations: 30, epsilon: 0.005, fb_max: 0.5, ..Default::default() });
let mut next = Vec::with_capacity(self.active.len());
for ((ti, _), q) in self.active.iter().zip(q) {
if let Some(q) = q {
let l = pyr.to_layer(q);
self.tracks[*ti].pts.push([l[0] as f32, l[1] as f32]);
next.push((*ti, q));
}
}
self.active = next;
}
_ => self.active.clear(),
}
// Re-detect where there are no features.
if (self.active.len() as f64) < want as f64 * 0.85 {
let md = (pyr.width().max(pyr.height()) as f64 / (want as f64).sqrt() * 0.6).clamp(5.0, 40.0);
let cell = md;
let (gw, gh) = ((pyr.width() as f64 / cell).ceil() as usize + 1, (pyr.height() as f64 / cell).ceil() as usize + 1);
let mut occ = vec![false; gw * gh];
for (_, p) in &self.active {
let (cx, cy) = ((p[0] / cell) as usize, (p[1] / cell) as usize);
for yy in cy.saturating_sub(1)..=(cy + 1).min(gh - 1) {
for xx in cx.saturating_sub(1)..=(cx + 1).min(gw - 1) {
occ[yy * gw + xx] = true;
}
}
}
let free = |p: [f64; 2]| {
let (cx, cy) = ((p[0] / cell) as usize, (p[1] / cell) as usize);
!occ.get(cy * gw + cx).copied().unwrap_or(true)
};
let opts = CornerOpts { max_features: want - self.active.len().min(want), min_distance: md, quality: 0.001, window: 2, border: 8 };
for p in good_features(&pyr, &opts, Some(&free)) {
let l = pyr.to_layer(p);
let id = self.tracks.len() as u32;
self.tracks.push(Track2D { id, start: k, pts: vec![[l[0] as f32, l[1] as f32]] });
self.active.push((id as usize, p));
}
}
self.prev = Some(pyr);
self.frame += 1;
}
pub fn finish(self, start: f64, frame_duration: f64) -> CameraTracks {
let mut tracks: Vec<Track2D> = self.tracks.into_iter().filter(|t| t.pts.len() >= 3).collect();
// Round to 1/100 pixel (smaller JSON) and renumber densely.
for (i, t) in tracks.iter_mut().enumerate() {
t.id = i as u32;
for p in t.pts.iter_mut() {
*p = [(p[0] * 100.0).round() / 100.0, (p[1] * 100.0).round() / 100.0];
}
}
CameraTracks { version: 1, size: self.size, start, frame_duration, frames: self.frame, factor: self.factor, detailed: self.opts.detailed, tracks }
}
}

View File

@ -0,0 +1,432 @@
//! Two-view and multi-view geometry for the camera solver, from Hartley & Zisserman (H&Z),
//! *Multiple View Geometry in Computer Vision* (2nd ed.) and Ma et al., *An Invitation to 3-D
//! Vision* (2004).
//!
//! All image points here are **normalised** (calibrated) coordinates `x = (u − c) / f`.
//! A camera is `(R, C)` with `X_cam = R (X − C)`.
use super::linalg::*;
/// A deterministic xorshift generator for RANSAC samples.
pub struct Rng(pub u64);
impl Rng {
pub fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
pub fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n.max(1) as u64) as usize
}
/// `k` distinct indices below `n`.
pub fn sample(&mut self, n: usize, k: usize, out: &mut Vec<usize>) {
out.clear();
let mut guard = 0;
while out.len() < k && guard < 1000 {
guard += 1;
let c = self.below(n);
if !out.contains(&c) {
out.push(c);
}
}
}
}
/// Hartley normalisation (H&Z §4.4.4): centroid to the origin, mean distance √2.
fn normalizer(p: &[[f64; 2]]) -> M3 {
let n = p.len().max(1) as f64;
let c = [p.iter().map(|q| q[0]).sum::<f64>() / n, p.iter().map(|q| q[1]).sum::<f64>() / n];
let d = p.iter().map(|q| (q[0] - c[0]).hypot(q[1] - c[1])).sum::<f64>() / n;
let s = if d > 1e-12 { std::f64::consts::SQRT_2 / d } else { 1.0 };
[[s, 0.0, -s * c[0]], [0.0, s, -s * c[1]], [0.0, 0.0, 1.0]]
}
fn h2(m: &M3, p: [f64; 2]) -> V3 {
mv(m, [p[0], p[1], 1.0])
}
/// The essential matrix from ≥ 8 correspondences (`x2ᵀ E x1 = 0`) by the normalised 8-point
/// algorithm (H&Z Alg. 11.1), with the essential constraint (two equal singular values, one
/// zero) enforced (H&Z §9.6.1 / Result 9.18).
pub fn essential_8pt(x1: &[[f64; 2]], x2: &[[f64; 2]]) -> Option<M3> {
let n = x1.len().min(x2.len());
if n < 8 {
return None;
}
let (t1, t2) = (normalizer(&x1[..n]), normalizer(&x2[..n]));
let mut a = Vec::with_capacity(n * 9);
for i in 0..n {
let p = h2(&t1, x1[i]);
let q = h2(&t2, x2[i]);
a.extend_from_slice(&[q[0] * p[0], q[0] * p[1], q[0], q[1] * p[0], q[1] * p[1], q[1], p[0], p[1], 1.0]);
}
let e = null_vector(&a, n, 9);
let en = [[e[0], e[1], e[2]], [e[3], e[4], e[5]], [e[6], e[7], e[8]]];
// Denormalise: E = T2ᵀ Ê T1.
let e = mm(&mt(&t2), &mm(&en, &t1));
let (u, s, v) = svd3(&e);
let m = (s[0] + s[1]) * 0.5;
if m <= 0.0 || !m.is_finite() {
return None;
}
let d = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]];
Some(mm(&u, &mm(&d, &mt(&v))))
}
/// Squared Sampson distance of a correspondence to `x2ᵀ E x1 = 0` (H&Z §11.4.3), in the units of
/// the coordinates.
pub fn sampson2(e: &M3, p: [f64; 2], q: [f64; 2]) -> f64 {
let x1 = [p[0], p[1], 1.0];
let x2 = [q[0], q[1], 1.0];
let ex1 = mv(e, x1);
let etx2 = mtv(e, x2);
let num = dot(x2, ex1);
let den = ex1[0] * ex1[0] + ex1[1] * ex1[1] + etx2[0] * etx2[0] + etx2[1] * etx2[1];
if den < 1e-300 { f64::INFINITY } else { num * num / den }
}
/// RANSAC essential matrix (8-point samples, Sampson distance below `thr`), refitted on the
/// consensus set. Returns the matrix and the inlier mask.
pub fn ransac_essential(x1: &[[f64; 2]], x2: &[[f64; 2]], thr: f64, iterations: usize, seed: u64) -> Option<(M3, Vec<bool>)> {
let n = x1.len().min(x2.len());
if n < 8 {
return None;
}
let t2 = thr * thr;
let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
let mut best: Option<(usize, f64, M3)> = None;
let mut idx = vec![];
let mut max_iter = iterations;
let mut it = 0;
let (mut s1, mut s2) = (vec![[0.0; 2]; 8], vec![[0.0; 2]; 8]);
while it < max_iter {
it += 1;
rng.sample(n, 8, &mut idx);
if idx.len() < 8 {
break;
}
for (k, &i) in idx.iter().enumerate() {
s1[k] = x1[i];
s2[k] = x2[i];
}
let Some(e) = essential_8pt(&s1, &s2) else { continue };
let (mut c, mut err) = (0, 0.0);
for i in 0..n {
let d = sampson2(&e, x1[i], x2[i]);
if d < t2 {
c += 1;
err += d;
}
}
if best.as_ref().is_none_or(|b| c > b.0 || (c == b.0 && err < b.1)) {
best = Some((c, err, e));
// Adaptive iteration count for 99 % confidence.
let w = c as f64 / n as f64;
let p_good = w.powi(8);
if p_good > 1e-9 {
let need = ((1.0 - 0.99f64).ln() / (1.0 - p_good).max(1e-12).ln()).ceil();
if need.is_finite() {
max_iter = max_iter.min((need as usize).max(30));
}
}
}
}
let (_, _, mut e) = best?;
// Refit on the inliers (twice: the consensus may grow).
let mut inl = vec![false; n];
for _ in 0..2 {
for i in 0..n {
inl[i] = sampson2(&e, x1[i], x2[i]) < t2;
}
let (a, b): (Vec<[f64; 2]>, Vec<[f64; 2]>) = (0..n).filter(|i| inl[*i]).map(|i| (x1[i], x2[i])).unzip();
match essential_8pt(&a, &b) {
Some(e2) => e = e2,
None => break,
}
}
for i in 0..n {
inl[i] = sampson2(&e, x1[i], x2[i]) < t2;
}
Some((e, inl))
}
/// The four `(R, t)` with `x2 ~ R x1 + t` (|t| = 1) of an essential matrix (H&Z Result 9.19).
pub fn decompose_essential(e: &M3) -> [(M3, V3); 4] {
let (mut u, _, mut v) = svd3(e);
if det3(&u) < 0.0 {
for row in u.iter_mut() {
row[2] = -row[2];
}
}
if det3(&v) < 0.0 {
for row in v.iter_mut() {
row[2] = -row[2];
}
}
let w = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
let r1 = mm(&u, &mm(&w, &mt(&v)));
let r2 = mm(&u, &mm(&mt(&w), &mt(&v)));
let t = col(&u, 2);
[(r1, t), (r1, scale(t, -1.0)), (r2, t), (r2, scale(t, -1.0))]
}
/// A camera `(R, C)`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pose {
pub r: M3,
pub c: V3,
}
impl Pose {
pub const IDENTITY: Pose = Pose { r: I3, c: [0.0; 3] };
/// From `x_cam = R X + t`.
pub fn from_rt(r: M3, t: V3) -> Pose {
Pose { r, c: scale(mtv(&r, t), -1.0) }
}
pub fn t(&self) -> V3 {
scale(mv(&self.r, self.c), -1.0)
}
pub fn to_cam(&self, x: V3) -> V3 {
mv(&self.r, sub(x, self.c))
}
}
/// Linear (DLT) triangulation from normalised observations (H&Z §12.2), minimising the
/// algebraic error over all views.
pub fn triangulate(views: &[(Pose, [f64; 2])]) -> Option<V3> {
if views.len() < 2 {
return None;
}
let mut ata = [0.0f64; 16];
for (p, x) in views {
let t = p.t();
let rows = [[p.r[0][0], p.r[0][1], p.r[0][2], t[0]], [p.r[1][0], p.r[1][1], p.r[1][2], t[1]], [p.r[2][0], p.r[2][1], p.r[2][2], t[2]]];
let a0: Vec<f64> = (0..4).map(|j| x[0] * rows[2][j] - rows[0][j]).collect();
let a1: Vec<f64> = (0..4).map(|j| x[1] * rows[2][j] - rows[1][j]).collect();
for a in [a0, a1] {
let nrm = a.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-300);
for i in 0..4 {
for j in 0..4 {
ata[i * 4 + j] += a[i] * a[j] / (nrm * nrm);
}
}
}
}
let (_, vecs) = sym_eigen(&ata, 4);
let h = [vecs[0], vecs[4], vecs[8], vecs[12]];
if h[3].abs() < 1e-12 {
return None;
}
let x = [h[0] / h[3], h[1] / h[3], h[2] / h[3]];
x.iter().all(|v| v.is_finite()).then_some(x)
}
/// Angle (radians) between the rays from two camera centres to `x`.
pub fn ray_angle(a: V3, b: V3, x: V3) -> f64 {
let (u, v) = (normalize(sub(x, a)), normalize(sub(x, b)));
dot(u, v).clamp(-1.0, 1.0).acos()
}
/// Squared reprojection error (normalised units) of `x` in `pose` against observation `o`
/// (`None` behind the camera).
pub fn reproj2(pose: &Pose, x: V3, o: [f64; 2]) -> Option<f64> {
let c = pose.to_cam(x);
if c[2] <= 1e-9 {
return None;
}
let (u, v) = (c[0] / c[2] - o[0], c[1] / c[2] - o[1]);
Some(u * u + v * v)
}
/// Pick the essential decomposition with the most points in front of both cameras; returns
/// the second camera (the first is the identity) and that count.
pub fn select_pose(cands: &[(M3, V3)], x1: &[[f64; 2]], x2: &[[f64; 2]]) -> Option<(Pose, usize)> {
let mut best: Option<(Pose, usize)> = None;
for (r, t) in cands {
let p2 = Pose::from_rt(*r, *t);
let mut c = 0;
for i in 0..x1.len() {
if let Some(x) = triangulate(&[(Pose::IDENTITY, x1[i]), (p2, x2[i])])
&& x[2] > 0.0
&& p2.to_cam(x)[2] > 0.0
{
c += 1;
}
}
if best.as_ref().is_none_or(|b| c > b.1) {
best = Some((p2, c));
}
}
best
}
/// Decompose a calibrated planar homography `H ~ R + t nᵀ / d` (normalised coordinates,
/// `x2 ~ H x1`) into its motions `(R, t/d, n)` (Ma et al. §5.3.3). Up to four solutions; those
/// that put the plane behind the first camera are dropped.
pub fn decompose_homography(h: &M3, x1: &[[f64; 2]], x2: &[[f64; 2]]) -> Vec<(M3, V3, V3)> {
let (_, s, _) = svd3(h);
if s[1] <= 1e-12 {
return vec![];
}
let mut hn = mscale(h, 1.0 / s[1]);
// Sign: x2ᵀ H x1 > 0 for the correspondences (positive depth).
let pos = x1.iter().zip(x2).filter(|(p, q)| dot([q[0], q[1], 1.0], mv(&hn, [p[0], p[1], 1.0])) > 0.0).count();
if pos * 2 < x1.len() {
hn = mscale(&hn, -1.0);
}
let hth = mm(&mt(&hn), &hn);
let a: Vec<f64> = hth.iter().flatten().copied().collect();
let (vals, vecs) = sym_eigen(&a, 3);
// Descending: σ1² ≥ σ2² = 1 ≥ σ3².
let v1 = [vecs[2], vecs[5], vecs[8]];
let v2 = [vecs[1], vecs[4], vecs[7]];
let v3 = [vecs[0], vecs[3], vecs[6]];
let (s1, s3) = (vals[2].max(1.0), vals[0].clamp(0.0, 1.0));
let mut out = vec![];
if s1 - s3 < 1e-10 {
// H is a rotation: no translation (pure rotation or plane at infinity).
return vec![(nearest_rotation(&hn), [0.0; 3], [0.0, 0.0, 1.0])];
}
let k = (s1 - s3).sqrt();
let (a1, a3) = ((1.0 - s3).sqrt(), (s1 - 1.0).sqrt());
let u1 = scale(add(scale(v1, a1), scale(v3, a3)), 1.0 / k);
let u2 = scale(sub(scale(v1, a1), scale(v3, a3)), 1.0 / k);
for u in [u1, u2] {
let uu = from_cols(v2, u, cross(v2, u));
let hv2 = mv(&hn, v2);
let hu = mv(&hn, u);
let ww = from_cols(hv2, hu, cross(hv2, hu));
let r = mm(&ww, &mt(&uu));
let n = cross(v2, u);
let t = mv(&madd(&hn, &r, -1.0), n);
for sgn in [1.0, -1.0] {
let (n2, t2) = (scale(n, sgn), scale(t, sgn));
// The plane must be in front of the first camera: nᵀ x1 > 0 (with n pointing away).
let front = x1.iter().filter(|p| dot(n2, [p[0], p[1], 1.0]) > 0.0).count();
if front * 2 >= x1.len() {
out.push((r, t2, n2));
}
}
}
out
}
/// Least-squares plane through points: (centroid, unit normal, RMS in-plane spread).
pub fn fit_plane(p: &[V3]) -> Option<(V3, V3, f64)> {
if p.len() < 3 {
return None;
}
let k = 1.0 / p.len() as f64;
let c = scale(p.iter().fold([0.0; 3], |a, b| add(a, *b)), k);
let mut cov = [0.0; 9];
for q in p {
let d = sub(*q, c);
for i in 0..3 {
for j in 0..3 {
cov[i * 3 + j] += d[i] * d[j] * k;
}
}
}
let (vals, vecs) = sym_eigen(&cov, 3);
if vals[1] <= 1e-18 {
return None;
}
let n = normalize([vecs[0], vecs[3], vecs[6]]);
Some((c, n, (vals[1] + vals[2]).max(0.0).sqrt()))
}
/// The rotation `R` minimising `Σ |b − R a|²` over unit rays (orthogonal Procrustes / Kabsch).
pub fn procrustes(a: &[V3], b: &[V3]) -> Option<M3> {
if a.len() < 2 {
return None;
}
let mut m = [[0.0; 3]; 3];
for (x, y) in a.iter().zip(b) {
m = madd(&m, &outer(*y, *x), 1.0);
}
let (u, _, v) = svd3(&m);
let mut r = mm(&u, &mt(&v));
if det3(&r) < 0.0 {
let mut u2 = u;
for row in u2.iter_mut() {
row[2] = -row[2];
}
r = mm(&u2, &mt(&v));
}
Some(r)
}
#[cfg(test)]
mod tests {
use super::*;
fn scene() -> (Vec<V3>, Pose) {
let mut rng = Rng(12345);
let pts: Vec<V3> = (0..60)
.map(|_| {
let f = |r: &mut Rng| (r.next_u64() % 10_000) as f64 / 10_000.0 - 0.5;
[f(&mut rng) * 4.0, f(&mut rng) * 3.0, 5.0 + f(&mut rng) * 3.0]
})
.collect();
let p2 = Pose { r: rodrigues([0.02, -0.15, 0.03]), c: [0.6, 0.1, -0.2] };
(pts, p2)
}
fn proj(p: &Pose, x: V3) -> [f64; 2] {
let c = p.to_cam(x);
[c[0] / c[2], c[1] / c[2]]
}
#[test]
fn essential_recovers_relative_pose() {
let (pts, p2) = scene();
let x1: Vec<[f64; 2]> = pts.iter().map(|x| proj(&Pose::IDENTITY, *x)).collect();
let mut x2: Vec<[f64; 2]> = pts.iter().map(|x| proj(&p2, *x)).collect();
// Outliers.
x2[3] = [0.3, -0.2];
x2[17] = [-0.1, 0.25];
let (e, inl) = ransac_essential(&x1, &x2, 1e-3, 500, 7).unwrap();
assert!(!inl[3] && !inl[17]);
assert!(inl.iter().filter(|b| **b).count() >= 57);
let (pose, n) = select_pose(&decompose_essential(&e), &x1, &x2).unwrap();
assert!(n >= 57);
let r_err = norm(log_rot(&mm(&pose.r, &mt(&p2.r))));
assert!(r_err < 1e-6, "{r_err}");
let dir = normalize(p2.c);
assert!(norm(sub(normalize(pose.c), dir)) < 1e-5, "{:?} {:?}", pose.c, dir);
}
#[test]
fn homography_decomposition_contains_the_motion() {
// Plane z = 4 (normal (0,0,1) in camera 1), seen after a motion.
let p2 = Pose { r: rodrigues([0.05, 0.2, -0.04]), c: [0.8, -0.3, 0.4] };
let pts: Vec<V3> = (0..30).map(|i| [((i * 37) % 17) as f64 / 8.0 - 1.0, ((i * 11) % 13) as f64 / 6.0 - 1.0, 4.0]).collect();
let x1: Vec<[f64; 2]> = pts.iter().map(|x| proj(&Pose::IDENTITY, *x)).collect();
let x2: Vec<[f64; 2]> = pts.iter().map(|x| proj(&p2, *x)).collect();
// H = R + t nᵀ / d with t = −R C, n = (0,0,1), d = 4.
let t = p2.t();
let h = madd(&p2.r, &outer(t, [0.0, 0.0, 0.25]), 1.0);
let sols = decompose_homography(&mscale(&h, 3.7), &x1, &x2);
assert!(!sols.is_empty());
let ok = sols
.iter()
.any(|(r, td, n)| norm(log_rot(&mm(r, &mt(&p2.r)))) < 1e-6 && norm(sub(*td, scale(t, 0.25))) < 1e-6 && norm(sub(*n, [0.0, 0.0, 1.0])) < 1e-6);
assert!(ok, "{sols:?}");
}
#[test]
fn triangulation_and_plane() {
let (pts, p2) = scene();
for x in &pts[..5] {
let y = triangulate(&[(Pose::IDENTITY, proj(&Pose::IDENTITY, *x)), (p2, proj(&p2, *x))]).unwrap();
assert!(norm(sub(*x, y)) < 1e-8);
}
let plane: Vec<V3> = (0..10).map(|i| [i as f64, (i * i % 7) as f64, 2.0 * i as f64 + 1.0]).collect();
let (_, n, _) = fit_plane(&plane).unwrap();
// All points satisfy z = 2x + 1: normal ∝ (2, 0, −1).
assert!(norm(cross(n, normalize([2.0, 0.0, -1.0]))) < 1e-9);
}
}

View File

@ -0,0 +1,486 @@
//! Small dense linear algebra for the camera solver, written from the textbook methods:
//!
//! - 3-vectors / 3 × 3 matrices and rotations (Rodrigues' formula and its inverse);
//! - the **one-sided Jacobi SVD** (Hestenes 1958; Golub & Van Loan, *Matrix Computations*,
//! §8.6.4), accurate for the small, badly scaled systems of the 8-point algorithm;
//! - the **cyclic Jacobi eigenvalue method** for symmetric matrices (Golub & Van Loan §8.5);
//! - **Cholesky** factorisation for the bundle adjustment's normal equations;
//! - the closed-form **similarity alignment** of two point sets (Umeyama, "Least-squares
//! estimation of transformation parameters between two point patterns", PAMI 1991).
pub type V3 = [f64; 3];
pub type M3 = [[f64; 3]; 3];
pub const I3: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
pub fn add(a: V3, b: V3) -> V3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
pub fn sub(a: V3, b: V3) -> V3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
pub fn scale(a: V3, k: f64) -> V3 {
[a[0] * k, a[1] * k, a[2] * k]
}
pub fn dot(a: V3, b: V3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
pub fn cross(a: V3, b: V3) -> V3 {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
pub fn norm(a: V3) -> f64 {
dot(a, a).sqrt()
}
pub fn normalize(a: V3) -> V3 {
let n = norm(a);
if n > 0.0 { scale(a, 1.0 / n) } else { a }
}
pub fn mv(m: &M3, v: V3) -> V3 {
[dot(m[0], v), dot(m[1], v), dot(m[2], v)]
}
/// `mᵀ v`.
pub fn mtv(m: &M3, v: V3) -> V3 {
[m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2], m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2], m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2]]
}
pub fn mm(a: &M3, b: &M3) -> M3 {
let mut o = [[0.0; 3]; 3];
for (i, row) in o.iter_mut().enumerate() {
for (j, v) in row.iter_mut().enumerate() {
*v = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
}
}
o
}
pub fn mt(a: &M3) -> M3 {
[[a[0][0], a[1][0], a[2][0]], [a[0][1], a[1][1], a[2][1]], [a[0][2], a[1][2], a[2][2]]]
}
pub fn madd(a: &M3, b: &M3, k: f64) -> M3 {
let mut o = *a;
for i in 0..3 {
for j in 0..3 {
o[i][j] += k * b[i][j];
}
}
o
}
pub fn mscale(a: &M3, k: f64) -> M3 {
madd(&[[0.0; 3]; 3], a, k)
}
pub fn det3(m: &M3) -> f64 {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
pub fn skew(v: V3) -> M3 {
[[0.0, -v[2], v[1]], [v[2], 0.0, -v[0]], [-v[1], v[0], 0.0]]
}
pub fn outer(a: V3, b: V3) -> M3 {
[[a[0] * b[0], a[0] * b[1], a[0] * b[2]], [a[1] * b[0], a[1] * b[1], a[1] * b[2]], [a[2] * b[0], a[2] * b[1], a[2] * b[2]]]
}
pub fn col(m: &M3, j: usize) -> V3 {
[m[0][j], m[1][j], m[2][j]]
}
pub fn from_cols(a: V3, b: V3, c: V3) -> M3 {
[[a[0], b[0], c[0]], [a[1], b[1], c[1]], [a[2], b[2], c[2]]]
}
pub fn inv3(m: &M3) -> Option<M3> {
let d = det3(m);
if d.abs() < 1e-300 {
return None;
}
let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0];
let adj = [[c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)], [-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)], [c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)]];
Some(mscale(&adj, 1.0 / d))
}
/// Rotation matrix of an axis-angle vector (Rodrigues' formula).
pub fn rodrigues(w: V3) -> M3 {
let th = norm(w);
let k = skew(w);
if th < 1e-12 {
return madd(&I3, &k, 1.0);
}
let a = th.sin() / th;
let b = (1.0 - th.cos()) / (th * th);
madd(&madd(&I3, &k, a), &mm(&k, &k), b)
}
/// Axis-angle vector of a rotation matrix (inverse of [`rodrigues`]).
pub fn log_rot(r: &M3) -> V3 {
let c = ((r[0][0] + r[1][1] + r[2][2] - 1.0) * 0.5).clamp(-1.0, 1.0);
let th = c.acos();
let v = [r[2][1] - r[1][2], r[0][2] - r[2][0], r[1][0] - r[0][1]];
if th < 1e-9 {
return scale(v, 0.5);
}
if std::f64::consts::PI - th < 1e-6 {
// Near π: the axis is the dominant column of R + I.
let b = madd(r, &I3, 1.0);
let j = (0..3).max_by(|&a, &bb| b[a][a].total_cmp(&b[bb][bb])).unwrap_or(0);
let axis = normalize(col(&b, j));
return scale(axis, th);
}
scale(v, th / (2.0 * th.sin()))
}
/// The rotation closest to `m` (Frobenius norm), via the SVD.
pub fn nearest_rotation(m: &M3) -> M3 {
let (u, _, v) = svd3(m);
let mut r = mm(&u, &mt(&v));
if det3(&r) < 0.0 {
let mut u2 = u;
for row in u2.iter_mut() {
row[2] = -row[2];
}
r = mm(&u2, &mt(&v));
}
r
}
/// Spherical interpolation between two rotations.
pub fn slerp(a: &M3, b: &M3, t: f64) -> M3 {
let d = log_rot(&mm(b, &mt(a)));
mm(&rodrigues(scale(d, t)), a)
}
/// Thin SVD `A = U diag(s) Vᵀ` of an `m × n` row-major matrix (one-sided Jacobi). `U` is
/// `max(m, n) × n` (rows beyond `m` belong to zero padding), `s` is sorted descending and `V`
/// is `n × n` (columns are the right singular vectors).
pub fn svd(a: &[f64], m: usize, n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let rows = m.max(n);
let mut u = vec![0.0; rows * n];
u[..m * n].copy_from_slice(&a[..m * n]);
let mut v = vec![0.0; n * n];
for i in 0..n {
v[i * n + i] = 1.0;
}
for _sweep in 0..60 {
let mut off = 0.0f64;
for p in 0..n {
for q in p + 1..n {
let (mut alpha, mut beta, mut gamma) = (0.0, 0.0, 0.0);
for r in 0..rows {
let (x, y) = (u[r * n + p], u[r * n + q]);
alpha += x * x;
beta += y * y;
gamma += x * y;
}
if gamma == 0.0 {
continue;
}
let c0 = gamma.abs() / (alpha * beta).sqrt().max(1e-300);
off = off.max(c0);
if c0 < 1e-15 {
continue;
}
let zeta = (beta - alpha) / (2.0 * gamma);
let t = zeta.signum() / (zeta.abs() + (1.0 + zeta * zeta).sqrt());
let t = if zeta == 0.0 { 1.0 } else { t };
let c = 1.0 / (1.0 + t * t).sqrt();
let s = c * t;
for r in 0..rows {
let (x, y) = (u[r * n + p], u[r * n + q]);
u[r * n + p] = c * x - s * y;
u[r * n + q] = s * x + c * y;
}
for r in 0..n {
let (x, y) = (v[r * n + p], v[r * n + q]);
v[r * n + p] = c * x - s * y;
v[r * n + q] = s * x + c * y;
}
}
}
if off < 1e-15 {
break;
}
}
let mut s: Vec<f64> = (0..n).map(|j| (0..rows).map(|r| u[r * n + j] * u[r * n + j]).sum::<f64>().sqrt()).collect();
for (j, sj) in s.iter().enumerate() {
if *sj > 1e-300 {
for r in 0..rows {
u[r * n + j] /= sj;
}
}
}
// Sort descending.
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| s[b].total_cmp(&s[a]));
let (mut u2, mut v2) = (vec![0.0; rows * n], vec![0.0; n * n]);
for (k, &j) in order.iter().enumerate() {
for r in 0..rows {
u2[r * n + k] = u[r * n + j];
}
for r in 0..n {
v2[r * n + k] = v[r * n + j];
}
}
s = order.iter().map(|&j| s[j]).collect();
(u2, s, v2)
}
/// SVD of a 3 × 3 matrix: (U, singular values descending, V) with `m = U diag(s) Vᵀ`.
pub fn svd3(m: &M3) -> (M3, V3, M3) {
let a: Vec<f64> = m.iter().flatten().copied().collect();
let (u, s, v) = svd(&a, 3, 3);
let g = |x: &[f64]| [[x[0], x[1], x[2]], [x[3], x[4], x[5]], [x[6], x[7], x[8]]];
let mut um = g(&u);
// Columns of U for zero singular values are undefined: complete them to an orthonormal basis.
if s[2] < 1e-300 {
let c = cross(col(&um, 0), col(&um, 1));
for (r, row) in um.iter_mut().enumerate() {
row[2] = c[r];
}
}
(um, [s[0], s[1], s[2]], g(&v))
}
/// The unit vector `x` minimising `|A x|` (`m × n` row-major): the right singular vector of
/// the smallest singular value.
pub fn null_vector(a: &[f64], m: usize, n: usize) -> Vec<f64> {
let (_, _, v) = svd(a, m, n);
(0..n).map(|r| v[r * n + n - 1]).collect()
}
/// Eigen-decomposition of a symmetric `n × n` matrix (cyclic Jacobi): eigenvalues ascending
/// and the matching eigenvectors as the columns of the returned row-major matrix.
pub fn sym_eigen(a: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
let mut a = a.to_vec();
let mut v = vec![0.0; n * n];
for i in 0..n {
v[i * n + i] = 1.0;
}
for _ in 0..100 {
let mut off = 0.0;
for p in 0..n {
for q in p + 1..n {
off += a[p * n + q] * a[p * n + q];
}
}
if off < 1e-30 {
break;
}
for p in 0..n {
for q in p + 1..n {
let apq = a[p * n + q];
if apq.abs() < 1e-300 {
continue;
}
let theta = (a[q * n + q] - a[p * n + p]) / (2.0 * apq);
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
let t = if theta == 0.0 { 1.0 } else { t };
let c = 1.0 / (t * t + 1.0).sqrt();
let s = t * c;
for k in 0..n {
let (akp, akq) = (a[k * n + p], a[k * n + q]);
a[k * n + p] = c * akp - s * akq;
a[k * n + q] = s * akp + c * akq;
}
for k in 0..n {
let (apk, aqk) = (a[p * n + k], a[q * n + k]);
a[p * n + k] = c * apk - s * aqk;
a[q * n + k] = s * apk + c * aqk;
}
for k in 0..n {
let (vkp, vkq) = (v[k * n + p], v[k * n + q]);
v[k * n + p] = c * vkp - s * vkq;
v[k * n + q] = s * vkp + c * vkq;
}
}
}
}
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&x, &y| a[x * n + x].total_cmp(&a[y * n + y]));
let vals = order.iter().map(|&j| a[j * n + j]).collect();
let mut vecs = vec![0.0; n * n];
for (k, &j) in order.iter().enumerate() {
for r in 0..n {
vecs[r * n + k] = v[r * n + j];
}
}
(vals, vecs)
}
/// Dot product, four lanes (lets the compiler vectorise).
fn dot_n(a: &[f64], b: &[f64]) -> f64 {
let mut acc = [0.0f64; 4];
let (ca, cb) = (a.chunks_exact(4), b.chunks_exact(4));
let (ra, rb) = (ca.remainder(), cb.remainder());
for (x, y) in ca.zip(cb) {
for k in 0..4 {
acc[k] += x[k] * y[k];
}
}
let mut s = acc[0] + acc[1] + acc[2] + acc[3];
for (x, y) in ra.iter().zip(rb) {
s += x * y;
}
s
}
/// Solve `A x = b` for a symmetric positive definite `A` (`n × n`, row-major) in place by
/// Cholesky factorisation. Returns false when `A` is not positive definite.
pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool {
for j in 0..n {
let mut d = a[j * n + j];
for k in 0..j {
d -= a[j * n + k] * a[j * n + k];
}
if d <= 0.0 || !d.is_finite() {
return false;
}
let d = d.sqrt();
a[j * n + j] = d;
let (head, tail) = a.split_at_mut((j + 1) * n);
let rowj = &head[j * n..j * n + j];
// Rows below j (sequential: per-column parallel dispatch costs more than it saves).
for row in tail.chunks_mut(n) {
row[j] = (row[j] - dot_n(&row[..j], rowj)) / d;
}
}
// Forward: L y = b.
for i in 0..n {
let mut s = b[i];
for k in 0..i {
s -= a[i * n + k] * b[k];
}
b[i] = s / a[i * n + i];
}
// Back: Lᵀ x = y.
for i in (0..n).rev() {
let mut s = b[i];
for k in i + 1..n {
s -= a[k * n + i] * b[k];
}
b[i] = s / a[i * n + i];
}
b.iter().all(|v| v.is_finite())
}
/// A similarity transform `x ↦ s R x + t`.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Similarity {
pub s: f64,
pub r: M3,
pub t: V3,
}
impl Default for Similarity {
fn default() -> Self {
Similarity { s: 1.0, r: I3, t: [0.0; 3] }
}
}
impl Similarity {
pub fn apply(&self, x: V3) -> V3 {
add(scale(mv(&self.r, x), self.s), self.t)
}
}
/// The similarity minimising `Σ |dst − (s R src + t)|²` (Umeyama 1991).
pub fn umeyama(src: &[V3], dst: &[V3]) -> Option<Similarity> {
let n = src.len().min(dst.len());
if n < 3 {
return None;
}
let k = 1.0 / n as f64;
let ms = scale(src[..n].iter().fold([0.0; 3], |a, b| add(a, *b)), k);
let md = scale(dst[..n].iter().fold([0.0; 3], |a, b| add(a, *b)), k);
let mut cov = [[0.0; 3]; 3];
let mut var = 0.0;
for i in 0..n {
let (a, b) = (sub(src[i], ms), sub(dst[i], md));
cov = madd(&cov, &outer(b, a), k);
var += dot(a, a) * k;
}
if var < 1e-300 {
return None;
}
let (u, d, v) = svd3(&cov);
let mut sgn = [1.0, 1.0, 1.0];
if det3(&u) * det3(&v) < 0.0 {
sgn[2] = -1.0;
}
let ud = from_cols(scale(col(&u, 0), sgn[0]), scale(col(&u, 1), sgn[1]), scale(col(&u, 2), sgn[2]));
let r = mm(&ud, &mt(&v));
let s = (d[0] * sgn[0] + d[1] * sgn[1] + d[2] * sgn[2]) / var;
let t = sub(md, scale(mv(&r, ms), s));
Some(Similarity { s, r, t })
}
/// Euler angles (degrees) `[x, y, z]` with `m = Rz(z) Ry(y) Rx(x)` (After Effects' Orientation
/// order: X, then Y, then Z applied to the vector).
pub fn euler_xyz(m: &M3) -> V3 {
let sy = (-m[2][0]).clamp(-1.0, 1.0);
let y = sy.asin();
let (x, z) = if sy.abs() < 0.999_999 { (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0])) } else { ((-m[1][2]).atan2(m[1][1]), 0.0) };
[x.to_degrees(), y.to_degrees(), z.to_degrees()]
}
/// `Rz(z) Ry(y) Rx(x)` from degrees.
pub fn from_euler_xyz(e: V3) -> M3 {
let (sx, cx) = e[0].to_radians().sin_cos();
let (sy, cy) = e[1].to_radians().sin_cos();
let (sz, cz) = e[2].to_radians().sin_cos();
let rx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]];
let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]];
let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]];
mm(&rz, &mm(&ry, &rx))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn svd_reconstructs() {
let a = [3.0, 1.0, 2.0, -1.0, 4.0, 0.5, 2.0, 2.0, 7.0, 0.1, -3.0, 1.0];
let (u, s, v) = svd(&a, 4, 3);
for r in 0..4 {
for c in 0..3 {
let x: f64 = (0..3).map(|k| u[r * 3 + k] * s[k] * v[c * 3 + k]).sum();
assert!((x - a[r * 3 + c]).abs() < 1e-10);
}
}
assert!(s[0] >= s[1] && s[1] >= s[2]);
}
#[test]
fn rotations_round_trip() {
let w = [0.3, -1.2, 0.7];
let r = rodrigues(w);
let w2 = log_rot(&r);
assert!(norm(sub(w, w2)) < 1e-12);
let e = euler_xyz(&r);
let r2 = from_euler_xyz(e);
for i in 0..3 {
for j in 0..3 {
assert!((r[i][j] - r2[i][j]).abs() < 1e-12);
}
}
assert!((det3(&r) - 1.0).abs() < 1e-12);
}
#[test]
fn eigen_and_cholesky() {
let a = [4.0, 1.0, 0.5, 1.0, 3.0, 0.2, 0.5, 0.2, 2.0];
let (vals, vecs) = sym_eigen(&a, 3);
for k in 0..3 {
let v = [vecs[k], vecs[3 + k], vecs[6 + k]];
let av = [dot([a[0], a[1], a[2]], v), dot([a[3], a[4], a[5]], v), dot([a[6], a[7], a[8]], v)];
assert!(norm(sub(av, scale(v, vals[k]))) < 1e-10);
}
let mut m = a;
let mut b = [1.0, 2.0, 3.0];
assert!(cholesky_solve(&mut m, 3, &mut b));
let r = [dot([a[0], a[1], a[2]], b), dot([a[3], a[4], a[5]], b), dot([a[6], a[7], a[8]], b)];
assert!(norm(sub(r, [1.0, 2.0, 3.0])) < 1e-12);
}
#[test]
fn umeyama_recovers_a_similarity() {
let t = Similarity { s: 2.5, r: rodrigues([0.1, 0.4, -0.3]), t: [1.0, -2.0, 5.0] };
let src = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 2.0, 3.0]];
let dst: Vec<V3> = src.iter().map(|p| t.apply(*p)).collect();
let e = umeyama(&src, &dst).unwrap();
assert!((e.s - 2.5).abs() < 1e-10);
assert!(norm(sub(e.t, t.t)) < 1e-9);
}
}

View File

@ -0,0 +1,380 @@
//! The 3D Camera Tracker's analysis: structure from motion on a single clip, clean-room from the
//! textbook pipeline (Hartley & Zisserman, *Multiple View Geometry in Computer Vision*, 2nd ed.,
//! 2004; Triggs et al., "Bundle Adjustment — A Modern Synthesis", 2000):
//!
//! 1. **Feature tracks** ([`features::TrackAnalyzer`]): Shi–Tomasi corners followed through the
//! clip with pyramidal Lucas–Kanade and a forward–backward check ([`crate::klt`]); lost
//! features end their track and new ones are detected where the frame has none, so tracks
//! are as long as possible.
//! 2. **Solve** ([`solve`]): keyframes are selected by parallax; a two-view reconstruction
//! initialises the scene from either the **essential matrix** (normalised 8-point algorithm
//! inside RANSAC, H&Z §11.2 and §9.6) or, for mostly flat scenes, a **homography
//! decomposition** (Ma, Soatto, Košecká & Sastry, *An Invitation to 3-D Vision*, 2004, §5.3);
//! the other keyframes are added incrementally (**resection** by robust pose refinement,
//! **triangulation** of new tracks by the linear method of H&Z §12.2); a sparse
//! **Levenberg–Marquardt bundle adjustment** with the Schur complement over the points
//! ([`bundle`]) refines everything; the frames between keyframes are resected last. Inlier
//! thresholds start from the analysis resolution and are re-estimated from the residuals
//! (median absolute residual) once the keyframes are solved, so features on moving objects,
//! occlusion edges and screen-locked overlays drop out.
//! 3. **Focal length**: *Specify Angle of View* fixes it; *Fixed Angle of View* searches it
//! (the reprojection error of the keyframe reconstruction over a log-spaced range of focal
//! lengths, refined by golden-section search) and then adjusts it in the bundle adjustment
//! as one shared parameter; *Variable Zoom* starts from the same estimate and adjusts a focal
//! length per frame, with a weak smoothness prior between neighbouring frames.
//! 4. **Tripod pan** shots (no camera translation) are solved as a pure rotation: frame-to-frame
//! rotations from the tracked rays (orthogonal Procrustes) refined by the same bundle
//! adjustment with the camera centres fixed. *Auto Detect* solves both ways and keeps the
//! tripod solution when it explains the tracks as well as the general one (a quick rotation fit
//! at the general solution's focal length skips the tripod search when it clearly fails).
//!
//! The camera model has square pixels, the principal point at the layer centre and no lens
//! distortion. Camera space is x right, y down, z forward (After Effects' convention); a camera
//! is `(R, C, f)` with `X_cam = R (X − C)` and pixel `u = f x / z + w / 2`.
//!
//! The solve is stored in a canonical frame: the first solved frame's camera is at the origin
//! looking along +z, scaled so the median depth of the points it sees is 1. [`CameraSolve::world`]
//! maps it into composition space for a camera zoom, optionally re-based on a ground plane.
pub mod bundle;
pub mod features;
pub mod geometry;
pub mod linalg;
mod solver;
#[cfg(test)]
mod tests;
use serde::{Deserialize, Serialize};
pub use features::{AnalyzeOpts, TrackAnalyzer};
use linalg::{M3, Similarity, V3};
pub use solver::{SolveError, solve};
/// One feature track: positions (layer pixels) on consecutive frames from `start`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Track2D {
pub id: u32,
pub start: u32,
pub pts: Vec<[f32; 2]>,
}
impl Track2D {
pub fn end(&self) -> u32 {
self.start + self.pts.len() as u32
}
/// Position on frame `k` when the track covers it.
pub fn at(&self, k: u32) -> Option<[f64; 2]> {
let i = k.checked_sub(self.start)? as usize;
self.pts.get(i).map(|p| [p[0] as f64, p[1] as f64])
}
}
/// The tracked features of a clip (step 1 of the analysis).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CameraTracks {
pub version: u32,
/// Layer size (pixels).
pub size: [f64; 2],
/// Layer time (seconds) of frame 0, and the frame duration.
pub start: f64,
pub frame_duration: f64,
pub frames: u32,
/// Analysis downsampling factor (image pixels per analysis pixel): the tracking noise scale.
pub factor: f64,
pub detailed: bool,
pub tracks: Vec<Track2D>,
}
impl CameraTracks {
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
pub fn from_json(s: &str) -> Option<CameraTracks> {
if s.trim().is_empty() {
return None;
}
serde_json::from_str(s).ok()
}
pub fn is_empty(&self) -> bool {
self.frames == 0
}
pub fn track(&self, id: u32) -> Option<&Track2D> {
self.tracks.binary_search_by_key(&id, |t| t.id).ok().map(|i| &self.tracks[i])
}
}
/// 3D Camera Tracker ▸ Shot Type.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ShotType {
#[default]
FixedAngle,
VariableZoom,
SpecifyAngle,
}
impl ShotType {
pub const ALL: [ShotType; 3] = [ShotType::FixedAngle, ShotType::VariableZoom, ShotType::SpecifyAngle];
pub fn label(self) -> &'static str {
match self {
ShotType::FixedAngle => "Fixed Angle of View",
ShotType::VariableZoom => "Variable Zoom",
ShotType::SpecifyAngle => "Specify Angle of View",
}
}
pub fn from_name(s: &str) -> Option<ShotType> {
let k = s.to_ascii_lowercase().replace([' ', '-', '_'], "");
match k.as_str() {
"fixed" | "fixedangle" | "fixedangleofview" => Some(ShotType::FixedAngle),
"variable" | "variablezoom" | "zoom" => Some(ShotType::VariableZoom),
"specify" | "specifyangle" | "specifyangleofview" => Some(ShotType::SpecifyAngle),
_ => None,
}
}
}
/// 3D Camera Tracker ▸ Advanced ▸ Solve Method (and Method Used).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SolveMethod {
#[default]
Auto,
Typical,
MostlyFlat,
TripodPan,
}
impl SolveMethod {
pub const ALL: [SolveMethod; 4] = [SolveMethod::Auto, SolveMethod::Typical, SolveMethod::MostlyFlat, SolveMethod::TripodPan];
pub fn label(self) -> &'static str {
match self {
SolveMethod::Auto => "Auto Detect",
SolveMethod::Typical => "Typical",
SolveMethod::MostlyFlat => "Mostly Flat Scene",
SolveMethod::TripodPan => "Tripod Pan",
}
}
pub fn from_name(s: &str) -> Option<SolveMethod> {
let k = s.to_ascii_lowercase().replace([' ', '-', '_'], "");
match k.as_str() {
"auto" | "autodetect" => Some(SolveMethod::Auto),
"typical" => Some(SolveMethod::Typical),
"flat" | "mostlyflat" | "mostlyflatscene" => Some(SolveMethod::MostlyFlat),
"tripod" | "tripodpan" => Some(SolveMethod::TripodPan),
_ => None,
}
}
}
/// What a solve is made with.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct SolveSettings {
pub shot: ShotType,
/// Horizontal angle of view (degrees) for [`ShotType::SpecifyAngle`].
pub hfov: f64,
pub method: SolveMethod,
/// Track ids the user deleted.
pub deleted: Vec<u32>,
}
/// The solved camera of one frame.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct SolvedFrame {
/// World → camera rotation (axis-angle).
pub rot: V3,
/// Camera centre.
pub center: V3,
/// Focal length (layer pixels).
pub focal: f64,
pub solved: bool,
}
impl SolvedFrame {
pub fn r(&self) -> M3 {
linalg::rodrigues(self.rot)
}
/// Camera-space position of a world point.
pub fn to_cam(&self, x: V3) -> V3 {
linalg::mv(&self.r(), linalg::sub(x, self.center))
}
/// Project a world point to layer pixels (`None` behind the camera).
pub fn project(&self, size: [f64; 2], x: V3) -> Option<[f64; 2]> {
let c = self.to_cam(x);
(c[2] > 1e-9).then(|| [self.focal * c[0] / c[2] + size[0] * 0.5, self.focal * c[1] / c[2] + size[1] * 0.5])
}
}
/// A solved 3D track point.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct SolvedPoint {
/// The feature track it was made from.
pub id: u32,
pub pos: V3,
/// Average reprojection error (pixels).
pub error: f32,
/// Frames `[first, last]` the point was tracked on.
pub first: u32,
pub last: u32,
}
/// A ground plane and origin (canonical solve coordinates).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Ground {
pub origin: V3,
/// Unit normal on the cameras' side ("up").
pub normal: V3,
/// Unit in-plane direction that becomes world +x.
pub x_axis: V3,
}
/// A plane through solved points: After Effects' target.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Target {
pub center: V3,
/// Unit normal facing the camera.
pub normal: V3,
/// Diameter in canonical units.
pub size: f64,
}
/// The camera solve (step 2 of the analysis).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CameraSolve {
pub version: u32,
pub size: [f64; 2],
pub start: f64,
pub frame_duration: f64,
pub shot: ShotType,
/// The model that was used (Auto Detect resolves to one of the others).
pub method_used: SolveMethod,
/// Average reprojection error (pixels) over all solved observations.
pub average_error: f64,
pub frames: Vec<SolvedFrame>,
pub points: Vec<SolvedPoint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ground: Option<Ground>,
}
impl CameraSolve {
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
pub fn from_json(s: &str) -> Option<CameraSolve> {
if s.trim().is_empty() {
return None;
}
serde_json::from_str(s).ok()
}
pub fn is_empty(&self) -> bool {
self.frames.is_empty()
}
/// Frame index at a layer time (held at the ends).
pub fn frame_at(&self, layer_time: f64) -> Option<usize> {
if self.frames.is_empty() || self.frame_duration <= 0.0 {
return None;
}
let i = ((layer_time - self.start) / self.frame_duration).round();
Some(i.clamp(0.0, (self.frames.len() - 1) as f64) as usize)
}
pub fn point(&self, id: u32) -> Option<&SolvedPoint> {
self.points.iter().find(|p| p.id == id)
}
/// Points tracked on frame `k`.
pub fn visible(&self, k: usize) -> impl Iterator<Item = &SolvedPoint> {
let k = k as u32;
self.points.iter().filter(move |p| p.first <= k && k <= p.last)
}
/// Median focal length of the solved frames (layer pixels).
pub fn focal(&self) -> f64 {
let mut f: Vec<f64> = self.frames.iter().filter(|c| c.solved).map(|c| c.focal).collect();
if f.is_empty() {
return 0.0;
}
f.sort_by(f64::total_cmp);
f[f.len() / 2]
}
/// Horizontal angle of view (degrees) of focal length `f`.
pub fn hfov(&self, f: f64) -> f64 {
2.0 * (self.size[0] * 0.5 / f.max(1e-9)).atan().to_degrees()
}
/// The plane through points `ids` seen from frame `k` (needs three points; with more the
/// plane is a least-squares fit).
pub fn target(&self, ids: &[u32], k: usize) -> Option<Target> {
let pts: Vec<V3> = ids.iter().filter_map(|i| self.point(*i)).map(|p| p.pos).collect();
let cam = self.frames.get(k).filter(|c| c.solved).or_else(|| self.frames.iter().find(|c| c.solved))?;
geometry::fit_plane(&pts).map(|(c, mut n, spread)| {
if linalg::dot(n, linalg::sub(cam.center, c)) < 0.0 {
n = linalg::scale(n, -1.0);
}
// After Effects sizes the target from the camera distance when the points are close.
let depth = linalg::norm(linalg::sub(cam.center, c));
Target { center: c, normal: n, size: (spread * 2.0).max(depth * 0.12) }
})
}
/// A ground plane through `ids` (origin at their centroid, normal towards frame `k`'s
/// camera, +x along that camera's right vector projected on the plane).
pub fn ground_from(&self, ids: &[u32], k: usize) -> Option<Ground> {
let t = self.target(ids, k)?;
let cam = self.frames.get(k).filter(|c| c.solved).or_else(|| self.frames.iter().find(|c| c.solved))?;
let right = linalg::mtv(&cam.r(), [1.0, 0.0, 0.0]);
let mut x = linalg::sub(right, linalg::scale(t.normal, linalg::dot(right, t.normal)));
if linalg::norm(x) < 1e-6 {
let fwd = linalg::mtv(&cam.r(), [0.0, 0.0, 1.0]);
x = linalg::cross(fwd, t.normal);
}
Some(Ground { origin: t.center, normal: t.normal, x_axis: linalg::normalize(x) })
}
/// Canonical solve coordinates → composition world, for a camera whose zoom on the first
/// solved frame is `zoom0` (comp pixels) in a `comp` sized composition. Without a ground
/// plane, the first solved frame's camera becomes After Effects' default camera (at
/// `(w/2, h/2, −zoom)` looking down +z, so points at the median depth land near z = 0). With
/// one, the ground plane becomes the X-Z plane through the origin, normal along −y (up).
pub fn world(&self, comp: [f64; 2], zoom0: f64) -> Similarity {
match &self.ground {
Some(g) => {
// Rows: canonical directions that map to world x, y, z.
let y = linalg::scale(g.normal, -1.0);
let x = g.x_axis;
let z = linalg::cross(x, y);
let r = [x, y, z];
let t = linalg::scale(linalg::mv(&r, g.origin), -zoom0);
Similarity { s: zoom0, r, t }
}
None => Similarity { s: zoom0, r: linalg::I3, t: [comp[0] * 0.5, comp[1] * 0.5, -zoom0] },
}
}
/// The first solved frame.
pub fn first_solved(&self) -> Option<usize> {
self.frames.iter().position(|c| c.solved)
}
}
/// Deterministic colour of a track point (After Effects draws each target in its own colour).
pub fn point_color(id: u32) -> [f32; 3] {
let mut h = id.wrapping_mul(0x9E37_79B9) ^ 0x5bd1_e995;
h ^= h >> 15;
let hue = (h % 360) as f32 / 60.0;
let x = 1.0 - (hue % 2.0 - 1.0).abs();
let (r, g, b) = match hue as u32 {
0 => (1.0, x, 0.0),
1 => (x, 1.0, 0.0),
2 => (0.0, 1.0, x),
3 => (0.0, x, 1.0),
4 => (x, 0.0, 1.0),
_ => (1.0, 0.0, x),
};
// Bright, slightly desaturated.
[0.25 + 0.75 * r, 0.25 + 0.75 * g, 0.25 + 0.75 * b]
}

View File

@ -0,0 +1,953 @@
//! The camera solve pipeline (see the module docs of [`super`]).
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use rayon::prelude::*;
use super::bundle::{self, BaCam, BaObs, BaOpts, FocalMode};
use super::geometry::{self, Pose};
use super::linalg::*;
use super::{CameraSolve, CameraTracks, ShotType, SolveMethod, SolveSettings, SolvedFrame, SolvedPoint};
use crate::fit::{Model, ransac};
fn debug() -> bool {
std::env::var_os("EC_CAMTRACK_DEBUG").is_some()
}
/// Why a solve failed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SolveError(pub String);
impl std::fmt::Display for SolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
fn err<T>(m: &str) -> Result<T, SolveError> {
Err(SolveError(m.to_string()))
}
/// Tracks in solver form: pixels relative to the principal point.
struct Data {
n: usize,
w: f64,
/// (track id, first frame, positions).
tracks: Vec<(u32, usize, Vec<[f64; 2]>)>,
/// Per frame: (track index, position).
by_frame: Vec<Vec<(u32, [f64; 2])>>,
/// Tracking noise scale (pixels): starts from the analysis resolution and is re-estimated
/// from the residuals once the keyframes are solved.
noise_bits: std::sync::atomic::AtomicU64,
}
impl Data {
fn new(t: &CameraTracks, deleted: &[u32]) -> Data {
let n = t.frames as usize;
let c = [t.size[0] * 0.5, t.size[1] * 0.5];
let del: HashSet<u32> = deleted.iter().copied().collect();
let mut tracks = vec![];
let mut by_frame = vec![vec![]; n];
for tr in t.tracks.iter().filter(|tr| !del.contains(&tr.id) && tr.pts.len() >= 3) {
let ti = tracks.len() as u32;
let pts: Vec<[f64; 2]> = tr.pts.iter().map(|p| [p[0] as f64 - c[0], p[1] as f64 - c[1]]).collect();
for (k, p) in pts.iter().enumerate() {
if let Some(v) = by_frame.get_mut(tr.start as usize + k) {
v.push((ti, *p));
}
}
tracks.push((tr.id, tr.start as usize, pts));
}
let noise = (0.5 * t.factor).max(0.5);
Data { n, w: t.size[0], tracks, by_frame, noise_bits: std::sync::atomic::AtomicU64::new(noise.to_bits()) }
}
fn at(&self, ti: u32, k: usize) -> Option<[f64; 2]> {
let (_, s, p) = &self.tracks[ti as usize];
k.checked_sub(*s).and_then(|i| p.get(i).copied())
}
fn noise(&self) -> f64 {
f64::from_bits(self.noise_bits.load(Ordering::Relaxed))
}
fn set_noise(&self, v: f64) {
self.noise_bits.store(v.to_bits(), Ordering::Relaxed);
}
fn inlier_thr(&self) -> f64 {
4.0 * self.noise()
}
fn huber(&self) -> f64 {
self.noise()
}
}
/// Parallax-based keyframes: a new keyframe when the median feature motion since the last
/// exceeds 1.5 % of the width, when fewer than 70 % of its features survive, after 12 frames, or
/// before the shared features drop below 24. The first and last frames with features are
/// keyframes.
fn keyframes(d: &Data) -> Vec<usize> {
let Some(first) = (0..d.n).find(|k| d.by_frame[*k].len() >= 8) else { return vec![] };
let mut kf = vec![first];
let mut last = first;
let mut j = first + 1;
while j < d.n {
let base = &d.by_frame[last];
let mut disp = vec![];
for (ti, p) in base {
if let Some(q) = d.at(*ti, j) {
disp.push((q[0] - p[0]).hypot(q[1] - p[1]));
}
}
if disp.len() < 24 && j > last + 1 {
// Too few shared features: the previous frame becomes a keyframe.
last = j - 1;
kf.push(last);
continue;
}
disp.sort_by(f64::total_cmp);
let med = disp.get(disp.len() / 2).copied().unwrap_or(0.0);
let ratio = disp.len() as f64 / base.len().max(1) as f64;
if med > 0.015 * d.w || ratio < 0.7 || disp.len() < 24 || j - last >= 12 {
last = j;
kf.push(j);
}
j += 1;
}
if let Some(lastf) = (0..d.n).rev().find(|k| d.by_frame[*k].len() >= 8)
&& *kf.last().expect("one") < lastf
{
kf.push(lastf);
}
kf
}
/// A (partial) reconstruction.
#[derive(Clone, Default)]
struct Recon {
cams: BTreeMap<usize, BaCam>,
pts: HashMap<u32, V3>,
/// Rejected observations (track index, frame).
outliers: HashSet<(u32, usize)>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Kind {
General,
Tripod,
}
#[derive(Clone, Copy, PartialEq, Debug)]
struct Cfg {
kind: Kind,
/// Focal mode of the global adjustments.
focal: FocalMode,
/// Registration estimates a focal length per frame.
per_frame_f: bool,
}
const F_PRIOR: f64 = 30.0;
impl Recon {
/// Observations among `frames` (inliers, with a 3D point).
fn observations(&self, d: &Data, frames: &[usize]) -> (Vec<BaCam>, Vec<V3>, Vec<BaObs>, Vec<u32>) {
let cams: Vec<BaCam> = frames.iter().map(|k| self.cams[k]).collect();
let mut pidx: HashMap<u32, u32> = HashMap::new();
let mut pts = vec![];
let mut ids = vec![];
let mut obs = vec![];
for (ci, k) in frames.iter().enumerate() {
for (ti, uv) in &d.by_frame[*k] {
let Some(x) = self.pts.get(ti) else { continue };
if self.outliers.contains(&(*ti, *k)) {
continue;
}
let pi = *pidx.entry(*ti).or_insert_with(|| {
pts.push(*x);
ids.push(*ti);
(pts.len() - 1) as u32
});
obs.push(BaObs { cam: ci as u32, pt: pi, uv: *uv });
}
}
(cams, pts, obs, ids)
}
/// Bundle-adjust the cameras of `frames` (the first stays fixed) and their points.
fn adjust(&mut self, d: &Data, frames: &[usize], cfg: &Cfg, focal: FocalMode, iters: usize) -> f64 {
let (mut cams, mut pts, obs, ids) = self.observations(d, frames);
if obs.is_empty() {
return f64::INFINITY;
}
let mut fixed = vec![false; cams.len()];
fixed[0] = true;
let rep = bundle::adjust(
&mut cams,
&mut pts,
&obs,
&BaOpts { focal, fix_points: false, fix_centers: cfg.kind == Kind::Tripod, fixed, huber: d.huber(), max_iter: iters },
);
for (k, c) in frames.iter().zip(&cams) {
self.cams.insert(*k, *c);
}
for (ti, x) in ids.iter().zip(&pts) {
self.pts.insert(*ti, *x);
}
rep.cost
}
/// Mark observations above the inlier threshold as outliers; drop points left with fewer
/// than two observations among the registered frames.
fn reject(&mut self, d: &Data) {
let thr2 = d.inlier_thr().powi(2);
let mut count: HashMap<u32, usize> = HashMap::new();
for (k, c) in &self.cams {
for (ti, uv) in &d.by_frame[*k] {
let Some(x) = self.pts.get(ti) else { continue };
if self.outliers.contains(&(*ti, *k)) {
continue;
}
match bundle::residual(c, *x, *uv) {
Some(r) if r[0] * r[0] + r[1] * r[1] <= thr2 => *count.entry(*ti).or_default() += 1,
_ => {
self.outliers.insert((*ti, *k));
}
}
}
}
self.pts.retain(|ti, _| count.get(ti).copied().unwrap_or(0) >= 2);
}
/// Triangulate tracks seen on ≥ 2 registered frames that have no point yet.
fn triangulate(&mut self, d: &Data, kind: Kind, min_angle_deg: f64, check: f64) {
let mut seen: HashMap<u32, Vec<usize>> = HashMap::new();
for k in self.cams.keys() {
for (ti, _) in &d.by_frame[*k] {
if !self.pts.contains_key(ti) {
seen.entry(*ti).or_default().push(*k);
}
}
}
let thr2 = (d.inlier_thr() * check).powi(2);
let new: Vec<(u32, V3)> = seen
.par_iter()
.filter(|(_, ks)| ks.len() >= 2)
.filter_map(|(ti, ks)| {
let views: Vec<(Pose, [f64; 2], &BaCam)> = ks
.iter()
.map(|k| {
let c = &self.cams[k];
let uv = d.at(*ti, *k).expect("seen");
(c.pose, [uv[0] / c.f, uv[1] / c.f], c)
})
.collect();
let x = match kind {
Kind::Tripod => normalize(views.iter().fold([0.0; 3], |a, (p, o, _)| add(a, normalize(mtv(&p.r, [o[0], o[1], 1.0]))))),
Kind::General => {
let v: Vec<(Pose, [f64; 2])> = views.iter().map(|(p, o, _)| (*p, *o)).collect();
let x = geometry::triangulate(&v)?;
// Enough parallax between the most separated views.
let (a, b) = (views[0].0.c, views[views.len() - 1].0.c);
if geometry::ray_angle(a, b, x).to_degrees() < min_angle_deg {
return None;
}
x
}
};
for (k, (_, _, c)) in ks.iter().zip(&views) {
let uv = d.at(*ti, *k).expect("seen");
let r = bundle::residual(c, x, uv)?;
if r[0] * r[0] + r[1] * r[1] > thr2 {
return None;
}
}
Some((*ti, x))
})
.collect();
self.pts.extend(new);
}
/// Resect frame `k` from the existing points starting at `init` (pose, and the focal
/// length when `per_frame_f`). Returns false when too few points agree.
fn register(&mut self, d: &Data, k: usize, init: BaCam, cfg: &Cfg) -> bool {
let mut pts = vec![];
let mut obs = vec![];
let mut ids = vec![];
for (ti, uv) in &d.by_frame[k] {
if let Some(x) = self.pts.get(ti) {
obs.push(BaObs { cam: 0, pt: pts.len() as u32, uv: *uv });
pts.push(*x);
ids.push(*ti);
}
}
if obs.len() < 6 {
return false;
}
let thr2 = d.inlier_thr().powi(2);
let opts = |huber: f64| BaOpts {
focal: if cfg.per_frame_f { FocalMode::PerCamera(0.0) } else { FocalMode::Fixed },
fix_points: true,
fix_centers: cfg.kind == Kind::Tripod,
fixed: vec![false],
huber,
max_iter: 40,
};
let mut best: Option<(usize, BaCam)> = None;
// Robust start (large Huber threshold), then tighten; from the prediction and, failing
// that, from the nearest registered camera.
let nearest = self.cams.iter().min_by_key(|(c, _)| c.abs_diff(k)).map(|(_, c)| *c);
for start in std::iter::once(init).chain(nearest) {
if best.as_ref().is_some_and(|b| b.0 as f64 >= 0.8 * obs.len() as f64) {
break;
}
let mut cams = vec![start];
bundle::adjust(&mut cams, &mut pts.clone(), &obs, &opts(d.inlier_thr() * 4.0));
bundle::adjust(&mut cams, &mut pts.clone(), &obs, &opts(d.huber()));
let inl = obs.iter().filter(|o| bundle::residual(&cams[0], pts[o.pt as usize], o.uv).is_some_and(|r| r[0] * r[0] + r[1] * r[1] <= thr2)).count();
if best.as_ref().is_none_or(|b| inl > b.0) {
best = Some((inl, cams[0]));
}
}
let Some((inl, cam)) = best else { return false };
if inl < 6 || (inl as f64) < 0.5 * obs.len() as f64 {
return false;
}
// Refine on the inliers only.
let keep: Vec<BaObs> =
obs.iter().filter(|o| bundle::residual(&cam, pts[o.pt as usize], o.uv).is_some_and(|r| r[0] * r[0] + r[1] * r[1] <= thr2)).copied().collect();
let mut cams = vec![cam];
bundle::adjust(&mut cams, &mut pts.clone(), &keep, &opts(d.huber()));
for o in &obs {
let ok = bundle::residual(&cams[0], pts[o.pt as usize], o.uv).is_some_and(|r| r[0] * r[0] + r[1] * r[1] <= thr2);
if !ok {
self.outliers.insert((ids[o.pt as usize], k));
}
}
self.cams.insert(k, cams[0]);
true
}
/// How well the reconstruction explains every observation on its frames: the mean of the
/// reprojection error truncated at the inlier threshold, an observation without a point
/// counting as the threshold.
fn score(&self, d: &Data) -> f64 {
let thr = d.inlier_thr();
let mut s = 0.0;
let mut n = 0usize;
for (k, c) in &self.cams {
for (ti, uv) in &d.by_frame[*k] {
n += 1;
s += match self.pts.get(ti).and_then(|x| bundle::residual(c, *x, *uv)) {
Some(r) => r[0].hypot(r[1]).min(thr),
None => thr,
};
}
}
if n > 0 { s / n as f64 } else { f64::INFINITY }
}
/// Average reprojection error (pixels) and observation count over the inliers.
fn error(&self, d: &Data) -> (f64, usize) {
let mut s = 0.0;
let mut n = 0;
for (k, c) in &self.cams {
for (ti, uv) in &d.by_frame[*k] {
let Some(x) = self.pts.get(ti) else { continue };
if self.outliers.contains(&(*ti, *k)) {
continue;
}
if let Some(r) = bundle::residual(c, *x, *uv) {
s += r[0].hypot(r[1]);
n += 1;
}
}
}
(if n > 0 { s / n as f64 } else { f64::INFINITY }, n)
}
}
/// Interpolated / extrapolated starting camera for frame `k` from the registered ones.
fn predict(r: &Recon, k: usize) -> Option<BaCam> {
let before = r.cams.range(..k).next_back().map(|(a, b)| (*a, *b));
let after = r.cams.range(k + 1..).next().map(|(a, b)| (*a, *b));
match (before, after) {
(Some((ka, a)), Some((kb, b))) => {
let t = (k - ka) as f64 / (kb - ka) as f64;
Some(BaCam { pose: Pose { r: slerp(&a.pose.r, &b.pose.r, t), c: add(scale(a.pose.c, 1.0 - t), scale(b.pose.c, t)) }, f: a.f * (b.f / a.f).powf(t) })
}
(Some((ka, a)), None) => {
// Constant velocity from the two previous cameras.
match r.cams.range(..ka).next_back() {
Some((kp, p)) => {
let t = (k - ka) as f64 / (ka - kp) as f64;
let dr = log_rot(&mm(&a.pose.r, &mt(&p.pose.r)));
let rr = mm(&rodrigues(scale(dr, t.min(2.0))), &a.pose.r);
let c = add(a.pose.c, scale(sub(a.pose.c, p.pose.c), t.min(2.0)));
Some(BaCam { pose: Pose { r: rr, c }, f: a.f })
}
None => Some(a),
}
}
(None, Some((kb, b))) => match r.cams.range(kb + 1..).next() {
Some((kn, nx)) => {
let t = (kb - k) as f64 / (kn - kb) as f64;
let dr = log_rot(&mm(&b.pose.r, &mt(&nx.pose.r)));
let rr = mm(&rodrigues(scale(dr, t.min(2.0))), &b.pose.r);
let c = add(b.pose.c, scale(sub(b.pose.c, nx.pose.c), t.min(2.0)));
Some(BaCam { pose: Pose { r: rr, c }, f: b.f })
}
None => Some(b),
},
(None, None) => None,
}
}
/// Shared tracks of frames `a` and `b`: (track index, pixels in a, pixels in b).
fn shared(d: &Data, a: usize, b: usize) -> Vec<(u32, [f64; 2], [f64; 2])> {
d.by_frame[a].iter().filter_map(|(ti, p)| d.at(*ti, b).map(|q| (*ti, *p, q))).collect()
}
/// Two-view initialisations of frames `a`, `b` at focal length `f`: the second camera and the
/// triangulated points (the first camera is the identity).
fn init_pair(d: &Data, a: usize, b: usize, f: f64, use_e: bool, use_h: bool) -> Vec<(Pose, HashMap<u32, V3>)> {
let sh = shared(d, a, b);
if sh.len() < 12 {
return vec![];
}
let x1: Vec<[f64; 2]> = sh.iter().map(|s| [s.1[0] / f, s.1[1] / f]).collect();
let x2: Vec<[f64; 2]> = sh.iter().map(|s| [s.2[0] / f, s.2[1] / f]).collect();
let thr = 2.0 * d.noise() / f;
let mut cands: Vec<Pose> = vec![];
if use_e && let Some((e, inl)) = geometry::ransac_essential(&x1, &x2, thr, 400, (a * 7919 + b) as u64) {
let (p1, p2): (Vec<[f64; 2]>, Vec<[f64; 2]>) = (0..x1.len()).filter(|i| inl[*i]).map(|i| (x1[i], x2[i])).unzip();
if let Some((pose, _)) = geometry::select_pose(&geometry::decompose_essential(&e), &p1, &p2) {
cands.push(pose);
}
}
if use_h {
let (s1, s2): (Vec<[f64; 2]>, Vec<[f64; 2]>) = sh.iter().map(|s| (s.1, s.2)).unzip();
if let Some(fit) = ransac(Model::Homography, &s1, &s2, 2.0 * d.noise(), 500, (a * 31 + b) as u64) {
let h = fit.h.0;
// Normalised: K⁻¹ H K with K = diag(f, f, 1).
let hn = [[h[0][0], h[0][1], h[0][2] / f], [h[1][0], h[1][1], h[1][2] / f], [h[2][0] * f, h[2][1] * f, h[2][2]]];
let (p1, p2): (Vec<[f64; 2]>, Vec<[f64; 2]>) = (0..x1.len()).filter(|i| fit.inliers[*i]).map(|i| (x1[i], x2[i])).unzip();
for (r, td, _) in geometry::decompose_homography(&hn, &p1, &p2) {
if norm(td) > 1e-6 {
cands.push(Pose::from_rt(r, normalize(td)));
}
}
}
}
let thr2 = (d.inlier_thr() / f).powi(2);
cands
.into_iter()
.filter_map(|pose| {
let mut pts = HashMap::new();
for (i, s) in sh.iter().enumerate() {
let Some(x) = geometry::triangulate(&[(Pose::IDENTITY, x1[i]), (pose, x2[i])]) else { continue };
let ok = geometry::reproj2(&Pose::IDENTITY, x, x1[i]).is_some_and(|e| e < thr2)
&& geometry::reproj2(&pose, x, x2[i]).is_some_and(|e| e < thr2)
&& geometry::ray_angle([0.0; 3], pose.c, x).to_degrees() > 0.3;
if ok {
pts.insert(s.0, x);
}
}
(pts.len() >= 10 && pts.len() * 3 >= sh.len()).then_some((pose, pts))
})
.collect()
}
/// Grow a reconstruction over keyframes `kfs` from an initial pair (`kfs[0]`, `kfs[j]`).
fn grow(d: &Data, kfs: &[usize], j: usize, init: &(Pose, HashMap<u32, V3>), f: f64, cfg: &Cfg, cancel: Option<&AtomicBool>) -> Option<Recon> {
let (a, b) = (kfs[0], kfs[j]);
let mut r = Recon::default();
r.cams.insert(a, BaCam { pose: Pose::IDENTITY, f });
r.cams.insert(b, BaCam { pose: init.0, f });
r.pts = init.1.clone();
let inner = |r: &mut Recon, frames: &[usize]| {
let focal = if cfg.per_frame_f && frames.len() > 2 { FocalMode::PerCamera(F_PRIOR) } else { FocalMode::Fixed };
r.adjust(d, frames, cfg, focal, 10);
r.reject(d);
};
inner(&mut r, &[a, b]);
let order: Vec<usize> = kfs[1..j].iter().chain(&kfs[j + 1..]).copied().collect();
let mut done = 0;
for k in order {
if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
return None;
}
let start = predict(&r, k)?;
if !r.register(d, k, start, cfg) {
// A failed keyframe ends the reconstruction (a later frame is too far from the known
// points); the caller resects the rest from what is known.
break;
}
r.triangulate(d, cfg.kind, 1.0, 1.0);
done += 1;
let frames: Vec<usize> = r.cams.keys().copied().collect();
if done % 3 == 0 || frames.len() <= 6 {
inner(&mut r, &frames);
}
}
let frames: Vec<usize> = r.cams.keys().copied().collect();
inner(&mut r, &frames);
(r.cams.len() >= 2 && r.pts.len() >= 8).then_some(r)
}
/// Tripod reconstruction over keyframes `kfs` at focal length `f`.
fn grow_tripod(d: &Data, kfs: &[usize], f: f64, cfg: &Cfg, cancel: Option<&AtomicBool>) -> Option<Recon> {
let mut r = Recon::default();
r.cams.insert(kfs[0], BaCam { pose: Pose::IDENTITY, f });
let ray = |p: [f64; 2], f: f64| normalize([p[0] / f, p[1] / f, 1.0]);
for w in kfs.windows(2) {
if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
return None;
}
let (a, b) = (w[0], w[1]);
let sh = shared(d, a, b);
if sh.len() < 6 {
break;
}
let ra: Vec<V3> = sh.iter().map(|s| ray(s.1, f)).collect();
let rb: Vec<V3> = sh.iter().map(|s| ray(s.2, f)).collect();
let mut rel = geometry::procrustes(&ra, &rb)?;
// One round of outlier trimming.
let errs: Vec<f64> = ra.iter().zip(&rb).map(|(x, y)| norm(sub(*y, mv(&rel, *x)))).collect();
let mut sorted = errs.clone();
sorted.sort_by(f64::total_cmp);
let lim = (sorted[sorted.len() / 2] * 3.0).max(d.inlier_thr() / f);
let (ia, ib): (Vec<V3>, Vec<V3>) = ra.iter().zip(&rb).zip(&errs).filter(|(_, e)| **e <= lim).map(|((x, y), _)| (*x, *y)).unzip();
if ia.len() >= 4 {
rel = geometry::procrustes(&ia, &ib)?;
}
let pa = r.cams[&a].pose.r;
r.cams.insert(b, BaCam { pose: Pose { r: mm(&rel, &pa), c: [0.0; 3] }, f });
}
r.triangulate(d, Kind::Tripod, 0.0, 20.0);
let frames: Vec<usize> = r.cams.keys().copied().collect();
let focal = if cfg.per_frame_f && frames.len() > 2 { FocalMode::PerCamera(F_PRIOR) } else { FocalMode::Fixed };
r.adjust(d, &frames, cfg, focal, 15);
r.reject(d);
r.triangulate(d, Kind::Tripod, 0.0, 1.0);
r.adjust(d, &frames, cfg, focal, 15);
(r.cams.len() >= 2 && r.pts.len() >= 8).then_some(r)
}
/// Focal length (pixels) of a horizontal angle of view.
fn focal_of(w: f64, hfov: f64) -> f64 {
w * 0.5 / (hfov.clamp(1.0, 170.0).to_radians() * 0.5).tan()
}
struct Attempt {
recon: Recon,
err: f64,
method: SolveMethod,
}
/// Choose the initial pair: the first keyframe and the later keyframe (among the next few)
/// that keeps the most points while having enough parallax.
fn best_init(d: &Data, kfs: &[usize], f: f64, method: SolveMethod) -> Option<(usize, (Pose, HashMap<u32, V3>), bool)> {
let (use_e, use_h) = match method {
SolveMethod::Typical => (true, false),
SolveMethod::MostlyFlat => (false, true),
_ => (true, true),
};
let mut best: Option<(f64, usize, (Pose, HashMap<u32, V3>), bool)> = None;
for j in 1..kfs.len().min(6) {
let cands = init_pair(d, kfs[0], kfs[j], f, use_e, false);
let hc = if use_h { init_pair(d, kfs[0], kfs[j], f, false, true) } else { vec![] };
for (c, is_h) in cands.into_iter().map(|c| (c, false)).chain(hc.into_iter().map(|c| (c, true))) {
// Score: points × median triangulation angle (capped).
let mut ang: Vec<f64> = c.1.values().map(|x| geometry::ray_angle([0.0; 3], c.0.c, *x).to_degrees()).collect();
ang.sort_by(f64::total_cmp);
let med = ang.get(ang.len() / 2).copied().unwrap_or(0.0);
let score = c.1.len() as f64 * med.min(4.0);
if best.as_ref().is_none_or(|b| score > b.0) {
best = Some((score, j, c, is_h));
}
}
}
best.map(|b| (b.1, b.2, b.3))
}
/// All initial candidates for the pair chosen by [`best_init`] (E and H decompositions).
fn init_candidates(d: &Data, kfs: &[usize], f: f64, method: SolveMethod) -> Vec<(usize, (Pose, HashMap<u32, V3>), bool)> {
let Some((j, first, is_h)) = best_init(d, kfs, f, method) else { return vec![] };
let mut out = vec![(j, first, is_h)];
if matches!(method, SolveMethod::Auto | SolveMethod::MostlyFlat) {
for c in init_pair(d, kfs[0], kfs[j], f, false, true) {
if out.iter().all(|o| norm(log_rot(&mm(&o.1.0.r, &mt(&c.0.r)))) > 1e-3 || norm(sub(o.1.0.c, c.0.c)) > 1e-3) {
out.push((j, c, true));
}
}
}
out
}
/// A general (translating camera) reconstruction of keyframes `kfs` at focal length `f`.
fn general(d: &Data, kfs: &[usize], f: f64, method: SolveMethod, cfg: &Cfg, cancel: Option<&AtomicBool>) -> Option<Attempt> {
let cands = init_candidates(d, kfs, f, method);
let res: Vec<Attempt> = cands
.par_iter()
.filter_map(|(j, init, is_h)| {
let r = grow(d, kfs, *j, init, f, cfg, cancel)?;
let (_, n) = r.error(d);
// Prefer reconstructions covering more keyframes.
let cover = r.cams.len() as f64 / kfs.len() as f64;
(n > 0).then(|| Attempt { err: r.score(d) / cover.max(0.05), recon: r, method: if *is_h { SolveMethod::MostlyFlat } else { SolveMethod::Typical } })
})
.collect();
res.into_iter().min_by(|a, b| a.err.total_cmp(&b.err))
}
fn tripod(d: &Data, kfs: &[usize], f: f64, cfg: &Cfg, cancel: Option<&AtomicBool>) -> Option<Attempt> {
let r = grow_tripod(d, kfs, f, cfg, cancel)?;
let cover = r.cams.len() as f64 / kfs.len() as f64;
Some(Attempt { err: r.score(d) / cover.max(0.05), recon: r, method: SolveMethod::TripodPan })
}
/// Search the focal length minimising `eval(f)` (a reprojection error): a log-spaced scan of
/// horizontal angles of view, then golden-section refinement around the best.
fn search_focal(w: f64, eval: &(dyn Fn(f64) -> f64 + Sync)) -> f64 {
let fovs = [8.0, 12.0, 16.0, 20.0, 25.0, 30.0, 36.0, 43.0, 51.0, 60.0, 70.0, 82.0, 96.0, 112.0];
let fs: Vec<f64> = fovs.iter().map(|a| focal_of(w, *a)).collect();
let errs: Vec<f64> = fs.par_iter().map(|f| eval(*f)).collect();
if debug() {
eprintln!("focal scan: {:?}", fs.iter().zip(&errs).map(|(f, e)| format!("{f:.0}:{e:.3}")).collect::<Vec<_>>());
}
let i = (0..fs.len()).min_by(|a, b| errs[*a].total_cmp(&errs[*b])).unwrap_or(5);
let (mut lo, mut hi) = (fs[(i + 1).min(fs.len() - 1)].ln(), fs[i.saturating_sub(1)].ln());
if lo > hi {
std::mem::swap(&mut lo, &mut hi);
}
let g = 0.618_033_988_75;
let (mut x1, mut x2) = (hi - g * (hi - lo), lo + g * (hi - lo));
let (mut e1, mut e2) = rayon::join(|| eval(x1.exp()), || eval(x2.exp()));
for _ in 0..7 {
if e1 < e2 {
hi = x2;
x2 = x1;
e2 = e1;
x1 = hi - g * (hi - lo);
e1 = eval(x1.exp());
} else {
lo = x1;
x1 = x2;
e1 = e2;
x2 = lo + g * (hi - lo);
e2 = eval(x2.exp());
}
}
let best = if e1 < e2 { (x1, e1) } else { (x2, e2) };
if best.1 <= errs[i] { best.0.exp() } else { fs[i] }
}
/// Solve the camera of a tracked clip.
pub fn solve(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBool>) -> Result<CameraSolve, SolveError> {
let d = Data::new(tracks, &s.deleted);
if d.n < 2 {
return err("the clip must be at least two frames long");
}
let kfs = keyframes(&d);
if kfs.len() < 2 {
return err("not enough features were tracked to solve the camera");
}
let cancelled = || cancel.is_some_and(|c| c.load(Ordering::Relaxed));
let fixed_f = (s.shot == ShotType::SpecifyAngle).then(|| focal_of(d.w, if s.hfov > 0.0 { s.hfov } else { 40.0 }));
let variable = s.shot == ShotType::VariableZoom;
let gcfg = Cfg { kind: Kind::General, focal: FocalMode::Fixed, per_frame_f: false };
let tcfg = Cfg { kind: Kind::Tripod, ..gcfg };
// The focal-length search runs on the first keyframes only.
let mini: Vec<usize> = kfs.iter().copied().take(10).collect();
let try_general = matches!(s.method, SolveMethod::Auto | SolveMethod::Typical | SolveMethod::MostlyFlat);
let try_tripod = matches!(s.method, SolveMethod::Auto | SolveMethod::TripodPan);
let t0 = web_time::Instant::now();
let lap = |what: &str| {
if debug() {
eprintln!("{what}: {:.2} s", t0.elapsed().as_secs_f64());
}
};
let mut results: Vec<(Attempt, f64)> = vec![];
if try_general {
let f = match fixed_f {
Some(f) => f,
None => {
search_focal(d.w, &|f| general(&d, &mini, f, s.method, &Cfg { per_frame_f: variable, ..gcfg }, cancel).map(|a| a.err).unwrap_or(f64::INFINITY))
}
};
if cancelled() {
return err("cancelled");
}
if let Some(a) = general(&d, &kfs, f, s.method, &Cfg { per_frame_f: variable, ..gcfg }, cancel) {
results.push((a, f));
}
}
lap("general");
// Auto Detect: a quick tripod check at the general solve's focal length; when a rotation
// clearly cannot explain the tracks, the tripod search is skipped.
let tripod_plausible = match (s.method, results.first()) {
(SolveMethod::Auto, Some((g, f))) => {
let quick = tripod(&d, &mini, *f, &tcfg, cancel).map(|a| a.err).unwrap_or(f64::INFINITY);
if debug() {
eprintln!("quick tripod check: {quick:.3} vs general {:.3}", g.err);
}
quick <= 3.0 * g.err + d.noise()
}
_ => true,
};
if try_tripod && tripod_plausible && !cancelled() {
let f = match fixed_f {
Some(f) => f,
None => search_focal(d.w, &|f| tripod(&d, &mini, f, &Cfg { per_frame_f: variable, ..tcfg }, cancel).map(|a| a.err).unwrap_or(f64::INFINITY)),
};
if let Some(a) = tripod(&d, &kfs, f, &Cfg { per_frame_f: variable, ..tcfg }, cancel) {
results.push((a, f));
}
}
if cancelled() {
return err("cancelled");
}
// Auto Detect keeps the tripod model when it explains the tracks about as well.
let pick = {
let g = results.iter().position(|r| r.0.method != SolveMethod::TripodPan);
let t = results.iter().position(|r| r.0.method == SolveMethod::TripodPan);
match (g, t) {
(Some(g), Some(t)) => {
let (eg, et) = (results[g].0.err, results[t].0.err);
// A general solve whose camera hardly moves relative to the scene depth is a pan.
let rg = &results[g].0.recon;
let cs: Vec<V3> = rg.cams.values().map(|c| c.pose.c).collect();
let base = cs.iter().flat_map(|a| cs.iter().map(move |b| norm(sub(*a, *b)))).fold(0.0, f64::max);
let c0 = rg.cams.values().next().expect("camera");
let mut depth: Vec<f64> = rg.pts.values().map(|x| norm(sub(*x, c0.pose.c))).collect();
depth.sort_by(f64::total_cmp);
let med = depth.get(depth.len() / 2).copied().unwrap_or(1.0);
if debug() {
eprintln!("auto: general {eg:.4} tripod {et:.4} baseline/depth {:.4}", base / med);
}
if et <= (eg * 1.25).max(eg + 0.05 * d.noise()) || base / med < 0.01 { t } else { g }
}
(Some(g), None) => g,
(None, Some(t)) => t,
(None, None) => return err("the camera could not be solved (not enough parallax or features)"),
}
};
if debug() {
for (a, f) in &results {
eprintln!("candidate {:?}: err {:.4} f {f:.1} cams {} pts {}", a.method, a.err, a.recon.cams.len(), a.recon.pts.len());
}
}
let (att, _) = results.swap_remove(pick);
let kind = if att.method == SolveMethod::TripodPan { Kind::Tripod } else { Kind::General };
let cfg = Cfg { kind, focal: FocalMode::Fixed, per_frame_f: variable };
let mut r = att.recon;
let focal = match (fixed_f, variable) {
(Some(_), _) => FocalMode::Fixed,
(None, true) => FocalMode::PerCamera(F_PRIOR),
(None, false) => FocalMode::Shared,
};
lap("tripod");
// Keyframes: adjust with the focal length free, reject, re-triangulate, adjust again.
let kf_frames: Vec<usize> = r.cams.keys().copied().collect();
r.adjust(&d, &kf_frames, &cfg, focal, 40);
r.reject(&d);
// Re-estimate the tracking noise from the inlier residuals (robustly, from their median:
// for 2D Gaussian noise of deviation σ the median distance is 1.1774 σ), then tighten the
// thresholds: observations of moving objects and occlusion edges become outliers.
let mut res: Vec<f64> = vec![];
for (k, c) in &r.cams {
for (ti, uv) in &d.by_frame[*k] {
if r.outliers.contains(&(*ti, *k)) {
continue;
}
if let Some(e) = r.pts.get(ti).and_then(|x| bundle::residual(c, *x, *uv)) {
res.push(e[0].hypot(e[1]));
}
}
}
if res.len() > 20 {
res.sort_by(f64::total_cmp);
let sigma = res[res.len() / 2] / 1.1774;
let floor = 0.05 * tracks.factor.max(1.0);
d.set_noise((1.5 * sigma).clamp(floor, d.noise()));
if debug() {
eprintln!("noise: σ {sigma:.3} → {:.3}", d.noise());
}
r.outliers.clear();
r.reject(&d);
}
r.triangulate(&d, kind, 1.0, 1.0);
r.adjust(&d, &kf_frames, &cfg, focal, 40);
r.reject(&d);
r.adjust(&d, &kf_frames, &cfg, focal, 20);
if cancelled() {
return err("cancelled");
}
lap("keyframe adjustment");
// Every other frame: resect from the points (nearest registered camera first).
let mut todo: Vec<usize> = (0..d.n).filter(|k| !r.cams.contains_key(k)).collect();
todo.sort_by_key(|k| r.cams.keys().map(|c| c.abs_diff(*k)).min().unwrap_or(usize::MAX));
let base = r.clone();
let resected: Vec<(usize, Option<BaCam>)> = todo
.par_iter()
.map(|k| {
let mut local = Recon { cams: base.cams.clone(), pts: base.pts.clone(), outliers: HashSet::new() };
let ok = predict(&local, *k).is_some_and(|start| local.register(&d, *k, start, &cfg));
(*k, if ok { local.cams.get(k).copied() } else { None })
})
.collect();
for (k, c) in resected {
if let Some(c) = c {
r.cams.insert(k, c);
}
}
if cancelled() {
return err("cancelled");
}
lap("resection");
// Points for the tracks that only lived between keyframes, then the final adjustment over
// every frame (when that stays affordable).
r.triangulate(&d, kind, 1.0, 1.0);
let all: Vec<usize> = r.cams.keys().copied().collect();
r.reject(&d);
if all.len() <= 160 {
r.adjust(&d, &all, &cfg, focal, 25);
r.reject(&d);
} else {
// Points only (cameras fixed) would be cheap but the keyframe solution is already
// adjusted; refine each non-keyframe once more against the final points.
let base = r.clone();
let refined: Vec<(usize, Option<BaCam>)> = all
.par_iter()
.filter(|k| !kf_frames.contains(k))
.map(|k| {
let mut local = Recon { cams: base.cams.clone(), pts: base.pts.clone(), outliers: HashSet::new() };
let start = local.cams[k];
let ok = local.register(&d, *k, start, &cfg);
(*k, if ok { local.cams.get(k).copied() } else { None })
})
.collect();
for (k, c) in refined.into_iter() {
if let Some(c) = c {
r.cams.insert(k, c);
}
}
r.reject(&d);
}
// Method Used: the chosen model; Auto Detect calls a general solve "Mostly Flat Scene" when
// its points are close to one plane.
let method = match (s.method, att.method) {
(_, SolveMethod::TripodPan) => SolveMethod::TripodPan,
(SolveMethod::Auto, _) => {
let p: Vec<V3> = r.pts.values().copied().collect();
let flat = cov_ratio(&p) < 0.08;
if flat { SolveMethod::MostlyFlat } else { SolveMethod::Typical }
}
(m, _) => m,
};
lap("final adjustment");
Ok(finish(&d, tracks, s, r, method, kind))
}
/// Thickness of a point cloud: √(smallest / middle eigenvalue of its covariance).
fn cov_ratio(p: &[V3]) -> f64 {
if p.len() < 3 {
return 1.0;
}
let k = 1.0 / p.len() as f64;
let c = scale(p.iter().fold([0.0; 3], |a, b| add(a, *b)), k);
let mut cov = [0.0; 9];
for q in p {
let d = sub(*q, c);
for i in 0..3 {
for j in 0..3 {
cov[i * 3 + j] += d[i] * d[j] * k;
}
}
}
let (vals, _) = sym_eigen(&cov, 3);
(vals[0].max(0.0) / vals[1].max(1e-300)).sqrt()
}
/// Canonical frame, per-frame output (unsolved frames interpolated), points and errors.
fn finish(d: &Data, tracks: &CameraTracks, s: &SolveSettings, r: Recon, method: SolveMethod, kind: Kind) -> CameraSolve {
let (k0, c0) = r.cams.iter().next().map(|(k, c)| (*k, *c)).expect("solved");
// Scale: median depth of the points seen by the first solved camera is 1.
let mut depths: Vec<f64> = d.by_frame[k0].iter().filter_map(|(ti, _)| r.pts.get(ti)).map(|x| c0.pose.to_cam(*x)[2]).filter(|z| *z > 0.0).collect();
depths.sort_by(f64::total_cmp);
let sc = match kind {
Kind::Tripod => 1.0,
Kind::General => depths.get(depths.len() / 2).copied().filter(|v| *v > 1e-12).unwrap_or(1.0),
};
let r0 = c0.pose.r;
let cc = c0.pose.c;
let to_world = |x: V3| scale(mv(&r0, sub(x, cc)), 1.0 / sc);
let mut frames = vec![SolvedFrame::default(); d.n];
for (k, c) in &r.cams {
let rr = mm(&c.pose.r, &mt(&r0));
frames[*k] = SolvedFrame { rot: log_rot(&rr), center: to_world(c.pose.c), focal: c.f, solved: true };
}
// Hold / interpolate unsolved frames so the camera path stays continuous.
let solved: Vec<usize> = (0..d.n).filter(|k| frames[*k].solved).collect();
for k in 0..d.n {
if frames[k].solved {
continue;
}
let a = solved.iter().rev().find(|x| **x < k).copied();
let b = solved.iter().find(|x| **x > k).copied();
frames[k] = match (a, b) {
(Some(a), Some(b)) => {
let t = (k - a) as f64 / (b - a) as f64;
let (fa, fb) = (frames[a], frames[b]);
let rr = slerp(&fa.r(), &fb.r(), t);
SolvedFrame {
rot: log_rot(&rr),
center: add(scale(fa.center, 1.0 - t), scale(fb.center, t)),
focal: fa.focal + (fb.focal - fa.focal) * t,
solved: false,
}
}
(Some(a), None) => SolvedFrame { solved: false, ..frames[a] },
(None, Some(b)) => SolvedFrame { solved: false, ..frames[b] },
(None, None) => frames[k],
};
}
let mut points = vec![];
let (mut esum, mut en) = (0.0, 0usize);
let mut ids: Vec<&u32> = r.pts.keys().collect();
ids.sort();
for ti in ids {
let x = r.pts[ti];
let (id, start, pts) = &d.tracks[*ti as usize];
let (mut s1, mut n1) = (0.0, 0);
for (i, uv) in pts.iter().enumerate() {
let k = start + i;
if r.outliers.contains(&(*ti, k)) {
continue;
}
if let Some(c) = r.cams.get(&k)
&& let Some(res) = bundle::residual(c, x, *uv)
{
s1 += res[0].hypot(res[1]);
n1 += 1;
}
}
if n1 < 2 {
continue;
}
esum += s1;
en += n1;
points.push(SolvedPoint { id: *id, pos: to_world(x), error: (s1 / n1 as f64) as f32, first: *start as u32, last: (start + pts.len() - 1) as u32 });
}
CameraSolve {
version: 1,
size: tracks.size,
start: tracks.start,
frame_duration: tracks.frame_duration,
shot: s.shot,
method_used: method,
average_error: if en > 0 { esum / en as f64 } else { 0.0 },
frames,
points,
ground: None,
}
}

View File

@ -0,0 +1,438 @@
//! Synthetic scenes: random 3D points (or a plane) seen by a known moving camera with a known
//! focal length, projected directly (with sub-pixel noise) into feature tracks.
use super::geometry::Rng;
use super::linalg::*;
use super::*;
const W: f64 = 1280.0;
const H: f64 = 720.0;
struct Truth {
/// Per frame: (R world→cam, centre, focal).
cams: Vec<(M3, V3, f64)>,
}
fn noise(rng: &mut Rng) -> f64 {
// Sum of uniforms ≈ Gaussian, σ ≈ 0.1 px.
let u: f64 = (0..4).map(|_| (rng.next_u64() % 10_000) as f64 / 10_000.0 - 0.5).sum();
u * 0.17
}
/// Project `pts` through the cameras into tracks (a track ends when its point leaves the frame;
/// re-entering starts a new track).
fn tracks_of(pts: &[V3], t: &Truth, seed: u64, noise_on: bool) -> CameraTracks {
let mut rng = Rng(seed);
let mut tracks: Vec<Track2D> = vec![];
for x in pts {
let mut cur: Option<Track2D> = None;
for (k, (r, c, f)) in t.cams.iter().enumerate() {
let p = mv(r, sub(*x, *c));
let uv = (p[2] > 0.1).then(|| [f * p[0] / p[2] + W / 2.0, f * p[1] / p[2] + H / 2.0]);
match uv.filter(|u| u[0] >= 2.0 && u[1] >= 2.0 && u[0] < W - 2.0 && u[1] < H - 2.0) {
Some(u) => {
let (nx, ny) = if noise_on { (noise(&mut rng), noise(&mut rng)) } else { (0.0, 0.0) };
let q = [(u[0] + nx) as f32, (u[1] + ny) as f32];
match &mut cur {
Some(tr) => tr.pts.push(q),
None => cur = Some(Track2D { id: 0, start: k as u32, pts: vec![q] }),
}
}
None => {
if let Some(tr) = cur.take() {
tracks.push(tr);
}
}
}
}
if let Some(tr) = cur.take() {
tracks.push(tr);
}
}
tracks.retain(|t| t.pts.len() >= 3);
for (i, tr) in tracks.iter_mut().enumerate() {
tr.id = i as u32;
}
CameraTracks { version: 1, size: [W, H], start: 0.0, frame_duration: 1.0 / 30.0, frames: t.cams.len() as u32, factor: 1.0, detailed: false, tracks }
}
fn random_points(n: usize, seed: u64, center: V3, ext: V3) -> Vec<V3> {
let mut rng = Rng(seed);
(0..n)
.map(|_| {
let mut u = || (rng.next_u64() % 100_000) as f64 / 100_000.0 - 0.5;
[center[0] + u() * ext[0], center[1] + u() * ext[1], center[2] + u() * ext[2]]
})
.collect()
}
/// A camera dollying sideways and forward while panning to keep the scene in view.
fn moving_camera(frames: usize, f: impl Fn(usize) -> f64) -> Truth {
let cams = (0..frames)
.map(|k| {
let t = k as f64 / (frames - 1) as f64;
let c = [-3.0 + 6.0 * t, -0.6 + 0.8 * t + 0.5 * (std::f64::consts::PI * t).sin(), -1.0 + 2.0 * t - 1.5 * (t - 0.5) * (t - 0.5)];
// Look at the scene centre (0, 0, 10).
let fwd = normalize(sub([0.3 * t, 0.2, 10.0], c));
let right = normalize(cross([0.0, 1.0, 0.0], fwd));
let down = cross(fwd, right);
let r = [right, down, fwd];
// A little roll.
let roll = rodrigues([0.0, 0.0, 0.05 * (t * 6.0).sin()]);
(mm(&roll, &r), c, f(k))
})
.collect();
Truth { cams }
}
/// Align the solved centres to the true ones (similarity) and return the RMS path error
/// relative to the path length, plus the worst rotation error (degrees).
fn path_error(s: &CameraSolve, t: &Truth) -> (f64, f64) {
let solved: Vec<usize> = (0..s.frames.len()).filter(|k| s.frames[*k].solved).collect();
let src: Vec<V3> = solved.iter().map(|k| s.frames[*k].center).collect();
let dst: Vec<V3> = solved.iter().map(|k| t.cams[*k].1).collect();
let sim = umeyama(&src, &dst).expect("alignment");
let len = norm(sub(dst[0], dst[dst.len() - 1])).max(1e-9);
let rms = (src.iter().zip(&dst).map(|(a, b)| dot(sub(sim.apply(*a), *b), sub(sim.apply(*a), *b))).sum::<f64>() / src.len() as f64).sqrt();
// Rotation: R_true ≈ R_solved · simᵀ.
let mut worst: f64 = 0.0;
for k in &solved {
let rs = mm(&s.frames[*k].r(), &mt(&sim.r));
let e = norm(log_rot(&mm(&rs, &mt(&t.cams[*k].0)))).to_degrees();
worst = worst.max(e);
}
(rms / len, worst)
}
fn settings(shot: ShotType, method: SolveMethod) -> SolveSettings {
SolveSettings { shot, hfov: 0.0, method, deleted: vec![] }
}
#[test]
fn fixed_angle_solve_recovers_path_and_focal() {
let f = 1100.0;
let t = moving_camera(40, |_| f);
let pts = random_points(400, 1, [0.0, 0.0, 10.0], [14.0, 8.0, 8.0]);
let tr = tracks_of(&pts, &t, 2, true);
let t0 = std::time::Instant::now();
let s = solve(&tr, &settings(ShotType::FixedAngle, SolveMethod::Typical), None).expect("solve");
eprintln!("fixed: {:?} err {:.3} px focal {:.1} in {:?}", s.method_used, s.average_error, s.focal(), t0.elapsed());
assert!(s.frames.iter().all(|c| c.solved));
assert!(s.average_error < 0.5, "{}", s.average_error);
assert!((s.focal() - f).abs() / f < 0.02, "focal {}", s.focal());
let (pe, re) = path_error(&s, &t);
assert!(pe < 0.01, "path {pe}");
assert!(re < 0.5, "rotation {re}");
// Canonical frame: first camera at the origin.
assert!(norm(s.frames[0].center) < 1e-9 && norm(s.frames[0].rot) < 1e-9);
// Serde round trip.
let j = s.to_json();
assert_eq!(CameraSolve::from_json(&j).unwrap().points.len(), s.points.len());
}
#[test]
fn auto_detect_picks_a_general_model_for_a_translating_camera() {
let f = 900.0;
let t = moving_camera(30, |_| f);
let pts = random_points(300, 3, [0.0, 0.0, 10.0], [14.0, 8.0, 8.0]);
let tr = tracks_of(&pts, &t, 4, true);
let s = solve(&tr, &settings(ShotType::FixedAngle, SolveMethod::Auto), None).expect("solve");
assert_eq!(s.method_used, SolveMethod::Typical);
assert!(s.average_error < 0.5);
assert!((s.focal() - f).abs() / f < 0.02, "focal {}", s.focal());
}
#[test]
fn specified_angle_of_view_is_kept() {
let f = 1000.0;
let t = moving_camera(25, |_| f);
let pts = random_points(300, 5, [0.0, 0.0, 10.0], [14.0, 8.0, 8.0]);
let tr = tracks_of(&pts, &t, 6, true);
let hfov = 2.0 * (W * 0.5 / f).atan().to_degrees();
let s = solve(&tr, &SolveSettings { shot: ShotType::SpecifyAngle, hfov, method: SolveMethod::Typical, deleted: vec![] }, None).unwrap();
assert!((s.focal() - f).abs() < 1e-6);
assert!(s.average_error < 0.5);
let (pe, _) = path_error(&s, &t);
assert!(pe < 0.01, "{pe}");
}
#[test]
fn variable_zoom_recovers_per_frame_focal() {
let t = moving_camera(40, |k| 950.0 + 6.0 * k as f64);
let pts = random_points(450, 7, [0.0, 0.0, 10.0], [16.0, 9.0, 8.0]);
let tr = tracks_of(&pts, &t, 8, true);
let s = solve(&tr, &settings(ShotType::VariableZoom, SolveMethod::Typical), None).expect("solve");
eprintln!("variable: err {:.3}", s.average_error);
assert!(s.average_error < 0.5);
for (k, c) in s.frames.iter().enumerate() {
let f = t.cams[k].2;
assert!((c.focal - f).abs() / f < 0.02, "frame {k}: {} vs {f}", c.focal);
}
}
#[test]
fn tripod_pan_solve() {
let f = 1200.0;
let cams = (0..40)
.map(|k| {
let a = -0.4 + 0.8 * k as f64 / 39.0;
(mm(&rodrigues([0.0, 0.0, 0.02]), &mm(&rodrigues([0.05 * a, 0.0, 0.0]), &rodrigues([0.0, a, 0.0]))), [0.0; 3], f)
})
.collect();
let t = Truth { cams };
// Points all around at varying distances (rotation only: depth is unobservable).
let pts = random_points(600, 9, [0.0, 0.0, 0.0], [60.0, 20.0, 60.0]).into_iter().filter(|p| p[2] > 3.0).collect::<Vec<_>>();
let tr = tracks_of(&pts, &t, 10, true);
let s = solve(&tr, &settings(ShotType::FixedAngle, SolveMethod::Auto), None).expect("solve");
eprintln!("tripod: {:?} err {:.3} focal {:.1}", s.method_used, s.average_error, s.focal());
assert_eq!(s.method_used, SolveMethod::TripodPan);
assert!(s.average_error < 0.5);
assert!((s.focal() - f).abs() / f < 0.02, "{}", s.focal());
for (k, c) in s.frames.iter().enumerate() {
assert!(norm(c.center) < 1e-9);
// Relative rotation to frame 0 matches.
let rel_s = mm(&c.r(), &mt(&s.frames[0].r()));
let rel_t = mm(&t.cams[k].0, &mt(&t.cams[0].0));
assert!(norm(log_rot(&mm(&rel_s, &mt(&rel_t)))).to_degrees() < 0.2);
}
}
#[test]
fn mostly_flat_scene_and_ground_plane() {
let f = 1000.0;
// A textured floor y = 2 (below the camera), seen by a camera moving forward and sideways.
let cams = (0..40)
.map(|k| {
let t = k as f64 / 39.0;
let c = [-2.0 + 4.0 * t, -0.3 * (std::f64::consts::PI * t).sin(), 2.0 * t];
let fwd = normalize([0.1 - 0.2 * t, 0.45, 1.0]);
let right = normalize(cross([0.0, 1.0, 0.0], fwd));
let down = cross(fwd, right);
([right, down, fwd], c, f)
})
.collect();
let t = Truth { cams };
let mut rng = Rng(11);
let pts: Vec<V3> = (0..500).map(|_| [((rng.next_u64() % 2000) as f64 / 100.0) - 10.0, 2.0, 2.0 + (rng.next_u64() % 2000) as f64 / 100.0]).collect();
let tr = tracks_of(&pts, &t, 12, true);
for method in [SolveMethod::MostlyFlat, SolveMethod::Auto] {
let s = solve(&tr, &settings(ShotType::FixedAngle, method), None).expect("solve");
eprintln!("flat {method:?}: {:?} err {:.3} focal {:.1}", s.method_used, s.average_error, s.focal());
assert_eq!(s.method_used, SolveMethod::MostlyFlat);
assert!(s.average_error < 0.5, "{}", s.average_error);
assert!((s.focal() - f).abs() / f < 0.02, "{}", s.focal());
let (pe, _) = path_error(&s, &t);
assert!(pe < 0.01, "{pe}");
}
// Ground plane from the solved (coplanar) points: every point lies on it.
let hfov = 2.0 * (W * 0.5 / f).atan().to_degrees();
let mut s = solve(&tr, &SolveSettings { shot: ShotType::SpecifyAngle, hfov, method: SolveMethod::MostlyFlat, deleted: vec![] }, None).expect("solve");
let ids: Vec<u32> = s.visible(0).map(|p| p.id).take(12).collect();
let g = s.ground_from(&ids, 0).expect("ground");
let spread = s.points.iter().map(|p| dot(sub(p.pos, g.origin), g.normal).abs()).fold(0.0, f64::max);
let depth = norm(sub(s.frames[0].center, g.origin));
assert!(spread < 0.01 * depth, "{spread}");
// Up points towards the camera.
assert!(dot(sub(s.frames[0].center, g.origin), g.normal) > 0.0);
s.ground = Some(g);
let w = s.world([W, H], 1000.0);
// In the world, ground points have y ≈ 0 and the cameras are above (y < 0).
for p in s.points.iter().take(20) {
assert!(w.apply(p.pos)[1].abs() < 0.01 * 1000.0 * depth, "{:?}", w.apply(p.pos));
}
assert!(w.apply(s.frames[0].center)[1] < 0.0);
assert!(norm(w.apply(g.origin)) < 1e-6);
}
#[test]
fn deleted_tracks_are_ignored_and_cancel_stops() {
let t = moving_camera(20, |_| 1000.0);
let pts = random_points(250, 13, [0.0, 0.0, 10.0], [14.0, 8.0, 8.0]);
let tr = tracks_of(&pts, &t, 14, true);
let deleted: Vec<u32> = (0..40).collect();
let hfov = 2.0 * (W * 0.5 / 1000.0f64).atan().to_degrees();
let s = solve(&tr, &SolveSettings { shot: ShotType::SpecifyAngle, hfov, method: SolveMethod::Typical, deleted: deleted.clone() }, None).unwrap();
assert!(s.points.iter().all(|p| !deleted.contains(&p.id)));
let stop = std::sync::atomic::AtomicBool::new(true);
let r = solve(&tr, &settings(ShotType::FixedAngle, SolveMethod::Typical), Some(&stop));
assert!(r.is_err());
}
/// End to end: rendered frames of a textured scene through the KLT feature tracker.
#[test]
fn rendered_scene_end_to_end() {
use effectcraft_raster::Image;
let (w, h) = (480u32, 270u32);
let f = 420.0;
// Three textured planes at different depths (a floor, a back wall and a box face).
fn tex(u: f64, v: f64) -> f32 {
fn hsh(i: i64, j: i64) -> f64 {
let mut x = (i.wrapping_mul(73856093) ^ j.wrapping_mul(19349663)) as u64;
x ^= x >> 13;
x = x.wrapping_mul(0x5bd1e995);
x ^= x >> 15;
(x % 1000) as f64 / 1000.0
}
fn noise(x: f64, y: f64) -> f64 {
let (i, j) = (x.floor(), y.floor());
let (tx, ty) = (x - i, y - j);
let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty));
let (i, j) = (i as i64, j as i64);
let a = hsh(i, j) + (hsh(i + 1, j) - hsh(i, j)) * sx;
let b = hsh(i, j + 1) + (hsh(i + 1, j + 1) - hsh(i, j + 1)) * sx;
a + (b - a) * sy
}
(0.15 + 0.5 * noise(u * 4.0, v * 4.0) + 0.3 * noise(u * 11.0 + 7.0, v * 11.0 + 3.0)) as f32
}
let frames = 24;
let t = Truth {
cams: (0..frames)
.map(|k| {
let s = k as f64 / (frames - 1) as f64;
let c = [-1.0 + 2.0 * s, -0.2, 0.5 * s];
let fwd = normalize(sub([0.0, 0.3, 8.0], c));
let right = normalize(cross([0.0, 1.0, 0.0], fwd));
([right, cross(fwd, right), fwd], c, f)
})
.collect(),
};
// Planes: (point, normal, u axis, v axis).
let planes: [(V3, V3, V3, V3); 3] = [
([0.0, 1.5, 0.0], [0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
([0.0, 0.0, 10.0], [0.0, 0.0, -1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
([-1.0, 0.0, 6.0], [0.3, 0.0, -1.0], [1.0, 0.0, 0.3], [0.0, 1.0, 0.0]),
];
let render = |k: usize| {
let (r, c, f) = t.cams[k];
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
let d = mtv(&r, [(x as f64 + 0.5 - w as f64 / 2.0) / f, (y as f64 + 0.5 - h as f64 / 2.0) / f, 1.0]);
let mut best = (f64::INFINITY, 0.0f32);
for (i, (p0, n, ua, va)) in planes.iter().enumerate() {
let den = dot(*n, d);
if den.abs() < 1e-9 {
continue;
}
let tt = dot(*n, sub(*p0, c)) / den;
let hit = add(c, scale(d, tt));
// The box face is limited in extent.
if i == 2 && (dot(sub(hit, *p0), *ua).abs() > 1.2 || dot(sub(hit, *p0), *va).abs() > 1.0) {
continue;
}
if tt > 0.1 && tt < best.0 {
best = (tt, tex(dot(sub(hit, *p0), *ua) + 10.0 * i as f64, dot(sub(hit, *p0), *va)));
}
}
let v = best.1;
img.set(x, y, [v, v, v, 1.0]);
}
}
img
};
let mut an = TrackAnalyzer::new([w as f64, h as f64], AnalyzeOpts::default());
for k in 0..frames {
let img = render(k);
an.push(&crate::Frame::new(&img));
}
let tr = an.finish(0.0, 1.0 / 24.0);
assert!(tr.tracks.len() > 100, "{}", tr.tracks.len());
let s = solve(&tr, &settings(ShotType::FixedAngle, SolveMethod::Auto), None).expect("solve");
eprintln!("rendered: {:?} err {:.3} focal {:.1} points {}", s.method_used, s.average_error, s.focal(), s.points.len());
assert!(s.average_error < 0.5, "{}", s.average_error);
assert!((s.focal() - f).abs() / f < 0.05, "{}", s.focal());
let (pe, _) = path_error(&s, &t);
assert!(pe < 0.03, "{pe}");
}
/// A long clip (300 frames, ~900 points): `cargo test -p effectcraft-track --release -- --ignored`.
#[test]
#[ignore]
fn perf_long_clip() {
let f = 1100.0;
let t = moving_camera(300, |_| f);
let pts = random_points(900, 21, [0.0, 0.0, 10.0], [16.0, 9.0, 8.0]);
let tr = tracks_of(&pts, &t, 22, true);
let t0 = std::time::Instant::now();
let s = solve(&tr, &settings(ShotType::FixedAngle, SolveMethod::Auto), None).expect("solve");
eprintln!("300 frames, {} tracks: {:?} err {:.3} focal {:.1} in {:?}", tr.tracks.len(), s.method_used, s.average_error, s.focal(), t0.elapsed());
assert!(s.average_error < 0.5);
}
/// Solve tracks saved from the app (`EC_CAMTRACK_FILE=tracks.json cargo test -p effectcraft-track
/// --release solve_saved_tracks -- --ignored --nocapture`): a debugging aid.
#[test]
#[ignore]
fn solve_saved_tracks() {
let Some(path) = std::env::var_os("EC_CAMTRACK_FILE") else { return };
let mut tr = CameraTracks::from_json(&std::fs::read_to_string(path).unwrap()).unwrap();
if let Some(maxy) = std::env::var("EC_CAMTRACK_MAXY").ok().and_then(|v| v.parse::<f32>().ok()) {
tr.tracks.retain(|t| t.pts.iter().all(|p| p[1] < maxy));
}
let shot = if std::env::var_os("EC_CAMTRACK_VARIABLE").is_some() { ShotType::VariableZoom } else { ShotType::FixedAngle };
let t0 = std::time::Instant::now();
// With the true cameras (`[{pos, poi, zoom}]` per frame, two-node), how consistent are the
// tracks themselves?
if let Some(tp) = std::env::var_os("EC_CAMTRACK_TRUTH") {
let v: Vec<serde_json::Value> = serde_json::from_str(&std::fs::read_to_string(tp).unwrap()).unwrap();
let g = |x: &serde_json::Value| -> V3 { [x[0].as_f64().unwrap(), x[1].as_f64().unwrap(), x[2].as_f64().unwrap()] };
let cams: Vec<(super::geometry::Pose, f64)> = v
.iter()
.map(|c| {
let (pos, poi) = (g(&c["pos"]), g(&c["poi"]));
let fwd = normalize(sub(poi, pos));
let d0 = [0.0, 1.0, 0.0];
let down = normalize(sub(d0, scale(fwd, dot(d0, fwd))));
let right = cross(down, fwd);
(super::geometry::Pose { r: [right, down, fwd], c: pos }, c["zoom"].as_f64().unwrap())
})
.collect();
let (w, h) = (tr.size[0], tr.size[1]);
let mut means = vec![];
let mut drop_ids = vec![];
for t in &tr.tracks {
let views: Vec<(super::geometry::Pose, [f64; 2])> = t
.pts
.iter()
.enumerate()
.map(|(i, p)| {
let (pose, z) = cams[t.start as usize + i];
(pose, [(p[0] as f64 - w / 2.0) / z, (p[1] as f64 - h / 2.0) / z])
})
.collect();
let Some(x) = super::geometry::triangulate(&views) else { continue };
let e: Vec<f64> = views.iter().filter_map(|(p, o)| super::geometry::reproj2(p, x, *o).map(|e| e.sqrt() * cams[0].1)).collect();
means.push((e.iter().sum::<f64>() / e.len().max(1) as f64, t.pts[0]));
if std::env::var_os("EC_CAMTRACK_TRUTH_FILTER").is_some() && means.last().unwrap().0 > 1.0 {
drop_ids.push(t.id);
}
}
tr.tracks.retain(|t| !drop_ids.contains(&t.id));
means.sort_by(|a, b| a.0.total_cmp(&b.0));
eprintln!(
"true-camera track errors (px): median {:.2}; quartiles {:?}",
means[means.len() / 2].0,
[means[means.len() / 4].0, means[means.len() * 3 / 4].0]
);
for m in means.iter().step_by(6) {
eprintln!(" {:.2} at {:?}", m.0, m.1);
}
}
let mut st = settings(shot, SolveMethod::Auto);
if let Some(a) = std::env::var("EC_CAMTRACK_AOV").ok().and_then(|v| v.parse::<f64>().ok()) {
st.shot = ShotType::SpecifyAngle;
st.hfov = a;
}
let s = solve(&tr, &st, None).expect("solve");
eprintln!(
"{} frames, {} tracks: {:?} err {:.3} focal {:.1} (hfov {:.1}) points {} in {:?}",
tr.frames,
tr.tracks.len(),
s.method_used,
s.average_error,
s.focal(),
s.hfov(s.focal()),
s.points.len(),
t0.elapsed()
);
}

View File

@ -18,6 +18,7 @@
//! Coordinates are *layer* pixels: the frame image's pixel `(i, j)` covers `[i, i+1) × [j, j+1)`
//! after subtracting the frame's `offset` (see [`Frame`]).
pub mod camtrack;
pub mod fit;
pub mod klt;
pub mod mask;

View File

@ -750,6 +750,11 @@ impl EffectcraftApp {
self.session.poll_roto(true);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// 3D Camera Tracker: likewise (tracking, then solving).
if self.session.camera_job.is_some() || !self.session.camera_pending.is_empty() {
self.session.poll_camera(true);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.apply_prefs(&ctx);
self.handle_events(&ctx);
self.tick_autosave(&ctx);

View File

@ -0,0 +1,438 @@
//! 3D Camera Tracker in the Composition viewer and Effect Controls:
//!
//! - while the effect is selected, the solved 3D track points are drawn as coloured targets
//! sized by depth (or the 2D source tracks with Show Track Points ▸ 2D Source);
//! - hovering between three points shows the target plane they span (a disc in perspective);
//! click a point (Shift adds / toggles) or drag a marquee to select points, click inside a
//! triangle to select its three points; the selection's target is drawn;
//! - right-click: Set Ground Plane and Origin, Create Text / Solid / Null / Shadow Catcher …
//! and Camera, Create Multiple …, Delete Selected Points; Delete removes the selection;
//! - the "Analyzing in background (step 1 of 2)" / "Solving camera" banner;
//! - the Effect Controls block with Analyze / Cancel / Create Camera, Method Used and Average
//! Error.
//!
//! Everything is dispatched to the `camera.*` commands.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::effects::camera_tracker as ct;
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::keyframe::Value;
use effectcraft_engine::project::{Comp, Layer, LayerId, PropGroup, Uid};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::track::camtrack::{CameraSolve, CameraTracks, Target, point_color};
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use super::viewer::ViewerMap;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
/// The tracker whose points the viewer shows.
struct Active {
layer: LayerId,
uid: Uid,
solve: Option<Arc<CameraSolve>>,
tracks: Option<Arc<CameraTracks>>,
show_3d: bool,
point_size: f64,
target_size: f64,
/// Layer pixels → comp pixels.
l2c: Mat3,
frame: usize,
}
/// A drawn point: (id, screen position, screen radius).
type Hit = (u32, Pos2, f32);
fn tracker_group(l: &Layer, uid: Uid) -> Option<&PropGroup> {
l.props.find_group(uid).filter(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == ct::ID))
}
/// The selected 3D Camera Tracker effect on an active layer (After Effects shows the points
/// while the effect is selected).
fn active(s: &Session, comp: &Comp, ectx: &EvalCtx, l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3) -> Option<Active> {
let time = ectx.time;
for (lid, uid) in &s.state.selected_props {
let Some(l) = comp.layer(*lid).filter(|l| l.is_active_at(time)) else { continue };
let Some(g) = tracker_group(l, *uid) else { continue };
let params = effectcraft_engine::camera_track::static_params(g);
let val = |id: &str| effectcraft_engine::effects::warp_stab::param(&params, id).cloned();
let solve = ct::solve(&params);
let tracks = ct::tracks(&params);
let lt = l.layer_time(time).seconds();
let frame = solve.as_ref().and_then(|sv| sv.frame_at(lt)).or_else(|| {
let t = tracks.as_ref()?;
(t.frame_duration > 0.0).then(|| ((lt - t.start) / t.frame_duration).round().clamp(0.0, t.frames.saturating_sub(1) as f64) as usize)
});
return Some(Active {
layer: *lid,
uid: *uid,
solve,
tracks,
show_3d: val("showTrackPoints").map(|v| v.as_enum()).unwrap_or(1) == 1,
point_size: val("trackPointSize").map(|v| v.as_f64()).unwrap_or(100.0),
target_size: val("targetSize").map(|v| v.as_f64()).unwrap_or(100.0),
l2c: l2c(ectx, l),
frame: frame.unwrap_or(0),
});
}
None
}
fn to_screen(a: &Active, map: &ViewerMap, q: [f64; 2]) -> Pos2 {
let c = a.l2c.apply(gv2(q[0], q[1]));
map.to_screen([c.x, c.y])
}
fn col(id: u32) -> Color32 {
let c = point_color(id);
Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)
}
/// Comp-pixel scale of the layer → comp mapping.
fn l2c_scale(m: &Mat3) -> f32 {
m.0[0][0].hypot(m.0[1][0]) as f32
}
/// Draw the target disc (concentric rings in perspective) of `t` on frame `k`.
fn draw_target(p: &egui::Painter, a: &Active, map: &ViewerMap, sv: &CameraSolve, t: &Target) {
let Some(cam) = sv.frames.get(a.frame) else { return };
let r = t.size * 0.5 * a.target_size / 100.0;
for (i, frac) in [1.0, 0.66, 0.33].into_iter().enumerate() {
let ring = ct::target_circle(t.center, t.normal, r * frac, 48);
let pts: Vec<Pos2> = ring.iter().filter_map(|x| cam.project(sv.size, *x)).map(|q| to_screen(a, map, q)).collect();
if pts.len() < 3 {
return;
}
let fill = if i % 2 == 0 { Color32::from_rgba_unmultiplied(0xd0, 0x30, 0x30, 70) } else { Color32::from_rgba_unmultiplied(0xff, 0xff, 0xff, 50) };
p.add(egui::Shape::convex_polygon(pts.clone(), fill, Stroke::new(1.0, Color32::from_rgb(0xe0, 0x40, 0x40))));
}
if let Some(c) = cam.project(sv.size, t.center) {
let c = to_screen(a, map, c);
p.circle_filled(c, 3.0, Color32::from_rgb(0xff, 0xd0, 0x40));
}
}
/// The three drawn points forming a triangle around `hp` (nearest first).
fn triangle_at(hits: &[Hit], hp: Pos2) -> Option<[u32; 3]> {
let mut near: Vec<&Hit> = hits.iter().collect();
near.sort_by(|x, y| x.1.distance_sq(hp).total_cmp(&y.1.distance_sq(hp)));
let near: Vec<&Hit> = near.into_iter().take(8).collect();
let inside = |a: Pos2, b: Pos2, c: Pos2| {
let s = |p: Pos2, q: Pos2| (q.x - p.x) * (hp.y - p.y) - (q.y - p.y) * (hp.x - p.x);
let (d1, d2, d3) = (s(a, b), s(b, c), s(c, a));
!((d1 < 0.0 || d2 < 0.0 || d3 < 0.0) && (d1 > 0.0 || d2 > 0.0 || d3 > 0.0))
};
for i in 0..near.len() {
for j in i + 1..near.len() {
for k in j + 1..near.len() {
let (a, b, c) = (near[i], near[j], near[k]);
// Skip slivers.
let area = ((b.1 - a.1).x * (c.1 - a.1).y - (b.1 - a.1).y * (c.1 - a.1).x).abs();
if area > 30.0 && inside(a.1, b.1, c.1) {
return Some([a.0, b.0, c.0]);
}
}
}
}
None
}
fn run(app: &mut EffectcraftApp, ctx: &egui::Context, cmd: &str, p: serde_json::Value) {
if let Err(e) = crate::menus::invoke(app, ctx, cmd, p) {
app.ui.status = e;
}
}
/// The viewer overlay and its interaction (call after the viewer's own gestures so it takes
/// the clicks while a 3D Camera Tracker is selected).
pub fn viewer_hook(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
map: &ViewerMap,
ectx: &EvalCtx,
l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3,
) {
let ctx = ui.ctx().clone();
let comp = ectx.comp;
banner(app, painter, map, ectx, l2c);
if !app.ui.viewer.show_layer_controls {
return;
}
let Some(a) = active(&app.session, comp, ectx, l2c) else { return };
let selected = app.session.state.camera_points.clone();
let lscale = l2c_scale(&a.l2c) * map.zoom;
let mut hits: Vec<Hit> = vec![];
match (&a.solve, a.show_3d) {
(Some(sv), true) => {
let pts = ct::projected(sv, a.frame);
let med = ct::median_depth(&pts);
for (id, q, z) in pts {
let sp = to_screen(&a, map, q);
if !map.area.expand(20.0).contains(sp) {
continue;
}
let r = (ct::target_radius(a.point_size, z, med) as f32 * lscale).clamp(2.0, 30.0);
let c = col(id);
let sel = selected.contains(&id);
if sel {
painter.circle_filled(sp, r, c);
painter.circle_stroke(sp, r + 1.5, Stroke::new(1.5, Color32::WHITE));
} else {
painter.circle_stroke(sp, r, Stroke::new((r * 0.3).clamp(1.0, 3.0), c));
painter.circle_filled(sp, (r * 0.25).max(1.0), c);
}
hits.push((id, sp, r));
}
}
_ => {
// 2D source tracks (also shown before the solve finishes).
if let Some(tr) = &a.tracks {
for t in &tr.tracks {
let Some(q) = t.at(a.frame as u32) else { continue };
let sp = to_screen(&a, map, q);
let c = col(t.id);
let r = (3.0 * a.point_size as f32 / 100.0).clamp(1.5, 12.0);
painter.line_segment([sp - vec2(r, r), sp + vec2(r, r)], Stroke::new(1.2, c));
painter.line_segment([sp - vec2(r, -r), sp + vec2(r, -r)], Stroke::new(1.2, c));
hits.push((t.id, sp, r));
}
}
}
}
let Some(sv) = a.solve.clone() else { return };
if app.ui.tool != crate::state::Tool::Selection {
return;
}
// Interaction over the image.
let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), Sense::click_and_drag());
app.auto.add("viewer.cameraTracker", map.area, "3D Camera Tracker points");
for (id, sp, r) in hits.iter().take(400) {
app.auto.add(&format!("viewer.cameraTracker.point.{id}"), Rect::from_center_size(*sp, vec2(r * 2.0 + 4.0, r * 2.0 + 4.0)), "track point");
}
let hover = resp.hover_pos();
let point_at = |p: Pos2| hits.iter().filter(|h| h.1.distance(p) <= h.2 + 3.0).min_by(|x, y| x.1.distance(p).total_cmp(&y.1.distance(p))).map(|h| h.0);
let tri = hover.filter(|p| point_at(*p).is_none()).and_then(|p| triangle_at(&hits, p));
let mid = egui::Id::new("viewer-camera-tracker-marquee");
let marquee: Option<Pos2> = ui.data(|d| d.get_temp(mid));
// Hover target / the selection's target.
if marquee.is_none() {
if let Some(t3) = tri
&& let Some(t) = effectcraft_engine::camera_track::target_for(&sv, &t3, a.frame)
{
let ps: Vec<Pos2> = t3.iter().filter_map(|i| hits.iter().find(|h| h.0 == *i)).map(|h| h.1).collect();
if ps.len() == 3 {
painter.add(egui::Shape::closed_line(ps, Stroke::new(1.0, Color32::from_white_alpha(200))));
}
draw_target(painter, &a, map, &sv, &t);
} else if !selected.is_empty()
&& let Some(t) = effectcraft_engine::camera_track::target_for(&sv, &selected, a.frame)
{
draw_target(painter, &a, map, &sv, &t);
}
}
let mods = ui.input(|i| i.modifiers);
if resp.drag_started()
&& let Some(p) = ui.input(|i| i.pointer.press_origin())
{
ui.data_mut(|d| d.insert_temp(mid, p));
}
if let (Some(start), Some(p)) = (marquee, resp.interact_pointer_pos()) {
let r = Rect::from_two_pos(start, p);
let t = app.tokens;
painter.rect_filled(r, 0.0, t.accent.gamma_multiply(0.12));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Middle);
if resp.drag_stopped() {
let ids: Vec<u32> = hits.iter().filter(|h| r.contains(h.1)).map(|h| h.0).collect();
ui.data_mut(|d| d.remove::<Pos2>(mid));
run(app, &ctx, "camera.selectPoints", json!({"points": ids, "add": mods.shift}));
}
} else if resp.drag_stopped() {
ui.data_mut(|d| d.remove::<Pos2>(mid));
}
if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
match (point_at(p), triangle_at(&hits, p)) {
(Some(id), _) => run(app, &ctx, "camera.selectPoints", json!({"points": [id], "toggle": mods.shift})),
(None, Some(t3)) => run(app, &ctx, "camera.selectPoints", json!({"points": t3, "add": mods.shift})),
(None, None) => run(app, &ctx, "camera.selectPoints", json!({"points": []})),
}
}
// Right-click: the points under the cursor (selection, else the hovered triangle).
let cid = egui::Id::new("viewer-camera-tracker-menu-points");
if resp.secondary_clicked() {
let pts: Vec<u32> = if !selected.is_empty() {
selected.clone()
} else if let Some(p) = resp.interact_pointer_pos() {
point_at(p).map(|i| vec![i]).or_else(|| triangle_at(&hits, p).map(|t| t.to_vec())).unwrap_or_default()
} else {
vec![]
};
ui.data_mut(|d| d.insert_temp(cid, pts));
}
let base = json!({"layer": a.layer.0, "effect": a.uid});
resp.context_menu(|ui| {
let pts: Vec<u32> = ui.data(|d| d.get_temp(cid)).unwrap_or_default();
let with = |kind: Option<&str>, multiple: bool| {
let mut q = base.clone();
q["points"] = json!(pts);
if let Some(k) = kind {
q["kind"] = json!(k);
}
if multiple {
q["multiple"] = json!(true);
}
q
};
let some = !pts.is_empty();
let many = pts.len() > 1;
let mut pick: Option<(&str, serde_json::Value)> = None;
if ui.add_enabled(some, egui::Button::new("Set Ground Plane and Origin")).clicked() {
pick = Some(("camera.setGroundPlane", with(None, false)));
}
ui.separator();
for (label, kind) in [
("Create Text and Camera", "text"),
("Create Solid and Camera", "solid"),
("Create Null and Camera", "null"),
("Create Shadow Catcher, Camera and Light", "shadowCatcher"),
] {
if ui.add_enabled(some, egui::Button::new(label)).clicked() {
pick = Some(("camera.createFromSolve", with(Some(kind), false)));
}
}
ui.separator();
for (label, kind) in
[("Create Multiple Text Layers and Camera", "text"), ("Create Multiple Solids and Camera", "solid"), ("Create Multiple Nulls and Camera", "null")]
{
if ui.add_enabled(many, egui::Button::new(label)).clicked() {
pick = Some(("camera.createFromSolve", with(Some(kind), true)));
}
}
ui.separator();
if ui.add_enabled(some, egui::Button::new("Delete Selected Points")).clicked() {
pick = Some(("camera.deletePoints", with(None, false)));
}
if let Some((cmd, q)) = pick {
ui.close();
run(app, &ctx, cmd, q);
}
});
if resp.hovered()
&& !selected.is_empty()
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
&& !ctx.egui_wants_keyboard_input()
{
run(app, &ctx, "camera.deletePoints", json!({"layer": a.layer.0, "effect": a.uid, "points": selected}));
}
}
/// "Analyzing in background (step 1 of 2)" / "Solving camera" over the analysed layer, and a
/// warning when the solve failed (unless Hide Warning Banner).
fn banner(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3) {
let comp = ectx.comp;
let time = ectx.time;
let cid = ectx.comp_id;
let draw = |app: &mut EffectcraftApp, layer: &Layer, text: String, color: Color32, id: &str| {
let m = l2c(ectx, layer);
let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
let c = m.apply(gv2(w as f64 / 2.0, h as f64 / 2.0));
let at = map.to_screen([c.x, c.y]);
let galley = painter.layout_no_wrap(text.clone(), Tokens::medium(13.0), Color32::WHITE);
let r = Rect::from_center_size(at, galley.size() + vec2(28.0, 14.0)).intersect(map.area);
painter.rect_filled(r, 3.0, color);
painter.galley(pos2(r.center().x - galley.size().x / 2.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
app.auto.add(id, r, &text);
};
if let Some((cc, cl, _)) = app.session.camera_target()
&& cc == cid
&& let Some(pr) = app.session.camera_progress()
&& let Some(layer) = comp.layer(cl)
&& layer.is_active_at(time)
{
draw(app, layer, pr.banner(), Color32::from_rgba_unmultiplied(0x1d, 0x4f, 0x9c, 0xe0), "viewer.cameraBanner");
return;
}
// A failed solve: tracks without a solve while nothing is running or queued.
for l in comp.layers.iter().filter(|l| l.is_active_at(time)) {
let Some(fx) = l.effects() else { continue };
for g in fx.groups().filter(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == ct::ID)) {
let params = effectcraft_engine::camera_track::static_params(g);
let hide = effectcraft_engine::effects::warp_stab::param(&params, "advanced/hideWarningBanner").is_some_and(Value::as_bool);
let solve_key = matches!(g.prop(ct::SOLVE_KEY).map(|p| &p.value), Some(Value::Str(s)) if !s.is_empty());
let failed = solve_key && ct::tracks(&params).is_some() && ct::solve(&params).is_none();
if failed && !hide && !app.session.camera_pending.iter().any(|(_, ll, u)| *ll == l.id && *u == g.uid) {
draw(app, l, "Unable to solve camera".into(), Color32::from_rgba_unmultiplied(0x9c, 0x2a, 0x1d, 0xe0), "viewer.cameraWarning");
return;
}
}
}
}
/// Effect Controls: Analyze / Cancel, Create Camera and the solve report.
#[allow(clippy::too_many_arguments)]
pub fn editor(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
g: &PropGroup,
r: Rect,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let t = app.tokens;
let running = app.session.camera_target().is_some_and(|(_, l, u)| l == layer.id && u == g.uid) && app.session.is_camera_analyzing();
let busy = app.session.is_camera_analyzing();
let me = json!({"layer": layer.id.0, "effect": g.uid});
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0));
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::new(("ct-analyze", g.uid))).clicked() && !busy {
actions.push(("camera.analyze".into(), me.clone()));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze");
if running {
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::new(("ct-cancel", g.uid))).clicked() {
actions.push(("camera.cancel".into(), json!({})));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel");
}
let params = effectcraft_engine::camera_track::static_params(g);
let solve = ct::solve(&params);
if solve.is_some() {
if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::new(("ct-camera", g.uid))).clicked() {
actions.push(("camera.create".into(), me.clone()));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera");
}
let status = match app.session.camera_progress() {
Some(pr) if running => pr.banner(),
_ => match &solve {
Some(sv) => format!("Method Used: {} Average Error: {:.2} pixels", sv.method_used.label(), sv.average_error),
None if ct::tracks(&params).is_some() && app.session.camera_pending.iter().any(|(_, l, u)| *l == layer.id && *u == g.uid) => {
"Waiting to solve".into()
}
None if ct::tracks(&params).is_some() => "Unable to solve camera: try another Solve Method or Shot Type".into(),
None if app.session.camera_pending.iter().any(|(_, l, u)| *l == layer.id && *u == g.uid) => "Waiting to analyze".into(),
None => "Not analyzed: click Analyze".into(),
},
};
p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim);
if let Some(sv) = &solve {
let f = sv.focal();
p.text(
pos2(r.min.x + 24.0, r.min.y + 56.0),
Align2::LEFT_CENTER,
format!("{} solved points · Horizontal Angle of View {:.1}°", sv.points.len(), sv.hfov(f)),
Tokens::ui(11.5),
t.text_faint,
);
}
}
/// Height of [`editor`].
pub const EDITOR_HEIGHT: f32 = 68.0;

View File

@ -574,6 +574,7 @@ fn editor_height(effect: &str, width: f32) -> f32 {
match effect {
"ec.color.curves" => 34.0 + curves_size(width) + 26.0,
effectcraft_engine::effects::warp_stab::ID => 50.0,
effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::EDITOR_HEIGHT,
effectcraft_engine::effects::roto::ID => 50.0,
"ec.color.levels" | "ec.color.levelsic" => 34.0 + 80.0 + 58.0,
"ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" => 24.0 + 80.0 + 12.0,
@ -950,6 +951,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
match effect.as_str() {
"ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions),
effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, &bp, &layer, g, er, &mut actions),
effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::editor(app, ui, &bp, &layer, g, er, &mut actions),
effectcraft_engine::effects::roto::ID => roto_editor(app, ui, &bp, &layer, g, er, &mut actions),
"ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions),
_ => histogram_only(app, ui, &bp, g, er),

View File

@ -1,6 +1,7 @@
//! Panel bodies.
pub mod anim_tools;
pub mod camera_tracker_ui;
pub mod comp_settings;
pub mod dialogs;
pub mod dialogs_3d;

View File

@ -130,11 +130,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let cur = current(app);
let can_track = has_comp && source.is_some();
// Track Camera / Warp Stabilizer (not available) and Track Motion / Stabilize Motion.
// Track Camera / Warp Stabilizer and Track Motion / Stabilize Motion.
let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
button(app, ui, r1, "Track Camera", false, "tracker.trackCamera");
let can_camera = app.session.is_enabled("track.camera") && !app.session.is_camera_analyzing();
if button(app, ui, r1, "Track Camera", can_camera, "tracker.trackCamera") {
run(app, &ctx, "track.camera", json!({}));
}
let can_warp = app.session.is_enabled("track.warpStabilizer") && !app.session.is_warp_analyzing();
if button(app, ui, r2, "Warp Stabilizer", can_warp, "tracker.warpStabilizer") {
run(app, &ctx, "track.warpStabilizer", json!({}));

View File

@ -1144,6 +1144,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.session.open_comp(*item);
}
}
// 3D Camera Tracker points, targets and their menu (on top of the viewer's gestures while
// the effect is selected), and its analysis banner.
super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);

View File

@ -0,0 +1,175 @@
//! Headless checks for the 3D Camera Tracker UI (egui_kittest, UI logic only): the viewer's track
//! points and selection, the Effect Controls buttons and the Tracker panel's Track Camera button.
//! The solve is a synthetic one written into the effect (the analysis itself is tested in the
//! engine and track crates).
use effectcraft_engine::Session;
use effectcraft_engine::effects::camera_tracker as ct;
use effectcraft_engine::track::camtrack::{CameraSolve, ShotType, SolveMethod, SolvedFrame, SolvedPoint};
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui_kittest::Harness;
use serde_json::json;
const W: f64 = 640.0;
const H: f64 = 360.0;
fn app() -> (EffectcraftApp, u64, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Shot", "width": W, "height": H, "duration": 2})).unwrap();
let plate = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [plate]})).unwrap();
let uid = r["effects"][0].as_u64().unwrap();
// A synthetic solve: a static camera (focal 600) looking at a 6 × 4 grid of points on z = 1.
let frames = 60;
let solve = CameraSolve {
version: 1,
size: [W, H],
start: 0.0,
frame_duration: 1.0 / 30.0,
shot: ShotType::FixedAngle,
method_used: SolveMethod::Typical,
average_error: 0.21,
frames: vec![SolvedFrame { rot: [0.0; 3], center: [0.0; 3], focal: 600.0, solved: true }; frames],
points: (0..24)
.map(|i| SolvedPoint {
id: i,
pos: [((i % 6) as f64 - 2.5) * 0.15, ((i / 6) as f64 - 1.5) * 0.12, 1.0 + 0.01 * (i % 3) as f64],
error: 0.2,
first: 0,
last: frames as u32 - 1,
})
.collect(),
ground: None,
};
s.execute("prop.set", json!({"layer": plate, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap();
s.camera_pending.clear();
s.state.selected_props = vec![(effectcraft_engine::project::LayerId(plate), uid)];
s.execute("layer.select", json!({"layers": [plate]})).unwrap();
s.state.selected_props = vec![(effectcraft_engine::project::LayerId(plate), uid)];
(EffectcraftApp::new(s), plate, uid)
}
fn ids(h: &Harness<'_, EffectcraftApp>) -> Vec<String> {
h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).map(|e| e.id.clone()).collect()
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
}
fn click(h: &mut Harness<'_, EffectcraftApp>, at: egui::Pos2, modifiers: egui::Modifiers) {
h.input_mut().events.push(egui::Event::ModifiersChanged(modifiers));
h.input_mut().events.push(egui::Event::PointerMoved(at));
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.run_steps(3);
h.input_mut().events.push(egui::Event::ModifiersChanged(Default::default()));
h.step();
}
#[test]
fn viewer_points_selection_and_effect_controls() {
let (a, plate, uid) = app();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| a);
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(4);
let all = ids(&h);
assert!(all.iter().any(|i| i == "viewer.cameraTracker"), "track point overlay registered");
let mut pts: Vec<&String> = all.iter().filter(|i| i.starts_with("viewer.cameraTracker.point.")).collect();
pts.sort();
pts.dedup();
let n = pts.len();
assert_eq!(n, 24, "every visible point is drawn");
// Click a point: it is selected; Shift-click another adds it.
let r = rect(&h, "viewer.cameraTracker.point.7");
click(&mut h, r.center(), Default::default());
assert_eq!(h.state().session.state.camera_points, vec![7]);
let r = rect(&h, "viewer.cameraTracker.point.9");
click(&mut h, r.center(), egui::Modifiers::SHIFT);
assert_eq!(h.state().session.state.camera_points, vec![7, 9]);
// Marquee over the first row selects its six points.
let (a0, a5) = (rect(&h, "viewer.cameraTracker.point.0"), rect(&h, "viewer.cameraTracker.point.5"));
let from = a0.left_top() - egui::vec2(6.0, 6.0);
let to = a5.right_bottom() + egui::vec2(6.0, 6.0);
h.input_mut().events.push(egui::Event::PointerMoved(from));
h.input_mut().events.push(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=8 {
h.input_mut().events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
let mut sel = h.state().session.state.camera_points.clone();
sel.sort();
assert_eq!(sel, vec![0, 1, 2, 3, 4, 5]);
// The right-click menu's Create Solid and Camera (via its command).
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "camera.createFromSolve", json!({"kind": "solid"})).unwrap();
let comp = h.state().session.active_comp().unwrap();
assert!(comp.layers.iter().any(|l| l.name == "3D Tracker Camera" && l.is_camera()));
assert!(comp.layers.iter().any(|l| l.name.starts_with("Track Solid")));
// Effect Controls: Analyze and Create Camera.
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.state_mut().show_panel(PanelKind::EffectControls);
h.run_steps(4);
let all = ids(&h);
for want in [format!("effectControls.cameraTracker.{uid}.analyze"), format!("effectControls.cameraTracker.{uid}.createCamera")] {
assert!(all.contains(&want), "missing {want}");
}
// Tracker panel: Track Camera is available for the selected layer.
h.state_mut().show_panel(PanelKind::Tracker);
h.run_steps(3);
assert!(ids(&h).iter().any(|i| i == "tracker.trackCamera"));
assert!(h.state().session.is_enabled("track.camera"));
}
/// Step the UI until background frame renders have landed.
fn settle(h: &mut Harness<'_, EffectcraftApp>) {
for _ in 0..600 {
h.step();
if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
for _ in 0..4 {
h.step();
}
}
#[test]
#[ignore = "analyses the demo's 3D Showcase and renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"]
fn snapshot_showcase_track() {
let dir = std::env::var("EC_SNAPSHOT_DIR").ok();
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("comp.new", json!({"name": "Camera Track", "width": 1920, "height": 1080, "duration": 4, "frameRate": 29.97})).unwrap();
s.execute("layer.addItem", json!({"item": "3D Showcase"})).unwrap();
s.execute("layer.select", json!({"layers": ["#1"]})).unwrap();
s.execute("track.camera", json!({"layer": "#1", "wait": true})).unwrap();
s.execute("time.set", json!({"time": 2.0})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(1.0).wgpu().build_eframe(|_| EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
settle(&mut h);
let pts: Vec<String> = ids(&h).into_iter().filter(|i| i.starts_with("viewer.cameraTracker.point.")).collect();
assert!(pts.len() > 10, "{}", pts.len());
// Hover between points to show a target, then select three and show the selection's target.
let r = rect(&h, &pts[0]);
h.input_mut().events.push(egui::Event::PointerMoved(r.center() + egui::vec2(12.0, 12.0)));
h.run_steps(2);
if let Some(d) = &dir {
h.render().expect("render").save(format!("{d}/camera_tracker_hover.png")).unwrap();
}
let sel: Vec<u64> = pts.iter().take(5).filter_map(|i| i.rsplit('.').next()?.parse().ok()).collect();
h.state_mut().session.execute("camera.selectPoints", json!({"points": sel})).unwrap();
h.state_mut().session.execute("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": ["#3"]})).unwrap();
settle(&mut h);
if let Some(d) = &dir {
h.render().expect("render").save(format!("{d}/camera_tracker_solid.png")).unwrap();
}
}

View File

@ -147,6 +147,34 @@ effectcraft-cli exec warp.analyze '{"layer":"#1","wait":true}' shaky.ecproj --sa
effectcraft-cli exec warp.status '{"layer":"#1"}' shaky.ecproj --json
```
### 3D Camera Tracker
Animation ▸ Track Camera and the Tracker panel's button (`track.camera {layer?, shotType?:
fixed|variable|specify, aov?, solveMethod?: auto|typical|flat|tripod, detailed?, wait?}`) apply
Effect ▸ Perspective ▸ 3D Camera Tracker (or reuse the layer's) and analyse it in the background:
"Analyzing in background (step 1 of 2)" tracks features, "Solving camera" solves.
`camera.analyze {layer?, effect?, wait?}` re-runs it, `camera.cancel` stops it without writing
anything. `camera.solveStatus` reports `progress`/`banner`, `analyzed`, `solved`, `methodUsed`,
`averageError` (pixels), `focalLength`, `horizontalAngleOfView`, the current frame's `camera`
(position, orientation, zoom) and `groundPlane`. `camera.points {time?}` lists the solved points
visible now with their `id`, `comp` position, `depth`, `world` position and `error`.
Select points with `camera.selectPoints {points, add?, toggle?}` (the viewer's click / Shift-click /
marquee), then the right-click menu's commands: `camera.setGroundPlane {points?}` (Set Ground Plane
and Origin), `camera.createFromSolve {kind: text|solid|null|shadowCatcher, points? | target:
{center, normal, size?}, multiple?}` (Create Text / Solid / Null / Shadow Catcher, Camera and Light,
Create Multiple …), `camera.deletePoints {points?, wait?}` (re-solves; with Auto-delete Points
Across Time the same feature's other tracks go too) and `camera.create` (the Create Camera button).
Each is one undo step; the first create adds the one-node "3D Tracker Camera" keyed on every frame.
Changing the layer's frames clears the analysis; changing `effects/#1/shotType`,
`effects/#1/horizontalAngleOfView`, `effects/#1/advanced/solveMethod` re-solves the stored tracks.
```sh
effectcraft-cli run shot.ecproj track.camera '{"layer":"#1","wait":true}' \
camera.points '{}' --json
effectcraft-cli run shot.ecproj camera.createFromSolve '{"kind":"solid","points":[12,40,77]}' --save
```
### Roto Brush & Refine Edge
The Roto Brush tool (Alt+W cycles Roto Brush / Refine Edge) paints in the Layer panel; agents use

View File

@ -219,6 +219,22 @@ auto-scale; cached per analysis and settings) and warps each frame; Synthesize E
borders from neighbouring frames read with `EffectHost::self_at`. The effect lives in the
effects crate, hence the `effects → track` edge.
**3D Camera Tracker** (`effects::camera_tracker`, `engine::camera_track`, `effectcraft-track`'s
`camtrack`): `camera.analyze` / `track.camera` render the layer's input to the effect on a
background thread and run structure from motion in two steps. Step 1 follows Shi–Tomasi features
through the clip with pyramidal Lucas–Kanade (forward–backward checked, re-detected where the frame
has none) into long 2D tracks. Step 2 solves the camera: parallax keyframes; a two-view start from
the essential matrix (normalised 8-point in RANSAC) or a planar homography decomposition; incremental
resection and triangulation; a sparse Levenberg–Marquardt bundle adjustment (Schur complement over
the points, Huber loss) with the focal length fixed, shared, or per frame; a log-spaced and
golden-section focal search for Fixed Angle of View / Variable Zoom; a rotation-only model for tripod
pans, chosen by Auto Detect when it explains the tracks as well. Tracks and solve are stored as JSON
in hidden effect parameters with keys: changing the layer's frames clears both, changing Shot Type,
Angle of View, Solve Method or deleting points only re-solves. The solve's canonical frame maps to
comp space so the first frame's camera is the default comp camera (or so a chosen ground plane is
the X-Z plane at the origin); `camera.createFromSolve` keys a one-node "3D Tracker Camera" on every
frame and places text, solids, nulls or a shadow catcher and light on the target plane.
**Roto Brush & Refine Edge** (`effectcraft_track::roto`, `effects::roto`, `engine::roto`):
strokes (foreground, background, Refine Edge) are stored as JSON in the effect's hidden Strokes
parameter with the base frame and segmentation span. Each frame is segmented by graph cut

View File

@ -15,7 +15,7 @@ rendered, does not count as done.
| P1 (professional daily use) | ≈ 38% |
| P2 (long tail) | ≈ 17% |
| Features done / partial / missing | 18 / 60 / 14 of 92 |
| **Effects** | **297 of 298** After Effects 2026 effects by name (99.7%, after M9.11 and Warp Stabilizer; was 257); only the 3D Camera Tracker is left |
| **Effects** | **298 of 298** After Effects 2026 effects by name (100%, after M9.11, Warp Stabilizer and the 3D Camera Tracker; was 257) |
| Remaining work | ≈ **167 agent-hours** (139 h of features + ≈ 20% for 1:1 polish against After Effects) |
| Wall-clock estimate | ≈ **42–50 hours** with five agents working in parallel and one integrating; ≈ 31–35 h for P0 + P1 only |
@ -73,13 +73,12 @@ library groundwork for mask tracking and Warp Stabilizer.
| Remaining (audit-scale agent-hours) | ≈ 167 | ≈ 100 |
| Wall clock, five parallel agents | 42–50 h | **≈ 8–10 h at today's measured pace; ≈ 25 h by the audit's conservative figures** |
What is left is concentrated in large systems: the 3D camera tracker,
the Warp Stabilizer and mask-tracking UI on top of
What is left is concentrated in large systems: the Warp Stabilizer and mask-tracking UI on top of
the new tracking library, and wasm threads. (The missing effect categories, 3D Channel, Immersive
Video and OCIO, landed in M9.11; the scripting object model in M14.4; Roto Brush and Refine Edge in
M6.7; native macOS menus, the Composition Flowchart, Timeline column/search depth, the Home screen
and every After Effects workspace in the UI-polish wave; PSD/SVG import, WebM/WAV/AIFF output and
the disk cache in the formats wave.)
the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
## By area
@ -95,18 +94,18 @@ the disk cache in the formats wave.)
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
| Text | ≈ 86% | 1.3 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no extruded strokes (extruded, bevelled text landed in M7.6); per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
| Web | 60% | 2.5 | threads, browser storage, audio |
| 3D | 72% | 12.0 | 3D camera tracker, multi-view layouts, Advanced 3D motion blur and blend modes, cameras/lights from models; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 |
| Effects | ≈ 75% | 5.0 | GPU versions of more effects and the 3D Camera Tracker (every other After Effects effect exists since M9.11 and M12.5) |
| 3D | 76% | 10.0 | multi-view layouts, Advanced 3D motion blur and blend modes, cameras/lights from models; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 |
| Effects | ≈ 75% | 5.0 | GPU versions of more effects (every After Effects effect exists since M9.11, M12.5 and M12.6) |
| Interface | 70% | 6.0 | Timeline horizontal scrolling of many columns, a richer Learn area (native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
| Masks & roto | 68% | 6.0 | variable-width mask feather points, Roto Brush's learned (3.0) segmentation model (mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Preview | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | ≈ 70% | 4.0 | 3D camera tracker, face tracking and Subspace Warp's mesh warp (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking and Warp Stabilizer landed in M6.x / M12.5) |
| Tracking | ≈ 85% | 2.5 | face tracking, Subspace Warp's mesh warp, lens distortion in the camera solve (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker landed in M6.x / M12.5 / M12.6) |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
## Effects still missing
The 3D Camera Tracker (built on the tracking library by other work). Boris FX
None: the 3D Camera Tracker landed in M12.6. Boris FX
Mocha and Cineware are third-party and not counted.
### Added in M9.11 (39 effects)