Skip to content

ENH: Add Nifti1DicomExtension + test #296

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
Mar 24, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0d115dd
Add Nifti1DicomExtension + test
kastman Feb 12, 2015
6bb904d
Add @dicom_test decorator to Pass w/o pydicom
kastman Feb 13, 2015
f591ee3
Use BytesIO (py3k compatibility)
kastman Feb 13, 2015
267304c
Fix BytesIO import (again), py3.3 compat
kastman Feb 13, 2015
dbe3946
Fix writing typo, add writing tests
kastman Feb 13, 2015
b5b6550
Zeropad Extension to 16 bytes, test writing
kastman Feb 14, 2015
a2719d3
A little cleanup
kastman Feb 14, 2015
32fd4b1
Read full datasets (with TransferSyntax)
kastman Feb 16, 2015
bf75e49
Use write_dataset for pydicom < 0.9.9 compat
kastman Feb 16, 2015
9d0cdee
PEP8 Whitespace
kastman Feb 16, 2015
947034d
Test TransferSyntax, fix zipped reading.
kastman Feb 16, 2015
3f35e5c
Revert "Read Full Datasets Using TransferSyntax"
kastman Feb 16, 2015
cf72525
Revert "PEP8 Whitespace"
kastman Feb 16, 2015
58b517c
Revert "Use write_dataset for pydicom < 0.9.9 compat"
kastman Feb 16, 2015
40d24dd
Revert "Read full datasets (with TransferSyntax)"
kastman Feb 16, 2015
e690148
PEP8 + consistency cleanup
kastman Feb 16, 2015
336f6bb
Create empty dataset or use one if provided
kastman Feb 17, 2015
cc3af8e
Add Doc page on NIfTI header
kastman Feb 17, 2015
d897a62
PEP8 and header warning (doc)
kastman Feb 17, 2015
13ae9ff
Import and doc cleanup
kastman Mar 26, 2015
f11ccf9
PEP8 Cleanup
kastman Mar 18, 2016
44f7430
NiftiHeader determines dicom byte encoding in extension
kastman Mar 18, 2016
0f897b7
BF: a couple of tiny fixes
matthew-brett Mar 18, 2016
e9b6256
Merge master branch into update-plus-for-296
matthew-brett Mar 19, 2016
2065503
RF: move dicom / pydicom imports into own module
matthew-brett Mar 18, 2016
14af19c
STY: remove a couple of blank lines for PEP8
matthew-brett Mar 19, 2016
8534bb4
Merge pull request #1 from matthew-brett/update-plus-for-296
kastman Mar 19, 2016
65de3fe
DOC Add docstring to DicomExtension __init__
kastman Mar 19, 2016
674bb25
RF Type cleanup from MB’s suggestions
kastman Mar 19, 2016
5e37a58
BF Import pydicom.values if first import is successful
kastman Mar 19, 2016
baddbde
BF Correct dicom import
kastman Mar 19, 2016
8fb8616
TST Assert TypeError for bad content type
kastman Mar 19, 2016
d87ee32
BF Remove unnecessary VR validation
kastman Mar 20, 2016
e066203
DOC Correct docstring per numpy guidelines
kastman Mar 20, 2016
71a3ce4
TST Add empty content test case
kastman Mar 20, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Changelog
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ References like "pr/298" refer to github pull request numbers.

* Upcoming

* Read and write support for DICOM tags in NIfTI Extended Header using
pydicom (pr/296);
* Trackvis reader will now allow final streamline to have fewer points that
tne numbe declared in the header, with ``strict=False`` argument to
``read`` function;
Expand Down
1 change: 1 addition & 0 deletions doc/source/dicom/dicom.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Contents:
dicom_mosaic
siemens_csa
spm_dicom
dicom_niftiheader

.. these documents not yet ready for public advertisement

Expand Down
73 changes: 73 additions & 0 deletions doc/source/dicom/dicom_niftiheader.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
.. _dicom-niftiheader:

##############################
DICOM Tags in the NIfTI Header
##############################

NIfTI images include an extended header (see the `NIfTI Extensions Standard`_)
to store, amongst others, DICOM tags and attributes. When NiBabel loads a NIfTI
file containing DICOM information (a NIfTI extension with ``ecode == 2``), it
parses it and returns a pydicom dataset as the content of the NIfTI extension.
This can be read and written to in order to facilitate communication with
software that uses specific DICOM codes found in the NIfTI header.

For example, the commercial PMOD software stores the Frame Start and Duration
times of images using the DICOM tags (0055, 1001) and (0055, 1004). Here's an
example of an image created in PMOD with those stored times accessed through
nibabel.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe mention that dicom headers read as generic if pydicom not installed?

.. code:: python

>> import nibabel as nib
>> nim = nib.load('pmod_pet.nii')
>> dcmext = nim.header.extensions[0]
>> dcmext
Nifti1Extension('dicom', '(0054, 1001) Units CS: 'Bq/ml'
(0055, 0010) Private Creator LO: 'PMOD_1'
(0055, 1001) [Frame Start Times Vector] FD: [0.0, 30.0, 60.0, ..., 13720.0, 14320.0]
(0055, 1004) [Frame Durations (ms) Vector] FD: [30000.0, 30000.0, 30000.0,600000.0, 600000.0]'))

+-------------+--------------------------------+---------------------------------------------------------+
| Tag | Name | Value |
+=============+================================+=========================================================+
| (0054, 1001)| Units | CS: 'Bq/ml' |
+-------------+--------------------------------+---------------------------------------------------------+
|(0055, 0010) | Private Creator | LO: 'PMOD_1' |
+-------------+--------------------------------+---------------------------------------------------------+
|(0055, 1001) | [Frame Start Times Vector] | FD: [0.0, 30.0, 60.0, ..., 13720.0, 14320.0 |
+-------------+--------------------------------+---------------------------------------------------------+
|(0055, 1004) | [Frame Durations (ms) Vector] | FD: [30000.0, 30000.0, 30000.0, ..., 600000.0, 600000.0 |
+-------------+--------------------------------+---------------------------------------------------------+

Access each value as you would with pydicom::

>> ds = dcmext.get_content()
>> start_times = ds[0x0055, 0x1001].value
>> durations = ds[0x0055, 0x1004].value

Creating a PMOD-compatible header is just as easy::

>> nim = nib.load('pet.nii')
>> nim.header.extensions
[]
>> from dicom.dataset import Dataset
>> ds = Dataset()
>> ds.add_new((0x0054,0x1001),'CS','Bq/ml')
>> ds.add_new((0x0055,0x0010),'LO','PMOD_1')
>> ds.add_new((0x0055,0x1001),'FD',[0.,30.,60.,13720.,14320.])
>> ds.add_new((0x0055,0x1004),'FD',[30000.,30000.,30000.,600000.,600000.])
>> dcmext = nib.nifti1.Nifti1DicomExtension(2,ds) # Use DICOM ecode 2
>> nim.header.extensions.append(dcmext)
>> nib.save(nim,'pet_withdcm.nii')

Be careful! Many imaging tools don't maintain information in the extended
header, so it's possible [likely] that this information may be lost during
routine use. You'll have to keep track, and re-write the information if
required.

Optional Dependency Note: If pydicom is not installed, nibabel uses a generic
:class:`nibabel.nifti1.Nifti1Extension` header instead of parsing DICOM data.

.. _`NIfTI Extensions Standard`: http://nifti.nimh.nih.gov/nifti-1/documentation/nifti1fields/nifti1fields_pages/extension.html

.. include:: links_names.txt
10 changes: 4 additions & 6 deletions nibabel/dft.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@

from .nifti1 import Nifti1Header

# Shield optional dicom import
from .optpkg import optional_package
dicom, have_dicom, _ = optional_package('dicom')
from .pydicom_compat import pydicom, read_file

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

Expand Down Expand Up @@ -236,7 +234,7 @@ def __getattribute__(self, name):
return val

def dicom(self):
return dicom.read_file(self.files[0])
return read_file(self.files[0])


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

def _update_file(c, path, fname, studies, series, storage_instances):
try:
do = dicom.read_file('%s/%s' % (path, fname))
except dicom.filereader.InvalidDicomError:
do = read_file('%s/%s' % (path, fname))
except pydicom.filereader.InvalidDicomError:
logger.debug(' not a DICOM file')
return None
try:
Expand Down
5 changes: 1 addition & 4 deletions nibabel/nicom/dicomwrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,7 @@ def wrapper_from_file(file_like, *args, **kwargs):
dcm_w : ``dicomwrappers.Wrapper`` or subclass
DICOM wrapper corresponding to DICOM data type
"""
try:
from dicom import read_file
except ImportError:
from pydicom.dicomio import read_file
from ..pydicom_compat import read_file

with ImageOpener(file_like) as fobj:
dcm_data = read_file(fobj, *args, **kwargs)
Expand Down
6 changes: 2 additions & 4 deletions nibabel/nicom/tests/test_csareader.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,8 @@

from numpy.testing.decorators import skipif

from .test_dicomwrappers import (have_dicom, dicom_test,
IO_DATA_PATH, DATA, DATA_FILE)
if have_dicom:
from .test_dicomwrappers import pydicom
from nibabel.pydicom_compat import dicom_test, pydicom
from .test_dicomwrappers import (IO_DATA_PATH, DATA)

CSA2_B0 = open(pjoin(IO_DATA_PATH, 'csa2_b0.bin'), 'rb').read()
CSA2_B1000 = open(pjoin(IO_DATA_PATH, 'csa2_b1000.bin'), 'rb').read()
Expand Down
11 changes: 4 additions & 7 deletions nibabel/nicom/tests/test_dicomreaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@

from .. import dicomreaders as didr

from .test_dicomwrappers import (dicom_test,
EXPECTED_AFFINE,
from nibabel.pydicom_compat import dicom_test, pydicom

from .test_dicomwrappers import (EXPECTED_AFFINE,
EXPECTED_PARAMS,
IO_DATA_PATH,
DATA)
Expand Down Expand Up @@ -41,10 +42,6 @@ def test_passing_kwds():
# Check that we correctly pass keywords to dicom
dwi_glob = 'siemens_dwi_*.dcm.gz'
csa_glob = 'csa*.bin'
try:
from dicom.filereader import InvalidDicomError
except ImportError:
from pydicom.filereader import InvalidDicomError
for func in (didr.read_mosaic_dwi_dir, didr.read_mosaic_dir):
data, aff, bs, gs = func(IO_DATA_PATH, dwi_glob)
# This should not raise an error
Expand All @@ -60,7 +57,7 @@ def test_passing_kwds():
dwi_glob,
dicom_kwargs=dict(not_a_parameter=True))
# These are invalid dicoms, so will raise an error unless force=True
assert_raises(InvalidDicomError,
assert_raises(pydicom.filereader.InvalidDicomError,
func,
IO_DATA_PATH,
csa_glob)
Expand Down
14 changes: 1 addition & 13 deletions nibabel/nicom/tests/test_dicomwrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,7 @@

import numpy as np

have_dicom = True
try:
import dicom as pydicom
read_file = pydicom.read_file
except ImportError:
try:
import pydicom
except ImportError:
have_dicom = False
else:
from pydicom.dicomio import read_file
dicom_test = np.testing.dec.skipif(not have_dicom,
'could not import pydicom')
from nibabel.pydicom_compat import have_dicom, pydicom, read_file, dicom_test

from .. import dicomwrappers as didw
from .. import dicomreaders as didr
Expand Down
10 changes: 3 additions & 7 deletions nibabel/nicom/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@

from ..utils import find_private_section

from .test_dicomwrappers import (have_dicom, dicom_test,
IO_DATA_PATH, DATA, DATA_PHILIPS)
from nibabel.pydicom_compat import dicom_test, pydicom
from .test_dicomwrappers import (DATA, DATA_PHILIPS)


@dicom_test
Expand All @@ -27,11 +27,7 @@ def test_find_private_section_real():
assert_equal(find_private_section(DATA_PHILIPS, 0x29, 'SIEMENS CSA HEADER'),
None)
# Make fake datasets
try:
from dicom.dataset import Dataset
except ImportError:
from pydicom.dataset import Dataset
ds = Dataset({})
ds = pydicom.dataset.Dataset({})
ds.add_new((0x11, 0x10), 'LO', b'some section')
assert_equal(find_private_section(ds, 0x11, 'some section'), 0x1000)
ds.add_new((0x11, 0x11), 'LO', b'anther section')
Expand Down
94 changes: 92 additions & 2 deletions nibabel/nifti1.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
'''
from __future__ import division, print_function
import warnings
from io import BytesIO

import numpy as np
import numpy.linalg as npl
Expand All @@ -24,6 +25,7 @@
from . import analyze # module import
from .spm99analyze import SpmAnalyzeHeader
from .casting import have_binary128
from .pydicom_compat import have_dicom, pydicom as pdcm

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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PEP8 - two lines between classes, two lines between functions one line between methods.


class Nifti1DicomExtension(Nifti1Extension):
"""NIfTI1 DICOM header extension

This class is a thin wrapper around pydicom to read a binary DICOM
byte string. If pydicom is available, content is exposed as a Dicom Dataset.
Otherwise, this silently falls back to the standard NiftiExtension class
and content is the raw bytestring loaded directly from the nifti file
header.
"""
def __init__(self, code, content, parent_hdr=None):
"""
Parameters
----------
code : int or str
Canonical extension code as defined in the NIfTI standard, given
either as integer or corresponding label
(see :data:`~nibabel.nifti1.extension_codes`)
content : bytes or pydicom Dataset or None
Extension content - either a bytestring as read from the NIfTI file
header or an existing pydicom Dataset. If a bystestring, the content
is converted into a Dataset on initialization. If None, a new empty
Dataset is created.
parent_hdr : :class:`~nibabel.nifti1.Nifti1Header`, optional
If a dicom extension belongs to an existing
:class:`~nibabel.nifti1.Nifti1Header`, it may be provided here to
ensure that the DICOM dataset is written with correctly corresponding
endianness; otherwise it is assumed the dataset is little endian.

Notes
-----

code should always be 2 for DICOM.
"""

self._code = code
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring here with parameters definitions would be useful.

if parent_hdr:
self._is_little_endian = parent_hdr.endianness == '<'
else:
self._is_little_endian = True
if isinstance(content, pdcm.dataset.Dataset):
self._is_implicit_VR = False
self._raw_content = self._mangle(content)
self._content = content
elif isinstance(content, bytes): # Got a byte string - unmangle it
self._raw_content = content
self._is_implicit_VR = self._guess_implicit_VR()
ds = self._unmangle(content, self._is_implicit_VR,
self._is_little_endian)
self._content = ds
elif content is None: # initialize a new dicom dataset
self._is_implicit_VR = False
self._content = pdcm.dataset.Dataset()
else:
raise TypeError("content must be either a bytestring or a pydicom "
"Dataset. Got %s" % content.__class__)

def _guess_implicit_VR(self):
"""Try to guess DICOM syntax by checking for valid VRs.

Without a DICOM Transfer Syntax, it's difficult to tell if Value
Representations (VRs) are included in the DICOM encoding or not.
This reads where the first VR would be and checks it against a list of
valid VRs
"""
potential_vr = self._raw_content[4:6].decode()
if potential_vr in pdcm.values.converters.keys():
implicit_VR = False
else:
implicit_VR = True
return implicit_VR

def _unmangle(self, value, is_implicit_VR=False, is_little_endian=True):
bio = BytesIO(value)
ds = pdcm.filereader.read_dataset(bio,
is_implicit_VR,
is_little_endian)
return ds

def _mangle(self, dataset):
bio = BytesIO()
dio = pdcm.filebase.DicomFileLike(bio)
dio.is_implicit_VR = self._is_implicit_VR
dio.is_little_endian = self._is_little_endian
ds_len = pdcm.filewriter.write_dataset(dio, dataset)
dio.seek(0)
return dio.read(ds_len)


# NIfTI header extension type codes (ECODE)
# see nifti1_io.h for a complete list of all known extensions and
# references to their description or contacts of the respective
# initiators
extension_codes = Recoder((
(0, "ignore", Nifti1Extension),
(2, "dicom", Nifti1Extension),
(2, "dicom", Nifti1DicomExtension if have_dicom else Nifti1Extension),
(4, "afni", Nifti1Extension),
(6, "comment", Nifti1Extension),
(8, "xcede", Nifti1Extension),
Expand Down
2 changes: 1 addition & 1 deletion nibabel/parrec.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
import re
from io import StringIO
from locale import getpreferredencoding
from collections import OrderedDict
from nibabel.externals import OrderedDict

from .keywordonly import kw_only_meth
from .spatialimages import SpatialHeader, SpatialImage
Expand Down
Loading