Skip to main content

projective_grid/topological/
classify.rs

1//! Axis-driven grid-edge classification plus local triangle diagonal inference
2//! (replaces the paper's color test).
3//!
4//! For a Delaunay half-edge from corner `a` to corner `b`, the edge angle
5//! `θ = atan2(b - a)` is compared to each corner's two axes (modulo π,
6//! since axes are undirected). If both endpoints see the edge within
7//! `axis_align_tol_rad` of one usable axis, the edge is a **Grid** edge.
8//!
9//! Diagonals are not classified by a fixed `axis ± π/4` angle. Under a
10//! projective warp, a projected cell diagonal is induced by the local
11//! projected grid-step vectors, not by the angle bisector in image space.
12//! After the Grid/Spurious pass, each Delaunay triangle is inspected: if
13//! exactly two of its edges are Grid edges and those two edges meet at a
14//! vertex using different local axis slots, the remaining edge is promoted
15//! to **Diagonal** for that triangle.
16//!
17//! Both endpoints of every edge are guaranteed to have at least one
18//! usable axis: high-`sigma` corners are filtered out at triangulation
19//! time (see [`super::triangulate_usable`]), so `Spurious` here only
20//! flags genuine geometric misalignment, not uncertainty rejection.
21
22use std::f32::consts::{FRAC_PI_2, PI};
23
24use nalgebra::Point2;
25use serde::{Deserialize, Serialize};
26
27use super::delaunay::Triangulation;
28use super::{AxisEstimate, TopologicalParams};
29
30/// Classification of a Delaunay edge against the recovered grid directions.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33#[non_exhaustive]
34pub enum EdgeKind {
35    /// Edge runs along a grid line (cell edge in the chessboard pattern).
36    Grid,
37    /// Edge crosses a cell as its diagonal.
38    Diagonal,
39    /// Edge is unaligned with any grid direction (background, noise,
40    /// occlusion).
41    Spurious,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq)]
45struct GridAxisMatch {
46    slot: usize,
47    distance_rad: f32,
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51struct GridEdgeMatch {
52    start_slot: usize,
53    end_slot: usize,
54}
55
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub(crate) struct EdgeMetric {
58    pub(crate) grid_distance_rad: Option<f32>,
59    pub(crate) grid_margin_rad: Option<f32>,
60}
61
62/// Smallest unsigned angle between two undirected directions, in `[0, π/2]`.
63///
64/// Both `theta` and `alpha` are interpreted modulo π (axes are
65/// undirected). The result is the geodesic distance on the half-circle.
66#[inline]
67fn axis_diff(theta: f32, alpha: f32) -> f32 {
68    let mut d = (theta - alpha).rem_euclid(PI);
69    if d > FRAC_PI_2 {
70        d = PI - d;
71    }
72    d
73}
74
75/// Nearest usable grid axis to `theta` at this corner.
76///
77/// Both endpoints of every classified edge are guaranteed usable by the
78/// upstream pre-filter (see [`super::triangulate_usable`]) — at least one
79/// axis at each endpoint has `sigma < max_axis_sigma_rad`. The per-axis
80/// `sigma` check below still skips an individual axis whose uncertainty
81/// is too high while keeping the corner's other (good) axis active.
82fn nearest_axis_at_corner(
83    theta: f32,
84    axes: &[AxisEstimate; 2],
85    params: &TopologicalParams,
86) -> Option<GridAxisMatch> {
87    let mut best: Option<GridAxisMatch> = None;
88    for (slot, a) in axes.iter().enumerate() {
89        if !a.sigma.is_finite() || a.sigma >= params.max_axis_sigma_rad {
90            continue;
91        }
92        let d = axis_diff(theta, a.angle);
93        if !d.is_finite() {
94            continue;
95        }
96        if best.is_none_or(|m| d < m.distance_rad) {
97            best = Some(GridAxisMatch {
98                slot,
99                distance_rad: d,
100            });
101        }
102    }
103    best
104}
105
106/// Smallest angular distance from `theta` to a usable grid axis, in radians.
107fn grid_distance_at_corner(
108    theta: f32,
109    axes: &[AxisEstimate; 2],
110    params: &TopologicalParams,
111) -> f32 {
112    let best = nearest_axis_at_corner(theta, axes, params);
113    debug_assert!(
114        best.is_some(),
115        "topological pre-filter must guarantee at least one usable axis per endpoint"
116    );
117    best.map_or(f32::INFINITY, |m| m.distance_rad)
118}
119
120fn grid_match_at_corner(
121    theta: f32,
122    axes: &[AxisEstimate; 2],
123    params: &TopologicalParams,
124) -> Option<GridAxisMatch> {
125    let best = nearest_axis_at_corner(theta, axes, params)?;
126    (best.distance_rad < params.axis_align_tol_rad).then_some(best)
127}
128
129pub(crate) fn classify_edge_metric(
130    positions: &[Point2<f32>],
131    axes: &[[AxisEstimate; 2]],
132    triangulation: &Triangulation,
133    edge: usize,
134    params: &TopologicalParams,
135) -> EdgeMetric {
136    let a = triangulation.triangles[edge];
137    let b = triangulation.triangles[Triangulation::next_edge(edge)];
138    let pa = positions[a];
139    let pb = positions[b];
140    let theta = (pb.y - pa.y).atan2(pb.x - pa.x);
141    let a_grid = grid_distance_at_corner(theta, &axes[a], params);
142    let b_grid = grid_distance_at_corner(theta, &axes[b], params);
143    let grid_distance_rad = a_grid.max(b_grid);
144    EdgeMetric {
145        grid_distance_rad: Some(grid_distance_rad),
146        grid_margin_rad: Some(params.axis_align_tol_rad - grid_distance_rad),
147    }
148}
149
150fn edge_vertices(triangulation: &Triangulation, edge: usize) -> (usize, usize) {
151    (
152        triangulation.triangles[edge],
153        triangulation.triangles[Triangulation::next_edge(edge)],
154    )
155}
156
157fn grid_axis_slot_at_vertex(
158    triangulation: &Triangulation,
159    grid_matches: &[Option<GridEdgeMatch>],
160    edge: usize,
161    vertex: usize,
162) -> Option<usize> {
163    let grid = grid_matches[edge]?;
164    let (start, end) = edge_vertices(triangulation, edge);
165    if vertex == start {
166        Some(grid.start_slot)
167    } else if vertex == end {
168        Some(grid.end_slot)
169    } else {
170        None
171    }
172}
173
174fn shared_vertex_of_edges(
175    triangulation: &Triangulation,
176    edge_a: usize,
177    edge_b: usize,
178) -> Option<usize> {
179    let (a0, a1) = edge_vertices(triangulation, edge_a);
180    let (b0, b1) = edge_vertices(triangulation, edge_b);
181    if a0 == b0 || a0 == b1 {
182        Some(a0)
183    } else if a1 == b0 || a1 == b1 {
184        Some(a1)
185    } else {
186        None
187    }
188}
189
190fn infer_triangle_diagonal(
191    triangulation: &Triangulation,
192    grid_matches: &[Option<GridEdgeMatch>],
193    kinds: &[EdgeKind],
194    triangle: usize,
195) -> Option<usize> {
196    let base = 3 * triangle;
197    let mut grid_edges = [usize::MAX; 2];
198    let mut grid_count = 0usize;
199    let mut non_grid_edge: Option<usize> = None;
200
201    for k in 0..3 {
202        let edge = base + k;
203        match kinds[edge] {
204            EdgeKind::Grid => {
205                if grid_count >= grid_edges.len() {
206                    return None;
207                }
208                grid_edges[grid_count] = edge;
209                grid_count += 1;
210            }
211            EdgeKind::Spurious => {
212                if non_grid_edge.is_some() {
213                    return None;
214                }
215                non_grid_edge = Some(k);
216            }
217            EdgeKind::Diagonal => return None,
218        }
219    }
220    if grid_count != 2 {
221        return None;
222    }
223
224    let shared = shared_vertex_of_edges(triangulation, grid_edges[0], grid_edges[1])?;
225    let slot0 = grid_axis_slot_at_vertex(triangulation, grid_matches, grid_edges[0], shared)?;
226    let slot1 = grid_axis_slot_at_vertex(triangulation, grid_matches, grid_edges[1], shared)?;
227    (slot0 != slot1).then_some(non_grid_edge?)
228}
229
230fn promote_triangle_diagonals_from_grid_edges(
231    triangulation: &Triangulation,
232    grid_matches: &[Option<GridEdgeMatch>],
233    kinds: &mut [EdgeKind],
234) {
235    for triangle in 0..triangulation.num_tri() {
236        if let Some(k) = infer_triangle_diagonal(triangulation, grid_matches, kinds, triangle) {
237            kinds[3 * triangle + k] = EdgeKind::Diagonal;
238        }
239    }
240}
241
242/// Classify every directed half-edge in the triangulation.
243///
244/// Length matches `triangulation.triangles.len()`.
245#[cfg_attr(
246    feature = "tracing",
247    tracing::instrument(
248        level = "debug",
249        skip_all,
250        fields(num_edges = triangulation.triangles.len()),
251    )
252)]
253pub(crate) fn classify_all_edges(
254    positions: &[Point2<f32>],
255    axes: &[[AxisEstimate; 2]],
256    triangulation: &Triangulation,
257    params: &TopologicalParams,
258) -> Vec<EdgeKind> {
259    let n = triangulation.triangles.len();
260    let mut kinds = vec![EdgeKind::Spurious; n];
261    let mut grid_matches = vec![None; n];
262    for (e, kind) in kinds.iter_mut().enumerate().take(n) {
263        let a = triangulation.triangles[e];
264        let b = triangulation.triangles[Triangulation::next_edge(e)];
265        let pa = positions[a];
266        let pb = positions[b];
267        let theta = (pb.y - pa.y).atan2(pb.x - pa.x);
268        let at_a = grid_match_at_corner(theta, &axes[a], params);
269        let at_b = grid_match_at_corner(theta, &axes[b], params);
270        if let (Some(a_match), Some(b_match)) = (at_a, at_b) {
271            grid_matches[e] = Some(GridEdgeMatch {
272                start_slot: a_match.slot,
273                end_slot: b_match.slot,
274            });
275            *kind = EdgeKind::Grid;
276        }
277    }
278    promote_triangle_diagonals_from_grid_edges(triangulation, &grid_matches, &mut kinds);
279    kinds
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::f32::consts::FRAC_PI_4;
286
287    fn axes(angle0: f32, angle1: f32) -> [AxisEstimate; 2] {
288        [
289            AxisEstimate {
290                angle: angle0,
291                sigma: 0.05,
292            },
293            AxisEstimate {
294                angle: angle1,
295                sigma: 0.05,
296            },
297        ]
298    }
299
300    #[test]
301    fn axis_diff_is_symmetric_modulo_pi() {
302        assert!((axis_diff(0.0, PI) - 0.0).abs() < 1e-6);
303        assert!((axis_diff(0.1, 0.0) - 0.1).abs() < 1e-6);
304        assert!((axis_diff(PI - 0.1, 0.0) - 0.1).abs() < 1e-6);
305        assert!((axis_diff(FRAC_PI_4, 0.0) - FRAC_PI_4).abs() < 1e-6);
306    }
307
308    #[test]
309    fn axis_aligned_edge_is_grid() {
310        let p = TopologicalParams::default();
311        let a = axes(0.0, FRAC_PI_2);
312        // Edge angle = 0 → aligned with first axis at (almost) zero distance.
313        let horizontal = grid_match_at_corner(0.0, &a, &p).unwrap();
314        assert_eq!(horizontal.slot, 0);
315        assert!(horizontal.distance_rad < 1e-6);
316        // Edge angle = π/2 → aligned with second axis.
317        let vertical = grid_match_at_corner(FRAC_PI_2, &a, &p).unwrap();
318        assert_eq!(vertical.slot, 1);
319        assert!(vertical.distance_rad < 1e-6);
320    }
321
322    #[test]
323    fn diagonal_angle_edge_is_not_a_grid_match() {
324        let p = TopologicalParams::default();
325        let a = axes(0.0, FRAC_PI_2);
326        // An edge at 45° to both axes is outside the grid-angle gate.
327        assert!(grid_match_at_corner(FRAC_PI_4, &a, &p).is_none());
328        assert!((grid_distance_at_corner(FRAC_PI_4, &a, &p) - FRAC_PI_4).abs() < 1e-6);
329    }
330
331    #[test]
332    fn unaligned_edge_is_spurious() {
333        let p = TopologicalParams::default();
334        let a = axes(0.0, FRAC_PI_2);
335        // 22° from horizontal axis: outside the 15° grid tolerance.
336        assert!(grid_match_at_corner(22.0_f32.to_radians(), &a, &p).is_none());
337    }
338}