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 multithreaded 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//! This 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 rayon::prelude::*;
23use std::path::PathBuf;
24use thiserror::Error;
25use utils::SetMValue;
26
27#[derive(Error, Debug)]
28pub enum StackerError {
29    #[error(transparent)]
30    OpenCvError(#[from] opencv::Error),
31    #[error("Not enough files")]
32    NotEnoughFiles,
33    #[error("Not implemented")]
34    NotImplemented,
35    #[error(transparent)]
36    IoError(#[from] std::io::Error),
37    #[error(transparent)]
38    PoisonError(#[from] std::sync::PoisonError<core::MatExprResult<core::MatExpr>>),
39    #[error("Invalid path encoding {0}")]
40    InvalidPathEncoding(PathBuf),
41    #[error("Invalid parameter(s) {0}")]
42    InvalidParams(String),
43    #[error("Internal error {0}")]
44    ProcessingError(String),
45}
46
47/// Parameters for keypoint matching and homography estimation.
48#[derive(Debug, Clone, Copy)]
49pub struct KeyPointMatchParameters {
50    /// Method used in `opencv::calib3d::find_homography()`, typically `opencv::calib3d::RANSAC`.
51    pub method: i32,
52
53    /// Reprojection threshold for RANSAC in `find_homography()`.
54    /// A lower value makes RANSAC stricter (fewer matches kept), while a higher value is more lenient.
55    pub ransac_reproj_threshold: f64,
56
57    /// Ratio of best matches to keep after sorting by distance.
58    /// Common values range from 0.5 to 0.8.
59    pub match_keep_ratio: f32,
60
61    /// Lowe’s ratio test threshold: how similar the best and second-best matches must be.
62    /// Used in `knn_match()` to filter ambiguous matches.
63    /// Common values range from 0.7 to 0.9.
64    pub match_ratio: f32,
65
66    /// Border mode used when warping images.
67    /// Default: `opencv::core::BORDER_CONSTANT`.
68    pub border_mode: i32,
69
70    /// Border value used in warping.
71    /// Default: `opencv::core::Scalar::default()`.
72    pub border_value: core::Scalar,
73}
74
75/// Aligns and combines multiple images by matching keypoints, with optional scaling for performance.
76///
77/// This function processes a sequence of images by:
78/// 1. Detecting ORB features (either at full resolution or scaled-down for faster processing)
79/// 2. Matching features between the first (reference) image and subsequent images
80/// 3. Computing homographies to align images to the reference
81/// 4. Warping images to the reference frame and averaging them
82///
83/// When scaling is enabled:
84/// - Feature detection/matching occurs on smaller images for performance
85/// - Homographies are scaled appropriately for full-size warping
86///
87/// # Parameters
88/// - `files`: An iterator of paths to image files (any type implementing `AsRef<Path>`)
89/// - `params`: Configuration parameters for keypoint matching:
90///   - `method`: Homography computation method (e.g., RANSAC, LMEDS)
91///   - `ransac_reproj_threshold`: Maximum reprojection error for inlier classification
92/// - `scale_down_width`: Controls performance/accuracy trade-off:
93///   - `Some(width)`: Process features at specified width (faster, recommended 400-800px)
94///   - `None`: Process at full resolution (more accurate but slower)
95///   - Must be smaller than original image width when specified
96///
97/// # Returns
98/// - `Ok((i32, Mat)).`: number of dropped frames + Combined/averaged image in CV_32F format (normalized 0-1 range)
99/// - `Err(StackerError)` on failure cases:
100///   - No input files provided
101///   - Invalid scale width (when specified)
102///   - Image loading/processing failures
103///
104/// # Performance Characteristics
105/// - Parallel processing (Rayon) for multi-image alignment
106/// - Memory efficient streaming processing
107/// - Output matches size/coordinates of first (reference) image
108/// - Scaling typically provides 2-4x speedup with minimal accuracy loss
109///
110/// ```rust,no_run
111/// # use libstacker::{prelude::*, opencv::prelude::*};
112/// # fn f() -> Result<(),StackerError> {
113/// let keypoint_match_img:opencv::core::Mat = keypoint_match(
114///     vec!["1.jpg","2.jpg","3.jpg","4.jpg","5.jpg"],
115///     KeyPointMatchParameters {
116///         method: opencv::calib3d::RANSAC,
117///         ransac_reproj_threshold: 5.0,
118///         match_ratio: 0.9,
119///          match_keep_ratio: 0.80,
120///          border_mode: opencv::core::BORDER_CONSTANT,
121///          border_value: opencv::core::Scalar::default(),
122///      },
123///      None)?.1;
124/// # Ok(())}
125/// ```
126/// # References
127/// - [OpenCV Keypoint Match](https://docs.opencv.org/4.x/dc/dc3/tutorial_py_matcher.html)
128/// - [ORB Feature Matching](https://docs.opencv.org/4.x/db/d95/classcv_1_1ORB.html)
129pub fn keypoint_match<I, P>(
130    files: I,
131    params: KeyPointMatchParameters,
132    scale_down_width: Option<f32>,
133) -> Result<(i32, Mat), StackerError>
134where
135    I: IntoIterator<Item = P>,
136    P: AsRef<std::path::Path>,
137{
138    let files = files.into_iter().map(|p| p.as_ref().to_path_buf());
139    if let Some(scale_down_width) = scale_down_width {
140        keypoint_match_scale_down(files, params, scale_down_width)
141    } else {
142        keypoint_match_no_scale(files, params)
143    }
144}
145
146fn keypoint_match_no_scale<I>(
147    files: I,
148    params: KeyPointMatchParameters,
149) -> Result<(i32, Mat), StackerError>
150where
151    I: IntoIterator<Item = PathBuf>,
152{
153    let files_vec: Vec<PathBuf> = files.into_iter().collect();
154
155    if files_vec.is_empty() {
156        return Err(StackerError::NotEnoughFiles);
157    }
158
159    // Process the first file to get reference keypoints and descriptors
160    let (first_grey_img, first_img_f32_wr) = {
161        let (g, f) = utils::read_grey_and_f32(&files_vec[0], imgcodecs::IMREAD_UNCHANGED)?;
162        (g, utils::UnsafeMatSyncWrapper(f))
163    };
164
165    // Get the image size from the first image for later use
166    let img_size = first_grey_img.size()?;
167
168    // Get keypoints and descriptors
169    let (first_kp_wr, first_des_wr) = {
170        let (kp, des) = utils::orb_detect_and_compute(&first_grey_img)?;
171        (
172            utils::UnsafeVectorKeyPointSyncWrapper(kp),
173            utils::UnsafeMatSyncWrapper(des),
174        )
175    };
176
177    // we do not need first_img anymore
178    drop(first_grey_img);
179
180    // Combined map-reduce step: Process each image and reduce on the fly
181    let result = {
182        // Create a reference to our wrappers that we can move into the closure
183        let first_kp_wrmv = &first_kp_wr;
184        let first_des_wrmv = &first_des_wr;
185        let first_img_f32_wrmv = &first_img_f32_wr;
186        let files_vec_mw = &files_vec;
187
188        (0..files_vec.len())
189            .into_par_iter()
190            //.into_iter()
191            .try_fold(
192                || None, // Initial accumulator for each thread
193                move |acc: Option<(i32, utils::UnsafeMatSyncWrapper)>, index| {
194                    let warped_img = if index == 0 {
195                        // For the first image, just use the already processed image
196                        Some(first_img_f32_wrmv.0.clone())
197                    } else {
198                        // Process non-first images
199                        // Process the first file to get reference keypoints and descriptors
200                        let (grey_img, img_f32) = utils::read_grey_and_f32(&files_vec_mw[index], imgcodecs::IMREAD_UNCHANGED)?;
201
202                        //python: src_pts = np.float32([first_kp[m.queryIdx].pt for m in matches]).reshape(-1, 1, 2)
203                        // Detect keypoints and compute descriptors
204                        let (kp, des) = utils::orb_detect_and_compute(&grey_img)?;
205
206                        // Match keypoints
207                        let matches: Vec<core::DMatch> = {
208                            let mut matcher = features2d::BFMatcher::create(core::NORM_HAMMING, false)?; // false for knn_match
209                            matcher.add(&des)?;
210                            let mut knn_matches = core::Vector::<core::Vector<core::DMatch>>::new();
211
212                            // Explicitly calling knn_match with the required parameters
213                            matcher.knn_match(
214                                &first_des_wrmv.0, // query descriptors
215                                &mut knn_matches,  // output matches
216                                2,              // k (2 best matches per descriptor)
217                                &Mat::default(),   // mask (no filtering here)
218                                false,// compact_result
219                            )?;
220
221                            let mut filtered_matches = knn_matches
222                                .iter()
223                                .filter_map(|m| {
224                                    if m.len() == 2 && m.get(0).unwrap().distance < params.match_ratio * m.get(1).unwrap().distance {
225                                        Some(m.get(0).unwrap())
226                                    } else {
227                                        None
228                                    }
229                                })
230                                .collect::<Vec<_>>();
231
232                            // Sort by distance
233                            filtered_matches.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap_or(std::cmp::Ordering::Equal));
234                            // Keep only the best 50% of matches (optional)
235                            let num_to_keep = (filtered_matches.len() as f32 * params.match_keep_ratio).round() as usize;
236                            filtered_matches.truncate(num_to_keep);
237
238                            filtered_matches
239                        };
240                        if matches.len() < 5 {
241                            return Ok(None)
242                        }
243
244                        // Calculate source points
245                        let src_pts = {
246                            let mut pts: Vector<Point2f> = Vector::with_capacity(matches.len());
247                            for m in matches.iter() {
248                                pts.push(first_kp_wrmv.0.get(m.query_idx as usize)?.pt());
249                            }
250                            let mut mat = Mat::from_exact_iter(pts.into_iter())?;
251                            mat.reshape_mut(2, 0)?;
252                            mat
253                        };
254
255                        // Calculate destination points
256                        let dst_pts = {
257                            let mut pts: Vector<Point2f> = Vector::with_capacity(matches.len());
258                            for m in matches.iter() {
259                                pts.push(kp.get(m.train_idx as usize)?.pt());
260                            }
261                            let mut mat = Mat::from_exact_iter(pts.into_iter())?;
262                            mat.reshape_mut(2, 0)?;
263                            mat
264                        };
265
266                        // Find homography matrix
267                        let h = match calib3d::find_homography(
268                            &dst_pts,
269                            &src_pts,
270                            &mut Mat::default(),
271                            params.method,
272                            params.ransac_reproj_threshold,
273                        ) {
274                            Ok(matrix) => matrix,
275                            Err(_) => return Ok(None), // Skip this image if homography fails
276                        };
277
278                        // Check if homography is valid
279                        if h.empty() || h.rows() != 3 || h.cols() != 3 {
280                            // Homography matrix is invalid
281                            return Ok(None); // This means this image will be skipped
282                        }
283
284                        if core::determinant(&h)?.abs() < 1e-6 {
285                            // Homography is degenerate
286                            return Ok(None);
287                        }
288
289                        // Warp image
290                        let mut warped_image = Mat::default();
291                        imgproc::warp_perspective(
292                            &img_f32,
293                            &mut warped_image,
294                            &h,
295                            img_size,
296                            imgproc::INTER_LINEAR,
297                            params.border_mode,
298                            params.border_value,
299                        )?;
300
301                        Some(warped_image)
302                    };
303
304                    //python: dst_pts = np.float32([kp[m.trainIdx].pt for m in matches]).reshape(-1, 1, 2)
305                    // Add to the accumulator (if accumulator is empty, just use the processed image)
306                    let result = match (acc, warped_img) {
307                        (None, None) => (1_i32,utils::UnsafeMatSyncWrapper(first_img_f32_wrmv.0.clone())),
308                        (Some((acc_d, acc_mat)), None) => (1_i32 + acc_d, acc_mat),
309                        (None, Some(warped_image)) => (0_i32, utils::UnsafeMatSyncWrapper(warped_image)),
310                        (Some((acc_d, acc_mat)), Some(warped_image)) => {
311                            let mat = utils::UnsafeMatSyncWrapper(
312                                (&acc_mat.0 + &warped_image).into_result()?.to_mat()?,
313                            );
314                            (acc_d, mat)
315                        }
316                    };
317
318                    Ok::<Option<(i32, utils::UnsafeMatSyncWrapper)>, StackerError>(Some(result))
319                },
320            )
321            .try_reduce(
322                || None,
323                |acc1, acc2| match (acc1, acc2) {
324                    (None, None) => Err(StackerError::InvalidParams("All images discarded: try modifying KeyPointMatchParameters::match_distance_threshold".to_string())),
325                    (Some(acc1), None) => Ok(Some(acc1)),
326                    (None, Some(acc2)) => Ok(Some(acc2)),
327                    (Some(acc1), Some(acc2)) => {
328                        // Combine the two accumulators
329                        let combined_img = utils::UnsafeMatSyncWrapper(
330                            (&acc1.1.0 + &acc2.1.0).into_result()?.to_mat()?,
331                        );
332                        Ok(Some((acc1.0+ acc2.0, combined_img)))
333                    }
334                },
335            )
336    }?;
337
338    // Final normalization
339    let final_result = if let Some((dropped, img)) = result {
340        (
341            dropped,
342            (img.0 / (files_vec.len() - dropped as usize) as f64)
343                .into_result()?
344                .to_mat()?,
345        )
346    } else {
347        return Err(StackerError::ProcessingError(
348            "Empty result after reduction".to_string(),
349        ));
350    };
351
352    Ok(final_result)
353}
354
355fn keypoint_match_scale_down<I>(
356    files: I,
357    params: KeyPointMatchParameters,
358    scale_down_width: f32,
359) -> Result<(i32, Mat), StackerError>
360where
361    I: IntoIterator<Item = PathBuf>,
362{
363    let files_vec: Vec<PathBuf> = files.into_iter().collect();
364    if files_vec.is_empty() {
365        return Err(StackerError::NotEnoughFiles);
366    }
367
368    // Process the first file to get reference keypoints and descriptors
369    let (grey_first_img, first_img_f32_wr) = {
370        let (g, f) = utils::read_grey_and_f32(&files_vec[0], imgcodecs::IMREAD_UNCHANGED)?;
371        (g, utils::UnsafeMatSyncWrapper(f))
372    };
373
374    // Get the image size from the first image for later use
375    let full_size = first_img_f32_wr.0.size()?;
376
377    if scale_down_width >= full_size.width as f32 {
378        return Err(StackerError::InvalidParams(format!(
379            "scale_down_to was larger (or equal) to the full image width: full_size:{}, scale_down_to:{}",
380            full_size.width, scale_down_width
381        )));
382    }
383
384    // Get keypoints and descriptors
385    let (first_kp_small_wr, first_des_small_wr) = {
386        // Scale down the first image
387        let grey_img_small = utils::scale_image(&grey_first_img, scale_down_width)?;
388
389        let (kp, des) = utils::orb_detect_and_compute(&grey_img_small)?;
390        (
391            utils::UnsafeVectorKeyPointSyncWrapper(kp),
392            utils::UnsafeMatSyncWrapper(des),
393        )
394    };
395
396    // we do not need grey_first_img anymore
397    drop(grey_first_img);
398
399    // Combined map-reduce step: Process each image and reduce on the fly
400    let result = {
401        // Create a reference to our wrappers that we can move into the closure
402        let first_kp_small_wrmv = &first_kp_small_wr;
403        let first_des_small_wrmv = &first_des_small_wr;
404        let first_img_f32_wrmv = &first_img_f32_wr;
405        let files_vec_wr = &files_vec;
406
407        (0..files_vec.len())
408            .into_par_iter()
409            //.into_iter()
410            .try_fold(
411                || None, // Initial accumulator for each thread
412                move |acc: Option<(i32, utils::UnsafeMatSyncWrapper)>, index| {
413                    let warped_img = if index == 0 {
414                        // For the first image, just use the already processed image
415                        Some(first_img_f32_wrmv.0.clone())
416                    } else {
417                        // Process non-first images
418
419                        // Load and scale down the image for feature detection
420                        let (img_f32, grey_img_small) = {
421                            let (grey_img, img_f32) = utils::read_grey_and_f32(
422                                &files_vec_wr[index],
423                                imgcodecs::IMREAD_UNCHANGED,
424                            )?;
425                            let grey_img_small = utils::scale_image(&grey_img, scale_down_width)?;
426                            (img_f32, grey_img_small)
427                        };
428
429                        // Detect features on the scaled-down image
430                        let (kp_small, des_small) = utils::orb_detect_and_compute(&grey_img_small)?;
431
432                        // Match features
433                        // Match keypoints
434                        let matches: Vec<core::DMatch> = {
435                            let mut matcher =
436                                features2d::BFMatcher::create(core::NORM_HAMMING, false)?; // false for knn_match
437                            matcher.add(&des_small)?;
438                            let mut knn_matches = core::Vector::<core::Vector<core::DMatch>>::new();
439
440                            // Explicitly calling knn_match with the required parameters
441                            matcher.knn_match(
442                                &first_des_small_wrmv.0, // query descriptors
443                                &mut knn_matches,        // output matches
444                                2,                       // k (2 best matches per descriptor)
445                                &Mat::default(),         // mask (no filtering here)
446                                false,                   // compact_result
447                            )?;
448
449                            let mut filtered_matches = knn_matches
450                                .iter()
451                                .filter_map(|m| {
452                                    if m.len() == 2
453                                        && m.get(0).unwrap().distance
454                                            < params.match_ratio * m.get(1).unwrap().distance
455                                    {
456                                        Some(m.get(0).unwrap())
457                                    } else {
458                                        None
459                                    }
460                                })
461                                .collect::<Vec<_>>();
462
463                            // Sort by distance
464                            filtered_matches.sort_by(|a, b| {
465                                a.distance
466                                    .partial_cmp(&b.distance)
467                                    .unwrap_or(std::cmp::Ordering::Equal)
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
719fn 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
849fn 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}