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}