Skip to main content

oximedia_align/
stabilize.rs

1//! Video stabilization pipeline.
2//!
3//! Combines optical flow estimation, affine transform fitting, temporal
4//! smoothing, and image warping to produce stabilized video output.
5//!
6//! # Pipeline stages
7//!
8//! 1. **Motion estimation**: Compute inter-frame motion using sparse KLT
9//!    tracking (or dense Farneback flow).
10//! 2. **Global motion model**: Fit an affine (or similarity) transform to the
11//!    estimated motion vectors using RANSAC.
12//! 3. **Trajectory smoothing**: Build a cumulative camera trajectory and smooth
13//!    it with a configurable Gaussian filter to remove high-frequency jitter
14//!    while preserving intentional camera movements.
15//! 4. **Compensation**: Apply the smoothed correction to each frame via affine
16//!    warping.
17
18#![allow(clippy::cast_precision_loss)]
19
20use crate::affine::AffineMatrix;
21use crate::{AlignError, AlignResult};
22
23/// Stabilization strategy.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum StabilizationMode {
26    /// Correct only translation (2 DOF).
27    Translation,
28    /// Correct translation + rotation (3 DOF).
29    TranslationRotation,
30    /// Full affine correction (6 DOF).
31    Affine,
32}
33
34/// Configuration for the stabilization pipeline.
35#[derive(Debug, Clone)]
36pub struct StabilizeConfig {
37    /// Stabilization mode.
38    pub mode: StabilizationMode,
39    /// Gaussian smoothing radius (in frames). Larger values produce smoother
40    /// output but can introduce more cropping/border artefacts.
41    pub smooth_radius: usize,
42    /// Standard deviation of the Gaussian kernel (in frames).
43    pub smooth_sigma: f64,
44    /// Maximum correction in pixels. Corrections exceeding this are clamped
45    /// to prevent extreme warping.
46    pub max_correction_px: f64,
47    /// Border handling: percentage of the frame to crop on each side (0.0 to 0.5).
48    pub crop_ratio: f64,
49}
50
51impl Default for StabilizeConfig {
52    fn default() -> Self {
53        Self {
54            mode: StabilizationMode::TranslationRotation,
55            smooth_radius: 15,
56            smooth_sigma: 5.0,
57            max_correction_px: 50.0,
58            crop_ratio: 0.05,
59        }
60    }
61}
62
63/// A single frame's estimated global motion relative to the previous frame.
64#[derive(Debug, Clone, Copy)]
65pub struct FrameMotion {
66    /// Translation X.
67    pub dx: f64,
68    /// Translation Y.
69    pub dy: f64,
70    /// Rotation angle (radians).
71    pub da: f64,
72    /// Scale factor (1.0 = no scaling).
73    pub ds: f64,
74}
75
76impl FrameMotion {
77    /// Create a new frame motion with all parameters.
78    #[must_use]
79    pub fn new(dx: f64, dy: f64, da: f64, ds: f64) -> Self {
80        Self { dx, dy, da, ds }
81    }
82
83    /// Identity (no motion).
84    #[must_use]
85    pub fn identity() -> Self {
86        Self {
87            dx: 0.0,
88            dy: 0.0,
89            da: 0.0,
90            ds: 1.0,
91        }
92    }
93
94    /// Convert to an affine matrix.
95    #[must_use]
96    pub fn to_affine(&self) -> AffineMatrix {
97        let c = (self.da.cos() * self.ds) as f32;
98        let s = (self.da.sin() * self.ds) as f32;
99        AffineMatrix {
100            data: [
101                [c, -s, self.dx as f32],
102                [s, c, self.dy as f32],
103                [0.0, 0.0, 1.0],
104            ],
105        }
106    }
107}
108
109/// Cumulative trajectory: the sum of all frame motions up to a given frame.
110#[derive(Debug, Clone, Copy)]
111pub struct Trajectory {
112    /// Cumulative X translation.
113    pub x: f64,
114    /// Cumulative Y translation.
115    pub y: f64,
116    /// Cumulative angle.
117    pub a: f64,
118}
119
120impl Trajectory {
121    /// Zero trajectory.
122    #[must_use]
123    pub fn zero() -> Self {
124        Self {
125            x: 0.0,
126            y: 0.0,
127            a: 0.0,
128        }
129    }
130}
131
132/// Build the cumulative trajectory from per-frame motions.
133#[must_use]
134pub fn build_trajectory(motions: &[FrameMotion]) -> Vec<Trajectory> {
135    let mut trajectory = Vec::with_capacity(motions.len());
136    let mut cum = Trajectory::zero();
137
138    for m in motions {
139        cum.x += m.dx;
140        cum.y += m.dy;
141        cum.a += m.da;
142        trajectory.push(cum);
143    }
144
145    trajectory
146}
147
148/// Smooth a trajectory using a 1D Gaussian filter.
149///
150/// The smoothed trajectory preserves intentional camera movements (pans, tilts)
151/// while removing high-frequency jitter.
152#[must_use]
153pub fn smooth_trajectory(trajectory: &[Trajectory], radius: usize, sigma: f64) -> Vec<Trajectory> {
154    if trajectory.is_empty() {
155        return Vec::new();
156    }
157
158    let n = trajectory.len();
159    let kernel = build_gaussian_kernel(radius, sigma);
160
161    let mut smoothed = Vec::with_capacity(n);
162
163    for i in 0..n {
164        let mut sum_x = 0.0_f64;
165        let mut sum_y = 0.0_f64;
166        let mut sum_a = 0.0_f64;
167        let mut sum_w = 0.0_f64;
168
169        for (ki, &w) in kernel.iter().enumerate() {
170            let j_signed = i as isize + ki as isize - radius as isize;
171            let j = j_signed.max(0).min((n - 1) as isize) as usize;
172
173            sum_x += trajectory[j].x * w;
174            sum_y += trajectory[j].y * w;
175            sum_a += trajectory[j].a * w;
176            sum_w += w;
177        }
178
179        if sum_w > 0.0 {
180            smoothed.push(Trajectory {
181                x: sum_x / sum_w,
182                y: sum_y / sum_w,
183                a: sum_a / sum_w,
184            });
185        } else {
186            smoothed.push(trajectory[i]);
187        }
188    }
189
190    smoothed
191}
192
193/// Compute the per-frame correction transforms.
194///
195/// The correction is: `smoothed_trajectory - original_trajectory`, which gives
196/// the delta to apply to each frame.
197#[must_use]
198pub fn compute_corrections(
199    motions: &[FrameMotion],
200    original: &[Trajectory],
201    smoothed: &[Trajectory],
202    config: &StabilizeConfig,
203) -> Vec<FrameMotion> {
204    let n = motions.len().min(original.len()).min(smoothed.len());
205    let mut corrections = Vec::with_capacity(n);
206
207    for i in 0..n {
208        let mut cdx = smoothed[i].x - original[i].x;
209        let mut cdy = smoothed[i].y - original[i].y;
210        let cda = smoothed[i].a - original[i].a;
211
212        // Clamp correction
213        let mag = (cdx * cdx + cdy * cdy).sqrt();
214        if mag > config.max_correction_px {
215            let scale = config.max_correction_px / mag;
216            cdx *= scale;
217            cdy *= scale;
218        }
219
220        corrections.push(FrameMotion::new(cdx, cdy, cda, 1.0));
221    }
222
223    corrections
224}
225
226/// Apply an affine correction to a single grayscale frame.
227///
228/// # Errors
229///
230/// Returns an error if the frame size is inconsistent.
231pub fn apply_stabilization(
232    frame: &[u8],
233    width: usize,
234    height: usize,
235    correction: &FrameMotion,
236    config: &StabilizeConfig,
237) -> AlignResult<Vec<u8>> {
238    if frame.len() != width * height {
239        return Err(AlignError::InvalidConfig(
240            "Frame size does not match width*height".to_string(),
241        ));
242    }
243
244    let mat = correction.to_affine();
245
246    // Compute inverse transform for backward mapping
247    let inv = invert_affine(&mat)?;
248
249    // Compute crop region
250    let crop_x = (width as f64 * config.crop_ratio) as usize;
251    let crop_y = (height as f64 * config.crop_ratio) as usize;
252
253    let mut output = vec![0u8; width * height];
254
255    for y in 0..height {
256        for x in 0..width {
257            // Map output pixel to input pixel via inverse transform
258            let (sx, sy) = inv.transform_point(x as f32, y as f32);
259
260            let sx = sx as isize;
261            let sy = sy as isize;
262
263            // Bounds check (accounting for crop region = fill with border value)
264            if sx >= crop_x as isize
265                && sy >= crop_y as isize
266                && sx < (width - crop_x) as isize
267                && sy < (height - crop_y) as isize
268            {
269                let src_idx = sy as usize * width + sx as usize;
270                if src_idx < frame.len() {
271                    output[y * width + x] = frame[src_idx];
272                }
273            }
274        }
275    }
276
277    Ok(output)
278}
279
280/// Full stabilization pipeline: given a sequence of frame motions, compute
281/// the stabilized correction transforms.
282///
283/// # Errors
284///
285/// Returns an error if the motions slice is empty.
286pub fn stabilize_pipeline(
287    motions: &[FrameMotion],
288    config: &StabilizeConfig,
289) -> AlignResult<Vec<FrameMotion>> {
290    if motions.is_empty() {
291        return Err(AlignError::InsufficientData(
292            "Need at least one frame motion".to_string(),
293        ));
294    }
295
296    let trajectory = build_trajectory(motions);
297    let smoothed = smooth_trajectory(&trajectory, config.smooth_radius, config.smooth_sigma);
298    let corrections = compute_corrections(motions, &trajectory, &smoothed, config);
299
300    Ok(corrections)
301}
302
303/// Estimate per-frame motion from consecutive grayscale frames using a simple
304/// block-matching approach (for use when KLT is not yet integrated).
305///
306/// Returns the estimated translation (dx, dy) between `prev` and `curr`.
307///
308/// # Errors
309///
310/// Returns an error if the frame sizes are inconsistent.
311pub fn estimate_motion_translation(
312    prev: &[u8],
313    curr: &[u8],
314    width: usize,
315    height: usize,
316    block_size: usize,
317    search_range: usize,
318) -> AlignResult<FrameMotion> {
319    if prev.len() != width * height || curr.len() != width * height {
320        return Err(AlignError::InvalidConfig(
321            "Frame size does not match width*height".to_string(),
322        ));
323    }
324
325    let mut total_dx = 0.0_f64;
326    let mut total_dy = 0.0_f64;
327    let mut count = 0u32;
328
329    // Sample blocks from the interior
330    let margin = search_range + block_size;
331    if margin >= width / 2 || margin >= height / 2 {
332        return Ok(FrameMotion::identity());
333    }
334
335    for by in (margin..height - margin).step_by(block_size) {
336        for bx in (margin..width - margin).step_by(block_size) {
337            let (best_dx, best_dy) =
338                match_block(prev, curr, width, height, bx, by, block_size, search_range);
339            total_dx += best_dx as f64;
340            total_dy += best_dy as f64;
341            count += 1;
342        }
343    }
344
345    if count == 0 {
346        return Ok(FrameMotion::identity());
347    }
348
349    Ok(FrameMotion::new(
350        total_dx / f64::from(count),
351        total_dy / f64::from(count),
352        0.0,
353        1.0,
354    ))
355}
356
357// -- Internal helpers ---------------------------------------------------------
358
359fn match_block(
360    prev: &[u8],
361    curr: &[u8],
362    width: usize,
363    _height: usize,
364    bx: usize,
365    by: usize,
366    block_size: usize,
367    search_range: usize,
368) -> (i32, i32) {
369    let mut best_sad = u64::MAX;
370    let mut best_dx = 0i32;
371    let mut best_dy = 0i32;
372    let sr = search_range as i32;
373
374    for dy in -sr..=sr {
375        for dx in -sr..=sr {
376            let mut sad = 0u64;
377            for row in 0..block_size {
378                for col in 0..block_size {
379                    let px = bx + col;
380                    let py = by + row;
381                    let cx = (px as i32 + dx) as usize;
382                    let cy = (py as i32 + dy) as usize;
383
384                    let prev_val = i32::from(prev[py * width + px]);
385                    let curr_val = i32::from(curr[cy * width + cx]);
386                    sad += (prev_val - curr_val).unsigned_abs() as u64;
387                }
388            }
389
390            if sad < best_sad
391                || (sad == best_sad
392                    && (dx.unsigned_abs() + dy.unsigned_abs())
393                        < (best_dx.unsigned_abs() + best_dy.unsigned_abs()))
394            {
395                best_sad = sad;
396                best_dx = dx;
397                best_dy = dy;
398            }
399        }
400    }
401
402    (best_dx, best_dy)
403}
404
405fn build_gaussian_kernel(radius: usize, sigma: f64) -> Vec<f64> {
406    let size = 2 * radius + 1;
407    let mut kernel = Vec::with_capacity(size);
408    let sigma2 = sigma * sigma;
409
410    for i in 0..size {
411        let x = i as f64 - radius as f64;
412        kernel.push((-0.5 * x * x / sigma2).exp());
413    }
414
415    kernel
416}
417
418fn invert_affine(mat: &AffineMatrix) -> AlignResult<AffineMatrix> {
419    let a = f64::from(mat.data[0][0]);
420    let b = f64::from(mat.data[0][1]);
421    let tx = f64::from(mat.data[0][2]);
422    let c = f64::from(mat.data[1][0]);
423    let d = f64::from(mat.data[1][1]);
424    let ty = f64::from(mat.data[1][2]);
425
426    let det = a * d - b * c;
427    if det.abs() < 1e-12 {
428        return Err(AlignError::NumericalError(
429            "Singular affine matrix cannot be inverted".to_string(),
430        ));
431    }
432
433    let inv_det = 1.0 / det;
434    let ia = (d * inv_det) as f32;
435    let ib = (-b * inv_det) as f32;
436    let ic = (-c * inv_det) as f32;
437    let id = (a * inv_det) as f32;
438    let itx = ((-d * tx + b * ty) * inv_det) as f32;
439    let ity = ((c * tx - a * ty) * inv_det) as f32;
440
441    Ok(AffineMatrix {
442        data: [[ia, ib, itx], [ic, id, ity], [0.0, 0.0, 1.0]],
443    })
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    // -- FrameMotion ----------------------------------------------------------
451
452    #[test]
453    fn test_frame_motion_identity() {
454        let m = FrameMotion::identity();
455        assert_eq!(m.dx, 0.0);
456        assert_eq!(m.dy, 0.0);
457        assert_eq!(m.da, 0.0);
458        assert_eq!(m.ds, 1.0);
459    }
460
461    #[test]
462    fn test_frame_motion_to_affine_identity() {
463        let m = FrameMotion::identity();
464        let mat = m.to_affine();
465        assert!(mat.is_identity());
466    }
467
468    #[test]
469    fn test_frame_motion_to_affine_translation() {
470        let m = FrameMotion::new(10.0, 20.0, 0.0, 1.0);
471        let mat = m.to_affine();
472        let (x, y) = mat.transform_point(0.0, 0.0);
473        assert!((x - 10.0).abs() < 1e-4);
474        assert!((y - 20.0).abs() < 1e-4);
475    }
476
477    #[test]
478    fn test_frame_motion_to_affine_rotation() {
479        let m = FrameMotion::new(0.0, 0.0, std::f64::consts::PI / 2.0, 1.0);
480        let mat = m.to_affine();
481        let (x, y) = mat.transform_point(1.0, 0.0);
482        assert!((x - 0.0).abs() < 1e-4, "x={x}");
483        assert!((y - 1.0).abs() < 1e-4, "y={y}");
484    }
485
486    // -- Trajectory -----------------------------------------------------------
487
488    #[test]
489    fn test_build_trajectory() {
490        let motions = vec![
491            FrameMotion::new(1.0, 0.0, 0.0, 1.0),
492            FrameMotion::new(2.0, 0.0, 0.0, 1.0),
493            FrameMotion::new(-1.0, 0.0, 0.0, 1.0),
494        ];
495        let traj = build_trajectory(&motions);
496        assert_eq!(traj.len(), 3);
497        assert!((traj[0].x - 1.0).abs() < 1e-10);
498        assert!((traj[1].x - 3.0).abs() < 1e-10);
499        assert!((traj[2].x - 2.0).abs() < 1e-10);
500    }
501
502    #[test]
503    fn test_build_trajectory_empty() {
504        let traj = build_trajectory(&[]);
505        assert!(traj.is_empty());
506    }
507
508    // -- Smoothing ------------------------------------------------------------
509
510    #[test]
511    fn test_smooth_trajectory_constant() {
512        let traj = vec![
513            Trajectory {
514                x: 5.0,
515                y: 3.0,
516                a: 0.0,
517            },
518            Trajectory {
519                x: 5.0,
520                y: 3.0,
521                a: 0.0,
522            },
523            Trajectory {
524                x: 5.0,
525                y: 3.0,
526                a: 0.0,
527            },
528            Trajectory {
529                x: 5.0,
530                y: 3.0,
531                a: 0.0,
532            },
533            Trajectory {
534                x: 5.0,
535                y: 3.0,
536                a: 0.0,
537            },
538        ];
539        let smoothed = smooth_trajectory(&traj, 2, 1.0);
540        for s in &smoothed {
541            assert!((s.x - 5.0).abs() < 1e-6);
542            assert!((s.y - 3.0).abs() < 1e-6);
543        }
544    }
545
546    #[test]
547    fn test_smooth_trajectory_reduces_spike() {
548        let traj = vec![
549            Trajectory {
550                x: 0.0,
551                y: 0.0,
552                a: 0.0,
553            },
554            Trajectory {
555                x: 0.0,
556                y: 0.0,
557                a: 0.0,
558            },
559            Trajectory {
560                x: 100.0,
561                y: 0.0,
562                a: 0.0,
563            }, // spike
564            Trajectory {
565                x: 0.0,
566                y: 0.0,
567                a: 0.0,
568            },
569            Trajectory {
570                x: 0.0,
571                y: 0.0,
572                a: 0.0,
573            },
574        ];
575        let smoothed = smooth_trajectory(&traj, 2, 1.0);
576        // The spike at index 2 should be attenuated
577        assert!(
578            smoothed[2].x < 100.0,
579            "spike should be smoothed: {}",
580            smoothed[2].x
581        );
582        assert!(smoothed[2].x > 0.0, "spike should still have some value");
583    }
584
585    #[test]
586    fn test_smooth_trajectory_empty() {
587        let smoothed = smooth_trajectory(&[], 5, 1.0);
588        assert!(smoothed.is_empty());
589    }
590
591    // -- Corrections ----------------------------------------------------------
592
593    #[test]
594    fn test_corrections_zero_when_no_smoothing() {
595        let motions = vec![FrameMotion::new(1.0, 0.0, 0.0, 1.0); 5];
596        let traj = build_trajectory(&motions);
597        // If smoothed == original, corrections should be zero
598        let corrections = compute_corrections(&motions, &traj, &traj, &StabilizeConfig::default());
599        for c in &corrections {
600            assert!((c.dx).abs() < 1e-10);
601            assert!((c.dy).abs() < 1e-10);
602        }
603    }
604
605    #[test]
606    fn test_corrections_clamped() {
607        let config = StabilizeConfig {
608            max_correction_px: 10.0,
609            ..StabilizeConfig::default()
610        };
611
612        let original = vec![Trajectory {
613            x: 0.0,
614            y: 0.0,
615            a: 0.0,
616        }];
617        let smoothed = vec![Trajectory {
618            x: 100.0,
619            y: 0.0,
620            a: 0.0,
621        }];
622        let motions = vec![FrameMotion::identity()];
623
624        let corrections = compute_corrections(&motions, &original, &smoothed, &config);
625        let mag =
626            (corrections[0].dx * corrections[0].dx + corrections[0].dy * corrections[0].dy).sqrt();
627        assert!(
628            mag <= config.max_correction_px + 1e-6,
629            "correction should be clamped: mag={mag}"
630        );
631    }
632
633    // -- Pipeline -------------------------------------------------------------
634
635    #[test]
636    fn test_stabilize_pipeline() {
637        let motions: Vec<FrameMotion> = (0..30)
638            .map(|i| FrameMotion::new((i as f64 * 0.5).sin() * 5.0, 0.0, 0.0, 1.0))
639            .collect();
640
641        let config = StabilizeConfig {
642            smooth_radius: 5,
643            smooth_sigma: 2.0,
644            max_correction_px: 50.0,
645            ..StabilizeConfig::default()
646        };
647
648        let corrections = stabilize_pipeline(&motions, &config).expect("should succeed");
649        assert_eq!(corrections.len(), motions.len());
650    }
651
652    #[test]
653    fn test_stabilize_pipeline_empty() {
654        let result = stabilize_pipeline(&[], &StabilizeConfig::default());
655        assert!(result.is_err());
656    }
657
658    // -- Apply stabilization --------------------------------------------------
659
660    #[test]
661    fn test_apply_stabilization_identity() {
662        let w = 32usize;
663        let h = 32usize;
664        let frame: Vec<u8> = (0..w * h).map(|i| (i % 256) as u8).collect();
665
666        let correction = FrameMotion::identity();
667        let config = StabilizeConfig {
668            crop_ratio: 0.0,
669            ..StabilizeConfig::default()
670        };
671
672        let output =
673            apply_stabilization(&frame, w, h, &correction, &config).expect("should succeed");
674        // With identity correction and no crop, output should match input
675        assert_eq!(output.len(), frame.len());
676        // Interior pixels should match
677        for y in 1..h - 1 {
678            for x in 1..w - 1 {
679                assert_eq!(
680                    output[y * w + x],
681                    frame[y * w + x],
682                    "mismatch at ({x}, {y})"
683                );
684            }
685        }
686    }
687
688    #[test]
689    fn test_apply_stabilization_size_mismatch() {
690        let result = apply_stabilization(
691            &[0u8; 100],
692            20,
693            20,
694            &FrameMotion::identity(),
695            &StabilizeConfig::default(),
696        );
697        assert!(result.is_err());
698    }
699
700    // -- Motion estimation ----------------------------------------------------
701
702    #[test]
703    fn test_estimate_motion_identical() {
704        let w = 64usize;
705        let h = 64usize;
706        let frame = vec![128u8; w * h];
707
708        let motion =
709            estimate_motion_translation(&frame, &frame, w, h, 8, 4).expect("should succeed");
710        assert!((motion.dx).abs() < 1.0);
711        assert!((motion.dy).abs() < 1.0);
712    }
713
714    #[test]
715    fn test_estimate_motion_shifted() {
716        let w = 128usize;
717        let h = 128usize;
718        let mut prev = vec![64u8; w * h];
719        let mut curr = vec![64u8; w * h];
720
721        // Create a strong vertical stripe pattern
722        for y in 0..h {
723            for x in 0..w {
724                prev[y * w + x] = if (x / 16) % 2 == 0 { 200 } else { 50 };
725                // Shift right by 3
726                let sx = if x >= 3 { x - 3 } else { 0 };
727                curr[y * w + x] = if (sx / 16) % 2 == 0 { 200 } else { 50 };
728            }
729        }
730
731        let motion =
732            estimate_motion_translation(&prev, &curr, w, h, 16, 8).expect("should succeed");
733        // Should detect roughly 3px rightward shift
734        assert!(
735            (motion.dx - 3.0).abs() < 3.0,
736            "expected ~3px shift, got dx={:.2}",
737            motion.dx
738        );
739    }
740
741    #[test]
742    fn test_estimate_motion_size_mismatch() {
743        let result = estimate_motion_translation(&[0u8; 100], &[0u8; 200], 10, 10, 4, 2);
744        assert!(result.is_err());
745    }
746
747    // -- Internal helpers -----------------------------------------------------
748
749    #[test]
750    fn test_gaussian_kernel_is_symmetric() {
751        let kernel = build_gaussian_kernel(5, 2.0);
752        assert_eq!(kernel.len(), 11);
753        for i in 0..5 {
754            assert!((kernel[i] - kernel[10 - i]).abs() < 1e-10);
755        }
756    }
757
758    #[test]
759    fn test_gaussian_kernel_peak_at_center() {
760        let kernel = build_gaussian_kernel(3, 1.0);
761        let center = kernel[3];
762        for (i, &v) in kernel.iter().enumerate() {
763            if i != 3 {
764                assert!(v <= center + 1e-10);
765            }
766        }
767    }
768
769    #[test]
770    fn test_invert_affine_identity() {
771        let id = AffineMatrix::identity();
772        let inv = invert_affine(&id).expect("should succeed");
773        assert!(inv.is_identity());
774    }
775
776    #[test]
777    fn test_invert_affine_translation() {
778        let t = AffineMatrix::translation(5.0, -3.0);
779        let inv = invert_affine(&t).expect("should succeed");
780        let (x, y) = inv.transform_point(5.0, -3.0);
781        assert!((x - 0.0).abs() < 1e-4);
782        assert!((y - 0.0).abs() < 1e-4);
783    }
784
785    #[test]
786    fn test_invert_affine_singular() {
787        let singular = AffineMatrix {
788            data: [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
789        };
790        assert!(invert_affine(&singular).is_err());
791    }
792
793    #[test]
794    fn test_stabilize_config_default() {
795        let config = StabilizeConfig::default();
796        assert_eq!(config.smooth_radius, 15);
797        assert_eq!(config.mode, StabilizationMode::TranslationRotation);
798    }
799}