use std::f64::consts::SQRT_2;
use ndarray::Array1;
use crate::convolve::Normalization;
use crate::float::Float;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GaussianSampling {
ErfIntegrated,
PointSampled,
}
pub fn gaussian1d<T: Float>(
sigma: f64,
truncate: f64,
sampling: GaussianSampling,
normalization: Normalization,
) -> Array1<T> {
assert!(
sigma > 0.0,
"gaussian1d sigma must be positive, got {sigma}"
);
assert!(
truncate > 0.0,
"gaussian1d truncate must be positive, got {truncate}"
);
let radius = (truncate * sigma).ceil() as usize;
let length = 2 * radius + 1;
let mut kernel = Array1::<f64>::zeros(length);
for tap in 0..length {
let offset = tap as f64 - radius as f64;
kernel[tap] = match sampling {
GaussianSampling::ErfIntegrated => {
normal_cdf((offset + 0.5) / sigma) - normal_cdf((offset - 0.5) / sigma)
}
GaussianSampling::PointSampled => (-(offset * offset) / (2.0 * sigma * sigma)).exp(),
};
}
match normalization {
Normalization::Sum => {
let sum: f64 = kernel.iter().sum();
kernel.mapv_inplace(|value| value / sum);
}
Normalization::L2 => {
let l2_norm = kernel.iter().map(|value| value * value).sum::<f64>().sqrt();
kernel.mapv_inplace(|value| value / l2_norm);
}
Normalization::None => {}
}
kernel.mapv(|value| T::from_f64(value).expect("gaussian kernel value fits in T"))
}
#[inline]
pub(crate) fn normal_cdf(x: f64) -> f64 {
0.5 * (1.0 + libm::erf(x / SQRT_2))
}
#[cfg(test)]
mod tests {
use super::*;
const TOL_F64: f64 = 1e-12;
const TOL_F32: f32 = 1e-6;
fn approx_eq_f64(a: f64, b: f64) -> bool {
(a - b).abs() <= TOL_F64 * a.abs().max(b.abs()).max(1.0)
}
fn approx_eq_f32(a: f32, b: f32) -> bool {
(a - b).abs() <= TOL_F32 * a.abs().max(b.abs()).max(1.0)
}
#[test]
fn length_is_odd_and_matches_formula_f64() {
for (sigma, truncate, expected_len) in [
(1.0, 4.0, 9), (2.0, 4.0, 17), (1.5, 4.0, 13), (0.3, 4.0, 5), (1.0, 3.0, 7), ] {
let kernel = gaussian1d::<f64>(
sigma,
truncate,
GaussianSampling::ErfIntegrated,
Normalization::None,
);
assert_eq!(kernel.len(), expected_len);
assert_eq!(kernel.len() % 2, 1, "kernel length must be odd");
}
}
#[test]
fn erf_integrated_known_values_sigma_one_f64() {
let kernel = gaussian1d::<f64>(
1.0,
4.0,
GaussianSampling::ErfIntegrated,
Normalization::None,
);
let center = kernel.len() / 2;
assert!(approx_eq_f64(kernel[center], 0.382_924_922_548_03));
assert!(approx_eq_f64(kernel[center + 1], 0.241_730_337_457_13));
assert!(approx_eq_f64(kernel[center + 2], 0.060_597_535_943_08));
}
#[test]
fn kernel_is_symmetric_about_center_f64() {
for sampling in [
GaussianSampling::ErfIntegrated,
GaussianSampling::PointSampled,
] {
for normalization in [Normalization::Sum, Normalization::L2, Normalization::None] {
let kernel = gaussian1d::<f64>(1.3, 4.0, sampling, normalization);
let center = kernel.len() / 2;
for k in 1..=center {
assert!(
approx_eq_f64(kernel[center + k], kernel[center - k]),
"asymmetric at k={k}"
);
}
}
}
}
#[test]
fn sum_normalization_sums_to_one_f64() {
for sampling in [
GaussianSampling::ErfIntegrated,
GaussianSampling::PointSampled,
] {
for sigma in [0.5, 1.0, 2.7] {
let kernel = gaussian1d::<f64>(sigma, 4.0, sampling, Normalization::Sum);
let sum: f64 = kernel.iter().sum();
assert!(approx_eq_f64(sum, 1.0), "sigma={sigma} sum={sum}");
}
}
}
#[test]
fn l2_normalization_sum_of_squares_is_one_f64() {
for sampling in [
GaussianSampling::ErfIntegrated,
GaussianSampling::PointSampled,
] {
for sigma in [0.5, 1.0, 2.7] {
let kernel = gaussian1d::<f64>(sigma, 4.0, sampling, Normalization::L2);
let sum_sq: f64 = kernel.iter().map(|v| v * v).sum();
assert!(approx_eq_f64(sum_sq, 1.0), "sigma={sigma} ss={sum_sq}");
}
}
}
#[test]
fn point_sampled_known_values_sigma_one_f64() {
let kernel = gaussian1d::<f64>(
1.0,
4.0,
GaussianSampling::PointSampled,
Normalization::None,
);
let center = kernel.len() / 2;
assert!(approx_eq_f64(kernel[center], 1.0)); assert!(approx_eq_f64(kernel[center + 1], (-0.5_f64).exp()));
assert!(approx_eq_f64(kernel[center + 2], (-2.0_f64).exp()));
}
#[test]
fn erf_and_point_sampling_differ_for_narrow_sigma_f64() {
let erf = gaussian1d::<f64>(
0.5,
4.0,
GaussianSampling::ErfIntegrated,
Normalization::Sum,
);
let point = gaussian1d::<f64>(0.5, 4.0, GaussianSampling::PointSampled, Normalization::Sum);
assert_eq!(erf.len(), point.len());
let max_abs_diff = erf
.iter()
.zip(point.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
max_abs_diff > 1e-3,
"sampling modes too close: max diff {max_abs_diff}"
);
}
#[test]
fn raw_erf_kernel_sum_approaches_one_with_wider_truncate_f64() {
let sum4: f64 = gaussian1d::<f64>(
1.0,
4.0,
GaussianSampling::ErfIntegrated,
Normalization::None,
)
.iter()
.sum();
let sum6: f64 = gaussian1d::<f64>(
1.0,
6.0,
GaussianSampling::ErfIntegrated,
Normalization::None,
)
.iter()
.sum();
assert!(sum4 < 1.0 && sum4 > 0.999);
assert!(sum6 < 1.0 && sum6 > sum4);
assert!((1.0 - sum6).abs() < 1e-9, "sum6={sum6}");
}
#[test]
#[should_panic(expected = "sigma must be positive")]
fn zero_sigma_panics() {
let _ = gaussian1d::<f64>(
0.0,
4.0,
GaussianSampling::ErfIntegrated,
Normalization::Sum,
);
}
#[test]
#[should_panic(expected = "truncate must be positive")]
fn zero_truncate_panics() {
let _ = gaussian1d::<f64>(
1.0,
0.0,
GaussianSampling::ErfIntegrated,
Normalization::Sum,
);
}
#[test]
fn works_with_f32() {
let kernel = gaussian1d::<f32>(
1.0,
4.0,
GaussianSampling::ErfIntegrated,
Normalization::Sum,
);
assert_eq!(kernel.len(), 9);
let sum: f32 = kernel.iter().sum();
assert!(approx_eq_f32(sum, 1.0));
let center = kernel.len() / 2;
for k in 1..=center {
assert!(approx_eq_f32(kernel[center + k], kernel[center - k]));
}
}
}