Skip to main content

scan_core/
lib.rs

1pub mod aruco;
2pub mod cv;
3pub mod detector;
4pub mod invisible;
5pub mod transform;
6pub mod image_utils;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ScanConfig {
12    pub mode: ScanMode,
13    pub debug: bool,
14    /// If using ArUco, list of marker IDs to expect (e.g. TL, TR, BR, BL order)
15    pub marker_ids: Option<Vec<u32>>,
16    /// Minimum number of markers required
17    pub min_markers: usize,
18    /// Max hamming distance for marker detection
19    pub max_hamming: u32,
20    /// If using Invisible mode, validation anchors
21    pub anchors: Option<Vec<AnchorConfig>>,
22    /// Width/height ratio of the template for perspective rectification.
23    /// Default: 0.7727 (3400/4400 = 17:22, standard exam template).
24    /// Set to match your template's actual proportions.
25    #[serde(default = "default_template_ratio")]
26    pub template_ratio: f32,
27}
28
29fn default_template_ratio() -> f32 {
30    3400.0 / 4400.0
31}
32
33#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
34#[serde(rename_all = "snake_case")]
35pub enum ScanMode {
36    #[default]
37    Aruco4x4,
38    Aruco5x5,
39    Aruco6x6,
40    Invisible,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AnchorConfig {
45    pub name: String,
46    // Typically percentage coordinates for validation
47    pub x: f32,
48    pub y: f32,
49    pub width: f32,
50    pub height: f32,
51    /// Reference image in Base64 for NCC matching
52    pub reference: Option<String>,
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56pub struct ScanResult {
57    pub success: bool,
58    pub error: Option<String>,
59    /// Base64 encoded PNG of the cropped/rectified exam
60    pub cropped_image: Option<String>, 
61    /// Base64 encoded JPEG of original image with marker outlines and bounding quad
62    pub marked_image: Option<String>,
63    /// Coordinates of the detected corners (TL, TR, BR, BL) in the original image
64    pub corners: Option<Vec<Point>>,
65    /// Alias for corners (used by skeleton-scanner-exams)
66    pub bounding_box: Option<Vec<Point>>,
67    /// Full markers found (ID + corners)
68    pub detected_markers: Option<Vec<crate::aruco::Marker>>,
69    /// Confidence score for invisible mode (0.0 to 1.0)
70    pub match_score: Option<f32>,
71    /// Base64 encoded PNG debugging image (threshold binary)
72    pub debug_image: Option<String>,
73    /// Debugging logs captured during processing
74    pub logs: Vec<String>,
75}
76
77impl ScanResult {
78    pub fn new() -> Self {
79        Self {
80            success: false,
81            error: None,
82            cropped_image: None,
83            marked_image: None,
84            corners: None,
85            bounding_box: None,
86            detected_markers: Some(Vec::new()),
87            match_score: None,
88            debug_image: None,
89            logs: Vec::new(),
90        }
91    }
92}
93
94#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
95pub struct Point {
96    pub x: f32,
97    pub y: f32,
98}
99
100impl Default for ScanConfig {
101    fn default() -> Self {
102        Self {
103            mode: ScanMode::Aruco4x4,
104            debug: false,
105            marker_ids: None,
106            min_markers: 2,
107            max_hamming: 1,
108            anchors: None,
109            template_ratio: default_template_ratio(),
110        }
111    }
112}
113
114pub fn scan(image: &image::GrayImage, config: &ScanConfig) -> anyhow::Result<ScanResult> {
115    use detector::Detector;
116    
117    match config.mode {
118        ScanMode::Invisible => {
119            let detector = invisible::detector::InvisibleDetector::new(config.clone());
120            detector.detect(image)
121        },
122        _ => {
123            let detector = aruco::ArucoDetector::new(config.clone());
124            detector.detect(image)
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::aruco::Dictionary;
133
134    #[test]
135    fn test_dictionary_load() {
136        let dict = Dictionary::new("ARUCO_4X4_50");
137        assert!(dict.is_some(), "Should load ARUCO_4X4_50");
138        
139        let dict = dict.unwrap();
140        assert_eq!(dict.n_bits, 16);
141    }
142    
143    #[test]
144    fn test_hamming_distance() {
145        let dict = Dictionary::new("ARUCO_4X4_50").unwrap();
146        // Code 0 in definition: [181, 50] -> 10110101 00110010 = 0xB532
147        let code_0_val = 0xB532; 
148        
149        // Exact match
150        let found = dict.find(code_0_val, 0);
151        assert!(found.is_some());
152        assert_eq!(found.unwrap().0, 0); // ID 0
153        
154        // 1 bit error
155        let code_error = code_0_val ^ 1; 
156        let found_err = dict.find(code_error, 1);
157        assert!(found_err.is_some());
158        assert_eq!(found_err.unwrap().0, 0);
159        
160        // 2 bit error (fail)
161        let code_error_2 = code_0_val ^ 3; 
162        let found_err_2 = dict.find(code_error_2, 1);
163        assert!(found_err_2.is_none());
164    }
165
166    #[test]
167    fn test_perspective_math() {
168         use crate::transform::perspective::PerspectiveTransform;
169         use crate::Point;
170         
171         let src = vec![
172             Point{x:0.0, y:0.0}, Point{x:10.0, y:0.0},
173             Point{x:10.0, y:10.0}, Point{x:0.0, y:10.0}
174         ];
175         let dst = src.clone();
176         
177         let pt = PerspectiveTransform::new(&src, &dst).expect("Valid transform");
178         
179         let p = pt.transform_inverse(5.0, 5.0);
180         assert!((p.x - 5.0).abs() < 0.001);
181         assert!((p.y - 5.0).abs() < 0.001);
182    }
183
184    #[test]
185    fn test_perspective_16_points() {
186         use crate::transform::perspective::PerspectiveTransform;
187         use crate::Point;
188         
189         // Ideal square corners (4 corners x 4 markers)
190         let mut src = Vec::new();
191         let mut dst = Vec::new();
192         
193         // Mocking 4 markers at the corners of a 100x100 space
194         let marker_pos = [(0.0, 0.0), (90.0, 0.0), (90.0, 90.0), (0.0, 90.0)];
195         for (mx, my) in marker_pos {
196             for (dx, dy) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] {
197                 dst.push(Point { x: mx + dx, y: my + dy });
198                 // Add some noise to src
199                 src.push(Point { x: mx + dx + 0.1, y: my + dy - 0.1 });
200             }
201         }
202         
203         let pt = PerspectiveTransform::new(&src, &dst).expect("Should solve 16 points");
204         
205         // Test a point in the middle
206         let p = pt.transform(50.0, 50.0);
207         println!("Projected 50,50 -> {:.4}, {:.4}", p.x, p.y);
208         assert!((p.x - 50.1).abs() < 0.5); 
209         assert!((p.y - 49.9).abs() < 0.5);
210    }
211}