Skip to main content

locus_core/
gradient.rs

1//! Gradient computation for edge-based quad detection.
2
3#![allow(clippy::cast_sign_loss)]
4#![allow(clippy::cast_possible_wrap)]
5#![allow(clippy::missing_panics_doc)]
6
7use crate::image::ImageView;
8use bumpalo::Bump;
9use bumpalo::collections::Vec as BumpVec;
10
11/// Gradient data for a single pixel.
12#[derive(Clone, Copy, Default)]
13pub struct Gradient {
14    /// Gradient in x-direction.
15    pub gx: i16,
16    /// Gradient in y-direction.
17    pub gy: i16,
18    /// Gradient magnitude.
19    pub mag: u16,
20}
21
22use multiversion::multiversion;
23
24/// Compute Sobel gradients for the entire image.
25/// Returns a flat array of Gradient structs.
26#[must_use]
27#[multiversion(targets(
28    "x86_64+avx2+bmi1+bmi2+popcnt+lzcnt",
29    "x86_64+avx512f+avx512bw+avx512dq+avx512vl",
30    "aarch64+neon"
31))]
32pub fn compute_sobel(img: &ImageView) -> Vec<Gradient> {
33    let w = img.width;
34    let h = img.height;
35    let mut grads = vec![Gradient::default(); w * h];
36
37    // Sobel kernels:
38    // Gx: [-1 0 1; -2 0 2; -1 0 1]
39    // Gy: [-1 -2 -1; 0 0 0; 1 2 1]
40
41    for y in 1..h - 1 {
42        for x in 1..w - 1 {
43            let p00 = i16::from(img.get_pixel(x - 1, y - 1));
44            let p10 = i16::from(img.get_pixel(x, y - 1));
45            let p20 = i16::from(img.get_pixel(x + 1, y - 1));
46            let p01 = i16::from(img.get_pixel(x - 1, y));
47            let p21 = i16::from(img.get_pixel(x + 1, y));
48            let p02 = i16::from(img.get_pixel(x - 1, y + 1));
49            let p12 = i16::from(img.get_pixel(x, y + 1));
50            let p22 = i16::from(img.get_pixel(x + 1, y + 1));
51
52            let gx = -p00 + p20 - 2 * p01 + 2 * p21 - p02 + p22;
53            let gy = -p00 - 2 * p10 - p20 + p02 + 2 * p12 + p22;
54
55            let mag = ((gx.abs() + gy.abs()) as u16).min(1000);
56
57            grads[y * w + x] = Gradient { gx, gy, mag };
58        }
59    }
60
61    grads
62}
63
64/// A detected line segment.
65#[derive(Clone, Copy, Debug)]
66pub struct LineSegment {
67    /// Start x coordinate.
68    pub x0: f32,
69    /// Start y coordinate.
70    pub y0: f32,
71    /// End x coordinate.
72    pub x1: f32,
73    /// End y coordinate.
74    pub y1: f32,
75    /// Angle of the gradient.
76    pub angle: f32,
77}
78
79/// Extract line segments from gradient image using a simplified LSD approach.
80/// This is a greedy region-growing algorithm on gradient direction.
81#[must_use]
82pub fn extract_line_segments(
83    grads: &[Gradient],
84    width: usize,
85    height: usize,
86    mag_thresh: u16,
87) -> Vec<LineSegment> {
88    let mut used = vec![false; width * height];
89    let mut segments = Vec::new();
90
91    for y in 2..height - 2 {
92        for x in 2..width - 2 {
93            let idx = y * width + x;
94            if used[idx] || grads[idx].mag < mag_thresh {
95                continue;
96            }
97
98            // Seed point found - grow a line segment
99            let seed_angle = f32::from(grads[idx].gy).atan2(f32::from(grads[idx].gx));
100
101            let mut points: Vec<(usize, usize)> = vec![(x, y)];
102            used[idx] = true;
103
104            // Simple 8-connected region growing with angle constraint
105            let mut changed = true;
106            while changed && points.len() < 500 {
107                changed = false;
108                let (lx, ly) = *points
109                    .last()
110                    .expect("Points list should not be empty during refinement");
111
112                for dy in -1i32..=1 {
113                    for dx in -1i32..=1 {
114                        if dx == 0 && dy == 0 {
115                            continue;
116                        }
117                        let nx = (lx as i32 + dx) as usize;
118                        let ny = (ly as i32 + dy) as usize;
119                        if nx >= width || ny >= height {
120                            continue;
121                        }
122
123                        let nidx = ny * width + nx;
124                        if used[nidx] || grads[nidx].mag < mag_thresh {
125                            continue;
126                        }
127
128                        let angle = f32::from(grads[nidx].gy).atan2(f32::from(grads[nidx].gx));
129                        let angle_diff = (angle - seed_angle).abs();
130                        let angle_diff = angle_diff.min(std::f32::consts::PI - angle_diff);
131
132                        if angle_diff < 0.3 {
133                            // ~17 degrees tolerance
134                            points.push((nx, ny));
135                            used[nidx] = true;
136                            changed = true;
137                            break;
138                        }
139                    }
140                    if changed {
141                        break;
142                    }
143                }
144            }
145
146            if points.len() >= 10 {
147                // Fit line to points (simple endpoints for now)
148                let (x0, y0) = points[0];
149                let (x1, y1) = points[points.len() - 1];
150
151                segments.push(LineSegment {
152                    x0: x0 as f32,
153                    y0: y0 as f32,
154                    x1: x1 as f32,
155                    y1: y1 as f32,
156                    angle: seed_angle,
157                });
158            }
159        }
160    }
161
162    segments
163}
164
165/// Find quads by grouping 4 line segments that form a closed quadrilateral.
166#[must_use]
167pub fn find_first_quad_from_segments(segments: &[LineSegment]) -> Option<[[f32; 2]; 4]> {
168    if segments.len() < 4 {
169        return None;
170    }
171
172    // For each segment, find 3 others that could form a quad
173    // This is O(n^4) but n is very small (usually 4)
174    for i in 0..segments.len() {
175        for j in i + 1..segments.len() {
176            for k in j + 1..segments.len() {
177                for l in k + 1..segments.len() {
178                    if let Some(corners) =
179                        try_form_quad(&segments[i], &segments[j], &segments[k], &segments[l])
180                    {
181                        return Some(corners);
182                    }
183                }
184            }
185        }
186    }
187
188    None
189}
190
191fn try_form_quad(
192    s0: &LineSegment,
193    s1: &LineSegment,
194    s2: &LineSegment,
195    s3: &LineSegment,
196) -> Option<[[f32; 2]; 4]> {
197    // Cluster segments into two groups by their gradient direction (roughly parallel pairs)
198    let mut segs = [*s0, *s1, *s2, *s3];
199    segs.sort_by(|a, b| a.angle.total_cmp(&b.angle));
200
201    let mut group1 = [None; 2];
202    let mut group2 = [None; 2];
203    let mut g1_idx = 0;
204    let mut g2_idx = 0;
205
206    group1[0] = Some(segs[0]);
207    g1_idx += 1;
208
209    for i in 1..4 {
210        let diff = angle_diff(segs[0].angle, segs[i].angle);
211        if diff < 0.6 {
212            if g1_idx < 2 {
213                group1[g1_idx] = Some(segs[i]);
214                g1_idx += 1;
215            } else {
216                return None;
217            }
218        } else if g2_idx < 2 {
219            group2[g2_idx] = Some(segs[i]);
220            g2_idx += 1;
221        } else {
222            return None;
223        }
224    }
225
226    if g1_idx != 2 || g2_idx != 2 {
227        return None;
228    }
229
230    let g1 = [
231        group1[0].expect("g1[0] exists"),
232        group1[1].expect("g1[1] exists"),
233    ];
234    let g2 = [
235        group2[0].expect("g2[0] exists"),
236        group2[1].expect("g2[1] exists"),
237    ];
238
239    // Ensure the two groups are roughly perpendicular
240    let angle_between_groups = angle_diff(g1[0].angle, g2[0].angle);
241    if (angle_between_groups - std::f32::consts::FRAC_PI_2).abs() > 0.6 {
242        return None;
243    }
244
245    // Compute intersections
246    let c0 = line_intersection(&g1[0], &g2[0])?;
247    let c1 = line_intersection(&g1[0], &g2[1])?;
248    let c2 = line_intersection(&g1[1], &g2[1])?;
249    let c3 = line_intersection(&g1[1], &g2[0])?;
250
251    // Validate quad: check area and convexity
252    // In image coordinates (Y-down), positive shoelace sum means Clockwise.
253    let area = quad_area(&[c0, c1, c2, c3]);
254
255    // Check magnitude first to avoid branching
256    if area.abs() < 16.0 || area.abs() > 1_000_000.0 {
257        return None;
258    }
259
260    if area > 0.0 {
261        Some([c0, c1, c2, c3]) // CW
262    } else {
263        Some([c0, c3, c2, c1]) // Flip CCW to CW
264    }
265}
266
267fn line_intersection(s1: &LineSegment, s2: &LineSegment) -> Option<[f32; 2]> {
268    let dx1 = s1.x1 - s1.x0;
269    let dy1 = s1.y1 - s1.y0;
270    let dx2 = s2.x1 - s2.x0;
271    let dy2 = s2.y1 - s2.y0;
272
273    let denom = dx1 * dy2 - dy1 * dx2;
274    if denom.abs() < 1e-6 {
275        return None; // Parallel
276    }
277
278    let t = ((s2.x0 - s1.x0) * dy2 - (s2.y0 - s1.y0) * dx2) / denom;
279    let p = [s1.x0 + t * dx1, s1.y0 + t * dy1];
280
281    // Sanity check: intersection must be near one of the segments
282    // (e.g. within 100 pixels of s1.x0, s1.y0)
283    let dist_sq = (p[0] - s1.x0).powi(2) + (p[1] - s1.y0).powi(2);
284    if dist_sq > 1000.0 * 1000.0 {
285        return None;
286    }
287
288    Some(p)
289}
290
291fn quad_area(corners: &[[f32; 2]; 4]) -> f32 {
292    let mut area = 0.0;
293    for i in 0..4 {
294        let j = (i + 1) % 4;
295        area += corners[i][0] * corners[j][1];
296        area -= corners[j][0] * corners[i][1];
297    }
298    area * 0.5
299}
300
301/// Fit a quad from a small component using on-demand gradient computation.
302///
303/// This is the optimized version that computes Sobel gradients only within
304/// the component's bounding box, avoiding full-image gradient computation.
305///
306/// # Arguments
307/// * `img` - The grayscale image
308/// * `labels` - Component labels from CCL
309/// * `label` - The specific component label to fit
310/// * `min_x`, `min_y`, `max_x`, `max_y` - Bounding box of the component
311///
312/// # Returns
313/// Quad corners if successfully fitted, None otherwise
314#[allow(clippy::cast_possible_wrap)]
315#[allow(clippy::cast_sign_loss)]
316#[allow(clippy::similar_names)]
317#[allow(clippy::too_many_arguments)]
318#[must_use]
319struct ComponentBounds {
320    min_x: usize,
321    min_y: usize,
322    max_x: usize,
323    max_y: usize,
324}
325
326struct QuadFitter<'a> {
327    arena: &'a Bump,
328    img: &'a ImageView<'a>,
329    labels: &'a [u32],
330    label: u32,
331    bounds: ComponentBounds,
332}
333
334impl<'a> QuadFitter<'a> {
335    #[allow(clippy::too_many_arguments)]
336    fn new(
337        arena: &'a Bump,
338        img: &'a ImageView<'a>,
339        labels: &'a [u32],
340        label: u32,
341        min_x: usize,
342        min_y: usize,
343        max_x: usize,
344        max_y: usize,
345    ) -> Self {
346        Self {
347            arena,
348            img,
349            labels,
350            label,
351            bounds: ComponentBounds {
352                min_x,
353                min_y,
354                max_x,
355                max_y,
356            },
357        }
358    }
359
360    #[allow(
361        clippy::cast_possible_wrap,
362        clippy::cast_sign_loss,
363        clippy::similar_names
364    )]
365    #[allow(unsafe_code)]
366    fn collect_boundary_points(&self) -> BumpVec<'a, (usize, usize, f32, f32)> {
367        let width = self.img.width;
368        let height = self.img.height;
369        let x0 = self.bounds.min_x.saturating_sub(1);
370        let y0 = self.bounds.min_y.saturating_sub(1);
371        let x1 = (self.bounds.max_x + 2).min(width);
372        let y1 = (self.bounds.max_y + 2).min(height);
373
374        let mut points = BumpVec::new_in(self.arena);
375
376        for y in y0.max(1)..y1.min(height - 1) {
377            for x in x0.max(1)..x1.min(width - 1) {
378                let idx = y * width + x;
379                if self.labels[idx] != self.label {
380                    continue;
381                }
382
383                if self.labels[idx - 1] == self.label
384                    && self.labels[idx + 1] == self.label
385                    && self.labels[idx - width] == self.label
386                    && self.labels[idx + width] == self.label
387                {
388                    continue;
389                }
390
391                // Inline Sobel
392                // SAFETY: bounds checked by loops x, y range 1..width-1 etc
393                unsafe {
394                    let p00 = i16::from(self.img.get_pixel_unchecked(x - 1, y - 1));
395                    let p10 = i16::from(self.img.get_pixel_unchecked(x, y - 1));
396                    let p20 = i16::from(self.img.get_pixel_unchecked(x + 1, y - 1));
397                    let p01 = i16::from(self.img.get_pixel_unchecked(x - 1, y));
398                    let p21 = i16::from(self.img.get_pixel_unchecked(x + 1, y));
399                    let p02 = i16::from(self.img.get_pixel_unchecked(x - 1, y + 1));
400                    let p12 = i16::from(self.img.get_pixel_unchecked(x, y + 1));
401                    let p22 = i16::from(self.img.get_pixel_unchecked(x + 1, y + 1));
402
403                    let gx = -p00 + p20 - 2 * p01 + 2 * p21 - p02 + p22;
404                    let gy = -p00 - 2 * p10 - p20 + p02 + 2 * p12 + p22;
405                    let mag = (gx.abs() + gy.abs()) as u16;
406
407                    if mag > 10 {
408                        let angle = f32::from(gy).atan2(f32::from(gx));
409                        points.push((x, y, angle, f32::from(mag)));
410                    }
411                }
412            }
413        }
414        points
415    }
416
417    fn fit(&self) -> Option<[[f32; 2]; 4]> {
418        let boundary_points = self.collect_boundary_points();
419        if boundary_points.len() < 8 {
420            return None;
421        }
422
423        let mut centroids = Self::compute_initial_centroids(&boundary_points);
424        let assignments = self.kmeans_cluster(&boundary_points, &mut centroids);
425        let lines = self.fit_lines(&boundary_points, &assignments);
426
427        if lines.len() < 4 {
428            return None;
429        }
430
431        find_first_quad_from_segments(&lines)
432    }
433
434    #[allow(clippy::similar_names)]
435    fn compute_initial_centroids(boundary_points: &[(usize, usize, f32, f32)]) -> [f32; 4] {
436        let mut weight_sum = 0.0f32;
437        let mut gx_sum = 0.0f32;
438        let mut gy_sum = 0.0f32;
439        let mut gxx_sum = 0.0f32;
440        let mut gyy_sum = 0.0f32;
441        let mut gxy_sum = 0.0f32;
442
443        for &(_x, _y, angle, mag) in boundary_points {
444            let w = mag;
445            let gx = angle.cos();
446            let gy = angle.sin();
447            weight_sum += w;
448            gx_sum += gx * w;
449            gy_sum += gy * w;
450            gxx_sum += gx * gx * w;
451            gyy_sum += gy * gy * w;
452            gxy_sum += gx * gy * w;
453        }
454
455        let mean_gx = gx_sum / weight_sum;
456        let mean_gy = gy_sum / weight_sum;
457        let cov_xx = gxx_sum / weight_sum - mean_gx * mean_gx;
458        let cov_yy = gyy_sum / weight_sum - mean_gy * mean_gy;
459        let cov_xy = gxy_sum / weight_sum - mean_gx * mean_gy;
460
461        let trace = cov_xx + cov_yy;
462        let det = cov_xx * cov_yy - cov_xy * cov_xy;
463        let discriminant = (trace * trace / 4.0 - det).max(0.0);
464        let lambda1 = trace / 2.0 + discriminant.sqrt();
465
466        let theta = if cov_xy.abs() > 1e-6 {
467            (lambda1 - cov_xx).atan2(cov_xy)
468        } else if cov_xx >= cov_yy {
469            0.0
470        } else {
471            std::f32::consts::FRAC_PI_2
472        };
473
474        let mut centroids = [
475            theta,
476            theta + std::f32::consts::FRAC_PI_2,
477            theta + std::f32::consts::PI,
478            theta - std::f32::consts::FRAC_PI_2,
479        ];
480        // Normalize centroids
481        for c in &mut centroids {
482            while *c > std::f32::consts::PI {
483                *c -= 2.0 * std::f32::consts::PI;
484            }
485            while *c < -std::f32::consts::PI {
486                *c += 2.0 * std::f32::consts::PI;
487            }
488        }
489        centroids
490    }
491
492    fn kmeans_cluster(
493        &self,
494        boundary_points: &[(usize, usize, f32, f32)],
495        centroids: &mut [f32; 4],
496    ) -> BumpVec<'a, usize> {
497        let mut assignments = BumpVec::with_capacity_in(boundary_points.len(), self.arena);
498        assignments.resize(boundary_points.len(), 0);
499
500        for _ in 0..5 {
501            // Assignment
502            for (i, &(_x, _y, angle, _mag)) in boundary_points.iter().enumerate() {
503                let mut best_cluster = 0;
504                let mut best_dist = f32::MAX;
505                for (c, &centroid) in centroids.iter().enumerate() {
506                    let diff = angle_diff(angle, centroid);
507                    if diff < best_dist {
508                        best_dist = diff;
509                        best_cluster = c;
510                    }
511                }
512                assignments[i] = best_cluster;
513            }
514
515            // Update
516            for (c, centroid) in centroids.iter_mut().enumerate() {
517                let mut sum_sin = 0.0f32;
518                let mut sum_cos = 0.0f32;
519                for (i, &(_x, _y, angle, mag)) in boundary_points.iter().enumerate() {
520                    if assignments[i] == c {
521                        sum_sin += angle.sin() * mag;
522                        sum_cos += angle.cos() * mag;
523                    }
524                }
525                if sum_sin.abs() > 1e-6 || sum_cos.abs() > 1e-6 {
526                    *centroid = sum_sin.atan2(sum_cos);
527                }
528            }
529        }
530        assignments
531    }
532
533    #[allow(clippy::similar_names)]
534    fn fit_lines(
535        &self,
536        boundary_points: &[(usize, usize, f32, f32)],
537        assignments: &[usize],
538    ) -> BumpVec<'a, LineSegment> {
539        let mut lines = BumpVec::new_in(self.arena);
540        // Just used as index, range loop is cleaner than iterator here
541        for c in 0..4 {
542            let mut cluster_count = 0;
543            let mut sum_w = 0.0f32;
544            let mut sum_wx = 0.0f32;
545            let mut sum_wy = 0.0f32;
546
547            for (i, &(x, y, _, mag)) in boundary_points.iter().enumerate() {
548                if assignments[i] == c {
549                    cluster_count += 1;
550                    let w = mag * mag;
551                    sum_w += w;
552                    sum_wx += x as f32 * w;
553                    sum_wy += y as f32 * w;
554                }
555            }
556
557            if cluster_count < 3 {
558                continue;
559            }
560
561            let mean_x = sum_wx / sum_w;
562            let mean_y = sum_wy / sum_w;
563
564            let mut cov_xx = 0.0f32;
565            let mut cov_yy = 0.0f32;
566            let mut cov_xy = 0.0f32;
567            for (i, &(x, y, _, mag)) in boundary_points.iter().enumerate() {
568                if assignments[i] == c {
569                    let w = mag * mag;
570                    let dx = x as f32 - mean_x;
571                    let dy = y as f32 - mean_y;
572                    cov_xx += dx * dx * w;
573                    cov_yy += dy * dy * w;
574                    cov_xy += dx * dy * w;
575                }
576            }
577
578            let direction = 0.5 * (2.0 * cov_xy).atan2(cov_xx - cov_yy);
579            let nx = direction.cos();
580            let ny = direction.sin();
581
582            let mut min_t = f32::MAX;
583            let mut max_t = f32::MIN;
584            for (i, &(x, y, _, _)) in boundary_points.iter().enumerate() {
585                if assignments[i] == c {
586                    let t = (x as f32 - mean_x) * nx + (y as f32 - mean_y) * ny;
587                    min_t = min_t.min(t);
588                    max_t = max_t.max(t);
589                }
590            }
591
592            let mut grad_angle = direction + std::f32::consts::FRAC_PI_2;
593            if grad_angle > std::f32::consts::PI {
594                grad_angle -= 2.0 * std::f32::consts::PI;
595            }
596
597            lines.push(LineSegment {
598                x0: mean_x + nx * min_t,
599                y0: mean_y + ny * min_t,
600                x1: mean_x + nx * max_t,
601                y1: mean_y + ny * max_t,
602                angle: grad_angle,
603            });
604        }
605        lines
606    }
607}
608
609/// Fit a quad from a small component using on-demand gradient computation.
610#[allow(clippy::too_many_arguments)]
611#[must_use]
612pub fn fit_quad_from_component(
613    arena: &Bump,
614    img: &ImageView,
615    labels: &[u32],
616    label: u32,
617    min_x: usize,
618    min_y: usize,
619    max_x: usize,
620    max_y: usize,
621) -> Option<[[f32; 2]; 4]> {
622    QuadFitter::new(arena, img, labels, label, min_x, min_y, max_x, max_y).fit()
623}
624
625/// Core solver that clusters boundary points into 4 groups and computes the quad.
626///
627/// 1. K-means clustering of gradient angles -> 4 groups (Right, Down, Left, Up)
628/// 2. Line fitting for each cluster
629/// 3. Intersection of lines to form CW quad [TL, TR, BR, BL]
630#[allow(clippy::needless_range_loop)]
631fn solve_quad_from_boundary_points(
632    boundary_points: &[(f32, f32, f32)], // x, y, angle
633    _img_width: usize, // Unused for now but kept for context if needed for boundary checks
634    min_pixels: usize,
635) -> Option<[[f32; 2]; 4]> {
636    // Cluster into 4 directions using simple k-means on angles
637    // Initialize with 4 orthogonal directions
638    let mut centroids = [
639        0.0f32,
640        std::f32::consts::FRAC_PI_2,
641        std::f32::consts::PI,
642        -std::f32::consts::FRAC_PI_2,
643    ];
644    let mut assignments = vec![0usize; boundary_points.len()];
645
646    for _ in 0..5 {
647        // 5 iterations of k-means
648        // Assignment step
649        for (i, (_x, _y, angle)) in boundary_points.iter().enumerate() {
650            let mut best_cluster = 0;
651            let mut best_dist = f32::MAX;
652            for (c, &centroid) in centroids.iter().enumerate() {
653                let diff = angle_diff(*angle, centroid);
654                if diff < best_dist {
655                    best_dist = diff;
656                    best_cluster = c;
657                }
658            }
659            assignments[i] = best_cluster;
660        }
661
662        // Update step
663        for c in 0..4 {
664            let mut sum_sin = 0.0f32;
665            let mut sum_cos = 0.0f32;
666            for (i, (_x, _y, angle)) in boundary_points.iter().enumerate() {
667                if assignments[i] == c {
668                    sum_sin += angle.sin();
669                    sum_cos += angle.cos();
670                }
671            }
672            if sum_sin.abs() > 1e-6 || sum_cos.abs() > 1e-6 {
673                centroids[c] = sum_sin.atan2(sum_cos);
674            }
675        }
676    }
677
678    // Fit line to each cluster
679    let mut lines = [LineSegment {
680        x0: 0.0,
681        y0: 0.0,
682        x1: 0.0,
683        y1: 0.0,
684        angle: 0.0,
685    }; 4];
686
687    // Check if all clusters have enough points
688    for c in 0..4 {
689        // We reuse the iterator logic but need to be careful with allocations.
690        // For performance, we can do a single pass to compute stats for all clusters.
691        let mut sum_x = 0.0f32;
692        let mut sum_y = 0.0f32;
693        let mut count = 0usize;
694
695        // This is slightly inefficient iterating 4 times, but N is small (<100 points usually).
696        // Optimized: Single pass over points would be better if we had arrays for sums.
697        for (i, (x, y, _)) in boundary_points.iter().enumerate() {
698            if assignments[i] == c {
699                sum_x += *x;
700                sum_y += *y;
701                count += 1;
702            }
703        }
704
705        if count < min_pixels {
706            return None;
707        }
708
709        let mean_x = sum_x / count as f32;
710        let mean_y = sum_y / count as f32;
711
712        // Compute dominant direction (PCA or simple angle average)
713        // Here we just use the centroid angle as the line normal/direction
714        let angle = centroids[c];
715        let nx = angle.cos();
716        let ny = angle.sin();
717
718        // Form line segment roughly through the mean with correct orientation
719        // We need an arbitrary extent for intersection
720        lines[c] = LineSegment {
721            x0: mean_x - nx * 100.0,
722            y0: mean_y - ny * 100.0,
723            x1: mean_x + nx * 100.0,
724            y1: mean_y + ny * 100.0,
725            angle,
726        };
727    }
728
729    // Direct intersection of ordered clusters:
730    // 0: Right (0), 1: Down (PI/2), 2: Left (PI), 3: Up (-PI/2)
731    // Corners for Clockwise (CW) winding in Y-Down image coordinates:
732    // Intersections for CW [TL, TR, BR, BL]:
733    // TL = Intersection of Left (2) and Top (3).
734    // TR = Intersection of Top (3) and Right (0).
735    // BR = Intersection of Right (0) and Bottom (1).
736    // BL = Intersection of Bottom (1) and Left (2).
737
738    let tl = line_intersection(&lines[2], &lines[3])?;
739    let tr = line_intersection(&lines[3], &lines[0])?;
740    let br = line_intersection(&lines[0], &lines[1])?;
741    let bl = line_intersection(&lines[1], &lines[2])?;
742
743    let corners = [tl, tr, br, bl];
744
745    // Verify convex and strictly Positive area (CW)
746    let area = quad_area(&corners);
747    if !(16.0..=1_000_000.0).contains(&area) {
748        return None;
749    }
750
751    Some(corners)
752}
753
754/// Fit a quad from boundary pixels using gradient direction clustering.
755#[allow(clippy::cast_possible_wrap)]
756#[allow(clippy::cast_sign_loss)]
757#[allow(clippy::similar_names)]
758#[must_use]
759pub fn fit_quad_from_gradients(
760    grads: &[Gradient],
761    labels: &[u32],
762    label: u32,
763    width: usize,
764    height: usize,
765    min_edge_pixels: usize,
766) -> Option<[[f32; 2]; 4]> {
767    // Collect boundary pixels
768    let mut boundary_points: Vec<(f32, f32, f32)> = Vec::with_capacity(min_edge_pixels * 4); // (x, y, angle)
769
770    for y in 1..height - 1 {
771        for x in 1..width - 1 {
772            let idx = y * width + x;
773            if labels[idx] != label {
774                continue;
775            }
776
777            // Check if boundary (any neighbor is different)
778            let is_boundary = labels[idx - 1] != label
779                || labels[idx + 1] != label
780                || labels[idx - width] != label
781                || labels[idx + width] != label;
782
783            if is_boundary && grads[idx].mag > 20 {
784                let angle = f32::from(grads[idx].gy).atan2(f32::from(grads[idx].gx));
785                boundary_points.push((x as f32, y as f32, angle));
786            }
787        }
788    }
789
790    if boundary_points.len() < min_edge_pixels * 4 {
791        return None; // Not enough boundary points
792    }
793
794    solve_quad_from_boundary_points(&boundary_points, width, min_edge_pixels)
795}
796
797/// Compute angle difference in range [0, π/2] (perpendicular equivalence)
798fn angle_diff(a: f32, b: f32) -> f32 {
799    let diff = (a - b).abs();
800    let diff = diff % std::f32::consts::PI;
801    diff.min(std::f32::consts::PI - diff)
802}
803
804#[cfg(test)]
805#[allow(clippy::unwrap_used, clippy::float_cmp)]
806mod tests {
807    use super::*;
808    use crate::image::ImageView;
809    use proptest::prelude::*;
810
811    proptest! {
812        #[test]
813        fn prop_sobel_magnitude_bounds(
814            width in 3..64usize,
815            height in 3..64usize,
816            data in prop::collection::vec(0..=255u8, 64*64)
817        ) {
818            let slice = &data[..width * height];
819            let view = ImageView::new(slice, width, height, width).unwrap();
820            let grads = compute_sobel(&view);
821
822            for g in grads {
823                assert!(g.mag <= 1000);
824            }
825        }
826
827        #[test]
828        fn prop_sobel_orientation_ramp(
829            width in 3..10usize,
830            height in 3..10usize,
831            is_horizontal in prop::bool::ANY
832        ) {
833            let mut data = vec![0u8; width * height];
834            for y in 0..height {
835                for x in 0..width {
836                    data[y * width + x] = if is_horizontal { x as u8 * 10 } else { y as u8 * 10 };
837                }
838            }
839
840            let view = ImageView::new(&data, width, height, width).unwrap();
841            let grads = compute_sobel(&view);
842
843            // Checking interior pixels
844            for y in 1..height-1 {
845                for x in 1..width-1 {
846                    let g = grads[y * width + x];
847                    if is_horizontal {
848                        assert!(g.gx > 0);
849                        assert_eq!(g.gy, 0);
850                    } else {
851                        assert_eq!(g.gx, 0);
852                        assert!(g.gy > 0);
853                    }
854                }
855            }
856        }
857
858        #[test]
859        fn prop_quad_area_invariants(
860            c in prop::collection::vec((0.0..100.0f32, 0.0..100.0f32), 4)
861        ) {
862            let corners = [
863                [c[0].0, c[0].1],
864                [c[1].0, c[1].1],
865                [c[2].0, c[2].1],
866                [c[3].0, c[3].1],
867            ];
868            let area = quad_area(&corners);
869
870            // Shoelace formula returns signed area.
871            // Asserting area >= 0.0 is incorrect for random points (random winding).
872            // Instead, verify that reversing the order negates the area.
873            let mut corners_rev = corners;
874            corners_rev.reverse();
875            let area_rev = quad_area(&corners_rev);
876
877            assert!((area + area_rev).abs() < 0.01);
878
879            // Area should be zero if points are identical
880            let identical_corners = [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]];
881            assert_eq!(quad_area(&identical_corners), 0.0);
882        }
883    }
884}