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