use crate::core::error::Result;
use crate::core::gradient::GradientField;
use crate::core::image_view::OwnedImage;
use crate::core::polarity::Polarity;
use crate::core::scalar::Scalar;
#[cfg(feature = "rayon")]
use rayon::prelude::*;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(default))]
#[non_exhaustive]
pub struct FrstConfig {
pub radii: Vec<u32>,
pub alpha: Scalar,
pub gradient_threshold: Scalar,
pub polarity: Polarity,
pub smoothing_factor: Scalar,
}
impl FrstConfig {
pub fn validate(&self) -> crate::core::error::Result<()> {
use crate::core::error::RadSymError;
if self.radii.is_empty() {
return Err(RadSymError::InvalidConfig {
reason: "radii must be non-empty",
});
}
if self.radii.contains(&0) {
return Err(RadSymError::InvalidConfig {
reason: "all radii must be > 0",
});
}
if self.alpha < 0.0 {
return Err(RadSymError::InvalidConfig {
reason: "alpha must be >= 0",
});
}
if self.smoothing_factor <= 0.0 {
return Err(RadSymError::InvalidConfig {
reason: "smoothing_factor must be > 0",
});
}
Ok(())
}
}
impl Default for FrstConfig {
fn default() -> Self {
Self {
radii: vec![3, 5, 7, 9, 11],
alpha: 2.0,
gradient_threshold: 0.0,
polarity: Polarity::Both,
smoothing_factor: 0.5,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(default))]
#[non_exhaustive]
pub struct FrstTuning {
pub alpha: Scalar,
pub gradient_threshold: Scalar,
pub smoothing_factor: Scalar,
}
impl Default for FrstTuning {
fn default() -> Self {
let FrstConfig {
alpha,
gradient_threshold,
smoothing_factor,
..
} = FrstConfig::default();
Self {
alpha,
gradient_threshold,
smoothing_factor,
}
}
}
impl FrstTuning {
pub fn to_frst_config(&self, radii: Vec<u32>, polarity: Polarity) -> FrstConfig {
FrstConfig {
radii,
alpha: self.alpha,
gradient_threshold: self.gradient_threshold,
polarity,
smoothing_factor: self.smoothing_factor,
}
}
}
impl From<FrstConfig> for FrstTuning {
fn from(config: FrstConfig) -> Self {
Self {
alpha: config.alpha,
gradient_threshold: config.gradient_threshold,
smoothing_factor: config.smoothing_factor,
}
}
}
struct FrstScratch {
o: Vec<Scalar>,
m: Vec<Scalar>,
s: OwnedImage<Scalar>,
blur: Vec<Scalar>,
}
impl FrstScratch {
fn new(w: usize, h: usize) -> Result<Self> {
Ok(Self {
o: vec![0.0; w * h],
m: vec![0.0; w * h],
s: OwnedImage::<Scalar>::zeros(w, h)?,
blur: Vec::new(),
})
}
}
fn frst_single_into(
gradient: &GradientField,
radius: u32,
config: &FrstConfig,
scratch: &mut FrstScratch,
) {
let w = gradient.width();
let h = gradient.height();
let n = radius as i32;
let n_f = n as Scalar;
let FrstScratch { o, m, s, blur } = scratch;
let o_data = o.as_mut_slice();
let m_data = m.as_mut_slice();
o_data.fill(0.0);
m_data.fill(0.0);
let gx_data = gradient.gx.data();
let gy_data = gradient.gy.data();
let thresh_sq = config.gradient_threshold * config.gradient_threshold;
let vote_pos = config.polarity.votes_positive();
let vote_neg = config.polarity.votes_negative();
for y in 0..h {
for x in 0..w {
let idx = y * w + x;
let gx = gx_data[idx];
let gy = gy_data[idx];
let mag_sq = gx * gx + gy * gy;
if mag_sq < thresh_sq {
continue;
}
let mag = mag_sq.sqrt();
let inv_mag = mag.recip();
let dx = gx * inv_mag;
let dy = gy * inv_mag;
let offset_x = (dx * n_f).round() as i32;
let offset_y = (dy * n_f).round() as i32;
if vote_pos {
let px = x as i32 + offset_x;
let py = y as i32 + offset_y;
if px >= 0 && (px as usize) < w && py >= 0 && (py as usize) < h {
let pidx = py as usize * w + px as usize;
o_data[pidx] += 1.0;
m_data[pidx] += mag;
}
}
if vote_neg {
let px = x as i32 - offset_x;
let py = y as i32 - offset_y;
if px >= 0 && (px as usize) < w && py >= 0 && (py as usize) < h {
let pidx = py as usize * w + px as usize;
o_data[pidx] -= 1.0;
m_data[pidx] += mag;
}
}
}
}
let mut o_acc = 0.0f32;
let mut m_acc = 0.0f32;
for i in 0..w * h {
o_acc = Scalar::max(o_acc, o_data[i].abs());
m_acc = Scalar::max(m_acc, m_data[i]);
}
let o_max = o_acc.max(1.0); let m_max = m_acc.max(1.0);
let alpha = config.alpha;
let f_data = s.data_mut();
match alpha as u32 {
1 if (alpha - 1.0).abs() < 1e-6 => {
for i in 0..w * h {
let o_abs = (o_data[i] / o_max).abs();
f_data[i] = o_abs * (m_data[i] / m_max);
}
}
2 if (alpha - 2.0).abs() < 1e-6 => {
for i in 0..w * h {
let o_abs = (o_data[i] / o_max).abs();
f_data[i] = o_abs * o_abs * (m_data[i] / m_max);
}
}
_ => {
for i in 0..w * h {
let o_abs = (o_data[i] / o_max).abs();
f_data[i] = o_abs.powf(alpha) * (m_data[i] / m_max);
}
}
}
let sigma = config.smoothing_factor * radius as Scalar;
if sigma > 0.5 {
crate::core::blur::gaussian_blur_inplace_buf(s, sigma, blur);
}
}
#[cfg_attr(not(feature = "rayon"), allow(dead_code))]
pub(crate) fn frst_response_single(
gradient: &GradientField,
radius: u32,
config: &FrstConfig,
) -> Result<OwnedImage<Scalar>> {
config.validate()?;
let mut scratch = FrstScratch::new(gradient.width(), gradient.height())?;
frst_single_into(gradient, radius, config, &mut scratch);
Ok(scratch.s)
}
pub fn frst_response(
gradient: &GradientField,
config: &FrstConfig,
) -> Result<super::extract::ResponseMap> {
config.validate()?;
let w = gradient.width();
let h = gradient.height();
let mut response = OwnedImage::<Scalar>::zeros(w, h)?;
#[cfg(feature = "rayon")]
{
let per_radius = config
.radii
.par_iter()
.map(|&radius| frst_response_single(gradient, radius, config))
.collect::<Vec<_>>();
let resp_data = response.data_mut();
for s_n in per_radius {
let s_n = s_n?;
let s_data = s_n.data();
for i in 0..w * h {
resp_data[i] += s_data[i];
}
}
}
#[cfg(not(feature = "rayon"))]
{
let mut scratch = FrstScratch::new(w, h)?;
let resp_data = response.data_mut();
for &radius in &config.radii {
frst_single_into(gradient, radius, config, &mut scratch);
let s_data = scratch.s.data();
for i in 0..w * h {
resp_data[i] += s_data[i];
}
}
}
Ok(super::extract::ResponseMap::new(
response,
super::seed::ProposalSource::Frst,
))
}
#[derive(Debug)]
#[non_exhaustive]
pub struct ScaledResponse {
pub response: super::extract::ResponseMap,
pub scale_map: OwnedImage<Scalar>,
}
pub fn frst_response_scaled(
gradient: &GradientField,
config: &FrstConfig,
) -> Result<ScaledResponse> {
config.validate()?;
let w = gradient.width();
let h = gradient.height();
let mut response = OwnedImage::<Scalar>::zeros(w, h)?;
let mut best = OwnedImage::<Scalar>::zeros(w, h)?;
let mut scale = OwnedImage::<Scalar>::zeros(w, h)?;
#[cfg(feature = "rayon")]
{
let per_radius = config
.radii
.par_iter()
.map(|&radius| (radius, frst_response_single(gradient, radius, config)))
.collect::<Vec<_>>();
let resp_data = response.data_mut();
let best_data = best.data_mut();
let scale_data = scale.data_mut();
for (radius, s_n) in per_radius {
let s_n = s_n?;
let s_data = s_n.data();
for i in 0..w * h {
let v = s_data[i];
resp_data[i] += v;
if v > best_data[i] {
best_data[i] = v;
scale_data[i] = radius as Scalar;
}
}
}
}
#[cfg(not(feature = "rayon"))]
{
let mut scratch = FrstScratch::new(w, h)?;
let resp_data = response.data_mut();
let best_data = best.data_mut();
let scale_data = scale.data_mut();
for &radius in &config.radii {
frst_single_into(gradient, radius, config, &mut scratch);
let s_data = scratch.s.data();
for i in 0..w * h {
let v = s_data[i];
resp_data[i] += v;
if v > best_data[i] {
best_data[i] = v;
scale_data[i] = radius as Scalar;
}
}
}
}
Ok(ScaledResponse {
response: super::extract::ResponseMap::new(response, super::seed::ProposalSource::Frst),
scale_map: scale,
})
}
pub fn frst_response_fused(
gradient: &GradientField,
config: &FrstConfig,
) -> Result<super::extract::ResponseMap> {
config.validate()?;
let accumulator = super::fused::fused_voting_pass(
gradient,
&config.radii,
config.gradient_threshold,
config.polarity,
config.smoothing_factor,
)?;
Ok(super::extract::ResponseMap::new(
accumulator,
super::seed::ProposalSource::Frst,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::gradient::sobel_gradient;
use crate::core::image_view::ImageView;
fn make_bright_disk(size: usize, cx: usize, cy: usize, radius: 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 as f32;
let dy = y as f32 - cy as f32;
if (dx * dx + dy * dy).sqrt() <= radius {
data[y * size + x] = 255;
}
}
}
data
}
fn make_ring(
size: usize,
cx: usize,
cy: usize,
inner_radius: f32,
outer_radius: 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 as f32;
let dy = y as f32 - cy as f32;
let r = (dx * dx + dy * dy).sqrt();
if r >= inner_radius && r <= outer_radius {
data[y * size + x] = 255;
}
}
}
data
}
#[test]
fn frst_detects_bright_disk_center() {
let size = 64;
let cx = 32;
let cy = 32;
let data = make_bright_disk(size, cx, cy, 10.0);
let image = ImageView::from_slice(&data, size, size).unwrap();
let grad = sobel_gradient(&image).unwrap();
let config = FrstConfig {
radii: vec![9, 10, 11],
alpha: 2.0,
gradient_threshold: 1.0,
polarity: Polarity::Bright,
smoothing_factor: 0.5,
};
let response = frst_response(&grad, &config).unwrap();
let resp_data = response.response().data();
let (max_idx, &max_val) = resp_data
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.unwrap();
let peak_x = max_idx % size;
let peak_y = max_idx / size;
assert!(
max_val > 0.0,
"response should have a positive peak, got {max_val}"
);
assert!(
(peak_x as f32 - cx as f32).abs() < 5.0,
"peak x={peak_x} should be near center x={cx}"
);
assert!(
(peak_y as f32 - cy as f32).abs() < 5.0,
"peak y={peak_y} should be near center y={cy}"
);
}
#[test]
fn frst_response_matches_scaled_sum() {
let size = 64;
let data = make_bright_disk(size, 32, 32, 10.0);
let image = ImageView::from_slice(&data, size, size).unwrap();
let grad = sobel_gradient(&image).unwrap();
let config = FrstConfig {
radii: vec![8, 9, 10, 11, 12],
alpha: 2.0,
gradient_threshold: 1.0,
polarity: Polarity::Bright,
smoothing_factor: 0.5,
};
let response = frst_response(&grad, &config).unwrap();
let scaled = frst_response_scaled(&grad, &config).unwrap();
assert_eq!(
response.response().data(),
scaled.response.response().data(),
"response-only path must equal the scaled variant's summed response bit-for-bit"
);
}
#[test]
fn frst_detects_dark_disk() {
let size = 64;
let cx = 32;
let cy = 32;
let mut data = make_bright_disk(size, cx, cy, 10.0);
for v in &mut data {
*v = 255 - *v;
}
let image = ImageView::from_slice(&data, size, size).unwrap();
let grad = sobel_gradient(&image).unwrap();
let config = FrstConfig {
radii: vec![9, 10, 11],
polarity: Polarity::Dark,
gradient_threshold: 1.0,
..FrstConfig::default()
};
let response = frst_response(&grad, &config).unwrap();
let resp_data = response.response().data();
let (max_idx, &max_val) = resp_data
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.unwrap();
let peak_x = max_idx % size;
let peak_y = max_idx / size;
assert!(max_val > 0.0);
assert!((peak_x as f32 - cx as f32).abs() < 5.0);
assert!((peak_y as f32 - cy as f32).abs() < 5.0);
}
#[test]
fn frst_detects_ring_center() {
let size = 80;
let cx = 40;
let cy = 40;
let data = make_ring(size, cx, cy, 12.0, 16.0);
let image = ImageView::from_slice(&data, size, size).unwrap();
let grad = sobel_gradient(&image).unwrap();
let config = FrstConfig {
radii: vec![11, 12, 13, 14, 15, 16, 17],
alpha: 2.0,
gradient_threshold: 1.0,
polarity: Polarity::Both,
smoothing_factor: 0.5,
};
let response = frst_response(&grad, &config).unwrap();
let resp_data = response.response().data();
let (max_idx, &max_val) = resp_data
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.unwrap();
let peak_x = max_idx % size;
let peak_y = max_idx / size;
assert!(max_val > 0.0, "ring should produce a positive response");
assert!(
(peak_x as f32 - cx as f32).abs() < 5.0,
"peak x={peak_x} should be near ring center x={cx}"
);
assert!(
(peak_y as f32 - cy as f32).abs() < 5.0,
"peak y={peak_y} should be near ring center y={cy}"
);
}
#[test]
fn frst_multiple_targets() {
let size = 128;
let mut data = vec![0u8; size * size];
let targets = [(32, 32, 8.0), (90, 90, 10.0)];
for &(cx, cy, r) in &targets {
for y in 0..size {
for x in 0..size {
let dx = x as f32 - cx as f32;
let dy = y as f32 - cy as f32;
if (dx * dx + dy * dy).sqrt() <= r {
data[y * size + x] = 255;
}
}
}
}
let image = ImageView::from_slice(&data, size, size).unwrap();
let grad = sobel_gradient(&image).unwrap();
let config = FrstConfig {
radii: vec![7, 8, 9, 10, 11],
gradient_threshold: 1.0,
polarity: Polarity::Bright,
..FrstConfig::default()
};
let response = frst_response(&grad, &config).unwrap();
for &(cx, cy, _) in &targets {
let val = response.view().get(cx, cy).copied().unwrap_or(0.0);
assert!(
val > 0.0,
"target at ({cx},{cy}) should have positive response, got {val}"
);
}
}
#[test]
fn frst_response_dimensions_match_input() {
let data = vec![128u8; 40 * 30];
let image = ImageView::from_slice(&data, 40, 30).unwrap();
let grad = sobel_gradient(&image).unwrap();
let config = FrstConfig::default();
let response = frst_response(&grad, &config).unwrap();
assert_eq!(response.response().width(), 40);
assert_eq!(response.response().height(), 30);
}
#[test]
fn default_config_passes_validation() {
FrstConfig::default().validate().unwrap();
}
#[test]
fn empty_radii_fails_validation() {
let config = FrstConfig {
radii: vec![],
..FrstConfig::default()
};
assert!(matches!(
config.validate(),
Err(crate::core::error::RadSymError::InvalidConfig { .. })
));
}
#[test]
fn zero_radius_fails_validation() {
let config = FrstConfig {
radii: vec![5, 0, 3],
..FrstConfig::default()
};
assert!(matches!(
config.validate(),
Err(crate::core::error::RadSymError::InvalidConfig { .. })
));
}
#[test]
fn negative_alpha_fails_validation() {
let config = FrstConfig {
alpha: -1.0,
..FrstConfig::default()
};
assert!(matches!(
config.validate(),
Err(crate::core::error::RadSymError::InvalidConfig { .. })
));
}
#[test]
fn zero_smoothing_factor_fails_validation() {
let config = FrstConfig {
smoothing_factor: 0.0,
..FrstConfig::default()
};
assert!(matches!(
config.validate(),
Err(crate::core::error::RadSymError::InvalidConfig { .. })
));
}
#[test]
fn frst_gradient_threshold_reduces_noise() {
let data = vec![128u8; 32 * 32];
let image = ImageView::from_slice(&data, 32, 32).unwrap();
let grad = sobel_gradient(&image).unwrap();
let config = FrstConfig {
radii: vec![5],
gradient_threshold: 100.0, ..FrstConfig::default()
};
let response = frst_response(&grad, &config).unwrap();
assert!(
response.response().data().iter().all(|&v| v == 0.0),
"high threshold on uniform image should produce zero response"
);
}
fn frst_single_reference(
gradient: &GradientField,
radius: u32,
config: &FrstConfig,
) -> OwnedImage<Scalar> {
let w = gradient.width();
let h = gradient.height();
let n = radius as i32;
let mut o_n = OwnedImage::<Scalar>::zeros(w, h).unwrap();
let mut m_n = OwnedImage::<Scalar>::zeros(w, h).unwrap();
let o_data = o_n.data_mut();
let m_data = m_n.data_mut();
let gx_data = gradient.gx.data();
let gy_data = gradient.gy.data();
let thresh_sq = config.gradient_threshold * config.gradient_threshold;
let vote_pos = config.polarity.votes_positive();
let vote_neg = config.polarity.votes_negative();
let n_f = n as Scalar;
for y in 0..h {
for x in 0..w {
let idx = y * w + x;
let gx = gx_data[idx];
let gy = gy_data[idx];
let mag_sq = gx * gx + gy * gy;
if mag_sq < thresh_sq {
continue;
}
let mag = mag_sq.sqrt();
let inv_mag = mag.recip();
let dx = gx * inv_mag;
let dy = gy * inv_mag;
let offset_x = (dx * n_f).round() as i32;
let offset_y = (dy * n_f).round() as i32;
if vote_pos {
let px = x as i32 + offset_x;
let py = y as i32 + offset_y;
if px >= 0 && (px as usize) < w && py >= 0 && (py as usize) < h {
let pidx = py as usize * w + px as usize;
o_data[pidx] += 1.0;
m_data[pidx] += mag;
}
}
if vote_neg {
let px = x as i32 - offset_x;
let py = y as i32 - offset_y;
if px >= 0 && (px as usize) < w && py >= 0 && (py as usize) < h {
let pidx = py as usize * w + px as usize;
o_data[pidx] -= 1.0;
m_data[pidx] += mag;
}
}
}
}
let o_max = o_data
.iter()
.map(|v| v.abs())
.fold(0.0f32, Scalar::max)
.max(1.0);
let m_max = m_data.iter().copied().fold(0.0f32, Scalar::max).max(1.0);
let alpha = config.alpha;
let mut f_n = OwnedImage::<Scalar>::zeros(w, h).unwrap();
let f_data = f_n.data_mut();
match alpha as u32 {
1 if (alpha - 1.0).abs() < 1e-6 => {
for i in 0..w * h {
let o_abs = (o_data[i] / o_max).abs();
f_data[i] = o_abs * (m_data[i] / m_max);
}
}
2 if (alpha - 2.0).abs() < 1e-6 => {
for i in 0..w * h {
let o_abs = (o_data[i] / o_max).abs();
f_data[i] = o_abs * o_abs * (m_data[i] / m_max);
}
}
_ => {
for i in 0..w * h {
let o_abs = (o_data[i] / o_max).abs();
f_data[i] = o_abs.powf(alpha) * (m_data[i] / m_max);
}
}
}
let sigma = config.smoothing_factor * radius as Scalar;
if sigma > 0.5 {
crate::core::blur::gaussian_blur_inplace(&mut f_n, sigma);
}
f_n
}
fn bits_equal(a: &[Scalar], b: &[Scalar]) -> Option<usize> {
assert_eq!(a.len(), b.len());
a.iter()
.zip(b.iter())
.position(|(x, y)| x.to_bits() != y.to_bits())
}
fn nonsquare_two_disks() -> (Vec<u8>, usize, usize) {
let (w, h) = (50usize, 36usize);
let mut data = vec![0u8; w * h];
for y in 0..h {
for x in 0..w {
let near = |cx: f32, cy: f32, r: f32| {
let (dx, dy) = (x as f32 - cx, y as f32 - cy);
(dx * dx + dy * dy).sqrt() <= r
};
if near(14.0, 12.0, 6.0) || near(34.0, 24.0, 8.0) {
data[y * w + x] = 255;
}
}
}
(data, w, h)
}
#[test]
fn optimized_frst_is_bit_identical_to_reference() {
let square = make_bright_disk(64, 30, 34, 11.0);
let (ns_data, ns_w, ns_h) = nonsquare_two_disks();
let inputs = [
("square", &square[..], 64usize, 64usize),
("nonsquare", &ns_data[..], ns_w, ns_h),
];
let radii = [1u32, 2, 5, 9];
let alphas = [1.0f32, 2.0, 3.0];
let polarities = [Polarity::Bright, Polarity::Dark, Polarity::Both];
let thresholds = [0.0f32, 1.0];
for (label, data, w, h) in inputs {
let image = ImageView::from_slice(data, w, h).unwrap();
let grad = sobel_gradient(&image).unwrap();
for &alpha in &alphas {
for &polarity in &polarities {
for &threshold in &thresholds {
let cfg = FrstConfig {
radii: radii.to_vec(),
alpha,
gradient_threshold: threshold,
polarity,
smoothing_factor: 0.5,
};
for &radius in &radii {
let got = frst_response_single(&grad, radius, &cfg).unwrap();
let want = frst_single_reference(&grad, radius, &cfg);
assert!(
bits_equal(got.data(), want.data()).is_none(),
"{label}: single mismatch at radius {radius}, \
alpha {alpha}, {polarity:?}, thresh {threshold} \
(index {:?})",
bits_equal(got.data(), want.data()),
);
}
let got = frst_response(&grad, &cfg).unwrap();
let mut want = vec![0.0f32; w * h];
for &radius in &radii {
let s = frst_single_reference(&grad, radius, &cfg);
for (acc, &v) in want.iter_mut().zip(s.data().iter()) {
*acc += v;
}
}
assert!(
bits_equal(got.response().data(), &want).is_none(),
"{label}: summed mismatch at alpha {alpha}, \
{polarity:?}, thresh {threshold} (index {:?})",
bits_equal(got.response().data(), &want),
);
}
}
}
}
}
#[test]
fn frst_fused_detects_bright_disk_center() {
let size = 80;
let data = make_bright_disk(size, 40, 40, 12.0);
let image = ImageView::from_slice(&data, size, size).unwrap();
let grad = sobel_gradient(&image).unwrap();
let config = FrstConfig {
radii: vec![11, 12, 13],
gradient_threshold: 1.0,
polarity: Polarity::Bright,
..FrstConfig::default()
};
let response = frst_response_fused(&grad, &config).unwrap();
let resp_data = response.response().data();
let (max_idx, _) = resp_data
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.unwrap();
let peak_x = max_idx % size;
let peak_y = max_idx / size;
let dx = peak_x as f32 - 40.0;
let dy = peak_y as f32 - 40.0;
assert!(
(dx * dx + dy * dy).sqrt() < 5.0,
"peak at ({peak_x}, {peak_y}) too far from center (40, 40)"
);
}
#[test]
fn frst_fused_detects_dark_disk() {
let size = 80;
let mut data = make_bright_disk(size, 40, 40, 12.0);
for v in &mut data {
*v = 255 - *v;
}
let image = ImageView::from_slice(&data, size, size).unwrap();
let grad = sobel_gradient(&image).unwrap();
let config = FrstConfig {
radii: vec![11, 12, 13],
polarity: Polarity::Dark,
gradient_threshold: 1.0,
..FrstConfig::default()
};
let response = frst_response_fused(&grad, &config).unwrap();
let resp_data = response.response().data();
let (max_idx, _) = resp_data
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.unwrap();
let peak_x = max_idx % size;
let peak_y = max_idx / size;
let dx = peak_x as f32 - 40.0;
let dy = peak_y as f32 - 40.0;
assert!(
(dx * dx + dy * dy).sqrt() < 5.0,
"dark peak at ({peak_x}, {peak_y}) too far from center (40, 40)"
);
}
#[test]
fn frst_fused_dimensions_match_input() {
let data = vec![128u8; 40 * 30];
let image = ImageView::from_slice(&data, 40, 30).unwrap();
let grad = sobel_gradient(&image).unwrap();
let response = frst_response_fused(&grad, &FrstConfig::default()).unwrap();
assert_eq!(response.response().width(), 40);
assert_eq!(response.response().height(), 30);
}
#[test]
fn frst_fused_gradient_threshold_suppresses_uniform() {
let data = vec![128u8; 32 * 32];
let image = ImageView::from_slice(&data, 32, 32).unwrap();
let grad = sobel_gradient(&image).unwrap();
let config = FrstConfig {
radii: vec![5],
gradient_threshold: 100.0,
..FrstConfig::default()
};
let response = frst_response_fused(&grad, &config).unwrap();
assert!(
response.response().data().iter().all(|&v| v == 0.0),
"high threshold on uniform image should produce zero response"
);
}
#[test]
fn frst_fused_matches_frst_peak_location() {
let size = 100;
let data = make_bright_disk(size, 50, 50, 16.0);
let image = ImageView::from_slice(&data, size, size).unwrap();
let grad = sobel_gradient(&image).unwrap();
let config = FrstConfig {
radii: vec![14, 15, 16, 17, 18],
gradient_threshold: 1.0,
polarity: Polarity::Bright,
..FrstConfig::default()
};
let frst = frst_response(&grad, &config).unwrap();
let multi = frst_response_fused(&grad, &config).unwrap();
let find_peak = |data: &[f32], w: usize| -> (usize, usize) {
let (idx, _) = data
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.unwrap();
(idx % w, idx / w)
};
let (fx, fy) = find_peak(frst.response().data(), size);
let (mx, my) = find_peak(multi.response().data(), size);
let dist = ((fx as f32 - mx as f32).powi(2) + (fy as f32 - my as f32).powi(2)).sqrt();
assert!(
dist < 3.0,
"peak locations differ by {dist}px: frst=({fx},{fy}) multi=({mx},{my})"
);
}
#[test]
fn compute_median_radius_odd() {
use crate::propose::fused::compute_median_radius;
assert_eq!(compute_median_radius(&[3, 7, 5]), 5.0);
assert_eq!(compute_median_radius(&[10]), 10.0);
assert_eq!(compute_median_radius(&[1, 2, 3, 4, 5]), 3.0);
}
#[test]
fn compute_median_radius_even() {
use crate::propose::fused::compute_median_radius;
assert_eq!(compute_median_radius(&[4, 8]), 6.0);
assert_eq!(compute_median_radius(&[2, 4, 6, 8]), 5.0);
}
}