1#![allow(clippy::cast_possible_wrap)]
10#![allow(clippy::cast_sign_loss)]
11#![allow(clippy::similar_names)]
12#![allow(unsafe_code)]
13
14use crate::Detection;
15use crate::config::DetectorConfig;
16use crate::image::ImageView;
17use crate::segmentation::LabelResult;
18use bumpalo::Bump;
19use bumpalo::collections::Vec as BumpVec;
20use multiversion::multiversion;
21
22pub(crate) fn erf_approx(x: f64) -> f64 {
24 let sign = if x < 0.0 { -1.0 } else { 1.0 };
25 let x = x.abs();
26
27 let a1 = 0.254_829_592;
29 let a2 = -0.284_496_736;
30 let a3 = 1.421_413_741;
31 let a4 = -1.453_152_027;
32 let a5 = 1.061_405_429;
33 let p = 0.327_591_1;
34
35 let t = 1.0 / (1.0 + p * x);
36 let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
37
38 sign * y
39}
40
41#[derive(Clone, Copy, Debug)]
43pub struct Point {
44 pub x: f64,
46 pub y: f64,
48}
49
50pub fn extract_quads_fast(
54 arena: &Bump,
55 img: &ImageView,
56 label_result: &LabelResult,
57) -> Vec<Detection> {
58 extract_quads_with_config(arena, img, label_result, &DetectorConfig::default(), 1, img)
59}
60
61#[allow(clippy::too_many_lines)]
66pub fn extract_quads_with_config(
67 _arena: &Bump,
68 img: &ImageView,
69 label_result: &LabelResult,
70 config: &DetectorConfig,
71 decimation: usize,
72 refinement_img: &ImageView,
73) -> Vec<Detection> {
74 use rayon::prelude::*;
75
76 let labels = label_result.labels;
77 let stats = &label_result.component_stats;
78 let min_edge_len_sq = config.quad_min_edge_length * config.quad_min_edge_length;
79 let d = decimation as f64;
80
81 stats
83 .par_iter()
84 .enumerate()
85 .filter_map(|(label_idx, stat)| {
86 let arena = Bump::new();
88
89 let label = (label_idx + 1) as u32;
90
91 let bbox_w = u32::from(stat.max_x - stat.min_x) + 1;
93 let bbox_h = u32::from(stat.max_y - stat.min_y) + 1;
94 let bbox_area = bbox_w * bbox_h;
95
96 if bbox_area < config.quad_min_area
98 || bbox_area > (img.width * img.height * 9 / 10) as u32
99 {
100 return None;
106 }
107
108 let aspect = bbox_w.max(bbox_h) as f32 / bbox_w.min(bbox_h).max(1) as f32;
110 if aspect > config.quad_max_aspect_ratio {
111 return None;
112 }
113
114 let fill = stat.pixel_count as f32 / bbox_area as f32;
116 if fill < config.quad_min_fill_ratio || fill > config.quad_max_fill_ratio {
117 return None;
118 }
119
120 let sx = stat.first_pixel_x as usize;
122 let sy = stat.first_pixel_y as usize;
123
124 if bbox_area < 1200
126 && let Some(grad_corners_dec) = crate::gradient::fit_quad_from_component(
127 &arena,
128 img,
129 labels,
130 label,
131 stat.min_x as usize,
132 stat.min_y as usize,
133 stat.max_x as usize,
134 stat.max_y as usize,
135 )
136 {
137 let grad_corners = [
139 [
140 f64::from(grad_corners_dec[0][0]) * d,
141 f64::from(grad_corners_dec[0][1]) * d,
142 ],
143 [
144 f64::from(grad_corners_dec[1][0]) * d,
145 f64::from(grad_corners_dec[1][1]) * d,
146 ],
147 [
148 f64::from(grad_corners_dec[2][0]) * d,
149 f64::from(grad_corners_dec[2][1]) * d,
150 ],
151 [
152 f64::from(grad_corners_dec[3][0]) * d,
153 f64::from(grad_corners_dec[3][1]) * d,
154 ],
155 ];
156
157 let corners = [
158 refine_corner(
159 &arena,
160 refinement_img,
161 Point {
162 x: grad_corners[0][0],
163 y: grad_corners[0][1],
164 },
165 Point {
166 x: grad_corners[3][0],
167 y: grad_corners[3][1],
168 },
169 Point {
170 x: grad_corners[1][0],
171 y: grad_corners[1][1],
172 },
173 config.subpixel_refinement_sigma,
174 decimation,
175 ),
176 refine_corner(
177 &arena,
178 refinement_img,
179 Point {
180 x: grad_corners[1][0],
181 y: grad_corners[1][1],
182 },
183 Point {
184 x: grad_corners[0][0],
185 y: grad_corners[0][1],
186 },
187 Point {
188 x: grad_corners[2][0],
189 y: grad_corners[2][1],
190 },
191 config.subpixel_refinement_sigma,
192 decimation,
193 ),
194 refine_corner(
195 &arena,
196 refinement_img,
197 Point {
198 x: grad_corners[2][0],
199 y: grad_corners[2][1],
200 },
201 Point {
202 x: grad_corners[1][0],
203 y: grad_corners[1][1],
204 },
205 Point {
206 x: grad_corners[3][0],
207 y: grad_corners[3][1],
208 },
209 config.subpixel_refinement_sigma,
210 decimation,
211 ),
212 refine_corner(
213 &arena,
214 refinement_img,
215 Point {
216 x: grad_corners[3][0],
217 y: grad_corners[3][1],
218 },
219 Point {
220 x: grad_corners[2][0],
221 y: grad_corners[2][1],
222 },
223 Point {
224 x: grad_corners[0][0],
225 y: grad_corners[0][1],
226 },
227 config.subpixel_refinement_sigma,
228 decimation,
229 ),
230 ];
231
232 let edge_score = calculate_edge_score(refinement_img, corners);
233 if edge_score > config.quad_min_edge_score {
234 return Some(Detection {
235 id: label,
236 center: [
237 f64::midpoint(grad_corners[0][0], grad_corners[2][0]),
238 f64::midpoint(grad_corners[0][1], grad_corners[2][1]),
239 ],
240 corners: [
241 [corners[0].x, corners[0].y],
242 [corners[1].x, corners[1].y],
243 [corners[2].x, corners[2].y],
244 [corners[3].x, corners[3].y],
245 ],
246 hamming: 0,
247 decision_margin: f64::from(bbox_area),
248 bits: 0,
249 pose: None,
250 pose_covariance: None,
251 });
252 }
253 }
254
255 let contour = trace_boundary(&arena, labels, img.width, img.height, sx, sy, label);
256
257 if contour.len() >= 12 {
258 let simple_contour = chain_approximation(&arena, &contour);
259 let perimeter = contour.len() as f64;
260 let epsilon = (perimeter * 0.02).max(1.0);
261 let simplified = douglas_peucker(&arena, &simple_contour, epsilon);
262
263 if simplified.len() >= 4 && simplified.len() <= 11 {
264 let simpl_len = simplified.len();
265 let reduced = if simpl_len == 5 {
266 simplified
267 } else if simpl_len == 4 {
268 let mut closed = BumpVec::new_in(&arena);
269 for p in &simplified {
270 closed.push(*p);
271 }
272 closed.push(simplified[0]);
273 closed
274 } else {
275 reduce_to_quad(&arena, &simplified)
276 };
277
278 if reduced.len() == 5 {
279 let area = polygon_area(&reduced);
280 let compactness = (12.566 * area.abs()) / (perimeter * perimeter);
281
282 if area.abs() > f64::from(config.quad_min_area) && compactness > 0.1 {
283 let quad_pts_dec = if area > 0.0 {
285 [reduced[0], reduced[1], reduced[2], reduced[3]]
286 } else {
287 [reduced[0], reduced[3], reduced[2], reduced[1]]
288 };
289
290 let quad_pts = [
292 Point {
293 x: (quad_pts_dec[0].x - 0.5) * d + 0.5,
294 y: (quad_pts_dec[0].y - 0.5) * d + 0.5,
295 },
296 Point {
297 x: (quad_pts_dec[1].x - 0.5) * d + 0.5,
298 y: (quad_pts_dec[1].y - 0.5) * d + 0.5,
299 },
300 Point {
301 x: (quad_pts_dec[2].x - 0.5) * d + 0.5,
302 y: (quad_pts_dec[2].y - 0.5) * d + 0.5,
303 },
304 Point {
305 x: (quad_pts_dec[3].x - 0.5) * d + 0.5,
306 y: (quad_pts_dec[3].y - 0.5) * d + 0.5,
307 },
308 ];
309
310 let mut ok = true;
311 for i in 0..4 {
312 let d2 = (quad_pts[i].x - quad_pts[(i + 1) % 4].x).powi(2)
313 + (quad_pts[i].y - quad_pts[(i + 1) % 4].y).powi(2);
314 if d2 < min_edge_len_sq {
315 ok = false;
316 break;
317 }
318 }
319
320 if ok {
321 let corners = [
322 refine_corner(
323 &arena,
324 refinement_img,
325 quad_pts[0],
326 quad_pts[3],
327 quad_pts[1],
328 config.subpixel_refinement_sigma,
329 decimation,
330 ),
331 refine_corner(
332 &arena,
333 refinement_img,
334 quad_pts[1],
335 quad_pts[0],
336 quad_pts[2],
337 config.subpixel_refinement_sigma,
338 decimation,
339 ),
340 refine_corner(
341 &arena,
342 refinement_img,
343 quad_pts[2],
344 quad_pts[1],
345 quad_pts[3],
346 config.subpixel_refinement_sigma,
347 decimation,
348 ),
349 refine_corner(
350 &arena,
351 refinement_img,
352 quad_pts[3],
353 quad_pts[2],
354 quad_pts[0],
355 config.subpixel_refinement_sigma,
356 decimation,
357 ),
358 ];
359
360 let edge_score = calculate_edge_score(refinement_img, corners);
361 if edge_score > config.quad_min_edge_score {
362 return Some(Detection {
363 id: label,
364 center: [
365 (corners[0].x
366 + corners[1].x
367 + corners[2].x
368 + corners[3].x)
369 / 4.0,
370 (corners[0].y
371 + corners[1].y
372 + corners[2].y
373 + corners[3].y)
374 / 4.0,
375 ],
376 corners: [
377 [corners[0].x, corners[0].y],
378 [corners[1].x, corners[1].y],
379 [corners[2].x, corners[2].y],
380 [corners[3].x, corners[3].y],
381 ],
382 hamming: 0,
383 decision_margin: area * d * d, bits: 0,
385 pose: None,
386 pose_covariance: None,
387 });
388 }
389 }
390 }
391 }
392 }
393 }
394 None
395 })
396 .collect()
397}
398
399pub fn extract_quads(arena: &Bump, img: &ImageView, labels: &[u32]) -> Vec<Detection> {
401 let mut detections = Vec::new();
403 let num_labels = (labels.len() / 32) + 1;
404 let processed_labels = arena.alloc_slice_fill_copy(num_labels, 0u32);
405
406 let width = img.width;
407 let height = img.height;
408
409 for y in 1..height - 1 {
410 let row_off = y * width;
411 let prev_row_off = (y - 1) * width;
412
413 for x in 1..width - 1 {
414 let idx = row_off + x;
415 let label = labels[idx];
416
417 if label == 0 {
418 continue;
419 }
420
421 if labels[idx - 1] == label || labels[prev_row_off + x] == label {
422 continue;
423 }
424
425 let bit_idx = (label as usize) / 32;
426 let bit_mask = 1 << (label % 32);
427 if processed_labels[bit_idx] & bit_mask != 0 {
428 continue;
429 }
430
431 processed_labels[bit_idx] |= bit_mask;
432 let contour = trace_boundary(arena, labels, width, height, x, y, label);
433
434 if contour.len() >= 12 {
435 let simplified = douglas_peucker(arena, &contour, 4.0);
437 if simplified.len() == 5 {
438 let area = polygon_area(&simplified);
439 let perimeter = contour.len() as f64;
440 let compactness = (12.566 * area) / (perimeter * perimeter);
441
442 if area > 400.0 && compactness > 0.5 {
443 let mut ok = true;
444 for i in 0..4 {
445 let d2 = (simplified[i].x - simplified[i + 1].x).powi(2)
446 + (simplified[i].y - simplified[i + 1].y).powi(2);
447 if d2 < 100.0 {
448 ok = false;
449 break;
450 }
451 }
452
453 if ok {
454 detections.push(Detection {
455 id: label,
456 center: polygon_center(&simplified),
457 corners: [
458 [simplified[0].x, simplified[0].y],
459 [simplified[1].x, simplified[1].y],
460 [simplified[2].x, simplified[2].y],
461 [simplified[3].x, simplified[3].y],
462 ],
463 hamming: 0,
464 decision_margin: area,
465 bits: 0,
466 pose: None,
467 pose_covariance: None,
468 });
469 }
470 }
471 }
472 }
473 }
474 }
475 detections
476}
477
478pub fn douglas_peucker<'a>(arena: &'a Bump, points: &[Point], epsilon: f64) -> BumpVec<'a, Point> {
483 if points.len() < 3 {
484 let mut v = BumpVec::new_in(arena);
485 v.extend_from_slice(points);
486 return v;
487 }
488
489 let n = points.len();
490 let mut keep = BumpVec::from_iter_in((0..n).map(|_| false), arena);
491 keep[0] = true;
492 keep[n - 1] = true;
493
494 let mut stack = BumpVec::new_in(arena);
495 stack.push((0, n - 1));
496
497 while let Some((start, end)) = stack.pop() {
498 if end - start < 2 {
499 continue;
500 }
501
502 let mut dmax = 0.0;
503 let mut index = start;
504
505 for i in start + 1..end {
506 let d = perpendicular_distance(points[i], points[start], points[end]);
507 if d > dmax {
508 index = i;
509 dmax = d;
510 }
511 }
512
513 if dmax > epsilon {
514 keep[index] = true;
515 stack.push((start, index));
516 stack.push((index, end));
517 }
518 }
519
520 let mut simplified = BumpVec::new_in(arena);
521 for (i, &k) in keep.iter().enumerate() {
522 if k {
523 simplified.push(points[i]);
524 }
525 }
526 simplified
527}
528
529fn perpendicular_distance(p: Point, a: Point, b: Point) -> f64 {
530 let dx = b.x - a.x;
531 let dy = b.y - a.y;
532 let mag = (dx * dx + dy * dy).sqrt();
533 if mag < 1e-9 {
534 return ((p.x - a.x).powi(2) + (p.y - a.y).powi(2)).sqrt();
535 }
536 ((dy * p.x - dx * p.y + b.x * a.y - b.y * a.x).abs()) / mag
537}
538
539fn polygon_area(points: &[Point]) -> f64 {
540 let mut area = 0.0;
541 for i in 0..points.len() - 1 {
542 area += (points[i].x * points[i + 1].y) - (points[i + 1].x * points[i].y);
543 }
544 area * 0.5
545}
546
547fn polygon_center(points: &[Point]) -> [f64; 2] {
548 let mut cx = 0.0;
549 let mut cy = 0.0;
550 let n = points.len() - 1;
551 for p in points.iter().take(n) {
552 cx += p.x;
553 cy += p.y;
554 }
555 [cx / n as f64, cy / n as f64]
556}
557
558#[must_use]
564pub fn refine_corner(
565 arena: &Bump,
566 img: &ImageView,
567 p: Point,
568 p_prev: Point,
569 p_next: Point,
570 sigma: f64,
571 decimation: usize,
572) -> Point {
573 let line1 = refine_edge_intensity(arena, img, p_prev, p, sigma, decimation)
575 .or_else(|| fit_edge_line(img, p_prev, p, decimation));
576 let line2 = refine_edge_intensity(arena, img, p, p_next, sigma, decimation)
577 .or_else(|| fit_edge_line(img, p, p_next, decimation));
578
579 if let (Some(l1), Some(l2)) = (line1, line2) {
580 let det = l1.0 * l2.1 - l2.0 * l1.1;
582 if det.abs() > 1e-6 {
583 let x = (l1.1 * l2.2 - l2.1 * l1.2) / det;
584 let y = (l2.0 * l1.2 - l1.0 * l2.2) / det;
585
586 let dist_sq = (x - p.x).powi(2) + (y - p.y).powi(2);
588 let max_dist = if decimation > 1 {
589 (decimation as f64) + 2.0
590 } else {
591 2.0
592 };
593 if dist_sq < max_dist * max_dist {
594 return Point { x, y };
595 }
596 }
597 }
598
599 p
600}
601
602fn fit_edge_line(
604 img: &ImageView,
605 p1: Point,
606 p2: Point,
607 decimation: usize,
608) -> Option<(f64, f64, f64)> {
609 let dx = p2.x - p1.x;
610 let dy = p2.y - p1.y;
611 let len = (dx * dx + dy * dy).sqrt();
612 if len < 4.0 {
613 return None;
614 }
615
616 let nx = -dy / len;
617 let ny = dx / len;
618
619 let mut sum_d = 0.0;
620 let mut count = 0;
621
622 let n_samples = (len as usize).clamp(5, 15);
623 let r = if decimation > 1 {
625 (decimation as i32) + 1
626 } else {
627 3
628 };
629
630 for i in 1..=n_samples {
631 let t = i as f64 / (n_samples + 1) as f64;
632 let px = p1.x + dx * t;
633 let py = p1.y + dy * t;
634
635 let mut best_px = px;
636 let mut best_py = py;
637 let mut best_mag = 0.0;
638
639 for step in -r..=r {
640 let sx = px + nx * f64::from(step);
641 let sy = py + ny * f64::from(step);
642
643 let g = img.sample_gradient_bilinear(sx, sy);
644 let mag = g[0] * g[0] + g[1] * g[1];
645 if mag > best_mag {
646 best_mag = mag;
647 best_px = sx;
648 best_py = sy;
649 }
650 }
651
652 if best_mag > 10.0 {
653 let mut mags = [0.0f64; 3];
654 for (j, offset) in [-1.0, 0.0, 1.0].iter().enumerate() {
655 let sx = best_px + nx * offset;
656 let sy = best_py + ny * offset;
657 let g = img.sample_gradient_bilinear(sx, sy);
658 mags[j] = g[0] * g[0] + g[1] * g[1];
659 }
660
661 let num = mags[2] - mags[0];
662 let den = 2.0 * (mags[0] + mags[2] - 2.0 * mags[1]);
663 let sub_offset = if den.abs() > 1e-6 {
664 (-num / den).clamp(-0.5, 0.5)
665 } else {
666 0.0
667 };
668
669 let refined_x = best_px + nx * sub_offset;
670 let refined_y = best_py + ny * sub_offset;
671
672 sum_d += -(nx * refined_x + ny * refined_y);
673 count += 1;
674 }
675 }
676
677 if count < 3 {
678 return None;
679 }
680
681 Some((nx, ny, sum_d / f64::from(count)))
682}
683
684#[allow(clippy::similar_names)]
696fn refine_edge_intensity(
698 arena: &Bump,
699 img: &ImageView,
700 p1: Point,
701 p2: Point,
702 sigma: f64,
703 decimation: usize,
704) -> Option<(f64, f64, f64)> {
705 let dx = p2.x - p1.x;
706 let dy = p2.y - p1.y;
707 let len = (dx * dx + dy * dy).sqrt();
708 if len < 4.0 {
709 return None;
710 }
711
712 let nx = -dy / len;
714 let ny = dx / len;
715 let mid_x = f64::midpoint(p1.x, p2.x);
716 let mid_y = f64::midpoint(p1.y, p2.y);
717 let mut d = -(nx * mid_x + ny * mid_y);
718
719 let window = if decimation > 1 {
722 (decimation as f64) + 1.0
723 } else {
724 2.5
725 };
726 let x0 = (p1.x.min(p2.x) - window - 0.5).max(1.0) as usize;
727 let x1 = (p1.x.max(p2.x) + window + 0.5).min((img.width - 2) as f64) as usize;
728 let y0 = (p1.y.min(p2.y) - window - 0.5).max(1.0) as usize;
729 let y1 = (p1.y.max(p2.y) + window + 0.5).min((img.height - 2) as f64) as usize;
730
731 let mut samples = BumpVec::new_in(arena);
734
735 let stride = if len > 100.0 { 2 } else { 1 };
738
739 let mut py = y0;
740 while py <= y1 {
741 let mut px = x0;
742 while px <= x1 {
743 let x = px as f64;
744 let y = py as f64;
745
746 let t = ((x - p1.x) * dx + (y - p1.y) * dy) / (len * len);
748 if !(-0.1..=1.1).contains(&t) {
749 px += stride;
750 continue;
751 }
752
753 let dist_signed = nx * x + ny * y + d;
754 if dist_signed.abs() < window {
755 let intensity = f64::from(img.get_pixel(px, py));
756 let projection = nx * x + ny * y;
758 samples.push((x, y, intensity, projection));
759 }
760 px += stride;
761 }
762 py += stride;
763 }
764
765 if samples.len() < 10 {
766 return Some((nx, ny, d)); }
768
769 let mut dark_sum = 0.0;
772 let mut dark_weight = 0.0;
773 let mut light_sum = 0.0;
774 let mut light_weight = 0.0;
775
776 for &(_x, _y, intensity, projection) in &samples {
777 let signed_dist = projection + d;
778 if signed_dist < -1.0 {
779 let w = (-signed_dist - 1.0).min(2.0); dark_sum += intensity * w;
781 dark_weight += w;
782 } else if signed_dist > 1.0 {
783 let w = (signed_dist - 1.0).min(2.0);
784 light_sum += intensity * w;
785 light_weight += w;
786 }
787 }
788
789 if dark_weight < 1.0 || light_weight < 1.0 {
790 return Some((nx, ny, d));
791 }
792
793 let a = dark_sum / dark_weight;
794 let b = light_sum / light_weight;
795 let inv_sigma = 1.0 / sigma;
796
797 for _iter in 0..15 {
800 let mut jtj = 0.0;
801 let mut jtr = 0.0;
802
803 for &(_x, _y, intensity, projection) in &samples {
804 let signed_dist = (projection + d) * inv_sigma;
805 if signed_dist.abs() > 3.0 {
806 continue;
807 } let model = (a + b) * 0.5 + (b - a) * 0.5 * erf_approx(signed_dist);
810 let residual = intensity - model;
811
812 let exp_term = (-signed_dist * signed_dist).exp();
813 let jacobian = (b - a) * 0.5 * std::f64::consts::FRAC_2_SQRT_PI * exp_term * inv_sigma;
814
815 jtj += jacobian * jacobian;
816 jtr += jacobian * residual;
817 }
818
819 if jtj.abs() > 1e-10 {
820 let delta = jtr / jtj;
821 d += delta.clamp(-0.5, 0.5);
822 if delta.abs() < 0.0001 {
823 break;
824 }
825 } else {
826 break;
827 }
828 }
829
830 Some((nx, ny, d))
831}
832
833fn reduce_to_quad<'a>(arena: &'a Bump, poly: &[Point]) -> BumpVec<'a, Point> {
837 if poly.len() <= 5 {
838 return BumpVec::from_iter_in(poly.iter().copied(), arena);
839 }
840
841 let mut current = BumpVec::from_iter_in(poly.iter().copied(), arena);
843 current.pop();
845
846 while current.len() > 4 {
847 let n = current.len();
848 let mut min_area = f64::MAX;
849 let mut min_idx = 0;
850
851 for i in 0..n {
852 let p_prev = current[(i + n - 1) % n];
853 let p_curr = current[i];
854 let p_next = current[(i + 1) % n];
855
856 let area = (p_prev.x * (p_curr.y - p_next.y)
858 + p_curr.x * (p_next.y - p_prev.y)
859 + p_next.x * (p_prev.y - p_curr.y))
860 .abs()
861 * 0.5;
862
863 if area < min_area {
864 min_area = area;
865 min_idx = i;
866 }
867 }
868
869 current.remove(min_idx);
871 }
872
873 if !current.is_empty() {
875 let first = current[0];
876 current.push(first);
877 }
878
879 current
880}
881
882fn calculate_edge_score(img: &ImageView, corners: [Point; 4]) -> f64 {
887 let mut min_score = f64::MAX;
888
889 for i in 0..4 {
890 let p1 = corners[i];
891 let p2 = corners[(i + 1) % 4];
892
893 let dx = p2.x - p1.x;
894 let dy = p2.y - p1.y;
895 let len = (dx * dx + dy * dy).sqrt();
896
897 if len < 4.0 {
898 return 0.0;
900 }
901
902 let n_samples = (len as usize).clamp(3, 10); let mut edge_mag_sum = 0.0;
905
906 for k in 1..=n_samples {
907 let t = k as f64 / (n_samples + 1) as f64;
909 let x = p1.x + dx * t;
910 let y = p1.y + dy * t;
911
912 let g = img.sample_gradient_bilinear(x, y);
913 edge_mag_sum += (g[0] * g[0] + g[1] * g[1]).sqrt();
914 }
915
916 let avg_mag = edge_mag_sum / n_samples as f64;
917 if avg_mag < min_score {
918 min_score = avg_mag;
919 }
920 }
921
922 min_score
923}
924
925#[cfg(test)]
926#[allow(clippy::unwrap_used, clippy::float_cmp)]
927mod tests {
928 use super::*;
929 use bumpalo::Bump;
930 use proptest::prelude::*;
931
932 #[test]
933 fn test_edge_score_rejection() {
934 let width = 20;
936 let height = 20;
937 let stride = 20;
938 let mut data = vec![128u8; width * height];
939
940 for y in 6..14 {
945 for x in 6..14 {
946 data[y * width + x] = 138;
947 }
948 }
949
950 let img = ImageView::new(&data, width, height, stride).unwrap();
951
952 let corners = [
953 Point { x: 6.0, y: 6.0 },
954 Point { x: 14.0, y: 6.0 },
955 Point { x: 14.0, y: 14.0 },
956 Point { x: 6.0, y: 14.0 },
957 ];
958
959 let score = calculate_edge_score(&img, corners);
960 assert!(score < 10.0, "Score {score} should be < 10.0");
966
967 for y in 6..14 {
971 for x in 6..14 {
972 data[y * width + x] = 200;
973 }
974 }
975 for y in 0..height {
977 for x in 0..width {
978 if !(6..14).contains(&x) || !(6..14).contains(&y) {
979 data[y * width + x] = 50;
980 }
981 }
982 }
983 let img = ImageView::new(&data, width, height, stride).unwrap();
984 let score = calculate_edge_score(&img, corners);
985 assert!(score > 40.0, "Score {score} should be > 40.0");
986 }
987
988 proptest! {
989 #[test]
990 fn prop_douglas_peucker_invariants(
991 points in prop::collection::vec((0.0..1000.0, 0.0..1000.0), 3..100),
992 epsilon in 0.1..10.0f64
993 ) {
994 let arena = Bump::new();
995 let contour: Vec<Point> = points.iter().map(|&(x, y)| Point { x, y }).collect();
996 let simplified = douglas_peucker(&arena, &contour, epsilon);
997
998 for p in &simplified {
1000 assert!(contour.iter().any(|&op| (op.x - p.x).abs() < 1e-9 && (op.y - p.y).abs() < 1e-9));
1001 }
1002
1003 assert_eq!(simplified[0].x, contour[0].x);
1005 assert_eq!(simplified[0].y, contour[0].y);
1006 assert_eq!(simplified.last().unwrap().x, contour.last().unwrap().x);
1007 assert_eq!(simplified.last().unwrap().y, contour.last().unwrap().y);
1008
1009 assert!(simplified.len() <= contour.len());
1011
1012 for i in 1..simplified.len() {
1014 let a = simplified[i-1];
1015 let b = simplified[i];
1016
1017 let mut start_idx = None;
1019 let mut end_idx = None;
1020 for (j, op) in contour.iter().enumerate() {
1021 if (op.x - a.x).abs() < 1e-9 && (op.y - a.y).abs() < 1e-9 {
1022 start_idx = Some(j);
1023 }
1024 if (op.x - b.x).abs() < 1e-9 && (op.y - b.y).abs() < 1e-9 {
1025 end_idx = Some(j);
1026 }
1027 }
1028
1029 if let (Some(s), Some(e)) = (start_idx, end_idx) {
1030 for op in contour.iter().take(e + 1).skip(s) {
1031 let d = perpendicular_distance(*op, a, b);
1032 assert!(d <= epsilon + 1e-7, "Distance {d} > epsilon {epsilon} at point");
1033 }
1034 }
1035 }
1036 }
1037 }
1038
1039 use crate::config::TagFamily;
1044 use crate::segmentation::label_components_with_stats;
1045 use crate::test_utils::{
1046 TestImageParams, compute_corner_error, generate_test_image_with_params,
1047 };
1048 use crate::threshold::ThresholdEngine;
1049
1050 fn run_quad_extraction(tag_size: usize, canvas_size: usize) -> (Vec<Detection>, [[f64; 2]; 4]) {
1052 let params = TestImageParams {
1053 family: TagFamily::AprilTag36h11,
1054 id: 0,
1055 tag_size,
1056 canvas_size,
1057 ..Default::default()
1058 };
1059
1060 let (data, corners) = generate_test_image_with_params(¶ms);
1061 let img = ImageView::new(&data, canvas_size, canvas_size, canvas_size).unwrap();
1062
1063 let arena = Bump::new();
1064 let engine = ThresholdEngine::new();
1065 let stats = engine.compute_tile_stats(&arena, &img);
1066 let mut binary = vec![0u8; canvas_size * canvas_size];
1067 engine.apply_threshold(&arena, &img, &stats, &mut binary);
1068 let label_result =
1069 label_components_with_stats(&arena, &binary, canvas_size, canvas_size, true);
1070 let detections = extract_quads_fast(&arena, &img, &label_result);
1071
1072 (detections, corners)
1073 }
1074
1075 #[test]
1077 fn test_quad_extraction_at_varying_sizes() {
1078 let canvas_size = 640;
1079 let tag_sizes = [32, 48, 64, 100, 150, 200, 300];
1080
1081 for tag_size in tag_sizes {
1082 let (detections, _corners) = run_quad_extraction(tag_size, canvas_size);
1083 let detected = !detections.is_empty();
1084
1085 if tag_size >= 48 {
1086 assert!(detected, "Tag size {tag_size}: No quad detected");
1087 }
1088
1089 if detected {
1090 println!(
1091 "Tag size {:>3}px: {} quads, center=[{:.1},{:.1}]",
1092 tag_size,
1093 detections.len(),
1094 detections[0].center[0],
1095 detections[0].center[1]
1096 );
1097 } else {
1098 println!("Tag size {tag_size:>3}px: No quad detected");
1099 }
1100 }
1101 }
1102
1103 #[test]
1105 fn test_quad_corner_accuracy() {
1106 let canvas_size = 640;
1107 let tag_sizes = [100, 150, 200, 300];
1108
1109 for tag_size in tag_sizes {
1110 let (detections, gt_corners) = run_quad_extraction(tag_size, canvas_size);
1111
1112 assert!(!detections.is_empty(), "Tag size {tag_size}: No detection");
1113
1114 let det_corners = detections[0].corners;
1115 let error = compute_corner_error(&det_corners, >_corners);
1116
1117 let max_error = 5.0;
1118 assert!(
1119 error < max_error,
1120 "Tag size {tag_size}: Corner error {error:.2}px exceeds max"
1121 );
1122
1123 println!("Tag size {tag_size:>3}px: Corner error = {error:.2}px");
1124 }
1125 }
1126
1127 #[test]
1129 fn test_quad_center_accuracy() {
1130 let canvas_size = 640;
1131 let tag_size = 150;
1132
1133 let (detections, gt_corners) = run_quad_extraction(tag_size, canvas_size);
1134 assert!(!detections.is_empty(), "No detection");
1135
1136 let expected_cx =
1137 (gt_corners[0][0] + gt_corners[1][0] + gt_corners[2][0] + gt_corners[3][0]) / 4.0;
1138 let expected_cy =
1139 (gt_corners[0][1] + gt_corners[1][1] + gt_corners[2][1] + gt_corners[3][1]) / 4.0;
1140
1141 let det_center = detections[0].center;
1142 let dx = det_center[0] - expected_cx;
1143 let dy = det_center[1] - expected_cy;
1144 let center_error = (dx * dx + dy * dy).sqrt();
1145
1146 assert!(
1147 center_error < 2.0,
1148 "Center error {center_error:.2}px exceeds 2px"
1149 );
1150
1151 println!(
1152 "Quad center: detected=[{:.1},{:.1}], expected=[{:.1},{:.1}], error={:.2}px",
1153 det_center[0], det_center[1], expected_cx, expected_cy, center_error
1154 );
1155 }
1156
1157 fn generate_vertical_edge_image(
1166 width: usize,
1167 height: usize,
1168 edge_x: f64,
1169 sigma: f64,
1170 dark: u8,
1171 light: u8,
1172 ) -> Vec<u8> {
1173 let mut data = vec![0u8; width * height];
1174 let a = f64::from(dark);
1175 let b = f64::from(light);
1176
1177 for y in 0..height {
1178 for x in 0..width {
1179 let px = x as f64;
1180 let intensity =
1181 f64::midpoint(a, b) + (b - a) / 2.0 * erf_approx((px - edge_x) / sigma);
1182 data[y * width + x] = intensity.clamp(0.0, 255.0) as u8;
1183 }
1184 }
1185 data
1186 }
1187
1188 fn generate_corner_image(
1191 width: usize,
1192 height: usize,
1193 corner_x: f64,
1194 corner_y: f64,
1195 sigma: f64,
1196 ) -> Vec<u8> {
1197 let mut data = vec![0u8; width * height];
1198
1199 for y in 0..height {
1205 for x in 0..width {
1206 let px = x as f64;
1207 let py = y as f64;
1208
1209 let dist_v = px - corner_x;
1211 let dist_h = py - corner_y;
1213
1214 let signed_dist = if px < corner_x && py < corner_y {
1221 -dist_v.abs().min(dist_h.abs())
1223 } else if px >= corner_x && py >= corner_y {
1224 dist_v.min(dist_h).max(0.0)
1226 } else {
1227 if px < corner_x {
1229 dist_h } else {
1231 dist_v }
1233 };
1234
1235 let intensity = 127.5 + 127.5 * erf_approx(signed_dist / sigma);
1237 data[y * width + x] = intensity.clamp(0.0, 255.0) as u8;
1238 }
1239 }
1240 data
1241 }
1242
1243 #[test]
1248 fn test_refine_corner_subpixel_accuracy() {
1249 let arena = Bump::new();
1250 let width = 60;
1251 let height = 60;
1252 let sigma = 0.6; let test_cases = [
1256 (30.4, 30.4), (25.7, 25.7), (35.23, 35.23), (28.0, 28.0), (32.5, 32.5), ];
1262
1263 for (true_x, true_y) in test_cases {
1264 let data = generate_corner_image(width, height, true_x, true_y, sigma);
1265 let img = ImageView::new(&data, width, height, width).unwrap();
1266
1267 let init_p = Point {
1269 x: true_x.round(),
1270 y: true_y.round(),
1271 };
1272
1273 let p_prev = Point {
1278 x: true_x.round(),
1279 y: true_y.round() - 10.0,
1280 };
1281 let p_next = Point {
1282 x: true_x.round() - 10.0,
1283 y: true_y.round(),
1284 };
1285
1286 let refined = refine_corner(&arena, &img, init_p, p_prev, p_next, sigma, 1);
1287
1288 let error_x = (refined.x - true_x).abs();
1289 let error_y = (refined.y - true_y).abs();
1290 let error_total = (error_x * error_x + error_y * error_y).sqrt();
1291
1292 println!(
1293 "Corner ({:.2}, {:.2}): refined=({:.4}, {:.4}), error=({:.4}, {:.4}), total={:.4}px",
1294 true_x, true_y, refined.x, refined.y, error_x, error_y, error_total
1295 );
1296
1297 assert!(
1300 error_total < 0.15,
1301 "Corner ({true_x}, {true_y}): error {error_total:.4}px exceeds 0.15px threshold"
1302 );
1303 }
1304 }
1305
1306 #[test]
1308 fn test_refine_corner_vertical_edge() {
1309 let arena = Bump::new();
1310 let width = 40;
1311 let height = 40;
1312 let sigma = 0.6;
1313
1314 let true_edge_x = 20.4;
1316 let data = generate_vertical_edge_image(width, height, true_edge_x, sigma, 0, 255);
1317 let img = ImageView::new(&data, width, height, width).unwrap();
1318
1319 let corner_y = 20.0;
1322 let init_p = Point {
1323 x: true_edge_x.round(),
1324 y: corner_y,
1325 };
1326 let p_prev = Point {
1327 x: true_edge_x.round(),
1328 y: corner_y - 10.0,
1329 };
1330 let p_next = Point {
1331 x: true_edge_x.round() - 10.0,
1332 y: corner_y,
1333 };
1334
1335 let refined = refine_corner(&arena, &img, init_p, p_prev, p_next, sigma, 1);
1336
1337 let error_x = (refined.x - true_edge_x).abs();
1340
1341 println!(
1342 "Vertical edge x={:.2}: refined.x={:.4}, error={:.4}px",
1343 true_edge_x, refined.x, error_x
1344 );
1345
1346 assert!(
1348 error_x < 0.1,
1349 "Vertical edge x={true_edge_x}: error {error_x:.4}px exceeds 0.1px threshold"
1350 );
1351 }
1352}
1353
1354#[multiversion(targets(
1355 "x86_64+avx2+bmi1+bmi2+popcnt+lzcnt",
1356 "x86_64+avx512f+avx512bw+avx512dq+avx512vl",
1357 "aarch64+neon"
1358))]
1359fn trace_boundary<'a>(
1364 arena: &'a Bump,
1365 labels: &[u32],
1366 width: usize,
1367 height: usize,
1368 start_x: usize,
1369 start_y: usize,
1370 target_label: u32,
1371) -> BumpVec<'a, Point> {
1372 let mut points = BumpVec::new_in(arena);
1373
1374 let w = width as isize;
1377 let offsets: [isize; 8] = [
1378 -w, -w + 1, 1, w + 1, w, w - 1, -1, -w - 1, ];
1387
1388 let dx: [isize; 8] = [0, 1, 1, 1, 0, -1, -1, -1];
1390 let dy: [isize; 8] = [-1, -1, 0, 1, 1, 1, 0, -1];
1391
1392 let mut curr_x = start_x as isize;
1393 let mut curr_y = start_y as isize;
1394 let mut curr_idx = start_y * width + start_x;
1395 let mut walk_dir = 2usize; for _ in 0..10000 {
1398 points.push(Point {
1399 x: curr_x as f64,
1400 y: curr_y as f64,
1401 });
1402
1403 let mut found = false;
1404 let search_start = (walk_dir + 6) % 8;
1405
1406 for i in 0..8 {
1407 let dir = (search_start + i) % 8;
1408 let nx = curr_x + dx[dir];
1409 let ny = curr_y + dy[dir];
1410
1411 if (nx as usize) < width && (ny as usize) < height {
1413 let nidx = (curr_idx as isize + offsets[dir]) as usize;
1414 if labels[nidx] == target_label {
1415 curr_x = nx;
1416 curr_y = ny;
1417 curr_idx = nidx;
1418 walk_dir = dir;
1419 found = true;
1420 break;
1421 }
1422 }
1423 }
1424
1425 if !found || (curr_x == start_x as isize && curr_y == start_y as isize) {
1426 break;
1427 }
1428 }
1429
1430 points
1431}
1432
1433pub fn chain_approximation<'a>(arena: &'a Bump, points: &[Point]) -> BumpVec<'a, Point> {
1436 if points.len() < 3 {
1437 let mut v = BumpVec::new_in(arena);
1438 v.extend_from_slice(points);
1439 return v;
1440 }
1441
1442 let mut result = BumpVec::new_in(arena);
1443 result.push(points[0]);
1444
1445 for i in 1..points.len() - 1 {
1446 let p_prev = points[i - 1];
1447 let p_curr = points[i];
1448 let p_next = points[i + 1];
1449
1450 let dx1 = p_curr.x - p_prev.x;
1451 let dy1 = p_curr.y - p_prev.y;
1452 let dx2 = p_next.x - p_curr.x;
1453 let dy2 = p_next.y - p_curr.y;
1454
1455 if (dx1 * dy2 - dx2 * dy1).abs() > 1e-6 {
1458 result.push(p_curr);
1459 }
1460 }
1461
1462 result.push(*points.last().unwrap_or(&points[0]));
1463 result
1464}