Skip to content

Commit 23bc068

Browse files
committed
Merge pull request #409 from grlee77/parrec_sort_fix
MRG: Add option for PARREC sorting with more header fields This PR is an attempt to address #408. I added a new boolean option upon loading a .PAR/.REC file called `strict_sort`. If it is set to `True` the `get_sorted_slice_indices` method of `PARRECHeader` uses several additional keys in the call to `np.lexsort`. `strict_sort=False` corresponds to the previously existing sort mode. This option is made available via a `--strict_sort` command line option to `parrec2nii` as well. In general, I think it is probably preferable to use `strict_sort=True` in all cases, but I have left the default as `False` for now to ensure backwards compatibility. Perhaps after a deprecation/testing period we could make `True` the new default. In particular, it may not be true that for any given DTI scan the b-vals/vectors would be sorted in exactly the same order as previously (If the user is using the b-vals/vectors as exported by `parrec2nii` this should not be an issue, but could cause problems if mixed with a previously stored table from the other approach). There could be some debate as to the preferred order of priority for the sort keys. I chose one that seemed logical to me. As before, slices is the lowest priority so that dimension 3 of the data is still always slices and `is_full` is the highest so that partially acquired scans get moved to the end for possible discarding. The sorting is a bit complicated, but I tried to add comments to make the reasoning clearer. The full set of potential sort keys implemented is: ``` slice_nos echos cardiac phases b-vectors b-values asl_labels dynamics vol_numbers() is_full ```
2 parents 87375ea + eac41a3 commit 23bc068

File tree

7 files changed

+1413
-35
lines changed

7 files changed

+1413
-35
lines changed

bin/parrec2nii

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ def get_opt_parser():
113113
default=False,
114114
help=one_line("""Overwrite file if it exists. Default:
115115
False""")))
116+
p.add_option(
117+
Option("--strict-sort", action="store_true", dest="strict_sort",
118+
default=False,
119+
help=one_line("""Use additional keys in determining the order
120+
to sort the slices within the .REC file. This may be necessary
121+
for more complicated scans with multiple echos,
122+
cardiac phases, ASL label states, etc.""")))
116123
return p
117124

118125

@@ -148,7 +155,8 @@ def proc_file(infile, opts):
148155
infile = fname_ext_ul_case(infile)
149156
pr_img = pr.load(infile,
150157
permit_truncated=opts.permit_truncated,
151-
scaling=scaling)
158+
scaling=scaling,
159+
strict_sort=opts.strict_sort)
152160
pr_hdr = pr_img.header
153161
affine = pr_hdr.get_affine(origin=opts.origin)
154162
slope, intercept = pr_hdr.get_data_scaling(scaling)

nibabel/parrec.py

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,8 @@ def __getitem__(self, slicer):
618618
class PARRECHeader(SpatialHeader):
619619
"""PAR/REC header"""
620620

621-
def __init__(self, info, image_defs, permit_truncated=False):
621+
def __init__(self, info, image_defs, permit_truncated=False,
622+
strict_sort=False):
622623
"""
623624
Parameters
624625
----------
@@ -631,10 +632,16 @@ def __init__(self, info, image_defs, permit_truncated=False):
631632
permit_truncated : bool, optional
632633
If True, a warning is emitted instead of an error when a truncated
633634
recording is detected.
635+
strict_sort : bool, optional, keyword-only
636+
If True, a larger number of header fields are used while sorting
637+
the REC data array. This may produce a different sort order than
638+
`strict_sort=False`, where volumes are sorted by the order in which
639+
the slices appear in the .PAR file.
634640
"""
635641
self.general_info = info.copy()
636642
self.image_defs = image_defs.copy()
637643
self.permit_truncated = permit_truncated
644+
self.strict_sort = strict_sort
638645
_truncation_checks(info, image_defs, permit_truncated)
639646
# charge with basic properties to be able to use base class
640647
# functionality
@@ -660,14 +667,16 @@ def from_header(klass, header=None):
660667
'non-PARREC header.')
661668

662669
@classmethod
663-
def from_fileobj(klass, fileobj, permit_truncated=False):
670+
def from_fileobj(klass, fileobj, permit_truncated=False,
671+
strict_sort=False):
664672
info, image_defs = parse_PAR_header(fileobj)
665-
return klass(info, image_defs, permit_truncated)
673+
return klass(info, image_defs, permit_truncated, strict_sort)
666674

667675
def copy(self):
668676
return PARRECHeader(deepcopy(self.general_info),
669677
self.image_defs.copy(),
670-
self.permit_truncated)
678+
self.permit_truncated,
679+
self.strict_sort)
671680

672681
def as_analyze_map(self):
673682
"""Convert PAR parameters to NIFTI1 format"""
@@ -740,6 +749,11 @@ def get_bvals_bvecs(self):
740749
bvecs = apply_affine(np.linalg.inv(permute_to_psl), bvecs)
741750
return bvals, bvecs
742751

752+
def get_def(self, name):
753+
"""Return a single image definition field (or None if missing) """
754+
idef = self.image_defs
755+
return idef[name] if name in idef.dtype.names else None
756+
743757
def _get_unique_image_prop(self, name):
744758
""" Scan image definitions and return unique value of a property.
745759
@@ -992,31 +1006,113 @@ def get_rec_shape(self):
9921006
inplane_shape = tuple(self._get_unique_image_prop('recon resolution'))
9931007
return inplane_shape + (len(self.image_defs),)
9941008

995-
def get_sorted_slice_indices(self):
996-
"""Return indices to sort (and maybe discard) slices in REC file.
1009+
def _strict_sort_order(self):
1010+
""" Determine the sort order based on several image definition fields.
1011+
1012+
The fields taken into consideration, if present, are (in order from
1013+
slowest to fastest variation after sorting):
1014+
1015+
- image_defs['image_type_mr'] # Re, Im, Mag, Phase
1016+
- image_defs['dynamic scan number'] # repetition
1017+
- image_defs['label type'] # ASL tag/control
1018+
- image_defs['diffusion b value number'] # diffusion b value
1019+
- image_defs['gradient orientation number'] # diffusion directoin
1020+
- image_defs['cardiac phase number'] # cardiac phase
1021+
- image_defs['echo number'] # echo
1022+
- image_defs['slice number'] # slice
1023+
1024+
Data sorting is done in two stages:
1025+
1026+
1. an initial sort using the keys described above
1027+
2. a resort after generating two additional sort keys:
1028+
1029+
* a key to assign unique volume numbers to any volumes that
1030+
didn't have a unique sort based on the keys above
1031+
(see :func:`vol_numbers`).
1032+
* a sort key based on `vol_is_full` to identify truncated
1033+
volumes
1034+
1035+
A case where the initial sort may not create a unique label for each
1036+
volume is diffusion scans acquired in the older V4 .PAR format, where
1037+
diffusion direction info is not available.
1038+
"""
1039+
# sort keys present in all supported .PAR versions
1040+
idefs = self.image_defs
1041+
slice_nos = idefs['slice number']
1042+
dynamics = idefs['dynamic scan number']
1043+
phases = idefs['cardiac phase number']
1044+
echos = idefs['echo number']
1045+
image_type = idefs['image_type_mr']
1046+
1047+
# sort keys only present in a subset of .PAR files
1048+
asl_keys = ((idefs['label type'], ) if 'label type' in
1049+
idefs.dtype.names else ())
1050+
if self.general_info['diffusion'] != 0:
1051+
bvals = self.get_def('diffusion b value number')
1052+
if bvals is None:
1053+
bvals = self.get_def('diffusion_b_factor')
1054+
bvecs = self.get_def('gradient orientation number')
1055+
if bvecs is None:
1056+
# no b-vectors available
1057+
diffusion_keys = (bvals, )
1058+
else:
1059+
diffusion_keys = (bvecs, bvals)
1060+
else:
1061+
diffusion_keys = ()
1062+
1063+
# initial sort (last key is highest precedence)
1064+
keys = (slice_nos, echos, phases) + \
1065+
diffusion_keys + asl_keys + (dynamics, image_type)
1066+
initial_sort_order = np.lexsort(keys)
1067+
1068+
# sequentially number the volumes based on the initial sort
1069+
vol_nos = vol_numbers(slice_nos[initial_sort_order])
1070+
# identify truncated volumes
1071+
is_full = vol_is_full(slice_nos[initial_sort_order],
1072+
self.general_info['max_slices'])
1073+
1074+
# second stage of sorting
1075+
return initial_sort_order[np.lexsort((vol_nos, is_full))]
9971076

1077+
def _lax_sort_order(self):
1078+
"""
9981079
Sorts by (fast to slow): slice number, volume number.
9991080
10001081
We calculate volume number by looking for repeating slice numbers (see
10011082
:func:`vol_numbers`).
1083+
"""
1084+
slice_nos = self.image_defs['slice number']
1085+
is_full = vol_is_full(slice_nos, self.general_info['max_slices'])
1086+
keys = (slice_nos, vol_numbers(slice_nos), np.logical_not(is_full))
1087+
return np.lexsort(keys)
1088+
1089+
def get_sorted_slice_indices(self):
1090+
"""Return indices to sort (and maybe discard) slices in REC file.
10021091
10031092
If the recording is truncated, the returned indices take care of
10041093
discarding any slice indices from incomplete volumes.
10051094
1095+
If `self.strict_sort` is True, a more complicated sorting based on
1096+
multiple fields from the .PAR file is used. This may produce a
1097+
different sort order than `strict_sort=False`, where volumes are sorted
1098+
by the order in which the slices appear in the .PAR file.
1099+
10061100
Returns
10071101
-------
10081102
slice_indices : list
10091103
List for indexing into the last (third) dimension of the REC data
10101104
array, and (equivalently) the only dimension of
10111105
``self.image_defs``.
10121106
"""
1013-
slice_nos = self.image_defs['slice number']
1014-
is_full = vol_is_full(slice_nos, self.general_info['max_slices'])
1015-
keys = (slice_nos, vol_numbers(slice_nos), np.logical_not(is_full))
1016-
# Figure out how many we need to remove from the end, and trim them
1017-
# Based on our sorting, they should always be last
1107+
if not self.strict_sort:
1108+
sort_order = self._lax_sort_order()
1109+
else:
1110+
sort_order = self._strict_sort_order()
1111+
1112+
# Figure out how many we need to remove from the end, and trim them.
1113+
# Based on our sorting, they should always be last.
10181114
n_used = np.prod(self.get_data_shape()[2:])
1019-
return np.lexsort(keys)[:n_used]
1115+
return sort_order[:n_used]
10201116

10211117

10221118
class PARRECImage(SpatialImage):
@@ -1033,7 +1129,7 @@ class PARRECImage(SpatialImage):
10331129
@classmethod
10341130
@kw_only_meth(1)
10351131
def from_file_map(klass, file_map, mmap=True, permit_truncated=False,
1036-
scaling='dv'):
1132+
scaling='dv', strict_sort=False):
10371133
""" Create PARREC image from file map `file_map`
10381134
10391135
Parameters
@@ -1054,11 +1150,17 @@ def from_file_map(klass, file_map, mmap=True, permit_truncated=False,
10541150
scaling : {'dv', 'fp'}, optional, keyword-only
10551151
Scaling method to apply to data (see
10561152
:meth:`PARRECHeader.get_data_scaling`).
1153+
strict_sort : bool, optional, keyword-only
1154+
If True, a larger number of header fields are used while sorting
1155+
the REC data array. This may produce a different sort order than
1156+
`strict_sort=False`, where volumes are sorted by the order in which
1157+
the slices appear in the .PAR file.
10571158
"""
10581159
with file_map['header'].get_prepare_fileobj('rt') as hdr_fobj:
10591160
hdr = klass.header_class.from_fileobj(
10601161
hdr_fobj,
1061-
permit_truncated=permit_truncated)
1162+
permit_truncated=permit_truncated,
1163+
strict_sort=strict_sort)
10621164
rec_fobj = file_map['image'].get_prepare_fileobj()
10631165
data = klass.ImageArrayProxy(rec_fobj, hdr,
10641166
mmap=mmap, scaling=scaling)
@@ -1068,7 +1170,7 @@ def from_file_map(klass, file_map, mmap=True, permit_truncated=False,
10681170
@classmethod
10691171
@kw_only_meth(1)
10701172
def from_filename(klass, filename, mmap=True, permit_truncated=False,
1071-
scaling='dv'):
1173+
scaling='dv', strict_sort=False):
10721174
""" Create PARREC image from filename `filename`
10731175
10741176
Parameters
@@ -1088,12 +1190,18 @@ def from_filename(klass, filename, mmap=True, permit_truncated=False,
10881190
scaling : {'dv', 'fp'}, optional, keyword-only
10891191
Scaling method to apply to data (see
10901192
:meth:`PARRECHeader.get_data_scaling`).
1193+
strict_sort : bool, optional, keyword-only
1194+
If True, a larger number of header fields are used while sorting
1195+
the REC data array. This may produce a different sort order than
1196+
`strict_sort=False`, where volumes are sorted by the order in which
1197+
the slices appear in the .PAR file.
10911198
"""
10921199
file_map = klass.filespec_to_file_map(filename)
10931200
return klass.from_file_map(file_map,
10941201
mmap=mmap,
10951202
permit_truncated=permit_truncated,
1096-
scaling=scaling)
1203+
scaling=scaling,
1204+
strict_sort=strict_sort)
10971205

10981206
load = from_filename
10991207

0 commit comments

Comments
 (0)