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 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 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 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 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 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; 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 if contours.len() > best_contour_count {
138 best_contour_count = contours.len();
139 best_thresh = thresh_img;
140 }
141
142 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 let new_markers = self.recognize_candidates(image, &candidates, &mut logs, " ");
151
152 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 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 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 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 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 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 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 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 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 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 let roi_markers = self.recognize_candidates(image, &global_cands, &mut logs, " ROI: ");
301
302 for rm in &roi_markers {
304 let should_use = if let Some(ref target_ids) = self.config.marker_ids {
305 target_ids.get(idx).map_or(false, |&expected_id| rm.id == expected_id)
307 } else {
308 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 estimate_missing_corners(&mut slots, &mut logs);
335
336 fix_perspective_outliers(&mut slots, &mut logs, img_w);
338
339 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 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 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 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 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 let ideal_marker_pos = [
415 Point { x: margin_w, y: margin_h }, Point { x: out_w - margin_w - m_size_w, y: margin_h }, Point { x: out_w - margin_w - m_size_w, y: out_h - margin_h - m_size_h }, Point { x: margin_w, y: out_h - margin_h - m_size_h }, ];
420
421 for i in 0..4 {
423 let marker = detected_markers.iter().find(|m| {
425 if let Some(target_ids) = &self.config.marker_ids {
427 target_ids.get(i).map_or(false, |&id| m.id == id)
428 } else {
429 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 }
434 });
435
436 if let Some(m) = marker {
437 for j in 0..4 {
439 src_pts.push(m.corners[j]);
440 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 src_pts.push(slots[i].unwrap());
454 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 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 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
517fn 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
527fn 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
547fn 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
561fn 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 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}