use radsym::{
Circle, DetectCirclesConfig, ImageView, Polarity, RadSymError, Rect, detect_circles,
score_circle_support, sobel_gradient,
};
fn paint_disk(data: &mut [u8], size: usize, cx: f32, cy: f32, r: f32) {
for y in 0..size {
for x in 0..size {
let dx = x as f32 - cx;
let dy = y as f32 - cy;
if (dx * dx + dy * dy).sqrt() <= r {
data[y * size + x] = 255;
}
}
}
}
fn disk_u8(size: usize, cx: f32, cy: f32, r: f32) -> Vec<u8> {
let mut data = vec![0u8; size * size];
for y in 0..size {
for x in 0..size {
let dx = x as f32 - cx;
let dy = y as f32 - cy;
if (dx * dx + dy * dy).sqrt() <= r {
data[y * size + x] = 255;
}
}
}
data
}
fn detect_config() -> DetectCirclesConfig {
let mut cfg = DetectCirclesConfig::for_radii([10, 12, 14])
.polarity(Polarity::Bright)
.radius_hint(12.0);
cfg.advanced.frst.gradient_threshold = 1.0;
cfg
}
#[test]
fn detect_circles_agrees_across_u8_u16_f32() {
let size = 96;
let (cx, cy, r) = (48.0f32, 48.0f32, 12.0f32);
let u8data = disk_u8(size, cx, cy, r);
let u16data: Vec<u16> = u8data.iter().map(|&v| v as u16 * 257).collect();
let f32data: Vec<f32> = u8data.iter().map(|&v| v as f32).collect();
let cfg = detect_config();
let u8v = ImageView::from_slice(&u8data, size, size).unwrap();
let u16v = ImageView::from_slice(&u16data, size, size).unwrap();
let f32v = ImageView::from_slice(&f32data, size, size).unwrap();
let d8 = detect_circles(&u8v, &cfg).unwrap();
let d16 = detect_circles(&u16v, &cfg).unwrap();
let df = detect_circles(&f32v, &cfg).unwrap();
for (label, det) in [("u8", &d8), ("u16", &d16), ("f32", &df)] {
assert!(!det.is_empty(), "{label}: expected a detection");
let c = det[0].hypothesis.center;
assert!(
(c.x - cx).abs() < 3.0 && (c.y - cy).abs() < 3.0,
"{label}: center ({}, {}) not near ({cx}, {cy})",
c.x,
c.y
);
}
assert!((d8[0].hypothesis.center.x - df[0].hypothesis.center.x).abs() < 1e-3);
assert!((d8[0].hypothesis.center.y - df[0].hypothesis.center.y).abs() < 1e-3);
}
#[test]
fn u16_gradient_scales_with_bit_depth() {
let size = 16;
let u8data = disk_u8(size, 8.0, 8.0, 4.0);
let u16data: Vec<u16> = u8data.iter().map(|&v| v as u16 * 257).collect();
let g8 = sobel_gradient(&ImageView::from_slice(&u8data, size, size).unwrap()).unwrap();
let g16 = sobel_gradient(&ImageView::from_slice(&u16data, size, size).unwrap()).unwrap();
let ratio = g16.max_magnitude() / g8.max_magnitude();
assert!(
(ratio - 257.0).abs() < 1.0,
"u16/u8 gradient ratio {ratio} should be ~257"
);
}
#[test]
fn invalid_nms_config_errors_instead_of_silent_empty() {
let u8data = disk_u8(64, 32.0, 32.0, 10.0);
let view = ImageView::from_slice(&u8data, 64, 64).unwrap();
let mut cfg = DetectCirclesConfig::for_radii([9, 10, 11]).polarity(Polarity::Bright);
cfg.advanced.nms.max_detections = 0;
let result = detect_circles(&view, &cfg);
assert!(
matches!(result, Err(RadSymError::InvalidConfig { .. })),
"max_detections = 0 should be a hard config error, got {result:?}"
);
}
#[test]
fn invalid_scoring_config_errors() {
let u8data = disk_u8(64, 32.0, 32.0, 10.0);
let view = ImageView::from_slice(&u8data, 64, 64).unwrap();
let mut cfg = DetectCirclesConfig::for_radii([9, 10, 11]).polarity(Polarity::Bright);
cfg.advanced.scoring.annulus_margin = 0.0;
let result = detect_circles(&view, &cfg);
assert!(
matches!(result, Err(RadSymError::InvalidConfig { .. })),
"annulus_margin = 0 should be a hard config error, got {result:?}"
);
}
#[test]
fn f32_scoring_path_still_available() {
let size = 64;
let f32data: Vec<f32> = disk_u8(size, 32.0, 32.0, 10.0)
.iter()
.map(|&v| v as f32)
.collect();
let grad = sobel_gradient(&ImageView::from_slice(&f32data, size, size).unwrap()).unwrap();
let circle = Circle::new(radsym::PixelCoord::new(32.0, 32.0), 10.0);
let score = score_circle_support(&grad, &circle, &Default::default());
assert!(score.total > 0.0);
}
#[test]
fn roi_restricts_search_and_restores_full_frame_coordinates() {
let size = 128;
let mut data = vec![0u8; size * size];
paint_disk(&mut data, size, 30.0, 30.0, 10.0); paint_disk(&mut data, size, 96.0, 96.0, 10.0); let view = ImageView::from_slice(&data, size, size).unwrap();
let cfg = DetectCirclesConfig::for_radii([9, 10, 11])
.polarity(Polarity::Bright)
.radius_hint(10.0)
.roi(Rect::new(70, 70, 50, 50));
let dets = detect_circles(&view, &cfg).unwrap();
assert!(!dets.is_empty(), "ROI should still detect disk B");
let c = dets[0].hypothesis.center;
assert!(
(c.x - 96.0).abs() < 4.0 && (c.y - 96.0).abs() < 4.0,
"ROI center ({}, {}) should be near disk B (96, 96) in full-frame coords",
c.x,
c.y
);
for d in &dets {
assert!(
(d.hypothesis.center.x - 30.0).abs() > 15.0
|| (d.hypothesis.center.y - 30.0).abs() > 15.0,
"disk A should be outside the ROI"
);
}
}
#[test]
fn mixed_size_circles_detected_via_scale_hint() {
let size = 176;
let mut data = vec![0u8; size * size];
paint_disk(&mut data, size, 44.0, 88.0, 8.0); paint_disk(&mut data, size, 120.0, 88.0, 24.0); let view = ImageView::from_slice(&data, size, size).unwrap();
let mut cfg = DetectCirclesConfig::for_radii([6, 8, 10, 22, 24, 26])
.polarity(Polarity::Bright)
.radius_hint(8.0) .min_score(0.15);
cfg.advanced.frst.gradient_threshold = 1.0;
let dets = detect_circles(&view, &cfg).unwrap();
let near = |d: &radsym::CircleDetection, cx: f32, cy: f32| {
(d.hypothesis.center.x - cx).abs() < 5.0 && (d.hypothesis.center.y - cy).abs() < 5.0
};
let small = dets.iter().find(|d| near(d, 44.0, 88.0));
let large = dets.iter().find(|d| near(d, 120.0, 88.0));
let summary: Vec<_> = dets
.iter()
.map(|d| {
(
d.hypothesis.center.x,
d.hypothesis.center.y,
d.hypothesis.radius,
)
})
.collect();
assert!(
small.is_some(),
"small disk missing; detections: {summary:?}"
);
assert!(
large.is_some(),
"large disk missing; detections: {summary:?}"
);
assert!(
large.unwrap().hypothesis.radius > 18.0,
"large disk radius {} should be ~24",
large.unwrap().hypothesis.radius
);
assert!(
small.unwrap().hypothesis.radius < 14.0,
"small disk radius {} should be ~8",
small.unwrap().hypothesis.radius
);
}
#[test]
fn out_of_bounds_roi_errors() {
let data = vec![0u8; 64 * 64];
let view = ImageView::from_slice(&data, 64, 64).unwrap();
let cfg = DetectCirclesConfig::for_radii([9, 10, 11])
.polarity(Polarity::Bright)
.roi(Rect::new(40, 40, 40, 40));
let result = detect_circles(&view, &cfg);
assert!(
matches!(result, Err(RadSymError::InvalidDimensions { .. })),
"out-of-bounds ROI should error, got {result:?}"
);
}