diff --git a/.circleci/ds054_outputs.txt b/.circleci/ds054_outputs.txt index 6e970152d9..71c614350e 100644 --- a/.circleci/ds054_outputs.txt +++ b/.circleci/ds054_outputs.txt @@ -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 @@ -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 diff --git a/src/smriprep/interfaces/bids.py b/src/smriprep/interfaces/bids.py new file mode 100644 index 0000000000..87ec5d6301 --- /dev/null +++ b/src/smriprep/interfaces/bids.py @@ -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 diff --git a/src/smriprep/interfaces/templateflow.py b/src/smriprep/interfaces/templateflow.py index a48321fdde..a1e1c2fbd4 100644 --- a/src/smriprep/interfaces/templateflow.py +++ b/src/smriprep/interfaces/templateflow.py @@ -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)}' + else: + self._results['uri'] = str(self.inputs.template) + return runtime diff --git a/src/smriprep/utils/bids.py b/src/smriprep/utils/bids.py index fe2bd3bb11..506ce07698 100644 --- a/src/smriprep/utils/bids.py +++ b/src/smriprep/utils/bids.py @@ -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 diff --git a/src/smriprep/workflows/outputs.py b/src/smriprep/workflows/outputs.py index 9a750f145e..405a0ec966 100644 --- a/src/smriprep/workflows/outputs.py +++ b/src/smriprep/workflows/outputs.py @@ -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 @@ -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: @@ -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', + ) + + 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 @@ -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'), @@ -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(