From 5ef58d5b7f18e14b2779383f366cf8edae54e596 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sat, 25 Apr 2026 23:30:59 -0500 Subject: [PATCH 01/48] [WIP] [FEAT] MEDIC distortion correction via warpkit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A revival of nipreps/sdcflows#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]). --- pyproject.toml | 4 + sdcflows/fieldmaps.py | 29 +- sdcflows/interfaces/warpkit.py | 536 +++++++++++++++++++++++++++++++++ 3 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 sdcflows/interfaces/warpkit.py diff --git a/pyproject.toml b/pyproject.toml index 303b3cfc46..0e346edb9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,10 @@ test = [ "pytest-xdist >= 2.5", ] +warpkit = [ + "warpkit >= 1.2.1", +] + # Aliases docs = ["sdcflows[doc]"] tests = ["sdcflows[test]"] diff --git a/sdcflows/fieldmaps.py b/sdcflows/fieldmaps.py index 149d584792..3e03e91a79 100644 --- a/sdcflows/fieldmaps.py +++ b/sdcflows/fieldmaps.py @@ -55,6 +55,7 @@ class EstimatorType(Enum): PHASEDIFF = auto() MAPPED = auto() ANAT = auto() + MEDIC = auto() MODALITIES = { @@ -88,6 +89,7 @@ def _type_setter(obj, attribute, value): EstimatorType.PHASEDIFF, EstimatorType.MAPPED, EstimatorType.ANAT, + EstimatorType.MEDIC, ): raise ValueError(f'Invalid estimation method type {value}.') @@ -338,6 +340,27 @@ 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} + if parts == {'phase', 'mag'} and suffix_set <= {'bold', 'epi', 'sbref'}: + 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( + 'MEDIC requires at least two echoes of phase data; ' + f'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'}: @@ -399,7 +422,11 @@ 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: diff --git a/sdcflows/interfaces/warpkit.py b/sdcflows/interfaces/warpkit.py new file mode 100644 index 0000000000..5aea30ad53 --- /dev/null +++ b/sdcflows/interfaces/warpkit.py @@ -0,0 +1,536 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2024 The NiPreps Developers +# +# 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/ +# +"""Nipype interfaces wrapping :mod:`warpkit.api`. + +`warpkit `__ implements MEDIC +(Multi-Echo DIstortion Correction) and related warp utilities. Each of +warpkit's seven ``wk-*`` CLI tools is mirrored here as a +:class:`~nipype.interfaces.base.SimpleInterface`, calling the corresponding +:mod:`warpkit.api` function in-process. ``warpkit`` is an optional dependency +— :class:`~nipype.interfaces.base.LibraryBaseInterface` produces a clean +"package not installed" error if ``warpkit`` is missing at runtime. +""" + +import os + +from nipype.interfaces.base import ( + BaseInterfaceInputSpec, + File, + InputMultiObject, + LibraryBaseInterface, + OutputMultiObject, + SimpleInterface, + TraitedSpec, + isdefined, + traits, +) + +PE_DIRECTIONS = ('i', 'j', 'k', 'i-', 'j-', 'k-', 'x', 'y', 'z', 'x-', 'y-', 'z-') +PE_AXES = ('i', 'j', 'k', 'x', 'y', 'z') +WARP_FORMATS = ('itk', 'fsl', 'ants', 'afni') + + +class WarpkitBaseInterface(LibraryBaseInterface): + """Base for all warpkit-backed interfaces.""" + + _pkg = 'warpkit' + + +def _as_str_list(x) -> list[str]: + if isinstance(x, str): + return [x] + return [str(p) for p in x] + + +# --------------------------------------------------------------------------- +# MEDIC — full multi-echo distortion correction pipeline +# --------------------------------------------------------------------------- + + +class _MEDICInputSpec(BaseInterfaceInputSpec): + phase = InputMultiObject( + File(exists=True), mandatory=True, desc='phase NIfTI, one per echo' + ) + magnitude = InputMultiObject( + File(exists=True), mandatory=True, desc='magnitude NIfTI, one per echo' + ) + echo_times = traits.List( + traits.Float, + xor=['metadata'], + desc='echo times in milliseconds, one per echo', + ) + total_readout_time = traits.Float( + xor=['metadata'], desc='EPI total readout time in seconds' + ) + phase_encoding_direction = traits.Enum( + *PE_DIRECTIONS, + xor=['metadata'], + desc='phase-encoding direction (with sign)', + ) + metadata = InputMultiObject( + File(exists=True), + xor=['echo_times', 'total_readout_time', 'phase_encoding_direction'], + desc='BIDS sidecar JSONs, one per echo (alternative to direct args)', + ) + out_prefix = traits.Str('medic', usedefault=True, desc='prefix for output filenames') + noise_frames = traits.Int( + 0, usedefault=True, desc='number of trailing noise frames to drop' + ) + n_cpus = traits.Int(4, usedefault=True, desc='number of CPUs to use') + wrap_limit = traits.Bool( + False, usedefault=True, desc='disable some phase-unwrapping heuristics' + ) + debug = traits.Bool(False, usedefault=True, desc='enable debug mode') + + +class _MEDICOutputSpec(TraitedSpec): + fieldmap_native = File(exists=True, desc='native-space B0 field map (Hz)') + displacement_map = File(exists=True, desc='displacement map (mm)') + fieldmap = File(exists=True, desc='undistorted-space B0 field map (Hz)') + + +class MEDIC(WarpkitBaseInterface, SimpleInterface): + """Run the full MEDIC pipeline (:func:`warpkit.api.medic`).""" + + input_spec = _MEDICInputSpec + output_spec = _MEDICOutputSpec + + def _run_interface(self, runtime): + from warpkit.api import medic + + out_prefix = os.path.join(runtime.cwd, self.inputs.out_prefix) + try: + result = medic( + phase=list(self.inputs.phase), + magnitude=list(self.inputs.magnitude), + out_prefix=out_prefix, + tes=( + list(self.inputs.echo_times) + if isdefined(self.inputs.echo_times) + else None + ), + total_readout_time=( + self.inputs.total_readout_time + if isdefined(self.inputs.total_readout_time) + else None + ), + phase_encoding_direction=( + self.inputs.phase_encoding_direction + if isdefined(self.inputs.phase_encoding_direction) + else None + ), + metadata=( + list(self.inputs.metadata) + if isdefined(self.inputs.metadata) + else None + ), + noise_frames=self.inputs.noise_frames, + n_cpus=self.inputs.n_cpus, + wrap_limit=self.inputs.wrap_limit, + debug=self.inputs.debug, + ) + except ValueError as e: + raise RuntimeError(str(e)) from e + + self._results['fieldmap_native'] = str(result.fieldmap_native) + self._results['displacement_map'] = str(result.displacement_map) + self._results['fieldmap'] = str(result.fieldmap) + return runtime + + +# --------------------------------------------------------------------------- +# UnwrapPhase — ROMEO multi-echo phase unwrapping +# --------------------------------------------------------------------------- + + +class _UnwrapPhaseInputSpec(BaseInterfaceInputSpec): + phase = InputMultiObject(File(exists=True), mandatory=True) + magnitude = InputMultiObject(File(exists=True), mandatory=True) + echo_times = traits.List(traits.Float, xor=['metadata']) + metadata = InputMultiObject(File(exists=True), xor=['echo_times']) + out_prefix = traits.Str('unwrap', usedefault=True) + noise_frames = traits.Int(0, usedefault=True) + n_cpus = traits.Int(4, usedefault=True) + wrap_limit = traits.Bool(False, usedefault=True) + debug = traits.Bool(False, usedefault=True) + + +class _UnwrapPhaseOutputSpec(TraitedSpec): + unwrapped = OutputMultiObject(File(exists=True), desc='unwrapped phase per echo') + masks = File(exists=True, desc='per-frame masks NIfTI') + + +class UnwrapPhase(WarpkitBaseInterface, SimpleInterface): + """ROMEO multi-echo phase unwrapping (:func:`warpkit.api.unwrap_phase`).""" + + input_spec = _UnwrapPhaseInputSpec + output_spec = _UnwrapPhaseOutputSpec + + def _run_interface(self, runtime): + from warpkit.api import unwrap_phase + + out_prefix = os.path.join(runtime.cwd, self.inputs.out_prefix) + try: + result = unwrap_phase( + phase=list(self.inputs.phase), + magnitude=list(self.inputs.magnitude), + out_prefix=out_prefix, + tes=( + list(self.inputs.echo_times) + if isdefined(self.inputs.echo_times) + else None + ), + metadata=( + list(self.inputs.metadata) + if isdefined(self.inputs.metadata) + else None + ), + noise_frames=self.inputs.noise_frames, + n_cpus=self.inputs.n_cpus, + wrap_limit=self.inputs.wrap_limit, + debug=self.inputs.debug, + ) + except ValueError as e: + raise RuntimeError(str(e)) from e + + self._results['unwrapped'] = [str(p) for p in result.unwrapped] + self._results['masks'] = str(result.masks) + return runtime + + +# --------------------------------------------------------------------------- +# ComputeFieldmap — post-unwrap stage of MEDIC +# --------------------------------------------------------------------------- + + +class _ComputeFieldmapInputSpec(BaseInterfaceInputSpec): + unwrapped = InputMultiObject( + File(exists=True), + mandatory=True, + desc='unwrapped phase per echo (output of UnwrapPhase)', + ) + magnitude = InputMultiObject(File(exists=True), mandatory=True) + masks = File( + exists=True, mandatory=True, desc='per-frame masks (output of UnwrapPhase)' + ) + echo_times = traits.List(traits.Float, xor=['metadata']) + total_readout_time = traits.Float(xor=['metadata']) + phase_encoding_direction = traits.Enum(*PE_DIRECTIONS, xor=['metadata']) + metadata = InputMultiObject( + File(exists=True), + xor=['echo_times', 'total_readout_time', 'phase_encoding_direction'], + ) + out_prefix = traits.Str('fieldmap', usedefault=True) + border_filt = traits.Tuple( + traits.Int(), + traits.Int(), + default=(1, 5), + usedefault=True, + desc='SVD components for the two-pass border filter', + ) + svd_filt = traits.Int(10, usedefault=True) + n_cpus = traits.Int(4, usedefault=True) + + +class _ComputeFieldmapOutputSpec(TraitedSpec): + fieldmap_native = File(exists=True) + displacement_map = File(exists=True) + fieldmap = File(exists=True) + + +class ComputeFieldmap(WarpkitBaseInterface, SimpleInterface): + """Post-unwrap MEDIC stage (:func:`warpkit.api.compute_fieldmap`).""" + + input_spec = _ComputeFieldmapInputSpec + output_spec = _ComputeFieldmapOutputSpec + + def _run_interface(self, runtime): + from warpkit.api import compute_fieldmap + + out_prefix = os.path.join(runtime.cwd, self.inputs.out_prefix) + try: + result = compute_fieldmap( + unwrapped=list(self.inputs.unwrapped), + magnitude=list(self.inputs.magnitude), + masks=self.inputs.masks, + out_prefix=out_prefix, + tes=( + list(self.inputs.echo_times) + if isdefined(self.inputs.echo_times) + else None + ), + total_readout_time=( + self.inputs.total_readout_time + if isdefined(self.inputs.total_readout_time) + else None + ), + phase_encoding_direction=( + self.inputs.phase_encoding_direction + if isdefined(self.inputs.phase_encoding_direction) + else None + ), + metadata=( + list(self.inputs.metadata) + if isdefined(self.inputs.metadata) + else None + ), + border_filt=tuple(self.inputs.border_filt), + svd_filt=self.inputs.svd_filt, + n_cpus=self.inputs.n_cpus, + ) + except ValueError as e: + raise RuntimeError(str(e)) from e + + self._results['fieldmap_native'] = str(result.fieldmap_native) + self._results['displacement_map'] = str(result.displacement_map) + self._results['fieldmap'] = str(result.fieldmap) + return runtime + + +# --------------------------------------------------------------------------- +# ApplyWarp — resample through a displacement transform +# --------------------------------------------------------------------------- + + +class _ApplyWarpInputSpec(BaseInterfaceInputSpec): + in_file = File(exists=True, mandatory=True) + transform = InputMultiObject(File(exists=True), mandatory=True) + out_file = traits.Str(desc='output path; defaults to /applied.nii.gz') + transform_type = traits.Enum('map', 'field', mandatory=True) + reference = File(exists=True) + phase_encoding_axis = traits.Enum(*PE_AXES) + format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') + + +class _ApplyWarpOutputSpec(TraitedSpec): + out_file = File(exists=True) + + +class ApplyWarp(WarpkitBaseInterface, SimpleInterface): + """Resample an image through a warpkit displacement transform + (:func:`warpkit.api.apply_warp`).""" + + input_spec = _ApplyWarpInputSpec + output_spec = _ApplyWarpOutputSpec + + def _run_interface(self, runtime): + from warpkit.api import apply_warp + + out_file = self.inputs.out_file + if not isdefined(out_file): + out_file = os.path.join(runtime.cwd, 'applied.nii.gz') + try: + result = apply_warp( + input=self.inputs.in_file, + transform=list(self.inputs.transform), + output=out_file, + transform_type=self.inputs.transform_type, + reference=( + self.inputs.reference + if isdefined(self.inputs.reference) + else None + ), + phase_encoding_axis=( + self.inputs.phase_encoding_axis + if isdefined(self.inputs.phase_encoding_axis) + else None + ), + format=self.inputs.format, + ) + except ValueError as e: + raise RuntimeError(str(e)) from e + + self._results['out_file'] = str(result.output) + return runtime + + +# --------------------------------------------------------------------------- +# ConvertWarp — interconvert / reformat / invert displacement transforms +# --------------------------------------------------------------------------- + + +class _ConvertWarpInputSpec(BaseInterfaceInputSpec): + in_file = InputMultiObject(File(exists=True), mandatory=True) + out_file = traits.Either( + traits.Str(), + traits.List(traits.Str()), + desc='output path(s); 1 path bundles, N paths split per frame', + ) + from_type = traits.Enum('map', 'field', mandatory=True) + to_type = traits.Enum('map', 'field') + from_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') + to_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') + axis = traits.Enum(*PE_AXES) + frame = traits.Int() + invert = traits.Bool(False, usedefault=True) + verbose = traits.Bool(False, usedefault=True) + + +class _ConvertWarpOutputSpec(TraitedSpec): + out_file = OutputMultiObject(File(exists=True)) + + +class ConvertWarp(WarpkitBaseInterface, SimpleInterface): + """Convert displacement transforms (:func:`warpkit.api.convert_warp`).""" + + input_spec = _ConvertWarpInputSpec + output_spec = _ConvertWarpOutputSpec + + def _run_interface(self, runtime): + from warpkit.api import convert_warp + + if isdefined(self.inputs.out_file): + out_paths = _as_str_list(self.inputs.out_file) + else: + out_paths = [os.path.join(runtime.cwd, 'converted.nii.gz')] + + try: + result = convert_warp( + input=list(self.inputs.in_file), + output=out_paths, + from_type=self.inputs.from_type, + to_type=self.inputs.to_type if isdefined(self.inputs.to_type) else None, + from_format=self.inputs.from_format, + to_format=self.inputs.to_format, + axis=self.inputs.axis if isdefined(self.inputs.axis) else None, + frame=self.inputs.frame if isdefined(self.inputs.frame) else None, + invert=self.inputs.invert, + verbose=self.inputs.verbose, + ) + except ValueError as e: + raise RuntimeError(str(e)) from e + + self._results['out_file'] = [str(p) for p in result.output] + return runtime + + +# --------------------------------------------------------------------------- +# ConvertFieldmap — mm displacement <-> Hz fieldmap +# --------------------------------------------------------------------------- + + +class _ConvertFieldmapInputSpec(BaseInterfaceInputSpec): + in_file = InputMultiObject(File(exists=True), mandatory=True) + out_file = traits.Either( + traits.Str(), + traits.List(traits.Str()), + ) + from_type = traits.Enum('map', 'field', 'fieldmap', mandatory=True) + to_type = traits.Enum('map', 'field', 'fieldmap', mandatory=True) + total_readout_time = traits.Float(mandatory=True) + phase_encoding_direction = traits.Enum(*PE_AXES, mandatory=True) + from_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') + to_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') + flip_sign = traits.Bool(False, usedefault=True) + frame = traits.Int() + + +class _ConvertFieldmapOutputSpec(TraitedSpec): + out_file = OutputMultiObject(File(exists=True)) + + +class ConvertFieldmap(WarpkitBaseInterface, SimpleInterface): + """Convert between mm displacement and Hz fieldmap + (:func:`warpkit.api.convert_fieldmap`).""" + + input_spec = _ConvertFieldmapInputSpec + output_spec = _ConvertFieldmapOutputSpec + + def _run_interface(self, runtime): + from warpkit.api import convert_fieldmap + + if isdefined(self.inputs.out_file): + out_paths = _as_str_list(self.inputs.out_file) + else: + out_paths = [os.path.join(runtime.cwd, 'converted.nii.gz')] + + try: + result = convert_fieldmap( + input=list(self.inputs.in_file), + output=out_paths, + from_type=self.inputs.from_type, + to_type=self.inputs.to_type, + total_readout_time=self.inputs.total_readout_time, + phase_encoding_direction=self.inputs.phase_encoding_direction, + from_format=self.inputs.from_format, + to_format=self.inputs.to_format, + flip_sign=self.inputs.flip_sign, + frame=self.inputs.frame if isdefined(self.inputs.frame) else None, + ) + except ValueError as e: + raise RuntimeError(str(e)) from e + + self._results['out_file'] = [str(p) for p in result.output] + return runtime + + +# --------------------------------------------------------------------------- +# ComputeJacobian +# --------------------------------------------------------------------------- + + +class _ComputeJacobianInputSpec(BaseInterfaceInputSpec): + in_file = InputMultiObject(File(exists=True), mandatory=True) + out_file = traits.Either( + traits.Str(), + traits.List(traits.Str()), + ) + from_type = traits.Enum('map', 'field', mandatory=True) + from_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') + axis = traits.Enum(*PE_AXES) + frame = traits.Int() + + +class _ComputeJacobianOutputSpec(TraitedSpec): + out_file = OutputMultiObject(File(exists=True)) + + +class ComputeJacobian(WarpkitBaseInterface, SimpleInterface): + """Jacobian determinant of a displacement warp + (:func:`warpkit.api.compute_jacobian`).""" + + input_spec = _ComputeJacobianInputSpec + output_spec = _ComputeJacobianOutputSpec + + def _run_interface(self, runtime): + from warpkit.api import compute_jacobian + + if isdefined(self.inputs.out_file): + out_paths = _as_str_list(self.inputs.out_file) + else: + out_paths = [os.path.join(runtime.cwd, 'jacobian.nii.gz')] + + try: + result = compute_jacobian( + input=list(self.inputs.in_file), + output=out_paths, + from_type=self.inputs.from_type, + from_format=self.inputs.from_format, + axis=self.inputs.axis if isdefined(self.inputs.axis) else None, + frame=self.inputs.frame if isdefined(self.inputs.frame) else None, + ) + except ValueError as e: + raise RuntimeError(str(e)) from e + + self._results['out_file'] = [str(p) for p in result.output] + return runtime From ceb679e349171e24495de6c2de282e209499b81b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 04:31:25 +0000 Subject: [PATCH 02/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- sdcflows/fieldmaps.py | 9 ++---- sdcflows/interfaces/warpkit.py | 58 +++++++--------------------------- 2 files changed, 13 insertions(+), 54 deletions(-) diff --git a/sdcflows/fieldmaps.py b/sdcflows/fieldmaps.py index 3e03e91a79..e501fbaadc 100644 --- a/sdcflows/fieldmaps.py +++ b/sdcflows/fieldmaps.py @@ -350,8 +350,7 @@ def __attrs_post_init__(self): mag_files = [f for f in self.sources if f.entities.get('part') == 'mag'] if len(phase_files) < 2: raise ValueError( - 'MEDIC requires at least two echoes of phase data; ' - f'got {len(phase_files)}.' + f'MEDIC requires at least two echoes of phase data; got {len(phase_files)}.' ) if len(phase_files) != len(mag_files): raise ValueError( @@ -422,11 +421,7 @@ def __attrs_post_init__(self): > 1 ) - if ( - self.method == EstimatorType.UNKNOWN - and _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: diff --git a/sdcflows/interfaces/warpkit.py b/sdcflows/interfaces/warpkit.py index 5aea30ad53..605ba97976 100644 --- a/sdcflows/interfaces/warpkit.py +++ b/sdcflows/interfaces/warpkit.py @@ -68,9 +68,7 @@ def _as_str_list(x) -> list[str]: class _MEDICInputSpec(BaseInterfaceInputSpec): - phase = InputMultiObject( - File(exists=True), mandatory=True, desc='phase NIfTI, one per echo' - ) + phase = InputMultiObject(File(exists=True), mandatory=True, desc='phase NIfTI, one per echo') magnitude = InputMultiObject( File(exists=True), mandatory=True, desc='magnitude NIfTI, one per echo' ) @@ -79,9 +77,7 @@ class _MEDICInputSpec(BaseInterfaceInputSpec): xor=['metadata'], desc='echo times in milliseconds, one per echo', ) - total_readout_time = traits.Float( - xor=['metadata'], desc='EPI total readout time in seconds' - ) + total_readout_time = traits.Float(xor=['metadata'], desc='EPI total readout time in seconds') phase_encoding_direction = traits.Enum( *PE_DIRECTIONS, xor=['metadata'], @@ -93,9 +89,7 @@ class _MEDICInputSpec(BaseInterfaceInputSpec): desc='BIDS sidecar JSONs, one per echo (alternative to direct args)', ) out_prefix = traits.Str('medic', usedefault=True, desc='prefix for output filenames') - noise_frames = traits.Int( - 0, usedefault=True, desc='number of trailing noise frames to drop' - ) + noise_frames = traits.Int(0, usedefault=True, desc='number of trailing noise frames to drop') n_cpus = traits.Int(4, usedefault=True, desc='number of CPUs to use') wrap_limit = traits.Bool( False, usedefault=True, desc='disable some phase-unwrapping heuristics' @@ -124,11 +118,7 @@ def _run_interface(self, runtime): phase=list(self.inputs.phase), magnitude=list(self.inputs.magnitude), out_prefix=out_prefix, - tes=( - list(self.inputs.echo_times) - if isdefined(self.inputs.echo_times) - else None - ), + tes=(list(self.inputs.echo_times) if isdefined(self.inputs.echo_times) else None), total_readout_time=( self.inputs.total_readout_time if isdefined(self.inputs.total_readout_time) @@ -139,11 +129,7 @@ def _run_interface(self, runtime): if isdefined(self.inputs.phase_encoding_direction) else None ), - metadata=( - list(self.inputs.metadata) - if isdefined(self.inputs.metadata) - else None - ), + metadata=(list(self.inputs.metadata) if isdefined(self.inputs.metadata) else None), noise_frames=self.inputs.noise_frames, n_cpus=self.inputs.n_cpus, wrap_limit=self.inputs.wrap_limit, @@ -195,16 +181,8 @@ def _run_interface(self, runtime): phase=list(self.inputs.phase), magnitude=list(self.inputs.magnitude), out_prefix=out_prefix, - tes=( - list(self.inputs.echo_times) - if isdefined(self.inputs.echo_times) - else None - ), - metadata=( - list(self.inputs.metadata) - if isdefined(self.inputs.metadata) - else None - ), + tes=(list(self.inputs.echo_times) if isdefined(self.inputs.echo_times) else None), + metadata=(list(self.inputs.metadata) if isdefined(self.inputs.metadata) else None), noise_frames=self.inputs.noise_frames, n_cpus=self.inputs.n_cpus, wrap_limit=self.inputs.wrap_limit, @@ -230,9 +208,7 @@ class _ComputeFieldmapInputSpec(BaseInterfaceInputSpec): desc='unwrapped phase per echo (output of UnwrapPhase)', ) magnitude = InputMultiObject(File(exists=True), mandatory=True) - masks = File( - exists=True, mandatory=True, desc='per-frame masks (output of UnwrapPhase)' - ) + masks = File(exists=True, mandatory=True, desc='per-frame masks (output of UnwrapPhase)') echo_times = traits.List(traits.Float, xor=['metadata']) total_readout_time = traits.Float(xor=['metadata']) phase_encoding_direction = traits.Enum(*PE_DIRECTIONS, xor=['metadata']) @@ -274,11 +250,7 @@ def _run_interface(self, runtime): magnitude=list(self.inputs.magnitude), masks=self.inputs.masks, out_prefix=out_prefix, - tes=( - list(self.inputs.echo_times) - if isdefined(self.inputs.echo_times) - else None - ), + tes=(list(self.inputs.echo_times) if isdefined(self.inputs.echo_times) else None), total_readout_time=( self.inputs.total_readout_time if isdefined(self.inputs.total_readout_time) @@ -289,11 +261,7 @@ def _run_interface(self, runtime): if isdefined(self.inputs.phase_encoding_direction) else None ), - metadata=( - list(self.inputs.metadata) - if isdefined(self.inputs.metadata) - else None - ), + metadata=(list(self.inputs.metadata) if isdefined(self.inputs.metadata) else None), border_filt=tuple(self.inputs.border_filt), svd_filt=self.inputs.svd_filt, n_cpus=self.inputs.n_cpus, @@ -345,11 +313,7 @@ def _run_interface(self, runtime): transform=list(self.inputs.transform), output=out_file, transform_type=self.inputs.transform_type, - reference=( - self.inputs.reference - if isdefined(self.inputs.reference) - else None - ), + reference=(self.inputs.reference if isdefined(self.inputs.reference) else None), phase_encoding_axis=( self.inputs.phase_encoding_axis if isdefined(self.inputs.phase_encoding_axis) From c9e94b3c18de8319c0d5dd1aad3cb569ba700e7c Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 4 May 2026 21:08:15 -0500 Subject: [PATCH 03/48] [WIP] [FEAT] init_medic_wf workflow + MEDIC wrangler integration - 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. --- pyproject.toml | 6 + sdcflows/fieldmaps.py | 15 ++ sdcflows/utils/tests/test_wrangler.py | 48 ++++ sdcflows/utils/wrangler.py | 77 +++++- sdcflows/workflows/base.py | 2 + sdcflows/workflows/fit/medic.py | 281 +++++++++++++++++++++ sdcflows/workflows/fit/tests/test_medic.py | 122 +++++++++ 7 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 sdcflows/workflows/fit/medic.py create mode 100644 sdcflows/workflows/fit/tests/test_medic.py diff --git a/pyproject.toml b/pyproject.toml index 0e346edb9f..e7c1482779 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,12 @@ test = [ "pytest-xdist >= 2.5", ] +# warpkit is a Washington University-licensed tool that powers the MEDIC +# workflow. Its license permits non-commercial use only; commercial users +# need a separate agreement with WUSTL OTM. Keep this extra opt-in and +# DO NOT add it to the `all` alias below — the default install must remain +# clean-Apache. +# See: https://github.com/vanandrew/warpkit/blob/main/LICENSE warpkit = [ "warpkit >= 1.2.1", ] diff --git a/sdcflows/fieldmaps.py b/sdcflows/fieldmaps.py index e501fbaadc..46e70722b3 100644 --- a/sdcflows/fieldmaps.py +++ b/sdcflows/fieldmaps.py @@ -524,6 +524,21 @@ 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 + + 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 diff --git a/sdcflows/utils/tests/test_wrangler.py b/sdcflows/utils/tests/test_wrangler.py index d933625eca..8b44dfc3b3 100644 --- a/sdcflows/utils/tests/test_wrangler.py +++ b/sdcflows/utils/tests/test_wrangler.py @@ -433,6 +433,51 @@ def gen_layout(bids_dir, database_dir=None): } +def _build_medic_skeleton(): + """Generate a 3-session × 3-echo × {mag,phase} BIDS skeleton for MEDIC. + + Each phase BOLD carries an ``IntendedFor`` listing all 6 mag/phase + siblings in its session, so the wrangler can detect the run via the + IntendedFor branch. + """ + echo_times = {'1': 0.0142, '2': 0.03893, '3': 0.06366} + sessions = [] + for ses in ('01', '02', '03'): + intended_for = [ + ( + f'bids::sub-01/ses-{ses}/func/' + f'sub-01_ses-{ses}_task-rest_echo-{echo}_part-{part}_bold.nii.gz' + ) + for echo in echo_times + for part in ('mag', 'phase') + ] + func = [] + for echo, te in echo_times.items(): + for part in ('mag', 'phase'): + func.append({ + 'task': 'rest', + 'echo': echo, + 'part': part, + 'suffix': 'bold', + 'metadata': { + 'EchoTime': te, + 'RepetitionTime': 0.8, + 'TotalReadoutTime': 0.5, + 'PhaseEncodingDirection': 'j', + 'IntendedFor': intended_for, + }, + }) + sessions.append({ + 'session': ses, + 'anat': [{'suffix': 'T1w', 'metadata': {'EchoTime': 1}}], + 'func': func, + }) + return {'01': sessions} + + +medic = _build_medic_skeleton() + + filters = { 'fmap': { 'datatype': 'fmap', @@ -440,6 +485,7 @@ def gen_layout(bids_dir, database_dir=None): }, 't1w': {'datatype': 'anat', 'session': '01', 'suffix': 'T1w'}, 'bold': {'datatype': 'func', 'session': '01', 'suffix': 'bold'}, + 'medic': {'datatype': ['fmap', 'func'], 'session': '01'}, } @@ -449,6 +495,7 @@ def gen_layout(bids_dir, database_dir=None): ('pepolar', pepolar, 1, 'fmap'), ('pepolar_b0ids', pepolar_b0ids, 1, 'bold'), ('phasediff', phasediff, 1, 'fmap'), + ('medic', medic, 1, 'medic'), ], ) def test_wrangler_filter(tmpdir, name, skeleton, estimations, bids_filters): @@ -466,6 +513,7 @@ def test_wrangler_filter(tmpdir, name, skeleton, estimations, bids_filters): ('pepolar', pepolar, 5, True), ('pepolar_b0ids', pepolar_b0ids, 2, False), ('phasediff', phasediff, 3, True), + ('medic', medic, 3, False), ], ) @pytest.mark.parametrize( diff --git a/sdcflows/utils/wrangler.py b/sdcflows/utils/wrangler.py index f22725167f..4265437585 100644 --- a/sdcflows/utils/wrangler.py +++ b/sdcflows/utils/wrangler.py @@ -326,7 +326,6 @@ def find_estimators( base_entities = { 'subject': subject, 'extension': ['.nii', '.nii.gz'], - 'part': ['mag', None], 'scope': 'raw', # Ensure derivatives are not captured } @@ -351,7 +350,12 @@ def find_estimators( # flatten lists from json (tupled in pybids for hashing), then unique b0_ids = reduce( set.union, - (listify(ids) for ids in layout.get_B0FieldIdentifiers(**base_entities)), + ( + listify(ids) + for ids in layout.get_B0FieldIdentifiers( + session=sessions, **base_entities + ) + ), set(), ) @@ -444,14 +448,77 @@ def find_estimators( _log_debug_estimation(logger, e, layout.root) estimators.append(e) - # At this point, only single-PE _epi files WITH ``IntendedFor`` can - # be automatically processed. + # MEDIC: multi-echo BOLD with mag+phase parts and ``IntendedFor``. + # The query keys on ``part='phase'`` so we get one hit per (run, echo) + # phase BOLD; we then expand to all matching mag+phase siblings. + bold_phase_with_intent = () + with suppress(ValueError): + bold_phase_with_intent = layout.get( + **{ + **base_entities, + **{ + 'session': sessions, + 'suffix': 'bold', + 'part': 'phase', + 'echo': Query.REQUIRED, + 'IntendedFor': Query.REQUIRED, + }, + } + ) + + for bold_fmap in bold_phase_with_intent: + # Pull every echo + part for this run. ``get_entities()`` already + # includes extension; we override part/echo to widen the query. + run_entities = { + k: v for k, v in bold_fmap.get_entities().items() + if k not in ('part', 'echo') + } + run_entities['part'] = ['phase', 'mag'] + run_entities['echo'] = Query.ANY + run_entities['scope'] = base_entities['scope'] + complex_imgs = layout.get(**run_entities) + if not complex_imgs: + continue + + already_claimed = { + str(s.path) + for est in estimators + for s in est.sources + } + if str(complex_imgs[0].path) in already_claimed: + logger.debug('Skipping MEDIC fmap %s (already in use)', complex_imgs[0].relpath) + continue + + try: + e = fm.FieldmapEstimation( + [ + fm.FieldmapFile( + img.path, + metadata=_filter_metadata(img.get_metadata(), subject), + ) + for img in complex_imgs + ] + ) + except (ValueError, TypeError) as err: + _log_debug_estimator_fail( + logger, 'unnamed MEDIC', list(complex_imgs), layout.root, str(err) + ) + else: + _log_debug_estimation(logger, e, layout.root) + estimators.append(e) + + # At this point, only single-PE _epi/_bold files WITH ``IntendedFor`` + # can be automatically processed. has_intended = () with suppress(ValueError): has_intended = layout.get( **{ **base_entities, - **{'suffix': 'epi', 'IntendedFor': Query.REQUIRED, 'session': sessions}, + **{ + 'suffix': ['epi', 'bold'], + 'IntendedFor': Query.REQUIRED, + 'session': sessions, + }, } ) diff --git a/sdcflows/workflows/base.py b/sdcflows/workflows/base.py index 40f938b6a6..29a28726f2 100644 --- a/sdcflows/workflows/base.py +++ b/sdcflows/workflows/base.py @@ -82,6 +82,7 @@ def init_fmap_preproc_wf( """ from sdcflows.fieldmaps import EstimatorType + from sdcflows.workflows.fit.medic import INPUT_FIELDS as _medic_fields from sdcflows.workflows.fit.pepolar import INPUT_FIELDS as _pepolar_fields from sdcflows.workflows.fit.syn import INPUT_FIELDS as _syn_fields from sdcflows.workflows.outputs import init_fmap_derivatives_wf, init_fmap_reports_wf @@ -89,6 +90,7 @@ def init_fmap_preproc_wf( INPUT_FIELDS = { EstimatorType.ANAT: _syn_fields, EstimatorType.PEPOLAR: _pepolar_fields, + EstimatorType.MEDIC: _medic_fields, } workflow = Workflow(name=name) diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py new file mode 100644 index 0000000000..0a405fb98d --- /dev/null +++ b/sdcflows/workflows/fit/medic.py @@ -0,0 +1,281 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2025 The NiPreps Developers +# +# 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/ +# +"""MEDIC dynamic distortion correction (multi-echo phase + magnitude). + +Backed by `warpkit `__, which is an +**optional** dependency carrying a Washington University non-commercial +license. Installing ``sdcflows[warpkit]`` opts the user into those terms; +the default ``sdcflows`` install never imports warpkit. Importing this +module does not require warpkit — the dependency is only resolved when the +:class:`~sdcflows.interfaces.warpkit.MEDIC` interface actually runs. +""" + +from nipype.interfaces import utility as niu +from nipype.pipeline import engine as pe +from niworkflows.engine.workflows import LiterateWorkflow as Workflow + +INPUT_FIELDS = ('phase', 'magnitude', 'metadata') + +_MEDIC_DESC = """\ +A dynamic *B0* nonuniformity map was estimated from multi-echo +magnitude and phase EPI series using MEDIC [Montez2024]_, as implemented in +``warpkit``. +""" + + +def init_medic_wf( + omp_nthreads=1, + sloppy=False, + debug=False, + name='medic_wf', + **kwargs, +): + """ + Estimate a fieldmap via MEDIC from multi-echo magnitude + phase EPI. + + Workflow Graph + .. workflow :: + :graph2use: orig + :simple_form: yes + + from sdcflows.workflows.fit.medic import init_medic_wf + wf = init_medic_wf() + + Parameters + ---------- + omp_nthreads : :obj:`int` + Maximum number of threads warpkit may use. + sloppy : :obj:`bool` + Currently unused; reserved for future fast-path knobs. + debug : :obj:`bool` + Pass through to :class:`~sdcflows.interfaces.warpkit.MEDIC`. + name : :obj:`str` + Workflow name. + + Inputs + ------ + phase : :obj:`list` of :obj:`str` + Phase NIfTI per echo. + magnitude : :obj:`list` of :obj:`str` + Magnitude NIfTI per echo. + metadata : :obj:`list` of :obj:`dict` + BIDS sidecar dicts, one per echo. Must contain ``EchoTime``, + ``TotalReadoutTime``, and ``PhaseEncodingDirection``. + + Outputs + ------- + fmap : :obj:`str` + Static :math:`B_0` map in Hz (temporal mean of the dynamic series), + for compatibility with the rest of sdcflows. + fmap_dynamic : :obj:`str` + 4D :math:`B_0` map in Hz, one volume per timepoint (undistorted + space). The actual MEDIC output; consumers wanting per-volume + distortion correction should use this instead of ``fmap``. + fmap_ref : :obj:`str` + Brain-extracted magnitude reference (first echo, temporal mean). + fmap_dynamic_ref : :obj:`str` + 4D first-echo magnitude (one volume per timepoint), for QC of + per-volume correction. Untouched passthrough of the input. + fmap_mask : :obj:`str` + Static binary brain mask co-registered with ``fmap_ref``. + fmap_dynamic_mask : :obj:`str` + 4D per-frame brain mask emitted by warpkit's phase-unwrapping + stage; tracks subject motion frame-by-frame. + fmap_coeff : :obj:`str` or :obj:`list` of :obj:`str` + B-spline coefficients fit to the static ``fmap``. **Emitted as a + compatibility shim**, not because it adds scientific value: the + existing :func:`~sdcflows.workflows.apply.correction.init_unwarp_wf` + consumes B-spline coefficients (via + :class:`~sdcflows.interfaces.bspline.ApplyCoeffsField`), so MEDIC + outputs need this representation to flow through the current apply + pipeline. The spline fit is redundant for MEDIC — its fieldmap is + already on the EPI grid (no resampling motivation) and warpkit's + internal SVD filter already smooths each frame. A future + MEDIC-aware apply workflow consuming ``fmap_dynamic`` directly + should obsolete this output for MEDIC pipelines. + method : :obj:`str` + Short description string. + + """ + # Project-internal imports only — none of these load warpkit at module + # import time. The warpkit dependency is resolved lazily inside + # MEDIC._run_interface, so this workflow can be constructed (and the + # module can be imported) without warpkit installed. + from niworkflows.interfaces.images import IntraModalMerge + + from ...interfaces.bspline import DEFAULT_HF_ZOOMS_MM, BSplineApprox + from ...interfaces.warpkit import ComputeFieldmap, UnwrapPhase + from ..ancillary import init_brainextraction_wf + + workflow = Workflow(name=name) + workflow.__desc__ = _MEDIC_DESC + + inputnode = pe.Node(niu.IdentityInterface(fields=INPUT_FIELDS), name='inputnode') + outputnode = pe.Node( + niu.IdentityInterface( + fields=[ + 'fmap', + 'fmap_dynamic', + 'fmap_ref', + 'fmap_dynamic_ref', + 'fmap_mask', + 'fmap_dynamic_mask', + 'fmap_coeff', + 'method', + ], + ), + name='outputnode', + ) + outputnode.inputs.method = 'MEDIC (multi-echo dynamic distortion correction)' + + # Pull echo_times / TRT / PED from sidecar dicts so warpkit gets them as + # direct args. (The interfaces also accept JSON sidecar paths, but the + # upstream sdcflows layer passes dicts.) + extract_meta = pe.Node( + niu.Function( + input_names=['metadata'], + output_names=['echo_times', 'total_readout_time', 'phase_encoding_direction'], + function=_unpack_metadata, + ), + name='extract_meta', + run_without_submitting=True, + ) + + # Two-stage warpkit path: UnwrapPhase exposes per-frame masks, which + # ComputeFieldmap then consumes. The one-shot MEDIC interface bundles + # both but hides the masks; we want them for ``fmap_dynamic_mask``. + unwrap = pe.Node(UnwrapPhase(), name='unwrap', n_procs=omp_nthreads) + unwrap.inputs.n_cpus = omp_nthreads + unwrap.inputs.debug = debug + + compute_fmap = pe.Node(ComputeFieldmap(), name='compute_fmap', n_procs=omp_nthreads) + compute_fmap.inputs.n_cpus = omp_nthreads + + # The 4D dynamic Hz fieldmap is the real MEDIC output — exposed below + # as ``fmap_dynamic`` for consumers that want per-volume correction. + # We also reduce it to 3D via temporal mean for the static ``fmap`` / + # B-spline path, which the rest of sdcflows expects. + fmap_mean = pe.Node( + niu.Function( + input_names=['in_file'], + output_names=['out_file'], + function=_temporal_mean, + ), + name='fmap_mean', + run_without_submitting=True, + ) + + # First-echo magnitude — used (a) as the dynamic reference passthrough + # and (b) temporally averaged for the static brain extraction. + pick_mag1 = pe.Node( + niu.Function( + input_names=['in_list'], output_names=['out_file'], function=_first, + ), + name='pick_mag1', + run_without_submitting=True, + ) + magmrg = pe.Node(IntraModalMerge(hmc=False, to_ras=False), name='magmrg') + brainextraction_wf = init_brainextraction_wf() + + # B-spline fit on the static fieldmap. Compatibility shim for the + # existing init_unwarp_wf, which only consumes B-spline coefficients + # (see ApplyCoeffsField). For MEDIC the spline adds no scientific + # value — the fieldmap is already on the EPI grid and warpkit's SVD + # filter has already smoothed it. See the ``fmap_coeff`` doc above. + bs_filter = pe.Node(BSplineApprox(), name='bs_filter') + bs_filter.interface._always_run = debug + bs_filter.inputs.bs_spacing = [DEFAULT_HF_ZOOMS_MM] + bs_filter.inputs.extrapolate = not debug + if sloppy: + bs_filter.inputs.zooms_min = 4.0 + + # fmt: off + workflow.connect([ + (inputnode, extract_meta, [('metadata', 'metadata')]), + (inputnode, unwrap, [('phase', 'phase'), + ('magnitude', 'magnitude')]), + (extract_meta, unwrap, [('echo_times', 'echo_times')]), + (inputnode, compute_fmap, [('magnitude', 'magnitude')]), + (unwrap, compute_fmap, [('unwrapped', 'unwrapped'), + ('masks', 'masks')]), + (extract_meta, compute_fmap, [ + ('echo_times', 'echo_times'), + ('total_readout_time', 'total_readout_time'), + ('phase_encoding_direction', 'phase_encoding_direction'), + ]), + (compute_fmap, fmap_mean, [('fieldmap', 'in_file')]), + (compute_fmap, outputnode, [('fieldmap', 'fmap_dynamic')]), + (unwrap, outputnode, [('masks', 'fmap_dynamic_mask')]), + (inputnode, pick_mag1, [('magnitude', 'in_list')]), + (pick_mag1, outputnode, [('out_file', 'fmap_dynamic_ref')]), + (pick_mag1, magmrg, [('out_file', 'in_files')]), + (magmrg, brainextraction_wf, [('out_avg', 'inputnode.in_file')]), + (brainextraction_wf, outputnode, [ + ('outputnode.out_file', 'fmap_ref'), + ('outputnode.out_mask', 'fmap_mask'), + ]), + (brainextraction_wf, bs_filter, [('outputnode.out_mask', 'in_mask')]), + (fmap_mean, bs_filter, [('out_file', 'in_data')]), + (bs_filter, outputnode, [ + ('out_extrapolated' if not debug else 'out_field', 'fmap'), + ('out_coeff', 'fmap_coeff'), + ]), + ]) + # fmt: on + + return workflow + + +def _unpack_metadata(metadata): + """Pull echo times (s→ms), TRT, and PE direction from BIDS sidecars.""" + if not metadata: + raise ValueError('MEDIC requires per-echo metadata.') + echo_times = [float(m['EchoTime']) * 1000.0 for m in metadata] + total_readout_time = float(metadata[0]['TotalReadoutTime']) + phase_encoding_direction = metadata[0]['PhaseEncodingDirection'] + peds = {m['PhaseEncodingDirection'] for m in metadata} + if len(peds) > 1: + raise ValueError( + f'MEDIC echoes must share PhaseEncodingDirection; got {sorted(peds)}.' + ) + return echo_times, total_readout_time, phase_encoding_direction + + +def _temporal_mean(in_file): + """Average a 4D NIfTI across time, returning a 3D volume.""" + import os + + import nibabel as nb + import numpy as np + + img = nb.load(in_file) + data = np.asanyarray(img.dataobj) + if data.ndim == 4: + data = data.mean(axis=3) + out_file = os.path.abspath('fmap_mean.nii.gz') + nb.Nifti1Image(data, img.affine, img.header).to_filename(out_file) + return out_file + + +def _first(in_list): + return in_list[0] if in_list else None diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py new file mode 100644 index 0000000000..4e5ca5028c --- /dev/null +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -0,0 +1,122 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2025 The NiPreps Developers +# +# 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 MEDIC dynamic fieldmap workflow.""" + +from json import loads +from pathlib import Path + +import pytest + +from ..medic import INPUT_FIELDS, _unpack_metadata, init_medic_wf + + +def test_medic_construct(): + """Build the workflow and verify its surface — no warpkit required. + + This guards against module-load regressions and confirms the inputnode/ + outputnode shape that ``init_fmap_preproc_wf`` depends on. + """ + wf = init_medic_wf() + assert wf.name == 'medic_wf' + + inputnode = wf.get_node('inputnode') + outputnode = wf.get_node('outputnode') + assert inputnode is not None and outputnode is not None + assert set(inputnode.outputs.copyable_trait_names()) >= set(INPUT_FIELDS) + assert set(outputnode.inputs.copyable_trait_names()) >= { + 'fmap', + 'fmap_dynamic', + 'fmap_ref', + 'fmap_dynamic_ref', + 'fmap_mask', + 'fmap_dynamic_mask', + 'fmap_coeff', + 'method', + } + # method is set unconditionally at construction. + assert outputnode.inputs.method.startswith('MEDIC') + + # Core nodes wired in the order the workflow describes. + for name in ( + 'extract_meta', + 'unwrap', + 'compute_fmap', + 'fmap_mean', + 'pick_mag1', + 'magmrg', + 'bs_filter', + ): + assert wf.get_node(name) is not None, f'missing node {name!r}' + + +def test_unpack_metadata_converts_te_to_ms(): + metadata = [ + {'EchoTime': 0.0142, 'TotalReadoutTime': 0.5, 'PhaseEncodingDirection': 'j'}, + {'EchoTime': 0.03893, 'TotalReadoutTime': 0.5, 'PhaseEncodingDirection': 'j'}, + ] + tes, trt, ped = _unpack_metadata(metadata) + assert tes == [pytest.approx(14.2), pytest.approx(38.93)] + assert trt == 0.5 + assert ped == 'j' + + +def test_unpack_metadata_rejects_mixed_pe(): + metadata = [ + {'EchoTime': 0.0142, 'TotalReadoutTime': 0.5, 'PhaseEncodingDirection': 'j'}, + {'EchoTime': 0.03893, 'TotalReadoutTime': 0.5, 'PhaseEncodingDirection': 'j-'}, + ] + with pytest.raises(ValueError, match='PhaseEncodingDirection'): + _unpack_metadata(metadata) + + +@pytest.mark.slow +def test_medic_run(tmpdir, datadir, workdir, outdir): + """End-to-end MEDIC run on a real multi-echo BOLD. + + Skipped if ``warpkit`` is unavailable (default CI install) or if the + expected ``ds005250`` multi-echo dataset is not present in the test data + fixture directory. Use ``TEST_DATA_HOME`` and install + ``sdcflows[warpkit]`` locally to opt in. + """ + pytest.importorskip('warpkit') + + pattern = 'ds005250/sub-04/ses-2/func/*_part-mag_bold.nii.gz' + magnitude_files = sorted(Path(datadir).glob(pattern)) + if not magnitude_files: + pytest.skip(f'no MEDIC fixtures found under {datadir}/ds005250') + + phase_files = [f.with_name(f.name.replace('part-mag', 'part-phase')) for f in magnitude_files] + metadata = [ + loads(f.with_name(f.name.replace('.nii.gz', '.json')).read_text()) + for f in phase_files + ] + + tmpdir.chdir() + medic_wf = init_medic_wf(omp_nthreads=2, debug=True) + medic_wf.inputs.inputnode.magnitude = [str(f) for f in magnitude_files] + medic_wf.inputs.inputnode.phase = [str(f) for f in phase_files] + medic_wf.inputs.inputnode.metadata = metadata + + if workdir: + medic_wf.base_dir = str(workdir) + medic_wf.run(plugin='Linear') From 82b8f50b3c100384ceaa2bd76b427e8dc48e567f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 02:10:28 +0000 Subject: [PATCH 04/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- sdcflows/utils/tests/test_wrangler.py | 40 ++++++++++++---------- sdcflows/utils/wrangler.py | 13 ++----- sdcflows/workflows/fit/medic.py | 8 ++--- sdcflows/workflows/fit/tests/test_medic.py | 3 +- 4 files changed, 30 insertions(+), 34 deletions(-) diff --git a/sdcflows/utils/tests/test_wrangler.py b/sdcflows/utils/tests/test_wrangler.py index 8b44dfc3b3..6037a0c181 100644 --- a/sdcflows/utils/tests/test_wrangler.py +++ b/sdcflows/utils/tests/test_wrangler.py @@ -454,24 +454,28 @@ def _build_medic_skeleton(): func = [] for echo, te in echo_times.items(): for part in ('mag', 'phase'): - func.append({ - 'task': 'rest', - 'echo': echo, - 'part': part, - 'suffix': 'bold', - 'metadata': { - 'EchoTime': te, - 'RepetitionTime': 0.8, - 'TotalReadoutTime': 0.5, - 'PhaseEncodingDirection': 'j', - 'IntendedFor': intended_for, - }, - }) - sessions.append({ - 'session': ses, - 'anat': [{'suffix': 'T1w', 'metadata': {'EchoTime': 1}}], - 'func': func, - }) + func.append( + { + 'task': 'rest', + 'echo': echo, + 'part': part, + 'suffix': 'bold', + 'metadata': { + 'EchoTime': te, + 'RepetitionTime': 0.8, + 'TotalReadoutTime': 0.5, + 'PhaseEncodingDirection': 'j', + 'IntendedFor': intended_for, + }, + } + ) + sessions.append( + { + 'session': ses, + 'anat': [{'suffix': 'T1w', 'metadata': {'EchoTime': 1}}], + 'func': func, + } + ) return {'01': sessions} diff --git a/sdcflows/utils/wrangler.py b/sdcflows/utils/wrangler.py index 4265437585..5a61e1f49e 100644 --- a/sdcflows/utils/wrangler.py +++ b/sdcflows/utils/wrangler.py @@ -352,9 +352,7 @@ def find_estimators( set.union, ( listify(ids) - for ids in layout.get_B0FieldIdentifiers( - session=sessions, **base_entities - ) + for ids in layout.get_B0FieldIdentifiers(session=sessions, **base_entities) ), set(), ) @@ -470,8 +468,7 @@ def find_estimators( # Pull every echo + part for this run. ``get_entities()`` already # includes extension; we override part/echo to widen the query. run_entities = { - k: v for k, v in bold_fmap.get_entities().items() - if k not in ('part', 'echo') + k: v for k, v in bold_fmap.get_entities().items() if k not in ('part', 'echo') } run_entities['part'] = ['phase', 'mag'] run_entities['echo'] = Query.ANY @@ -480,11 +477,7 @@ def find_estimators( if not complex_imgs: continue - already_claimed = { - str(s.path) - for est in estimators - for s in est.sources - } + already_claimed = {str(s.path) for est in estimators for s in est.sources} if str(complex_imgs[0].path) in already_claimed: logger.debug('Skipping MEDIC fmap %s (already in use)', complex_imgs[0].relpath) continue diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index 0a405fb98d..7737bf8b96 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -189,7 +189,9 @@ def init_medic_wf( # and (b) temporally averaged for the static brain extraction. pick_mag1 = pe.Node( niu.Function( - input_names=['in_list'], output_names=['out_file'], function=_first, + input_names=['in_list'], + output_names=['out_file'], + function=_first, ), name='pick_mag1', run_without_submitting=True, @@ -255,9 +257,7 @@ def _unpack_metadata(metadata): phase_encoding_direction = metadata[0]['PhaseEncodingDirection'] peds = {m['PhaseEncodingDirection'] for m in metadata} if len(peds) > 1: - raise ValueError( - f'MEDIC echoes must share PhaseEncodingDirection; got {sorted(peds)}.' - ) + raise ValueError(f'MEDIC echoes must share PhaseEncodingDirection; got {sorted(peds)}.') return echo_times, total_readout_time, phase_encoding_direction diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py index 4e5ca5028c..1bd0432140 100644 --- a/sdcflows/workflows/fit/tests/test_medic.py +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -107,8 +107,7 @@ def test_medic_run(tmpdir, datadir, workdir, outdir): phase_files = [f.with_name(f.name.replace('part-mag', 'part-phase')) for f in magnitude_files] metadata = [ - loads(f.with_name(f.name.replace('.nii.gz', '.json')).read_text()) - for f in phase_files + loads(f.with_name(f.name.replace('.nii.gz', '.json')).read_text()) for f in phase_files ] tmpdir.chdir() From ff4e17c038694e32b5519e194c2518fefe60f82a Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 4 May 2026 21:13:41 -0500 Subject: [PATCH 05/48] [WIP] [FEAT] Plumb MEDIC dynamic outputs through derivatives writer - Adds write_dynamic flag to init_fmap_derivatives_wf, plus three new DerivativesDataSink nodes (fmap_dynamic, fmap_dynamic_ref, fmap_dynamic_mask) that emit the 4D Hz fieldmap, raw first-echo magnitude reference, and per-frame brain mask under desc-dynamic / desc-dynamicbrain. - init_fmap_preproc_wf now sets write_dynamic=True for MEDIC estimators and wires the three dynamic ports from the fit workflow's outputnode through the derivatives writer to the per-estimator out_map. Other methods are unaffected. - Adds python_version >= '3.11' marker to the warpkit extra in pyproject.toml so `uv sync` resolves cleanly with the project's >=3.10 requires-python (warpkit itself needs 3.11). --- pyproject.toml | 2 +- sdcflows/workflows/base.py | 28 ++++++++++- sdcflows/workflows/outputs.py | 94 ++++++++++++++++++++++++++++++++++- 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e7c1482779..871a58178c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ test = [ # clean-Apache. # See: https://github.com/vanandrew/warpkit/blob/main/LICENSE warpkit = [ - "warpkit >= 1.2.1", + "warpkit >= 1.2.1; python_version >= '3.11'", ] # Aliases diff --git a/sdcflows/workflows/base.py b/sdcflows/workflows/base.py index 29a28726f2..e59f477cd3 100644 --- a/sdcflows/workflows/base.py +++ b/sdcflows/workflows/base.py @@ -95,7 +95,17 @@ def init_fmap_preproc_wf( workflow = Workflow(name=name) - out_fields = ('fmap', 'fmap_coeff', 'fmap_ref', 'fmap_mask', 'fmap_id', 'method') + out_fields = ( + 'fmap', + 'fmap_coeff', + 'fmap_ref', + 'fmap_mask', + 'fmap_id', + 'method', + 'fmap_dynamic', + 'fmap_dynamic_ref', + 'fmap_dynamic_mask', + ) out_merge = {f: pe.Node(niu.Merge(len(estimators)), name=f'out_merge_{f}') for f in out_fields} # Fieldmaps and coefficient files can come in pairs, ensure they are not flattened out_merge['fmap'].inputs.no_flatten = True @@ -136,10 +146,12 @@ def init_fmap_preproc_wf( ) out_map.inputs.fmap_id = estimator.bids_id + is_medic = estimator.method == EstimatorType.MEDIC fmap_derivatives_wf = init_fmap_derivatives_wf( output_dir=str(output_dir), write_coeff=True, write_mask=True, + write_dynamic=is_medic, bids_fmap_id=estimator.bids_id, name=f'fmap_derivatives_wf_{estimator.sanitized_id}', ) @@ -187,6 +199,20 @@ def init_fmap_preproc_wf( ]), ]) # fmt:skip + if is_medic: + workflow.connect([ + (est_wf, fmap_derivatives_wf, [ + ('outputnode.fmap_dynamic', 'inputnode.fmap_dynamic'), + ('outputnode.fmap_dynamic_ref', 'inputnode.fmap_dynamic_ref'), + ('outputnode.fmap_dynamic_mask', 'inputnode.fmap_dynamic_mask'), + ]), + (fmap_derivatives_wf, out_map, [ + ('outputnode.fmap_dynamic', 'fmap_dynamic'), + ('outputnode.fmap_dynamic_ref', 'fmap_dynamic_ref'), + ('outputnode.fmap_dynamic_mask', 'fmap_dynamic_mask'), + ]), + ]) # fmt:skip + for field, mergenode in out_merge.items(): workflow.connect(out_map, field, mergenode, f'in{n}') diff --git a/sdcflows/workflows/outputs.py b/sdcflows/workflows/outputs.py index de72d4cb34..b16e989e6d 100644 --- a/sdcflows/workflows/outputs.py +++ b/sdcflows/workflows/outputs.py @@ -124,6 +124,7 @@ def init_fmap_derivatives_wf( name='fmap_derivatives_wf', write_coeff=False, write_mask=False, + write_dynamic=False, ): """ Set up datasinks to store derivatives in the right location. @@ -140,6 +141,9 @@ def init_fmap_derivatives_wf( Workflow name (default: ``"fmap_derivatives_wf"``) write_coeff : :obj:`bool` Build the workflow path to map coefficients into target space. + write_dynamic : :obj:`bool` + Build the workflow path to write the per-volume dynamic fieldmap, + magnitude reference, and brain-mask series. Used by MEDIC. Inputs ------ @@ -151,6 +155,12 @@ def init_fmap_derivatives_wf( Field coefficient(s) file(s) fmap_ref An anatomical reference (e.g., magnitude file) + fmap_dynamic + 4D per-volume fieldmap (Hz). Only consumed when ``write_dynamic``. + fmap_dynamic_ref + 4D per-volume magnitude reference. Only consumed when ``write_dynamic``. + fmap_dynamic_mask + 4D per-volume brain mask. Only consumed when ``write_dynamic``. """ custom_entities = custom_entities or {} @@ -160,12 +170,32 @@ def init_fmap_derivatives_wf( workflow = pe.Workflow(name=name) inputnode = pe.Node( niu.IdentityInterface( - fields=['source_files', 'fieldmap', 'fmap_coeff', 'fmap_ref', 'fmap_mask', 'fmap_meta'] + fields=[ + 'source_files', + 'fieldmap', + 'fmap_coeff', + 'fmap_ref', + 'fmap_mask', + 'fmap_meta', + 'fmap_dynamic', + 'fmap_dynamic_ref', + 'fmap_dynamic_mask', + ] ), name='inputnode', ) outputnode = pe.Node( - niu.IdentityInterface(fields=['fieldmap', 'fmap_coeff', 'fmap_ref', 'fmap_mask']), + niu.IdentityInterface( + fields=[ + 'fieldmap', + 'fmap_coeff', + 'fmap_ref', + 'fmap_mask', + 'fmap_dynamic', + 'fmap_dynamic_ref', + 'fmap_dynamic_mask', + ] + ), name='outputnode', ) @@ -239,6 +269,66 @@ def init_fmap_derivatives_wf( (ds_mask, outputnode, [("out_file", "fmap_mask")]), ]) # fmt:skip + if write_dynamic: + # 4D Hz fieldmap (one volume per timepoint, undistorted space). + ds_fmap_dynamic = pe.Node( + DerivativesDataSink( + base_directory=output_dir, + desc='dynamic', + suffix='fieldmap', + datatype='fmap', + compress=True, + allowed_entities=tuple(custom_entities), + ), + name='ds_fmap_dynamic', + ) + ds_fmap_dynamic.inputs.Units = 'Hz' + if bids_fmap_id: + ds_fmap_dynamic.inputs.B0FieldIdentifier = bids_fmap_id + ds_fmap_dynamic.inputs.trait_set(**custom_entities) + + # 4D first-echo magnitude reference (raw passthrough, for QC). + ds_fmap_dynamic_ref = pe.Node( + DerivativesDataSink( + base_directory=output_dir, + desc='dynamic', + suffix='magnitude', + datatype='fmap', + compress=True, + dismiss_entities=('fmap',), + allowed_entities=tuple(custom_entities), + ), + name='ds_fmap_dynamic_ref', + ) + ds_fmap_dynamic_ref.inputs.trait_set(**custom_entities) + + # 4D per-frame brain mask. + ds_fmap_dynamic_mask = pe.Node( + DerivativesDataSink( + base_directory=output_dir, + desc='dynamicbrain', + suffix='mask', + datatype='fmap', + compress=True, + dismiss_entities=('fmap',), + allowed_entities=tuple(custom_entities), + ), + name='ds_fmap_dynamic_mask', + ) + ds_fmap_dynamic_mask.inputs.trait_set(**custom_entities) + + workflow.connect([ + (inputnode, ds_fmap_dynamic, [('source_files', 'source_file'), + ('fmap_dynamic', 'in_file')]), + (inputnode, ds_fmap_dynamic_ref, [('source_files', 'source_file'), + ('fmap_dynamic_ref', 'in_file')]), + (inputnode, ds_fmap_dynamic_mask, [('source_files', 'source_file'), + ('fmap_dynamic_mask', 'in_file')]), + (ds_fmap_dynamic, outputnode, [('out_file', 'fmap_dynamic')]), + (ds_fmap_dynamic_ref, outputnode, [('out_file', 'fmap_dynamic_ref')]), + (ds_fmap_dynamic_mask, outputnode, [('out_file', 'fmap_dynamic_mask')]), + ]) # fmt:skip + if not write_coeff: return workflow From 786fefabdb3b9c209680c169e231ef2c4132efdc Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 4 May 2026 21:17:42 -0500 Subject: [PATCH 06/48] =?UTF-8?q?[WIP]=20[FEAT]=20init=5Fdynamic=5Funwarp?= =?UTF-8?q?=5Fwf=20=E2=80=94=20per-volume=20MEDIC=20apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New sdcflows/workflows/apply/dynamic.py: init_dynamic_unwarp_wf takes a 4D EPI series, BIDS metadata, and a 4D Hz fieldmap (e.g., init_medic_wf's fmap_dynamic), and applies a different warp per timepoint via warpkit's ConvertFieldmap (Hz -> mm displacement) + ApplyWarp. Outputs the corrected 4D EPI, a brain-extracted reference + mask, and the displacement series used. - Assumes the canonical MEDIC case: fieldmap is on the EPI grid and frame-aligned with the target. Skips the static apply path's B-spline evaluation, fmap-to-data registration, and HMC bundling — those are out of scope for the simple per-volume case and can be added when a use case demands them. - Module-load pure: warpkit is only resolved when ApplyWarp / ConvertFieldmap actually run. - Tests: workflow construction smoke + _pe_axis sign-stripping unit tests always run; slow integration test wires init_medic_wf -> init_dynamic_unwarp_wf, guarded by importorskip('warpkit') and a multi-echo fixture skip. --- sdcflows/workflows/apply/dynamic.py | 176 ++++++++++++++++++ .../workflows/apply/tests/test_dynamic.py | 99 ++++++++++ 2 files changed, 275 insertions(+) create mode 100644 sdcflows/workflows/apply/dynamic.py create mode 100644 sdcflows/workflows/apply/tests/test_dynamic.py diff --git a/sdcflows/workflows/apply/dynamic.py b/sdcflows/workflows/apply/dynamic.py new file mode 100644 index 0000000000..ea1187048b --- /dev/null +++ b/sdcflows/workflows/apply/dynamic.py @@ -0,0 +1,176 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2025 The NiPreps Developers +# +# 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/ +# +"""Per-volume distortion correction using a dynamic (4D) fieldmap. + +Counterpart to :func:`~sdcflows.workflows.apply.correction.init_unwarp_wf`, +specialized for fieldmaps that vary across time (typically MEDIC). Where +the static apply path interpolates a single B-spline-encoded field onto +the EPI grid and applies the same warp to every volume, this workflow +takes a 4D Hz fieldmap *already on the EPI grid* and applies a different +warp to each timepoint. + +Backed by ``warpkit``, an optional dependency. The workflow is +module-load pure: warpkit is only resolved when the underlying interfaces +actually run. +""" + +from nipype.interfaces import utility as niu +from nipype.pipeline import engine as pe +from niworkflows.engine.workflows import LiterateWorkflow as Workflow + +INPUT_FIELDS = ('distorted', 'metadata', 'fmap_dynamic') + + +def init_dynamic_unwarp_wf( + *, + omp_nthreads=1, + debug=False, + name='dynamic_unwarp_wf', +): + r""" + Apply a per-volume MEDIC fieldmap to unwarp a 4D EPI series. + + Workflow Graph + .. workflow :: + :graph2use: orig + :simple_form: yes + + from sdcflows.workflows.apply.dynamic import init_dynamic_unwarp_wf + wf = init_dynamic_unwarp_wf() + + Parameters + ---------- + omp_nthreads : :obj:`int` + Maximum number of threads warpkit may use. + debug : :obj:`bool` + Reserved. + name : :obj:`str` + Workflow name. + + Inputs + ------ + distorted : :obj:`str` + 4D EPI series to unwarp. Must share frame count with ``fmap_dynamic``. + metadata : :obj:`dict` + BIDS sidecar metadata. ``TotalReadoutTime`` and + ``PhaseEncodingDirection`` are required. + fmap_dynamic : :obj:`str` + 4D B\ :sub:`0` field map in Hz, already on the EPI grid (typically + from :func:`~sdcflows.workflows.fit.medic.init_medic_wf`). + + Outputs + ------- + corrected : :obj:`str` + 4D unwarped EPI. + corrected_ref : :obj:`str` + 3D temporal-mean reference of the corrected series, brain-extracted. + corrected_mask : :obj:`str` + Binary brain mask co-registered with ``corrected_ref``. + fieldwarp : :obj:`str` + 4D displacement map (mm along PE axis) used for the resampling. + + Notes + ----- + Unlike :func:`~sdcflows.workflows.apply.correction.init_unwarp_wf`, + this workflow does **not** consume B-spline coefficients, head-motion + transforms, or fmap-to-data transforms. It assumes the fieldmap is + already in the EPI's space and frame-aligned with the target — the + canonical MEDIC case where the fieldmap and the EPI are computed from + the same multi-echo series. + + A future enhancement could add Jacobian-determinant intensity + correction; ``warpkit`` exposes :class:`~sdcflows.interfaces.warpkit.ComputeJacobian` + for that, but it is not wired here yet. + """ + # Project-internal imports only; warpkit stays unloaded until interfaces run. + from niworkflows.interfaces.images import RobustAverage + + from ...interfaces.epi import GetReadoutTime + from ...interfaces.warpkit import ApplyWarp, ConvertFieldmap + from ..ancillary import init_brainextraction_wf + + workflow = Workflow(name=name) + + inputnode = pe.Node(niu.IdentityInterface(fields=INPUT_FIELDS), name='inputnode') + outputnode = pe.Node( + niu.IdentityInterface( + fields=['corrected', 'corrected_ref', 'corrected_mask', 'fieldwarp'], + ), + name='outputnode', + ) + + rotime = pe.Node(GetReadoutTime(), name='rotime', run_without_submitting=True) + + # Strip the optional trailing '-' from PE direction since the warpkit + # interfaces want the axis only ('i'/'j'/'k'). + pe_axis = pe.Node( + niu.Function(input_names=['pe_direction'], output_names=['axis'], function=_pe_axis), + name='pe_axis', + run_without_submitting=True, + ) + + # Hz fieldmap → 1-channel mm displacement map along the PE axis. + convert_fmap = pe.Node( + ConvertFieldmap(from_type='fieldmap', to_type='map'), + name='convert_fmap', + n_procs=omp_nthreads, + ) + + # Per-frame resampling. Frame count of `transform` must match `distorted`. + apply_warp = pe.Node( + ApplyWarp(transform_type='map'), + name='apply_warp', + n_procs=omp_nthreads, + ) + + average = pe.Node(RobustAverage(mc_method=None), name='average') + brainextraction_wf = init_brainextraction_wf() + + # fmt: off + workflow.connect([ + (inputnode, rotime, [('distorted', 'in_file'), + ('metadata', 'metadata')]), + (rotime, pe_axis, [('pe_direction', 'pe_direction')]), + (pe_axis, convert_fmap, [('axis', 'phase_encoding_direction')]), + (rotime, convert_fmap, [('readout_time', 'total_readout_time')]), + (inputnode, convert_fmap, [('fmap_dynamic', 'in_file')]), + (pe_axis, apply_warp, [('axis', 'phase_encoding_axis')]), + (convert_fmap, apply_warp, [('out_file', 'transform')]), + (inputnode, apply_warp, [('distorted', 'in_file')]), + (apply_warp, average, [('out_file', 'in_file')]), + (average, brainextraction_wf, [('out_file', 'inputnode.in_file')]), + (apply_warp, outputnode, [('out_file', 'corrected')]), + (convert_fmap, outputnode, [('out_file', 'fieldwarp')]), + (brainextraction_wf, outputnode, [ + ('outputnode.out_file', 'corrected_ref'), + ('outputnode.out_mask', 'corrected_mask'), + ]), + ]) + # fmt: on + + return workflow + + +def _pe_axis(pe_direction): + """Strip optional trailing '-' from a BIDS PE direction string.""" + return pe_direction.rstrip('-') diff --git a/sdcflows/workflows/apply/tests/test_dynamic.py b/sdcflows/workflows/apply/tests/test_dynamic.py new file mode 100644 index 0000000000..97a11744dc --- /dev/null +++ b/sdcflows/workflows/apply/tests/test_dynamic.py @@ -0,0 +1,99 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2025 The NiPreps Developers +# +# 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 per-volume MEDIC apply workflow.""" + +from json import loads +from pathlib import Path + +import pytest + +from ..dynamic import INPUT_FIELDS, _pe_axis, init_dynamic_unwarp_wf + + +def test_dynamic_unwarp_construct(): + """Build the workflow without warpkit installed and verify shape.""" + wf = init_dynamic_unwarp_wf() + assert wf.name == 'dynamic_unwarp_wf' + + inputnode = wf.get_node('inputnode') + outputnode = wf.get_node('outputnode') + assert inputnode is not None and outputnode is not None + assert set(inputnode.outputs.copyable_trait_names()) >= set(INPUT_FIELDS) + assert set(outputnode.inputs.copyable_trait_names()) >= { + 'corrected', + 'corrected_ref', + 'corrected_mask', + 'fieldwarp', + } + + for node_name in ('rotime', 'pe_axis', 'convert_fmap', 'apply_warp', 'average'): + assert wf.get_node(node_name) is not None, f'missing node {node_name!r}' + + +@pytest.mark.parametrize( + 'pe_direction,expected', + [('i', 'i'), ('i-', 'i'), ('j', 'j'), ('j-', 'j'), ('k', 'k'), ('k-', 'k')], +) +def test_pe_axis_strips_sign(pe_direction, expected): + assert _pe_axis(pe_direction) == expected + + +@pytest.mark.slow +def test_dynamic_unwarp_run(tmpdir, datadir, workdir): + """End-to-end run: estimate via MEDIC then apply via this workflow. + + Skipped without ``warpkit`` or without the multi-echo fixture. + """ + pytest.importorskip('warpkit') + + from sdcflows.workflows.fit.medic import init_medic_wf + + pattern = 'ds005250/sub-04/ses-2/func/*_part-mag_bold.nii.gz' + magnitude_files = sorted(Path(datadir).glob(pattern)) + if not magnitude_files: + pytest.skip(f'no MEDIC fixtures found under {datadir}/ds005250') + + phase_files = [f.with_name(f.name.replace('part-mag', 'part-phase')) for f in magnitude_files] + metadata = [ + loads(f.with_name(f.name.replace('.nii.gz', '.json')).read_text()) for f in phase_files + ] + + tmpdir.chdir() + fit_wf = init_medic_wf(omp_nthreads=2, debug=True) + fit_wf.inputs.inputnode.magnitude = [str(f) for f in magnitude_files] + fit_wf.inputs.inputnode.phase = [str(f) for f in phase_files] + fit_wf.inputs.inputnode.metadata = metadata + + apply_wf = init_dynamic_unwarp_wf(omp_nthreads=2) + # Use the first-echo magnitude as the distorted target. + apply_wf.inputs.inputnode.distorted = str(magnitude_files[0]) + apply_wf.inputs.inputnode.metadata = metadata[0] + + from niworkflows.engine.workflows import LiterateWorkflow as Workflow + + wf = Workflow(name=f'medic_apply_{magnitude_files[0].stem.replace(".nii", "")}') + wf.connect([(fit_wf, apply_wf, [('outputnode.fmap_dynamic', 'inputnode.fmap_dynamic')])]) + + if workdir: + wf.base_dir = str(workdir) + wf.run(plugin='Linear') From be00ad0189debe74d0da7b70a0ad1330eb434cc6 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 4 May 2026 21:27:38 -0500 Subject: [PATCH 07/48] [FIX] init_dynamic_unwarp_wf: forward PE direction sign to ConvertFieldmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hz->mm conversion direction depends on whether the BIDS PE direction is positive ('j') or negative ('j-'). warpkit's convert_fieldmap takes the unsigned axis via phase_encoding_direction and the sign separately via flip_sign; previously the workflow stripped the sign and dropped it, which produced un-flipped displacements for any negative-PE acquisition (roughly half of real-world EPI). _pe_axis now returns (axis, flip_sign); the pe_axis Function node forwards both, and convert_fmap is wired to receive flip_sign. ApplyWarp doesn't need the sign because the resulting displacement map already encodes direction in its data values. Also drops the docstring Notes block claiming HMC / fmap-to-data transforms are "scoped out" — they're inapplicable by construction since the MEDIC fieldmap is derived from the EPI itself. --- sdcflows/workflows/apply/dynamic.py | 38 +++++++++---------- .../workflows/apply/tests/test_dynamic.py | 11 +++++- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/sdcflows/workflows/apply/dynamic.py b/sdcflows/workflows/apply/dynamic.py index ea1187048b..c3adb8a144 100644 --- a/sdcflows/workflows/apply/dynamic.py +++ b/sdcflows/workflows/apply/dynamic.py @@ -88,19 +88,6 @@ def init_dynamic_unwarp_wf( Binary brain mask co-registered with ``corrected_ref``. fieldwarp : :obj:`str` 4D displacement map (mm along PE axis) used for the resampling. - - Notes - ----- - Unlike :func:`~sdcflows.workflows.apply.correction.init_unwarp_wf`, - this workflow does **not** consume B-spline coefficients, head-motion - transforms, or fmap-to-data transforms. It assumes the fieldmap is - already in the EPI's space and frame-aligned with the target — the - canonical MEDIC case where the fieldmap and the EPI are computed from - the same multi-echo series. - - A future enhancement could add Jacobian-determinant intensity - correction; ``warpkit`` exposes :class:`~sdcflows.interfaces.warpkit.ComputeJacobian` - for that, but it is not wired here yet. """ # Project-internal imports only; warpkit stays unloaded until interfaces run. from niworkflows.interfaces.images import RobustAverage @@ -121,10 +108,17 @@ def init_dynamic_unwarp_wf( rotime = pe.Node(GetReadoutTime(), name='rotime', run_without_submitting=True) - # Strip the optional trailing '-' from PE direction since the warpkit - # interfaces want the axis only ('i'/'j'/'k'). + # Split the BIDS PE direction (e.g. 'j-') into the unsigned axis the + # warpkit interfaces accept ('j') and a flip_sign boolean. The sign + # only matters for the Hz->mm conversion in convert_fmap; ApplyWarp + # doesn't need it because the resulting displacement map already + # encodes direction in its data values. pe_axis = pe.Node( - niu.Function(input_names=['pe_direction'], output_names=['axis'], function=_pe_axis), + niu.Function( + input_names=['pe_direction'], + output_names=['axis', 'flip_sign'], + function=_pe_axis, + ), name='pe_axis', run_without_submitting=True, ) @@ -151,7 +145,8 @@ def init_dynamic_unwarp_wf( (inputnode, rotime, [('distorted', 'in_file'), ('metadata', 'metadata')]), (rotime, pe_axis, [('pe_direction', 'pe_direction')]), - (pe_axis, convert_fmap, [('axis', 'phase_encoding_direction')]), + (pe_axis, convert_fmap, [('axis', 'phase_encoding_direction'), + ('flip_sign', 'flip_sign')]), (rotime, convert_fmap, [('readout_time', 'total_readout_time')]), (inputnode, convert_fmap, [('fmap_dynamic', 'in_file')]), (pe_axis, apply_warp, [('axis', 'phase_encoding_axis')]), @@ -172,5 +167,10 @@ def init_dynamic_unwarp_wf( def _pe_axis(pe_direction): - """Strip optional trailing '-' from a BIDS PE direction string.""" - return pe_direction.rstrip('-') + """Split a BIDS PE direction into (unsigned axis, flip_sign). + + warpkit's APIs take the axis index alone — the sign of the encoding + is conveyed separately via the ``flip_sign`` boolean on + :func:`warpkit.api.convert_fieldmap`. + """ + return pe_direction.rstrip('-'), pe_direction.endswith('-') diff --git a/sdcflows/workflows/apply/tests/test_dynamic.py b/sdcflows/workflows/apply/tests/test_dynamic.py index 97a11744dc..4f5b84e02f 100644 --- a/sdcflows/workflows/apply/tests/test_dynamic.py +++ b/sdcflows/workflows/apply/tests/test_dynamic.py @@ -52,9 +52,16 @@ def test_dynamic_unwarp_construct(): @pytest.mark.parametrize( 'pe_direction,expected', - [('i', 'i'), ('i-', 'i'), ('j', 'j'), ('j-', 'j'), ('k', 'k'), ('k-', 'k')], + [ + ('i', ('i', False)), + ('i-', ('i', True)), + ('j', ('j', False)), + ('j-', ('j', True)), + ('k', ('k', False)), + ('k-', ('k', True)), + ], ) -def test_pe_axis_strips_sign(pe_direction, expected): +def test_pe_axis_splits_axis_and_sign(pe_direction, expected): assert _pe_axis(pe_direction) == expected From d4e6c07c440201cca52be350c2e3b06b7da5ff34 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 4 May 2026 21:30:52 -0500 Subject: [PATCH 08/48] [FIX] Forward signed PE direction directly instead of using flip_sign Supersedes the flip_sign approach in 701332e9. The previous fix mis-attributed the Hz->mm sign handling to the flip_sign parameter, but flip_sign is actually only consulted on the inverse (mm->Hz) path of warpkit's convert_fieldmap. The Hz->mm path lives in field_maps_to_displacement_maps, which peeks at the trailing '-' of the phase_encoding_direction string itself to set the displacement sign. Real fix: - Loosen _ConvertFieldmapInputSpec.phase_encoding_direction from PE_AXES to PE_DIRECTIONS so signed BIDS forms ('j-') pass through the trait validator and reach warpkit unchanged. - Loosen _ApplyWarpInputSpec.phase_encoding_axis to PE_DIRECTIONS as well, mirroring warpkit's AXIS_MAP which accepts both signed and unsigned forms (sign is harmless there since the displacement-map values already encode direction). - Drop the _pe_axis Function node and helper from the workflow: rotime.pe_direction now feeds convert_fmap.phase_encoding_direction and apply_warp.phase_encoding_axis directly. - Drop the corresponding parametrized test for _pe_axis and the pe_axis node assertion in the construct test. --- sdcflows/interfaces/warpkit.py | 12 +++++-- sdcflows/workflows/apply/dynamic.py | 33 ++----------------- .../workflows/apply/tests/test_dynamic.py | 19 ++--------- 3 files changed, 15 insertions(+), 49 deletions(-) diff --git a/sdcflows/interfaces/warpkit.py b/sdcflows/interfaces/warpkit.py index 605ba97976..6a77dee291 100644 --- a/sdcflows/interfaces/warpkit.py +++ b/sdcflows/interfaces/warpkit.py @@ -286,7 +286,10 @@ class _ApplyWarpInputSpec(BaseInterfaceInputSpec): out_file = traits.Str(desc='output path; defaults to /applied.nii.gz') transform_type = traits.Enum('map', 'field', mandatory=True) reference = File(exists=True) - phase_encoding_axis = traits.Enum(*PE_AXES) + # warpkit folds signed forms ('j-') to the same axis index as their + # unsigned counterparts here — the sign in the displacement-map values + # is what carries direction. Accept both to spare callers a strip step. + phase_encoding_axis = traits.Enum(*PE_DIRECTIONS) format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') @@ -402,9 +405,14 @@ class _ConvertFieldmapInputSpec(BaseInterfaceInputSpec): from_type = traits.Enum('map', 'field', 'fieldmap', mandatory=True) to_type = traits.Enum('map', 'field', 'fieldmap', mandatory=True) total_readout_time = traits.Float(mandatory=True) - phase_encoding_direction = traits.Enum(*PE_AXES, mandatory=True) + # Accept signed PE direction strings ('j-' etc.); warpkit's + # field_maps_to_displacement_maps peeks at the trailing '-' to set the + # voxel-size sign, so the sign IS meaningful on the Hz->mm path. + phase_encoding_direction = traits.Enum(*PE_DIRECTIONS, mandatory=True) from_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') to_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') + # flip_sign is only used on the inverse (mm->Hz) path; it is ignored + # when ``to_type='map'``/``'field'``. flip_sign = traits.Bool(False, usedefault=True) frame = traits.Int() diff --git a/sdcflows/workflows/apply/dynamic.py b/sdcflows/workflows/apply/dynamic.py index c3adb8a144..bf19697f1c 100644 --- a/sdcflows/workflows/apply/dynamic.py +++ b/sdcflows/workflows/apply/dynamic.py @@ -108,21 +108,6 @@ def init_dynamic_unwarp_wf( rotime = pe.Node(GetReadoutTime(), name='rotime', run_without_submitting=True) - # Split the BIDS PE direction (e.g. 'j-') into the unsigned axis the - # warpkit interfaces accept ('j') and a flip_sign boolean. The sign - # only matters for the Hz->mm conversion in convert_fmap; ApplyWarp - # doesn't need it because the resulting displacement map already - # encodes direction in its data values. - pe_axis = pe.Node( - niu.Function( - input_names=['pe_direction'], - output_names=['axis', 'flip_sign'], - function=_pe_axis, - ), - name='pe_axis', - run_without_submitting=True, - ) - # Hz fieldmap → 1-channel mm displacement map along the PE axis. convert_fmap = pe.Node( ConvertFieldmap(from_type='fieldmap', to_type='map'), @@ -144,12 +129,10 @@ def init_dynamic_unwarp_wf( workflow.connect([ (inputnode, rotime, [('distorted', 'in_file'), ('metadata', 'metadata')]), - (rotime, pe_axis, [('pe_direction', 'pe_direction')]), - (pe_axis, convert_fmap, [('axis', 'phase_encoding_direction'), - ('flip_sign', 'flip_sign')]), - (rotime, convert_fmap, [('readout_time', 'total_readout_time')]), + (rotime, convert_fmap, [('pe_direction', 'phase_encoding_direction'), + ('readout_time', 'total_readout_time')]), (inputnode, convert_fmap, [('fmap_dynamic', 'in_file')]), - (pe_axis, apply_warp, [('axis', 'phase_encoding_axis')]), + (rotime, apply_warp, [('pe_direction', 'phase_encoding_axis')]), (convert_fmap, apply_warp, [('out_file', 'transform')]), (inputnode, apply_warp, [('distorted', 'in_file')]), (apply_warp, average, [('out_file', 'in_file')]), @@ -164,13 +147,3 @@ def init_dynamic_unwarp_wf( # fmt: on return workflow - - -def _pe_axis(pe_direction): - """Split a BIDS PE direction into (unsigned axis, flip_sign). - - warpkit's APIs take the axis index alone — the sign of the encoding - is conveyed separately via the ``flip_sign`` boolean on - :func:`warpkit.api.convert_fieldmap`. - """ - return pe_direction.rstrip('-'), pe_direction.endswith('-') diff --git a/sdcflows/workflows/apply/tests/test_dynamic.py b/sdcflows/workflows/apply/tests/test_dynamic.py index 4f5b84e02f..e70026b968 100644 --- a/sdcflows/workflows/apply/tests/test_dynamic.py +++ b/sdcflows/workflows/apply/tests/test_dynamic.py @@ -27,7 +27,7 @@ import pytest -from ..dynamic import INPUT_FIELDS, _pe_axis, init_dynamic_unwarp_wf +from ..dynamic import INPUT_FIELDS, init_dynamic_unwarp_wf def test_dynamic_unwarp_construct(): @@ -46,25 +46,10 @@ def test_dynamic_unwarp_construct(): 'fieldwarp', } - for node_name in ('rotime', 'pe_axis', 'convert_fmap', 'apply_warp', 'average'): + for node_name in ('rotime', 'convert_fmap', 'apply_warp', 'average'): assert wf.get_node(node_name) is not None, f'missing node {node_name!r}' -@pytest.mark.parametrize( - 'pe_direction,expected', - [ - ('i', ('i', False)), - ('i-', ('i', True)), - ('j', ('j', False)), - ('j-', ('j', True)), - ('k', ('k', False)), - ('k-', ('k', True)), - ], -) -def test_pe_axis_splits_axis_and_sign(pe_direction, expected): - assert _pe_axis(pe_direction) == expected - - @pytest.mark.slow def test_dynamic_unwarp_run(tmpdir, datadir, workdir): """End-to-end run: estimate via MEDIC then apply via this workflow. From e6efe5ba0a619c933526526e398c5e8b2616aaa0 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sat, 9 May 2026 22:31:19 -0400 Subject: [PATCH 09/48] [CI] Wire up MEDIC end-to-end tests on the veryslow job Re-mark the warpkit-dependent MEDIC fixtures as veryslow so they land on the py3.13 runner (warpkit requires Python 3.11+), add the warpkit extra to the veryslow tox factor, and stage ds006926 sub-a01 (VisMot tr1800) via datalad in CI. Cache key bumped to refresh the dataset cache. --- .github/workflows/build-test-publish.yml | 7 +++- .../workflows/apply/tests/test_dynamic.py | 32 ++++++++++---- sdcflows/workflows/fit/tests/test_medic.py | 42 +++++++++++++++---- tox.ini | 4 +- 4 files changed, 67 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 5c7adfdde0..6e99fed7eb 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -161,7 +161,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 @@ -206,6 +206,11 @@ 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_* + - name: Set FreeSurfer variables run: | echo "FREESURFER_HOME=$HOME/.cache/freesurfer" >> $GITHUB_ENV diff --git a/sdcflows/workflows/apply/tests/test_dynamic.py b/sdcflows/workflows/apply/tests/test_dynamic.py index e70026b968..a2b57c4dc6 100644 --- a/sdcflows/workflows/apply/tests/test_dynamic.py +++ b/sdcflows/workflows/apply/tests/test_dynamic.py @@ -50,20 +50,38 @@ def test_dynamic_unwarp_construct(): assert wf.get_node(node_name) is not None, f'missing node {node_name!r}' -@pytest.mark.slow -def test_dynamic_unwarp_run(tmpdir, datadir, workdir): +# Mirror of ``MEDIC_FIXTURES`` in ``test_medic.py``. Kept duplicated rather than +# imported to avoid cross-test-module coupling; update both lists together. +MEDIC_FIXTURES = [ + pytest.param( + 'ds005250', + 'sub-04/ses-2/func/*_part-mag_bold.nii.gz', + id='ds005250', + ), + pytest.param( + 'ds006926', + 'sub-a01/func/sub-a01_task-VisMot_acq-tr1800_echo-*_part-mag_bold.nii.gz', + id='ds006926', + ), +] + + +@pytest.mark.veryslow +@pytest.mark.parametrize(('dataset', 'pattern'), MEDIC_FIXTURES) +def test_dynamic_unwarp_run(tmpdir, datadir, workdir, dataset, pattern): """End-to-end run: estimate via MEDIC then apply via this workflow. - Skipped without ``warpkit`` or without the multi-echo fixture. + Skipped without ``warpkit`` or without the multi-echo fixture under + ``$TEST_DATA_HOME``. See ``test_medic_run`` for fetch instructions. """ pytest.importorskip('warpkit') from sdcflows.workflows.fit.medic import init_medic_wf - pattern = 'ds005250/sub-04/ses-2/func/*_part-mag_bold.nii.gz' - magnitude_files = sorted(Path(datadir).glob(pattern)) + full_pattern = f'{dataset}/{pattern}' + magnitude_files = sorted(Path(datadir).glob(full_pattern)) if not magnitude_files: - pytest.skip(f'no MEDIC fixtures found under {datadir}/ds005250') + pytest.skip(f'no MEDIC fixtures found under {datadir}/{dataset}') phase_files = [f.with_name(f.name.replace('part-mag', 'part-phase')) for f in magnitude_files] metadata = [ @@ -71,7 +89,7 @@ def test_dynamic_unwarp_run(tmpdir, datadir, workdir): ] tmpdir.chdir() - fit_wf = init_medic_wf(omp_nthreads=2, debug=True) + fit_wf = init_medic_wf(omp_nthreads=2) fit_wf.inputs.inputnode.magnitude = [str(f) for f in magnitude_files] fit_wf.inputs.inputnode.phase = [str(f) for f in phase_files] fit_wf.inputs.inputnode.metadata = metadata diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py index 1bd0432140..1fa32c4879 100644 --- a/sdcflows/workflows/fit/tests/test_medic.py +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -89,21 +89,45 @@ def test_unpack_metadata_rejects_mixed_pe(): _unpack_metadata(metadata) -@pytest.mark.slow -def test_medic_run(tmpdir, datadir, workdir, outdir): +# Each entry is (dataset, mag_glob_under_dataset). +# Add new fixtures here — the test self-skips when a dataset isn't on disk. +MEDIC_FIXTURES = [ + pytest.param( + 'ds005250', + 'sub-04/ses-2/func/*_part-mag_bold.nii.gz', + id='ds005250', + ), + pytest.param( + 'ds006926', + 'sub-a01/func/sub-a01_task-VisMot_acq-tr1800_echo-*_part-mag_bold.nii.gz', + id='ds006926', + ), +] + + +@pytest.mark.veryslow +@pytest.mark.parametrize(('dataset', 'pattern'), MEDIC_FIXTURES) +def test_medic_run(tmpdir, datadir, workdir, outdir, dataset, pattern): """End-to-end MEDIC run on a real multi-echo BOLD. Skipped if ``warpkit`` is unavailable (default CI install) or if the - expected ``ds005250`` multi-echo dataset is not present in the test data - fixture directory. Use ``TEST_DATA_HOME`` and install - ``sdcflows[warpkit]`` locally to opt in. + expected dataset is not present under ``$TEST_DATA_HOME``. To opt in, + install ``sdcflows[warpkit]`` and stage the dataset, e.g. + + .. code-block:: console + + # ds006926: OpenNeuro multi-echo mag+phase BOLD (publicly available) + cd $TEST_DATA_HOME + datalad install https://github.com/OpenNeuroDatasets/ds006926.git + datalad get -d ds006926 sub-a01/func/sub-a01_task-VisMot_acq-tr1800_* + """ pytest.importorskip('warpkit') - pattern = 'ds005250/sub-04/ses-2/func/*_part-mag_bold.nii.gz' - magnitude_files = sorted(Path(datadir).glob(pattern)) + full_pattern = f'{dataset}/{pattern}' + magnitude_files = sorted(Path(datadir).glob(full_pattern)) if not magnitude_files: - pytest.skip(f'no MEDIC fixtures found under {datadir}/ds005250') + pytest.skip(f'no MEDIC fixtures found under {datadir}/{dataset}') phase_files = [f.with_name(f.name.replace('part-mag', 'part-phase')) for f in magnitude_files] metadata = [ @@ -111,7 +135,7 @@ def test_medic_run(tmpdir, datadir, workdir, outdir): ] tmpdir.chdir() - medic_wf = init_medic_wf(omp_nthreads=2, debug=True) + medic_wf = init_medic_wf(omp_nthreads=2) medic_wf.inputs.inputnode.magnitude = [str(f) for f in magnitude_files] medic_wf.inputs.inputnode.phase = [str(f) for f in phase_files] medic_wf.inputs.inputnode.metadata = metadata diff --git a/tox.ini b/tox.ini index 1cd1963132..45f62dc11f 100644 --- a/tox.ini +++ b/tox.ini @@ -71,7 +71,9 @@ pass_env = CLICOLOR CLICOLOR_FORCE PYTHON_GIL -extras = tests +extras = + tests + veryslow: warpkit setenv = pre: PIP_EXTRA_INDEX_URL=https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pre: UV_INDEX=https://pypi.anaconda.org/scientific-python-nightly-wheels/simple From 09ea041a7f60b29154bafee93d20129b4979f675 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sat, 9 May 2026 23:15:43 -0400 Subject: [PATCH 10/48] [CI] Swap MEDIC fixture from deleted ds005250 to ds007637 ds005250 was deleted from OpenNeuro as a duplicate dataset; ds007637 is its successor (same sub-04/ses-2 task-fracback acq-MBME 5-echo BOLD). Narrow the glob to the fracback task only since ds007637's session folder also contains task-rest acquisitions. --- .github/workflows/build-test-publish.yml | 5 +++++ sdcflows/workflows/apply/tests/test_dynamic.py | 6 +++--- sdcflows/workflows/fit/tests/test_medic.py | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 6e99fed7eb..1df931a099 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -211,6 +211,11 @@ jobs: 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 diff --git a/sdcflows/workflows/apply/tests/test_dynamic.py b/sdcflows/workflows/apply/tests/test_dynamic.py index a2b57c4dc6..32e597ee59 100644 --- a/sdcflows/workflows/apply/tests/test_dynamic.py +++ b/sdcflows/workflows/apply/tests/test_dynamic.py @@ -54,9 +54,9 @@ def test_dynamic_unwarp_construct(): # imported to avoid cross-test-module coupling; update both lists together. MEDIC_FIXTURES = [ pytest.param( - 'ds005250', - 'sub-04/ses-2/func/*_part-mag_bold.nii.gz', - id='ds005250', + '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', diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py index 1fa32c4879..415caf379f 100644 --- a/sdcflows/workflows/fit/tests/test_medic.py +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -93,9 +93,9 @@ def test_unpack_metadata_rejects_mixed_pe(): # Add new fixtures here — the test self-skips when a dataset isn't on disk. MEDIC_FIXTURES = [ pytest.param( - 'ds005250', - 'sub-04/ses-2/func/*_part-mag_bold.nii.gz', - id='ds005250', + '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', From e3f1881b2784d0e79be126c42115b30624936bf0 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sun, 10 May 2026 00:26:05 -0400 Subject: [PATCH 11/48] =?UTF-8?q?[REFACTOR]=20init=5Fmedic=5Fwf=20?= =?UTF-8?q?=E2=80=94=20review=20cleanups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop **kwargs (silently swallows typos), fix sloppy/fmap docstrings, and replace the inlined magmrg + brainextraction_wf pair with init_magnitude_wf to share the canonical sdcflows pattern. Adjust the construction test to expect the magnitude_wf sub-workflow node. --- sdcflows/workflows/fit/medic.py | 35 +++++++++++----------- sdcflows/workflows/fit/tests/test_medic.py | 2 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index 7737bf8b96..98a1bc9dd3 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -48,7 +48,6 @@ def init_medic_wf( sloppy=False, debug=False, name='medic_wf', - **kwargs, ): """ Estimate a fieldmap via MEDIC from multi-echo magnitude + phase EPI. @@ -66,7 +65,8 @@ def init_medic_wf( omp_nthreads : :obj:`int` Maximum number of threads warpkit may use. sloppy : :obj:`bool` - Currently unused; reserved for future fast-path knobs. + Lower the B-spline ``zooms_min`` for a faster, lower-resolution + spline fit. Affects the static ``fmap`` only. debug : :obj:`bool` Pass through to :class:`~sdcflows.interfaces.warpkit.MEDIC`. name : :obj:`str` @@ -85,8 +85,10 @@ def init_medic_wf( Outputs ------- fmap : :obj:`str` - Static :math:`B_0` map in Hz (temporal mean of the dynamic series), - for compatibility with the rest of sdcflows. + Static :math:`B_0` map in Hz: a B-spline approximation of the + temporal mean of the dynamic series, on the EPI grid. Emitted + as a compatibility shim for the existing apply path — see + ``fmap_coeff`` below for context. fmap_dynamic : :obj:`str` 4D :math:`B_0` map in Hz, one volume per timepoint (undistorted space). The actual MEDIC output; consumers wanting per-volume @@ -121,11 +123,9 @@ def init_medic_wf( # import time. The warpkit dependency is resolved lazily inside # MEDIC._run_interface, so this workflow can be constructed (and the # module can be imported) without warpkit installed. - from niworkflows.interfaces.images import IntraModalMerge - from ...interfaces.bspline import DEFAULT_HF_ZOOMS_MM, BSplineApprox from ...interfaces.warpkit import ComputeFieldmap, UnwrapPhase - from ..ancillary import init_brainextraction_wf + from .fieldmap import init_magnitude_wf workflow = Workflow(name=name) workflow.__desc__ = _MEDIC_DESC @@ -168,6 +168,8 @@ def init_medic_wf( unwrap.inputs.n_cpus = omp_nthreads unwrap.inputs.debug = debug + # ComputeFieldmap doesn't expose a ``debug`` input — only UnwrapPhase + # does, so the asymmetry is intentional. compute_fmap = pe.Node(ComputeFieldmap(), name='compute_fmap', n_procs=omp_nthreads) compute_fmap.inputs.n_cpus = omp_nthreads @@ -185,8 +187,9 @@ def init_medic_wf( run_without_submitting=True, ) - # First-echo magnitude — used (a) as the dynamic reference passthrough - # and (b) temporally averaged for the static brain extraction. + # First-echo magnitude. The 4D file is the dynamic reference passthrough + # (``fmap_dynamic_ref``); ``init_magnitude_wf`` then time-averages it via + # IntraModalMerge before brain extraction for ``fmap_ref`` / ``fmap_mask``. pick_mag1 = pe.Node( niu.Function( input_names=['in_list'], @@ -196,8 +199,7 @@ def init_medic_wf( name='pick_mag1', run_without_submitting=True, ) - magmrg = pe.Node(IntraModalMerge(hmc=False, to_ras=False), name='magmrg') - brainextraction_wf = init_brainextraction_wf() + magnitude_wf = init_magnitude_wf(omp_nthreads=omp_nthreads) # B-spline fit on the static fieldmap. Compatibility shim for the # existing init_unwarp_wf, which only consumes B-spline coefficients @@ -230,13 +232,12 @@ def init_medic_wf( (unwrap, outputnode, [('masks', 'fmap_dynamic_mask')]), (inputnode, pick_mag1, [('magnitude', 'in_list')]), (pick_mag1, outputnode, [('out_file', 'fmap_dynamic_ref')]), - (pick_mag1, magmrg, [('out_file', 'in_files')]), - (magmrg, brainextraction_wf, [('out_avg', 'inputnode.in_file')]), - (brainextraction_wf, outputnode, [ - ('outputnode.out_file', 'fmap_ref'), - ('outputnode.out_mask', 'fmap_mask'), + (pick_mag1, magnitude_wf, [('out_file', 'inputnode.magnitude')]), + (magnitude_wf, outputnode, [ + ('outputnode.fmap_ref', 'fmap_ref'), + ('outputnode.fmap_mask', 'fmap_mask'), ]), - (brainextraction_wf, bs_filter, [('outputnode.out_mask', 'in_mask')]), + (magnitude_wf, bs_filter, [('outputnode.fmap_mask', 'in_mask')]), (fmap_mean, bs_filter, [('out_file', 'in_data')]), (bs_filter, outputnode, [ ('out_extrapolated' if not debug else 'out_field', 'fmap'), diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py index 415caf379f..965684ff83 100644 --- a/sdcflows/workflows/fit/tests/test_medic.py +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -63,7 +63,7 @@ def test_medic_construct(): 'compute_fmap', 'fmap_mean', 'pick_mag1', - 'magmrg', + 'magnitude_wf', 'bs_filter', ): assert wf.get_node(name) is not None, f'missing node {name!r}' From 3ec497a34c287555e534b6691d4707e9b0fc1c9f Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sun, 10 May 2026 00:26:06 -0400 Subject: [PATCH 12/48] =?UTF-8?q?[FEAT]=20init=5Fdynamic=5Funwarp=5Fwf=20?= =?UTF-8?q?=E2=80=94=20Jacobian=20intensity=20correction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same |J| ≈ 1 + ∂(VSM)/∂(PE) correction the static path uses, extending it to per-volume MEDIC unwarping. Extract the formula from transform._sdc_unwarp into a public transform.fieldmap_jacobian helper that works for 3D or 4D fmap_hz, refactor _sdc_unwarp to call it, and wire a Function node in dynamic.py that loads the resampled EPI plus 4D Hz fieldmap, computes signed readout time from PhaseEncodingDirection, and multiplies by |J| post-resampling. Also drop the unused debug parameter, clarify omp_nthreads as scheduler-only, and document the absence of a coreg step (warpkit produces fmap_dynamic on the EPI grid). Tests cover the helper end-to-end against a Hz ramp (both PE polarities) and confirm jacobian=False cleanly drops the node. --- sdcflows/transform.py | 38 ++++++-- sdcflows/workflows/apply/dynamic.py | 87 +++++++++++++++++-- .../workflows/apply/tests/test_dynamic.py | 59 ++++++++++++- 3 files changed, 171 insertions(+), 13 deletions(-) diff --git a/sdcflows/transform.py b/sdcflows/transform.py index d2611e8ba9..82b820898f 100644 --- a/sdcflows/transform.py +++ b/sdcflows/transform.py @@ -106,17 +106,43 @@ def _sdc_unwarp( prefilter=prefilter, ) - # The Jacobian determinant image is the amount of stretching in the PE direction. - # Using central differences accounts for the shift in neighboring voxels. - # The full Jacobian at each voxel would be a 3x3 matrix, but because there is - # only warping in one direction, we end up with a diagonal matrix with two 1s. - # The following is the other entry at each voxel, and hence the determinant. if jacobian: - resampled *= 1 + np.gradient(vsm, axis=pe_info[0]) + resampled *= fieldmap_jacobian(fmap_hz, pe_info[1], pe_info[0]) return resampled +def fieldmap_jacobian( + fmap_hz: np.ndarray, + ro_time: float, + pe_axis: int, +) -> np.ndarray: + r""" + Voxel-wise Jacobian determinant of a one-axis (PE) EPI distortion. + + EPI distortion only acts along the phase-encoding axis, so the full + 3×3 Jacobian collapses to a diagonal with two 1s and one nontrivial + entry: :math:`|J| \approx 1 + \partial(\mathrm{VSM})/\partial(\mathrm{PE})`, + where the voxel shift map is :math:`\mathrm{VSM} = f_{\mathrm{Hz}}\cdot t_{\mathrm{ro}}` + (central differences capture the relative shift of neighboring voxels). + Multiplying a resampled EPI by this scalar field preserves total signal + through the regions that compress and expand under unwarping. + + Parameters + ---------- + fmap_hz : :class:`numpy.ndarray` + :math:`B_0` field in Hz. 3D ``(I, J, K)`` for a static fieldmap, or + 4D ``(I, J, K, T)`` for a per-volume dynamic fieldmap (e.g. MEDIC). + ro_time : :obj:`float` + Signed total readout time (seconds). The sign carries PE polarity + and any data-orientation flip — callers must apply that sign before + passing it in. + pe_axis : :obj:`int` + Spatial axis index (``0``, ``1`` or ``2``) along which the EPI distorts. + """ + return 1 + np.gradient(fmap_hz * ro_time, axis=pe_axis) + + async def worker( data: np.ndarray, coordinates: np.ndarray, diff --git a/sdcflows/workflows/apply/dynamic.py b/sdcflows/workflows/apply/dynamic.py index bf19697f1c..266aa3840b 100644 --- a/sdcflows/workflows/apply/dynamic.py +++ b/sdcflows/workflows/apply/dynamic.py @@ -43,8 +43,8 @@ def init_dynamic_unwarp_wf( *, + jacobian=True, omp_nthreads=1, - debug=False, name='dynamic_unwarp_wf', ): r""" @@ -60,10 +60,16 @@ def init_dynamic_unwarp_wf( Parameters ---------- + jacobian : :obj:`bool` + If :obj:`True`, apply Jacobian determinant correction after + resampling, preserving total signal through compression/expansion + regions of the EPI distortion. Mirrors the + :func:`~sdcflows.workflows.apply.correction.init_unwarp_wf` default. omp_nthreads : :obj:`int` - Maximum number of threads warpkit may use. - debug : :obj:`bool` - Reserved. + Per-node ``n_procs`` hint for the Nipype scheduler. Note that + warpkit's ``apply_warp`` / ``convert_fieldmap`` C++ paths don't + accept a thread count, so this only affects scheduling, not + warpkit's internal parallelism. name : :obj:`str` Workflow name. @@ -108,6 +114,11 @@ def init_dynamic_unwarp_wf( rotime = pe.Node(GetReadoutTime(), name='rotime', run_without_submitting=True) + # No coregistration step: warpkit emits ``fmap_dynamic`` on the EPI grid + # by construction (the fieldmap is computed from the same multi-echo + # acquisition being corrected here), so the static path's + # ``fmap2data_xfm`` plumbing has no analog. + # Hz fieldmap → 1-channel mm displacement map along the PE axis. convert_fmap = pe.Node( ConvertFieldmap(from_type='fieldmap', to_type='map'), @@ -122,6 +133,20 @@ def init_dynamic_unwarp_wf( n_procs=omp_nthreads, ) + # Optional Jacobian-determinant intensity correction. Mirrors what + # ``init_unwarp_wf`` does via ``ApplyCoeffsField(jacobian=True)`` — + # we just call the same numpy formula (``transform.fieldmap_jacobian``) + # against the dynamic 4D fieldmap, post-resampling. + if jacobian: + jac_correct = pe.Node( + niu.Function( + input_names=['in_file', 'fmap_dynamic', 'pe_direction', 'readout_time'], + output_names=['out_file'], + function=_apply_jacobian, + ), + name='jac_correct', + ) + average = pe.Node(RobustAverage(mc_method=None), name='average') brainextraction_wf = init_brainextraction_wf() @@ -135,9 +160,7 @@ def init_dynamic_unwarp_wf( (rotime, apply_warp, [('pe_direction', 'phase_encoding_axis')]), (convert_fmap, apply_warp, [('out_file', 'transform')]), (inputnode, apply_warp, [('distorted', 'in_file')]), - (apply_warp, average, [('out_file', 'in_file')]), (average, brainextraction_wf, [('out_file', 'inputnode.in_file')]), - (apply_warp, outputnode, [('out_file', 'corrected')]), (convert_fmap, outputnode, [('out_file', 'fieldwarp')]), (brainextraction_wf, outputnode, [ ('outputnode.out_file', 'corrected_ref'), @@ -146,4 +169,56 @@ def init_dynamic_unwarp_wf( ]) # fmt: on + if jacobian: + # fmt: off + workflow.connect([ + (apply_warp, jac_correct, [('out_file', 'in_file')]), + (inputnode, jac_correct, [('fmap_dynamic', 'fmap_dynamic')]), + (rotime, jac_correct, [('pe_direction', 'pe_direction'), + ('readout_time', 'readout_time')]), + (jac_correct, average, [('out_file', 'in_file')]), + (jac_correct, outputnode, [('out_file', 'corrected')]), + ]) + # fmt: on + else: + # fmt: off + workflow.connect([ + (apply_warp, average, [('out_file', 'in_file')]), + (apply_warp, outputnode, [('out_file', 'corrected')]), + ]) + # fmt: on + return workflow + + +def _apply_jacobian(in_file, fmap_dynamic, pe_direction, readout_time): + """Multiply ``in_file`` by the per-frame Jacobian determinant of ``fmap_dynamic``. + + ``in_file`` and ``fmap_dynamic`` must share the spatial grid (the + dynamic apply path guarantees this — ``fmap_dynamic`` is on the EPI + grid by construction). The PE-axis sign convention matches warpkit's + ``ConvertFieldmap``: ``pe_direction`` ending in ``-`` flips the + readout time, the result is fed to + :func:`~sdcflows.transform.fieldmap_jacobian`. + """ + import os + + import nibabel as nb + import numpy as np + + from sdcflows.transform import fieldmap_jacobian + + pe_axis = 'ijk'.index(pe_direction[0]) + ro_signed = -float(readout_time) if pe_direction.endswith('-') else float(readout_time) + + img = nb.load(in_file) + fmap_img = nb.load(fmap_dynamic) + data = np.asanyarray(img.dataobj, dtype='float32') + fmap_hz = np.asanyarray(fmap_img.dataobj, dtype='float32') + + jac = fieldmap_jacobian(fmap_hz, ro_signed, pe_axis) + corrected = data * jac + + out_file = os.path.abspath('corrected_jac.nii.gz') + nb.Nifti1Image(corrected.astype('float32'), img.affine, img.header).to_filename(out_file) + return out_file diff --git a/sdcflows/workflows/apply/tests/test_dynamic.py b/sdcflows/workflows/apply/tests/test_dynamic.py index 32e597ee59..47c17e1b2a 100644 --- a/sdcflows/workflows/apply/tests/test_dynamic.py +++ b/sdcflows/workflows/apply/tests/test_dynamic.py @@ -46,10 +46,67 @@ def test_dynamic_unwarp_construct(): 'fieldwarp', } - for node_name in ('rotime', 'convert_fmap', 'apply_warp', 'average'): + for node_name in ('rotime', 'convert_fmap', 'apply_warp', 'jac_correct', 'average'): assert wf.get_node(node_name) is not None, f'missing node {node_name!r}' +def test_dynamic_unwarp_jacobian_disabled(): + """``jacobian=False`` drops the correction node; default is True.""" + wf = init_dynamic_unwarp_wf(jacobian=False) + assert wf.get_node('jac_correct') is None + assert wf.get_node('apply_warp') is not None + + +def test_apply_jacobian_preserves_signal(tmp_path, monkeypatch): + """End-to-end check on the helper: |J| = 1 + dVSM/dPE for a Hz ramp. + + A linear Hz ramp along PE produces a constant gradient, hence a + constant Jacobian. Multiplying a uniform image by it must give + that constant everywhere — a sanity check the formula and sign + plumbing match :func:`sdcflows.transform.fieldmap_jacobian`. + """ + import nibabel as nb + import numpy as np + + from sdcflows.transform import fieldmap_jacobian + from sdcflows.workflows.apply.dynamic import _apply_jacobian + + monkeypatch.chdir(tmp_path) + + shape = (4, 6, 4, 2) + affine = np.eye(4) + # Hz ramp along axis j (PE='j'); 1 Hz per voxel along j. + j_idx = np.arange(shape[1], dtype='float32') + fmap_hz = np.broadcast_to(j_idx[None, :, None, None], shape).astype('float32') + distorted = np.ones(shape, dtype='float32') + + distorted_path = tmp_path / 'distorted.nii.gz' + fmap_path = tmp_path / 'fmap_dynamic.nii.gz' + nb.Nifti1Image(distorted, affine).to_filename(distorted_path) + nb.Nifti1Image(fmap_hz, affine).to_filename(fmap_path) + + out = _apply_jacobian( + in_file=str(distorted_path), + fmap_dynamic=str(fmap_path), + pe_direction='j', + readout_time=0.5, + ) + out_data = nb.load(out).get_fdata() + # |J| = 1 + d(0.5*j_idx)/dj = 1 + 0.5 everywhere (interior). + expected = fieldmap_jacobian(fmap_hz, 0.5, pe_axis=1) + assert np.allclose(out_data, expected, atol=1e-5) + + # Sign flip with j-: |J| = 1 - 0.5 + out_neg = _apply_jacobian( + in_file=str(distorted_path), + fmap_dynamic=str(fmap_path), + pe_direction='j-', + readout_time=0.5, + ) + expected_neg = fieldmap_jacobian(fmap_hz, -0.5, pe_axis=1) + assert np.allclose(nb.load(out_neg).get_fdata(), expected_neg, atol=1e-5) + + # Mirror of ``MEDIC_FIXTURES`` in ``test_medic.py``. Kept duplicated rather than # imported to avoid cross-test-module coupling; update both lists together. MEDIC_FIXTURES = [ From 7645a68b4b469480307e4e5f72cf0cb4a7fe63c8 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sun, 10 May 2026 00:30:54 -0400 Subject: [PATCH 13/48] [REFACTOR] FieldmapEstimation MEDIC: file pre-flight + reject incomplete part sets Mirror the MAPPED/PHASEDIFF branch by validating each source path exists before instantiating init_medic_wf, so a missing file fails with a clear FileNotFoundError instead of inside warpkit. Also reject MEDIC-shaped inputs that are missing a part (phase-only or mag-only) or that mix part-tagged and untagged sources, instead of silently falling through to the PEPOLAR branch with a confusing failure mode. --- sdcflows/fieldmaps.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/sdcflows/fieldmaps.py b/sdcflows/fieldmaps.py index 46e70722b3..6943525897 100644 --- a/sdcflows/fieldmaps.py +++ b/sdcflows/fieldmaps.py @@ -345,7 +345,17 @@ def __attrs_post_init__(self): # 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} - if parts == {'phase', 'mag'} and suffix_set <= {'bold', 'epi', 'sbref'}: + 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: @@ -527,6 +537,13 @@ def get_workflow(self, set_inputs=True, **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: From 7c4469771aba426738daa9317dbf6291fc674835 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sun, 10 May 2026 00:30:54 -0400 Subject: [PATCH 14/48] [FIX] wrangler MEDIC: tighten dedup + cover mag-side IntendedFor Seed the MEDIC detection loop with both ``part='phase'`` and ``part='mag'`` queries (some datasets carry IntendedFor only on the magnitude side), and scan the full sibling set against already-claimed sources rather than just ``complex_imgs[0]`` so dedup doesn't depend on contractual pybids ordering. Same run seeded twice (once per part) collapses to a single estimator. --- sdcflows/utils/wrangler.py | 45 +++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/sdcflows/utils/wrangler.py b/sdcflows/utils/wrangler.py index 5a61e1f49e..a36fdb65ec 100644 --- a/sdcflows/utils/wrangler.py +++ b/sdcflows/utils/wrangler.py @@ -447,24 +447,29 @@ def find_estimators( estimators.append(e) # MEDIC: multi-echo BOLD with mag+phase parts and ``IntendedFor``. - # The query keys on ``part='phase'`` so we get one hit per (run, echo) - # phase BOLD; we then expand to all matching mag+phase siblings. - bold_phase_with_intent = () - with suppress(ValueError): - bold_phase_with_intent = layout.get( - **{ - **base_entities, - **{ - 'session': sessions, - 'suffix': 'bold', - 'part': 'phase', - 'echo': Query.REQUIRED, - 'IntendedFor': Query.REQUIRED, - }, - } - ) + # We query both parts as seeds — datasets vary on which side carries + # ``IntendedFor`` — and rely on the dedup check below to keep the + # estimator unique per (run, echo-set). Each seed is then expanded + # to all matching mag+phase echo siblings of its run. + medic_seeds = [] + for part in ('phase', 'mag'): + with suppress(ValueError): + medic_seeds.extend( + layout.get( + **{ + **base_entities, + **{ + 'session': sessions, + 'suffix': 'bold', + 'part': part, + 'echo': Query.REQUIRED, + 'IntendedFor': Query.REQUIRED, + }, + } + ) + ) - for bold_fmap in bold_phase_with_intent: + for bold_fmap in medic_seeds: # Pull every echo + part for this run. ``get_entities()`` already # includes extension; we override part/echo to widen the query. run_entities = { @@ -477,8 +482,12 @@ def find_estimators( if not complex_imgs: continue + # Dedup against every prior estimator's full source set, not just + # the first complex image — pybids ordering is not contractual, + # and the same run can be seeded twice (once via phase, once via + # mag) when both parts carry ``IntendedFor``. already_claimed = {str(s.path) for est in estimators for s in est.sources} - if str(complex_imgs[0].path) in already_claimed: + if any(str(c.path) in already_claimed for c in complex_imgs): logger.debug('Skipping MEDIC fmap %s (already in use)', complex_imgs[0].relpath) continue From 7c628493341421d932d9c2771f56376cdb804cdf Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sun, 10 May 2026 00:33:34 -0400 Subject: [PATCH 15/48] [REFACTOR] init_medic_wf: runtime guard for single-echo input FieldmapEstimation already rejects single-echo MEDIC inputs at construction, but callers that build init_medic_wf directly (tests, ad-hoc scripts) currently bypass that check and only see warpkit's internal failure. Add a fail-fast in _unpack_metadata so direct callers get the same clear error message. --- sdcflows/workflows/fit/medic.py | 6 ++++++ sdcflows/workflows/fit/tests/test_medic.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index 98a1bc9dd3..cbbefe36fd 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -253,6 +253,12 @@ def _unpack_metadata(metadata): """Pull echo times (s→ms), TRT, and PE direction from BIDS sidecars.""" if not metadata: raise ValueError('MEDIC requires per-echo metadata.') + if len(metadata) < 2: + raise ValueError( + f'MEDIC requires at least two echoes; got {len(metadata)}. ' + '(FieldmapEstimation enforces this for wrangler-built workflows; ' + 'this guard catches direct callers that bypass it.)' + ) echo_times = [float(m['EchoTime']) * 1000.0 for m in metadata] total_readout_time = float(metadata[0]['TotalReadoutTime']) phase_encoding_direction = metadata[0]['PhaseEncodingDirection'] diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py index 965684ff83..fd834d1615 100644 --- a/sdcflows/workflows/fit/tests/test_medic.py +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -80,6 +80,12 @@ def test_unpack_metadata_converts_te_to_ms(): assert ped == 'j' +def test_unpack_metadata_rejects_single_echo(): + metadata = [{'EchoTime': 0.0142, 'TotalReadoutTime': 0.5, 'PhaseEncodingDirection': 'j'}] + with pytest.raises(ValueError, match='at least two echoes'): + _unpack_metadata(metadata) + + def test_unpack_metadata_rejects_mixed_pe(): metadata = [ {'EchoTime': 0.0142, 'TotalReadoutTime': 0.5, 'PhaseEncodingDirection': 'j'}, From 4961880472a773c1dd02cb904cfafdf698d5aefe Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sun, 10 May 2026 23:10:03 -0400 Subject: [PATCH 16/48] DOC: use pandoc-style citation key in MEDIC __desc__ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[Montez2024]_` was RST footnote syntax with no matching definition anywhere in the sdcflows tree, so the rendered fmriprep CITATION report showed the literal token instead of a resolved citation. Switch to `[@van2023medic]` — pandoc-style, matching the precedent set by `init_syn_sdc_wf` (`[@fieldmapless1; @fieldmapless2]`). The bib entry lives downstream in fmriprep/data/boilerplate.bib alongside the other sdcflows-origin references. --- sdcflows/workflows/fit/medic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index cbbefe36fd..97e9064ab5 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -38,7 +38,7 @@ _MEDIC_DESC = """\ A dynamic *B0* nonuniformity map was estimated from multi-echo -magnitude and phase EPI series using MEDIC [Montez2024]_, as implemented in +magnitude and phase EPI series using MEDIC [@van2023medic], as implemented in ``warpkit``. """ From d0140eb28f5052d981ff068fe03f3eb8d44dd1f8 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 11 May 2026 14:52:48 -0400 Subject: [PATCH 17/48] [FIX] conftest: skip ds006926/ds007637 in layouts auto-index The MEDIC test datasets are full OpenNeuro trees (~8k and ~15k JSON sidecars). Even though CI only `datalad get`s a handful of files, `BIDSLayout(derivatives=True)` at conftest import time walks every sidecar, stalling pytest past the 20-minute tox watchdog. The MEDIC tests reach their files via the `datadir` fixture, not `layouts`, so excluding them is safe. --- sdcflows/conftest.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sdcflows/conftest.py b/sdcflows/conftest.py index cdacab6292..b0ff3fc950 100644 --- a/sdcflows/conftest.py +++ b/sdcflows/conftest.py @@ -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' From d071f61cab84a4c90a5a49b93412e7caeb2aa6b9 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 11 May 2026 15:34:36 -0400 Subject: [PATCH 18/48] [FIX] MEDIC veryslow tests: truncate BOLD to 3 volumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixtures ship 200+ volumes × 5 echoes × mag+phase (~3 GB per dataset). Running the parametrized end-to-end MEDIC runs under ``pytest -n auto`` peaked past the GitHub runner's RAM and the veryslow job was SIGTERM'd at the 4-minute mark, well before the 20-minute tox watchdog. Three timepoints still exercises the full per-volume unwrap + fmap path; locally the four tests now run in ~70s with peak RSS ~2.7 GB (single test), so four parallel xdist workers should comfortably fit under 16 GB. --- .../workflows/apply/tests/test_dynamic.py | 22 +++++++++++++++++ sdcflows/workflows/fit/tests/test_medic.py | 24 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/sdcflows/workflows/apply/tests/test_dynamic.py b/sdcflows/workflows/apply/tests/test_dynamic.py index 47c17e1b2a..bf7007a394 100644 --- a/sdcflows/workflows/apply/tests/test_dynamic.py +++ b/sdcflows/workflows/apply/tests/test_dynamic.py @@ -29,6 +29,23 @@ from ..dynamic import INPUT_FIELDS, init_dynamic_unwarp_wf +# See test_medic._MEDIC_TEST_VOLUMES — keep these in sync. +_MEDIC_TEST_VOLUMES = 3 + + +def _truncate_to_volumes(in_files, volumes, dest): + import nibabel as nb + + out = [] + for f in in_files: + img = nb.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 + def test_dynamic_unwarp_construct(): """Build the workflow without warpkit installed and verify shape.""" @@ -146,6 +163,11 @@ def test_dynamic_unwarp_run(tmpdir, datadir, workdir, dataset, pattern): ] tmpdir.chdir() + trunc_dir = Path(str(tmpdir)) / 'trunc' + trunc_dir.mkdir(exist_ok=True) + magnitude_files = _truncate_to_volumes(magnitude_files, _MEDIC_TEST_VOLUMES, trunc_dir) + phase_files = _truncate_to_volumes(phase_files, _MEDIC_TEST_VOLUMES, trunc_dir) + fit_wf = init_medic_wf(omp_nthreads=2) fit_wf.inputs.inputnode.magnitude = [str(f) for f in magnitude_files] fit_wf.inputs.inputnode.phase = [str(f) for f in phase_files] diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py index fd834d1615..a54d23feb3 100644 --- a/sdcflows/workflows/fit/tests/test_medic.py +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -29,6 +29,25 @@ from ..medic import INPUT_FIELDS, _unpack_metadata, init_medic_wf +# A handful of timepoints is enough to exercise the full per-volume MEDIC +# path; the source datasets ship 200+ volumes × 5 echoes × mag+phase, which +# OOM-kills CI runners when xdist schedules these fixtures in parallel. +_MEDIC_TEST_VOLUMES = 3 + + +def _truncate_to_volumes(in_files, volumes, dest): + import nibabel as nb + + out = [] + for f in in_files: + img = nb.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 + def test_medic_construct(): """Build the workflow and verify its surface — no warpkit required. @@ -141,6 +160,11 @@ def test_medic_run(tmpdir, datadir, workdir, outdir, dataset, pattern): ] tmpdir.chdir() + trunc_dir = Path(str(tmpdir)) / 'trunc' + trunc_dir.mkdir(exist_ok=True) + magnitude_files = _truncate_to_volumes(magnitude_files, _MEDIC_TEST_VOLUMES, trunc_dir) + phase_files = _truncate_to_volumes(phase_files, _MEDIC_TEST_VOLUMES, trunc_dir) + medic_wf = init_medic_wf(omp_nthreads=2) medic_wf.inputs.inputnode.magnitude = [str(f) for f in magnitude_files] medic_wf.inputs.inputnode.phase = [str(f) for f in phase_files] From 877d1e7211be939ccbb2c675570b6d083e336169 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 11 May 2026 16:51:13 -0400 Subject: [PATCH 19/48] [FIX] init_fmap_preproc_wf: align dynamic merges across non-MEDIC estimators The dynamic output fields (fmap_dynamic, fmap_dynamic_ref, fmap_dynamic_mask) are only connected for MEDIC estimators, so the corresponding out_merge_* nodes ended up with zero inputs in workflows without any MEDIC estimator. This broke the test_fmap_wf regression check, which asserts every out_merge_* node yields len(estimators) items. Explicitly bind None on out_map for non-MEDIC estimators so each merge slot is filled and the outputnode shape stays uniform regardless of estimator mix. --- sdcflows/workflows/base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sdcflows/workflows/base.py b/sdcflows/workflows/base.py index e59f477cd3..cc52c75330 100644 --- a/sdcflows/workflows/base.py +++ b/sdcflows/workflows/base.py @@ -212,6 +212,12 @@ def init_fmap_preproc_wf( ('outputnode.fmap_dynamic_mask', 'fmap_dynamic_mask'), ]), ]) # fmt:skip + else: + # Keep the dynamic merge nodes aligned with len(estimators) so the + # outputnode shape is uniform across non-MEDIC estimators. + out_map.inputs.fmap_dynamic = None + out_map.inputs.fmap_dynamic_ref = None + out_map.inputs.fmap_dynamic_mask = None for field, mergenode in out_merge.items(): workflow.connect(out_map, field, mergenode, f'in{n}') From 23e9b789431ccf50677540a4da348dd593efb56c Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Tue, 12 May 2026 20:12:20 -0500 Subject: [PATCH 20/48] [FIX] init_medic_wf: accept use_metadata_estimates / fallback_total_readout_time kwargs Align signature with the other estimator workflows so init_fmap_preproc_wf can forward these kwargs uniformly without a TypeError on MEDIC. --- sdcflows/workflows/fit/medic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index 97e9064ab5..0b3193fa34 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -48,6 +48,8 @@ def init_medic_wf( sloppy=False, debug=False, name='medic_wf', + use_metadata_estimates=False, + fallback_total_readout_time=None, ): """ Estimate a fieldmap via MEDIC from multi-echo magnitude + phase EPI. From 20cdeb6918121a403b518a511ce405e349d26022 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Tue, 12 May 2026 20:43:02 -0500 Subject: [PATCH 21/48] [FIX] outputs: dismiss task entity on MEDIC dynamic ref/mask sinks Drop the BOLD task entity from the 4D fmap_dynamic magnitude reference and per-frame mask filenames so they land under fmap/ with fmap-style naming rather than carrying through the source task label. --- sdcflows/workflows/outputs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdcflows/workflows/outputs.py b/sdcflows/workflows/outputs.py index b16e989e6d..43303545e7 100644 --- a/sdcflows/workflows/outputs.py +++ b/sdcflows/workflows/outputs.py @@ -295,7 +295,7 @@ def init_fmap_derivatives_wf( suffix='magnitude', datatype='fmap', compress=True, - dismiss_entities=('fmap',), + dismiss_entities=('fmap', 'task'), allowed_entities=tuple(custom_entities), ), name='ds_fmap_dynamic_ref', @@ -310,7 +310,7 @@ def init_fmap_derivatives_wf( suffix='mask', datatype='fmap', compress=True, - dismiss_entities=('fmap',), + dismiss_entities=('fmap', 'task'), allowed_entities=tuple(custom_entities), ), name='ds_fmap_dynamic_mask', From 1c9642252bf8bb7b38949b547866de1cce3cdac5 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Tue, 12 May 2026 20:46:24 -0500 Subject: [PATCH 22/48] [FIX] outputs: route MEDIC dynamic magnitude ref through fieldmap suffix niworkflows' nipreps.json only defines fmap/ path patterns for the fieldmap and mask suffixes, so the magnitude-suffixed sink had no matching pattern. Switch to suffix=fieldmap with desc=dynamicref to distinguish it from the Hz dynamic fieldmap. --- sdcflows/workflows/outputs.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sdcflows/workflows/outputs.py b/sdcflows/workflows/outputs.py index 43303545e7..38eb98f721 100644 --- a/sdcflows/workflows/outputs.py +++ b/sdcflows/workflows/outputs.py @@ -288,11 +288,14 @@ def init_fmap_derivatives_wf( ds_fmap_dynamic.inputs.trait_set(**custom_entities) # 4D first-echo magnitude reference (raw passthrough, for QC). + # NOTE: suffix is `fieldmap` (not `magnitude`) because niworkflows' + # nipreps.json only ships fmap path patterns for suffix. + # Distinguished from the Hz fieldmap by desc='dynamicref'. ds_fmap_dynamic_ref = pe.Node( DerivativesDataSink( base_directory=output_dir, - desc='dynamic', - suffix='magnitude', + desc='dynamicref', + suffix='fieldmap', datatype='fmap', compress=True, dismiss_entities=('fmap', 'task'), From e3ce5d134ad384329b51d3103a03310410d5530a Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Tue, 12 May 2026 21:12:49 -0500 Subject: [PATCH 23/48] [FIX] warpkit: restore border_filt=(1, 5) default lost to traits.Tuple quirk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `traits.Tuple(Int(), Int(), default=(1, 5))` silently ignores the outer `default` kwarg — the inner Int()s default to 0, so border_filt resolved to (0, 0) at runtime. With zero SVD components the border-filter pass in warpkit.unwrap.svd_filtering reconstructs the mask==1 ring as literal zeros, collapsing the dynamic fieldmap footprint to mask==2 only and making the output look hard-brain-masked. Pass the defaults to the inner Ints so the trait actually emits (1, 5), matching warpkit.distortion.medic. --- sdcflows/interfaces/warpkit.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sdcflows/interfaces/warpkit.py b/sdcflows/interfaces/warpkit.py index 6a77dee291..f69f19b3e8 100644 --- a/sdcflows/interfaces/warpkit.py +++ b/sdcflows/interfaces/warpkit.py @@ -217,10 +217,14 @@ class _ComputeFieldmapInputSpec(BaseInterfaceInputSpec): xor=['echo_times', 'total_readout_time', 'phase_encoding_direction'], ) out_prefix = traits.Str('fieldmap', usedefault=True) + # NOTE: `traits.Tuple(Int(), Int(), default=(1, 5))` is silently ignored — + # the inner Int()s default to 0, and the outer `default` kwarg loses. + # That collapses the border-filter to 0 SVD components, which zeros the + # mask==1 ring in warpkit.unwrap.svd_filtering and makes the dynamic + # fieldmap appear hard-brain-masked. Pass defaults to the inner Ints. border_filt = traits.Tuple( - traits.Int(), - traits.Int(), - default=(1, 5), + traits.Int(1), + traits.Int(5), usedefault=True, desc='SVD components for the two-pass border filter', ) From de21052a4e24c66e213990f0227bb3a372558bdc Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Tue, 12 May 2026 22:28:01 -0500 Subject: [PATCH 24/48] [ENH] wrangler: add force_medic for datasets without IntendedFor 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. --- sdcflows/utils/tests/test_wrangler.py | 48 +++++++-- sdcflows/utils/wrangler.py | 144 +++++++++++++++----------- 2 files changed, 118 insertions(+), 74 deletions(-) diff --git a/sdcflows/utils/tests/test_wrangler.py b/sdcflows/utils/tests/test_wrangler.py index 6037a0c181..d272c51e54 100644 --- a/sdcflows/utils/tests/test_wrangler.py +++ b/sdcflows/utils/tests/test_wrangler.py @@ -433,12 +433,13 @@ def gen_layout(bids_dir, database_dir=None): } -def _build_medic_skeleton(): +def _build_medic_skeleton(*, with_intended_for: bool = True): """Generate a 3-session × 3-echo × {mag,phase} BIDS skeleton for MEDIC. - Each phase BOLD carries an ``IntendedFor`` listing all 6 mag/phase - siblings in its session, so the wrangler can detect the run via the - IntendedFor branch. + When ``with_intended_for`` is ``True``, each complex BOLD carries an + ``IntendedFor`` listing all 6 mag/phase siblings in its session so the + wrangler picks the run up via the default discovery path. Setting it to + ``False`` produces the no-metadata case exercised by ``force_medic``. """ echo_times = {'1': 0.0142, '2': 0.03893, '3': 0.06366} sessions = [] @@ -454,19 +455,21 @@ def _build_medic_skeleton(): func = [] for echo, te in echo_times.items(): for part in ('mag', 'phase'): + metadata = { + 'EchoTime': te, + 'RepetitionTime': 0.8, + 'TotalReadoutTime': 0.5, + 'PhaseEncodingDirection': 'j', + } + if with_intended_for: + metadata['IntendedFor'] = intended_for func.append( { 'task': 'rest', 'echo': echo, 'part': part, 'suffix': 'bold', - 'metadata': { - 'EchoTime': te, - 'RepetitionTime': 0.8, - 'TotalReadoutTime': 0.5, - 'PhaseEncodingDirection': 'j', - 'IntendedFor': intended_for, - }, + 'metadata': metadata, } ) sessions.append( @@ -480,6 +483,7 @@ def _build_medic_skeleton(): medic = _build_medic_skeleton() +medic_no_intended_for = _build_medic_skeleton(with_intended_for=False) filters = { @@ -548,6 +552,28 @@ def test_wrangler_URIs(tmpdir, name, skeleton, session, estimations, total_estim clear_registry() +@pytest.mark.parametrize( + 'force_medic, expected', + [ + (False, 0), # no IntendedFor, no B0FieldIdentifier → nothing found + (True, 3), # ``force_medic`` discovers all three sessions + ], +) +def test_wrangler_force_medic_without_intended_for(tmp_path, force_medic, expected): + bids_dir = str(tmp_path / 'medic_no_intended') + generate_bids_skeleton(bids_dir, medic_no_intended_for) + layout = gen_layout(bids_dir) + # ``fmapless=False`` disables the SyN-SDC ANAT fallback so this test only + # exercises the MEDIC discovery path. + est = find_estimators(layout=layout, subject='01', fmapless=False, force_medic=force_medic) + assert len(est) == expected + if force_medic: + # Each estimator should claim all 6 (3 echoes × {mag, phase}) for its session. + for estimator in est: + assert len(estimator.sources) == 6 + clear_registry() + + def test_single_reverse_pedir(tmp_path): bids_dir = tmp_path / 'bids' generate_bids_skeleton(bids_dir, pepolar) diff --git a/sdcflows/utils/wrangler.py b/sdcflows/utils/wrangler.py index a36fdb65ec..54d1519f0a 100644 --- a/sdcflows/utils/wrangler.py +++ b/sdcflows/utils/wrangler.py @@ -74,6 +74,7 @@ def find_estimators( sessions: list[str] | None = None, fmapless: bool | set = True, force_fmapless: bool = False, + force_medic: bool = False, logger: logging.Logger | None = None, bids_filters: dict | None = None, anat_suffix: str | list[str] = 'T1w', @@ -103,6 +104,14 @@ def find_estimators( force_fmapless : :obj:`bool` When some other fieldmap estimation methods have been found, fieldmap-less estimation will be skipped except if ``force_fmapless`` is ``True``. + force_medic : :obj:`bool` + Auto-discover MEDIC estimators from multi-echo BOLD with ``part-mag`` + and ``part-phase`` even when neither ``IntendedFor`` nor + ``B0FieldIdentifier`` is set on the complex BOLD sidecars. Useful for + public datasets that ship complex multi-echo BOLD without the metadata + needed for the default discovery path. Pairing is unambiguous because + the part-mag and part-phase echoes of the same run are the MEDIC + sources by construction. logger The logger used to relay messages. If not provided, one will be created. bids_filters @@ -446,69 +455,6 @@ def find_estimators( _log_debug_estimation(logger, e, layout.root) estimators.append(e) - # MEDIC: multi-echo BOLD with mag+phase parts and ``IntendedFor``. - # We query both parts as seeds — datasets vary on which side carries - # ``IntendedFor`` — and rely on the dedup check below to keep the - # estimator unique per (run, echo-set). Each seed is then expanded - # to all matching mag+phase echo siblings of its run. - medic_seeds = [] - for part in ('phase', 'mag'): - with suppress(ValueError): - medic_seeds.extend( - layout.get( - **{ - **base_entities, - **{ - 'session': sessions, - 'suffix': 'bold', - 'part': part, - 'echo': Query.REQUIRED, - 'IntendedFor': Query.REQUIRED, - }, - } - ) - ) - - for bold_fmap in medic_seeds: - # Pull every echo + part for this run. ``get_entities()`` already - # includes extension; we override part/echo to widen the query. - run_entities = { - k: v for k, v in bold_fmap.get_entities().items() if k not in ('part', 'echo') - } - run_entities['part'] = ['phase', 'mag'] - run_entities['echo'] = Query.ANY - run_entities['scope'] = base_entities['scope'] - complex_imgs = layout.get(**run_entities) - if not complex_imgs: - continue - - # Dedup against every prior estimator's full source set, not just - # the first complex image — pybids ordering is not contractual, - # and the same run can be seeded twice (once via phase, once via - # mag) when both parts carry ``IntendedFor``. - already_claimed = {str(s.path) for est in estimators for s in est.sources} - if any(str(c.path) in already_claimed for c in complex_imgs): - logger.debug('Skipping MEDIC fmap %s (already in use)', complex_imgs[0].relpath) - continue - - try: - e = fm.FieldmapEstimation( - [ - fm.FieldmapFile( - img.path, - metadata=_filter_metadata(img.get_metadata(), subject), - ) - for img in complex_imgs - ] - ) - except (ValueError, TypeError) as err: - _log_debug_estimator_fail( - logger, 'unnamed MEDIC', list(complex_imgs), layout.root, str(err) - ) - else: - _log_debug_estimation(logger, e, layout.root) - estimators.append(e) - # At this point, only single-PE _epi/_bold files WITH ``IntendedFor`` # can be automatically processed. has_intended = () @@ -580,6 +526,78 @@ def find_estimators( _log_debug_estimation(logger, e, layout.root) estimators.append(e) + # MEDIC: multi-echo BOLD with mag+phase parts. + # + # Runs in two modes: + # * Default: ``IntendedFor`` is required on the complex BOLD sidecars, + # matching how the other heuristics gate auto-discovery. Skipped when + # ``B0FieldIdentifier`` was used to build estimators above (those + # paths already let users opt complex multi-echo runs into MEDIC). + # * ``force_medic=True``: ``IntendedFor`` is dropped from the seed + # query. Useful for public datasets that ship complex multi-echo + # BOLD without the metadata needed for the default path. Runs + # regardless of ``B0FieldIdentifier`` so a forced MEDIC can coexist + # with other tagged estimators. + if force_medic or not b0_ids: + medic_seed_query = { + **base_entities, + 'session': sessions, + 'suffix': 'bold', + 'echo': Query.REQUIRED, + } + if not force_medic: + medic_seed_query['IntendedFor'] = Query.REQUIRED + + # Query both parts as seeds — datasets vary on which side carries + # ``IntendedFor`` — and rely on the dedup check below to keep the + # estimator unique per (run, echo-set). Each seed is then expanded + # to all matching mag+phase echo siblings of its run. + medic_seeds = [] + for part in ('phase', 'mag'): + with suppress(ValueError): + medic_seeds.extend(layout.get(**{**medic_seed_query, 'part': part})) + + for bold_fmap in medic_seeds: + # Pull every echo + part for this run. ``get_entities()`` already + # includes extension; we override part/echo to widen the query. + run_entities = { + k: v for k, v in bold_fmap.get_entities().items() if k not in ('part', 'echo') + } + run_entities['part'] = ['phase', 'mag'] + run_entities['echo'] = Query.ANY + run_entities['scope'] = base_entities['scope'] + complex_imgs = layout.get(**run_entities) + if not complex_imgs: + continue + + # Dedup against every prior estimator's full source set, not just + # the first complex image — pybids ordering is not contractual, + # and the same run can be seeded twice (once via phase, once via + # mag) when both parts carry ``IntendedFor`` (or under + # ``force_medic`` when both parts exist on disk). + already_claimed = {str(s.path) for est in estimators for s in est.sources} + if any(str(c.path) in already_claimed for c in complex_imgs): + logger.debug('Skipping MEDIC fmap %s (already in use)', complex_imgs[0].relpath) + continue + + try: + e = fm.FieldmapEstimation( + [ + fm.FieldmapFile( + img.path, + metadata=_filter_metadata(img.get_metadata(), subject), + ) + for img in complex_imgs + ] + ) + except (ValueError, TypeError) as err: + _log_debug_estimator_fail( + logger, 'unnamed MEDIC', list(complex_imgs), layout.root, str(err) + ) + else: + _log_debug_estimation(logger, e, layout.root) + estimators.append(e) + if estimators and not force_fmapless: fmapless = False From 0881c62aa7d0abe316f58db8345404a3ccccf0ab Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Wed, 13 May 2026 22:53:50 -0500 Subject: [PATCH 25/48] [TEST] Lift MEDIC patch coverage above project gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additions targeting the codecov/patch failure on #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. --- sdcflows/interfaces/tests/test_warpkit.py | 123 +++++++++++++++++++++ sdcflows/workflows/fit/tests/test_medic.py | 54 ++++++++- sdcflows/workflows/tests/test_outputs.py | 81 ++++++++++++++ 3 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 sdcflows/interfaces/tests/test_warpkit.py create mode 100644 sdcflows/workflows/tests/test_outputs.py diff --git a/sdcflows/interfaces/tests/test_warpkit.py b/sdcflows/interfaces/tests/test_warpkit.py new file mode 100644 index 0000000000..a4fcc48540 --- /dev/null +++ b/sdcflows/interfaces/tests/test_warpkit.py @@ -0,0 +1,123 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2025 The NiPreps Developers +# +# 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_as_str_list_string_passthrough(): + """Strings are wrapped in a list rather than iterated character-by-character.""" + assert wk._as_str_list('/tmp/x.nii.gz') == ['/tmp/x.nii.gz'] + + +def test_as_str_list_pathlike_to_str(): + """Iterables of path-like objects are coerced to ``str``.""" + from pathlib import Path + + assert wk._as_str_list([Path('/tmp/a.nii.gz'), Path('/tmp/b.nii.gz')]) == [ + '/tmp/a.nii.gz', + '/tmp/b.nii.gz', + ] + + +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.MEDIC, + wk.UnwrapPhase, + wk.ComputeFieldmap, + wk.ApplyWarp, + wk.ConvertWarp, + wk.ConvertFieldmap, + wk.ComputeJacobian, + ): + assert issubclass(cls, wk.WarpkitBaseInterface) + + +@pytest.mark.parametrize( + 'cls,expected_inputs,expected_outputs', + [ + ( + wk.MEDIC, + {'phase', 'magnitude', 'echo_times', 'total_readout_time'}, + {'fieldmap_native', 'displacement_map', 'fieldmap'}, + ), + ( + wk.UnwrapPhase, + {'phase', 'magnitude', 'echo_times'}, + {'unwrapped', 'masks'}, + ), + ( + wk.ComputeFieldmap, + {'unwrapped', 'magnitude', 'masks', 'border_filt', 'svd_filt'}, + {'fieldmap_native', 'displacement_map', 'fieldmap'}, + ), + ( + wk.ApplyWarp, + {'in_file', 'transform', 'transform_type'}, + {'out_file'}, + ), + ( + wk.ConvertWarp, + {'in_file', 'from_type'}, + {'out_file'}, + ), + ( + wk.ConvertFieldmap, + {'in_file', 'from_type', 'to_type', 'total_readout_time'}, + {'out_file'}, + ), + ( + wk.ComputeJacobian, + {'in_file', 'from_type'}, + {'out_file'}, + ), + ], +) +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) diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py index a54d23feb3..9f5a199cb3 100644 --- a/sdcflows/workflows/fit/tests/test_medic.py +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -27,7 +27,7 @@ import pytest -from ..medic import INPUT_FIELDS, _unpack_metadata, init_medic_wf +from ..medic import INPUT_FIELDS, _first, _temporal_mean, _unpack_metadata, init_medic_wf # A handful of timepoints is enough to exercise the full per-volume MEDIC # path; the source datasets ship 200+ volumes × 5 echoes × mag+phase, which @@ -114,6 +114,58 @@ def test_unpack_metadata_rejects_mixed_pe(): _unpack_metadata(metadata) +def test_unpack_metadata_rejects_empty(): + with pytest.raises(ValueError, match='per-echo metadata'): + _unpack_metadata([]) + + +def test_medic_wf_sloppy_sets_zooms_min(): + """``sloppy=True`` lowers the B-spline ``zooms_min`` for the static path.""" + wf = init_medic_wf(sloppy=True) + bs_filter = wf.get_node('bs_filter') + assert bs_filter.inputs.zooms_min == 4.0 + + +def test_first_helper(): + """``_first`` returns the head of the list or ``None`` when empty.""" + assert _first(['a', 'b', 'c']) == 'a' + assert _first([]) is None + + +def test_temporal_mean_collapses_4d(tmp_path, monkeypatch): + """``_temporal_mean`` averages along the time axis and emits a 3D NIfTI.""" + import nibabel as nb + import numpy as np + + monkeypatch.chdir(tmp_path) + + data = np.stack( + [np.ones((3, 4, 2), dtype='float32') * scalar for scalar in (1.0, 3.0, 5.0)], + axis=-1, + ) + in_file = tmp_path / 'in.nii.gz' + nb.Nifti1Image(data, np.eye(4)).to_filename(in_file) + + out_file = _temporal_mean(str(in_file)) + out_img = nb.load(out_file) + assert out_img.ndim == 3 + assert np.allclose(np.asanyarray(out_img.dataobj), 3.0) + + +def test_temporal_mean_passthrough_3d(tmp_path, monkeypatch): + """3D inputs are written back unchanged (no axis to average over).""" + import nibabel as nb + import numpy as np + + monkeypatch.chdir(tmp_path) + data = np.full((3, 4, 2), 7.0, dtype='float32') + in_file = tmp_path / 'in3d.nii.gz' + nb.Nifti1Image(data, np.eye(4)).to_filename(in_file) + + out_file = _temporal_mean(str(in_file)) + assert np.allclose(np.asanyarray(nb.load(out_file).dataobj), 7.0) + + # Each entry is (dataset, mag_glob_under_dataset). # Add new fixtures here — the test self-skips when a dataset isn't on disk. MEDIC_FIXTURES = [ diff --git a/sdcflows/workflows/tests/test_outputs.py b/sdcflows/workflows/tests/test_outputs.py new file mode 100644 index 0000000000..b1400fe57c --- /dev/null +++ b/sdcflows/workflows/tests/test_outputs.py @@ -0,0 +1,81 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2025 The NiPreps Developers +# +# 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/ +# +"""Construction-only tests for :mod:`sdcflows.workflows.outputs`.""" + +from ..outputs import init_fmap_derivatives_wf + + +def test_fmap_derivatives_wf_default(tmp_path): + """Default workflow should expose only the static fieldmap sinks.""" + wf = init_fmap_derivatives_wf(output_dir=str(tmp_path)) + assert wf.get_node('ds_fieldmap') is not None + assert wf.get_node('ds_reference') is not None + # write_dynamic / write_mask off by default. + assert wf.get_node('ds_mask') is None + assert wf.get_node('ds_fmap_dynamic') is None + assert wf.get_node('ds_fmap_dynamic_ref') is None + assert wf.get_node('ds_fmap_dynamic_mask') is None + + +def test_fmap_derivatives_wf_write_dynamic(tmp_path): + """``write_dynamic=True`` adds the 4D MEDIC sinks and tags the fieldmap.""" + wf = init_fmap_derivatives_wf( + output_dir=str(tmp_path), + write_dynamic=True, + bids_fmap_id='medic_0', + ) + + ds_fmap_dynamic = wf.get_node('ds_fmap_dynamic') + ds_fmap_dynamic_ref = wf.get_node('ds_fmap_dynamic_ref') + ds_fmap_dynamic_mask = wf.get_node('ds_fmap_dynamic_mask') + assert ds_fmap_dynamic is not None + assert ds_fmap_dynamic_ref is not None + assert ds_fmap_dynamic_mask is not None + + # The Hz dynamic sink carries Units + B0FieldIdentifier so downstream + # consumers can join it to the same B0 group as the static fieldmap. + assert ds_fmap_dynamic.inputs.Units == 'Hz' + assert ds_fmap_dynamic.inputs.B0FieldIdentifier == 'medic_0' + + # `desc` distinguishes the three 4D outputs at the BIDS-path level — + # niworkflows' nipreps.json only has fmap path patterns for the + # fieldmap/mask suffixes, so the magnitude ref reuses the fieldmap suffix. + assert ds_fmap_dynamic.inputs.desc == 'dynamic' + assert ds_fmap_dynamic.inputs.suffix == 'fieldmap' + assert ds_fmap_dynamic_ref.inputs.desc == 'dynamicref' + assert ds_fmap_dynamic_ref.inputs.suffix == 'fieldmap' + assert ds_fmap_dynamic_mask.inputs.desc == 'dynamicbrain' + assert ds_fmap_dynamic_mask.inputs.suffix == 'mask' + + # Each dynamic sink should land under the fmap/ datatype. + for node in (ds_fmap_dynamic, ds_fmap_dynamic_ref, ds_fmap_dynamic_mask): + assert node.inputs.datatype == 'fmap' + + +def test_fmap_derivatives_wf_write_dynamic_no_b0id(tmp_path): + """Without ``bids_fmap_id``, the Hz dynamic sink omits B0FieldIdentifier.""" + wf = init_fmap_derivatives_wf(output_dir=str(tmp_path), write_dynamic=True) + ds_fmap_dynamic = wf.get_node('ds_fmap_dynamic') + # ``B0FieldIdentifier`` is set as a dynamic trait only when bids_fmap_id is + # provided; without it the attribute should not exist on the sink inputs. + assert not hasattr(ds_fmap_dynamic.inputs, 'B0FieldIdentifier') From 92e0d02f135ebf07bf279bda910c6dc585c5da04 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Fri, 15 May 2026 21:33:42 -0500 Subject: [PATCH 26/48] [FIX] warpkit: drop noise_frames trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- sdcflows/interfaces/warpkit.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/sdcflows/interfaces/warpkit.py b/sdcflows/interfaces/warpkit.py index f69f19b3e8..eed852fa5a 100644 --- a/sdcflows/interfaces/warpkit.py +++ b/sdcflows/interfaces/warpkit.py @@ -89,7 +89,6 @@ class _MEDICInputSpec(BaseInterfaceInputSpec): desc='BIDS sidecar JSONs, one per echo (alternative to direct args)', ) out_prefix = traits.Str('medic', usedefault=True, desc='prefix for output filenames') - noise_frames = traits.Int(0, usedefault=True, desc='number of trailing noise frames to drop') n_cpus = traits.Int(4, usedefault=True, desc='number of CPUs to use') wrap_limit = traits.Bool( False, usedefault=True, desc='disable some phase-unwrapping heuristics' @@ -130,7 +129,6 @@ def _run_interface(self, runtime): else None ), metadata=(list(self.inputs.metadata) if isdefined(self.inputs.metadata) else None), - noise_frames=self.inputs.noise_frames, n_cpus=self.inputs.n_cpus, wrap_limit=self.inputs.wrap_limit, debug=self.inputs.debug, @@ -155,7 +153,6 @@ class _UnwrapPhaseInputSpec(BaseInterfaceInputSpec): echo_times = traits.List(traits.Float, xor=['metadata']) metadata = InputMultiObject(File(exists=True), xor=['echo_times']) out_prefix = traits.Str('unwrap', usedefault=True) - noise_frames = traits.Int(0, usedefault=True) n_cpus = traits.Int(4, usedefault=True) wrap_limit = traits.Bool(False, usedefault=True) debug = traits.Bool(False, usedefault=True) @@ -183,7 +180,6 @@ def _run_interface(self, runtime): out_prefix=out_prefix, tes=(list(self.inputs.echo_times) if isdefined(self.inputs.echo_times) else None), metadata=(list(self.inputs.metadata) if isdefined(self.inputs.metadata) else None), - noise_frames=self.inputs.noise_frames, n_cpus=self.inputs.n_cpus, wrap_limit=self.inputs.wrap_limit, debug=self.inputs.debug, From 838105bf10edba2a643d8784d35af4e06345788c Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Fri, 15 May 2026 21:34:02 -0500 Subject: [PATCH 27/48] [REF] medic: build UnwrapPhase/ComputeFieldmap nodes with ctor inputs Passing n_cpus and debug through the interface constructor keeps node construction in one place and removes the post-init mutation pattern. --- sdcflows/workflows/fit/medic.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index 0b3193fa34..a4a693ae68 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -166,14 +166,19 @@ def init_medic_wf( # Two-stage warpkit path: UnwrapPhase exposes per-frame masks, which # ComputeFieldmap then consumes. The one-shot MEDIC interface bundles # both but hides the masks; we want them for ``fmap_dynamic_mask``. - unwrap = pe.Node(UnwrapPhase(), name='unwrap', n_procs=omp_nthreads) - unwrap.inputs.n_cpus = omp_nthreads - unwrap.inputs.debug = debug + unwrap = pe.Node( + UnwrapPhase(n_cpus=omp_nthreads, debug=debug), + name='unwrap', + n_procs=omp_nthreads, + ) # ComputeFieldmap doesn't expose a ``debug`` input — only UnwrapPhase # does, so the asymmetry is intentional. - compute_fmap = pe.Node(ComputeFieldmap(), name='compute_fmap', n_procs=omp_nthreads) - compute_fmap.inputs.n_cpus = omp_nthreads + compute_fmap = pe.Node( + ComputeFieldmap(n_cpus=omp_nthreads), + name='compute_fmap', + n_procs=omp_nthreads, + ) # The 4D dynamic Hz fieldmap is the real MEDIC output — exposed below # as ``fmap_dynamic`` for consumers that want per-volume correction. From 8ff5fcccd9b8c8c7e0bef2932f22a9730e048224 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Fri, 15 May 2026 21:36:55 -0500 Subject: [PATCH 28/48] [REF] dynamic apply: drop unused warpkit interfaces, switch to nitransforms 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. --- sdcflows/interfaces/tests/test_warpkit.py | 50 +-- sdcflows/interfaces/warpkit.py | 333 +----------------- sdcflows/transform.py | 148 ++++++++ sdcflows/workflows/apply/dynamic.py | 138 +++----- .../workflows/apply/tests/test_dynamic.py | 107 +++--- 5 files changed, 264 insertions(+), 512 deletions(-) diff --git a/sdcflows/interfaces/tests/test_warpkit.py b/sdcflows/interfaces/tests/test_warpkit.py index a4fcc48540..9e67453bd5 100644 --- a/sdcflows/interfaces/tests/test_warpkit.py +++ b/sdcflows/interfaces/tests/test_warpkit.py @@ -31,21 +31,6 @@ from sdcflows.interfaces import warpkit as wk -def test_as_str_list_string_passthrough(): - """Strings are wrapped in a list rather than iterated character-by-character.""" - assert wk._as_str_list('/tmp/x.nii.gz') == ['/tmp/x.nii.gz'] - - -def test_as_str_list_pathlike_to_str(): - """Iterables of path-like objects are coerced to ``str``.""" - from pathlib import Path - - assert wk._as_str_list([Path('/tmp/a.nii.gz'), Path('/tmp/b.nii.gz')]) == [ - '/tmp/a.nii.gz', - '/tmp/b.nii.gz', - ] - - def test_warpkit_base_interface_pkg(): """All warpkit interfaces share the ``warpkit`` library tag. @@ -53,26 +38,13 @@ def test_warpkit_base_interface_pkg(): "warpkit not installed" message rather than per-class noise. """ assert wk.WarpkitBaseInterface._pkg == 'warpkit' - for cls in ( - wk.MEDIC, - wk.UnwrapPhase, - wk.ComputeFieldmap, - wk.ApplyWarp, - wk.ConvertWarp, - wk.ConvertFieldmap, - wk.ComputeJacobian, - ): + for cls in (wk.UnwrapPhase, wk.ComputeFieldmap): assert issubclass(cls, wk.WarpkitBaseInterface) @pytest.mark.parametrize( 'cls,expected_inputs,expected_outputs', [ - ( - wk.MEDIC, - {'phase', 'magnitude', 'echo_times', 'total_readout_time'}, - {'fieldmap_native', 'displacement_map', 'fieldmap'}, - ), ( wk.UnwrapPhase, {'phase', 'magnitude', 'echo_times'}, @@ -83,26 +55,6 @@ def test_warpkit_base_interface_pkg(): {'unwrapped', 'magnitude', 'masks', 'border_filt', 'svd_filt'}, {'fieldmap_native', 'displacement_map', 'fieldmap'}, ), - ( - wk.ApplyWarp, - {'in_file', 'transform', 'transform_type'}, - {'out_file'}, - ), - ( - wk.ConvertWarp, - {'in_file', 'from_type'}, - {'out_file'}, - ), - ( - wk.ConvertFieldmap, - {'in_file', 'from_type', 'to_type', 'total_readout_time'}, - {'out_file'}, - ), - ( - wk.ComputeJacobian, - {'in_file', 'from_type'}, - {'out_file'}, - ), ], ) def test_interface_spec_traits(cls, expected_inputs, expected_outputs): diff --git a/sdcflows/interfaces/warpkit.py b/sdcflows/interfaces/warpkit.py index eed852fa5a..45053833e8 100644 --- a/sdcflows/interfaces/warpkit.py +++ b/sdcflows/interfaces/warpkit.py @@ -23,12 +23,12 @@ """Nipype interfaces wrapping :mod:`warpkit.api`. `warpkit `__ implements MEDIC -(Multi-Echo DIstortion Correction) and related warp utilities. Each of -warpkit's seven ``wk-*`` CLI tools is mirrored here as a -:class:`~nipype.interfaces.base.SimpleInterface`, calling the corresponding -:mod:`warpkit.api` function in-process. ``warpkit`` is an optional dependency -— :class:`~nipype.interfaces.base.LibraryBaseInterface` produces a clean -"package not installed" error if ``warpkit`` is missing at runtime. +(Multi-Echo DIstortion Correction). This module exposes the two MEDIC +stages SDCFlows actually drives — phase unwrapping and fieldmap +computation — as :class:`~nipype.interfaces.base.SimpleInterface` +subclasses calling :mod:`warpkit.api` in-process. ``warpkit`` is an +optional dependency; :class:`~nipype.interfaces.base.LibraryBaseInterface` +emits a clean "package not installed" error if it is missing at runtime. """ import os @@ -46,8 +46,6 @@ ) PE_DIRECTIONS = ('i', 'j', 'k', 'i-', 'j-', 'k-', 'x', 'y', 'z', 'x-', 'y-', 'z-') -PE_AXES = ('i', 'j', 'k', 'x', 'y', 'z') -WARP_FORMATS = ('itk', 'fsl', 'ants', 'afni') class WarpkitBaseInterface(LibraryBaseInterface): @@ -56,92 +54,6 @@ class WarpkitBaseInterface(LibraryBaseInterface): _pkg = 'warpkit' -def _as_str_list(x) -> list[str]: - if isinstance(x, str): - return [x] - return [str(p) for p in x] - - -# --------------------------------------------------------------------------- -# MEDIC — full multi-echo distortion correction pipeline -# --------------------------------------------------------------------------- - - -class _MEDICInputSpec(BaseInterfaceInputSpec): - phase = InputMultiObject(File(exists=True), mandatory=True, desc='phase NIfTI, one per echo') - magnitude = InputMultiObject( - File(exists=True), mandatory=True, desc='magnitude NIfTI, one per echo' - ) - echo_times = traits.List( - traits.Float, - xor=['metadata'], - desc='echo times in milliseconds, one per echo', - ) - total_readout_time = traits.Float(xor=['metadata'], desc='EPI total readout time in seconds') - phase_encoding_direction = traits.Enum( - *PE_DIRECTIONS, - xor=['metadata'], - desc='phase-encoding direction (with sign)', - ) - metadata = InputMultiObject( - File(exists=True), - xor=['echo_times', 'total_readout_time', 'phase_encoding_direction'], - desc='BIDS sidecar JSONs, one per echo (alternative to direct args)', - ) - out_prefix = traits.Str('medic', usedefault=True, desc='prefix for output filenames') - n_cpus = traits.Int(4, usedefault=True, desc='number of CPUs to use') - wrap_limit = traits.Bool( - False, usedefault=True, desc='disable some phase-unwrapping heuristics' - ) - debug = traits.Bool(False, usedefault=True, desc='enable debug mode') - - -class _MEDICOutputSpec(TraitedSpec): - fieldmap_native = File(exists=True, desc='native-space B0 field map (Hz)') - displacement_map = File(exists=True, desc='displacement map (mm)') - fieldmap = File(exists=True, desc='undistorted-space B0 field map (Hz)') - - -class MEDIC(WarpkitBaseInterface, SimpleInterface): - """Run the full MEDIC pipeline (:func:`warpkit.api.medic`).""" - - input_spec = _MEDICInputSpec - output_spec = _MEDICOutputSpec - - def _run_interface(self, runtime): - from warpkit.api import medic - - out_prefix = os.path.join(runtime.cwd, self.inputs.out_prefix) - try: - result = medic( - phase=list(self.inputs.phase), - magnitude=list(self.inputs.magnitude), - out_prefix=out_prefix, - tes=(list(self.inputs.echo_times) if isdefined(self.inputs.echo_times) else None), - total_readout_time=( - self.inputs.total_readout_time - if isdefined(self.inputs.total_readout_time) - else None - ), - phase_encoding_direction=( - self.inputs.phase_encoding_direction - if isdefined(self.inputs.phase_encoding_direction) - else None - ), - metadata=(list(self.inputs.metadata) if isdefined(self.inputs.metadata) else None), - n_cpus=self.inputs.n_cpus, - wrap_limit=self.inputs.wrap_limit, - debug=self.inputs.debug, - ) - except ValueError as e: - raise RuntimeError(str(e)) from e - - self._results['fieldmap_native'] = str(result.fieldmap_native) - self._results['displacement_map'] = str(result.displacement_map) - self._results['fieldmap'] = str(result.fieldmap) - return runtime - - # --------------------------------------------------------------------------- # UnwrapPhase — ROMEO multi-echo phase unwrapping # --------------------------------------------------------------------------- @@ -273,236 +185,3 @@ def _run_interface(self, runtime): self._results['displacement_map'] = str(result.displacement_map) self._results['fieldmap'] = str(result.fieldmap) return runtime - - -# --------------------------------------------------------------------------- -# ApplyWarp — resample through a displacement transform -# --------------------------------------------------------------------------- - - -class _ApplyWarpInputSpec(BaseInterfaceInputSpec): - in_file = File(exists=True, mandatory=True) - transform = InputMultiObject(File(exists=True), mandatory=True) - out_file = traits.Str(desc='output path; defaults to /applied.nii.gz') - transform_type = traits.Enum('map', 'field', mandatory=True) - reference = File(exists=True) - # warpkit folds signed forms ('j-') to the same axis index as their - # unsigned counterparts here — the sign in the displacement-map values - # is what carries direction. Accept both to spare callers a strip step. - phase_encoding_axis = traits.Enum(*PE_DIRECTIONS) - format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') - - -class _ApplyWarpOutputSpec(TraitedSpec): - out_file = File(exists=True) - - -class ApplyWarp(WarpkitBaseInterface, SimpleInterface): - """Resample an image through a warpkit displacement transform - (:func:`warpkit.api.apply_warp`).""" - - input_spec = _ApplyWarpInputSpec - output_spec = _ApplyWarpOutputSpec - - def _run_interface(self, runtime): - from warpkit.api import apply_warp - - out_file = self.inputs.out_file - if not isdefined(out_file): - out_file = os.path.join(runtime.cwd, 'applied.nii.gz') - try: - result = apply_warp( - input=self.inputs.in_file, - transform=list(self.inputs.transform), - output=out_file, - transform_type=self.inputs.transform_type, - reference=(self.inputs.reference if isdefined(self.inputs.reference) else None), - phase_encoding_axis=( - self.inputs.phase_encoding_axis - if isdefined(self.inputs.phase_encoding_axis) - else None - ), - format=self.inputs.format, - ) - except ValueError as e: - raise RuntimeError(str(e)) from e - - self._results['out_file'] = str(result.output) - return runtime - - -# --------------------------------------------------------------------------- -# ConvertWarp — interconvert / reformat / invert displacement transforms -# --------------------------------------------------------------------------- - - -class _ConvertWarpInputSpec(BaseInterfaceInputSpec): - in_file = InputMultiObject(File(exists=True), mandatory=True) - out_file = traits.Either( - traits.Str(), - traits.List(traits.Str()), - desc='output path(s); 1 path bundles, N paths split per frame', - ) - from_type = traits.Enum('map', 'field', mandatory=True) - to_type = traits.Enum('map', 'field') - from_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') - to_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') - axis = traits.Enum(*PE_AXES) - frame = traits.Int() - invert = traits.Bool(False, usedefault=True) - verbose = traits.Bool(False, usedefault=True) - - -class _ConvertWarpOutputSpec(TraitedSpec): - out_file = OutputMultiObject(File(exists=True)) - - -class ConvertWarp(WarpkitBaseInterface, SimpleInterface): - """Convert displacement transforms (:func:`warpkit.api.convert_warp`).""" - - input_spec = _ConvertWarpInputSpec - output_spec = _ConvertWarpOutputSpec - - def _run_interface(self, runtime): - from warpkit.api import convert_warp - - if isdefined(self.inputs.out_file): - out_paths = _as_str_list(self.inputs.out_file) - else: - out_paths = [os.path.join(runtime.cwd, 'converted.nii.gz')] - - try: - result = convert_warp( - input=list(self.inputs.in_file), - output=out_paths, - from_type=self.inputs.from_type, - to_type=self.inputs.to_type if isdefined(self.inputs.to_type) else None, - from_format=self.inputs.from_format, - to_format=self.inputs.to_format, - axis=self.inputs.axis if isdefined(self.inputs.axis) else None, - frame=self.inputs.frame if isdefined(self.inputs.frame) else None, - invert=self.inputs.invert, - verbose=self.inputs.verbose, - ) - except ValueError as e: - raise RuntimeError(str(e)) from e - - self._results['out_file'] = [str(p) for p in result.output] - return runtime - - -# --------------------------------------------------------------------------- -# ConvertFieldmap — mm displacement <-> Hz fieldmap -# --------------------------------------------------------------------------- - - -class _ConvertFieldmapInputSpec(BaseInterfaceInputSpec): - in_file = InputMultiObject(File(exists=True), mandatory=True) - out_file = traits.Either( - traits.Str(), - traits.List(traits.Str()), - ) - from_type = traits.Enum('map', 'field', 'fieldmap', mandatory=True) - to_type = traits.Enum('map', 'field', 'fieldmap', mandatory=True) - total_readout_time = traits.Float(mandatory=True) - # Accept signed PE direction strings ('j-' etc.); warpkit's - # field_maps_to_displacement_maps peeks at the trailing '-' to set the - # voxel-size sign, so the sign IS meaningful on the Hz->mm path. - phase_encoding_direction = traits.Enum(*PE_DIRECTIONS, mandatory=True) - from_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') - to_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') - # flip_sign is only used on the inverse (mm->Hz) path; it is ignored - # when ``to_type='map'``/``'field'``. - flip_sign = traits.Bool(False, usedefault=True) - frame = traits.Int() - - -class _ConvertFieldmapOutputSpec(TraitedSpec): - out_file = OutputMultiObject(File(exists=True)) - - -class ConvertFieldmap(WarpkitBaseInterface, SimpleInterface): - """Convert between mm displacement and Hz fieldmap - (:func:`warpkit.api.convert_fieldmap`).""" - - input_spec = _ConvertFieldmapInputSpec - output_spec = _ConvertFieldmapOutputSpec - - def _run_interface(self, runtime): - from warpkit.api import convert_fieldmap - - if isdefined(self.inputs.out_file): - out_paths = _as_str_list(self.inputs.out_file) - else: - out_paths = [os.path.join(runtime.cwd, 'converted.nii.gz')] - - try: - result = convert_fieldmap( - input=list(self.inputs.in_file), - output=out_paths, - from_type=self.inputs.from_type, - to_type=self.inputs.to_type, - total_readout_time=self.inputs.total_readout_time, - phase_encoding_direction=self.inputs.phase_encoding_direction, - from_format=self.inputs.from_format, - to_format=self.inputs.to_format, - flip_sign=self.inputs.flip_sign, - frame=self.inputs.frame if isdefined(self.inputs.frame) else None, - ) - except ValueError as e: - raise RuntimeError(str(e)) from e - - self._results['out_file'] = [str(p) for p in result.output] - return runtime - - -# --------------------------------------------------------------------------- -# ComputeJacobian -# --------------------------------------------------------------------------- - - -class _ComputeJacobianInputSpec(BaseInterfaceInputSpec): - in_file = InputMultiObject(File(exists=True), mandatory=True) - out_file = traits.Either( - traits.Str(), - traits.List(traits.Str()), - ) - from_type = traits.Enum('map', 'field', mandatory=True) - from_format = traits.Enum(*WARP_FORMATS, usedefault=True, default='itk') - axis = traits.Enum(*PE_AXES) - frame = traits.Int() - - -class _ComputeJacobianOutputSpec(TraitedSpec): - out_file = OutputMultiObject(File(exists=True)) - - -class ComputeJacobian(WarpkitBaseInterface, SimpleInterface): - """Jacobian determinant of a displacement warp - (:func:`warpkit.api.compute_jacobian`).""" - - input_spec = _ComputeJacobianInputSpec - output_spec = _ComputeJacobianOutputSpec - - def _run_interface(self, runtime): - from warpkit.api import compute_jacobian - - if isdefined(self.inputs.out_file): - out_paths = _as_str_list(self.inputs.out_file) - else: - out_paths = [os.path.join(runtime.cwd, 'jacobian.nii.gz')] - - try: - result = compute_jacobian( - input=list(self.inputs.in_file), - output=out_paths, - from_type=self.inputs.from_type, - from_format=self.inputs.from_format, - axis=self.inputs.axis if isdefined(self.inputs.axis) else None, - frame=self.inputs.frame if isdefined(self.inputs.frame) else None, - ) - except ValueError as e: - raise RuntimeError(str(e)) from e - - self._results['out_file'] = [str(p) for p in result.output] - return runtime diff --git a/sdcflows/transform.py b/sdcflows/transform.py index 82b820898f..f383da125f 100644 --- a/sdcflows/transform.py +++ b/sdcflows/transform.py @@ -598,6 +598,154 @@ def to_displacements(self, ro_time, pe_dir, itk_format=True): return fmap_to_disp(self.mapped, ro_time, pe_dir, itk_format=itk_format) +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) + + def fmap_to_disp(fmap_nii, ro_time, pe_dir, itk_format=True): """ Convert a fieldmap in Hz into an ITK/ANTs-compatible displacements field. diff --git a/sdcflows/workflows/apply/dynamic.py b/sdcflows/workflows/apply/dynamic.py index 266aa3840b..a84e25d30b 100644 --- a/sdcflows/workflows/apply/dynamic.py +++ b/sdcflows/workflows/apply/dynamic.py @@ -28,10 +28,6 @@ the EPI grid and applies the same warp to every volume, this workflow takes a 4D Hz fieldmap *already on the EPI grid* and applies a different warp to each timepoint. - -Backed by ``warpkit``, an optional dependency. The workflow is -module-load pure: warpkit is only resolved when the underlying interfaces -actually run. """ from nipype.interfaces import utility as niu @@ -48,7 +44,7 @@ def init_dynamic_unwarp_wf( name='dynamic_unwarp_wf', ): r""" - Apply a per-volume MEDIC fieldmap to unwarp a 4D EPI series. + Apply a per-volume 4D fieldmap to unwarp a 4D EPI series. Workflow Graph .. workflow :: @@ -66,10 +62,7 @@ def init_dynamic_unwarp_wf( regions of the EPI distortion. Mirrors the :func:`~sdcflows.workflows.apply.correction.init_unwarp_wf` default. omp_nthreads : :obj:`int` - Per-node ``n_procs`` hint for the Nipype scheduler. Note that - warpkit's ``apply_warp`` / ``convert_fieldmap`` C++ paths don't - accept a thread count, so this only affects scheduling, not - warpkit's internal parallelism. + Maximum number of parallel volume resamplings. name : :obj:`str` Workflow name. @@ -92,14 +85,10 @@ def init_dynamic_unwarp_wf( 3D temporal-mean reference of the corrected series, brain-extracted. corrected_mask : :obj:`str` Binary brain mask co-registered with ``corrected_ref``. - fieldwarp : :obj:`str` - 4D displacement map (mm along PE axis) used for the resampling. """ - # Project-internal imports only; warpkit stays unloaded until interfaces run. from niworkflows.interfaces.images import RobustAverage from ...interfaces.epi import GetReadoutTime - from ...interfaces.warpkit import ApplyWarp, ConvertFieldmap from ..ancillary import init_brainextraction_wf workflow = Workflow(name=name) @@ -107,45 +96,35 @@ def init_dynamic_unwarp_wf( inputnode = pe.Node(niu.IdentityInterface(fields=INPUT_FIELDS), name='inputnode') outputnode = pe.Node( niu.IdentityInterface( - fields=['corrected', 'corrected_ref', 'corrected_mask', 'fieldwarp'], + fields=['corrected', 'corrected_ref', 'corrected_mask'], ), name='outputnode', ) rotime = pe.Node(GetReadoutTime(), name='rotime', run_without_submitting=True) - # No coregistration step: warpkit emits ``fmap_dynamic`` on the EPI grid - # by construction (the fieldmap is computed from the same multi-echo - # acquisition being corrected here), so the static path's + # No coregistration step: the dynamic fieldmap is on the EPI grid by + # construction (e.g., warpkit's MEDIC output is computed from the same + # multi-echo acquisition being corrected here), so the static path's # ``fmap2data_xfm`` plumbing has no analog. - - # Hz fieldmap → 1-channel mm displacement map along the PE axis. - convert_fmap = pe.Node( - ConvertFieldmap(from_type='fieldmap', to_type='map'), - name='convert_fmap', - n_procs=omp_nthreads, - ) - - # Per-frame resampling. Frame count of `transform` must match `distorted`. - apply_warp = pe.Node( - ApplyWarp(transform_type='map'), - name='apply_warp', + unwarp = pe.Node( + niu.Function( + input_names=[ + 'distorted', + 'fmap_dynamic', + 'pe_direction', + 'readout_time', + 'jacobian', + 'num_threads', + ], + output_names=['out_file'], + function=_dynamic_unwarp, + ), + name='unwarp', n_procs=omp_nthreads, ) - - # Optional Jacobian-determinant intensity correction. Mirrors what - # ``init_unwarp_wf`` does via ``ApplyCoeffsField(jacobian=True)`` — - # we just call the same numpy formula (``transform.fieldmap_jacobian``) - # against the dynamic 4D fieldmap, post-resampling. - if jacobian: - jac_correct = pe.Node( - niu.Function( - input_names=['in_file', 'fmap_dynamic', 'pe_direction', 'readout_time'], - output_names=['out_file'], - function=_apply_jacobian, - ), - name='jac_correct', - ) + unwarp.inputs.jacobian = jacobian + unwarp.inputs.num_threads = omp_nthreads average = pe.Node(RobustAverage(mc_method=None), name='average') brainextraction_wf = init_brainextraction_wf() @@ -154,14 +133,13 @@ def init_dynamic_unwarp_wf( workflow.connect([ (inputnode, rotime, [('distorted', 'in_file'), ('metadata', 'metadata')]), - (rotime, convert_fmap, [('pe_direction', 'phase_encoding_direction'), - ('readout_time', 'total_readout_time')]), - (inputnode, convert_fmap, [('fmap_dynamic', 'in_file')]), - (rotime, apply_warp, [('pe_direction', 'phase_encoding_axis')]), - (convert_fmap, apply_warp, [('out_file', 'transform')]), - (inputnode, apply_warp, [('distorted', 'in_file')]), + (inputnode, unwarp, [('distorted', 'distorted'), + ('fmap_dynamic', 'fmap_dynamic')]), + (rotime, unwarp, [('pe_direction', 'pe_direction'), + ('readout_time', 'readout_time')]), + (unwarp, average, [('out_file', 'in_file')]), + (unwarp, outputnode, [('out_file', 'corrected')]), (average, brainextraction_wf, [('out_file', 'inputnode.in_file')]), - (convert_fmap, outputnode, [('out_file', 'fieldwarp')]), (brainextraction_wf, outputnode, [ ('outputnode.out_file', 'corrected_ref'), ('outputnode.out_mask', 'corrected_mask'), @@ -169,56 +147,28 @@ def init_dynamic_unwarp_wf( ]) # fmt: on - if jacobian: - # fmt: off - workflow.connect([ - (apply_warp, jac_correct, [('out_file', 'in_file')]), - (inputnode, jac_correct, [('fmap_dynamic', 'fmap_dynamic')]), - (rotime, jac_correct, [('pe_direction', 'pe_direction'), - ('readout_time', 'readout_time')]), - (jac_correct, average, [('out_file', 'in_file')]), - (jac_correct, outputnode, [('out_file', 'corrected')]), - ]) - # fmt: on - else: - # fmt: off - workflow.connect([ - (apply_warp, average, [('out_file', 'in_file')]), - (apply_warp, outputnode, [('out_file', 'corrected')]), - ]) - # fmt: on - return workflow -def _apply_jacobian(in_file, fmap_dynamic, pe_direction, readout_time): - """Multiply ``in_file`` by the per-frame Jacobian determinant of ``fmap_dynamic``. +def _dynamic_unwarp(distorted, fmap_dynamic, pe_direction, readout_time, jacobian, num_threads): + """Resample a 4D EPI through a per-frame 4D Hz fieldmap on the same grid. - ``in_file`` and ``fmap_dynamic`` must share the spatial grid (the - dynamic apply path guarantees this — ``fmap_dynamic`` is on the EPI - grid by construction). The PE-axis sign convention matches warpkit's - ``ConvertFieldmap``: ``pe_direction`` ending in ``-`` flips the - readout time, the result is fed to - :func:`~sdcflows.transform.fieldmap_jacobian`. + Uses :func:`~sdcflows.transform.apply_dynamic_unwarp`, which delegates + per-volume resampling to the same scipy-backed primitives that + :class:`~sdcflows.transform.B0FieldTransform` uses for the static path. """ import os - import nibabel as nb - import numpy as np - - from sdcflows.transform import fieldmap_jacobian - - pe_axis = 'ijk'.index(pe_direction[0]) - ro_signed = -float(readout_time) if pe_direction.endswith('-') else float(readout_time) + from sdcflows.transform import apply_dynamic_unwarp - img = nb.load(in_file) - fmap_img = nb.load(fmap_dynamic) - data = np.asanyarray(img.dataobj, dtype='float32') - fmap_hz = np.asanyarray(fmap_img.dataobj, dtype='float32') - - jac = fieldmap_jacobian(fmap_hz, ro_signed, pe_axis) - corrected = data * jac - - out_file = os.path.abspath('corrected_jac.nii.gz') - nb.Nifti1Image(corrected.astype('float32'), img.affine, img.header).to_filename(out_file) + resampled = apply_dynamic_unwarp( + distorted, + fmap_dynamic, + pe_dir=pe_direction, + ro_time=readout_time, + jacobian=jacobian, + num_threads=num_threads, + ) + out_file = os.path.abspath('corrected.nii.gz') + resampled.to_filename(out_file) return out_file diff --git a/sdcflows/workflows/apply/tests/test_dynamic.py b/sdcflows/workflows/apply/tests/test_dynamic.py index bf7007a394..74a116fbce 100644 --- a/sdcflows/workflows/apply/tests/test_dynamic.py +++ b/sdcflows/workflows/apply/tests/test_dynamic.py @@ -20,7 +20,7 @@ # # https://www.nipreps.org/community/licensing/ # -"""Tests for the per-volume MEDIC apply workflow.""" +"""Tests for the per-volume dynamic apply workflow.""" from json import loads from pathlib import Path @@ -48,7 +48,7 @@ def _truncate_to_volumes(in_files, volumes, dest): def test_dynamic_unwarp_construct(): - """Build the workflow without warpkit installed and verify shape.""" + """Build the workflow and verify shape.""" wf = init_dynamic_unwarp_wf() assert wf.name == 'dynamic_unwarp_wf' @@ -60,68 +60,91 @@ def test_dynamic_unwarp_construct(): 'corrected', 'corrected_ref', 'corrected_mask', - 'fieldwarp', } - for node_name in ('rotime', 'convert_fmap', 'apply_warp', 'jac_correct', 'average'): + for node_name in ('rotime', 'unwarp', 'average'): assert wf.get_node(node_name) is not None, f'missing node {node_name!r}' -def test_dynamic_unwarp_jacobian_disabled(): - """``jacobian=False`` drops the correction node; default is True.""" +def test_dynamic_unwarp_jacobian_flag_propagates(): + """The ``jacobian`` ctor flag forwards to the per-volume resampler.""" wf = init_dynamic_unwarp_wf(jacobian=False) - assert wf.get_node('jac_correct') is None - assert wf.get_node('apply_warp') is not None + unwarp = wf.get_node('unwarp') + assert unwarp.inputs.jacobian is False + wf = init_dynamic_unwarp_wf(jacobian=True) + unwarp = wf.get_node('unwarp') + assert unwarp.inputs.jacobian is True -def test_apply_jacobian_preserves_signal(tmp_path, monkeypatch): - """End-to-end check on the helper: |J| = 1 + dVSM/dPE for a Hz ramp. - A linear Hz ramp along PE produces a constant gradient, hence a - constant Jacobian. Multiplying a uniform image by it must give - that constant everywhere — a sanity check the formula and sign - plumbing match :func:`sdcflows.transform.fieldmap_jacobian`. +def test_apply_dynamic_unwarp_matches_static(tmp_path, monkeypatch): + """For a 4D fmap with identical frames, per-volume resampling matches the + static path frame-by-frame. + + This pins :func:`sdcflows.transform.apply_dynamic_unwarp` to the same + Hz→VSM + scipy.ndimage convention as the rest of the codebase — if the + static path ever changes its sign or pe_info handling, this test catches + the drift. """ import nibabel as nb import numpy as np - from sdcflows.transform import fieldmap_jacobian - from sdcflows.workflows.apply.dynamic import _apply_jacobian + from sdcflows.transform import _sdc_unwarp, apply_dynamic_unwarp + from sdcflows.utils.tools import ensure_positive_cosines monkeypatch.chdir(tmp_path) - shape = (4, 6, 4, 2) + rng = np.random.default_rng(0) + shape = (5, 7, 5) + n_frames = 3 affine = np.eye(4) - # Hz ramp along axis j (PE='j'); 1 Hz per voxel along j. - j_idx = np.arange(shape[1], dtype='float32') - fmap_hz = np.broadcast_to(j_idx[None, :, None, None], shape).astype('float32') - distorted = np.ones(shape, dtype='float32') + + fmap_3d = rng.normal(scale=0.5, size=shape).astype('float32') + fmap_4d = np.broadcast_to(fmap_3d[..., None], (*shape, n_frames)).astype('float32') + distorted = rng.normal(size=(*shape, n_frames)).astype('float32') distorted_path = tmp_path / 'distorted.nii.gz' - fmap_path = tmp_path / 'fmap_dynamic.nii.gz' + fmap_path = tmp_path / 'fmap.nii.gz' nb.Nifti1Image(distorted, affine).to_filename(distorted_path) - nb.Nifti1Image(fmap_hz, affine).to_filename(fmap_path) - - out = _apply_jacobian( - in_file=str(distorted_path), - fmap_dynamic=str(fmap_path), - pe_direction='j', - readout_time=0.5, + nb.Nifti1Image(fmap_4d, affine).to_filename(fmap_path) + + resampled = apply_dynamic_unwarp( + str(distorted_path), + str(fmap_path), + pe_dir='j', + ro_time=0.1, + jacobian=True, + order=1, + prefilter=False, + num_threads=1, + allow_negative=True, ) - out_data = nb.load(out).get_fdata() - # |J| = 1 + d(0.5*j_idx)/dj = 1 + 0.5 everywhere (interior). - expected = fieldmap_jacobian(fmap_hz, 0.5, pe_axis=1) - assert np.allclose(out_data, expected, atol=1e-5) - - # Sign flip with j-: |J| = 1 - 0.5 - out_neg = _apply_jacobian( - in_file=str(distorted_path), - fmap_dynamic=str(fmap_path), - pe_direction='j-', - readout_time=0.5, + out_data = np.asanyarray(resampled.dataobj) + + # Run the same primitive directly, per-frame, with no parallelism. + img, axcodes = ensure_positive_cosines(nb.load(str(distorted_path))) + voxcoords = np.indices(shape, dtype='float32') + pe_axis = 'ijk'.index('j') + flip = (axcodes[pe_axis] in 'LPI') ^ False + pe_info = (pe_axis, -0.1 if flip else 0.1) + expected = np.stack( + [ + _sdc_unwarp( + distorted[..., t], + voxcoords.copy(), + pe_info, + None, + jacobian=True, + fmap_hz=fmap_3d, + output_dtype='float32', + order=1, + prefilter=False, + ) + for t in range(n_frames) + ], + axis=-1, ) - expected_neg = fieldmap_jacobian(fmap_hz, -0.5, pe_axis=1) - assert np.allclose(nb.load(out_neg).get_fdata(), expected_neg, atol=1e-5) + assert np.allclose(out_data, expected, atol=1e-5) # Mirror of ``MEDIC_FIXTURES`` in ``test_medic.py``. Kept duplicated rather than From eb2dec323df7e9fb446b6f19747279170686f5b0 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Fri, 15 May 2026 21:38:15 -0500 Subject: [PATCH 29/48] [FIX] medic: unify static and dynamic fmap outputs ``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. --- sdcflows/workflows/apply/dynamic.py | 14 +-- .../workflows/apply/tests/test_dynamic.py | 2 +- sdcflows/workflows/base.py | 59 ++++------ sdcflows/workflows/fit/medic.py | 107 +++--------------- sdcflows/workflows/fit/tests/test_medic.py | 49 +------- sdcflows/workflows/outputs.py | 85 +------------- sdcflows/workflows/tests/test_outputs.py | 54 ++------- 7 files changed, 58 insertions(+), 312 deletions(-) diff --git a/sdcflows/workflows/apply/dynamic.py b/sdcflows/workflows/apply/dynamic.py index a84e25d30b..2a5f790773 100644 --- a/sdcflows/workflows/apply/dynamic.py +++ b/sdcflows/workflows/apply/dynamic.py @@ -34,7 +34,7 @@ from nipype.pipeline import engine as pe from niworkflows.engine.workflows import LiterateWorkflow as Workflow -INPUT_FIELDS = ('distorted', 'metadata', 'fmap_dynamic') +INPUT_FIELDS = ('distorted', 'metadata', 'fmap') def init_dynamic_unwarp_wf( @@ -69,11 +69,11 @@ def init_dynamic_unwarp_wf( Inputs ------ distorted : :obj:`str` - 4D EPI series to unwarp. Must share frame count with ``fmap_dynamic``. + 4D EPI series to unwarp. Must share frame count with ``fmap``. metadata : :obj:`dict` BIDS sidecar metadata. ``TotalReadoutTime`` and ``PhaseEncodingDirection`` are required. - fmap_dynamic : :obj:`str` + fmap : :obj:`str` 4D B\ :sub:`0` field map in Hz, already on the EPI grid (typically from :func:`~sdcflows.workflows.fit.medic.init_medic_wf`). @@ -111,7 +111,7 @@ def init_dynamic_unwarp_wf( niu.Function( input_names=[ 'distorted', - 'fmap_dynamic', + 'fmap', 'pe_direction', 'readout_time', 'jacobian', @@ -134,7 +134,7 @@ def init_dynamic_unwarp_wf( (inputnode, rotime, [('distorted', 'in_file'), ('metadata', 'metadata')]), (inputnode, unwarp, [('distorted', 'distorted'), - ('fmap_dynamic', 'fmap_dynamic')]), + ('fmap', 'fmap')]), (rotime, unwarp, [('pe_direction', 'pe_direction'), ('readout_time', 'readout_time')]), (unwarp, average, [('out_file', 'in_file')]), @@ -150,7 +150,7 @@ def init_dynamic_unwarp_wf( return workflow -def _dynamic_unwarp(distorted, fmap_dynamic, pe_direction, readout_time, jacobian, num_threads): +def _dynamic_unwarp(distorted, fmap, pe_direction, readout_time, jacobian, num_threads): """Resample a 4D EPI through a per-frame 4D Hz fieldmap on the same grid. Uses :func:`~sdcflows.transform.apply_dynamic_unwarp`, which delegates @@ -163,7 +163,7 @@ def _dynamic_unwarp(distorted, fmap_dynamic, pe_direction, readout_time, jacobia resampled = apply_dynamic_unwarp( distorted, - fmap_dynamic, + fmap, pe_dir=pe_direction, ro_time=readout_time, jacobian=jacobian, diff --git a/sdcflows/workflows/apply/tests/test_dynamic.py b/sdcflows/workflows/apply/tests/test_dynamic.py index 74a116fbce..c14871c913 100644 --- a/sdcflows/workflows/apply/tests/test_dynamic.py +++ b/sdcflows/workflows/apply/tests/test_dynamic.py @@ -204,7 +204,7 @@ def test_dynamic_unwarp_run(tmpdir, datadir, workdir, dataset, pattern): from niworkflows.engine.workflows import LiterateWorkflow as Workflow wf = Workflow(name=f'medic_apply_{magnitude_files[0].stem.replace(".nii", "")}') - wf.connect([(fit_wf, apply_wf, [('outputnode.fmap_dynamic', 'inputnode.fmap_dynamic')])]) + wf.connect([(fit_wf, apply_wf, [('outputnode.fmap', 'inputnode.fmap')])]) if workdir: wf.base_dir = str(workdir) diff --git a/sdcflows/workflows/base.py b/sdcflows/workflows/base.py index cc52c75330..5036a733f9 100644 --- a/sdcflows/workflows/base.py +++ b/sdcflows/workflows/base.py @@ -102,9 +102,6 @@ def init_fmap_preproc_wf( 'fmap_mask', 'fmap_id', 'method', - 'fmap_dynamic', - 'fmap_dynamic_ref', - 'fmap_dynamic_mask', ) out_merge = {f: pe.Node(niu.Merge(len(estimators)), name=f'out_merge_{f}') for f in out_fields} # Fieldmaps and coefficient files can come in pairs, ensure they are not flattened @@ -146,12 +143,13 @@ def init_fmap_preproc_wf( ) out_map.inputs.fmap_id = estimator.bids_id + # MEDIC emits a 4D fieldmap directly on the EPI grid; no B-spline + # coefficient representation is produced for it. is_medic = estimator.method == EstimatorType.MEDIC fmap_derivatives_wf = init_fmap_derivatives_wf( output_dir=str(output_dir), - write_coeff=True, + write_coeff=not is_medic, write_mask=True, - write_dynamic=is_medic, bids_fmap_id=estimator.bids_id, name=f'fmap_derivatives_wf_{estimator.sanitized_id}', ) @@ -176,13 +174,25 @@ def init_fmap_preproc_wf( (inputnode, est_wf, [(f, f"inputnode.{f}") for f in fields]) ]) # fmt:skip + deriv_conns = [ + ('outputnode.fmap', 'inputnode.fieldmap'), + ('outputnode.fmap_ref', 'inputnode.fmap_ref'), + ('outputnode.fmap_mask', 'inputnode.fmap_mask'), + ] + out_map_conns = [ + ('outputnode.fieldmap', 'fmap'), + ('outputnode.fmap_ref', 'fmap_ref'), + ('outputnode.fmap_mask', 'fmap_mask'), + ] + if not is_medic: + deriv_conns.append(('outputnode.fmap_coeff', 'inputnode.fmap_coeff')) + out_map_conns.append(('outputnode.fmap_coeff', 'fmap_coeff')) + else: + # Keep the merge node aligned across estimators that don't emit coeffs. + out_map.inputs.fmap_coeff = None + workflow.connect([ - (est_wf, fmap_derivatives_wf, [ - ("outputnode.fmap", "inputnode.fieldmap"), - ("outputnode.fmap_ref", "inputnode.fmap_ref"), - ("outputnode.fmap_coeff", "inputnode.fmap_coeff"), - ("outputnode.fmap_mask", "inputnode.fmap_mask"), - ]), + (est_wf, fmap_derivatives_wf, deriv_conns), (est_wf, fmap_reports_wf, [ ("outputnode.fmap", "inputnode.fieldmap"), ("outputnode.fmap_ref", "inputnode.fmap_ref"), @@ -191,34 +201,9 @@ def init_fmap_preproc_wf( (est_wf, out_map, [ ("outputnode.method", "method") ]), - (fmap_derivatives_wf, out_map, [ - ("outputnode.fieldmap", "fmap"), - ("outputnode.fmap_ref", "fmap_ref"), - ("outputnode.fmap_coeff", "fmap_coeff"), - ("outputnode.fmap_mask", "fmap_mask"), - ]), + (fmap_derivatives_wf, out_map, out_map_conns), ]) # fmt:skip - if is_medic: - workflow.connect([ - (est_wf, fmap_derivatives_wf, [ - ('outputnode.fmap_dynamic', 'inputnode.fmap_dynamic'), - ('outputnode.fmap_dynamic_ref', 'inputnode.fmap_dynamic_ref'), - ('outputnode.fmap_dynamic_mask', 'inputnode.fmap_dynamic_mask'), - ]), - (fmap_derivatives_wf, out_map, [ - ('outputnode.fmap_dynamic', 'fmap_dynamic'), - ('outputnode.fmap_dynamic_ref', 'fmap_dynamic_ref'), - ('outputnode.fmap_dynamic_mask', 'fmap_dynamic_mask'), - ]), - ]) # fmt:skip - else: - # Keep the dynamic merge nodes aligned with len(estimators) so the - # outputnode shape is uniform across non-MEDIC estimators. - out_map.inputs.fmap_dynamic = None - out_map.inputs.fmap_dynamic_ref = None - out_map.inputs.fmap_dynamic_mask = None - for field, mergenode in out_merge.items(): workflow.connect(out_map, field, mergenode, f'in{n}') diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index a4a693ae68..e4cedceb17 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -27,7 +27,8 @@ license. Installing ``sdcflows[warpkit]`` opts the user into those terms; the default ``sdcflows`` install never imports warpkit. Importing this module does not require warpkit — the dependency is only resolved when the -:class:`~sdcflows.interfaces.warpkit.MEDIC` interface actually runs. +:class:`~sdcflows.interfaces.warpkit.UnwrapPhase` and +:class:`~sdcflows.interfaces.warpkit.ComputeFieldmap` interfaces actually run. """ from nipype.interfaces import utility as niu @@ -67,10 +68,10 @@ def init_medic_wf( omp_nthreads : :obj:`int` Maximum number of threads warpkit may use. sloppy : :obj:`bool` - Lower the B-spline ``zooms_min`` for a faster, lower-resolution - spline fit. Affects the static ``fmap`` only. + Currently unused for MEDIC; accepted for parity with other + ``init_*_wf`` constructors. debug : :obj:`bool` - Pass through to :class:`~sdcflows.interfaces.warpkit.MEDIC`. + Pass through to :class:`~sdcflows.interfaces.warpkit.UnwrapPhase`. name : :obj:`str` Workflow name. @@ -87,45 +88,21 @@ def init_medic_wf( Outputs ------- fmap : :obj:`str` - Static :math:`B_0` map in Hz: a B-spline approximation of the - temporal mean of the dynamic series, on the EPI grid. Emitted - as a compatibility shim for the existing apply path — see - ``fmap_coeff`` below for context. - fmap_dynamic : :obj:`str` - 4D :math:`B_0` map in Hz, one volume per timepoint (undistorted - space). The actual MEDIC output; consumers wanting per-volume - distortion correction should use this instead of ``fmap``. + 4D :math:`B_0` map in Hz, one volume per timepoint, already on the + EPI grid. Consumers must dispatch on dimensionality (3D for static + estimators, 4D for MEDIC) when applying. fmap_ref : :obj:`str` Brain-extracted magnitude reference (first echo, temporal mean). - fmap_dynamic_ref : :obj:`str` - 4D first-echo magnitude (one volume per timepoint), for QC of - per-volume correction. Untouched passthrough of the input. fmap_mask : :obj:`str` Static binary brain mask co-registered with ``fmap_ref``. - fmap_dynamic_mask : :obj:`str` - 4D per-frame brain mask emitted by warpkit's phase-unwrapping - stage; tracks subject motion frame-by-frame. - fmap_coeff : :obj:`str` or :obj:`list` of :obj:`str` - B-spline coefficients fit to the static ``fmap``. **Emitted as a - compatibility shim**, not because it adds scientific value: the - existing :func:`~sdcflows.workflows.apply.correction.init_unwarp_wf` - consumes B-spline coefficients (via - :class:`~sdcflows.interfaces.bspline.ApplyCoeffsField`), so MEDIC - outputs need this representation to flow through the current apply - pipeline. The spline fit is redundant for MEDIC — its fieldmap is - already on the EPI grid (no resampling motivation) and warpkit's - internal SVD filter already smooths each frame. A future - MEDIC-aware apply workflow consuming ``fmap_dynamic`` directly - should obsolete this output for MEDIC pipelines. method : :obj:`str` Short description string. """ # Project-internal imports only — none of these load warpkit at module - # import time. The warpkit dependency is resolved lazily inside - # MEDIC._run_interface, so this workflow can be constructed (and the - # module can be imported) without warpkit installed. - from ...interfaces.bspline import DEFAULT_HF_ZOOMS_MM, BSplineApprox + # import time. The warpkit dependency is resolved lazily inside the + # MEDIC interfaces at run time. + del sloppy, use_metadata_estimates, fallback_total_readout_time from ...interfaces.warpkit import ComputeFieldmap, UnwrapPhase from .fieldmap import init_magnitude_wf @@ -137,12 +114,8 @@ def init_medic_wf( niu.IdentityInterface( fields=[ 'fmap', - 'fmap_dynamic', 'fmap_ref', - 'fmap_dynamic_ref', 'fmap_mask', - 'fmap_dynamic_mask', - 'fmap_coeff', 'method', ], ), @@ -165,7 +138,7 @@ def init_medic_wf( # Two-stage warpkit path: UnwrapPhase exposes per-frame masks, which # ComputeFieldmap then consumes. The one-shot MEDIC interface bundles - # both but hides the masks; we want them for ``fmap_dynamic_mask``. + # both but doesn't materially differ for the fieldmap outputs we need. unwrap = pe.Node( UnwrapPhase(n_cpus=omp_nthreads, debug=debug), name='unwrap', @@ -180,22 +153,7 @@ def init_medic_wf( n_procs=omp_nthreads, ) - # The 4D dynamic Hz fieldmap is the real MEDIC output — exposed below - # as ``fmap_dynamic`` for consumers that want per-volume correction. - # We also reduce it to 3D via temporal mean for the static ``fmap`` / - # B-spline path, which the rest of sdcflows expects. - fmap_mean = pe.Node( - niu.Function( - input_names=['in_file'], - output_names=['out_file'], - function=_temporal_mean, - ), - name='fmap_mean', - run_without_submitting=True, - ) - - # First-echo magnitude. The 4D file is the dynamic reference passthrough - # (``fmap_dynamic_ref``); ``init_magnitude_wf`` then time-averages it via + # First-echo magnitude. ``init_magnitude_wf`` time-averages it via # IntraModalMerge before brain extraction for ``fmap_ref`` / ``fmap_mask``. pick_mag1 = pe.Node( niu.Function( @@ -208,18 +166,6 @@ def init_medic_wf( ) magnitude_wf = init_magnitude_wf(omp_nthreads=omp_nthreads) - # B-spline fit on the static fieldmap. Compatibility shim for the - # existing init_unwarp_wf, which only consumes B-spline coefficients - # (see ApplyCoeffsField). For MEDIC the spline adds no scientific - # value — the fieldmap is already on the EPI grid and warpkit's SVD - # filter has already smoothed it. See the ``fmap_coeff`` doc above. - bs_filter = pe.Node(BSplineApprox(), name='bs_filter') - bs_filter.interface._always_run = debug - bs_filter.inputs.bs_spacing = [DEFAULT_HF_ZOOMS_MM] - bs_filter.inputs.extrapolate = not debug - if sloppy: - bs_filter.inputs.zooms_min = 4.0 - # fmt: off workflow.connect([ (inputnode, extract_meta, [('metadata', 'metadata')]), @@ -234,22 +180,13 @@ def init_medic_wf( ('total_readout_time', 'total_readout_time'), ('phase_encoding_direction', 'phase_encoding_direction'), ]), - (compute_fmap, fmap_mean, [('fieldmap', 'in_file')]), - (compute_fmap, outputnode, [('fieldmap', 'fmap_dynamic')]), - (unwrap, outputnode, [('masks', 'fmap_dynamic_mask')]), + (compute_fmap, outputnode, [('fieldmap', 'fmap')]), (inputnode, pick_mag1, [('magnitude', 'in_list')]), - (pick_mag1, outputnode, [('out_file', 'fmap_dynamic_ref')]), (pick_mag1, magnitude_wf, [('out_file', 'inputnode.magnitude')]), (magnitude_wf, outputnode, [ ('outputnode.fmap_ref', 'fmap_ref'), ('outputnode.fmap_mask', 'fmap_mask'), ]), - (magnitude_wf, bs_filter, [('outputnode.fmap_mask', 'in_mask')]), - (fmap_mean, bs_filter, [('out_file', 'in_data')]), - (bs_filter, outputnode, [ - ('out_extrapolated' if not debug else 'out_field', 'fmap'), - ('out_coeff', 'fmap_coeff'), - ]), ]) # fmt: on @@ -275,21 +212,5 @@ def _unpack_metadata(metadata): return echo_times, total_readout_time, phase_encoding_direction -def _temporal_mean(in_file): - """Average a 4D NIfTI across time, returning a 3D volume.""" - import os - - import nibabel as nb - import numpy as np - - img = nb.load(in_file) - data = np.asanyarray(img.dataobj) - if data.ndim == 4: - data = data.mean(axis=3) - out_file = os.path.abspath('fmap_mean.nii.gz') - nb.Nifti1Image(data, img.affine, img.header).to_filename(out_file) - return out_file - - def _first(in_list): return in_list[0] if in_list else None diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py index 9f5a199cb3..8a9dd41a55 100644 --- a/sdcflows/workflows/fit/tests/test_medic.py +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -27,7 +27,7 @@ import pytest -from ..medic import INPUT_FIELDS, _first, _temporal_mean, _unpack_metadata, init_medic_wf +from ..medic import INPUT_FIELDS, _first, _unpack_metadata, init_medic_wf # A handful of timepoints is enough to exercise the full per-volume MEDIC # path; the source datasets ship 200+ volumes × 5 echoes × mag+phase, which @@ -64,12 +64,8 @@ def test_medic_construct(): assert set(inputnode.outputs.copyable_trait_names()) >= set(INPUT_FIELDS) assert set(outputnode.inputs.copyable_trait_names()) >= { 'fmap', - 'fmap_dynamic', 'fmap_ref', - 'fmap_dynamic_ref', 'fmap_mask', - 'fmap_dynamic_mask', - 'fmap_coeff', 'method', } # method is set unconditionally at construction. @@ -80,10 +76,8 @@ def test_medic_construct(): 'extract_meta', 'unwrap', 'compute_fmap', - 'fmap_mean', 'pick_mag1', 'magnitude_wf', - 'bs_filter', ): assert wf.get_node(name) is not None, f'missing node {name!r}' @@ -119,53 +113,12 @@ def test_unpack_metadata_rejects_empty(): _unpack_metadata([]) -def test_medic_wf_sloppy_sets_zooms_min(): - """``sloppy=True`` lowers the B-spline ``zooms_min`` for the static path.""" - wf = init_medic_wf(sloppy=True) - bs_filter = wf.get_node('bs_filter') - assert bs_filter.inputs.zooms_min == 4.0 - - def test_first_helper(): """``_first`` returns the head of the list or ``None`` when empty.""" assert _first(['a', 'b', 'c']) == 'a' assert _first([]) is None -def test_temporal_mean_collapses_4d(tmp_path, monkeypatch): - """``_temporal_mean`` averages along the time axis and emits a 3D NIfTI.""" - import nibabel as nb - import numpy as np - - monkeypatch.chdir(tmp_path) - - data = np.stack( - [np.ones((3, 4, 2), dtype='float32') * scalar for scalar in (1.0, 3.0, 5.0)], - axis=-1, - ) - in_file = tmp_path / 'in.nii.gz' - nb.Nifti1Image(data, np.eye(4)).to_filename(in_file) - - out_file = _temporal_mean(str(in_file)) - out_img = nb.load(out_file) - assert out_img.ndim == 3 - assert np.allclose(np.asanyarray(out_img.dataobj), 3.0) - - -def test_temporal_mean_passthrough_3d(tmp_path, monkeypatch): - """3D inputs are written back unchanged (no axis to average over).""" - import nibabel as nb - import numpy as np - - monkeypatch.chdir(tmp_path) - data = np.full((3, 4, 2), 7.0, dtype='float32') - in_file = tmp_path / 'in3d.nii.gz' - nb.Nifti1Image(data, np.eye(4)).to_filename(in_file) - - out_file = _temporal_mean(str(in_file)) - assert np.allclose(np.asanyarray(nb.load(out_file).dataobj), 7.0) - - # Each entry is (dataset, mag_glob_under_dataset). # Add new fixtures here — the test self-skips when a dataset isn't on disk. MEDIC_FIXTURES = [ diff --git a/sdcflows/workflows/outputs.py b/sdcflows/workflows/outputs.py index 38eb98f721..116e3b60d7 100644 --- a/sdcflows/workflows/outputs.py +++ b/sdcflows/workflows/outputs.py @@ -124,7 +124,6 @@ def init_fmap_derivatives_wf( name='fmap_derivatives_wf', write_coeff=False, write_mask=False, - write_dynamic=False, ): """ Set up datasinks to store derivatives in the right location. @@ -141,9 +140,6 @@ def init_fmap_derivatives_wf( Workflow name (default: ``"fmap_derivatives_wf"``) write_coeff : :obj:`bool` Build the workflow path to map coefficients into target space. - write_dynamic : :obj:`bool` - Build the workflow path to write the per-volume dynamic fieldmap, - magnitude reference, and brain-mask series. Used by MEDIC. Inputs ------ @@ -151,16 +147,11 @@ def init_fmap_derivatives_wf( One or more fieldmap file(s) of the BIDS dataset that will serve for naming reference. fieldmap The preprocessed fieldmap, in its original space with Hz units. + Can be 3D (static estimators) or 4D (dynamic estimators such as MEDIC). fmap_coeff Field coefficient(s) file(s) fmap_ref An anatomical reference (e.g., magnitude file) - fmap_dynamic - 4D per-volume fieldmap (Hz). Only consumed when ``write_dynamic``. - fmap_dynamic_ref - 4D per-volume magnitude reference. Only consumed when ``write_dynamic``. - fmap_dynamic_mask - 4D per-volume brain mask. Only consumed when ``write_dynamic``. """ custom_entities = custom_entities or {} @@ -177,9 +168,6 @@ def init_fmap_derivatives_wf( 'fmap_ref', 'fmap_mask', 'fmap_meta', - 'fmap_dynamic', - 'fmap_dynamic_ref', - 'fmap_dynamic_mask', ] ), name='inputnode', @@ -191,15 +179,15 @@ def init_fmap_derivatives_wf( 'fmap_coeff', 'fmap_ref', 'fmap_mask', - 'fmap_dynamic', - 'fmap_dynamic_ref', - 'fmap_dynamic_mask', ] ), name='outputnode', ) - merge_fmap = pe.Node(MergeSeries(), name='merge_fmap') + # ``allow_4D`` lets MEDIC's 4D Hz fieldmap pass through alongside the + # 3D outputs of the static estimators — MergeSeries splits 4D inputs + # into per-frame 3D and re-concatenates them. + merge_fmap = pe.Node(MergeSeries(allow_4D=True), name='merge_fmap') ds_reference = pe.Node( DerivativesDataSink( @@ -269,69 +257,6 @@ def init_fmap_derivatives_wf( (ds_mask, outputnode, [("out_file", "fmap_mask")]), ]) # fmt:skip - if write_dynamic: - # 4D Hz fieldmap (one volume per timepoint, undistorted space). - ds_fmap_dynamic = pe.Node( - DerivativesDataSink( - base_directory=output_dir, - desc='dynamic', - suffix='fieldmap', - datatype='fmap', - compress=True, - allowed_entities=tuple(custom_entities), - ), - name='ds_fmap_dynamic', - ) - ds_fmap_dynamic.inputs.Units = 'Hz' - if bids_fmap_id: - ds_fmap_dynamic.inputs.B0FieldIdentifier = bids_fmap_id - ds_fmap_dynamic.inputs.trait_set(**custom_entities) - - # 4D first-echo magnitude reference (raw passthrough, for QC). - # NOTE: suffix is `fieldmap` (not `magnitude`) because niworkflows' - # nipreps.json only ships fmap path patterns for suffix. - # Distinguished from the Hz fieldmap by desc='dynamicref'. - ds_fmap_dynamic_ref = pe.Node( - DerivativesDataSink( - base_directory=output_dir, - desc='dynamicref', - suffix='fieldmap', - datatype='fmap', - compress=True, - dismiss_entities=('fmap', 'task'), - allowed_entities=tuple(custom_entities), - ), - name='ds_fmap_dynamic_ref', - ) - ds_fmap_dynamic_ref.inputs.trait_set(**custom_entities) - - # 4D per-frame brain mask. - ds_fmap_dynamic_mask = pe.Node( - DerivativesDataSink( - base_directory=output_dir, - desc='dynamicbrain', - suffix='mask', - datatype='fmap', - compress=True, - dismiss_entities=('fmap', 'task'), - allowed_entities=tuple(custom_entities), - ), - name='ds_fmap_dynamic_mask', - ) - ds_fmap_dynamic_mask.inputs.trait_set(**custom_entities) - - workflow.connect([ - (inputnode, ds_fmap_dynamic, [('source_files', 'source_file'), - ('fmap_dynamic', 'in_file')]), - (inputnode, ds_fmap_dynamic_ref, [('source_files', 'source_file'), - ('fmap_dynamic_ref', 'in_file')]), - (inputnode, ds_fmap_dynamic_mask, [('source_files', 'source_file'), - ('fmap_dynamic_mask', 'in_file')]), - (ds_fmap_dynamic, outputnode, [('out_file', 'fmap_dynamic')]), - (ds_fmap_dynamic_ref, outputnode, [('out_file', 'fmap_dynamic_ref')]), - (ds_fmap_dynamic_mask, outputnode, [('out_file', 'fmap_dynamic_mask')]), - ]) # fmt:skip - if not write_coeff: return workflow diff --git a/sdcflows/workflows/tests/test_outputs.py b/sdcflows/workflows/tests/test_outputs.py index b1400fe57c..024786eca6 100644 --- a/sdcflows/workflows/tests/test_outputs.py +++ b/sdcflows/workflows/tests/test_outputs.py @@ -30,52 +30,14 @@ def test_fmap_derivatives_wf_default(tmp_path): wf = init_fmap_derivatives_wf(output_dir=str(tmp_path)) assert wf.get_node('ds_fieldmap') is not None assert wf.get_node('ds_reference') is not None - # write_dynamic / write_mask off by default. + # write_mask off by default. assert wf.get_node('ds_mask') is None - assert wf.get_node('ds_fmap_dynamic') is None - assert wf.get_node('ds_fmap_dynamic_ref') is None - assert wf.get_node('ds_fmap_dynamic_mask') is None -def test_fmap_derivatives_wf_write_dynamic(tmp_path): - """``write_dynamic=True`` adds the 4D MEDIC sinks and tags the fieldmap.""" - wf = init_fmap_derivatives_wf( - output_dir=str(tmp_path), - write_dynamic=True, - bids_fmap_id='medic_0', - ) - - ds_fmap_dynamic = wf.get_node('ds_fmap_dynamic') - ds_fmap_dynamic_ref = wf.get_node('ds_fmap_dynamic_ref') - ds_fmap_dynamic_mask = wf.get_node('ds_fmap_dynamic_mask') - assert ds_fmap_dynamic is not None - assert ds_fmap_dynamic_ref is not None - assert ds_fmap_dynamic_mask is not None - - # The Hz dynamic sink carries Units + B0FieldIdentifier so downstream - # consumers can join it to the same B0 group as the static fieldmap. - assert ds_fmap_dynamic.inputs.Units == 'Hz' - assert ds_fmap_dynamic.inputs.B0FieldIdentifier == 'medic_0' - - # `desc` distinguishes the three 4D outputs at the BIDS-path level — - # niworkflows' nipreps.json only has fmap path patterns for the - # fieldmap/mask suffixes, so the magnitude ref reuses the fieldmap suffix. - assert ds_fmap_dynamic.inputs.desc == 'dynamic' - assert ds_fmap_dynamic.inputs.suffix == 'fieldmap' - assert ds_fmap_dynamic_ref.inputs.desc == 'dynamicref' - assert ds_fmap_dynamic_ref.inputs.suffix == 'fieldmap' - assert ds_fmap_dynamic_mask.inputs.desc == 'dynamicbrain' - assert ds_fmap_dynamic_mask.inputs.suffix == 'mask' - - # Each dynamic sink should land under the fmap/ datatype. - for node in (ds_fmap_dynamic, ds_fmap_dynamic_ref, ds_fmap_dynamic_mask): - assert node.inputs.datatype == 'fmap' - - -def test_fmap_derivatives_wf_write_dynamic_no_b0id(tmp_path): - """Without ``bids_fmap_id``, the Hz dynamic sink omits B0FieldIdentifier.""" - wf = init_fmap_derivatives_wf(output_dir=str(tmp_path), write_dynamic=True) - ds_fmap_dynamic = wf.get_node('ds_fmap_dynamic') - # ``B0FieldIdentifier`` is set as a dynamic trait only when bids_fmap_id is - # provided; without it the attribute should not exist on the sink inputs. - assert not hasattr(ds_fmap_dynamic.inputs, 'B0FieldIdentifier') +def test_fmap_derivatives_wf_merge_fmap_allows_4d(tmp_path): + """``merge_fmap`` must accept 4D inputs so MEDIC's per-frame Hz fmap flows + through the same sink as the static estimators' 3D fmaps.""" + wf = init_fmap_derivatives_wf(output_dir=str(tmp_path)) + merge_fmap = wf.get_node('merge_fmap') + assert merge_fmap is not None + assert merge_fmap.inputs.allow_4D is True From f859fbc2e0c7daa747d023016eeee709656b8816 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sun, 17 May 2026 18:55:06 -0500 Subject: [PATCH 30/48] [ENH] medic: per-frame fmap_ref/fmap_mask via init_dynamic_magnitude_wf 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. --- sdcflows/workflows/fit/fieldmap.py | 90 ++++++++++++++++++++++++++++++ sdcflows/workflows/fit/medic.py | 15 +++-- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/sdcflows/workflows/fit/fieldmap.py b/sdcflows/workflows/fit/fieldmap.py index 106f69f53f..83ab504688 100644 --- a/sdcflows/workflows/fit/fieldmap.py +++ b/sdcflows/workflows/fit/fieldmap.py @@ -261,6 +261,96 @@ def init_magnitude_wf(omp_nthreads, name='magnitude_wf'): return workflow +def init_dynamic_magnitude_wf(omp_nthreads, name='dynamic_magnitude_wf'): + """ + Per-frame counterpart to :func:`init_magnitude_wf` for 4D magnitude inputs. + + Splits a 4D magnitude series into per-frame 3D volumes, runs N4 + + skull-stripping on each frame, and concatenates the per-frame outputs + back into 4D so the resulting ``fmap_ref`` and ``fmap_mask`` track a + time-varying fieldmap (typically MEDIC). + + Workflow Graph + .. workflow :: + :graph2use: orig + :simple_form: yes + + from sdcflows.workflows.fit.fieldmap import init_dynamic_magnitude_wf + wf = init_dynamic_magnitude_wf(omp_nthreads=6) + + Parameters + ---------- + omp_nthreads : :obj:`int` + Maximum number of threads an individual process may use. + name : :obj:`str` + Name of workflow (default: ``dynamic_magnitude_wf``). + + Inputs + ------ + magnitude : :obj:`os.PathLike` + Path to a 4D magnitude series (one volume per timepoint). + + Outputs + ------- + fmap_ref : :obj:`os.PathLike` + 4D anatomical reference: per-frame N4-corrected and clipped magnitude. + fmap_mask : :obj:`os.PathLike` + 4D binary brain mask: one mask per frame, aligned with ``fmap_ref``. + + """ + from nipype.interfaces.ants import N4BiasFieldCorrection + from niworkflows.interfaces.nibabel import IntensityClip, MergeSeries, SplitSeries + + from ...interfaces.brainmask import BrainExtraction + + workflow = Workflow(name=name) + inputnode = pe.Node(niu.IdentityInterface(fields=['magnitude']), name='inputnode') + outputnode = pe.Node( + niu.IdentityInterface(fields=['fmap_ref', 'fmap_mask']), + name='outputnode', + ) + + split = pe.Node(SplitSeries(), name='split', run_without_submitting=True) + + clipper_pre = pe.MapNode(IntensityClip(), iterfield=['in_file'], name='clipper_pre') + n4 = pe.MapNode( + N4BiasFieldCorrection( + dimension=3, + copy_header=True, + n_iterations=[50] * 5, + convergence_threshold=1e-7, + shrink_factor=4, + ), + iterfield=['input_image'], + n_procs=omp_nthreads, + name='n4', + ) + clipper_post = pe.MapNode( + IntensityClip(p_min=0.01, p_max=99.9), + iterfield=['in_file'], + name='clipper_post', + ) + masker = pe.MapNode(BrainExtraction(), iterfield=['in_file'], name='masker') + + merge_ref = pe.Node(MergeSeries(), name='merge_ref') + merge_mask = pe.Node(MergeSeries(), name='merge_mask') + + # fmt: off + workflow.connect([ + (inputnode, split, [('magnitude', 'in_file')]), + (split, clipper_pre, [('out_files', 'in_file')]), + (clipper_pre, n4, [('out_file', 'input_image')]), + (n4, clipper_post, [('output_image', 'in_file')]), + (clipper_post, masker, [('out_file', 'in_file')]), + (clipper_post, merge_ref, [('out_file', 'in_files')]), + (masker, merge_mask, [('out_mask', 'in_files')]), + (merge_ref, outputnode, [('out_file', 'fmap_ref')]), + (merge_mask, outputnode, [('out_file', 'fmap_mask')]), + ]) + # fmt: on + return workflow + + def init_phdiff_wf(omp_nthreads, debug=False, name='phdiff_wf'): r""" Generate a :math:`B_0` field from consecutive-phases and phase-difference maps. diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index e4cedceb17..fca7878824 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -92,9 +92,11 @@ def init_medic_wf( EPI grid. Consumers must dispatch on dimensionality (3D for static estimators, 4D for MEDIC) when applying. fmap_ref : :obj:`str` - Brain-extracted magnitude reference (first echo, temporal mean). + 4D brain-extracted magnitude reference (first echo), with one volume + per timepoint matching ``fmap``. fmap_mask : :obj:`str` - Static binary brain mask co-registered with ``fmap_ref``. + 4D binary brain mask (one mask per timepoint) co-registered with + ``fmap_ref``. method : :obj:`str` Short description string. @@ -104,7 +106,7 @@ def init_medic_wf( # MEDIC interfaces at run time. del sloppy, use_metadata_estimates, fallback_total_readout_time from ...interfaces.warpkit import ComputeFieldmap, UnwrapPhase - from .fieldmap import init_magnitude_wf + from .fieldmap import init_dynamic_magnitude_wf workflow = Workflow(name=name) workflow.__desc__ = _MEDIC_DESC @@ -153,8 +155,9 @@ def init_medic_wf( n_procs=omp_nthreads, ) - # First-echo magnitude. ``init_magnitude_wf`` time-averages it via - # IntraModalMerge before brain extraction for ``fmap_ref`` / ``fmap_mask``. + # First-echo magnitude. ``init_dynamic_magnitude_wf`` splits the 4D + # series into per-frame volumes, brain-extracts each, and re-concatenates + # so ``fmap_ref`` / ``fmap_mask`` track the per-volume MEDIC fieldmap. pick_mag1 = pe.Node( niu.Function( input_names=['in_list'], @@ -164,7 +167,7 @@ def init_medic_wf( name='pick_mag1', run_without_submitting=True, ) - magnitude_wf = init_magnitude_wf(omp_nthreads=omp_nthreads) + magnitude_wf = init_dynamic_magnitude_wf(omp_nthreads=omp_nthreads, name='magnitude_wf') # fmt: off workflow.connect([ From 2dc8ad74a5a567861b65e75c6c182052b37805a7 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Thu, 21 May 2026 22:54:50 -0500 Subject: [PATCH 31/48] [TEST] medic: cover FieldmapEstimation MEDIC reject branches + document 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. --- sdcflows/tests/test_fieldmaps.py | 45 ++++++++++++++++++++++++++++++++ sdcflows/workflows/fit/medic.py | 12 +++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/sdcflows/tests/test_fieldmaps.py b/sdcflows/tests/test_fieldmaps.py index 383376b988..44376137db 100644 --- a/sdcflows/tests/test_fieldmaps.py +++ b/sdcflows/tests/test_fieldmaps.py @@ -333,6 +333,51 @@ def test_FieldmapEstimation_missing_files(tmpdir, dsA_dir): ) +def _make_medic_files(dest, source_path, specs): + """Copy a NIfTI into part/echo-tagged filenames under ``dest``. + + Returns the list of :class:`~sdcflows.fieldmaps.FieldmapFile`s built from + those paths with the minimum metadata required for MEDIC source files. + """ + base_meta = {'PhaseEncodingDirection': 'j', 'TotalReadoutTime': 0.05} + files = [] + for echo, part in specs: + name = f'sub-01_task-rest_echo-{echo}_part-{part}_bold.nii.gz' + dst = dest / name + shutil.copy(str(source_path), str(dst)) + files.append( + fm.FieldmapFile( + str(dst), + metadata={**base_meta, 'EchoTime': 0.01 * echo}, + ) + ) + return files + + +def test_FieldmapEstimation_MEDIC_requires_both_parts(tmp_path, dsA_dir): + """MEDIC sources tagged only ``part-phase`` must reject in the part guard.""" + src = dsA_dir / 'sub-01' / 'func' / 'sub-01_task-rest_bold.nii.gz' + files = _make_medic_files(tmp_path, src, [(1, 'phase'), (2, 'phase')]) + with pytest.raises(ValueError, match='MEDIC requires every source'): + fm.FieldmapEstimation(files) + + +def test_FieldmapEstimation_MEDIC_single_echo(tmp_path, dsA_dir): + """One mag+phase pair (single echo) must reject in the echo-count guard.""" + src = dsA_dir / 'sub-01' / 'func' / 'sub-01_task-rest_bold.nii.gz' + files = _make_medic_files(tmp_path, src, [(1, 'mag'), (1, 'phase')]) + with pytest.raises(ValueError, match='at least two echoes of phase'): + fm.FieldmapEstimation(files) + + +def test_FieldmapEstimation_MEDIC_mismatched_pairs(tmp_path, dsA_dir): + """Unequal magnitude / phase echo counts must reject in the pairing guard.""" + src = dsA_dir / 'sub-01' / 'func' / 'sub-01_task-rest_bold.nii.gz' + files = _make_medic_files(tmp_path, src, [(1, 'phase'), (2, 'phase'), (1, 'mag')]) + with pytest.raises(ValueError, match='matched magnitude/phase pairs'): + fm.FieldmapEstimation(files) + + def test_FieldmapFile_filename(tmp_path, dsA_dir): datadir = tmp_path / 'phasediff' datadir.mkdir(exist_ok=True) diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index fca7878824..d79e6dda93 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -68,12 +68,20 @@ def init_medic_wf( omp_nthreads : :obj:`int` Maximum number of threads warpkit may use. sloppy : :obj:`bool` - Currently unused for MEDIC; accepted for parity with other - ``init_*_wf`` constructors. + Accepted for parity with other ``init_*_wf`` constructors; currently + unused for MEDIC. debug : :obj:`bool` Pass through to :class:`~sdcflows.interfaces.warpkit.UnwrapPhase`. name : :obj:`str` Workflow name. + use_metadata_estimates : :obj:`bool` + Accepted for parity with other ``init_*_wf`` constructors; currently + unused for MEDIC, which reads ``TotalReadoutTime`` straight from the + per-echo BIDS sidecars rather than going through + :func:`~sdcflows.utils.epimanip.get_trt`. + fallback_total_readout_time : :obj:`float`, optional + Accepted for parity with other ``init_*_wf`` constructors; currently + unused for MEDIC. Inputs ------ From 2df1289920f9fe77a58b42c420ec8549ff2fb7d5 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Thu, 21 May 2026 23:00:50 -0500 Subject: [PATCH 32/48] [REF] fmap_preproc: gate static-vs-dynamic branching on FieldmapEstimation.is_dynamic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- sdcflows/fieldmaps.py | 11 +++++++++++ sdcflows/tests/test_fieldmaps.py | 18 ++++++++++++++++++ sdcflows/workflows/base.py | 10 +++++----- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/sdcflows/fieldmaps.py b/sdcflows/fieldmaps.py index 6943525897..722deb6044 100644 --- a/sdcflows/fieldmaps.py +++ b/sdcflows/fieldmaps.py @@ -76,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.""" @@ -487,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)) diff --git a/sdcflows/tests/test_fieldmaps.py b/sdcflows/tests/test_fieldmaps.py index 44376137db..aefba6fbb6 100644 --- a/sdcflows/tests/test_fieldmaps.py +++ b/sdcflows/tests/test_fieldmaps.py @@ -378,6 +378,24 @@ def test_FieldmapEstimation_MEDIC_mismatched_pairs(tmp_path, dsA_dir): fm.FieldmapEstimation(files) +def test_FieldmapEstimation_is_dynamic(tmp_path, dsA_dir): + """``is_dynamic`` flags MEDIC (4D fmap on EPI grid) and not the static estimators.""" + src = dsA_dir / 'sub-01' / 'func' / 'sub-01_task-rest_bold.nii.gz' + medic_files = _make_medic_files( + tmp_path, src, [(1, 'mag'), (1, 'phase'), (2, 'mag'), (2, 'phase')] + ) + assert fm.FieldmapEstimation(medic_files).is_dynamic is True + + sub_dir = dsA_dir / 'sub-01' + phasediff = fm.FieldmapEstimation( + [ + sub_dir / 'fmap/sub-01_phase1.nii.gz', + sub_dir / 'fmap/sub-01_phase2.nii.gz', + ] + ) + assert phasediff.is_dynamic is False + + def test_FieldmapFile_filename(tmp_path, dsA_dir): datadir = tmp_path / 'phasediff' datadir.mkdir(exist_ok=True) diff --git a/sdcflows/workflows/base.py b/sdcflows/workflows/base.py index 5036a733f9..bc281805b9 100644 --- a/sdcflows/workflows/base.py +++ b/sdcflows/workflows/base.py @@ -143,12 +143,12 @@ def init_fmap_preproc_wf( ) out_map.inputs.fmap_id = estimator.bids_id - # MEDIC emits a 4D fieldmap directly on the EPI grid; no B-spline - # coefficient representation is produced for it. - is_medic = estimator.method == EstimatorType.MEDIC + # Dynamic estimators (currently MEDIC) emit a 4D fieldmap directly on + # the EPI grid; no B-spline coefficient representation is produced. + is_dynamic = estimator.is_dynamic fmap_derivatives_wf = init_fmap_derivatives_wf( output_dir=str(output_dir), - write_coeff=not is_medic, + write_coeff=not is_dynamic, write_mask=True, bids_fmap_id=estimator.bids_id, name=f'fmap_derivatives_wf_{estimator.sanitized_id}', @@ -184,7 +184,7 @@ def init_fmap_preproc_wf( ('outputnode.fmap_ref', 'fmap_ref'), ('outputnode.fmap_mask', 'fmap_mask'), ] - if not is_medic: + if not is_dynamic: deriv_conns.append(('outputnode.fmap_coeff', 'inputnode.fmap_coeff')) out_map_conns.append(('outputnode.fmap_coeff', 'fmap_coeff')) else: From b10c8c3dda00dfcc12fb1bd9ee5d799fc8fc496a Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Thu, 21 May 2026 23:26:17 -0500 Subject: [PATCH 33/48] [TEST] wrangler: pin MEDIC discovery trigger matrix side-by-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- sdcflows/utils/tests/test_wrangler.py | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/sdcflows/utils/tests/test_wrangler.py b/sdcflows/utils/tests/test_wrangler.py index d272c51e54..d26d72975b 100644 --- a/sdcflows/utils/tests/test_wrangler.py +++ b/sdcflows/utils/tests/test_wrangler.py @@ -574,6 +574,46 @@ def test_wrangler_force_medic_without_intended_for(tmp_path, force_medic, expect clear_registry() +@pytest.mark.parametrize( + ('skeleton', 'force_medic', 'expected'), + [ + # A: IntendedFor present, default discovery picks up one MEDIC per session. + (medic, False, 3), + # B: no IntendedFor + force_medic override picks up one MEDIC per session. + (medic_no_intended_for, True, 3), + # C: baseline — no IntendedFor, no override, no fmapless: nothing should fire. + (medic_no_intended_for, False, 0), + ], + ids=['default-IntendedFor', 'force_medic', 'baseline-no-trigger'], +) +def test_wrangler_medic_trigger(tmp_path, skeleton, force_medic, expected): + """The three trigger modes of MEDIC discovery, side-by-side. + + * **default-IntendedFor**: complex BOLD sidecars carry ``IntendedFor`` and + the default discovery path picks them up. + * **force_medic**: sidecars carry no ``IntendedFor`` — the explicit flag + short-circuits the default gate. + * **baseline-no-trigger**: no metadata, no override, no fmapless fallback + — MEDIC must refuse to fire so that runs without the expected metadata + are not silently picked up. + """ + bids_dir = str(tmp_path / 'medic_trigger') + generate_bids_skeleton(bids_dir, skeleton) + layout = gen_layout(bids_dir) + estimators = find_estimators( + layout=layout, + subject='01', + fmapless=False, + force_medic=force_medic, + ) + assert len(estimators) == expected + for estimator in estimators: + assert estimator.method.name == 'MEDIC' + # 3 echoes × {mag, phase} per session. + assert len(estimator.sources) == 6 + clear_registry() + + def test_single_reverse_pedir(tmp_path): bids_dir = tmp_path / 'bids' generate_bids_skeleton(bids_dir, pepolar) From 3510b338b7071f0f8a31301746166ce51d0a9f78 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Fri, 22 May 2026 20:38:22 -0500 Subject: [PATCH 34/48] [MAINT] zenodo: add Andrew Van (Washington University in St. Louis) as contributor --- .zenodo.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.zenodo.json b/.zenodo.json index 9894144510..8f155af06d 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -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": [ From c11137bf08bfe5194deee3f11d281b85aee899aa Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 25 May 2026 12:55:31 -0500 Subject: [PATCH 35/48] [CI] free runner disk before data-cache-v3 restore 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. --- .github/workflows/build-test-publish.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 1df931a099..6e60b44163 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -85,6 +85,15 @@ jobs: dependencies: "pre" steps: + - 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 - uses: actions/checkout@v5 - uses: actions/cache@v4 with: From 584b6f6e574e85359ccf1ed50935522137993631 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 25 May 2026 12:59:25 -0500 Subject: [PATCH 36/48] [CI] pin free-disk-space to v1.3.1 --- .github/workflows/build-test-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 6e60b44163..c5759cbae5 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -86,7 +86,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@main + uses: jlumbroso/free-disk-space@v1.3.1 with: tool-cache: false android: true From e468ce44fa137cf1176891e6c6f6a6c12dca6b47 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Sat, 30 May 2026 00:13:59 -0500 Subject: [PATCH 37/48] [MAINT] bump warpkit minimum to 1.2.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 871a58178c..e7c663671d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ test = [ # clean-Apache. # See: https://github.com/vanandrew/warpkit/blob/main/LICENSE warpkit = [ - "warpkit >= 1.2.1; python_version >= '3.11'", + "warpkit >= 1.2.2; python_version >= '3.11'", ] # Aliases From 9ff76216bb44c7e3e34c8ba1f86a9a69c427123a Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 1 Jun 2026 20:30:09 -0500 Subject: [PATCH 38/48] [REF] medic: fmap_ref from raw first-echo magnitude, fmap_mask from warpkit 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. --- sdcflows/workflows/fit/fieldmap.py | 90 ---------------------- sdcflows/workflows/fit/medic.py | 24 +++--- sdcflows/workflows/fit/tests/test_medic.py | 1 - 3 files changed, 10 insertions(+), 105 deletions(-) diff --git a/sdcflows/workflows/fit/fieldmap.py b/sdcflows/workflows/fit/fieldmap.py index 83ab504688..106f69f53f 100644 --- a/sdcflows/workflows/fit/fieldmap.py +++ b/sdcflows/workflows/fit/fieldmap.py @@ -261,96 +261,6 @@ def init_magnitude_wf(omp_nthreads, name='magnitude_wf'): return workflow -def init_dynamic_magnitude_wf(omp_nthreads, name='dynamic_magnitude_wf'): - """ - Per-frame counterpart to :func:`init_magnitude_wf` for 4D magnitude inputs. - - Splits a 4D magnitude series into per-frame 3D volumes, runs N4 + - skull-stripping on each frame, and concatenates the per-frame outputs - back into 4D so the resulting ``fmap_ref`` and ``fmap_mask`` track a - time-varying fieldmap (typically MEDIC). - - Workflow Graph - .. workflow :: - :graph2use: orig - :simple_form: yes - - from sdcflows.workflows.fit.fieldmap import init_dynamic_magnitude_wf - wf = init_dynamic_magnitude_wf(omp_nthreads=6) - - Parameters - ---------- - omp_nthreads : :obj:`int` - Maximum number of threads an individual process may use. - name : :obj:`str` - Name of workflow (default: ``dynamic_magnitude_wf``). - - Inputs - ------ - magnitude : :obj:`os.PathLike` - Path to a 4D magnitude series (one volume per timepoint). - - Outputs - ------- - fmap_ref : :obj:`os.PathLike` - 4D anatomical reference: per-frame N4-corrected and clipped magnitude. - fmap_mask : :obj:`os.PathLike` - 4D binary brain mask: one mask per frame, aligned with ``fmap_ref``. - - """ - from nipype.interfaces.ants import N4BiasFieldCorrection - from niworkflows.interfaces.nibabel import IntensityClip, MergeSeries, SplitSeries - - from ...interfaces.brainmask import BrainExtraction - - workflow = Workflow(name=name) - inputnode = pe.Node(niu.IdentityInterface(fields=['magnitude']), name='inputnode') - outputnode = pe.Node( - niu.IdentityInterface(fields=['fmap_ref', 'fmap_mask']), - name='outputnode', - ) - - split = pe.Node(SplitSeries(), name='split', run_without_submitting=True) - - clipper_pre = pe.MapNode(IntensityClip(), iterfield=['in_file'], name='clipper_pre') - n4 = pe.MapNode( - N4BiasFieldCorrection( - dimension=3, - copy_header=True, - n_iterations=[50] * 5, - convergence_threshold=1e-7, - shrink_factor=4, - ), - iterfield=['input_image'], - n_procs=omp_nthreads, - name='n4', - ) - clipper_post = pe.MapNode( - IntensityClip(p_min=0.01, p_max=99.9), - iterfield=['in_file'], - name='clipper_post', - ) - masker = pe.MapNode(BrainExtraction(), iterfield=['in_file'], name='masker') - - merge_ref = pe.Node(MergeSeries(), name='merge_ref') - merge_mask = pe.Node(MergeSeries(), name='merge_mask') - - # fmt: off - workflow.connect([ - (inputnode, split, [('magnitude', 'in_file')]), - (split, clipper_pre, [('out_files', 'in_file')]), - (clipper_pre, n4, [('out_file', 'input_image')]), - (n4, clipper_post, [('output_image', 'in_file')]), - (clipper_post, masker, [('out_file', 'in_file')]), - (clipper_post, merge_ref, [('out_file', 'in_files')]), - (masker, merge_mask, [('out_mask', 'in_files')]), - (merge_ref, outputnode, [('out_file', 'fmap_ref')]), - (merge_mask, outputnode, [('out_file', 'fmap_mask')]), - ]) - # fmt: on - return workflow - - def init_phdiff_wf(omp_nthreads, debug=False, name='phdiff_wf'): r""" Generate a :math:`B_0` field from consecutive-phases and phase-difference maps. diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index d79e6dda93..d03ba73f93 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -100,11 +100,11 @@ def init_medic_wf( EPI grid. Consumers must dispatch on dimensionality (3D for static estimators, 4D for MEDIC) when applying. fmap_ref : :obj:`str` - 4D brain-extracted magnitude reference (first echo), with one volume - per timepoint matching ``fmap``. + First-echo magnitude series, unprocessed: one volume per timepoint + matching ``fmap``. fmap_mask : :obj:`str` - 4D binary brain mask (one mask per timepoint) co-registered with - ``fmap_ref``. + 4D binary brain mask (one mask per timepoint) as produced by MEDIC + (``warpkit``), aligned with ``fmap``. method : :obj:`str` Short description string. @@ -114,7 +114,6 @@ def init_medic_wf( # MEDIC interfaces at run time. del sloppy, use_metadata_estimates, fallback_total_readout_time from ...interfaces.warpkit import ComputeFieldmap, UnwrapPhase - from .fieldmap import init_dynamic_magnitude_wf workflow = Workflow(name=name) workflow.__desc__ = _MEDIC_DESC @@ -163,9 +162,10 @@ def init_medic_wf( n_procs=omp_nthreads, ) - # First-echo magnitude. ``init_dynamic_magnitude_wf`` splits the 4D - # series into per-frame volumes, brain-extracts each, and re-concatenates - # so ``fmap_ref`` / ``fmap_mask`` track the per-volume MEDIC fieldmap. + # ``fmap_ref`` is just the first-echo magnitude series, passed through + # untouched. ``fmap_mask`` reuses the per-frame masks MEDIC already + # computes during phase unwrapping, so both track the per-volume fieldmap + # without any extra N4/skull-strip work. pick_mag1 = pe.Node( niu.Function( input_names=['in_list'], @@ -175,7 +175,6 @@ def init_medic_wf( name='pick_mag1', run_without_submitting=True, ) - magnitude_wf = init_dynamic_magnitude_wf(omp_nthreads=omp_nthreads, name='magnitude_wf') # fmt: off workflow.connect([ @@ -193,11 +192,8 @@ def init_medic_wf( ]), (compute_fmap, outputnode, [('fieldmap', 'fmap')]), (inputnode, pick_mag1, [('magnitude', 'in_list')]), - (pick_mag1, magnitude_wf, [('out_file', 'inputnode.magnitude')]), - (magnitude_wf, outputnode, [ - ('outputnode.fmap_ref', 'fmap_ref'), - ('outputnode.fmap_mask', 'fmap_mask'), - ]), + (pick_mag1, outputnode, [('out_file', 'fmap_ref')]), + (unwrap, outputnode, [('masks', 'fmap_mask')]), ]) # fmt: on diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py index 8a9dd41a55..0d328d94fc 100644 --- a/sdcflows/workflows/fit/tests/test_medic.py +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -77,7 +77,6 @@ def test_medic_construct(): 'unwrap', 'compute_fmap', 'pick_mag1', - 'magnitude_wf', ): assert wf.get_node(name) is not None, f'missing node {name!r}' From 0bcccae9da66ba6f68dbed224d3b53bd740356d8 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Mon, 1 Jun 2026 20:30:25 -0500 Subject: [PATCH 39/48] [ENH] wrangler: BIDS-intent-driven MEDIC discovery, no_medic flag, MEDIC-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. --- sdcflows/cli/main.py | 1 + sdcflows/cli/parser.py | 8 ++ sdcflows/config.py | 2 + sdcflows/utils/tests/test_wrangler.py | 157 ++++++++++++++++++-------- sdcflows/utils/wrangler.py | 77 +++++++++---- 5 files changed, 176 insertions(+), 69 deletions(-) diff --git a/sdcflows/cli/main.py b/sdcflows/cli/main.py index 9336903f31..dfeb0fb04b 100644 --- a/sdcflows/cli/main.py +++ b/sdcflows/cli/main.py @@ -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, ) diff --git a/sdcflows/cli/parser.py b/sdcflows/cli/parser.py index ed89836d1f..c8551d8c32 100644 --- a/sdcflows/cli/parser.py +++ b/sdcflows/cli/parser.py @@ -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', diff --git a/sdcflows/config.py b/sdcflows/config.py index 98a660c7dd..a0b6c3bb09 100644 --- a/sdcflows/config.py +++ b/sdcflows/config.py @@ -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' diff --git a/sdcflows/utils/tests/test_wrangler.py b/sdcflows/utils/tests/test_wrangler.py index d26d72975b..b944d9f5fd 100644 --- a/sdcflows/utils/tests/test_wrangler.py +++ b/sdcflows/utils/tests/test_wrangler.py @@ -433,13 +433,20 @@ def gen_layout(bids_dir, database_dir=None): } -def _build_medic_skeleton(*, with_intended_for: bool = True): +def _build_medic_skeleton(*, intent: str | None = 'intended_for'): """Generate a 3-session × 3-echo × {mag,phase} BIDS skeleton for MEDIC. - When ``with_intended_for`` is ``True``, each complex BOLD carries an - ``IntendedFor`` listing all 6 mag/phase siblings in its session so the - wrangler picks the run up via the default discovery path. Setting it to - ``False`` produces the no-metadata case exercised by ``force_medic``. + MEDIC is discovered only from BIDS intent metadata, never from file + structure alone. ``intent`` selects how that intent is expressed on each + complex BOLD: + + * ``"b0_identifier"`` — the BIDS-RECOMMENDED route: a self-referential + ``B0FieldIdentifier``/``B0FieldSource`` (the pattern BIDS endorses for + images that estimate their own B0 field). + * ``"intended_for"`` — the legacy route: ``IntendedFor`` listing the run's + 6 mag/phase siblings. + * ``None`` — no intent metadata at all, to confirm MEDIC does **not** fire + on structure alone. """ echo_times = {'1': 0.0142, '2': 0.03893, '3': 0.06366} sessions = [] @@ -461,8 +468,17 @@ def _build_medic_skeleton(*, with_intended_for: bool = True): 'TotalReadoutTime': 0.5, 'PhaseEncodingDirection': 'j', } - if with_intended_for: + if intent == 'intended_for': metadata['IntendedFor'] = intended_for + elif intent == 'b0_identifier': + # Every mag+phase echo is an *input* to the estimation, so + # all carry B0FieldIdentifier. Only the magnitude echoes are + # *corrected* analysis targets, so B0FieldSource sits on mag + # alone (the pepolar-style self-correction pattern). + b0_id = f'medic_ses{ses}' + metadata['B0FieldIdentifier'] = b0_id + if part == 'mag': + metadata['B0FieldSource'] = b0_id func.append( { 'task': 'rest', @@ -482,8 +498,9 @@ def _build_medic_skeleton(*, with_intended_for: bool = True): return {'01': sessions} -medic = _build_medic_skeleton() -medic_no_intended_for = _build_medic_skeleton(with_intended_for=False) +medic = _build_medic_skeleton(intent='intended_for') +medic_b0_identifier = _build_medic_skeleton(intent='b0_identifier') +medic_no_intent = _build_medic_skeleton(intent=None) filters = { @@ -552,50 +569,43 @@ def test_wrangler_URIs(tmpdir, name, skeleton, session, estimations, total_estim clear_registry() -@pytest.mark.parametrize( - 'force_medic, expected', - [ - (False, 0), # no IntendedFor, no B0FieldIdentifier → nothing found - (True, 3), # ``force_medic`` discovers all three sessions - ], -) -def test_wrangler_force_medic_without_intended_for(tmp_path, force_medic, expected): - bids_dir = str(tmp_path / 'medic_no_intended') - generate_bids_skeleton(bids_dir, medic_no_intended_for) +def test_wrangler_medic_no_intent_does_not_fire(tmp_path): + """Structure alone must not trigger MEDIC. + + A complex multi-echo BOLD with no ``B0FieldIdentifier`` and no + ``IntendedFor`` carries no BIDS intent, so MEDIC must not be discovered + (``fmapless=False`` rules out the ANAT fallback, isolating the MEDIC path). + """ + bids_dir = str(tmp_path / 'medic_no_intent') + generate_bids_skeleton(bids_dir, medic_no_intent) layout = gen_layout(bids_dir) - # ``fmapless=False`` disables the SyN-SDC ANAT fallback so this test only - # exercises the MEDIC discovery path. - est = find_estimators(layout=layout, subject='01', fmapless=False, force_medic=force_medic) - assert len(est) == expected - if force_medic: - # Each estimator should claim all 6 (3 echoes × {mag, phase}) for its session. - for estimator in est: - assert len(estimator.sources) == 6 + est = find_estimators(layout=layout, subject='01', fmapless=False) + assert est == [] clear_registry() @pytest.mark.parametrize( - ('skeleton', 'force_medic', 'expected'), + ('skeleton', 'no_medic', 'expected'), [ - # A: IntendedFor present, default discovery picks up one MEDIC per session. + # A: BIDS-recommended route — self-referential B0FieldIdentifier. + (medic_b0_identifier, False, 3), + # B: legacy route — IntendedFor on the complex BOLD sidecars. (medic, False, 3), - # B: no IntendedFor + force_medic override picks up one MEDIC per session. - (medic_no_intended_for, True, 3), - # C: baseline — no IntendedFor, no override, no fmapless: nothing should fire. - (medic_no_intended_for, False, 0), + # C/D: ``no_medic`` suppresses discovery via either route. + (medic_b0_identifier, True, 0), + (medic, True, 0), ], - ids=['default-IntendedFor', 'force_medic', 'baseline-no-trigger'], + ids=['b0-identifier', 'intended-for', 'no_medic-b0', 'no_medic-intended-for'], ) -def test_wrangler_medic_trigger(tmp_path, skeleton, force_medic, expected): - """The three trigger modes of MEDIC discovery, side-by-side. - - * **default-IntendedFor**: complex BOLD sidecars carry ``IntendedFor`` and - the default discovery path picks them up. - * **force_medic**: sidecars carry no ``IntendedFor`` — the explicit flag - short-circuits the default gate. - * **baseline-no-trigger**: no metadata, no override, no fmapless fallback - — MEDIC must refuse to fire so that runs without the expected metadata - are not silently picked up. +def test_wrangler_medic_trigger(tmp_path, skeleton, no_medic, expected): + """Metadata-driven MEDIC discovery and the ``no_medic`` override. + + * **b0-identifier**: complex BOLD carries a self-referential + ``B0FieldIdentifier`` (the BIDS-recommended route); discovered via Step 1. + * **intended-for**: complex BOLD carries ``IntendedFor`` (legacy route); + discovered via the dedicated MEDIC block. + * **no_medic-***: ``no_medic=True`` skips MEDIC via either route, so + nothing fires (``fmapless=False`` rules out the ANAT fallback). """ bids_dir = str(tmp_path / 'medic_trigger') generate_bids_skeleton(bids_dir, skeleton) @@ -604,7 +614,7 @@ def test_wrangler_medic_trigger(tmp_path, skeleton, force_medic, expected): layout=layout, subject='01', fmapless=False, - force_medic=force_medic, + no_medic=no_medic, ) assert len(estimators) == expected for estimator in estimators: @@ -614,6 +624,65 @@ def test_wrangler_medic_trigger(tmp_path, skeleton, force_medic, expected): clear_registry() +def test_wrangler_medic_ordered_first(tmp_path): + """MEDIC precedes static estimators in the returned list. + + Consumers (fMRIPrep) walk this list and select the first applicable + estimator per target, so a dynamic MEDIC estimator must come before a + coexisting static fieldmap. Here a PEPOLAR pair and a complex multi-echo + BOLD each carry their own ``B0FieldIdentifier``; both are discovered and + MEDIC must sort first regardless of ``B0FieldIdentifier`` iteration order. + """ + skeleton = { + '01': [ + { + 'anat': [{'suffix': 'T1w', 'metadata': {'EchoTime': 1}}], + 'func': [ + { + 'task': 'rest', + 'run': run, + 'suffix': 'bold', + 'metadata': { + 'RepetitionTime': 0.8, + 'TotalReadoutTime': 0.5, + 'PhaseEncodingDirection': ped, + 'B0FieldIdentifier': 'pepolar1', + 'B0FieldSource': 'pepolar1', + }, + } + for run, ped in ((1, 'j'), (2, 'j-')) + ] + + [ + { + 'task': 'medic', + 'echo': echo, + 'part': part, + 'suffix': 'bold', + 'metadata': { + 'EchoTime': te, + 'RepetitionTime': 0.8, + 'TotalReadoutTime': 0.5, + 'PhaseEncodingDirection': 'j', + 'B0FieldIdentifier': 'medic1', + **({'B0FieldSource': 'medic1'} if part == 'mag' else {}), + }, + } + for echo, te in (('1', 0.0142), ('2', 0.0389)) + for part in ('mag', 'phase') + ], + } + ] + } + bids_dir = str(tmp_path / 'medic_first') + generate_bids_skeleton(bids_dir, skeleton) + layout = gen_layout(bids_dir) + estimators = find_estimators(layout=layout, subject='01', fmapless=False) + methods = [e.method.name for e in estimators] + assert methods[0] == 'MEDIC', methods + assert 'PEPOLAR' in methods + clear_registry() + + def test_single_reverse_pedir(tmp_path): bids_dir = tmp_path / 'bids' generate_bids_skeleton(bids_dir, pepolar) diff --git a/sdcflows/utils/wrangler.py b/sdcflows/utils/wrangler.py index 54d1519f0a..dfd8dd9fb4 100644 --- a/sdcflows/utils/wrangler.py +++ b/sdcflows/utils/wrangler.py @@ -74,7 +74,7 @@ def find_estimators( sessions: list[str] | None = None, fmapless: bool | set = True, force_fmapless: bool = False, - force_medic: bool = False, + no_medic: bool = False, logger: logging.Logger | None = None, bids_filters: dict | None = None, anat_suffix: str | list[str] = 'T1w', @@ -104,14 +104,15 @@ def find_estimators( force_fmapless : :obj:`bool` When some other fieldmap estimation methods have been found, fieldmap-less estimation will be skipped except if ``force_fmapless`` is ``True``. - force_medic : :obj:`bool` - Auto-discover MEDIC estimators from multi-echo BOLD with ``part-mag`` - and ``part-phase`` even when neither ``IntendedFor`` nor - ``B0FieldIdentifier`` is set on the complex BOLD sidecars. Useful for - public datasets that ship complex multi-echo BOLD without the metadata - needed for the default discovery path. Pairing is unambiguous because - the part-mag and part-phase echoes of the same run are the MEDIC - sources by construction. + no_medic : :obj:`bool` + Disable MEDIC discovery entirely. MEDIC is discovered like any other + fieldmap — only from BIDS intent metadata, never from file structure + alone: a complex multi-echo BOLD becomes a MEDIC estimator when it + carries a ``B0FieldIdentifier`` (the self-referential pattern BIDS + endorses for images that estimate their own B0 field, as in + ``pepolar``) or, on legacy datasets, ``IntendedFor``. Setting + ``no_medic=True`` skips those MEDIC estimators (other fieldmaps are + unaffected). logger The logger used to relay messages. If not provided, one will be created. bids_filters @@ -128,6 +129,12 @@ def find_estimators( successfully been built (meaning, all necessary inputs and corresponding metadata are present in the given layout.) + The list is returned in a deterministic order: dynamic (MEDIC) + estimators first — an intentional priority so that consumers selecting + the first applicable estimator per target prefer MEDIC over a + coexisting static fieldmap — then the remaining estimators ordered by + ``bids_id``, with fieldmap-less (ANAT) estimators last. + Examples -------- Our ``ds000054`` dataset, created for *fMRIPrep*, only has one *phasediff* type of fieldmap @@ -383,6 +390,15 @@ def find_estimators( B0FieldIdentifier=f'"{b0_id}"', # Double quotes to match JSON, not Python repr regex_search=True, ) + + if no_medic and any( + fmap.entities.get('part') in ('mag', 'phase') for fmap in bare_ids + listed_ids + ): + # ``part``-tagged BOLD under a B0FieldIdentifier is a MEDIC + # source; ``no_medic`` suppresses it (other identifiers stand). + logger.debug('Skipping B0FieldIdentifier %s (MEDIC; no_medic set)', b0_id) + continue + try: e = fm.FieldmapEstimation( [ @@ -526,27 +542,25 @@ def find_estimators( _log_debug_estimation(logger, e, layout.root) estimators.append(e) - # MEDIC: multi-echo BOLD with mag+phase parts. + # MEDIC: multi-echo BOLD with mag+phase parts — legacy ``IntendedFor`` path. # - # Runs in two modes: - # * Default: ``IntendedFor`` is required on the complex BOLD sidecars, - # matching how the other heuristics gate auto-discovery. Skipped when - # ``B0FieldIdentifier`` was used to build estimators above (those - # paths already let users opt complex multi-echo runs into MEDIC). - # * ``force_medic=True``: ``IntendedFor`` is dropped from the seed - # query. Useful for public datasets that ship complex multi-echo - # BOLD without the metadata needed for the default path. Runs - # regardless of ``B0FieldIdentifier`` so a forced MEDIC can coexist - # with other tagged estimators. - if force_medic or not b0_ids: + # MEDIC is discovered from BIDS intent metadata only, never from file + # structure alone. The primary, BIDS-RECOMMENDED route is + # ``B0FieldIdentifier`` (handled by Step 1 above, where a complex + # multi-echo BOLD tagged with an identifier — the self-referential pattern + # BIDS endorses for images that estimate their own B0 field — is built as a + # MEDIC estimator). This block is the legacy fallback: when no + # ``B0FieldIdentifier`` is present, a complex multi-echo BOLD that declares + # ``IntendedFor`` is picked up here. Skipped entirely when ``no_medic`` is + # set or when ``B0FieldIdentifier`` metadata already drove discovery. + if not no_medic and not b0_ids: medic_seed_query = { **base_entities, 'session': sessions, 'suffix': 'bold', 'echo': Query.REQUIRED, + 'IntendedFor': Query.REQUIRED, } - if not force_medic: - medic_seed_query['IntendedFor'] = Query.REQUIRED # Query both parts as seeds — datasets vary on which side carries # ``IntendedFor`` — and rely on the dedup check below to keep the @@ -573,8 +587,7 @@ def find_estimators( # Dedup against every prior estimator's full source set, not just # the first complex image — pybids ordering is not contractual, # and the same run can be seeded twice (once via phase, once via - # mag) when both parts carry ``IntendedFor`` (or under - # ``force_medic`` when both parts exist on disk). + # mag) when both parts carry ``IntendedFor``. already_claimed = {str(s.path) for est in estimators for s in est.sources} if any(str(c.path) in already_claimed for c in complex_imgs): logger.debug('Skipping MEDIC fmap %s (already in use)', complex_imgs[0].relpath) @@ -598,6 +611,20 @@ def find_estimators( _log_debug_estimation(logger, e, layout.root) estimators.append(e) + # Return estimators in a stable, deterministic order so the same dataset + # always yields the same list (Step 1 iterates a ``set`` of + # ``B0FieldIdentifier``s, whose order is otherwise hash-seed dependent). + # + # The ordering encodes one intentional, documented priority: dynamic + # (MEDIC) estimators come first. A consumer that simply walks this list and + # takes the first estimator applicable to a target therefore prefers MEDIC + # over a coexisting static fieldmap. Ties (and all non-MEDIC estimators) + # are then ordered by ``bids_id`` — which preserves discovery order for the + # auto-named heuristic path (``auto_NNNNN`` ids are monotonic) and is + # alphabetical for explicitly named ``B0FieldIdentifier`` estimators. + # Fieldmap-less ANAT estimators are appended after this point and stay last. + estimators.sort(key=lambda e: (not e.is_dynamic, e.bids_id)) + if estimators and not force_fmapless: fmapless = False From b0a6904c5ceac438be75fd2a4fd0bb49bab68ccf Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Tue, 2 Jun 2026 15:34:14 -0500 Subject: [PATCH 40/48] [FIX] medic: correct MEDIC citation key to van2026medic --- sdcflows/workflows/fit/medic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index d03ba73f93..4bdaddcf57 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -39,7 +39,7 @@ _MEDIC_DESC = """\ A dynamic *B0* nonuniformity map was estimated from multi-echo -magnitude and phase EPI series using MEDIC [@van2023medic], as implemented in +magnitude and phase EPI series using MEDIC [@van2026medic], as implemented in ``warpkit``. """ From ced076d939370aefe7b2c666ea2fb8943ad662b9 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Wed, 10 Jun 2026 18:24:52 -0500 Subject: [PATCH 41/48] [STY] dynamic apply: drop fmt markers for inline skip, prefer pathlib 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. --- sdcflows/interfaces/tests/test_warpkit.py | 2 +- sdcflows/interfaces/warpkit.py | 2 +- sdcflows/workflows/apply/dynamic.py | 10 ++++------ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/sdcflows/interfaces/tests/test_warpkit.py b/sdcflows/interfaces/tests/test_warpkit.py index 9e67453bd5..c0d859ce24 100644 --- a/sdcflows/interfaces/tests/test_warpkit.py +++ b/sdcflows/interfaces/tests/test_warpkit.py @@ -1,7 +1,7 @@ # emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: # -# Copyright 2025 The NiPreps Developers +# Copyright The NiPreps Developers # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/sdcflows/interfaces/warpkit.py b/sdcflows/interfaces/warpkit.py index 45053833e8..1a7d3a3e14 100644 --- a/sdcflows/interfaces/warpkit.py +++ b/sdcflows/interfaces/warpkit.py @@ -1,7 +1,7 @@ # emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: # -# Copyright 2024 The NiPreps Developers +# Copyright The NiPreps Developers # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/sdcflows/workflows/apply/dynamic.py b/sdcflows/workflows/apply/dynamic.py index 2a5f790773..dceb30a419 100644 --- a/sdcflows/workflows/apply/dynamic.py +++ b/sdcflows/workflows/apply/dynamic.py @@ -1,7 +1,7 @@ # emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: # -# Copyright 2025 The NiPreps Developers +# Copyright The NiPreps Developers # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -129,7 +129,6 @@ def init_dynamic_unwarp_wf( average = pe.Node(RobustAverage(mc_method=None), name='average') brainextraction_wf = init_brainextraction_wf() - # fmt: off workflow.connect([ (inputnode, rotime, [('distorted', 'in_file'), ('metadata', 'metadata')]), @@ -144,8 +143,7 @@ def init_dynamic_unwarp_wf( ('outputnode.out_file', 'corrected_ref'), ('outputnode.out_mask', 'corrected_mask'), ]), - ]) - # fmt: on + ]) # fmt:skip return workflow @@ -157,7 +155,7 @@ def _dynamic_unwarp(distorted, fmap, pe_direction, readout_time, jacobian, num_t per-volume resampling to the same scipy-backed primitives that :class:`~sdcflows.transform.B0FieldTransform` uses for the static path. """ - import os + from pathlib import Path from sdcflows.transform import apply_dynamic_unwarp @@ -169,6 +167,6 @@ def _dynamic_unwarp(distorted, fmap, pe_direction, readout_time, jacobian, num_t jacobian=jacobian, num_threads=num_threads, ) - out_file = os.path.abspath('corrected.nii.gz') + out_file = Path('corrected.nii.gz').absolute() resampled.to_filename(out_file) return out_file From ea0892f0f6aaf8eaa95deed78360d687ca044d7c Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Wed, 10 Jun 2026 18:25:03 -0500 Subject: [PATCH 42/48] [REF] medic: fold parity-only kwargs, inline workflow desc 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. --- sdcflows/workflows/fit/medic.py | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index 4bdaddcf57..18f411438d 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -1,7 +1,7 @@ # emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: # -# Copyright 2025 The NiPreps Developers +# Copyright The NiPreps Developers # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -37,20 +37,13 @@ INPUT_FIELDS = ('phase', 'magnitude', 'metadata') -_MEDIC_DESC = """\ -A dynamic *B0* nonuniformity map was estimated from multi-echo -magnitude and phase EPI series using MEDIC [@van2026medic], as implemented in -``warpkit``. -""" - def init_medic_wf( omp_nthreads=1, sloppy=False, debug=False, name='medic_wf', - use_metadata_estimates=False, - fallback_total_readout_time=None, + **kwargs, ): """ Estimate a fieldmap via MEDIC from multi-echo magnitude + phase EPI. @@ -74,14 +67,6 @@ def init_medic_wf( Pass through to :class:`~sdcflows.interfaces.warpkit.UnwrapPhase`. name : :obj:`str` Workflow name. - use_metadata_estimates : :obj:`bool` - Accepted for parity with other ``init_*_wf`` constructors; currently - unused for MEDIC, which reads ``TotalReadoutTime`` straight from the - per-echo BIDS sidecars rather than going through - :func:`~sdcflows.utils.epimanip.get_trt`. - fallback_total_readout_time : :obj:`float`, optional - Accepted for parity with other ``init_*_wf`` constructors; currently - unused for MEDIC. Inputs ------ @@ -112,11 +97,14 @@ def init_medic_wf( # Project-internal imports only — none of these load warpkit at module # import time. The warpkit dependency is resolved lazily inside the # MEDIC interfaces at run time. - del sloppy, use_metadata_estimates, fallback_total_readout_time from ...interfaces.warpkit import ComputeFieldmap, UnwrapPhase workflow = Workflow(name=name) - workflow.__desc__ = _MEDIC_DESC + workflow.__desc__ = """\ +A dynamic *B0* nonuniformity map was estimated from multi-echo +magnitude and phase EPI series using MEDIC [@van2026medic], as implemented in +``warpkit``. +""" inputnode = pe.Node(niu.IdentityInterface(fields=INPUT_FIELDS), name='inputnode') outputnode = pe.Node( From 8800cb36dac9a25c4a53ee2271e3636af3770e97 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Wed, 10 Jun 2026 18:25:11 -0500 Subject: [PATCH 43/48] [TST] medic: share volume/fixture scaffolding via conftest 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. --- sdcflows/conftest.py | 49 +++++++++++++++++++ .../workflows/apply/tests/test_dynamic.py | 45 +++-------------- sdcflows/workflows/fit/tests/test_medic.py | 47 +++--------------- sdcflows/workflows/tests/test_outputs.py | 2 +- 4 files changed, 64 insertions(+), 79 deletions(-) diff --git a/sdcflows/conftest.py b/sdcflows/conftest.py index b0ff3fc950..f9d03085f9 100644 --- a/sdcflows/conftest.py +++ b/sdcflows/conftest.py @@ -135,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 diff --git a/sdcflows/workflows/apply/tests/test_dynamic.py b/sdcflows/workflows/apply/tests/test_dynamic.py index c14871c913..5b51daf906 100644 --- a/sdcflows/workflows/apply/tests/test_dynamic.py +++ b/sdcflows/workflows/apply/tests/test_dynamic.py @@ -1,7 +1,7 @@ # emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: # -# Copyright 2025 The NiPreps Developers +# Copyright The NiPreps Developers # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -29,23 +29,6 @@ from ..dynamic import INPUT_FIELDS, init_dynamic_unwarp_wf -# See test_medic._MEDIC_TEST_VOLUMES — keep these in sync. -_MEDIC_TEST_VOLUMES = 3 - - -def _truncate_to_volumes(in_files, volumes, dest): - import nibabel as nb - - out = [] - for f in in_files: - img = nb.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 - def test_dynamic_unwarp_construct(): """Build the workflow and verify shape.""" @@ -147,25 +130,10 @@ def test_apply_dynamic_unwarp_matches_static(tmp_path, monkeypatch): assert np.allclose(out_data, expected, atol=1e-5) -# Mirror of ``MEDIC_FIXTURES`` in ``test_medic.py``. Kept duplicated rather than -# imported to avoid cross-test-module coupling; update both lists together. -MEDIC_FIXTURES = [ - 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.mark.veryslow -@pytest.mark.parametrize(('dataset', 'pattern'), MEDIC_FIXTURES) -def test_dynamic_unwarp_run(tmpdir, datadir, workdir, dataset, pattern): +def test_dynamic_unwarp_run( + tmpdir, datadir, workdir, medic_fixture, medic_test_volumes, truncate_to_volumes +): """End-to-end run: estimate via MEDIC then apply via this workflow. Skipped without ``warpkit`` or without the multi-echo fixture under @@ -175,6 +143,7 @@ def test_dynamic_unwarp_run(tmpdir, datadir, workdir, dataset, pattern): from sdcflows.workflows.fit.medic import init_medic_wf + dataset, pattern = medic_fixture full_pattern = f'{dataset}/{pattern}' magnitude_files = sorted(Path(datadir).glob(full_pattern)) if not magnitude_files: @@ -188,8 +157,8 @@ def test_dynamic_unwarp_run(tmpdir, datadir, workdir, dataset, pattern): tmpdir.chdir() trunc_dir = Path(str(tmpdir)) / 'trunc' trunc_dir.mkdir(exist_ok=True) - magnitude_files = _truncate_to_volumes(magnitude_files, _MEDIC_TEST_VOLUMES, trunc_dir) - phase_files = _truncate_to_volumes(phase_files, _MEDIC_TEST_VOLUMES, trunc_dir) + magnitude_files = truncate_to_volumes(magnitude_files, medic_test_volumes, trunc_dir) + phase_files = truncate_to_volumes(phase_files, medic_test_volumes, trunc_dir) fit_wf = init_medic_wf(omp_nthreads=2) fit_wf.inputs.inputnode.magnitude = [str(f) for f in magnitude_files] diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py index 0d328d94fc..edbcfbce4c 100644 --- a/sdcflows/workflows/fit/tests/test_medic.py +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -1,7 +1,7 @@ # emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: # -# Copyright 2025 The NiPreps Developers +# Copyright The NiPreps Developers # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -29,25 +29,6 @@ from ..medic import INPUT_FIELDS, _first, _unpack_metadata, init_medic_wf -# A handful of timepoints is enough to exercise the full per-volume MEDIC -# path; the source datasets ship 200+ volumes × 5 echoes × mag+phase, which -# OOM-kills CI runners when xdist schedules these fixtures in parallel. -_MEDIC_TEST_VOLUMES = 3 - - -def _truncate_to_volumes(in_files, volumes, dest): - import nibabel as nb - - out = [] - for f in in_files: - img = nb.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 - def test_medic_construct(): """Build the workflow and verify its surface — no warpkit required. @@ -118,25 +99,10 @@ def test_first_helper(): assert _first([]) is None -# Each entry is (dataset, mag_glob_under_dataset). -# Add new fixtures here — the test self-skips when a dataset isn't on disk. -MEDIC_FIXTURES = [ - 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.mark.veryslow -@pytest.mark.parametrize(('dataset', 'pattern'), MEDIC_FIXTURES) -def test_medic_run(tmpdir, datadir, workdir, outdir, dataset, pattern): +def test_medic_run( + tmpdir, datadir, workdir, outdir, medic_fixture, medic_test_volumes, truncate_to_volumes +): """End-to-end MEDIC run on a real multi-echo BOLD. Skipped if ``warpkit`` is unavailable (default CI install) or if the @@ -153,6 +119,7 @@ def test_medic_run(tmpdir, datadir, workdir, outdir, dataset, pattern): """ pytest.importorskip('warpkit') + dataset, pattern = medic_fixture full_pattern = f'{dataset}/{pattern}' magnitude_files = sorted(Path(datadir).glob(full_pattern)) if not magnitude_files: @@ -166,8 +133,8 @@ def test_medic_run(tmpdir, datadir, workdir, outdir, dataset, pattern): tmpdir.chdir() trunc_dir = Path(str(tmpdir)) / 'trunc' trunc_dir.mkdir(exist_ok=True) - magnitude_files = _truncate_to_volumes(magnitude_files, _MEDIC_TEST_VOLUMES, trunc_dir) - phase_files = _truncate_to_volumes(phase_files, _MEDIC_TEST_VOLUMES, trunc_dir) + magnitude_files = truncate_to_volumes(magnitude_files, medic_test_volumes, trunc_dir) + phase_files = truncate_to_volumes(phase_files, medic_test_volumes, trunc_dir) medic_wf = init_medic_wf(omp_nthreads=2) medic_wf.inputs.inputnode.magnitude = [str(f) for f in magnitude_files] diff --git a/sdcflows/workflows/tests/test_outputs.py b/sdcflows/workflows/tests/test_outputs.py index 024786eca6..a83ece9a98 100644 --- a/sdcflows/workflows/tests/test_outputs.py +++ b/sdcflows/workflows/tests/test_outputs.py @@ -1,7 +1,7 @@ # emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: # -# Copyright 2025 The NiPreps Developers +# Copyright The NiPreps Developers # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. From 0092e438fb5eef7b6434809be27ed96d459356f6 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Wed, 10 Jun 2026 18:36:03 -0500 Subject: [PATCH 44/48] [MAINT] warpkit: promote to core dependency, drop extras group 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. --- pyproject.toml | 12 ++---------- sdcflows/workflows/fit/medic.py | 10 +++++----- sdcflows/workflows/fit/tests/test_medic.py | 6 +++--- tox.ini | 1 - 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e7c663671d..60441bcdf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.2.2; python_version >= '3.11'", ] dynamic = ["version"] @@ -76,16 +78,6 @@ test = [ "pytest-xdist >= 2.5", ] -# warpkit is a Washington University-licensed tool that powers the MEDIC -# workflow. Its license permits non-commercial use only; commercial users -# need a separate agreement with WUSTL OTM. Keep this extra opt-in and -# DO NOT add it to the `all` alias below — the default install must remain -# clean-Apache. -# See: https://github.com/vanandrew/warpkit/blob/main/LICENSE -warpkit = [ - "warpkit >= 1.2.2; python_version >= '3.11'", -] - # Aliases docs = ["sdcflows[doc]"] tests = ["sdcflows[test]"] diff --git a/sdcflows/workflows/fit/medic.py b/sdcflows/workflows/fit/medic.py index 18f411438d..2175994376 100644 --- a/sdcflows/workflows/fit/medic.py +++ b/sdcflows/workflows/fit/medic.py @@ -22,11 +22,11 @@ # """MEDIC dynamic distortion correction (multi-echo phase + magnitude). -Backed by `warpkit `__, which is an -**optional** dependency carrying a Washington University non-commercial -license. Installing ``sdcflows[warpkit]`` opts the user into those terms; -the default ``sdcflows`` install never imports warpkit. Importing this -module does not require warpkit — the dependency is only resolved when the +Backed by `warpkit `__, a standard +``sdcflows`` dependency (on Python >= 3.11) that carries a Washington +University **non-commercial** license — commercial use requires a separate +WUSTL OTM agreement. Importing this module does not require warpkit; the +dependency is only resolved when the :class:`~sdcflows.interfaces.warpkit.UnwrapPhase` and :class:`~sdcflows.interfaces.warpkit.ComputeFieldmap` interfaces actually run. """ diff --git a/sdcflows/workflows/fit/tests/test_medic.py b/sdcflows/workflows/fit/tests/test_medic.py index edbcfbce4c..b171865f62 100644 --- a/sdcflows/workflows/fit/tests/test_medic.py +++ b/sdcflows/workflows/fit/tests/test_medic.py @@ -105,9 +105,9 @@ def test_medic_run( ): """End-to-end MEDIC run on a real multi-echo BOLD. - Skipped if ``warpkit`` is unavailable (default CI install) or if the - expected dataset is not present under ``$TEST_DATA_HOME``. To opt in, - install ``sdcflows[warpkit]`` and stage the dataset, e.g. + Skipped if ``warpkit`` is unavailable (e.g. Python 3.10) or if the + expected dataset is not present under ``$TEST_DATA_HOME``. To run it, + stage the dataset, e.g. .. code-block:: console diff --git a/tox.ini b/tox.ini index 45f62dc11f..3c65377653 100644 --- a/tox.ini +++ b/tox.ini @@ -73,7 +73,6 @@ pass_env = PYTHON_GIL extras = tests - veryslow: warpkit setenv = pre: PIP_EXTRA_INDEX_URL=https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pre: UV_INDEX=https://pypi.anaconda.org/scientific-python-nightly-wheels/simple From 70b0e0c2ff8db8427a56fcf845854151710754d7 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Wed, 10 Jun 2026 18:46:18 -0500 Subject: [PATCH 45/48] [REF] transform: fieldmap_jacobian takes the VSM directly 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. --- sdcflows/transform.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/sdcflows/transform.py b/sdcflows/transform.py index f383da125f..520cb6158b 100644 --- a/sdcflows/transform.py +++ b/sdcflows/transform.py @@ -107,14 +107,13 @@ def _sdc_unwarp( ) if jacobian: - resampled *= fieldmap_jacobian(fmap_hz, pe_info[1], pe_info[0]) + resampled *= fieldmap_jacobian(vsm, pe_info[0]) return resampled def fieldmap_jacobian( - fmap_hz: np.ndarray, - ro_time: float, + vsm: np.ndarray, pe_axis: int, ) -> np.ndarray: r""" @@ -122,25 +121,23 @@ def fieldmap_jacobian( EPI distortion only acts along the phase-encoding axis, so the full 3×3 Jacobian collapses to a diagonal with two 1s and one nontrivial - entry: :math:`|J| \approx 1 + \partial(\mathrm{VSM})/\partial(\mathrm{PE})`, - where the voxel shift map is :math:`\mathrm{VSM} = f_{\mathrm{Hz}}\cdot t_{\mathrm{ro}}` + entry: :math:`|J| \approx 1 + \partial(\mathrm{VSM})/\partial(\mathrm{PE})` (central differences capture the relative shift of neighboring voxels). Multiplying a resampled EPI by this scalar field preserves total signal through the regions that compress and expand under unwarping. Parameters ---------- - fmap_hz : :class:`numpy.ndarray` - :math:`B_0` field in Hz. 3D ``(I, J, K)`` for a static fieldmap, or - 4D ``(I, J, K, T)`` for a per-volume dynamic fieldmap (e.g. MEDIC). - ro_time : :obj:`float` - Signed total readout time (seconds). The sign carries PE polarity - and any data-orientation flip — callers must apply that sign before - passing it in. + vsm : :class:`numpy.ndarray` + Voxel shift map, :math:`\mathrm{VSM} = f_{\mathrm{Hz}}\cdot t_{\mathrm{ro}}`, + in voxel units. 3D ``(I, J, K)`` for a static fieldmap, or 4D + ``(I, J, K, T)`` for a per-volume dynamic fieldmap (e.g. MEDIC). The + readout time already carries PE polarity and any data-orientation + flip, so the sign must be applied before computing the VSM. pe_axis : :obj:`int` Spatial axis index (``0``, ``1`` or ``2``) along which the EPI distorts. """ - return 1 + np.gradient(fmap_hz * ro_time, axis=pe_axis) + return 1 + np.gradient(vsm, axis=pe_axis) async def worker( From a7cf33b373cbd4ee38ecc743d56d52d1a9f322fd Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Thu, 11 Jun 2026 17:25:42 -0500 Subject: [PATCH 46/48] [FIX] dynamic apply: return corrected path as str, not Path 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. --- sdcflows/workflows/apply/dynamic.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdcflows/workflows/apply/dynamic.py b/sdcflows/workflows/apply/dynamic.py index dceb30a419..50c8418c66 100644 --- a/sdcflows/workflows/apply/dynamic.py +++ b/sdcflows/workflows/apply/dynamic.py @@ -167,6 +167,9 @@ def _dynamic_unwarp(distorted, fmap, pe_direction, readout_time, jacobian, num_t jacobian=jacobian, num_threads=num_threads, ) - out_file = Path('corrected.nii.gz').absolute() + # Return a ``str`` (not ``Path``): nipype prunes a node's working dir to the + # files referenced by its string-valued outputs, so a ``PosixPath`` return + # leaves ``corrected.nii.gz`` unrecognized and it gets deleted post-run. + out_file = str(Path('corrected.nii.gz').absolute()) resampled.to_filename(out_file) return out_file From e94f285af9d03dd0ea744b790203ac1d3d4d64ca Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Thu, 11 Jun 2026 17:26:03 -0500 Subject: [PATCH 47/48] [REF] transform: one apply path for 3D and 4D fieldmaps 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. --- sdcflows/transform.py | 279 +++++++----------- sdcflows/workflows/apply/dynamic.py | 13 +- .../workflows/apply/tests/test_dynamic.py | 15 +- 3 files changed, 113 insertions(+), 194 deletions(-) diff --git a/sdcflows/transform.py b/sdcflows/transform.py index 520cb6158b..21255b6f8a 100644 --- a/sdcflows/transform.py +++ b/sdcflows/transform.py @@ -182,7 +182,9 @@ async def unwarp_parallel( An array of shape (3, I, J, K) array providing the voxel (index) coordinates of the reference image (i.e., interpolated points) before SDC/HMC. fmap_hz : :obj:`~numpy.ndarray` - An array of shape (I, J, K) containing the displacement of each voxel in voxel units. + The :math:`B_0` field in Hz. 3D ``(I, J, K)`` for a field shared across + all volumes (static estimators), or 4D ``(I, J, K, T)`` for one field + per EPI volume (dynamic estimators, e.g. MEDIC). pe_info : :obj:`tuple` of (:obj:`int`, :obj:`float`) A tuple containing the index of the phase-encoding axis in the data array and the readout time (including sign, if displacements must be reversed) @@ -224,21 +226,29 @@ async def unwarp_parallel( if fulldataset.ndim == 3: fulldataset = fulldataset[..., np.newaxis] - func = partial( - _sdc_unwarp, - jacobian=jacobian, - fmap_hz=fmap_hz, - output_dtype=output_dtype, - order=order, - mode=mode, - cval=cval, - prefilter=prefilter, - ) + n_volumes = fulldataset.shape[-1] - # Create a worker task for each chunk + # Normalize to a per-frame 4D field: a 3D field is shared across all volumes + # (static estimators), so broadcast it; a 4D field already carries one Hz + # volume per EPI frame (dynamic estimators, e.g. MEDIC). After this, frame + # selection is a single, branchless ``fmap_hz[..., volid]`` below. + if fmap_hz.ndim == 3: + fmap_hz = np.broadcast_to(fmap_hz[..., np.newaxis], (*fmap_hz.shape, n_volumes)) + + # Create a worker task for each volume tasks = [] for volid, volume in enumerate(np.rollaxis(fulldataset, -1, 0)): xfm = None if xfms is None else xfms[volid] + func = partial( + _sdc_unwarp, + jacobian=jacobian, + fmap_hz=fmap_hz[..., volid], + output_dtype=output_dtype, + order=order, + mode=mode, + cval=cval, + prefilter=prefilter, + ) # IMPORTANT - the coordinates array must be copied every time anew per thread task = asyncio.create_task( @@ -268,10 +278,15 @@ class B0FieldTransform: coeffs = attr.ib(default=None) """B-Spline coefficients (one value per control point).""" - mapped = attr.ib(default=None, init=False) + mapped = attr.ib(default=None) """ - A cache of the interpolated field in Hz (i.e., the fieldmap *mapped* on to the - target image we want to correct). + The fieldmap in Hz, mapped onto the target image we want to correct. + + Populated by :meth:`fit` from :attr:`coeffs` (static B-Spline estimators), + or supplied directly at construction when the field is already on the + target grid (dynamic estimators, e.g. MEDIC). May be 3D ``(I, J, K)`` — + one field shared by every EPI volume — or 4D ``(I, J, K, T)`` — one field + per volume. """ def fit( @@ -483,61 +498,31 @@ def apply( # Make sure the data array has all cosines positive (i.e., no axes are flipped) moving, axcodes = ensure_positive_cosines(moving) - if self.mapped is not None: - warn( - 'The fieldmap has been already fit, the user is responsible for ' - 'ensuring the parameters of the EPI target are consistent.', - stacklevel=2, + if self.coeffs is None and self.mapped is None: + raise ValueError( + 'B0FieldTransform needs either B-Spline coefficients (coeffs) to fit, ' + 'or a pre-gridded fieldmap in Hz (mapped) to resample with.' ) + + if self.mapped is not None and self.coeffs is None: + # Pre-gridded field (e.g., MEDIC dynamic): already on the target + # grid, nothing to reconstruct — just normalize its orientation. + fmap_img, _ = ensure_positive_cosines(self.mapped) + fmap_hz = np.asanyarray(fmap_img.dataobj, dtype='float32') else: - # Generate warp field (before ensuring positive cosines) - self.fit(moving, xfm_data2fmap=xfm_data2fmap, approx=approx) - - # Squeeze non-spatial dimensions - newshape = moving.shape[:3] + tuple(dim for dim in moving.shape[3:] if dim > 1) - data = nb.arrayproxy.reshape_dataobj(moving.dataobj, newshape) - ndim = min(data.ndim, 3) - n_volumes = data.shape[3] if data.ndim == 4 else 1 - output_dtype = output_dtype or moving.header.get_data_dtype() - - # Prepare input parameters - if isinstance(pe_dir, str): - pe_dir = [pe_dir] - - if isinstance(ro_time, float): - ro_time = [ro_time] - - if n_volumes > 1 and len(pe_dir) == 1: - pe_dir *= n_volumes - - if n_volumes > 1 and len(ro_time) == 1: - 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]) - # Displacements are reversed if either is true (after ensuring positive cosines) - flip = (axcodes[pe_axis] in 'LPI') ^ vol_pe_dir.endswith('-') - - pe_info.append((pe_axis, -vol_ro_time if flip else vol_ro_time)) - - # Reference image's voxel coordinates (in voxel units) - voxcoords = ( - nt.linear.Affine(reference=moving) - .reference.ndindex.T.reshape((ndim, *data.shape[:ndim])) - .astype('float32') - ) + if self.mapped is not None: + warn( + 'The fieldmap has been already fit, the user is responsible for ' + 'ensuring the parameters of the EPI target are consistent.', + stacklevel=2, + ) + else: + # Generate warp field (before ensuring positive cosines) + self.fit(moving, xfm_data2fmap=xfm_data2fmap, approx=approx) + fmap_hz = self.mapped.get_fdata(dtype='float32') - # Convert head-motion transforms to voxel-to-voxel: + # Head-motion compensation is not yet wired through the unwarp. if xfms is not None: - # if len(xfms) != n_volumes: - # raise RuntimeError( - # f"Number of head-motion estimates ({len(xfms)}) does not match the " - # f"number of volumes ({n_volumes})" - # ) - # vox2ras = moving.affine.copy() - # ras2vox = np.linalg.inv(vox2ras) - # xfms = [ras2vox @ xfm @ vox2ras for xfm in xfms] xfms = None warn( 'Head-motion compensating (realignment) transforms are ignored when applying ' @@ -546,31 +531,23 @@ def apply( stacklevel=1, ) - # Resample - resampled = asyncio.run( - unwarp_parallel( - data, - voxcoords, - self.mapped.get_fdata(dtype='float32'), # fieldmap in Hz - pe_info, - xfms, - jacobian, - output_dtype='float32', - order=order, - mode=mode, - cval=cval, - prefilter=prefilter, - max_concurrent=num_threads or min(os.cpu_count(), 12), - ) + return _resample_with_fieldmap( + moving, + axcodes, + fmap_hz, + pe_dir, + ro_time, + xfms=xfms, + jacobian=jacobian, + order=order, + mode=mode, + cval=cval, + prefilter=prefilter, + output_dtype=output_dtype, + num_threads=num_threads, + allow_negative=allow_negative, ) - 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) - def to_displacements(self, ro_time, pe_dir, itk_format=True): """ Generate a NIfTI file containing a displacements field transform compatible with ITK/ANTs. @@ -595,58 +572,14 @@ def to_displacements(self, ro_time, pe_dir, itk_format=True): return fmap_to_disp(self.mapped, ro_time, pe_dir, itk_format=itk_format) -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( +def _resample_with_fieldmap( moving, - fmap_dynamic, + axcodes, + fmap_hz: np.ndarray, pe_dir, ro_time, + *, + xfms: Sequence[np.ndarray] | None = None, jacobian: bool = True, order: int = 3, mode: str = 'constant', @@ -656,76 +589,62 @@ def apply_dynamic_unwarp( num_threads: int | None = None, allow_negative: bool = False, ): - r"""Apply a per-frame 4D Hz fieldmap to unwarp a 4D EPI series. + """Resample ``moving`` through an on-grid Hz fieldmap (3D or 4D). - 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`). + Shared core of :meth:`B0FieldTransform.apply`. The caller is responsible + for producing ``fmap_hz`` already on the ``moving`` grid — B-spline + reconstruction plus coregistration for static estimators, or a pre-gridded + per-frame field for dynamic estimators — and for ensuring ``moving`` has + positive cosines. - 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. + A 3D ``fmap_hz`` is shared across all EPI volumes; a 4D ``fmap_hz`` carries + one Hz volume per EPI frame and must match the number of volumes. """ - 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)) + # Squeeze non-spatial dimensions + newshape = moving.shape[:3] + tuple(dim for dim in moving.shape[3:] if dim > 1) + data = np.asanyarray(nb.arrayproxy.reshape_dataobj(moving.dataobj, newshape)) + ndim = min(data.ndim, 3) 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: + if fmap_hz.ndim == 4 and fmap_hz.shape[-1] != n_volumes: raise ValueError( - f'Dynamic fieldmap frame count ({fmap_data.shape[-1]}) does not match ' + f'Dynamic fieldmap frame count ({fmap_hz.shape[-1]}) does not match ' f'EPI volumes ({n_volumes}).' ) + # Prepare input parameters if isinstance(pe_dir, str): - pe_dir = [pe_dir] * n_volumes + pe_dir = [pe_dir] if isinstance(ro_time, (int, float)): - ro_time = [float(ro_time)] * n_volumes + ro_time = [float(ro_time)] + if n_volumes > 1 and len(pe_dir) == 1: + pe_dir = pe_dir * n_volumes + if n_volumes > 1 and len(ro_time) == 1: + ro_time = 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]) + # Displacements are reversed if either is true (after ensuring positive cosines) flip = (axcodes[pe_axis] in 'LPI') ^ vol_pe_dir.endswith('-') pe_info.append((pe_axis, -vol_ro_time if flip else vol_ro_time)) + # Reference image's voxel coordinates (in voxel units) voxcoords = ( nt.linear.Affine(reference=moving) - .reference.ndindex.T.reshape((3, *data.shape[:3])) + .reference.ndindex.T.reshape((ndim, *data.shape[:ndim])) .astype('float32') ) resampled = asyncio.run( - _dynamic_unwarp_parallel( + unwarp_parallel( data, voxcoords, - fmap_data, + fmap_hz, pe_info, - jacobian=jacobian, + xfms, + jacobian, output_dtype='float32', order=order, mode=mode, diff --git a/sdcflows/workflows/apply/dynamic.py b/sdcflows/workflows/apply/dynamic.py index 50c8418c66..5e7f5b2e39 100644 --- a/sdcflows/workflows/apply/dynamic.py +++ b/sdcflows/workflows/apply/dynamic.py @@ -151,17 +151,18 @@ def init_dynamic_unwarp_wf( def _dynamic_unwarp(distorted, fmap, pe_direction, readout_time, jacobian, num_threads): """Resample a 4D EPI through a per-frame 4D Hz fieldmap on the same grid. - Uses :func:`~sdcflows.transform.apply_dynamic_unwarp`, which delegates - per-volume resampling to the same scipy-backed primitives that - :class:`~sdcflows.transform.B0FieldTransform` uses for the static path. + The 4D fieldmap is handed to :class:`~sdcflows.transform.B0FieldTransform` + as a pre-gridded field (no B-spline reconstruction or coregistration), so it + flows through the same resampling machinery as the static path. """ from pathlib import Path - from sdcflows.transform import apply_dynamic_unwarp + import nibabel as nb - resampled = apply_dynamic_unwarp( + from sdcflows.transform import B0FieldTransform + + resampled = B0FieldTransform(mapped=nb.load(fmap)).apply( distorted, - fmap, pe_dir=pe_direction, ro_time=readout_time, jacobian=jacobian, diff --git a/sdcflows/workflows/apply/tests/test_dynamic.py b/sdcflows/workflows/apply/tests/test_dynamic.py index 5b51daf906..c1c518a159 100644 --- a/sdcflows/workflows/apply/tests/test_dynamic.py +++ b/sdcflows/workflows/apply/tests/test_dynamic.py @@ -60,19 +60,19 @@ def test_dynamic_unwarp_jacobian_flag_propagates(): assert unwarp.inputs.jacobian is True -def test_apply_dynamic_unwarp_matches_static(tmp_path, monkeypatch): +def test_dynamic_unwarp_matches_static(tmp_path, monkeypatch): """For a 4D fmap with identical frames, per-volume resampling matches the static path frame-by-frame. - This pins :func:`sdcflows.transform.apply_dynamic_unwarp` to the same - Hz→VSM + scipy.ndimage convention as the rest of the codebase — if the - static path ever changes its sign or pe_info handling, this test catches - the drift. + This pins the pre-gridded :class:`sdcflows.transform.B0FieldTransform` path + to the same Hz→VSM + scipy.ndimage convention as the rest of the codebase — + if the static path ever changes its sign or pe_info handling, this test + catches the drift. """ import nibabel as nb import numpy as np - from sdcflows.transform import _sdc_unwarp, apply_dynamic_unwarp + from sdcflows.transform import B0FieldTransform, _sdc_unwarp from sdcflows.utils.tools import ensure_positive_cosines monkeypatch.chdir(tmp_path) @@ -91,9 +91,8 @@ def test_apply_dynamic_unwarp_matches_static(tmp_path, monkeypatch): nb.Nifti1Image(distorted, affine).to_filename(distorted_path) nb.Nifti1Image(fmap_4d, affine).to_filename(fmap_path) - resampled = apply_dynamic_unwarp( + resampled = B0FieldTransform(mapped=nb.load(str(fmap_path))).apply( str(distorted_path), - str(fmap_path), pe_dir='j', ro_time=0.1, jacobian=True, From 9993c5ab494fbd208bbdbcaf12c09085e73ea9f1 Mon Sep 17 00:00:00 2001 From: Andrew Van Date: Fri, 12 Jun 2026 00:30:16 -0500 Subject: [PATCH 48/48] [MAINT] warpkit: bump minimum version to 1.4.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 60441bcdf1..6daee63782 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "templateflow >= 23.1", "toml >= 0.10", # The marker keeps Python 3.10 installs working — warpkit requires >= 3.11. - "warpkit >= 1.2.2; python_version >= '3.11'", + "warpkit >= 1.4.0; python_version >= '3.11'", ] dynamic = ["version"]