1use image::GrayImage;
2use anyhow::{Result, anyhow};
3use base64::{Engine as _, engine::general_purpose::STANDARD};
4use crate::{ScanResult, ScanConfig, Point};
5use crate::detector::Detector;
6use crate::cv::{clahe, canny, contours, geometry, ncc};
7use crate::transform::perspective::PerspectiveTransform;
8
9pub struct InvisibleDetector {
10 config: ScanConfig,
11}
12
13impl InvisibleDetector {
14 pub fn new(config: ScanConfig) -> Self {
15 Self { config }
16 }
17
18 fn calculate_match_score(&self, image: &GrayImage, corners: &[Point], logs: &mut Vec<String>) -> (f32, u32, u32) {
19 let anchors = match &self.config.anchors {
20 Some(a) if !a.is_empty() => a,
21 _ => return (1.0, 1000, 1414),
22 };
23
24 let target_width = 1000;
25
26 let w = geometry::perimeter(&[corners[0], corners[1]]);
28 let h = geometry::perimeter(&[corners[0], corners[3]]);
29 let ratio = h / w; let target_height = if ratio > 1.35 { 1414 } else { 1294 }; logs.push(format!(" [Warp] Relación de aspecto detectada: {:.3} -> Target: {}x{}", ratio, target_width, target_height));
34
35 let dst_pts = vec![
36 Point { x: 0.0, y: 0.0 },
37 Point { x: target_width as f32, y: 0.0 },
38 Point { x: target_width as f32, y: target_height as f32 },
39 Point { x: 0.0, y: target_height as f32 },
40 ];
41
42 let transform = match PerspectiveTransform::new(corners, &dst_pts) {
43 Some(t) => t,
44 None => return (0.0, target_width, target_height),
45 };
46
47 let warped = crate::image_utils::warp_perspective(image, &transform, target_width, target_height);
48
49 let mut total_score = 0.0;
50 let mut count = 0;
51
52 for anchor in anchors {
53 if let Some(ref_b64) = &anchor.reference {
54 if let Ok(ref_bytes) = STANDARD.decode(ref_b64) {
55 if let Ok(ref_img) = image::load_from_memory(&ref_bytes) {
56 let ref_gray = ref_img.to_luma8();
57
58 let scale = target_width as f32 / 100.0;
60 let roi_w = (anchor.width * scale).round() as u32;
61 let roi_h = (anchor.height * scale).round() as u32;
62
63 let ref_resized = image::imageops::resize(&ref_gray, roi_w, roi_h, image::imageops::FilterType::Triangle);
64
65 let score = ncc::match_anchor_with_search(&warped, &ref_resized, anchor.x, anchor.y, 15);
67
68 logs.push(format!(" [NCC] Ancla '{}' pos={:?}, score: {:.3}", anchor.name, (anchor.x, anchor.y), score));
69 total_score += score;
70 count += 1;
71 } else {
72 logs.push(format!(" [NCC] Error al cargar imagen de referencia del ancla '{}'", anchor.name));
73 }
74 } else {
75 logs.push(format!(" [NCC] Error al decodificar base64 del ancla '{}'", anchor.name));
76 }
77 } else {
78 logs.push(format!(" [NCC] El ancla '{}' no tiene imagen de referencia", anchor.name));
79 }
80 }
81
82 if count == 0 {
83 logs.push(" [NCC] No se pudieron evaluar anclas (sin referencias válidas)".to_string());
84 (1.0, target_width, target_height)
85 } else {
86 let avg = total_score / count as f32;
87 logs.push(format!(" [NCC] Score promedio de candidatos: {:.3}", avg));
88 (avg, target_width, target_height)
89 }
90 }
91}
92
93impl Detector for InvisibleDetector {
94 fn detect(&self, image: &GrayImage) -> Result<ScanResult> {
95 let mut logs = Vec::new();
96 logs.push("Iniciando detección Invisible (Modo Guiado)...".to_string());
97
98 let contrast = clahe::clahe(image, 8, 3.0);
100 let edges = canny::canny_robust(&contrast);
101
102 let mut debug_image = None;
103 if self.config.debug {
104 let b64 = STANDARD.encode(crate::image_utils::to_png_bytes(&edges)?);
105 debug_image = Some(b64);
106 }
107
108 let all_contours = contours::find_contours(&edges);
110 let mut candidates = Vec::new();
111
112 let mut stats_size = 0;
113 let mut stats_approx = 0;
114 let mut stats_convex = 0;
115 let mut stats_ratio = 0;
116
117 logs.push(format!("Contornos totales encontrados: {}", all_contours.len()));
118
119 for contour in all_contours {
120 let peri = geometry::perimeter(&contour);
121 if peri < (image.width() as f32 * 0.3) {
123 stats_size += 1;
124 continue;
125 }
126
127 let approx = geometry::approx_poly_dp(&contour, 0.04 * peri);
128
129 if approx.len() != 4 {
130 stats_approx += 1;
131 continue;
132 }
133
134 if !geometry::is_contour_convex(&approx) {
135 stats_convex += 1;
136 continue;
137 }
138
139 let c = approx;
140 let sum: Vec<f32> = c.iter().map(|p| p.x + p.y).collect();
141 let diff: Vec<f32> = c.iter().map(|p| p.x - p.y).collect();
142
143 let tl_idx = sum.iter().enumerate().min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()).unwrap().0;
144 let br_idx = sum.iter().enumerate().max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()).unwrap().0;
145 let tr_idx = diff.iter().enumerate().max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()).unwrap().0;
146 let bl_idx = diff.iter().enumerate().min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()).unwrap().0;
147
148 let ordered = vec![c[tl_idx], c[tr_idx], c[br_idx], c[bl_idx]];
149
150 let w = geometry::perimeter(&[ordered[0], ordered[1]]) ;
151 let h = geometry::perimeter(&[ordered[0], ordered[3]]) ;
152 let ratio = w / h;
153
154 if ratio > 0.3 && ratio < 1.7 {
155 let area = geometry::polygon_area(&ordered);
156 candidates.push((ordered, area));
157 } else {
158 stats_ratio += 1;
159 }
160 }
161
162 logs.push(format!("Filtrado: {} pequeños, {} no-cuatros, {} no-convexos, {} ratio-incorrecto",
163 stats_size, stats_approx, stats_convex, stats_ratio));
164
165 let full_frame = vec![
167 Point { x: 0.0, y: 0.0 },
168 Point { x: image.width() as f32, y: 0.0 },
169 Point { x: image.width() as f32, y: image.height() as f32 },
170 Point { x: 0.0, y: image.height() as f32 },
171 ];
172 let full_area = (image.width() * image.height()) as f32;
173 candidates.push((full_frame, full_area));
174
175 logs.push(format!("Candidatos finales (incluyendo full-frame): {}", candidates.len()));
176
177 candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
178
179 let mut best_corners = None;
181 let mut best_score = -1.0;
182 let mut best_candidate_idx = 0;
183 let mut needs_rotate = false;
184 let mut final_target_w = 1000;
185 let mut final_target_h = 1414;
186
187 let has_anchors = self.config.anchors.as_ref().map(|a| a.iter().any(|an| an.reference.is_some())).unwrap_or(false);
188
189 let limit = if has_anchors { 15.min(candidates.len()) } else { 1.min(candidates.len()) };
191
192 for i in 0..limit {
193 let (corners, area) = &candidates[i];
194
195 let mut temp_logs = Vec::new();
197 let (score_0, tw_0, th_0) = self.calculate_match_score(image, corners, &mut temp_logs);
198
199 let corners_180 = vec![corners[2], corners[3], corners[0], corners[1]];
201 let mut temp_logs_180 = Vec::new();
202 let (score_180, tw_180, th_180) = self.calculate_match_score(image, &corners_180, &mut temp_logs_180);
203
204 let (win_score, win_rotate, win_tw, win_th) = if score_180 > score_0 {
205 (score_180, true, tw_180, th_180)
206 } else {
207 (score_0, false, tw_0, th_0)
208 };
209
210 logs.push(format!("Candidato #{} (área: {:.0}): score 0°={:.3}, 180°={:.3}", i+1, area, score_0, score_180));
211 if win_score > best_score {
212 best_score = win_score;
213 best_corners = Some(if win_rotate { corners_180 } else { corners.clone() });
214 best_candidate_idx = i + 1;
215 needs_rotate = win_rotate;
216 final_target_w = win_tw;
217 final_target_h = win_th;
218
219 if win_rotate { logs.extend(temp_logs_180); } else { logs.extend(temp_logs); }
221 }
222 }
223
224 if let Some(_) = best_corners {
225 logs.push(format!("-> Seleccionado Candidato #{} como mejor coincidencia (Score: {:.3})", best_candidate_idx, best_score));
226 }
227
228 let corners = match best_corners {
229 Some(c) => c,
230 None => {
231 let err_msg = if candidates.is_empty() {
232 "No se encontró ningún contorno de 4 lados que parezca un examen.".to_string()
233 } else {
234 "Se encontraron candidatos pero ninguno superó la validación por anclas.".to_string()
235 };
236 return Ok(ScanResult {
237 success: false,
238 error: Some(err_msg),
239 logs,
240 ..ScanResult::new()
241 });
242 }
243 };
244
245 if has_anchors && best_score < 0.35 {
247 return Ok(ScanResult {
248 success: false,
249 error: Some(format!("Validación fallida: El examen no coincide con las anclas (Score: {:.3})", best_score)),
250 logs,
251 ..ScanResult::new()
252 });
253 }
254
255 let dst_pts = vec![
257 Point { x: 0.0, y: 0.0 },
258 Point { x: final_target_w as f32, y: 0.0 },
259 Point { x: final_target_w as f32, y: final_target_h as f32 },
260 Point { x: 0.0, y: final_target_h as f32 },
261 ];
262
263 let transform = PerspectiveTransform::new(&corners, &dst_pts).ok_or_else(|| anyhow!("Error en transformación final"))?;
264 let warped = crate::image_utils::warp_perspective(image, &transform, final_target_w, final_target_h);
265
266 let cropped_b64 = STANDARD.encode(crate::image_utils::to_png_bytes(&warped)?);
267 let marked_corners = if needs_rotate { vec![corners[2], corners[3], corners[0], corners[1]] } else { corners.clone() };
271 let marked_image = Some(crate::image_utils::generate_marked_image(image, &marked_corners, self.config.anchors.as_deref(), None, Some(&transform)));
272
273 Ok(ScanResult {
274 success: true,
275 error: None,
276 cropped_image: Some(cropped_b64),
277 marked_image,
278 corners: Some(marked_corners.clone()),
279 bounding_box: Some(marked_corners),
280 detected_markers: Some(Vec::new()),
281 match_score: Some(best_score),
282 debug_image,
283 logs,
284 })
285 }
286}