libstacker/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2021,2025 lacklustr@protonmail.com https://github.com/eadf
3
4//! This library contains multi-threaded image stacking functions,
5//! based on OpenCV <https://crates.io/crates/opencv> and Rayon <https://crates.io/crates/rayon>.
6//!
7//! Copyright (c) 2021, 2025 Eadf <lacklustr@protonmail.com>.
8//! License: MIT/Apache 2.0
9//!
10//! The is a port of code written by Mathias Sundholm.
11//! Copyright (c) 2021 <https://github.com/maitek/image_stacking>
12//! License: MIT
13//!
14//! Read more about image alignment with OpenCV here:
15//! <https://learnopencv.com/image-alignment-ecc-in-opencv-c-python>
16
17pub mod utils;
18
19pub use opencv;
20use opencv::core::{Point2f, Vector};
21use opencv::{calib3d, core, features2d, imgcodecs, imgproc, prelude::*};
22use ordered_float::OrderedFloat;
23use rayon::prelude::*;
24use std::path::PathBuf;
25use thiserror::Error;
26use utils::SetMValue;
27
28#[derive(Error, Debug)]
29pub enum StackerError {
30    #[error(transparent)]
31    OpenCvError(#[from] opencv::Error),
32    #[error("Not enough files")]
33    NotEnoughFiles,
34    #[error("Not implemented")]
35    NotImplemented,
36    #[error(transparent)]
37    IoError(#[from] std::io::Error),
38    #[error(transparent)]
39    PoisonError(#[from] std::sync::PoisonError<core::MatExprResult<core::MatExpr>>),
40    #[error("Invalid path encoding {0}")]
41    InvalidPathEncoding(PathBuf),
42    #[error("Invalid parameter(s) {0}")]
43    InvalidParams(String),
44    #[error("Internal error {0}")]
45    ProcessingError(String),
46}
47
48/// Parameters for keypoint matching and homography estimation.
49#[derive(Debug, Clone, Copy)]
50pub struct KeyPointMatchParameters {
51    /// Method used in `opencv::calib3d::find_homography()`, typically `opencv::calib3d::RANSAC`.
52    pub method: i32,
53
54    /// Reprojection threshold for RANSAC in `find_homography()`.
55    /// A lower value makes RANSAC stricter (fewer matches kept), while a higher value is more lenient.
56    pub ransac_reproj_threshold: f64,
57
58    /// Ratio of best matches to keep after sorting by distance.
59    /// Common values range from 0.5 to 0.8.
60    pub match_keep_ratio: f32,
61
62    /// Lowe’s ratio test threshold: how similar the best and second-best matches must be.
63    /// Used in `knn_match()` to filter ambiguous matches.
64    /// Common values range from 0.7 to 0.9.
65    pub match_ratio: f32,
66
67    /// Border mode used when warping images.
68    /// Default: `opencv::core::BORDER_CONSTANT`.
69    pub border_mode: i32,
70
71    /// Border value used in warping.
72    /// Default: `opencv::core::Scalar::default()`.
73    pub border_value: core::Scalar,
74}
75
76/// Aligns and combines multiple images by matching keypoints, with optional scaling for performance.
77///
78/// This function processes a sequence of images by:
79/// 1. Detecting ORB features (either at full resolution or scaled-down for faster processing)
80/// 2. Matching features between the first (reference) image and subsequent images
81/// 3. Computing homographies to align images to the reference
82/// 4. Warping images to the reference frame and averaging them
83///
84/// When scaling is enabled:
85/// - Feature detection/matching occurs on smaller images for performance
86/// - Homographies are scaled appropriately for full-size warping
87///
88/// # Parameters
89/// - `files`: An iterator of paths to image files (any type implementing `AsRef<Path>`)
90/// - `params`: Configuration parameters for keypoint matching:
91///   - `method`: Homography computation method (e.g., RANSAC, LMEDS)
92///   - `ransac_reproj_threshold`: Maximum reprojection error for inlier classification
93/// - `scale_down_width`: Controls performance/accuracy trade-off:
94///   - `Some(width)`: Process features at specified width (faster, recommended 400-800px)
95///   - `None`: Process at full resolution (more accurate but slower)
96///   - Must be smaller than original image width when specified
97///
98/// # Returns
99/// - `Ok((i32, Mat)).`: number of dropped frames + Combined/averaged image in CV_32F format (normalized 0-1 range)
100/// - `Err(StackerError)` on failure cases:
101///   - No input files provided
102///   - Invalid scale width (when specified)
103///   - Image loading/processing failures
104///
105/// # Performance Characteristics
106/// - Parallel processing (Rayon) for multi-image alignment
107/// - Memory efficient streaming processing
108/// - Output matches size/coordinates of first (reference) image
109/// - Scaling typically provides 2-4x speedup with minimal accuracy loss
110///
111/// ```rust,no_run
112/// # use libstacker::{prelude::*, opencv::prelude::*};
113/// # fn f() -> Result<(),StackerError> {
114/// let keypoint_match_img:opencv::core::Mat = keypoint_match(
115///     vec!["1.jpg","2.jpg","3.jpg","4.jpg","5.jpg"],
116///     KeyPointMatchParameters {
117///         method: opencv::calib3d::RANSAC,
118///         ransac_reproj_threshold: 5.0,
119///         match_ratio: 0.9,
120///          match_keep_ratio: 0.80,
121///          border_mode: opencv::core::BORDER_CONSTANT,
122///          border_value: opencv::core::Scalar::default(),
123///      },
124///      None)?.1;
125/// # Ok(())}
126/// ```
127/// # References
128/// - [OpenCV Keypoint Match](https://docs.opencv.org/4.x/dc/dc3/tutorial_py_matcher.html)
129/// - [ORB Feature Matching](https://docs.opencv.org/4.x/db/d95/classcv_1_1ORB.html)
130pub fn keypoint_match<I, P>(
131    files: I,
132    params: KeyPointMatchParameters,
133    scale_down_width: Option<f32>,
134) -> Result<(i32, Mat), StackerError>
135where
136    I: IntoIterator<Item = P>,
137    P: AsRef<std::path::Path>,
138{
139    let files = files.into_iter().map(|p| p.as_ref().to_path_buf());
140    if let Some(scale_down_width) = scale_down_width {
141        keypoint_match_scale_down(files, params, scale_down_width)
142    } else {
143        keypoint_match_no_scale(files, params)
144    }
145}
146
147fn keypoint_match_no_scale<I>(
148    files: I,
149    params: KeyPointMatchParameters,
150) -> Result<(i32, Mat), StackerError>
151where
152    I: IntoIterator<Item = PathBuf>,
153{
154    let files_vec: Vec<PathBuf> = files.into_iter().collect();
155
156    if files_vec.is_empty() {
157        return Err(StackerError::NotEnoughFiles);
158    }
159
160    // Process the first file to get reference keypoints and descriptors
161    let (first_grey_img, first_img_f32_wr) = {
162        let (g, f) = utils::read_grey_and_f32(&files_vec[0], imgcodecs::IMREAD_UNCHANGED)?;
163        (g, utils::UnsafeMatSyncWrapper(f))
164    };
165
166    // Get the image size from the first image for later use
167    let img_size = first_grey_img.size()?;
168
169    // Get keypoints and descriptors
170    let (first_kp_wr, first_des_wr) = {
171        let (kp, des) = utils::orb_detect_and_compute(&first_grey_img)?;
172        (
173            utils::UnsafeVectorKeyPointSyncWrapper(kp),
174            utils::UnsafeMatSyncWrapper(des),
175        )
176    };
177
178    // we do not need first_img anymore
179    drop(first_grey_img);
180
181    // Combined map-reduce step: Process each image and reduce on the fly
182    let result = {
183        // Create a reference to our wrappers that we can move into the closure
184        let first_kp_wrmv = &first_kp_wr;
185        let first_des_wrmv = &first_des_wr;
186        let first_img_f32_wrmv = &first_img_f32_wr;
187        let files_vec_mw = &files_vec;
188
189        (0..files_vec.len())
190            .into_par_iter()
191            //.into_iter()
192            .try_fold(
193                || None, // Initial accumulator for each thread
194                move |acc: Option<(i32, utils::UnsafeMatSyncWrapper)>, index| {
195                    let warped_img = if index == 0 {
196                        // For the first image, just use the already processed image
197                        Some(first_img_f32_wrmv.0.clone())
198                    } else {
199                        // Process non-first images
200                        // Process the first file to get reference keypoints and descriptors
201                        let (grey_img, img_f32) = utils::read_grey_and_f32(&files_vec_mw[index], imgcodecs::IMREAD_UNCHANGED)?;
202
203                        //python: src_pts = np.float32([first_kp[m.queryIdx].pt for m in matches]).reshape(-1, 1, 2)
204                        // Detect keypoints and compute descriptors
205                        let (kp, des) = utils::orb_detect_and_compute(&grey_img)?;
206
207                        // Match keypoints
208                        let matches: Vec<core::DMatch> = {
209                            let mut matcher = features2d::BFMatcher::create(core::NORM_HAMMING, false)?; // false for knn_match
210                            matcher.add(&des)?;
211                            let mut knn_matches = core::Vector::<core::Vector<core::DMatch>>::new();
212
213                            // Explicitly calling knn_match with the required parameters
214                            matcher.knn_match(
215                                &first_des_wrmv.0, // query descriptors
216                                &mut knn_matches,  // output matches
217                                2,              // k (2 best matches per descriptor)
218                                &Mat::default(),   // mask (no filtering here)
219                                false,// compact_result
220                            )?;
221
222                            let mut filtered_matches = knn_matches
223                                .iter()
224                                .filter_map(|m| {
225                                    if m.len() == 2 && m.get(0).unwrap().distance < params.match_ratio * m.get(1).unwrap().distance {
226                                        Some(m.get(0).unwrap())
227                                    } else {
228                                        None
229                                    }
230                                })
231                                .collect::<Vec<_>>();
232
233                            // Sort by distance
234                            filtered_matches.sort_by(|a, b| OrderedFloat(a.distance).cmp(&OrderedFloat(b.distance)));
235
236                            // Keep only the best 50% of matches (optional)
237                            let num_to_keep = (filtered_matches.len() as f32 * params.match_keep_ratio).round() as usize;
238                            filtered_matches.truncate(num_to_keep);
239
240                            filtered_matches
241                        };
242                        if matches.len() < 5 {
243                            return Ok(None)
244                        }
245
246                        // Calculate source points
247                        let src_pts = {
248                            let mut pts: Vector<Point2f> = Vector::with_capacity(matches.len());
249                            for m in matches.iter() {
250                                pts.push(first_kp_wrmv.0.get(m.query_idx as usize)?.pt());
251                            }
252                            let mut mat = Mat::from_exact_iter(pts.into_iter())?;
253                            mat.reshape_mut(2, 0)?;
254                            mat
255                        };
256
257                        // Calculate destination points
258                        let dst_pts = {
259                            let mut pts: Vector<Point2f> = Vector::with_capacity(matches.len());
260                            for m in matches.iter() {
261                                pts.push(kp.get(m.train_idx as usize)?.pt());
262                            }
263                            let mut mat = Mat::from_exact_iter(pts.into_iter())?;
264                            mat.reshape_mut(2, 0)?;
265                            mat
266                        };
267
268                        // Find homography matrix
269                        let h = match calib3d::find_homography(
270                            &dst_pts,
271                            &src_pts,
272                            &mut Mat::default(),
273                            params.method,
274                            params.ransac_reproj_threshold,
275                        ) {
276                            Ok(matrix) => matrix,
277                            Err(_) => return Ok(None), // Skip this image if homography fails
278                        };
279
280                        // Check if homography is valid
281                        if h.empty() || h.rows() != 3 || h.cols() != 3 {
282                            // Homography matrix is invalid
283                            return Ok(None); // This means this image will be skipped
284                        }
285
286                        if core::determinant(&h)?.abs() < 1e-6 {
287                            // Homography is degenerate
288                            return Ok(None);
289                        }
290
291                        // Warp image
292                        let mut warped_image = Mat::default();
293                        imgproc::warp_perspective(
294                            &img_f32,
295                            &mut warped_image,
296                            &h,
297                            img_size,
298                            imgproc::INTER_LINEAR,
299                            params.border_mode,
300                            params.border_value,
301                        )?;
302
303                        Some(warped_image)
304                    };
305
306                    //python: dst_pts = np.float32([kp[m.trainIdx].pt for m in matches]).reshape(-1, 1, 2)
307                    // Add to the accumulator (if accumulator is empty, just use the processed image)
308                    let result = match (acc, warped_img) {
309                        (None, None) => (1_i32,utils::UnsafeMatSyncWrapper(first_img_f32_wrmv.0.clone())),
310                        (Some((acc_d, acc_mat)), None) => (1_i32 + acc_d, acc_mat),
311                        (None, Some(warped_image)) => (0_i32, utils::UnsafeMatSyncWrapper(warped_image)),
312                        (Some((acc_d, acc_mat)), Some(warped_image)) => {
313                            let mat = utils::UnsafeMatSyncWrapper(
314                                (&acc_mat.0 + &warped_image).into_result()?.to_mat()?,
315                            );
316                            (acc_d, mat)
317                        }
318                    };
319
320                    Ok::<Option<(i32, utils::UnsafeMatSyncWrapper)>, StackerError>(Some(result))
321                },
322            )
323            .try_reduce(
324                || None,
325                |acc1, acc2| match (acc1, acc2) {
326                    (None, None) => Err(StackerError::InvalidParams("All images discarded: try modifying KeyPointMatchParameters::match_distance_threshold".to_string())),
327                    (Some(acc1), None) => Ok(Some(acc1)),
328                    (None, Some(acc2)) => Ok(Some(acc2)),
329                    (Some(acc1), Some(acc2)) => {
330                        // Combine the two accumulators
331                        let combined_img = utils::UnsafeMatSyncWrapper(
332                            (&acc1.1.0 + &acc2.1.0).into_result()?.to_mat()?,
333                        );
334                        Ok(Some((acc1.0+ acc2.0, combined_img)))
335                    }
336                },
337            )
338    }?;
339
340    // Final normalization
341    let final_result = if let Some((dropped, img)) = result {
342        (
343            dropped,
344            (img.0 / (files_vec.len() - dropped as usize) as f64)
345                .into_result()?
346                .to_mat()?,
347        )
348    } else {
349        return Err(StackerError::ProcessingError(
350            "Empty result after reduction".to_string(),
351        ));
352    };
353
354    Ok(final_result)
355}
356
357fn keypoint_match_scale_down<I>(
358    files: I,
359    params: KeyPointMatchParameters,
360    scale_down_width: f32,
361) -> Result<(i32, Mat), StackerError>
362where
363    I: IntoIterator<Item = PathBuf>,
364{
365    let files_vec: Vec<PathBuf> = files.into_iter().collect();
366    if files_vec.is_empty() {
367        return Err(StackerError::NotEnoughFiles);
368    }
369
370    // Process the first file to get reference keypoints and descriptors
371    let (grey_first_img, first_img_f32_wr) = {
372        let (g, f) = utils::read_grey_and_f32(&files_vec[0], imgcodecs::IMREAD_UNCHANGED)?;
373        (g, utils::UnsafeMatSyncWrapper(f))
374    };
375
376    // Get the image size from the first image for later use
377    let full_size = first_img_f32_wr.0.size()?;
378
379    if scale_down_width >= full_size.width as f32 {
380        return Err(StackerError::InvalidParams(format!(
381            "scale_down_to was larger (or equal) to the full image width: full_size:{}, scale_down_to:{}",
382            full_size.width, scale_down_width
383        )));
384    }
385
386    // Get keypoints and descriptors
387    let (first_kp_small_wr, first_des_small_wr) = {
388        // Scale down the first image
389        let grey_img_small = utils::scale_image(&grey_first_img, scale_down_width)?;
390
391        let (kp, des) = utils::orb_detect_and_compute(&grey_img_small)?;
392        (
393            utils::UnsafeVectorKeyPointSyncWrapper(kp),
394            utils::UnsafeMatSyncWrapper(des),
395        )
396    };
397
398    // we do not need grey_first_img anymore
399    drop(grey_first_img);
400
401    // Combined map-reduce step: Process each image and reduce on the fly
402    let result = {
403        // Create a reference to our wrappers that we can move into the closure
404        let first_kp_small_wrmv = &first_kp_small_wr;
405        let first_des_small_wrmv = &first_des_small_wr;
406        let first_img_f32_wrmv = &first_img_f32_wr;
407        let files_vec_wr = &files_vec;
408
409        (0..files_vec.len())
410            .into_par_iter()
411            //.into_iter()
412            .try_fold(
413                || None, // Initial accumulator for each thread
414                move |acc: Option<(i32, utils::UnsafeMatSyncWrapper)>, index| {
415                    let warped_img = if index == 0 {
416                        // For the first image, just use the already processed image
417                        Some(first_img_f32_wrmv.0.clone())
418                    } else {
419                        // Process non-first images
420
421                        // Load and scale down the image for feature detection
422                        let (img_f32, grey_img_small) = {
423                            let (grey_img, img_f32) = utils::read_grey_and_f32(
424                                &files_vec_wr[index],
425                                imgcodecs::IMREAD_UNCHANGED,
426                            )?;
427                            let grey_img_small = utils::scale_image(&grey_img, scale_down_width)?;
428                            (img_f32, grey_img_small)
429                        };
430
431                        // Detect features on the scaled-down image
432                        let (kp_small, des_small) = utils::orb_detect_and_compute(&grey_img_small)?;
433
434                        // Match features
435                        // Match keypoints
436                        let matches: Vec<core::DMatch> = {
437                            let mut matcher =
438                                features2d::BFMatcher::create(core::NORM_HAMMING, false)?; // false for knn_match
439                            matcher.add(&des_small)?;
440                            let mut knn_matches = core::Vector::<core::Vector<core::DMatch>>::new();
441
442                            // Explicitly calling knn_match with the required parameters
443                            matcher.knn_match(
444                                &first_des_small_wrmv.0, // query descriptors
445                                &mut knn_matches,        // output matches
446                                2,                       // k (2 best matches per descriptor)
447                                &Mat::default(),         // mask (no filtering here)
448                                false,                   // compact_result
449                            )?;
450
451                            let mut filtered_matches = knn_matches
452                                .iter()
453                                .filter_map(|m| {
454                                    if m.len() == 2
455                                        && m.get(0).unwrap().distance
456                                            < params.match_ratio * m.get(1).unwrap().distance
457                                    {
458                                        Some(m.get(0).unwrap())
459                                    } else {
460                                        None
461                                    }
462                                })
463                                .collect::<Vec<_>>();
464
465                            // Sort by distance
466                            filtered_matches.sort_by(|a, b| {
467                                OrderedFloat(a.distance).cmp(&OrderedFloat(b.distance))
468                            });
469
470                            // Keep only the best 50% of matches (optional)
471                            let num_to_keep = (filtered_matches.len() as f32
472                                * params.match_keep_ratio)
473                                .round() as usize;
474                            filtered_matches.truncate(num_to_keep);
475
476                            filtered_matches
477                        };
478                        if matches.len() < 5 {
479                            return Ok(None);
480                        }
481
482                        // Get corresponding points (on small images)
483                        let src_pts = {
484                            let mut pts: Vector<Point2f> = Vector::with_capacity(matches.len());
485                            for m in matches.iter() {
486                                pts.push(first_kp_small_wrmv.0.get(m.query_idx as usize)?.pt());
487                            }
488                            let mut mat = Mat::from_exact_iter(pts.into_iter())?;
489                            mat.reshape_mut(2, 0)?;
490                            mat
491                        };
492
493                        let dst_pts = {
494                            let mut pts: Vector<Point2f> = Vector::with_capacity(matches.len());
495                            for m in matches.iter() {
496                                pts.push(kp_small.get(m.train_idx as usize)?.pt());
497                            }
498                            let mut mat = Mat::from_exact_iter(pts.into_iter())?;
499                            mat.reshape_mut(2, 0)?;
500                            mat
501                        };
502
503                        // Calculate homography on small images
504                        let h_small = match calib3d::find_homography(
505                            &dst_pts,
506                            &src_pts,
507                            &mut Mat::default(),
508                            params.method,
509                            params.ransac_reproj_threshold,
510                        ) {
511                            Ok(matrix) => matrix,
512                            Err(_) => return Ok(None), // Skip this image if homography fails
513                        };
514
515                        // Check if homography is valid
516                        if h_small.empty() || h_small.rows() != 3 || h_small.cols() != 3 {
517                            // Homography matrix is invalid
518                            return Ok(None); // This means this image will be skipped
519                        }
520
521                        if core::determinant(&h_small)?.abs() < 1e-6 {
522                            // Homography is degenerate
523                            return Ok(None);
524                        }
525
526                        // Scale homography matrix to apply to original-sized images
527                        let h = utils::adjust_homography_for_scale_f64(
528                            &h_small,
529                            &grey_img_small,
530                            &img_f32,
531                        )?;
532
533                        // Warp the original full-sized image
534                        let mut warped_image = Mat::default();
535
536                        imgproc::warp_perspective(
537                            &img_f32,
538                            &mut warped_image,
539                            &h,
540                            full_size,
541                            imgproc::INTER_LINEAR,
542                            params.border_mode,
543                            params.border_value,
544                        )?;
545
546                        Some(warped_image)
547                    };
548                    // Add to the accumulator (if accumulator is empty, just use the processed image)
549                    let result = match (acc, warped_img) {
550                        (None, None) => (
551                            1_i32,
552                            utils::UnsafeMatSyncWrapper(first_img_f32_wrmv.0.clone()),
553                        ),
554                        (Some((acc_d, acc_mat)), None) => (1_i32 + acc_d, acc_mat),
555                        (None, Some(warped_image)) => {
556                            (0_i32, utils::UnsafeMatSyncWrapper(warped_image))
557                        }
558                        (Some((acc_d, acc_mat)), Some(warped_image)) => {
559                            let mat = utils::UnsafeMatSyncWrapper(
560                                (&acc_mat.0 + &warped_image).into_result()?.to_mat()?,
561                            );
562                            (acc_d, mat)
563                        }
564                    };
565
566                    Ok::<Option<(i32, utils::UnsafeMatSyncWrapper)>, StackerError>(Some(result))
567                },
568            )
569            .try_reduce(
570                || None,
571                |acc1, acc2| match (acc1, acc2) {
572                    (Some(acc1), None) => Ok(Some(acc1)),
573                    (None, Some(acc2)) => Ok(Some(acc2)),
574                    (Some(acc1), Some(acc2)) => {
575                        // Combine the two accumulators
576                        let combined_img = utils::UnsafeMatSyncWrapper(
577                            (&acc1.1.0 + &acc2.1.0).into_result()?.to_mat()?,
578                        );
579                        Ok(Some((acc1.0 + acc2.0, combined_img)))
580                    }
581                    _ => unreachable!(),
582                },
583            )
584    }?;
585
586    // Final normalization
587    let final_result = if let Some((dropped, img)) = result {
588        (
589            dropped,
590            (img.0 / (files_vec.len() - dropped as usize) as f64)
591                .into_result()?
592                .to_mat()?,
593        )
594    } else {
595        return Err(StackerError::ProcessingError(
596            "Empty result after reduction".to_string(),
597        ));
598    };
599
600    Ok(final_result)
601}
602
603#[derive(Debug, Copy, Clone, PartialEq, Eq)]
604pub enum MotionType {
605    Homography = opencv::video::MOTION_HOMOGRAPHY as isize,
606    Affine = opencv::video::MOTION_AFFINE as isize,
607    Euclidean = opencv::video::MOTION_EUCLIDEAN as isize,
608    Translation = opencv::video::MOTION_TRANSLATION as isize,
609}
610
611#[derive(Debug, Copy, Clone)]
612/// Structure containing the opencv parameters needed to calculate `ecc_match()`
613pub struct EccMatchParameters {
614    pub motion_type: MotionType,
615    /// parameter used as `opencv::core::TermCriteria::max_count`
616    pub max_count: Option<i32>,
617    /// parameter used as `opencv::core::TermCriteria::epsilon`
618    pub epsilon: Option<f64>,
619    /// parameter used in `opencv::video::find_transform_ecc()`
620    pub gauss_filt_size: i32,
621    // todo: Add border_mode: core::BORDER_CONSTANT & border_value: Scalar::default(),
622    // todo: impl Default
623}
624
625/// Aligns and stacks images using OpenCV's Enhanced Correlation Coefficient (ECC) algorithm.
626///
627/// Performs intensity-based image alignment using pyramidal ECC maximization, with optional
628/// scaling for performance. The first image serves as the reference frame for alignment.
629///
630/// # Algorithm Overview
631/// 1. Converts images to grayscale for alignment computation
632/// 2. Optionally scales images down for faster ECC computation
633/// 3. Estimates transformation (translation/affine/homography) using ECC maximization
634/// 4. Applies transformation to original full-resolution images
635/// 5. Averages all aligned images
636///
637/// # When to Use
638/// - When images need alignment but lack distinctive features for keypoint matching
639/// - For alignment under illumination changes (ECC is illumination-invariant)
640/// - When precise subpixel alignment is required
641///
642/// # Parameters
643/// - `files`: Iterator of image paths (any `AsRef<Path>` type)
644/// - `params`: ECC configuration parameters:
645///   - `motion_type`: Type of transformation to estimate:
646///     - `Translation`, `Euclidean`, `Affine`, or `Homography`
647///   - `max_count`: Maximum iterations (None for OpenCV default)
648///   - `epsilon`: Convergence threshold (None for OpenCV default)
649///   - `gauss_filt_size`: Gaussian filter size for pyramid levels (odd number ≥1)
650/// - `scale_down_width`: Performance/accuracy trade-off:
651///   - `Some(width)`: Process ECC at specified width (faster)
652///   - `None`: Process at full resolution (more precise)
653///   - Must be < original width when specified
654///
655/// # Returns
656/// - `Ok(Mat)`: Stacked image in CV_32F format (normalized 0-1 range)
657/// - `Err(StackerError)` on:
658///   - No input files
659///   - Invalid scale width
660///   - Image loading/processing failures
661///
662/// # Performance Notes
663/// - Parallel processing for multi-image alignment
664/// - Scaling provides 3-5x speedup with minor accuracy impact
665/// - Computation complexity depends on:
666///   - Motion type (Translation < Affine < Homography)
667///   - Image resolution
668///   - Convergence criteria
669///
670/// # Example
671/// ```rust,no_run
672/// # use libstacker::{prelude::*, opencv::prelude::*};
673/// # fn main() -> Result<(), StackerError> {
674/// // Fast homography alignment with scaling
675/// let aligned = ecc_match(
676///     ["1.jpg", "2.jpg", "3.jpg"],
677///     EccMatchParameters {
678///         motion_type: MotionType::Homography,
679///         max_count: Some(5000),
680///         epsilon: Some(1e-6),
681///         gauss_filt_size: 3,
682///     },
683///     Some(800.0)  // Scale to 800px width
684/// )?;
685///
686/// // Precise affine alignment (full resolution)
687/// let precise = ecc_match(
688///     vec!["1.tif", "2.tif"],
689///     EccMatchParameters {
690///         motion_type: MotionType::Affine,
691///         max_count: Some(5000),
692///         epsilon: Some(1e-6),
693///         gauss_filt_size: 3,
694///     },
695///     None  // No scaling
696/// )?;
697/// # Ok(())}
698/// ```
699///
700/// # References
701/// - [Image Alignment Tutorial](https://learnopencv.com/image-alignment-ecc-in-opencv-c-python)
702pub fn ecc_match<I, P>(
703    files: I,
704    params: EccMatchParameters,
705    scale_down_width: Option<f32>,
706) -> Result<Mat, StackerError>
707where
708    I: IntoIterator<Item = P>,
709    P: AsRef<std::path::Path>,
710{
711    let files = files.into_iter().map(|p| p.as_ref().to_path_buf());
712    if let Some(scale_down_width) = scale_down_width {
713        ecc_match_scaling_down(files, params, scale_down_width)
714    } else {
715        ecc_match_no_scaling(files, params)
716    }
717}
718
719pub fn ecc_match_no_scaling<I>(files: I, params: EccMatchParameters) -> Result<Mat, StackerError>
720where
721    I: IntoIterator<Item = PathBuf>,
722{
723    let files_vec: Vec<PathBuf> = files.into_iter().collect();
724
725    if files_vec.is_empty() {
726        return Err(StackerError::NotEnoughFiles);
727    }
728
729    let criteria = Result::<core::TermCriteria, StackerError>::from(params)?;
730
731    let (first_img_grey_wr, first_img_f32_wr) = {
732        let (img_grey, first_img_f32) =
733            utils::read_grey_and_f32(&files_vec[0], imgcodecs::IMREAD_UNCHANGED)?;
734        (
735            utils::UnsafeMatSyncWrapper(img_grey),
736            utils::UnsafeMatSyncWrapper(first_img_f32),
737        )
738    };
739
740    // Combined map-reduce step: Process each image and reduce on the fly
741    let result = {
742        let first_img_f32_wrmv = &first_img_f32_wr;
743        let first_img_grey_wrmw = &first_img_grey_wr;
744        let files_vec_mv = &files_vec;
745
746        (0..files_vec.len())
747            .into_par_iter()
748            .with_min_len(1)
749            .try_fold(
750                || None, // Initial accumulator for each thread
751                move |acc: Option<utils::UnsafeMatSyncWrapper>, index| {
752                    let warped_image = if index == 0 {
753                        // For the first image, just use the already processed image
754                        first_img_f32_wrmv.0.clone()
755                    } else {
756                        let (img_grey, img_f32) = utils::read_grey_and_f32(
757                            &files_vec_mv[index],
758                            imgcodecs::IMREAD_UNCHANGED,
759                        )?;
760
761                        // s, M = cv2.findTransformECC(cv2.cvtColor(image,cv2.COLOR_BGR2GRAY), first_image, M, cv2.MOTION_HOMOGRAPHY)
762
763                        let mut warp_matrix = if params.motion_type != MotionType::Homography {
764                            Mat::eye(2, 3, opencv::core::CV_32F)?.to_mat()?
765                        } else {
766                            Mat::eye(3, 3, opencv::core::CV_32F)?.to_mat()?
767                        };
768
769                        let _ = opencv::video::find_transform_ecc(
770                            &img_grey,
771                            &first_img_grey_wrmw.0,
772                            &mut warp_matrix,
773                            params.motion_type as i32,
774                            criteria,
775                            &Mat::default(),
776                            params.gauss_filt_size,
777                        )?;
778                        let mut warped_image = Mat::default();
779
780                        if params.motion_type != MotionType::Homography {
781                            // Use warp_affine() for Translation, Euclidean and Affine
782                            imgproc::warp_affine(
783                                &img_f32,
784                                &mut warped_image,
785                                &warp_matrix,
786                                img_f32.size()?,
787                                imgproc::INTER_LINEAR,
788                                core::BORDER_CONSTANT,
789                                core::Scalar::default(),
790                            )?;
791                        } else {
792                            // Use warp_perspective() for Homography
793                            // image = cv2.warpPerspective(image, M, (h, w))
794                            imgproc::warp_perspective(
795                                &img_f32,
796                                &mut warped_image,
797                                &warp_matrix,
798                                img_f32.size()?,
799                                imgproc::INTER_LINEAR,
800                                core::BORDER_CONSTANT,
801                                core::Scalar::default(),
802                            )?;
803                        }
804                        warped_image
805                    };
806
807                    let result = if let Some(acc) = acc {
808                        let rv = utils::UnsafeMatSyncWrapper(
809                            (&acc.0 + &warped_image).into_result()?.to_mat()?,
810                        );
811                        Some(rv)
812                    } else {
813                        Some(utils::UnsafeMatSyncWrapper(warped_image))
814                    };
815
816                    Ok::<Option<utils::UnsafeMatSyncWrapper>, StackerError>(result)
817                },
818            )
819            .try_reduce(
820                || None,
821                |acc1, acc2| match (acc1, acc2) {
822                    (Some(acc1), None) => Ok(Some(acc1)),
823                    (None, Some(acc2)) => Ok(Some(acc2)),
824                    (Some(acc1), Some(acc2)) => {
825                        // Combine the two accumulators
826                        let combined = utils::UnsafeMatSyncWrapper(
827                            (&acc1.0 + &acc2.0).into_result()?.to_mat()?,
828                        );
829                        Ok(Some(combined))
830                    }
831                    _ => unreachable!(),
832                },
833            )?
834    };
835    // Final normalization
836    let final_result = if let Some(result) = result {
837        (result.0 / files_vec.len() as f64)
838            .into_result()?
839            .to_mat()?
840    } else {
841        return Err(StackerError::ProcessingError(
842            "Empty result after reduction".to_string(),
843        ));
844    };
845
846    Ok(final_result)
847}
848
849pub fn ecc_match_scaling_down<I>(
850    files: I,
851    params: EccMatchParameters,
852    scale_down_width: f32,
853) -> Result<Mat, StackerError>
854where
855    I: IntoIterator<Item = PathBuf>,
856{
857    let files_vec: Vec<PathBuf> = files.into_iter().collect();
858
859    if files_vec.is_empty() {
860        return Err(StackerError::NotEnoughFiles);
861    }
862
863    let criteria = Result::<core::TermCriteria, StackerError>::from(params)?;
864
865    let (first_img_grey_wr, first_img_f32_wr) = {
866        let (img_grey, first_img_f32) =
867            utils::read_grey_and_f32(&files_vec[0], imgcodecs::IMREAD_UNCHANGED)?;
868        (
869            utils::UnsafeMatSyncWrapper(img_grey),
870            utils::UnsafeMatSyncWrapper(first_img_f32),
871        )
872    };
873
874    // Load first image in color and grayscale
875    let full_size = first_img_f32_wr.0.size()?;
876    if scale_down_width >= full_size.width as f32 {
877        return Err(StackerError::InvalidParams(format!(
878            "scale_down_to was larger (or equal) to the full image width: full_size:{}, scale_down_to:{}",
879            full_size.width, scale_down_width
880        )));
881    }
882
883    if scale_down_width <= 10.0 {
884        return Err(StackerError::InvalidParams(format!(
885            "scale_down_to was too small scale_down_to:{}",
886            scale_down_width
887        )));
888    }
889
890    // Scale down the first image for ECC
891    let first_img_grey_small_wr =
892        utils::UnsafeMatSyncWrapper(utils::scale_image(&first_img_grey_wr.0, scale_down_width)?);
893    let small_size = first_img_grey_small_wr.0.size()?;
894
895    // Combined map-reduce step: Process each image and reduce on the fly
896    let result = {
897        let first_img_f32_wrmv = &first_img_f32_wr;
898        let files_vec_mv = &files_vec;
899
900        (0..files_vec.len())
901            .into_par_iter()
902            .with_min_len(1)
903            .try_fold(
904                || None, // Initial accumulator for each thread
905                move |acc: Option<utils::UnsafeMatSyncWrapper>, index| {
906                    let warped_image = if index == 0 {
907                        // For the first image, just use the already processed image
908                        first_img_f32_wrmv.0.clone()
909                    } else {
910                        // Load image in both color and grayscale
911                        let (img_grey_original, img_f32) = utils::read_grey_and_f32(
912                            &files_vec_mv[index],
913                            imgcodecs::IMREAD_UNCHANGED,
914                        )?;
915                        let first_img_grey_small = &first_img_grey_small_wr;
916
917                        // Scale down for ECC matching
918                        let img_small = utils::scale_image(&img_f32, scale_down_width)?;
919                        let img_grey_small =
920                            utils::scale_image(&img_grey_original, scale_down_width)?;
921
922                        // Initialize warp matrix based on motion type
923                        let mut warp_matrix_small = if params.motion_type != MotionType::Homography
924                        {
925                            Mat::eye(2, 3, opencv::core::CV_32F)?.to_mat()?
926                        } else {
927                            Mat::eye(3, 3, opencv::core::CV_32F)?.to_mat()?
928                        };
929
930                        // Find transformation on small images
931                        let _ = opencv::video::find_transform_ecc(
932                            &img_grey_small,
933                            &first_img_grey_small.0,
934                            &mut warp_matrix_small,
935                            params.motion_type as i32,
936                            criteria,
937                            &Mat::default(),
938                            params.gauss_filt_size,
939                        )?;
940
941                        let warp_matrix = if params.motion_type != MotionType::Homography {
942                            // For affine transformations, adjust the translation part of the matrix
943                            let mut full_warp = warp_matrix_small.clone();
944
945                            // Scale the translation components (third column)
946                            let tx = full_warp.at_2d_mut::<f32>(0, 2)?;
947                            *tx *= full_size.width as f32 / small_size.width as f32;
948                            let ty = full_warp.at_2d_mut::<f32>(1, 2)?;
949                            *ty *= full_size.height as f32 / small_size.height as f32;
950
951                            full_warp
952                        } else {
953                            utils::adjust_homography_for_scale_f32(
954                                &warp_matrix_small,
955                                &img_small,
956                                &img_f32,
957                            )?
958                        };
959
960                        let mut warped_image = Mat::default();
961
962                        if params.motion_type != MotionType::Homography {
963                            // Use warp_affine() for Translation, Euclidean and Affine
964                            imgproc::warp_affine(
965                                &img_f32,
966                                &mut warped_image,
967                                &warp_matrix,
968                                full_size,
969                                imgproc::INTER_LINEAR,
970                                core::BORDER_CONSTANT,
971                                core::Scalar::default(),
972                            )?;
973                        } else {
974                            // Use warp_perspective() for Homography
975                            imgproc::warp_perspective(
976                                &img_f32,
977                                &mut warped_image,
978                                &warp_matrix,
979                                full_size,
980                                imgproc::INTER_LINEAR,
981                                core::BORDER_CONSTANT,
982                                core::Scalar::default(),
983                            )?;
984                        }
985                        warped_image
986                    };
987
988                    let result = if let Some(acc) = acc {
989                        let rv = utils::UnsafeMatSyncWrapper(
990                            (&acc.0 + &warped_image).into_result()?.to_mat()?,
991                        );
992                        Some(rv)
993                    } else {
994                        Some(utils::UnsafeMatSyncWrapper(warped_image))
995                    };
996
997                    Ok::<Option<utils::UnsafeMatSyncWrapper>, StackerError>(result)
998                },
999            )
1000            .try_reduce(
1001                || None,
1002                |acc1, acc2| match (acc1, acc2) {
1003                    (Some(acc1), None) => Ok(Some(acc1)),
1004                    (None, Some(acc2)) => Ok(Some(acc2)),
1005                    (Some(acc1), Some(acc2)) => {
1006                        // Combine the two accumulators
1007                        let combined = utils::UnsafeMatSyncWrapper(
1008                            (&acc1.0 + &acc2.0).into_result()?.to_mat()?,
1009                        );
1010                        Ok(Some(combined))
1011                    }
1012                    _ => unreachable!(),
1013                },
1014            )?
1015    };
1016    // Final normalization
1017    let final_result = if let Some(result) = result {
1018        (result.0 / files_vec.len() as f64)
1019            .into_result()?
1020            .to_mat()?
1021    } else {
1022        return Err(StackerError::ProcessingError(
1023            "Empty result after reduction".to_string(),
1024        ));
1025    };
1026
1027    Ok(final_result)
1028}
1029
1030/// Detect sharpness of an image <https://stackoverflow.com/a/7768918>
1031/// OpenCV port of 'LAPM' algorithm (Nayar89)
1032pub fn sharpness_modified_laplacian(src_mat: &Mat) -> Result<f64, StackerError> {
1033    let mut m = unsafe { Mat::new_rows_cols(1, 3, core::CV_64FC1)? };
1034    m.set_2d::<f64>(0, 0, -1.0)?;
1035    m.set_2d::<f64>(0, 1, 2.0)?;
1036    m.set_2d::<f64>(0, 2, -1.0)?;
1037
1038    let g = imgproc::get_gaussian_kernel(3, -1.0, core::CV_64FC1)?;
1039    let mut lx = Mat::default();
1040    imgproc::sep_filter_2d(
1041        src_mat,
1042        &mut lx,
1043        core::CV_64FC1,
1044        &m,
1045        &g,
1046        core::Point::new(-1, -1),
1047        0.0,
1048        core::BORDER_DEFAULT,
1049    )?;
1050
1051    let mut ly = Mat::default();
1052    imgproc::sep_filter_2d(
1053        src_mat,
1054        &mut ly,
1055        core::CV_64FC1,
1056        &g,
1057        &m,
1058        core::Point::new(-1, -1),
1059        0.0,
1060        core::BORDER_DEFAULT,
1061    )?;
1062
1063    let fm = (core::abs(&lx)? + core::abs(&ly)?)
1064        .into_result()?
1065        .to_mat()?;
1066    Ok(*core::mean(&fm, &Mat::default())?
1067        .0
1068        .first()
1069        .unwrap_or(&f64::MAX))
1070}
1071
1072/// Detect sharpness of an image <https://stackoverflow.com/a/7768918>
1073/// OpenCV port of 'LAPV' algorithm (Pech2000)
1074pub fn sharpness_variance_of_laplacian(src_mat: &Mat) -> Result<f64, StackerError> {
1075    let mut lap = Mat::default();
1076    imgproc::laplacian(
1077        src_mat,
1078        &mut lap,
1079        core::CV_64FC1,
1080        3,
1081        1.0,
1082        0.0,
1083        core::BORDER_REPLICATE,
1084    )?;
1085    let mut mu = Mat::default();
1086    let mut sigma = Mat::default();
1087    opencv::core::mean_std_dev(&lap, &mut mu, &mut sigma, &Mat::default())?;
1088    let focus_measure = sigma.at_2d::<f64>(0, 0)?;
1089    Ok(focus_measure * focus_measure)
1090}
1091
1092/// Detect sharpness of an image using Tenengrad algorithm (Krotkov86)
1093/// <https://stackoverflow.com/a/7768918>
1094///
1095/// # Arguments
1096/// * `src_grey_mat` - Input grayscale image (single channel)
1097/// * `k_size` - Kernel size for Sobel operator (must be 1, 3, 5, or 7)
1098///
1099/// # Returns
1100/// Sharpness metric (higher values indicate sharper images)
1101pub fn sharpness_tenengrad(src_grey_mat: &Mat, k_size: i32) -> Result<f64, StackerError> {
1102    // Validate kernel size
1103    if ![1, 3, 5, 7].contains(&k_size) {
1104        return Err(StackerError::InvalidParams(
1105            "Kernel size must be 1, 3, 5, or 7".into(),
1106        ));
1107    }
1108    // Compute gradients using Sobel
1109    let mut gx = Mat::default();
1110    let mut gy = Mat::default();
1111    imgproc::sobel(
1112        src_grey_mat,
1113        &mut gx,
1114        core::CV_64FC1,
1115        1, // x-order
1116        0, // y-order
1117        k_size,
1118        1.0, // scale
1119        0.0, // delta
1120        core::BORDER_DEFAULT,
1121    )?;
1122    imgproc::sobel(
1123        src_grey_mat,
1124        &mut gy,
1125        core::CV_64FC1,
1126        0, // x-order
1127        1, // y-order
1128        k_size,
1129        1.0,
1130        0.0,
1131        core::BORDER_DEFAULT,
1132    )?;
1133
1134    // Compute squared gradients (gx² and gy²)
1135    let mut gx2 = Mat::default();
1136    let mut gy2 = Mat::default();
1137    core::multiply(&gx, &gx, &mut gx2, 1.0, -1)?;
1138    core::multiply(&gy, &gy, &mut gy2, 1.0, -1)?;
1139
1140    // Sum the squared gradients (gx² + gy²)
1141    let mut sum = Mat::default();
1142    core::add(&gx2, &gy2, &mut sum, &core::no_array(), -1)?;
1143
1144    // Compute mean of the squared gradient magnitudes
1145    let mean = core::mean(&sum, &core::no_array())?;
1146    Ok(mean[0])
1147}
1148
1149/// Detect sharpness of an image <https://stackoverflow.com/a/7768918>
1150/// OpenCV port of 'GLVN' algorithm (Santos97)
1151pub fn sharpness_normalized_gray_level_variance(src_mat: &Mat) -> Result<f64, StackerError> {
1152    // Convert to floating point representation
1153    let mut src_float = Mat::default();
1154    src_mat.convert_to(&mut src_float, core::CV_64FC1, 1.0, 0.0)?;
1155
1156    // Compute statistics
1157    let mut mu = Mat::default();
1158    let mut sigma = Mat::default();
1159    core::mean_std_dev(&src_float, &mut mu, &mut sigma, &Mat::default())?;
1160
1161    // Numerical safety
1162    let variance = sigma.at_2d::<f64>(0, 0)?.powi(2);
1163    let mu_value = mu.at_2d::<f64>(0, 0)?.max(f64::EPSILON);
1164
1165    Ok(variance / mu_value)
1166}
1167
1168pub mod prelude {
1169    pub use super::{
1170        EccMatchParameters, KeyPointMatchParameters, MotionType, StackerError, ecc_match,
1171        keypoint_match,
1172    };
1173}