Skip to main content

scan_core/invisible/
detector.rs

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        // Calcular la relación de aspecto del candidato para evitar distorsión
27        let w = geometry::perimeter(&[corners[0], corners[1]]);
28        let h = geometry::perimeter(&[corners[0], corners[3]]);
29        let ratio = h / w; // EJ: 1.414 para A4, 1.294 para Carta
30        
31        let target_height = if ratio > 1.35 { 1414 } else { 1294 }; // A4 vs Carta
32        
33        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                        // Calculamos el tamaño esperado del ROI para redimensionar la referencia
59                        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                        // Buscamos con un rango de búsqueda de 15 píxeles (robusto ante ligeras variaciones)
66                        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        // 1. Pre-procesamiento robusto
99        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        // 2. Encontrar candidatos
109        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            // Relajar levemente el filtro de tamaño: de 0.5 a 0.3 del ancho
122            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        // FALLBACK: Si es digital (o no hay bordes), añadir el marco completo como candidato
166        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        // 3. Evaluar los mejores N candidatos
180        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        // Probar hasta 15 candidatos si hay anclas para mayor robustez
190        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            // Probar 0°
196            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            // Probar 180° (invirtiendo orden de esquinas para el warp)
200            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                // Loguear detalles solo del mejor hasta ahora
220                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        // 3b. Umbral de validación final si hay anclas con referencia
246        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        // 4. Warp Final con dimensiones optimizadas
256        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        // Usar esquinas originales (sin rotar) para marcar la imagen de debug
268        // Pero espera, si needs_rotate es true, 'corners' ya es la versión invertida.
269        // Queremos dibujar el cuadrilátero original en la imagen marcada.
270        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}