dicom.py 7.24 KB
Newer Older
1
2
#!/usr/bin/env python
#
Paul McCarthy's avatar
Paul McCarthy committed
3
# dicom.py - Access data in DICOM directories.
4
5
6
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
7
8
9
"""This module provides the :class:`.DicomImage` class, which represents a
volumetric DICOM data series. The ``DicomImage`` is simply an :class:`.`Image`
which provides accessors for additional DICOM meta data.
10

11
The following other functions are provided in this module, which are thin
Paul McCarthy's avatar
Paul McCarthy committed
12
wrappers around functionality provided by Chris Rorden's ``dcm2niix`` program:
13
14
15
16

.. autosummary::
   :nosignatures:

17
   enabled
18
   scanDir
Paul McCarthy's avatar
Paul McCarthy committed
19
   loadSeries
20

Paul McCarthy's avatar
Paul McCarthy committed
21
22
See: https://github.com/rordenlab/dcm2niix/

Paul McCarthy's avatar
Paul McCarthy committed
23
24
25
.. note:: These functions will not work if an executable called ``dcm2niix``
          cannot be found.
"""
26
27


28
import               os
Paul McCarthy's avatar
Paul McCarthy committed
29
30
import os.path    as op
import subprocess as sp
31
import               re
Paul McCarthy's avatar
Paul McCarthy committed
32
33
import               glob
import               json
34
import               logging
35
import               deprecation
36

Paul McCarthy's avatar
Paul McCarthy committed
37
38
import nibabel    as nib

Paul McCarthy's avatar
Paul McCarthy committed
39
import fsl.utils.tempdir as tempdir
40
import fsl.utils.memoize as memoize
Paul McCarthy's avatar
Paul McCarthy committed
41
import fsl.data.image    as fslimage
42
43


44
45
46
log = logging.getLogger(__name__)


47
48
49
50
MIN_DCM2NIIX_VERSION = (1, 0, 2017, 12, 15)
"""Minimum version of dcm2niix that is required for this module to work. """


51
class DicomImage(fslimage.Image):
Paul McCarthy's avatar
Paul McCarthy committed
52
    """The ``DicomImage`` is a volumetric :class:`.Image` with some associated
53
54
55
    DICOM metadata.

    The ``Image`` class is used to manage the data and the voxel-to-world
56
57
    transformation. Additional DICOM metadata may be accessed via the
    :class:`.Image` metadata access methods.
58
59
    """

60
61

    def __init__(self, image, metadata, dicomDir, *args, **kwargs):
62
        """Create a ``DicomImage``.
Paul McCarthy's avatar
Paul McCarthy committed
63

64
65
66
        :arg image:    Passed through to :meth:`.Image.__init__`.
        :arg metadata: Dictionary containing DICOM meta-data.
        :arg dicomDir: Directory that the dicom image was loaded from.
67
        """
Paul McCarthy's avatar
Paul McCarthy committed
68
        fslimage.Image.__init__(self, image, *args, **kwargs)
69
70
71
72
73

        self.__dicomDir = dicomDir

        if metadata is not None:
            for k, v in metadata.items():
74
                self.setMeta(k, v)
75
76
77
78
79
80


    @property
    def dicomDir(self):
        """Returns the directory that the DICOM image data was loaded from. """
        return self.__dicomDir
Paul McCarthy's avatar
Paul McCarthy committed
81
82


83
84
85
    @deprecation.deprecated(deprecated_in='1.6.0',
                            removed_in='2.0.0',
                            details='Use metaKeys instead')
Paul McCarthy's avatar
Paul McCarthy committed
86
    def keys(self):
87
88
        """Deprecated - use :meth:`.Image.metaKeys`. """
        return self.metaKeys()
89
90


91
92
93
    @deprecation.deprecated(deprecated_in='1.6.0',
                            removed_in='2.0.0',
                            details='Use metaValues instead')
Paul McCarthy's avatar
Paul McCarthy committed
94
    def values(self):
95
96
        """Deprecated - use :meth:`.Image.metaValues`. """
        return self.metaValues()
Paul McCarthy's avatar
Paul McCarthy committed
97
98


99
100
101
    @deprecation.deprecated(deprecated_in='1.6.0',
                            removed_in='2.0.0',
                            details='Use metaItems instead')
Paul McCarthy's avatar
Paul McCarthy committed
102
    def items(self):
103
104
        """Deprecated - use :meth:`.Image.metaItems`. """
        return self.metaItems()
105
106


107
108
109
    @deprecation.deprecated(deprecated_in='1.6.0',
                            removed_in='2.0.0',
                            details='Use getMeta instead')
Paul McCarthy's avatar
Paul McCarthy committed
110
    def get(self, *args, **kwargs):
111
112
        """Deprecated - use :meth:`.Image.getMeta`. """
        return self.getMeta(*args, **kwargs)
113
114


115
116
117
118
119
120
121
@memoize.memoize
def enabled():
    """Returns ``True`` if ``dcm2niix`` is present, and recent enough,
    ``False`` otherwise.
    """

    cmd            = 'dcm2niix -h'
Paul McCarthy's avatar
Paul McCarthy committed
122
123
124
125
126
127
    versionPattern = re.compile(r'v'
                                 '(?P<major>[0-9]+)\.'
                                 '(?P<minor>[0-9]+)\.'
                                 '(?P<year>[0-9]{4})'
                                 '(?P<month>[0-9]{2})'
                                 '(?P<day>[0-9]{2})')
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150

    try:
        output = sp.check_output(cmd.split()).decode()
        output = [l for l in output.split('\n') if 'version' in l.lower()]
        output = '\n'.join(output).split()

        for word in output:

            match = re.match(versionPattern, word)

            if match is None:
                continue

            installedVersion = (
                int(match.group('major')),
                int(match.group('minor')),
                int(match.group('year')),
                int(match.group('month')),
                int(match.group('day')))

            # make sure installed version
            # is equal to or newer than
            # minimum required version
151
            for iv, mv in zip(installedVersion, MIN_DCM2NIIX_VERSION):
152
153
154
155
156
157
158
159
160
161
162
163
                if   iv > mv: return True
                elif iv < mv: return False

            # if we get here, versions are equal
            return True

    except Exception as e:
        log.debug('Error parsing dcm2niix version string: {}'.format(e))

    return False


Paul McCarthy's avatar
Paul McCarthy committed
164
165
166
167
def scanDir(dcmdir):
    """Uses ``dcm2niix`` to scans the given DICOM directory, and returns a
    list of dictionaries, one for each data series that was identified.
    Each dictionary is populated with some basic metadata about the series.
168

Paul McCarthy's avatar
Paul McCarthy committed
169
    :arg dcmdir: Directory containing DICOM files.
170

Paul McCarthy's avatar
Paul McCarthy committed
171
172
    :returns:    A list of dictionaries, each containing metadata about
                 one DICOM data series.
173
174
    """

175
176
177
    if not enabled():
        raise RuntimeError('dcm2niix is not available or is too old')

178
179
180
    dcmdir      = op.abspath(dcmdir)
    cmd         = 'dcm2niix -b o -ba n -f %s -o . {}'.format(dcmdir)
    snumPattern = re.compile('^[0-9]+')
181

Paul McCarthy's avatar
Paul McCarthy committed
182
183
    with tempdir.tempdir() as td:

184
185
        with open(os.devnull, 'wb') as devnull:
            sp.call(cmd.split(), stdout=devnull, stderr=devnull)
Paul McCarthy's avatar
Paul McCarthy committed
186
187
188
189
190
191

        files = glob.glob(op.join(td, '*.json'))

        if len(files) == 0:
            return []

192
193
194
195
196
197
        # sort numerically by series number if possible
        try:
            def sortkey(f):
                match = re.match(snumPattern, f)
                snum  = int(match.group(0))
                return snum
Paul McCarthy's avatar
Paul McCarthy committed
198

199
200
201
202
            files = sorted(files, key=sortkey)

        except Exception:
            files = sorted(files)
Paul McCarthy's avatar
Paul McCarthy committed
203
204
205
206
207
208
209
210
211
212
213

        series = []
        for fn in files:
            with open(fn, 'rt') as f:
                meta = json.load(f)
                meta['DicomDir'] = dcmdir
                series.append(meta)

        return series


Paul McCarthy's avatar
Paul McCarthy committed
214
def loadSeries(series):
Paul McCarthy's avatar
Paul McCarthy committed
215
216
217
218
219
220
221
222
223
    """Takes a DICOM series meta data dictionary, as returned by
    :func:`scanDir`, and loads the associated data as one or more NIFTI
    images.

    :arg series: Dictionary as returned by :func:`scanDir`, containing
                 meta data about one DICOM data series.

    :returns:    List containing one or more :class:`.DicomImage` objects.
    """
224

225
226
227
    if not enabled():
        raise RuntimeError('dcm2niix is not available or is too old')

Paul McCarthy's avatar
Paul McCarthy committed
228
229
    dcmdir = series['DicomDir']
    snum   = series['SeriesNumber']
Paul McCarthy's avatar
Paul McCarthy committed
230
    desc   = series['SeriesDescription']
231
    cmd    = 'dcm2niix -b n -f %s -z n -o . -n {} {}'.format(snum, dcmdir)
232

Paul McCarthy's avatar
Paul McCarthy committed
233
    with tempdir.tempdir() as td:
234

235
236
        with open(os.devnull, 'wb') as devnull:
            sp.call(cmd.split(), stdout=devnull, stderr=devnull)
Paul McCarthy's avatar
Paul McCarthy committed
237

238
        files  = glob.glob(op.join(td, '{}*.nii'.format(snum)))
239
240
241
242
        images = [nib.load(f) for f in files]

        # Force-load images into memory
        [i.get_data() for i in images]
243

244
        return [DicomImage(i, series, dcmdir, name=desc) for i in images]