Skip to content

Commit 4fa88c1

Browse files
committed
Canonicalize AFNI output for caching, drop unused scale_bold
AFNI tools write a NIfTI extension with a wall-clock HISTORY_NOTE, a random IDCODE_STRING, and an IDCODE_DATE timestamp into every output file. Those fields blow up content-hash based caching: any downstream tool sees different bytes each run even when the logical content is identical, and the whole cache chain cascade-misses. deoblique_and_reorient mutates via 3drefit (mutable=True) which styxcache 0.2.0 doesn't replay on hits, compounding the issue. Bypass the 3drefit call so it always runs, then strip the volatile AFNI extension before the downstream cached 3dresample sees the file. Warm-run speedup on tests/integration/functional/test_truncate.py:: test_truncate_trs drops from 33.8s to 10.5s locally. strip_afni_volatile_metadata in rbc.core.nifti removes the full AFNI extension (code 4). sform/qform survive so AFNI geometry is preserved; the rest of the extension is audit metadata AFNI tools recompute. scale_bold was dead code (no callers in src/ or tests/) and had the same 3drefit hazard. Delete it rather than carry the workaround.
1 parent 0831299 commit 4fa88c1

4 files changed

Lines changed: 49 additions & 34 deletions

File tree

src/rbc/core/common.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,28 @@
77

88
from __future__ import annotations
99

10+
import contextlib
1011
from typing import TYPE_CHECKING
1112

1213
import nibabel as nib
1314
from niwrap import afni
1415

1516
if TYPE_CHECKING:
16-
from collections.abc import Sequence
17+
from collections.abc import Iterator, Sequence
1718
from pathlib import Path
1819

1920
from rbc.core.fileops import file_tmp_copy
21+
from rbc.core.nifti import strip_afni_volatile_metadata
2022
from rbc.core.niwrap import generate_exec_folder
2123

24+
try:
25+
from styxcache import bypass as _styxcache_bypass
26+
except ImportError: # styxcache is optional — only used on CI
27+
28+
@contextlib.contextmanager
29+
def _styxcache_bypass() -> Iterator[None]:
30+
yield
31+
2232
__all__ = ["deoblique_and_reorient", "merge_3d_to_4d", "split_4d"]
2333

2434

@@ -41,7 +51,13 @@ def deoblique_and_reorient(
4151
AFNI 3dresample outputs (use ``.out_file`` for the reoriented image).
4252
"""
4353
with file_tmp_copy(in_file) as tmp_file:
44-
afni.v_3drefit(in_file=tmp_file, deoblique=True)
54+
# 3drefit mutates in place, and styxcache 0.2.0 does not replay
55+
# mutable-input mutations on cache hits. Bypass it so it always runs,
56+
# then strip AFNI's non-deterministic extension (timestamps + random
57+
# UUID) so the downstream cached 3dresample call keys on stable bytes.
58+
with _styxcache_bypass():
59+
afni.v_3drefit(in_file=tmp_file, deoblique=True)
60+
strip_afni_volatile_metadata(tmp_file)
4561
return afni.v_3dresample(
4662
in_file=tmp_file, prefix=output_fname, orientation="RPI"
4763
)

src/rbc/core/functional/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
is_valid_pe_direction,
2121
)
2222
from .erosion import erode_mask_by_distance, erode_mask_to_proportion
23-
from .initialization import scale_bold, truncate_trs
23+
from .initialization import truncate_trs
2424
from .masking import bold_masking
2525
from .motion import extract_motion_reference, fsl_motion_correction
2626
from .nuisance import (
@@ -69,7 +69,6 @@
6969
"is_valid_pe_direction",
7070
"nuisance_regression",
7171
"resample_bold_to_template",
72-
"scale_bold",
7372
"slice_timing_correction",
7473
"truncate_trs",
7574
"write_regressor_file",
Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
"""BOLD initialization steps.
22
3-
After reorientation (handled in ``rbc.core.common``), BOLD data undergoes
4-
two initialization steps before motion correction:
5-
6-
1. **TR truncation** -- discard the first *N* volumes (default 2) to allow
7-
the scanner signal to reach steady state.
8-
2. **Voxel scaling** -- rescale voxel dimensions (divide by 10) to match
9-
the coordinate conventions expected downstream.
3+
After reorientation (handled in ``rbc.core.common``), BOLD data undergoes an
4+
initialization step before motion correction: TR truncation -- discard the
5+
first *N* volumes (default 2) to allow the scanner signal to reach steady
6+
state.
107
"""
118

129
from __future__ import annotations
@@ -41,26 +38,3 @@ def truncate_trs(in_file: Path, start_tr: int) -> Path:
4138
)
4239
assert result.output_file is not None # noqa: S101
4340
return result.output_file
44-
45-
46-
def scale_bold(in_file: Path, scale_factor: float = 0.1) -> afni.V3drefitOutputs:
47-
"""Rescale BOLD voxel dimensions via ``3drefit -xyzscale``.
48-
49-
Some pipelines store BOLD data with inflated voxel sizes. This step
50-
multiplies all voxel dimensions by *scale_factor* (default 0.1, i.e.
51-
divide by 10) to bring them into the expected coordinate range.
52-
53-
Note:
54-
This modifies the NIfTI header in-place; the voxel data are unchanged.
55-
56-
Args:
57-
in_file: BOLD timeseries whose header should be updated.
58-
scale_factor: Multiplier for voxel dimensions (default 0.1).
59-
60-
Returns:
61-
AFNI 3drefit outputs.
62-
"""
63-
return afni.v_3drefit(
64-
in_file=in_file,
65-
xyzscale=scale_factor,
66-
)

src/rbc/core/nifti.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,15 @@
2626
"Volume",
2727
"nifti_num_slices",
2828
"nifti_num_volumes",
29+
"strip_afni_volatile_metadata",
2930
]
3031

32+
# AFNI embeds a NIfTI extension (code 4) with an XML payload that contains
33+
# wall-clock timestamps and a random per-invocation UUID. Those poison
34+
# content-hash based caching for any tool downstream. Drop the extension
35+
# entirely; AFNI tools fall back to sform/qform (untouched) for geometry.
36+
_AFNI_EXTENSION_CODE = 4
37+
3138

3239
# NIfTI spatial unit codes (nibabel xyzt_units bits 0-2)
3340
_NIB_UNIT_TO_UNITS: dict[int, Units] = {} # populated after Units definition
@@ -524,3 +531,22 @@ def nifti_num_slices(in_file: str | Path) -> int:
524531
return img.shape[slice_axis]
525532

526533
return img.shape[2] if len(img.shape) >= 3 else 1
534+
535+
536+
def strip_afni_volatile_metadata(path: str | Path) -> None:
537+
"""Rewrite a NIfTI file with AFNI's non-deterministic extension removed.
538+
539+
AFNI tools embed a NIfTI extension carrying wall-clock timestamps and a
540+
random per-invocation UUID, which breaks content-hash based caching for
541+
any downstream consumer. We drop the whole AFNI extension; the standard
542+
NIfTI sform/qform are untouched so downstream tools still get correct
543+
geometry.
544+
"""
545+
img = nib.nifti1.load(path)
546+
ext_list = img.header.extensions
547+
kept = [e for e in ext_list if e.get_code() != _AFNI_EXTENSION_CODE]
548+
if len(kept) == len(ext_list):
549+
return
550+
ext_list.clear()
551+
ext_list.extend(kept)
552+
nib.save(img, path)

0 commit comments

Comments
 (0)