1use serde::{Deserialize, Serialize};
16
17#[cfg(feature = "rayon")]
18use rayon::prelude::*;
19
20#[cfg(all(feature = "simd", not(feature = "radon-sat-u32")))]
21use core::simd::Simd;
22
23#[cfg(all(feature = "simd", not(feature = "radon-sat-u32")))]
24use std::simd::cmp::SimdOrd;
25
26use super::primitives::{box_blur_inplace, PeakFitMode};
27use crate::ResponseMap;
28
29#[cfg(all(feature = "simd", not(feature = "radon-sat-u32")))]
35const RADON_LANES: usize = 8;
36
37#[cfg(not(feature = "radon-sat-u32"))]
40pub type SatElem = i64;
41
42#[cfg(feature = "radon-sat-u32")]
44pub type SatElem = u32;
45
46#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
48#[serde(default)]
49pub struct RadonDetectorParams {
50 pub ray_radius: u32,
54 pub image_upsample: u32,
59 pub response_blur_radius: u32,
62 pub peak_fit: PeakFitMode,
65 pub threshold_rel: f32,
68 pub threshold_abs: Option<f32>,
73 pub nms_radius: u32,
76 pub min_cluster_size: u32,
79}
80
81impl Default for RadonDetectorParams {
82 fn default() -> Self {
83 Self {
84 ray_radius: 4,
85 image_upsample: 2,
86 response_blur_radius: 1,
87 peak_fit: PeakFitMode::Gaussian,
88 threshold_rel: 0.01,
89 threshold_abs: None,
90 nms_radius: 4,
91 min_cluster_size: 2,
92 }
93 }
94}
95
96pub const MAX_IMAGE_UPSAMPLE: u32 = 2;
101
102impl RadonDetectorParams {
103 #[inline]
107 pub(crate) fn image_upsample_clamped(&self) -> u32 {
108 self.image_upsample.clamp(1, MAX_IMAGE_UPSAMPLE)
109 }
110
111 #[inline]
112 pub(crate) fn ray_radius_clamped(&self) -> u32 {
113 self.ray_radius.max(1)
114 }
115}
116
117#[derive(Debug, Default)]
122pub struct RadonBuffers {
123 upsampled: Vec<u8>,
124 working_w: usize,
125 working_h: usize,
126 row_cumsum: Vec<SatElem>,
127 col_cumsum: Vec<SatElem>,
128 diag_pos_cumsum: Vec<SatElem>,
129 diag_neg_cumsum: Vec<SatElem>,
130 response: Vec<f32>,
131 blur_scratch: Vec<f32>,
132}
133
134impl RadonBuffers {
135 pub fn new() -> Self {
137 Self::default()
138 }
139
140 fn ensure_capacity(&mut self, input_w: usize, input_h: usize, upsample: u32) {
141 let up = upsample.max(1) as usize;
142 let ww = input_w * up;
143 let wh = input_h * up;
144 let n = ww * wh;
145 self.working_w = ww;
146 self.working_h = wh;
147 if up > 1 {
148 self.upsampled.resize(n, 0);
149 } else {
150 self.upsampled.clear();
151 }
152 self.row_cumsum.resize(n, SatElem::default());
153 self.col_cumsum.resize(n, SatElem::default());
154 self.diag_pos_cumsum.resize(n, SatElem::default());
155 self.diag_neg_cumsum.resize(n, SatElem::default());
156 self.response.resize(n, 0.0);
157 self.blur_scratch.resize(n, 0.0);
158 }
159}
160
161fn upsample_bilinear_2x(src: &[u8], w: usize, h: usize, out: &mut [u8]) {
162 debug_assert_eq!(src.len(), w * h);
163 debug_assert_eq!(out.len(), 4 * w * h);
164 if w == 0 || h == 0 {
165 return;
166 }
167 let ww = 2 * w;
168
169 let row_kernel = |iy: usize, dst: &mut [u8]| {
170 let sy = iy as f32 * 0.5;
171 let y0f = sy.floor();
172 let y0 = (y0f as isize).max(0) as usize;
173 let y1 = (y0 + 1).min(h - 1);
174 let ty = (sy - y0f).clamp(0.0, 1.0);
175 for (ix, out_px) in dst.iter_mut().enumerate() {
176 let sx = ix as f32 * 0.5;
177 let x0f = sx.floor();
178 let x0 = (x0f as isize).max(0) as usize;
179 let x1 = (x0 + 1).min(w - 1);
180 let tx = (sx - x0f).clamp(0.0, 1.0);
181 let i00 = src[y0 * w + x0] as f32;
182 let i10 = src[y0 * w + x1] as f32;
183 let i01 = src[y1 * w + x0] as f32;
184 let i11 = src[y1 * w + x1] as f32;
185 let a = i00 + (i10 - i00) * tx;
186 let b = i01 + (i11 - i01) * tx;
187 let v = a + (b - a) * ty;
188 *out_px = v.round().clamp(0.0, 255.0) as u8;
189 }
190 };
191
192 #[cfg(feature = "rayon")]
193 {
194 out.par_chunks_mut(ww)
195 .enumerate()
196 .for_each(|(iy, row)| row_kernel(iy, row));
197 }
198 #[cfg(not(feature = "rayon"))]
199 {
200 for (iy, row) in out.chunks_mut(ww).enumerate() {
201 row_kernel(iy, row);
202 }
203 }
204}
205
206#[inline]
207fn sat_row(img: &[u8], w: usize, h: usize, row_cumsum: &mut [SatElem]) {
208 debug_assert_eq!(row_cumsum.len(), w * h);
209 for y in 0..h {
210 let mut acc: SatElem = SatElem::default();
211 for x in 0..w {
212 acc += SatElem::from(img[y * w + x]);
213 row_cumsum[y * w + x] = acc;
214 }
215 }
216}
217
218#[inline]
219fn sat_col(img: &[u8], w: usize, h: usize, col_cumsum: &mut [SatElem]) {
220 debug_assert_eq!(col_cumsum.len(), w * h);
221 for x in 0..w {
222 let mut acc: SatElem = SatElem::default();
223 for y in 0..h {
224 acc += SatElem::from(img[y * w + x]);
225 col_cumsum[y * w + x] = acc;
226 }
227 }
228}
229
230#[inline]
231fn sat_diag_pos(img: &[u8], w: usize, h: usize, diag_pos_cumsum: &mut [SatElem]) {
232 debug_assert_eq!(diag_pos_cumsum.len(), w * h);
233 for y in 0..h {
234 for x in 0..w {
235 let prev = if y > 0 && x > 0 {
236 diag_pos_cumsum[(y - 1) * w + (x - 1)]
237 } else {
238 SatElem::default()
239 };
240 diag_pos_cumsum[y * w + x] = prev + SatElem::from(img[y * w + x]);
241 }
242 }
243}
244
245#[inline]
246fn sat_diag_neg(img: &[u8], w: usize, h: usize, diag_neg_cumsum: &mut [SatElem]) {
247 debug_assert_eq!(diag_neg_cumsum.len(), w * h);
248 for y in 0..h {
249 for x in 0..w {
250 let prev = if y > 0 && x + 1 < w {
251 diag_neg_cumsum[(y - 1) * w + (x + 1)]
252 } else {
253 SatElem::default()
254 };
255 diag_neg_cumsum[y * w + x] = prev + SatElem::from(img[y * w + x]);
256 }
257 }
258}
259
260fn build_cumsums(
261 img: &[u8],
262 w: usize,
263 h: usize,
264 row_cumsum: &mut [SatElem],
265 col_cumsum: &mut [SatElem],
266 diag_pos_cumsum: &mut [SatElem],
267 diag_neg_cumsum: &mut [SatElem],
268) {
269 debug_assert_eq!(img.len(), w * h);
270
271 #[cfg(feature = "rayon")]
272 {
273 rayon::join(
274 || {
275 rayon::join(
276 || sat_row(img, w, h, row_cumsum),
277 || sat_col(img, w, h, col_cumsum),
278 );
279 },
280 || {
281 rayon::join(
282 || sat_diag_pos(img, w, h, diag_pos_cumsum),
283 || sat_diag_neg(img, w, h, diag_neg_cumsum),
284 );
285 },
286 );
287 }
288 #[cfg(not(feature = "rayon"))]
289 {
290 sat_row(img, w, h, row_cumsum);
291 sat_col(img, w, h, col_cumsum);
292 sat_diag_pos(img, w, h, diag_pos_cumsum);
293 sat_diag_neg(img, w, h, diag_neg_cumsum);
294 }
295}
296
297struct Cumsums<'a> {
299 row: &'a [SatElem],
300 col: &'a [SatElem],
301 diag_pos: &'a [SatElem],
302 diag_neg: &'a [SatElem],
303 w: usize,
304 h: usize,
305}
306
307#[inline(always)]
308fn radon_response_at(cs: &Cumsums<'_>, r: usize, x: usize, y: usize) -> f32 {
309 let w = cs.w;
310 let s_h_hi = cs.row[y * w + (x + r)];
311 let s_h_lo = if x > r {
312 cs.row[y * w + (x - r - 1)]
313 } else {
314 SatElem::default()
315 };
316 let s_h = s_h_hi - s_h_lo;
317
318 let s_v_hi = cs.col[(y + r) * w + x];
319 let s_v_lo = if y > r {
320 cs.col[(y - r - 1) * w + x]
321 } else {
322 SatElem::default()
323 };
324 let s_v = s_v_hi - s_v_lo;
325
326 let s_d1_hi = cs.diag_pos[(y + r) * w + (x + r)];
327 let s_d1_lo = if x > r && y > r {
328 cs.diag_pos[(y - r - 1) * w + (x - r - 1)]
329 } else {
330 SatElem::default()
331 };
332 let s_d1 = s_d1_hi - s_d1_lo;
333
334 let s_d2_hi = cs.diag_neg[(y + r) * w + (x - r)];
335 let s_d2_lo = if y > r && x + r + 1 < w {
336 cs.diag_neg[(y - r - 1) * w + (x + r + 1)]
337 } else {
338 SatElem::default()
339 };
340 let s_d2 = s_d2_hi - s_d2_lo;
341
342 let s = [s_h, s_v, s_d1, s_d2];
343 let (mut mx, mut mn) = (s[0], s[0]);
344 for &v in &s[1..] {
345 if v > mx {
346 mx = v;
347 }
348 if v < mn {
349 mn = v;
350 }
351 }
352 let d = sat_to_f32(mx - mn);
353 d * d
354}
355
356#[inline]
357fn compute_response_row(cs: &Cumsums<'_>, ray_radius: usize, y: usize, row: &mut [f32]) {
358 let w = cs.w;
359 let h = cs.h;
360 let r = ray_radius;
361 if y < r || y + r >= h {
362 for v in row.iter_mut() {
363 *v = 0.0;
364 }
365 return;
366 }
367 for v in row[..r].iter_mut() {
368 *v = 0.0;
369 }
370
371 #[cfg(all(feature = "simd", not(feature = "radon-sat-u32")))]
372 {
373 compute_response_row_simd(cs, r, y, row);
374 }
375 #[cfg(not(all(feature = "simd", not(feature = "radon-sat-u32"))))]
376 {
377 for (x, out_px) in row.iter_mut().enumerate().take(w - r).skip(r) {
378 *out_px = radon_response_at(cs, r, x, y);
379 }
380 }
381
382 for v in row[(w - r)..].iter_mut() {
383 *v = 0.0;
384 }
385}
386
387#[cfg(all(feature = "simd", not(feature = "radon-sat-u32")))]
388#[inline]
389fn compute_response_row_simd(cs: &Cumsums<'_>, r: usize, y: usize, row: &mut [f32]) {
390 type S = Simd<i64, RADON_LANES>;
391
392 let w = cs.w;
393
394 row[r] = radon_response_at(cs, r, r, y);
395
396 let interior_start = r + 1;
397 let interior_end = w - r;
398 let mut x = interior_start;
399
400 if y <= r {
401 for (x, cell) in row
402 .iter_mut()
403 .enumerate()
404 .take(interior_end)
405 .skip(interior_start)
406 {
407 *cell = radon_response_at(cs, r, x, y);
408 }
409 return;
410 }
411
412 let h_row_base = y * w;
413 let v_hi_base = (y + r) * w;
414 let v_lo_base = (y - r - 1) * w;
415 let d1_hi_base = (y + r) * w;
416 let d1_lo_base = (y - r - 1) * w;
417 let d2_hi_base = (y + r) * w;
418 let d2_lo_base = (y - r - 1) * w;
419
420 while x + RADON_LANES < interior_end {
421 let s_h_hi = S::from_slice(&cs.row[h_row_base + x + r..h_row_base + x + r + RADON_LANES]);
422 let s_h_lo =
423 S::from_slice(&cs.row[h_row_base + x - r - 1..h_row_base + x - r - 1 + RADON_LANES]);
424 let s_h = s_h_hi - s_h_lo;
425
426 let s_v_hi = S::from_slice(&cs.col[v_hi_base + x..v_hi_base + x + RADON_LANES]);
427 let s_v_lo = S::from_slice(&cs.col[v_lo_base + x..v_lo_base + x + RADON_LANES]);
428 let s_v = s_v_hi - s_v_lo;
429
430 let s_d1_hi =
431 S::from_slice(&cs.diag_pos[d1_hi_base + x + r..d1_hi_base + x + r + RADON_LANES]);
432 let s_d1_lo = S::from_slice(
433 &cs.diag_pos[d1_lo_base + x - r - 1..d1_lo_base + x - r - 1 + RADON_LANES],
434 );
435 let s_d1 = s_d1_hi - s_d1_lo;
436
437 let s_d2_hi =
438 S::from_slice(&cs.diag_neg[d2_hi_base + x - r..d2_hi_base + x - r + RADON_LANES]);
439 let s_d2_lo = S::from_slice(
440 &cs.diag_neg[d2_lo_base + x + r + 1..d2_lo_base + x + r + 1 + RADON_LANES],
441 );
442 let s_d2 = s_d2_hi - s_d2_lo;
443
444 let mx = s_h.simd_max(s_v).simd_max(s_d1.simd_max(s_d2));
445 let mn = s_h.simd_min(s_v).simd_min(s_d1.simd_min(s_d2));
446 let diff = mx - mn;
447 let arr = diff.to_array();
448 for (lane, v) in arr.iter().enumerate() {
449 let f = *v as f32;
450 row[x + lane] = f * f;
451 }
452
453 x += RADON_LANES;
454 }
455
456 while x < interior_end {
457 row[x] = radon_response_at(cs, r, x, y);
458 x += 1;
459 }
460}
461
462fn compute_response(cs: &Cumsums<'_>, ray_radius: usize, out: &mut [f32]) {
463 let w = cs.w;
464 let h = cs.h;
465 debug_assert_eq!(out.len(), w * h);
466 if w <= 2 * ray_radius || h <= 2 * ray_radius {
467 out.fill(0.0);
468 return;
469 }
470
471 #[cfg(feature = "rayon")]
472 {
473 out.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
474 compute_response_row(cs, ray_radius, y, row);
475 });
476 }
477 #[cfg(not(feature = "rayon"))]
478 {
479 for (y, row) in out.chunks_mut(w).enumerate() {
480 compute_response_row(cs, ray_radius, y, row);
481 }
482 }
483}
484
485#[inline]
486fn sat_to_f32(v: SatElem) -> f32 {
487 v as f32
488}
489
490pub fn radon_response_u8<'a>(
494 img: &[u8],
495 w: usize,
496 h: usize,
497 params: &RadonDetectorParams,
498 buffers: &'a mut RadonBuffers,
499) -> RadonResponseView<'a> {
500 assert_eq!(img.len(), w * h, "img len must equal w*h");
501 let up = params.image_upsample_clamped();
502 buffers.ensure_capacity(w, h, up);
503 let ww = buffers.working_w;
504 let wh = buffers.working_h;
505
506 #[cfg(feature = "radon-sat-u32")]
507 {
508 let pixels = (ww as u64) * (wh as u64);
509 let max_sum = 255u64.checked_mul(pixels);
510 assert!(
511 matches!(max_sum, Some(v) if v <= u32::MAX as u64),
512 "radon-sat-u32: 255*W*H ({}*{}) exceeds u32::MAX; \
513 either rebuild without the radon-sat-u32 feature or \
514 downsample the input",
515 ww,
516 wh,
517 );
518 }
519
520 let working_img: &[u8] = if up > 1 {
521 upsample_bilinear_2x_if_needed(img, w, h, up, &mut buffers.upsampled);
522 &buffers.upsampled
523 } else {
524 img
525 };
526
527 build_cumsums(
528 working_img,
529 ww,
530 wh,
531 &mut buffers.row_cumsum,
532 &mut buffers.col_cumsum,
533 &mut buffers.diag_pos_cumsum,
534 &mut buffers.diag_neg_cumsum,
535 );
536
537 let cs = Cumsums {
538 row: &buffers.row_cumsum,
539 col: &buffers.col_cumsum,
540 diag_pos: &buffers.diag_pos_cumsum,
541 diag_neg: &buffers.diag_neg_cumsum,
542 w: ww,
543 h: wh,
544 };
545 compute_response(
546 &cs,
547 params.ray_radius_clamped() as usize,
548 &mut buffers.response,
549 );
550
551 box_blur_inplace(
552 &mut buffers.response,
553 &mut buffers.blur_scratch,
554 ww,
555 wh,
556 params.response_blur_radius as usize,
557 );
558 RadonResponseView {
559 data: &buffers.response,
560 w: ww,
561 h: wh,
562 }
563}
564
565#[derive(Debug)]
570pub struct RadonResponseView<'a> {
571 pub(super) data: &'a [f32],
572 pub(super) w: usize,
573 pub(super) h: usize,
574}
575
576impl<'a> RadonResponseView<'a> {
577 #[inline]
578 pub fn width(&self) -> usize {
579 self.w
580 }
581
582 #[inline]
583 pub fn height(&self) -> usize {
584 self.h
585 }
586
587 #[inline]
588 pub fn data(&self) -> &[f32] {
589 self.data
590 }
591
592 #[inline]
593 pub fn at(&self, x: usize, y: usize) -> f32 {
594 self.data[y * self.w + x]
595 }
596
597 pub fn to_response_map(&self) -> ResponseMap {
598 ResponseMap {
599 w: self.w,
600 h: self.h,
601 data: self.data.to_vec(),
602 }
603 }
604}
605
606#[inline]
607fn upsample_bilinear_2x_if_needed(img: &[u8], w: usize, h: usize, up: u32, out: &mut Vec<u8>) {
608 debug_assert_eq!(up, 2, "image_upsample must be 1 or 2");
609 let _ = up;
610 out.resize(4 * w * h, 0);
611 upsample_bilinear_2x(img, w, h, out);
612}
613
614#[cfg(test)]
615mod tests {
616 use super::super::test_fixtures::synthetic_chessboard_aa;
617 use super::*;
618
619 #[test]
620 fn row_cumsum_matches_naive_sum() {
621 let w = 5usize;
622 let h = 3usize;
623 let img: Vec<u8> = (0..(w * h) as u8).collect();
624 let mut r = vec![SatElem::default(); w * h];
625 let mut c = vec![SatElem::default(); w * h];
626 let mut d1 = vec![SatElem::default(); w * h];
627 let mut d2 = vec![SatElem::default(); w * h];
628 build_cumsums(&img, w, h, &mut r, &mut c, &mut d1, &mut d2);
629 for y in 0..h {
630 let mut expected: SatElem = SatElem::default();
631 for x in 0..w {
632 expected += SatElem::from(img[y * w + x]);
633 assert_eq!(r[y * w + x], expected);
634 }
635 }
636 }
637
638 #[test]
639 fn col_cumsum_matches_naive_sum() {
640 let w = 4usize;
641 let h = 5usize;
642 let img: Vec<u8> = (0..(w * h) as u8).collect();
643 let mut r = vec![SatElem::default(); w * h];
644 let mut c = vec![SatElem::default(); w * h];
645 let mut d1 = vec![SatElem::default(); w * h];
646 let mut d2 = vec![SatElem::default(); w * h];
647 build_cumsums(&img, w, h, &mut r, &mut c, &mut d1, &mut d2);
648 for x in 0..w {
649 let mut expected: SatElem = SatElem::default();
650 for y in 0..h {
651 expected += SatElem::from(img[y * w + x]);
652 assert_eq!(c[y * w + x], expected);
653 }
654 }
655 }
656
657 #[test]
658 fn diag_pos_cumsum_matches_naive_sum() {
659 let w = 4usize;
660 let h = 4usize;
661 let img: Vec<u8> = (0..(w * h) as u8).collect();
662 let mut r = vec![SatElem::default(); w * h];
663 let mut c = vec![SatElem::default(); w * h];
664 let mut d1 = vec![SatElem::default(); w * h];
665 let mut d2 = vec![SatElem::default(); w * h];
666 build_cumsums(&img, w, h, &mut r, &mut c, &mut d1, &mut d2);
667 for y in 0..h {
668 for x in 0..w {
669 let mut expected: SatElem = SatElem::default();
670 let mut xi = x as isize;
671 let mut yi = y as isize;
672 while xi >= 0 && yi >= 0 {
673 expected += SatElem::from(img[yi as usize * w + xi as usize]);
674 xi -= 1;
675 yi -= 1;
676 }
677 assert_eq!(d1[y * w + x], expected, "at ({},{})", x, y);
678 }
679 }
680 }
681
682 #[test]
683 fn diag_neg_cumsum_matches_naive_sum() {
684 let w = 4usize;
685 let h = 4usize;
686 let img: Vec<u8> = (0..(w * h) as u8).collect();
687 let mut r = vec![SatElem::default(); w * h];
688 let mut c = vec![SatElem::default(); w * h];
689 let mut d1 = vec![SatElem::default(); w * h];
690 let mut d2 = vec![SatElem::default(); w * h];
691 build_cumsums(&img, w, h, &mut r, &mut c, &mut d1, &mut d2);
692 for y in 0..h {
693 for x in 0..w {
694 let mut expected: SatElem = SatElem::default();
695 let mut xi = x as isize;
696 let mut yi = y as isize;
697 while xi < w as isize && yi >= 0 {
698 expected += SatElem::from(img[yi as usize * w + xi as usize]);
699 xi += 1;
700 yi -= 1;
701 }
702 assert_eq!(d2[y * w + x], expected, "at ({},{})", x, y);
703 }
704 }
705 }
706
707 #[test]
708 fn ray_sums_via_sat_match_direct_sums() {
709 let w = 15usize;
710 let h = 15usize;
711 let img: Vec<u8> = (0..(w * h)).map(|i| (i % 251) as u8).collect();
712 let mut r = vec![SatElem::default(); w * h];
713 let mut c = vec![SatElem::default(); w * h];
714 let mut d1 = vec![SatElem::default(); w * h];
715 let mut d2 = vec![SatElem::default(); w * h];
716 build_cumsums(&img, w, h, &mut r, &mut c, &mut d1, &mut d2);
717
718 let ray_r = 3usize;
719 for y in ray_r..(h - ray_r) {
720 for x in ray_r..(w - ray_r) {
721 let mut h_sum: SatElem = SatElem::default();
722 for k in 0..=(2 * ray_r) {
723 let xx = x + k - ray_r;
724 h_sum += SatElem::from(img[y * w + xx]);
725 }
726 let h_hi = r[y * w + (x + ray_r)];
727 let h_lo = if x > ray_r {
728 r[y * w + (x - ray_r - 1)]
729 } else {
730 SatElem::default()
731 };
732 assert_eq!(h_hi - h_lo, h_sum, "horiz at ({},{})", x, y);
733
734 let mut v_sum: SatElem = SatElem::default();
735 for k in 0..=(2 * ray_r) {
736 let yy = y + k - ray_r;
737 v_sum += SatElem::from(img[yy * w + x]);
738 }
739 let v_hi = c[(y + ray_r) * w + x];
740 let v_lo = if y > ray_r {
741 c[(y - ray_r - 1) * w + x]
742 } else {
743 SatElem::default()
744 };
745 assert_eq!(v_hi - v_lo, v_sum, "vert at ({},{})", x, y);
746
747 let mut d1_sum: SatElem = SatElem::default();
748 for k in 0..=(2 * ray_r) {
749 let xx = x + k - ray_r;
750 let yy = y + k - ray_r;
751 d1_sum += SatElem::from(img[yy * w + xx]);
752 }
753 let d1_hi = d1[(y + ray_r) * w + (x + ray_r)];
754 let d1_lo = if x > ray_r && y > ray_r {
755 d1[(y - ray_r - 1) * w + (x - ray_r - 1)]
756 } else {
757 SatElem::default()
758 };
759 assert_eq!(d1_hi - d1_lo, d1_sum, "diag+ at ({},{})", x, y);
760
761 let mut d2_sum: SatElem = SatElem::default();
762 for k in 0..=(2 * ray_r) {
763 let xx = x + ray_r - k;
764 let yy = y + k - ray_r;
765 d2_sum += SatElem::from(img[yy * w + xx]);
766 }
767 let d2_hi = d2[(y + ray_r) * w + (x - ray_r)];
768 let d2_lo = if y > ray_r && x + ray_r + 1 < w {
769 d2[(y - ray_r - 1) * w + (x + ray_r + 1)]
770 } else {
771 SatElem::default()
772 };
773 assert_eq!(d2_hi - d2_lo, d2_sum, "diag- at ({},{})", x, y);
774 }
775 }
776 }
777
778 #[test]
779 fn response_map_is_non_negative_everywhere() {
780 const SIZE: usize = 29;
781 const CELL: usize = 6;
782 let img = synthetic_chessboard_aa(SIZE, CELL, (14.2, 14.6), 30, 230);
783 let params = RadonDetectorParams {
784 image_upsample: 1,
785 ..RadonDetectorParams::default()
786 };
787 let mut buffers = RadonBuffers::new();
788 let resp = radon_response_u8(&img, SIZE, SIZE, ¶ms, &mut buffers);
789 for &v in resp.data() {
790 assert!(v >= 0.0, "negative response value: {v}");
791 }
792 }
793
794 #[test]
795 fn image_upsample_above_cap_is_clamped_not_panicked() {
796 const SIZE: usize = 29;
797 const CELL: usize = 6;
798 let img = synthetic_chessboard_aa(SIZE, CELL, (14.2, 14.6), 30, 230);
799 let params = RadonDetectorParams {
800 image_upsample: 5,
801 ..RadonDetectorParams::default()
802 };
803 assert_eq!(params.image_upsample_clamped(), MAX_IMAGE_UPSAMPLE);
804
805 let mut buffers = RadonBuffers::new();
806 let resp = radon_response_u8(&img, SIZE, SIZE, ¶ms, &mut buffers);
807 assert_eq!(resp.width(), SIZE * MAX_IMAGE_UPSAMPLE as usize);
808 assert_eq!(resp.height(), SIZE * MAX_IMAGE_UPSAMPLE as usize);
809 }
810
811 #[test]
812 fn image_upsample_zero_is_clamped_to_one() {
813 const SIZE: usize = 21;
814 const CELL: usize = 5;
815 let img = synthetic_chessboard_aa(SIZE, CELL, (10.1, 10.4), 30, 230);
816 let params = RadonDetectorParams {
817 image_upsample: 0,
818 ..RadonDetectorParams::default()
819 };
820 assert_eq!(params.image_upsample_clamped(), 1);
821
822 let mut buffers = RadonBuffers::new();
823 let resp = radon_response_u8(&img, SIZE, SIZE, ¶ms, &mut buffers);
824 assert_eq!(resp.width(), SIZE);
825 assert_eq!(resp.height(), SIZE);
826 }
827
828 #[test]
829 fn radon_response_u8_handles_zero_extent_image() {
830 let img: Vec<u8> = Vec::new();
831 let params = RadonDetectorParams::default();
832 let mut buffers = RadonBuffers::new();
833 let resp = radon_response_u8(&img, 0, 0, ¶ms, &mut buffers);
834 assert_eq!(resp.width(), 0);
835 assert_eq!(resp.height(), 0);
836 assert!(resp.data().is_empty());
837
838 let params_no_upsample = RadonDetectorParams {
839 image_upsample: 1,
840 ..RadonDetectorParams::default()
841 };
842 let resp = radon_response_u8(&img, 0, 0, ¶ms_no_upsample, &mut buffers);
843 assert_eq!(resp.width(), 0);
844 assert_eq!(resp.height(), 0);
845 }
846
847 #[cfg(all(feature = "simd", not(feature = "radon-sat-u32")))]
848 #[test]
849 fn simd_kernel_matches_scalar_at_every_interior_pixel() {
850 for w in [16usize, 17, 18, 23, 24, 25, 32, 33] {
851 let h = 24usize;
852 let mut img = vec![0u8; w * h];
853 for (i, p) in img.iter_mut().enumerate() {
854 *p = ((i.wrapping_mul(37) ^ (i >> 3)) & 0xff) as u8;
855 }
856 let params = RadonDetectorParams {
857 image_upsample: 1,
858 response_blur_radius: 0,
859 threshold_abs: Some(0.0),
860 ..RadonDetectorParams::default()
861 };
862 let mut buffers = RadonBuffers::new();
863 let response_snapshot: Vec<f32> = {
864 let resp = radon_response_u8(&img, w, h, ¶ms, &mut buffers);
865 resp.data().to_vec()
866 };
867
868 let r = params.ray_radius_clamped() as usize;
869 let cs = Cumsums {
870 row: &buffers.row_cumsum,
871 col: &buffers.col_cumsum,
872 diag_pos: &buffers.diag_pos_cumsum,
873 diag_neg: &buffers.diag_neg_cumsum,
874 w,
875 h,
876 };
877 for y in r..(h - r) {
878 for x in r..(w - r) {
879 let expected = radon_response_at(&cs, r, x, y);
880 let got = response_snapshot[y * w + x];
881 assert!(
882 (expected - got).abs() < 1e-3,
883 "mismatch at w={w}, (x={x}, y={y}): scalar={expected}, simd={got}",
884 );
885 }
886 }
887 }
888 }
889}