1#![allow(clippy::cast_precision_loss)]
19
20use crate::affine::AffineMatrix;
21use crate::{AlignError, AlignResult};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum StabilizationMode {
26 Translation,
28 TranslationRotation,
30 Affine,
32}
33
34#[derive(Debug, Clone)]
36pub struct StabilizeConfig {
37 pub mode: StabilizationMode,
39 pub smooth_radius: usize,
42 pub smooth_sigma: f64,
44 pub max_correction_px: f64,
47 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#[derive(Debug, Clone, Copy)]
65pub struct FrameMotion {
66 pub dx: f64,
68 pub dy: f64,
70 pub da: f64,
72 pub ds: f64,
74}
75
76impl FrameMotion {
77 #[must_use]
79 pub fn new(dx: f64, dy: f64, da: f64, ds: f64) -> Self {
80 Self { dx, dy, da, ds }
81 }
82
83 #[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 #[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#[derive(Debug, Clone, Copy)]
111pub struct Trajectory {
112 pub x: f64,
114 pub y: f64,
116 pub a: f64,
118}
119
120impl Trajectory {
121 #[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#[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#[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#[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 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
226pub 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 let inv = invert_affine(&mat)?;
248
249 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 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 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
280pub 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
303pub 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 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
357fn 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 #[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 #[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 #[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 }, 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 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 #[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 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 #[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 #[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 assert_eq!(output.len(), frame.len());
676 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 #[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 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 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 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 #[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}