Skip to content

Commit e1aea51

Browse files
committed
Merge pull request #296 from kastman/ENH-NiftiDicomExt
MRG: Add Nifti1DicomExtension + tests Provides a thin wrapper class to read DICOM binary header information as encoded with the [DICOM extended header code 2](http://nifti.nimh.nih.gov/nifti-1/documentation/faq#Q21), instead of just providing the ugly & useless byte string. This depends on [pydicom](https://github.com/darcymason/pydicom), but should fall back to a standard `Nifti1Extension` in the case of an `ImportError`.
2 parents 7b78dec + 71a3ce4 commit e1aea51

File tree

13 files changed

+314
-46
lines changed

13 files changed

+314
-46
lines changed

Changelog

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ References like "pr/298" refer to github pull request numbers.
2626

2727
* Upcoming
2828

29+
* Read and write support for DICOM tags in NIfTI Extended Header using
30+
pydicom (pr/296);
2931
* Trackvis reader will now allow final streamline to have fewer points that
3032
tne numbe declared in the header, with ``strict=False`` argument to
3133
``read`` function;

doc/source/dicom/dicom.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Contents:
1616
dicom_mosaic
1717
siemens_csa
1818
spm_dicom
19+
dicom_niftiheader
1920

2021
.. these documents not yet ready for public advertisement
2122
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
.. _dicom-niftiheader:
2+
3+
##############################
4+
DICOM Tags in the NIfTI Header
5+
##############################
6+
7+
NIfTI images include an extended header (see the `NIfTI Extensions Standard`_)
8+
to store, amongst others, DICOM tags and attributes. When NiBabel loads a NIfTI
9+
file containing DICOM information (a NIfTI extension with ``ecode == 2``), it
10+
parses it and returns a pydicom dataset as the content of the NIfTI extension.
11+
This can be read and written to in order to facilitate communication with
12+
software that uses specific DICOM codes found in the NIfTI header.
13+
14+
For example, the commercial PMOD software stores the Frame Start and Duration
15+
times of images using the DICOM tags (0055, 1001) and (0055, 1004). Here's an
16+
example of an image created in PMOD with those stored times accessed through
17+
nibabel.
18+
19+
.. code:: python
20+
21+
>> import nibabel as nib
22+
>> nim = nib.load('pmod_pet.nii')
23+
>> dcmext = nim.header.extensions[0]
24+
>> dcmext
25+
Nifti1Extension('dicom', '(0054, 1001) Units CS: 'Bq/ml'
26+
(0055, 0010) Private Creator LO: 'PMOD_1'
27+
(0055, 1001) [Frame Start Times Vector] FD: [0.0, 30.0, 60.0, ..., 13720.0, 14320.0]
28+
(0055, 1004) [Frame Durations (ms) Vector] FD: [30000.0, 30000.0, 30000.0,600000.0, 600000.0]'))
29+
30+
+-------------+--------------------------------+---------------------------------------------------------+
31+
| Tag | Name | Value |
32+
+=============+================================+=========================================================+
33+
| (0054, 1001)| Units | CS: 'Bq/ml' |
34+
+-------------+--------------------------------+---------------------------------------------------------+
35+
|(0055, 0010) | Private Creator | LO: 'PMOD_1' |
36+
+-------------+--------------------------------+---------------------------------------------------------+
37+
|(0055, 1001) | [Frame Start Times Vector] | FD: [0.0, 30.0, 60.0, ..., 13720.0, 14320.0 |
38+
+-------------+--------------------------------+---------------------------------------------------------+
39+
|(0055, 1004) | [Frame Durations (ms) Vector] | FD: [30000.0, 30000.0, 30000.0, ..., 600000.0, 600000.0 |
40+
+-------------+--------------------------------+---------------------------------------------------------+
41+
42+
Access each value as you would with pydicom::
43+
44+
>> ds = dcmext.get_content()
45+
>> start_times = ds[0x0055, 0x1001].value
46+
>> durations = ds[0x0055, 0x1004].value
47+
48+
Creating a PMOD-compatible header is just as easy::
49+
50+
>> nim = nib.load('pet.nii')
51+
>> nim.header.extensions
52+
[]
53+
>> from dicom.dataset import Dataset
54+
>> ds = Dataset()
55+
>> ds.add_new((0x0054,0x1001),'CS','Bq/ml')
56+
>> ds.add_new((0x0055,0x0010),'LO','PMOD_1')
57+
>> ds.add_new((0x0055,0x1001),'FD',[0.,30.,60.,13720.,14320.])
58+
>> ds.add_new((0x0055,0x1004),'FD',[30000.,30000.,30000.,600000.,600000.])
59+
>> dcmext = nib.nifti1.Nifti1DicomExtension(2,ds) # Use DICOM ecode 2
60+
>> nim.header.extensions.append(dcmext)
61+
>> nib.save(nim,'pet_withdcm.nii')
62+
63+
Be careful! Many imaging tools don't maintain information in the extended
64+
header, so it's possible [likely] that this information may be lost during
65+
routine use. You'll have to keep track, and re-write the information if
66+
required.
67+
68+
Optional Dependency Note: If pydicom is not installed, nibabel uses a generic
69+
:class:`nibabel.nifti1.Nifti1Extension` header instead of parsing DICOM data.
70+
71+
.. _`NIfTI Extensions Standard`: http://nifti.nimh.nih.gov/nifti-1/documentation/nifti1fields/nifti1fields_pages/extension.html
72+
73+
.. include:: links_names.txt

nibabel/dft.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@
2626

2727
from .nifti1 import Nifti1Header
2828

29-
# Shield optional dicom import
30-
from .optpkg import optional_package
31-
dicom, have_dicom, _ = optional_package('dicom')
29+
from .pydicom_compat import pydicom, read_file
3230

3331
logger = logging.getLogger('nibabel.dft')
3432

@@ -236,7 +234,7 @@ def __getattribute__(self, name):
236234
return val
237235

238236
def dicom(self):
239-
return dicom.read_file(self.files[0])
237+
return read_file(self.files[0])
240238

241239

242240
class _db_nochange:
@@ -383,8 +381,8 @@ def _update_dir(c, dir, files, studies, series, storage_instances):
383381

384382
def _update_file(c, path, fname, studies, series, storage_instances):
385383
try:
386-
do = dicom.read_file('%s/%s' % (path, fname))
387-
except dicom.filereader.InvalidDicomError:
384+
do = read_file('%s/%s' % (path, fname))
385+
except pydicom.filereader.InvalidDicomError:
388386
logger.debug(' not a DICOM file')
389387
return None
390388
try:

nibabel/nicom/dicomwrappers.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,7 @@ def wrapper_from_file(file_like, *args, **kwargs):
5050
dcm_w : ``dicomwrappers.Wrapper`` or subclass
5151
DICOM wrapper corresponding to DICOM data type
5252
"""
53-
try:
54-
from dicom import read_file
55-
except ImportError:
56-
from pydicom.dicomio import read_file
53+
from ..pydicom_compat import read_file
5754

5855
with ImageOpener(file_like) as fobj:
5956
dcm_data = read_file(fobj, *args, **kwargs)

nibabel/nicom/tests/test_csareader.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,8 @@
1414

1515
from numpy.testing.decorators import skipif
1616

17-
from .test_dicomwrappers import (have_dicom, dicom_test,
18-
IO_DATA_PATH, DATA, DATA_FILE)
19-
if have_dicom:
20-
from .test_dicomwrappers import pydicom
17+
from nibabel.pydicom_compat import dicom_test, pydicom
18+
from .test_dicomwrappers import (IO_DATA_PATH, DATA)
2119

2220
CSA2_B0 = open(pjoin(IO_DATA_PATH, 'csa2_b0.bin'), 'rb').read()
2321
CSA2_B1000 = open(pjoin(IO_DATA_PATH, 'csa2_b1000.bin'), 'rb').read()

nibabel/nicom/tests/test_dicomreaders.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66

77
from .. import dicomreaders as didr
88

9-
from .test_dicomwrappers import (dicom_test,
10-
EXPECTED_AFFINE,
9+
from nibabel.pydicom_compat import dicom_test, pydicom
10+
11+
from .test_dicomwrappers import (EXPECTED_AFFINE,
1112
EXPECTED_PARAMS,
1213
IO_DATA_PATH,
1314
DATA)
@@ -41,10 +42,6 @@ def test_passing_kwds():
4142
# Check that we correctly pass keywords to dicom
4243
dwi_glob = 'siemens_dwi_*.dcm.gz'
4344
csa_glob = 'csa*.bin'
44-
try:
45-
from dicom.filereader import InvalidDicomError
46-
except ImportError:
47-
from pydicom.filereader import InvalidDicomError
4845
for func in (didr.read_mosaic_dwi_dir, didr.read_mosaic_dir):
4946
data, aff, bs, gs = func(IO_DATA_PATH, dwi_glob)
5047
# This should not raise an error
@@ -60,7 +57,7 @@ def test_passing_kwds():
6057
dwi_glob,
6158
dicom_kwargs=dict(not_a_parameter=True))
6259
# These are invalid dicoms, so will raise an error unless force=True
63-
assert_raises(InvalidDicomError,
60+
assert_raises(pydicom.filereader.InvalidDicomError,
6461
func,
6562
IO_DATA_PATH,
6663
csa_glob)

nibabel/nicom/tests/test_dicomwrappers.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,7 @@
99

1010
import numpy as np
1111

12-
have_dicom = True
13-
try:
14-
import dicom as pydicom
15-
read_file = pydicom.read_file
16-
except ImportError:
17-
try:
18-
import pydicom
19-
except ImportError:
20-
have_dicom = False
21-
else:
22-
from pydicom.dicomio import read_file
23-
dicom_test = np.testing.dec.skipif(not have_dicom,
24-
'could not import pydicom')
12+
from nibabel.pydicom_compat import have_dicom, pydicom, read_file, dicom_test
2513

2614
from .. import dicomwrappers as didw
2715
from .. import dicomreaders as didr

nibabel/nicom/tests/test_utils.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212

1313
from ..utils import find_private_section
1414

15-
from .test_dicomwrappers import (have_dicom, dicom_test,
16-
IO_DATA_PATH, DATA, DATA_PHILIPS)
15+
from nibabel.pydicom_compat import dicom_test, pydicom
16+
from .test_dicomwrappers import (DATA, DATA_PHILIPS)
1717

1818

1919
@dicom_test
@@ -27,11 +27,7 @@ def test_find_private_section_real():
2727
assert_equal(find_private_section(DATA_PHILIPS, 0x29, 'SIEMENS CSA HEADER'),
2828
None)
2929
# Make fake datasets
30-
try:
31-
from dicom.dataset import Dataset
32-
except ImportError:
33-
from pydicom.dataset import Dataset
34-
ds = Dataset({})
30+
ds = pydicom.dataset.Dataset({})
3531
ds.add_new((0x11, 0x10), 'LO', b'some section')
3632
assert_equal(find_private_section(ds, 0x11, 'some section'), 0x1000)
3733
ds.add_new((0x11, 0x11), 'LO', b'anther section')

nibabel/nifti1.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
'''
1313
from __future__ import division, print_function
1414
import warnings
15+
from io import BytesIO
1516

1617
import numpy as np
1718
import numpy.linalg as npl
@@ -24,6 +25,7 @@
2425
from . import analyze # module import
2526
from .spm99analyze import SpmAnalyzeHeader
2627
from .casting import have_binary128
28+
from .pydicom_compat import have_dicom, pydicom as pdcm
2729

2830
# nifti1 flat header definition for Analyze-like first 348 bytes
2931
# first number in comments indicates offset in file header in bytes
@@ -257,7 +259,7 @@ def __init__(self, code, content):
257259
"""
258260
Parameters
259261
----------
260-
code : int|str
262+
code : int or str
261263
Canonical extension code as defined in the NIfTI standard, given
262264
either as integer or corresponding label
263265
(see :data:`~nibabel.nifti1.extension_codes`)
@@ -379,13 +381,101 @@ def write_to(self, fileobj, byteswap):
379381
fileobj.write(b'\x00' * (extstart + rawsize - fileobj.tell()))
380382

381383

384+
class Nifti1DicomExtension(Nifti1Extension):
385+
"""NIfTI1 DICOM header extension
386+
387+
This class is a thin wrapper around pydicom to read a binary DICOM
388+
byte string. If pydicom is available, content is exposed as a Dicom Dataset.
389+
Otherwise, this silently falls back to the standard NiftiExtension class
390+
and content is the raw bytestring loaded directly from the nifti file
391+
header.
392+
"""
393+
def __init__(self, code, content, parent_hdr=None):
394+
"""
395+
Parameters
396+
----------
397+
code : int or str
398+
Canonical extension code as defined in the NIfTI standard, given
399+
either as integer or corresponding label
400+
(see :data:`~nibabel.nifti1.extension_codes`)
401+
content : bytes or pydicom Dataset or None
402+
Extension content - either a bytestring as read from the NIfTI file
403+
header or an existing pydicom Dataset. If a bystestring, the content
404+
is converted into a Dataset on initialization. If None, a new empty
405+
Dataset is created.
406+
parent_hdr : :class:`~nibabel.nifti1.Nifti1Header`, optional
407+
If a dicom extension belongs to an existing
408+
:class:`~nibabel.nifti1.Nifti1Header`, it may be provided here to
409+
ensure that the DICOM dataset is written with correctly corresponding
410+
endianness; otherwise it is assumed the dataset is little endian.
411+
412+
Notes
413+
-----
414+
415+
code should always be 2 for DICOM.
416+
"""
417+
418+
self._code = code
419+
if parent_hdr:
420+
self._is_little_endian = parent_hdr.endianness == '<'
421+
else:
422+
self._is_little_endian = True
423+
if isinstance(content, pdcm.dataset.Dataset):
424+
self._is_implicit_VR = False
425+
self._raw_content = self._mangle(content)
426+
self._content = content
427+
elif isinstance(content, bytes): # Got a byte string - unmangle it
428+
self._raw_content = content
429+
self._is_implicit_VR = self._guess_implicit_VR()
430+
ds = self._unmangle(content, self._is_implicit_VR,
431+
self._is_little_endian)
432+
self._content = ds
433+
elif content is None: # initialize a new dicom dataset
434+
self._is_implicit_VR = False
435+
self._content = pdcm.dataset.Dataset()
436+
else:
437+
raise TypeError("content must be either a bytestring or a pydicom "
438+
"Dataset. Got %s" % content.__class__)
439+
440+
def _guess_implicit_VR(self):
441+
"""Try to guess DICOM syntax by checking for valid VRs.
442+
443+
Without a DICOM Transfer Syntax, it's difficult to tell if Value
444+
Representations (VRs) are included in the DICOM encoding or not.
445+
This reads where the first VR would be and checks it against a list of
446+
valid VRs
447+
"""
448+
potential_vr = self._raw_content[4:6].decode()
449+
if potential_vr in pdcm.values.converters.keys():
450+
implicit_VR = False
451+
else:
452+
implicit_VR = True
453+
return implicit_VR
454+
455+
def _unmangle(self, value, is_implicit_VR=False, is_little_endian=True):
456+
bio = BytesIO(value)
457+
ds = pdcm.filereader.read_dataset(bio,
458+
is_implicit_VR,
459+
is_little_endian)
460+
return ds
461+
462+
def _mangle(self, dataset):
463+
bio = BytesIO()
464+
dio = pdcm.filebase.DicomFileLike(bio)
465+
dio.is_implicit_VR = self._is_implicit_VR
466+
dio.is_little_endian = self._is_little_endian
467+
ds_len = pdcm.filewriter.write_dataset(dio, dataset)
468+
dio.seek(0)
469+
return dio.read(ds_len)
470+
471+
382472
# NIfTI header extension type codes (ECODE)
383473
# see nifti1_io.h for a complete list of all known extensions and
384474
# references to their description or contacts of the respective
385475
# initiators
386476
extension_codes = Recoder((
387477
(0, "ignore", Nifti1Extension),
388-
(2, "dicom", Nifti1Extension),
478+
(2, "dicom", Nifti1DicomExtension if have_dicom else Nifti1Extension),
389479
(4, "afni", Nifti1Extension),
390480
(6, "comment", Nifti1Extension),
391481
(8, "xcede", Nifti1Extension),

0 commit comments

Comments
 (0)