Skip to content

Commit dbfcb95

Browse files
Update brain_plots.py (#210)
* Update brain_plots.py * add interpolate_electrodes_onto_brain function * Update plot_intracranial_electrodes.py * Update plot_intracranial_electrodes.py * Update __init__.py * Update freesurfer.py * Update surfdist.py * Update brain_plots.py * Update plot_intracranial_electrodes.py * Update freesurfer.py * Update brain_plots.py
1 parent 9268737 commit dbfcb95

File tree

5 files changed

+233
-16
lines changed

5 files changed

+233
-16
lines changed

examples/brain_plotting/plot_intracranial_electrodes.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,20 @@
7777
dist_from_HG = brain.distance_from_region(coords, isleft, region='pmHG', metric='surf')
7878
print(dist_from_HG)
7979

80+
###############################################################################
81+
# Smoothly interpolate values over the brain's surface from electrodes
82+
83+
# As an example, we will use the y coordinate of the electrode
84+
values_per_electrode = coords[:,1] - coords[:,1].min()
85+
86+
# Interpolate onto only the temporal lobe, using the 5 nearest neighbor interpolation with
87+
# a maximum distance of 10mm
88+
brain.interpolate_electrodes_onto_brain(coords, values_per_electrode, isleft, roi='temporal', k=5, max_dist=10)
89+
90+
# Plot the overlay for just the left hemisphere
91+
fig, axes = plot_brain_overlay(brain, cmap='Reds', view='lateral', figsize=(12,6), hemi='lh')
92+
plt.show()
93+
8094
###############################################################################
8195
# Create a brain with the inflated surface for plotting
8296
brain = Brain('inflated', subject_dir='./fsaverage/').split_hg('midpoint').split_stg().simplify_labels()

naplib/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,5 @@ def set_logging(level: Union[int, str]):
5656
from .data import Data, join_fields, concat
5757
import naplib.naplab
5858

59-
__version__ = "2.2.0"
59+
__version__ = "2.3.0"
6060

naplib/localization/freesurfer.py

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@
111111
}
112112
region2num = {v: k for k, v in num2region.items()}
113113

114+
temporal_regions_nums = [33, 34, 35, 36, 74, 41, 43, 72, 73, 38, 37, 75, 76, 77, 78, 79, 80, 81]
115+
temporal_regions_superlist = [num2region[num] for num in temporal_regions_nums]
116+
temporal_regions_superlist += ['alHG','pmHG','HG','TTS','PT','PP','MTG','ITG','mSTG','pSTG','STG','STS','T.Pole']
117+
118+
114119
num2region_mni = {
115120
0: 'unknown',
116121
1: 'bankssts',
@@ -766,6 +771,85 @@ def paint_overlay(self, labels, value=1):
766771
self.has_overlay[verts == 1] = True
767772
self.has_overlay_cells[add_overlay == 1] = True
768773
return self
774+
775+
def interpolate_electrodes_onto_brain(self, coords, values, k, max_dist, roi='all'):
776+
"""
777+
Use electrode coordinates to interpolate 1-dimensional values corresponding
778+
to each electrode onto the brain's surface.
779+
780+
Parameters
781+
----------
782+
coords : np.ndarray (elecs, 3)
783+
3D coordinates of electrodes
784+
values : np.ndarray (elecs,)
785+
Value for each electrode
786+
k : int
787+
Number of nearest neighbors to consider
788+
max_dist : float
789+
Maximum distance outside of which nearest neighbors will be ignored
790+
roi : list of strings, or string in {'all', 'temporal'}, default='all'
791+
Regions to allow interpolation over. By default, the entire brain surface
792+
is allowed. Can also be specified as a list of string labels (drawing from self.label_names)
793+
794+
Notes
795+
-----
796+
After running this function, you can use the visualization function ``plot_brain_overlay``
797+
for a quick matplotlib plot, or you can extract the surface values from the ``self.overlay``
798+
attribute for plotting with another tool like pysurfer.
799+
"""
800+
801+
if isinstance(roi, str) and roi == 'all':
802+
roi_list = self.label_names
803+
elif isinstance(roi, str) and roi == 'temporal':
804+
if self.atlas == 'MNI152':
805+
raise ValueError("roi='temporal' is not supported for MNI brain. Must specify list of specific region names")
806+
roi_list = temporal_regions_superlist
807+
else:
808+
roi_list = roi
809+
assert isinstance(roi, list)
810+
811+
roi_list_subset = [x for x in roi_list if x in self.label_names]
812+
zones_to_include, _, _ = self.zones(roi_list_subset)
813+
814+
# Euclidean distances from each surface vertex to each coordinate
815+
dists = cdist(self.surf[0], coords)
816+
sorted_dists = np.sort(dists, axis=-1)[:, :k]
817+
indices = np.argsort(dists, axis=-1)[:, :k] # get closest k electrodes to each vertex
818+
819+
# Mask out distances greater than max_dist
820+
valid_mask = sorted_dists <= max_dist
821+
822+
# Retrieve the corresponding values using indices
823+
neighbor_values = values[indices]
824+
825+
# Mask invalid values
826+
masked_values = np.where(valid_mask, neighbor_values, np.nan)
827+
masked_distances = np.where(valid_mask, sorted_dists, np.nan)
828+
829+
# Compute weights: inverse distance weighting (avoiding division by zero)
830+
weights = np.where(valid_mask, 1 / (masked_distances + 1e-10), 0)
831+
832+
# # Compute weighted sum and normalize by total weight per vertex
833+
weighted_sum = np.nansum(masked_values * weights, axis=1)
834+
total_weight = np.nansum(weights, axis=1)
835+
836+
# # Normalize to get final smoothed values
837+
updated_vertices = np.logical_and(total_weight > 0, zones_to_include)
838+
total_weight[~updated_vertices] += 1e-10 # this just gets ride of the division by zero warning, but doesn't affect result since these values are turned to nan anyway
839+
smoothed_values = np.where(updated_vertices, weighted_sum / total_weight, np.nan)
840+
841+
# update the surface vertices and triangle attributes with the values
842+
verts = updated_vertices.astype('float')
843+
trigs = np.zeros(self.n_trigs, dtype=float)
844+
for i in range(self.n_trigs):
845+
trigs[i] = np.mean([verts[self.trigs[i, j]] != 0 for j in range(3)])
846+
847+
self.overlay[updated_vertices] = smoothed_values[updated_vertices]
848+
self.has_overlay[updated_vertices] = True
849+
self.has_overlay_cells[trigs == 1] = True
850+
851+
return self
852+
769853

770854
def mark_overlay(self, verts, value=1, inner_radius=0.8, taper=True):
771855
"""
@@ -801,6 +885,11 @@ def set_visible(self, labels, min_alpha=0):
801885
self.keep_visible_cells = self.alpha > min_alpha
802886
self.alpha = np.maximum(self.alpha, min_alpha)
803887
return self
888+
889+
def reset_overlay_except(self, labels):
890+
keep_visible, self.alpha, _ = self.zones(labels, min_alpha=0)
891+
self.overlay[~keep_visible] = 0
892+
return self
804893

805894

806895
class Brain:
@@ -1134,7 +1223,7 @@ def mark_overlay(self, verts, isleft, value=1, inner_radius=0.8, taper=True):
11341223

11351224
def set_visible(self, labels, min_alpha=0):
11361225
"""
1137-
Set certain regions as visible with a float label.
1226+
Set certain regions as visible with a float label, and the rest will be invisible.
11381227
11391228
Parameters
11401229
----------
@@ -1150,6 +1239,61 @@ def set_visible(self, labels, min_alpha=0):
11501239
self.lh.set_visible(labels, min_alpha)
11511240
self.rh.set_visible(labels, min_alpha)
11521241
return self
1242+
1243+
def reset_overlay_except(self, labels):
1244+
"""
1245+
Keep certain regions and the rest as colorless.
1246+
1247+
Parameters
1248+
----------
1249+
labels : str | list[str]
1250+
Label(s) to set as visible.
1251+
1252+
Returns
1253+
-------
1254+
self : instance of self
1255+
"""
1256+
self.lh.reset_overlay_except(labels)
1257+
self.rh.reset_overlay_except(labels)
1258+
return self
1259+
1260+
def interpolate_electrodes_onto_brain(self, coords, values, isleft=None, k=10, max_dist=10, roi='all', reset_overlay_first=True):
1261+
"""
1262+
Use electrode coordinates to interpolate 1-dimensional values corresponding
1263+
to each electrode onto the brain's surface.
1264+
1265+
Parameters
1266+
----------
1267+
coords : np.ndarray (elecs, 3)
1268+
3D coordinates of electrodes
1269+
values : np.ndarray (elecs,)
1270+
Value for each electrode
1271+
isleft : np.ndarray (elecs,), optional
1272+
If provided, specifies a boolean which is True for each electrode that is in the left hemisphere.
1273+
If not given, this will be inferred from the first dimension of the coords (negative is left).
1274+
k : int, default=10
1275+
Number of nearest neighbors to consider
1276+
max_dist : float, default=10
1277+
Maximum distance (in mm) outside of which nearest neighbors will be ignored
1278+
roi : list of strings, or string in {'all', 'temporal'}, default='all'
1279+
Regions to allow interpolation over. By default, the entire brain surface
1280+
is allowed. Can also be specified as a list of string labels (drawing from self.lh.label_names)
1281+
reset_overlay_first : bool, default=True
1282+
If True (default), reset the overlay before creating a new overlay
1283+
1284+
Notes
1285+
-----
1286+
After running this function, you can use the visualization function ``plot_brain_overlay``
1287+
for a quick matplotlib plot, or you can extract the surface values from the ``self.lh.overlay``
1288+
and ``self.rh.overlay`` attributes, etc, for plotting with another tool like pysurfer or plotly.
1289+
"""
1290+
if reset_overlay_first:
1291+
self.reset_overlay()
1292+
if isleft is None:
1293+
isleft = coords[:,0] < 0
1294+
self.lh.interpolate_electrodes_onto_brain(coords[isleft], values[isleft], k=k, max_dist=max_dist, roi=roi)
1295+
self.rh.interpolate_electrodes_onto_brain(coords[~isleft], values[~isleft], k=k, max_dist=max_dist, roi=roi)
1296+
return self
11531297

11541298

11551299
def get_nearest_vert_index(coords, isleft, surf_lh, surf_rh, verbose=False):
@@ -1190,4 +1334,3 @@ def find_closest_vertices(surface_coords, point_coords):
11901334
point_coords = np.atleast_2d(point_coords)
11911335
dists = cdist(surface_coords, point_coords)
11921336
return np.argmin(dists, axis=0), np.min(dists, axis=0)
1193-

naplib/utils/surfdist.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ def surfdist_viz(
126126
bg_on_stat=False,
127127
figsize=None,
128128
ax=None,
129+
vmin=None,
130+
vmax=None,
129131
):
130132
"""Visualize results on cortical surface using matplotlib.
131133
@@ -232,8 +234,10 @@ def surfdist_viz(
232234

233235
# Ensure symmetric colour range, based on Nilearn helper function:
234236
# https://github.com/nilearn/nilearn/blob/master/nilearn/plotting/img_plotting.py#L52
235-
vmax = max(-np.nanmin(stat_map_faces), np.nanmax(stat_map_faces))
236-
vmin = -vmax
237+
if vmax is None:
238+
vmax = max(-np.nanmin(stat_map_faces), np.nanmax(stat_map_faces))
239+
if vmin is None:
240+
vmin = -vmax
237241

238242
if threshold is not None:
239243
kept_indices = np.where(abs(stat_map_faces) >= threshold)[0]

naplib/visualization/brain_plots.py

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -74,24 +74,26 @@ def _view(hemi, mode: str = "lateral", backend: str = "mpl"):
7474
raise ValueError(f"Unknown `mode`: {mode}.")
7575

7676

77-
def _plot_hemi(hemi, cmap="coolwarm", ax=None, denorm=False, view="best"):
77+
def _plot_hemi(hemi, cmap="coolwarm", ax=None, view="best", thresh=None, vmin=None, vmax=None):
7878
surfdist_viz(
7979
*hemi.surf,
8080
hemi.overlay,
8181
*_view(hemi.hemi, mode=view),
82-
cmap=cmap(hemi.overlay.max()) if denorm else cmap,
83-
threshold=0.25,
82+
cmap=cmap,
83+
threshold=thresh,
8484
alpha=hemi.alpha,
8585
bg_map=hemi.sulc,
8686
bg_on_stat=True,
8787
ax=ax,
88+
vmin=vmin,
89+
vmax=vmax
8890
)
8991
ax.axes.set_axis_off()
9092
ax.grid(False)
9193

9294

9395
def plot_brain_overlay(
94-
brain, cmap="coolwarm", ax=None, denorm=False, view="best", **kwargs
96+
brain, cmap="coolwarm", ax=None, hemi='both', denorm=False, view="best", cmap_quantile=1.0, **kwargs
9597
):
9698
"""
9799
Plot brain overlay on the 3D cortical surface using matplotlib.
@@ -106,12 +108,21 @@ def plot_brain_overlay(
106108
Colormap to use.
107109
ax : list | tuple of matplotlib Axes
108110
2 Axes to plot the left and right hemispheres with.
111+
hemi : {'both', 'lh', 'rh'}, default='both'
112+
Hemisphere(s) to plot. If 'both', then 2 subplots are created, one for each hemisphere.
113+
Otherwise only one hemisphere is displayed with its overlay.
109114
denorm : bool, default=False
110115
Whether to center the overlay labels around 0 or not before sending to the colormap.
111116
view : {'lateral','medial','frontal','top','best'}, default='best'
112117
Which view to plot for each hemisphere.
118+
cmap_quantile : float | tuple of floats (optional), default=1.0
119+
If a single float less than 1, will only use the central ``cmap_quantile`` portion of the range
120+
of values to create the vmin and vmax for the colormap. For example, if set to 0.95,
121+
then only the middle 95% of the values will be used to set the range of the colormap. If a tuple,
122+
then it should specify 2 quantiles, one for the vmin and one for the vmax, such as (0.025, 0.975),
123+
which would be equivalent to passing a single value of 0.95.
113124
**kwargs : kwargs
114-
Any other kwargs to pass to matplotlib.pyplot.figure
125+
Any other kwargs to pass to matplotlib.pyplot.figure (such as figsize)
115126
116127
Returns
117128
-------
@@ -121,14 +132,59 @@ def plot_brain_overlay(
121132
"""
122133
fig = plt.figure(**kwargs)
123134
if ax is None:
124-
ax1 = fig.add_subplot(1, 2, 1, projection="3d")
125-
ax2 = fig.add_subplot(1, 2, 2, projection="3d")
126-
ax = (ax1, ax2)
135+
if hemi in ['both', 'b']:
136+
ax1 = fig.add_subplot(1, 2, 1, projection="3d")
137+
ax2 = fig.add_subplot(1, 2, 2, projection="3d")
138+
ax = (ax1, ax2)
139+
else:
140+
ax = fig.add_subplot(1, 1, 1, projection="3d")
141+
if hemi in ['left','lh']:
142+
ax = [ax, None]
143+
elif hemi in ['right','rh']:
144+
ax = [None, ax]
127145
else:
128-
ax1, ax2 = ax
146+
if hemi in ['both', 'b']:
147+
assert len(ax) == 2
129148

130-
_plot_hemi(brain.lh, cmap, ax1, denorm, view=view)
131-
_plot_hemi(brain.rh, cmap, ax2, denorm, view=view)
149+
150+
if cmap_quantile is not None:
151+
if isinstance(cmap_quantile, float):
152+
assert cmap_quantile <= 1 and cmap_quantile > 0
153+
cmap_diff = (1.0 - cmap_quantile) / 2.
154+
vmin_l = np.quantile(brain.lh.overlay[brain.lh.overlay!=0], cmap_diff)
155+
vmax_l = np.quantile(brain.lh.overlay[brain.lh.overlay!=0], 1.0 - cmap_diff)
156+
vmin_r = np.quantile(brain.rh.overlay[brain.rh.overlay!=0], cmap_diff)
157+
vmax_r = np.quantile(brain.rh.overlay[brain.rh.overlay!=0], 1.0 - cmap_diff)
158+
elif isinstance(cmap_quantile, tuple):
159+
vmin_l = np.quantile(brain.lh.overlay[brain.lh.overlay!=0], cmap_quantile[0])
160+
vmax_l = np.quantile(brain.lh.overlay[brain.lh.overlay!=0], cmap_quantile[1])
161+
vmin_r = np.quantile(brain.rh.overlay[brain.rh.overlay!=0], cmap_quantile[0])
162+
vmax_r = np.quantile(brain.rh.overlay[brain.rh.overlay!=0], cmap_quantile[1])
163+
else:
164+
raise ValueError('cmap_quantile must be either a float or a tuple')
165+
else:
166+
vmin_l = brain.lh.overlay[brain.lh.overlay!=0].min()
167+
vmax_l = brain.lh.overlay[brain.lh.overlay!=0].max()
168+
vmin_r = brain.rh.overlay[brain.rh.overlay!=0].min()
169+
vmax_r = brain.rh.overlay[brain.rh.overlay!=0].max()
170+
171+
172+
# determine vmin and vmax
173+
if hemi in ['both', 'b']:
174+
vmin = min([vmin_l, vmin_r])
175+
vmax = max([vmax_l, vmax_r])
176+
elif hemi in ['left','lh']:
177+
vmin = vmin_l
178+
vmax = vmax_l
179+
elif hemi in ['right','rh']:
180+
vmin = vmin_r
181+
vmax = vmax_r
182+
183+
184+
if ax[0] is not None:
185+
_plot_hemi(brain.lh, cmap, ax[0], view=view, vmin=vmin, vmax=vmax)
186+
if ax[1] is not None:
187+
_plot_hemi(brain.rh, cmap, ax[1], view=view, vmin=vmin, vmax=vmax)
132188

133189
return fig, ax
134190

0 commit comments

Comments
 (0)