Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
5ef58d5
[WIP] [FEAT] MEDIC distortion correction via warpkit
vanandrew Apr 26, 2026
ceb679e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 26, 2026
c9e94b3
[WIP] [FEAT] init_medic_wf workflow + MEDIC wrangler integration
vanandrew May 5, 2026
82b8f50
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 5, 2026
ff4e17c
[WIP] [FEAT] Plumb MEDIC dynamic outputs through derivatives writer
vanandrew May 5, 2026
786fefa
[WIP] [FEAT] init_dynamic_unwarp_wf — per-volume MEDIC apply
vanandrew May 5, 2026
be00ad0
[FIX] init_dynamic_unwarp_wf: forward PE direction sign to ConvertFie…
vanandrew May 5, 2026
d4e6c07
[FIX] Forward signed PE direction directly instead of using flip_sign
vanandrew May 5, 2026
e6efe5b
[CI] Wire up MEDIC end-to-end tests on the veryslow job
vanandrew May 10, 2026
09ea041
[CI] Swap MEDIC fixture from deleted ds005250 to ds007637
vanandrew May 10, 2026
e3f1881
[REFACTOR] init_medic_wf — review cleanups
vanandrew May 10, 2026
3ec497a
[FEAT] init_dynamic_unwarp_wf — Jacobian intensity correction
vanandrew May 10, 2026
7645a68
[REFACTOR] FieldmapEstimation MEDIC: file pre-flight + reject incompl…
vanandrew May 10, 2026
7c44697
[FIX] wrangler MEDIC: tighten dedup + cover mag-side IntendedFor
vanandrew May 10, 2026
7c62849
[REFACTOR] init_medic_wf: runtime guard for single-echo input
vanandrew May 10, 2026
4961880
DOC: use pandoc-style citation key in MEDIC __desc__
vanandrew May 11, 2026
d0140eb
[FIX] conftest: skip ds006926/ds007637 in layouts auto-index
vanandrew May 11, 2026
d071f61
[FIX] MEDIC veryslow tests: truncate BOLD to 3 volumes
vanandrew May 11, 2026
877d1e7
[FIX] init_fmap_preproc_wf: align dynamic merges across non-MEDIC est…
vanandrew May 11, 2026
23e9b78
[FIX] init_medic_wf: accept use_metadata_estimates / fallback_total_r…
vanandrew May 13, 2026
20cdeb6
[FIX] outputs: dismiss task entity on MEDIC dynamic ref/mask sinks
vanandrew May 13, 2026
1c96422
[FIX] outputs: route MEDIC dynamic magnitude ref through fieldmap suffix
vanandrew May 13, 2026
e3ce5d1
[FIX] warpkit: restore border_filt=(1, 5) default lost to traits.Tupl…
vanandrew May 13, 2026
de21052
[ENH] wrangler: add force_medic for datasets without IntendedFor
vanandrew May 13, 2026
0881c62
[TEST] Lift MEDIC patch coverage above project gate
vanandrew May 14, 2026
92e0d02
[FIX] warpkit: drop noise_frames trait
vanandrew May 16, 2026
838105b
[REF] medic: build UnwrapPhase/ComputeFieldmap nodes with ctor inputs
vanandrew May 16, 2026
8ff5fcc
[REF] dynamic apply: drop unused warpkit interfaces, switch to nitran…
vanandrew May 16, 2026
eb2dec3
[FIX] medic: unify static and dynamic fmap outputs
vanandrew May 16, 2026
f859fbc
[ENH] medic: per-frame fmap_ref/fmap_mask via init_dynamic_magnitude_wf
vanandrew May 17, 2026
2dc8ad7
[TEST] medic: cover FieldmapEstimation MEDIC reject branches + docume…
vanandrew May 22, 2026
2df1289
[REF] fmap_preproc: gate static-vs-dynamic branching on FieldmapEstim…
vanandrew May 22, 2026
b10c8c3
[TEST] wrangler: pin MEDIC discovery trigger matrix side-by-side
vanandrew May 22, 2026
3510b33
[MAINT] zenodo: add Andrew Van (Washington University in St. Louis) a…
vanandrew May 23, 2026
c11137b
[CI] free runner disk before data-cache-v3 restore
vanandrew May 25, 2026
584b6f6
[CI] pin free-disk-space to v1.3.1
vanandrew May 25, 2026
e468ce4
[MAINT] bump warpkit minimum to 1.2.2
vanandrew May 30, 2026
9ff7621
[REF] medic: fmap_ref from raw first-echo magnitude, fmap_mask from w…
vanandrew Jun 2, 2026
0bcccae
[ENH] wrangler: BIDS-intent-driven MEDIC discovery, no_medic flag, ME…
vanandrew Jun 2, 2026
b0a6904
[FIX] medic: correct MEDIC citation key to van2026medic
vanandrew Jun 2, 2026
ced076d
[STY] dynamic apply: drop fmt markers for inline skip, prefer pathlib
vanandrew Jun 10, 2026
ea0892f
[REF] medic: fold parity-only kwargs, inline workflow desc
vanandrew Jun 10, 2026
8800cb3
[TST] medic: share volume/fixture scaffolding via conftest
vanandrew Jun 10, 2026
0092e43
[MAINT] warpkit: promote to core dependency, drop extras group
vanandrew Jun 10, 2026
70b0e0c
[REF] transform: fieldmap_jacobian takes the VSM directly
vanandrew Jun 10, 2026
a7cf33b
[FIX] dynamic apply: return corrected path as str, not Path
vanandrew Jun 11, 2026
e94f285
[REF] transform: one apply path for 3D and 4D fieldmaps
vanandrew Jun 11, 2026
9993c5a
[MAINT] warpkit: bump minimum version to 1.4.0
vanandrew Jun 12, 2026
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
21 changes: 20 additions & 1 deletion .github/workflows/build-test-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ jobs:
dependencies: "pre"

steps:
- name: Free disk space
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: false
swap-storage: false
- uses: actions/checkout@v5
- uses: actions/cache@v4
with:
Expand Down Expand Up @@ -161,7 +170,7 @@ jobs:
- uses: actions/cache@v4
with:
path: ${{ env.TEST_DATA_HOME }}
key: data-cache-v2
key: data-cache-v3
restore-keys: |
data-cache-
- name: Install test data
Expand Down Expand Up @@ -206,6 +215,16 @@ jobs:
datalad update -r --merge -d hcph-pilot_fieldmaps/
datalad get -r -J 2 -d hcph-pilot_fieldmaps/ hcph-pilot_fieldmaps/*

# ds006926 — MEDIC multi-echo mag+phase BOLD (sub-a01 only)
datalad install -r https://github.com/OpenNeuroDatasets/ds006926.git
datalad update -r --merge -d ds006926/
datalad get -r -J 2 -d ds006926/ ds006926/sub-a01/func/sub-a01_task-VisMot_acq-tr1800_*

# ds007637 — MEDIC multi-echo mag+phase BOLD (sub-04/ses-2 fracback only)
datalad install -r https://github.com/OpenNeuroDatasets/ds007637.git
datalad update -r --merge -d ds007637/
datalad get -r -J 2 -d ds007637/ ds007637/sub-04/ses-2/func/sub-04_ses-2_task-fracback_acq-MBME_echo-*_part-mag_bold.nii.gz ds007637/sub-04/ses-2/func/sub-04_ses-2_task-fracback_acq-MBME_echo-*_part-phase_bold.nii.gz

- name: Set FreeSurfer variables
run: |
echo "FREESURFER_HOME=$HOME/.cache/freesurfer" >> $GITHUB_ENV
Expand Down
6 changes: 6 additions & 0 deletions .zenodo.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@
"affiliation": "Department of Psychology, Stanford University, CA, USA",
"name": "Russell A. Poldrack",
"type": "Researcher"
},
{
"orcid": "0000-0002-8787-0943",
"affiliation": "Department of Biomedical Engineering, Washington University in St. Louis, MO, USA",
"name": "Andrew Van",
"type": "Researcher"
}
],
"keywords": [
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ dependencies = [
"scipy >= 1.10",
"templateflow >= 23.1",
"toml >= 0.10",
# The marker keeps Python 3.10 installs working — warpkit requires >= 3.11.
"warpkit >= 1.4.0; python_version >= '3.11'",
]
dynamic = ["version"]

Expand Down
1 change: 1 addition & 0 deletions sdcflows/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def main(argv=None):
layout=config.execution.layout,
subject=subject,
fmapless=config.workflow.fmapless,
no_medic=config.workflow.no_medic,
logger=config.loggers.cli,
)

Expand Down
8 changes: 8 additions & 0 deletions sdcflows/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,14 @@ def _bids_filter(value):
default=True,
help='Allow fieldmap-less estimation',
)
g_outputs.add_argument(
'--no-medic',
action='store_true',
dest='no_medic',
default=False,
help='Disable MEDIC discovery (by default MEDIC takes priority for '
'complex multi-echo BOLD)',
)
g_outputs.add_argument(
'--use-plugin',
action='store',
Expand Down
2 changes: 2 additions & 0 deletions sdcflows/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,8 @@ class workflow(_Config):
"""Level of analysis."""
fmapless = False
"""Allow fieldmap-less estimation"""
no_medic = False
"""Disable MEDIC discovery (otherwise MEDIC takes priority for complex multi-echo BOLD)"""
species = 'human'
"""Subject species to choose most appropriate template"""
template_id = 'MNI152NLin2009cAsym'
Expand Down
58 changes: 57 additions & 1 deletion sdcflows/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,17 @@
test_workdir = os.getenv('TEST_WORK_DIR')
_sloppy_mode = os.getenv('TEST_PRODUCTION', 'off').lower() not in ('on', '1', 'true', 'yes', 'y')

# MEDIC fixtures live in full OpenNeuro trees (tens of thousands of JSON
# sidecars) but only a few files are actually fetched via ``datalad get``.
# Indexing those trees with ``BIDSLayout(derivatives=True)`` at collection
# time stalled CI past the 20-minute tox watchdog. The MEDIC tests reach
# their files via the ``datadir`` fixture directly, not via ``layouts``.
_SKIP_LAYOUTS = {'ds006926', 'ds007637'}

layouts = {
p.name: BIDSLayout(str(p), validate=False, derivatives=True)
for p in Path(test_data_env).glob('*')
if p.is_dir()
if p.is_dir() and p.name not in _SKIP_LAYOUTS
}

data_dir = Path(__file__).parent / 'tests' / 'data'
Expand Down Expand Up @@ -128,3 +135,52 @@ def dsA_dir():
@pytest.fixture
def sloppy_mode():
return _sloppy_mode


# MEDIC end-to-end fixtures, shared by the fit (``test_medic``) and apply
# (``test_dynamic``) test modules. A handful of timepoints is enough to
# exercise the full per-volume path; the source datasets ship 200+ volumes ×
# 5 echoes × mag+phase, which OOM-kills CI runners when xdist schedules these
# in parallel.
_MEDIC_DATASETS = [
pytest.param(
(
'ds007637',
'sub-04/ses-2/func/sub-04_ses-2_task-fracback_acq-MBME_echo-*_part-mag_bold.nii.gz',
),
id='ds007637',
),
pytest.param(
('ds006926', 'sub-a01/func/sub-a01_task-VisMot_acq-tr1800_echo-*_part-mag_bold.nii.gz'),
id='ds006926',
),
]


@pytest.fixture
def medic_test_volumes():
return 3


@pytest.fixture(params=_MEDIC_DATASETS)
def medic_fixture(request):
"""Yield ``(dataset, mag_glob_under_dataset)`` for each MEDIC fixture."""
return request.param


@pytest.fixture
def truncate_to_volumes():
"""Return a helper that slices 4D NIfTIs down to ``volumes`` timepoints."""

def _truncate(in_files, volumes, dest):
out = []
for f in in_files:
img = nibabel.load(str(f))
if img.shape[-1] > volumes:
img = img.slicer[..., :volumes]
new = dest / f.name
img.to_filename(new)
out.append(new)
return out

return _truncate
67 changes: 66 additions & 1 deletion sdcflows/fieldmaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class EstimatorType(Enum):
PHASEDIFF = auto()
MAPPED = auto()
ANAT = auto()
MEDIC = auto()


MODALITIES = {
Expand All @@ -75,6 +76,12 @@ class EstimatorType(Enum):
'T2w': EstimatorType.ANAT,
}

# Estimator types that emit a per-volume 4D fieldmap on the EPI grid (and
# therefore do not produce B-spline coefficients). Add new dynamic methods
# here so consumers — ``init_fmap_preproc_wf`` in particular — pick them up
# without per-method branching.
_DYNAMIC_METHODS = frozenset({EstimatorType.MEDIC})


def _type_setter(obj, attribute, value):
"""Make sure the type of estimation is not changed."""
Expand All @@ -88,6 +95,7 @@ def _type_setter(obj, attribute, value):
EstimatorType.PHASEDIFF,
EstimatorType.MAPPED,
EstimatorType.ANAT,
EstimatorType.MEDIC,
):
raise ValueError(f'Invalid estimation method type {value}.')

Expand Down Expand Up @@ -338,6 +346,36 @@ def __attrs_post_init__(self):
suffix_list = [f.suffix for f in self.sources]
suffix_set = set(suffix_list)

# Fieldmap option 0: MEDIC — multi-echo phase + magnitude
# ``bold`` / ``epi`` sources tagged with the BIDS ``part-{phase,mag}``
# entity. PEPOLAR uses ``dir-`` instead, so the part entity is the
# cleanest way to disambiguate.
parts = {f.entities.get('part') for f in self.sources}
medic_parts = parts & {'phase', 'mag'}
if suffix_set <= {'bold', 'epi', 'sbref'} and medic_parts:
# Any sources is ``part``-tagged: this is a MEDIC-shaped input.
# Reject incomplete sets explicitly rather than letting them slip
# through to the PEPOLAR branch and produce a confusing failure.
if parts != {'phase', 'mag'}:
raise ValueError(
'MEDIC requires every source to be tagged ``part-mag`` or '
'``part-phase``, with both present; got '
f'parts={sorted(str(p) for p in parts)!r}.'
)
phase_files = [f for f in self.sources if f.entities.get('part') == 'phase']
mag_files = [f for f in self.sources if f.entities.get('part') == 'mag']
if len(phase_files) < 2:
raise ValueError(
f'MEDIC requires at least two echoes of phase data; got {len(phase_files)}.'
)
if len(phase_files) != len(mag_files):
raise ValueError(
f'MEDIC requires matched magnitude/phase pairs per echo; '
f'got {len(phase_files)} phase and {len(mag_files)} '
'magnitude file(s).'
)
self.method = EstimatorType.MEDIC

# Fieldmap option 1: actual field-mapping sequences
fmap_types = suffix_set.intersection(('fieldmap', 'phasediff', 'phase1', 'phase2'))
if len(fmap_types) > 1 and fmap_types - {'phase1', 'phase2'}:
Expand Down Expand Up @@ -399,7 +437,7 @@ def __attrs_post_init__(self):
> 1
)

if _pepolar_estimation and not anat_types:
if self.method == EstimatorType.UNKNOWN and _pepolar_estimation and not anat_types:
self.method = MODALITIES[pepolar_types.pop()]
_pe = {f.metadata['PhaseEncodingDirection'] for f in self.sources}
if len(_pe) == 1:
Expand Down Expand Up @@ -455,6 +493,11 @@ def __attrs_post_init__(self):
# special characters are not allowed.
self.sanitized_id = re.sub(r'[^a-zA-Z0-9]', '_', self.bids_id)

@property
def is_dynamic(self) -> bool:
"""The estimator emits a per-volume 4D fieldmap and no B-spline coefficients."""
return self.method in _DYNAMIC_METHODS

def paths(self):
"""Return a tuple of paths that are sorted."""
return tuple(sorted(str(f.path) for f in self.sources))
Expand Down Expand Up @@ -502,6 +545,28 @@ def get_workflow(self, set_inputs=True, **kwargs):
from .workflows.fit.syn import init_syn_sdc_wf

self._wf = init_syn_sdc_wf(**kwargs)
elif self.method == EstimatorType.MEDIC:
from .workflows.fit.medic import init_medic_wf

for f in self.sources:
if not f.path.is_file():
raise FileNotFoundError(
f'File path <{f.path}> does not exist, '
'is a broken link, or it is not a file'
)

self._wf = init_medic_wf(**kwargs)

if set_inputs:
phase_files = [f for f in self.sources if f.entities.get('part') == 'phase']
mag_files = [f for f in self.sources if f.entities.get('part') == 'mag']
# Order both lists by EchoTime so warpkit gets aligned echo
# series. BIDS does not guarantee echo entity == numeric order.
phase_files = sorted(phase_files, key=lambda f: f.metadata['EchoTime'])
mag_files = sorted(mag_files, key=lambda f: f.metadata['EchoTime'])
self._wf.inputs.inputnode.phase = [str(f.path.absolute()) for f in phase_files]
self._wf.inputs.inputnode.magnitude = [str(f.path.absolute()) for f in mag_files]
self._wf.inputs.inputnode.metadata = [f.metadata for f in phase_files]

return self._wf

Expand Down
75 changes: 75 additions & 0 deletions sdcflows/interfaces/tests/test_warpkit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 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/
#
"""Tests for the warpkit nipype interface wrappers.

These tests check spec shape and small helpers; the actual ``_run_interface``
methods require :mod:`warpkit`, which is an optional dependency.
"""

import pytest

from sdcflows.interfaces import warpkit as wk


def test_warpkit_base_interface_pkg():
"""All warpkit interfaces share the ``warpkit`` library tag.

This lets nipype's ``LibraryBaseInterface`` emit a single, consistent
"warpkit not installed" message rather than per-class noise.
"""
assert wk.WarpkitBaseInterface._pkg == 'warpkit'
for cls in (wk.UnwrapPhase, wk.ComputeFieldmap):
assert issubclass(cls, wk.WarpkitBaseInterface)


@pytest.mark.parametrize(
'cls,expected_inputs,expected_outputs',
[
(
wk.UnwrapPhase,
{'phase', 'magnitude', 'echo_times'},
{'unwrapped', 'masks'},
),
(
wk.ComputeFieldmap,
{'unwrapped', 'magnitude', 'masks', 'border_filt', 'svd_filt'},
{'fieldmap_native', 'displacement_map', 'fieldmap'},
),
],
)
def test_interface_spec_traits(cls, expected_inputs, expected_outputs):
"""Each interface declares the expected input/output traits."""
iface = cls()
assert expected_inputs <= set(iface.inputs.copyable_trait_names())
assert expected_outputs <= set(iface.output_spec().copyable_trait_names())


def test_compute_fieldmap_border_filt_default():
"""``border_filt`` defaults to ``(1, 5)``.

Regression test for an upstream ``traits.Tuple`` quirk where the outer
``default`` kwarg silently lost to inner ``Int()`` zeros, collapsing the
SVD border filter and clipping the dynamic fieldmap footprint.
"""
iface = wk.ComputeFieldmap()
assert tuple(iface.inputs.border_filt) == (1, 5)
Loading
Loading