Skip to content

Commit a73d6f9

Browse files
committed
Merge remote-tracking branch 'upstream/maint/1.3.x'
2 parents 2600e4f + 691436a commit a73d6f9

File tree

4 files changed

+122
-20
lines changed

4 files changed

+122
-20
lines changed

CHANGES.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ Prior 1.4.0rc6
103103
* ``NormalizeMotionParams`` now under ``confounds``.
104104
* ``FMRISummary``, ``CompCorVariancePlot``, ``ConfoundsCorrelationPlot`` from ``plotting``
105105

106+
1.3.4 (June 8, 2021)
107+
====================
108+
Bug-fix release in the 1.3.x series.
109+
110+
* RF: Write derivatives once, using deterministic gzip settings
111+
106112
1.3.3 (April 15, 2021)
107113
======================
108114
Bug-fix release in the 1.3.x series.

niworkflows/interfaces/bids.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
from nipype.interfaces.io import add_traits
5353
from templateflow.api import templates as _get_template_list
5454
from ..utils.bids import _init_layout, relative_to_root
55-
from ..utils.images import overwrite_header
55+
from ..utils.images import set_consumables, unsafe_write_nifti_header_and_data
5656
from ..utils.misc import splitext as _splitext, _copy_any
5757

5858
regz = re.compile(r"\.gz$")
@@ -596,16 +596,18 @@ def _run_interface(self, runtime):
596596
out_file = out_path / dest_file
597597
out_file.parent.mkdir(exist_ok=True, parents=True)
598598
self._results["out_file"].append(str(out_file))
599-
self._results["compression"].append(_copy_any(orig_file, str(out_file)))
599+
self._results["compression"].append(str(dest_file).endswith(".gz"))
600+
601+
# Set data and header iff changes need to be made. If these are
602+
# still None when it's time to write, just copy.
603+
new_data, new_header = None, None
600604

601605
is_nifti = out_file.name.endswith(
602606
(".nii", ".nii.gz")
603607
) and not out_file.name.endswith((".dtseries.nii", ".dtseries.nii.gz"))
604608
data_dtype = self.inputs.data_dtype or DEFAULT_DTYPES[self.inputs.suffix]
605609
if is_nifti and any((self.inputs.check_hdr, data_dtype)):
606-
# Do not use mmap; if we need to access the data at all, it will be to
607-
# rewrite, risking a BusError
608-
nii = nb.load(out_file, mmap=False)
610+
nii = nb.load(orig_file)
609611

610612
if self.inputs.check_hdr:
611613
hdr = nii.header
@@ -627,12 +629,10 @@ def _run_interface(self, runtime):
627629

628630
if curr_codes != xcodes or curr_units != units:
629631
self._results["fixed_hdr"][i] = True
630-
hdr.set_qform(nii.affine, xcodes[0])
631-
hdr.set_sform(nii.affine, xcodes[1])
632-
hdr.set_xyzt_units(*units)
633-
634-
# Rewrite file with new header
635-
overwrite_header(nii, out_file)
632+
new_header = hdr.copy()
633+
new_header.set_qform(nii.affine, xcodes[0])
634+
new_header.set_sform(nii.affine, xcodes[1])
635+
new_header.set_xyzt_units(*units)
636636

637637
if data_dtype == "source": # match source dtype
638638
try:
@@ -644,9 +644,6 @@ def _run_interface(self, runtime):
644644
data_dtype = None
645645

646646
if data_dtype:
647-
if self.inputs.check_hdr:
648-
# load updated NIfTI
649-
nii = nb.load(out_file, mmap=False)
650647
data_dtype = np.dtype(data_dtype)
651648
orig_dtype = nii.get_data_dtype()
652649
if orig_dtype != data_dtype:
@@ -659,9 +656,24 @@ def _run_interface(self, runtime):
659656
else:
660657
new_data = np.asanyarray(nii.dataobj, dtype=data_dtype)
661658
# and set header to match
662-
nii.set_data_dtype(data_dtype)
663-
nii = nii.__class__(new_data, nii.affine, nii.header)
664-
nii.to_filename(out_file)
659+
if new_header is None:
660+
new_header = nii.header.copy()
661+
new_header.set_data_dtype(data_dtype)
662+
del nii
663+
664+
if new_data is new_header is None:
665+
_copy_any(orig_file, str(out_file))
666+
else:
667+
orig_img = nb.load(orig_file)
668+
if new_data is None:
669+
set_consumables(new_header, orig_img.dataobj)
670+
new_data = orig_img.dataobj.get_unscaled()
671+
unsafe_write_nifti_header_and_data(
672+
fname=out_file,
673+
header=new_header,
674+
data=new_data
675+
)
676+
del orig_img
665677

666678
if len(self._results["out_file"]) == 1:
667679
meta_fields = self.inputs.copyable_trait_names()

niworkflows/interfaces/tests/test_bids.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import os
2525
from pathlib import Path
2626
import json
27+
from hashlib import sha1
2728

2829
import numpy as np
2930
import nibabel as nb
@@ -46,61 +47,70 @@
4647

4748
@pytest.mark.parametrize("out_path_base", [None, "fmriprep"])
4849
@pytest.mark.parametrize(
49-
"source,input_files,entities,expectation",
50+
"source,input_files,entities,expectation,checksum",
5051
[
5152
(
5253
T1W_PATH,
5354
["anat.nii.gz"],
5455
{"desc": "preproc"},
5556
"sub-100185/anat/sub-100185_desc-preproc_T1w.nii.gz",
57+
"7c047921def32da260df4a985019b9f5231659fa",
5658
),
5759
(
5860
T1W_PATH,
5961
["anat.nii.gz"],
6062
{"desc": "preproc", "space": "MNI"},
6163
"sub-100185/anat/sub-100185_space-MNI_desc-preproc_T1w.nii.gz",
64+
"b22399f50ce454049d5d074457a92ab13e7fdf8c",
6265
),
6366
(
6467
T1W_PATH,
6568
["anat.nii.gz"],
6669
{"desc": "preproc", "space": "MNI", "resolution": "native"},
6770
"sub-100185/anat/sub-100185_space-MNI_desc-preproc_T1w.nii.gz",
71+
"b22399f50ce454049d5d074457a92ab13e7fdf8c",
6872
),
6973
(
7074
T1W_PATH,
7175
["anat.nii.gz"],
7276
{"desc": "preproc", "space": "MNI", "resolution": "high"},
7377
"sub-100185/anat/sub-100185_space-MNI_res-high_desc-preproc_T1w.nii.gz",
78+
"b22399f50ce454049d5d074457a92ab13e7fdf8c",
7479
),
7580
(
7681
T1W_PATH,
7782
["tfm.txt"],
7883
{"from": "fsnative", "to": "T1w", "suffix": "xfm"},
7984
"sub-100185/anat/sub-100185_from-fsnative_to-T1w_mode-image_xfm.txt",
85+
"da39a3ee5e6b4b0d3255bfef95601890afd80709",
8086
),
8187
(
8288
T1W_PATH,
8389
["tfm.h5"],
8490
{"from": "MNI152NLin2009cAsym", "to": "T1w", "suffix": "xfm"},
8591
"sub-100185/anat/sub-100185_from-MNI152NLin2009cAsym_to-T1w_mode-image_xfm.h5",
92+
"da39a3ee5e6b4b0d3255bfef95601890afd80709",
8693
),
8794
(
8895
T1W_PATH,
8996
["anat.nii.gz"],
9097
{"desc": "brain", "suffix": "mask"},
9198
"sub-100185/anat/sub-100185_desc-brain_mask.nii.gz",
99+
"d425f0096b6b6d1252973e48b31d760c0b1bdc11",
92100
),
93101
(
94102
T1W_PATH,
95103
["anat.nii.gz"],
96104
{"desc": "brain", "suffix": "mask", "space": "MNI"},
97105
"sub-100185/anat/sub-100185_space-MNI_desc-brain_mask.nii.gz",
106+
"a2a6efa16eb23173d0ee64779de879711bc74643",
98107
),
99108
(
100109
T1W_PATH,
101110
["anat.surf.gii"],
102111
{"suffix": "pial", "hemi": "L"},
103112
"sub-100185/anat/sub-100185_hemi-L_pial.surf.gii",
113+
"da39a3ee5e6b4b0d3255bfef95601890afd80709",
104114
),
105115
(
106116
T1W_PATH,
@@ -110,6 +120,8 @@
110120
f"sub-100185/anat/sub-100185_desc-{s}_dseg.nii"
111121
for s in ("aseg", "aparcaseg")
112122
],
123+
["a235cdf59f9bf077ba30bf2523a56508e3a5aabb",
124+
"a235cdf59f9bf077ba30bf2523a56508e3a5aabb"],
113125
),
114126
(
115127
T1W_PATH,
@@ -119,6 +131,8 @@
119131
f"sub-100185/anat/sub-100185_desc-preproc_T1w.{ext}"
120132
for ext in ("nii", "json")
121133
],
134+
["25c107d4a3e6f98e48aa752c5bbd88ab8e8d069f",
135+
"da39a3ee5e6b4b0d3255bfef95601890afd80709"],
122136
),
123137
(
124138
T1W_PATH,
@@ -128,63 +142,73 @@
128142
f"sub-100185/anat/sub-100185_label-{lab}_probseg.nii.gz"
129143
for lab in ("GM", "WM", "CSF")
130144
],
145+
["7c047921def32da260df4a985019b9f5231659fa"] * 3,
131146
),
132147
# BOLD data
133148
(
134149
BOLD_PATH,
135150
["aroma.csv"],
136151
{"suffix": "AROMAnoiseICs"},
137152
"sub-100185/func/sub-100185_task-machinegame_run-1_AROMAnoiseICs.csv",
153+
"da39a3ee5e6b4b0d3255bfef95601890afd80709",
138154
),
139155
(
140156
BOLD_PATH,
141157
["confounds.tsv"],
142158
{"suffix": "regressors", "desc": "confounds"},
143159
"sub-100185/func/sub-100185_task-machinegame_run-1_desc-confounds_regressors.tsv",
160+
"da39a3ee5e6b4b0d3255bfef95601890afd80709",
144161
),
145162
(
146163
BOLD_PATH,
147164
["mixing.tsv"],
148165
{"suffix": "mixing", "desc": "MELODIC"},
149166
"sub-100185/func/sub-100185_task-machinegame_run-1_desc-MELODIC_mixing.tsv",
167+
"da39a3ee5e6b4b0d3255bfef95601890afd80709",
150168
),
151169
(
152170
BOLD_PATH,
153171
["lh.func.gii"],
154172
{"space": "fsaverage", "density": "10k", "hemi": "L"},
155173
"sub-100185/func/sub-100185_task-machinegame_run-1_"
156174
"space-fsaverage_den-10k_hemi-L_bold.func.gii",
175+
"da39a3ee5e6b4b0d3255bfef95601890afd80709",
157176
),
158177
(
159178
BOLD_PATH,
160179
["hcp.dtseries.nii"],
161180
{"space": "fsLR", "density": "91k"},
162181
"sub-100185/func/sub-100185_task-machinegame_run-1_"
163182
"space-fsLR_den-91k_bold.dtseries.nii",
183+
"53d9b486d08fec5a952f68fcbcddb38a72818d4c",
164184
),
165185
(
166186
BOLD_PATH,
167187
["ref.nii"],
168188
{"space": "MNI", "suffix": "boldref"},
169189
"sub-100185/func/sub-100185_task-machinegame_run-1_space-MNI_boldref.nii",
190+
"53d9b486d08fec5a952f68fcbcddb38a72818d4c",
170191
),
171192
(
172193
BOLD_PATH,
173194
["dseg.nii"],
174195
{"space": "MNI", "suffix": "dseg", "desc": "aseg"},
175196
"sub-100185/func/sub-100185_task-machinegame_run-1_space-MNI_desc-aseg_dseg.nii",
197+
"6d2cae7f56c246d7934e2e21e7b472ecc63a4257",
176198
),
177199
(
178200
BOLD_PATH,
179201
["mask.nii"],
180202
{"space": "MNI", "suffix": "mask", "desc": "brain"},
181203
"sub-100185/func/sub-100185_task-machinegame_run-1_space-MNI_desc-brain_mask.nii",
204+
"c365991854931181a1444d6803f5289448e7e266",
182205
),
183206
(
184207
BOLD_PATH,
185208
["bold.nii"],
186209
{"space": "MNI", "desc": "preproc"},
187210
"sub-100185/func/sub-100185_task-machinegame_run-1_space-MNI_desc-preproc_bold.nii",
211+
"aa1eed935e6a8dcca646b0c78ee57218e30e2974",
188212
),
189213
# Nondeterministic order - do we really need this to work, or we can stay safe with
190214
# MapNodes?
@@ -197,30 +221,35 @@
197221
["anat.html"],
198222
{"desc": "conform", "datatype": "figures"},
199223
"sub-100185/figures/sub-100185_desc-conform_T1w.html",
224+
"da39a3ee5e6b4b0d3255bfef95601890afd80709",
200225
),
201226
(
202227
BOLD_PATH,
203228
["aroma.csv"],
204229
{"suffix": "AROMAnoiseICs", "extension": "h5"},
205230
ValueError,
231+
None,
206232
),
207233
(
208234
T1W_PATH,
209235
["anat.nii.gz"] * 3,
210236
{"desc": "preproc", "space": "MNI"},
211237
ValueError,
238+
None,
212239
),
213240
(
214241
"sub-07/ses-preop/anat/sub-07_ses-preop_T1w.nii.gz",
215242
["tfm.h5"],
216243
{"from": "orig", "to": "target", "suffix": "xfm"},
217244
"sub-07/ses-preop/anat/sub-07_ses-preop_from-orig_to-target_mode-image_xfm.h5",
245+
"da39a3ee5e6b4b0d3255bfef95601890afd80709",
218246
),
219247
(
220248
"sub-07/ses-preop/anat/sub-07_ses-preop_run-1_T1w.nii.gz",
221249
["tfm.txt"],
222250
{"from": "orig", "to": "T1w", "suffix": "xfm"},
223251
"sub-07/ses-preop/anat/sub-07_ses-preop_run-1_from-orig_to-T1w_mode-image_xfm.txt",
252+
"da39a3ee5e6b4b0d3255bfef95601890afd80709",
224253
),
225254
],
226255
)
@@ -232,6 +261,7 @@ def test_DerivativesDataSink_build_path(
232261
input_files,
233262
entities,
234263
expectation,
264+
checksum,
235265
dismiss_entities,
236266
):
237267
"""Check a few common derivatives generated by NiPreps."""
@@ -296,9 +326,12 @@ def test_DerivativesDataSink_build_path(
296326
output = dds.run().outputs.out_file
297327
if isinstance(output, str):
298328
output = [output]
329+
checksum = [checksum]
299330

300331
for out, exp in zip(output, expectation):
301332
assert Path(out).relative_to(tmp_path) == Path(base) / exp
333+
for out, chksum in zip(output, checksum):
334+
assert sha1(Path(out).read_bytes()).hexdigest() == chksum
302335

303336

304337
def test_DerivativesDataSink_dtseries_json_hack(tmp_path):
@@ -522,6 +555,52 @@ def test_DerivativesDataSink_fmapid(tmp_path):
522555
assert dds.outputs.out_file.endswith("sub-36_fmapid-auto00000_desc-pepolar_fieldmap.svg")
523556

524557

558+
@pytest.mark.parametrize("dtype", ("i2", "u2", "f4"))
559+
def test_DerivativesDataSink_values(tmp_path, dtype):
560+
# We use static checksums above, which ensures we don't break things, but
561+
# pins the tests to specific values.
562+
# Here we use random values, check that the values are preserved, and then
563+
# the checksums are unchanged across two runs.
564+
fname = str(tmp_path / "source.nii.gz")
565+
rng = np.random.default_rng()
566+
hdr = nb.Nifti1Header()
567+
hdr.set_qform(np.eye(4), code=1)
568+
hdr.set_sform(np.eye(4), code=1)
569+
nb.Nifti1Image(rng.uniform(500, 2000, (5, 5, 5)), np.eye(4), hdr).to_filename(fname)
570+
571+
orig_data = np.asanyarray(nb.load(fname).dataobj)
572+
expected = np.rint(orig_data) if dtype[0] in "iu" else orig_data
573+
574+
dds = bintfs.DerivativesDataSink(
575+
base_directory=str(tmp_path),
576+
keep_dtype=True,
577+
data_dtype=dtype,
578+
desc="preproc",
579+
source_file=T1W_PATH,
580+
in_file=fname,
581+
).run()
582+
583+
out_file = Path(dds.outputs.out_file)
584+
585+
nii = nb.load(out_file)
586+
assert np.allclose(nii.dataobj, expected)
587+
588+
checksum = sha1(out_file.read_bytes()).hexdigest()
589+
out_file.unlink()
590+
591+
# Rerun to ensure determinism with non-zero data
592+
dds = bintfs.DerivativesDataSink(
593+
base_directory=str(tmp_path),
594+
keep_dtype=True,
595+
data_dtype=dtype,
596+
desc="preproc",
597+
source_file=T1W_PATH,
598+
in_file=fname,
599+
).run()
600+
601+
assert sha1(out_file.read_bytes()).hexdigest() == checksum
602+
603+
525604
@pytest.mark.parametrize("field", ["RepetitionTime", "UndefinedField"])
526605
def test_ReadSidecarJSON_connection(testdata_dir, field):
527606
"""

0 commit comments

Comments
 (0)