brainstat 0.4.2__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {brainstat-0.4.2/brainstat.egg-info → brainstat-0.6.0}/PKG-INFO +38 -7
  2. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/__init__.py +1 -1
  3. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/_utils.py +28 -3
  4. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/context/genetics.py +131 -47
  5. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/context/histology.py +45 -7
  6. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/datasets/base.py +163 -5
  7. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/mesh/interpolate.py +1 -1
  8. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/stats/SLM.py +62 -11
  9. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/stats/_linear_model.py +3 -1
  10. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/stats/_multiple_comparisons.py +2 -3
  11. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/stats/_t_test.py +4 -3
  12. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/stats/terms.py +16 -7
  13. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/stats/utils.py +4 -2
  14. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_SLM.py +81 -0
  15. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_datasets.py +1 -0
  16. brainstat-0.6.0/brainstat/tests/test_genetics.py +33 -0
  17. brainstat-0.6.0/brainstat/tests/test_histology.py +32 -0
  18. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_mesh_edges.py +1 -1
  19. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_terms.py +26 -2
  20. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tutorial/utils.py +1 -1
  21. {brainstat-0.4.2 → brainstat-0.6.0/brainstat.egg-info}/PKG-INFO +38 -7
  22. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat.egg-info/SOURCES.txt +1 -0
  23. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat.egg-info/requires.txt +6 -5
  24. {brainstat-0.4.2 → brainstat-0.6.0}/setup.cfg +1 -1
  25. {brainstat-0.4.2 → brainstat-0.6.0}/setup.py +11 -9
  26. brainstat-0.4.2/brainstat/tests/test_histology.py +0 -21
  27. {brainstat-0.4.2 → brainstat-0.6.0}/LICENSE +0 -0
  28. {brainstat-0.4.2 → brainstat-0.6.0}/MANIFEST.in +0 -0
  29. {brainstat-0.4.2 → brainstat-0.6.0}/README.rst +0 -0
  30. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/_typing.py +0 -0
  31. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/context/__init__.py +0 -0
  32. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/context/meta_analysis.py +0 -0
  33. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/context/resting.py +0 -0
  34. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/data/data_urls.json +0 -0
  35. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/data/logging.conf +0 -0
  36. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/data/nn_interp_civet164k.nii.gz +0 -0
  37. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/data/nn_interp_civet41k.nii.gz +0 -0
  38. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/data/nn_interp_fsaverage.nii.gz +0 -0
  39. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/data/nn_interp_fsaverage5.nii.gz +0 -0
  40. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/data/nn_interp_fslr32k.nii.gz +0 -0
  41. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/datasets/__init__.py +0 -0
  42. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/mesh/__init__.py +0 -0
  43. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/mesh/data.py +0 -0
  44. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/mesh/utils.py +0 -0
  45. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/stats/__init__.py +0 -0
  46. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/__init__.py +0 -0
  47. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/conftest.py +0 -0
  48. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/datagen_f.py +0 -0
  49. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/datagen_fdr.py +0 -0
  50. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/datagen_linear_model.py +0 -0
  51. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/datagen_mesh_edges.py +0 -0
  52. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/datagen_mesh_smooth.py +0 -0
  53. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/datagen_peak_clus.py +0 -0
  54. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/datagen_random_field_theory.py +0 -0
  55. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/datagen_resels.py +0 -0
  56. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/datagen_slm.py +0 -0
  57. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/datagen_stat_threshold.py +0 -0
  58. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/datagen_t_test.py +0 -0
  59. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_f_test.py +0 -0
  60. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_fdr.py +0 -0
  61. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_linear_model.py +0 -0
  62. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_mesh_smooth.py +0 -0
  63. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_peak_clus.py +0 -0
  64. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_random_field_theory.py +0 -0
  65. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_resels.py +0 -0
  66. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_stat_threshold.py +0 -0
  67. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/test_t_test.py +0 -0
  68. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tests/testutil.py +0 -0
  69. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat/tutorial/__init__.py +0 -0
  70. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat.egg-info/dependency_links.txt +0 -0
  71. {brainstat-0.4.2 → brainstat-0.6.0}/brainstat.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: brainstat
3
- Version: 0.4.2
3
+ Version: 0.6.0
4
4
  Summary: A toolbox for statistical analysis of neuroimaging data
5
5
  Home-page: https://github.com/MICA-LAB/BrainStat
6
6
  Author: MNI-MICA Lab and MPI-CNG Lab
@@ -13,13 +13,44 @@ Classifier: Intended Audience :: Science/Research
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Topic :: Software Development :: Build Tools
15
15
  Classifier: Topic :: Scientific/Engineering
16
- Classifier: Programming Language :: Python :: 3.7
17
- Classifier: Programming Language :: Python :: 3.8
18
- Classifier: Programming Language :: Python :: 3.9
19
- Requires-Python: >=3.7.*
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Requires-Python: >=3.10
20
18
  Description-Content-Type: text/x-rst
21
- Provides-Extra: dev
22
19
  License-File: LICENSE
20
+ Requires-Dist: abagen>=0.1
21
+ Requires-Dist: brainspace>=0.1.2
22
+ Requires-Dist: h5py
23
+ Requires-Dist: matplotlib>=3.4
24
+ Requires-Dist: netneurotools<0.3.0
25
+ Requires-Dist: neurosynth
26
+ Requires-Dist: nibabel
27
+ Requires-Dist: nilearn>=0.7.0
28
+ Requires-Dist: nimare
29
+ Requires-Dist: numpy<2,>=1.21
30
+ Requires-Dist: pandas<2.1,>=1.3
31
+ Requires-Dist: scikit_learn>=1.0
32
+ Requires-Dist: scipy>=1.7
33
+ Requires-Dist: templateflow
34
+ Requires-Dist: trimesh
35
+ Provides-Extra: dev
36
+ Requires-Dist: gitpython; extra == "dev"
37
+ Requires-Dist: hcp-utils; extra == "dev"
38
+ Requires-Dist: mypy; extra == "dev"
39
+ Requires-Dist: plotly; extra == "dev"
40
+ Requires-Dist: pytest; extra == "dev"
41
+ Dynamic: author
42
+ Dynamic: author-email
43
+ Dynamic: classifier
44
+ Dynamic: description
45
+ Dynamic: description-content-type
46
+ Dynamic: home-page
47
+ Dynamic: license
48
+ Dynamic: license-file
49
+ Dynamic: project-url
50
+ Dynamic: provides-extra
51
+ Dynamic: requires-dist
52
+ Dynamic: requires-python
53
+ Dynamic: summary
23
54
 
24
55
  BrainStat : A toolbox for statistical analysis of neuroimaging data
25
56
  ===================================================================
@@ -1,2 +1,2 @@
1
1
  """Neuroimaging statistics toolbox."""
2
- __version__ = "0.4.2"
2
+ __version__ = "0.6.0"
@@ -150,7 +150,7 @@ def deprecated(message: str) -> Callable:
150
150
  def _download_file(
151
151
  url: str, output_file: Path, overwrite: bool = False, verbose=True
152
152
  ) -> None:
153
- """Downloads a file.
153
+ """Downloads a file with retry logic for network failures.
154
154
 
155
155
  Parameters
156
156
  ----------
@@ -163,14 +163,39 @@ def _download_file(
163
163
  verbose : bool
164
164
  If true, print a download message, defaults to True.
165
165
  """
166
+ import time
167
+ from http.client import RemoteDisconnected
168
+ from urllib.error import URLError
166
169
 
167
170
  if output_file.exists() and not overwrite:
168
171
  return
169
172
 
170
173
  if verbose:
171
174
  logger.info("Downloading " + str(output_file) + " from " + url + ".")
172
- with urllib.request.urlopen(url) as response, open(output_file, "wb") as out_file:
173
- shutil.copyfileobj(response, out_file)
175
+
176
+ # Retry logic for intermittent network failures
177
+ max_retries = 3
178
+ retry_delay = 2 # seconds
179
+
180
+ for attempt in range(max_retries):
181
+ try:
182
+ with urllib.request.urlopen(url, timeout=30) as response, open(output_file, "wb") as out_file:
183
+ shutil.copyfileobj(response, out_file)
184
+ return # Success, exit function
185
+ except (RemoteDisconnected, URLError, TimeoutError) as e:
186
+ if attempt < max_retries - 1:
187
+ logger.warning(
188
+ f"Download attempt {attempt + 1}/{max_retries} failed: {e}. "
189
+ f"Retrying in {retry_delay} seconds..."
190
+ )
191
+ time.sleep(retry_delay)
192
+ retry_delay *= 2 # Exponential backoff
193
+ else:
194
+ logger.error(f"Download failed after {max_retries} attempts.")
195
+ raise # Re-raise the exception after all retries fail
196
+
197
+
198
+
174
199
 
175
200
 
176
201
  if __name__ == "__main__":
@@ -2,13 +2,59 @@
2
2
  import tempfile
3
3
  from pathlib import Path
4
4
  from typing import Optional, Sequence, Union
5
-
5
+ import os
6
6
  import nibabel as nib
7
7
  import numpy as np
8
8
  import pandas as pd
9
+
10
+ # Monkeypatch for abagen compatibility with pandas 2.0+
11
+ if not hasattr(pd.DataFrame, 'append'):
12
+ def _append(self, other, ignore_index=False, verify_integrity=False, sort=False):
13
+ from pandas import concat
14
+ if isinstance(other, (list, tuple)):
15
+ to_concat = [self] + list(other)
16
+ else:
17
+ to_concat = [self, other]
18
+ return concat(to_concat, ignore_index=ignore_index, verify_integrity=verify_integrity, sort=sort)
19
+ pd.DataFrame.append = _append
20
+
21
+ # Monkeypatch for abagen compatibility with pandas 2.0+ (set_axis inplace)
22
+ _original_set_axis = pd.DataFrame.set_axis
23
+ def _set_axis_patched(self, labels, *args, **kwargs):
24
+ if 'inplace' in kwargs:
25
+ del kwargs['inplace']
26
+ return _original_set_axis(self, labels, *args, **kwargs)
27
+ pd.DataFrame.set_axis = _set_axis_patched
28
+
29
+ import abagen.images
30
+
31
+ def _labeltable_to_df_patched(labels):
32
+ """
33
+ Patched version of abagen.utils.labeltable_to_df to handle missing 0 index
34
+ and use pd.concat instead of append.
35
+ """
36
+ info = pd.DataFrame(columns=['id', 'label', 'hemisphere', 'structure'])
37
+ for table, hemi in zip(labels, ('L', 'R')):
38
+ if len(table) == 0:
39
+ continue
40
+ ids, label = zip(*table.items())
41
+ new_df = pd.DataFrame(dict(id=ids, label=label, hemisphere=hemi, structure='cortex'))
42
+ info = pd.concat([info, new_df], ignore_index=True)
43
+
44
+ # Use errors='ignore' to handle missing 0
45
+ info = info.set_index('id').drop([0], axis=0, errors='ignore').sort_index()
46
+
47
+ if len(info) != 0:
48
+ return info
49
+
50
+ abagen.images.labeltable_to_df = _labeltable_to_df_patched
51
+
52
+
53
+ import collections
9
54
  from abagen import check_atlas, get_expression_data
10
55
  from brainspace.mesh.mesh_io import read_surface, write_surface
11
56
  from sklearn.model_selection import ParameterGrid
57
+ from nibabel.gifti import GiftiImage, GiftiDataArray
12
58
 
13
59
  from brainstat._utils import data_directories, logger
14
60
  from brainstat.datasets.base import (
@@ -23,7 +69,7 @@ def surface_genetic_expression(
23
69
  surfaces: Optional[Union[str, Sequence[str]]] = None,
24
70
  space: Optional[str] = None,
25
71
  *,
26
- atlas_info: str = None,
72
+ atlas_info: Optional[str] = None,
27
73
  ibf_threshold: float = 0.5,
28
74
  probe_selection: str = "diff_stability",
29
75
  donor_probes: str = "aggregate",
@@ -89,52 +135,90 @@ def surface_genetic_expression(
89
135
  elif surfaces is None:
90
136
  surfaces = []
91
137
 
92
- surfaces_gii = []
93
- for surface in surfaces:
94
- if not isinstance(surface, str) and not isinstance(surface, Path):
95
- # Rather roundabout deletion of the temporary file for Windows compatibility.
138
+ temp_files = []
139
+ try:
140
+ if isinstance(labels, np.ndarray):
141
+ # Assuming 'labels' is a 1D NumPy array of length 20484
142
+ num_vertices = len(labels) # Should be 20484
143
+ half_size = num_vertices // 2 # Half of 20484, which is 10242
144
+
145
+ # Split the array into two halves
146
+ labels_left = labels[:half_size] # First half for the left hemisphere
147
+ labels_right = labels[half_size:] # Second half for the right hemisphere
148
+
149
+ # Create GiftiDataArrays for each hemisphere
150
+ data_array_left = GiftiDataArray(data=labels_left)
151
+ data_array_right = GiftiDataArray(data=labels_right)
152
+
153
+ # Create separate GiftiImages for each hemisphere
154
+ labels_left_gii = GiftiImage(darrays=[data_array_left])
155
+ labels_right_gii = GiftiImage(darrays=[data_array_right])
156
+
157
+ # Save to temporary files for abagen compatibility
158
+ labels_files = []
159
+ for img in [labels_left_gii, labels_right_gii]:
160
+ f = tempfile.NamedTemporaryFile(suffix=".gii", delete=False)
161
+ f.close()
162
+ nib.save(img, f.name)
163
+ labels_files.append(f.name)
164
+ temp_files.append(f.name)
165
+ labels = tuple(labels_files)
166
+
167
+ surfaces_gii = []
168
+ for surface in surfaces:
169
+ if not isinstance(surface, str) and not isinstance(surface, Path):
170
+ # Cast surface data to float32 to comply with GIFTI standard
171
+ # GIFTI only supports uint8, int32, and float32 datatypes
172
+ if hasattr(surface, 'Points') and surface.Points.dtype != np.float32:
173
+ surface = surface.copy()
174
+ surface.Points = surface.Points.astype(np.float32)
175
+
176
+ f = tempfile.NamedTemporaryFile(suffix=".gii", delete=False)
177
+ f.close()
178
+ write_surface(surface, f.name, otype="gii")
179
+ surfaces_gii.append(f.name)
180
+ temp_files.append(f.name)
181
+ else:
182
+ surfaces_gii.append(surface)
183
+
184
+ # Use abagen to grab expression data.
185
+ logger.info(
186
+ "If you use BrainStat's genetics functionality, please cite abagen (https://abagen.readthedocs.io/en/stable/citing.html)."
187
+ )
188
+ atlas = check_atlas(labels, geometry=surfaces_gii, space=space)
189
+ expression = get_expression_data(
190
+ atlas,
191
+ atlas_info=atlas_info,
192
+ ibf_threshold=ibf_threshold,
193
+ probe_selection=probe_selection,
194
+ donor_probes=donor_probes,
195
+ lr_mirror=lr_mirror,
196
+ missing=missing,
197
+ tolerance=tolerance,
198
+ sample_norm=sample_norm,
199
+ gene_norm=gene_norm,
200
+ norm_matched=norm_matched,
201
+ norm_structures=norm_structures,
202
+ region_agg=region_agg,
203
+ agg_metric=agg_metric,
204
+ corrected_mni=corrected_mni,
205
+ reannotated=reannotated,
206
+ return_counts=return_counts,
207
+ return_donors=return_donors,
208
+ return_report=return_report,
209
+ donors=donors,
210
+ data_dir=data_dir,
211
+ verbose=verbose,
212
+ n_proc=n_proc,
213
+ )
214
+
215
+ return expression
216
+ finally:
217
+ for f in temp_files:
96
218
  try:
97
- with tempfile.NamedTemporaryFile(suffix=".gii", delete=False) as f:
98
- name = f.name
99
- write_surface(surface, name, otype="gii")
100
- surfaces_gii.append(nib.load(name))
101
- finally:
102
- Path(name).unlink()
103
- else:
104
- surfaces_gii.append(nib.load(surface))
105
-
106
- # Use abagen to grab expression data.
107
- logger.info(
108
- "If you use BrainStat's genetics functionality, please cite abagen (https://abagen.readthedocs.io/en/stable/citing.html)."
109
- )
110
- atlas = check_atlas(labels, geometry=surfaces_gii, space=space)
111
- expression = get_expression_data(
112
- atlas,
113
- atlas_info=atlas_info,
114
- ibf_threshold=ibf_threshold,
115
- probe_selection=probe_selection,
116
- donor_probes=donor_probes,
117
- lr_mirror=lr_mirror,
118
- missing=missing,
119
- tolerance=tolerance,
120
- sample_norm=sample_norm,
121
- gene_norm=gene_norm,
122
- norm_matched=norm_matched,
123
- norm_structures=norm_structures,
124
- region_agg=region_agg,
125
- agg_metric=agg_metric,
126
- corrected_mni=corrected_mni,
127
- reannotated=reannotated,
128
- return_counts=return_counts,
129
- return_donors=return_donors,
130
- return_report=return_report,
131
- donors=donors,
132
- data_dir=data_dir,
133
- verbose=verbose,
134
- n_proc=n_proc,
135
- )
136
-
137
- return expression
219
+ Path(f).unlink()
220
+ except FileNotFoundError:
221
+ pass
138
222
 
139
223
 
140
224
  def __create_precomputed(
@@ -114,6 +114,7 @@ def read_histology_profile(
114
114
  data_dir: Optional[Union[str, Path]] = None,
115
115
  template: str = "fsaverage",
116
116
  overwrite: bool = False,
117
+ invert: bool = True,
117
118
  ) -> np.ndarray:
118
119
  """Reads BigBrain histology profiles.
119
120
 
@@ -127,6 +128,15 @@ def read_histology_profile(
127
128
  default 'fsaverage'.
128
129
  overwrite : bool, optional
129
130
  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.
130
140
 
131
141
  Returns
132
142
  -------
@@ -156,13 +166,32 @@ def read_histology_profile(
156
166
  )
157
167
 
158
168
  with h5py.File(histology_file, "r") as h5_file:
159
- profiles = h5_file.get(template)[...]
160
- if civet_template:
161
- fsaverage_surface = fetch_template_surface("fsaverage")
162
- civet_surface = fetch_template_surface(civet_template)
163
- return _surf2surf(fsaverage_surface, civet_surface, profiles.T).T
169
+ if template == "fslr32k":
170
+ # Known issue #369: The fslr32k data file on the server actually contains
171
+ # fs_LR_64k resolution data (64984 vertices) instead of fs_LR_32k (32492 vertices).
172
+ # This is a data hosting issue. Users expecting 32k resolution data should be aware
173
+ # that they will receive 64k resolution data until this is fixed on the server.
174
+ logger.warning(
175
+ "Known issue: The fslr32k histology profile data currently contains "
176
+ "fs_LR_64k resolution (64984 vertices) instead of fs_LR_32k (32492 vertices). "
177
+ "See https://github.com/MICA-MNI/BrainStat/issues/369 for details."
178
+ )
179
+ profiles = h5_file.get("fs_LR_64k")[...]
164
180
  else:
165
- return profiles
181
+ profiles = h5_file.get(template)[...]
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
166
195
 
167
196
 
168
197
  def download_histology_profiles(
@@ -191,7 +220,16 @@ def download_histology_profiles(
191
220
 
192
221
  data_dir = Path(data_dir) if data_dir else data_directories["BIGBRAIN_DATA_DIR"]
193
222
  data_dir.mkdir(parents=True, exist_ok=True)
194
- output_file = data_dir / ("histology_" + template + ".h5")
223
+ if template == "fslr32k":
224
+ output_file = data_dir / "histology_fslr32k.h5"
225
+ # Known issue #369: warn users about data resolution mismatch
226
+ logger.warning(
227
+ "Note: The fslr32k histology profile data currently contains "
228
+ "fs_LR_64k resolution (64984 vertices) instead of fs_LR_32k. "
229
+ "See https://github.com/MICA-MNI/BrainStat/issues/369 for details."
230
+ )
231
+ else:
232
+ output_file = data_dir / ("histology_" + template + ".h5")
195
233
 
196
234
  url = read_data_fetcher_json()["bigbrain_profiles"][template]["url"]
197
235
 
@@ -1,6 +1,8 @@
1
1
  """ Load external datasets. """
2
+ import json
2
3
  import tempfile
3
4
  import zipfile
5
+ from collections import namedtuple
4
6
  from pathlib import Path
5
7
  from typing import List, Optional, Tuple, Union
6
8
 
@@ -11,9 +13,19 @@ from brainspace.mesh.mesh_io import read_surface
11
13
  from brainspace.mesh.mesh_operations import combine_surfaces
12
14
  from brainspace.vtk_interface.wrappers.data_object import BSPolyData
13
15
  from netneurotools import datasets as nnt_datasets
14
- from netneurotools.civet import read_civet
15
16
  from nibabel import load as nib_load
16
17
  from nibabel.freesurfer.io import read_annot, read_geometry
18
+ from sklearn.utils import Bunch
19
+
20
+ try:
21
+ from netneurotools.datasets.utils import _get_data_dir, _get_dataset_info
22
+ except ImportError:
23
+ from netneurotools.datasets.datasets_utils import _get_data_dir, _get_dataset_info
24
+
25
+ try:
26
+ from nilearn.datasets._utils import fetch_files as _fetch_files
27
+ except ImportError:
28
+ from nilearn.datasets.utils import fetch_files as _fetch_files
17
29
 
18
30
  from brainstat._utils import (
19
31
  _download_file,
@@ -24,6 +36,65 @@ from brainstat._utils import (
24
36
  from brainstat.mesh.interpolate import _surf2surf
25
37
 
26
38
 
39
+ def read_civet(fname: Union[str, Path]) -> Tuple[np.ndarray, np.ndarray]:
40
+ """
41
+ Reads a CIVET surface file (MNI OBJ format).
42
+
43
+ Parameters
44
+ ----------
45
+ fname : str or Path
46
+ Path to the CIVET surface file.
47
+
48
+ Returns
49
+ -------
50
+ vertices : numpy.ndarray
51
+ (N, 3) array of vertex coordinates.
52
+ faces : numpy.ndarray
53
+ (M, 3) array of face indices.
54
+ """
55
+ fname = str(fname)
56
+ with open(fname, "rb") as f:
57
+ first_char = f.read(1)
58
+
59
+ if first_char == b"P":
60
+ with open(fname, "r") as f:
61
+ content = f.read().split()
62
+
63
+ n_vertices = int(content[6])
64
+
65
+ idx = 7
66
+ # Vertices
67
+ vertices = np.array(content[idx : idx + n_vertices * 3], dtype=float).reshape(
68
+ -1, 3
69
+ )
70
+ idx += n_vertices * 3
71
+
72
+ # Normals
73
+ idx += n_vertices * 3
74
+
75
+ n_triangles = int(content[idx])
76
+ idx += 1
77
+
78
+ color_flag = int(content[idx])
79
+ idx += 1
80
+
81
+ if color_flag == 0:
82
+ idx += 4
83
+ else:
84
+ idx += n_vertices * 4
85
+
86
+ # Check remaining items to decide if we need to skip
87
+ remaining = len(content) - idx
88
+ if remaining == n_triangles * 3 + n_triangles:
89
+ idx += n_triangles
90
+
91
+ triangles = np.array(content[idx:], dtype=int).reshape(-1, 3)
92
+
93
+ return vertices, triangles
94
+ else:
95
+ raise NotImplementedError("Binary CIVET files are not supported yet.")
96
+
97
+
27
98
  def fetch_parcellation(
28
99
  template: str,
29
100
  atlas: str,
@@ -40,12 +111,15 @@ def fetch_parcellation(
40
111
  The surface template. Valid values are "fsaverage", "fsaverage5",
41
112
  "fsaverage6", "fslr32k", "civet41k", "civet164k", by default "fsaverage5".
42
113
  atlas : str
43
- 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".
44
116
  n_regions : int
45
117
  Number of regions of the requested atlas. Valid values for the cammoun
46
118
  atlas are 33, 60, 125, 250, 500. Valid values for the glasser atlas are
47
119
  360. Valid values for the "schaefer" atlas are 100, 200, 300, 400, 500,
48
- 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``.
49
123
  join : bool, optional
50
124
  If true, returns parcellation as a single array, if false, returns an
51
125
  array per hemisphere, by default True.
@@ -83,6 +157,8 @@ def fetch_parcellation(
83
157
  parcellations = _fetch_glasser_parcellation(template, data_dir)
84
158
  elif atlas == "yeo":
85
159
  parcellations = _fetch_yeo_parcellation(template, n_regions, data_dir)
160
+ elif atlas == "destrieux":
161
+ parcellations = _fetch_destrieux_parcellation(template, data_dir)
86
162
  else:
87
163
  raise ValueError(f"Invalid atlas: {atlas}")
88
164
 
@@ -147,7 +223,7 @@ def fetch_template_surface(
147
223
  surfaces_fs = [read_geometry(file) for file in surface_files]
148
224
  surfaces = [build_polydata(surface[0], surface[1]) for surface in surfaces_fs]
149
225
  elif template == "fslr32k":
150
- surfaces = [read_surface(file) for file in surface_files]
226
+ surfaces = [read_surface(str(file)) for file in surface_files]
151
227
  else:
152
228
  surfaces_obj = [read_civet(file) for file in surface_files]
153
229
  surfaces = [build_polydata(surface[0], surface[1]) for surface in surfaces_obj]
@@ -384,7 +460,7 @@ def _fetch_template_surface_files(
384
460
 
385
461
  if template == "fslr32k":
386
462
  layer = layer if layer else "midthickness"
387
- bunch = nnt_datasets.fetch_conte69(data_dir=str(data_dir))
463
+ bunch = _fetch_conte69_fixed(data_dir=str(data_dir))
388
464
  elif template == "civet41k" or template == "civet164k":
389
465
  layer = layer if layer else "mid"
390
466
  if layer == "sphere":
@@ -466,6 +542,48 @@ def _fetch_glasser_parcellation(template: str, data_dir: Path) -> List[np.ndarra
466
542
  return parcellations
467
543
 
468
544
 
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
+
469
587
  def _fetch_yeo_parcellation(
470
588
  template: str, n_regions: int, data_dir: Path
471
589
  ) -> List[np.ndarray]:
@@ -515,3 +633,43 @@ def _fetch_civet_spheres(template: str, data_dir: Path) -> Tuple[str, str]:
515
633
 
516
634
  # Return two filenames to conform to other left/right hemisphere functions.
517
635
  return (str(filename), str(filename))
636
+
637
+
638
+ SURFACE = namedtuple('Surface', ('lh', 'rh'))
639
+
640
+
641
+ def _fetch_conte69_fixed(data_dir=None, url=None, resume=True, verbose=1):
642
+ """
643
+ Download files for Van Essen et al., 2012 Conte69 template.
644
+ Fixed version to handle encoding on Windows.
645
+ """
646
+ dataset_name = 'tpl-conte69'
647
+ keys = ['midthickness', 'inflated', 'vinflated']
648
+
649
+ data_dir = _get_data_dir(data_dir=data_dir)
650
+ info = _get_dataset_info(dataset_name)
651
+ if url is None:
652
+ url = info['url']
653
+
654
+ opts = {
655
+ 'uncompress': True,
656
+ 'md5sum': info['md5'],
657
+ 'move': '{}.tar.gz'.format(dataset_name)
658
+ }
659
+
660
+ filenames = [
661
+ 'tpl-conte69/tpl-conte69_space-MNI305_variant-fsLR32k_{}.{}.surf.gii'
662
+ .format(res, hemi) for res in keys for hemi in ['L', 'R']
663
+ ] + ['tpl-conte69/template_description.json']
664
+
665
+ data = _fetch_files(data_dir, files=[(f, url, opts) for f in filenames],
666
+ resume=resume, verbose=verbose)
667
+
668
+ # Fix: Specify encoding='utf-8'
669
+ with open(data[-1], 'r', encoding='utf-8') as src:
670
+ data[-1] = json.load(src)
671
+
672
+ # bundle hemispheres together
673
+ data = [SURFACE(*data[:-1][i:i + 2]) for i in range(0, 6, 2)] + [data[-1]]
674
+
675
+ return Bunch(**dict(zip(keys + ['info'], data)))
@@ -13,7 +13,7 @@ from brainspace.mesh.mesh_elements import get_cells, get_points
13
13
  from brainspace.mesh.mesh_io import read_surface
14
14
  from brainspace.vtk_interface.wrappers.data_object import BSPolyData
15
15
  from nilearn.datasets import load_mni152_brain_mask
16
- from scipy.interpolate.ndgriddata import LinearNDInterpolator, NearestNDInterpolator
16
+ from scipy.interpolate import LinearNDInterpolator, NearestNDInterpolator
17
17
  from scipy.spatial import cKDTree
18
18
 
19
19
  from brainstat._typing import ArrayLike