use lowe_sift::{GrayImage, Sift, estimate_affine_from_pairs, match_features};
pub const MATCH_RATIO_THRESHOLD: f32 = 0.8;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AffineTransformation {
pub tx: f64,
pub ty: f64,
pub sx: f64,
pub sy: f64,
pub rotation: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MatchedKeypoint {
pub ax: f32,
pub ay: f32,
pub bx: f32,
pub by: f32,
pub scale: f32,
}
#[derive(Clone, Debug)]
pub struct SiftAlignment {
pub width: usize,
pub height: usize,
pub aligned: Vec<f32>,
pub matrix: [[f64; 2]; 2],
pub offset: (f64, f64),
pub transformation: AffineTransformation,
pub matches: Vec<MatchedKeypoint>,
}
fn normalize01(data: &[f32]) -> Vec<f32> {
let mut min = f32::INFINITY;
let mut max = f32::NEG_INFINITY;
for &v in data {
if v.is_finite() {
min = min.min(v);
max = max.max(v);
}
}
if !min.is_finite() || !max.is_finite() || max <= min {
return vec![0.0; data.len()];
}
let inv = 1.0 / (max - min);
data.iter()
.map(|&v| if v.is_finite() { (v - min) * inv } else { 0.0 })
.collect()
}
fn pad_top_left(src: &[f32], src_w: usize, src_h: usize, dst_w: usize, dst_h: usize) -> Vec<f32> {
let mut out = vec![0.0f32; dst_w * dst_h];
if src_w == 0 || src_h == 0 || src_w > dst_w || src_h > dst_h {
return out;
}
for r in 0..src_h {
let dst_base = r * dst_w;
let src_base = r * src_w;
out[dst_base..dst_base + src_w].copy_from_slice(&src[src_base..src_base + src_w]);
}
out
}
pub fn decompose_affine(matrix: [[f64; 2]; 2], offset: (f64, f64)) -> AffineTransformation {
let [[a, b], [c, d]] = matrix;
let sign = |v: f64| if v < 0.0 { -1.0 } else { 1.0 };
AffineTransformation {
tx: offset.0,
ty: offset.1,
sx: sign(a) * (a * a + b * b).sqrt(),
sy: sign(d) * (c * c + d * d).sqrt(),
rotation: (-b).atan2(a),
}
}
fn sample_bilinear_border0(data: &[f32], w: usize, h: usize, x: f64, y: f64) -> f32 {
if w == 0 || h == 0 || x < 0.0 || y < 0.0 || x > (w - 1) as f64 || y > (h - 1) as f64 {
return 0.0;
}
let x0 = x.floor() as usize;
let y0 = y.floor() as usize;
let x1 = (x0 + 1).min(w - 1);
let y1 = (y0 + 1).min(h - 1);
let fx = x - x0 as f64;
let fy = y - y0 as f64;
let at = |r: usize, col: usize| data[r * w + col] as f64;
let top = at(y0, x0) * (1.0 - fx) + at(y0, x1) * fx;
let bot = at(y1, x0) * (1.0 - fx) + at(y1, x1) * fx;
(top * (1.0 - fy) + bot * fy) as f32
}
fn warp_affine(
b: &[f32],
w: usize,
h: usize,
matrix: [[f64; 2]; 2],
offset: (f64, f64),
) -> Vec<f32> {
let [[a, bb], [c, d]] = matrix;
let (tx, ty) = offset;
let mut out = vec![0.0f32; w * h];
for y in 0..h {
for x in 0..w {
let xf = x as f64;
let yf = y as f64;
let bx = a * xf + bb * yf + tx;
let by = c * xf + d * yf + ty;
out[y * w + x] = sample_bilinear_border0(b, w, h, bx, by);
}
}
out
}
pub fn sift_auto_align(
a: &[f32],
wa: usize,
ha: usize,
b: &[f32],
wb: usize,
hb: usize,
) -> Option<SiftAlignment> {
if a.len() != wa * ha || b.len() != wb * hb {
return None;
}
let cw = wa.max(wb);
let ch = ha.max(hb);
if cw < 3 || ch < 3 {
return None;
}
let a_pad = pad_top_left(a, wa, ha, cw, ch);
let b_pad = pad_top_left(b, wb, hb, cw, ch);
let sift = Sift::default();
let gray_a = GrayImage::new(cw, ch, normalize01(&a_pad)).ok()?;
let gray_b = GrayImage::new(cw, ch, normalize01(&b_pad)).ok()?;
let feats_a = sift.detect_and_compute(&gray_a);
let feats_b = sift.detect_and_compute(&gray_b);
let raw = match_features(&feats_a, &feats_b, MATCH_RATIO_THRESHOLD);
if raw.len() < 3 {
return None;
}
let mut matches = Vec::with_capacity(raw.len());
let mut pairs = Vec::with_capacity(raw.len());
for m in &raw {
let fa = feats_a.get(m.query_index)?;
let fb = feats_b.get(m.train_index)?;
matches.push(MatchedKeypoint {
ax: fa.keypoint.x,
ay: fa.keypoint.y,
bx: fb.keypoint.x,
by: fb.keypoint.y,
scale: fa.keypoint.scale,
});
pairs.push((
(fa.keypoint.x, fa.keypoint.y),
(fb.keypoint.x, fb.keypoint.y),
));
}
let affine = estimate_affine_from_pairs(&pairs).ok()?;
let matrix = [
[affine.m11 as f64, affine.m12 as f64],
[affine.m21 as f64, affine.m22 as f64],
];
let offset = (affine.tx as f64, affine.ty as f64);
let aligned = warp_affine(&b_pad, cw, ch, matrix, offset);
Some(SiftAlignment {
width: cw,
height: ch,
aligned,
matrix,
offset,
transformation: decompose_affine(matrix, offset),
matches,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize01_maps_min_to_zero_max_to_one() {
let out = normalize01(&[2.0, 4.0, 6.0]);
assert!((out[0] - 0.0).abs() < 1e-6);
assert!((out[1] - 0.5).abs() < 1e-6);
assert!((out[2] - 1.0).abs() < 1e-6);
}
#[test]
fn normalize01_flat_or_nan_is_all_zero() {
assert_eq!(normalize01(&[3.0, 3.0, 3.0]), vec![0.0, 0.0, 0.0]);
let out = normalize01(&[f32::NAN, 1.0, 3.0]);
assert_eq!(out[0], 0.0); assert!((out[2] - 1.0).abs() < 1e-6);
}
#[test]
fn pad_top_left_anchors_at_origin() {
let src = [1.0f32, 2.0, 3.0, 4.0]; let out = pad_top_left(&src, 2, 2, 3, 3);
#[rustfmt::skip]
let expected = vec![
1.0, 2.0, 0.0,
3.0, 4.0, 0.0,
0.0, 0.0, 0.0,
];
assert_eq!(out, expected);
}
#[test]
fn decompose_affine_identity() {
let t = decompose_affine([[1.0, 0.0], [0.0, 1.0]], (5.0, -3.0));
assert!((t.tx - 5.0).abs() < 1e-12);
assert!((t.ty + 3.0).abs() < 1e-12);
assert!((t.sx - 1.0).abs() < 1e-12);
assert!((t.sy - 1.0).abs() < 1e-12);
assert!(t.rotation.abs() < 1e-12);
}
#[test]
fn decompose_affine_rotation_and_scale() {
let t = decompose_affine([[0.0, -2.0], [2.0, 0.0]], (0.0, 0.0));
assert!((t.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-9);
assert!((t.sx - 2.0).abs() < 1e-9);
assert!((t.sy - 2.0).abs() < 1e-9);
}
#[test]
fn warp_affine_identity_returns_input() {
let b = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; let out = warp_affine(&b, 3, 3, [[1.0, 0.0], [0.0, 1.0]], (0.0, 0.0));
assert_eq!(out, b.to_vec());
}
#[test]
fn warp_affine_translation_shifts_and_zero_fills() {
let b = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0]; let out = warp_affine(&b, 3, 2, [[1.0, 0.0], [0.0, 1.0]], (1.0, 0.0));
assert_eq!(out, vec![2.0, 3.0, 0.0, 5.0, 6.0, 0.0]);
}
fn blob_image(w: usize, h: usize) -> Vec<f32> {
let blobs = [
(25.0f32, 30.0, 4.0, 1.0f32),
(60.0, 25.0, 6.0, 0.9),
(40.0, 60.0, 5.0, 1.0),
(70.0, 65.0, 3.0, 0.8),
(50.0, 45.0, 7.0, 0.7),
(30.0, 70.0, 4.5, 0.85),
];
let mut img = vec![0.0f32; w * h];
for y in 0..h {
for x in 0..w {
let mut v = 0.0f32;
for &(cx, cy, sigma, amp) in &blobs {
let dx = x as f32 - cx;
let dy = y as f32 - cy;
v += amp * (-(dx * dx + dy * dy) / (2.0 * sigma * sigma)).exp();
}
img[y * w + x] = v;
}
}
img
}
fn shift_image(a: &[f32], w: usize, h: usize, dx: isize, dy: isize) -> Vec<f32> {
let mut b = vec![0.0f32; w * h];
for y in 0..h {
for x in 0..w {
let sx = x as isize - dx;
let sy = y as isize - dy;
if sx >= 0 && sy >= 0 && (sx as usize) < w && (sy as usize) < h {
b[y * w + x] = a[sy as usize * w + sx as usize];
}
}
}
b
}
#[test]
fn sift_auto_align_recovers_a_known_translation() {
let (w, h) = (96usize, 96usize);
let a = blob_image(w, h);
let b = shift_image(&a, w, h, 3, 2);
let result = sift_auto_align(&a, w, h, &b, w, h).expect("alignment");
assert_eq!((result.width, result.height), (w, h));
assert!(
result.matches.len() >= 3,
"expected >=3 matches, got {}",
result.matches.len()
);
let t = result.transformation;
assert!((t.tx - 3.0).abs() < 1.0, "tx={}", t.tx);
assert!((t.ty - 2.0).abs() < 1.0, "ty={}", t.ty);
assert!((t.sx - 1.0).abs() < 0.1, "sx={}", t.sx);
assert!((t.sy - 1.0).abs() < 0.1, "sy={}", t.sy);
assert!(t.rotation.abs() < 0.1, "rot={}", t.rotation);
let mut sum_abs = 0.0f64;
let mut count = 0usize;
for y in 10..(h - 10) {
for x in 10..(w - 10) {
sum_abs += (result.aligned[y * w + x] - a[y * w + x]).abs() as f64;
count += 1;
}
}
let mean_abs = sum_abs / count as f64;
assert!(mean_abs < 0.05, "mean |aligned - A| = {mean_abs}");
}
#[test]
fn sift_auto_align_rejects_too_few_matches() {
let flat = vec![0.5f32; 32 * 32];
assert!(sift_auto_align(&flat, 32, 32, &flat, 32, 32).is_none());
}
#[test]
fn sift_auto_align_rejects_bad_lengths() {
assert!(sift_auto_align(&[0.0; 3], 2, 2, &[0.0; 4], 2, 2).is_none());
}
}