Skip to main content

locus_core/
gwlf.rs

1//! Gradient-Weighted Line Fitting (GWLF) for sub-pixel corner refinement.
2
3use crate::image::ImageView;
4use nalgebra::{Matrix2, Matrix3, SMatrix, Vector2, Vector3};
5
6/// Accumulator for gradient-weighted spatial moments of an edge.
7#[derive(Clone, Copy, Debug, Default)]
8pub struct MomentAccumulator {
9    /// Sum of weights: sum(w_i)
10    pub sum_w: f64,
11    /// Sum of weighted x: sum(w_i * x_i)
12    pub sum_wx: f64,
13    /// Sum of weighted y: sum(w_i * y_i)
14    pub sum_wy: f64,
15    /// Sum of weighted x squared: sum(w_i * x_i^2)
16    pub sum_wxx: f64,
17    /// Sum of weighted y squared: sum(w_i * y_i^2)
18    pub sum_wyy: f64,
19    /// Sum of weighted x*y: sum(w_i * x_i * y_i)
20    pub sum_wxy: f64,
21}
22
23impl MomentAccumulator {
24    /// Create a new empty accumulator.
25    #[must_use]
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    /// Add a weighted point to the accumulator.
31    pub fn add(&mut self, x: f64, y: f64, w: f64) {
32        self.sum_w += w;
33        self.sum_wx += w * x;
34        self.sum_wy += w * y;
35        self.sum_wxx += w * x * x;
36        self.sum_wyy += w * y * y;
37        self.sum_wxy += w * x * y;
38    }
39
40    /// Compute the gradient-weighted centroid (cx, cy).
41    #[must_use]
42    pub fn centroid(&self) -> Option<Vector2<f64>> {
43        if self.sum_w < 1e-9 {
44            None
45        } else {
46            Some(Vector2::new(
47                self.sum_wx / self.sum_w,
48                self.sum_wy / self.sum_w,
49            ))
50        }
51    }
52
53    /// Compute the 2x2 gradient-weighted spatial covariance matrix.
54    #[must_use]
55    #[allow(clippy::similar_names)]
56    pub fn covariance(&self) -> Option<Matrix2<f64>> {
57        let c = self.centroid()?;
58        let s_w = self.sum_w;
59
60        let s_xx = (self.sum_wxx / s_w) - (c.x * c.x);
61        let s_yy = (self.sum_wyy / s_w) - (c.y * c.y);
62        let s_xy = (self.sum_wxy / s_w) - (c.x * c.y);
63
64        Some(Matrix2::new(s_xx, s_xy, s_xy, s_yy))
65    }
66}
67
68/// A line in homogeneous coordinates l such that l^T * [x, y, 1]^T = 0.
69#[derive(Clone, Copy, Debug)]
70pub struct HomogeneousLine {
71    /// Line parameters [nx, ny, d].
72    pub l: Vector3<f64>,
73    /// 3x3 covariance matrix of the line parameters.
74    pub cov: Matrix3<f64>,
75}
76
77impl HomogeneousLine {
78    /// Compute the intersection of two homogeneous lines.
79    /// Returns (Cartesian corner, Cartesian 2x2 covariance).
80    #[must_use]
81    pub fn intersect(&self, other: &Self) -> Option<(Vector2<f64>, Matrix2<f64>)> {
82        // 1. Cross product intersection: c_hom = l1 x l2
83        let c_hom = self.l.cross(&other.l);
84        let w = c_hom.z;
85        if w.abs() < 1e-9 {
86            return None; // Parallel lines
87        }
88
89        // Phase B: Covariance of the Projective Intersection
90        // Σ_ch = [l2]x * Σ_l1 * [l2]x^T + [l1]x * Σ_l2 * [l1]x^T
91        let l1_x = self.l.cross_matrix();
92        let l2_x = other.l.cross_matrix();
93        let cov_ch = l2_x * self.cov * l2_x.transpose() + l1_x * other.cov * l1_x.transpose();
94
95        // Phase C: Projection to the Affine Plane
96        // c = [u/w, v/w]
97        let x = c_hom.x / w;
98        let y = c_hom.y / w;
99        let corner = Vector2::new(x, y);
100
101        // Jacobian of perspective division J_pi = (1/w) * [ 1 0 -u/w; 0 1 -v/w ]
102        let mut j_pi = SMatrix::<f64, 2, 3>::zeros();
103        let w_inv = 1.0 / w;
104        j_pi[(0, 0)] = w_inv;
105        j_pi[(0, 2)] = -x * w_inv;
106        j_pi[(1, 1)] = w_inv;
107        j_pi[(1, 2)] = -y * w_inv;
108
109        let cov_c = j_pi * cov_ch * j_pi.transpose();
110
111        Some((corner, cov_c))
112    }
113}
114
115/// Result of analytic eigendecomposition of a 2x2 symmetric matrix.
116pub struct EigenResult {
117    /// Largest eigenvalue.
118    pub l_max: f64,
119    /// Smallest eigenvalue.
120    pub l_min: f64,
121    /// Eigenvector corresponding to the largest eigenvalue.
122    pub v_max: Vector2<f64>,
123    /// Eigenvector corresponding to the smallest eigenvalue.
124    pub v_min: Vector2<f64>,
125}
126
127/// Solves the eigendecomposition of a 2x2 symmetric matrix [[a, b], [b, c]].
128#[must_use]
129#[allow(clippy::manual_midpoint)]
130pub fn solve_2x2_symmetric(a: f64, b: f64, c: f64) -> EigenResult {
131    let trace = a + c;
132    let det = a * c - b * b;
133
134    let disc = (trace * trace - 4.0 * det).max(0.0).sqrt();
135    let l_max = (trace + disc) / 2.0;
136    let l_min = (trace - disc) / 2.0;
137
138    // Smallest eigenvalue eigenvector (normal n)
139    let v_min = if b.abs() > 1e-9 {
140        let vx = b;
141        let vy = l_min - a;
142        Vector2::new(vx, vy).normalize()
143    } else if a < c {
144        Vector2::new(1.0, 0.0)
145    } else {
146        Vector2::new(0.0, 1.0)
147    };
148
149    // Largest eigenvalue eigenvector (tangent t)
150    let v_max = Vector2::new(-v_min.y, v_min.x);
151
152    EigenResult {
153        l_max,
154        l_min,
155        v_max,
156        v_min,
157    }
158}
159
160/// Refine quad corners using Gradient-Weighted Line Fitting (GWLF).
161///
162/// Returns the refined corners [[x, y]; 4] and their 2x2 covariances [Matrix2; 4].
163#[must_use]
164#[allow(clippy::similar_names)]
165#[allow(clippy::cast_sign_loss)]
166#[allow(clippy::cast_possible_wrap)]
167#[allow(clippy::type_complexity)]
168pub fn refine_quad_gwlf_with_cov(
169    img: &ImageView,
170    coarse_corners: &[[f32; 2]; 4],
171    alpha: f64,
172) -> Option<([[f32; 2]; 4], [Matrix2<f64>; 4])> {
173    let mut lines = [HomogeneousLine {
174        l: Vector3::zeros(),
175        cov: Matrix3::zeros(),
176    }; 4];
177
178    for i in 0..4 {
179        let p0 = coarse_corners[i];
180        let p1 = coarse_corners[(i + 1) % 4];
181
182        let dx_edge = f64::from(p1[0] - p0[0]);
183        let dy_edge = f64::from(p1[1] - p0[1]);
184        let len = (dx_edge * dx_edge + dy_edge * dy_edge).sqrt();
185        if len < 2.0 {
186            return None;
187        }
188
189        let ux = dx_edge / len;
190        let uy = dy_edge / len;
191        let nx_coarse = -uy;
192        let ny_coarse = ux;
193
194        let mut acc = MomentAccumulator::new();
195        let steps = (len * 2.0) as usize;
196
197        // Adaptive Transversal Windowing: search band scales with edge length L.
198        // Band = +/- max(2, alpha * L).
199        let window_half_width = (alpha * len).max(2.0);
200        let k_min = -window_half_width.round() as i32;
201        let k_max = window_half_width.round() as i32;
202
203        for s in 0..=steps {
204            let t = (s as f64) / (steps as f64);
205            let px = f64::from(p0[0]) + t * dx_edge;
206            let py = f64::from(p0[1]) + t * dy_edge;
207
208            for k in k_min..=k_max {
209                let sx = px + f64::from(k) * nx_coarse;
210                let sy = py + f64::from(k) * ny_coarse;
211
212                if sx < 1.0
213                    || sx >= (img.width - 2) as f64
214                    || sy < 1.0
215                    || sy >= (img.height - 2) as f64
216                {
217                    continue;
218                }
219
220                let g = img.sample_gradient_bilinear(sx, sy);
221                let w = g[0] * g[0] + g[1] * g[1];
222
223                // Weight noise floor
224                if w > 100.0 {
225                    acc.add(sx, sy, w);
226                }
227            }
228        }
229
230        let cov_spatial = acc.covariance()?;
231        let res = solve_2x2_symmetric(
232            cov_spatial[(0, 0)],
233            cov_spatial[(0, 1)],
234            cov_spatial[(1, 1)],
235        );
236        let n = res.v_min;
237        let x_bar = acc.centroid()?;
238        let w_total = acc.sum_w;
239
240        // Phase A: Covariance of the Line Parameters
241        // Σ_xbar = (λ_min / W) * I
242        let sigma_xbar = Matrix2::identity().scale(res.l_min / w_total);
243        // Σ_n = (λ_min / (W * (λ_max - λ_min))) * n_perp * n_perp^T
244        let n_perp = res.v_max;
245        let sigma_n = (n_perp * n_perp.transpose())
246            .scale(res.l_min / (w_total * (res.l_max - res.l_min).max(1e-6)));
247
248        // l = [n; -x_bar^T * n]
249        // J_n = [I; -x_bar^T], J_xbar = [0; -n^T]
250        let mut j_n = SMatrix::<f64, 3, 2>::zeros();
251        j_n.fixed_view_mut::<2, 2>(0, 0)
252            .copy_from(&Matrix2::identity());
253        j_n[(2, 0)] = -x_bar.x;
254        j_n[(2, 1)] = -x_bar.y;
255
256        let mut j_xbar = SMatrix::<f64, 3, 2>::zeros();
257        j_xbar[(2, 0)] = -n.x;
258        j_xbar[(2, 1)] = -n.y;
259
260        let cov_l = j_n * sigma_n * j_n.transpose() + j_xbar * sigma_xbar * j_xbar.transpose();
261
262        lines[i] = HomogeneousLine {
263            l: Vector3::new(n.x, n.y, -x_bar.dot(&n)),
264            cov: cov_l,
265        };
266    }
267
268    let mut refined_corners = [[0.0f32; 2]; 4];
269    let mut refined_covs = [Matrix2::zeros(); 4];
270
271    for i in 0..4 {
272        let l_prev = lines[(i + 3) % 4];
273        let l_curr = lines[i];
274
275        let (corner, cov) = l_prev.intersect(&l_curr)?;
276
277        // Sanity check
278        let dist_sq = (corner.x - f64::from(coarse_corners[i][0])).powi(2)
279            + (corner.y - f64::from(coarse_corners[i][1])).powi(2);
280        if dist_sq > 25.0 {
281            // Relaxed sanity check for blur (5.0 px)
282            return None;
283        }
284
285        refined_corners[i] = [corner.x as f32, corner.y as f32];
286        refined_covs[i] = cov;
287    }
288
289    Some((refined_corners, refined_covs))
290}
291
292/// Compatibility wrapper for the existing API.
293#[must_use]
294pub fn refine_quad_gwlf(
295    img: &ImageView,
296    coarse_corners: &[[f32; 2]; 4],
297    alpha: f64,
298) -> Option<[[f32; 2]; 4]> {
299    refine_quad_gwlf_with_cov(img, coarse_corners, alpha).map(|(c, _)| c)
300}