From ece0bd63e9b5e43d8610d5050ecf6a7f9b7e9038 Mon Sep 17 00:00:00 2001 From: Paul McCarthy <pauldmccarthy@gmail.com> Date: Thu, 7 Dec 2017 16:25:36 +1030 Subject: [PATCH] Including a copy of dcmstack, which will be removed when it is brought up to date. --- fsl/data/dcmstack/COPYING | 35 + fsl/data/dcmstack/__init__.py | 7 + fsl/data/dcmstack/dcmmeta.py | 1804 +++++++++++++++++++++++++++++ fsl/data/dcmstack/dcmstack.py | 1162 +++++++++++++++++++ fsl/data/dcmstack/dcmstack_cli.py | 357 ++++++ fsl/data/dcmstack/extract.py | 520 +++++++++ fsl/data/dcmstack/info.py | 54 + fsl/data/dcmstack/nitool_cli.py | 251 ++++ 8 files changed, 4190 insertions(+) create mode 100644 fsl/data/dcmstack/COPYING create mode 100644 fsl/data/dcmstack/__init__.py create mode 100644 fsl/data/dcmstack/dcmmeta.py create mode 100644 fsl/data/dcmstack/dcmstack.py create mode 100755 fsl/data/dcmstack/dcmstack_cli.py create mode 100644 fsl/data/dcmstack/extract.py create mode 100644 fsl/data/dcmstack/info.py create mode 100644 fsl/data/dcmstack/nitool_cli.py diff --git a/fsl/data/dcmstack/COPYING b/fsl/data/dcmstack/COPYING new file mode 100644 index 000000000..7374d5d05 --- /dev/null +++ b/fsl/data/dcmstack/COPYING @@ -0,0 +1,35 @@ +********************** +Copyright and Licenses +********************** + +dcmstack +======= + +The dcmstack package, including all examples, code snippets and attached +documentation is covered by the MIT license. + +:: + + The MIT License + + Copyright (c) 2011-2012 Brendan Moloney <moloney@ohsu.edu> + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + + diff --git a/fsl/data/dcmstack/__init__.py b/fsl/data/dcmstack/__init__.py new file mode 100644 index 000000000..2663c2259 --- /dev/null +++ b/fsl/data/dcmstack/__init__.py @@ -0,0 +1,7 @@ +""" +Package for stacking DICOM images into multi dimensional volumes, extracting +the DICOM meta data, converting the result to Nifti files with the meta +data stored in a header extension, and work with these extended Nifti files. +""" +from .info import __version__ +from .dcmstack import * diff --git a/fsl/data/dcmstack/dcmmeta.py b/fsl/data/dcmstack/dcmmeta.py new file mode 100644 index 000000000..9049d7d7c --- /dev/null +++ b/fsl/data/dcmstack/dcmmeta.py @@ -0,0 +1,1804 @@ +""" +DcmMeta header extension and NiftiWrapper for working with extended Niftis. +""" +from __future__ import print_function + +import sys +import json, warnings +from copy import deepcopy +import numpy as np +import nibabel as nb +from nibabel.nifti1 import Nifti1Extension +from nibabel.spatialimages import HeaderDataError + +try: + from collections import OrderedDict +except ImportError: + from ordereddict import OrderedDict + +with warnings.catch_warnings(): + warnings.simplefilter('ignore') + from nibabel.nicom.dicomwrappers import wrapper_from_data + +dcm_meta_ecode = 0 + +_meta_version = 0.6 + +_req_base_keys_map= {0.5 : set(('dcmmeta_affine', + 'dcmmeta_slice_dim', + 'dcmmeta_shape', + 'dcmmeta_version', + 'global', + ) + ), + 0.6 : set(('dcmmeta_affine', + 'dcmmeta_reorient_transform', + 'dcmmeta_slice_dim', + 'dcmmeta_shape', + 'dcmmeta_version', + 'global', + ) + ), + } +'''Minimum required keys in the base dictionaty to be considered valid''' + +def is_constant(sequence, period=None): + '''Returns true if all elements in (each period of) the sequence are equal. + + Parameters + ---------- + sequence : sequence + The sequence of elements to check. + + period : int + If not None then each subsequence of that length is checked. + ''' + if period is None: + return all(val == sequence[0] for val in sequence) + else: + if period <= 1: + raise ValueError('The period must be greater than one') + seq_len = len(sequence) + if seq_len % period != 0: + raise ValueError('The sequence length is not evenly divisible by ' + 'the period length.') + + for period_idx in range(seq_len / period): + start_idx = period_idx * period + end_idx = start_idx + period + if not all(val == sequence[start_idx] + for val in sequence[start_idx:end_idx]): + return False + + return True + +def is_repeating(sequence, period): + '''Returns true if the elements in the sequence repeat with the given + period. + + Parameters + ---------- + sequence : sequence + The sequence of elements to check. + + period : int + The period over which the elements should repeat. + ''' + seq_len = len(sequence) + if period <= 1 or period >= seq_len: + raise ValueError('The period must be greater than one and less than ' + 'the length of the sequence') + if seq_len % period != 0: + raise ValueError('The sequence length is not evenly divisible by the ' + 'period length.') + + for period_idx in range(1, seq_len / period): + start_idx = period_idx * period + end_idx = start_idx + period + if sequence[start_idx:end_idx] != sequence[:period]: + return False + + return True + +class InvalidExtensionError(Exception): + def __init__(self, msg): + '''Exception denoting than a DcmMetaExtension is invalid.''' + self.msg = msg + + def __str__(self): + return 'The extension is not valid: %s' % self.msg + + +class DcmMetaExtension(Nifti1Extension): + '''Nifti extension for storing a summary of the meta data from the source + DICOM files. + ''' + + @property + def reorient_transform(self): + '''The transformation due to reorientation of the data array. Can be + used to update directional DICOM meta data (after converting to RAS if + needed) into the same space as the affine.''' + if self.version < 0.6: + return None + if self._content['dcmmeta_reorient_transform'] is None: + return None + return np.array(self._content['dcmmeta_reorient_transform']) + + @reorient_transform.setter + def reorient_transform(self, value): + if not value is None and value.shape != (4, 4): + raise ValueError("The reorient_transform must be none or (4,4) " + "array") + if value is None: + self._content['dcmmeta_reorient_transform'] = None + else: + self._content['dcmmeta_reorient_transform'] = value.tolist() + + @property + def affine(self): + '''The affine associated with the meta data. If this differs from the + image affine, the per-slice meta data will not be used. ''' + return np.array(self._content['dcmmeta_affine']) + + @affine.setter + def affine(self, value): + if value.shape != (4, 4): + raise ValueError("Invalid shape for affine") + self._content['dcmmeta_affine'] = value.tolist() + + @property + def slice_dim(self): + '''The index of the slice dimension associated with the per-slice + meta data.''' + return self._content['dcmmeta_slice_dim'] + + @slice_dim.setter + def slice_dim(self, value): + if not value is None and not (0 <= value < 3): + raise ValueError("The slice dimension must be between zero and " + "two") + self._content['dcmmeta_slice_dim'] = value + + @property + def shape(self): + '''The shape of the data associated with the meta data. Defines the + number of values for the meta data classifications.''' + return tuple(self._content['dcmmeta_shape']) + + @shape.setter + def shape(self, value): + if not (3 <= len(value) < 6): + raise ValueError("The shape must have a length between three and " + "six") + self._content['dcmmeta_shape'][:] = value + + @property + def version(self): + '''The version of the meta data extension.''' + return self._content['dcmmeta_version'] + + @version.setter + def version(self, value): + '''Set the version of the meta data extension.''' + self._content['dcmmeta_version'] = value + + @property + def slice_normal(self): + '''The slice normal associated with the per-slice meta data.''' + slice_dim = self.slice_dim + if slice_dim is None: + return None + return np.array(self.affine[slice_dim][:3]) + + @property + def n_slices(self): + '''The number of slices associated with the per-slice meta data.''' + slice_dim = self.slice_dim + if slice_dim is None: + return None + return self.shape[slice_dim] + + classifications = (('global', 'const'), + ('global', 'slices'), + ('time', 'samples'), + ('time', 'slices'), + ('vector', 'samples'), + ('vector', 'slices'), + ) + '''The classifications used to separate meta data based on if and how the + values repeat. Each class is a tuple with a base class and a sub class.''' + + def get_valid_classes(self): + '''Return the meta data classifications that are valid for this + extension. + + Returns + ------- + valid_classes : tuple + The classifications that are valid for this extension (based on its + shape). + + ''' + shape = self.shape + n_dims = len(shape) + if n_dims == 3: + return self.classifications[:2] + elif n_dims == 4: + return self.classifications[:4] + elif n_dims == 5: + if shape[3] != 1: + return self.classifications + else: + return self.classifications[:2] + self.classifications[-2:] + else: + raise ValueError("There must be 3 to 5 dimensions.") + + def get_multiplicity(self, classification): + '''Get the number of meta data values for all meta data of the provided + classification. + + Parameters + ---------- + classification : tuple + The meta data classification. + + Returns + ------- + multiplicity : int + The number of values for any meta data of the provided + `classification`. + ''' + if not classification in self.get_valid_classes(): + raise ValueError("Invalid classification: %s" % classification) + + base, sub = classification + shape = self.shape + n_vals = 1 + if sub == 'slices': + n_vals = self.n_slices + if n_vals is None: + return 0 + if base == 'vector': + n_vals *= shape[3] + elif base == 'global': + for dim_size in shape[3:]: + n_vals *= dim_size + elif sub == 'samples': + if base == 'time': + n_vals = shape[3] + if len(shape) == 5: + n_vals *= shape[4] + elif base == 'vector': + n_vals = shape[4] + + return n_vals + + def check_valid(self): + '''Check if the extension is valid. + + Raises + ------ + InvalidExtensionError + The extension is missing required meta data or classifications, or + some element(s) have the wrong number of values for their + classification. + ''' + #Check for the required base keys in the json data + if not _req_base_keys_map[self.version] <= set(self._content): + raise InvalidExtensionError('Missing one or more required keys') + + #Check the orientation/shape/version + if self.affine.shape != (4, 4): + raise InvalidExtensionError('Affine has incorrect shape') + slice_dim = self.slice_dim + if slice_dim is not None: + if not (0 <= slice_dim < 3): + raise InvalidExtensionError('Slice dimension is not valid') + if not (3 <= len(self.shape) < 6): + raise InvalidExtensionError('Shape is not valid') + + #Check all required meta dictionaries, make sure values have correct + #multiplicity + valid_classes = self.get_valid_classes() + for classes in valid_classes: + if not classes[0] in self._content: + raise InvalidExtensionError('Missing required base ' + 'classification %s' % classes[0]) + if not classes[1] in self._content[classes[0]]: + raise InvalidExtensionError(('Missing required sub ' + 'classification %s in base ' + 'classification %s') % classes) + cls_meta = self.get_class_dict(classes) + cls_mult = self.get_multiplicity(classes) + if cls_mult == 0 and len(cls_meta) != 0: + raise InvalidExtensionError('Slice dim is None but per-slice ' + 'meta data is present') + elif cls_mult > 1: + for key, vals in cls_meta.iteritems(): + n_vals = len(vals) + if n_vals != cls_mult: + msg = (('Incorrect number of values for key %s with ' + 'classification %s, expected %d found %d') % + (key, classes, cls_mult, n_vals) + ) + raise InvalidExtensionError(msg) + + #Check that all keys are uniquely classified + for classes in valid_classes: + for other_classes in valid_classes: + if classes == other_classes: + continue + intersect = (set(self.get_class_dict(classes)) & + set(self.get_class_dict(other_classes)) + ) + if len(intersect) != 0: + raise InvalidExtensionError("One or more keys have " + "multiple classifications") + + def get_keys(self): + '''Get a list of all the meta data keys that are available.''' + keys = [] + for base_class, sub_class in self.get_valid_classes(): + keys += self._content[base_class][sub_class].keys() + return keys + + def get_classification(self, key): + '''Get the classification for the given `key`. + + Parameters + ---------- + key : str + The meta data key. + + Returns + ------- + classification : tuple or None + The classification tuple for the provided key or None if the key is + not found. + + ''' + for base_class, sub_class in self.get_valid_classes(): + if key in self._content[base_class][sub_class]: + return (base_class, sub_class) + + return None + + def get_class_dict(self, classification): + '''Get the dictionary for the given classification. + + Parameters + ---------- + classification : tuple + The meta data classification. + + Returns + ------- + meta_dict : dict + The dictionary for the provided classification. + ''' + base, sub = classification + return self._content[base][sub] + + def get_values(self, key): + '''Get all values for the provided key. + + Parameters + ---------- + key : str + The meta data key. + + Returns + ------- + values + The value or values for the given key. The number of values + returned depends on the classification (see 'get_multiplicity'). + ''' + classification = self.get_classification(key) + if classification is None: + return None + return self.get_class_dict(classification)[key] + + def get_values_and_class(self, key): + '''Get the values and the classification for the provided key. + + Parameters + ---------- + key : str + The meta data key. + + Returns + ------- + vals_and_class : tuple + None for both the value and classification if the key is not found. + + ''' + classification = self.get_classification(key) + if classification is None: + return (None, None) + return (self.get_class_dict(classification)[key], classification) + + def filter_meta(self, filter_func): + '''Filter the meta data. + + Parameters + ---------- + filter_func : callable + Must take a key and values as parameters and return True if they + should be filtered out. + + ''' + for classes in self.get_valid_classes(): + filtered = [] + curr_dict = self.get_class_dict(classes) + for key, values in curr_dict.iteritems(): + if filter_func(key, values): + filtered.append(key) + for key in filtered: + del curr_dict[key] + + def clear_slice_meta(self): + '''Clear all meta data that is per slice.''' + for base_class, sub_class in self.get_valid_classes(): + if sub_class == 'slices': + self.get_class_dict((base_class, sub_class)).clear() + + def get_subset(self, dim, idx): + '''Get a DcmMetaExtension containing a subset of the meta data. + + Parameters + ---------- + dim : int + The dimension we are taking the subset along. + + idx : int + The position on the dimension `dim` for the subset. + + Returns + ------- + result : DcmMetaExtension + A new DcmMetaExtension corresponding to the subset. + + ''' + if not 0 <= dim < 5: + raise ValueError("The argument 'dim' must be in the range [0, 5).") + + shape = self.shape + valid_classes = self.get_valid_classes() + + #Make an empty extension for the result + result_shape = list(shape) + result_shape[dim] = 1 + while result_shape[-1] == 1 and len(result_shape) > 3: + result_shape = result_shape[:-1] + result = self.make_empty(result_shape, + self.affine, + self.reorient_transform, + self.slice_dim + ) + + for src_class in valid_classes: + #Constants remain constant + if src_class == ('global', 'const'): + for key, val in self.get_class_dict(src_class).iteritems(): + result.get_class_dict(src_class)[key] = deepcopy(val) + continue + + if dim == self.slice_dim: + if src_class[1] != 'slices': + for key, vals in self.get_class_dict(src_class).iteritems(): + result.get_class_dict(src_class)[key] = deepcopy(vals) + else: + result._copy_slice(self, src_class, idx) + elif dim < 3: + for key, vals in self.get_class_dict(src_class).iteritems(): + result.get_class_dict(src_class)[key] = deepcopy(vals) + elif dim == 3: + result._copy_sample(self, src_class, 'time', idx) + else: + result._copy_sample(self, src_class, 'vector', idx) + + return result + + def to_json(self): + '''Return the extension encoded as a JSON string.''' + self.check_valid() + return self._mangle(self._content) + + @classmethod + def from_json(klass, json_str): + '''Create an extension from the JSON string representation.''' + result = klass(dcm_meta_ecode, json_str) + result.check_valid() + return result + + @classmethod + def make_empty(klass, shape, affine, reorient_transform=None, + slice_dim=None): + '''Make an empty DcmMetaExtension. + + Parameters + ---------- + shape : tuple + The shape of the data associated with this extension. + + affine : array + The RAS affine for the data associated with this extension. + + reorient_transform : array + The transformation matrix representing any reorientation of the + data array. + + slice_dim : int + The index of the slice dimension for the data associated with this + extension + + Returns + ------- + result : DcmMetaExtension + An empty DcmMetaExtension with the required values set to the + given arguments. + + ''' + result = klass(dcm_meta_ecode, '{}') + result._content['global'] = OrderedDict() + result._content['global']['const'] = OrderedDict() + result._content['global']['slices'] = OrderedDict() + + if len(shape) > 3 and shape[3] != 1: + result._content['time'] = OrderedDict() + result._content['time']['samples'] = OrderedDict() + result._content['time']['slices'] = OrderedDict() + + if len(shape) > 4: + result._content['vector'] = OrderedDict() + result._content['vector']['samples'] = OrderedDict() + result._content['vector']['slices'] = OrderedDict() + + result._content['dcmmeta_shape'] = [] + result.shape = shape + result.affine = affine + result.reorient_transform = reorient_transform + result.slice_dim = slice_dim + result.version = _meta_version + + return result + + @classmethod + def from_runtime_repr(klass, runtime_repr): + '''Create an extension from the Python runtime representation (nested + dictionaries). + ''' + result = klass(dcm_meta_ecode, '{}') + result._content = runtime_repr + result.check_valid() + return result + + @classmethod + def from_sequence(klass, seq, dim, affine=None, slice_dim=None): + '''Create an extension from a sequence of extensions. + + Parameters + ---------- + seq : sequence + The sequence of DcmMetaExtension objects. + + dim : int + The dimension to merge the extensions along. + + affine : array + The affine to use in the resulting extension. If None, the affine + from the first extension in `seq` will be used. + + slice_dim : int + The slice dimension to use in the resulting extension. If None, the + slice dimension from the first extension in `seq` will be used. + + Returns + ------- + result : DcmMetaExtension + The result of merging the extensions in `seq` along the dimension + `dim`. + ''' + if not 0 <= dim < 5: + raise ValueError("The argument 'dim' must be in the range [0, 5).") + + n_inputs = len(seq) + first_input = seq[0] + input_shape = first_input.shape + + if len(input_shape) > dim and input_shape[dim] != 1: + raise ValueError("The dim must be singular or not exist for the " + "inputs.") + + output_shape = list(input_shape) + while len(output_shape) <= dim: + output_shape.append(1) + output_shape[dim] = n_inputs + + if affine is None: + affine = first_input.affine + if slice_dim is None: + slice_dim = first_input.slice_dim + + result = klass.make_empty(output_shape, + affine, + None, + slice_dim) + + #Need to initialize the result with the first extension in 'seq' + result_slc_norm = result.slice_normal + first_slc_norm = first_input.slice_normal + use_slices = (not result_slc_norm is None and + not first_slc_norm is None and + np.allclose(result_slc_norm, first_slc_norm)) + for classes in first_input.get_valid_classes(): + if classes[1] == 'slices' and not use_slices: + continue + result._content[classes[0]][classes[1]] = \ + deepcopy(first_input.get_class_dict(classes)) + + #Adjust the shape to what the extension actually contains + shape = list(result.shape) + shape[dim] = 1 + result.shape = shape + + #Initialize reorient transform + reorient_transform = first_input.reorient_transform + + #Add the other extensions, updating the shape as we go + for input_ext in seq[1:]: + #If the affines or reorient_transforms don't match, we set the + #reorient_transform to None as we can not reliably use it to update + #directional meta data + if ((reorient_transform is None or + input_ext.reorient_transform is None) or + not (np.allclose(input_ext.affine, affine) or + np.allclose(input_ext.reorient_transform, + reorient_transform) + ) + ): + reorient_transform = None + result._insert(dim, input_ext) + shape[dim] += 1 + result.shape = shape + + #Set the reorient transform + result.reorient_transform = reorient_transform + + #Try simplifying any keys in global slices + for key in result.get_class_dict(('global', 'slices')).keys(): + result._simplify(key) + + return result + + def __str__(self): + return self._mangle(self._content) + + def __eq__(self, other): + if not np.allclose(self.affine, other.affine): + return False + if self.shape != other.shape: + return False + if self.slice_dim != other.slice_dim: + return False + if self.version != other.version: + return False + for classes in self.get_valid_classes(): + if (dict(self.get_class_dict(classes)) != + dict(other.get_class_dict(classes))): + return False + + return True + + def _unmangle(self, value): + '''Go from extension data to runtime representation.''' + #Its not possible to preserve order while loading with python 2.6 + kwargs = {} + if sys.version_info >= (2, 7): + kwargs['object_pairs_hook'] = OrderedDict + return json.loads(value, **kwargs) + + def _mangle(self, value): + '''Go from runtime representation to extension data.''' + return json.dumps(value, indent=4) + + _const_tests = {('global', 'slices') : (('global', 'const'), + ('vector', 'samples'), + ('time', 'samples') + ), + ('vector', 'slices') : (('global', 'const'), + ('time', 'samples') + ), + ('time', 'slices') : (('global', 'const'), + ), + ('time', 'samples') : (('global', 'const'), + ('vector', 'samples'), + ), + ('vector', 'samples') : (('global', 'const'),) + } + '''Classification mapping showing possible reductions in multiplicity for + values that are constant with some period.''' + + def _get_const_period(self, src_cls, dest_cls): + '''Get the period over which we test for const-ness with for the + given classification change.''' + if dest_cls == ('global', 'const'): + return None + elif src_cls == ('global', 'slices'): + return self.get_multiplicity(src_cls) / self.get_multiplicity(dest_cls) + elif src_cls == ('vector', 'slices'): #implies dest_cls == ('time', 'samples'): + return self.n_slices + elif src_cls == ('time', 'samples'): #implies dest_cls == ('vector', 'samples') + return self.shape[3] + assert False #Should take one of the above branches + + _repeat_tests = {('global', 'slices') : (('time', 'slices'), + ('vector', 'slices') + ), + ('vector', 'slices') : (('time', 'slices'),), + } + '''Classification mapping showing possible reductions in multiplicity for + values that are repeating with some period.''' + + def _simplify(self, key): + '''Try to simplify (reduce the multiplicity) of a single meta data + element by changing its classification. Return True if the + classification is changed, otherwise False. + + Looks for values that are constant or repeating with some pattern. + Constant elements with a value of None will be deleted. + ''' + values, curr_class = self.get_values_and_class(key) + + #If the class is global const then just delete it if the value is None + if curr_class == ('global', 'const'): + if values is None: + del self.get_class_dict(curr_class)[key] + return True + return False + + #Test if the values are constant with some period + dests = self._const_tests[curr_class] + for dest_cls in dests: + if dest_cls[0] in self._content: + period = self._get_const_period(curr_class, dest_cls) + #If the period is one, the two classifications have the + #same multiplicity so we are dealing with a degenerate + #case (i.e. single slice data). Just change the + #classification to the "simpler" one in this case + if period == 1 or is_constant(values, period): + if period is None: + self.get_class_dict(dest_cls)[key] = \ + values[0] + else: + self.get_class_dict(dest_cls)[key] = \ + values[::period] + break + else: #Otherwise test if values are repeating with some period + if curr_class in self._repeat_tests: + for dest_cls in self._repeat_tests[curr_class]: + if dest_cls[0] in self._content: + dest_mult = self.get_multiplicity(dest_cls) + if is_repeating(values, dest_mult): + self.get_class_dict(dest_cls)[key] = \ + values[:dest_mult] + break + else: #Can't simplify + return False + else: + return False + + del self.get_class_dict(curr_class)[key] + return True + + _preserving_changes = {None : (('global', 'const'), + ('vector', 'samples'), + ('time', 'samples'), + ('time', 'slices'), + ('vector', 'slices'), + ('global', 'slices'), + ), + ('global', 'const') : (('vector', 'samples'), + ('time', 'samples'), + ('time', 'slices'), + ('vector', 'slices'), + ('global', 'slices'), + ), + ('vector', 'samples') : (('time', 'samples'), + ('global', 'slices'), + ), + ('time', 'samples') : (('global', 'slices'), + ), + ('time', 'slices') : (('vector', 'slices'), + ('global', 'slices'), + ), + ('vector', 'slices') : (('global', 'slices'), + ), + ('global', 'slices') : tuple(), + } + '''Classification mapping showing allowed changes when increasing the + multiplicity.''' + + def _get_changed_class(self, key, new_class, slice_dim=None): + '''Get an array of values corresponding to a single meta data + element with its classification changed by increasing its + multiplicity. This will preserve all the meta data and allow easier + merging of values with different classifications.''' + values, curr_class = self.get_values_and_class(key) + if curr_class == new_class: + return values + + if not new_class in self._preserving_changes[curr_class]: + raise ValueError("Classification change would lose data.") + + if curr_class is None: + curr_mult = 1 + per_slice = False + else: + curr_mult = self.get_multiplicity(curr_class) + per_slice = curr_class[1] == 'slices' + if new_class in self.get_valid_classes(): + new_mult = self.get_multiplicity(new_class) + #Only way we get 0 for mult is if slice dim is undefined + if new_mult == 0: + new_mult = self.shape[slice_dim] + else: + new_mult = 1 + mult_fact = new_mult / curr_mult + if curr_mult == 1: + values = [values] + + + if per_slice: + result = values * mult_fact + else: + result = [] + for value in values: + result.extend([deepcopy(value)] * mult_fact) + + if new_class == ('global', 'const'): + result = result[0] + + return result + + + def _change_class(self, key, new_class): + '''Change the classification of the meta data element in place. See + _get_changed_class.''' + values, curr_class = self.get_values_and_class(key) + if curr_class == new_class: + return + + self.get_class_dict(new_class)[key] = self._get_changed_class(key, + new_class) + + if not curr_class is None: + del self.get_class_dict(curr_class)[key] + + + + def _copy_slice(self, other, src_class, idx): + '''Get a copy of the meta data from the 'other' instance with + classification 'src_class', corresponding to the slice with index + 'idx'.''' + if src_class[0] == 'global': + for classes in (('time', 'samples'), + ('vector', 'samples'), + ('global', 'const')): + if classes in self.get_valid_classes(): + dest_class = classes + break + elif src_class[0] == 'vector': + for classes in (('time', 'samples'), + ('global', 'const')): + if classes in self.get_valid_classes(): + dest_class = classes + break + else: + dest_class = ('global', 'const') + + src_dict = other.get_class_dict(src_class) + dest_dict = self.get_class_dict(dest_class) + dest_mult = self.get_multiplicity(dest_class) + stride = other.n_slices + for key, vals in src_dict.iteritems(): + subset_vals = vals[idx::stride] + + if len(subset_vals) < dest_mult: + full_vals = [] + for val_idx in xrange(dest_mult / len(subset_vals)): + full_vals += deepcopy(subset_vals) + subset_vals = full_vals + if len(subset_vals) == 1: + subset_vals = subset_vals[0] + dest_dict[key] = deepcopy(subset_vals) + self._simplify(key) + + def _global_slice_subset(self, key, sample_base, idx): + '''Get a subset of the meta data values with the classificaion + ('global', 'slices') corresponding to a single sample along the + time or vector dimension (as specified by 'sample_base' and 'idx'). + ''' + n_slices = self.n_slices + shape = self.shape + src_dict = self.get_class_dict(('global', 'slices')) + if sample_base == 'vector': + slices_per_vec = n_slices * shape[3] + start_idx = idx * slices_per_vec + end_idx = start_idx + slices_per_vec + return src_dict[key][start_idx:end_idx] + else: + if not ('vector', 'samples') in self.get_valid_classes(): + start_idx = idx * n_slices + end_idx = start_idx + n_slices + return src_dict[key][start_idx:end_idx] + else: + result = [] + slices_per_vec = n_slices * shape[3] + for vec_idx in xrange(shape[4]): + start_idx = (vec_idx * slices_per_vec) + (idx * n_slices) + end_idx = start_idx + n_slices + result.extend(src_dict[key][start_idx:end_idx]) + return result + + def _copy_sample(self, other, src_class, sample_base, idx): + '''Get a copy of meta data from 'other' instance with classification + 'src_class', corresponding to one sample along the time or vector + dimension.''' + assert src_class != ('global', 'const') + src_dict = other.get_class_dict(src_class) + if src_class[1] == 'samples': + #If we are indexing on the same dim as the src_class we need to + #change the classification + if src_class[0] == sample_base: + #Time samples may become vector samples, otherwise const + best_dest = None + for dest_cls in (('vector', 'samples'), + ('global', 'const')): + if (dest_cls != src_class and + dest_cls in self.get_valid_classes() + ): + best_dest = dest_cls + break + + dest_mult = self.get_multiplicity(dest_cls) + if dest_mult == 1: + for key, vals in src_dict.iteritems(): + self.get_class_dict(dest_cls)[key] = \ + deepcopy(vals[idx]) + else: #We must be doing time samples -> vector samples + stride = other.shape[3] + for key, vals in src_dict.iteritems(): + self.get_class_dict(dest_cls)[key] = \ + deepcopy(vals[idx::stride]) + for key in src_dict.keys(): + self._simplify(key) + + else: #Otherwise classification does not change + #The multiplicity will change for time samples if splitting + #vector dimension + if src_class == ('time', 'samples'): + dest_mult = self.get_multiplicity(src_class) + start_idx = idx * dest_mult + end_idx = start_idx + dest_mult + for key, vals in src_dict.iteritems(): + self.get_class_dict(src_class)[key] = \ + deepcopy(vals[start_idx:end_idx]) + self._simplify(key) + else: #Otherwise multiplicity is unchanged + for key, vals in src_dict.iteritems(): + self.get_class_dict(src_class)[key] = deepcopy(vals) + else: #The src_class is per slice + if src_class[0] == sample_base: + best_dest = None + for dest_class in self._preserving_changes[src_class]: + if dest_class in self.get_valid_classes(): + best_dest = dest_class + break + for key, vals in src_dict.iteritems(): + self.get_class_dict(best_dest)[key] = deepcopy(vals) + elif src_class[0] != 'global': + if sample_base == 'time': + #Take a subset of vector slices + n_slices = self.n_slices + start_idx = idx * n_slices + end_idx = start_idx + n_slices + for key, vals in src_dict.iteritems(): + self.get_class_dict(src_class)[key] = \ + deepcopy(vals[start_idx:end_idx]) + self._simplify(key) + else: + #Time slices are unchanged + for key, vals in src_dict.iteritems(): + self.get_class_dict(src_class)[key] = deepcopy(vals) + else: + #Take a subset of global slices + for key, vals in src_dict.iteritems(): + subset_vals = \ + other._global_slice_subset(key, sample_base, idx) + self.get_class_dict(src_class)[key] = deepcopy(subset_vals) + self._simplify(key) + + def _insert(self, dim, other): + self_slc_norm = self.slice_normal + other_slc_norm = other.slice_normal + + #If we are not using slice meta data, temporarily remove it from the + #other dcmmeta object + use_slices = (not self_slc_norm is None and + not other_slc_norm is None and + np.allclose(self_slc_norm, other_slc_norm)) + other_slc_meta = {} + if not use_slices: + for classes in other.get_valid_classes(): + if classes[1] == 'slices': + other_slc_meta[classes] = other.get_class_dict(classes) + other._content[classes[0]][classes[1]] = {} + missing_keys = list(set(self.get_keys()) - set(other.get_keys())) + for other_classes in other.get_valid_classes(): + other_keys = other.get_class_dict(other_classes).keys() + + #Treat missing keys as if they were in global const and have a value + #of None + if other_classes == ('global', 'const'): + other_keys += missing_keys + + #When possible, reclassify our meta data so it matches the other + #classification + for key in other_keys: + local_classes = self.get_classification(key) + if local_classes != other_classes: + local_allow = self._preserving_changes[local_classes] + other_allow = self._preserving_changes[other_classes] + + if other_classes in local_allow: + self._change_class(key, other_classes) + elif not local_classes in other_allow: + best_dest = None + for dest_class in local_allow: + if (dest_class[0] in self._content and + dest_class in other_allow): + best_dest = dest_class + break + self._change_class(key, best_dest) + + #Insert new meta data and further reclassify as necessary + for key in other_keys: + if dim == self.slice_dim: + self._insert_slice(key, other) + elif dim < 3: + self._insert_non_slice(key, other) + elif dim == 3: + self._insert_sample(key, other, 'time') + elif dim == 4: + self._insert_sample(key, other, 'vector') + + #Restore per slice meta if needed + if not use_slices: + for classes in other.get_valid_classes(): + if classes[1] == 'slices': + other._content[classes[0]][classes[1]] = \ + other_slc_meta[classes] + + def _insert_slice(self, key, other): + local_vals, classes = self.get_values_and_class(key) + other_vals = other._get_changed_class(key, classes, self.slice_dim) + + + #Handle some common / simple insertions with special cases + if classes == ('global', 'const'): + if local_vals != other_vals: + for dest_base in ('time', 'vector', 'global'): + if dest_base in self._content: + self._change_class(key, (dest_base, 'slices')) + other_vals = other._get_changed_class(key, + (dest_base, + 'slices'), + self.slice_dim + ) + self.get_values(key).extend(other_vals) + break + elif classes == ('time', 'slices'): + local_vals.extend(other_vals) + else: + #Default to putting in global slices and simplifying later + if classes != ('global', 'slices'): + self._change_class(key, ('global', 'slices')) + local_vals = self.get_class_dict(('global', 'slices'))[key] + other_vals = other._get_changed_class(key, + ('global', 'slices'), + self.slice_dim) + + #Need to interleave slices from different volumes + n_slices = self.n_slices + other_n_slices = other.n_slices + shape = self.shape + n_vols = 1 + for dim_size in shape[3:]: + n_vols *= dim_size + + intlv = [] + loc_start = 0 + oth_start = 0 + for vol_idx in xrange(n_vols): + intlv += local_vals[loc_start:loc_start + n_slices] + intlv += other_vals[oth_start:oth_start + other_n_slices] + loc_start += n_slices + oth_start += other_n_slices + + self.get_class_dict(('global', 'slices'))[key] = intlv + + def _insert_non_slice(self, key, other): + local_vals, classes = self.get_values_and_class(key) + other_vals = other._get_changed_class(key, classes, self.slice_dim) + + if local_vals != other_vals: + del self.get_class_dict(classes)[key] + + def _insert_sample(self, key, other, sample_base): + local_vals, classes = self.get_values_and_class(key) + other_vals = other._get_changed_class(key, classes, self.slice_dim) + + if classes == ('global', 'const'): + if local_vals != other_vals: + self._change_class(key, (sample_base, 'samples')) + local_vals = self.get_values(key) + other_vals = other._get_changed_class(key, + (sample_base, 'samples'), + self.slice_dim + ) + local_vals.extend(other_vals) + elif classes == (sample_base, 'samples'): + local_vals.extend(other_vals) + else: + if classes != ('global', 'slices'): + self._change_class(key, ('global', 'slices')) + local_vals = self.get_values(key) + other_vals = other._get_changed_class(key, + ('global', 'slices'), + self.slice_dim) + + shape = self.shape + n_dims = len(shape) + if sample_base == 'time' and n_dims == 5: + #Need to interleave values from the time points in each vector + #component + n_slices = self.n_slices + slices_per_vec = n_slices * shape[3] + oth_slc_per_vec = n_slices * other.shape[3] + + intlv = [] + loc_start = 0 + oth_start = 0 + for vec_idx in xrange(shape[4]): + intlv += local_vals[loc_start:loc_start+slices_per_vec] + intlv += other_vals[oth_start:oth_start+oth_slc_per_vec] + loc_start += slices_per_vec + oth_start += oth_slc_per_vec + + self.get_class_dict(('global', 'slices'))[key] = intlv + else: + local_vals.extend(other_vals) + +#Add our extension to nibabel +nb.nifti1.extension_codes.add_codes(((dcm_meta_ecode, + "dcmmeta", + DcmMetaExtension),) + ) + +class MissingExtensionError(Exception): + '''Exception denoting that there is no DcmMetaExtension in the Nifti header. + ''' + def __str__(self): + return 'No dcmmeta extension found.' + +def patch_dcm_ds_is(dcm): + '''Convert all elements in `dcm` with VR of 'DS' or 'IS' to floats and ints. + This is a hackish work around for the backwards incompatibility of pydicom + 0.9.7 and should not be needed once nibabel is updated. + ''' + for elem in dcm: + if elem.VM == 1: + if elem.VR in ('DS', 'IS'): + if elem.value == '': + continue + if elem.VR == 'DS': + elem.VR = 'FD' + elem.value = float(elem.value) + else: + elem.VR = 'SL' + elem.value = int(elem.value) + else: + if elem.VR in ('DS', 'IS'): + if elem.value == '': + continue + if elem.VR == 'DS': + elem.VR = 'FD' + elem.value = [float(val) for val in elem.value] + else: + elem.VR = 'SL' + elem.value = [int(val) for val in elem.value] + + +class NiftiWrapper(object): + '''Wraps a Nifti1Image object containing a DcmMeta header extension. + Provides access to the meta data and the ability to split or merge the + data array while updating the meta data. + + Parameters + ---------- + nii_img : nibabel.nifti1.Nifti1Image + The Nifti1Image to wrap. + + make_empty : bool + If True an empty DcmMetaExtension will be created if none is found. + + Raises + ------ + MissingExtensionError + No valid DcmMetaExtension was found. + + ValueError + More than one valid DcmMetaExtension was found. + ''' + + def __init__(self, nii_img, make_empty=False): + self.nii_img = nii_img + hdr = nii_img.get_header() + self.meta_ext = None + for extension in hdr.extensions: + if extension.get_code() == dcm_meta_ecode: + try: + extension.check_valid() + except InvalidExtensionError as e: + print("Found candidate extension, but invalid: %s" % e) + else: + if not self.meta_ext is None: + raise ValueError('More than one valid DcmMeta ' + 'extension found.') + self.meta_ext = extension + if not self.meta_ext: + if make_empty: + slice_dim = hdr.get_dim_info()[2] + self.meta_ext = \ + DcmMetaExtension.make_empty(self.nii_img.shape, + hdr.get_best_affine(), + None, + slice_dim) + hdr.extensions.append(self.meta_ext) + else: + raise MissingExtensionError + self.meta_ext.check_valid() + + def __getitem__(self, key): + '''Get the value for the given meta data key. Only considers meta data + that is globally constant. To access varying meta data you must use the + method 'get_meta'.''' + return self.meta_ext.get_class_dict(('global', 'const'))[key] + + def meta_valid(self, classification): + '''Return true if the meta data with the given classification appears + to be valid for the wrapped Nifti image. Considers the shape and + orientation of the image and the meta data extension.''' + if classification == ('global', 'const'): + return True + + img_shape = self.nii_img.get_shape() + meta_shape = self.meta_ext.shape + if classification == ('vector', 'samples'): + return meta_shape[4:] == img_shape[4:] + if classification == ('time', 'samples'): + return meta_shape[3:] == img_shape[3:] + + hdr = self.nii_img.get_header() + if self.meta_ext.n_slices != hdr.get_n_slices(): + return False + + slice_dim = hdr.get_dim_info()[2] + slice_dir = self.nii_img.get_affine()[slice_dim, :3] + slices_aligned = np.allclose(slice_dir, + self.meta_ext.slice_normal, + atol=1e-6) + + if classification == ('time', 'slices'): + return slices_aligned + if classification == ('vector', 'slices'): + return meta_shape[3] == img_shape[3] and slices_aligned + if classification == ('global', 'slices'): + return meta_shape[3:] == img_shape[3:] and slices_aligned + + def get_meta(self, key, index=None, default=None): + '''Return the meta data value for the provided `key`. + + Parameters + ---------- + key : str + The meta data key. + + index : tuple + The voxel index we are interested in. + + default + This will be returned if the meta data for `key` is not found. + + Returns + ------- + value + The meta data value for the given `key` (and optionally `index`) + + Notes + ----- + The per-sample and per-slice meta data will only be considered if the + `samples_valid` and `slices_valid` methods return True (respectively), + and an `index` is specified. + ''' + #Get the value(s) and classification for the key + values, classes = self.meta_ext.get_values_and_class(key) + if classes is None: + return default + + #Check if the value is constant + if classes == ('global', 'const'): + return values + + #Check if the classification is valid + if not self.meta_valid(classes): + return default + + #If an index is provided check the varying values + if not index is None: + #Test if the index is valid + shape = self.nii_img.get_shape() + if len(index) != len(shape): + raise IndexError('Incorrect number of indices.') + for dim, ind_val in enumerate(index): + if not 0 <= ind_val < shape[dim]: + raise IndexError('Index is out of bounds.') + + #First try per time/vector sample values + if classes == ('time', 'samples'): + return values[index[3]] + if classes == ('vector', 'samples'): + return values[index[4]] + + #Finally, if aligned, try per-slice values + slice_dim = self.nii_img.get_header().get_dim_info()[2] + n_slices = shape[slice_dim] + if classes == ('global', 'slices'): + val_idx = index[slice_dim] + for count, idx_val in enumerate(index[3:]): + val_idx += idx_val * n_slices + n_slices *= shape[count+3] + return values[val_idx] + elif classes == ('time', 'slices'): + val_idx = index[slice_dim] + return values[val_idx] + elif classes == ('vector', 'slices'): + val_idx = index[slice_dim] + val_idx += index[3]*n_slices + return values[val_idx] + + return default + + def remove_extension(self): + '''Remove the DcmMetaExtension from the header of nii_img. The + attribute `meta_ext` will still point to the extension.''' + hdr = self.nii_img.get_header() + target_idx = None + for idx, ext in enumerate(hdr.extensions): + if id(ext) == id(self.meta_ext): + target_idx = idx + break + else: + raise IndexError('Extension not found in header') + del hdr.extensions[target_idx] + # Nifti1Image.update_header will increase this if necessary + hdr['vox_offset'] = 0 + + def replace_extension(self, dcmmeta_ext): + '''Replace the DcmMetaExtension. + + Parameters + ---------- + dcmmeta_ext : DcmMetaExtension + The new DcmMetaExtension. + + ''' + self.remove_extension() + self.nii_img.get_header().extensions.append(dcmmeta_ext) + self.meta_ext = dcmmeta_ext + + def split(self, dim=None): + '''Generate splits of the array and meta data along the specified + dimension. + + Parameters + ---------- + dim : int + The dimension to split the voxel array along. If None it will + prefer the vector, then time, then slice dimensions. + + Returns + ------- + result + Generator which yields a NiftiWrapper result for each index + along `dim`. + + ''' + shape = self.nii_img.get_shape() + data = self.nii_img.get_data() + header = self.nii_img.get_header() + slice_dim = header.get_dim_info()[2] + + #If dim is None, choose the vector/time/slice dim in that order + if dim is None: + dim = len(shape) - 1 + if dim == 2: + if slice_dim is None: + raise ValueError("Slice dimension is not known") + dim = slice_dim + + #If we are splitting on a spatial dimension, we need to update the + #translation + trans_update = None + if dim < 3: + trans_update = header.get_best_affine()[:3, dim] + + split_hdr = header.copy() + slices = [slice(None)] * len(shape) + for idx in xrange(shape[dim]): + #Grab the split data, get rid of trailing singular dimensions + if dim >= 3 and dim == len(shape) - 1: + slices[dim] = idx + else: + slices[dim] = slice(idx, idx+1) + + split_data = data[slices].copy() + + #Update the translation in any affines if needed + if not trans_update is None and idx != 0: + qform = split_hdr.get_qform() + if not qform is None: + qform[:3, 3] += trans_update + split_hdr.set_qform(qform) + sform = split_hdr.get_sform() + if not sform is None: + sform[:3, 3] += trans_update + split_hdr.set_sform(sform) + + #Create the initial Nifti1Image object + split_nii = nb.Nifti1Image(split_data, + split_hdr.get_best_affine(), + header=split_hdr) + + #Replace the meta data with the appropriate subset + meta_dim = dim + if dim == slice_dim: + meta_dim = self.meta_ext.slice_dim + split_meta = self.meta_ext.get_subset(meta_dim, idx) + result = NiftiWrapper(split_nii) + result.replace_extension(split_meta) + + yield result + + def to_filename(self, out_path): + '''Write out the wrapped Nifti to a file + + Parameters + ---------- + out_path : str + The path to write out the file to + + Notes + ----- + Will check that the DcmMetaExtension is valid before writing the file. + ''' + self.meta_ext.check_valid() + self.nii_img.to_filename(out_path) + + @classmethod + def from_filename(klass, path): + '''Create a NiftiWrapper from a file. + + Parameters + ---------- + path : str + The path to the Nifti file to load. + ''' + return klass(nb.load(path)) + + @classmethod + def from_dicom_wrapper(klass, dcm_wrp, meta_dict=None): + '''Create a NiftiWrapper from a nibabel DicomWrapper. + + Parameters + ---------- + dcm_wrap : nicom.dicomwrappers.DicomWrapper + The dataset to convert into a NiftiWrapper. + + meta_dict : dict + An optional dictionary of meta data extracted from `dcm_data`. See + the `extract` module for generating this dict. + + ''' + data = dcm_wrp.get_data() + + #The Nifti patient space flips the x and y directions + affine = np.dot(np.diag([-1., -1., 1., 1.]), dcm_wrp.get_affine()) + + #Make 2D data 3D + if len(data.shape) == 2: + data = data.reshape(data.shape + (1,)) + + #Create the nifti image and set header data + nii_img = nb.nifti1.Nifti1Image(data, affine) + hdr = nii_img.get_header() + hdr.set_xyzt_units('mm', 'sec') + dim_info = {'freq' : None, + 'phase' : None, + 'slice' : 2 + } + if hasattr(dcm_wrp.dcm_data, 'InplanePhaseEncodingDirection'): + if dcm_wrp['InplanePhaseEncodingDirection'] == 'ROW': + dim_info['phase'] = 1 + dim_info['freq'] = 0 + else: + dim_info['phase'] = 0 + dim_info['freq'] = 1 + hdr.set_dim_info(**dim_info) + + #Embed the meta data extension + result = klass(nii_img, make_empty=True) + + result.meta_ext.reorient_transform = np.eye(4) + if meta_dict: + result.meta_ext.get_class_dict(('global', 'const')).update(meta_dict) + + return result + + @classmethod + def from_dicom(klass, dcm_data, meta_dict=None): + '''Create a NiftiWrapper from a single DICOM dataset. + + Parameters + ---------- + dcm_data : dicom.dataset.Dataset + The DICOM dataset to convert into a NiftiWrapper. + + meta_dict : dict + An optional dictionary of meta data extracted from `dcm_data`. See + the `extract` module for generating this dict. + + ''' + dcm_wrp = wrapper_from_data(dcm_data) + return klass.from_dicom_wrapper(dcm_wrp, meta_dict) + + @classmethod + def from_sequence(klass, seq, dim=None): + '''Create a NiftiWrapper by joining a sequence of NiftiWrapper objects + along the given dimension. + + Parameters + ---------- + seq : sequence + The sequence of NiftiWrapper objects. + + dim : int + The dimension to join the NiftiWrapper objects along. If None, + 2D inputs will become 3D, 3D inputs will become 4D, and 4D inputs + will become 5D. + + Returns + ------- + result : NiftiWrapper + The merged NiftiWrapper with updated meta data. + ''' + n_inputs = len(seq) + first_input = seq[0] + first_nii = first_input.nii_img + first_hdr = first_nii.get_header() + shape = first_nii.shape + affine = first_nii.get_affine().copy() + + #If dim is None, choose a sane default + if dim is None: + if len(shape) == 3: + singular_dim = None + for dim_idx, dim_size in enumerate(shape): + if dim_size == 1: + singular_dim = dim_idx + if singular_dim is None: + dim = 3 + else: + dim = singular_dim + if len(shape) == 4: + dim = 4 + else: + if not 0 <= dim < 5: + raise ValueError("The argument 'dim' must be in the range " + "[0, 5).") + if dim < len(shape) and shape[dim] != 1: + raise ValueError('The dimension must be singular or not exist') + + #Pull out the three axes vectors for validation of other input affines + axes = [] + for axis_idx in xrange(3): + axis_vec = affine[:3, axis_idx] + if axis_idx == dim: + axis_vec = axis_vec.copy() + axis_vec /= np.sqrt(np.dot(axis_vec, axis_vec)) + axes.append(axis_vec) + #Pull out the translation + trans = affine[:3, 3] + + #Determine the shape of the result data array and create it + result_shape = list(shape) + while dim >= len(result_shape): + result_shape.append(1) + result_shape[dim] = n_inputs + + result_dtype = max(input_wrp.nii_img.get_data().dtype + for input_wrp in seq) + result_data = np.empty(result_shape, dtype=result_dtype) + + #Start with the header info from the first input + hdr_info = {'qform' : first_hdr.get_qform(), + 'qform_code' : first_hdr['qform_code'], + 'sform' : first_hdr.get_sform(), + 'sform_code' : first_hdr['sform_code'], + 'dim_info' : list(first_hdr.get_dim_info()), + 'xyzt_units' : list(first_hdr.get_xyzt_units()), + } + + try: + hdr_info['slice_duration'] = first_hdr.get_slice_duration() + except HeaderDataError: + hdr_info['slice_duration'] = None + try: + hdr_info['intent'] = first_hdr.get_intent() + except HeaderDataError: + hdr_info['intent'] = None + try: + hdr_info['slice_times'] = first_hdr.get_slice_times() + except HeaderDataError: + hdr_info['slice_times'] = None + + #Fill the data array, check header consistency + data_slices = [slice(None)] * len(result_shape) + for dim_idx, dim_size in enumerate(result_shape): + if dim_size == 1: + data_slices[dim_idx] = 0 + last_trans = None #Keep track of the translation from last input + for input_idx in range(n_inputs): + + input_wrp = seq[input_idx] + input_nii = input_wrp.nii_img + input_aff = input_nii.get_affine() + input_hdr = input_nii.get_header() + + #Check that the affines match appropriately + for axis_idx, axis_vec in enumerate(axes): + in_vec = input_aff[:3, axis_idx] + + #If we are joining on this dimension + if axis_idx == dim: + #Allow scaling difference as it will be updated later + in_vec = in_vec.copy() + in_vec /= np.sqrt(np.dot(in_vec, in_vec)) + + in_trans = input_aff[:3, 3] + if not last_trans is None: + #Must be translated along the axis + trans_diff = in_trans - last_trans + if not np.allclose(trans_diff, 0.0): + trans_diff /= np.sqrt(np.dot(trans_diff, trans_diff)) + + if (np.allclose(trans_diff, 0.0) or + not np.allclose(np.dot(trans_diff, in_vec), + 1.0, + atol=1e-6) + ): + raise ValueError("Slices must be translated along the " + "normal direction") + + #Update reference to last translation + last_trans = in_trans + + #Check that axis vectors match + if not np.allclose(in_vec, axis_vec, atol=5e-4): + raise ValueError("Cannot join images with different " + "orientations.") + + + data_slices[dim] = input_idx + result_data[data_slices] = input_nii.get_data().squeeze() + + if input_idx != 0: + if (hdr_info['qform'] is None or + input_hdr.get_qform() is None or + not np.allclose(input_hdr.get_qform(), hdr_info['qform']) + ): + hdr_info['qform'] = None + if input_hdr['qform_code'] != hdr_info['qform_code']: + hdr_info['qform_code'] = None + if (hdr_info['sform'] is None or + input_hdr.get_sform() is None or + not np.allclose(input_hdr.get_sform(), hdr_info['sform']) + ): + hdr_info['sform'] = None + if input_hdr['sform_code'] != hdr_info['sform_code']: + hdr_info['sform_code'] = None + in_dim_info = list(input_hdr.get_dim_info()) + if in_dim_info != hdr_info['dim_info']: + for idx in xrange(3): + if in_dim_info[idx] != hdr_info['dim_info'][idx]: + hdr_info['dim_info'][idx] = None + in_xyzt_units = list(input_hdr.get_xyzt_units()) + if in_xyzt_units != hdr_info['xyzt_units']: + for idx in xrange(2): + if in_xyzt_units[idx] != hdr_info['xyzt_units'][idx]: + hdr_info['xyzt_units'][idx] = None + + try: + if input_hdr.get_slice_duration() != hdr_info['slice_duration']: + hdr_info['slice_duration'] = None + except HeaderDataError: + hdr_info['slice_duration'] = None + try: + if input_hdr.get_intent() != hdr_info['intent']: + hdr_info['intent'] = None + except HeaderDataError: + hdr_info['intent'] = None + try: + if input_hdr.get_slice_times() != hdr_info['slice_times']: + hdr_info['slice_times'] = None + except HeaderDataError: + hdr_info['slice_times'] = None + + #If we joined along a spatial dim, rescale the appropriate axis + scaled_dim_dir = None + if dim < 3: + scaled_dim_dir = seq[1].nii_img.get_affine()[:3, 3] - trans + affine[:3, dim] = scaled_dim_dir + + #Create the resulting Nifti and wrapper + result_nii = nb.Nifti1Image(result_data, affine) + result_hdr = result_nii.get_header() + + #Update the header with any info that is consistent across inputs + if hdr_info['qform'] is not None and hdr_info['qform_code'] is not None: + if not scaled_dim_dir is None: + hdr_info['qform'][:3, dim] = scaled_dim_dir + result_nii.set_qform(hdr_info['qform'], + int(hdr_info['qform_code']), + update_affine=True) + if hdr_info['sform'] is not None and hdr_info['sform_code'] is not None: + if not scaled_dim_dir is None: + hdr_info['sform'][:3, dim] = scaled_dim_dir + result_nii.set_sform(hdr_info['sform'], + int(hdr_info['sform_code']), + update_affine=True) + if hdr_info['dim_info'] is not None: + result_hdr.set_dim_info(*hdr_info['dim_info']) + slice_dim = hdr_info['dim_info'][2] + else: + slice_dim = None + if hdr_info['intent'] is not None: + result_hdr.set_intent(*hdr_info['intent']) + if hdr_info['xyzt_units'] is not None: + result_hdr.set_xyzt_units(*hdr_info['xyzt_units']) + if hdr_info['slice_duration'] is not None: + result_hdr.set_slice_duration(hdr_info['slice_duration']) + if hdr_info['slice_times'] is not None: + result_hdr.set_slice_times(hdr_info['slice_times']) + + #Create the meta data extension and insert it + seq_exts = [elem.meta_ext for elem in seq] + result_ext = DcmMetaExtension.from_sequence(seq_exts, + dim, + affine, + slice_dim) + result_hdr.extensions.append(result_ext) + + return NiftiWrapper(result_nii) diff --git a/fsl/data/dcmstack/dcmstack.py b/fsl/data/dcmstack/dcmstack.py new file mode 100644 index 000000000..ee7d93f7c --- /dev/null +++ b/fsl/data/dcmstack/dcmstack.py @@ -0,0 +1,1162 @@ +""" +Stack DICOM datasets into volumes. The contents of this module are imported +into the package namespace. +""" +import warnings, re, dicom +from copy import deepcopy +import nibabel as nb +from nibabel.nifti1 import Nifti1Extensions +from nibabel.spatialimages import HeaderDataError +from nibabel.orientations import (io_orientation, + apply_orientation, + inv_ornt_aff) +import numpy as np +from .dcmmeta import DcmMetaExtension, NiftiWrapper + +with warnings.catch_warnings(): + warnings.simplefilter('ignore') + from nibabel.nicom.dicomwrappers import wrapper_from_data + +def make_key_regex_filter(exclude_res, force_include_res=None): + '''Make a meta data filter using regular expressions. + + Parameters + ---------- + exclude_res : sequence + Sequence of regular expression strings. Any meta data where the key + matches one of these expressions will be excluded, unless it matches + one of the `force_include_res`. + force_include_res : sequence + Sequence of regular expression strings. Any meta data where the key + matches one of these expressions will be included. + + Returns + ------- + A callable which can be passed to `DicomStack` as the `meta_filter`. + ''' + exclude_re = re.compile('|'.join(['(?:' + regex + ')' + for regex in exclude_res]) + ) + include_re = None + if force_include_res: + include_re = re.compile('|'.join(['(?:' + regex + ')' + for regex in force_include_res]) + ) + + def key_regex_filter(key, value): + return (exclude_re.search(key) and + not (include_re and include_re.search(key))) + return key_regex_filter + +default_key_excl_res = ['Patient', + 'Physician', + 'Operator', + 'Date', + 'Birth', + 'Address', + 'Institution', + 'Station', + 'SiteName', + 'Age', + 'Comment', + 'Phone', + 'Telephone', + 'Insurance', + 'Religious', + 'Language', + 'Military', + 'MedicalRecord', + 'Ethnic', + 'Occupation', + 'Unknown', + 'PrivateTagData', + 'UID', + 'StudyDescription', + 'DeviceSerialNumber', + 'ReferencedImageSequence', + 'RequestedProcedureDescription', + 'PerformedProcedureStepDescription', + 'PerformedProcedureStepID', + ] +'''A list of regexes passed to `make_key_regex_filter` as `exclude_res` to +create the `default_meta_filter`.''' + +default_key_incl_res = ['ImageOrientationPatient', + 'ImagePositionPatient', + ] +'''A list of regexes passed to `make_key_regex_filter` as `force_include_res` +to create the `default_meta_filter`.''' + +default_meta_filter = make_key_regex_filter(default_key_excl_res, + default_key_incl_res) +'''Default meta_filter for `DicomStack`.''' + +def ornt_transform(start_ornt, end_ornt): + '''Return the orientation that transforms from `start_ornt` to `end_ornt`. + + Parameters + ---------- + start_ornt : (n,2) orientation array + Initial orientation. + + end_ornt : (n,2) orientation array + Final orientation. + + Returns + ------- + orientations : (p, 2) ndarray + The orientation that will transform the `start_ornt` to the `end_ornt`. + ''' + start_ornt = np.asarray(start_ornt) + end_ornt = np.asarray(end_ornt) + if start_ornt.shape != end_ornt.shape: + raise ValueError("The orientations must have the same shape") + if start_ornt.shape[1] != 2: + raise ValueError("Invalid shape for an orientation: %s" % + start_ornt.shape) + result = np.empty_like(start_ornt) + for end_in_idx, (end_out_idx, end_flip) in enumerate(end_ornt): + for start_in_idx, (start_out_idx, start_flip) in enumerate(start_ornt): + if end_out_idx == start_out_idx: + if start_flip == end_flip: + flip = 1 + else: + flip = -1 + result[start_in_idx, :] = [end_in_idx, flip] + break + else: + raise ValueError("Unable to find out axis %d in start_ornt" % + end_out_idx) + return result + +def axcodes2ornt(axcodes, labels=None): + """ Convert axis codes `axcodes` to an orientation + + Parameters + ---------- + axcodes : (N,) tuple + axis codes - see ornt2axcodes docstring + labels : optional, None or sequence of (2,) sequences + (2,) sequences are labels for (beginning, end) of output axis. That + is, if the first element in `axcodes` is ``front``, and the second + (2,) sequence in `labels` is ('back', 'front') then the first + row of `ornt` will be ``[1, 1]``. If None, equivalent to + ``(('L','R'),('P','A'),('I','S'))`` - that is - RAS axes. + + Returns + ------- + ornt : (N,2) array-like + oritation array - see io_orientation docstring + + Examples + -------- + >>> axcodes2ornt(('F', 'L', 'U'), (('L','R'),('B','F'),('D','U'))) + [[1, 1],[0,-1],[2,1]] + """ + + if labels is None: + labels = zip('LPI', 'RAS') + + n_axes = len(axcodes) + ornt = np.ones((n_axes, 2), dtype=np.int8) * np.nan + for code_idx, code in enumerate(axcodes): + for label_idx, codes in enumerate(labels): + if code is None: + continue + if code in codes: + if code == codes[0]: + ornt[code_idx, :] = [label_idx, -1] + else: + ornt[code_idx, :] = [label_idx, 1] + break + return ornt + +def reorder_voxels(vox_array, affine, voxel_order): + '''Reorder the given voxel array and corresponding affine. + + Parameters + ---------- + vox_array : array + The array of voxel data + + affine : array + The affine for mapping voxel indices to Nifti patient space + + voxel_order : str + A three character code specifing the desired ending point for rows, + columns, and slices in terms of the orthogonal axes of patient space: + (l)eft, (r)ight, (a)nterior, (p)osterior, (s)uperior, and (i)nferior. + + Returns + ------- + out_vox : array + An updated view of vox_array. + + out_aff : array + A new array with the updated affine + + reorient_transform : array + The transform used to update the affine. + + ornt_trans : tuple + The orientation transform used to update the orientation. + + ''' + #Check if voxel_order is valid + voxel_order = voxel_order.upper() + if len(voxel_order) != 3: + raise ValueError('The voxel_order must contain three characters') + dcm_axes = ['LR', 'AP', 'SI'] + for char in voxel_order: + if not char in 'LRAPSI': + raise ValueError('The characters in voxel_order must be one ' + 'of: L,R,A,P,I,S') + for idx, axis in enumerate(dcm_axes): + if char in axis: + del dcm_axes[idx] + if len(dcm_axes) != 0: + raise ValueError('No character in voxel_order corresponding to ' + 'axes: %s' % dcm_axes) + + #Check the vox_array and affine have correct shape/size + if len(vox_array.shape) < 3: + raise ValueError('The vox_array must be at least three dimensional') + if affine.shape != (4, 4): + raise ValueError('The affine must be 4x4') + + #Pull the current index directions from the affine + orig_ornt = io_orientation(affine) + new_ornt = axcodes2ornt(voxel_order) + ornt_trans = ornt_transform(orig_ornt, new_ornt) + orig_shape = vox_array.shape + vox_array = apply_orientation(vox_array, ornt_trans) + aff_trans = inv_ornt_aff(ornt_trans, orig_shape) + affine = np.dot(affine, aff_trans) + + return (vox_array, affine, aff_trans, ornt_trans) + +def dcm_time_to_sec(time_str): + '''Convert a DICOM time value (value representation of 'TM') to the number + of seconds past midnight. + + Parameters + ---------- + time_str : str + The DICOM time value string + + Returns + ------- + A floating point representing the number of seconds past midnight + ''' + #Allow ACR/NEMA style format by removing any colon chars + time_str = time_str.replace(':', '') + + #Only the hours portion is required + result = int(time_str[:2]) * 3600 + + str_len = len(time_str) + if str_len > 2: + result += int(time_str[2:4]) * 60 + if str_len > 4: + result += float(time_str[4:]) + + return float(result) + +class IncongruentImageError(Exception): + def __init__(self, msg): + '''An exception denoting that a DICOM with incorrect size or orientation + was passed to `DicomStack.add_dcm`.''' + self.msg = msg + + def __str__(self): + return 'The image is not congruent to the existing stack: %s' % self.msg + +class ImageCollisionError(Exception): + '''An exception denoting that a DICOM which collides with one already in + the stack was passed to a `DicomStack.add_dcm`.''' + def __str__(self): + return 'The image collides with one already in the stack' + +class InvalidStackError(Exception): + def __init__(self, msg): + '''An exception denoting that a `DicomStack` is not currently valid''' + self.msg = msg + + def __str__(self): + return 'The DICOM stack is not valid: %s' % self.msg + +class DicomOrdering(object): + '''Object defining an ordering for a set of dicom datasets. Create a + DicomOrdering with the given DICOM element keyword. + + Parameters + ---------- + key : str + The DICOM keyword to use for ordering the datasets + + abs_ordering : sequence + A sequence specifying the absolute order for values corresponding + to the `key`. Instead of ordering by the value associated with the + `key`, the index of the value in this sequence will be used. + + abs_as_str : bool + If true, the values will be converted to strings before looking up + the index in `abs_ordering`. + ''' + + def __init__(self, key, abs_ordering=None, abs_as_str=False): + self.key = key + self.abs_ordering = abs_ordering + self.abs_as_str = abs_as_str + + def get_ordinate(self, ds): + '''Get the ordinate for the given DICOM data set. + + Parameters + ---------- + ds : dict like + The DICOM data set we want the ordinate of. Should allow + dict like access where DICOM keywords return the corresponing + value. + + Returns + ------- + An ordinate for the data set. If `abs_ordering` is None then this will + just be the value for the keyword `key`. Otherwise it will be an + integer. + ''' + try: + val = ds[self.key] + except KeyError: + return None + + if self.abs_ordering: + if self.abs_as_str: + val = str(val) + return self.abs_ordering.index(val) + + return val + +def _make_dummy(reference, meta, iop): + '''Make a "dummy" NiftiWrapper (no valid pixel data).''' + #Create the dummy data array filled with largest representable value + data = np.empty_like(reference.nii_img.get_data()) + data[...] = np.iinfo(np.int16).max + + #Create the nifti image and set header data + aff = reference.nii_img.get_affine().copy() + aff[:3, 3] = [iop[1], iop[0], iop[2]] + nii_img = nb.nifti1.Nifti1Image(data, aff) + hdr = nii_img.get_header() + hdr.set_xyzt_units('mm', 'sec') + dim_info = {'freq' : None, + 'phase' : None, + 'slice' : 2 + } + if 'InplanePhaseEncodingDirection' in meta: + if meta['InplanePhaseEncodingDirection'] == 'ROW': + dim_info['phase'] = 1 + dim_info['freq'] = 0 + else: + dim_info['phase'] = 0 + dim_info['freq'] = 1 + hdr.set_dim_info(**dim_info) + + #Embed the meta data extension + result = NiftiWrapper(nii_img, make_empty=True) + result.meta_ext.reorient_transform = np.diag([-1., -1., 1., 1.]) + result.meta_ext.get_class_dict(('global', 'const')).update(meta) + + return result + +default_group_keys = ('SeriesInstanceUID', + 'SeriesNumber', + 'ProtocolName', + 'ImageOrientationPatient') +'''Default keys for grouping DICOM files that belong in the same +multi-dimensional array together.''' + +class DicomStack(object): + '''Defines a method for stacking together DICOM data sets into a multi + dimensional volume. + + Tailored towards creating NiftiImage output, but can also just create numpy + arrays. Can summarize all of the meta data from the input DICOM data sets + into a Nifti header extension (see `dcmmeta.DcmMetaExtension`). + + Parameters + ---------- + time_order : str or DicomOrdering + The DICOM keyword or DicomOrdering object specifying how to order + the DICOM data sets along the time dimension. + + vector_order : str or DicomOrdering + The DICOM keyword or DicomOrdering object specifying how to order + the DICOM data sets along the vector dimension. + + allow_dummies : bool + If True then data sets without pixel data can be added to the stack. + The "dummy" voxels will have the maximum representable value for + the datatype. + + meta_filter : callable + A callable that takes a meta data key and value, and returns True if + that meta data element should be excluded from the DcmMeta extension. + + Notes + ----- + If both time_order and vector_order are None, the time_order will be + guessed based off the data sets. + ''' + + sort_guesses = ['EchoTime', + 'InversionTime', + 'RepetitionTime', + 'FlipAngle', + 'TriggerTime', + 'AcquisitionTime', + 'ContentTime', + 'AcquisitionNumber', + 'InstanceNumber', + ] + '''The meta data keywords used when trying to guess the sorting order. + Keys that come earlier in the list are given higher priority.''' + + minimal_keys = set(sort_guesses + + ['Rows', + 'Columns', + 'PixelSpacing', + 'ImageOrientationPatient', + 'InPlanePhaseEncodingDirection', + 'RepetitionTime', + 'AcquisitionTime' + ] + + list(default_group_keys) + ) + '''Set of minimal meta data keys that should be provided if they exist in + the source DICOM files.''' + + def __init__(self, time_order=None, vector_order=None, + allow_dummies=False, meta_filter=None): + if isinstance(time_order, str): + self._time_order = DicomOrdering(time_order) + else: + self._time_order = time_order + if isinstance(vector_order, str): + self._vector_order = DicomOrdering(vector_order) + else: + self._vector_order = vector_order + + if meta_filter is None: + self._meta_filter = default_meta_filter + else: + self._meta_filter = meta_filter + + self._allow_dummies = allow_dummies + + #Sets all the state variables to their defaults + self.clear() + + def _chk_equal(self, keys, meta1, meta2): + for key in keys: + if meta1[key] != meta2[key]: + raise IncongruentImageError("%s does not match" % key) + + def _chk_close(self, keys, meta1, meta2): + for key in keys: + if not np.allclose(meta1[key], meta2[key], atol=5e-5): + raise IncongruentImageError("%s is not close to matching" % + key) + + def _chk_congruent(self, meta): + is_dummy = not 'Rows' in meta or not 'Columns' in meta + if is_dummy and not self._allow_dummies: + raise IncongruentImageError('Missing Rows/Columns') + + if not self._ref_input is None: + self._chk_close(('PixelSpacing', + 'ImageOrientationPatient'), + meta, + self._ref_input + ) + if not is_dummy: + self._chk_equal(('Rows', 'Columns'), meta, self._ref_input) + elif len(self._dummies) != 0: + self._chk_close(('PixelSpacing', + 'ImageOrientationPatient'), + meta, + self._dummies[0][0] + ) + return is_dummy + + + def add_dcm(self, dcm, meta=None): + '''Add a pydicom dataset to the stack. + + Parameters + ---------- + dcm : dicom.dataset.Dataset + The data set being added to the stack + + meta : dict + The extracted meta data for the DICOM data set `dcm`. If None + extract.default_extractor will be used. + + Raises + ------ + IncongruentImageError + The provided `dcm` does not match the orientation or dimensions of + those already in the stack. + + ImageCollisionError + The provided `dcm` has the same slice location and time/vector + values. + + ''' + if meta is None: + from .extract import default_extractor + meta = default_extractor(dcm) + + dw = wrapper_from_data(dcm) + + is_dummy = self._chk_congruent(meta) + + self._phase_enc_dirs.add(meta.get('InPlanePhaseEncodingDirection')) + self._repetition_times.add(meta.get('RepetitionTime')) + + #Pull the info used for sorting + slice_pos = dw.slice_indicator + self._slice_pos_vals.add(slice_pos) + time_val = None + if self._time_order: + time_val = self._time_order.get_ordinate(meta) + self._time_vals.add(time_val) + vector_val = None + if self._vector_order: + vector_val = self._vector_order.get_ordinate(meta) + self._vector_vals.add(vector_val) + + #Create a tuple with the sorting values + sorting_tuple = (vector_val, time_val, slice_pos) + + #If a explicit order was specified, raise an exception if image + #collides with another already in the stack + if ((not self._time_order is None or + not self._vector_order is None) and + sorting_tuple in self._sorting_tuples + ): + raise ImageCollisionError() + self._sorting_tuples.add(sorting_tuple) + + #Create a NiftiWrapper for this input if possible + nii_wrp = None + if not is_dummy: + nii_wrp = NiftiWrapper.from_dicom_wrapper(dw, meta) + if self._ref_input is None: + #We don't have a reference input yet, use this one + self._ref_input = nii_wrp + #Convert any dummies that we have stashed previously + for dummy_meta, dummy_tuple, iop in self._dummies: + dummy_wrp = _make_dummy(self._ref_input, dummy_meta, iop) + self._files_info.append((dummy_wrp, dummy_tuple)) + else: + if self._ref_input is None: + #We don't have a reference input, so stash the dummy for now + self._dummies.append((meta, sorting_tuple, dcm.ImagePositionPatient)) + else: + #Convert dummy using the reference input + nii_wrp = _make_dummy(self._ref_input, meta, dcm.ImagePositionPatient) + + #If we made a NiftiWrapper add it to the stack + if not nii_wrp is None: + self._files_info.append((nii_wrp, sorting_tuple)) + + #Set the dirty flags + self._shape_dirty = True + self._meta_dirty = True + + def clear(self): + '''Remove any DICOM datasets from the stack.''' + self._slice_pos_vals = set() + self._time_vals = set() + self._vector_vals = set() + self._sorting_tuples = set() + + self._phase_enc_dirs = set() + self._repetition_times = set() + + self._dummies = [] + self._ref_input = None + + self._shape_dirty = True + self._shape = None + + self._meta_dirty = True + self._meta = None + + self._files_info = [] + + def _chk_order(self, slice_positions, files_per_vol, num_volumes, + num_time_points, num_vec_comps): + #Sort the files + self._files_info.sort(key=lambda x: x[1]) + if files_per_vol > 1: + for vol_idx in range(num_volumes): + start_slice = vol_idx * files_per_vol + end_slice = start_slice + files_per_vol + self._files_info[start_slice:end_slice] = \ + sorted(self._files_info[start_slice:end_slice], + key=lambda x: x[1][-1]) + + #Do a thorough check for correctness + for vec_idx in xrange(num_vec_comps): + file_idx = vec_idx*num_time_points*files_per_vol + curr_vec_val = self._files_info[file_idx][1][0] + for time_idx in xrange(num_time_points): + for slice_idx in xrange(files_per_vol): + file_idx = (vec_idx*num_time_points*files_per_vol + + time_idx*files_per_vol + slice_idx) + file_info = self._files_info[file_idx] + if file_info[1][0] != curr_vec_val: + raise InvalidStackError("Not enough images with the " + + "vector value of " + + str(curr_vec_val)) + if (file_info[1][2] != slice_positions[slice_idx]): + if (file_info[1][2] == slice_positions[slice_idx-1]): + error_msg = ["Duplicate slice position"] + else: + error_msg = ["Missing slice position"] + error_msg.append(" at slice index %d" % slice_idx) + if num_time_points > 1: + error_msg.append(' in time point %d' % time_idx) + if num_vec_comps > 1: + error_msg.append(' for vector component %s' % + str(curr_vec_val)) + raise InvalidStackError(''.join(error_msg)) + + + def get_shape(self): + '''Get the shape of the stack. + + Returns + ------- + A tuple of integers giving the size of the dimensions of the stack. + + Raises + ------ + InvalidStackError + The stack is incomplete or invalid. + ''' + #If the dirty flag is not set, return the cached value + if not self._shape_dirty: + return self._shape + + #We need at least one non-dummy file in the stack + if len(self._files_info) == 0: + raise InvalidStackError("No (non-dummy) files in the stack") + + #Figure out number of files and slices per volume + files_per_vol = len(self._slice_pos_vals) + slice_positions = sorted(list(self._slice_pos_vals)) + + #If more than one file per volume, check that slice spacing is equal + if files_per_vol > 1: + spacings = [] + for idx in xrange(files_per_vol - 1): + spacings.append(slice_positions[idx+1] - slice_positions[idx]) + spacings = np.array(spacings) + avg_spacing = np.mean(spacings) + if not np.allclose(avg_spacing, spacings, rtol=4e-2): + raise InvalidStackError("Slice spacings are not consistent") + + #Simple check for an incomplete stack + if len(self._files_info) % files_per_vol != 0: + raise InvalidStackError("Number of files is not an even multiple " + "of the number of unique slice positions.") + num_volumes = len(self._files_info) / files_per_vol + + #Figure out the number of vector components and time points + num_vec_comps = len(self._vector_vals) + if num_vec_comps > num_volumes: + raise InvalidStackError("Vector variable varies within volumes") + if num_volumes % num_vec_comps != 0: + raise InvalidStackError("Number of volumes not an even multiple " + "of the number of vector components.") + num_time_points = num_volumes / num_vec_comps + + #If both sort keys are None try to guess + if (num_volumes > 1 and self._time_order is None and + self._vector_order is None): + #Get a list of possible sort orders + possible_orders = [] + for key in self.sort_guesses: + vals = set([file_info[0].get_meta(key) + for file_info in self._files_info] + ) + if len(vals) == num_volumes or len(vals) == len(self._files_info): + possible_orders.append(key) + if len(possible_orders) == 0: + raise InvalidStackError("Unable to guess key for sorting the " + "fourth dimension") + + #Try out each possible sort order + for time_order in possible_orders: + #Update sorting tuples + for idx in xrange(len(self._files_info)): + nii_wrp, curr_tuple = self._files_info[idx] + self._files_info[idx] = (nii_wrp, + (curr_tuple[0], + nii_wrp[time_order], + curr_tuple[2] + ) + ) + + #Check the order + try: + self._chk_order(slice_positions, + files_per_vol, + num_volumes, + num_time_points, + num_vec_comps) + except InvalidStackError: + pass + else: + break + else: + raise InvalidStackError("Unable to guess key for sorting the " + "fourth dimension") + else: #If at least on sort key was specified, just check the order + self._chk_order(slice_positions, + files_per_vol, + num_volumes, + num_time_points, + num_vec_comps) + + #Stack appears to be valid, build the shape tuple + file_shape = self._files_info[0][0].nii_img.get_shape() + vol_shape = list(file_shape) + if files_per_vol > 1: + vol_shape[2] = files_per_vol + shape = vol_shape+ [num_time_points, num_vec_comps] + if shape[4] == 1: + shape = shape[:-1] + if shape[3] == 1: + shape = shape[:-1] + self._shape = tuple(shape) + + self._shape_dirty = False + return self._shape + + def get_data(self): + '''Get an array of the voxel values. + + Returns + ------- + A numpy array filled with values from the DICOM data sets' pixels. + + Raises + ------ + InvalidStackError + The stack is incomplete or invalid. + ''' + #Create a numpy array for storing the voxel data + stack_shape = self.get_shape() + stack_shape = tuple(list(stack_shape) + ((5 - len(stack_shape)) * [1])) + stack_dtype = self._files_info[0][0].nii_img.get_data_dtype() + #This is a hack to keep fslview happy, Shouldn't cause issues as the + #original data should be 12-bit and any scaling will result in float + #data + if stack_dtype == np.uint16: + stack_dtype = np.int16 + vox_array = np.empty(stack_shape, dtype=stack_dtype) + + #Fill the array with data + n_vols = 1 + if len(stack_shape) > 3: + n_vols *= stack_shape[3] + if len(stack_shape) > 4: + n_vols *= stack_shape[4] + files_per_vol = len(self._files_info) / n_vols + file_shape = self._files_info[0][0].nii_img.get_shape() + for vec_idx in range(stack_shape[4]): + for time_idx in range(stack_shape[3]): + if files_per_vol == 1 and file_shape[2] != 1: + file_idx = vec_idx*(stack_shape[3]) + time_idx + vox_array[:, :, :, time_idx, vec_idx] = \ + self._files_info[file_idx][0].nii_img.get_data() + else: + for slice_idx in range(files_per_vol): + file_idx = (vec_idx*(stack_shape[3]*stack_shape[2]) + + time_idx*(stack_shape[2]) + slice_idx) + vox_array[:, :, slice_idx, time_idx, vec_idx] = \ + self._files_info[file_idx][0].nii_img.get_data()[:, :, 0] + + #Trim unused time/vector dimensions + if stack_shape[4] == 1: + vox_array = vox_array[...,0] + if stack_shape[3] == 1: + vox_array = vox_array[...,0] + + return vox_array + + def get_affine(self): + '''Get the affine transform for mapping row/column/slice indices + to Nifti (RAS) patient space. + + Returns + ------- + A 4x4 numpy array containing the affine transform. + + Raises + ------ + InvalidStackError + The stack is incomplete or invalid. + ''' + #Figure out the number of three (or two) dimensional volumes + shape = self.get_shape() + n_vols = 1 + if len(shape) > 3: + n_vols *= shape[3] + if len(shape) > 4: + n_vols *= shape[4] + + #Figure out the number of files in each volume + files_per_vol = len(self._files_info) / n_vols + + #Pull the DICOM Patient Space affine from the first input + aff = self._files_info[0][0].nii_img.get_affine() + + #If there is more than one file per volume, we need to fix slice scaling + if files_per_vol > 1: + first_offset = aff[:3, 3] + second_offset = self._files_info[1][0].nii_img.get_affine()[:3, 3] + scaled_slc_dir = second_offset - first_offset + aff[:3, 2] = scaled_slc_dir + + return aff + + def to_nifti(self, voxel_order='LAS', embed_meta=False): + '''Returns a NiftiImage with the data and affine from the stack. + + Parameters + ---------- + voxel_order : str + A three character string repsenting the voxel order in patient + space (see the function `reorder_voxels`). Can be None or an empty + string to disable reorientation. + + embed_meta : bool + If true a dcmmeta.DcmMetaExtension will be embedded in the Nifti + header. + + Returns + ------- + A nibabel.nifti1.Nifti1Image created with the stack's data and affine. + ''' + #Get the voxel data and affine + data = self.get_data() + affine = self.get_affine() + + #Figure out the number of three (or two) dimensional volumes + n_vols = 1 + if len(data.shape) > 3: + n_vols *= data.shape[3] + if len(data.shape) > 4: + n_vols *= data.shape[4] + + files_per_vol = len(self._files_info) / n_vols + + #Reorder the voxel data if requested + permutation = [0, 1, 2] + slice_dim = 2 + reorient_transform = np.eye(4) + if voxel_order: + (data, + affine, + reorient_transform, + ornt_trans) = reorder_voxels(data, affine, voxel_order) + permutation, flips = zip(*ornt_trans) + permutation = [int(val) for val in permutation] + + #Reverse file order in each volume's files if we flipped slice order + #This will keep the slice times and meta data order correct + if files_per_vol > 1 and flips[slice_dim] == -1: + self._shape_dirty = True + for vol_idx in xrange(n_vols): + start = vol_idx * files_per_vol + stop = start + files_per_vol + self._files_info[start:stop] = [self._files_info[idx] + for idx in xrange(stop - 1, + start - 1, + -1) + ] + + #Update the slice dim + slice_dim = permutation[2] + + #Create the nifti image using the data array + nifti_image = nb.Nifti1Image(data, affine) + nifti_header = nifti_image.get_header() + + #Set the units and dimension info + nifti_header.set_xyzt_units('mm', 'msec') + if len(self._repetition_times) == 1 and not None in self._repetition_times: + nifti_header['pixdim'][4] = list(self._repetition_times)[0] + dim_info = {'freq' : None, 'phase' : None, 'slice' : slice_dim} + if len(self._phase_enc_dirs) == 1 and not None in self._phase_enc_dirs: + phase_dir = list(self._phase_enc_dirs)[0] + if phase_dir == 'ROW': + dim_info['phase'] = permutation[1] + dim_info['freq'] = permutation[0] + else: + dim_info['phase'] = permutation[0] + dim_info['freq'] = permutation[1] + nifti_header.set_dim_info(**dim_info) + n_slices = data.shape[slice_dim] + + #Set the slice timing header info + has_acq_time = (self._files_info[0][0].get_meta('AcquisitionTime') != + None) + if files_per_vol > 1 and has_acq_time: + #Pull out the relative slice times for the first volume + slice_times = np.array([dcm_time_to_sec(file_info[0]['AcquisitionTime']) + for file_info in self._files_info[:n_slices]] + ) + slice_times -= np.min(slice_times) + + #If there is more than one volume, check if times are consistent + is_consistent = True + for vol_idx in xrange(1, n_vols): + start_slice = vol_idx * n_slices + end_slice = start_slice + n_slices + slices_info = self._files_info[start_slice:end_slice] + vol_slc_times = \ + np.array([dcm_time_to_sec(file_info[0]['AcquisitionTime']) + for file_info in slices_info] + ) + vol_slc_times -= np.min(vol_slc_times) + if not np.allclose(slice_times, vol_slc_times): + is_consistent = False + break + + #If the times are consistent and not all zero, try setting the slice + #times (sets the slice duration and code if possible). + if is_consistent and not np.allclose(slice_times, 0.0): + try: + nifti_header.set_slice_times(slice_times) + except HeaderDataError: + pass + + #Embed the meta data extension if requested + if embed_meta: + #Build meta data for each volume if needed + vol_meta = [] + if files_per_vol > 1: + for vol_idx in xrange(n_vols): + start_slice = vol_idx * n_slices + end_slice = start_slice + n_slices + exts = [file_info[0].meta_ext + for file_info in self._files_info[start_slice:end_slice]] + meta = DcmMetaExtension.from_sequence(exts, 2) + vol_meta.append(meta) + else: + vol_meta = [file_info[0].meta_ext + for file_info in self._files_info] + + #Build meta data for each time point / vector component + if len(data.shape) == 5: + if data.shape[3] != 1: + vec_meta = [] + for vec_idx in xrange(data.shape[4]): + start_idx = vec_idx * data.shape[3] + end_idx = start_idx + data.shape[3] + meta = DcmMetaExtension.from_sequence(\ + vol_meta[start_idx:end_idx], 3) + vec_meta.append(meta) + else: + vec_meta = vol_meta + + meta_ext = DcmMetaExtension.from_sequence(vec_meta, 4) + elif len(data.shape) == 4: + meta_ext = DcmMetaExtension.from_sequence(vol_meta, 3) + else: + meta_ext = vol_meta[0] + if meta_ext is file_info[0].meta_ext: + meta_ext = deepcopy(meta_ext) + + meta_ext.shape = data.shape + meta_ext.slice_dim = slice_dim + meta_ext.affine = nifti_header.get_best_affine() + meta_ext.reorient_transform = reorient_transform + + #Filter and embed the meta data + meta_ext.filter_meta(self._meta_filter) + nifti_header.extensions = Nifti1Extensions([meta_ext]) + + nifti_image.update_header() + return nifti_image + + def to_nifti_wrapper(self, voxel_order=''): + '''Convienance method. Calls `to_nifti` and returns a `NiftiWrapper` + generated from the result. + ''' + return NiftiWrapper(self.to_nifti(voxel_order, True)) + +def parse_and_group(src_paths, group_by=default_group_keys, extractor=None, + force=False, warn_on_except=False, + close_tests=('ImageOrientationPatient',)): + '''Parse the given dicom files and group them together. Each group is + stored as a (list) value in a dict where the key is a tuple of values + corresponding to the keys in 'group_by' + + Parameters + ---------- + src_paths : sequence + A list of paths to the source DICOM files. + + group_by : tuple + Meta data keys to group data sets with. Any data set with the same + values for these keys will be grouped together. This tuple of values + will also be the key in the result dictionary. + + extractor : callable + Should take a dicom.dataset.Dataset and return a dictionary of the + extracted meta data. + + force : bool + Force reading source files even if they do not appear to be DICOM. + + warn_on_except : bool + Convert exceptions into warnings, possibly allowing some results to be + returned. + + close_tests : sequence + Any `group_by` key listed here is tested with `numpy.allclose` instead + of straight equality when determining group membership. + + Returns + ------- + groups : dict + A dict mapping tuples of values (corresponding to 'group_by') to groups + of data sets. Each element in the list is a tuple containing the dicom + object, the parsed meta data, and the filename. + ''' + if extractor is None: + from .extract import default_extractor + extractor = default_extractor + + results = {} + close_elems = {} + for dcm_path in src_paths: + #Read the DICOM file + try: + dcm = dicom.read_file(dcm_path, force=force) + except Exception as e: + if warn_on_except: + warnings.warn('Error reading file %s: %s' % (dcm_path, str(e))) + continue + else: + raise + + #Extract the meta data and group + meta = extractor(dcm) + key_list = [] # Values from group_by elems with equality testing + close_list = [] # Values from group_by elems with np.allclose testing + for grp_key in group_by: + key_elem = meta.get(grp_key) + if isinstance(key_elem, list): + key_elem = tuple(key_elem) + if grp_key in close_tests: + close_list.append(key_elem) + else: + key_list.append(key_elem) + + # Initially each key has multiple sub_results (corresponding to + # different values of the "close" keys) + key = tuple(key_list) + if not key in results: + results[key] = [(close_list, [(dcm, meta, dcm_path)])] + else: + # Look for a matching sub_result + for c_list, sub_res in results[key]: + for c_idx, c_val in enumerate(c_list): + if not np.allclose(c_val, close_list[c_idx], atol=5e-5): + break + else: + sub_res.append((dcm, meta, dcm_path)) + break + else: + # No match found, append another sub result + results[key].append((close_list, [(dcm, meta, dcm_path)])) + + # Unpack sub results, using the canonical value for the close keys + full_results = {} + for eq_key, sub_res_list in results.iteritems(): + for close_key, sub_res in sub_res_list: + full_key = [] + eq_idx = 0 + close_idx = 0 + for grp_key in group_by: + if grp_key in close_tests: + full_key.append(close_key[close_idx]) + close_idx += 1 + else: + full_key.append(eq_key[eq_idx]) + eq_idx += 1 + full_key = tuple(full_key) + full_results[full_key] = sub_res + + return full_results + +def stack_group(group, warn_on_except=False, **stack_args): + result = DicomStack(**stack_args) + for dcm, meta, fn in group: + try: + result.add_dcm(dcm, meta) + except Exception as e: + if warn_on_except: + warnings.warn('Error adding file %s to stack: %s' % + (fn, str(e))) + else: + raise + return result + +def parse_and_stack(src_paths, group_by=default_group_keys, extractor=None, + force=False, warn_on_except=False, **stack_args): + '''Parse the given dicom files into a dictionary containing one or more + DicomStack objects. + + Parameters + ---------- + src_paths : sequence + A list of paths to the source DICOM files. + + group_by : tuple + Meta data keys to group data sets with. Any data set with the same + values for these keys will be grouped together. This tuple of values + will also be the key in the result dictionary. + + extractor : callable + Should take a dicom.dataset.Dataset and return a dictionary of the + extracted meta data. + + force : bool + Force reading source files even if they do not appear to be DICOM. + + warn_on_except : bool + Convert exceptions into warnings, possibly allowing some results to be + returned. + + stack_args : kwargs + Keyword arguments to pass to the DicomStack constructor. + ''' + results = parse_and_group(src_paths, + group_by, + extractor, + force, + warn_on_except) + + for key, group in results.iteritems(): + results[key] = stack_group(group, warn_on_except, **stack_args) + + return results diff --git a/fsl/data/dcmstack/dcmstack_cli.py b/fsl/data/dcmstack/dcmstack_cli.py new file mode 100755 index 000000000..1c041a9cb --- /dev/null +++ b/fsl/data/dcmstack/dcmstack_cli.py @@ -0,0 +1,357 @@ +""" +Command line interface to dcmstack. + +@author: moloney +""" +from __future__ import print_function + +import os, sys, argparse, string +from glob import glob +import dicom +from . import dcmstack +from .dcmstack import (parse_and_group, stack_group, DicomOrdering, + default_group_keys) +from .dcmmeta import NiftiWrapper +from . import extract +from .info import __version__ + + +prog_descrip = """Stack DICOM files from each source directory into 2D to 5D +volumes, optionally extracting meta data. +""" + +prog_epilog = """IT IS YOUR RESPONSIBILITY TO KNOW IF THERE IS PRIVATE HEALTH +INFORMATION IN THE METADATA EXTRACTED BY THIS PROGRAM.""" + +def parse_tags(opt_str): + tag_strs = opt_str.split(',') + tags = [] + for tag_str in tag_strs: + tokens = tag_str.split('_') + if len(tokens) != 2: + raise ValueError('Invalid str format for tags') + tags.append(dicom.tag.Tag(int(tokens[0].strip(), 16), + int(tokens[1].strip(), 16)) + ) + return tags + +def sanitize_path_comp(path_comp): + result = [] + for char in path_comp: + if not char in string.letters + string.digits + '-_.': + result.append('_') + else: + result.append(char) + return ''.join(result) + +def main(argv=sys.argv): + #Handle command line options + arg_parser = argparse.ArgumentParser(description=prog_descrip, + epilog=prog_epilog) + arg_parser.add_argument('src_dirs', nargs='*', help=('The source ' + 'directories containing DICOM files.')) + + input_opt = arg_parser.add_argument_group('Input options') + input_opt.add_argument('--force-read', action='store_true', default=False, + help=('Try reading all files as DICOM, even if they ' + 'are missing the preamble.')) + input_opt.add_argument('--file-ext', default='.dcm', help=('Only try reading ' + 'files with the given extension. Default: ' + '%(default)s')) + input_opt.add_argument('--allow-dummies', action='store_true', default=False, + help=('Allow DICOM files that are missing pixel ' + 'data, filling that slice of the output nifti with ' + 'the maximum representable value.')) + + output_opt = arg_parser.add_argument_group('Output options') + output_opt.add_argument('--dest-dir', default=None, + help=('Destination directory, defaults to the ' + 'source directory.')) + output_opt.add_argument('-o', '--output-name', default=None, + help=('Python format string determining the output ' + 'filenames based on DICOM tags.')) + output_opt.add_argument('--output-ext', default='.nii.gz', + help=('The extension for the output file type. ' + 'Default: %(default)s')) + output_opt.add_argument('-d', '--dump-meta', default=False, + action='store_true', help=('Dump the extracted ' + 'meta data into a JSON file with the same base ' + 'name as the generated Nifti')) + output_opt.add_argument('--embed-meta', default=False, action='store_true', + help=('Embed the extracted meta data into a Nifti ' + 'header extension (in JSON format).')) + + stack_opt = arg_parser.add_argument_group('Stacking Options') + stack_opt.add_argument('-g', '--group-by', default=None, + help=("Comma separated list of meta data keys to " + "group input files into stacks with.")) + stack_opt.add_argument('--voxel-order', default='LAS', + help=('Order the voxels so the spatial indices ' + 'start from these directions in patient space. ' + 'The directions in patient space should be given ' + 'as a three character code: (l)eft, (r)ight, ' + '(a)nterior, (p)osterior, (s)uperior, (i)nferior. ' + 'Passing an empty string will disable ' + 'reorientation. ' + 'Default: %(default)s')) + stack_opt.add_argument('-t', '--time-var', default=None, + help=('The DICOM element keyword to use for ' + 'ordering the stack along the time dimension.')) + stack_opt.add_argument('--vector-var', default=None, + help=('The DICOM element keyword to use for ' + 'ordering the stack along the vector dimension.')) + stack_opt.add_argument('--time-order', default=None, + help=('Provide a text file with the desired order ' + 'for the values (one per line) of the attribute ' + 'used as the time variable. This option is rarely ' + 'needed.')) + stack_opt.add_argument('--vector-order', default=None, + help=('Provide a text file with the desired order ' + 'for the values (one per line) of the attribute ' + 'used as the vector variable. This option is rarely ' + 'needed.')) + + meta_opt = arg_parser.add_argument_group('Meta Extraction and Filtering ' + 'Options') + meta_opt.add_argument('-l', '--list-translators', default=False, + action='store_true', help=('List enabled translators ' + 'and exit')) + meta_opt.add_argument('--disable-translator', default=None, + help=('Disable the translators for the provided ' + 'tags. Tags should be given in the format ' + '"0x0_0x0". More than one can be given in a comma ' + 'separated list. If the word "all" is provided, all ' + 'translators will be disabled.')) + meta_opt.add_argument('--extract-private', default=False, + action='store_true', + help=('Extract meta data from private elements, even ' + 'if there is no translator. If the value for the ' + 'element contains non-ascii bytes it will still be ' + 'ignored. The extracted meta data may still be ' + 'filtered out by the regular expressions.')) + meta_opt.add_argument('-i', '--include-regex', action='append', + help=('Include any meta data where the key matches ' + 'the provided regular expression. This will override ' + 'any exclude expressions. Applies to all meta data.')) + meta_opt.add_argument('-e', '--exclude-regex', action='append', + help=('Exclude any meta data where the key matches ' + 'the provided regular expression. This will ' + 'supplement the default exclude expressions. Applies ' + 'to all meta data.')) + meta_opt.add_argument('--default-regexes', default=False, + action='store_true', + help=('Print the list of default include and exclude ' + 'regular expressions and exit.')) + + gen_opt = arg_parser.add_argument_group('General Options') + gen_opt.add_argument('-v', '--verbose', default=False, action='store_true', + help=('Print additional information.')) + gen_opt.add_argument('--strict', default=False, action='store_true', + help=('Fail on the first exception instead of ' + 'showing a warning.')) + gen_opt.add_argument('--version', default=False, action='store_true', + help=('Show the version and exit.')) + + args = arg_parser.parse_args(argv[1:]) + + if args.version: + print(__version__) + return 0 + + #Check if we are just listing the translators + if args.list_translators: + for translator in extract.default_translators: + print('%s -> %s' % (translator.tag, translator.name)) + return 0 + + #Check if we are just listing the default exclude regular expressions + if args.default_regexes: + print('Default exclude regular expressions:') + for regex in dcmstack.default_key_excl_res: + print('\t' + regex) + print('Default include regular expressions:') + for regex in dcmstack.default_key_incl_res: + print('\t' + regex) + return 0 + + #Check if we are generating meta data + gen_meta = args.embed_meta or args.dump_meta + + if gen_meta: + #Start with the module defaults + ignore_rules = extract.default_ignore_rules + translators = extract.default_translators + + #Disable translators if requested + if args.disable_translator: + if args.disable_translator.lower() == 'all': + translators = tuple() + else: + try: + disable_tags = parse_tags(args.disable_translator) + except: + arg_parser.error('Invalid tag format to --disable-translator.') + new_translators = [] + for translator in translators: + if not translator.tag in disable_tags: + new_translators.append(translator) + translators = new_translators + + #Include non-translated private elements if requested + if args.extract_private: + ignore_rules = (extract.ignore_pixel_data, + extract.ignore_overlay_data, + extract.ignore_color_lut_data) + + extractor = extract.MetaExtractor(ignore_rules, translators) + + else: + extractor = extract.minimal_extractor + + #Add include/exclude regexes to meta filter + include_regexes = dcmstack.default_key_incl_res + if args.include_regex: + include_regexes += args.include_regex + exclude_regexes = dcmstack.default_key_excl_res + if args.exclude_regex: + exclude_regexes += args.exclude_regex + meta_filter = dcmstack.make_key_regex_filter(exclude_regexes, + include_regexes) + + #Figure out time and vector ordering + if args.time_var: + if args.time_order: + order_file = open(args.time_order) + abs_order = [line.strip() for line in order_file.readlines()] + order_file.close() + time_order = DicomOrdering(args.time_var, abs_order, True) + else: + time_order = DicomOrdering(args.time_var) + else: + time_order = None + if args.vector_var: + if args.vector_order: + order_file = open(args.vector_order) + abs_order = [line.strip() for line in order_file.readlines()] + order_file.close() + vector_order = DicomOrdering(args.vector_var, abs_order, True) + else: + vector_order = DicomOrdering(args.vector_var) + else: + vector_order = None + + if len(args.src_dirs) == 0: + arg_parser.error('No source directories were provided.') + + #Handle group-by option + if not args.group_by is None: + group_by = args.group_by.split(',') + else: + group_by = default_group_keys + + #Handle each source directory individually + for src_dir in args.src_dirs: + if not os.path.isdir(src_dir): + print('%s is not a directory, skipping' % src_dir, file=sys.stderr) + + if args.verbose: + print("Processing source directory %s" % src_dir) + + #Build a list of paths to source files + glob_str = os.path.join(src_dir, '*') + if args.file_ext: + glob_str += args.file_ext + src_paths = glob(glob_str) + + if args.verbose: + print("Found %d source files in the directory" % len(src_paths)) + + #Group the files in this directory + groups = parse_and_group(src_paths, + group_by, + extractor, + args.force_read, + not args.strict, + ) + + if args.verbose: + print("Found %d groups of DICOM images" % len(groups)) + + if len(groups) == 0: + print("No DICOM files found in %s" % src_dir) + + out_idx = 0 + generated_outs = set() + for key, group in groups.iteritems(): + stack = stack_group(group, + warn_on_except=not args.strict, + time_order=time_order, + vector_order=vector_order, + allow_dummies=args.allow_dummies, + meta_filter=meta_filter) + meta = group[0][1] + + #Build an appropriate output format string if none was specified + if args.output_name is None: + out_fmt = [] + if 'SeriesNumber' in meta: + out_fmt.append('%(SeriesNumber)03d') + if 'ProtocolName' in meta: + out_fmt.append('%(ProtocolName)s') + elif 'SeriesDescription' in meta: + out_fmt.append('%(SeriesDescription)s') + else: + out_fmt.append('series') + out_fmt = '-'.join(out_fmt) + else: + out_fmt = args.output_name + + #Get the output filename from the format string, make sure the + #result is unique for this source directory + out_fn = sanitize_path_comp(out_fmt % meta) + if out_fn in generated_outs: + out_fn += '-%03d' % out_idx + generated_outs.add(out_fn) + out_idx += 1 + out_fn = out_fn + args.output_ext + + if args.dest_dir: + out_path = os.path.join(args.dest_dir, out_fn) + else: + out_path = os.path.join(src_dir, out_fn) + + if args.verbose: + print("Writing out stack to path %s" % out_path) + + nii = stack.to_nifti(args.voxel_order, gen_meta) + + if args.dump_meta: + nii_wrp = NiftiWrapper(nii) + path_tokens = out_path.split('.') + if path_tokens[-1] == 'gz': + path_tokens = path_tokens[:-1] + if path_tokens[-1] == 'nii': + path_tokens = path_tokens[:-1] + meta_path = '.'.join(path_tokens + ['json']) + out_file = open(meta_path, 'w') + out_file.write(nii_wrp.meta_ext.to_json()) + out_file.close() + + if not args.embed_meta: + nii_wrp.remove_extension() + + del nii_wrp + + nii.to_filename(out_path) + + del key + del group + del stack + del meta + del nii + del groups + + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/fsl/data/dcmstack/extract.py b/fsl/data/dcmstack/extract.py new file mode 100644 index 000000000..40fd91157 --- /dev/null +++ b/fsl/data/dcmstack/extract.py @@ -0,0 +1,520 @@ +""" +Extract meta data from a DICOM data set. +""" +import struct, warnings +from collections import namedtuple, defaultdict +import dicom +from dicom.datadict import keyword_for_tag +from nibabel.nicom import csareader +from .dcmstack import DicomStack +try: + from collections import OrderedDict +except ImportError: + from ordereddict import OrderedDict +try: + import chardet + have_chardet = True +except ImportError: + have_chardet = False + pass + +#This is needed to allow extraction on files with invalid values (e.g. too +#long of a decimal string) +dicom.config.enforce_valid_values = False + +# Python 2 / 3 compatibility +unicode_str = unicode if sys.version_info[0] < 3 else str + +def is_ascii(in_str): + '''Return true if the given string is valid ASCII.''' + if all(' ' <= c <= '~' for c in in_str): + return True + return False + +def ignore_private(elem): + '''Ignore rule for `MetaExtractor` to skip private DICOM elements (odd + group number).''' + if elem.tag.group % 2 == 1: + return True + return False + +def ignore_pixel_data(elem): + return elem.tag == dicom.tag.Tag(0x7fe0, 0x10) + +def ignore_overlay_data(elem): + return elem.tag.group & 0xff00 == 0x6000 and elem.tag.elem == 0x3000 + +def ignore_color_lut_data(elem): + return (elem.tag.group == 0x28 and + elem.tag.elem in (0x1201, 0x1202, 0x1203, 0x1221, 0x1222, 0x1223)) + +default_ignore_rules = (ignore_private, + ignore_pixel_data, + ignore_overlay_data, + ignore_color_lut_data) +'''The default tuple of ignore rules for `MetaExtractor`.''' + + +Translator = namedtuple('Translator', ['name', + 'tag', + 'priv_creator', + 'trans_func'] + ) +'''A namedtuple for storing the four elements of a translator: a name, the +dicom.tag.Tag that can be translated, the private creator string (optional), and +the function which takes the DICOM element and returns a dictionary.''' + +def simplify_csa_dict(csa_dict): + '''Simplify the result of nibabel.nicom.csareader. + + Parameters + ---------- + csa_dict : dict + The result from nibabel.nicom.csareader + + Returns + ------- + result : OrderedDict + Result where the keys come from the 'tags' sub dictionary of `csa_dict`. + The values come from the 'items' within that tags sub sub dictionary. + If items has only one element it will be unpacked from the list. + ''' + if csa_dict is None: + return None + + result = OrderedDict() + for tag in csa_dict['tags']: + items = csa_dict['tags'][tag]['items'] + if len(items) == 0: + continue + elif len(items) == 1: + result[tag] = items[0] + else: + result[tag] = items + return result + +def csa_image_trans_func(elem): + '''Function for translating the CSA image sub header.''' + return simplify_csa_dict(csareader.read(elem.value)) + +csa_image_trans = Translator('CsaImage', + dicom.tag.Tag(0x29, 0x1010), + 'SIEMENS CSA HEADER', + csa_image_trans_func) +'''Translator for the CSA image sub header.''' + +class PhoenixParseError(Exception): + def __init__(self, line): + '''Exception indicating a error parsing a line from the Phoenix + Protocol. + ''' + self.line = line + + def __str__(self): + return 'Unable to parse phoenix protocol line: %s' % self.line + +def _parse_phoenix_line(line, str_delim='""'): + delim_len = len(str_delim) + #Handle most comments (not always when string literal involved) + comment_idx = line.find('#') + if comment_idx != -1: + #Check if the pound sign is in a string literal + if line[:comment_idx].count(str_delim) == 1: + if line[comment_idx:].find(str_delim) == -1: + raise PhoenixParseError(line) + else: + line = line[:comment_idx] + + #Allow empty lines + if line.strip() == '': + return None + + #Find the first equals sign and use that to split key/value + equals_idx = line.find('=') + if equals_idx == -1: + raise PhoenixParseError(line) + key = line[:equals_idx].strip() + val_str = line[equals_idx + 1:].strip() + + #If there is a string literal, pull that out + if val_str.startswith(str_delim): + end_quote = val_str[delim_len:].find(str_delim) + delim_len + if end_quote == -1: + raise PhoenixParseError(line) + elif not end_quote == len(val_str) - delim_len: + #Make sure remainder is just comment + if not val_str[end_quote+delim_len:].strip().startswith('#'): + raise PhoenixParseError(line) + + return (key, val_str[2:end_quote]) + + else: #Otherwise try to convert to an int or float + val = None + try: + val = int(val_str) + except ValueError: + pass + else: + return (key, val) + + try: + val = int(val_str, 16) + except ValueError: + pass + else: + return (key, val) + + try: + val = float(val_str) + except ValueError: + pass + else: + return (key, val) + + raise PhoenixParseError(line) + +def parse_phoenix_prot(prot_key, prot_val): + '''Parse the MrPheonixProtocol string. + + Parameters + ---------- + prot_str : str + The 'MrPheonixProtocol' string from the CSA Series sub header. + + Returns + ------- + prot_dict : OrderedDict + Meta data pulled from the ASCCONV section. + + Raises + ------ + PhoenixParseError : A line of the ASCCONV section could not be parsed. + ''' + if prot_key == 'MrPhoenixProtocol': + str_delim = '""' + elif prot_key == 'MrProtocol': + str_delim = '"' + else: + raise ValueError('Unknown protocol key: %s' % prot_key) + ascconv_start = prot_val.find('### ASCCONV BEGIN ') + ascconv_end = prot_val.find('### ASCCONV END ###') + ascconv = prot_val[ascconv_start:ascconv_end].split('\n')[1:-1] + + result = OrderedDict() + for line in ascconv: + parse_result = _parse_phoenix_line(line, str_delim) + if parse_result: + result[parse_result[0]] = parse_result[1] + + return result + +def csa_series_trans_func(elem): + '''Function for parsing the CSA series sub header.''' + csa_dict = simplify_csa_dict(csareader.read(elem.value)) + + #If there is a phoenix protocol, parse it and dump it into the csa_dict + phx_src = None + if 'MrPhoenixProtocol' in csa_dict: + phx_src = 'MrPhoenixProtocol' + elif 'MrProtocol' in csa_dict: + phx_src = 'MrProtocol' + + if not phx_src is None: + phoenix_dict = parse_phoenix_prot(phx_src, csa_dict[phx_src]) + del csa_dict[phx_src] + for key, val in phoenix_dict.items(): + new_key = '%s.%s' % ('MrPhoenixProtocol', key) + csa_dict[new_key] = val + + return csa_dict + +csa_series_trans = Translator('CsaSeries', + dicom.tag.Tag(0x29, 0x1020), + 'SIEMENS CSA HEADER', + csa_series_trans_func) +'''Translator for parsing the CSA series sub header.''' + + +default_translators = (csa_image_trans, + csa_series_trans, + ) +'''Default translators for MetaExtractor.''' + +def tag_to_str(tag): + '''Convert a DICOM tag to a string representation using the group and + element hex values seprated by an underscore.''' + return '%#X_%#X' % (tag.group, tag.elem) + +unpack_vr_map = {'SL' : 'i', + 'UL' : 'I', + 'FL' : 'f', + 'FD' : 'd', + 'SS' : 'h', + 'US' : 'H', + 'US or SS' : 'H', + } +'''Dictionary mapping value representations to corresponding format strings for +the struct.unpack function.''' + +def tm_to_seconds(time_str): + '''Convert a DICOM time value (value representation of 'TM') to the number + of seconds past midnight. + + Parameters + ---------- + time_str : str + The DICOM time value string + + Returns + ------- + A floating point representing the number of seconds past midnight + ''' + #Allow ACR/NEMA style format by removing any colon chars + time_str = time_str.replace(':', '') + + #Only the hours portion is required + result = int(time_str[:2]) * 3600 + + str_len = len(time_str) + if str_len > 2: + result += int(time_str[2:4]) * 60 + if str_len > 4: + result += float(time_str[4:]) + + return float(result) + +def get_text(byte_str): + '''If the given byte string contains text data return it as unicode, + otherwise return None. + + If the 'chardet' package is installed, this will be used to detect the + text encoding. Otherwise the input will only be decoded if it is ASCII. + ''' + if have_chardet: + match = chardet.detect(byte_str) + if match['encoding'] is None: + return None + else: + return byte_str.decode(match['encoding']) + else: + if not is_ascii(byte_str): + return None + else: + return byte_str.decode('ascii') + +default_conversions = {'DS' : float, + 'IS' : int, + 'AT' : str, + 'OW' : get_text, + 'OB' : get_text, + 'OW or OB' : get_text, + 'OB or OW' : get_text, + 'UN' : get_text, + 'PN' : unicode_str, + 'UI' : unicode_str, + } + +class MetaExtractor(object): + '''Callable object for extracting meta data from a dicom dataset. + Initialize with a set of ignore rules, translators, and type + conversions. + + Parameters + ---------- + ignore_rules : sequence + A sequence of callables, each of which should take a DICOM element + and return True if it should be ignored. If None the module + default is used. + + translators : sequence + A sequence of `Translator` objects each of which can convert a + DICOM element into a dictionary. Overrides any ignore rules. If + None the module default is used. + + conversions : dict + Mapping of DICOM value representation (VR) strings to callables + that perform some conversion on the value + + warn_on_trans_except : bool + Convert any exceptions from translators into warnings. + ''' + + def __init__(self, ignore_rules=None, translators=None, conversions=None, + warn_on_trans_except=True): + if ignore_rules is None: + self.ignore_rules = default_ignore_rules + else: + self.ignore_rules = ignore_rules + if translators is None: + self.translators = default_translators + else: + self.translators = translators + if conversions is None: + self.conversions = default_conversions + else: + self.conversions = conversions + self.warn_on_trans_except = warn_on_trans_except + + def _get_elem_key(self, elem): + '''Get the key for any non-translated elements.''' + #Use standard DICOM keywords if possible + key = keyword_for_tag(elem.tag) + + #For private tags we take elem.name and convert to camel case + if key == '': + key = elem.name + if key.startswith('[') and key.endswith(']'): + key = key[1:-1] + tokens = [token[0].upper() + token[1:] + for token in key.split()] + key = ''.join(tokens) + + return key + + def _get_elem_value(self, elem): + '''Get the value for any non-translated elements''' + #If the VR is implicit, we may need to unpack the values from a byte + #string. This may require us to make an assumption about whether the + #value is signed or not, but this is unavoidable. + if elem.VR in unpack_vr_map and isinstance(elem.value, str): + n_vals = len(elem.value)/struct.calcsize(unpack_vr_map[elem.VR]) + if n_vals != elem.VM: + warnings.warn("The element's VM and the number of values do " + "not match.") + if n_vals == 1: + value = struct.unpack(unpack_vr_map[elem.VR], elem.value)[0] + else: + value = list(struct.unpack(unpack_vr_map[elem.VR]*n_vals, + elem.value) + ) + else: + #Otherwise, just take a copy if the value is a list + n_vals = elem.VM + if n_vals > 1: + value = elem.value[:] + else: + value = elem.value + + #Handle any conversions + if elem.VR in self.conversions: + if n_vals == 1: + value = self.conversions[elem.VR](value) + else: + value = [self.conversions[elem.VR](val) for val in value] + + return value + + def __call__(self, dcm): + '''Extract the meta data from a DICOM dataset. + + Parameters + ---------- + dcm : dicom.dataset.Dataset + The DICOM dataset to extract the meta data from. + + Returns + ------- + meta : dict + A dictionary of extracted meta data. + + Notes + ----- + Non-private tags use the DICOM keywords as keys. Translators have their + name, followed by a dot, prepended to the keys of any meta elements + they produce. Values are unchanged, except when the value + representation is 'DS' or 'IS' (decimal/integer strings) they are + converted to float and int types. + ''' + standard_meta = [] + trans_meta_dicts = OrderedDict() + + #Make dict to track which tags map to which translators + trans_map = {} + + # Convert text elements to unicode + dcm.decode() + + for elem in dcm: + if isinstance(elem.value, str) and elem.value.strip() == '': + continue + + #Get the name for non-translated elements + name = self._get_elem_key(elem) + + #If it is a private creator element, setup any corresponding + #translators + if elem.name == "Private Creator": + for translator in self.translators: + if translator.priv_creator == elem.value: + new_elem = ((translator.tag.elem & 0xff) | + (elem.tag.elem * 16**2)) + new_tag = dicom.tag.Tag(elem.tag.group, new_elem) + if new_tag in trans_map: + raise ValueError('More than one translator ' + 'for tag: %s' % new_tag) + trans_map[new_tag] = translator + + #If there is a translator for this element, use it + if elem.tag in trans_map: + try: + meta = trans_map[elem.tag].trans_func(elem) + except Exception as e: + if self.warn_on_trans_except: + warnings.warn("Exception from translator %s: %s" % + (trans_map[elem.tag].name, + repr(str(e)))) + else: + raise + else: + if meta: + trans_meta_dicts[trans_map[elem.tag].name] = meta + #Otherwise see if we are supposed to ignore the element + elif any(rule(elem) for rule in self.ignore_rules): + continue + #Handle elements that are sequences with recursion + elif isinstance(elem.value, dicom.sequence.Sequence): + value = [] + for val in elem.value: + value.append(self(val)) + if all(x is None for x in value): + continue + standard_meta.append((name, value, elem.tag)) + #Otherwise just make sure the value is unpacked + else: + value = self._get_elem_value(elem) + if value is None: + continue + standard_meta.append((name, value, elem.tag)) + + #Handle name collisions + name_counts = defaultdict(int) + for elem in standard_meta: + name_counts[elem[0]] += 1 + result = OrderedDict() + for name, value, tag in standard_meta: + if name_counts[name] > 1: + name = name + '_' + tag_to_str(tag) + result[name] = value + + #Inject translator results + for trans_name, meta in trans_meta_dicts.items(): + for name, value in meta.items(): + name = '%s.%s' % (trans_name, name) + result[name] = value + + return result + +def minimal_extractor(dcm): + '''Meta data extractor that just extracts the minimal set of keys needed + by DicomStack objects. + ''' + result = {} + for key in DicomStack.minimal_keys: + try: + result[key] = dcm.__getattr__(key) + except AttributeError: + pass + return result + +default_extractor = MetaExtractor() +'''The default `MetaExtractor`.''' diff --git a/fsl/data/dcmstack/info.py b/fsl/data/dcmstack/info.py new file mode 100644 index 000000000..a39ab07fd --- /dev/null +++ b/fsl/data/dcmstack/info.py @@ -0,0 +1,54 @@ +""" Information for setup.py that we may also want to access in dcmstack. Can +not import dcmstack. +""" +import sys + +_version_major = 0 +_version_minor = 7 +_version_micro = 0 +_version_extra = 'dev' +__version__ = "%s.%s.%s%s" % (_version_major, + _version_minor, + _version_micro, + _version_extra) + +CLASSIFIERS = ["Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Topic :: Scientific/Engineering"] + +description = 'Stack DICOM images into volumes and convert to Nifti' + +# Hard dependencies +install_requires = ['pydicom >= 0.9.7', + 'nibabel >= 2.0.0', + ] +# Add version specific dependencies +if sys.version_info < (2, 6): + raise Exception("must use python 2.6 or greater") +elif sys.version_info < (2, 7): + install_requires.append('ordereddict') + +# Extra requirements for building documentation and testing +extras_requires = {'doc': ["sphinx", "numpydoc"], + 'test': ["nose"], + } + + +NAME = 'dcmstack' +AUTHOR = "Brendan Moloney" +AUTHOR_EMAIL = "moloney@ohsu.edu" +MAINTAINER = "Brendan Moloney" +MAINTAINER_EMAIL = "moloney@ohsu.edu" +DESCRIPTION = description +LICENSE = "MIT license" +CLASSIFIERS = CLASSIFIERS +PLATFORMS = "OS Independent" +ISRELEASE = _version_extra == '' +VERSION = __version__ +INSTALL_REQUIRES = install_requires +EXTRAS_REQUIRES = extras_requires +PROVIDES = ["dcmstack"] \ No newline at end of file diff --git a/fsl/data/dcmstack/nitool_cli.py b/fsl/data/dcmstack/nitool_cli.py new file mode 100644 index 000000000..5e83da519 --- /dev/null +++ b/fsl/data/dcmstack/nitool_cli.py @@ -0,0 +1,251 @@ +""" +Command line interface for nitool. + +@author: moloney +""" +from __future__ import print_function + +import os, sys, argparse +import nibabel as nb +from .dcmmeta import NiftiWrapper, DcmMetaExtension, MissingExtensionError + +prog_descrip = """Work with extended Nifti files created by dcmstack""" + +def main(argv=sys.argv): + #Setup the top level parser + arg_parser = argparse.ArgumentParser(description=prog_descrip) + sub_parsers = arg_parser.add_subparsers(title="Subcommands") + + #Split command + split_help = ("Split src_nii file along a dimension. Defaults to the slice " + "dimension if 3D, otherwise the last dimension.") + split_parser = sub_parsers.add_parser('split', help=split_help) + split_parser.add_argument('src_nii', nargs=1) + split_parser.add_argument('-d', '--dimension', default=None, type=int, + help=("The dimension to split along. Must be in " + "the range [0, 5)")) + split_parser.add_argument('-o', '--output-format', default=None, + help=("Format string used to create the output " + "file names. Default is to prepend the index " + "number to the src_nii filename.")) + split_parser.set_defaults(func=split) + + #Merge Command + merge_help = ("Merge the provided Nifti files along a dimension. Defaults " + "to slice, then time, and then vector.") + merge_parser = sub_parsers.add_parser('merge', help=merge_help) + merge_parser.add_argument('output', nargs=1) + merge_parser.add_argument('src_niis', nargs='+') + merge_parser.add_argument('-d', '--dimension', default=None, type=int, + help=("The dimension to merge along. Must be " + "in the range [0, 5)")) + merge_parser.add_argument('-s', '--sort', default=None, + help=("Sort the source files using the provided " + "meta data key before merging")) + merge_parser.add_argument('-c', '--clear-slices', action='store_true', + help="Clear all per slice meta data") + merge_parser.set_defaults(func=merge) + + #Dump Command + dump_help = "Dump the JSON meta data extension from the provided Nifti." + dump_parser = sub_parsers.add_parser('dump', help=dump_help) + dump_parser.add_argument('src_nii', nargs=1) + dump_parser.add_argument('dest_json', nargs='?', + type=argparse.FileType('w'), + default=sys.stdout) + dump_parser.add_argument('-m', '--make-empty', default=False, + action='store_true', + help="Make an empty extension if none exists") + dump_parser.add_argument('-r', '--remove', default=False, + action='store_true', + help="Remove the extension from the Nifti file") + dump_parser.set_defaults(func=dump) + + #Embed Command + embed_help = "Embed a JSON extension into the Nifti file." + embed_parser = sub_parsers.add_parser('embed', help=embed_help) + embed_parser.add_argument('src_json', nargs='?', type=argparse.FileType('r'), + default=sys.stdin) + embed_parser.add_argument('dest_nii', nargs=1) + embed_parser.add_argument('-f', '--force-overwrite', action='store_true', + help="Overwrite any existing dcmmeta extension") + embed_parser.set_defaults(func=embed) + + #Lookup command + lookup_help = "Lookup the value for the given meta data key." + lookup_parser = sub_parsers.add_parser('lookup', help=lookup_help) + lookup_parser.add_argument('key', nargs=1) + lookup_parser.add_argument('src_nii', nargs=1) + lookup_parser.add_argument('-i', '--index', + help=("Use the given voxel index. The index " + "must be provided as a comma separated list of " + "integers (one for each dimension).")) + lookup_parser.set_defaults(func=lookup) + + #Inject command + inject_help = "Inject meta data into the JSON extension." + inject_parser = sub_parsers.add_parser('inject', help=inject_help) + inject_parser.add_argument('dest_nii', nargs=1) + inject_parser.add_argument('classification', nargs=2) + inject_parser.add_argument('key', nargs=1) + inject_parser.add_argument('values', nargs='+') + inject_parser.add_argument('-f', '--force-overwrite', + action='store_true', + help=("Overwrite any existing values " + "for the key")) + inject_parser.add_argument('-t', '--type', default=None, + help="Interpret the value as this type instead " + "of trying to determine the type automatically") + inject_parser.set_defaults(func=inject) + + #Parse the arguments and call the appropriate function + args = arg_parser.parse_args(argv[1:]) + return args.func(args) + +def split(args): + src_path = args.src_nii[0] + src_fn = os.path.basename(src_path) + src_dir = os.path.dirname(src_path) + + src_nii = nb.load(src_path) + try: + src_wrp = NiftiWrapper(src_nii) + except MissingExtensionError: + print("No dcmmeta extension found, making empty one...") + src_wrp = NiftiWrapper(src_nii, make_empty=True) + for split_idx, split in enumerate(src_wrp.split(args.dimension)): + if args.output_format: + out_name = (args.output_format % + split.meta_ext.get_class_dict(('global', 'const')) + ) + else: + out_name = os.path.join(src_dir, '%03d-%s' % (split_idx, src_fn)) + nb.save(split, out_name) + return 0 + +def make_key_func(meta_key, index=None): + def key_func(src_nii): + result = src_nii.get_meta(meta_key, index) + if result is None: + raise ValueError('Key not found: %s' ) % meta_key + return result + + return key_func + +def merge(args): + src_wrps = [] + for src_path in args.src_niis: + src_nii = nb.load(src_path) + try: + src_wrp = NiftiWrapper(src_nii) + except MissingExtensionError: + print("No dcmmeta extension found, making empty one...") + src_wrp = NiftiWrapper(src_nii, make_empty=True) + src_wrps.append(src_wrp) + + if args.sort: + src_wrps.sort(key=make_key_func(args.sort)) + + result_wrp = NiftiWrapper.from_sequence(src_wrps, args.dimension) + + if args.clear_slices: + result_wrp.meta_ext.clear_slice_meta() + + out_name = (args.output[0] % + result_wrp.meta_ext.get_class_dict(('global', 'const'))) + result_wrp.to_filename(out_name) + return 0 + +def dump(args): + src_nii = nb.load(args.src_nii[0]) + src_wrp = NiftiWrapper(src_nii, args.make_empty) + meta_str = src_wrp.meta_ext.to_json() + args.dest_json.write(meta_str) + args.dest_json.write('\n') + + if args.remove: + src_wrp.remove_extension() + src_wrp.to_filename(args.src_nii[0]) + return 0 + +def check_overwrite(): + usr_input = '' + while not usr_input in ('y', 'n'): + usr_input = input('Existing DcmMeta extension found, overwrite? ' + '[y/n]').lower() + return usr_input == 'y' + +def embed(args): + dest_nii = nb.load(args.dest_nii[0]) + hdr = dest_nii.get_header() + try: + src_wrp = NiftiWrapper(dest_nii, False) + except MissingExtensionError: + pass + else: + if not args.force_overwrite: + if not check_overwrite(): + return + src_wrp.remove_extension() + + hdr.extensions.append(DcmMetaExtension.from_json(args.src_json.read())) + nb.save(dest_nii, args.dest_nii[0]) + return 0 + +def lookup(args): + src_wrp = NiftiWrapper.from_filename(args.src_nii[0]) + index = None + if args.index: + index = tuple(int(idx.strip()) for idx in args.index.split(',')) + meta = src_wrp.get_meta(args.key[0], index) + if not meta is None: + print(meta) + return 0 + +def convert_values(values, type_str=None): + if type_str is None: + for conv_type in (int, float): + try: + values = [conv_type(val) for val in values] + except ValueError: + pass + else: + break + else: + if type_str not in ('str', 'int', 'float'): + raise ValueError("Unrecognized type: %s" % type_str) + conv_type = eval(type_str) + values = [conv_type(val) for val in values] + if len(values) == 1: + return values[0] + return values + +def inject(args): + dest_nii = nb.load(args.dest_nii[0]) + dest_wrp = NiftiWrapper(dest_nii, make_empty=True) + classification = tuple(args.classification) + if not classification in dest_wrp.meta_ext.get_valid_classes(): + print("Invalid classification: %s" % (classification,)) + return 1 + n_vals = len(args.values) + mult = dest_wrp.meta_ext.get_multiplicity(classification) + if n_vals != mult: + print(("Invalid number of values for classification. Expected " + "%d but got %d") % (mult, n_vals)) + return 1 + key = args.key[0] + if key in dest_wrp.meta_ext.get_keys(): + if not args.force_overwrite: + print("Key already exists, must pass --force-overwrite") + return 1 + else: + curr_class = dest_wrp.meta_ext.get_classification(key) + curr_dict = dest_wrp.meta_ext.get_class_dict(curr_class) + del curr_dict[key] + class_dict = dest_wrp.meta_ext.get_class_dict(classification) + class_dict[key] = convert_values(args.values, args.type) + nb.save(dest_nii, args.dest_nii[0]) + return 0 + +if __name__ == '__main__': + sys.exit(main()) -- GitLab