use std::sync::Arc;
use ndarray::Array2;
use num_traits::{Float, cast};
use realfft::{ComplexToReal, RealFftPlanner, RealToComplex};
use rustfft::{Fft, FftNum, FftPlanner, num_complex::Complex};
use thiserror::Error;
use crate::beam::{Beam, gauss_factor};
#[derive(Debug, Error)]
pub enum ConvolveError {
#[error("image is entirely NaN")]
AllNaN,
#[error("beam larger than cutoff — image blanked")]
AboveCutoff,
}
pub trait FftFloat: FftNum + Float {}
impl FftFloat for f32 {}
impl FftFloat for f64 {}
pub(crate) fn cast_saturating<T: FftFloat>(value: f64) -> T {
cast::<f64, T>(value).unwrap_or_else(|| {
if value.is_sign_negative() {
T::neg_infinity()
} else {
T::infinity()
}
})
}
pub struct ConvolutionResult<T = f32> {
pub image: Array2<T>,
pub scaling_factor: f64,
}
pub struct FftPlans<T: FftNum = f32> {
nrows: usize,
ncols: usize,
nhalf: usize,
r2c: Arc<dyn RealToComplex<T>>,
c2r: Arc<dyn ComplexToReal<T>>,
col_fwd: Arc<dyn Fft<T>>,
col_inv: Arc<dyn Fft<T>>,
}
impl<T: FftNum> FftPlans<T> {
pub fn new(nrows: usize, ncols: usize) -> Self {
let mut rplanner = RealFftPlanner::<T>::new();
let r2c = rplanner.plan_fft_forward(ncols);
let c2r = rplanner.plan_fft_inverse(ncols);
let mut cplanner = FftPlanner::<T>::new();
let col_fwd = cplanner.plan_fft_forward(nrows);
let col_inv = cplanner.plan_fft_inverse(nrows);
Self {
nrows,
ncols,
nhalf: ncols / 2 + 1,
r2c,
c2r,
col_fwd,
col_inv,
}
}
pub fn dim(&self) -> (usize, usize) {
(self.nrows, self.ncols)
}
}
pub fn convolve_uv<T: FftFloat>(
image: &Array2<T>,
old_beam: &Beam,
new_beam: &Beam,
dx_deg: f64,
dy_deg: f64,
cutoff_arcsec: Option<f64>,
) -> Result<ConvolutionResult<T>, ConvolveError> {
let (nrows, ncols) = image.dim();
let plans = FftPlans::<T>::new(nrows, ncols);
convolve_uv_with_plans(
image,
old_beam,
new_beam,
dx_deg,
dy_deg,
cutoff_arcsec,
&plans,
)
}
pub fn convolve_uv_with_plans<T: FftFloat>(
image: &Array2<T>,
old_beam: &Beam,
new_beam: &Beam,
dx_deg: f64,
dy_deg: f64,
cutoff_arcsec: Option<f64>,
plans: &FftPlans<T>,
) -> Result<ConvolutionResult<T>, ConvolveError> {
if let Some(cutoff) = cutoff_arcsec
&& old_beam.major_arcsec() > cutoff
{
return Err(ConvolveError::AboveCutoff);
}
if old_beam.approx_eq(new_beam) {
return Ok(ConvolutionResult {
image: image.clone(),
scaling_factor: 1.0,
});
}
let conv_beam = new_beam.deconvolve_or_zero(old_beam);
let (fac, ..) = gauss_factor(
&conv_beam,
old_beam,
dx_deg.abs() * 3600.0,
dy_deg.abs() * 3600.0,
);
let (nrows, ncols) = image.dim();
assert_eq!(
plans.dim(),
(nrows, ncols),
"FftPlans built for {:?} but image is {:?}",
plans.dim(),
(nrows, ncols)
);
let mut clean_image: Vec<T> = Vec::with_capacity(nrows * ncols);
let mut nan_count = 0usize;
for &x in image.iter() {
if x.is_nan() {
nan_count += 1;
clean_image.push(T::zero());
} else {
clean_image.push(x);
}
}
if nan_count == nrows * ncols {
return Ok(ConvolutionResult {
image: image.clone(),
scaling_factor: fac,
});
}
let nan_mask: Option<Vec<T>> = if nan_count > 0 {
Some(
image
.iter()
.map(|&x| if x.is_nan() { T::one() } else { T::zero() })
.collect(),
)
} else {
None
};
let nhalf = ncols / 2 + 1;
let dx_rad = dx_deg.to_radians();
let dy_rad = dy_deg.to_radians();
let u_freqs = fftfreq(nrows, dx_rad); let v_freqs_full = fftfreq(ncols, dy_rad);
let v_freqs = &v_freqs_full[..nhalf];
let (g_final, g_ratio) = gaussft(old_beam, new_beam, &u_freqs, v_freqs);
let g_t: Vec<T> = g_final.iter().map(|&g| cast_saturating::<T>(g)).collect();
let mut im_f = rfft2(plans, &clean_image);
for (s, &g) in im_f.iter_mut().zip(g_t.iter()) {
*s = s.scale(g);
}
let im_conv_flat = irfft2(plans, im_f);
let out_flat: Vec<T> = if let Some(mask) = nan_mask {
let mut mask_f = rfft2(plans, &mask);
for (s, &g) in mask_f.iter_mut().zip(g_t.iter()) {
*s = s.scale(g);
}
let mask_conv = irfft2(plans, mask_f);
let blank_threshold = T::one() - cast_saturating::<T>(1e-2);
im_conv_flat
.iter()
.zip(mask_conv.iter())
.map(|(&v, &m)| if m >= blank_threshold { T::nan() } else { v })
.collect()
} else {
im_conv_flat
};
let out = Array2::from_shape_vec((nrows, ncols), out_flat)
.expect("shape mismatch in convolve_uv output");
Ok(ConvolutionResult {
image: out,
scaling_factor: g_ratio,
})
}
pub fn gaussft(
old_beam: &Beam,
new_beam: &Beam,
u_freqs: &[f64],
v_freqs: &[f64],
) -> (Vec<f64>, f64) {
let deg2rad = std::f64::consts::PI / 180.0;
let two_ln2 = 2.0 * 2_f64.ln();
let fwhm_to_sigma = 2.0 * two_ln2.sqrt();
let bmaj_rad = new_beam.major_deg * deg2rad;
let bmin_rad = new_beam.minor_deg * deg2rad;
let bpa_rad = new_beam.pa_deg * deg2rad;
let sx = bmaj_rad / fwhm_to_sigma;
let sy = bmin_rad / fwhm_to_sigma;
let bmaj_in_rad = old_beam.major_deg * deg2rad;
let bmin_in_rad = old_beam.minor_deg * deg2rad;
let bpa_in_rad = old_beam.pa_deg * deg2rad;
let sx_in = bmaj_in_rad / fwhm_to_sigma;
let sy_in = bmin_in_rad / fwhm_to_sigma;
let g_amp = (2.0 * std::f64::consts::PI * sx * sy).sqrt();
let dg_amp = (2.0 * std::f64::consts::PI * sx_in * sy_in).sqrt();
let g_ratio = g_amp / dg_amp;
let pi2 = std::f64::consts::PI * std::f64::consts::PI;
let nrows = u_freqs.len();
let ncols = v_freqs.len();
let mut g_final = vec![0.0_f64; nrows * ncols];
let u_cos = u_freqs
.iter()
.map(|&u| u * bpa_rad.cos())
.collect::<Vec<_>>();
let u_sin = u_freqs
.iter()
.map(|&u| u * bpa_rad.sin())
.collect::<Vec<_>>();
let v_cos = v_freqs
.iter()
.map(|&v| v * bpa_rad.cos())
.collect::<Vec<_>>();
let v_sin = v_freqs
.iter()
.map(|&v| v * bpa_rad.sin())
.collect::<Vec<_>>();
let u_cos_in = u_freqs
.iter()
.map(|&u| u * bpa_in_rad.cos())
.collect::<Vec<_>>();
let u_sin_in = u_freqs
.iter()
.map(|&u| u * bpa_in_rad.sin())
.collect::<Vec<_>>();
let v_cos_in = v_freqs
.iter()
.map(|&v| v * bpa_in_rad.cos())
.collect::<Vec<_>>();
let v_sin_in = v_freqs
.iter()
.map(|&v| v * bpa_in_rad.sin())
.collect::<Vec<_>>();
for i in 0..nrows {
for j in 0..ncols {
let ur = u_cos[i] - v_sin[j];
let vr = u_sin[i] + v_cos[j];
let ur_in = u_cos_in[i] - v_sin_in[j];
let vr_in = u_sin_in[i] + v_cos_in[j];
let g_arg = -2.0 * pi2 * ((sx * ur).powi(2) + (sy * vr).powi(2));
let dg_arg = -2.0 * pi2 * ((sx_in * ur_in).powi(2) + (sy_in * vr_in).powi(2));
g_final[i * ncols + j] = g_ratio * (g_arg - dg_arg).exp();
}
}
(g_final, g_ratio)
}
pub fn fftfreq(n: usize, d: f64) -> Vec<f64> {
let val = 1.0 / (n as f64 * d);
let m = n.div_ceil(2); let mut freqs = vec![0.0_f64; n];
for (i, freq) in freqs.iter_mut().enumerate().take(m) {
*freq = i as f64 * val;
}
for (i, freq) in freqs.iter_mut().enumerate().take(n).skip(m) {
*freq = (i as f64 - n as f64) * val;
}
freqs
}
fn rfft2<T: FftFloat>(plans: &FftPlans<T>, data: &[T]) -> Vec<Complex<T>> {
let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
let zero = Complex::new(T::zero(), T::zero());
let mut scratch = plans.r2c.make_scratch_vec();
let mut inrow = plans.r2c.make_input_vec();
let mut spectrum = vec![zero; nrows * nhalf];
for (i, chunk) in data.chunks(ncols).enumerate() {
inrow.copy_from_slice(chunk);
plans
.r2c
.process_with_scratch(
&mut inrow,
&mut spectrum[i * nhalf..(i + 1) * nhalf],
&mut scratch,
)
.expect("r2c FFT");
}
let mut col_scratch = vec![zero; plans.col_fwd.get_inplace_scratch_len()];
let mut col_buf = vec![zero; nrows];
for j in 0..nhalf {
for i in 0..nrows {
col_buf[i] = spectrum[i * nhalf + j];
}
plans
.col_fwd
.process_with_scratch(&mut col_buf, &mut col_scratch);
for i in 0..nrows {
spectrum[i * nhalf + j] = col_buf[i];
}
}
spectrum
}
fn irfft2<T: FftFloat>(plans: &FftPlans<T>, mut spectrum: Vec<Complex<T>>) -> Vec<T> {
let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
let zero = Complex::new(T::zero(), T::zero());
let mut col_scratch = vec![zero; plans.col_inv.get_inplace_scratch_len()];
let mut col_buf = vec![zero; nrows];
for j in 0..nhalf {
for i in 0..nrows {
col_buf[i] = spectrum[i * nhalf + j];
}
plans
.col_inv
.process_with_scratch(&mut col_buf, &mut col_scratch);
for i in 0..nrows {
spectrum[i * nhalf + j] = col_buf[i];
}
}
let mut scratch = plans.c2r.make_scratch_vec();
let mut inrow = plans.c2r.make_input_vec();
let mut out = vec![T::zero(); nrows * ncols];
let even = ncols.is_multiple_of(2);
for i in 0..nrows {
inrow.copy_from_slice(&spectrum[i * nhalf..(i + 1) * nhalf]);
inrow[0].im = T::zero();
if even {
inrow[nhalf - 1].im = T::zero();
}
plans
.c2r
.process_with_scratch(
&mut inrow,
&mut out[i * ncols..(i + 1) * ncols],
&mut scratch,
)
.expect("c2r FFT");
}
let norm = cast::<usize, T>(nrows * ncols).expect("size out of range");
for v in out.iter_mut() {
*v = *v / norm;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::Array2;
#[test]
fn test_fftfreq() {
let f = fftfreq(4, 1.0);
let expected = [0.0, 0.25, -0.5, -0.25];
for (a, b) in f.iter().zip(expected.iter()) {
assert!((a - b).abs() < 1e-12, "got {a}, want {b}");
}
}
#[test]
fn test_rfft2_irfft2_roundtrip() {
let data = vec![
1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0,
16.0,
];
let (nrows, ncols) = (4, 4);
let plans = FftPlans::<f64>::new(nrows, ncols);
let spectrum = rfft2(&plans, &data);
let recovered = irfft2(&plans, spectrum);
for (a, b) in data.iter().zip(recovered.iter()) {
assert!((a - b).abs() < 1e-10, "roundtrip failed: {a} vs {b}");
}
}
#[test]
fn test_rfft2_irfft2_roundtrip_f32() {
let data: Vec<f32> = (1..=16).map(|x| x as f32).collect();
let (nrows, ncols) = (4, 4);
let plans = FftPlans::<f32>::new(nrows, ncols);
let spectrum = rfft2(&plans, &data);
let recovered = irfft2(&plans, spectrum);
for (a, b) in data.iter().zip(recovered.iter()) {
assert!((a - b).abs() < 1e-3, "f32 roundtrip failed: {a} vs {b}");
}
}
#[test]
fn test_convolve_uv_no_change_when_beams_equal() {
let beam = Beam::new(10.0 / 3600.0, 10.0 / 3600.0, 0.0).unwrap();
let img = Array2::from_elem((16, 16), 1.0_f32);
let result = convolve_uv(&img, &beam, &beam, 2.5 / 3600.0, 2.5 / 3600.0, None).unwrap();
assert!((result.scaling_factor - 1.0).abs() < 1e-10);
}
#[test]
fn test_convolve_uv_point_source_flux_and_peak() {
let (n, dx) = (64usize, 2.0 / 3600.0);
let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
let mut img = Array2::<f64>::zeros((n, n));
img[(n / 2, n / 2)] = 1.0;
let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
let total: f64 = res.image.iter().sum();
assert!(
(total - res.scaling_factor).abs() < 1e-6,
"integral {total} != DC gain {}",
res.scaling_factor
);
let peak = res.image[(n / 2, n / 2)];
assert!(peak > 0.0);
for &v in res.image.iter() {
assert!(v <= peak + 1e-9, "pixel {v} exceeds peak {peak}");
}
}
#[test]
fn test_convolve_uv_f32_matches_f64() {
let (n, dx) = (48usize, 2.5 / 3600.0);
let old = Beam::from_arcsec(8.0, 6.0, 20.0).unwrap();
let new = Beam::from_arcsec(15.0, 12.0, 20.0).unwrap();
let img64 =
Array2::<f64>::from_shape_fn((n, n), |(i, j)| ((i * 7 + j * 3) % 11) as f64 / 11.0);
let img32 = img64.mapv(|x| x as f32);
let r64 = convolve_uv(&img64, &old, &new, dx, dx, None).unwrap();
let r32 = convolve_uv(&img32, &old, &new, dx, dx, None).unwrap();
for (a, b) in r64.image.iter().zip(r32.image.iter()) {
assert!(
(*a - *b as f64).abs() < 1e-4,
"f32/f64 mismatch: {a} vs {b}"
);
}
}
#[test]
fn test_convolve_uv_propagates_nans() {
let (n, dx) = (48usize, 2.5 / 3600.0);
let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
let mut img = Array2::<f32>::from_elem((n, n), 1.0);
for i in 0..12 {
for j in 0..12 {
img[(i, j)] = f32::NAN;
}
}
let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
assert!(res.image[(3, 3)].is_nan(), "block interior should stay NaN");
assert!(res.image[(n - 1, n - 1)].is_finite());
}
#[test]
fn test_with_plans_matches_per_call() {
let (n, dx) = (32usize, 2.5 / 3600.0);
let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
let img = Array2::<f32>::from_shape_fn((n, n), |(i, j)| (i + 2 * j) as f32);
let per_call = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
let plans = FftPlans::<f32>::new(n, n);
let reused = convolve_uv_with_plans(&img, &old, &new, dx, dx, None, &plans).unwrap();
for (a, b) in per_call.image.iter().zip(reused.image.iter()) {
assert_eq!(a.to_bits(), b.to_bits(), "plan reuse changed output");
}
}
#[test]
fn test_gaussft_dc_equals_ratio() {
let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
let new = Beam::from_arcsec(12.0, 10.0, 30.0).unwrap();
let (g, ratio) = gaussft(&old, &new, &[0.0], &[0.0]);
assert!(
(g[0] - ratio).abs() < 1e-12,
"DC {} != ratio {}",
g[0],
ratio
);
assert!(ratio > 1.0, "larger target beam should have ratio > 1");
}
}