Skip to content

Commit 64fde4b

Browse files
matthew-brettmoloney
authored andcommitted
RF: refactor of PR419 using some regexps
Also some numpy fancification of the nice error minimization of the AS string selection.
1 parent 5be8d58 commit 64fde4b

File tree

2 files changed

+94
-73
lines changed

2 files changed

+94
-73
lines changed

nibabel/nicom/tests/test_utils.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def test_tm_to_seconds():
6464
assert_almost_equal(tm_to_seconds('010101'), 61*60 + 1)
6565
assert_almost_equal(tm_to_seconds('010101.001'), 61*60 + 1.001)
6666
assert_almost_equal(tm_to_seconds('01:01:01.001'), 61*60 + 1.001)
67+
assert_almost_equal(tm_to_seconds('02:03'), 123 * 60)
6768

6869

6970
def test_tm_rt():
@@ -82,5 +83,15 @@ def test_as_to_years():
8283

8384

8485
def test_as_rt():
85-
for as_val in ('1Y', '53Y', '2M', '2W', '2D'):
86+
# Round trip
87+
for as_val in ('1Y', '53Y', '153Y',
88+
'2M', '42M', '200M',
89+
'2W', '42W', '930W',
90+
'2D', '45D', '999D'):
8691
assert as_val == years_to_as(as_to_years(as_val))
92+
# Any day multiple of 7 may be represented as weeks
93+
for as_val, other_as_val in (('7D', '1W'),
94+
('14D', '2W'),
95+
('21D', '3W'),
96+
('42D', '6W')):
97+
assert years_to_as(as_to_years(as_val)) in (as_val, other_as_val)

nibabel/nicom/utils.py

Lines changed: 82 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
"""
33

44
import re, string
5+
56
from numpy.compat.py3k import asstr
7+
import numpy as np
8+
9+
from ..externals import OrderedDict
610

711

812
def find_private_section(dcm_data, group_no, creator):
@@ -53,9 +57,16 @@ def find_private_section(dcm_data, group_no, creator):
5357
return None
5458

5559

60+
TM_EXP = re.compile(r"^(\d\d)(\d\d)?(\d\d)?(\.\d+)?$")
61+
# Allow ACR/NEMA style format which includes colons between hours/minutes and
62+
# minutes/seconds. See TM / time description in PS3.5 of the DICOM standard at
63+
# http://dicom.nema.org/Dicom/2011/11_05pu.pdf
64+
TM_EXP_1COLON = re.compile(r"^(\d\d):(\d\d)()?()?$")
65+
TM_EXP_2COLONS = re.compile(r"^(\d\d):(\d\d):(\d\d)?(\.\d+)?$")
66+
67+
5668
def tm_to_seconds(time_str):
57-
'''Convert a DICOM time value (value representation of 'TM') to the number
58-
of seconds past midnight.
69+
'''Convert DICOM time value (VR of 'TM') to seconds past midnight.
5970
6071
Parameters
6172
----------
@@ -66,45 +77,36 @@ def tm_to_seconds(time_str):
6677
-------
6778
sec_past_midnight : float
6879
The number of seconds past midnight
80+
81+
Notes
82+
-----
83+
From TM / time description in `PS3.5 of the DICOM standard
84+
<http://dicom.nema.org/Dicom/2011/11_05pu.pdf>`_::
85+
86+
A string of characters of the format HHMMSS.FFFFFF; where HH contains
87+
hours (range "00" - "23"), MM contains minutes (range "00" - "59"), SS
88+
contains seconds (range "00" - "60"), and FFFFFF contains a fractional
89+
part of a second as small as 1 millionth of a second (range “000000” -
90+
“999999”). A 24-hour clock is used. Midnight shall be represented by
91+
only “0000“ since “2400“ would violate the hour range. The string may
92+
be padded with trailing spaces. Leading and embedded spaces are not
93+
allowed.
94+
95+
One or more of the components MM, SS, or FFFFFF may be unspecified as
96+
long as every component to the right of an unspecified component is
97+
also unspecified, which indicates that the value is not precise to the
98+
precision of those unspecified components.
6999
'''
70100
# Allow trailing white space
71101
time_str = time_str.rstrip()
72-
73-
# Allow ACR/NEMA style format which includes colons between hours/minutes
74-
# and minutes/seconds
75-
colons = [x.start() for x in re.finditer(':', time_str)]
76-
if len(colons) > 0:
77-
if colons not in ([2], [2, 5]):
78-
raise ValueError("Invalid use of colons in 'TM' VR")
79-
time_str = time_str.replace(':', '')
80-
81-
# Make sure the string length is valid
82-
str_len = len(time_str)
83-
is_valid = str_len > 0
84-
if str_len <= 6:
85-
# If there are six or less chars, there should be an even number
86-
if str_len % 2 != 0:
87-
is_valid = False
102+
for matcher in (TM_EXP, TM_EXP_1COLON, TM_EXP_2COLONS):
103+
match = matcher.match(time_str)
104+
if match is not None:
105+
break
88106
else:
89-
# If there are more than six chars, the seventh position should be
90-
# a decimal followed by at least one digit
91-
if str_len == 7 or time_str[6] != '.':
92-
is_valid = False
93-
if not is_valid:
94-
raise ValueError("Invalid number of digits for 'TM' VR")
95-
96-
# Make sure we don't have leading white space
97-
if time_str[0] in string.whitespace:
98-
raise ValueError("Leading whitespace not allowed in 'TM' VR")
99-
100-
# The minutes and seconds are optional
101-
result = int(time_str[:2]) * 3600
102-
if str_len > 2:
103-
result += int(time_str[2:4]) * 60
104-
if str_len > 4:
105-
result += float(time_str[4:])
106-
107-
return float(result)
107+
raise ValueError('Invalid tm string "{0}"'.format(time_str))
108+
parts = [float(v) if v else 0 for v in match.groups()]
109+
return np.multiply(parts, [3600, 60, 1, 1]).sum()
108110

109111

110112
def seconds_to_tm(seconds):
@@ -119,18 +121,25 @@ def seconds_to_tm(seconds):
119121
-------
120122
tm : str
121123
String suitable for use as value in DICOM element with VR of 'TM'
124+
125+
Notes
126+
-----
127+
See docstring for :func:`tm_to_seconds`.
122128
'''
123-
hours = seconds // 3600
124-
seconds -= hours * 3600
125-
minutes = seconds // 60
126-
seconds -= minutes * 60
127-
res = '%02d%02d%08.5f' % (hours, minutes, seconds)
128-
return res
129+
hours, seconds = divmod(seconds, 3600)
130+
minutes, seconds = divmod(seconds, 60)
131+
return '%02d%02d%08.5f' % (hours, minutes, seconds)
132+
133+
134+
CONVERSIONS = OrderedDict((('Y', 1), ('M', 12), ('W', (365. / 7)), ('D', 365)))
135+
CONV_KEYS = list(CONVERSIONS)
136+
CONV_VALS = np.array(list(CONVERSIONS.values()))
137+
138+
AGE_EXP = re.compile(r'^(\d+)(Y|M|W|D)?$')
129139

130140

131141
def as_to_years(age_str):
132-
'''Convert a DICOM age value (value representation of 'AS') to the age in
133-
years.
142+
'''Convert DICOM age value (VR of 'AS') to the age in years
134143
135144
Parameters
136145
----------
@@ -141,18 +150,23 @@ def as_to_years(age_str):
141150
-------
142151
age : float
143152
The age of the subject in years
153+
154+
Notes
155+
-----
156+
From AS / age string description in `PS3.5 of the DICOM standard
157+
<http://dicom.nema.org/Dicom/2011/11_05pu.pdf>`_::
158+
159+
A string of characters with one of the following formats -- nnnD, nnnW,
160+
nnnM, nnnY; where nnn shall contain the number of days for D, weeks for
161+
W, months for M, or years for Y. Example: “018M” would represent an
162+
age of 18 months.
144163
'''
145-
age_str = age_str.strip()
146-
if age_str[-1] == 'Y':
147-
return float(age_str[:-1])
148-
elif age_str[-1] == 'M':
149-
return float(age_str[:-1]) / 12
150-
elif age_str[-1] == 'W':
151-
return float(age_str[:-1]) / (365. / 7)
152-
elif age_str[-1] == 'D':
153-
return float(age_str[:-1]) / 365
154-
else:
155-
return float(age_str)
164+
match = AGE_EXP.match(age_str.strip())
165+
if not match:
166+
raise ValueError('Invalid age string "{0}"'.format(age_str))
167+
val, code = match.groups()
168+
code = 'Y' if code is None else code
169+
return float(val) / CONVERSIONS[code]
156170

157171

158172
def years_to_as(years):
@@ -167,22 +181,18 @@ def years_to_as(years):
167181
-------
168182
as : str
169183
String suitable for use as value in DICOM element with VR of 'AS'
184+
185+
Notes
186+
-----
187+
See docstring for :func:`as_to_years`.
170188
'''
171189
if years == round(years):
172190
return '%dY' % years
173-
174-
# Choose how to represent the age (years, months, weeks, or days)
175-
conversions = (('Y', 1), ('M', 12), ('W', (365. / 7)), ('D', 365))
176-
# Try all the conversions, ignore ones that have more than three digits
177-
# which is the limit for the AS value representation, or where they round
178-
# to zero
179-
results = [(years * x[1], x[0]) for x in conversions]
180-
results = [x for x in results
181-
if round(x[0]) > 0 and len('%d' % x[0]) < 4]
182-
# Choose the first one that is close to the minimum error
183-
errors = [abs(x[0] - round(x[0])) for x in results]
184-
min_error = min(errors)
185-
best_idx = 0
186-
while errors[best_idx] - min_error > 0.001:
187-
best_idx += 1
188-
return '%d%s' % (round(results[best_idx][0]), results[best_idx][1])
191+
# Choose how to represent the age (years, months, weeks, or days).
192+
# Try all the conversions, ignore ones that have more than three digits,
193+
# which is the limit for the AS value representation.
194+
conved = years * CONV_VALS
195+
conved[conved >= 1000] = np.nan # Too many digits for AS field
196+
year_error = np.abs(conved - np.round(conved)) / CONV_VALS
197+
best_i = np.nanargmin(year_error)
198+
return "{0:.0f}{1}".format(conved[best_i], CONV_KEYS[best_i])

0 commit comments

Comments
 (0)