Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
ab2139e
[WIP] [FEAT] MEDIC distortion correction via warpkit
vanandrew Apr 26, 2026
5e0ea6f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 26, 2026
2a9d522
[WIP] [FEAT] init_medic_wf workflow + MEDIC wrangler integration
vanandrew May 5, 2026
d4c5f90
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 5, 2026
58a6384
[WIP] [FEAT] Plumb MEDIC dynamic outputs through derivatives writer
vanandrew May 5, 2026
15bbaf2
[WIP] [FEAT] init_dynamic_unwarp_wf — per-volume MEDIC apply
vanandrew May 5, 2026
6087c15
[FIX] init_dynamic_unwarp_wf: forward PE direction sign to ConvertFie…
vanandrew May 5, 2026
4064683
[FIX] Forward signed PE direction directly instead of using flip_sign
vanandrew May 5, 2026
399e5a8
[CI] Wire up MEDIC end-to-end tests on the veryslow job
vanandrew May 10, 2026
98e0649
[CI] Swap MEDIC fixture from deleted ds005250 to ds007637
vanandrew May 10, 2026
083606a
[REFACTOR] init_medic_wf — review cleanups
vanandrew May 10, 2026
3d5d819
[FEAT] init_dynamic_unwarp_wf — Jacobian intensity correction
vanandrew May 10, 2026
6cf6f99
[REFACTOR] FieldmapEstimation MEDIC: file pre-flight + reject incompl…
vanandrew May 10, 2026
4f5d482
[FIX] wrangler MEDIC: tighten dedup + cover mag-side IntendedFor
vanandrew May 10, 2026
3ae5b15
[REFACTOR] init_medic_wf: runtime guard for single-echo input
vanandrew May 10, 2026
baf4e76
DOC: use pandoc-style citation key in MEDIC __desc__
vanandrew May 11, 2026
a3ff64f
[FIX] conftest: skip ds006926/ds007637 in layouts auto-index
vanandrew May 11, 2026
0353b30
[FIX] MEDIC veryslow tests: truncate BOLD to 3 volumes
vanandrew May 11, 2026
9be3e01
[FIX] init_fmap_preproc_wf: align dynamic merges across non-MEDIC est…
vanandrew May 11, 2026
02c5f1f
[FIX] init_medic_wf: accept use_metadata_estimates / fallback_total_r…
vanandrew May 13, 2026
7a5f45e
[FIX] outputs: dismiss task entity on MEDIC dynamic ref/mask sinks
vanandrew May 13, 2026
0fca3e5
[FIX] outputs: route MEDIC dynamic magnitude ref through fieldmap suffix
vanandrew May 13, 2026
d4fb5a1
[FIX] warpkit: restore border_filt=(1, 5) default lost to traits.Tupl…
vanandrew May 13, 2026
5b7e692
[ENH] wrangler: add force_medic for datasets without IntendedFor
vanandrew May 13, 2026
b1ce37b
[TEST] Lift MEDIC patch coverage above project gate
vanandrew May 14, 2026
f86a455
[FIX] warpkit: drop noise_frames trait
vanandrew May 16, 2026
990e687
[REF] medic: build UnwrapPhase/ComputeFieldmap nodes with ctor inputs
vanandrew May 16, 2026
ffef6dd
[REF] dynamic apply: drop unused warpkit interfaces, switch to nitran…
vanandrew May 16, 2026
0a700a2
[FIX] medic: unify static and dynamic fmap outputs
vanandrew May 16, 2026
be7c632
[ENH] medic: per-frame fmap_ref/fmap_mask via init_dynamic_magnitude_wf
vanandrew May 17, 2026
42a39cf
[TEST] medic: cover FieldmapEstimation MEDIC reject branches + docume…
vanandrew May 22, 2026
1d603d7
[REF] fmap_preproc: gate static-vs-dynamic branching on FieldmapEstim…
vanandrew May 22, 2026
11f757b
[TEST] wrangler: pin MEDIC discovery trigger matrix side-by-side
vanandrew May 22, 2026
622a798
[MAINT] zenodo: add Andrew Van (Washington University in St. Louis) a…
vanandrew May 23, 2026
80eca9b
[CI] free runner disk before data-cache-v3 restore
vanandrew May 25, 2026
4916d8c
[CI] pin free-disk-space to v1.3.1
vanandrew May 25, 2026
14cb378
[MAINT] bump warpkit minimum to 1.2.2
vanandrew May 30, 2026
cd20c6e
[REF] medic: fmap_ref from raw first-echo magnitude, fmap_mask from w…
vanandrew Jun 2, 2026
af4a0b7
[ENH] wrangler: BIDS-intent-driven MEDIC discovery, no_medic flag, ME…
vanandrew Jun 2, 2026
da3ba82
[FIX] medic: correct MEDIC citation key to van2026medic
vanandrew Jun 2, 2026
ec54f2c
[STY] dynamic apply: drop fmt markers for inline skip, prefer pathlib
vanandrew Jun 10, 2026
8bb65da
[REF] medic: fold parity-only kwargs, inline workflow desc
vanandrew Jun 10, 2026
656fb73
[TST] medic: share volume/fixture scaffolding via conftest
vanandrew Jun 10, 2026
e3f15dd
[MAINT] warpkit: promote to core dependency, drop extras group
vanandrew Jun 10, 2026
4cc805e
[REF] transform: fieldmap_jacobian takes the VSM directly
vanandrew Jun 10, 2026
4f656f6
[FIX] dynamic apply: return corrected path as str, not Path
vanandrew Jun 11, 2026
a012408
[REF] transform: one apply path for 3D and 4D fieldmaps
vanandrew Jun 11, 2026
a68a1e4
[MAINT] warpkit: bump minimum version to 1.4.0
vanandrew Jun 12, 2026
5627382
bump warpkit version to 1.4.1
vanandrew Jul 23, 2026
fafe3bd
[REF] warpkit interfaces: adopt nipype num_threads convention, simplify
vanandrew Jul 23, 2026
7b02d9a
[DOC] transform: clarify _resample_with_fieldmap fmap_hz preconditions
vanandrew Jul 23, 2026
b799d17
bump warpkit version to 1.5.0
vanandrew Jul 28, 2026
71f7ba7
[FIX] warpkit interfaces: drop wrap_limit, removed in warpkit 1.5.0
vanandrew Jul 29, 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.5.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