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.
Files changed (89) hide show
  1. bsplot/__init__.py +23 -0
  2. bsplot/anat.py +55 -0
  3. bsplot/animate.py +194 -0
  4. bsplot/bioicons.py +777 -0
  5. bsplot/brain.py +188 -0
  6. bsplot/colors.py +1935 -0
  7. bsplot/data/HCP_avg-SC.txt +379 -0
  8. bsplot/data/MNI152.rh.pial +0 -0
  9. bsplot/data/STN_lh.nii.gz +0 -0
  10. bsplot/data/STN_rh.nii.gz +0 -0
  11. bsplot/data/__init__.py +22 -0
  12. bsplot/data/left_electrode.ply +257270 -0
  13. bsplot/data/parcellations/HCP-MMP1.L.label.gii +442 -0
  14. bsplot/data/parcellations/HCP-MMP1.R.label.gii +442 -0
  15. bsplot/data/parcellations/code/split_hcpmmp1.sh +4 -0
  16. bsplot/data/right_electrode.ply +257270 -0
  17. bsplot/data/surface.py +112 -0
  18. bsplot/data/tpl-MNI152NLin2009b_atlas-hcpmmp1_desc-ordered_dseg.nii.gz +0 -0
  19. bsplot/figure.py +177 -0
  20. bsplot/graph/__init__.py +31 -0
  21. bsplot/graph/edges.py +312 -0
  22. bsplot/graph/flowchart.py +762 -0
  23. bsplot/graph/layout.py +201 -0
  24. bsplot/graph/network.py +974 -0
  25. bsplot/graph/nodes.py +452 -0
  26. bsplot/panels.py +110 -0
  27. bsplot/scientific_color_maps/__init__.py +0 -0
  28. bsplot/scientific_color_maps/_sync_from_zenodo_download.sh +1 -0
  29. bsplot/scientific_color_maps/batlowK.txt +256 -0
  30. bsplot/scientific_color_maps/batlowW.txt +256 -0
  31. bsplot/scientific_color_maps/cyclic/bamO.txt +256 -0
  32. bsplot/scientific_color_maps/cyclic/brocO.txt +256 -0
  33. bsplot/scientific_color_maps/cyclic/corkO.txt +256 -0
  34. bsplot/scientific_color_maps/cyclic/romaO.txt +256 -0
  35. bsplot/scientific_color_maps/cyclic/vikO.txt +256 -0
  36. bsplot/scientific_color_maps/diverging/bam.txt +256 -0
  37. bsplot/scientific_color_maps/diverging/berlin.txt +256 -0
  38. bsplot/scientific_color_maps/diverging/broc.txt +256 -0
  39. bsplot/scientific_color_maps/diverging/cork.txt +256 -0
  40. bsplot/scientific_color_maps/diverging/lisbon.txt +256 -0
  41. bsplot/scientific_color_maps/diverging/managua.txt +256 -0
  42. bsplot/scientific_color_maps/diverging/roma.txt +256 -0
  43. bsplot/scientific_color_maps/diverging/tofino.txt +256 -0
  44. bsplot/scientific_color_maps/diverging/vanimo.txt +256 -0
  45. bsplot/scientific_color_maps/diverging/vik.txt +256 -0
  46. bsplot/scientific_color_maps/multisequential/bukavu.txt +256 -0
  47. bsplot/scientific_color_maps/multisequential/fes.txt +256 -0
  48. bsplot/scientific_color_maps/multisequential/oleron.txt +256 -0
  49. bsplot/scientific_color_maps/naviaW.txt +256 -0
  50. bsplot/scientific_color_maps/sequential/acton.txt +256 -0
  51. bsplot/scientific_color_maps/sequential/bamako.txt +256 -0
  52. bsplot/scientific_color_maps/sequential/batlow.txt +256 -0
  53. bsplot/scientific_color_maps/sequential/bilbao.txt +256 -0
  54. bsplot/scientific_color_maps/sequential/buda.txt +256 -0
  55. bsplot/scientific_color_maps/sequential/davos.txt +256 -0
  56. bsplot/scientific_color_maps/sequential/devon.txt +256 -0
  57. bsplot/scientific_color_maps/sequential/glasgow.txt +256 -0
  58. bsplot/scientific_color_maps/sequential/grayC.txt +256 -0
  59. bsplot/scientific_color_maps/sequential/hawaii.txt +256 -0
  60. bsplot/scientific_color_maps/sequential/imola.txt +256 -0
  61. bsplot/scientific_color_maps/sequential/lajolla.txt +256 -0
  62. bsplot/scientific_color_maps/sequential/lapaz.txt +256 -0
  63. bsplot/scientific_color_maps/sequential/lipari.txt +256 -0
  64. bsplot/scientific_color_maps/sequential/navia.txt +256 -0
  65. bsplot/scientific_color_maps/sequential/nuuk.txt +256 -0
  66. bsplot/scientific_color_maps/sequential/oslo.txt +256 -0
  67. bsplot/scientific_color_maps/sequential/tokyo.txt +256 -0
  68. bsplot/scientific_color_maps/sequential/turku.txt +256 -0
  69. bsplot/streamlines.py +314 -0
  70. bsplot/style.py +791 -0
  71. bsplot/styles/black.mplstyle +78 -0
  72. bsplot/styles/bss.mplstyle +50 -0
  73. bsplot/styles/bwcomp.mplstyle +30 -0
  74. bsplot/styles/nature.mplstyle +57 -0
  75. bsplot/styles/transparent.mplstyle +16 -0
  76. bsplot/styles/tvbo.mplstyle +57 -0
  77. bsplot/surface.py +2370 -0
  78. bsplot/templates.py +270 -0
  79. bsplot/text.py +199 -0
  80. bsplot/text2obj.py +59 -0
  81. bsplot/timeseries.py +10 -0
  82. bsplot/utils.py +13 -0
  83. bsplot/volume.py +958 -0
  84. bsplot-0.0.2.dist-info/METADATA +60 -0
  85. bsplot-0.0.2.dist-info/RECORD +89 -0
  86. bsplot-0.0.2.dist-info/WHEEL +5 -0
  87. bsplot-0.0.2.dist-info/entry_points.txt +2 -0
  88. bsplot-0.0.2.dist-info/licenses/LICENSE +193 -0
  89. 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)