Skip to content

Commit 8a76a72

Browse files
authored
Merge pull request #380 from MICA-MNI/fix/issues-274-341-343
Implement #274 (histology bit-flip), #343 (Destrieux atlas), #341 (resilient Neurosynth fetch)
2 parents ec7e96c + ccf5af8 commit 8a76a72

3 files changed

Lines changed: 140 additions & 13 deletions

File tree

brainstat/context/histology.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def read_histology_profile(
114114
data_dir: Optional[Union[str, Path]] = None,
115115
template: str = "fsaverage",
116116
overwrite: bool = False,
117+
invert: bool = True,
117118
) -> np.ndarray:
118119
"""Reads BigBrain histology profiles.
119120
@@ -127,6 +128,15 @@ def read_histology_profile(
127128
default 'fsaverage'.
128129
overwrite : bool, optional
129130
If true, existing data will be overwrriten, by default False.
131+
invert : bool, optional
132+
If True (default), invert the 8-bit intensity values so that higher
133+
numbers correspond to darker, more cell-dense regions, matching the
134+
convention used in Paquola et al. 2021 (eLife) and most downstream
135+
BigBrain analyses. The raw BigBrainWarp data uses the opposite
136+
convention (0 = black). Set to False to return the raw values. MPC
137+
and gradient computations (correlation-based) are invariant to this
138+
flip; only direct inspection of profile intensities is affected.
139+
See https://github.com/MICA-MNI/BrainStat/issues/274 for context.
130140
131141
Returns
132142
-------
@@ -169,12 +179,19 @@ def read_histology_profile(
169179
profiles = h5_file.get("fs_LR_64k")[...]
170180
else:
171181
profiles = h5_file.get(template)[...]
172-
if civet_template:
173-
fsaverage_surface = fetch_template_surface("fsaverage")
174-
civet_surface = fetch_template_surface(civet_template)
175-
return _surf2surf(fsaverage_surface, civet_surface, profiles.T).T
176-
else:
177-
return profiles
182+
183+
if invert:
184+
# Map from BigBrainWarp convention (low = dark/dense) to the
185+
# literature convention (high = dark/dense). The data is unsigned
186+
# 8-bit, so the inversion is `255 - x`.
187+
max_value = np.iinfo(profiles.dtype).max if np.issubdtype(profiles.dtype, np.integer) else 255
188+
profiles = max_value - profiles
189+
190+
if civet_template:
191+
fsaverage_surface = fetch_template_surface("fsaverage")
192+
civet_surface = fetch_template_surface(civet_template)
193+
return _surf2surf(fsaverage_surface, civet_surface, profiles.T).T
194+
return profiles
178195

179196

180197
def download_histology_profiles(

brainstat/datasets/base.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,15 @@ def fetch_parcellation(
111111
The surface template. Valid values are "fsaverage", "fsaverage5",
112112
"fsaverage6", "fslr32k", "civet41k", "civet164k", by default "fsaverage5".
113113
atlas : str
114-
Name of the atlas. Valid names are "cammoun", "glasser", "schaefer", "yeo".
114+
Name of the atlas. Valid names are "cammoun", "destrieux", "glasser",
115+
"schaefer", "yeo".
115116
n_regions : int
116117
Number of regions of the requested atlas. Valid values for the cammoun
117118
atlas are 33, 60, 125, 250, 500. Valid values for the glasser atlas are
118119
360. Valid values for the "schaefer" atlas are 100, 200, 300, 400, 500,
119-
600, 800, 1000. Valid values for "yeo" are 7 and 17.
120+
600, 800, 1000. Valid values for "yeo" are 7 and 17. The
121+
"destrieux" atlas (a2009s, FreeSurfer's 75 cortical labels per
122+
hemisphere) ignores ``n_regions``.
120123
join : bool, optional
121124
If true, returns parcellation as a single array, if false, returns an
122125
array per hemisphere, by default True.
@@ -154,6 +157,8 @@ def fetch_parcellation(
154157
parcellations = _fetch_glasser_parcellation(template, data_dir)
155158
elif atlas == "yeo":
156159
parcellations = _fetch_yeo_parcellation(template, n_regions, data_dir)
160+
elif atlas == "destrieux":
161+
parcellations = _fetch_destrieux_parcellation(template, data_dir)
157162
else:
158163
raise ValueError(f"Invalid atlas: {atlas}")
159164

@@ -537,6 +542,48 @@ def _fetch_glasser_parcellation(template: str, data_dir: Path) -> List[np.ndarra
537542
return parcellations
538543

539544

545+
def _fetch_destrieux_parcellation(
546+
template: str, data_dir: Path
547+
) -> List[np.ndarray]:
548+
"""Fetches the Destrieux 2009 (FreeSurfer ``aparc.a2009s``) parcellation.
549+
550+
Uses :func:`nilearn.datasets.fetch_atlas_surf_destrieux`, which provides
551+
the parcellation on the ``fsaverage5`` surface. For other ``fsaverage*``
552+
templates we resample with nearest-neighbour interpolation; ``fslr32k``
553+
is not supported because the destrieux atlas is FreeSurfer-specific.
554+
"""
555+
from nilearn.datasets import fetch_atlas_surf_destrieux
556+
557+
if template == "fslr32k":
558+
raise ValueError(
559+
"The destrieux atlas is FreeSurfer-specific; fslr32k is not "
560+
"supported. Use a fsaverage* template instead."
561+
)
562+
563+
bunch = fetch_atlas_surf_destrieux(data_dir=str(data_dir))
564+
# nilearn returns int32 arrays for fsaverage5 (10242 vertices/hemi).
565+
parcellations = [np.asarray(bunch["map_left"]), np.asarray(bunch["map_right"])]
566+
567+
if template != "fsaverage5":
568+
# Nearest-neighbour resample fsaverage5 -> requested fsaverage*.
569+
from brainstat.mesh.interpolate import _surf2surf
570+
571+
src_left, src_right = fetch_template_surface(
572+
"fsaverage5", layer="white", join=False
573+
)
574+
dst_left, dst_right = fetch_template_surface(
575+
template, layer="white", join=False
576+
)
577+
parcellations[0] = _surf2surf(
578+
src_left, dst_left, parcellations[0], interpolation="nearest"
579+
).astype(parcellations[0].dtype)
580+
parcellations[1] = _surf2surf(
581+
src_right, dst_right, parcellations[1], interpolation="nearest"
582+
).astype(parcellations[1].dtype)
583+
584+
return parcellations
585+
586+
540587
def _fetch_yeo_parcellation(
541588
template: str, n_regions: int, data_dir: Path
542589
) -> List[np.ndarray]:

brainstat_matlab/context/meta_analytic_decoder.m

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,29 +79,92 @@
7979

8080
function neurosynth_files = fetch_neurosynth_data(data_dir)
8181
% Fetches neurosynth data files.
82+
%
83+
% Downloads the precomputed Neurosynth archive and extracts it into
84+
% data_dir. Robust to interrupted downloads (which previously left a
85+
% truncated .zip on disk and broke subsequent calls; see #341): the zip is
86+
% verified before unzip, and the download is retried up to a small number
87+
% of times. The zip and any partial extracted state are cleaned up if the
88+
% download or extraction fails.
8289

8390
json = brainstat_utils.read_data_fetcher_json();
8491

8592
neurosynth_files = find_files(data_dir);
8693

8794
if numel(neurosynth_files) ~= json.neurosynth_precomputed.n_files
95+
if ~exist(data_dir, 'dir'); mkdir(data_dir); end
8896
disp('Downloading Neurosynth files. This may take several minutes.')
89-
zip_file = tempname(data_dir) + ".zip";
90-
cleanup = onCleanup(@() delete(zip_file));
91-
websave(zip_file, json.neurosynth_precomputed.url);
92-
unzip(zip_file, data_dir)
97+
98+
max_attempts = 3;
99+
last_err = [];
100+
for attempt = 1:max_attempts
101+
zip_file = tempname(data_dir) + ".zip";
102+
cleanup = onCleanup(@() delete_if_exists(zip_file)); %#ok<NASGU>
103+
try
104+
websave(zip_file, json.neurosynth_precomputed.url);
105+
verify_zip_integrity(zip_file);
106+
unzip(zip_file, data_dir);
107+
last_err = [];
108+
break
109+
catch ME
110+
last_err = ME;
111+
warning('BrainStat:NeurosynthDownload', ...
112+
'Attempt %d/%d failed: %s', attempt, max_attempts, ME.message);
113+
end
114+
end
115+
116+
if ~isempty(last_err)
117+
rethrow(last_err);
118+
end
119+
93120
neurosynth_files = find_files(data_dir);
121+
if numel(neurosynth_files) ~= json.neurosynth_precomputed.n_files
122+
error('BrainStat:NeurosynthDownload', ...
123+
['Neurosynth archive extracted but %d files were found (expected %d). ' ...
124+
'Try removing %s and re-running.'], ...
125+
numel(neurosynth_files), json.neurosynth_precomputed.n_files, data_dir);
126+
end
94127
end
95128
function neurosynth_files = find_files(data_dir)
129+
if ~exist(data_dir, 'dir')
130+
neurosynth_files = strings(0, 1);
131+
return
132+
end
96133
data_dir_contents = dir(data_dir);
97134
data_dir_filenames = {data_dir_contents.name};
98135
neurosynth_files = regexp(data_dir_filenames, "Neurosynth_TFIDF.*_z_.*consistency.nii.gz", ...
99136
'match', 'once');
100-
neurosynth_files(cellfun(@isempty, neurosynth_files)) = [];
137+
neurosynth_files(cellfun(@isempty, neurosynth_files)) = [];
101138
neurosynth_files = data_dir + filesep + neurosynth_files;
102139
end
103140
end
104141

142+
function verify_zip_integrity(zip_file)
143+
% Sanity-checks a downloaded zip before handing it to MATLAB's unzip,
144+
% which is unhelpfully terse when given a truncated archive. We use
145+
% Java's ZipFile to validate the central directory without extracting
146+
% the (multi-GB) contents.
147+
info = dir(zip_file);
148+
if isempty(info) || info.bytes < 1024
149+
error('BrainStat:NeurosynthDownload', ...
150+
'Downloaded zip is missing or implausibly small (%d bytes).', ...
151+
max(0, info.bytes));
152+
end
153+
try
154+
zf = java.util.zip.ZipFile(zip_file);
155+
zf.close();
156+
catch ME
157+
error('BrainStat:NeurosynthDownload', ...
158+
'Downloaded zip failed integrity check: %s', ME.message);
159+
end
160+
end
161+
162+
function delete_if_exists(filepath)
163+
if exist(filepath, 'file')
164+
delete(filepath);
165+
end
166+
end
167+
105168
function interpolated_volume = nearest_neighbor_interpolation(template, labels)
106169
% Performs nearest neighbor interpolation using precomputed volumes.
107170
precomputed_data_dir = brainstat_utils.get_brainstat_directories('brainstat_precomputed_data');

0 commit comments

Comments
 (0)