Skip to main content

scirs2_vision/vos/
mod.rs

1//! Video Object Segmentation (VOS) foundations.
2//!
3//! Provides semi-supervised VOS: given a binary object mask in frame 0,
4//! propagate it to subsequent frames using a matching-based attention mechanism
5//! inspired by STM / STCN.
6//!
7//! ## Overview
8//!
9//! 1. **Feature extraction** – downsample the frame to a coarser resolution
10//!    and compute lightweight colour + spatial-pyramid features per cell.
11//! 2. **Mask propagation** – compute soft attention between query and memory
12//!    cell features, accumulate weighted memory masks, upsample to the original
13//!    resolution.
14//! 3. **Evaluation** – threshold to binary, compute mask IoU.
15
16use crate::error::{Result, VisionError};
17
18// ─────────────────────────────────────────────────────────────────────────────
19// Configuration
20// ─────────────────────────────────────────────────────────────────────────────
21
22/// Configuration for video object segmentation.
23#[derive(Debug, Clone)]
24pub struct VosConfig {
25    /// Maximum number of memory frames to keep.
26    pub n_memory_frames: usize,
27    /// Spatial downsampling factor (feature map has H/stride × W/stride cells).
28    pub feature_stride: usize,
29    /// Softmax temperature for attention computation.
30    pub similarity_temperature: f64,
31}
32
33impl Default for VosConfig {
34    fn default() -> Self {
35        Self {
36            n_memory_frames: 3,
37            feature_stride: 4,
38            similarity_temperature: 0.1,
39        }
40    }
41}
42
43// ─────────────────────────────────────────────────────────────────────────────
44// Data structures
45// ─────────────────────────────────────────────────────────────────────────────
46
47/// Binary object mask for a single frame.
48#[derive(Debug, Clone)]
49pub struct FrameMask {
50    /// Index of the frame in the video sequence.
51    pub frame_idx: usize,
52    /// Binary mask: `mask[row][col]` is `true` if the pixel belongs to the
53    /// foreground object.
54    pub mask: Vec<Vec<bool>>,
55}
56
57impl FrameMask {
58    /// Create a new `FrameMask` with the given frame index and mask data.
59    pub fn new(frame_idx: usize, mask: Vec<Vec<bool>>) -> Self {
60        Self { frame_idx, mask }
61    }
62
63    /// Return (height, width) of the mask.
64    pub fn shape(&self) -> (usize, usize) {
65        let h = self.mask.len();
66        let w = if h > 0 { self.mask[0].len() } else { 0 };
67        (h, w)
68    }
69}
70
71/// Feature representation of a single frame at reduced resolution.
72///
73/// Shape: `(H/stride) × (W/stride) × feature_dim`.
74#[derive(Debug, Clone)]
75pub struct FrameFeatures {
76    /// Index of the source frame.
77    pub frame_idx: usize,
78    /// Feature tensor stored as a nested Vec: `features[row][col]` is the
79    /// feature vector for that cell.
80    pub features: Vec<Vec<Vec<f64>>>,
81}
82
83impl FrameFeatures {
84    /// Return `(feat_height, feat_width, feature_dim)`.
85    pub fn shape(&self) -> (usize, usize, usize) {
86        let fh = self.features.len();
87        let fw = if fh > 0 { self.features[0].len() } else { 0 };
88        let fd = if fh > 0 && fw > 0 {
89            self.features[0][0].len()
90        } else {
91            0
92        };
93        (fh, fw, fd)
94    }
95}
96
97// ─────────────────────────────────────────────────────────────────────────────
98// Feature extraction
99// ─────────────────────────────────────────────────────────────────────────────
100
101/// Extract mask-conditioned features from a video frame.
102///
103/// The frame is downsampled by `stride` in both spatial dimensions.  For each
104/// cell at `(row, col)` in the feature map the corresponding `stride × stride`
105/// patch is examined:
106///
107/// - **Mean colour** `[mean_r, mean_g, mean_b]` of the masked pixels inside the
108///   patch (or the mean of all pixels if no foreground pixel is present).
109/// - **2 × 2 spatial pyramid** – the patch is further divided into four
110///   quadrants; each quadrant contributes the fraction of masked pixels in that
111///   quadrant, yielding 4 values.
112///
113/// The resulting feature vector has length 7 (= 3 + 4).
114///
115/// # Errors
116///
117/// Returns [`VisionError::InvalidParameter`] if `frame` is empty or `stride`
118/// is zero.
119pub fn extract_mask_features(
120    frame: &[Vec<[f64; 3]>],
121    mask: &[Vec<bool>],
122    stride: usize,
123) -> Result<FrameFeatures> {
124    if frame.is_empty() {
125        return Err(VisionError::InvalidParameter(
126            "frame must not be empty".into(),
127        ));
128    }
129    if stride == 0 {
130        return Err(VisionError::InvalidParameter("stride must be > 0".into()));
131    }
132
133    let h = frame.len();
134    let w = frame[0].len();
135    let fh = h.div_ceil(stride);
136    let fw = w.div_ceil(stride);
137
138    let mut feat_map: Vec<Vec<Vec<f64>>> = Vec::with_capacity(fh);
139
140    for fr in 0..fh {
141        let mut row_feats: Vec<Vec<f64>> = Vec::with_capacity(fw);
142        for fc in 0..fw {
143            let pr_start = fr * stride;
144            let pr_end = (pr_start + stride).min(h);
145            let pc_start = fc * stride;
146            let pc_end = (pc_start + stride).min(w);
147
148            // Collect mean colour of masked pixels in patch
149            let mut sum_r = 0.0_f64;
150            let mut sum_g = 0.0_f64;
151            let mut sum_b = 0.0_f64;
152            let mut mask_count = 0usize;
153            let mut total_count = 0usize;
154
155            // 2×2 quadrant counts
156            let mid_r = pr_start + (pr_end - pr_start) / 2;
157            let mid_c = pc_start + (pc_end - pc_start) / 2;
158            let mut quad_mask = [0usize; 4];
159            let mut quad_total = [0usize; 4];
160
161            for (r, frame_row) in frame.iter().enumerate().take(pr_end).skip(pr_start) {
162                for (c, pixel) in frame_row.iter().enumerate().take(pc_end).skip(pc_start) {
163                    let is_masked = mask
164                        .get(r)
165                        .and_then(|row| row.get(c))
166                        .copied()
167                        .unwrap_or(false);
168                    total_count += 1;
169
170                    let q = match (r < mid_r, c < mid_c) {
171                        (true, true) => 0,
172                        (true, false) => 1,
173                        (false, true) => 2,
174                        (false, false) => 3,
175                    };
176                    quad_total[q] += 1;
177
178                    if is_masked {
179                        sum_r += pixel[0];
180                        sum_g += pixel[1];
181                        sum_b += pixel[2];
182                        mask_count += 1;
183                        quad_mask[q] += 1;
184                    }
185                }
186            }
187
188            let (mean_r, mean_g, mean_b) = if mask_count > 0 {
189                let n = mask_count as f64;
190                (sum_r / n, sum_g / n, sum_b / n)
191            } else if total_count > 0 {
192                // Fall back to mean of all pixels
193                let n = total_count as f64;
194                let (ar, ag, ab) = frame[pr_start..pr_end]
195                    .iter()
196                    .flat_map(|r| r[pc_start..pc_end].iter())
197                    .fold((0.0_f64, 0.0_f64, 0.0_f64), |(ar, ag, ab), px| {
198                        (ar + px[0], ag + px[1], ab + px[2])
199                    });
200                (ar / n, ag / n, ab / n)
201            } else {
202                (0.0, 0.0, 0.0)
203            };
204
205            // Spatial pyramid fractions
206            let pyramid: Vec<f64> = (0..4)
207                .map(|q| {
208                    if quad_total[q] > 0 {
209                        quad_mask[q] as f64 / quad_total[q] as f64
210                    } else {
211                        0.0
212                    }
213                })
214                .collect();
215
216            let mut fv = vec![mean_r, mean_g, mean_b];
217            fv.extend_from_slice(&pyramid);
218            row_feats.push(fv);
219        }
220        feat_map.push(row_feats);
221    }
222
223    Ok(FrameFeatures {
224        frame_idx: 0,
225        features: feat_map,
226    })
227}
228
229// ─────────────────────────────────────────────────────────────────────────────
230// Dot product for feature vectors
231// ─────────────────────────────────────────────────────────────────────────────
232
233fn dot(a: &[f64], b: &[f64]) -> f64 {
234    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
235}
236
237// ─────────────────────────────────────────────────────────────────────────────
238// Mask propagation
239// ─────────────────────────────────────────────────────────────────────────────
240
241/// Propagate memory masks to the query frame using soft attention.
242///
243/// For each query cell `q_i` and each memory cell `m_j` the attention weight
244/// is:
245///
246/// ```text
247/// A[i, j] = exp(q_i · m_j / T) / Σ_k exp(q_i · m_k / T)
248/// ```
249///
250/// The soft mask at query cell `i` is `Σ_j A[i,j] * mask_value(m_j)`.
251/// The mask value of a memory cell is the fraction of foreground pixels in the
252/// corresponding patch of the full-resolution binary mask.
253///
254/// The soft feature-map mask is then upsampled to the original frame
255/// resolution using nearest-neighbour interpolation.
256///
257/// # Errors
258///
259/// Returns [`VisionError::InvalidParameter`] if `memory_frames` and
260/// `memory_features` are empty or of inconsistent length.
261pub fn propagate_mask(
262    memory_frames: &[FrameMask],
263    memory_features: &[FrameFeatures],
264    query_features: &FrameFeatures,
265    config: &VosConfig,
266) -> Result<Vec<Vec<f64>>> {
267    if memory_frames.is_empty() || memory_features.is_empty() {
268        return Err(VisionError::InvalidParameter(
269            "memory_frames and memory_features must not be empty".into(),
270        ));
271    }
272    if memory_frames.len() != memory_features.len() {
273        return Err(VisionError::InvalidParameter(
274            "memory_frames and memory_features must have the same length".into(),
275        ));
276    }
277
278    let (qfh, qfw, _) = query_features.shape();
279    let temp = config.similarity_temperature;
280
281    // Build flattened memory cells: (feature_vec, mask_value)
282    // mask_value = fraction of foreground pixels in the cell's patch.
283    let mut mem_cells: Vec<(Vec<f64>, f64)> = Vec::new();
284
285    for (mf, mfeat) in memory_frames.iter().zip(memory_features.iter()) {
286        let (mh, mw) = mf.shape();
287        let (fh, fw, _) = mfeat.shape();
288
289        let stride_r = if fh > 0 { mh.div_ceil(fh) } else { 1 };
290        let stride_c = if fw > 0 { mw.div_ceil(fw) } else { 1 };
291
292        for fr in 0..fh {
293            for fc in 0..fw {
294                let fv = mfeat.features[fr][fc].clone();
295
296                // Fraction of foreground pixels in the corresponding patch
297                let pr_start = fr * stride_r;
298                let pr_end = (pr_start + stride_r).min(mh);
299                let pc_start = fc * stride_c;
300                let pc_end = (pc_start + stride_c).min(mw);
301
302                let mut fg_count = 0usize;
303                let mut total = 0usize;
304                for r in pr_start..pr_end {
305                    for c in pc_start..pc_end {
306                        total += 1;
307                        if mf
308                            .mask
309                            .get(r)
310                            .and_then(|row| row.get(c))
311                            .copied()
312                            .unwrap_or(false)
313                        {
314                            fg_count += 1;
315                        }
316                    }
317                }
318                let mask_val = if total > 0 {
319                    fg_count as f64 / total as f64
320                } else {
321                    0.0
322                };
323                mem_cells.push((fv, mask_val));
324            }
325        }
326    }
327
328    if mem_cells.is_empty() {
329        return Err(VisionError::InvalidParameter(
330            "No memory cells available".into(),
331        ));
332    }
333
334    // Compute soft mask at feature resolution (qfh × qfw)
335    let mut soft_feat: Vec<Vec<f64>> = vec![vec![0.0; qfw]; qfh];
336
337    for (qr, soft_feat_row) in soft_feat.iter_mut().enumerate().take(qfh) {
338        for (qc, soft_feat_val) in soft_feat_row.iter_mut().enumerate().take(qfw) {
339            let q = &query_features.features[qr][qc];
340
341            // Attention weights (numerically stable softmax)
342            let logits: Vec<f64> = mem_cells.iter().map(|(mv, _)| dot(q, mv) / temp).collect();
343            let max_l = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
344            let exps: Vec<f64> = logits.iter().map(|&l| (l - max_l).exp()).collect();
345            let sum_exp: f64 = exps.iter().sum();
346
347            let soft = if sum_exp > 0.0 {
348                exps.iter()
349                    .zip(mem_cells.iter())
350                    .map(|(&e, (_, mv))| (e / sum_exp) * mv)
351                    .sum::<f64>()
352            } else {
353                0.0_f64
354            };
355            *soft_feat_val = soft.clamp(0.0, 1.0);
356        }
357    }
358
359    // Upsample to original resolution using nearest-neighbour
360    // We don't know the original H×W exactly; infer from memory mask shape.
361    let (orig_h, orig_w) = memory_frames[0].shape();
362    let out_h = if orig_h > 0 { orig_h } else { qfh };
363    let out_w = if orig_w > 0 { orig_w } else { qfw };
364    let stride_h = config.feature_stride.max(1);
365    let stride_w = config.feature_stride.max(1);
366
367    let mut soft_mask: Vec<Vec<f64>> = vec![vec![0.0; out_w]; out_h];
368    for (r, mask_row) in soft_mask.iter_mut().enumerate().take(out_h) {
369        for (c, mask_val) in mask_row.iter_mut().enumerate().take(out_w) {
370            let fr = (r / stride_h).min(qfh.saturating_sub(1));
371            let fc = (c / stride_w).min(qfw.saturating_sub(1));
372            *mask_val = soft_feat[fr][fc];
373        }
374    }
375
376    Ok(soft_mask)
377}
378
379// ─────────────────────────────────────────────────────────────────────────────
380// Thresholding and evaluation
381// ─────────────────────────────────────────────────────────────────────────────
382
383/// Convert a soft probability mask to a binary mask by thresholding.
384///
385/// A pixel is foreground if `soft_mask[r][c] >= threshold`.
386pub fn threshold_mask(soft_mask: &[Vec<f64>], threshold: f64) -> Vec<Vec<bool>> {
387    soft_mask
388        .iter()
389        .map(|row| row.iter().map(|&v| v >= threshold).collect())
390        .collect()
391}
392
393/// Compute the Intersection over Union between two binary masks.
394///
395/// Returns a value in `[0, 1]`.  Returns `1.0` if both masks are entirely
396/// background (no positive pixels).
397pub fn mask_iou(pred: &[Vec<bool>], gt: &[Vec<bool>]) -> f64 {
398    let mut intersection = 0usize;
399    let mut union_ = 0usize;
400
401    let h = pred.len().min(gt.len());
402    for r in 0..h {
403        let w = pred[r].len().min(gt[r].len());
404        for c in 0..w {
405            let p = pred[r][c];
406            let g = gt[r][c];
407            if p && g {
408                intersection += 1;
409            }
410            if p || g {
411                union_ += 1;
412            }
413        }
414    }
415
416    if union_ == 0 {
417        1.0
418    } else {
419        intersection as f64 / union_ as f64
420    }
421}
422
423// ─────────────────────────────────────────────────────────────────────────────
424// Tests
425// ─────────────────────────────────────────────────────────────────────────────
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    fn make_frame(h: usize, w: usize) -> Vec<Vec<[f64; 3]>> {
432        (0..h)
433            .map(|r| {
434                (0..w)
435                    .map(|c| [(r as f64) / (h as f64), (c as f64) / (w as f64), 0.5])
436                    .collect()
437            })
438            .collect()
439    }
440
441    fn make_mask(h: usize, w: usize, fg_rows: std::ops::Range<usize>) -> Vec<Vec<bool>> {
442        (0..h)
443            .map(|r| (0..w).map(|_| fg_rows.contains(&r)).collect())
444            .collect()
445    }
446
447    #[test]
448    fn test_extract_mask_features_shape() {
449        let h = 16;
450        let w = 20;
451        let stride = 4;
452        let frame = make_frame(h, w);
453        let mask = make_mask(h, w, 4..12);
454        let ff = extract_mask_features(&frame, &mask, stride).expect("extraction failed");
455        let (fh, fw, fd) = ff.shape();
456        // ceil(16/4)=4, ceil(20/4)=5
457        assert_eq!(fh, 4, "fh mismatch");
458        assert_eq!(fw, 5, "fw mismatch");
459        assert_eq!(fd, 7, "fd mismatch: expected 3 colour + 4 spatial = 7");
460    }
461
462    #[test]
463    fn test_extract_mask_features_zero_stride_error() {
464        let frame = make_frame(8, 8);
465        let mask = make_mask(8, 8, 0..4);
466        assert!(extract_mask_features(&frame, &mask, 0).is_err());
467    }
468
469    #[test]
470    fn test_extract_mask_features_empty_error() {
471        let mask: Vec<Vec<bool>> = Vec::new();
472        let frame: Vec<Vec<[f64; 3]>> = Vec::new();
473        assert!(extract_mask_features(&frame, &mask, 4).is_err());
474    }
475
476    #[test]
477    fn test_propagate_mask_soft_in_range() {
478        let h = 8;
479        let w = 8;
480        let stride = 2;
481        let frame = make_frame(h, w);
482        let mask_data = make_mask(h, w, 2..6);
483
484        let ff = extract_mask_features(&frame, &mask_data, stride).expect("extract failed");
485        let fm = FrameMask::new(0, mask_data);
486
487        let config = VosConfig {
488            n_memory_frames: 1,
489            feature_stride: stride,
490            similarity_temperature: 0.1,
491        };
492
493        let soft = propagate_mask(&[fm], std::slice::from_ref(&ff), &ff, &config)
494            .expect("propagation failed");
495
496        for row in &soft {
497            for &v in row {
498                assert!((0.0..=1.0).contains(&v), "soft value out of range: {v}");
499            }
500        }
501    }
502
503    #[test]
504    fn test_mask_iou_identical() {
505        let mask = make_mask(8, 8, 2..6);
506        let iou = mask_iou(&mask, &mask);
507        assert!((iou - 1.0).abs() < 1e-9, "iou = {iou}");
508    }
509
510    #[test]
511    fn test_mask_iou_disjoint() {
512        let pred = make_mask(8, 8, 0..4);
513        let gt = make_mask(8, 8, 4..8);
514        let iou = mask_iou(&pred, &gt);
515        assert_eq!(iou, 0.0, "iou = {iou}");
516    }
517
518    #[test]
519    fn test_mask_iou_all_background() {
520        let h = 4;
521        let w = 4;
522        let pred: Vec<Vec<bool>> = vec![vec![false; w]; h];
523        let gt: Vec<Vec<bool>> = vec![vec![false; w]; h];
524        let iou = mask_iou(&pred, &gt);
525        assert_eq!(iou, 1.0);
526    }
527
528    #[test]
529    fn test_threshold_mask() {
530        let soft: Vec<Vec<f64>> = vec![vec![0.3, 0.7], vec![0.5, 0.2]];
531        let binary = threshold_mask(&soft, 0.5);
532        assert!(!binary[0][0]);
533        assert!(binary[0][1]);
534        assert!(binary[1][0]);
535        assert!(!binary[1][1]);
536    }
537
538    #[test]
539    fn test_frame_mask_shape() {
540        let mask = make_mask(10, 12, 0..5);
541        let fm = FrameMask::new(3, mask);
542        assert_eq!(fm.shape(), (10, 12));
543        assert_eq!(fm.frame_idx, 3);
544    }
545}