Skip to main content

locus_core/test_utils/
mod.rs

1use rand_distr::{Distribution, Normal};
2
3/// Generate a synthetic image containing a single AprilTag or ArUco tag.
4///
5/// This generates a tag with a white quiet zone, placed on a white background,
6/// matching the setup used in Python benchmarks.
7/// rotation (rad), translation (x,y), scaling (pixels).
8#[must_use]
9#[allow(
10    clippy::cast_possible_truncation,
11    clippy::cast_sign_loss,
12    clippy::missing_panics_doc
13)]
14pub fn generate_synthetic_test_image(
15    family: crate::config::TagFamily,
16    id: u16,
17    tag_size: usize,
18    canvas_size: usize,
19    noise_sigma: f32,
20) -> (Vec<u8>, [[f64; 2]; 4]) {
21    let mut data = vec![255u8; canvas_size * canvas_size];
22
23    // Calculate tag position (centered)
24    let margin = (canvas_size - tag_size) / 2;
25    let quiet_zone = tag_size / 5;
26
27    // Draw white quiet zone (optional since background is 255, but adds robustness)
28    for y in margin.saturating_sub(quiet_zone)..(margin + tag_size + quiet_zone).min(canvas_size) {
29        for x in
30            margin.saturating_sub(quiet_zone)..(margin + tag_size + quiet_zone).min(canvas_size)
31        {
32            data[y * canvas_size + x] = 255;
33        }
34    }
35
36    // Generate tag pattern (bits)
37    let decoder = crate::decoder::family_to_decoder(family);
38    let code = decoder.get_code(id).expect("Invalid tag ID for family");
39    let dim = decoder.dimension();
40
41    let cell_size = tag_size / (dim + 2); // dim + 2 for black border
42    let actual_tag_size = cell_size * (dim + 2);
43    let start_x = margin + (tag_size - actual_tag_size) / 2;
44    let start_y = margin + (tag_size - actual_tag_size) / 2;
45
46    // Draw black border
47    for y in 0..(dim + 2) {
48        for x in 0..(dim + 2) {
49            if x == 0 || x == dim + 1 || y == 0 || y == dim + 1 {
50                draw_cell(&mut data, canvas_size, start_x, start_y, x, y, cell_size, 0);
51            }
52        }
53    }
54
55    // Draw data bits using decoder's canonical sampling points
56    let points = decoder.sample_points();
57    let d_f = (dim + 2) as f64;
58    for (i, p) in points.iter().enumerate() {
59        // Map canonical [-1, 1] to grid index [0, D]
60        let gx = ((p.0 + 1.0) * d_f / 2.0 - 0.5).round() as usize;
61        let gy = ((p.1 + 1.0) * d_f / 2.0 - 0.5).round() as usize;
62
63        let bit = (code >> i) & 1;
64        let val = if bit != 0 { 255 } else { 0 };
65        draw_cell(
66            &mut data,
67            canvas_size,
68            start_x,
69            start_y,
70            gx,
71            gy,
72            cell_size,
73            val,
74        );
75    }
76
77    if noise_sigma > 0.0 {
78        let mut rng = rand::rng();
79        let normal = Normal::new(0.0, f64::from(noise_sigma)).expect("Invalid noise params");
80
81        for pixel in &mut data {
82            let noise = normal.sample(&mut rng) as i32;
83            let val = (i32::from(*pixel) + noise).clamp(0, 255);
84            *pixel = val as u8;
85        }
86    }
87
88    let gt_corners = [
89        [start_x as f64, start_y as f64],
90        [(start_x + actual_tag_size) as f64, start_y as f64],
91        [
92            (start_x + actual_tag_size) as f64,
93            (start_y + actual_tag_size) as f64,
94        ],
95        [start_x as f64, (start_y + actual_tag_size) as f64],
96    ];
97
98    (data, gt_corners)
99}
100
101#[allow(clippy::too_many_arguments)]
102fn draw_cell(
103    data: &mut [u8],
104    stride: usize,
105    start_x: usize,
106    start_y: usize,
107    cx: usize,
108    cy: usize,
109    size: usize,
110    val: u8,
111) {
112    let px = start_x + cx * size;
113    let py = start_y + cy * size;
114    for y in py..(py + size) {
115        for x in px..(px + size) {
116            data[y * stride + x] = val;
117        }
118    }
119}
120
121/// Compute RMSE between detected and ground truth corners.
122/// Use this when comparing against ground truth datasets with known corner ordering conventions.
123#[must_use]
124pub fn compute_corner_error(detected: &[[f64; 2]; 4], ground_truth: &[[f64; 2]; 4]) -> f64 {
125    compute_rmse(detected, ground_truth)
126}
127
128/// Compute RMSE corner error between detected and ground truth corners.
129///
130/// Unlike `compute_corner_error`, this does NOT try rotations - it assumes corners
131/// are already in the correct order. Use this when comparing against ground truth
132/// datasets with known corner ordering conventions.
133///
134/// Formula: sqrt( (sum of squared distances for all 4 corners) / 4 )
135#[must_use]
136pub fn compute_rmse(detected: &[[f64; 2]; 4], ground_truth: &[[f64; 2]; 4]) -> f64 {
137    let mut sum_sq = 0.0;
138    for i in 0..4 {
139        let dx = detected[i][0] - ground_truth[i][0];
140        let dy = detected[i][1] - ground_truth[i][1];
141        sum_sq += dx * dx + dy * dy;
142    }
143    (sum_sq / 4.0).sqrt()
144}
145
146// ============================================================================
147// ROBUSTNESS TEST UTILITIES
148// ============================================================================
149
150/// Parameters for generating test images with photometric variations.
151#[derive(Clone, Debug)]
152#[allow(dead_code)]
153pub(crate) struct TestImageParams {
154    /// Tag family to generate.
155    pub family: crate::config::TagFamily,
156    /// Tag ID to generate.
157    pub id: u16,
158    /// Tag size in pixels.
159    pub tag_size: usize,
160    /// Canvas size in pixels.
161    pub canvas_size: usize,
162    /// Gaussian noise standard deviation (0.0 = no noise).
163    pub noise_sigma: f32,
164    /// Brightness offset (-255 to +255).
165    pub brightness_offset: i16,
166    /// Contrast scale (1.0 = no change, 0.5 = reduce, 1.5 = increase).
167    pub contrast_scale: f32,
168}
169
170impl Default for TestImageParams {
171    fn default() -> Self {
172        Self {
173            family: crate::config::TagFamily::AprilTag36h11,
174            id: 0,
175            tag_size: 100,
176            canvas_size: 320,
177            noise_sigma: 0.0,
178            brightness_offset: 0,
179            contrast_scale: 1.0,
180        }
181    }
182}
183
184/// Generate a test image based on the provided parameters.
185/// Includes tag generation, placement, and photometric adjustments.
186#[must_use]
187#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
188#[allow(dead_code)]
189pub(crate) fn generate_test_image_with_params(
190    params: &TestImageParams,
191) -> (Vec<u8>, [[f64; 2]; 4]) {
192    // First generate base image
193    let (mut data, corners) = generate_synthetic_test_image(
194        params.family,
195        params.id,
196        params.tag_size,
197        params.canvas_size,
198        params.noise_sigma,
199    );
200
201    // Apply brightness and contrast adjustments
202    if params.brightness_offset != 0 || (params.contrast_scale - 1.0).abs() > 0.001 {
203        apply_brightness_contrast(
204            &mut data,
205            i32::from(params.brightness_offset),
206            params.contrast_scale,
207        );
208    }
209
210    (data, corners)
211}
212
213/// Apply brightness and contrast to an image.
214/// `brightness`: -255 to +255
215/// `contrast`: 0.0 to 127.0 (1.0 = no change)
216#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
217#[allow(dead_code)]
218pub(crate) fn apply_brightness_contrast(image: &mut [u8], brightness: i32, contrast: f32) {
219    for pixel in image.iter_mut() {
220        let b = f32::from(*pixel);
221        let with_contrast = (b - 128.0) * contrast + 128.0;
222        let with_brightness = with_contrast as i32 + brightness;
223        *pixel = with_brightness.clamp(0, 255) as u8;
224    }
225}
226
227/// Count black pixels in binary data.
228#[must_use]
229#[allow(clippy::naive_bytecount)]
230#[allow(dead_code)]
231pub(crate) fn count_black_pixels(data: &[u8]) -> usize {
232    data.iter().filter(|&&p| p == 0).count()
233}
234
235/// Check if the tag's outer black border is correctly binarized.
236/// Returns the ratio of correctly black pixels in the 1-cell-wide border (0.0 to 1.0).
237#[must_use]
238#[allow(clippy::cast_sign_loss)]
239#[allow(dead_code)]
240pub(crate) fn measure_border_integrity(
241    binary: &[u8],
242    width: usize,
243    corners: &[[f64; 2]; 4],
244) -> f64 {
245    let min_x = corners
246        .iter()
247        .map(|c| c[0])
248        .fold(f64::MAX, f64::min)
249        .max(0.0) as usize;
250    let max_x = corners
251        .iter()
252        .map(|c| c[0])
253        .fold(f64::MIN, f64::max)
254        .max(0.0) as usize;
255    let min_y = corners
256        .iter()
257        .map(|c| c[1])
258        .fold(f64::MAX, f64::min)
259        .max(0.0) as usize;
260    let max_y = corners
261        .iter()
262        .map(|c| c[1])
263        .fold(f64::MIN, f64::max)
264        .max(0.0) as usize;
265
266    let height = binary.len() / width;
267    let min_x = min_x.min(width.saturating_sub(1));
268    let max_x = max_x.min(width.saturating_sub(1));
269    let min_y = min_y.min(height.saturating_sub(1));
270    let max_y = max_y.min(height.saturating_sub(1));
271
272    if max_x <= min_x || max_y <= min_y {
273        return 0.0;
274    }
275
276    let tag_width = max_x - min_x;
277    let tag_height = max_y - min_y;
278
279    // For AprilTag 36h11: 8 cells total, border is 1 cell = 1/8 of tag
280    let cell_size_x = tag_width / 8;
281    let cell_size_y = tag_height / 8;
282
283    if cell_size_x == 0 || cell_size_y == 0 {
284        return 0.0;
285    }
286
287    let mut black_count = 0usize;
288    let mut total_count = 0usize;
289
290    // Top border row
291    for y in min_y..(min_y + cell_size_y).min(max_y) {
292        for x in min_x..=max_x {
293            if y < height && x < width {
294                total_count += 1;
295                if binary[y * width + x] == 0 {
296                    black_count += 1;
297                }
298            }
299        }
300    }
301
302    // Bottom border row
303    let bottom_start = max_y.saturating_sub(cell_size_y);
304    for y in bottom_start..=max_y {
305        for x in min_x..=max_x {
306            if y < height && x < width {
307                total_count += 1;
308                if binary[y * width + x] == 0 {
309                    black_count += 1;
310                }
311            }
312        }
313    }
314
315    // Left border column (excluding corners)
316    for y in (min_y + cell_size_y)..(max_y.saturating_sub(cell_size_y)) {
317        for x in min_x..(min_x + cell_size_x).min(max_x) {
318            if y < height && x < width {
319                total_count += 1;
320                if binary[y * width + x] == 0 {
321                    black_count += 1;
322                }
323            }
324        }
325    }
326
327    // Right border column (excluding corners)
328    let right_start = max_x.saturating_sub(cell_size_x);
329    for y in (min_y + cell_size_y)..(max_y.saturating_sub(cell_size_y)) {
330        for x in right_start..=max_x {
331            if y < height && x < width {
332                total_count += 1;
333                if binary[y * width + x] == 0 {
334                    black_count += 1;
335                }
336            }
337        }
338    }
339
340    if total_count == 0 {
341        0.0
342    } else {
343        black_count as f64 / total_count as f64
344    }
345}
346
347/// Generates a checkered pattern image for benchmarking.
348#[must_use]
349pub fn generate_checkered(width: usize, height: usize) -> Vec<u8> {
350    let mut data = vec![200u8; width * height];
351    for y in (0..height).step_by(16) {
352        for x in (0..width).step_by(16) {
353            if ((x / 16) + (y / 16)) % 2 == 0 {
354                for dy in 0..16 {
355                    if y + dy < height {
356                        let row_off = (y + dy) * width;
357                        for dx in 0..16 {
358                            if x + dx < width {
359                                data[row_off + x + dx] = 50;
360                            }
361                        }
362                    }
363                }
364            }
365        }
366    }
367    data
368}
369
370/// Sub-pixel edge rendering utilities for accuracy testing.
371pub mod subpixel;
372
373/// Complex multi-tag scene generation for integration testing.
374#[cfg(any(feature = "extended-tests", feature = "extended-bench"))]
375pub mod scene;
376#[cfg(any(feature = "extended-tests", feature = "extended-bench"))]
377pub use scene::{SceneBuilder, TagPlacement};