nipost 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nipost/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ try:
2
+ from ._version import __version__
3
+ except ImportError: # pragma: no cover
4
+ __version__ = '0+unknown'
5
+
6
+ from nipost.epi import ensure_positive_cosines, get_trt, prepare_epi
7
+ from nipost.fieldmap import reconstruct_fieldmap
8
+ from nipost.resampling import resample_image
9
+ from nipost.transforms import load_transforms
10
+
11
+ __all__ = [
12
+ '__version__',
13
+ 'ensure_positive_cosines',
14
+ 'get_trt',
15
+ 'load_transforms',
16
+ 'prepare_epi',
17
+ 'reconstruct_fieldmap',
18
+ 'resample_image',
19
+ ]
nipost/_async.py ADDED
@@ -0,0 +1,8 @@
1
+ import asyncio
2
+ from collections.abc import Callable
3
+
4
+
5
+ async def worker[R](job: Callable[[], R], semaphore: asyncio.Semaphore) -> R:
6
+ async with semaphore:
7
+ loop = asyncio.get_running_loop()
8
+ return await loop.run_in_executor(None, job)
nipost/_version.pyi ADDED
@@ -0,0 +1 @@
1
+ __version__: str
@@ -0,0 +1,17 @@
1
+ """BIDS derivatives discovery (requires the ``nipost[bids]`` extra)."""
2
+
3
+ try:
4
+ from nipost.bids.collect import collect_derivatives, collect_fieldmaps
5
+ from nipost.bids.spec import load_spec, sanitize_fieldmap_id, sanitize_space
6
+ except ImportError as exc: # pragma: no cover
7
+ raise ImportError(
8
+ "nipost.bids requires the 'bids' extra. Install with: pip install 'nipost[bids]'"
9
+ ) from exc
10
+
11
+ __all__ = [
12
+ 'collect_derivatives',
13
+ 'collect_fieldmaps',
14
+ 'load_spec',
15
+ 'sanitize_fieldmap_id',
16
+ 'sanitize_space',
17
+ ]
nipost/bids/_layout.py ADDED
@@ -0,0 +1,17 @@
1
+ """Cached BIDSLayout factory for derivative discovery."""
2
+
3
+ from functools import cache
4
+ from pathlib import Path
5
+
6
+ from bids.layout import BIDSLayout
7
+
8
+
9
+ @cache
10
+ def get_layout(derivatives_dir: Path) -> BIDSLayout:
11
+ import niworkflows.data
12
+
13
+ return BIDSLayout(
14
+ derivatives_dir,
15
+ config=[niworkflows.data.load('nipreps.json')],
16
+ validate=False,
17
+ )
nipost/bids/collect.py ADDED
@@ -0,0 +1,292 @@
1
+ # src/nipost/bids/collect.py
2
+ """Generic, spec-driven derivative discovery."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+
10
+ from bids.layout import Query as BIDSQuery
11
+
12
+ from nipost.bids._layout import get_layout
13
+ from nipost.bids.spec import _PLACEHOLDER_RE, Group, Query, Spec, load_spec
14
+
15
+
16
+ def _resolve(alt: dict, base: dict, scope: list[str] | None, params: dict) -> dict:
17
+ """Build a PyBIDS filter dict from an entities spec and caller-supplied entities.
18
+
19
+ Spec-declared entities override caller-supplied ones.
20
+ ``scope`` restricts the caller-supplied entities to the ones this query
21
+ accepts; ``None`` accepts all of them and ``[]`` accepts none.
22
+ Values of the form ``{name}`` are replaced with ``params[name]`` verbatim,
23
+ so ``None`` requires the entity to be absent.
24
+ """
25
+ merged = {
26
+ # Filter supplied entities by scope, if provided
27
+ **{key: val for key, val in base.items() if scope is None or key in scope},
28
+ # Override with spec-declared entities, which may contain placeholders
29
+ **alt,
30
+ }
31
+ out: dict = {}
32
+ for key, value in merged.items():
33
+ resolved = []
34
+ for declared in value if isinstance(value, list) else [value]:
35
+ match = _PLACEHOLDER_RE.match(declared) if isinstance(declared, str) else None
36
+ if match is None:
37
+ resolved.append(declared)
38
+ continue
39
+ resolved.append(params[match.group(1)])
40
+ out[key] = resolved
41
+ return out
42
+
43
+
44
+ def _lookup(
45
+ layout, location: str, query: Query, base: dict, params: dict
46
+ ) -> str | list[str] | None:
47
+ """Return the reduced result for the first alternative that matches anything.
48
+
49
+ If a single file is expected, the result is a string or ``None``.
50
+ If an unordered list of files is expected, the result is a non-empty list or ``None``.
51
+ If an ordered list of files is expected, the result is a complete list or ``None``.
52
+
53
+ Ambiguous results (multiple matches for either a single file
54
+ or a single value in an ordered list) produce ValueErrors.
55
+
56
+ Ordered lists are sorted by matching entity values.
57
+ Unordered lists are natural-sorted by path to ensure deterministic results,
58
+ and to support use-cases such as :func:`nipost.reconstruct_fieldmap` that depend
59
+ on position (``coefficients[-1]`` is the finest B-spline level).
60
+ """
61
+ found = []
62
+ entities: dict = {}
63
+ filters: dict = {}
64
+ index = 0
65
+ for index, entities in enumerate(query.entities):
66
+ filters = _resolve(entities, base, query.scope, params)
67
+ found = layout.get(**filters)
68
+ if found:
69
+ break
70
+ where = f'{location} in {layout.root} (alternative {index})'
71
+
72
+ # Unordered, single or multi
73
+ if query.order is None:
74
+ if not (paths := [f.path for f in found]):
75
+ return None
76
+ if query.is_multi:
77
+ return sorted(paths, key=_natural_key)
78
+ if len(paths) > 1:
79
+ raise ValueError(
80
+ f'{where}: expected at most one match for {filters}, got {len(paths)}: {paths}'
81
+ )
82
+ return paths[0]
83
+
84
+ # Ordered result
85
+ by_value: dict[str | None, list[str]] = {}
86
+ for f in found:
87
+ by_value.setdefault(f.entities.get(query.order), []).append(f.path)
88
+
89
+ for value, group in by_value.items():
90
+ if len(group) > 1:
91
+ raise ValueError(
92
+ f'{where}: expected at most one match for {filters} with '
93
+ f'{query.order}={value!r}, got {len(group)}: {group}'
94
+ )
95
+
96
+ order_values = _coerce_axis(entities.get(query.order, []), by_value)
97
+
98
+ if not order_values or any(value not in by_value for value in order_values):
99
+ return None
100
+
101
+ return [by_value[value][0] for value in order_values]
102
+
103
+
104
+ def _natural_key(path: str) -> list[int | str]:
105
+ """Sort key ordering embedded integers numerically, so coeff2 precedes coeff10.
106
+
107
+ ``re.split`` with a capture group always yields non-digit segments at
108
+ even indices and digit runs at odd ones, so within a single key an
109
+ ``int`` is never compared against a ``str`` at the same position.
110
+ """
111
+ return [int(part) if part.isdigit() else part for part in re.split(r'(\d+)', path)]
112
+
113
+
114
+ def _coerce_axis(order_values: list[str | None], parsed: Iterable[str | None]) -> list[str | None]:
115
+ """Coerce declared axis values to the type PyBIDS parsed the entity as.
116
+
117
+ PyBIDS will coerce '01' to an int if the entity is int-typed,
118
+ so the axis must do the same in order to match values
119
+ (``'01' != PaddedInt('01')``).
120
+
121
+ Coerce rather than error, because the values have already been accepted
122
+ by PyBIDS.
123
+ """
124
+
125
+ if (sample := next((val for val in parsed if val is not None), None)) is None:
126
+ return order_values
127
+ cls = type(sample)
128
+ coerced: list[str | None] = []
129
+ for value in order_values:
130
+ if value is None or isinstance(value, cls):
131
+ coerced.append(value)
132
+ continue
133
+ try:
134
+ coerced.append(cls(value))
135
+ except (TypeError, ValueError):
136
+ coerced.append(value)
137
+ return coerced
138
+
139
+
140
+ def collect_derivatives(
141
+ derivatives_dir: Path,
142
+ *,
143
+ spec: Spec,
144
+ entities: dict | None = None,
145
+ params: dict | None = None,
146
+ ) -> dict:
147
+ """Collect precomputed derivatives described by ``spec``.
148
+
149
+ Parameters
150
+ ----------
151
+ derivatives_dir
152
+ Root of the derivatives dataset to search.
153
+ spec
154
+ Author-named groups of queries, from :func:`nipost.bids.spec.load_spec`.
155
+ entities
156
+ BIDS entities to match, e.g. ``{'subject': '01', 'task': 'rest'}``.
157
+ A query's ``scope`` may narrow which of these it accepts.
158
+ params
159
+ Values for the placeholders the spec references. A group declaring
160
+ ``per: <name>`` iterates ``params[<name>]``: ``None`` for no iterations,
161
+ a single value, or a sequence of values. Any other placeholder takes
162
+ ``params[<name>]`` as a scalar, used verbatim: ``None`` requires the
163
+ entity to be absent, and a ``bids.layout.Query`` member has its
164
+ PyBIDS meaning. Values are used verbatim, so apply
165
+ :func:`nipost.bids.spec.sanitize_space` or
166
+ :func:`nipost.bids.spec.sanitize_fieldmap_id` first if needed.
167
+
168
+ Returns
169
+ -------
170
+ dict
171
+ One key per group declared by ``spec``, always present even when the
172
+ group collected nothing. A plain group maps to ``{query: result}``; a
173
+ group with ``per`` maps to ``{param value: {query: result}}``.
174
+
175
+ Raises
176
+ ------
177
+ TypeError
178
+ If a group's ``per`` parameter is missing or contains a
179
+ ``bids.layout.Query`` member, a placeholder has no value in
180
+ ``params``, or a list or tuple is bound to a scalar placeholder.
181
+ ValueError
182
+ If a query matches more than one file in one alternative, or two
183
+ files share an ordering value.
184
+ """
185
+ base: dict = dict(entities or {})
186
+ supplied: dict = dict(params or {})
187
+
188
+ # Check every group before querying, so a malformed call fails on any dataset
189
+ iterations: dict[str, list | None] = {}
190
+ for group_name, group in spec.items():
191
+ if group.per is not None:
192
+ if group.per not in supplied:
193
+ raise TypeError(
194
+ f'Group {group_name!r} iterates over {group.per!r}; '
195
+ f'pass params[{group.per!r}], or None for no iterations'
196
+ )
197
+ values = supplied[group.per]
198
+ if values is None:
199
+ values = []
200
+ elif isinstance(values, str) or not isinstance(values, Iterable):
201
+ values = [values]
202
+ else:
203
+ values = list(values)
204
+ if any(isinstance(value, (list, tuple)) for value in values):
205
+ raise TypeError(
206
+ f'Group {group_name!r}: params[{group.per!r}] cannot bind a list '
207
+ f'or tuple to a scalar placeholder'
208
+ )
209
+ if any(isinstance(value, BIDSQuery) for value in values):
210
+ raise TypeError(
211
+ f'Group {group_name!r}: params[{group.per!r}] cannot contain '
212
+ f'bids.layout.Query members'
213
+ )
214
+ iterations[group_name] = values
215
+ else:
216
+ iterations[group_name] = None
217
+ for query_name, query in group.queries.items():
218
+ for i, names in enumerate(query.placeholders):
219
+ if missing := sorted(names - supplied.keys()):
220
+ raise TypeError(
221
+ f'{group_name}/{query_name} (alternative {i}) has no value for '
222
+ f'params {missing}'
223
+ )
224
+ for name in names:
225
+ if name != group.per and isinstance(supplied.get(name), (list, tuple)):
226
+ raise TypeError(
227
+ f'{group_name}/{query_name} (alternative {i}): '
228
+ f'params[{name!r}] must be a scalar, not a list or tuple'
229
+ )
230
+
231
+ layout = get_layout(Path(derivatives_dir))
232
+
233
+ def _run_queries(prefix: str, group: Group, params: dict) -> dict:
234
+ """Run every query in a group once, dropping the keys that found nothing."""
235
+ return {
236
+ key: result
237
+ for key, query in group.queries.items()
238
+ if (result := _lookup(layout, f'{prefix}/{key}', query, base, params)) is not None
239
+ }
240
+
241
+ out: dict = {}
242
+ for group_name, group in spec.items():
243
+ values = iterations[group_name]
244
+ if values is None:
245
+ out[group_name] = _run_queries(group_name, group, supplied)
246
+ continue
247
+ out[group_name] = {
248
+ value: _run_queries(f'{group_name}[{value}]', group, {**supplied, group.per: value})
249
+ for value in values
250
+ }
251
+ return out
252
+
253
+
254
+ def collect_fieldmaps(
255
+ derivatives_dir: Path,
256
+ *,
257
+ entities: dict,
258
+ spec: Spec | None = None,
259
+ ) -> dict[str, dict[str, dict[str, str | list[str]]]]:
260
+ """Collect fieldmap derivatives, grouped by fieldmap id.
261
+
262
+ Parameters
263
+ ----------
264
+ derivatives_dir
265
+ Root of the derivatives dataset to search.
266
+ entities
267
+ BIDS entities to match, e.g. ``{'subject': '01'}``. Also scopes the
268
+ fieldmap-id enumeration, so only ids reachable under these entities
269
+ are collected.
270
+ spec
271
+ Groups of queries to run against each id. Defaults to the bundled
272
+ ``fmap`` spec.
273
+
274
+ Returns
275
+ -------
276
+ dict
277
+ The grouped output of :func:`collect_derivatives`. With the bundled
278
+ spec that is ``{'fieldmaps': {fmapid: {'fieldmap': ..., 'coeffs': [...],
279
+ 'magnitude': ...}}}``, where ``coeffs`` has one entry per B-spline
280
+ level and is present only when coefficient files are found, which is
281
+ what :func:`nipost.reconstruct_fieldmap` expects.
282
+ """
283
+ if spec is None:
284
+ spec = load_spec('fmap')
285
+
286
+ layout = get_layout(Path(derivatives_dir))
287
+
288
+ fmapids: list[str] = layout.get_fmapids(**entities)
289
+
290
+ return collect_derivatives(
291
+ derivatives_dir, spec=spec, entities=entities, params={'fmapid': fmapids}
292
+ )
@@ -0,0 +1,57 @@
1
+ images:
2
+ queries:
3
+ t1w_preproc:
4
+ entities:
5
+ - datatype: anat
6
+ suffix: T1w
7
+ desc: preproc
8
+ space: null
9
+ extension: [.nii.gz, .nii]
10
+ t2w_preproc:
11
+ entities:
12
+ - datatype: anat
13
+ suffix: T2w
14
+ desc: preproc
15
+ space: null
16
+ extension: [.nii.gz, .nii]
17
+ mask:
18
+ entities:
19
+ - datatype: anat
20
+ suffix: mask
21
+ desc: brain
22
+ space: null
23
+ extension: [.nii.gz, .nii]
24
+ dseg:
25
+ entities:
26
+ - datatype: anat
27
+ suffix: dseg
28
+ desc: null
29
+ space: null
30
+ extension: [.nii.gz, .nii]
31
+ tpms:
32
+ entities:
33
+ - datatype: anat
34
+ suffix: probseg
35
+ space: null
36
+ label: [GM, WM, CSF]
37
+ extension: [.nii.gz, .nii]
38
+ multi: { order: label }
39
+ anat_ribbon:
40
+ entities:
41
+ - datatype: anat
42
+ desc: ribbon
43
+ suffix: mask
44
+ extension: [.nii.gz, .nii]
45
+ transforms:
46
+ per: space
47
+ queries:
48
+ forward:
49
+ entities:
50
+ - from: T1w
51
+ to: "{space}"
52
+ suffix: xfm
53
+ reverse:
54
+ entities:
55
+ - from: "{space}"
56
+ to: T1w
57
+ suffix: xfm
@@ -0,0 +1,25 @@
1
+ fieldmaps:
2
+ per: fmapid
3
+ queries:
4
+ fieldmap:
5
+ entities:
6
+ - datatype: fmap
7
+ fmapid: "{fmapid}"
8
+ desc: preproc
9
+ suffix: fieldmap
10
+ extension: [.nii.gz, .nii]
11
+ coeffs:
12
+ entities:
13
+ - datatype: fmap
14
+ fmapid: "{fmapid}"
15
+ desc: [coeff, coeff0, coeff1]
16
+ suffix: fieldmap
17
+ extension: [.nii.gz, .nii]
18
+ multi: true
19
+ magnitude:
20
+ entities:
21
+ - datatype: fmap
22
+ fmapid: "{fmapid}"
23
+ desc: epi
24
+ suffix: fieldmap
25
+ extension: [.nii.gz, .nii]
@@ -0,0 +1,125 @@
1
+ boldrefs:
2
+ queries:
3
+ hmc:
4
+ entities:
5
+ - datatype: func
6
+ space: orig
7
+ desc: hmc
8
+ suffix: boldref
9
+ extension: [.nii.gz, .nii]
10
+ - datatype: func
11
+ space: null
12
+ desc: hmc
13
+ suffix: boldref
14
+ extension: [.nii.gz, .nii]
15
+ run:
16
+ entities:
17
+ - datatype: func
18
+ space: run
19
+ desc: null
20
+ suffix: boldref
21
+ extension: [.nii.gz, .nii]
22
+ - datatype: func
23
+ space: null
24
+ desc: coreg
25
+ suffix: boldref
26
+ extension: [.nii.gz, .nii]
27
+ session:
28
+ entities:
29
+ - datatype: func
30
+ space: session
31
+ suffix: boldref
32
+ extension: [.nii.gz, .nii]
33
+ scope: [subject, session]
34
+ subject:
35
+ entities:
36
+ - datatype: func
37
+ space: subject
38
+ suffix: boldref
39
+ extension: [.nii.gz, .nii]
40
+ scope: [subject]
41
+ transforms:
42
+ queries:
43
+ hmc:
44
+ entities:
45
+ - datatype: func
46
+ from: orig
47
+ to: run
48
+ mode: image
49
+ suffix: xfm
50
+ extension: .txt
51
+ - datatype: func
52
+ from: orig
53
+ to: boldref
54
+ mode: image
55
+ suffix: xfm
56
+ extension: .txt
57
+ run2anat:
58
+ entities:
59
+ - datatype: func
60
+ from: run
61
+ to: [anat, T1w, T2w]
62
+ mode: image
63
+ suffix: xfm
64
+ extension: .txt
65
+ - datatype: func
66
+ from: boldref
67
+ to: [anat, T1w, T2w]
68
+ mode: image
69
+ suffix: xfm
70
+ extension: .txt
71
+ run2session:
72
+ entities:
73
+ - datatype: func
74
+ from: run
75
+ to: session
76
+ desc: coreg
77
+ mode: image
78
+ suffix: xfm
79
+ extension: .txt
80
+ run2subject:
81
+ entities:
82
+ - datatype: func
83
+ from: run
84
+ to: subject
85
+ desc: coreg
86
+ mode: image
87
+ suffix: xfm
88
+ extension: .txt
89
+ session2anat:
90
+ entities:
91
+ - datatype: func
92
+ from: session
93
+ to: [anat, T1w, T2w]
94
+ mode: image
95
+ suffix: xfm
96
+ extension: .txt
97
+ scope: [subject, session]
98
+ subject2anat:
99
+ entities:
100
+ - datatype: func
101
+ from: subject
102
+ to: [anat, T1w, T2w]
103
+ mode: image
104
+ suffix: xfm
105
+ extension: .txt
106
+ scope: [subject]
107
+ fmap_transforms:
108
+ per: fmapid
109
+ queries:
110
+ run2fmap:
111
+ entities:
112
+ - datatype: func
113
+ from: run
114
+ to: "{fmapid}"
115
+ desc: fmap
116
+ mode: image
117
+ suffix: xfm
118
+ extension: .txt
119
+ - datatype: func
120
+ from: boldref
121
+ to: "{fmapid}"
122
+ desc: null
123
+ mode: image
124
+ suffix: xfm
125
+ extension: .txt