From b5111a8c3cafc8da5e0cabc4b656b8967a4869eb Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 4 Dec 2025 12:14:48 -0500 Subject: [PATCH 1/9] Add SpatialReference field to some outputs. --- src/smriprep/interfaces/templateflow.py | 31 +++++++++++++++++++++++++ src/smriprep/workflows/outputs.py | 18 ++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/smriprep/interfaces/templateflow.py b/src/smriprep/interfaces/templateflow.py index a48321fdde..f899eb9489 100644 --- a/src/smriprep/interfaces/templateflow.py +++ b/src/smriprep/interfaces/templateflow.py @@ -218,3 +218,34 @@ 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' + local_path = self.inputs.templateflow_dir + + rel_path = Path(self.inputs.template).relative_to(local_path) + 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/workflows/outputs.py b/src/smriprep/workflows/outputs.py index 9a750f145e..a6bfacf9c2 100644 --- a/src/smriprep/workflows/outputs.py +++ b/src/smriprep/workflows/outputs.py @@ -33,7 +33,11 @@ from niworkflows.interfaces.utility import KeySelect from ..interfaces import DerivativesDataSink -from ..interfaces.templateflow import TemplateFlowSelect, fetch_template_files +from ..interfaces.templateflow import ( + TemplateFlowReference, + TemplateFlowSelect, + fetch_template_files, +) if ty.TYPE_CHECKING: from niworkflows.utils.spaces import SpatialReferences @@ -958,6 +962,11 @@ 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', + ) + gen_ref = pe.Node(GenerateSamplingReference(), name='gen_ref', mem_gb=0.01) # Mask T1w preproc images @@ -1018,9 +1027,11 @@ def init_ds_anat_volumes_wf( ds_std_tpms.inputs.label = tpm_labels workflow.connect([ + (inputnode, spatial_reference, [('ref_file', 'in_file')]), (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 +1041,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, ds_std_t1w, [('out_file', 'SpatialReference')]), (anat2std_mask, ds_std_mask, [('output_image', 'in_file')]), + (spatial_reference, ds_std_mask, [('out_file', 'SpatialReference')]), (anat2std_dseg, ds_std_dseg, [('output_image', 'in_file')]), + (spatial_reference, ds_std_dseg, [('out_file', 'SpatialReference')]), (anat2std_tpms, ds_std_tpms, [('output_image', 'in_file')]), + (spatial_reference, ds_std_tpms, [('out_file', 'SpatialReference')]), ]) # fmt:skip workflow.connect( From bf7898368138b6307428f32a85bf5363f2ad00ee Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 4 Dec 2025 12:22:20 -0500 Subject: [PATCH 2/9] Fix connections. --- src/smriprep/workflows/outputs.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/smriprep/workflows/outputs.py b/src/smriprep/workflows/outputs.py index a6bfacf9c2..7e35640435 100644 --- a/src/smriprep/workflows/outputs.py +++ b/src/smriprep/workflows/outputs.py @@ -1027,7 +1027,7 @@ def init_ds_anat_volumes_wf( ds_std_tpms.inputs.label = tpm_labels workflow.connect([ - (inputnode, spatial_reference, [('ref_file', 'in_file')]), + (inputnode, spatial_reference, [('ref_file', 'template')]), (inputnode, gen_ref, [ ('ref_file', 'fixed_image'), (('resolution', _is_native), 'keep_native'), @@ -1042,13 +1042,13 @@ def init_ds_anat_volumes_wf( (inputnode, anat2std_dseg, [('anat_dseg', 'input_image')]), (inputnode, anat2std_tpms, [('anat_tpms', 'input_image')]), (anat2std_t1w, ds_std_t1w, [('output_image', 'in_file')]), - (spatial_reference, ds_std_t1w, [('out_file', 'SpatialReference')]), + (spatial_reference, ds_std_t1w, [('uri', 'SpatialReference')]), (anat2std_mask, ds_std_mask, [('output_image', 'in_file')]), - (spatial_reference, ds_std_mask, [('out_file', 'SpatialReference')]), + (spatial_reference, ds_std_mask, [('uri', 'SpatialReference')]), (anat2std_dseg, ds_std_dseg, [('output_image', 'in_file')]), - (spatial_reference, ds_std_dseg, [('out_file', 'SpatialReference')]), + (spatial_reference, ds_std_dseg, [('uri', 'SpatialReference')]), (anat2std_tpms, ds_std_tpms, [('output_image', 'in_file')]), - (spatial_reference, ds_std_tpms, [('out_file', 'SpatialReference')]), + (spatial_reference, ds_std_tpms, [('uri', 'SpatialReference')]), ]) # fmt:skip workflow.connect( From b265c2d948d13cc9c52af63a81da218de7ddb4ae Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 4 Dec 2025 15:57:07 -0500 Subject: [PATCH 3/9] Fix. --- src/smriprep/interfaces/templateflow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/smriprep/interfaces/templateflow.py b/src/smriprep/interfaces/templateflow.py index f899eb9489..a1e1c2fbd4 100644 --- a/src/smriprep/interfaces/templateflow.py +++ b/src/smriprep/interfaces/templateflow.py @@ -240,9 +240,8 @@ def _run_interface(self, runtime): # For standard templates from TemplateFlow, we want the URL. # For custom templates, we want the local path. tf_url = 'https://templateflow.s3.amazonaws.com' - local_path = self.inputs.templateflow_dir - rel_path = Path(self.inputs.template).relative_to(local_path) + 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)}' From 27878f8930ab41aede8a9dace20e0c74da33c6de Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 4 Dec 2025 16:20:15 -0500 Subject: [PATCH 4/9] Update expected outputs. --- .circleci/ds054_outputs.txt | 2 ++ 1 file changed, 2 insertions(+) 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 From 15f7fc89e7368a1d2b96d4d672c04f87b616d0e0 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 5 Dec 2025 14:09:17 -0500 Subject: [PATCH 5/9] Add dataset_links input and write BIDSURI. --- src/smriprep/interfaces/bids.py | 62 +++++++++++++++++++++++++++++ src/smriprep/utils/bids.py | 65 +++++++++++++++++++++++++++++++ src/smriprep/workflows/outputs.py | 24 ++++++++++-- 3 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 src/smriprep/interfaces/bids.py 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/utils/bids.py b/src/smriprep/utils/bids.py index fe2bd3bb11..d92b46a401 100644 --- a/src/smriprep/utils/bids.py +++ b/src/smriprep/utils/bids.py @@ -197,3 +197,68 @@ 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' + """ + # Don't modify BIDS-URIs + if isinstance(input_path, str) and input_path.startswith('bids:'): + 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 7e35640435..e44ae67a8a 100644 --- a/src/smriprep/workflows/outputs.py +++ b/src/smriprep/workflows/outputs.py @@ -33,6 +33,7 @@ from niworkflows.interfaces.utility import KeySelect from ..interfaces import DerivativesDataSink +from ..interfaces.bids import BIDSURI from ..interfaces.templateflow import ( TemplateFlowReference, TemplateFlowSelect, @@ -933,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: @@ -967,6 +969,19 @@ def init_ds_anat_volumes_wf( name='spatial_reference', ) + dataset_links = dataset_links or {} + if 'bids' not in dataset_links: + dataset_links['bids'] = str(output_dir) + + 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 @@ -1028,6 +1043,7 @@ def init_ds_anat_volumes_wf( 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'), @@ -1042,13 +1058,13 @@ def init_ds_anat_volumes_wf( (inputnode, anat2std_dseg, [('anat_dseg', 'input_image')]), (inputnode, anat2std_tpms, [('anat_tpms', 'input_image')]), (anat2std_t1w, ds_std_t1w, [('output_image', 'in_file')]), - (spatial_reference, ds_std_t1w, [('uri', 'SpatialReference')]), + (spatial_reference_uri, ds_std_t1w, [(('out', _pop), 'SpatialReference')]), (anat2std_mask, ds_std_mask, [('output_image', 'in_file')]), - (spatial_reference, ds_std_mask, [('uri', 'SpatialReference')]), + (spatial_reference_uri, ds_std_mask, [(('out', _pop), 'SpatialReference')]), (anat2std_dseg, ds_std_dseg, [('output_image', 'in_file')]), - (spatial_reference, ds_std_dseg, [('uri', 'SpatialReference')]), + (spatial_reference_uri, ds_std_dseg, [(('out', _pop), 'SpatialReference')]), (anat2std_tpms, ds_std_tpms, [('output_image', 'in_file')]), - (spatial_reference, ds_std_tpms, [('uri', 'SpatialReference')]), + (spatial_reference_uri, ds_std_tpms, [(('out', _pop), 'SpatialReference')]), ]) # fmt:skip workflow.connect( From d3f4e63b0c5fc7f479bb33efe02cacbe85bb0332 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 5 Dec 2025 14:31:49 -0500 Subject: [PATCH 6/9] Work around URLs. --- src/smriprep/utils/bids.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/smriprep/utils/bids.py b/src/smriprep/utils/bids.py index d92b46a401..81e987e822 100644 --- a/src/smriprep/utils/bids.py +++ b/src/smriprep/utils/bids.py @@ -242,11 +242,25 @@ def _find_nearest_path(path_dict, input_path): >>> 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' """ # 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 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(): From c8633d371aad44e73119ab0e6ac031e1b85c6f33 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 5 Dec 2025 14:39:05 -0500 Subject: [PATCH 7/9] Update bids.py --- src/smriprep/utils/bids.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/smriprep/utils/bids.py b/src/smriprep/utils/bids.py index 81e987e822..fe5d340d76 100644 --- a/src/smriprep/utils/bids.py +++ b/src/smriprep/utils/bids.py @@ -254,7 +254,7 @@ def _find_nearest_path(path_dict, input_path): if isinstance(input_path, str) and input_path.startswith('http'): remote_found = False for path in path_dict.values(): - if path.startswith('http'): + if str(path).startswith('http'): remote_found = True break From 7b3edfc7619987e843586b3822e3f50fabb8ae55 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 5 Dec 2025 14:56:02 -0500 Subject: [PATCH 8/9] Support templateflow URIs. --- src/smriprep/utils/bids.py | 3 +++ src/smriprep/workflows/outputs.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/smriprep/utils/bids.py b/src/smriprep/utils/bids.py index fe5d340d76..506ce07698 100644 --- a/src/smriprep/utils/bids.py +++ b/src/smriprep/utils/bids.py @@ -245,6 +245,9 @@ def _find_nearest_path(path_dict, input_path): >>> 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:'): diff --git a/src/smriprep/workflows/outputs.py b/src/smriprep/workflows/outputs.py index e44ae67a8a..6da3203bd2 100644 --- a/src/smriprep/workflows/outputs.py +++ b/src/smriprep/workflows/outputs.py @@ -972,6 +972,8 @@ def init_ds_anat_volumes_wf( dataset_links = dataset_links 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( From f02d4d2d288d0f3689d8fb2fa0f15b17c88043e4 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 5 Dec 2025 17:14:21 -0500 Subject: [PATCH 9/9] Update src/smriprep/workflows/outputs.py Co-authored-by: Chris Markiewicz --- src/smriprep/workflows/outputs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/smriprep/workflows/outputs.py b/src/smriprep/workflows/outputs.py index 6da3203bd2..405a0ec966 100644 --- a/src/smriprep/workflows/outputs.py +++ b/src/smriprep/workflows/outputs.py @@ -969,7 +969,7 @@ def init_ds_anat_volumes_wf( name='spatial_reference', ) - dataset_links = dataset_links or {} + dataset_links = dataset_links.copy() or {} if 'bids' not in dataset_links: dataset_links['bids'] = str(output_dir) if 'templateflow' not in dataset_links: