Skip to main content

openkspace_recon/
strategy.rs

1//! Reconstruction-strategy abstraction.
2//!
3//! A [`ReconStrategy`] turns raw k-space data into a real magnitude image
4//! volume. Multiple strategies can coexist: a textbook Fourier recon
5//! ([`IfftRss`]), parallel-imaging (GRAPPA/SENSE -- planned), or compressed
6//! sensing (planned).
7//!
8//! This trait is intentionally narrow. It hides the decision of whether a
9//! dataset is 2D multi-slice or 3D, whether pre-whitening is enabled, and
10//! so forth -- each strategy handles those internally.
11
12use crate::coil::rss_combine_4d;
13use crate::crop::center_crop_3d;
14use crate::fft::{ifft2_inplace, ifft3_inplace};
15use crate::oversampling::OversamplingRemover;
16use crate::partial_fourier::{homodyne_reconstruct, PartialFourierPlan};
17use crate::phasecorr::PhaseCorrector;
18use crate::prewhiten::NoisePrewhitener;
19use ndarray::Array3;
20use openkspace_io::error::{IoError, IoResult};
21use openkspace_io::ismrmrd::IsmrmrdFile;
22use tracing::info;
23
24/// A magnitude image volume with simple provenance.
25#[derive(Debug)]
26pub struct ImageVolume {
27    /// Real magnitude image, axis order `[slice, y, x]`.
28    pub data: Array3<f32>,
29    /// Name of the strategy that produced this volume (for logging).
30    pub strategy: &'static str,
31    /// Optional SENSE g-factor map, same shape as `data`, populated
32    /// when the strategy supports it and the caller requested it.
33    pub gfactor: Option<Array3<f32>>,
34}
35
36impl ImageVolume {
37    /// Convenience constructor: creates a volume without a g-factor map.
38    pub fn new(data: Array3<f32>, strategy: &'static str) -> Self {
39        Self {
40            data,
41            strategy,
42            gfactor: None,
43        }
44    }
45}
46
47/// Reconstruction strategy: k-space -> magnitude image.
48///
49/// # Design note
50///
51/// The trait takes an [`IsmrmrdFile`] rather than an abstract "k-space source"
52/// because all implemented strategies require calibration data (noise
53/// measurements, phase-correction navigators, ACS lines) that are carried by
54/// the ISMRMRD container. FastMRI files are fully-sampled, pre-coil-combined
55/// tensors and are handled by a separate, simpler path in the CLI
56/// (`cmd_recon_fastmri`). A generic trait over an abstract reader is planned
57/// for v0.2 once the set of supported formats stabilises.
58pub trait ReconStrategy {
59    /// A human-readable identifier (used in logs & output filenames).
60    fn name(&self) -> &'static str;
61
62    /// Produce a magnitude volume from the given file.
63    ///
64    /// The strategy owns any pre-processing passes it wants to perform
65    /// (reading noise / navigator scans, computing calibrations, etc.).
66    fn reconstruct(&self, file: &IsmrmrdFile) -> IoResult<ImageVolume>;
67}
68
69/// Which FFT axes to transform.
70#[non_exhaustive]
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum FftMode {
73    /// Pick 2D vs 3D from the acquisitions (2D if `kspace_encode_step_2==0`).
74    Auto,
75    /// Force per-slice 2D IFFT over `(ky, kx)` only.
76    TwoD,
77    /// Force 3D IFFT over `(kz, ky, kx)`.
78    ThreeD,
79}
80
81/// Classical textbook reconstruction: centred IFFT + RSS coil combine,
82/// optionally preceded by readout oversampling removal, noise
83/// pre-whitening, and navigator phase correction.
84#[non_exhaustive]
85#[derive(Debug, Clone, Copy)]
86pub struct IfftRss {
87    pub remove_oversampling: bool,
88    pub prewhiten: bool,
89    pub phase_correct: bool,
90    pub partial_fourier: bool,
91    pub fft_mode: FftMode,
92    pub crop_to_recon_matrix: bool,
93}
94
95impl Default for IfftRss {
96    fn default() -> Self {
97        Self {
98            remove_oversampling: true,
99            prewhiten: true,
100            phase_correct: true,
101            partial_fourier: true,
102            fft_mode: FftMode::Auto,
103            crop_to_recon_matrix: true,
104        }
105    }
106}
107
108impl ReconStrategy for IfftRss {
109    fn name(&self) -> &'static str {
110        "ifft-rss"
111    }
112
113    fn reconstruct(&self, file: &IsmrmrdFile) -> IoResult<ImageVolume> {
114        // --- 1. Calibration pass --------------------------------------------
115        let (whitener, phase_corr) = if self.prewhiten || self.phase_correct {
116            let cal = file.read_calibration()?;
117            let w = if self.prewhiten {
118                NoisePrewhitener::from_noise_acqs(&cal.noise)
119            } else {
120                None
121            };
122            let pc = if self.phase_correct {
123                PhaseCorrector::from_phasecorr_acqs(&cal.phasecorr)
124            } else {
125                PhaseCorrector::default()
126            };
127            (w, pc)
128        } else {
129            (None, PhaseCorrector::default())
130        };
131
132        if whitener.is_some() {
133            info!("Noise pre-whitening: enabled");
134        }
135        if !phase_corr.is_empty() {
136            info!("Navigator phase correction: enabled");
137        }
138
139        // --- 1b. Readout oversampling removal -------------------------------
140        let os_remover = if self.remove_oversampling {
141            let enc_x = file.header.encoding.encoded_matrix.x as usize;
142            let rec_x = file.header.encoding.recon_matrix.x as usize;
143            let r = OversamplingRemover::new(enc_x, rec_x);
144            if let Some(r) = &r {
145                r.log_summary();
146            }
147            r
148        } else {
149            None
150        };
151
152        // --- 3. Image pass: read, apply corrections, place into k-space -----
153        let (mut kspace, mask) = file.read_kspace_with_mask(|acq| {
154            if let Some(w) = whitener.as_ref() {
155                w.apply(acq);
156            }
157            phase_corr.apply(acq);
158            if let Some(os) = os_remover.as_ref() {
159                os.apply(acq);
160            }
161        })?;
162
163        // --- 3b. Partial Fourier (homodyne) ---------------------------------
164        //
165        // If the acquisition is asymmetric along ky and otherwise contiguous
166        // (no periodic gaps), dispatch to homodyne reconstruction instead of
167        // the plain IFFT+RSS path. Does nothing in 3D mode.
168        let three_d = match self.fft_mode {
169            FftMode::Auto => file.is_3d_encoding()?,
170            FftMode::TwoD => false,
171            FftMode::ThreeD => true,
172        };
173        if !three_d && self.partial_fourier {
174            let ky_dc = kspace.shape()[2] / 2;
175            if let Some(plan) = PartialFourierPlan::detect(&mask, ky_dc) {
176                let mut magnitude = homodyne_reconstruct(&kspace, &plan);
177                drop(kspace);
178                if self.crop_to_recon_matrix {
179                    magnitude = self.maybe_crop(magnitude, file);
180                }
181                return Ok(ImageVolume {
182                    data: magnitude,
183                    strategy: self.name(),
184                    gfactor: None,
185                });
186            }
187        }
188
189        // --- 4. Centred inverse FFT -----------------------------------------
190        //
191        // Tensor axes are `[channel, kz, ky, kx]` = `[0, 1, 2, 3]`.
192        info!(
193            "FFT mode: {}",
194            if three_d {
195                "3D (kz, ky, kx)"
196            } else {
197                "2D (ky, kx)"
198            }
199        );
200        if three_d {
201            info!("Running 3D IFFT on axes (kz=1, ky=2, kx=3)");
202            ifft3_inplace(&mut kspace, (1, 2, 3));
203        } else {
204            info!("Running 2D IFFT on axes (ky=2, kx=3) for all channels/slices");
205            ifft2_inplace(&mut kspace, (2, 3));
206        }
207
208        // --- 5. RSS coil combine --------------------------------------------
209        info!("RSS coil combine");
210        let mut magnitude = rss_combine_4d(&kspace);
211        drop(kspace);
212
213        // --- 6. Optional crop to the recon matrix ---------------------------
214        if self.crop_to_recon_matrix {
215            magnitude = self.maybe_crop(magnitude, file);
216        }
217
218        Ok(ImageVolume {
219            data: magnitude,
220            strategy: self.name(),
221            gfactor: None,
222        })
223    }
224}
225
226impl IfftRss {
227    fn maybe_crop(&self, mut magnitude: Array3<f32>, file: &IsmrmrdFile) -> Array3<f32> {
228        let rm = &file.header.encoding.recon_matrix;
229        let (nz, ny, nx) = magnitude.dim();
230        let tz = if (rm.z as usize) >= 2 {
231            (rm.z as usize).min(nz)
232        } else {
233            nz
234        };
235        let ty = if rm.y as usize >= 1 {
236            (rm.y as usize).min(ny)
237        } else {
238            ny
239        };
240        let tx = if rm.x as usize >= 1 {
241            (rm.x as usize).min(nx)
242        } else {
243            nx
244        };
245        if (tz, ty, tx) != (nz, ny, nx) && tx <= nx && ty <= ny && tz <= nz {
246            info!(
247                "Cropping recon from {}x{}x{} -> {}x{}x{} (recon matrix)",
248                nz, ny, nx, tz, ty, tx
249            );
250            magnitude = center_crop_3d(&magnitude, (tz, ty, tx));
251        }
252        magnitude
253    }
254}
255
256// ----------------------------------------------------------------------------
257// GRAPPA
258// ----------------------------------------------------------------------------
259
260use crate::grappa::{detect_pattern, extract_acs_slice, GrappaKernel};
261use ndarray::s;
262use tracing::warn;
263
264/// Parallel-imaging reconstruction via GRAPPA kernel synthesis, followed
265/// by IFFT + RSS coil combine.
266///
267/// Detects the undersampling pattern from the acquisition mask, calibrates
268/// a kernel per slice from the auto-calibration region, synthesizes the
269/// missing lines, and then runs the standard Fourier path.
270///
271/// Falls back to [`IfftRss`] behavior when the data is fully sampled or
272/// the sampling pattern is unsupported.
273#[non_exhaustive]
274#[derive(Debug, Clone, Copy)]
275pub struct GrappaRss {
276    pub remove_oversampling: bool,
277    pub prewhiten: bool,
278    pub phase_correct: bool,
279    pub kernel_ky: usize,
280    pub kernel_kx: usize,
281    pub ridge: f32,
282    pub fft_mode: FftMode,
283    pub crop_to_recon_matrix: bool,
284}
285
286impl Default for GrappaRss {
287    fn default() -> Self {
288        Self {
289            remove_oversampling: true,
290            prewhiten: true,
291            phase_correct: true,
292            kernel_ky: 4,
293            kernel_kx: 5,
294            ridge: 1e-3,
295            fft_mode: FftMode::Auto,
296            crop_to_recon_matrix: true,
297        }
298    }
299}
300
301impl ReconStrategy for GrappaRss {
302    fn name(&self) -> &'static str {
303        "grappa-rss"
304    }
305
306    fn reconstruct(&self, file: &IsmrmrdFile) -> IoResult<ImageVolume> {
307        // --- 1. Calibration pass (same as IfftRss) --------------------------
308        let (whitener, phase_corr) = if self.prewhiten || self.phase_correct {
309            let cal = file.read_calibration()?;
310            let w = if self.prewhiten {
311                NoisePrewhitener::from_noise_acqs(&cal.noise)
312            } else {
313                None
314            };
315            let pc = if self.phase_correct {
316                PhaseCorrector::from_phasecorr_acqs(&cal.phasecorr)
317            } else {
318                PhaseCorrector::default()
319            };
320            (w, pc)
321        } else {
322            (None, PhaseCorrector::default())
323        };
324
325        let os_remover = if self.remove_oversampling {
326            let enc_x = file.header.encoding.encoded_matrix.x as usize;
327            let rec_x = file.header.encoding.recon_matrix.x as usize;
328            let r = OversamplingRemover::new(enc_x, rec_x);
329            if let Some(r) = &r {
330                r.log_summary();
331            }
332            r
333        } else {
334            None
335        };
336
337        // --- 2. Read k-space with sampling mask -----------------------------
338        let (mut kspace, mask) = file.read_kspace_with_mask(|acq| {
339            if let Some(w) = whitener.as_ref() {
340                w.apply(acq);
341            }
342            phase_corr.apply(acq);
343            if let Some(os) = os_remover.as_ref() {
344                os.apply(acq);
345            }
346        })?;
347
348        // --- 3. Detect pattern ----------------------------------------------
349        match detect_pattern(&mask) {
350            None => {
351                info!(
352                    "GRAPPA: data appears fully sampled or pattern unsupported; \
353                     skipping kernel synthesis"
354                );
355            }
356            Some(pattern) => {
357                info!(
358                    "GRAPPA: R={}, ACS ky=[{}, {}) (length {})",
359                    pattern.r,
360                    pattern.acs_start,
361                    pattern.acs_end,
362                    pattern.acs_len()
363                );
364                let nz = kspace.shape()[1];
365                // Calibrate per slice and synthesize.
366                for kz in 0..nz {
367                    let acs = extract_acs_slice(&kspace, kz, &pattern);
368                    match GrappaKernel::calibrate(
369                        acs.view(),
370                        pattern.r,
371                        self.kernel_ky,
372                        self.kernel_kx,
373                        self.ridge,
374                    ) {
375                        Ok(kernel) => {
376                            // Synthesize only on this slice: build a view and
377                            // call synthesize on the full tensor -- it walks
378                            // all slices but only touches those whose ACS
379                            // matches. Simpler: build a per-slice kspace view
380                            // by slicing along axis 1 and call synthesize.
381                            let mut slice_view = kspace.slice_mut(s![.., kz..=kz, .., ..]);
382                            let mut slice_owned = slice_view.to_owned();
383                            if let Err(e) = kernel.synthesize(&mut slice_owned) {
384                                warn!("GRAPPA synthesize failed on slice {}: {}", kz, e);
385                            } else {
386                                slice_view.assign(&slice_owned);
387                            }
388                        }
389                        Err(e) => {
390                            warn!(
391                                "GRAPPA calibration failed on slice {}: {} \
392                                 -- leaving this slice undersampled",
393                                kz, e
394                            );
395                        }
396                    }
397                }
398            }
399        }
400
401        // --- 4. IFFT (2D per-slice or full 3D) ------------------------------
402        let three_d = match self.fft_mode {
403            FftMode::Auto => file.is_3d_encoding()?,
404            FftMode::TwoD => false,
405            FftMode::ThreeD => true,
406        };
407        if three_d {
408            info!("Running 3D IFFT on axes (kz=1, ky=2, kx=3)");
409            ifft3_inplace(&mut kspace, (1, 2, 3));
410        } else {
411            info!("Running 2D IFFT on axes (ky=2, kx=3) for all channels/slices");
412            ifft2_inplace(&mut kspace, (2, 3));
413        }
414
415        // --- 5. RSS coil combine --------------------------------------------
416        info!("RSS coil combine");
417        let mut magnitude = rss_combine_4d(&kspace);
418        drop(kspace);
419
420        // --- 6. Optional crop to the recon matrix ---------------------------
421        if self.crop_to_recon_matrix {
422            let rm = &file.header.encoding.recon_matrix;
423            let (nz, ny, nx) = magnitude.dim();
424            let tz = if (rm.z as usize) >= 2 {
425                (rm.z as usize).min(nz)
426            } else {
427                nz
428            };
429            let ty = if rm.y as usize >= 1 {
430                (rm.y as usize).min(ny)
431            } else {
432                ny
433            };
434            let tx = if rm.x as usize >= 1 {
435                (rm.x as usize).min(nx)
436            } else {
437                nx
438            };
439            if (tz, ty, tx) != (nz, ny, nx) && tx <= nx && ty <= ny && tz <= nz {
440                info!(
441                    "Cropping recon from {}x{}x{} -> {}x{}x{} (recon matrix)",
442                    nz, ny, nx, tz, ty, tx
443                );
444                magnitude = center_crop_3d(&magnitude, (tz, ty, tx));
445            }
446        }
447
448        Ok(ImageVolume {
449            data: magnitude,
450            strategy: self.name(),
451            gfactor: None,
452        })
453    }
454}
455
456// ----------------------------------------------------------------------------
457// SENSE
458// ----------------------------------------------------------------------------
459
460use crate::espirit::espirit_sensitivity_maps;
461use crate::sense::sense_unfold_1d;
462use crate::sensitivity::walsh_sensitivity_maps;
463use ndarray::{Array4, Axis};
464use num_complex::Complex32;
465
466/// Which sensitivity-map estimator the SENSE strategy uses.
467#[non_exhaustive]
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum SenseMapSource {
470    /// Walsh adaptive combine from low-resolution ACS images.
471    Walsh,
472    /// ESPIRiT eigenvalue approach from the calibration matrix.
473    Espirit,
474}
475
476/// parallel-imaging reconstruction via SENSE with Walsh sensitivity maps.
477///
478/// Detects a regular 1-D Cartesian undersampling pattern along ky (same
479/// pattern recognised by [`GrappaRss`]), estimates full-FOV coil
480/// sensitivity maps from the ACS block via the Walsh adaptive method,
481/// and unfolds the aliased coil images by solving a small least-squares
482/// system at every voxel in the reduced FOV.
483///
484/// References (credited in `CREDITS.md`, no code copied):
485/// * Pruessmann, Weiger, Scheidegger, Boesiger, *MRM* 42(5), 1999 -- SENSE.
486/// * Walsh, Gmitro, Marcellin, *MRM* 43(5), 2000 -- adaptive maps.
487///
488/// Falls back to plain IFFT+RSS when the pattern is fully sampled or
489/// unsupported (no ACS, non-integer acceleration, 3-D encoding).
490#[non_exhaustive]
491#[derive(Debug, Clone, Copy)]
492pub struct SenseRss {
493    pub remove_oversampling: bool,
494    pub prewhiten: bool,
495    pub phase_correct: bool,
496    /// Source of per-voxel coil sensitivities.
497    pub map_source: SenseMapSource,
498    /// Half-size of the Walsh covariance window (voxels).
499    pub walsh_window: usize,
500    /// Number of Walsh power-iteration steps per voxel.
501    pub walsh_iters: usize,
502    /// ESPIRiT k-space kernel size (odd).
503    pub espirit_kernel: usize,
504    /// ESPIRiT calibration singular-value threshold (fraction of max).
505    pub espirit_threshold: f32,
506    /// ESPIRiT power-iteration steps (both SVD and per-voxel).
507    pub espirit_iters: usize,
508    /// Tikhonov ridge added to `C^H C` in the SENSE normal equations.
509    pub ridge: f32,
510    /// If true, compute the SENSE g-factor map alongside the unfolded
511    /// image and return it on [`ImageVolume::gfactor`].
512    pub compute_gfactor: bool,
513    pub fft_mode: FftMode,
514    pub crop_to_recon_matrix: bool,
515}
516
517impl Default for SenseRss {
518    fn default() -> Self {
519        Self {
520            remove_oversampling: true,
521            prewhiten: true,
522            phase_correct: true,
523            map_source: SenseMapSource::Walsh,
524            walsh_window: 3,
525            walsh_iters: 6,
526            espirit_kernel: 5,
527            espirit_threshold: 0.02,
528            espirit_iters: 30,
529            ridge: 1e-4,
530            compute_gfactor: false,
531            fft_mode: FftMode::Auto,
532            crop_to_recon_matrix: true,
533        }
534    }
535}
536
537impl ReconStrategy for SenseRss {
538    fn name(&self) -> &'static str {
539        "sense"
540    }
541
542    fn reconstruct(&self, file: &IsmrmrdFile) -> IoResult<ImageVolume> {
543        // --- 1. Calibration (same as IfftRss) -------------------------------
544        let (whitener, phase_corr) = if self.prewhiten || self.phase_correct {
545            let cal = file.read_calibration()?;
546            let w = if self.prewhiten {
547                NoisePrewhitener::from_noise_acqs(&cal.noise)
548            } else {
549                None
550            };
551            let pc = if self.phase_correct {
552                PhaseCorrector::from_phasecorr_acqs(&cal.phasecorr)
553            } else {
554                PhaseCorrector::default()
555            };
556            (w, pc)
557        } else {
558            (None, PhaseCorrector::default())
559        };
560        let os_remover = if self.remove_oversampling {
561            let enc_x = file.header.encoding.encoded_matrix.x as usize;
562            let rec_x = file.header.encoding.recon_matrix.x as usize;
563            let r = OversamplingRemover::new(enc_x, rec_x);
564            if let Some(r) = &r {
565                r.log_summary();
566            }
567            r
568        } else {
569            None
570        };
571
572        // --- 2. Read k-space with sampling mask -----------------------------
573        let (mut kspace, mask) = file.read_kspace_with_mask(|acq| {
574            if let Some(w) = whitener.as_ref() {
575                w.apply(acq);
576            }
577            phase_corr.apply(acq);
578            if let Some(os) = os_remover.as_ref() {
579                os.apply(acq);
580            }
581        })?;
582
583        let three_d = match self.fft_mode {
584            FftMode::Auto => file.is_3d_encoding()?,
585            FftMode::TwoD => false,
586            FftMode::ThreeD => true,
587        };
588
589        // For 3-D encodings with undersampling along ky only, decouple
590        // the kz axis first: a centred 1-D IFFT along kz makes every
591        // resulting z-slice an independent 2-D SENSE problem. This is
592        // exact when the kz axis is fully sampled (which is the
593        // common clinical case for 3-D parallel imaging).
594        if three_d {
595            info!("SENSE: 3D encoding; decoupling kz via 1-D IFFT");
596            crate::fft::ifft1_inplace(&mut kspace, 1);
597        }
598
599        // --- 3. Detect pattern; bail to IFFT+RSS on unsupported cases ------
600        let pattern_opt = detect_pattern(&mask);
601
602        let Some(pattern) = pattern_opt else {
603            info!(
604                "SENSE: data appears fully sampled or pattern unsupported; \
605                 falling back to plain IFFT+RSS"
606            );
607            ifft2_inplace(&mut kspace, (2, 3));
608            let mut magnitude = rss_combine_4d(&kspace);
609            drop(kspace);
610            if self.crop_to_recon_matrix {
611                magnitude = IfftRss::default().maybe_crop(magnitude, file);
612            }
613            return Ok(ImageVolume {
614                data: magnitude,
615                strategy: self.name(),
616                gfactor: None,
617            });
618        };
619
620        info!(
621            "SENSE: R={}, ACS ky=[{}, {}) (length {}), map_source={:?} ridge={:.1e}",
622            pattern.r,
623            pattern.acs_start,
624            pattern.acs_end,
625            pattern.acs_len(),
626            self.map_source,
627            self.ridge
628        );
629
630        let (nc, nz, ny, nx) = kspace.dim();
631        if ny % pattern.r != 0 {
632            warn!(
633                "SENSE: Ny={} not divisible by R={}; falling back to IFFT+RSS",
634                ny, pattern.r
635            );
636            ifft2_inplace(&mut kspace, (2, 3));
637            let mut magnitude = rss_combine_4d(&kspace);
638            drop(kspace);
639            if self.crop_to_recon_matrix {
640                magnitude = IfftRss::default().maybe_crop(magnitude, file);
641            }
642            return Ok(ImageVolume {
643                data: magnitude,
644                strategy: self.name(),
645                gfactor: None,
646            });
647        }
648
649        // --- 4. Per-slice: sensitivity maps from ACS, then SENSE unfold ----
650        let mut output = Array3::<f32>::zeros((nz, ny, nx));
651        let mut gfactor_vol = if self.compute_gfactor {
652            Some(Array3::<f32>::zeros((nz, ny, nx)))
653        } else {
654            None
655        };
656        for kz in 0..nz {
657            let maps = match self.map_source {
658                SenseMapSource::Walsh => {
659                    // Low-res coil images from ACS only.
660                    let mut acs_k = Array4::<Complex32>::zeros(kspace.raw_dim());
661                    for c in 0..nc {
662                        for ky in pattern.acs_start..pattern.acs_end {
663                            for x in 0..nx {
664                                acs_k[[c, kz, ky, x]] = kspace[[c, kz, ky, x]];
665                            }
666                        }
667                    }
668                    ifft2_inplace(&mut acs_k, (2, 3));
669                    let low_res = acs_k.index_axis(Axis(1), kz).to_owned();
670                    walsh_sensitivity_maps(&low_res, self.walsh_window, self.walsh_iters)
671                }
672                SenseMapSource::Espirit => {
673                    // Extract the contiguous ACS block of k-space for this
674                    // slice and pass it to ESPIRiT.
675                    let acs_len = pattern.acs_end - pattern.acs_start;
676                    let mut acs_block = ndarray::Array3::<Complex32>::zeros((nc, acs_len, nx));
677                    for c in 0..nc {
678                        for (i, ky) in (pattern.acs_start..pattern.acs_end).enumerate() {
679                            for x in 0..nx {
680                                acs_block[[c, i, x]] = kspace[[c, kz, ky, x]];
681                            }
682                        }
683                    }
684                    espirit_sensitivity_maps(
685                        &acs_block,
686                        (ny, nx),
687                        self.espirit_kernel,
688                        self.espirit_threshold,
689                        self.espirit_iters,
690                    )
691                }
692            };
693
694            // Aliased coil images: IFFT the full kspace slice (zeros at
695            // missing lines).
696            let mut aliased_k = kspace.slice(s![.., kz..=kz, .., ..]).to_owned();
697            ifft2_inplace(&mut aliased_k, (2, 3));
698            let aliased = aliased_k.index_axis(Axis(1), 0).to_owned();
699
700            let unfolded = sense_unfold_1d(&aliased, &maps, pattern.r, self.ridge)
701                .map_err(|e| IoError::Inconsistent(e.to_string()))?;
702            for y in 0..ny {
703                for x in 0..nx {
704                    output[[kz, y, x]] = unfolded[[y, x]].norm();
705                }
706            }
707            if let Some(gv) = gfactor_vol.as_mut() {
708                let gslice = crate::sense::sense_gfactor_1d(&maps, pattern.r, self.ridge)
709                    .map_err(|e| IoError::Inconsistent(e.to_string()))?;
710                for y in 0..ny {
711                    for x in 0..nx {
712                        gv[[kz, y, x]] = gslice[[y, x]];
713                    }
714                }
715            }
716        }
717        drop(kspace);
718
719        let mut magnitude = output;
720        if self.crop_to_recon_matrix {
721            magnitude = IfftRss::default().maybe_crop(magnitude, file);
722            if let Some(gv) = gfactor_vol.as_mut() {
723                let cropped = IfftRss::default().maybe_crop(gv.clone(), file);
724                *gv = cropped;
725            }
726        }
727
728        Ok(ImageVolume {
729            data: magnitude,
730            strategy: self.name(),
731            gfactor: gfactor_vol,
732        })
733    }
734}
735
736// ----------------------------------------------------------------------------
737// Compressed sensing (L1-wavelet, per-coil FISTA + RSS combine)
738// ----------------------------------------------------------------------------
739
740use crate::cs::fista_cs_single_coil;
741
742/// Compressed-sensing reconstruction.
743///
744/// For each slice and each coil independently, runs FISTA on the
745/// undersampled k-space with a 1-level Haar-wavelet L1 prior, then
746/// combines the coil images with RSS. This is the "Sparse MRI"
747/// approach of Lustig, Donoho, Pauly (MRM 58(6), 2007), solved with
748/// FISTA (Beck & Teboulle 2009); citations are in `CREDITS.md`.
749///
750/// Falls back to plain IFFT+RSS for 3-D encodings.
751#[non_exhaustive]
752#[derive(Debug, Clone, Copy)]
753pub struct CsRss {
754    pub remove_oversampling: bool,
755    pub prewhiten: bool,
756    pub phase_correct: bool,
757    pub iters: usize,
758    pub lambda: f32,
759    pub fft_mode: FftMode,
760    pub crop_to_recon_matrix: bool,
761}
762
763impl Default for CsRss {
764    fn default() -> Self {
765        Self {
766            remove_oversampling: true,
767            prewhiten: true,
768            phase_correct: true,
769            iters: 60,
770            lambda: 0.01,
771            fft_mode: FftMode::Auto,
772            crop_to_recon_matrix: true,
773        }
774    }
775}
776
777impl ReconStrategy for CsRss {
778    fn name(&self) -> &'static str {
779        "cs"
780    }
781
782    fn reconstruct(&self, file: &IsmrmrdFile) -> IoResult<ImageVolume> {
783        let (whitener, phase_corr) = if self.prewhiten || self.phase_correct {
784            let cal = file.read_calibration()?;
785            let w = if self.prewhiten {
786                NoisePrewhitener::from_noise_acqs(&cal.noise)
787            } else {
788                None
789            };
790            let pc = if self.phase_correct {
791                PhaseCorrector::from_phasecorr_acqs(&cal.phasecorr)
792            } else {
793                PhaseCorrector::default()
794            };
795            (w, pc)
796        } else {
797            (None, PhaseCorrector::default())
798        };
799        let os_remover = if self.remove_oversampling {
800            let enc_x = file.header.encoding.encoded_matrix.x as usize;
801            let rec_x = file.header.encoding.recon_matrix.x as usize;
802            OversamplingRemover::new(enc_x, rec_x)
803        } else {
804            None
805        };
806
807        let (mut kspace, mask) = file.read_kspace_with_mask(|acq| {
808            if let Some(w) = whitener.as_ref() {
809                w.apply(acq);
810            }
811            phase_corr.apply(acq);
812            if let Some(os) = os_remover.as_ref() {
813                os.apply(acq);
814            }
815        })?;
816
817        let three_d = match self.fft_mode {
818            FftMode::Auto => file.is_3d_encoding()?,
819            FftMode::TwoD => false,
820            FftMode::ThreeD => true,
821        };
822        // Decouple a 3-D acquisition by a 1-D IFFT along kz: each
823        // resulting z-slice becomes an independent 2-D CS problem.
824        if three_d {
825            info!("CS: 3D encoding; decoupling kz via 1-D IFFT");
826            crate::fft::ifft1_inplace(&mut kspace, 1);
827        }
828        let (nc, nz, ny, nx) = kspace.dim();
829        if ny % 2 != 0 || nx % 2 != 0 {
830            info!("CS: odd spatial dims -- falling back to plain IFFT+RSS");
831            let mut k = kspace;
832            ifft2_inplace(&mut k, (2, 3));
833            let mut magnitude = rss_combine_4d(&k);
834            if self.crop_to_recon_matrix {
835                magnitude = IfftRss::default().maybe_crop(magnitude, file);
836            }
837            return Ok(ImageVolume {
838                data: magnitude,
839                strategy: self.name(),
840                gfactor: None,
841            });
842        }
843
844        info!(
845            "CS: iters={} lambda={:.3e} (per-coil FISTA + RSS)",
846            self.iters, self.lambda
847        );
848
849        let mut output = Array3::<f32>::zeros((nz, ny, nx));
850        for kz in 0..nz {
851            // 2D sampling mask for this slice.
852            let mut mask2 = ndarray::Array2::<bool>::from_elem((ny, nx), false);
853            for y in 0..ny {
854                for x in 0..nx {
855                    mask2[[y, x]] = mask[[kz, y, x]];
856                }
857            }
858            let mut coil_imgs: Vec<ndarray::Array2<Complex32>> = Vec::with_capacity(nc);
859            for c in 0..nc {
860                let mut kzf = ndarray::Array2::<Complex32>::zeros((ny, nx));
861                for y in 0..ny {
862                    for x in 0..nx {
863                        kzf[[y, x]] = kspace[[c, kz, y, x]];
864                    }
865                }
866                let recon = fista_cs_single_coil(&kzf, &mask2, self.iters, self.lambda)
867                    .map_err(|e| IoError::Inconsistent(e.to_string()))?;
868                coil_imgs.push(recon);
869            }
870            #[allow(clippy::needless_range_loop)]
871            for y in 0..ny {
872                for x in 0..nx {
873                    let mut s = 0.0f32;
874                    for c in 0..nc {
875                        s += coil_imgs[c][[y, x]].norm_sqr();
876                    }
877                    output[[kz, y, x]] = s.sqrt();
878                }
879            }
880        }
881
882        let mut magnitude = output;
883        if self.crop_to_recon_matrix {
884            magnitude = IfftRss::default().maybe_crop(magnitude, file);
885        }
886        Ok(ImageVolume {
887            data: magnitude,
888            strategy: self.name(),
889            gfactor: None,
890        })
891    }
892}