1use rand_distr::{Distribution, Normal};
2
3#[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 let margin = (canvas_size - tag_size) / 2;
25 let quiet_zone = tag_size / 5;
26
27 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 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); 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 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 let points = decoder.sample_points();
57 let d_f = (dim + 2) as f64;
58 for (i, p) in points.iter().enumerate() {
59 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#[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#[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#[derive(Clone, Debug)]
152#[allow(dead_code)]
153pub(crate) struct TestImageParams {
154 pub family: crate::config::TagFamily,
156 pub id: u16,
158 pub tag_size: usize,
160 pub canvas_size: usize,
162 pub noise_sigma: f32,
164 pub brightness_offset: i16,
166 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#[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 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 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#[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#[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#[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 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 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 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 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 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#[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
370pub mod subpixel;
372
373#[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};