Skip to main content

scan_core/aruco/
detector.rs

1use image::GrayImage;
2use anyhow::Result;
3use crate::{ScanResult, ScanConfig, Point, ScanMode};
4use base64::{Engine as _, engine::general_purpose::STANDARD};
5use crate::detector::Detector;
6use crate::aruco::Dictionary;
7use crate::cv;
8use crate::transform::perspective::PerspectiveTransform;
9
10pub struct ArucoDetector {
11    config: ScanConfig,
12    dictionary: Dictionary,
13}
14
15impl ArucoDetector {
16    pub fn new(config: ScanConfig) -> Self {
17        let dict_name = match config.mode {
18            ScanMode::Aruco4x4 => "ARUCO_4X4_50",
19            ScanMode::Aruco5x5 => "ARUCO_5X5",
20            ScanMode::Aruco6x6 => "ARUCO_6X6",
21            _ => "ARUCO_4X4_50",
22        };
23
24        let dictionary = Dictionary::new(dict_name).expect("Dictionary not found");
25
26        Self { config, dictionary }
27    }
28
29    fn find_candidates(&self, contours: &[Vec<Point>], min_size: f32, _epsilon: f32, min_edge: f32) -> Vec<Vec<Point>> {
30        let mut candidates = Vec::new();
31
32        for contour in contours {
33            if (contour.len() as f32) < min_size { continue; }
34
35            // Use 3% of perimeter as epsilon for approxPolyDP (Standard ArUco constant)
36            let peri = cv::geometry::perimeter(contour);
37            let poly = cv::geometry::approx_poly_dp(contour, peri * 0.03);
38
39            if poly.len() == 4 && cv::geometry::is_contour_convex(&poly) {
40                if cv::geometry::min_edge_length(&poly) >= min_edge {
41                    candidates.push(poly);
42                }
43            }
44        }
45
46        candidates
47    }
48
49    /// Run marker recognition pipeline on a set of candidates.
50    /// Returns recognized markers with deduplication (lowest hamming wins).
51    fn recognize_candidates(
52        &self,
53        image: &GrayImage,
54        candidates: &[Vec<Point>],
55        logs: &mut Vec<String>,
56        log_prefix: &str,
57    ) -> Vec<crate::aruco::Marker> {
58        let mut markers: Vec<crate::aruco::Marker> = Vec::new();
59
60        for (i, candidate) in candidates.iter().enumerate() {
61            let grid_size = (self.dictionary.n_bits as f64).sqrt() as u32 + 2;
62            let sample_resolution = grid_size * 8;
63            
64            let dst_pts = vec![
65                Point { x: 0.0, y: 0.0 },
66                Point { x: sample_resolution as f32, y: 0.0 },
67                Point { x: sample_resolution as f32, y: sample_resolution as f32 },
68                Point { x: 0.0, y: sample_resolution as f32 },
69            ];
70
71            let transform = if let Some(t) = PerspectiveTransform::new(candidate, &dst_pts) { t } else { continue };
72            let warped = crate::image_utils::warp_perspective(image, &transform, sample_resolution, sample_resolution);
73
74            let otsu = cv::threshold::otsu_threshold(&warped);
75            let bin = cv::threshold::global_threshold(&warped, otsu);
76
77            let mark_size = grid_size as usize;
78
79            // Don't pass logs into recognize_marker (verbose internal detail)
80            if let Some(marker) = self.dictionary.recognize_marker(&bin, mark_size, self.config.max_hamming, candidate, None) {
81                if self.config.debug {
82                    logs.push(format!("{}Candidate {} = ID {} (hamming {})", log_prefix, i, marker.id, marker.hamming));
83                }
84                // Deduplicate: keep lower hamming
85                let mut found = false;
86                for existing in &mut markers {
87                    if existing.id == marker.id {
88                        if marker.hamming < existing.hamming {
89                            *existing = marker.clone();
90                        }
91                        found = true;
92                        break;
93                    }
94                }
95                if !found {
96                    markers.push(marker);
97                }
98            }
99        }
100
101        markers
102    }
103}
104
105impl Detector for ArucoDetector {
106    fn detect(&self, image: &GrayImage) -> Result<ScanResult> {
107        let mut logs = Vec::new();
108        let img_w = image.width() as f32;
109        let img_h = image.height() as f32;
110        let img_center = (img_w / 2.0, img_h / 2.0);
111
112        if self.config.debug {
113            logs.push("DEBUG: ArUco Detector v2.0".to_string());
114        }
115
116        // ── Step 1: Multi-threshold scanning ──
117        // Try ALL thresholds and ACCUMULATE recognized markers (not just contours).
118        // This ensures we don't miss markers that only appear under certain lighting conditions.
119        let thresholds = [10, 7, 13, 5, 16];
120        let adaptive_kernel = (img_w / 500.0).max(3.0) as usize;
121        let min_size = img_w * 0.01; // Require markers to be at least 1% of image width
122        let mut all_recognized_markers: Vec<crate::aruco::Marker> = Vec::new();
123        let mut best_thresh = GrayImage::new(image.width(), image.height());
124        let mut best_contour_count = 0;
125
126        for (ti, &t_val) in thresholds.iter().enumerate() {
127            let thresh_img = cv::threshold::adaptive_threshold(image, adaptive_kernel, t_val);
128            let contours = cv::contours::find_contours(&thresh_img);
129
130            if self.config.debug {
131                logs.push(format!("  Threshold {} (val={}, kernel={}): {} contours", ti, t_val, adaptive_kernel, contours.len()));
132            }
133
134            if contours.is_empty() { continue; }
135
136            // Track best threshold for debug image
137            if contours.len() > best_contour_count {
138                best_contour_count = contours.len();
139                best_thresh = thresh_img;
140            }
141
142            // Find quad candidates
143            let mut candidates = self.find_candidates(&contours, min_size, 0.03, 5.0);
144            cv::geometry::clockwise_corners(&mut candidates);
145            let candidates = cv::geometry::not_too_near(&candidates, 10.0);
146
147            if candidates.is_empty() { continue; }
148
149            // Recognize markers from these candidates
150            let new_markers = self.recognize_candidates(image, &candidates, &mut logs, "  ");
151
152            // Merge into accumulated list (deduplicate, keep lowest hamming)
153            for m in new_markers {
154                let mut found = false;
155                for existing in &mut all_recognized_markers {
156                    if existing.id == m.id {
157                        if m.hamming < existing.hamming {
158                            *existing = m.clone();
159                        }
160                        found = true;
161                        break;
162                    }
163                }
164                if !found {
165                    all_recognized_markers.push(m);
166                }
167            }
168
169            // Early exit if we already found all 4 target markers
170            if let Some(ref target_ids) = self.config.marker_ids {
171                let found_all = target_ids.iter().all(|id| all_recognized_markers.iter().any(|m| m.id == *id));
172                if found_all {
173                    if self.config.debug {
174                        logs.push(format!("  All {} target markers found, stopping threshold scan", target_ids.len()));
175                    }
176                    break;
177                }
178            } else if all_recognized_markers.len() >= 4 {
179                break;
180            }
181        }
182
183        if self.config.debug {
184            logs.push(format!("Total unique markers found: {}", all_recognized_markers.len()));
185        }
186
187        if all_recognized_markers.is_empty() {
188            return Ok(ScanResult {
189                success: false,
190                error: Some("No se encontraron marcadores ArUco en la imagen".to_string()),
191                logs,
192                ..ScanResult::new()
193            });
194        }
195
196        // ── Step 2: Assign markers to slots [TL, TR, BR, BL] ──
197        let mut slots: Vec<Option<Point>> = vec![None; 4];
198        let mut assigned_marker_ids = std::collections::HashSet::new();
199
200        let get_physical_extreme = |m: &crate::aruco::Marker| -> Point {
201            let c = marker_center(&m.corners);
202            let (mode_x_max, mode_y_max) = if c.y < img_center.1 {
203                if c.x < img_center.0 { (false, false) } else { (true, false) }
204            } else {
205                if c.x > img_center.0 { (true, true) } else { (false, true) }
206            };
207            cv::geometry::get_extreme_corner(&m.corners, mode_x_max, mode_y_max)
208        };
209
210        // 2a. Assign by ID (high priority)
211        if let Some(target_ids) = &self.config.marker_ids {
212            for m in &all_recognized_markers {
213                if let Some(idx) = target_ids.iter().position(|&id| id == m.id) {
214                    slots[idx] = Some(get_physical_extreme(m));
215                    assigned_marker_ids.insert(m.id);
216                    if self.config.debug {
217                        logs.push(format!("  Assigned ID {} -> slot {}", m.id, ["TL", "TR", "BR", "BL"][idx]));
218                    }
219                }
220            }
221        } else {
222            for m in &all_recognized_markers {
223                let c = marker_center(&m.corners);
224                let idx = if c.y < img_center.1 {
225                    if c.x < img_center.0 { 0 } else { 1 }
226                } else {
227                    if c.x > img_center.0 { 2 } else { 3 }
228                };
229                slots[idx] = Some(get_physical_extreme(m));
230                assigned_marker_ids.insert(m.id);
231            }
232        }
233
234        // 2b. Quadrant fallback for unassigned markers
235        for m in &all_recognized_markers {
236            if assigned_marker_ids.contains(&m.id) { continue; }
237            let c = marker_center(&m.corners);
238            let idx = if c.y < img_center.1 {
239                if c.x < img_center.0 { 0 } else { 1 }
240            } else {
241                if c.x > img_center.0 { 2 } else { 3 }
242            };
243            if slots[idx].is_none() {
244                slots[idx] = Some(get_physical_extreme(m));
245                assigned_marker_ids.insert(m.id);
246                logs.push(format!("  Fallback: ID {} -> slot {}", m.id, ["TL", "TR", "BR", "BL"][idx]));
247            }
248        }
249
250        let detected_markers: Vec<_> = all_recognized_markers.into_iter()
251            .filter(|m| assigned_marker_ids.contains(&m.id))
252            .collect();
253
254        // ── Step 3: Targeted Search for missing markers ──
255        // If a slot is empty and we know the expected ID, try a focused search in the predicted area.
256        let missing_slots: Vec<usize> = (0..4).filter(|&i| slots[i].is_none()).collect();
257
258        if !missing_slots.is_empty() && slots.iter().filter(|s| s.is_some()).count() >= 3 {
259            // First, estimate where the missing corner should be (vector addition)
260            for &idx in &missing_slots {
261                let estimated = estimate_point_from_parallelogram(&slots, idx);
262                if let Some(est_pt) = estimated {
263                    if self.config.debug {
264                        logs.push(format!("  Targeted Search: Looking for slot {} near ({:.0}, {:.0})", ["TL", "TR", "BR", "BL"][idx], est_pt.x, est_pt.y));
265                    }
266
267                    // Define ROI around estimated point
268                    let roi_half = 150.0;
269                    let x1 = (est_pt.x - roi_half).max(0.0) as u32;
270                    let y1 = (est_pt.y - roi_half).max(0.0) as u32;
271                    let x2 = (est_pt.x + roi_half).min(img_w) as u32;
272                    let y2 = (est_pt.y + roi_half).min(img_h) as u32;
273
274                    if x2 <= x1 || y2 <= y1 { continue; }
275
276                    let roi_w = x2 - x1;
277                    let roi_h = y2 - y1;
278                    let roi = image::imageops::crop_imm(image, x1, y1, roi_w, roi_h).to_image();
279
280                    // Run full ArUco pipeline on ROI with aggressive thresholds
281                    let roi_thresholds = [10, 5, 7, 15, 20, 3];
282                    let roi_min_size = roi_w as f32 * 0.03;
283                    let mut roi_found = false;
284
285                    for &t_val in &roi_thresholds {
286                        let roi_thresh = cv::threshold::adaptive_threshold(&roi, 3, t_val);
287                        let roi_contours = cv::contours::find_contours(&roi_thresh);
288                        let mut roi_cands = self.find_candidates(&roi_contours, roi_min_size, 0.05, 5.0);
289                        cv::geometry::clockwise_corners(&mut roi_cands);
290                        let roi_cands = cv::geometry::not_too_near(&roi_cands, 5.0);
291
292                        if roi_cands.is_empty() { continue; }
293
294                        // Offset candidates to global coordinates
295                        let global_cands: Vec<Vec<Point>> = roi_cands.iter().map(|c| {
296                            c.iter().map(|p| Point { x: p.x + x1 as f32, y: p.y + y1 as f32 }).collect()
297                        }).collect();
298
299                        // Try to recognize markers in global coords
300                        let roi_markers = self.recognize_candidates(image, &global_cands, &mut logs, "    ROI: ");
301
302                        // Check if any recognized marker fills our missing slot
303                        for rm in &roi_markers {
304                            let should_use = if let Some(ref target_ids) = self.config.marker_ids {
305                                // Accept if it's one of our target IDs and matches this slot
306                                target_ids.get(idx).map_or(false, |&expected_id| rm.id == expected_id)
307                            } else {
308                                // No target IDs: accept any marker in right quadrant
309                                let c = marker_center(&rm.corners);
310                                let q = if c.y < img_center.1 {
311                                    if c.x < img_center.0 { 0 } else { 1 }
312                                } else {
313                                    if c.x > img_center.0 { 2 } else { 3 }
314                                };
315                                q == idx
316                            };
317
318                            if should_use {
319                                let pt = get_physical_extreme(rm);
320                                slots[idx] = Some(pt);
321                                logs.push(format!("  Targeted Search: RECOVERED slot {} with ID {} (threshold={})", ["TL", "TR", "BR", "BL"][idx], rm.id, t_val));
322                                roi_found = true;
323                                break;
324                            }
325                        }
326
327                        if roi_found { break; }
328                    }
329                }
330            }
331        }
332
333        // ── Step 4: Estimate remaining missing corners (vector addition) ──
334        estimate_missing_corners(&mut slots, &mut logs);
335
336        // ── Step 5: Fix outliers (relaxed threshold) ──
337        fix_perspective_outliers(&mut slots, &mut logs, img_w);
338
339        // ── Step 6: Refine corners (sub-pixel precision) ──
340        for i in 0..4 {
341            if let Some(p) = slots[i] {
342                let refined = cv::geometry::refine_corner(image.as_raw(), image.width() as usize, image.height() as usize, p, 5);
343                if (refined.x - p.x).abs() > 0.1 || (refined.y - p.y).abs() > 0.1 {
344                    if self.config.debug {
345                        logs.push(format!("  Refined {}: ({:.1},{:.1}) -> ({:.1},{:.1})", ["TL","TR","BR","BL"][i], p.x, p.y, refined.x, refined.y));
346                    }
347                }
348                slots[i] = Some(refined);
349            }
350        }
351
352        let valid_count = slots.iter().filter(|s| s.is_some()).count();
353
354        // ── Step 7: Build result ──
355        if detected_markers.len() < self.config.min_markers {
356            let mut result = ScanResult {
357                success: false,
358                error: Some(format!("Found {} markers, required {}", detected_markers.len(), self.config.min_markers)),
359                detected_markers: Some(detected_markers),
360                logs,
361                ..ScanResult::new()
362            };
363            if self.config.debug {
364                result.debug_image = crate::image_utils::encode_debug_image(&best_thresh);
365            }
366            return Ok(result);
367        }
368
369        if valid_count < 4 {
370            let mut result = ScanResult {
371                success: false,
372                error: Some(format!("Only {} of 4 corners resolved", valid_count)),
373                detected_markers: Some(detected_markers),
374                logs,
375                ..ScanResult::new()
376            };
377            if self.config.debug {
378                result.debug_image = crate::image_utils::encode_debug_image(&best_thresh);
379            }
380            return Ok(result);
381        }
382
383        let pts: Vec<Point> = slots.iter().map(|s| s.unwrap()).collect();
384
385        // ── Step 7.5: Robust Perspective rectification (16-point Least Squares) ──
386        // Instead of using only 4 corners, we use all 4 corners of EACH detected ArUco marker
387        // (up to 16 points). This allows the Least Squares homography to average out 
388        // lens distortion and local noise, forcing markers to become perfect squares.
389        
390        let mut src_pts = Vec::with_capacity(16);
391        let mut dst_pts = Vec::with_capacity(16);
392
393        let template_ratio = self.config.template_ratio;
394        
395        // Use largest measured dimension for output resolution
396        let measured_w = ((slots[0].unwrap().x - slots[1].unwrap().x).hypot(slots[0].unwrap().y - slots[1].unwrap().y)
397            + (slots[3].unwrap().x - slots[2].unwrap().x).hypot(slots[3].unwrap().y - slots[2].unwrap().y)) / 2.0;
398        let measured_h = ((slots[0].unwrap().x - slots[3].unwrap().x).hypot(slots[0].unwrap().y - slots[3].unwrap().y)
399            + (slots[1].unwrap().x - slots[2].unwrap().x).hypot(slots[1].unwrap().y - slots[2].unwrap().y)) / 2.0;
400
401        let (out_w, out_h) = if measured_w > measured_h {
402            (measured_w, measured_w / template_ratio)
403        } else {
404            (measured_h * template_ratio, measured_h)
405        };
406
407        // Standard metrics from A4 template (3400x4400)
408        let margin_w = out_w * (50.0 / 3400.0);
409        let margin_h = out_h * (50.0 / 4400.0);
410        let m_size_w = out_w * (80.0 / 3400.0);
411        let m_size_h = out_h * (80.0 / 4400.0);
412
413        // Ideal marker locations (Top-Left corner of each marker)
414        let ideal_marker_pos = [
415            Point { x: margin_w, y: margin_h },                               // TL
416            Point { x: out_w - margin_w - m_size_w, y: margin_h },           // TR
417            Point { x: out_w - margin_w - m_size_w, y: out_h - margin_h - m_size_h }, // BR
418            Point { x: margin_w, y: out_h - margin_h - m_size_h },           // BL
419        ];
420
421        // Collect all available corners
422        for i in 0..4 {
423            // Find if we have a detected marker for this slot
424            let marker = detected_markers.iter().find(|m| {
425                // Check if this marker's center is closest to this slot's point
426                if let Some(target_ids) = &self.config.marker_ids {
427                    target_ids.get(i).map_or(false, |&id| m.id == id)
428                } else {
429                    // Fallback to spatial proximity if IDs aren't specified
430                    let c = marker_center(&m.corners);
431                    let dist = (c.x - slots[i].unwrap().x).hypot(c.y - slots[i].unwrap().y);
432                    dist < 100.0 // Close enough to be the slot marker
433                }
434            });
435
436            if let Some(m) = marker {
437                // Add all 4 corners of the marker for robustness
438                for j in 0..4 {
439                    src_pts.push(m.corners[j]);
440                    // Ideal corner j of marker i
441                    let base = ideal_marker_pos[i];
442                    let ideal_p = match j {
443                        0 => base,
444                        1 => Point { x: base.x + m_size_w, y: base.y },
445                        2 => Point { x: base.x + m_size_w, y: base.y + m_size_h },
446                        3 => Point { x: base.x, y: base.y + m_size_h },
447                        _ => unreachable!(),
448                    };
449                    dst_pts.push(ideal_p);
450                }
451            } else {
452                // Fallback: use the estimated single corner point
453                src_pts.push(slots[i].unwrap());
454                // The slot point corresponds to the OUTSIDE extreme corner of the marker
455                let ideal_p = match i {
456                    0 => Point { x: margin_w, y: margin_h },
457                    1 => Point { x: out_w - margin_w, y: margin_h },
458                    2 => Point { x: out_w - margin_w, y: out_h - margin_h },
459                    3 => Point { x: margin_w, y: out_h - margin_h },
460                    _ => unreachable!(),
461                };
462                dst_pts.push(ideal_p);
463            }
464        }
465
466        let rectified_image = if let Some(pt) = PerspectiveTransform::new(&src_pts, &dst_pts) {
467            if self.config.debug {
468                logs.push(format!("  Robust Rectification: {}x{} px using {} points",
469                    out_w as u32, out_h as u32, src_pts.len()));
470            }
471            crate::image_utils::warp_perspective(image, &pt, out_w as u32, out_h as u32)
472        } else {
473            logs.push("  WARNING: robust rectification failed, using original image".to_string());
474            image.clone()
475        };
476
477        // ── Step 8: Crop = rectified image (already perspective-corrected) ──
478        // No second warp needed: the rectified image IS the aligned exam sheet.
479        let cropped = crate::image_utils::to_png_bytes(&rectified_image)
480            .ok()
481            .map(|b| STANDARD.encode(b));
482        
483        let pts: Vec<Point> = slots.iter().map(|s| s.unwrap()).collect();
484        let transform = PerspectiveTransform::new(&src_pts, &dst_pts);
485
486        // ── Step 9: Marked image ──
487        let marker_points: Vec<Vec<crate::Point>> = detected_markers.iter().map(|m| m.corners.clone()).collect();
488        let marked = crate::image_utils::generate_marked_image(
489            image,
490            &pts,
491            None,
492            Some(&marker_points),
493            transform.as_ref()
494        );
495
496        let mut result = ScanResult {
497            success: true,
498            error: None,
499            cropped_image: cropped,
500            marked_image: Some(marked),
501            corners: Some(pts.clone()),
502            bounding_box: Some(pts),
503            detected_markers: Some(detected_markers),
504            match_score: None,
505            debug_image: None,
506            logs,
507        };
508
509        if self.config.debug {
510            result.debug_image = crate::image_utils::encode_debug_image(&best_thresh);
511        }
512
513        Ok(result)
514    }
515}
516
517// ── Helper functions ──
518
519fn marker_center(corners: &[Point]) -> Point {
520    let n = corners.len() as f32;
521    Point {
522        x: corners.iter().map(|p| p.x).sum::<f32>() / n,
523        y: corners.iter().map(|p| p.y).sum::<f32>() / n,
524    }
525}
526
527/// Estimate one missing point from 3 known points using parallelogram vector addition.
528/// Returns None if the required 3 points aren't available.
529fn estimate_point_from_parallelogram(slots: &[Option<Point>], missing: usize) -> Option<Point> {
530    match missing {
531        0 => if let (Some(tr), Some(bl), Some(br)) = (slots[1], slots[3], slots[2]) {
532            Some(Point { x: tr.x + bl.x - br.x, y: tr.y + bl.y - br.y })
533        } else { None },
534        1 => if let (Some(tl), Some(br), Some(bl)) = (slots[0], slots[2], slots[3]) {
535            Some(Point { x: tl.x + br.x - bl.x, y: tl.y + br.y - bl.y })
536        } else { None },
537        2 => if let (Some(tr), Some(bl), Some(tl)) = (slots[1], slots[3], slots[0]) {
538            Some(Point { x: tr.x + bl.x - tl.x, y: tr.y + bl.y - tl.y })
539        } else { None },
540        3 => if let (Some(tl), Some(br), Some(tr)) = (slots[0], slots[2], slots[1]) {
541            Some(Point { x: tl.x + br.x - tr.x, y: tl.y + br.y - tr.y })
542        } else { None },
543        _ => None,
544    }
545}
546
547/// Fill any single missing slot using vector addition.
548fn estimate_missing_corners(slots: &mut Vec<Option<Point>>, logs: &mut Vec<String>) {
549    let valid_count = slots.iter().filter(|s| s.is_some()).count();
550    if valid_count >= 4 || valid_count < 3 { return; }
551
552    if let Some(m) = (0..4).find(|i| slots[*i].is_none()) {
553        if let Some(p) = estimate_point_from_parallelogram(slots, m) {
554            let names = ["TL", "TR", "BR", "BL"];
555            logs.push(format!("  Estimated corner {} at ({:.0}, {:.0})", names[m], p.x, p.y));
556            slots[m] = Some(p);
557        }
558    }
559}
560
561/// Validate if 4 corners form a plausible parallelogram.
562/// Only correct if one corner deviates grossly (e.g. detected on wrong feature).
563fn fix_perspective_outliers(slots: &mut Vec<Option<Point>>, logs: &mut Vec<String>, img_w: f32) {
564    if slots.iter().any(|s| s.is_none()) { return; }
565
566    let pts: Vec<_> = slots.iter().map(|s| s.unwrap()).collect();
567    let names = ["TL", "TR", "BR", "BL"];
568
569    let mut max_dev = 0.0;
570    let mut max_idx = 0;
571    let mut best_prediction = Point { x: 0.0, y: 0.0 };
572
573    for i in 0..4 {
574        let prediction = match i {
575            0 => Point { x: pts[1].x + pts[3].x - pts[2].x, y: pts[1].y + pts[3].y - pts[2].y },
576            1 => Point { x: pts[0].x + pts[2].x - pts[3].x, y: pts[0].y + pts[2].y - pts[3].y },
577            2 => Point { x: pts[1].x + pts[3].x - pts[0].x, y: pts[1].y + pts[3].y - pts[0].y },
578            3 => Point { x: pts[0].x + pts[2].x - pts[1].x, y: pts[0].y + pts[2].y - pts[1].y },
579            _ => unreachable!(),
580        };
581
582        let dx = pts[i].x - prediction.x;
583        let dy = pts[i].y - prediction.y;
584        let dev = (dx * dx + dy * dy).sqrt();
585
586        if dev > max_dev {
587            max_dev = dev;
588            max_idx = i;
589            best_prediction = prediction;
590        }
591    }
592
593    // Relaxed threshold: 5% of image width, min 50px, max 200px
594    // Only corrects truly gross errors (wrong feature detected as marker)
595    let threshold = (img_w * 0.05).clamp(50.0, 200.0);
596
597    if max_dev > threshold {
598        logs.push(format!("  WARNING: Corner {} outlier (dev {:.0}px > {:.0}px). Correcting.", names[max_idx], max_dev, threshold));
599        slots[max_idx] = Some(best_prediction);
600    }
601}