Skip to main content

qsm_core/pipeline/
inversion.rs

1//! Dipole inversion stage
2//!
3//! Dispatcher for standard dipole inversion algorithms.
4//! Also contains TGV and QSMART pipeline runners which combine
5//! multiple stages internally.
6//!
7//! MEDI Hz↔radians conversion is handled internally — the caller
8//! passes ppm fields and receives ppm results.
9
10use std::f64::consts::PI;
11
12use super::config::*;
13
14/// Run standard dipole inversion on a local field.
15///
16/// Handles MEDI's unit conversion (ppm → radians) internally.
17///
18/// # Arguments
19/// * `local_field_ppm` - Local field in ppm (after background removal)
20/// * `mask` - Eroded binary mask from background removal
21/// * `metadata` - Scan metadata
22/// * `config` - Inversion configuration
23/// * `magnitude` - Combined magnitude image (needed for MEDI edge weighting)
24/// * `progress` - Progress callback (current_iter, max_iter)
25///
26/// # Returns
27/// Susceptibility map in ppm (unreferenced)
28pub fn run_dipole_inversion(
29    local_field_ppm: &[f64],
30    mask: &[u8],
31    metadata: &ScanMetadata,
32    config: &InversionConfig,
33    magnitude: Option<&[f64]>,
34    progress: &mut dyn FnMut(usize, usize),
35) -> Result<Vec<f64>, PipelineError> {
36    let grid = metadata.grid();
37    let bdir = metadata.b0_direction;
38    let n_voxels = grid.n_total();
39
40    if local_field_ppm.len() != n_voxels {
41        return Err(PipelineError::DimensionMismatch {
42            expected: n_voxels,
43            got: local_field_ppm.len(),
44        });
45    }
46
47    let chi = match config.algorithm {
48        InversionAlgorithm::Tkd => {
49            crate::inversion::tkd(
50                local_field_ppm, mask, &grid, bdir, &config.tkd,
51            )
52        }
53        InversionAlgorithm::Tsvd => {
54            crate::inversion::tsvd(
55                local_field_ppm, mask, &grid, bdir, &config.tsvd,
56            )
57        }
58        InversionAlgorithm::Tikhonov => {
59            crate::inversion::tikhonov(
60                local_field_ppm, mask, &grid, bdir, &config.tikhonov,
61            )
62        }
63        InversionAlgorithm::Tv => {
64            crate::inversion::tv_admm(
65                local_field_ppm, mask, &grid, bdir, &config.tv, progress,
66            )
67        }
68        InversionAlgorithm::Rts => {
69            crate::inversion::rts(
70                local_field_ppm, mask, &grid, bdir, &config.rts, progress,
71            )
72        }
73        InversionAlgorithm::Nltv => {
74            crate::inversion::nltv(
75                local_field_ppm, mask, &grid, bdir, &config.nltv, progress,
76            )
77        }
78        InversionAlgorithm::Medi => {
79            // MEDI requires field in radians, not ppm
80            let gamma_hz = 42.576e6;
81            let te1 = metadata.echo_times.first().copied().unwrap_or(0.005);
82            let ppm_to_rad = 2.0 * PI * gamma_hz * metadata.field_strength * te1 * 1e-6;
83
84            let local_field_rad: Vec<f64> = local_field_ppm.iter()
85                .map(|&v| v * ppm_to_rad)
86                .collect();
87
88            let uniform_mag = vec![1.0f64; n_voxels];
89            let mag = magnitude.unwrap_or(&uniform_mag);
90            let n_std = vec![1.0f64; n_voxels];
91
92            let chi_rad = crate::inversion::medi(
93                &local_field_rad, &n_std, mag, mask,
94                &grid, bdir, &config.medi, progress,
95            );
96
97            let rad_to_ppm = 1.0 / ppm_to_rad;
98            chi_rad.iter().map(|&v| v * rad_to_ppm).collect()
99        }
100        InversionAlgorithm::Tfi => {
101            // TFI takes the field in ppm (same convention as NDI and the other inversions —
102            // NOT MEDI's radians). It is single-step: `local_field_ppm` is fed as the TOTAL
103            // field (caller supplies the pre-background-removal field). Converting to radians
104            // here would wrap the large total field in exp(i·field) and destroy the result.
105            let uniform_mag = vec![1.0f64; n_voxels];
106            let mag = magnitude.unwrap_or(&uniform_mag);
107            let n_std = vec![1.0f64; n_voxels];
108
109            crate::inversion::tfi(
110                local_field_ppm, &n_std, mag, mask,
111                &grid, bdir, &config.tfi, progress,
112            )
113        }
114        InversionAlgorithm::Ilsqr => {
115            let (chi, _, _, _) = crate::inversion::ilsqr(
116                local_field_ppm, mask, &grid, bdir, &config.ilsqr, &mut *progress,
117            );
118            chi
119        }
120        InversionAlgorithm::Ndi => {
121            crate::inversion::ndi(
122                local_field_ppm, mask, &grid, bdir, &config.ndi, progress,
123            )
124        }
125        InversionAlgorithm::Fansi => {
126            let params = crate::inversion::FansiParams { is_tgv: false, ..config.fansi.clone() };
127            crate::inversion::fansi(
128                local_field_ppm, mask, &grid, bdir, &params, progress,
129            )
130        }
131        InversionAlgorithm::FansiTgv => {
132            let params = crate::inversion::FansiParams { is_tgv: true, ..config.fansi.clone() };
133            crate::inversion::fansi(
134                local_field_ppm, mask, &grid, bdir, &params, progress,
135            )
136        }
137        InversionAlgorithm::L1qsm => {
138            crate::inversion::l1qsm(
139                local_field_ppm, mask, &grid, bdir, &config.l1qsm, progress,
140            )
141        }
142        InversionAlgorithm::Whqsm => {
143            crate::inversion::whqsm(
144                local_field_ppm, mask, &grid, bdir, &config.whqsm, progress,
145            )
146        }
147        InversionAlgorithm::Hdqsm => {
148            crate::inversion::hdqsm(
149                local_field_ppm, mask, &grid, bdir, &config.hdqsm, progress,
150            )
151        }
152        InversionAlgorithm::AmpPe => {
153            // AMP-PE takes the local field in ppm (like NDI). `b0` (field strength)
154            // is a scan parameter sourced from metadata, not a user knob; magnitude,
155            // when present, is the data-fidelity weight + morphology mask.
156            let params = crate::inversion::AmpPeParams {
157                b0: metadata.field_strength,
158                ..config.amp_pe.clone()
159            };
160            crate::inversion::amp_pe(
161                local_field_ppm, mask, magnitude, &grid, bdir, &params, |i, n| progress(i, n),
162            )
163        }
164        InversionAlgorithm::Xqsm => run_xqsm(local_field_ppm, mask, &grid, config.tile, progress)?,
165        InversionAlgorithm::Qsmnet => run_qsmnet(local_field_ppm, mask, &grid, "qsmnet", config.tile, progress)?,
166        InversionAlgorithm::QsmnetPlus => run_qsmnet(local_field_ppm, mask, &grid, "qsmnet-plus", config.tile, progress)?,
167        InversionAlgorithm::Autoqsm => run_autoqsm(local_field_ppm, mask, &grid)?,
168        InversionAlgorithm::Qsmgan => run_qsmgan(local_field_ppm, mask, &grid)?,
169        InversionAlgorithm::Ir2qsm => run_ir2qsm(local_field_ppm, mask, &grid, config.tile, progress)?,
170        InversionAlgorithm::Lpcnn => run_lpcnn(local_field_ppm, mask, &grid, bdir, config.tile, progress)?,
171        InversionAlgorithm::ModlQsm => run_modl_qsm(local_field_ppm, mask, &grid, bdir, config.tile, progress)?,
172        // NeXtQSM is single-step (own BFR): `local_field_ppm` must be the total field.
173        InversionAlgorithm::Nextqsm => run_nextqsm(local_field_ppm, mask, &grid, bdir, config.tile, progress)?,
174        InversionAlgorithm::Tgv | InversionAlgorithm::Qsmart => {
175            return Err(PipelineError::InvalidConfig(
176                format!("{:?} should use run_tgv or run_qsmart", config.algorithm),
177            ));
178        }
179        // iQSM/iQSM+ reconstruct from wrapped phase (not a local field) — end-to-end.
180        InversionAlgorithm::Iqsm | InversionAlgorithm::IqsmPlus => {
181            return Err(PipelineError::InvalidConfig(
182                format!("{:?} should use run_iqsm / run_iqsm_plus (it takes wrapped phase)", config.algorithm),
183            ));
184        }
185    };
186
187    Ok(chi)
188}
189
190/// Source the xQSM weights and run inference. Requires the `onnx` feature;
191/// weights come from the model registry (local `$QSM_MODEL_DIR`/cache, or the
192/// `download` feature).
193#[cfg(feature = "onnx")]
194fn run_xqsm(
195    local_field_ppm: &[f64],
196    mask: &[u8],
197    grid: &crate::Grid,
198    tile: Option<(usize, usize)>,
199    progress: &mut dyn FnMut(usize, usize),
200) -> Result<Vec<f64>, PipelineError> {
201    let spec = crate::models::find_model("xqsm")
202        .ok_or_else(|| PipelineError::InvalidConfig("xqsm not in model registry".into()))?;
203    let bytes = crate::models::primary_weight_bytes(spec)
204        .map_err(PipelineError::InvalidConfig)?;
205    match tile {
206        Some((core, halo)) => crate::inversion::xqsm_tiled(
207            local_field_ppm, mask, grid, &bytes, &crate::inversion::TileConfig { core, halo }, progress,
208        ),
209        None => crate::inversion::xqsm(local_field_ppm, mask, grid, &bytes),
210    }
211    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
212}
213
214#[cfg(not(feature = "onnx"))]
215fn run_xqsm(
216    _local_field_ppm: &[f64],
217    _mask: &[u8],
218    _grid: &crate::Grid,
219    _tile: Option<(usize, usize)>,
220    _progress: &mut dyn FnMut(usize, usize),
221) -> Result<Vec<f64>, PipelineError> {
222    Err(PipelineError::InvalidConfig(
223        "xQSM requires building qsm-core with the 'onnx' feature".into(),
224    ))
225}
226
227/// Run NeXtQSM end-to-end from the **total** field (it does its own background
228/// removal), sourcing both weight files from the registry. Unlike the entries in
229/// [`InversionAlgorithm`], NeXtQSM spans BFR + dipole inversion, so it is exposed
230/// as a standalone reconstruction rather than a dipole-inversion-stage option.
231///
232/// `total_field_ppm` and `mask` are column-major `(nx,ny,nz)`; `bdir` is the B0
233/// direction. Requires the `onnx` feature; weights resolve local-first then via
234/// the `download` feature. Returns susceptibility (ppm), masked.
235#[cfg(feature = "onnx")]
236pub fn run_nextqsm(
237    total_field_ppm: &[f64],
238    mask: &[u8],
239    grid: &crate::Grid,
240    bdir: (f64, f64, f64),
241    tile: Option<(usize, usize)>,
242    progress: &mut dyn FnMut(usize, usize),
243) -> Result<Vec<f64>, PipelineError> {
244    let spec = crate::models::find_model("nextqsm")
245        .ok_or_else(|| PipelineError::InvalidConfig("nextqsm not in model registry".into()))?;
246    let files = crate::models::all_weight_bytes(spec).map_err(PipelineError::InvalidConfig)?;
247    let [bf, vjp] = &files[..] else {
248        return Err(PipelineError::InvalidConfig(
249            format!("nextqsm expects 2 weight files (BFR, VJP), got {}", files.len()),
250        ));
251    };
252    match tile {
253        Some((core, halo)) => crate::inversion::nextqsm_tiled(
254            total_field_ppm, mask, grid, bdir, bf, vjp, &crate::inversion::TileConfig { core, halo }, progress,
255        ),
256        None => crate::inversion::nextqsm(total_field_ppm, mask, grid, bdir, bf, vjp),
257    }
258    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
259}
260
261/// Stub when built without the `onnx` feature.
262#[cfg(not(feature = "onnx"))]
263pub fn run_nextqsm(
264    _total_field_ppm: &[f64],
265    _mask: &[u8],
266    _grid: &crate::Grid,
267    _bdir: (f64, f64, f64),
268    _tile: Option<(usize, usize)>,
269    _progress: &mut dyn FnMut(usize, usize),
270) -> Result<Vec<f64>, PipelineError> {
271    Err(PipelineError::InvalidConfig(
272        "NeXtQSM requires building qsm-core with the 'onnx' feature".into(),
273    ))
274}
275
276/// Source a QSMnet-family model's weights and run inference (requires the `onnx`
277/// feature). `model_id` selects the registry entry (`qsmnet` / `qsmnet-plus`),
278/// `norm` supplies that checkpoint's normalization constants.
279#[cfg(feature = "onnx")]
280fn run_qsmnet(
281    local_field_ppm: &[f64],
282    mask: &[u8],
283    grid: &crate::Grid,
284    model_id: &str,
285    tile: Option<(usize, usize)>,
286    progress: &mut dyn FnMut(usize, usize),
287) -> Result<Vec<f64>, PipelineError> {
288    use crate::inversion::QsmnetNorm;
289    let norm = match model_id {
290        "qsmnet-plus" => QsmnetNorm::qsmnet_plus(),
291        _ => QsmnetNorm::qsmnet(),
292    };
293    let spec = crate::models::find_model(model_id).ok_or_else(|| {
294        PipelineError::InvalidConfig(format!("{model_id} not in model registry"))
295    })?;
296    let bytes = crate::models::primary_weight_bytes(spec)
297        .map_err(PipelineError::InvalidConfig)?;
298    match tile {
299        Some((core, halo)) => crate::inversion::qsmnet_tiled(
300            local_field_ppm, mask, grid, &bytes, &norm, &crate::inversion::TileConfig { core, halo }, progress,
301        ),
302        None => crate::inversion::qsmnet(local_field_ppm, mask, grid, &bytes, &norm),
303    }
304    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
305}
306
307#[cfg(not(feature = "onnx"))]
308fn run_qsmnet(
309    _local_field_ppm: &[f64],
310    _mask: &[u8],
311    _grid: &crate::Grid,
312    _model_id: &str,
313    _tile: Option<(usize, usize)>,
314    _progress: &mut dyn FnMut(usize, usize),
315) -> Result<Vec<f64>, PipelineError> {
316    Err(PipelineError::InvalidConfig(
317        "QSMnet requires building qsm-core with the 'onnx' feature".into(),
318    ))
319}
320
321/// Source the AutoQSM weights and run inference (requires the `onnx` feature).
322/// `field` is the **total** field (AutoQSM does its own background removal).
323#[cfg(feature = "onnx")]
324fn run_autoqsm(
325    field: &[f64],
326    mask: &[u8],
327    grid: &crate::Grid,
328) -> Result<Vec<f64>, PipelineError> {
329    let spec = crate::models::find_model("autoqsm")
330        .ok_or_else(|| PipelineError::InvalidConfig("autoqsm not in model registry".into()))?;
331    let bytes = crate::models::primary_weight_bytes(spec).map_err(PipelineError::InvalidConfig)?;
332    crate::inversion::autoqsm(field, mask, grid, &bytes)
333        .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
334}
335
336#[cfg(not(feature = "onnx"))]
337fn run_autoqsm(
338    _field: &[f64],
339    _mask: &[u8],
340    _grid: &crate::Grid,
341) -> Result<Vec<f64>, PipelineError> {
342    Err(PipelineError::InvalidConfig(
343        "AutoQSM requires building qsm-core with the 'onnx' feature".into(),
344    ))
345}
346
347/// Source the QSMGAN generator weights and run inference (requires the `onnx` feature).
348#[cfg(feature = "onnx")]
349fn run_qsmgan(local_field_ppm: &[f64], mask: &[u8], grid: &crate::Grid) -> Result<Vec<f64>, PipelineError> {
350    let bytes = crate::models::primary_weight("qsmgan").map_err(PipelineError::InvalidConfig)?;
351    crate::inversion::qsmgan(local_field_ppm, mask, grid, &bytes)
352        .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
353}
354
355/// Source the IR2QSM weights and run inference (requires the `onnx` feature).
356#[cfg(feature = "onnx")]
357fn run_ir2qsm(
358    local_field_ppm: &[f64],
359    mask: &[u8],
360    grid: &crate::Grid,
361    tile: Option<(usize, usize)>,
362    progress: &mut dyn FnMut(usize, usize),
363) -> Result<Vec<f64>, PipelineError> {
364    let bytes = crate::models::primary_weight("ir2qsm").map_err(PipelineError::InvalidConfig)?;
365    match tile {
366        Some((core, halo)) => crate::inversion::ir2qsm_tiled(
367            local_field_ppm, mask, grid, &bytes, &crate::inversion::TileConfig { core, halo }, progress,
368        ),
369        None => crate::inversion::ir2qsm(local_field_ppm, mask, grid, &bytes),
370    }
371    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
372}
373
374/// Source the LPCNN proximal-CNN weights and run inference (requires the `onnx` feature).
375/// LPCNN's k-space data-consistency step uses the B0 direction.
376#[cfg(feature = "onnx")]
377fn run_lpcnn(
378    local_field_ppm: &[f64],
379    mask: &[u8],
380    grid: &crate::Grid,
381    bdir: (f64, f64, f64),
382    tile: Option<(usize, usize)>,
383    progress: &mut dyn FnMut(usize, usize),
384) -> Result<Vec<f64>, PipelineError> {
385    let bytes = crate::models::primary_weight("lpcnn").map_err(PipelineError::InvalidConfig)?;
386    match tile {
387        Some((core, halo)) => crate::inversion::lpcnn_tiled(
388            local_field_ppm, mask, grid, bdir, &bytes, &crate::inversion::TileConfig { core, halo }, progress,
389        ),
390        None => crate::inversion::lpcnn(local_field_ppm, mask, grid, bdir, &bytes),
391    }
392    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
393}
394
395/// Source the MoDL-QSM prior-CNN weights and run inference (requires the `onnx` feature).
396/// Output is the STI χ33 component.
397#[cfg(feature = "onnx")]
398fn run_modl_qsm(
399    local_field_ppm: &[f64],
400    mask: &[u8],
401    grid: &crate::Grid,
402    bdir: (f64, f64, f64),
403    tile: Option<(usize, usize)>,
404    progress: &mut dyn FnMut(usize, usize),
405) -> Result<Vec<f64>, PipelineError> {
406    let bytes = crate::models::primary_weight("modl-qsm").map_err(PipelineError::InvalidConfig)?;
407    match tile {
408        Some((core, halo)) => crate::inversion::modl_qsm_tiled(
409            local_field_ppm, mask, grid, bdir, &bytes, &crate::inversion::TileConfig { core, halo }, progress,
410        ),
411        None => crate::inversion::modl_qsm(local_field_ppm, mask, grid, bdir, &bytes),
412    }
413    .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
414}
415
416#[cfg(not(feature = "onnx"))]
417fn run_qsmgan(_f: &[f64], _m: &[u8], _g: &crate::Grid) -> Result<Vec<f64>, PipelineError> {
418    Err(PipelineError::InvalidConfig("QSMGAN requires building qsm-core with the 'onnx' feature".into()))
419}
420#[cfg(not(feature = "onnx"))]
421fn run_ir2qsm(
422    _f: &[f64],
423    _m: &[u8],
424    _g: &crate::Grid,
425    _tile: Option<(usize, usize)>,
426    _progress: &mut dyn FnMut(usize, usize),
427) -> Result<Vec<f64>, PipelineError> {
428    Err(PipelineError::InvalidConfig("IR2QSM requires building qsm-core with the 'onnx' feature".into()))
429}
430#[cfg(not(feature = "onnx"))]
431fn run_lpcnn(
432    _f: &[f64],
433    _m: &[u8],
434    _g: &crate::Grid,
435    _b: (f64, f64, f64),
436    _tile: Option<(usize, usize)>,
437    _progress: &mut dyn FnMut(usize, usize),
438) -> Result<Vec<f64>, PipelineError> {
439    Err(PipelineError::InvalidConfig("LPCNN requires building qsm-core with the 'onnx' feature".into()))
440}
441#[cfg(not(feature = "onnx"))]
442fn run_modl_qsm(_f: &[f64], _m: &[u8], _g: &crate::Grid, _b: (f64, f64, f64), _tile: Option<(usize, usize)>, _progress: &mut dyn FnMut(usize, usize)) -> Result<Vec<f64>, PipelineError> {
443    Err(PipelineError::InvalidConfig("MoDL-QSM requires building qsm-core with the 'onnx' feature".into()))
444}
445
446/// The authors' fixed inference conventions for the iQSM/iQFM LoT-Unet family:
447/// phase sign `-1` and a 3-voxel mask-erosion radius. These are training-time
448/// constants, not user knobs, so the pipeline runners bake them in.
449#[cfg(feature = "onnx")]
450const IQSM_PHASE_SIGN: f64 = -1.0;
451#[cfg(feature = "onnx")]
452const IQSM_ERODED_RAD: i32 = 3;
453
454/// Run iQSM single-step reconstruction from wrapped **phase** (joint unwrap +
455/// background removal + dipole inversion). Multi-echo inputs are reconstructed
456/// per echo and magnitude·TE²-combined. Requires the `onnx` feature + `iqsm` weights.
457///
458/// `phases`/`magnitudes` are per-echo wrapped-phase / magnitude volumes (column-major);
459/// `metadata` supplies the grid, echo times, and B0. Returns susceptibility (ppm),
460/// referenced per `reference`.
461#[cfg(feature = "onnx")]
462pub fn run_iqsm(
463    phases: &[&[f64]],
464    magnitudes: &[&[f64]],
465    mask: &[u8],
466    metadata: &ScanMetadata,
467    reference: QsmReference,
468) -> Result<Vec<f64>, PipelineError> {
469    let grid = metadata.grid();
470    let bytes = crate::models::primary_weight("iqsm").map_err(PipelineError::InvalidConfig)?;
471    let chi = crate::inversion::iqsm_multi_echo(
472        phases, magnitudes, mask, &grid, &metadata.echo_times,
473        metadata.field_strength, IQSM_PHASE_SIGN, IQSM_ERODED_RAD, &bytes,
474    ).map_err(|e| PipelineError::AlgorithmError(e.to_string()))?;
475    Ok(super::referencing::apply_reference(&chi, mask, reference))
476}
477
478#[cfg(not(feature = "onnx"))]
479pub fn run_iqsm(
480    _phases: &[&[f64]], _magnitudes: &[&[f64]], _mask: &[u8],
481    _metadata: &ScanMetadata, _reference: QsmReference,
482) -> Result<Vec<f64>, PipelineError> {
483    Err(PipelineError::InvalidConfig("iQSM requires building qsm-core with the 'onnx' feature".into()))
484}
485
486/// Run iQSM+ single-step reconstruction from wrapped **phase** (orientation-adaptive
487/// variant; the B0 direction is a genuine network input). See [`run_iqsm`].
488#[cfg(feature = "onnx")]
489pub fn run_iqsm_plus(
490    phases: &[&[f64]],
491    magnitudes: &[&[f64]],
492    mask: &[u8],
493    metadata: &ScanMetadata,
494    reference: QsmReference,
495) -> Result<Vec<f64>, PipelineError> {
496    let grid = metadata.grid();
497    let bytes = crate::models::primary_weight("iqsm-plus").map_err(PipelineError::InvalidConfig)?;
498    let chi = crate::inversion::iqsm_plus_multi_echo(
499        phases, magnitudes, mask, &grid, &metadata.echo_times,
500        metadata.field_strength, metadata.b0_direction, IQSM_PHASE_SIGN, IQSM_ERODED_RAD, &bytes,
501    ).map_err(|e| PipelineError::AlgorithmError(e.to_string()))?;
502    Ok(super::referencing::apply_reference(&chi, mask, reference))
503}
504
505#[cfg(not(feature = "onnx"))]
506pub fn run_iqsm_plus(
507    _phases: &[&[f64]], _magnitudes: &[&[f64]], _mask: &[u8],
508    _metadata: &ScanMetadata, _reference: QsmReference,
509) -> Result<Vec<f64>, PipelineError> {
510    Err(PipelineError::InvalidConfig("iQSM+ requires building qsm-core with the 'onnx' feature".into()))
511}
512
513/// Run iQFM: joint unwrapping + background removal from wrapped **phase** in one
514/// network (the tissue-field head of the iQSM LoT-Unet). Returns the local
515/// (background-removed) field in ppm — feed it to any dipole inversion. This is a
516/// deep-learning replacement for the unwrap + BFR stages, not an inversion.
517/// Requires the `onnx` feature + `iqfm` weights.
518#[cfg(feature = "onnx")]
519pub fn run_iqfm(
520    phases: &[&[f64]],
521    magnitudes: &[&[f64]],
522    mask: &[u8],
523    metadata: &ScanMetadata,
524) -> Result<Vec<f64>, PipelineError> {
525    let grid = metadata.grid();
526    let bytes = crate::models::primary_weight("iqfm").map_err(PipelineError::InvalidConfig)?;
527    crate::inversion::iqfm_multi_echo(
528        phases, magnitudes, mask, &grid, &metadata.echo_times,
529        metadata.field_strength, IQSM_PHASE_SIGN, IQSM_ERODED_RAD, &bytes,
530    ).map_err(|e| PipelineError::AlgorithmError(e.to_string()))
531}
532
533#[cfg(not(feature = "onnx"))]
534pub fn run_iqfm(
535    _phases: &[&[f64]], _magnitudes: &[&[f64]], _mask: &[u8], _metadata: &ScanMetadata,
536) -> Result<Vec<f64>, PipelineError> {
537    Err(PipelineError::InvalidConfig("iQFM requires building qsm-core with the 'onnx' feature".into()))
538}
539
540/// Run TGV single-step QSM reconstruction.
541///
542/// For multi-echo data, runs field mapping first to get B0, then converts
543/// to phase at TE1. For single-echo, uses wrapped phase directly.
544/// TGV internally handles unwrapping + background removal + inversion.
545///
546/// # Returns
547/// Susceptibility map in ppm (unreferenced — call `apply_reference` after)
548pub fn run_tgv(
549    phases: &[&[f64]],
550    magnitudes: Option<&[&[f64]]>,
551    mask: &[u8],
552    metadata: &ScanMetadata,
553    field_mapping_config: &FieldMappingConfig,
554    tgv_params: &crate::inversion::TgvParams,
555    reference: QsmReference,
556    progress: &mut dyn FnMut(usize, usize),
557) -> Result<Vec<f64>, PipelineError> {
558    let grid = metadata.grid();
559    let (bx, by, bz) = metadata.b0_direction;
560
561    // For multi-echo: compute B0 field map first, then convert to phase
562    // For single-echo: use wrapped phase directly
563    let phase_data: Vec<f64> = if phases.len() > 1 {
564        let field_result = super::field_mapping::run_field_mapping(
565            phases, magnitudes, mask, metadata,
566            field_mapping_config, &mut |_, _| {},
567        )?;
568        let gamma_hz = 42.576e6;
569        let te1 = metadata.echo_times[0];
570        let ppm_to_rad = 2.0 * PI * gamma_hz * metadata.field_strength * te1 * 1e-6;
571        field_result.b0_field_ppm.iter().map(|&v| v * ppm_to_rad).collect()
572    } else {
573        phases[0].to_vec()
574    };
575
576    let chi_ppm = crate::inversion::tgv_qsm(
577        &phase_data, mask, &grid, tgv_params, (bx, by, bz), &mut *progress,
578    );
579
580    Ok(super::referencing::apply_reference(&chi_ppm, mask, reference))
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn test_inversion_tkd() {
589        let (nx, ny, nz) = (8, 8, 8);
590        let n = nx * ny * nz;
591        let field = vec![0.01; n];
592        let mask = vec![1u8; n];
593        let meta = ScanMetadata {
594            dims: (nx, ny, nz),
595            voxel_size: (1.0, 1.0, 1.0),
596            echo_times: vec![0.005],
597            field_strength: 3.0,
598            b0_direction: (0.0, 0.0, 1.0),
599        };
600        let config = InversionConfig {
601            algorithm: InversionAlgorithm::Tkd,
602            ..Default::default()
603        };
604
605        let result = run_dipole_inversion(
606            &field, &mask, &meta, &config, None, &mut |_, _| {},
607        );
608        assert!(result.is_ok());
609        assert_eq!(result.unwrap().len(), n);
610    }
611
612    fn make_inversion_test(alg: InversionAlgorithm) -> Vec<f64> {
613        let (nx, ny, nz) = (8, 8, 8);
614        let n = nx * ny * nz;
615        let field = vec![0.01; n];
616        let mask = vec![1u8; n];
617        let meta = ScanMetadata {
618            dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0),
619            echo_times: vec![0.005], field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0),
620        };
621        let config = InversionConfig { algorithm: alg, ..Default::default() };
622        run_dipole_inversion(&field, &mask, &meta, &config, None, &mut |_, _| {}).unwrap()
623    }
624
625    #[test]
626    fn test_inversion_tsvd() {
627        let chi = make_inversion_test(InversionAlgorithm::Tsvd);
628        assert_eq!(chi.len(), 8 * 8 * 8);
629    }
630
631    #[test]
632    fn test_inversion_tikhonov() {
633        let chi = make_inversion_test(InversionAlgorithm::Tikhonov);
634        assert_eq!(chi.len(), 8 * 8 * 8);
635    }
636
637    #[test]
638    fn test_inversion_tv() {
639        let chi = make_inversion_test(InversionAlgorithm::Tv);
640        assert_eq!(chi.len(), 8 * 8 * 8);
641    }
642
643    #[test]
644    fn test_inversion_rts() {
645        let chi = make_inversion_test(InversionAlgorithm::Rts);
646        assert_eq!(chi.len(), 8 * 8 * 8);
647    }
648
649    #[test]
650    fn test_inversion_nltv() {
651        let chi = make_inversion_test(InversionAlgorithm::Nltv);
652        assert_eq!(chi.len(), 8 * 8 * 8);
653    }
654
655    #[test]
656    fn test_inversion_ilsqr() {
657        let chi = make_inversion_test(InversionAlgorithm::Ilsqr);
658        assert_eq!(chi.len(), 8 * 8 * 8);
659    }
660
661    #[test]
662    fn test_inversion_amp_pe() {
663        let chi = make_inversion_test(InversionAlgorithm::AmpPe);
664        assert_eq!(chi.len(), 8 * 8 * 8);
665    }
666
667    #[test]
668    fn test_inversion_medi() {
669        let (nx, ny, nz) = (8, 8, 8);
670        let n = nx * ny * nz;
671        let field = vec![0.01; n];
672        let mask = vec![1u8; n];
673        let mag = vec![1.0; n];
674        let meta = ScanMetadata {
675            dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0),
676            echo_times: vec![0.005], field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0),
677        };
678        let config = InversionConfig { algorithm: InversionAlgorithm::Medi, ..Default::default() };
679        let chi = run_dipole_inversion(&field, &mask, &meta, &config, Some(&mag), &mut |_, _| {}).unwrap();
680        assert_eq!(chi.len(), n);
681    }
682
683    #[test]
684    fn test_inversion_rejects_tgv() {
685        let n = 64;
686        let meta = ScanMetadata {
687            dims: (4, 4, 4),
688            voxel_size: (1.0, 1.0, 1.0),
689            echo_times: vec![0.005],
690            field_strength: 3.0,
691            b0_direction: (0.0, 0.0, 1.0),
692        };
693        let config = InversionConfig {
694            algorithm: InversionAlgorithm::Tgv,
695            ..Default::default()
696        };
697
698        let result = run_dipole_inversion(
699            &vec![0.0; n], &vec![1u8; n], &meta, &config, None, &mut |_, _| {},
700        );
701        assert!(result.is_err());
702    }
703}