Skip to content

Commit

Permalink
Implements the participants table in the bidsmap (GitHub issue #253)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelzwiers committed Jan 14, 2025
1 parent a45d0d1 commit 1f1ef5f
Show file tree
Hide file tree
Showing 23 changed files with 457 additions and 517 deletions.
1 change: 1 addition & 0 deletions bidscoin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ def trackusage(event: str, dryrun: bool=False) -> dict:
except Exception as shelveerror:
warnings.warn(f"Please report the following error to the developers:\n{shelveerror}: {trackfile}", RuntimeWarning)
for corruptfile in trackfile.parent.glob(trackfile.name + '.*'):
print(f"Deleting corrupt file: {corruptfile}")
corruptfile.unlink()
data['event'] = 'trackusage_exception'

Expand Down
19 changes: 10 additions & 9 deletions bidscoin/bcoin.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ def install_plugins(filenames: list[str]=()) -> None:
LOGGER.success(f"The '{file.name}' plugin was successfully installed")


def uninstall_plugins(filenames: list[str]=(), wipe: bool=True) -> None:
def uninstall_plugins(filenames: list[str]=(), wipe: bool=False) -> None:
"""
Uninstalls template bidsmaps and plugins and removes the plugin Options and data format section from the default template bidsmap
Expand Down Expand Up @@ -358,14 +358,15 @@ def uninstall_plugins(filenames: list[str]=(), wipe: bool=True) -> None:
if not module:
LOGGER.warning(f"Cannot remove any {file.stem} bidsmap options from the {bidsmap_template.stem} template")
continue
if hasattr(module, 'OPTIONS') or hasattr(module, 'BIDSMAP'):
if hasattr(module, 'OPTIONS'):
LOGGER.info(f"Removing default {file.stem} bidsmap options from the {bidsmap_template.stem} template")
template['Options']['plugins'].pop(file.stem, None)
if wipe and hasattr(module, 'BIDSMAP'):
for key, value in module.BIDSMAP.items():
LOGGER.info(f"Removing default {key} bidsmappings from the {bidsmap_template.stem} template")
template.pop(key, None)
if removed := hasattr(module, 'OPTIONS'):
LOGGER.info(f"Removing default {file.stem} bidsmap options from the {bidsmap_template.stem} template")
template['Options']['plugins'].pop(file.stem, None)
if wipe and hasattr(module, 'BIDSMAP'):
removed = True
for key, value in module.BIDSMAP.items():
LOGGER.info(f"Removing default {key} bidsmappings from the {bidsmap_template.stem} template")
template.pop(key, None)
if removed:
with open(bidsmap_template, 'w') as stream:
yaml.dump(template, stream)

Expand Down
29 changes: 18 additions & 11 deletions bidscoin/bids.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,7 @@ def __init__(self, dataformat: str, data: dict, options: Options, plugins: Plugi
Reads from a YAML dataformat dictionary
:param dataformat: The name of the dataformat (= section in the bidsmap)
:param data: The YAML dataformat dictionary, i.e. subject and session items + a set of datatypes
:param data: The YAML dataformat dictionary, i.e. participant items + a set of datatypes
:param options: The dictionary with the BIDScoin options
:param plugins: The plugin dictionaries with their options
"""
Expand All @@ -785,7 +785,7 @@ def __init__(self, dataformat: str, data: dict, options: Options, plugins: Plugi
self.plugins = plugins
"""The plugin dictionaries with their options"""
self._data = data
"""The YAML dataformat dictionary, i.e. subject and session items + a set of datatypes"""
"""The YAML dataformat dictionary, i.e. participant items + a set of datatypes"""

def __str__(self):

Expand All @@ -808,32 +808,43 @@ def __hash__(self):

return hash(str(self))

@property
def participant(self) -> dict:
"""The data to populate the participants.tsv table"""

return self._data['participant']

@participant.setter
def participant(self, value: dict):

self._data['participant'] = value

@property
def subject(self) -> str:
"""The regular expression for extracting the subject identifier"""

return self._data['subject']
return self._data['participant']['participant_id']

@subject.setter
def subject(self, value: str):

self._data['subject'] = value
self._data['participant']['participant_id'] = value

@property
def session(self) -> str:
"""The regular expression for extracting the session identifier"""

return self._data['session']
return self._data['participant']['session_id']

@session.setter
def session(self, value: str):
self._data['session'] = value
self._data['participant']['session_id'] = value

@property
def datatypes(self) -> list[DataType]:
"""Gets a list of DataType objects for the dataformat"""

return [DataType(self.dataformat, datatype, self._data[datatype], self.options, self.plugins) for datatype in self._data if datatype not in ('subject', 'session')]
return [DataType(self.dataformat, datatype, self._data[datatype], self.options, self.plugins) for datatype in self._data if datatype not in ('participant',)]

def datatype(self, datatype: Union[str, DataType]) -> DataType:
"""Gets the DataType object for the dataformat"""
Expand Down Expand Up @@ -2100,7 +2111,6 @@ def addparticipant(participants_tsv: Path, subid: str='', sesid: str='', data: d
if subid not in table.index:
if sesid:
table.loc[subid, 'session_id'] = sesid
table.loc[subid, 'group'] = None
data_added = True
for key in data:
if key not in table or pd.isnull(table.loc[subid, key]) or table.loc[subid, key] == 'n/a':
Expand All @@ -2121,9 +2131,6 @@ def addparticipant(participants_tsv: Path, subid: str='', sesid: str='', data: d
if not meta.get('session_id') and 'session_id' in table.columns:
meta['session_id'] = {'Description': 'Session identifier'}
key_added = True
if not meta.get('group') and 'group' in table.columns:
meta['group'] = {'Description': 'Group identifier'}
key_added = True
for col in table.columns:
if col not in meta:
key_added = True
Expand Down
35 changes: 18 additions & 17 deletions bidscoin/bidscoiner.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,25 +257,26 @@ def bidscoiner(sourcefolder: str, bidsfolder: str, participant: list=(), force:
sesfolders, unpacked = unpack(session, bidsmap.options.get('unzip',''))
for sesfolder in sesfolders:

# Check if we should skip the session-folder
datasource = bids.get_datasource(sesfolder, bidsmap.plugins)
if not datasource.dataformat:
LOGGER.info(f">>> No datasources found in '{sesfolder}'")
continue
LOGGER.info(f">>> Coining datasources in: {sesfolder}")
subid = bidsmap.dataformat(datasource.dataformat).subject
sesid = bidsmap.dataformat(datasource.dataformat).session
subid, sesid = datasource.subid_sesid(subid, sesid or '')
bidssession = bidsfolder/subid/sesid # TODO: Support DICOMDIR with multiple subjects (as in PYDICOMDIR)
if bidssession.is_dir():
LOGGER.warning(f"Existing BIDS output-directory found, which may result in duplicate data (with increased run-index). Make sure {bidssession} was cleaned-up from old data before (re)running the bidscoiner")
bidssession.mkdir(parents=True, exist_ok=True)

# Run the bidscoiner plugins
for plugin in plugins:
LOGGER.verbose(f"Executing plugin: {Path(plugin.__file__).stem}")
trackusage(Path(plugin.__file__).stem)
personals = plugin.Interface().bidscoiner(sesfolder, bidsmap, bidssession)

# Check if we should skip the sesfolder
name = Path(plugin.__file__).stem
datasource = bids.get_datasource(sesfolder, {name: bidsmap.plugins[name]})
if not datasource.dataformat:
LOGGER.info(f">>> No {name} datasources found in '{sesfolder}'")
continue
LOGGER.info(f">>> Coining {name} datasources in: {sesfolder}")
subid = bidsmap.dataformat(datasource.dataformat).subject
sesid = bidsmap.dataformat(datasource.dataformat).session
subid, sesid = datasource.subid_sesid(subid, sesid or '')
bidssession = bidsfolder/subid/sesid # TODO: Support DICOMDIR with multiple subjects (as in PYDICOMDIR)
bidssession.mkdir(parents=True, exist_ok=True)

LOGGER.verbose(f"Executing plugin: {name}")
trackusage(name)
plugin.Interface().bidscoiner(sesfolder, bidsmap, bidssession)
personals = plugin.Interface().personals(bidsmap, datasource)

# Add a subject row to the participants table (if there is any data)
if next(bidssession.rglob('*.json'), None):
Expand Down
16 changes: 7 additions & 9 deletions bidscoin/bidseditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
e.g. extra_data/;myfile.txt;yourfile.csv
subprefix: The subject prefix used in the source data folders (e.g. "Pt" is the subprefix if subject folders are named "Pt018", "Pt019", ...)
sesprefix: The session prefix used in the source data folders (e.g. "M_" is the subprefix if session folders are named "M_pre", "M_post", ...)
anon: Set this anonymization flag to 'y' to round off age and to discard acquisition date from the meta data
For more information see: {MAIN_HELP_URL}/options.html"""

TOOLTIP_DCM2NIIX = """dcm2niix2bids
Expand All @@ -67,8 +68,6 @@
args: Argument string that is passed to dcm2niix. Click [Test] and see the terminal output for usage
Tip: SPM users may want to use '-z n', which produces unzipped NIfTI's
anon: Set this anonymization flag to 'y' to round off age and to discard acquisition date from the meta data
meta: The file extensions of the associated / equally named (meta)data sourcefiles that are copied over as
BIDS (sidecar) files, such as ['.json', '.tsv', '.tsv.gz']. You can use this to enrich json sidecar files,
or add data that is not supported by this plugin
Expand Down Expand Up @@ -958,8 +957,8 @@ def open_inspectwindow(self, index: int):

datafile = Path(self.filesystem.fileInfo(index).absoluteFilePath())
if datafile.is_file():
ext = ''.join(datafile.suffixes).lower()
if is_dicomfile(datafile) or is_parfile(datafile) or ext in sum((klass.valid_exts for klass in nib.imageclasses.all_image_classes), ('.nii.gz',)):
ext = datafile.suffix.lower()
if is_dicomfile(datafile) or is_parfile(datafile) or ext in sum((klass.valid_exts for klass in nib.imageclasses.all_image_classes), ('.7','.spar')) or datafile.name.endswith('.nii.gz'):
self.popup = InspectWindow(datafile)
self.popup.show()
self.popup.scrollbar.setValue(0) # This can only be done after self.popup.show()
Expand Down Expand Up @@ -1006,7 +1005,7 @@ def __init__(self, runitem: RunItem, bidsmap: BidsMap, template_bidsmap: BidsMap
"""The data format of the run-item being edited (bidsmap[dataformat][datatype][run-item])"""
self.unknowndatatypes: list[str] = [datatype for datatype in bidsmap.options['unknowntypes'] if datatype in template_bidsmap.dataformat(self.dataformat).datatypes]
self.ignoredatatypes: list[str] = [datatype for datatype in bidsmap.options['ignoretypes'] if datatype in template_bidsmap.dataformat(self.dataformat).datatypes]
self.bidsdatatypes = [str(datatype) for datatype in template_bidsmap.dataformat(self.dataformat).datatypes if datatype not in self.unknowndatatypes + self.ignoredatatypes + ['subject', 'session']]
self.bidsdatatypes = [str(datatype) for datatype in template_bidsmap.dataformat(self.dataformat).datatypes if datatype not in self.unknowndatatypes + self.ignoredatatypes]
self.bidsignore = bidsmap.options['bidsignore']
self.output_bidsmap = bidsmap
"""The bidsmap at the start of the edit = output_bidsmap in the MainWindow"""
Expand Down Expand Up @@ -2041,14 +2040,13 @@ class InspectWindow(QDialog):
def __init__(self, filename: Path):
super().__init__()

ext = ''.join(filename.suffixes).lower()
if is_dicomfile(filename):
if filename.name == 'DICOMDIR':
LOGGER.bcdebug(f"Getting DICOM fields from {filename} will raise dcmread error below if pydicom => v3.0")
text = str(dcmread(filename, force=True))
elif is_parfile(filename) or ext in ('.spar', '.txt', '.text', '.log'):
elif is_parfile(filename) or filename.suffix.lower() in ('.spar', '.txt', '.text', '.log'):
text = filename.read_text()
elif ext == '.7':
elif filename.suffix.lower() == '.7':
try:
from spec2nii.GE.ge_read_pfile import Pfile
text = ''
Expand All @@ -2062,7 +2060,7 @@ def __init__(self, filename: Path):
except ImportError as perror:
text = f"Could not inspect: {filename}"
LOGGER.verbose(f"Could not import spec2nii to read {filename}\n{perror}")
elif filename.is_file() and ext in sum((klass.valid_exts for klass in nib.imageclasses.all_image_classes), ('.nii.gz',)):
elif filename.is_file() and filename.suffix.lower() in sum((klass.valid_exts for klass in nib.imageclasses.all_image_classes), ('.nii',)) or filename.name.endswith('.nii.gz'):
text = str(nib.load(filename).header)
else:
text = f"Could not inspect: {filename}"
Expand Down
3 changes: 1 addition & 2 deletions bidscoin/bidsmapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,8 @@ def bidsmapper(sourcefolder: str, bidsfolder: str, bidsmap: str, template: str,
sessions = [subject]
for session in sessions:

LOGGER.info(f"Mapping: {session} (subject {n}/{len(subjects)})")

# Unpack the data in a temporary folder if it is tarballed/zipped and/or contains a DICOMDIR file
LOGGER.info(f"Mapping: {session} (subject {n}/{len(subjects)})")
sesfolders, unpacked = unpack(session, unzip)
for sesfolder in sesfolders:
if store:
Expand Down
6 changes: 4 additions & 2 deletions bidscoin/heuristics/bidsmap_bids2bids.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Options:
bidsignore: [extra_data/, sub-*_ct.*] # List of entries that are added to the .bidsignore file (for more info, see BIDS specifications), e.g. [extra_data/, pet/, myfile.txt, yourfile.csv]
unknowntypes: [extra_data] # A list of datatypes that are converted to BIDS-like datatype folders
ignoretypes: [exclude] # A list of datatypes that are excluded / not converted to BIDS
anon: y # Set this anonymization flag to 'y' to round off age and discard acquisition date from the meta data
unzip: # Wildcard pattern to select tarball/zip-files in the source folders that need to be unzipped (in a tempdir) to expose the data, e.g. '*.tar.gz'
plugins: # List of plugins with plugin-specific key-value pairs (that can be used by the plugin)
nibabel2bids:
Expand All @@ -40,8 +41,9 @@ Nibabel:
# --------------------------------------------------------------------------------
# Nibabel key-value heuristics (header fields that are mapped to the BIDS labels)
# --------------------------------------------------------------------------------
subject: <<filepath:/sub-(.*?)/>> # This filesystem property extracts the subject label from the source directory. NB: Any property or attribute can be used as subject-label, e.g. <PatientID>
session: <<filepath:/sub-.*?/ses-(.*?)/>> # This filesystem property extracts the subject label from the source directory. NB: Any property or attribute can be used as session-label, e.g. <StudyID>
participant: # Attributes or properties to populate the participants table/tsv-file
participant_id: <<filepath:/sub-(.*?)/>> # This filesystem property extracts the subject label from the source directory. NB: Any property or attribute can be used as subject-label, e.g. <PatientID>
session_id: <<filepath:/sub-.*?/ses-(.*?)/>> # This filesystem property extracts the subject label from the source directory. NB: Any property or attribute can be used as session-label, e.g. <StudyID>

anat: # ----------------------- All anatomical runs --------------------
- properties: &fileprop_anat # This is an optional (stub) entry of properties matching (could be added to any run-item)
Expand Down
Loading

0 comments on commit 1f1ef5f

Please sign in to comment.