Skip to main content

scirs2_ndimage/
optical_flow.rs

1//! Optical flow algorithms for dense motion estimation
2//!
3//! This module provides classic and modern dense optical flow methods:
4//! - Lucas-Kanade (local window-based, differential)
5//! - Horn-Schunck (global variational smoothness constraint)
6//! - Farneback (polynomial expansion-based)
7
8use crate::error::{NdimageError, NdimageResult};
9use scirs2_core::ndarray::{Array2, Array3};
10
11// ─── FlowField ───────────────────────────────────────────────────────────────
12
13/// A dense optical flow field.
14///
15/// The underlying array has shape `(rows, cols, 2)` where
16/// index `[r, c, 0]` is the horizontal flow `u` (x-direction) and
17/// `[r, c, 1]` is the vertical flow `v` (y-direction).
18#[derive(Debug, Clone)]
19pub struct FlowField {
20    /// Internal array of shape (rows, cols, 2).
21    pub data: Array3<f64>,
22}
23
24impl FlowField {
25    /// Create a zero flow field.
26    pub fn zeros(rows: usize, cols: usize) -> Self {
27        FlowField {
28            data: Array3::<f64>::zeros((rows, cols, 2)),
29        }
30    }
31
32    /// Create from existing Array3.
33    pub fn from_array(data: Array3<f64>) -> NdimageResult<Self> {
34        if data.ndim() != 3 || data.shape()[2] != 2 {
35            return Err(NdimageError::InvalidInput(
36                "FlowField data must have shape (rows, cols, 2)".into(),
37            ));
38        }
39        Ok(FlowField { data })
40    }
41
42    /// Number of rows.
43    pub fn rows(&self) -> usize {
44        self.data.shape()[0]
45    }
46
47    /// Number of columns.
48    pub fn cols(&self) -> usize {
49        self.data.shape()[1]
50    }
51
52    /// Get flow vector at pixel (r, c) as (u, v).
53    pub fn get(&self, r: usize, c: usize) -> (f64, f64) {
54        (self.data[[r, c, 0]], self.data[[r, c, 1]])
55    }
56
57    /// Set flow vector at pixel (r, c).
58    pub fn set(&mut self, r: usize, c: usize, u: f64, v: f64) {
59        self.data[[r, c, 0]] = u;
60        self.data[[r, c, 1]] = v;
61    }
62
63    /// Compute the magnitude of the flow at each pixel, returning Array2.
64    pub fn magnitude(&self) -> Array2<f64> {
65        let rows = self.rows();
66        let cols = self.cols();
67        Array2::from_shape_fn((rows, cols), |(r, c)| {
68            let u = self.data[[r, c, 0]];
69            let v = self.data[[r, c, 1]];
70            (u * u + v * v).sqrt()
71        })
72    }
73}
74
75// ─── Shared derivative helpers ───────────────────────────────────────────────
76
77/// Compute spatial and temporal image derivatives for a frame pair.
78/// Returns (Ix, Iy, It) each of shape (rows, cols).
79fn compute_derivatives(
80    prev: &Array2<f64>,
81    next: &Array2<f64>,
82) -> NdimageResult<(Array2<f64>, Array2<f64>, Array2<f64>)> {
83    let rows = prev.nrows();
84    let cols = prev.ncols();
85    if next.shape() != prev.shape() {
86        return Err(NdimageError::DimensionError(
87            "prev and next frames must have the same shape".into(),
88        ));
89    }
90    if rows < 2 || cols < 2 {
91        return Err(NdimageError::InvalidInput(
92            "Image must be at least 2×2".into(),
93        ));
94    }
95
96    // Central difference for spatial; temporal is (next - prev)/2
97    let mut ix = Array2::<f64>::zeros((rows, cols));
98    let mut iy = Array2::<f64>::zeros((rows, cols));
99    let mut it = Array2::<f64>::zeros((rows, cols));
100
101    for r in 0..rows {
102        for c in 0..cols {
103            let ip1 = if c + 1 < cols {
104                prev[[r, c + 1]]
105            } else {
106                prev[[r, c]]
107            };
108            let im1 = if c > 0 {
109                prev[[r, c - 1]]
110            } else {
111                prev[[r, c]]
112            };
113            ix[[r, c]] = (ip1 - im1) / 2.0;
114
115            let jp1 = if r + 1 < rows {
116                prev[[r + 1, c]]
117            } else {
118                prev[[r, c]]
119            };
120            let jm1 = if r > 0 {
121                prev[[r - 1, c]]
122            } else {
123                prev[[r, c]]
124            };
125            iy[[r, c]] = (jp1 - jm1) / 2.0;
126
127            it[[r, c]] = next[[r, c]] - prev[[r, c]];
128        }
129    }
130    Ok((ix, iy, it))
131}
132
133/// Simple box average over a window of given half-width.
134fn box_average(arr: &Array2<f64>, half_win: usize) -> Array2<f64> {
135    let rows = arr.nrows();
136    let cols = arr.ncols();
137    let mut out = Array2::<f64>::zeros((rows, cols));
138    let wsize = 2 * half_win + 1;
139    let area = (wsize * wsize) as f64;
140    for r in 0..rows {
141        for c in 0..cols {
142            let mut s = 0.0f64;
143            for dr in 0..wsize {
144                let nr = (r + dr).saturating_sub(half_win).min(rows - 1);
145                for dc in 0..wsize {
146                    let nc = (c + dc).saturating_sub(half_win).min(cols - 1);
147                    s += arr[[nr, nc]];
148                }
149            }
150            out[[r, c]] = s / area;
151        }
152    }
153    out
154}
155
156/// Laplacian (neighbour average – self) used in Horn-Schunck smoothness.
157fn laplacian(u: &Array2<f64>) -> Array2<f64> {
158    let rows = u.nrows();
159    let cols = u.ncols();
160    let mut out = Array2::<f64>::zeros((rows, cols));
161    for r in 0..rows {
162        for c in 0..cols {
163            let sum_nb = {
164                let up = if r > 0 { u[[r - 1, c]] } else { u[[r, c]] };
165                let dn = if r + 1 < rows {
166                    u[[r + 1, c]]
167                } else {
168                    u[[r, c]]
169                };
170                let lt = if c > 0 { u[[r, c - 1]] } else { u[[r, c]] };
171                let rt = if c + 1 < cols {
172                    u[[r, c + 1]]
173                } else {
174                    u[[r, c]]
175                };
176                up + dn + lt + rt
177            };
178            out[[r, c]] = sum_nb / 4.0 - u[[r, c]];
179        }
180    }
181    out
182}
183
184// ─── Lucas-Kanade Optical Flow ───────────────────────────────────────────────
185
186/// Dense Lucas-Kanade optical flow.
187///
188/// For each pixel, builds a local least-squares system using the
189/// brightness constancy constraint over a `window_size × window_size`
190/// window and solves for (u, v).
191///
192/// # Arguments
193/// * `prev`        – first frame (rows × cols)
194/// * `next`        – second frame (same shape)
195/// * `window_size` – size of integration window (odd, e.g. 5 or 15)
196///
197/// # Returns
198/// Flow field of shape (rows, cols, 2).
199pub fn lucas_kanade_flow(
200    prev: &Array2<f64>,
201    next: &Array2<f64>,
202    window_size: usize,
203) -> NdimageResult<Array3<f64>> {
204    if window_size == 0 {
205        return Err(NdimageError::InvalidInput(
206            "window_size must be positive".into(),
207        ));
208    }
209    let rows = prev.nrows();
210    let cols = prev.ncols();
211    if rows == 0 || cols == 0 {
212        return Err(NdimageError::InvalidInput(
213            "Frames must not be empty".into(),
214        ));
215    }
216    if prev.shape() != next.shape() {
217        return Err(NdimageError::DimensionError(
218            "prev and next must have the same shape".into(),
219        ));
220    }
221
222    let (ix, iy, it) = compute_derivatives(prev, next)?;
223
224    // Precompute products for the structure tensor
225    let ixx = {
226        let mut a = Array2::<f64>::zeros((rows, cols));
227        for r in 0..rows {
228            for c in 0..cols {
229                a[[r, c]] = ix[[r, c]] * ix[[r, c]];
230            }
231        }
232        a
233    };
234    let iyy = {
235        let mut a = Array2::<f64>::zeros((rows, cols));
236        for r in 0..rows {
237            for c in 0..cols {
238                a[[r, c]] = iy[[r, c]] * iy[[r, c]];
239            }
240        }
241        a
242    };
243    let ixy = {
244        let mut a = Array2::<f64>::zeros((rows, cols));
245        for r in 0..rows {
246            for c in 0..cols {
247                a[[r, c]] = ix[[r, c]] * iy[[r, c]];
248            }
249        }
250        a
251    };
252    let ixt = {
253        let mut a = Array2::<f64>::zeros((rows, cols));
254        for r in 0..rows {
255            for c in 0..cols {
256                a[[r, c]] = ix[[r, c]] * it[[r, c]];
257            }
258        }
259        a
260    };
261    let iyt = {
262        let mut a = Array2::<f64>::zeros((rows, cols));
263        for r in 0..rows {
264            for c in 0..cols {
265                a[[r, c]] = iy[[r, c]] * it[[r, c]];
266            }
267        }
268        a
269    };
270
271    let hw = window_size / 2;
272    // Box-average the structure tensor components
273    let sxx = box_average(&ixx, hw);
274    let syy = box_average(&iyy, hw);
275    let sxy = box_average(&ixy, hw);
276    let sxt = box_average(&ixt, hw);
277    let syt = box_average(&iyt, hw);
278
279    let mut flow = Array3::<f64>::zeros((rows, cols, 2));
280    let eps = 1e-12f64;
281
282    for r in 0..rows {
283        for c in 0..cols {
284            let a = sxx[[r, c]];
285            let b = sxy[[r, c]];
286            let d = syy[[r, c]];
287            let det = a * d - b * b;
288            if det.abs() > eps {
289                let u = (-d * sxt[[r, c]] + b * syt[[r, c]]) / det;
290                let v = (b * sxt[[r, c]] - a * syt[[r, c]]) / det;
291                flow[[r, c, 0]] = u;
292                flow[[r, c, 1]] = v;
293            }
294        }
295    }
296    Ok(flow)
297}
298
299// ─── Horn-Schunck Optical Flow ───────────────────────────────────────────────
300
301/// Horn-Schunck global variational optical flow.
302///
303/// Minimises the energy functional combining the brightness constancy
304/// constraint with a global smoothness term (controlled by `alpha`).
305/// Solved iteratively via Gauss-Seidel.
306///
307/// # Arguments
308/// * `prev`       – first frame
309/// * `next`       – second frame (same shape)
310/// * `alpha`      – smoothness weight (larger → smoother flow, typical: 1.0–100.0)
311/// * `iterations` – number of Gauss-Seidel iterations
312///
313/// # Returns
314/// Flow field of shape (rows, cols, 2).
315pub fn horn_schunck_flow(
316    prev: &Array2<f64>,
317    next: &Array2<f64>,
318    alpha: f64,
319    iterations: usize,
320) -> NdimageResult<Array3<f64>> {
321    if alpha <= 0.0 {
322        return Err(NdimageError::InvalidInput("alpha must be positive".into()));
323    }
324    if iterations == 0 {
325        return Err(NdimageError::InvalidInput(
326            "iterations must be at least 1".into(),
327        ));
328    }
329    let rows = prev.nrows();
330    let cols = prev.ncols();
331    if rows == 0 || cols == 0 {
332        return Err(NdimageError::InvalidInput(
333            "Frames must not be empty".into(),
334        ));
335    }
336
337    let (ix, iy, it) = compute_derivatives(prev, next)?;
338
339    let alpha_sq = alpha * alpha;
340    let mut u = Array2::<f64>::zeros((rows, cols));
341    let mut v = Array2::<f64>::zeros((rows, cols));
342
343    for _ in 0..iterations {
344        // Compute local averages using 4-connected neighbourhood
345        let u_avg = {
346            let mut a = Array2::<f64>::zeros((rows, cols));
347            for r in 0..rows {
348                for c in 0..cols {
349                    let up = if r > 0 { u[[r - 1, c]] } else { u[[r, c]] };
350                    let dn = if r + 1 < rows {
351                        u[[r + 1, c]]
352                    } else {
353                        u[[r, c]]
354                    };
355                    let lt = if c > 0 { u[[r, c - 1]] } else { u[[r, c]] };
356                    let rt = if c + 1 < cols {
357                        u[[r, c + 1]]
358                    } else {
359                        u[[r, c]]
360                    };
361                    a[[r, c]] = (up + dn + lt + rt) / 4.0;
362                }
363            }
364            a
365        };
366        let v_avg = {
367            let mut a = Array2::<f64>::zeros((rows, cols));
368            for r in 0..rows {
369                for c in 0..cols {
370                    let up = if r > 0 { v[[r - 1, c]] } else { v[[r, c]] };
371                    let dn = if r + 1 < rows {
372                        v[[r + 1, c]]
373                    } else {
374                        v[[r, c]]
375                    };
376                    let lt = if c > 0 { v[[r, c - 1]] } else { v[[r, c]] };
377                    let rt = if c + 1 < cols {
378                        v[[r, c + 1]]
379                    } else {
380                        v[[r, c]]
381                    };
382                    a[[r, c]] = (up + dn + lt + rt) / 4.0;
383                }
384            }
385            a
386        };
387
388        for r in 0..rows {
389            for c in 0..cols {
390                let ixi = ix[[r, c]];
391                let iyi = iy[[r, c]];
392                let iti = it[[r, c]];
393                let ua = u_avg[[r, c]];
394                let va = v_avg[[r, c]];
395                let denom = alpha_sq + ixi * ixi + iyi * iyi;
396                let p = (ixi * ua + iyi * va + iti) / denom;
397                u[[r, c]] = ua - ixi * p;
398                v[[r, c]] = va - iyi * p;
399            }
400        }
401    }
402
403    let mut flow = Array3::<f64>::zeros((rows, cols, 2));
404    for r in 0..rows {
405        for c in 0..cols {
406            flow[[r, c, 0]] = u[[r, c]];
407            flow[[r, c, 1]] = v[[r, c]];
408        }
409    }
410    Ok(flow)
411}
412
413// ─── Farneback Polynomial Expansion Optical Flow ─────────────────────────────
414
415/// Farneback dense optical flow using polynomial expansion.
416///
417/// Approximates each image neighbourhood by a quadratic polynomial and
418/// estimates the displacement that aligns the polynomial approximations
419/// of two frames. Uses a coarse-to-fine pyramid for large displacements.
420///
421/// # Arguments
422/// * `prev`   – first frame
423/// * `next`   – second frame (same shape)
424/// * `levels` – number of pyramid levels (1 = no pyramid)
425/// * `winsize` – polynomial expansion window half-size
426///
427/// # Returns
428/// Flow field of shape (rows, cols, 2).
429pub fn farneback_flow(
430    prev: &Array2<f64>,
431    next: &Array2<f64>,
432    levels: usize,
433    winsize: usize,
434) -> NdimageResult<Array3<f64>> {
435    if levels == 0 {
436        return Err(NdimageError::InvalidInput(
437            "levels must be at least 1".into(),
438        ));
439    }
440    if winsize == 0 {
441        return Err(NdimageError::InvalidInput(
442            "winsize must be positive".into(),
443        ));
444    }
445    let rows = prev.nrows();
446    let cols = prev.ncols();
447    if rows == 0 || cols == 0 {
448        return Err(NdimageError::InvalidInput(
449            "Frames must not be empty".into(),
450        ));
451    }
452    if prev.shape() != next.shape() {
453        return Err(NdimageError::DimensionError(
454            "prev and next must have the same shape".into(),
455        ));
456    }
457
458    // Build Gaussian pyramids
459    let prev_pyr = build_pyramid(prev, levels);
460    let next_pyr = build_pyramid(next, levels);
461
462    // Coarse-to-fine estimation
463    let coarsest = levels - 1;
464    let pr = prev_pyr[coarsest].nrows();
465    let pc = prev_pyr[coarsest].ncols();
466    let mut flow_u = Array2::<f64>::zeros((pr, pc));
467    let mut flow_v = Array2::<f64>::zeros((pr, pc));
468
469    for lvl in (0..levels).rev() {
470        let p = &prev_pyr[lvl];
471        let n = &next_pyr[lvl];
472        let lr = p.nrows();
473        let lc = p.ncols();
474
475        // Upsample flow if not at coarsest
476        if lvl < coarsest || (lr != flow_u.nrows() || lc != flow_u.ncols()) {
477            flow_u = upsample_flow(&flow_u, lr, lc, 2.0);
478            flow_v = upsample_flow(&flow_v, lr, lc, 2.0);
479        }
480
481        // Warp next frame by current flow estimate
482        let warped = warp_image(n, &flow_u, &flow_v);
483
484        // Polynomial expansion: fit A*x^2 + B*y^2 + C*x*y + D*x + E*y + F
485        // to windows in both frames. Extract gradient from the linear terms.
486        let (ix, iy, it) = compute_derivatives(p, &warped)?;
487
488        // Update flow using Lucas-Kanade style on the polynomial coefficients
489        let hw = winsize;
490        let ixx = Array2::from_shape_fn((lr, lc), |(r, c)| ix[[r, c]] * ix[[r, c]]);
491        let iyy = Array2::from_shape_fn((lr, lc), |(r, c)| iy[[r, c]] * iy[[r, c]]);
492        let ixy = Array2::from_shape_fn((lr, lc), |(r, c)| ix[[r, c]] * iy[[r, c]]);
493        let ixt = Array2::from_shape_fn((lr, lc), |(r, c)| ix[[r, c]] * it[[r, c]]);
494        let iyt = Array2::from_shape_fn((lr, lc), |(r, c)| iy[[r, c]] * it[[r, c]]);
495
496        let sxx = box_average(&ixx, hw);
497        let syy = box_average(&iyy, hw);
498        let sxy = box_average(&ixy, hw);
499        let sxt = box_average(&ixt, hw);
500        let syt = box_average(&iyt, hw);
501
502        let eps = 1e-12f64;
503        for r in 0..lr {
504            for c in 0..lc {
505                let a = sxx[[r, c]];
506                let b = sxy[[r, c]];
507                let d = syy[[r, c]];
508                let det = a * d - b * b;
509                if det.abs() > eps {
510                    let du = (-d * sxt[[r, c]] + b * syt[[r, c]]) / det;
511                    let dv = (b * sxt[[r, c]] - a * syt[[r, c]]) / det;
512                    flow_u[[r, c]] += du;
513                    flow_v[[r, c]] += dv;
514                }
515            }
516        }
517    }
518
519    // Build output at original resolution
520    let mut flow = Array3::<f64>::zeros((rows, cols, 2));
521    let final_u = if flow_u.nrows() == rows && flow_u.ncols() == cols {
522        flow_u
523    } else {
524        upsample_flow(
525            &flow_u,
526            rows,
527            cols,
528            (rows as f64 / flow_u.nrows() as f64).max(1.0),
529        )
530    };
531    let final_v = if flow_v.nrows() == rows && flow_v.ncols() == cols {
532        flow_v
533    } else {
534        upsample_flow(
535            &flow_v,
536            rows,
537            cols,
538            (rows as f64 / flow_v.nrows() as f64).max(1.0),
539        )
540    };
541    for r in 0..rows {
542        for c in 0..cols {
543            flow[[r, c, 0]] = final_u[[r, c]];
544            flow[[r, c, 1]] = final_v[[r, c]];
545        }
546    }
547    Ok(flow)
548}
549
550/// Build a Gaussian image pyramid with `levels` levels.
551fn build_pyramid(image: &Array2<f64>, levels: usize) -> Vec<Array2<f64>> {
552    let mut pyr = vec![image.clone()];
553    for _ in 1..levels {
554        let last = &pyr[pyr.len() - 1];
555        let down = downsample(last);
556        pyr.push(down);
557    }
558    pyr
559}
560
561/// Downsample an image by a factor of 2 (with Gaussian pre-blur).
562fn downsample(image: &Array2<f64>) -> Array2<f64> {
563    let rows = image.nrows();
564    let cols = image.ncols();
565    let new_rows = (rows + 1) / 2;
566    let new_cols = (cols + 1) / 2;
567    if new_rows == 0 || new_cols == 0 {
568        return image.clone();
569    }
570    // Simple 2×2 box blur then subsample
571    Array2::from_shape_fn((new_rows, new_cols), |(r, c)| {
572        let sr = r * 2;
573        let sc = c * 2;
574        let mut s = 0.0f64;
575        let mut cnt = 0u32;
576        for dr in 0..=1usize {
577            for dc in 0..=1usize {
578                let nr = (sr + dr).min(rows - 1);
579                let nc = (sc + dc).min(cols - 1);
580                s += image[[nr, nc]];
581                cnt += 1;
582            }
583        }
584        s / cnt as f64
585    })
586}
587
588/// Upsample a flow array to (target_rows, target_cols) and scale values.
589fn upsample_flow(
590    flow: &Array2<f64>,
591    target_rows: usize,
592    target_cols: usize,
593    scale: f64,
594) -> Array2<f64> {
595    let src_rows = flow.nrows();
596    let src_cols = flow.ncols();
597    Array2::from_shape_fn((target_rows, target_cols), |(r, c)| {
598        let sr = (r * src_rows / target_rows).min(src_rows - 1);
599        let sc = (c * src_cols / target_cols).min(src_cols - 1);
600        flow[[sr, sc]] * scale
601    })
602}
603
604/// Warp `image` by the displacement field (u, v) using bilinear interpolation.
605fn warp_image(image: &Array2<f64>, u: &Array2<f64>, v: &Array2<f64>) -> Array2<f64> {
606    let rows = image.nrows();
607    let cols = image.ncols();
608    Array2::from_shape_fn((rows, cols), |(r, c)| {
609        let x = c as f64 + u[[r, c]];
610        let y = r as f64 + v[[r, c]];
611        // Bilinear interpolation with clamped boundary
612        let x0 = x.floor() as isize;
613        let y0 = y.floor() as isize;
614        let fx = x - x0 as f64;
615        let fy = y - y0 as f64;
616        let sample = |ri: isize, ci: isize| -> f64 {
617            let ri = ri.max(0).min(rows as isize - 1) as usize;
618            let ci = ci.max(0).min(cols as isize - 1) as usize;
619            image[[ri, ci]]
620        };
621        let i00 = sample(y0, x0);
622        let i01 = sample(y0, x0 + 1);
623        let i10 = sample(y0 + 1, x0);
624        let i11 = sample(y0 + 1, x0 + 1);
625        i00 * (1.0 - fx) * (1.0 - fy)
626            + i01 * fx * (1.0 - fy)
627            + i10 * (1.0 - fx) * fy
628            + i11 * fx * fy
629    })
630}
631
632// ─── Tests ───────────────────────────────────────────────────────────────────
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use scirs2_core::ndarray::Array2;
638
639    /// Create a simple gradient-shifted frame pair with known horizontal shift.
640    fn make_shifted_pair(rows: usize, cols: usize, shift_c: isize) -> (Array2<f64>, Array2<f64>) {
641        let prev = Array2::from_shape_fn((rows, cols), |(_, c)| c as f64 / cols as f64);
642        let next = Array2::from_shape_fn((rows, cols), |(_, c)| {
643            let nc = (c as isize - shift_c).max(0).min(cols as isize - 1) as usize;
644            nc as f64 / cols as f64
645        });
646        (prev, next)
647    }
648
649    // ── FlowField tests ──────────────────────────────────────────────────────
650
651    #[test]
652    fn test_flow_field_zeros() {
653        let ff = FlowField::zeros(4, 4);
654        assert_eq!(ff.rows(), 4);
655        assert_eq!(ff.cols(), 4);
656        let (u, v) = ff.get(0, 0);
657        assert!((u - 0.0).abs() < 1e-12);
658        assert!((v - 0.0).abs() < 1e-12);
659    }
660
661    #[test]
662    fn test_flow_field_set_get() {
663        let mut ff = FlowField::zeros(4, 4);
664        ff.set(2, 3, 1.5, -0.5);
665        let (u, v) = ff.get(2, 3);
666        assert!((u - 1.5).abs() < 1e-12);
667        assert!((v + 0.5).abs() < 1e-12);
668    }
669
670    #[test]
671    fn test_flow_field_from_array_invalid() {
672        let bad = Array3::<f64>::zeros((4, 4, 3)); // should be 2, not 3
673        assert!(FlowField::from_array(bad).is_err());
674    }
675
676    #[test]
677    fn test_flow_field_magnitude() {
678        let mut ff = FlowField::zeros(3, 3);
679        ff.set(1, 1, 3.0, 4.0);
680        let mag = ff.magnitude();
681        assert!((mag[[1, 1]] - 5.0).abs() < 1e-9);
682    }
683
684    // ── Lucas-Kanade tests ───────────────────────────────────────────────────
685
686    #[test]
687    fn test_lucas_kanade_shape() {
688        let (prev, next) = make_shifted_pair(16, 16, 1);
689        let flow = lucas_kanade_flow(&prev, &next, 5).expect("LK failed");
690        assert_eq!(flow.shape(), &[16, 16, 2]);
691    }
692
693    #[test]
694    fn test_lucas_kanade_zero_motion() {
695        let img = Array2::from_shape_fn((10, 10), |(r, c)| (r + c) as f64 * 0.1);
696        let flow = lucas_kanade_flow(&img, &img, 5).expect("LK zero motion");
697        // No motion → flow should be near zero
698        let max_flow = flow.iter().cloned().fold(0.0f64, |acc, v| acc.max(v.abs()));
699        assert!(max_flow < 1e-6, "Expected near-zero flow, got {max_flow}");
700    }
701
702    #[test]
703    fn test_lucas_kanade_invalid_window() {
704        let img = Array2::<f64>::zeros((8, 8));
705        assert!(lucas_kanade_flow(&img, &img, 0).is_err());
706    }
707
708    #[test]
709    fn test_lucas_kanade_shape_mismatch() {
710        let prev = Array2::<f64>::zeros((8, 8));
711        let next = Array2::<f64>::zeros((4, 4));
712        assert!(lucas_kanade_flow(&prev, &next, 5).is_err());
713    }
714
715    // ── Horn-Schunck tests ───────────────────────────────────────────────────
716
717    #[test]
718    fn test_horn_schunck_shape() {
719        let (prev, next) = make_shifted_pair(12, 12, 1);
720        let flow = horn_schunck_flow(&prev, &next, 1.0, 50).expect("HS failed");
721        assert_eq!(flow.shape(), &[12, 12, 2]);
722    }
723
724    #[test]
725    fn test_horn_schunck_zero_motion() {
726        let img = Array2::from_shape_fn((8, 8), |(r, c)| (r * c) as f64 * 0.01);
727        let flow = horn_schunck_flow(&img, &img, 10.0, 20).expect("HS zero motion");
728        let max_flow = flow.iter().cloned().fold(0.0f64, |acc, v| acc.max(v.abs()));
729        assert!(max_flow < 1e-6, "Expected near-zero flow, got {max_flow}");
730    }
731
732    #[test]
733    fn test_horn_schunck_invalid_alpha() {
734        let img = Array2::<f64>::zeros((4, 4));
735        assert!(horn_schunck_flow(&img, &img, 0.0, 10).is_err());
736        assert!(horn_schunck_flow(&img, &img, -1.0, 10).is_err());
737    }
738
739    #[test]
740    fn test_horn_schunck_invalid_iterations() {
741        let img = Array2::<f64>::zeros((4, 4));
742        assert!(horn_schunck_flow(&img, &img, 1.0, 0).is_err());
743    }
744
745    // ── Farneback tests ──────────────────────────────────────────────────────
746
747    #[test]
748    fn test_farneback_shape() {
749        let (prev, next) = make_shifted_pair(16, 16, 1);
750        let flow = farneback_flow(&prev, &next, 2, 5).expect("Farneback failed");
751        assert_eq!(flow.shape(), &[16, 16, 2]);
752    }
753
754    #[test]
755    fn test_farneback_zero_motion() {
756        let img = Array2::from_shape_fn((8, 8), |(r, c)| (r + c) as f64 * 0.1);
757        let flow = farneback_flow(&img, &img, 1, 3).expect("Farneback zero motion");
758        let max_flow = flow.iter().cloned().fold(0.0f64, |acc, v| acc.max(v.abs()));
759        assert!(max_flow < 1e-4, "Expected near-zero flow, got {max_flow}");
760    }
761
762    #[test]
763    fn test_farneback_invalid_levels() {
764        let img = Array2::<f64>::zeros((8, 8));
765        assert!(farneback_flow(&img, &img, 0, 3).is_err());
766    }
767
768    #[test]
769    fn test_farneback_invalid_winsize() {
770        let img = Array2::<f64>::zeros((8, 8));
771        assert!(farneback_flow(&img, &img, 2, 0).is_err());
772    }
773}