Skip to content

Commit 966b8e9

Browse files
author
Agah
committed
cli: write single-slice fit maps as 2D, matching qMRLab (fixes viewer overlay)
Output maps now always go through write_map_nifti (2D when z=1, 3D otherwise), so single-slice derivatives match qMRLab's make_nii dim[0]=2 instead of a 3D z=1 volume that misregistered against 2D references on oblique acquisitions. Claude-Session: https://claude.ai/code/session_014DCAAax3rn83w46iL9QNEf
1 parent 173bb26 commit 966b8e9

2 files changed

Lines changed: 20 additions & 28 deletions

File tree

crates/qmrust-cli/src/commands.rs

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -386,11 +386,10 @@ pub fn run_fit(
386386
}
387387
let aux = AuxMaps::new(aux_pairs);
388388

389-
// NIfTI inputs carry a real spatial header → preserve it (write_3d_nifti).
390-
// .mat inputs have none → emit a make_nii-compatible header
391-
// (write_map_nifti: 2D when z=1, sform origin at voxel (1,1,1)) so the
392-
// maps overlay/subtract cleanly against qMRLab's FitResults.
393-
let from_mat = input.nifti_header.is_none();
389+
// NIfTI inputs carry a real spatial header → preserve it; .mat inputs have
390+
// none → emit a make_nii-compatible minimal header. Either way the map
391+
// writer collapses a singleton z to 2D so maps overlay/subtract cleanly
392+
// against qMRLab's FitResults.
394393
let header = input.nifti_header.unwrap_or_else(|| {
395394
let (nx, ny, nz, _) = input.data.dim();
396395
make_minimal_header(nx, ny, nz)
@@ -403,7 +402,6 @@ pub fn run_fit(
403402
input.mask.as_ref(),
404403
&aux,
405404
&header,
406-
from_mat,
407405
&output_dir,
408406
)
409407
}
@@ -451,7 +449,6 @@ fn fit_and_write(
451449
mask: Option<&Array3<bool>>,
452450
aux: &AuxMaps,
453451
header: &NiftiHeader,
454-
from_mat: bool,
455452
output_dir: &Path,
456453
) -> Result<()> {
457454
let results = run_model_fit(model, data, proto, mask, aux)?;
@@ -460,11 +457,7 @@ fn fit_and_write(
460457
eprintln!("Writing results to {:?}...", output_dir);
461458
for (name, map) in &results {
462459
let path = output_dir.join(format!("{}.nii.gz", name));
463-
if from_mat {
464-
io::nifti::write_map_nifti(map, header, &path)?;
465-
} else {
466-
io::nifti::write_3d_nifti(map, header, &path)?;
467-
}
460+
io::nifti::write_map_nifti(map, header, &path)?;
468461
let fname = format!("{}.nii.gz", name);
469462
eprintln!(
470463
" {}",
@@ -528,14 +521,13 @@ fn collection_sources(c: &Collection, bids_dir: &Path) -> Vec<String> {
528521
/// Outputs `bids_outputs()` doesn't declare (diagnostics like
529522
/// `res`/`idx`/`kf`/`resnorm`) are never written —
530523
/// only real BIDS maps get exported to the derivatives layout. Uses the same
531-
/// writer `fit_and_write`'s flat output uses (`write_map_nifti` for
532-
/// `.mat`-sourced data, `write_3d_nifti` otherwise), so map values are
533-
/// byte-identical between the flat and derivatives layouts. Also ensures a
524+
/// writer `fit_and_write`'s flat output uses (`write_map_nifti`), so map values
525+
/// are byte-identical between the flat and derivatives layouts. Also ensures a
534526
/// `deriv_root/qmrust/dataset_description.json` exists (created once, never
535527
/// overwritten on subsequent subjects/sessions).
536528
// One parameter per independent piece of write context (map data, model,
537-
// BIDS entities, output root, header/source-format, provenance) — a params
538-
// struct would just relocate the same fields without reducing them.
529+
// BIDS entities, output root, header, provenance) — a params struct would
530+
// just relocate the same fields without reducing them.
539531
#[allow(clippy::too_many_arguments)]
540532
fn write_derivatives(
541533
results: &qmrust_core::fitting::FitResults,
@@ -544,7 +536,6 @@ fn write_derivatives(
544536
session: Option<&str>,
545537
deriv_root: &Path,
546538
header: &NiftiHeader,
547-
from_mat: bool,
548539
prov: &crate::provenance::FitProvenance,
549540
) -> Result<()> {
550541
let qmrust_root = deriv_root.join("qmrust");
@@ -578,11 +569,7 @@ fn write_derivatives(
578569
};
579570
let base = format!("{entity_stem}_{suffix}");
580571
let nii_path = anat_dir.join(format!("{base}.nii.gz"));
581-
if from_mat {
582-
io::nifti::write_map_nifti(map, header, &nii_path)?;
583-
} else {
584-
io::nifti::write_3d_nifti(map, header, &nii_path)?;
585-
}
572+
io::nifti::write_map_nifti(map, header, &nii_path)?;
586573
let json_path = anat_dir.join(format!("{base}.json"));
587574
std::fs::write(
588575
&json_path,
@@ -727,7 +714,6 @@ pub fn run_fit_bids(
727714
let model = (entry.build)(&raw, &proto)?;
728715
eprintln!(" Model: {}, {} volumes", cfg.model, data.dim().3);
729716

730-
let from_mat = header.is_none();
731717
let (nx, ny, nz, _) = data.dim();
732718
let header = header.unwrap_or_else(|| make_minimal_header(nx, ny, nz));
733719

@@ -770,7 +756,6 @@ pub fn run_fit_bids(
770756
c.session.as_deref(),
771757
&output_dir,
772758
&header,
773-
from_mat,
774759
&prov,
775760
)?;
776761
fit_count += 1;
@@ -1225,7 +1210,6 @@ mod tests {
12251210
None,
12261211
&deriv_root,
12271212
&header,
1228-
true,
12291213
&prov,
12301214
)
12311215
.unwrap();

crates/qmrust-cli/src/io/nifti.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@ pub fn read_map_nifti_with_header(path: &Path) -> Result<(Array3<f64>, NiftiHead
109109
Ok((arr, header))
110110
}
111111

112-
/// Create a 3D header from a 4D reference header.
112+
/// Create a 3D header from a 4D reference header. Test-only: production output
113+
/// goes through [`write_map_nifti`] (3D for z > 1, 2D for a single slice).
114+
#[cfg(test)]
113115
fn make_3d_header(header_4d: &NiftiHeader) -> NiftiHeader {
114116
let mut h = header_4d.clone();
115117
h.dim[0] = 3;
@@ -121,7 +123,10 @@ fn make_3d_header(header_4d: &NiftiHeader) -> NiftiHeader {
121123
h
122124
}
123125

124-
/// Write a 3D f64 array as a NIfTI file, using a reference header for spatial metadata.
126+
/// Write a 3D f64 array as a NIfTI file, using a reference header for spatial
127+
/// metadata. Test-only helper for building 3D fixtures; production map output
128+
/// uses [`write_map_nifti`].
129+
#[cfg(test)]
125130
pub fn write_3d_nifti(
126131
data: &Array3<f64>,
127132
reference_header: &NiftiHeader,
@@ -154,6 +159,9 @@ pub fn write_map_nifti(
154159
header.datatype = 64;
155160
header.bitpix = 64;
156161
if nz == 1 {
162+
// A 2D image has no temporal axis, so clear its spacing (qMRLab's
163+
// make_nii leaves pixdim[4] = 0), matching reference maps field-for-field.
164+
header.pixdim[4] = 0.0;
157165
// Drop the singleton z axis → 2D (nx, ny), preserving (i, j) order.
158166
let slice2d = data.index_axis(ndarray::Axis(2), 0).to_owned();
159167
WriterOptions::new(output_path)

0 commit comments

Comments
 (0)