Skip to content

[FEAT] MEDIC distortion correction via warpkit#541

Open
vanandrew wants to merge 48 commits into
nipreps:mainfrom
vanandrew:feat/medic-warpkit
Open

[FEAT] MEDIC distortion correction via warpkit#541
vanandrew wants to merge 48 commits into
nipreps:mainfrom
vanandrew:feat/medic-warpkit

Conversation

@vanandrew

@vanandrew vanandrew commented Apr 26, 2026

Copy link
Copy Markdown

Summary

Adds multi-echo dynamic distortion correction (MEDIC) to sdcflows, backed by warpkit. MEDIC estimates a per-volume B0 fieldmap directly from a multi-echo, mag+phase BOLD series, capturing breathing- and motion-driven field changes that a single static fieldmap can't.

Revives #435 / #438 with the simpler pure-Python warpkit (post vanandrew/warpkit#16, no Julia/C++ setup required). Closes #36.

What's new

Workflows

  • init_medic_wf (sdcflows/workflows/fit/medic.py) — multi-echo phase + magnitude → 4D Hz fieldmap (one volume per timepoint, on the EPI grid) + brain-extracted reference + brain mask. Two-stage warpkit call (UnwrapPhaseComputeFieldmap) so the per-frame masks stay accessible. Single fmap output is 4D for MEDIC, leaving the 3D-vs-4D dispatch to the apply consumer.
  • init_dynamic_unwarp_wf (sdcflows/workflows/apply/dynamic.py) — per-volume apply path built on sdcflows.transform.apply_dynamic_unwarp, a per-frame extension of the same scipy/nitransforms-backed resampling that powers the static init_unwarp_wf. No warpkit needed at runtime. Includes Jacobian determinant intensity correction (jacobian=True default) sharing transform.fieldmap_jacobian with the static path.

Interfaces

  • sdcflows/interfaces/warpkit.py — thin LibraryBaseInterface wrappers around the two MEDIC stages SDCFlows actually drives: UnwrapPhase (ROMEO) and ComputeFieldmap. Lazy import — sdcflows imports warpkit only when these interfaces actually run.

Detection / dispatch

  • EstimatorType.MEDIC added. FieldmapEstimation.__attrs_post_init__ detects MEDIC inputs (part-{phase,mag} on bold/epi/sbref sources), enforces matched echo cardinality (≥2 phase, equal mag count), and rejects partial or mixed part sets. get_workflow instantiates init_medic_wf with EchoTime-sorted lists (BIDS doesn't guarantee echo entity == numeric order).
  • Wrangler seeds MEDIC detection from both part='phase' and part='mag' queries (some datasets carry IntendedFor only on one side); dedup walks the full sibling set, no reliance on pybids ordering.
  • force_medic opt-in on find_estimators — auto-discover MEDIC estimators from complex multi-echo BOLD even when neither IntendedFor nor B0FieldIdentifier is set. Pairing is unambiguous because the part-mag/part-phase echoes of the same run are MEDIC sources by construction. Intended for public datasets that ship the required echoes without the metadata the default discovery path needs.

Plumbing

  • init_fmap_preproc_wf skips fmap_coeff for MEDIC (the fieldmap is on the EPI grid by construction, no B-spline rep).
  • init_fmap_derivatives_wf uses MergeSeries(allow_4D=True) so MEDIC's 4D fmap passes through the same ds_fieldmap sink as the 3D static fmaps.

Packaging

  • New warpkit extra in pyproject.toml, explicitly excluded from [all] because warpkit ships under a non-commercial WUSTL license. Default pip install sdcflows stays Apache-clean.
  • tox.ini: the veryslow env pulls the warpkit extra so MEDIC end-to-end tests only run there.
  • CI: datalad-fetches sub-04/ses-2 of ds007637 and sub-a01 of ds006926 as MEDIC fixtures (cache key bumped to v3).

Validation

  • Three-layer multi-echo guard: wrangler seed query (echo=Query.REQUIRED), FieldmapEstimation cardinality check (len(phase_files) < 2), and _unpack_metadata runtime guard inside init_medic_wf.
  • Construction tests for both workflows run on every CI lane (no warpkit needed).
  • test_apply_dynamic_unwarp_matches_static pins apply_dynamic_unwarp to the same Hz→VSM + scipy.ndimage convention as the static _sdc_unwarp path — catches drift in sign / pe_info handling.
  • test_wrangler_filter / test_wrangler_URIs parametrized with a 3-session × 3-echo × {mag,phase} BIDS skeleton.
  • End-to-end runs on ds006926 (3-echo, 64×64×40, 205 frames) and ds007637 (5-echo, 110×110×72, 237 frames) confirm Jacobian factor sits at ~1.0 in the brain mask (std 0.087 / 0.118) with ~1–2% of voxels in compression/expansion regions and total signal change <2% — physically sensible.

Known compromises

  • Default wrangler MEDIC discovery still keys off IntendedFor / B0FieldIdentifier — same constraint as the existing single-PE EPI branch. Datasets missing both can opt into discovery via force_medic=True on find_estimators (see Detection / dispatch above).
  • The single fmap output is 4D for MEDIC. Downstream tools that expect 3D field maps need to either dispatch on dimensionality or block MEDIC-based estimators until they do.

Test plan

  • pytest sdcflows/utils/tests/test_wrangler.py — wrangler MEDIC detection paths (including force_medic)
  • pytest sdcflows/workflows/fit/tests/test_medic.pyinit_medic_wf construction + _unpack_metadata guards
  • pytest sdcflows/workflows/apply/tests/test_dynamic.pyinit_dynamic_unwarp_wf construction + jacobian flag + per-frame resampling vs. static path
  • pytest -m veryslow with pip install sdcflows[warpkit] and ds006926 / ds007637 fixtures present — full MEDIC fit + dynamic apply

@codecov

codecov Bot commented Apr 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.58333% with 37 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.41%. Comparing base (8578e49) to head (9993c5a).

Files with missing lines Patch % Lines
sdcflows/fieldmaps.py 56.25% 14 Missing ⚠️
sdcflows/workflows/apply/dynamic.py 75.00% 7 Missing ⚠️
sdcflows/transform.py 86.95% 3 Missing and 3 partials ⚠️
sdcflows/interfaces/warpkit.py 93.44% 4 Missing ⚠️
sdcflows/utils/wrangler.py 85.71% 3 Missing and 1 partial ⚠️
sdcflows/workflows/base.py 77.77% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #541      +/-   ##
==========================================
+ Coverage   84.09%   84.41%   +0.31%     
==========================================
  Files          30       33       +3     
  Lines        2880     3086     +206     
  Branches      383      424      +41     
==========================================
+ Hits         2422     2605     +183     
- Misses        382      408      +26     
+ Partials       76       73       -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@vanandrew vanandrew force-pushed the feat/medic-warpkit branch from 57ccc47 to 21ab1c6 Compare May 10, 2026 03:21
@vanandrew vanandrew marked this pull request as ready for review May 10, 2026 04:49
@vanandrew vanandrew changed the title [WIP] [FEAT] MEDIC distortion correction via warpkit [FEAT] MEDIC distortion correction via warpkit May 11, 2026
@vanandrew vanandrew force-pushed the feat/medic-warpkit branch 6 times, most recently from bcaf4ad to 9ecfbae Compare May 13, 2026 03:10
vanandrew added a commit to vanandrew/sdcflows that referenced this pull request May 14, 2026
Three additions targeting the codecov/patch failure on nipreps#541:

* sdcflows/interfaces/tests/test_warpkit.py (new) — instantiates every
  warpkit-backed interface so its spec class body is hit at import (was
  0% on codecov despite running locally; likely an xdist worker-merge
  artefact). Also covers ``_as_str_list``, the ``_pkg`` invariant, and
  the ``border_filt=(1, 5)`` traits default regression.

* sdcflows/workflows/tests/test_outputs.py (new) — direct construction
  test for ``init_fmap_derivatives_wf``, exercising both the default
  and ``write_dynamic=True`` paths. The MEDIC dynamic sinks branch was
  only reached transitively from the ``test_fmap_wf`` slow test, hence
  0% patch coverage on outputs.py.

* sdcflows/workflows/fit/tests/test_medic.py — fill the remaining
  helper gaps: empty-metadata rejection, ``sloppy=True`` zooms_min,
  ``_first``, and ``_temporal_mean`` for both 3D and 4D inputs.

The ``_run_interface`` method bodies in interfaces/warpkit.py remain
uncovered in the fast/slow envs since they require warpkit to import;
the existing ``veryslow`` MEDIC fixtures exercise them when the optional
dependency is installed.
@vanandrew

Copy link
Copy Markdown
Author

I have no idea why test (3.13, pre, fast) is failing on the cache step.

It looks like an out-of-space error on the runner (but the other jobs with the same steps succeeded, so very confused):

[test (3.13, pre, fast)](https://github.com/nipreps/sdcflows/actions/runs/25840682073/job/75990934202)
System.IO.IOException: No space left on device : '/home/runner/actions-runner/cached/2.334.0/_diag/Worker_20260514-125354-utc.log'
   at System.IO.RandomAccess.WriteAtOffset(SafeFileHandle handle, ReadOnlySpan`1 buffer, Int64 fileOffset)
   at System.IO.StreamWriter.Flush(Boolean flushStream, Boolean flushEncoder)
   at System.Diagnostics.TextWriterTraceListener.Flush()
   at GitHub.Runner.Common.HostTraceListener.WriteHeader(String source, TraceEventType eventType, Int32 id)
   at System.Diagnostics.TraceSource.TraceEvent(TraceEventType eventType, Int32 id, String message)
   at GitHub.Runner.Worker.Worker.RunAsync(String pipeIn, String pipeOut)
   at GitHub.Runner.Worker.Program.MainAsync(IHostContext context, String[] args)
System.IO.IOException: No space left on device : '/home/runner/actions-runner/cached/2.334.0/_diag/Worker_20260514-125354-utc.log'
   at System.IO.RandomAccess.WriteAtOffset(SafeFileHandle handle, ReadOnlySpan`1 buffer, Int64 fileOffset)
   at System.IO.StreamWriter.Flush(Boolean flushStream, Boolean flushEncoder)
   at System.Diagnostics.TextWriterTraceListener.Flush()
   at GitHub.Runner.Common.HostTraceListener.WriteHeader(String source, TraceEventType eventType, Int32 id)
   at System.Diagnostics.TraceSource.TraceEvent(TraceEventType eventType, Int32 id, String message)
   at GitHub.Runner.Common.Tracing.Error(Exception exception)
   at GitHub.Runner.Worker.Program.MainAsync(IHostContext context, String[] args)
Unhandled exception. System.IO.IOException: No space left on device : '/home/runner/actions-runner/cached/2.334.0/_diag/Worker_20260514-125354-utc.log'
   at System.IO.RandomAccess.WriteAtOffset(SafeFileHandle handle, ReadOnlySpan`1 buffer, Int64 fileOffset)
   at System.IO.StreamWriter.Flush(Boolean flushStream, Boolean flushEncoder)
   at System.Diagnostics.TextWriterTraceListener.Flush()
   at System.Diagnostics.TraceSource.Flush()
   at GitHub.Runner.Common.Tracing.Dispose(Boolean disposing)
   at GitHub.Runner.Common.Tracing.Dispose()
   at GitHub.Runner.Common.TraceManager.Dispose(Boolean disposing)
   at GitHub.Runner.Common.TraceManager.Dispose()
   at GitHub.Runner.Common.HostContext.Dispose(Boolean disposing)
   at GitHub.Runner.Common.HostContext.Dispose()
   at GitHub.Runner.Worker.Program.Main(String[] args)

@tsalo

tsalo commented May 15, 2026

Copy link
Copy Markdown
Contributor

@vanandrew I think someone must have rerun that failing job and now it's passing.

@tsalo tsalo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this! I made a quick pass through the code.

Comment thread sdcflows/interfaces/warpkit.py Outdated
Comment thread sdcflows/interfaces/warpkit.py Outdated
Comment thread sdcflows/interfaces/warpkit.py Outdated
Comment thread sdcflows/utils/wrangler.py Outdated
Comment thread sdcflows/workflows/apply/dynamic.py Outdated
Comment thread sdcflows/workflows/fit/medic.py Outdated
Comment thread sdcflows/workflows/fit/medic.py Outdated
Comment thread sdcflows/utils/wrangler.py
@vanandrew

Copy link
Copy Markdown
Author

Pushed four commits addressing your feedback (noise_frames, ctor inputs, nitransforms switch + interface cleanup, unified fmap output).

@vanandrew

Copy link
Copy Markdown
Author

I can't tell if these test failures are something wrong with my dataset additions or if it's just another one-off error:

update(error): . (dataset) [Fetch failed: CommandError(CommandError: 'git -c diff.ignoreSubmodules=none -c core.quotepath=false fetch --verbose --progress --no-recurse-submodules --
prune origin' failed with exitcode 128 under /home/runner/sdcflows-tests/brain-extraction-tests [err: 'fatal: unable to access 'https://gin.g-node.org/nipreps-data/brain-extraction-
tests/': The requested URL returned error: 403'])] [CommandError: 'git -c diff.ignoreSubmodules=none -c core.quotepath=false fetch --verbose --progress --no-recurse-submodules --
prune origin' failed with exitcode 128 under /home/runner/sdcflows-tests/brain-extraction-tests [err: 'fatal: unable to access 'https://gin.g-node.org/nipreps-data/brain-extraction-
tests/': The requested URL returned error: 403']]
Error: Process completed with exit code 1.

@vanandrew vanandrew requested a review from tsalo May 22, 2026 04:27
@vanandrew vanandrew force-pushed the feat/medic-warpkit branch from a4c4781 to b070878 Compare May 23, 2026 14:24
@vanandrew

Copy link
Copy Markdown
Author

Looks like the CI keeps failing because it's running out of space (I added two datasets for tests so that might have pushed it over the edge).

I pushed 6f5cfff2 adding a jlumbroso/free-disk-space@main step at the top of the test job:

- name: Free disk space
  uses: jlumbroso/free-disk-space@main
  with:
    tool-cache: false
    android: true
    dotnet: true
    haskell: true
    large-packages: false
    swap-storage: false

This gets rid of some unneeded development tools on the github runner to free room for data.

@vanandrew

Copy link
Copy Markdown
Author

@tsalo Alright. I made changes based on our convo today:

  • find_estimators discovers a fieldmap estimator for every fieldmap declared in BIDS intent metadata: grouping inputs by B0FieldIdentifier (Step 1), or falling back to IntendedFor heuristics (Step 2) only when no B0FieldIdentifier exists. (no_medic suppresses MEDIC via either route). It adds a sort that always returns dynamic estimators (i.e. MEDIC) first.
  • Simplified fmap_ref and fmap_mask to be the first echo magntude and MEDIC fmap masks respectively. These likely won't be used by downstream consumers.
  • Removed force_medic and replaced with no_medic

Comment thread sdcflows/workflows/fit/medic.py Outdated
Comment thread pyproject.toml Outdated
Comment on lines +82 to +83
# DO NOT add it to the `all` alias below — the default install must remain
# clean-Apache.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is necessary. A number of SDCFlows' dependencies do not allow commercial use. I think we need to review and document restrictively licensed dependencies rather than put them in extra groups. @effigies wdyt?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, IMO it's fine to include in the normal dependencies - though up to @vanandrew how available they would like it

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved warpkit into the core dependencies (0092e43) rather than an extras group, per the consensus here. The non-commercial WUSTL license is now documented in the init_medic_wf module docstring instead of the dependency list.

vanandrew added a commit to vanandrew/sdcflows that referenced this pull request Jun 2, 2026
Three additions targeting the codecov/patch failure on nipreps#541:

* sdcflows/interfaces/tests/test_warpkit.py (new) — instantiates every
  warpkit-backed interface so its spec class body is hit at import (was
  0% on codecov despite running locally; likely an xdist worker-merge
  artefact). Also covers ``_as_str_list``, the ``_pkg`` invariant, and
  the ``border_filt=(1, 5)`` traits default regression.

* sdcflows/workflows/tests/test_outputs.py (new) — direct construction
  test for ``init_fmap_derivatives_wf``, exercising both the default
  and ``write_dynamic=True`` paths. The MEDIC dynamic sinks branch was
  only reached transitively from the ``test_fmap_wf`` slow test, hence
  0% patch coverage on outputs.py.

* sdcflows/workflows/fit/tests/test_medic.py — fill the remaining
  helper gaps: empty-metadata rejection, ``sloppy=True`` zooms_min,
  ``_first``, and ``_temporal_mean`` for both 3D and 4D inputs.

The ``_run_interface`` method bodies in interfaces/warpkit.py remain
uncovered in the fast/slow envs since they require warpkit to import;
the existing ``veryslow`` MEDIC fixtures exercise them when the optional
dependency is installed.
@vanandrew vanandrew force-pushed the feat/medic-warpkit branch from da9d07b to c55efd0 Compare June 2, 2026 20:36
vanandrew and others added 3 commits June 2, 2026 15:37
A revival of nipreps#435. Wraps warpkit (>=1.2.1) — which now
ships pre-compiled PyPI wheels — instead of reimplementing MEDIC in
pure Python.

- sdcflows/interfaces/warpkit.py: seven SimpleInterface classes wrapping
  warpkit.api (MEDIC, UnwrapPhase, ComputeFieldmap, ApplyWarp,
  ConvertWarp, ConvertFieldmap, ComputeJacobian).
- sdcflows/fieldmaps.py: new EstimatorType.MEDIC with auto-detection in
  FieldmapEstimation.__attrs_post_init__ for bold/epi/sbref sources
  tagged with the BIDS part-{phase,mag} entity. PEPOLAR branch gated
  on UNKNOWN method to avoid clobbering MEDIC.
- pyproject.toml: warpkit added as an optional extra (sdcflows[warpkit]).
- New sdcflows/workflows/fit/medic.py: init_medic_wf using the two-stage
  warpkit Python API (UnwrapPhase -> ComputeFieldmap) so per-frame masks
  surface as a real output. Module-load pure: warpkit is only resolved at
  run time via the LibraryBaseInterface guard.
- Outputs: static (fmap/fmap_ref/fmap_mask/fmap_coeff) for compatibility
  with init_unwarp_wf, plus dynamic (fmap_dynamic/fmap_dynamic_ref/
  fmap_dynamic_mask) carrying the 4D per-frame data for a future
  MEDIC-aware apply path. fmap_coeff is documented as a structural shim
  (init_unwarp_wf only consumes B-spline coefficients); the spline fit
  adds no scientific value for MEDIC since the field is already on the
  EPI grid and warpkit's SVD filter already smooths.
- Wrangler picks up multi-echo phase BOLDs via IntendedFor as MEDIC
  estimators; single-PE EPI fallback also accepts the 'bold' suffix.
  Drops the implicit part=mag base filter so phase data surfaces.
- fieldmaps.py: get_workflow() now dispatches EstimatorType.MEDIC to
  init_medic_wf; sources are sorted by EchoTime before wiring.
- workflows/base.py: registers MEDIC in the INPUT_FIELDS dispatch dict.
- pyproject.toml: comment block on the warpkit extra spelling out the
  WUSTL non-commercial license terms; keeps the extra out of the `all`
  alias.
- Tests: workflow construction smoke + metadata-helper unit tests
  always run; slow integration test guarded by pytest.importorskip
  on warpkit. MEDIC parametrize entries added to test_wrangler.
vanandrew added 17 commits June 2, 2026 15:37
Auto-discover MEDIC estimators from complex multi-echo BOLD even when
neither ``IntendedFor`` nor ``B0FieldIdentifier`` is set, gated behind
an opt-in ``force_medic`` flag for public datasets that ship the
required mag+phase echoes without the metadata the default path needs.
Three additions targeting the codecov/patch failure on nipreps#541:

* sdcflows/interfaces/tests/test_warpkit.py (new) — instantiates every
  warpkit-backed interface so its spec class body is hit at import (was
  0% on codecov despite running locally; likely an xdist worker-merge
  artefact). Also covers ``_as_str_list``, the ``_pkg`` invariant, and
  the ``border_filt=(1, 5)`` traits default regression.

* sdcflows/workflows/tests/test_outputs.py (new) — direct construction
  test for ``init_fmap_derivatives_wf``, exercising both the default
  and ``write_dynamic=True`` paths. The MEDIC dynamic sinks branch was
  only reached transitively from the ``test_fmap_wf`` slow test, hence
  0% patch coverage on outputs.py.

* sdcflows/workflows/fit/tests/test_medic.py — fill the remaining
  helper gaps: empty-metadata rejection, ``sloppy=True`` zooms_min,
  ``_first``, and ``_temporal_mean`` for both 3D and 4D inputs.

The ``_run_interface`` method bodies in interfaces/warpkit.py remain
uncovered in the fast/slow envs since they require warpkit to import;
the existing ``veryslow`` MEDIC fixtures exercise them when the optional
dependency is installed.
BIDS keeps trailing noise frames in separate noRF files, so SDCFlows
shouldn't carry an interface input for them — let warpkit's default
of zero apply.
Passing n_cpus and debug through the interface constructor keeps node
construction in one place and removes the post-init mutation pattern.
…sforms

The dynamic-fieldmap apply workflow was the only consumer of
``ApplyWarp``/``ConvertFieldmap`` in warpkit; rewrite it on top of
``sdcflows.transform.apply_dynamic_unwarp``, a per-frame extension of
the existing scipy/nitransforms-backed resampling that powers the
static path. With that internal consumer gone, the warpkit interface
module shrinks to just the two stages SDCFlows actually runs
(``UnwrapPhase``, ``ComputeFieldmap``); the unused ``MEDIC``,
``ApplyWarp``, ``ConvertWarp``, ``ConvertFieldmap`` and
``ComputeJacobian`` wrappers, along with the helpers that only served
them, are removed.
``init_medic_wf`` previously emitted both a 3D static ``fmap`` (a
B-spline-smoothed temporal mean of the dynamic field) and a separate
4D ``fmap_dynamic``, alongside parallel ``fmap_dynamic_ref`` and
``fmap_dynamic_mask`` plumbing in the base preproc workflow and a
``write_dynamic`` branch in the derivatives sinks. Per review,
collapse all of that to a single ``fmap`` output whose dimensionality
(3D for static estimators, 4D for MEDIC) tells consumers which apply
path to use. The B-spline shim is gone (MEDIC's field is already on
the EPI grid), the derivatives sink uses ``MergeSeries(allow_4D=True)``
to pass 3D and 4D fmaps through the same path, and the per-volume
apply workflow renames its input from ``fmap_dynamic`` to ``fmap`` to
match. Downstream tools that don't yet handle 4D field maps must
block MEDIC-based estimators until they do.
The static init_magnitude_wf averaged across time before brain-extracting,
which collapsed MEDIC's per-volume magnitude into one 3D ref/mask. The new
init_dynamic_magnitude_wf splits the 4D first-echo magnitude, runs the
IntensityClip/N4/BrainExtraction chain per frame, and re-concatenates so
fmap_ref and fmap_mask are 4D and track the per-volume fieldmap.
…nt accepted kwargs

Adds three regression tests around the MEDIC guard clauses in
``FieldmapEstimation.__attrs_post_init__`` (single-part input, single-echo
pair, mismatched mag/phase counts), and documents the two ``init_medic_wf``
parameters (``use_metadata_estimates``, ``fallback_total_readout_time``) that
are accepted via ``init_fmap_preproc_wf`` for parity but currently ignored.
…ation.is_dynamic

Replaces the ``estimator.method == EstimatorType.MEDIC`` check in
``init_fmap_preproc_wf`` with a new ``FieldmapEstimation.is_dynamic`` property
backed by a module-level ``_DYNAMIC_METHODS`` set in ``sdcflows.fieldmaps``.
Future per-volume estimators (DOCMA, TOAST, ...) only need to be added to
``_DYNAMIC_METHODS`` — the shared workflow no longer carries per-method
branching.
Adds ``test_wrangler_medic_trigger`` covering the three modes of MEDIC
discovery against synthetic BIDS skeletons in a single parametrized test:

1. ``default-IntendedFor`` — sidecars carry ``IntendedFor`` and the default
   discovery path picks them up.
2. ``force_medic`` — sidecars carry no metadata; the explicit flag
   short-circuits the default ``IntendedFor`` gate.
3. ``baseline-no-trigger`` — no metadata, no override, no fmapless; MEDIC
   must refuse to fire so runs without expected metadata are not silently
   picked up.

The existing ``test_wrangler_force_medic_without_intended_for`` covers cases
2 and 3 implicitly via ``medic_no_intended_for``; this new test pins all
three side-by-side and was validated against a real ds006926/sub-a01 layout
before being formalized here.
The bumped data-cache-v3 (now includes ds006926 + ds007637) tips the
3.10/min/fast lane over the 14 GB root-disk limit during cache restore.
Free ~20 GB up front by purging the Android SDK, .NET, and Haskell
tool-caches; keep the tool-cache, swap, and apt large-packages alone so
later steps (apt-cache restore, conda, uv) aren't disturbed.
…arpkit masks

The MEDIC workflow now exposes the first-echo magnitude series untouched as
``fmap_ref`` (the ``pick_mag1`` output) and routes warpkit's per-frame
``UnwrapPhase`` masks straight to ``fmap_mask``, instead of running a
per-frame N4 + skull-strip pass to synthesize both.

``init_dynamic_magnitude_wf`` was only used by MEDIC, so it is removed from
``fit/fieldmap.py`` entirely. The expensive per-volume MapNode work (N4,
intensity clip, brain extraction over every frame) is gone with it.

Updates ``test_medic_construct`` to drop the removed ``magnitude_wf`` node.
…DIC-first ordering

Make MEDIC discovery follow the BIDS fieldmap-intent model rather than file
structure:

* MEDIC is now discovered only from declared intent metadata -- a complex
  multi-echo BOLD is picked up via its ``B0FieldIdentifier`` (the
  self-referential pattern BIDS endorses for images that estimate their own
  B0 field, as in pepolar) through the existing Step 1 path, or via legacy
  ``IntendedFor`` in the dedicated MEDIC block. The structure-only
  auto-discovery (and the ``force_medic`` flag that enabled it) is removed:
  part-mag/part-phase alone no longer triggers MEDIC.
* Add ``no_medic`` to disable MEDIC discovery via either route (kwarg +
  ``--no-medic`` CLI flag + ``config.workflow.no_medic``); it also skips a
  MEDIC-shaped ``B0FieldIdentifier`` group in Step 1.

Return estimators in a deterministic order: ``estimators.sort(key=lambda e:
(not e.is_dynamic, e.bids_id))``. Step 1 iterates a ``set`` of
``B0FieldIdentifier``s, so the prior order was hash-seed dependent. The sort
also encodes one intentional, documented priority -- dynamic (MEDIC)
estimators come first, so a consumer selecting the first applicable estimator
per target prefers MEDIC over a coexisting static fieldmap. Fieldmap-less
ANAT estimators are appended afterwards and stay last.

Tests: cover the B0FieldIdentifier and IntendedFor MEDIC routes, a guard that
structure alone does not fire MEDIC, and that MEDIC sorts ahead of a
coexisting PEPOLAR estimator.
@vanandrew vanandrew force-pushed the feat/medic-warpkit branch from c55efd0 to b0a6904 Compare June 2, 2026 20:37
@vanandrew

Copy link
Copy Markdown
Author

@tsalo I did a rebase to the latest main, hopefully to quash the 3.13 test error that happened in the last CI run: https://github.com/nipreps/sdcflows/actions/runs/26792754492/job/79155816998#step:22:320

@mgxd mgxd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pushing on this @vanandrew! I gave it a first pass and left some comments / questions, but overall the implementation looks reasonable.

While waiting for this to get more feedback, we should probably coordinate with nipreps/fmriprep#3645 (cc @DVSneuro) to sync up and use this branch, so we can have a branch that emulates how integration will actually look.

Comment thread sdcflows/transform.py Outdated
Comment thread sdcflows/interfaces/tests/test_warpkit.py Outdated
Comment thread sdcflows/interfaces/warpkit.py Outdated
Comment thread sdcflows/transform.py Outdated
Comment on lines +601 to +746
async def _dynamic_unwarp_parallel(
fulldataset: np.ndarray,
coordinates: np.ndarray,
fmap_dynamic: np.ndarray,
pe_info: Sequence[tuple[int, float]],
jacobian: bool,
order: int = 3,
mode: str = 'constant',
cval: float = 0.0,
prefilter: bool = True,
output_dtype: str | np.dtype | None = None,
max_concurrent: int = min(os.cpu_count(), 12),
) -> np.ndarray:
"""Per-volume unwarp where each EPI frame uses its matching fmap frame."""
semaphore = asyncio.Semaphore(max_concurrent)
if fulldataset.ndim == 3:
fulldataset = fulldataset[..., np.newaxis]

tasks = []
for volid, volume in enumerate(np.rollaxis(fulldataset, -1, 0)):
func = partial(
_sdc_unwarp,
jacobian=jacobian,
fmap_hz=fmap_dynamic[..., volid],
output_dtype=output_dtype,
order=order,
mode=mode,
cval=cval,
prefilter=prefilter,
)
tasks.append(
asyncio.create_task(
worker(
volume,
coordinates.copy(),
pe_info[volid],
None,
func,
semaphore,
)
)
)

await asyncio.gather(*tasks)
return np.stack([t.result() for t in tasks], -1)


def apply_dynamic_unwarp(
moving,
fmap_dynamic,
pe_dir,
ro_time,
jacobian: bool = True,
order: int = 3,
mode: str = 'constant',
cval: float = 0.0,
prefilter: bool = True,
output_dtype: str | np.dtype | None = None,
num_threads: int | None = None,
allow_negative: bool = False,
):
r"""Apply a per-frame 4D Hz fieldmap to unwarp a 4D EPI series.

Unlike :class:`B0FieldTransform`, the fieldmap is assumed to already be on
the EPI grid (one Hz volume per EPI volume), so no B-spline reconstruction
or coregistration takes place. Each EPI volume is resampled through its
matching fieldmap frame using the same scipy-backed primitives that the
static apply path uses (:func:`_sdc_unwarp`).

Parameters
----------
moving : :obj:`str` or :class:`~nibabel.spatialimages.SpatialImage`
4D EPI image to unwarp.
fmap_dynamic : :obj:`str` or :class:`~nibabel.spatialimages.SpatialImage`
4D Hz fieldmap, one volume per ``moving`` frame, on ``moving``'s grid.
pe_dir : :obj:`str` or list of :obj:`str`
``PhaseEncodingDirection`` metadata value(s). A scalar is broadcast
across frames.
ro_time : :obj:`float` or list of :obj:`float`
Total readout time(s) in seconds. A scalar is broadcast across frames.
jacobian : :obj:`bool`
Apply Jacobian determinant correction after resampling.
num_threads : :obj:`int`, optional
Cap on parallel volume resamplings.
"""
if isinstance(moving, (str, bytes, Path)):
moving = nb.load(moving)
if isinstance(fmap_dynamic, (str, bytes, Path)):
fmap_dynamic = nb.load(fmap_dynamic)

moving, axcodes = ensure_positive_cosines(moving)
fmap_dynamic, _ = ensure_positive_cosines(fmap_dynamic)

newshape = moving.shape[:3] + tuple(d for d in moving.shape[3:] if d > 1)
data = np.asarray(nb.arrayproxy.reshape_dataobj(moving.dataobj, newshape))
n_volumes = data.shape[3] if data.ndim == 4 else 1
output_dtype = output_dtype or moving.header.get_data_dtype()

fmap_data = np.asanyarray(fmap_dynamic.dataobj, dtype='float32')
if fmap_data.ndim == 3:
fmap_data = fmap_data[..., np.newaxis]
if fmap_data.shape[-1] != n_volumes:
raise ValueError(
f'Dynamic fieldmap frame count ({fmap_data.shape[-1]}) does not match '
f'EPI volumes ({n_volumes}).'
)

if isinstance(pe_dir, str):
pe_dir = [pe_dir] * n_volumes
if isinstance(ro_time, (int, float)):
ro_time = [float(ro_time)] * n_volumes

pe_info = []
for vol_pe_dir, vol_ro_time in zip(pe_dir, ro_time, strict=False):
pe_axis = 'ijk'.index(vol_pe_dir[0])
flip = (axcodes[pe_axis] in 'LPI') ^ vol_pe_dir.endswith('-')
pe_info.append((pe_axis, -vol_ro_time if flip else vol_ro_time))

voxcoords = (
nt.linear.Affine(reference=moving)
.reference.ndindex.T.reshape((3, *data.shape[:3]))
.astype('float32')
)

resampled = asyncio.run(
_dynamic_unwarp_parallel(
data,
voxcoords,
fmap_data,
pe_info,
jacobian=jacobian,
output_dtype='float32',
order=order,
mode=mode,
cval=cval,
prefilter=prefilter,
max_concurrent=num_threads or min(os.cpu_count(), 12),
)
)

if not allow_negative:
resampled[resampled < 0] = cval

moved = moving.__class__(resampled, moving.affine, moving.header)
moved.header.set_data_dtype(output_dtype)
return reorient_image(moved, axcodes)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's too much overlap here with unwarp_parallel and apply - can we unify these with the new 4D fieldmap methods?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bigger change. I'll take a look and see what I can merge.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unified in e94f285. I made one path for both 3D/4D fields.

unwarp_parallel takes a 3D or 4D field. A 3D (static) field is np.broadcast_to-viewed across frames, so per-frame selection is a single branchless fmap_hz[..., volid]. The 3D case is now just the degenerate 4D case.

B0FieldTransform is where it gets a little messy, since MEDIC field maps don't use bspline coefficients. I modified the class so that it can be constructed from a pre-gridded field (mapped=) while maintaining initialization from B-spline coeffs. apply() dispatches on provenance (coeffs → fit(); pre-gridded → use as-is), not on shape, then both routes go through one shared _resample_with_fieldmap helper.

Comment thread sdcflows/workflows/apply/dynamic.py Outdated
Comment thread sdcflows/workflows/fit/medic.py Outdated
Comment thread sdcflows/workflows/fit/tests/test_medic.py Outdated
Comment thread sdcflows/workflows/fit/tests/test_medic.py Outdated
Comment thread sdcflows/workflows/fit/tests/test_medic.py Outdated
Comment thread sdcflows/workflows/tests/test_outputs.py Outdated
Address review: replace the fmt: off/on pair around workflow.connect with an
inline # fmt:skip, and use pathlib.Path over os.path for the corrected output.
Also normalize the warpkit module/test copyright headers to the unversioned
NiPreps form.
Address review: collapse the unused use_metadata_estimates and
fallback_total_readout_time parity args into **kwargs (dropping the now-dead
del statement and their docstring entries), and inline the single-use
_MEDIC_DESC string at the workflow.__desc__ assignment.
Address review: move the duplicated _MEDIC_TEST_VOLUMES, _truncate_to_volumes
helper, and MEDIC_FIXTURES list out of the fit/apply test modules into shared
conftest fixtures (medic_test_volumes, truncate_to_volumes, and a parametrized
medic_fixture). Both end-to-end tests now consume the fixtures, removing the
keep-in-sync duplication. Also normalize remaining copyright headers.
Move warpkit into the core dependencies (keeping the python_version >= '3.11'
marker so 3.10 installs still resolve) rather than gating it behind an opt-in
extra. Drop the [warpkit] optional-dependencies group and the now-redundant
veryslow: warpkit tox extra. The non-commercial WUSTL license is documented in
the init_medic_wf module docstring.
Address review: the helper recomputed fmap_hz * ro_time internally, duplicating
the VSM already computed in _sdc_unwarp. Change the signature to accept the VSM
and reuse it at the call site, keeping the 3D/4D-capable helper for downstream
use.
nipype prunes a node's working directory to the files referenced by its
string-valued outputs. _dynamic_unwarp returned a PosixPath, which the pruning
did not recognize, so corrected.nii.gz was deleted before the downstream
average node could read it. Return str() of the path.
Collapse the parallel dynamic apply stack into the static machinery:

- unwarp_parallel accepts a 3D or 4D field; a 3D (shared) field is
  np.broadcast_to-viewed across frames so per-frame selection is a single
  branchless fmap_hz[..., volid]. The 3D case is the degenerate 4D case.
- B0FieldTransform can be constructed from a pre-gridded field (mapped=) in
  addition to B-spline coeffs; apply() dispatches on provenance (coeffs ->
  fit(); pre-gridded -> use as-is), then both routes share one
  _resample_with_fieldmap helper (the formerly duplicated tail). Guard added
  for the empty (no coeffs, no mapped) case.
- Drop _dynamic_unwarp_parallel and the apply_dynamic_unwarp wrapper; the
  MEDIC apply node constructs B0FieldTransform(mapped=...).apply(...) directly,
  mirroring how ApplyCoeffsField drives the static path with coeffs.
@vanandrew vanandrew force-pushed the feat/medic-warpkit branch from adcd22c to 9993c5a Compare June 12, 2026 23:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support dynamic distortion correction with DOCMA

3 participants