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
84 changes: 84 additions & 0 deletions src/smriprep/interfaces/bids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
# vi: set ft=python sts=4 ts=4 sw=4 et:
#
# Copyright The NiPreps Developers <nipreps@gmail.com>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# We support and encourage derived works from this project, please read
# about our expectations at
#
# https://www.nipreps.org/community/licensing/
#
"""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
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
30 changes: 28 additions & 2 deletions src/smriprep/workflows/outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,14 @@
from niworkflows.interfaces.nibabel import ApplyMask, GenerateSamplingReference
from niworkflows.interfaces.space import SpaceDataSource
from niworkflows.interfaces.utility import KeySelect
from templateflow import api as tf

from ..interfaces import DerivativesDataSink
from ..interfaces.templateflow import TemplateFlowSelect, fetch_template_files
from ..interfaces.bids import BIDSURI
from ..interfaces.templateflow import (
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 @@ -957,6 +963,21 @@ 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

dataset_links = (dataset_links or {}).copy()
if 'bids' not in dataset_links:
dataset_links['bids'] = str(output_dir)
if 'templateflow' not in dataset_links:
dataset_links['templateflow'] = str(tf.TF_LAYOUT.root)

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 @@ -1017,9 +1038,11 @@ def init_ds_anat_volumes_wf(
ds_std_tpms.inputs.label = tpm_labels

workflow.connect([
(inputnode, spatial_reference_uri, [('ref_file', '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 @@ -1029,11 +1052,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