bsplot 0.0.2__py3-none-any.whl
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.
- bsplot/__init__.py +23 -0
- bsplot/anat.py +55 -0
- bsplot/animate.py +194 -0
- bsplot/bioicons.py +777 -0
- bsplot/brain.py +188 -0
- bsplot/colors.py +1935 -0
- bsplot/data/HCP_avg-SC.txt +379 -0
- bsplot/data/MNI152.rh.pial +0 -0
- bsplot/data/STN_lh.nii.gz +0 -0
- bsplot/data/STN_rh.nii.gz +0 -0
- bsplot/data/__init__.py +22 -0
- bsplot/data/left_electrode.ply +257270 -0
- bsplot/data/parcellations/HCP-MMP1.L.label.gii +442 -0
- bsplot/data/parcellations/HCP-MMP1.R.label.gii +442 -0
- bsplot/data/parcellations/code/split_hcpmmp1.sh +4 -0
- bsplot/data/right_electrode.ply +257270 -0
- bsplot/data/surface.py +112 -0
- bsplot/data/tpl-MNI152NLin2009b_atlas-hcpmmp1_desc-ordered_dseg.nii.gz +0 -0
- bsplot/figure.py +177 -0
- bsplot/graph/__init__.py +31 -0
- bsplot/graph/edges.py +312 -0
- bsplot/graph/flowchart.py +762 -0
- bsplot/graph/layout.py +201 -0
- bsplot/graph/network.py +974 -0
- bsplot/graph/nodes.py +452 -0
- bsplot/panels.py +110 -0
- bsplot/scientific_color_maps/__init__.py +0 -0
- bsplot/scientific_color_maps/_sync_from_zenodo_download.sh +1 -0
- bsplot/scientific_color_maps/batlowK.txt +256 -0
- bsplot/scientific_color_maps/batlowW.txt +256 -0
- bsplot/scientific_color_maps/cyclic/bamO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/brocO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/corkO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/romaO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/vikO.txt +256 -0
- bsplot/scientific_color_maps/diverging/bam.txt +256 -0
- bsplot/scientific_color_maps/diverging/berlin.txt +256 -0
- bsplot/scientific_color_maps/diverging/broc.txt +256 -0
- bsplot/scientific_color_maps/diverging/cork.txt +256 -0
- bsplot/scientific_color_maps/diverging/lisbon.txt +256 -0
- bsplot/scientific_color_maps/diverging/managua.txt +256 -0
- bsplot/scientific_color_maps/diverging/roma.txt +256 -0
- bsplot/scientific_color_maps/diverging/tofino.txt +256 -0
- bsplot/scientific_color_maps/diverging/vanimo.txt +256 -0
- bsplot/scientific_color_maps/diverging/vik.txt +256 -0
- bsplot/scientific_color_maps/multisequential/bukavu.txt +256 -0
- bsplot/scientific_color_maps/multisequential/fes.txt +256 -0
- bsplot/scientific_color_maps/multisequential/oleron.txt +256 -0
- bsplot/scientific_color_maps/naviaW.txt +256 -0
- bsplot/scientific_color_maps/sequential/acton.txt +256 -0
- bsplot/scientific_color_maps/sequential/bamako.txt +256 -0
- bsplot/scientific_color_maps/sequential/batlow.txt +256 -0
- bsplot/scientific_color_maps/sequential/bilbao.txt +256 -0
- bsplot/scientific_color_maps/sequential/buda.txt +256 -0
- bsplot/scientific_color_maps/sequential/davos.txt +256 -0
- bsplot/scientific_color_maps/sequential/devon.txt +256 -0
- bsplot/scientific_color_maps/sequential/glasgow.txt +256 -0
- bsplot/scientific_color_maps/sequential/grayC.txt +256 -0
- bsplot/scientific_color_maps/sequential/hawaii.txt +256 -0
- bsplot/scientific_color_maps/sequential/imola.txt +256 -0
- bsplot/scientific_color_maps/sequential/lajolla.txt +256 -0
- bsplot/scientific_color_maps/sequential/lapaz.txt +256 -0
- bsplot/scientific_color_maps/sequential/lipari.txt +256 -0
- bsplot/scientific_color_maps/sequential/navia.txt +256 -0
- bsplot/scientific_color_maps/sequential/nuuk.txt +256 -0
- bsplot/scientific_color_maps/sequential/oslo.txt +256 -0
- bsplot/scientific_color_maps/sequential/tokyo.txt +256 -0
- bsplot/scientific_color_maps/sequential/turku.txt +256 -0
- bsplot/streamlines.py +314 -0
- bsplot/style.py +791 -0
- bsplot/styles/black.mplstyle +78 -0
- bsplot/styles/bss.mplstyle +50 -0
- bsplot/styles/bwcomp.mplstyle +30 -0
- bsplot/styles/nature.mplstyle +57 -0
- bsplot/styles/transparent.mplstyle +16 -0
- bsplot/styles/tvbo.mplstyle +57 -0
- bsplot/surface.py +2370 -0
- bsplot/templates.py +270 -0
- bsplot/text.py +199 -0
- bsplot/text2obj.py +59 -0
- bsplot/timeseries.py +10 -0
- bsplot/utils.py +13 -0
- bsplot/volume.py +958 -0
- bsplot-0.0.2.dist-info/METADATA +60 -0
- bsplot-0.0.2.dist-info/RECORD +89 -0
- bsplot-0.0.2.dist-info/WHEEL +5 -0
- bsplot-0.0.2.dist-info/entry_points.txt +2 -0
- bsplot-0.0.2.dist-info/licenses/LICENSE +193 -0
- bsplot-0.0.2.dist-info/top_level.txt +1 -0
bsplot/volume.py
ADDED
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import nibabel as nib
|
|
6
|
+
import numpy as np
|
|
7
|
+
import numpy.ma as ma
|
|
8
|
+
from matplotlib import colors, patches, transforms
|
|
9
|
+
from scipy.ndimage import center_of_mass
|
|
10
|
+
from skimage.measure import find_contours
|
|
11
|
+
|
|
12
|
+
from bsplot.colors import Colormap
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_center_of_mass(img, mode="absolute", space="voxel", view=None):
|
|
16
|
+
"""
|
|
17
|
+
Compute center of mass of image data.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
img : str, PathLike, or Nifti1Image
|
|
22
|
+
NIfTI image or path to one.
|
|
23
|
+
mode : str
|
|
24
|
+
Weighting mode for center of mass calculation:
|
|
25
|
+
- 'absolute': use absolute values (default)
|
|
26
|
+
- 'positive': only positive values
|
|
27
|
+
- 'negative': only negative values (inverted)
|
|
28
|
+
- 'relative': shift minimum to zero
|
|
29
|
+
space : str
|
|
30
|
+
- 'voxel': return [i, j, k] voxel indices (int)
|
|
31
|
+
- 'world' or 'mm': return [x, y, z] in millimeters (float)
|
|
32
|
+
view : str, optional
|
|
33
|
+
If provided, return only the coordinate for this view plane:
|
|
34
|
+
- 'sagittal'/'lateral': X coordinate (index 0)
|
|
35
|
+
- 'coronal'/'frontal': Y coordinate (index 1)
|
|
36
|
+
- 'axial'/'horizontal'/'transverse': Z coordinate (index 2)
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
coords : ndarray or float
|
|
41
|
+
Center of mass coordinates. If view is None, returns shape (3,) array.
|
|
42
|
+
If view is specified, returns single coordinate (int for voxel, float for world).
|
|
43
|
+
"""
|
|
44
|
+
if isinstance(img, (str, os.PathLike)):
|
|
45
|
+
img = nib.load(img)
|
|
46
|
+
|
|
47
|
+
data = img.get_fdata()
|
|
48
|
+
data = np.nan_to_num(data, nan=0.0)
|
|
49
|
+
|
|
50
|
+
if mode == "absolute":
|
|
51
|
+
weights = np.abs(data)
|
|
52
|
+
elif mode == "positive":
|
|
53
|
+
weights = np.where(data > 0, data, 0)
|
|
54
|
+
elif mode == "negative":
|
|
55
|
+
weights = np.where(data < 0, -data, 0)
|
|
56
|
+
elif mode == "relative":
|
|
57
|
+
data = data - np.nanmin(data)
|
|
58
|
+
weights = np.where(data == 0, 0, data)
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(f"Invalid mode: {mode}")
|
|
61
|
+
|
|
62
|
+
if not np.any(weights):
|
|
63
|
+
com_voxel = np.array([0.0, 0.0, 0.0])
|
|
64
|
+
else:
|
|
65
|
+
com_voxel = np.array(center_of_mass(weights))
|
|
66
|
+
|
|
67
|
+
if space == "voxel":
|
|
68
|
+
# Return integer voxel indices, clamped to valid range
|
|
69
|
+
coords = np.array(
|
|
70
|
+
[
|
|
71
|
+
min(max(0, int(round(c))), s - 1)
|
|
72
|
+
for c, s in zip(com_voxel, img.shape[:3])
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
elif space in ("world", "mm"):
|
|
76
|
+
coords = nib.affines.apply_affine(img.affine, com_voxel)
|
|
77
|
+
else:
|
|
78
|
+
raise ValueError(f"Invalid space: {space}. Use 'voxel' or 'world'/'mm'.")
|
|
79
|
+
|
|
80
|
+
if view is not None:
|
|
81
|
+
idx = get_view_index(view)
|
|
82
|
+
return int(round(coords[idx])) if space == "voxel" else coords[idx]
|
|
83
|
+
|
|
84
|
+
return coords
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Backwards compatibility aliases
|
|
88
|
+
def get_cut_coords(nii_img):
|
|
89
|
+
"""Deprecated: use get_center_of_mass(img, space='world')"""
|
|
90
|
+
return get_center_of_mass(nii_img, space="world", view=None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_com_slice_index(nii_img, plane="sagittal"):
|
|
94
|
+
"""Deprecated: use get_center_of_mass(img, space='world', view=plane)"""
|
|
95
|
+
return get_center_of_mass(nii_img, space="world", view=plane)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_slice_index(img, mode="absolute"):
|
|
99
|
+
"""Deprecated: use get_center_of_mass(img, mode=mode, space='voxel')"""
|
|
100
|
+
return list(get_center_of_mass(img, mode=mode, space="voxel", view=None))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_world_coords(img):
|
|
104
|
+
"""Get world coordinate arrays for each axis."""
|
|
105
|
+
affine = img.affine
|
|
106
|
+
shape = img.shape
|
|
107
|
+
|
|
108
|
+
x_voxel = np.arange(shape[0])
|
|
109
|
+
y_voxel = np.arange(shape[1])
|
|
110
|
+
z_voxel = np.arange(shape[2])
|
|
111
|
+
|
|
112
|
+
x_world = affine[0, 0] * x_voxel + affine[0, 3]
|
|
113
|
+
y_world = affine[1, 1] * y_voxel + affine[1, 3]
|
|
114
|
+
z_world = affine[2, 2] * z_voxel + affine[2, 3]
|
|
115
|
+
|
|
116
|
+
return x_world, y_world, z_world
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _normalize_view(view):
|
|
120
|
+
"""Map plot_surf-style view names to canonical slice plane names.
|
|
121
|
+
|
|
122
|
+
Accepts all view aliases used by plot_surf and maps them to:
|
|
123
|
+
'sagittal', 'coronal', or 'horizontal'.
|
|
124
|
+
"""
|
|
125
|
+
v = view.lower().strip()
|
|
126
|
+
if v in ("sagittal", "lateral", "medial"):
|
|
127
|
+
return "sagittal"
|
|
128
|
+
elif v in ("coronal", "frontal", "anterior", "posterior", "front", "back"):
|
|
129
|
+
return "coronal"
|
|
130
|
+
elif v in (
|
|
131
|
+
"horizontal", "axial", "transverse",
|
|
132
|
+
"dorsal", "ventral", "top", "bottom", "superior", "inferior",
|
|
133
|
+
):
|
|
134
|
+
return "horizontal"
|
|
135
|
+
else:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"Invalid view: '{view}'. Use one of: sagittal/lateral/medial, "
|
|
138
|
+
f"coronal/frontal/anterior/posterior/front/back, "
|
|
139
|
+
f"horizontal/axial/transverse/dorsal/ventral/top/bottom/superior/inferior."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_view_index(view):
|
|
144
|
+
"""Map view name to axis index (0=sagittal/x, 1=coronal/y, 2=axial/z)."""
|
|
145
|
+
canon = _normalize_view(view)
|
|
146
|
+
if canon == "sagittal":
|
|
147
|
+
return 0
|
|
148
|
+
elif canon == "coronal":
|
|
149
|
+
return 1
|
|
150
|
+
else:
|
|
151
|
+
return 2
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_plane_indices(view):
|
|
155
|
+
"""Map view name to the two in-plane axis indices."""
|
|
156
|
+
canon = _normalize_view(view)
|
|
157
|
+
if canon == "sagittal":
|
|
158
|
+
return (1, 2)
|
|
159
|
+
elif canon == "coronal":
|
|
160
|
+
return (0, 2)
|
|
161
|
+
else:
|
|
162
|
+
return (0, 1)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def plot_slice(
|
|
166
|
+
img,
|
|
167
|
+
ax=None,
|
|
168
|
+
cmap="grey",
|
|
169
|
+
view="sagittal",
|
|
170
|
+
alpha=1.0,
|
|
171
|
+
sl=None,
|
|
172
|
+
slice_mm=None,
|
|
173
|
+
center_coordinate=None,
|
|
174
|
+
threshold=0,
|
|
175
|
+
plot_cut_coord=False,
|
|
176
|
+
annotate_slice_mm=False, # new: annotate the slice position in mm
|
|
177
|
+
intensity_projection=None, # max, mean, sum, absmax
|
|
178
|
+
diverging=False,
|
|
179
|
+
outline=False,
|
|
180
|
+
outline_kwargs={"color": "k", "linewidth": 0.5, "alpha": 1},
|
|
181
|
+
colorbar=False,
|
|
182
|
+
set_axis_extent=True,
|
|
183
|
+
rotate=0, # rotation in degrees (0, 90, 180, 270)
|
|
184
|
+
flip_lr=False, # flip data left-right for radiological convention
|
|
185
|
+
orientation_labels=None, # None=no labels, "axes"=axis labels, "corner"=corner indicators
|
|
186
|
+
hemi=None, # hemisphere hint for view-dependent x-axis direction
|
|
187
|
+
hide_hemi=None, # None, 'left', or 'right' – mask out that hemisphere (x<0 or x>0)
|
|
188
|
+
**kwargs,
|
|
189
|
+
):
|
|
190
|
+
# Save the original directional view name before normalising so we
|
|
191
|
+
# can determine whether the x-extent must be negated to match the
|
|
192
|
+
# world-coordinate convention used by plot_surf.
|
|
193
|
+
_original_view = view.lower().strip() if isinstance(view, str) else str(view)
|
|
194
|
+
|
|
195
|
+
# Normalize view aliases (e.g. "anterior" → "coronal") so all
|
|
196
|
+
# downstream checks use canonical names.
|
|
197
|
+
view = _normalize_view(view)
|
|
198
|
+
|
|
199
|
+
# Determine whether the horizontal axis should be negated to match
|
|
200
|
+
# the camera direction that plot_surf uses for the same view string.
|
|
201
|
+
# anterior / front / frontal → always negate x (hemisphere-independent)
|
|
202
|
+
# medial + rh → negate x
|
|
203
|
+
# lateral + lh → negate x
|
|
204
|
+
_negate_x = False
|
|
205
|
+
if _original_view in ("anterior", "front", "frontal", "bottom", "ventral", "inferior"):
|
|
206
|
+
_negate_x = True
|
|
207
|
+
elif hemi is not None:
|
|
208
|
+
_h = hemi.lower().strip()
|
|
209
|
+
_is_rh = _h.startswith("r")
|
|
210
|
+
if _original_view == "medial" and _is_rh:
|
|
211
|
+
_negate_x = True
|
|
212
|
+
elif _original_view == "lateral" and not _is_rh:
|
|
213
|
+
_negate_x = True
|
|
214
|
+
|
|
215
|
+
if outline and "color" in kwargs:
|
|
216
|
+
outline_kwargs["color"] = kwargs.pop("color")
|
|
217
|
+
if outline and "linewidth" in kwargs:
|
|
218
|
+
outline_kwargs["linewidth"] = kwargs.pop("linewidth")
|
|
219
|
+
if outline and "alpha" in kwargs:
|
|
220
|
+
outline_kwargs["alpha"] = kwargs.pop("alpha")
|
|
221
|
+
|
|
222
|
+
# Extract vmin/vmax from kwargs to allow user overrides
|
|
223
|
+
user_vmin = kwargs.pop("vmin", None)
|
|
224
|
+
user_vmax = kwargs.pop("vmax", None)
|
|
225
|
+
|
|
226
|
+
if isinstance(img, (str, os.PathLike)):
|
|
227
|
+
img = nib.load(img)
|
|
228
|
+
if ax is None:
|
|
229
|
+
fig, ax = plt.subplots()
|
|
230
|
+
return_fig = True
|
|
231
|
+
else:
|
|
232
|
+
return_fig = False
|
|
233
|
+
|
|
234
|
+
data = img.get_fdata()
|
|
235
|
+
|
|
236
|
+
contains_negative = np.any(data < 0) or diverging
|
|
237
|
+
|
|
238
|
+
data = np.where(data == 0, np.nan, data)
|
|
239
|
+
|
|
240
|
+
x_ras, y_ras, z_ras = get_world_coords(img)
|
|
241
|
+
|
|
242
|
+
if center_coordinate is not None:
|
|
243
|
+
x_slice_index, y_slice_index, z_slice_index = [
|
|
244
|
+
int(round(c))
|
|
245
|
+
for c in nib.affines.apply_affine(
|
|
246
|
+
np.linalg.inv(img.affine), center_coordinate
|
|
247
|
+
)
|
|
248
|
+
]
|
|
249
|
+
else:
|
|
250
|
+
x_slice_index, y_slice_index, z_slice_index = get_center_of_mass(img)
|
|
251
|
+
|
|
252
|
+
if view.lower() in ["sagittal", "lateral"]:
|
|
253
|
+
xlabel = r"P $\rightarrow$ A"
|
|
254
|
+
ylabel = r"I $\rightarrow$ S"
|
|
255
|
+
if slice_mm is not None:
|
|
256
|
+
sl = int(
|
|
257
|
+
round(
|
|
258
|
+
nib.affines.apply_affine(
|
|
259
|
+
np.linalg.inv(img.affine), [slice_mm, 0, 0]
|
|
260
|
+
)[0]
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
if sl is None:
|
|
264
|
+
sl = x_slice_index
|
|
265
|
+
data_slice = data[sl, :, :].T
|
|
266
|
+
ras_extent = (y_ras, z_ras)
|
|
267
|
+
|
|
268
|
+
elif view.lower() in ["coronal", "frontal"]:
|
|
269
|
+
xlabel = r"L $\rightarrow$ R"
|
|
270
|
+
ylabel = r"I $\rightarrow$ S"
|
|
271
|
+
|
|
272
|
+
if slice_mm is not None:
|
|
273
|
+
sl = int(
|
|
274
|
+
round(
|
|
275
|
+
nib.affines.apply_affine(
|
|
276
|
+
np.linalg.inv(img.affine), [0, slice_mm, 0]
|
|
277
|
+
)[1]
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
if sl is None:
|
|
281
|
+
sl = y_slice_index
|
|
282
|
+
data_slice = data[:, sl, :].T
|
|
283
|
+
ras_extent = (x_ras, z_ras)
|
|
284
|
+
|
|
285
|
+
elif view.lower() in ["horizontal", "axial", "transverse"]:
|
|
286
|
+
xlabel = r"L $\rightarrow$ R"
|
|
287
|
+
ylabel = r"P $\rightarrow$ A"
|
|
288
|
+
if slice_mm is not None:
|
|
289
|
+
sl = int(
|
|
290
|
+
round(
|
|
291
|
+
nib.affines.apply_affine(
|
|
292
|
+
np.linalg.inv(img.affine), [0, 0, slice_mm]
|
|
293
|
+
)[2]
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
if sl is None:
|
|
297
|
+
sl = z_slice_index
|
|
298
|
+
data_slice = data[:, :, sl].T
|
|
299
|
+
ras_extent = (x_ras, y_ras)
|
|
300
|
+
|
|
301
|
+
if intensity_projection:
|
|
302
|
+
mip_axis = (
|
|
303
|
+
0
|
|
304
|
+
if view.lower() in ["sagittal", "lateral"]
|
|
305
|
+
else 1 if view.lower() in ["coronal", "frontal"] else 2
|
|
306
|
+
)
|
|
307
|
+
# Use preprocessed data (zeros -> NaNs) so background stays transparent
|
|
308
|
+
# and extents remain consistent for MIP across views.
|
|
309
|
+
data_full = data
|
|
310
|
+
masked_full = ma.masked_invalid(data_full)
|
|
311
|
+
if intensity_projection == "max":
|
|
312
|
+
data_slice = masked_full.max(axis=mip_axis).filled(np.nan).T
|
|
313
|
+
elif intensity_projection == "min":
|
|
314
|
+
data_slice = masked_full.min(axis=mip_axis).filled(np.nan).T
|
|
315
|
+
# elif intensity_projection == 'minmax':
|
|
316
|
+
# data_slice = masked_full.max(axis=mip_axis).filled(np.nan).T if
|
|
317
|
+
elif intensity_projection == "mean":
|
|
318
|
+
data_slice = masked_full.mean(axis=mip_axis).filled(np.nan).T
|
|
319
|
+
elif intensity_projection == "median":
|
|
320
|
+
data_slice = ma.median(masked_full, axis=mip_axis).filled(np.nan).T
|
|
321
|
+
elif intensity_projection == "sum":
|
|
322
|
+
data_slice = masked_full.sum(axis=mip_axis).filled(np.nan).T
|
|
323
|
+
elif intensity_projection == "absmax":
|
|
324
|
+
max_arr = masked_full.max(axis=mip_axis).filled(np.nan)
|
|
325
|
+
min_arr = masked_full.min(axis=mip_axis).filled(np.nan)
|
|
326
|
+
pos_abs = np.abs(max_arr)
|
|
327
|
+
neg_abs = np.abs(min_arr)
|
|
328
|
+
data_slice = np.where(neg_abs > pos_abs, min_arr, max_arr).T
|
|
329
|
+
else:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"Unsupported intensity_projection: {intensity_projection}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Hide one hemisphere by masking x < 0 (left) or x > 0 (right) in the 2-D slice.
|
|
335
|
+
# Only meaningful for coronal and axial views where ras_extent[0] is x_ras.
|
|
336
|
+
if hide_hemi is not None and view.lower() not in ["sagittal", "lateral"]:
|
|
337
|
+
x_coords = ras_extent[0]
|
|
338
|
+
if hide_hemi.lower() == "left":
|
|
339
|
+
mask_cols = x_coords < 0
|
|
340
|
+
elif hide_hemi.lower() == "right":
|
|
341
|
+
mask_cols = x_coords > 0
|
|
342
|
+
else:
|
|
343
|
+
raise ValueError(f"hide_hemi must be None, 'left', or 'right', got {hide_hemi!r}")
|
|
344
|
+
data_slice = np.array(data_slice, dtype=float)
|
|
345
|
+
data_slice[:, mask_cols] = np.nan
|
|
346
|
+
|
|
347
|
+
# Store rotation for later application via transform (not data manipulation)
|
|
348
|
+
rotation_angle = rotate
|
|
349
|
+
|
|
350
|
+
data_slice = np.where(abs(data_slice) <= threshold, np.nan, data_slice)
|
|
351
|
+
data_slice = ma.masked_where(np.isnan(data_slice), data_slice)
|
|
352
|
+
|
|
353
|
+
# Determine valid rows/cols based on NaN mask to preserve real-world extents
|
|
354
|
+
data_slice_filled = data_slice.filled(np.nan)
|
|
355
|
+
valid_rows = ~np.all(np.isnan(data_slice_filled), axis=1)
|
|
356
|
+
valid_cols = ~np.all(np.isnan(data_slice_filled), axis=0)
|
|
357
|
+
|
|
358
|
+
# Crop the data slice to remove fully-NaN rows and columns
|
|
359
|
+
data_slice_cropped = data_slice[valid_rows][:, valid_cols]
|
|
360
|
+
cropped_filled = data_slice_cropped.filled(np.nan)
|
|
361
|
+
|
|
362
|
+
# Update extent based on the new bounds; if slice is fully empty, use full world extents
|
|
363
|
+
col_indices = np.where(valid_cols)[0]
|
|
364
|
+
row_indices = np.where(valid_rows)[0]
|
|
365
|
+
|
|
366
|
+
if col_indices.size == 0 or row_indices.size == 0:
|
|
367
|
+
extent = [
|
|
368
|
+
ras_extent[0][0],
|
|
369
|
+
ras_extent[0][-1],
|
|
370
|
+
ras_extent[1][0],
|
|
371
|
+
ras_extent[1][-1],
|
|
372
|
+
]
|
|
373
|
+
data_to_plot = ma.masked_all((2, 2), dtype=float) # transparent placeholder
|
|
374
|
+
else:
|
|
375
|
+
extent = [
|
|
376
|
+
ras_extent[0][col_indices[0]],
|
|
377
|
+
ras_extent[0][col_indices[-1]],
|
|
378
|
+
ras_extent[1][row_indices[0]],
|
|
379
|
+
ras_extent[1][row_indices[-1]],
|
|
380
|
+
]
|
|
381
|
+
data_to_plot = data_slice_cropped
|
|
382
|
+
|
|
383
|
+
# Negate horizontal extent so the slice aligns with plot_surf's
|
|
384
|
+
# world-coordinate projection for directional views.
|
|
385
|
+
if _negate_x:
|
|
386
|
+
extent[0], extent[1] = -extent[0], -extent[1]
|
|
387
|
+
|
|
388
|
+
if outline:
|
|
389
|
+
# Use the cropped slice shape consistently when mapping contour
|
|
390
|
+
# vertices back to world coordinates, so that outlines align with
|
|
391
|
+
# the displayed image region.
|
|
392
|
+
outline_data = np.fliplr(cropped_filled) if flip_lr else cropped_filled
|
|
393
|
+
contours = find_contours(np.nan_to_num(outline_data), level=0.5)
|
|
394
|
+
h, w = outline_data.shape
|
|
395
|
+
interp_x = np.linspace(extent[0], extent[1], w)
|
|
396
|
+
interp_y = np.linspace(extent[2], extent[3], h)
|
|
397
|
+
|
|
398
|
+
# Apply rotation transform if specified
|
|
399
|
+
if rotation_angle != 0:
|
|
400
|
+
from matplotlib.transforms import Affine2D
|
|
401
|
+
|
|
402
|
+
rotation_transform = Affine2D().rotate_deg(rotation_angle) + ax.transData
|
|
403
|
+
else:
|
|
404
|
+
rotation_transform = ax.transData
|
|
405
|
+
|
|
406
|
+
for contour in contours:
|
|
407
|
+
real_world_contour = np.column_stack(
|
|
408
|
+
[
|
|
409
|
+
np.interp(contour[:, 1], np.arange(w), interp_x),
|
|
410
|
+
np.interp(contour[:, 0], np.arange(h), interp_y),
|
|
411
|
+
]
|
|
412
|
+
)
|
|
413
|
+
ax.plot(
|
|
414
|
+
real_world_contour[:, 0],
|
|
415
|
+
real_world_contour[:, 1],
|
|
416
|
+
transform=rotation_transform,
|
|
417
|
+
**outline_kwargs,
|
|
418
|
+
)
|
|
419
|
+
mappable = None
|
|
420
|
+
else:
|
|
421
|
+
# Colormap scaling: if the data contains negatives (or diverging=True),
|
|
422
|
+
# use a symmetric range around zero so post-projection negative values remain visible.
|
|
423
|
+
# Otherwise, start at the threshold (if provided) for a cleaner lower bound.
|
|
424
|
+
|
|
425
|
+
if isinstance(cmap, str):
|
|
426
|
+
cmap = Colormap(cmap)
|
|
427
|
+
|
|
428
|
+
if "norm" in kwargs:
|
|
429
|
+
vmin = kwargs.pop("vmin", None)
|
|
430
|
+
vmax = kwargs.pop("vmax", None)
|
|
431
|
+
else:
|
|
432
|
+
if contains_negative:
|
|
433
|
+
vmax = (
|
|
434
|
+
np.nanmax(np.abs(cropped_filled))
|
|
435
|
+
if col_indices.size and row_indices.size
|
|
436
|
+
else 1.0
|
|
437
|
+
)
|
|
438
|
+
vmin = -vmax
|
|
439
|
+
else:
|
|
440
|
+
if col_indices.size and row_indices.size:
|
|
441
|
+
vmin = threshold if threshold != 0 else np.nanmin(cropped_filled)
|
|
442
|
+
vmax = np.nanmax(cropped_filled)
|
|
443
|
+
else:
|
|
444
|
+
vmin, vmax = None, None
|
|
445
|
+
|
|
446
|
+
# Allow user-provided vmin/vmax to override computed values
|
|
447
|
+
if user_vmin is not None:
|
|
448
|
+
vmin = user_vmin
|
|
449
|
+
if user_vmax is not None:
|
|
450
|
+
vmax = user_vmax
|
|
451
|
+
kwargs["vmin"] = vmin
|
|
452
|
+
kwargs["vmax"] = vmax
|
|
453
|
+
|
|
454
|
+
# Apply rotation transform if specified
|
|
455
|
+
if rotation_angle != 0:
|
|
456
|
+
from matplotlib.transforms import Affine2D
|
|
457
|
+
|
|
458
|
+
rotation_transform = Affine2D().rotate_deg(rotation_angle) + ax.transData
|
|
459
|
+
else:
|
|
460
|
+
rotation_transform = ax.transData
|
|
461
|
+
|
|
462
|
+
# Flip data left-right for radiological convention if requested
|
|
463
|
+
if flip_lr:
|
|
464
|
+
data_to_plot = np.fliplr(data_to_plot)
|
|
465
|
+
|
|
466
|
+
mappable = ax.imshow(
|
|
467
|
+
data_to_plot,
|
|
468
|
+
cmap=cmap,
|
|
469
|
+
interpolation="none",
|
|
470
|
+
alpha=alpha,
|
|
471
|
+
origin="lower",
|
|
472
|
+
extent=extent,
|
|
473
|
+
transform=rotation_transform,
|
|
474
|
+
**kwargs,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Ensure axes start with negative values if possible
|
|
478
|
+
if set_axis_extent:
|
|
479
|
+
# Calculate rotated extent if rotation is applied
|
|
480
|
+
if rotation_angle != 0:
|
|
481
|
+
# Get corners of extent rectangle
|
|
482
|
+
corners = np.array(
|
|
483
|
+
[
|
|
484
|
+
[extent[0], extent[2]], # bottom-left
|
|
485
|
+
[extent[1], extent[2]], # bottom-right
|
|
486
|
+
[extent[1], extent[3]], # top-right
|
|
487
|
+
[extent[0], extent[3]], # top-left
|
|
488
|
+
]
|
|
489
|
+
)
|
|
490
|
+
# Rotate corners around origin
|
|
491
|
+
angle_rad = np.deg2rad(rotation_angle)
|
|
492
|
+
cos_a, sin_a = np.cos(angle_rad), np.sin(angle_rad)
|
|
493
|
+
rotated_corners = np.column_stack(
|
|
494
|
+
[
|
|
495
|
+
corners[:, 0] * cos_a - corners[:, 1] * sin_a,
|
|
496
|
+
corners[:, 0] * sin_a + corners[:, 1] * cos_a,
|
|
497
|
+
]
|
|
498
|
+
)
|
|
499
|
+
# Get bounding box of rotated corners
|
|
500
|
+
xlim = (rotated_corners[:, 0].min(), rotated_corners[:, 0].max())
|
|
501
|
+
ylim = (rotated_corners[:, 1].min(), rotated_corners[:, 1].max())
|
|
502
|
+
else:
|
|
503
|
+
xlim = (min(extent[0], extent[1]), max(extent[0], extent[1]))
|
|
504
|
+
ylim = (min(extent[2], extent[3]), max(extent[2], extent[3]))
|
|
505
|
+
|
|
506
|
+
# Avoid matplotlib warning when min == max by adding small epsilon
|
|
507
|
+
if xlim[0] == xlim[1]:
|
|
508
|
+
xlim = (xlim[0] - 0.5, xlim[1] + 0.5)
|
|
509
|
+
if ylim[0] == ylim[1]:
|
|
510
|
+
ylim = (ylim[0] - 0.5, ylim[1] + 0.5)
|
|
511
|
+
|
|
512
|
+
ax.set_xlim(xlim[0], xlim[1])
|
|
513
|
+
ax.set_ylim(ylim[0], ylim[1])
|
|
514
|
+
|
|
515
|
+
if plot_cut_coord:
|
|
516
|
+
|
|
517
|
+
ax.text(
|
|
518
|
+
0.95,
|
|
519
|
+
0.95,
|
|
520
|
+
f"{'xyz'[get_view_index(view)]} = {sl}",
|
|
521
|
+
transform=ax.transAxes,
|
|
522
|
+
ha="right",
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# Optional annotation of the slice position in real-world millimeter space
|
|
526
|
+
if annotate_slice_mm:
|
|
527
|
+
# Determine world-space slice coordinate if not explicitly provided
|
|
528
|
+
world_arrays = [x_ras, y_ras, z_ras]
|
|
529
|
+
axis_idx = get_view_index(view)
|
|
530
|
+
axis_char = "xyz"[axis_idx]
|
|
531
|
+
if slice_mm is not None:
|
|
532
|
+
slice_mm_value = slice_mm
|
|
533
|
+
else:
|
|
534
|
+
# Derive from index -> mm using precomputed world arrays
|
|
535
|
+
slice_mm_value = world_arrays[axis_idx][sl]
|
|
536
|
+
ax.text(
|
|
537
|
+
0.95,
|
|
538
|
+
0.90, # slightly lower to avoid overlap with plot_cut_coord text
|
|
539
|
+
f"{axis_char} = {slice_mm_value:.1f} mm",
|
|
540
|
+
transform=ax.transAxes,
|
|
541
|
+
ha="right",
|
|
542
|
+
va="top",
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
ax.set_aspect("equal")
|
|
546
|
+
|
|
547
|
+
# Handle radiological convention: update labels only (no axis inversion)
|
|
548
|
+
if flip_lr:
|
|
549
|
+
if view.lower() in ["coronal", "frontal", "horizontal", "axial", "transverse"]:
|
|
550
|
+
xlabel = r"R $\rightarrow$ L"
|
|
551
|
+
if view.lower() in ["sagittal", "lateral"]:
|
|
552
|
+
xlabel = r"A $\rightarrow$ P" # flip anterior-posterior for sagittal
|
|
553
|
+
|
|
554
|
+
# Orientation labels
|
|
555
|
+
if orientation_labels == "axes":
|
|
556
|
+
ax.set_xlabel(xlabel)
|
|
557
|
+
ax.set_ylabel(ylabel)
|
|
558
|
+
elif orientation_labels == "corner":
|
|
559
|
+
# Add corner orientation indicators (like clinical neuroradiology images)
|
|
560
|
+
# Determine labels based on view and flip_lr
|
|
561
|
+
if view.lower() in ["coronal", "frontal"]:
|
|
562
|
+
left_label, right_label = ("R", "L") if flip_lr else ("L", "R")
|
|
563
|
+
top_label, bottom_label = "S", "I"
|
|
564
|
+
elif view.lower() in ["sagittal", "lateral"]:
|
|
565
|
+
left_label, right_label = ("A", "P") if flip_lr else ("P", "A")
|
|
566
|
+
top_label, bottom_label = "S", "I"
|
|
567
|
+
elif view.lower() in ["horizontal", "axial", "transverse"]:
|
|
568
|
+
left_label, right_label = ("R", "L") if flip_lr else ("L", "R")
|
|
569
|
+
top_label, bottom_label = "A", "P"
|
|
570
|
+
else:
|
|
571
|
+
left_label, right_label, top_label, bottom_label = "", "", "", ""
|
|
572
|
+
|
|
573
|
+
# Add labels outside the axes using data coordinates
|
|
574
|
+
from matplotlib.patheffects import withStroke
|
|
575
|
+
xlim = ax.get_xlim()
|
|
576
|
+
ylim = ax.get_ylim()
|
|
577
|
+
x_margin = (xlim[1] - xlim[0]) * 0.02
|
|
578
|
+
y_margin = (ylim[1] - ylim[0]) * 0.02
|
|
579
|
+
|
|
580
|
+
text_kwargs = dict(
|
|
581
|
+
fontsize=12, fontweight="bold", color="white", clip_on=False,
|
|
582
|
+
path_effects=[withStroke(linewidth=2, foreground="black")]
|
|
583
|
+
)
|
|
584
|
+
# Left/right at vertical center, just inside edges
|
|
585
|
+
ax.text(xlim[0] + x_margin, (ylim[0] + ylim[1]) / 2, left_label,
|
|
586
|
+
ha="left", va="center", **text_kwargs)
|
|
587
|
+
ax.text(xlim[1] - x_margin, (ylim[0] + ylim[1]) / 2, right_label,
|
|
588
|
+
ha="right", va="center", **text_kwargs)
|
|
589
|
+
# Top/bottom at horizontal center, just inside edges
|
|
590
|
+
ax.text((xlim[0] + xlim[1]) / 2, ylim[1] - y_margin, top_label,
|
|
591
|
+
ha="center", va="top", **text_kwargs)
|
|
592
|
+
ax.text((xlim[0] + xlim[1]) / 2, ylim[0] + y_margin, bottom_label,
|
|
593
|
+
ha="center", va="bottom", **text_kwargs)
|
|
594
|
+
|
|
595
|
+
if colorbar and mappable is not None:
|
|
596
|
+
cbar = plt.colorbar(mappable, ax=ax, shrink=0.8)
|
|
597
|
+
|
|
598
|
+
if return_fig:
|
|
599
|
+
plt.close()
|
|
600
|
+
return fig
|
|
601
|
+
|
|
602
|
+
return mappable
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# Backwards compatibility alias
|
|
606
|
+
def plot_img(*args, **kwargs):
|
|
607
|
+
"""Deprecated: use plot_slice instead."""
|
|
608
|
+
return plot_slice(*args, **kwargs)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def translate_com(source_img, target_img, view):
|
|
612
|
+
com = get_center_of_mass(source_img)
|
|
613
|
+
com_realworld = nib.affines.apply_affine(source_img.affine, com)
|
|
614
|
+
com_bg = nib.affines.apply_affine(np.linalg.inv(target_img.affine), com_realworld)
|
|
615
|
+
sl_bg = int(round(com_bg[get_view_index(view)]))
|
|
616
|
+
return sl_bg
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def get_com_view(img, view):
|
|
620
|
+
if isinstance(img, str) and os.path.isfile(img):
|
|
621
|
+
img = nib.load(img)
|
|
622
|
+
com = get_center_of_mass(img)
|
|
623
|
+
return com[get_view_index(view)]
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def plot_img_on_bg(
|
|
627
|
+
img, bg_img, ax=None, view="sagittal", cmap="viridis", zoom=1, **kwargs
|
|
628
|
+
):
|
|
629
|
+
|
|
630
|
+
if ax is None:
|
|
631
|
+
fig, ax = plt.subplots()
|
|
632
|
+
return_fig = True
|
|
633
|
+
else:
|
|
634
|
+
return_fig = False
|
|
635
|
+
|
|
636
|
+
com = get_center_of_mass(img)
|
|
637
|
+
sl = int(round(com[get_view_index(view)]))
|
|
638
|
+
com_realworld = nib.affines.apply_affine(img.affine, com)
|
|
639
|
+
com_bg = nib.affines.apply_affine(np.linalg.inv(bg_img.affine), com_realworld)
|
|
640
|
+
sl_bg = int(round(com_bg[get_view_index(view)]))
|
|
641
|
+
|
|
642
|
+
plot_slice(bg_img, ax=ax, cmap="gray", view=view, sl=sl_bg)
|
|
643
|
+
xlim = ax.get_xlim()
|
|
644
|
+
ylim = ax.get_ylim()
|
|
645
|
+
|
|
646
|
+
if not isinstance(img, list):
|
|
647
|
+
img = [img]
|
|
648
|
+
|
|
649
|
+
for iimg in img:
|
|
650
|
+
mappable = plot_slice(iimg, ax=ax, view=view, cmap=cmap, sl=sl, **kwargs)
|
|
651
|
+
|
|
652
|
+
ax.set_xlim([l / zoom for l in xlim])
|
|
653
|
+
ax.set_ylim([l / zoom for l in ylim])
|
|
654
|
+
|
|
655
|
+
if return_fig:
|
|
656
|
+
plt.close()
|
|
657
|
+
return fig
|
|
658
|
+
else:
|
|
659
|
+
return mappable
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _get_object_bounds(json_content, transform):
|
|
663
|
+
xmin, xmax, ymin, ymax = json_content["metadata"]["bounds"]
|
|
664
|
+
x0, y0 = transform.transform((xmin, ymin))
|
|
665
|
+
x1, y1 = transform.transform((xmax, ymax))
|
|
666
|
+
|
|
667
|
+
xmin, xmax = min(x0, x1), max(x0, x1)
|
|
668
|
+
ymin, ymax = min(y0, y1), max(y0, y1)
|
|
669
|
+
|
|
670
|
+
# A combination of a proportional factor (fraction of the drawing)
|
|
671
|
+
# and a guestimate of the linewidth
|
|
672
|
+
xmargin = (xmax - xmin) * 0.025
|
|
673
|
+
ymargin = (ymax - ymin) * 0.025
|
|
674
|
+
return xmin, xmax, ymin, ymax
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def plot_brain_schematics(ax, view="sagittal", **kwargs):
|
|
678
|
+
from nilearn.plotting import glass_brain
|
|
679
|
+
|
|
680
|
+
if view.lower() == "sagittal":
|
|
681
|
+
direction = "x"
|
|
682
|
+
shift_x = -5
|
|
683
|
+
shift_y = -5
|
|
684
|
+
elif view.lower() == "coronal":
|
|
685
|
+
direction = "y"
|
|
686
|
+
shift_x = 5
|
|
687
|
+
shift_y = -5
|
|
688
|
+
elif view.lower() == "horizontal":
|
|
689
|
+
direction = "z"
|
|
690
|
+
shift_x = 5
|
|
691
|
+
shift_y = 5
|
|
692
|
+
shift_x = 0
|
|
693
|
+
shift_y = 0
|
|
694
|
+
|
|
695
|
+
get_axis_bg_color = ax.get_facecolor()
|
|
696
|
+
|
|
697
|
+
black_bg = colors.colorConverter.to_rgba(
|
|
698
|
+
get_axis_bg_color
|
|
699
|
+
) == colors.colorConverter.to_rgba("k")
|
|
700
|
+
|
|
701
|
+
json_filename, transform = glass_brain._get_json_and_transform(direction)
|
|
702
|
+
with open(json_filename) as json_file:
|
|
703
|
+
json_content = json.load(json_file)
|
|
704
|
+
|
|
705
|
+
zoom = 1
|
|
706
|
+
|
|
707
|
+
xmin, xmax, ymin, ymax = json_content["metadata"]["bounds"]
|
|
708
|
+
cx = (xmin + xmax) / 2
|
|
709
|
+
cy = (ymin + ymax) / 2
|
|
710
|
+
|
|
711
|
+
# Compose zoom-around-center transform
|
|
712
|
+
transform = (
|
|
713
|
+
transforms.Affine2D()
|
|
714
|
+
.translate(-cx, -cy) # move to center
|
|
715
|
+
.scale(zoom, zoom) # zoom
|
|
716
|
+
.translate(cx, cy) # move back
|
|
717
|
+
.translate(shift_x, shift_y) # final shift
|
|
718
|
+
+ transform
|
|
719
|
+
)
|
|
720
|
+
# transform = transform.scale(zoom, zoom)
|
|
721
|
+
linewidth_scale = kwargs.pop("linewidth", None)
|
|
722
|
+
abs_linewidth = kwargs.pop("abs_linewidth", None)
|
|
723
|
+
mpl_patches = glass_brain._get_mpl_patches(
|
|
724
|
+
json_content,
|
|
725
|
+
transform=transform + ax.transData,
|
|
726
|
+
invert_color=black_bg,
|
|
727
|
+
**kwargs,
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
# Build absolute linewidth mapping: original widths -> replacement widths
|
|
731
|
+
if abs_linewidth is not None:
|
|
732
|
+
original_widths = sorted({p["linewidth"] for p in json_content["paths"]})
|
|
733
|
+
if isinstance(abs_linewidth, (int, float)):
|
|
734
|
+
lw_map = {ow: abs_linewidth for ow in original_widths}
|
|
735
|
+
else:
|
|
736
|
+
# list/tuple: map to [fine, medium, bold] in ascending order
|
|
737
|
+
abs_linewidth = list(abs_linewidth)
|
|
738
|
+
if len(abs_linewidth) != len(original_widths):
|
|
739
|
+
raise ValueError(
|
|
740
|
+
f"abs_linewidth list must have {len(original_widths)} values "
|
|
741
|
+
f"(one per width category: {original_widths}), "
|
|
742
|
+
f"got {len(abs_linewidth)}"
|
|
743
|
+
)
|
|
744
|
+
lw_map = dict(zip(original_widths, abs_linewidth))
|
|
745
|
+
|
|
746
|
+
for mpl_patch in mpl_patches:
|
|
747
|
+
if abs_linewidth is not None:
|
|
748
|
+
# Map original width to user-specified absolute width
|
|
749
|
+
orig_lw = mpl_patch.get_linewidth()
|
|
750
|
+
mpl_patch.set_linewidth(lw_map.get(orig_lw, orig_lw))
|
|
751
|
+
elif linewidth_scale is not None:
|
|
752
|
+
mpl_patch.set_linewidth(mpl_patch.get_linewidth() * linewidth_scale)
|
|
753
|
+
ax.add_patch(mpl_patch)
|
|
754
|
+
|
|
755
|
+
bounds = _get_object_bounds(json_content, transform)
|
|
756
|
+
ax.set_xlim(bounds[0], bounds[1])
|
|
757
|
+
ax.set_ylim(bounds[2], bounds[3])
|
|
758
|
+
ax.set_aspect("equal")
|
|
759
|
+
return bounds
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def get_world_extent(img):
|
|
763
|
+
from itertools import product
|
|
764
|
+
|
|
765
|
+
shape = np.array(img.shape[:3])
|
|
766
|
+
corners = np.array(
|
|
767
|
+
list(product([0, shape[0] - 1], [0, shape[1] - 1], [0, shape[2] - 1]))
|
|
768
|
+
)
|
|
769
|
+
real = nib.affines.apply_affine(img.affine, corners)
|
|
770
|
+
return np.min(real, axis=0), np.max(real, axis=0)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def plot_image_extent(
|
|
774
|
+
img,
|
|
775
|
+
ax,
|
|
776
|
+
view="sagittal",
|
|
777
|
+
color="r",
|
|
778
|
+
zoom_in=False,
|
|
779
|
+
pad=5,
|
|
780
|
+
slice_mm=None,
|
|
781
|
+
threshold=0,
|
|
782
|
+
hemi=None,
|
|
783
|
+
**kwargs,
|
|
784
|
+
):
|
|
785
|
+
if isinstance(img, (str, os.PathLike)):
|
|
786
|
+
img = nib.load(img)
|
|
787
|
+
|
|
788
|
+
# Determine whether the horizontal axis must be negated to match
|
|
789
|
+
# the coordinate convention used by plot_slice / plot_surf.
|
|
790
|
+
_original_view = view.lower().strip() if isinstance(view, str) else str(view)
|
|
791
|
+
_negate_x = _original_view in ("anterior", "front", "frontal",
|
|
792
|
+
"bottom", "ventral", "inferior")
|
|
793
|
+
if not _negate_x and hemi is not None:
|
|
794
|
+
_h = hemi.lower().strip()
|
|
795
|
+
_is_rh = _h.startswith("r")
|
|
796
|
+
if _original_view == "medial" and _is_rh:
|
|
797
|
+
_negate_x = True
|
|
798
|
+
elif _original_view == "lateral" and not _is_rh:
|
|
799
|
+
_negate_x = True
|
|
800
|
+
|
|
801
|
+
# Use get_plane_indices which normalises view aliases internally.
|
|
802
|
+
axes = get_plane_indices(view)
|
|
803
|
+
|
|
804
|
+
if slice_mm is not None:
|
|
805
|
+
# Get extent at specific slice
|
|
806
|
+
data = img.get_fdata()
|
|
807
|
+
slice_axis = get_view_index(view)
|
|
808
|
+
|
|
809
|
+
# Convert slice_mm to voxel index
|
|
810
|
+
slice_voxel = int(
|
|
811
|
+
round(
|
|
812
|
+
nib.affines.apply_affine(
|
|
813
|
+
np.linalg.inv(img.affine),
|
|
814
|
+
[
|
|
815
|
+
slice_mm if slice_axis == 0 else 0,
|
|
816
|
+
slice_mm if slice_axis == 1 else 0,
|
|
817
|
+
slice_mm if slice_axis == 2 else 0,
|
|
818
|
+
],
|
|
819
|
+
)[slice_axis]
|
|
820
|
+
)
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
# Extract the slice
|
|
824
|
+
if slice_axis == 0:
|
|
825
|
+
slice_data = data[slice_voxel, :, :]
|
|
826
|
+
elif slice_axis == 1:
|
|
827
|
+
slice_data = data[:, slice_voxel, :]
|
|
828
|
+
else:
|
|
829
|
+
slice_data = data[:, :, slice_voxel]
|
|
830
|
+
|
|
831
|
+
# Find extent above threshold in the slice
|
|
832
|
+
nonzero_coords = np.argwhere(np.abs(slice_data) > threshold)
|
|
833
|
+
if len(nonzero_coords) == 0:
|
|
834
|
+
return # Nothing to plot
|
|
835
|
+
|
|
836
|
+
# Get min/max in slice coordinates
|
|
837
|
+
min_coords = nonzero_coords.min(axis=0)
|
|
838
|
+
max_coords = nonzero_coords.max(axis=0)
|
|
839
|
+
|
|
840
|
+
# Convert to voxel coordinates in 3D
|
|
841
|
+
if slice_axis == 0: # sagittal
|
|
842
|
+
voxel_min = np.array([slice_voxel, min_coords[0], min_coords[1]])
|
|
843
|
+
voxel_max = np.array([slice_voxel, max_coords[0], max_coords[1]])
|
|
844
|
+
elif slice_axis == 1: # coronal
|
|
845
|
+
voxel_min = np.array([min_coords[0], slice_voxel, min_coords[1]])
|
|
846
|
+
voxel_max = np.array([max_coords[0], slice_voxel, max_coords[1]])
|
|
847
|
+
else: # horizontal
|
|
848
|
+
voxel_min = np.array([min_coords[0], min_coords[1], slice_voxel])
|
|
849
|
+
voxel_max = np.array([max_coords[0], max_coords[1], slice_voxel])
|
|
850
|
+
|
|
851
|
+
# Convert to world coordinates
|
|
852
|
+
world_min = nib.affines.apply_affine(img.affine, voxel_min)
|
|
853
|
+
world_max = nib.affines.apply_affine(img.affine, voxel_max)
|
|
854
|
+
|
|
855
|
+
x_min = world_min[axes[0]]
|
|
856
|
+
x_max = world_max[axes[0]]
|
|
857
|
+
y0 = world_min[axes[1]]
|
|
858
|
+
height = world_max[axes[1]] - world_min[axes[1]]
|
|
859
|
+
else:
|
|
860
|
+
# Compute extent from voxels above *threshold* (projected along
|
|
861
|
+
# the slice axis). Falls back to full volume extent when no
|
|
862
|
+
# voxels survive the threshold.
|
|
863
|
+
data = img.get_fdata()
|
|
864
|
+
above = np.argwhere(np.abs(data) > threshold)
|
|
865
|
+
if above.size == 0:
|
|
866
|
+
# Nothing above threshold – use full volume corners
|
|
867
|
+
world_extent = get_world_extent(img)
|
|
868
|
+
x_min = world_extent[0][axes[0]]
|
|
869
|
+
x_max = world_extent[1][axes[0]]
|
|
870
|
+
y0 = world_extent[0][axes[1]]
|
|
871
|
+
height = world_extent[1][axes[1]] - world_extent[0][axes[1]]
|
|
872
|
+
else:
|
|
873
|
+
voxel_min = above.min(axis=0)
|
|
874
|
+
voxel_max = above.max(axis=0)
|
|
875
|
+
world_min = nib.affines.apply_affine(img.affine, voxel_min)
|
|
876
|
+
world_max = nib.affines.apply_affine(img.affine, voxel_max)
|
|
877
|
+
x_min = world_min[axes[0]]
|
|
878
|
+
x_max = world_max[axes[0]]
|
|
879
|
+
y0 = world_min[axes[1]]
|
|
880
|
+
height = world_max[axes[1]] - world_min[axes[1]]
|
|
881
|
+
|
|
882
|
+
# Negate horizontal extent for views where plot_slice / plot_surf
|
|
883
|
+
# flip the x-axis (e.g. frontal, bottom).
|
|
884
|
+
if _negate_x:
|
|
885
|
+
x_min, x_max = -x_max, -x_min
|
|
886
|
+
|
|
887
|
+
x0 = x_min
|
|
888
|
+
width = x_max - x_min
|
|
889
|
+
|
|
890
|
+
if "facecolor" not in kwargs:
|
|
891
|
+
kwargs["facecolor"] = "none"
|
|
892
|
+
if "edgecolor" not in kwargs:
|
|
893
|
+
kwargs["edgecolor"] = color
|
|
894
|
+
rect = patches.Rectangle((x0, y0), width, height, **kwargs)
|
|
895
|
+
ax.add_patch(rect)
|
|
896
|
+
|
|
897
|
+
if zoom_in:
|
|
898
|
+
ax.set_xlim(x0 - pad, x0 + width + pad)
|
|
899
|
+
ax.set_ylim(y0 - pad, y0 + height + pad)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def apply_brain_mask(
|
|
903
|
+
img,
|
|
904
|
+
template="MNI152NLin2009cAsym",
|
|
905
|
+
resolution=1,
|
|
906
|
+
desc="brain",
|
|
907
|
+
suffix="mask",
|
|
908
|
+
):
|
|
909
|
+
"""
|
|
910
|
+
Mask a NIfTI image in MNI space with a brain mask from TemplateFlow.
|
|
911
|
+
|
|
912
|
+
The brain mask is resampled to match the input image grid using
|
|
913
|
+
nearest-neighbour interpolation and then applied multiplicatively.
|
|
914
|
+
|
|
915
|
+
Parameters
|
|
916
|
+
----------
|
|
917
|
+
img : str, PathLike, or Nifti1Image
|
|
918
|
+
NIfTI image (or path) in MNI space to be masked.
|
|
919
|
+
template : str, optional
|
|
920
|
+
TemplateFlow template name (default ``"MNI152NLin2009cAsym"``).
|
|
921
|
+
resolution : int, optional
|
|
922
|
+
Resolution index passed to ``templateflow.api.get``
|
|
923
|
+
(default ``1``).
|
|
924
|
+
desc : str, optional
|
|
925
|
+
Description label for the mask (default ``"brain"``).
|
|
926
|
+
suffix : str, optional
|
|
927
|
+
BIDS suffix for the mask (default ``"mask"``).
|
|
928
|
+
|
|
929
|
+
Returns
|
|
930
|
+
-------
|
|
931
|
+
masked_img : Nifti1Image
|
|
932
|
+
Brain-masked image with the same affine, header, and data type
|
|
933
|
+
as the input.
|
|
934
|
+
"""
|
|
935
|
+
from templateflow import api as tflow
|
|
936
|
+
from nilearn.image import resample_to_img
|
|
937
|
+
|
|
938
|
+
if isinstance(img, (str, os.PathLike)):
|
|
939
|
+
img = nib.load(img)
|
|
940
|
+
|
|
941
|
+
mask_path = tflow.get(
|
|
942
|
+
template,
|
|
943
|
+
resolution=resolution,
|
|
944
|
+
desc=desc,
|
|
945
|
+
suffix=suffix,
|
|
946
|
+
)
|
|
947
|
+
mask_img = nib.load(mask_path)
|
|
948
|
+
|
|
949
|
+
mask_resampled = resample_to_img(
|
|
950
|
+
mask_img, img, interpolation="nearest",
|
|
951
|
+
force_resample=True, copy_header=True,
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
data = img.get_fdata()
|
|
955
|
+
mask_data = mask_resampled.get_fdata().astype(bool)
|
|
956
|
+
masked_data = data * mask_data
|
|
957
|
+
|
|
958
|
+
return nib.Nifti1Image(masked_data, img.affine, img.header)
|