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}