Skip to content

Commit 1edb800

Browse files
authored
Merge pull request #356 from effigies/enh/validate-phase12
ENH: Check registration of magnitude1/magnitude2 images and update headers
2 parents 0d97d9e + bf337d7 commit 1edb800

File tree

2 files changed

+212
-18
lines changed

2 files changed

+212
-18
lines changed

sdcflows/interfaces/fmap.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
# https://www.nipreps.org/community/licensing/
2222
#
2323
"""Interfaces to deal with the various types of fieldmap sources."""
24+
import os
2425
import numpy as np
2526
import nibabel as nb
27+
import nitransforms as nt
2628
from nipype.utils.filemanip import fname_presuffix
2729
from nipype import logging
2830
from nipype.interfaces.base import (
@@ -31,7 +33,10 @@
3133
File,
3234
traits,
3335
SimpleInterface,
36+
InputMultiObject,
37+
OutputMultiObject,
3438
)
39+
from nipype.interfaces import freesurfer as fs
3540

3641
LOGGER = logging.getLogger("nipype.interface")
3742

@@ -201,3 +206,185 @@ def _run_interface(self, runtime):
201206

202207
fmapnii.to_filename(self._results["out_file"])
203208
return runtime
209+
210+
211+
class _CheckRegisterInputSpec(TraitedSpec):
212+
mag_files = InputMultiObject(
213+
File(exists=True),
214+
mandatory=True,
215+
minlen=1,
216+
maxlen=2,
217+
desc="Magnitude image(s) to verify registration",
218+
)
219+
fmap_files = InputMultiObject(
220+
File(exists=True),
221+
mandatory=True,
222+
minlen=1,
223+
maxlen=2,
224+
desc="Phase(diff) or fieldmap image(s) to update affines",
225+
)
226+
rot_thresh = traits.Float(
227+
0.02, usedefault=True, mandatory=True, desc="rotation threshold in radians"
228+
)
229+
trans_thresh = traits.Float(
230+
1., usedefault=True, mandatory=True, desc="translation threshold in mm"
231+
)
232+
233+
234+
class _CheckRegisterOutputSpec(TraitedSpec):
235+
mag_files = OutputMultiObject(
236+
File,
237+
desc="Magnitude image(s) verified to be in register, with consistent affines",
238+
)
239+
fmap_files = OutputMultiObject(
240+
File,
241+
desc="Fieldmap files with consistent affines",
242+
)
243+
244+
245+
class CheckRegister(SimpleInterface):
246+
"""Check registration of one or more images and paired files
247+
248+
Use cases:
249+
250+
Phase1 + Phase2:
251+
252+
>>> fmap_files = ['*_phase1.nii.gz', '*_phase2.nii.gz']
253+
>>> mag_files = ['*_magnitude1.nii.gz', '*_magnitude2.nii.gz']
254+
255+
Phasediff (2 magnitude):
256+
257+
>>> fmap_files = ['*_phasediff.nii.gz']
258+
>>> mag_files = ['*_magnitude1.nii.gz', '*_magnitude2.nii.gz']
259+
260+
Phasediff (1 magnitude):
261+
262+
>>> fmap_files = ['*_phasediff.nii.gz']
263+
>>> mag_files = ['*_magnitude1.nii.gz']
264+
265+
Fieldmap (1 magnitude):
266+
267+
>>> fmap_files = ['*_fieldmap.nii.gz']
268+
>>> mag_files = ['*_magnitude.nii.gz']
269+
270+
In general, we expect all files to have the same affine and shape,
271+
and this will be a pass-through interface. If there are two magnitude
272+
files where the affine differs, then we will register them and inspect
273+
the registration parameters for evidence of significantly different FoV.
274+
The default values of 0.02rad and 1mm both correspond to shifts of 1mm
275+
(assuming 50mm radius brain).
276+
277+
This may run ``mri_robust_register``, so should not be run without
278+
submitting.
279+
"""
280+
281+
input_spec = _CheckRegisterInputSpec
282+
output_spec = _CheckRegisterOutputSpec
283+
284+
def _run_interface(self, runtime):
285+
mag_files = self.inputs.mag_files
286+
fmap_files = self.inputs.fmap_files
287+
288+
mag_imgs = [nb.load(fname) for fname in mag_files]
289+
fmap_imgs = [nb.load(fname) for fname in fmap_files]
290+
291+
# Baseline check: paired magnitude/phase maps are basically the same
292+
for mag, fmap in zip(mag_imgs, fmap_imgs):
293+
msg = _check_gross_geometry(mag, fmap)
294+
if msg is not None:
295+
LOGGER.critical(msg)
296+
raise ValueError(msg)
297+
298+
# Verify images are in register before conforming affines
299+
if len(mag_files) == 2:
300+
msg = _check_gross_geometry(mag_imgs[0], mag_imgs[1])
301+
if msg is not None:
302+
LOGGER.critical(msg)
303+
raise ValueError(msg)
304+
305+
# If affines match, do not attempt to register
306+
# Treat this as an assertion by the scanner or curator that they are aligned
307+
if not np.allclose(mag_imgs[0].affine, mag_imgs[1].affine):
308+
reg = fs.RobustRegister(
309+
target_file=mag_files[0],
310+
source_file=mag_files[1],
311+
auto_sens=True,
312+
)
313+
result = reg.run()
314+
lta = nt.io.lta.FSLinearTransform.from_filename(result.outputs.out_reg_file)
315+
mat, vec = nb.affines.to_matvec(lta.to_ras())
316+
angles = np.abs(nb.eulerangles.mat2euler(mat))
317+
rot_thresh, trans_thresh = (
318+
self.inputs.rot_thresh,
319+
self.inputs.trans_thresh,
320+
)
321+
322+
if np.any(angles > rot_thresh) or np.any(vec > trans_thresh):
323+
LOGGER.critical(
324+
"Magnitude files {mag_files} are not in register with rotation "
325+
f"threshold {self.inputs.rot_thresh} and translation threshold "
326+
f"{self.inputs.trans_thresh}. Please manually verify images "
327+
"are in register and update the image headers before running SDC."
328+
)
329+
raise ValueError(
330+
"Magnitude 1/2 orientation mismatch too big to ignore."
331+
)
332+
333+
# Probably redundant, but we could hit this error
334+
# with phase1/magnitude1 + wonky phase2 + no magnitude2
335+
if len(fmap_files) == 2:
336+
msg = _check_gross_geometry(fmap_imgs[0], fmap_imgs[1])
337+
if msg is not None:
338+
LOGGER.critical(msg)
339+
raise ValueError(msg)
340+
341+
# Check/copy affines
342+
out_mags = [_conform_img(mag, mag_imgs[0], runtime.cwd) for mag in mag_imgs]
343+
out_fmaps = [_conform_img(fmap, mag_imgs[0], runtime.cwd) for fmap in fmap_imgs]
344+
345+
self._results = {
346+
"mag_files": out_mags,
347+
"fmap_files": out_fmaps,
348+
}
349+
350+
return runtime
351+
352+
353+
def _conform_img(
354+
img: nb.spatialimages.SpatialImage,
355+
target_img: nb.spatialimages.SpatialImage,
356+
cwd: str,
357+
) -> str:
358+
"""Return path to image matching target_img geometry
359+
360+
Copy target_affine to a new image if necessary.
361+
"""
362+
if np.allclose(img.affine, target_img.affine):
363+
return img.get_filename()
364+
365+
basename = os.basename(img.get_filename())
366+
out_file = os.path.join(cwd, basename)
367+
368+
LOGGER.info(f"Copying affine to {basename}")
369+
new_img = img.__class__(img.dataobj, target_img.affine, img.header)
370+
new_img.to_filename(out_file)
371+
372+
return out_file
373+
374+
375+
def _check_gross_geometry(
376+
img1: nb.spatialimages.SpatialImage,
377+
img2: nb.spatialimages.SpatialImage,
378+
):
379+
if img1.shape[:3] != img2.shape[:3]:
380+
return (
381+
"Images have shape mismatch: "
382+
f"{img1.get_filename()} {img1.shape}, "
383+
f"{img2.get_filename()} {img2.shape}"
384+
)
385+
if nb.aff2axcodes(img1.affine) != nb.aff2axcodes(img2.affine):
386+
return (
387+
"Images have orientation mismatch: "
388+
f"{img1.get_filename()} {''.join(nb.aff2axcodes(img1.affine))}, "
389+
f"{img2.get_filename()} {''.join(nb.aff2axcodes(img2.affine))}"
390+
)

sdcflows/workflows/fit/fieldmap.py

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def init_fmap_wf(omp_nthreads=1, sloppy=False, debug=False, mode="phasediff", na
9090
DEFAULT_HF_ZOOMS_MM,
9191
DEFAULT_ZOOMS_MM,
9292
)
93+
from ...interfaces.fmap import CheckRegister
9394

9495
workflow = Workflow(name=name)
9596
inputnode = pe.Node(niu.IdentityInterface(fields=INPUT_FIELDS), name="inputnode")
@@ -98,6 +99,18 @@ def init_fmap_wf(omp_nthreads=1, sloppy=False, debug=False, mode="phasediff", na
9899
name="outputnode",
99100
)
100101

102+
def _unzip(fmap_spec):
103+
return fmap_spec
104+
105+
unzip = pe.MapNode(
106+
niu.Function(function=_unzip, output_names=["fmap_file", "meta"]),
107+
run_without_submitting=True,
108+
iterfield=["fmap_spec"],
109+
name="unzip",
110+
)
111+
112+
check_register = pe.Node(CheckRegister(), name='check_register')
113+
101114
magnitude_wf = init_magnitude_wf(omp_nthreads=omp_nthreads)
102115
bs_filter = pe.Node(BSplineApprox(), name="bs_filter")
103116
bs_filter.interface._always_run = debug
@@ -108,7 +121,10 @@ def init_fmap_wf(omp_nthreads=1, sloppy=False, debug=False, mode="phasediff", na
108121

109122
# fmt: off
110123
workflow.connect([
111-
(inputnode, magnitude_wf, [("magnitude", "inputnode.magnitude")]),
124+
(inputnode, unzip, [("fieldmap", "fmap_spec")]),
125+
(inputnode, check_register, [("magnitude", "mag_files")]),
126+
(unzip, check_register, [("fmap_file", "fmap_files")]),
127+
(check_register, magnitude_wf, [("mag_files", "inputnode.magnitude")]),
112128
(magnitude_wf, bs_filter, [("outputnode.fmap_mask", "in_mask")]),
113129
(magnitude_wf, outputnode, [
114130
("outputnode.fmap_mask", "fmap_mask"),
@@ -131,7 +147,8 @@ def init_fmap_wf(omp_nthreads=1, sloppy=False, debug=False, mode="phasediff", na
131147

132148
# fmt: off
133149
workflow.connect([
134-
(inputnode, phdiff_wf, [("fieldmap", "inputnode.phase")]),
150+
(unzip, phdiff_wf, [("meta", "inputnode.phase_meta")]),
151+
(check_register, phdiff_wf, [("fmap_files", "inputnode.phase")]),
135152
(magnitude_wf, phdiff_wf, [
136153
("outputnode.fmap_ref", "inputnode.magnitude"),
137154
("outputnode.fmap_mask", "inputnode.mask"),
@@ -160,7 +177,7 @@ def init_fmap_wf(omp_nthreads=1, sloppy=False, debug=False, mode="phasediff", na
160177
# fmt: off
161178
workflow.connect([
162179
(inputnode, units, [(("fieldmap", _get_units), "units")]),
163-
(inputnode, fmapmrg, [(("fieldmap", _get_file), "in_files")]),
180+
(check_register, fmapmrg, [("fmap_files", "in_files")]),
164181
(fmapmrg, units, [("out_avg", "in_file")]),
165182
(units, bs_filter, [("out_file", "in_data")]),
166183
])
@@ -298,24 +315,15 @@ def init_phdiff_wf(omp_nthreads, debug=False, name="phdiff_wf"):
298315
"""
299316

300317
inputnode = pe.Node(
301-
niu.IdentityInterface(fields=["magnitude", "phase", "mask"]), name="inputnode"
318+
niu.IdentityInterface(fields=["magnitude", "phase", "phase_meta", "mask"]),
319+
name="inputnode",
302320
)
303321

304322
outputnode = pe.Node(
305323
niu.IdentityInterface(fields=["fieldmap"]),
306324
name="outputnode",
307325
)
308326

309-
def _split(phase):
310-
return phase
311-
312-
split = pe.MapNode( # We cannot use an inline connection function with MapNode
313-
niu.Function(function=_split, output_names=["map_file", "meta"]),
314-
iterfield=["phase"],
315-
run_without_submitting=True,
316-
name="split",
317-
)
318-
319327
# phase diff -> radians
320328
phmap2rads = pe.MapNode(
321329
PhaseMap2rads(),
@@ -334,15 +342,14 @@ def _split(phase):
334342

335343
# fmt: off
336344
workflow.connect([
337-
(inputnode, split, [("phase", "phase")]),
345+
(inputnode, phmap2rads, [("phase", "in_file")]),
346+
(inputnode, calc_phdiff, [("phase_meta", "in_meta")]),
338347
(inputnode, prelude, [("magnitude", "magnitude_file"),
339348
("mask", "mask_file")]),
340-
(split, phmap2rads, [("map_file", "in_file")]),
341349
(phmap2rads, calc_phdiff, [("out_file", "in_phases")]),
342-
(split, calc_phdiff, [("meta", "in_meta")]),
343350
(calc_phdiff, prelude, [("phase_diff", "phase_file")]),
344-
(prelude, compfmap, [("unwrapped_phase_file", "in_file")]),
345351
(calc_phdiff, compfmap, [("metadata", "metadata")]),
352+
(prelude, compfmap, [("unwrapped_phase_file", "in_file")]),
346353
(compfmap, outputnode, [("out_file", "fieldmap")]),
347354
])
348355
# fmt: on

0 commit comments

Comments
 (0)