Skip to content
Open
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
2 changes: 2 additions & 0 deletions .circleci/ds054_outputs.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ smriprep/sub-100185/anat/sub-100185_space-MNI152Lin_desc-brain_mask.json
smriprep/sub-100185/anat/sub-100185_space-MNI152Lin_desc-brain_mask.nii.gz
smriprep/sub-100185/anat/sub-100185_space-MNI152Lin_desc-preproc_T1w.json
smriprep/sub-100185/anat/sub-100185_space-MNI152Lin_desc-preproc_T1w.nii.gz
smriprep/sub-100185/anat/sub-100185_space-MNI152Lin_dseg.json
smriprep/sub-100185/anat/sub-100185_space-MNI152Lin_dseg.nii.gz
smriprep/sub-100185/anat/sub-100185_space-MNI152Lin_label-CSF_probseg.nii.gz
smriprep/sub-100185/anat/sub-100185_space-MNI152Lin_label-GM_probseg.nii.gz
Expand All @@ -32,6 +33,7 @@ smriprep/sub-100185/anat/sub-100185_space-MNI152NLin2009cAsym_desc-brain_mask.js
smriprep/sub-100185/anat/sub-100185_space-MNI152NLin2009cAsym_desc-brain_mask.nii.gz
smriprep/sub-100185/anat/sub-100185_space-MNI152NLin2009cAsym_desc-preproc_T1w.json
smriprep/sub-100185/anat/sub-100185_space-MNI152NLin2009cAsym_desc-preproc_T1w.nii.gz
smriprep/sub-100185/anat/sub-100185_space-MNI152NLin2009cAsym_dseg.json
smriprep/sub-100185/anat/sub-100185_space-MNI152NLin2009cAsym_dseg.nii.gz
smriprep/sub-100185/anat/sub-100185_space-MNI152NLin2009cAsym_label-CSF_probseg.nii.gz
smriprep/sub-100185/anat/sub-100185_space-MNI152NLin2009cAsym_label-GM_probseg.nii.gz
Expand Down
62 changes: 62 additions & 0 deletions src/smriprep/interfaces/bids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""BIDS-related interfaces."""

from pathlib import Path

from bids.utils import listify
from nipype.interfaces.base import (
DynamicTraitedSpec,
SimpleInterface,
TraitedSpec,
isdefined,
traits,
)
from nipype.interfaces.io import add_traits
from nipype.interfaces.utility.base import _ravel

from ..utils.bids import _find_nearest_path


class _BIDSURIInputSpec(DynamicTraitedSpec):
dataset_links = traits.Dict(mandatory=True, desc='Dataset links')
out_dir = traits.Str(mandatory=True, desc='Output directory')


class _BIDSURIOutputSpec(TraitedSpec):
out = traits.List(
traits.Str,
desc='BIDS URI(s) for file',
)


class BIDSURI(SimpleInterface):
"""Convert input filenames to BIDS URIs, based on links in the dataset.

This interface can combine multiple lists of inputs.
"""

input_spec = _BIDSURIInputSpec
output_spec = _BIDSURIOutputSpec

def __init__(self, numinputs=0, **inputs):
super().__init__(**inputs)
self._numinputs = numinputs
if numinputs >= 1:
input_names = [f'in{i + 1}' for i in range(numinputs)]
else:
input_names = []
add_traits(self.inputs, input_names)

def _run_interface(self, runtime):
inputs = [getattr(self.inputs, f'in{i + 1}') for i in range(self._numinputs)]
in_files = listify(inputs)
in_files = _ravel(in_files)
# Remove undefined inputs
in_files = [f for f in in_files if isdefined(f)]
# Convert the dataset links to BIDS URI prefixes
updated_keys = {f'bids:{k}:': Path(v) for k, v in self.inputs.dataset_links.items()}
updated_keys['bids::'] = Path(self.inputs.out_dir)
# Convert the paths to BIDS URIs
out = [_find_nearest_path(updated_keys, f) for f in in_files]
self._results['out'] = out

return runtime
30 changes: 30 additions & 0 deletions src/smriprep/interfaces/templateflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,33 @@ def fetch_template_files(
# Not guaranteed to exist so add fallback
files['t2w'] = tf.get(name[0], desc=None, suffix='T2w', **specs) or Undefined
return files


class _TemplateFlowReferenceInputSpec(BaseInterfaceInputSpec):
template = File(exists=True, mandatory=True, desc='Path to template reference file')


class _TemplateFlowReferenceOutputSpec(TraitedSpec):
uri = traits.Str(desc='URI of the template reference file')


class TemplateFlowReference(SimpleInterface):
"""Get template reference file from TemplateFlow."""

input_spec = _TemplateFlowReferenceInputSpec
output_spec = _TemplateFlowReferenceOutputSpec

def _run_interface(self, runtime):
from pathlib import Path

# For standard templates from TemplateFlow, we want the URL.
# For custom templates, we want the local path.
tf_url = 'https://templateflow.s3.amazonaws.com'

rel_path = Path(self.inputs.template).relative_to(tf.TF_LAYOUT.root)
template_name = rel_path.name.split('_')[0].split('-')[1]
if template_name in tf.TF_LAYOUT.get_templates():
self._results['uri'] = f'{tf_url}/{str(rel_path)}'
Comment on lines +246 to +247
Copy link
Member

Choose a reason for hiding this comment

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

Presumably this is going to return custom templates found in TF_LAYOUT, so I'm not sure that the else branch will ever get hit.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh... I had hoped that get_templates would only return built-in templates, but that makes sense. Is there any way to distinguish built-in templates from custom ones?

Copy link
Member

Choose a reason for hiding this comment

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

Hmm. I suppose we could inspect the skeleton in the templateflow client.

import zipfile
import templateflow

templates = [
    tpl.name.removeprefix('tpl-')
    for tpl in zipfile.Path(templateflow.conf.load_data('templateflow-skel.zip')).iterdir()
]

That's very much unsupported API, but it's doable.

else:
self._results['uri'] = str(self.inputs.template)
return runtime
82 changes: 82 additions & 0 deletions src/smriprep/utils/bids.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,85 @@ def write_derivative_description(bids_dir, deriv_dir):
desc['License'] = orig_desc['License']

Path.write_text(deriv_dir / 'dataset_description.json', json.dumps(desc, indent=4))


def _find_nearest_path(path_dict, input_path):
"""Find the nearest relative path from an input path to a dictionary of paths.

If ``input_path`` is not relative to any of the paths in ``path_dict``,
the absolute path string is returned.

If ``input_path`` is already a BIDS-URI, then it will be returned unmodified.

Parameters
----------
path_dict : dict of (str, Path)
A dictionary of paths.
input_path : Path
The input path to match.

Returns
-------
matching_path : str
The nearest relative path from the input path to a path in the dictionary.
This is either the concatenation of the associated key from ``path_dict``
and the relative path from the associated value from ``path_dict`` to ``input_path``,
or the absolute path to ``input_path`` if no matching path is found from ``path_dict``.

Examples
--------
>>> from pathlib import Path
>>> path_dict = {
... 'bids::': Path('/data/derivatives/fmriprep'),
... 'bids:raw:': Path('/data'),
... 'bids:deriv-0:': Path('/data/derivatives/source-1'),
... }
>>> input_path = Path('/data/derivatives/source-1/sub-01/func/sub-01_task-rest_bold.nii.gz')
>>> _find_nearest_path(path_dict, input_path) # match to 'bids:deriv-0:'
'bids:deriv-0:sub-01/func/sub-01_task-rest_bold.nii.gz'
>>> input_path = Path('/out/sub-01/func/sub-01_task-rest_bold.nii.gz')
>>> _find_nearest_path(path_dict, input_path) # no match- absolute path
'/out/sub-01/func/sub-01_task-rest_bold.nii.gz'
>>> input_path = Path('/data/sub-01/func/sub-01_task-rest_bold.nii.gz')
>>> _find_nearest_path(path_dict, input_path) # match to 'bids:raw:'
'bids:raw:sub-01/func/sub-01_task-rest_bold.nii.gz'
>>> input_path = 'bids::sub-01/func/sub-01_task-rest_bold.nii.gz'
>>> _find_nearest_path(path_dict, input_path) # already a BIDS-URI
'bids::sub-01/func/sub-01_task-rest_bold.nii.gz'
>>> input_path = 'https://example.com/sub-01/func/sub-01_task-rest_bold.nii.gz'
>>> _find_nearest_path(path_dict, input_path) # already a URL
'https://example.com/sub-01/func/sub-01_task-rest_bold.nii.gz'
>>> path_dict['bids:tfl:'] = 'https://example.com'
>>> _find_nearest_path(path_dict, input_path) # match to 'bids:tfl:'
'bids:tfl:sub-01/func/sub-01_task-rest_bold.nii.gz'
"""
# Don't modify BIDS-URIs
if isinstance(input_path, str) and input_path.startswith('bids:'):
return input_path

# Only modify URLs if there's a URL in the path_dict
if isinstance(input_path, str) and input_path.startswith('http'):
remote_found = False
for path in path_dict.values():
if str(path).startswith('http'):
remote_found = True
break

if not remote_found:
return input_path

input_path = Path(input_path)
matching_path = None
for key, path in path_dict.items():
if input_path.is_relative_to(path):
relative_path = input_path.relative_to(path)
if (matching_path is None) or (len(relative_path.parts) < len(matching_path.parts)):
matching_key = key
matching_path = relative_path

if matching_path is None:
matching_path = str(input_path.absolute())
else:
matching_path = f'{matching_key}{matching_path}'

return matching_path
36 changes: 34 additions & 2 deletions src/smriprep/workflows/outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@
from niworkflows.interfaces.utility import KeySelect

from ..interfaces import DerivativesDataSink
from ..interfaces.templateflow import TemplateFlowSelect, fetch_template_files
from ..interfaces.bids import BIDSURI
from ..interfaces.templateflow import (
TemplateFlowReference,
TemplateFlowSelect,
fetch_template_files,
)

if ty.TYPE_CHECKING:
from niworkflows.utils.spaces import SpatialReferences
Expand Down Expand Up @@ -929,6 +934,7 @@ def init_ds_anat_volumes_wf(
*,
bids_root: str,
output_dir: str,
dataset_links: dict[str, str] | None = None,
name='ds_anat_volumes_wf',
tpm_labels=BIDS_TISSUE_ORDER,
) -> pe.Workflow:
Expand Down Expand Up @@ -958,6 +964,26 @@ def init_ds_anat_volumes_wf(
raw_sources = pe.Node(niu.Function(function=_bids_relative), name='raw_sources')
raw_sources.inputs.bids_root = bids_root

spatial_reference = pe.Node(
TemplateFlowReference(),
name='spatial_reference',
)
Comment on lines +967 to +970
Copy link
Member

Choose a reason for hiding this comment

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

I'm worried this is going to interfere with passing dataset_links from fmriprep, since that maps TF_LAYOUT.root onto bids:templateflow: and accepts raw paths.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think we should update fMRIPrep to point to the website when it's a built-in template, but I could pass the dataset_links into this node to use a local path if templateflow is a key in the dictionary and doesn't start with http.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

One reason I was leaning toward the website is that the CIFTI SpatialReference dictionary uses the website. If we do commit to using the local templateflow location then we should change that as well.


dataset_links = dataset_links.copy() or {}
if 'bids' not in dataset_links:
dataset_links['bids'] = str(output_dir)
if 'templateflow' not in dataset_links:
dataset_links['templateflow'] = 'https://templateflow.s3.amazonaws.com'

spatial_reference_uri = pe.Node(
BIDSURI(
numinputs=1,
dataset_links=dataset_links,
out_dir=str(output_dir),
),
name='spatial_reference_uri',
)

gen_ref = pe.Node(GenerateSamplingReference(), name='gen_ref', mem_gb=0.01)

# Mask T1w preproc images
Expand Down Expand Up @@ -1018,9 +1044,12 @@ def init_ds_anat_volumes_wf(
ds_std_tpms.inputs.label = tpm_labels

workflow.connect([
(inputnode, spatial_reference, [('ref_file', 'template')]),
(spatial_reference, spatial_reference_uri, [('uri', 'in1')]),
(inputnode, gen_ref, [
('ref_file', 'fixed_image'),
(('resolution', _is_native), 'keep_native'),
('anat_preproc', 'moving_image'),
]),
(inputnode, mask_anat, [
('anat_preproc', 'in_file'),
Expand All @@ -1030,11 +1059,14 @@ def init_ds_anat_volumes_wf(
(inputnode, anat2std_mask, [('anat_mask', 'input_image')]),
(inputnode, anat2std_dseg, [('anat_dseg', 'input_image')]),
(inputnode, anat2std_tpms, [('anat_tpms', 'input_image')]),
(inputnode, gen_ref, [('anat_preproc', 'moving_image')]),
(anat2std_t1w, ds_std_t1w, [('output_image', 'in_file')]),
(spatial_reference_uri, ds_std_t1w, [(('out', _pop), 'SpatialReference')]),
(anat2std_mask, ds_std_mask, [('output_image', 'in_file')]),
(spatial_reference_uri, ds_std_mask, [(('out', _pop), 'SpatialReference')]),
(anat2std_dseg, ds_std_dseg, [('output_image', 'in_file')]),
(spatial_reference_uri, ds_std_dseg, [(('out', _pop), 'SpatialReference')]),
(anat2std_tpms, ds_std_tpms, [('output_image', 'in_file')]),
(spatial_reference_uri, ds_std_tpms, [(('out', _pop), 'SpatialReference')]),
]) # fmt:skip

workflow.connect(
Expand Down
Loading