Skip to content

Commit 6551e85

Browse files
committed
v2022.8.25 Major refactor and recoding. DICOM header based multiecho and recon-type handling. Improved auto run-numbering.
1 parent 4df6863 commit 6551e85

File tree

6 files changed

+91
-60
lines changed

6 files changed

+91
-60
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# BIDSKIT
22

3-
### Version 2022.8.24
3+
### Version 2022.8.25
44
Python utilities for converting from DICOM to BIDS neuroimaging formats.
55

66
The *bidskit* console command takes a directory tree containing imaging series from one or more subjects (eg T1w MPRAGE, BOLD EPI, Fieldmaps), converts the imaging data to Nifti-1 format with JSON metadata files (sidecars) and populates a directory tree according to the latest BIDS specification.

bidskit/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from .io import (
2+
read_json,
3+
write_json,
4+
dcm_info,
5+
parse_dcm2niix_fname,
6+
parse_bids_fname_keyvals,
7+
safe_copy,
8+
safe_mkdir,
9+
create_file_if_missing,
10+
strip_extensions,
11+
nii_to_json
12+
)

bidskit/dcm2niix.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ def organize_series(
122122
# Load JSON sidecar metadata
123123
src_meta = bio.read_json(src_json_fname)
124124

125-
# dcm2niix replaces ' ' with '_' for series description in filenames. We must do the same
125+
# DICOM series description string from BIDS sidecar
126+
# For consistency with dcm2niix, replace spaces in DICOM SerDesc (eg ' RMS') with underscores
126127
ser_desc = src_meta['SeriesDescription'].replace(' ', '_')
127128

128129
# Check if we're creating a new protocol dictionary
@@ -233,20 +234,18 @@ def handle_multiecho(work_json_fname, bids_json_fname, echo_flag, nii_ext):
233234
flag to add echo- key to filename (if necessary)
234235
"""
235236

236-
# Isolate echo/part suffix (e*[_ph])
237-
work_info = bio.parse_dcm2niix_fname(work_json_fname)
238-
suffix = work_info['Suffix']
237+
# Load BIDS sidecar metadata
238+
bids_info = bio.read_json(work_json_fname)
239239

240-
# Default BIDS Nifti filename from JSON filename
241-
bids_nii_fname = bids_json_fname.replace('.json', nii_ext)
240+
# Init Nifti image fname
241+
bids_nii_fname = bids_json_fname.replace('.json', '.nii.gz')
242242

243-
if suffix.startswith('e'):
243+
# DICOM EchoNumber tag only present for multiecho sequences
244+
if 'EchoNumber' in bids_info.keys():
244245

245-
print(' Multiple echoes detected')
246+
echo_num = bids_info['EchoNumber']
246247

247-
# Split at '_' if present
248-
chunks = suffix.split('_')
249-
echo_num = int(chunks[0][1:])
248+
print(f' Multiple echoes detected')
250249
print(f' Echo number {echo_num:d}')
251250

252251
# Add an "echo-{echo_num}" key to the BIDS Nifti and JSON filenames

bidskit/io.py

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ def parse_bids_fname_keyvals(fname):
207207

208208
# Split fname in containing directory and base name
209209
dname = os.path.dirname(fname)
210-
bname = os.path.basename(fname)
210+
bids_stub = os.path.basename(fname)
211211

212212
# Init return dictionary with BIDS 1.1.1 valid key strings
213213
bids_keys = {
@@ -228,37 +228,56 @@ def parse_bids_fname_keyvals(fname):
228228

229229
# Extract base filename and strip up to two extensions
230230
# Accounts for both '.nii' and '.nii.gz' variants
231-
bname, ext1 = os.path.splitext(bname)
232-
bname, ext2 = os.path.splitext(bname)
231+
bids_stub, ext1 = os.path.splitext(bids_stub)
232+
bids_stub, ext2 = os.path.splitext(bids_stub)
233233

234234
# Remember full extension
235235
bids_keys['extension'] = ext2 + ext1
236236

237-
# Locate, record and remove final contrast suffix
237+
#
238+
# Logic for identifying, saving and trimming sequence type suffix (_bold, _T1w, etc)
239+
#
240+
241+
# Check for recon variants keys at end of basename
242+
# These can have leading '_' or ' ' eg 'acq-mez_T1w RMS' and 'task-rest_bold_SBRef'
243+
244+
recon_list = ['SBRef', 'RMS']
245+
recon_key = ''
246+
247+
# DEBUG
248+
if bids_stub.endswith('RMS'):
249+
pass
250+
251+
for recon_str in recon_list:
252+
if bids_stub.endswith(recon_str):
253+
recon_start = bids_stub.rfind(recon_str)
254+
recon_key = recon_str
255+
bids_stub = bids_stub[:(recon_start-1)]
238256

239-
# Find position of first underscore from right of basename
240-
suffix_start = bname.rfind('_') + 1
241-
draft_suffix = bname[suffix_start:]
257+
# Find position of last underscore in basename
258+
last_underscore = bids_stub.rfind('_')
242259

243-
# Handle special case of no suffix, only key-value pairs
244-
if '-' in draft_suffix or len(draft_suffix) < 1:
260+
if last_underscore < 0:
245261

246-
# Leave suffix empty in dict
262+
# No underscores found in bids_stub - set empty suffix
247263
bids_keys['suffix'] = ''
248264

249265
else:
250266

251-
# Handle double suffices introduced by some Siemens research sequences
252-
# eg *_bold_SBRef and *_T1w_RMS
253-
# This code is only relevant when parsing ReproIn style series descriptions through this function
254-
if bname.endswith('SBRef') or bname.endswith('RMS'):
255-
# Find the second underscore in from the right
256-
tmp = bname[:(suffix_start-1)]
257-
suffix_start = tmp.rfind('_') + 1
267+
suffix_start = bids_stub.rfind('_') + 1
268+
draft_suffix = bids_stub[suffix_start:]
269+
270+
# Handle special case of no suffix, only key-value pairs
271+
if '-' in draft_suffix or len(draft_suffix) < 1:
272+
273+
# Leave suffix empty in dict
274+
bids_keys['suffix'] = ''
275+
276+
else:
258277

259-
# Split basename into prefix and suffix
260-
bids_keys['suffix'] = bname[suffix_start:]
261-
bname = bname[:suffix_start]
278+
# Split basename into prefix and suffix
279+
bids_keys['suffix'] = bids_stub[suffix_start:]
280+
bids_stub = bids_stub[:(suffix_start-1)]
262281

263282
# Divide filename into keys and values
264283
# Value segments are delimited by '<key>-' strings
@@ -273,7 +292,7 @@ def parse_bids_fname_keyvals(fname):
273292

274293
key_str = key + '-'
275294

276-
i0 = bname.find(key_str)
295+
i0 = bids_stub.find(key_str)
277296
if i0 > -1:
278297
i1 = i0 + len(key_str)
279298
key_name.append(key)
@@ -300,19 +319,16 @@ def parse_bids_fname_keyvals(fname):
300319

301320
# Catch negative vend (only happens for final key-value without suffix)
302321
if vend < 0:
303-
bids_keys[kname] = bname[vstart:]
322+
bids_keys[kname] = bids_stub[vstart:]
304323
else:
305-
bids_keys[kname] = bname[vstart:vend]
324+
bids_keys[kname] = bids_stub[vstart:vend]
306325

307326
# Tidy up Siemens recon extensions
308-
# Only relevant when using this function to parse ReproIn-style series descriptions
309-
if bids_keys['suffix'].endswith('SBRef'):
327+
if 'SBRef' in recon_key:
310328
# Replace entire double suffix with 'sbref'
311329
bids_keys['suffix'] = 'sbref'
312330

313-
if bids_keys['suffix'].endswith('RMS'):
314-
# Retain left part of double suffix ('T1w', etc)
315-
bids_keys['suffix'] = bids_keys['suffix'].split('_')[0]
331+
if 'RMS' in recon_key:
316332
# Add 'rms' to acq key
317333
bids_keys['acq'] = bids_keys['acq'] + 'rms'
318334

bidskit/translate.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,9 @@
2929
from .io import (read_json,
3030
write_json,
3131
parse_bids_fname_keyvals,
32-
parse_dcm2niix_fname,
3332
safe_copy,
3433
create_file_if_missing,
35-
strip_extensions)
34+
nii_to_json)
3635

3736

3837
def add_participant_record(studydir, subject, age, sex):
@@ -141,15 +140,15 @@ def purpose_handling(bids_meta,
141140

142141
print(' Identifying fieldmap image type')
143142

144-
if scan_seq == 'GR':
143+
if 'GR' in scan_seq:
145144

146145
print(' Gradient echo fieldmap detected')
147146
print(' Identifying magnitude and phase images')
148147

149148
# Update BIDS filenames according to BIDS Fieldmap Case (1 or 2 - see specification)
150149
bids_nii_fname, bids_json_fname = fmaps.handle_fmap_case(work_json_fname, bids_nii_fname, bids_json_fname)
151150

152-
elif scan_seq == 'EP':
151+
elif 'EP' in scan_seq:
153152

154153
print(' EPI fieldmap detected')
155154

@@ -164,7 +163,7 @@ def purpose_handling(bids_meta,
164163

165164
elif bids_purpose == 'anat':
166165

167-
if scan_seq == 'GR_IR':
166+
if 'GR' in scan_seq and 'IR' in scan_seq:
168167

169168
print(' IR-prepared GRE detected - likely T1w MPRAGE or MEMPRAGE')
170169

@@ -180,13 +179,13 @@ def purpose_handling(bids_meta,
180179
bids_nii_fname, bids_json_fname = d2n.handle_bias_recon(
181180
work_json_fname, bids_json_fname, key_flags['Recon'], nii_ext)
182181

183-
elif scan_seq == 'SE':
182+
elif 'SE' in scan_seq:
184183

185184
print(' Spin echo detected - likely T1w or T2w anatomic image')
186185
bids_nii_fname, bids_json_fname = d2n.handle_bias_recon(
187186
work_json_fname, bids_json_fname, key_flags['Recon'], nii_ext)
188187

189-
elif scan_seq == 'GR':
188+
elif 'GR' in scan_seq:
190189

191190
print(' Gradient echo detected')
192191

@@ -345,7 +344,7 @@ def bids_legalize_keys(keys):
345344
return keys
346345

347346

348-
def auto_run_no(file_list, prot_dict):
347+
def auto_run_no(d2n_nii_list, prot_dict):
349348
"""
350349
Search for duplicate series names in dcm2niix output file list
351350
Return inferred run numbers accounting for duplication and multiple recons from single acquisition
@@ -358,8 +357,8 @@ def auto_run_no(file_list, prot_dict):
358357
- If no duplicates of a given series are found, drop the run- key from the BIDS filename
359358
- Current dcm2niix version: 1.0.20211006
360359
361-
:param file_list: list of str
362-
Nifti file name list
360+
:param d2n_nii_list: list of str
361+
dcm2niix output Nifti filename list
363362
:param prot_dict: dictionary
364363
Protocol translation dictionary
365364
:return: run_num, array of int
@@ -368,17 +367,22 @@ def auto_run_no(file_list, prot_dict):
368367
# Construct list of series descriptions and original numbers from file names
369368
series_id_list = []
370369

371-
for fname in file_list:
370+
# Loop over all
371+
for nii_fname in d2n_nii_list:
372372

373-
# Parse dcm2niix filename into relevant keys, including suffix
374-
info = parse_dcm2niix_fname(fname)
373+
# Load JSON sidecar for this Nifti image
374+
json_fname = nii_to_json(nii_fname, '.nii.gz')
375+
bids_info = read_json(json_fname)
375376

376-
ser_desc = info['SerDesc']
377-
echo_no = info['EchoNo']
378-
suffix = info['Suffix']
377+
ser_desc = bids_info['SeriesDescription'].replace(' ', '_')
378+
if 'EchoNumber' in bids_info.keys():
379+
echo_no = bids_info['EchoNumber']
380+
else:
381+
echo_no = 1
382+
recon_type = '-'.join(bids_info['ImageType'])
379383

380384
if ser_desc in prot_dict:
381-
_, bids_stub, _ = prot_dict[info['SerDesc']]
385+
_, bids_stub, _ = prot_dict[ser_desc]
382386
else:
383387
print('')
384388
print('* Series description {} missing from code/Protocol_Translator.json'.format(ser_desc))
@@ -387,7 +391,7 @@ def auto_run_no(file_list, prot_dict):
387391
sys.exit(1)
388392

389393
# Construct a unique series identifier including echo number and suffix
390-
series_id = f"{bids_stub}_{echo_no}_{suffix}"
394+
series_id = f"{bids_stub}_ECHO{echo_no}_{recon_type}"
391395

392396
# Add to list
393397
series_id_list.append(series_id)
@@ -396,7 +400,7 @@ def auto_run_no(file_list, prot_dict):
396400
unique_series_ids = set(series_id_list)
397401

398402
# Init vector of run numbers and max run numbers for each series
399-
run_no = np.zeros(len(file_list)).astype(int)
403+
run_no = np.zeros(len(d2n_nii_list)).astype(int)
400404

401405
# Loop over unique series descriptions
402406
for unique_series_id in unique_series_ids:

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
# For a discussion on single-sourcing the version across setup.py and the
4646
# project code, see
4747
# https://packaging.python.org/en/latest/single_source_version.html
48-
version='2022.8.24', # Required
48+
version='2022.8.25', # Required
4949

5050
# This is a one-line description or tagline of what your project does. This
5151
# corresponds to the "Summary" metadata field:

0 commit comments

Comments
 (0)