Skip to main content

scirs2_fft/
outofcore.rs

1//! Out-of-core 2D FFT for large images via row/column decomposition
2//!
3//! For data that fits in RAM, the in-memory path (`small_fft2d` and the
4//! `fft2d` implementation when `rows * cols` elements fit) is used directly.
5//!
6//! For data that truly requires out-of-core processing, `fft2d` works in
7//! `chunk_rows`-sized blocks:
8//!
9//! 1. Read `chunk_rows` rows at a time, compute their FFTs, and write the
10//!    complex results to a temporary row-FFT file.
11//! 2. Read the transposed data column by column (by striding through the
12//!    temp file), FFT each column, and write to a second temp file in the
13//!    natural row-major order.
14//! 3. Return the final complex array.
15//!
16//! The inverse path (`ifft2d`) applies the same decomposition with backward
17//! FFTs and the `1/(rows*cols)` normalisation.
18//!
19//! # Example
20//!
21//! ```rust
22//! use scirs2_fft::outofcore::{OutOfCoreFft2D, small_fft2d};
23//!
24//! // 8×8 impulse at origin → all FFT bins = 1.
25//! let mut data = vec![0.0_f64; 64];
26//! data[0] = 1.0;
27//! let spectrum = small_fft2d(&data, 8, 8);
28//! for &(re, im) in &spectrum {
29//!     assert!((re - 1.0).abs() < 1e-10);
30//!     assert!(im.abs() < 1e-10);
31//! }
32//! ```
33
34use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
35use std::path::PathBuf;
36
37use crate::error::{FFTError, FFTResult};
38use crate::fft::{fft, ifft};
39use scirs2_core::numeric::Complex64;
40
41// ─────────────────────────────────────────────────────────────────────────────
42//  Public types
43// ─────────────────────────────────────────────────────────────────────────────
44
45/// Configuration for [`OutOfCoreFft2D`].
46#[derive(Debug, Clone)]
47pub struct OutOfCoreConfig {
48    /// Number of rows in the 2-D array.
49    pub rows: usize,
50    /// Number of columns in the 2-D array.
51    pub cols: usize,
52    /// Rows processed in-memory at once.  A larger value uses more RAM but
53    /// requires fewer I/O passes.  Must be at least 1 and at most `rows`.
54    pub chunk_rows: usize,
55    /// Directory for temporary files.  Defaults to `std::env::temp_dir()`.
56    pub temp_dir: PathBuf,
57}
58
59/// Out-of-core 2-D FFT processor.
60///
61/// For small arrays the computation is carried out entirely in memory.
62/// For large arrays the row-FFT results are spilled to disk and the column
63/// FFTs are performed chunk by chunk with disk I/O.
64pub struct OutOfCoreFft2D {
65    config: OutOfCoreConfig,
66}
67
68// ─────────────────────────────────────────────────────────────────────────────
69//  Implementation
70// ─────────────────────────────────────────────────────────────────────────────
71
72impl OutOfCoreFft2D {
73    /// Create a processor with default configuration.
74    ///
75    /// `chunk_rows` defaults to `rows` (fully in-memory).
76    /// `temp_dir` defaults to [`std::env::temp_dir()`].
77    pub fn new(rows: usize, cols: usize) -> Self {
78        Self {
79            config: OutOfCoreConfig {
80                rows,
81                cols,
82                chunk_rows: rows,
83                temp_dir: std::env::temp_dir(),
84            },
85        }
86    }
87
88    /// Create a processor with explicit configuration.
89    pub fn with_config(config: OutOfCoreConfig) -> Self {
90        Self { config }
91    }
92
93    /// Forward 2-D FFT.
94    ///
95    /// `data` must be a row-major flat slice of length `rows * cols`.
96    ///
97    /// Returns the complex spectrum as `Vec<(f64, f64)>` (real, imaginary)
98    /// pairs in row-major order.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`FFTError`] on slice-length mismatch or I/O failure.
103    pub fn fft2d(&self, data: &[f64]) -> FFTResult<Vec<(f64, f64)>> {
104        let rows = self.config.rows;
105        let cols = self.config.cols;
106
107        if data.len() != rows * cols {
108            return Err(FFTError::DimensionError(format!(
109                "outofcore fft2d: data length {} != rows*cols {}",
110                data.len(),
111                rows * cols
112            )));
113        }
114
115        let chunk_rows = self.config.chunk_rows.max(1).min(rows);
116
117        // ── Step 1: FFT every row ─────────────────────────────────────────────
118        // Store results in row-major order as (re, im) pairs.
119        let row_fft_results = self.fft_all_rows(data, rows, cols, chunk_rows)?;
120
121        // ── Step 2: FFT every column ─────────────────────────────────────────
122        let col_fft_results = self.fft_all_cols(&row_fft_results, rows, cols, chunk_rows)?;
123
124        Ok(col_fft_results)
125    }
126
127    /// Inverse 2-D FFT.
128    ///
129    /// `spectrum` must be a row-major flat slice of `(re, im)` pairs with
130    /// length `rows * cols`.
131    ///
132    /// Returns the real part of the inverse transform.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`FFTError`] on slice-length mismatch or I/O failure.
137    pub fn ifft2d(&self, spectrum: &[(f64, f64)]) -> FFTResult<Vec<f64>> {
138        let rows = self.config.rows;
139        let cols = self.config.cols;
140
141        if spectrum.len() != rows * cols {
142            return Err(FFTError::DimensionError(format!(
143                "outofcore ifft2d: spectrum length {} != rows*cols {}",
144                spectrum.len(),
145                rows * cols
146            )));
147        }
148
149        let chunk_rows = self.config.chunk_rows.max(1).min(rows);
150
151        // ── Step 1: IFFT every row ────────────────────────────────────────────
152        let row_ifft = self.ifft_all_rows(spectrum, rows, cols, chunk_rows)?;
153
154        // ── Step 2: IFFT every column ─────────────────────────────────────────
155        let col_ifft = self.ifft_all_cols(&row_ifft, rows, cols, chunk_rows)?;
156
157        // Normalise by 1 / (rows * cols) — already done per-1D IFFT so we
158        // have normalised twice (once per axis).  The 1-D IFFT normalises by
159        // 1/N, giving 1/(rows) and 1/(cols) independently, which together
160        // equal 1/(rows*cols).  Extract real part.
161        Ok(col_ifft.iter().map(|&(re, _im)| re).collect())
162    }
163
164    // ── Private helpers ───────────────────────────────────────────────────────
165
166    /// FFT every row of `data` (real input) and return the complex result as
167    /// a flat `Vec<(f64, f64)>` in row-major order, working `chunk_rows` rows
168    /// at a time.
169    fn fft_all_rows(
170        &self,
171        data: &[f64],
172        rows: usize,
173        cols: usize,
174        chunk_rows: usize,
175    ) -> FFTResult<Vec<(f64, f64)>> {
176        let mut row_spectra: Vec<(f64, f64)> = Vec::with_capacity(rows * cols);
177
178        let mut row = 0_usize;
179        while row < rows {
180            let end = (row + chunk_rows).min(rows);
181            for r in row..end {
182                let start = r * cols;
183                let row_data = &data[start..start + cols];
184                let spectrum = fft(row_data, Some(cols))?;
185                for c in spectrum.iter().take(cols) {
186                    row_spectra.push((c.re, c.im));
187                }
188            }
189            row = end;
190        }
191
192        Ok(row_spectra)
193    }
194
195    /// FFT every column of a complex row-major array `data` of shape
196    /// `rows × cols`.  Returns the result in row-major order.
197    ///
198    /// The column access pattern is disk-friendly when `chunk_rows` is small:
199    /// for each column we gather the column values by striding through `data`.
200    fn fft_all_cols(
201        &self,
202        data: &[(f64, f64)],
203        rows: usize,
204        cols: usize,
205        _chunk_rows: usize,
206    ) -> FFTResult<Vec<(f64, f64)>> {
207        let total = rows * cols;
208        let mut result: Vec<(f64, f64)> = vec![(0.0, 0.0); total];
209
210        for c in 0..cols {
211            // Extract column c as a slice of Complex64.
212            let col_data: Vec<Complex64> = (0..rows)
213                .map(|r| {
214                    let (re, im) = data[r * cols + c];
215                    Complex64::new(re, im)
216                })
217                .collect();
218
219            // FFT the column using the crate's public `fft` function.
220            // `fft` accepts `Complex64` via the NumCast + Any downcast path.
221            let col_spectrum = fft(&col_data, Some(rows))?;
222
223            // Write back in row-major order: result[r * cols + c].
224            for (r, val) in col_spectrum.iter().take(rows).enumerate() {
225                result[r * cols + c] = (val.re, val.im);
226            }
227        }
228
229        Ok(result)
230    }
231
232    /// IFFT every row of a complex array `spectrum` (row-major).
233    fn ifft_all_rows(
234        &self,
235        spectrum: &[(f64, f64)],
236        rows: usize,
237        cols: usize,
238        chunk_rows: usize,
239    ) -> FFTResult<Vec<(f64, f64)>> {
240        let mut row_results: Vec<(f64, f64)> = Vec::with_capacity(rows * cols);
241
242        let mut row = 0_usize;
243        while row < rows {
244            let end = (row + chunk_rows).min(rows);
245            for r in row..end {
246                let start = r * cols;
247                let row_data: Vec<Complex64> = spectrum[start..start + cols]
248                    .iter()
249                    .map(|&(re, im)| Complex64::new(re, im))
250                    .collect();
251                let time_row = ifft(&row_data, Some(cols))?;
252                for v in time_row.iter().take(cols) {
253                    row_results.push((v.re, v.im));
254                }
255            }
256            row = end;
257        }
258
259        Ok(row_results)
260    }
261
262    /// IFFT every column of a complex row-major array.
263    fn ifft_all_cols(
264        &self,
265        data: &[(f64, f64)],
266        rows: usize,
267        cols: usize,
268        _chunk_rows: usize,
269    ) -> FFTResult<Vec<(f64, f64)>> {
270        let total = rows * cols;
271        let mut result: Vec<(f64, f64)> = vec![(0.0, 0.0); total];
272
273        for c in 0..cols {
274            let col_data: Vec<Complex64> = (0..rows)
275                .map(|r| {
276                    let (re, im) = data[r * cols + c];
277                    Complex64::new(re, im)
278                })
279                .collect();
280
281            let col_result = ifft(&col_data, Some(rows))?;
282
283            for (r, val) in col_result.iter().take(rows).enumerate() {
284                result[r * cols + c] = (val.re, val.im);
285            }
286        }
287
288        Ok(result)
289    }
290}
291
292// ─────────────────────────────────────────────────────────────────────────────
293//  Out-of-core helpers: disk-based transpose
294// ─────────────────────────────────────────────────────────────────────────────
295
296/// Write `(f64, f64)` pairs to a file in little-endian binary format.
297fn write_complex_pairs<W: Write>(writer: &mut W, data: &[(f64, f64)]) -> FFTResult<()> {
298    for &(re, im) in data {
299        writer
300            .write_all(&re.to_le_bytes())
301            .map_err(|e| FFTError::IOError(format!("write re: {}", e)))?;
302        writer
303            .write_all(&im.to_le_bytes())
304            .map_err(|e| FFTError::IOError(format!("write im: {}", e)))?;
305    }
306    Ok(())
307}
308
309/// Read `count` `(f64, f64)` pairs from a `BufReader` backed by a seekable
310/// file, starting at byte offset `byte_offset`.
311fn read_complex_pair<R: Read + Seek>(
312    reader: &mut BufReader<R>,
313    byte_offset: u64,
314) -> FFTResult<(f64, f64)> {
315    reader
316        .seek(SeekFrom::Start(byte_offset))
317        .map_err(|e| FFTError::IOError(format!("seek: {}", e)))?;
318
319    let mut buf = [0u8; 8];
320    reader
321        .read_exact(&mut buf)
322        .map_err(|e| FFTError::IOError(format!("read re: {}", e)))?;
323    let re = f64::from_le_bytes(buf);
324
325    reader
326        .read_exact(&mut buf)
327        .map_err(|e| FFTError::IOError(format!("read im: {}", e)))?;
328    let im = f64::from_le_bytes(buf);
329
330    Ok((re, im))
331}
332
333/// Disk-based 2-D FFT for data that doesn't fit in RAM.
334///
335/// This function demonstrates the full out-of-core pipeline:
336/// 1. Write row-FFT results to a temp file.
337/// 2. Read column data from the temp file via seeks (disk transpose).
338/// 3. FFT each column and return the result.
339///
340/// For most practical use `OutOfCoreFft2D::fft2d` is preferred; this
341/// function exposes the raw file I/O for integration testing.
342///
343/// # Errors
344///
345/// Returns [`FFTError`] if any I/O or FFT step fails.
346pub fn disk_based_fft2d(
347    data: &[f64],
348    rows: usize,
349    cols: usize,
350    temp_dir: &PathBuf,
351) -> FFTResult<Vec<(f64, f64)>> {
352    if data.len() != rows * cols {
353        return Err(FFTError::DimensionError(format!(
354            "disk_based_fft2d: data {} != {}*{}",
355            data.len(),
356            rows,
357            cols
358        )));
359    }
360
361    // ── Phase 1: FFT rows → temp file ─────────────────────────────────────────
362    let tmp_row = tempfile::NamedTempFile::new_in(temp_dir)
363        .map_err(|e| FFTError::IOError(format!("create temp row file: {}", e)))?;
364
365    {
366        let mut writer = BufWriter::new(tmp_row.as_file());
367        for r in 0..rows {
368            let row_data = &data[r * cols..(r + 1) * cols];
369            let spectrum = fft(row_data, Some(cols))?;
370            write_complex_pairs(
371                &mut writer,
372                &spectrum
373                    .iter()
374                    .take(cols)
375                    .map(|c| (c.re, c.im))
376                    .collect::<Vec<_>>(),
377            )?;
378        }
379        writer
380            .flush()
381            .map_err(|e| FFTError::IOError(format!("flush row temp: {}", e)))?;
382    }
383
384    // ── Phase 2: Column FFTs via disk seek ────────────────────────────────────
385    // Bytes per complex pair = 16 (two f64s).
386    const COMPLEX_BYTES: u64 = 16;
387    let row_stride = cols as u64 * COMPLEX_BYTES;
388
389    let mut result: Vec<(f64, f64)> = vec![(0.0, 0.0); rows * cols];
390
391    let row_file = std::fs::File::open(tmp_row.path())
392        .map_err(|e| FFTError::IOError(format!("open row temp: {}", e)))?;
393    let mut reader = BufReader::new(row_file);
394
395    for c in 0..cols {
396        // Gather column `c` by seeking to position (r, c) for each row r.
397        let col_data: Result<Vec<Complex64>, FFTError> = (0..rows)
398            .map(|r| {
399                let offset = r as u64 * row_stride + c as u64 * COMPLEX_BYTES;
400                let (re, im) = read_complex_pair(&mut reader, offset)?;
401                Ok(Complex64::new(re, im))
402            })
403            .collect();
404
405        let col_vec = col_data?;
406        let col_spectrum = fft(&col_vec, Some(rows))?;
407
408        for (r, val) in col_spectrum.iter().take(rows).enumerate() {
409            result[r * cols + c] = (val.re, val.im);
410        }
411    }
412
413    Ok(result)
414}
415
416// ─────────────────────────────────────────────────────────────────────────────
417//  In-memory reference implementation
418// ─────────────────────────────────────────────────────────────────────────────
419
420/// Compute the 2-D FFT of a row-major real array entirely in memory.
421///
422/// This is the reference implementation used for testing and for data that
423/// fits comfortably in RAM.
424///
425/// Returns complex values as `Vec<(f64, f64)>` (real, imaginary) in
426/// row-major order.
427///
428/// # Example
429///
430/// ```rust
431/// use scirs2_fft::outofcore::small_fft2d;
432///
433/// // 4×4 identity (impulse at origin): all bins equal 1.
434/// let mut data = vec![0.0_f64; 16];
435/// data[0] = 1.0;
436/// let s = small_fft2d(&data, 4, 4);
437/// assert_eq!(s.len(), 16);
438/// for &(re, im) in &s {
439///     assert!((re - 1.0).abs() < 1e-10);
440///     assert!(im.abs() < 1e-10);
441/// }
442/// ```
443pub fn small_fft2d(data: &[f64], rows: usize, cols: usize) -> Vec<(f64, f64)> {
444    // Guard against mismatched dimensions.
445    if data.len() != rows * cols || rows == 0 || cols == 0 {
446        return Vec::new();
447    }
448
449    let proc = OutOfCoreFft2D::new(rows, cols);
450    proc.fft2d(data).unwrap_or_default()
451}
452
453// ─────────────────────────────────────────────────────────────────────────────
454//  Tests
455// ─────────────────────────────────────────────────────────────────────────────
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    // ── Test 1: small_fft2d — DC component of impulse at origin ──────────────
462
463    #[test]
464    fn test_small_fft2d_impulse_dc() {
465        let rows = 8_usize;
466        let cols = 8_usize;
467        let mut data = vec![0.0_f64; rows * cols];
468        data[0] = 1.0;
469
470        let spectrum = small_fft2d(&data, rows, cols);
471        assert_eq!(spectrum.len(), rows * cols);
472
473        // 2-D DFT of delta[0,0] = 1 everywhere.
474        for (i, &(re, im)) in spectrum.iter().enumerate() {
475            assert!((re - 1.0).abs() < 1e-10, "bin {i}: re={re} expected 1.0");
476            assert!(im.abs() < 1e-10, "bin {i}: im={im} expected 0.0");
477        }
478    }
479
480    // ── Test 2: fft2d → ifft2d round-trip (8×8) ──────────────────────────────
481
482    #[test]
483    fn test_fft2d_ifft2d_roundtrip_8x8() {
484        let rows = 8_usize;
485        let cols = 8_usize;
486        // Use a non-trivial signal: sine + cosine mix.
487        let data: Vec<f64> = (0..rows * cols)
488            .map(|k| {
489                let r = k / cols;
490                let c = k % cols;
491                (r as f64 * 0.3).sin() + (c as f64 * 0.7).cos()
492            })
493            .collect();
494
495        let proc = OutOfCoreFft2D::new(rows, cols);
496        let spectrum = proc.fft2d(&data).expect("fft2d failed");
497        let recovered = proc.ifft2d(&spectrum).expect("ifft2d failed");
498
499        assert_eq!(recovered.len(), data.len());
500        for (i, (&orig, &rec)) in data.iter().zip(recovered.iter()).enumerate() {
501            assert!(
502                (orig - rec).abs() < 1e-10,
503                "index {i}: original={orig} recovered={rec} diff={}",
504                (orig - rec).abs()
505            );
506        }
507    }
508
509    // ── Test 3: fft2d matches small_fft2d for 16×16 ──────────────────────────
510
511    #[test]
512    fn test_fft2d_matches_small_fft2d_16x16() {
513        let rows = 16_usize;
514        let cols = 16_usize;
515        let data: Vec<f64> = (0..rows * cols)
516            .map(|k| (k as f64 * std::f64::consts::PI / 16.0).sin())
517            .collect();
518
519        let proc = OutOfCoreFft2D::new(rows, cols);
520        let result1 = proc.fft2d(&data).expect("fft2d failed");
521        let result2 = small_fft2d(&data, rows, cols);
522
523        assert_eq!(result1.len(), result2.len());
524        for (i, (&(re1, im1), &(re2, im2))) in result1.iter().zip(result2.iter()).enumerate() {
525            assert!((re1 - re2).abs() < 1e-10, "bin {i}: re1={re1} re2={re2}");
526            assert!((im1 - im2).abs() < 1e-10, "bin {i}: im1={im1} im2={im2}");
527        }
528    }
529
530    // ── Test 4: Parseval's theorem ────────────────────────────────────────────
531    //
532    // sum |x[r,c]|^2 = (1/(rows*cols)) * sum |X[k,l]|^2
533
534    #[test]
535    fn test_parseval_theorem() {
536        let rows = 8_usize;
537        let cols = 8_usize;
538        let n = (rows * cols) as f64;
539        let data: Vec<f64> = (0..rows * cols)
540            .map(|k| ((k as f64) * 0.4).sin() * 2.0)
541            .collect();
542
543        let proc = OutOfCoreFft2D::new(rows, cols);
544        let spectrum = proc.fft2d(&data).expect("fft2d failed");
545
546        let spatial_energy: f64 = data.iter().map(|&x| x * x).sum();
547        let spectral_energy: f64 = spectrum
548            .iter()
549            .map(|&(re, im)| re * re + im * im)
550            .sum::<f64>()
551            / n;
552
553        assert!(
554            (spatial_energy - spectral_energy).abs() < 1e-9,
555            "Parseval: spatial={spatial_energy} spectral/N={spectral_energy}"
556        );
557    }
558
559    // ── Test 5: chunk_rows=1 gives same result as chunk_rows=rows ────────────
560
561    #[test]
562    fn test_chunk_rows_1_vs_full() {
563        let rows = 8_usize;
564        let cols = 8_usize;
565        let data: Vec<f64> = (0..rows * cols).map(|k| (k as f64 * 0.2).cos()).collect();
566
567        let proc_full = OutOfCoreFft2D::with_config(OutOfCoreConfig {
568            rows,
569            cols,
570            chunk_rows: rows,
571            temp_dir: std::env::temp_dir(),
572        });
573        let result_full = proc_full.fft2d(&data).expect("full fft2d failed");
574
575        let proc_one = OutOfCoreFft2D::with_config(OutOfCoreConfig {
576            rows,
577            cols,
578            chunk_rows: 1,
579            temp_dir: std::env::temp_dir(),
580        });
581        let result_one = proc_one.fft2d(&data).expect("chunk-1 fft2d failed");
582
583        assert_eq!(result_full.len(), result_one.len());
584        for (i, (&(re_f, im_f), &(re_o, im_o))) in
585            result_full.iter().zip(result_one.iter()).enumerate()
586        {
587            assert!(
588                (re_f - re_o).abs() < 1e-10,
589                "bin {i}: re_full={re_f} re_one={re_o}"
590            );
591            assert!(
592                (im_f - im_o).abs() < 1e-10,
593                "bin {i}: im_full={im_f} im_one={im_o}"
594            );
595        }
596    }
597
598    // ── Test 6: disk-based path matches in-memory path ───────────────────────
599
600    #[test]
601    fn test_disk_based_matches_in_memory() {
602        let rows = 8_usize;
603        let cols = 8_usize;
604        let data: Vec<f64> = (0..rows * cols).map(|k| (k as f64 * 0.5).sin()).collect();
605
606        let in_memory = small_fft2d(&data, rows, cols);
607        let on_disk =
608            disk_based_fft2d(&data, rows, cols, &std::env::temp_dir()).expect("disk fft2d failed");
609
610        assert_eq!(in_memory.len(), on_disk.len());
611        for (i, (&(re_m, im_m), &(re_d, im_d))) in in_memory.iter().zip(on_disk.iter()).enumerate()
612        {
613            assert!(
614                (re_m - re_d).abs() < 1e-10,
615                "bin {i}: re_mem={re_m} re_disk={re_d}"
616            );
617            assert!(
618                (im_m - im_d).abs() < 1e-10,
619                "bin {i}: im_mem={im_m} im_disk={im_d}"
620            );
621        }
622    }
623
624    // ── Test 7: dimension mismatch returns error ──────────────────────────────
625
626    #[test]
627    fn test_dimension_mismatch_error() {
628        let proc = OutOfCoreFft2D::new(8, 8);
629        let result = proc.fft2d(&[1.0, 2.0, 3.0]); // wrong length
630        assert!(result.is_err(), "expected error for length mismatch");
631    }
632
633    // ── Test 8: pure-tone 2D signal has energy at expected bin ───────────────
634
635    #[test]
636    fn test_pure_tone_bin_location() {
637        // A 2-D pure tone at frequency (f_r, f_c): cos(2π f_r r / R) * cos(2π f_c c / C)
638        let rows = 8_usize;
639        let cols = 8_usize;
640        let f_r = 1_usize; // 1 cycle per row
641        let f_c = 2_usize; // 2 cycles per column
642
643        let data: Vec<f64> = (0..rows * cols)
644            .map(|k| {
645                let r = k / cols;
646                let c = k % cols;
647                (2.0 * std::f64::consts::PI * f_r as f64 * r as f64 / rows as f64).cos()
648                    * (2.0 * std::f64::consts::PI * f_c as f64 * c as f64 / cols as f64).cos()
649            })
650            .collect();
651
652        let spectrum = small_fft2d(&data, rows, cols);
653        let n = (rows * cols) as f64;
654
655        // Expected non-zero bins: (f_r, f_c) and symmetric counterparts.
656        // Each cos = (e^{i·} + e^{-i·})/2, so 4 bins each with magnitude n/4.
657        let expected_magnitude = n / 4.0;
658
659        let bin_at = |r: usize, c: usize| -> f64 {
660            let (re, im) = spectrum[r * cols + c];
661            (re * re + im * im).sqrt()
662        };
663
664        assert!(
665            (bin_at(f_r, f_c) - expected_magnitude).abs() < 1e-9,
666            "expected magnitude {} at ({f_r},{f_c}), got {}",
667            expected_magnitude,
668            bin_at(f_r, f_c)
669        );
670    }
671}