diff --git a/nibabel/nicom/tests/test_utils.py b/nibabel/nicom/tests/test_utils.py index 142daa3d16..422ecf6e27 100644 --- a/nibabel/nicom/tests/test_utils.py +++ b/nibabel/nicom/tests/test_utils.py @@ -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 @@ -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) + 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) diff --git a/nibabel/nicom/utils.py b/nibabel/nicom/utils.py index f1d5810775..fbb8d87e1a 100644 --- a/nibabel/nicom/utils.py +++ b/nibabel/nicom/utils.py @@ -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): @@ -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 + `_:: + + 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. + ''' + # 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 + + 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 + + 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 + `_:: + + 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 + # 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])