// Modified work: ported from Hugging Face `transformers` `models/sam2_video/modeling_sam2_video.py` // (Sam2VideoModel and Sam2VideoInferenceSession: memory-conditioned single-frame inference, // memory bank selection, object pointers, propagation; Apache-2.0, Copyright The HuggingFace // Inc. team) to Rust on candle. See NOTICE. //! The video tracker: per-object memory banks, prompts on any frame, propagation forwards and //! backwards with streaming results and cancellation. use std::collections::{BTreeMap, VecDeque}; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use candle_core::{DType, Tensor}; use candle_nn::{Linear, ops::sigmoid}; use crate::decoder::FeedForward; use crate::gpu::Gpu; use crate::gpu::encoder::GpuEncoder; use crate::gpu::memattn::GpuMemoryAttention; use crate::gpu::memenc::GpuMemoryEncoder; use crate::memattn::MemoryAttention; use crate::memory::{Memory, MemoryEncoder}; use crate::model::{Encoded, ImageModel}; use crate::neck::ImageFeatures; use crate::nn::{apply, linear}; use crate::prompt::{INPUT_SIZE, Prompt}; use crate::resize::antialias_bilinear; use crate::weights::Weights; use crate::{Error, Result}; /// Mask logit used where the object is absent (the reference's `NO_OBJ_SCORE`). const NO_OBJECT: f32 = -1024.0; /// Frames of memory the attention looks back over. const MEMORY_FRAMES: usize = 7; /// Object pointers kept per query. const MAX_POINTERS: usize = 16; /// Largest clip the session accepts. const MAX_FRAMES: usize = 1_000_000; const GRID: usize = 64; /// Frames of the clip, already resized to the model input and normalised: `[1, 3, 1024, 1024]`. pub trait FrameSource { fn num_frames(&self) -> usize; fn frame(&self, index: usize) -> Result; } /// Cooperative cancellation shared with the caller. #[derive(Clone, Default)] pub struct Cancel(Arc); impl Cancel { pub fn new() -> Cancel { Cancel::default() } pub fn cancel(&self) { self.0.store(true, Ordering::Relaxed); } pub fn is_cancelled(&self) -> bool { self.0.load(Ordering::Relaxed) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ObjectId(pub u32); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Direction { Forward, Backward, } /// One object's mask on one frame. #[derive(Clone, Debug, PartialEq)] pub struct FrameMask { pub frame: usize, pub object: ObjectId, /// Mask logits at 256 x 256 (positive is inside the object); `-1024` everywhere when the /// object is absent. pub logits: Vec, /// The model's own estimate of the mask quality (0..1). pub iou: f32, /// Object-presence logit; at or below zero the object is not in the frame. pub object_score: f32, pub occluded: bool, } /// Which device runs the heavy parts (the backbone, the neck and the memory attention). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Backend { /// Candle on the CPU: the reference path. Cpu, /// wgpu compute on the GPU (any vendor); an error if there is none. Gpu, /// The GPU when there is a real one and it loads, else the CPU. The environment variable /// `EFFECTCRAFT_SAM2_DEVICE` (`cpu`, `gpu` or `auto`) overrides the choice. Auto, } /// The GPU path: kernels for the backbone and memory attention. Device failures disable it; /// memory-attention budget limits fall back to the CPU for that call only. struct GpuParts { encoder: GpuEncoder, attention: GpuMemoryAttention, memory_encoder: GpuMemoryEncoder, ok: AtomicBool, adapter: String, } /// The weights of everything the video tracker needs. pub struct VideoModel { gpu: Option, pub image: ImageModel, memory_encoder: MemoryEncoder, memory_attention: MemoryAttention, temporal_pos: Vec, no_object_pointer: Tensor, pointer_proj: FeedForward, pointer_time_proj: Linear, /// `mask_downsample`: a 4 x 4 stride-4 convolution taking a mask prompt to the prompt encoder's resolution. mask_down: (Tensor, Tensor), } impl VideoModel { /// On the CPU: the reference path every test compares against. pub fn load(w: &Weights) -> Result { VideoModel::load_with(w, Backend::Cpu) } /// On the GPU when there is one (see [`Backend::Auto`]). pub fn load_auto(w: &Weights) -> Result { VideoModel::load_with(w, Backend::Auto) } pub fn load_with(w: &Weights, backend: Backend) -> Result { let backend = match (backend, std::env::var("EFFECTCRAFT_SAM2_DEVICE").ok().as_deref()) { (Backend::Auto, Some("cpu")) => Backend::Cpu, (Backend::Auto, Some("gpu")) => Backend::Gpu, (b, _) => b, }; let image = ImageModel::load(w)?; let gpu = match backend { Backend::Cpu => None, Backend::Gpu => Some(Self::load_gpu(w, &image)?), Backend::Auto => match Self::load_gpu(w, &image) { Ok(g) => Some(g), Err(e) => { log::info!("SAM 2 runs on the CPU: {e}"); None } }, }; Ok(VideoModel { gpu, image, memory_encoder: MemoryEncoder::load(w)?, memory_attention: MemoryAttention::load(w)?, temporal_pos: w.get("memory_temporal_positional_encoding", &[MEMORY_FRAMES, 1, 1, 64])?.flatten_all()?.to_vec1::()?, no_object_pointer: w.get("no_object_pointer", &[1, 256])?, pointer_proj: FeedForward::load(w, "object_pointer_proj", 256, 256, 256, 3, false)?, pointer_time_proj: linear(w, "temporal_positional_encoding_projection_layer", 256, 64)?, mask_down: (w.get("mask_downsample.weight", &[1, 1, 4, 4])?, w.get("mask_downsample.bias", &[1])?), }) } fn load_gpu(w: &Weights, image: &ImageModel) -> Result { let gpu = Gpu::shared()?; Ok(GpuParts { encoder: GpuEncoder::load(gpu.clone(), w, image.vision.hiera.clone())?, attention: GpuMemoryAttention::load(gpu.clone(), w)?, memory_encoder: GpuMemoryEncoder::load(gpu.clone(), w)?, ok: AtomicBool::new(true), adapter: gpu.adapter_name().to_string(), }) } /// The device the heavy parts run on now: the GPU's name, or "CPU". pub fn device_name(&self) -> String { match &self.gpu { Some(g) if g.ok.load(Ordering::Relaxed) => g.adapter.clone(), _ => "CPU".to_string(), } } /// Run on the CPU from now on (a GPU that misbehaves, or the user's choice). Weights and results are /// unchanged; only the device differs. pub fn disable_gpu(&self) { if let Some(g) = &self.gpu { g.ok.store(false, Ordering::Relaxed); } } fn gpu_failed(&self, what: &str, e: &Error) { if let Some(g) = &self.gpu && g.ok.swap(false, Ordering::Relaxed) { log::warn!("SAM 2 {what} failed on the GPU ({e}); the CPU takes over"); } } /// Encode a normalised `[1, 3, 1024, 1024]` frame on the GPU if it is working, else on the CPU. pub fn encode(&self, pixels: &Tensor) -> Result { if let Some(g) = self.gpu.as_ref().filter(|g| g.ok.load(Ordering::Relaxed)) { match g.encoder.encode(pixels) { Ok(f) => { let none = || Tensor::zeros((1, 0, 0, 0), DType::F32, &candle_core::Device::Cpu); let pos = self.image.vision.neck.position(GRID, GRID)?; let features = ImageFeatures { features: vec![none()?, none()?, f.low], positions: vec![none()?, none()?, pos] }; return Ok(Encoded { features, high: f.high }); } Err(e) => self.gpu_failed("encoding", &e), } } self.image.encode(pixels) } /// The memory of a frame (its features and the scaled mask), on the GPU if it is working, else on the CPU. fn memorize(&self, features: &Tensor, mask: &Tensor, present: bool) -> Result { if let Some(g) = self.gpu.as_ref().filter(|g| g.ok.load(Ordering::Relaxed)) { match g.memory_encoder.encode(features, mask).and_then(|t| self.memory_encoder.finish(&t, present)) { Ok(m) => return Ok(m), Err(e) => self.gpu_failed("memory encoding", &e), } } self.memory_encoder.memory(features, mask, present) } /// Memory attention on the GPU if it is working, else on the CPU. fn attend(&self, current: &Tensor, current_pos: &Tensor, memory: &Tensor, memory_pos: &Tensor, pointers: usize) -> Result { if let Some(g) = self.gpu.as_ref().filter(|g| g.ok.load(Ordering::Relaxed)) { match g.attention.forward(current, current_pos, memory, memory_pos, pointers) { Ok(t) => return Ok(t), Err(Error::Budget(reason)) => log::info!("SAM 2 memory attention on the CPU for this call: {reason}"), Err(e) => self.gpu_failed("memory attention", &e), } } self.memory_attention.forward(current, current_pos, memory, memory_pos, pointers) } pub fn open(path: &Path) -> Result { VideoModel::load(&Weights::open(path)?) } } /// What the tracker keeps for a frame it has processed. struct FrameOut { memory: Memory, /// `[1, 256]` pointer: Tensor, /// The mask of a conditioning frame (returned again, unchanged, when propagation passes it). result: Option, } #[derive(Default)] struct ObjectState { cond: BTreeMap, non_cond: BTreeMap, } struct FrameFeatures { /// Lowest-resolution level `[1, 256, 64, 64]` and its position encoding. low: Tensor, pos: Tensor, high: (Tensor, Tensor), } /// What a tracker may hold and take. A call that would exceed it stops with [`Error::Budget`]. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Budget { /// Memory banks, results and cached frame features (bytes). pub max_state_bytes: usize, /// Wall-clock seconds one `propagate` call may take (`None`: no limit). pub max_seconds: Option, } impl Default for Budget { fn default() -> Budget { Budget { max_state_bytes: 2 << 30, max_seconds: None } } } /// Bytes of one stored frame: memory features and position encoding `[4096, 64]`, the pointer. const FRAME_BYTES: usize = 2 * GRID * GRID * 64 * 4 + 256 * 4; /// Bytes of a conditioning frame's kept result (`256 x 256` logits). const RESULT_BYTES: usize = 4 * GRID * 4 * GRID * 4; /// Bytes of one frame's cached neck features (lowest level and position, two skip levels). const FEATURE_BYTES: usize = (2 * 256 * GRID * GRID + 32 * 16 * GRID * GRID + 64 * 4 * GRID * GRID) * 4; pub struct VideoTracker<'a> { model: Arc, frames: Box, objects: BTreeMap, cache: VecDeque<(usize, Arc)>, budget: Budget, peak: usize, } fn sine_embedding_1d(positions: &[f32], dim: usize) -> Result { let half = dim / 2; let dim_t: Vec = (0..half).map(|i| 10000f32.powf(2.0 * (i / 2) as f32 / half as f32)).collect(); let mut out = Vec::with_capacity(positions.len() * dim); for p in positions { out.extend(dim_t.iter().map(|d| (p / d).sin())); out.extend(dim_t.iter().map(|d| (p / d).cos())); } Ok(Tensor::from_vec(out, (positions.len(), dim), &candle_core::Device::Cpu)?) } fn scalar(t: &Tensor) -> Result { Ok(t.flatten_all()?.to_vec1::()?.first().copied().unwrap_or(0.0)) } impl<'a> VideoTracker<'a> { pub fn new(model: Arc, frames: Box) -> Result> { let n = frames.num_frames(); if n == 0 || n > MAX_FRAMES { return Err(Error::Input(format!("a clip needs between 1 and {MAX_FRAMES} frames, got {n}"))); } Ok(VideoTracker { model, frames, objects: BTreeMap::new(), cache: VecDeque::new(), budget: Budget::default(), peak: 0 }) } pub fn num_frames(&self) -> usize { self.frames.num_frames() } /// Replace the limits (see [`Budget`]). pub fn set_budget(&mut self, budget: Budget) { self.budget = budget; } /// Bytes held now: stored frames, kept results and cached features. pub fn state_bytes(&self) -> usize { let frames: usize = self.objects.values().map(|o| (o.cond.len() + o.non_cond.len()) * FRAME_BYTES + o.cond.len() * RESULT_BYTES).sum(); frames + self.cache.len() * FEATURE_BYTES } /// The most [`VideoTracker::state_bytes`] reached so far. pub fn peak_state_bytes(&self) -> usize { self.peak } fn check_budget(&mut self) -> Result<()> { let used = self.state_bytes(); self.peak = self.peak.max(used); if used > self.budget.max_state_bytes { return Err(Error::Budget(format!("the tracker holds {} MB, over its {} MB limit", used >> 20, self.budget.max_state_bytes >> 20))); } Ok(()) } /// Forget every object and cached frame (the clip stays). pub fn reset(&mut self) { self.objects.clear(); self.cache.clear(); } fn features(&mut self, frame: usize) -> Result> { if let Some((_, f)) = self.cache.iter().find(|(i, _)| *i == frame) { return Ok(f.clone()); } let pixels = self.frames.frame(frame)?; let enc = self.model.encode(&pixels)?; let (low, pos) = match (enc.features.features.get(2), enc.features.positions.get(2)) { (Some(l), Some(p)) => (l.clone(), p.clone()), _ => return Err(Error::Compute("vision encoder returned too few levels".into())), }; let f = Arc::new(FrameFeatures { low, pos, high: enc.high }); self.cache.push_back((frame, f.clone())); while self.cache.len() > 2 { self.cache.pop_front(); } Ok(f) } /// The memory bank for `frame`: memory frames and object pointers with their position /// encodings, plus how many trailing tokens are pointers. fn memory_bank(&self, obj: &ObjectState, frame: usize, reverse: bool) -> Result<(Tensor, Tensor, usize)> { let (mut feats, mut poss) = (vec![], vec![]); let temporal = |off: usize| -> Result { // The reference indexes with `offset - 1`, so the conditioning frames (offset 0) use the last row. let row = if off == 0 { MEMORY_FRAMES - 1 } else { off - 1 }; let v = self.model.temporal_pos.get(row * 64..(row + 1) * 64).ok_or_else(|| Error::Weights("temporal position table".into()))?; Ok(Tensor::from_slice(v, (1, 64), &candle_core::Device::Cpu)?) }; for out in obj.cond.values() { feats.push(out.memory.features.clone()); poss.push(out.memory.pos.broadcast_add(&temporal(0)?)?); } for off in (1..MEMORY_FRAMES).rev() { let prev = if reverse { frame.checked_add(off) } else { frame.checked_sub(off) }; if let Some(out) = prev.and_then(|p| obj.non_cond.get(&p)) { feats.push(out.memory.features.clone()); poss.push(out.memory.pos.broadcast_add(&temporal(off)?)?); } } // Object pointers: conditioning frames on the already-tracked side, then recent frames. let n_frames = self.frames.num_frames(); let max_ptrs = n_frames.min(MAX_POINTERS); let (mut offsets, mut pointers): (Vec, Vec) = (vec![], vec![]); let sign = if reverse { -1.0f32 } else { 1.0 }; for (t, out) in &obj.cond { if if reverse { *t >= frame } else { *t <= frame } { offsets.push((frame as f32 - *t as f32) * sign); pointers.push(out.pointer.clone()); } } for off in 1..max_ptrs { let r = if reverse { frame.checked_add(off) } else { frame.checked_sub(off) }; let Some(r) = r.filter(|r| *r < n_frames) else { break }; if let Some(out) = obj.non_cond.get(&r) { offsets.push(off as f32); pointers.push(out.pointer.clone()); } } let mut n_pointer_tokens = 0; if !pointers.is_empty() { let max_diff = (max_ptrs.saturating_sub(1)).max(1) as f32; let norm: Vec = offsets.iter().map(|o| o / max_diff).collect(); let pe = apply(&self.model.pointer_time_proj, &sine_embedding_1d(&norm, 256)?)?; let ptrs = Tensor::cat(&pointers, 0)?; // [P, 256] let p = ptrs.dim(0)?; let tokens = ptrs.reshape((p * 4, 64))?; let pe = pe.unsqueeze(1)?.broadcast_as((p, 4, 64))?.reshape((p * 4, 64))?; n_pointer_tokens = p * 4; feats.push(tokens); poss.push(pe.contiguous()?); } if feats.is_empty() { return Err(Error::Input("the object has no conditioning frame; add a prompt first".into())); } Ok((Tensor::cat(&feats, 0)?, Tensor::cat(&poss, 0)?, n_pointer_tokens)) } /// Run the model on one frame for one object and store the result. fn step(&mut self, id: ObjectId, frame: usize, prompt: Option<&Prompt>, reverse: bool, conditioning: bool) -> Result { // EFFECTCRAFT_SAM2_PROFILE=1 prints where a frame's time goes (stderr). let profile = std::env::var_os("EFFECTCRAFT_SAM2_PROFILE").is_some(); let mut lap = std::time::Instant::now(); let mut mark = |what: &str| { if profile { eprintln!("sam2 frame {frame}: {what} {:.0} ms", lap.elapsed().as_secs_f64() * 1e3); } lap = std::time::Instant::now(); }; let feats = self.features(frame)?; mark("encode"); let model = self.model.clone(); let tokens = |t: &Tensor| -> Result { Ok(t.flatten_from(2)?.squeeze(0)?.t()?.contiguous()?) }; let conditioned = if conditioning { feats.low.broadcast_add(model.image.no_memory())? } else { let obj = self.objects.get(&id).ok_or_else(|| Error::Input("unknown object".into()))?; let (mem, mem_pos, n_ptr) = self.memory_bank(obj, frame, reverse)?; let out = model.attend(&tokens(&feats.low)?, &tokens(&feats.pos)?, &mem, &mem_pos, n_ptr)?; out.t()?.contiguous()?.reshape((1, 256, GRID, GRID))? }; mark("memory attention"); let num_points = prompt.map_or(0, |p| p.points.len() + 2 * usize::from(p.bbox.is_some())); let multimask = num_points <= 1; let sparse = match prompt { Some(p) => model.image.prompt.sparse(p)?, None => model.image.prompt.sparse_none()?, }; let dense = model.image.prompt.dense_empty()?; let out = model.image.decode_embedded(&conditioned, &feats.high, &sparse, &dense, multimask)?; mark("decoder"); let score = scalar(&out.object_score)?; let present = score > 0.0; let ious = out.iou.flatten_all()?.to_vec1::()?; let best = if multimask { ious.iter().enumerate().fold((0usize, f32::NEG_INFINITY), |b, (i, v)| if *v > b.1 { (i, *v) } else { b }).0 } else { 0 }; let low = out.masks.narrow(1, best, 1)?; let low = if present { low } else { Tensor::full(NO_OBJECT, low.dims(), low.device())? }; let token = if multimask { out.sam_tokens.narrow(1, best, 1)? } else { out.sam_tokens.narrow(1, 0, 1)? }.squeeze(1)?; let pointer = if present { model.pointer_proj.forward(&token)? } else { model.no_object_pointer.clone() }; let high = low.upsample_bilinear2d(INPUT_SIZE, INPUT_SIZE, false)?; let for_memory = if prompt.is_some() { high.gt(0f32)?.to_dtype(DType::F32)? } else { sigmoid(&high)? }; let for_memory = ((for_memory * 20.0)? - 10.0)?; mark("upsample and mask"); let memory = model.memorize(&feats.low, &for_memory, present)?; mark("memory encoder"); let iou = ious.get(best).copied().unwrap_or(0.0); let result = FrameMask { frame, object: id, logits: low.flatten_all()?.to_vec1::()?, iou, object_score: score, occluded: !present }; let state = self.objects.entry(id).or_default(); let entry = FrameOut { memory, pointer, result: conditioning.then(|| result.clone()) }; if conditioning { state.non_cond.remove(&frame); state.cond.insert(frame, entry); } else { state.non_cond.insert(frame, entry); Self::prune(state, frame); } self.check_budget()?; Ok(result) } /// Drop memories that no later frame in either direction can still look at. fn prune(state: &mut ObjectState, frame: usize) { let keep = |f: usize, cond: &BTreeMap| f.abs_diff(frame) <= MAX_POINTERS || cond.keys().any(|c| f.abs_diff(*c) <= MEMORY_FRAMES); let doomed: Vec = state.non_cond.keys().copied().filter(|f| !keep(*f, &state.cond)).collect(); for f in doomed { state.non_cond.remove(&f); } } fn check_frame(&self, frame: usize) -> Result<()> { if frame >= self.frames.num_frames() { return Err(Error::Input(format!("frame {frame} is outside the clip of {} frames", self.frames.num_frames()))); } Ok(()) } /// Prompt an object on a frame (clicks and/or a box). The frame becomes one of the object's /// conditioning frames when it has not been tracked yet; the mask comes back at once. pub fn prompt(&mut self, frame: usize, object: ObjectId, prompt: &Prompt) -> Result { self.check_frame(frame)?; prompt.validate()?; if prompt.points.is_empty() && prompt.bbox.is_none() { return Err(Error::Input("a prompt needs at least one point or a box".into())); } let tracked = self.objects.get(&object).is_some_and(|o| o.non_cond.contains_key(&frame)); if tracked { // A correction on a tracked frame is conditioned on the memory like any tracked frame. let reverse = false; return self.step(object, frame, Some(prompt), reverse, false); } self.objects.entry(object).or_default(); self.step(object, frame, Some(prompt), false, true) } /// Prompt an object on a frame with a mask: `[1, 1, 1024, 1024]` of 0 and 1 (see /// [`crate::resize::mask_to_model`]). The mask becomes the frame's output as it is (no decode of /// the mask itself); the decoder only makes the object pointer. Like a click prompt, the frame is a /// conditioning frame unless it has been tracked already. pub fn prompt_mask(&mut self, frame: usize, object: ObjectId, mask: &Tensor) -> Result { self.check_frame(frame)?; if mask.dims() != [1, 1, INPUT_SIZE, INPUT_SIZE] { return Err(Error::Input(format!("a mask prompt must be [1, 1, {INPUT_SIZE}, {INPUT_SIZE}], got {:?}", mask.dims()))); } let values = mask.flatten_all()?.to_vec1::()?; if values.iter().any(|v| !v.is_finite()) { return Err(Error::Input("a mask prompt must be finite".into())); } let tracked = self.objects.get(&object).is_some_and(|o| o.non_cond.contains_key(&frame)); self.objects.entry(object).or_default(); self.step_mask(object, frame, mask, &values, !tracked) } /// The reference's `_use_mask_as_output`: the mask is the output, the decoder makes the pointer. fn step_mask(&mut self, id: ObjectId, frame: usize, mask: &Tensor, values: &[f32], conditioning: bool) -> Result { let feats = self.features(frame)?; let model = self.model.clone(); let present = values.iter().any(|v| *v > 0.0); let high = ((mask * 20.0)? - 10.0)?; let low = antialias_bilinear(&high.flatten_all()?.to_vec1::()?, (INPUT_SIZE, INPUT_SIZE), (4 * GRID, 4 * GRID))?; // The pointer: the decoder with the mask as its dense prompt and no clicks, on the frame's features // without the no-memory embedding. let down = mask.conv2d(&model.mask_down.0, 0, 4, 1, 1)?.broadcast_add(&model.mask_down.1.reshape((1, 1, 1, 1))?)?; let dense = model.image.prompt.dense_mask(&down)?; let out = model.image.decode_embedded(&feats.low, &feats.high, &model.image.prompt.sparse_none()?, &dense, true)?; let ious = out.iou.flatten_all()?.to_vec1::()?; let best = ious.iter().enumerate().fold((0usize, f32::NEG_INFINITY), |b, (i, v)| if *v > b.1 { (i, *v) } else { b }).0; let token = out.sam_tokens.narrow(1, best, 1)?.squeeze(1)?; let decoder_present = scalar(&out.object_score)? > 0.0; let pointer = if decoder_present { model.pointer_proj.forward(&token)? } else { model.no_object_pointer.clone() }; let pointer = if present { pointer } else { model.no_object_pointer.clone() }; let memory = model.memorize(&feats.low, &high, present)?; let score = if present { 10.0 } else { -10.0 }; let result = FrameMask { frame, object: id, logits: low, iou: 1.0, object_score: score, occluded: !present }; let state = self.objects.entry(id).or_default(); let entry = FrameOut { memory, pointer, result: conditioning.then(|| result.clone()) }; if conditioning { state.non_cond.remove(&frame); state.cond.insert(frame, entry); } else { state.non_cond.insert(frame, entry); Self::prune(state, frame); } self.check_budget()?; Ok(result) } /// Track every object from `start` through `max_frames` frames (the rest of the clip when /// `None`) in `direction`, calling `out` for each object on each frame as it is finished. pub fn propagate(&mut self, start: usize, direction: Direction, max_frames: Option, cancel: &Cancel, out: &mut dyn FnMut(FrameMask)) -> Result<()> { self.propagate_objects(None, start, direction, max_frames, cancel, out) } /// [`VideoTracker::propagate`] for the listed objects only (`None`: every prompted object). pub fn propagate_objects( &mut self, only: Option<&[ObjectId]>, start: usize, direction: Direction, max_frames: Option, cancel: &Cancel, out: &mut dyn FnMut(FrameMask), ) -> Result<()> { self.check_frame(start)?; let n = self.frames.num_frames(); let reverse = direction == Direction::Backward; let span = max_frames.unwrap_or(n).min(n); let ids: Vec = self.objects.iter().filter(|(id, o)| !o.cond.is_empty() && only.is_none_or(|l| l.contains(id))).map(|(id, _)| *id).collect(); if ids.is_empty() { return Err(Error::Input("no object has been prompted".into())); } let started = std::time::Instant::now(); for k in 0..=span { let frame = if reverse { start.checked_sub(k) } else { Some(start + k).filter(|f| *f < n) }; let Some(frame) = frame else { break }; for id in &ids { if cancel.is_cancelled() { return Err(Error::Cancelled); } if let Some(limit) = self.budget.max_seconds.filter(|l| started.elapsed().as_secs_f64() > *l) { return Err(Error::Budget(format!("tracking took more than {limit} s"))); } let mask = self.mask_at(*id, frame, reverse)?; out(mask); } } Ok(()) } /// Object `id` on `frame`: the result stored with its prompt there, else the tracker's step. fn mask_at(&mut self, id: ObjectId, frame: usize, reverse: bool) -> Result { let stored = self.objects.get(&id).and_then(|o| o.cond.get(&frame)).and_then(|o| o.result.clone()); match stored { Some(m) => Ok(m), None => self.step(id, frame, None, reverse, false), } } /// Track every listed object from its own prompted frame (`starts`: object, frame) in one pass /// in `direction`, frame by frame with all the objects that have started: a frame is finished /// for all of them before the next begins, so a caller that assembles the objects of a frame /// does not hold frames waiting for a pass that has not got there. A backward pass leaves out /// each object's own start frame (the forward pass reported it). pub fn propagate_each(&mut self, starts: &[(ObjectId, usize)], direction: Direction, cancel: &Cancel, out: &mut dyn FnMut(FrameMask)) -> Result<()> { let n = self.frames.num_frames(); for (id, start) in starts { self.check_frame(*start)?; if self.objects.get(id).is_none_or(|o| o.cond.is_empty()) { return Err(Error::Input("no object has been prompted".into())); } } if starts.is_empty() { return Err(Error::Input("no object has been prompted".into())); } let reverse = direction == Direction::Backward; let started = std::time::Instant::now(); for (id, frame) in pass_order(starts, n, reverse) { if cancel.is_cancelled() { return Err(Error::Cancelled); } if let Some(limit) = self.budget.max_seconds.filter(|l| started.elapsed().as_secs_f64() > *l) { return Err(Error::Budget(format!("tracking took more than {limit} s"))); } let mask = self.mask_at(id, frame, reverse)?; out(mask); } Ok(()) } } /// The (object, frame) steps of [`VideoTracker::propagate_each`], in order: frame by frame from the /// first start (the last, backwards), each frame for the objects whose pass has reached it. fn pass_order(starts: &[(ObjectId, usize)], frames: usize, reverse: bool) -> impl Iterator + '_ { let first = if reverse { starts.iter().map(|s| s.1).max() } else { starts.iter().map(|s| s.1).min() }; let span = match first { Some(f) if reverse => f + 1, Some(f) => frames.saturating_sub(f), None => 0, }; (0..span).flat_map(move |k| { let frame = if reverse { first.unwrap_or(0).saturating_sub(k) } else { first.unwrap_or(0) + k }; starts.iter().filter_map(move |(id, start)| { let reached = if reverse { *start > frame } else { *start <= frame }; reached.then_some((*id, frame)) }) }) } #[cfg(test)] mod tests { use super::*; fn order(starts: &[(u32, usize)], frames: usize, reverse: bool) -> Vec<(u32, usize)> { let starts: Vec<(ObjectId, usize)> = starts.iter().map(|(o, s)| (ObjectId(*o), *s)).collect(); pass_order(&starts, frames, reverse).map(|(o, f)| (o.0, f)).collect() } /// Forward, every frame is finished for all the objects that have started before the next frame /// begins; an object joins the pass at its own start frame. #[test] fn the_forward_pass_goes_frame_by_frame_over_the_objects_that_have_started() { assert_eq!(order(&[(1, 0), (2, 3)], 6, false), [(1, 0), (1, 1), (1, 2), (1, 3), (2, 3), (1, 4), (2, 4), (1, 5), (2, 5)]); // Prompts on the same frame go together; the pass starts at the earliest start. assert_eq!(order(&[(1, 2), (2, 2)], 4, false), [(1, 2), (2, 2), (1, 3), (2, 3)]); assert_eq!(order(&[(1, 2)], 4, false), [(1, 2), (1, 3)]); } /// Backward, from the last start down to frame 0, leaving out each object's own start frame. #[test] fn the_backward_pass_goes_frame_by_frame_and_skips_each_objects_own_start() { assert_eq!(order(&[(1, 0), (2, 3)], 6, true), [(2, 2), (2, 1), (2, 0)]); assert_eq!(order(&[(1, 2), (2, 3)], 6, true), [(2, 2), (1, 1), (2, 1), (1, 0), (2, 0)]); assert!(order(&[(1, 0)], 6, true).is_empty(), "nothing before frame 0"); } /// A frame is finished for every object before the next begins, however far apart the prompts /// are: the number of frames waiting for an object never grows with the clip. #[test] fn no_frame_waits_for_an_object_that_has_not_reached_it() { let steps = order(&[(1, 0), (2, 1000)], 2000, false); let mut waiting = 0usize; let mut worst = 0usize; let mut seen = std::collections::BTreeMap::::new(); for (_, f) in steps { let n = seen.entry(f).or_default(); *n += 1; // From frame 1000 on, both objects report each frame back to back. if f >= 1000 && *n == 2 { waiting -= 1; } else if f >= 1000 { waiting += 1; } worst = worst.max(waiting); } assert!(worst <= 1, "{worst} frames waiting"); } }