Skip to content

ENH: Add some conversions for DICOM values #419

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

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 49 additions & 1 deletion nibabel/nicom/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
"""
import re

from ..utils import find_private_section
import pytest
from numpy.testing import (assert_almost_equal,
assert_array_equal)

from ..utils import (find_private_section, seconds_to_tm, tm_to_seconds,
as_to_years, years_to_as)

from . import dicom_test
from ...pydicom_compat import pydicom
Expand Down Expand Up @@ -47,3 +52,46 @@ def test_find_private_section_real():
assert find_private_section(ds, 0x11, 'near section') == 0x1300
ds.add_new((0x11, 0x15), 'LO', b'far section')
assert find_private_section(ds, 0x11, 'far section') == 0x1500


def test_tm_to_seconds():
for str_val in ('', '1', '111', '11111', '111111.', '1111111', '1:11',
' 111'):
with pytest.raises(ValueError):
tm_to_seconds(str_val)
assert_almost_equal(tm_to_seconds('01'), 60*60)
assert_almost_equal(tm_to_seconds('0101'), 61*60)
assert_almost_equal(tm_to_seconds('010101'), 61*60 + 1)
assert_almost_equal(tm_to_seconds('010101.001'), 61*60 + 1.001)
assert_almost_equal(tm_to_seconds('01:01:01.001'), 61*60 + 1.001)
Copy link
Member

Choose a reason for hiding this comment

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

Add single colon case?

assert_almost_equal(tm_to_seconds('02:03'), 123 * 60)


def test_tm_rt():
for tm_val in ('010101.00000', '010101.00100', '122432.12345'):
assert tm_val == seconds_to_tm(tm_to_seconds(tm_val))


def test_as_to_years():
assert as_to_years('1') == 1.0
assert as_to_years('1Y') == 1.0
assert as_to_years('53') == 53.0
assert as_to_years('53Y') == 53.0
assert_almost_equal(as_to_years('2M'), 2. / 12.)
assert_almost_equal(as_to_years('2D'), 2. / 365.)
assert_almost_equal(as_to_years('2W'), 2. * (7. / 365.))


def test_as_rt():
# Round trip
for as_val in ('1Y', '53Y', '153Y',
'2M', '42M', '200M',
'2W', '42W', '930W',
'2D', '45D', '999D'):
assert as_val == years_to_as(as_to_years(as_val))
# Any day multiple of 7 may be represented as weeks
for as_val, other_as_val in (('7D', '1W'),
('14D', '2W'),
('21D', '3W'),
('42D', '6W')):
assert years_to_as(as_to_years(as_val)) in (as_val, other_as_val)
146 changes: 146 additions & 0 deletions nibabel/nicom/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
""" Utilities for working with DICOM datasets
"""

import re

from numpy.compat.py3k import asstr
import numpy as np

from ..externals import OrderedDict


def find_private_section(dcm_data, group_no, creator):
Expand Down Expand Up @@ -50,3 +55,144 @@ def find_private_section(dcm_data, group_no, creator):
if creator == name:
return elno * 0x100
return None


TM_EXP = re.compile(r"^(\d\d)(\d\d)?(\d\d)?(\.\d+)?$")
# Allow ACR/NEMA style format which includes colons between hours/minutes and
# minutes/seconds. See TM / time description in PS3.5 of the DICOM standard at
# http://dicom.nema.org/Dicom/2011/11_05pu.pdf
TM_EXP_1COLON = re.compile(r"^(\d\d):(\d\d)()?()?$")
TM_EXP_2COLONS = re.compile(r"^(\d\d):(\d\d):(\d\d)?(\.\d+)?$")


def tm_to_seconds(time_str):
'''Convert DICOM time value (VR of 'TM') to seconds past midnight.

Parameters
----------
time_str : str
The string value from the DICOM element

Returns
-------
sec_past_midnight : float
The number of seconds past midnight

Notes
-----
From TM / time description in `PS3.5 of the DICOM standard
<http://dicom.nema.org/Dicom/2011/11_05pu.pdf>`_::

A string of characters of the format HHMMSS.FFFFFF; where HH contains
hours (range "00" - "23"), MM contains minutes (range "00" - "59"), SS
contains seconds (range "00" - "60"), and FFFFFF contains a fractional
part of a second as small as 1 millionth of a second (range “000000” -
“999999”). A 24-hour clock is used. Midnight shall be represented by
only “0000“ since “2400“ would violate the hour range. The string may
be padded with trailing spaces. Leading and embedded spaces are not
allowed.

One or more of the components MM, SS, or FFFFFF may be unspecified as
long as every component to the right of an unspecified component is
also unspecified, which indicates that the value is not precise to the
precision of those unspecified components.
'''
Copy link
Member

Choose a reason for hiding this comment

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

Have you got a good reference for the rules you're using below?

# Allow trailing white space
time_str = time_str.rstrip()
for matcher in (TM_EXP, TM_EXP_1COLON, TM_EXP_2COLONS):
match = matcher.match(time_str)
if match is not None:
break
else:
raise ValueError('Invalid tm string "{0}"'.format(time_str))
parts = [float(v) if v else 0 for v in match.groups()]
return np.multiply(parts, [3600, 60, 1, 1]).sum()


def seconds_to_tm(seconds):
'''Convert a float representing seconds past midnight into DICOM TM value

Parameters
----------
seconds : float
Number of seconds past midnights
Copy link
Member

Choose a reason for hiding this comment

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

Typo midnight


Returns
-------
tm : str
String suitable for use as value in DICOM element with VR of 'TM'

Notes
-----
See docstring for :func:`tm_to_seconds`.
'''
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
return '%02d%02d%08.5f' % (hours, minutes, seconds)


CONVERSIONS = OrderedDict((('Y', 1), ('M', 12), ('W', (365. / 7)), ('D', 365)))
CONV_KEYS = list(CONVERSIONS)
CONV_VALS = np.array(list(CONVERSIONS.values()))

AGE_EXP = re.compile(r'^(\d+)(Y|M|W|D)?$')


def as_to_years(age_str):
'''Convert DICOM age value (VR of 'AS') to the age in years

Copy link
Member

Choose a reason for hiding this comment

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

Add reference to http://dicom.nema.org/Dicom/2011/11_05pu.pdf "age string" ?

Parameters
----------
age_str : str
The string value from the DICOM element

Returns
-------
age : float
The age of the subject in years

Notes
-----
From AS / age string description in `PS3.5 of the DICOM standard
<http://dicom.nema.org/Dicom/2011/11_05pu.pdf>`_::

A string of characters with one of the following formats -- nnnD, nnnW,
nnnM, nnnY; where nnn shall contain the number of days for D, weeks for
W, months for M, or years for Y. Example: “018M” would represent an
age of 18 months.
'''
match = AGE_EXP.match(age_str.strip())
if not match:
raise ValueError('Invalid age string "{0}"'.format(age_str))
val, code = match.groups()
code = 'Y' if code is None else code
return float(val) / CONVERSIONS[code]


def years_to_as(years):
'''Convert float representing age in years to DICOM 'AS' value

Parameters
----------
years : float
The years of age

Returns
-------
as : str
String suitable for use as value in DICOM element with VR of 'AS'

Notes
-----
See docstring for :func:`as_to_years`.
'''
if years == round(years):
return '%dY' % years
Copy link
Member

Choose a reason for hiding this comment

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

String doesn't have to be zero padded, length 4?

# Choose how to represent the age (years, months, weeks, or days).
# Try all the conversions, ignore ones that have more than three digits,
# which is the limit for the AS value representation.
conved = years * CONV_VALS
conved[conved >= 1000] = np.nan # Too many digits for AS field
year_error = np.abs(conved - np.round(conved)) / CONV_VALS
best_i = np.nanargmin(year_error)
return "{0:.0f}{1}".format(conved[best_i], CONV_KEYS[best_i])