1use 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#[derive(Debug)]
26pub struct ImageVolume {
27 pub data: Array3<f32>,
29 pub strategy: &'static str,
31 pub gfactor: Option<Array3<f32>>,
34}
35
36impl ImageVolume {
37 pub fn new(data: Array3<f32>, strategy: &'static str) -> Self {
39 Self {
40 data,
41 strategy,
42 gfactor: None,
43 }
44 }
45}
46
47pub trait ReconStrategy {
59 fn name(&self) -> &'static str;
61
62 fn reconstruct(&self, file: &IsmrmrdFile) -> IoResult<ImageVolume>;
67}
68
69#[non_exhaustive]
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum FftMode {
73 Auto,
75 TwoD,
77 ThreeD,
79}
80
81#[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 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 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 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 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 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 info!("RSS coil combine");
210 let mut magnitude = rss_combine_4d(&kspace);
211 drop(kspace);
212
213 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
256use crate::grappa::{detect_pattern, extract_acs_slice, GrappaKernel};
261use ndarray::s;
262use tracing::warn;
263
264#[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 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 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 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 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 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 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 info!("RSS coil combine");
417 let mut magnitude = rss_combine_4d(&kspace);
418 drop(kspace);
419
420 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
456use 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#[non_exhaustive]
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum SenseMapSource {
470 Walsh,
472 Espirit,
474}
475
476#[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 pub map_source: SenseMapSource,
498 pub walsh_window: usize,
500 pub walsh_iters: usize,
502 pub espirit_kernel: usize,
504 pub espirit_threshold: f32,
506 pub espirit_iters: usize,
508 pub ridge: f32,
510 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 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 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 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 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 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 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 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 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
736use crate::cs::fista_cs_single_coil;
741
742#[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 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 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}