Skip to content

Commit 6a2b68f

Browse files
authored
Skip no-op slice timing correction when timing metadata is absent (#294)
1 parent 272a825 commit 6a2b68f

3 files changed

Lines changed: 70 additions & 7 deletions

File tree

src/rbc/metadata.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ def _validate_slice_timing(slice_timing: list[float], tr: float) -> None:
117117
raise ValueError(msg)
118118

119119

120+
def _header_slice_timing(hdr: nib.Nifti1Header) -> list[float] | None:
121+
"""Extract per-slice acquisition times from the NIfTI header, if present.
122+
123+
Returns *None* when the header lacks the necessary fields
124+
(slice_code, slice_duration, dim_info).
125+
"""
126+
try:
127+
return list(hdr.get_slice_times())
128+
except nib.spatialimages.HeaderDataError:
129+
return None
130+
131+
120132
@dataclass(frozen=True)
121133
class FunctionalMetadata:
122134
"""Validated, immutable metadata for a single BOLD run.
@@ -166,6 +178,23 @@ def load(
166178

167179
slice_timing: list[float] | None = sidecar.get("SliceTiming")
168180
if slice_timing is not None:
181+
_logger.info(
182+
"SliceTiming: from BIDS sidecar (%d slices)", len(slice_timing)
183+
)
169184
_validate_slice_timing(slice_timing, tr)
185+
else:
186+
slice_timing = _header_slice_timing(hdr)
187+
if slice_timing is not None:
188+
_logger.warning(
189+
"No SliceTiming in BIDS sidecar; "
190+
"falling back to NIfTI header (%d slices)",
191+
len(slice_timing),
192+
)
193+
_validate_slice_timing(slice_timing, tr)
194+
else:
195+
_logger.warning(
196+
"No SliceTiming in BIDS sidecar or NIfTI header; "
197+
"slice timing correction will be skipped"
198+
)
170199

171200
return cls(tr=tr, slice_timing=slice_timing)

src/rbc/workflows/functional.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,18 @@ def single_session_preprocess(
254254
mc = fsl_motion_correction(in_file=despiked, ref_file=effective_ref)
255255

256256
# 7. Slice timing correction
257-
_logger.info("Slice timing correction")
258-
st_corrected = slice_timing_correction(
259-
in_file=despiked,
260-
tr=metadata.tr,
261-
tpattern=metadata.slice_timing,
262-
)
257+
if metadata.slice_timing is not None:
258+
_logger.info("Slice timing correction")
259+
st_corrected = slice_timing_correction(
260+
in_file=despiked,
261+
tr=metadata.tr,
262+
tpattern=metadata.slice_timing,
263+
)
264+
else:
265+
_logger.info(
266+
"Skipping slice timing correction (no SliceTiming in sidecar or header)"
267+
)
268+
st_corrected = despiked
263269

264270
# 8. Apply pre-STC motion transforms to STC BOLD
265271
# native-space MC + STC BOLD used in step 12

tests/unit/test_metadata.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import TYPE_CHECKING
77
from unittest.mock import MagicMock, patch
88

9+
import nibabel as nib
910
import pytest
1011

1112
from rbc.metadata import (
@@ -146,14 +147,15 @@ def test_load_with_override(self, tmp_path: Path) -> None:
146147
assert meta.tr == pytest.approx(1.5)
147148

148149
def test_load_no_slice_timing(self, tmp_path: Path) -> None:
149-
"""load() sets slice_timing to None when absent from sidecar."""
150+
"""load() sets slice_timing to None when absent from sidecar and header."""
150151
bold = tmp_path / "bold.nii.gz"
151152
bold.touch()
152153

153154
mock_hdr = MagicMock()
154155
mock_hdr.__getitem__ = lambda _, key: (
155156
[0, 1, 1, 1, 2.0] if key == "pixdim" else None
156157
)
158+
mock_hdr.get_slice_times.side_effect = nib.spatialimages.HeaderDataError()
157159
mock_img = MagicMock()
158160
mock_img.header = mock_hdr
159161

@@ -168,6 +170,32 @@ def test_load_no_slice_timing(self, tmp_path: Path) -> None:
168170

169171
assert meta.slice_timing is None
170172

173+
def test_load_slice_timing_from_header(self, tmp_path: Path) -> None:
174+
"""load() falls back to NIfTI header when sidecar lacks SliceTiming."""
175+
bold = tmp_path / "bold.nii.gz"
176+
bold.touch()
177+
178+
header_times = [0.0, 0.5, 1.0, 1.5]
179+
180+
mock_hdr = MagicMock()
181+
mock_hdr.__getitem__ = lambda _, key: (
182+
[0, 1, 1, 1, 2.0] if key == "pixdim" else None
183+
)
184+
mock_hdr.get_slice_times.return_value = header_times
185+
mock_img = MagicMock()
186+
mock_img.header = mock_hdr
187+
188+
with (
189+
patch(
190+
"rbc.metadata.load_bids_metadata",
191+
return_value={"RepetitionTime": 2.0},
192+
),
193+
patch("rbc.metadata.nib.nifti1.load", return_value=mock_img),
194+
):
195+
meta = FunctionalMetadata.load(bold)
196+
197+
assert meta.slice_timing == header_times
198+
171199
def test_load_missing_tr_raises(self, tmp_path: Path) -> None:
172200
"""load() raises when TR cannot be determined."""
173201
bold = tmp_path / "bold.nii.gz"

0 commit comments

Comments
 (0)