Skip to content

Commit a424150

Browse files
committed
FIX: updated roi outline code, WIP
1 parent 7184962 commit a424150

File tree

2 files changed

+171
-36
lines changed

2 files changed

+171
-36
lines changed

cortex/dartboards.py

Lines changed: 162 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,28 @@ def pol2cart(phi, rho):
6363
return x, y
6464

6565

66+
def circ_dist(a, b, degrees=True):
67+
"""Compute angle between two angles w/ circular statistics
68+
69+
Parameters
70+
----------
71+
a : scalar or array
72+
angle(s) TO WHICH to compute angular (aka rotational) distance
73+
b : scalar or array
74+
angle(s) FROM WHICH to compute angular (aka rotational) distance
75+
degrees : bool
76+
Whether a and b are in degrees (defaults to True; False means they are in radians)
77+
"""
78+
if degrees:
79+
a = np.radians(a)
80+
b = np.radians(b)
81+
phase = np.e**(1j*a) / np.e**(1j*b)
82+
dist = np.arctan2(phase.imag, phase.real)
83+
if degrees:
84+
dist = np.degrees(dist)
85+
return dist
86+
87+
6688
def nonzero_coords(coords):
6789
return coords[np.any(coords != 0, axis=1)]
6890

@@ -737,25 +759,38 @@ def resample_roi_outline(
737759
distances,
738760
resolution=100,
739761
every_n=5,
762+
start_angle=np.pi/2,
763+
fixed_sampling_between=(0, 90, 180, 270), # TO DO
740764
even_sampling_over='polar angle',
741765
):
742766
"""resample roi outline to smooth outline
767+
768+
Also useful for creating average ROI outlines for different participants
769+
that can be averaged in dartboard space to create an average ROI.
743770
744-
Relies on angles being monotonically increasing
771+
Relies on angles being monotonically increasing.
745772
746773
Parameters
747774
----------
748775
angles : _type_
749-
_description_
750-
even_sampling_over : str, optional
751-
_description_, by default 'polar angle'
776+
angle to each ROI outline point. This is theta of (rho, theta)
777+
[i.e. (radius, polar angle)] in polar coordinates.
778+
even_sampling_over : str, or None, optional
779+
method by which to sample along roi border, either 'path length',
780+
'polar angle', or None; by default 'path length'. Sampling over
781+
polar angle from the center of the ROI is a sensible option if the
782+
ROI is approximately circular, without drastic elongation or non-convex
783+
regions along the border. For ROIs with non-convex regions,
784+
sampling over path length (at even distances along the ROI border)
785+
will give more sensible results. Given that the failure case for
786+
sampling
752787
resolution : int, optional
753788
number of points to sample along the ROI border, by default 100
754789
755790
Returns
756791
-------
757-
_type_
758-
_description_
792+
angles, distances
793+
returns angles and distances in polar coordinates.
759794
"""
760795
delta = 1e-5
761796
# Periodic linear resample (upsample) to assure values from 0 to 2pi
@@ -777,10 +812,12 @@ def resample_roi_outline(
777812
angles_out = np.linspace(0, 2 * np.pi - delta, resolution)
778813
dist_out = smooth(angles_out)
779814
return angles_out, dist_out
780-
781-
if even_sampling_over == 'path length':
815+
elif even_sampling_over == 'path length':
782816
## Resample
783817
xy = np.array(pol2cart(angles, distances)).T
818+
vertex_order = sort_roi_border(xy, angles, start_angle=start_angle, max_deg_from_zero=5, verbose=False)
819+
xy = xy[vertex_order]
820+
angles = angles[vertex_order]
784821
path_dist = np.cumsum(
785822
np.hstack([0, np.linalg.norm(np.diff(xy, axis=0), axis=1)]))
786823
max_dist = np.max(path_dist)
@@ -810,9 +847,69 @@ def resample_roi_outline(
810847
# Convert back to angles
811848
angles_out, dist_out = cart2pol(*xy_out.T)
812849
return angles_out, dist_out
850+
elif even_sampling_over is None:
851+
return angles, distances
852+
853+
def sort_roi_border(xy, angles, start_angle=0, start_index=None, max_deg_from_zero=5, verbose=False):
854+
"""start_index overrides start_angle"""
855+
dsts = distance.squareform(distance.pdist(xy))
856+
if start_index is None:
857+
# Choose max distance vertex that is approximately straight up
858+
# from whatever center the angles were computed from.
859+
# Best would be maybe to compute max distance from actual center
860+
# that is the center from which angles were computed.
861+
deg_from_zero = 2
862+
while deg_from_zero <= max_deg_from_zero:
863+
max_radians_from_up = np.radians(max_deg_from_zero)
864+
close_to_straight_up, = np.nonzero(np.abs(circ_dist(angles, start_angle, degrees=False)) <= max_radians_from_up)
865+
if len(close_to_straight_up) > 0:
866+
# pick farthest away from approx. center that is straight up
867+
dist_from_median = np.linalg.norm(xy - np.median(xy, axis=0), axis=1)
868+
jj = np.argmax(dist_from_median[close_to_straight_up])
869+
start_index = close_to_straight_up[jj]
870+
break
871+
else:
872+
deg_from_zero += 1
873+
if start_index is None:
874+
start_index = np.argmin(
875+
np.abs(circ_dist(angles, start_angle, degrees=False)))
876+
vertex_order = [start_index]
877+
ct = 0
878+
for i_vert in range(len(xy)):
879+
# Special case for first one: go clockwise
880+
if i_vert == 0:
881+
new_verts = np.argsort(dsts[start_index])[:5]
882+
new_verts = np.array([x for x in new_verts if x not in vertex_order])
883+
angles_from_origin = circ_dist(angles[new_verts], angles[start_index], degrees=False)
884+
is_clockwise = angles_from_origin > 0
885+
#if ~any(is_clockwise):
886+
# plt.plot(*xy.T)
887+
# plt.plot()
888+
min_clockwise_angle = np.min(angles_from_origin[is_clockwise])
889+
j = new_verts[angles_from_origin == min_clockwise_angle][0]
890+
vertex_order.append(j)
891+
else:
892+
success = False
893+
n = 3
894+
while (not success) and (n<15):
895+
new_verts = np.argsort(dsts[vertex_order[-1]])[:n]
896+
new_verts = np.array([x for x in new_verts if x not in vertex_order])
897+
success = len(new_verts==1)
898+
n += 1
899+
#if n > 4:
900+
# print(n)
901+
if len(new_verts) > 0:
902+
vertex_order.append(new_verts[-1])
903+
else:
904+
ct += 1
905+
if verbose:
906+
print(f"Skipped a vertex ({ct} skipped)")
907+
#1/0
908+
vertex_order.append(vertex_order[0])
909+
return np.array(vertex_order)
813910

814911

815-
def _get_interpolated_outlines(overlay,
912+
def get_interpolated_outlines(overlay,
816913
outline_roi,
817914
geodesic_distances=True,
818915
resolution=100,
@@ -898,9 +995,6 @@ def _get_interpolated_outlines(overlay,
898995
# Convert to polar relative to these centroids
899996
relative_phi, relative_rho = cart2pol(x,y)
900997
# Even angular interpolation
901-
## CHANGED HERE: May require re-ordering of angles, may rest on assumption
902-
## about what angle is first
903-
#relative_phi_interp, relative_rho_interp = interpolate_outlines(relative_phi, relative_rho, resolution=resolution)
904998
relative_phi_interp, relative_rho_interp = resample_roi_outline(
905999
relative_phi,
9061000
relative_rho,
@@ -913,6 +1007,7 @@ def _get_interpolated_outlines(overlay,
9131007
# Add back own centroid
9141008
x_interp += centroid_x
9151009
y_interp += centroid_y
1010+
# Convert back to polar coordinates
9161011
interpolated_outlines.append(np.stack(cart2pol(x_interp,y_interp), axis=1))
9171012
# cache result
9181013
if verbose:
@@ -926,13 +1021,18 @@ def show_outlines_on_dartboard(
9261021
**plot_kwargs):
9271022
"""Plot outlines of regions of interest on dartboard plots.
9281023
1024+
Plots all ROIs for one hemisphere to one axis.
1025+
9291026
Parameters
9301027
----------
931-
outlines : _type_
932-
_description_
1028+
outlines : array
1029+
array of size (n_rois, n_points_along_boundary, coordinates), containing polar
1030+
coordinates for outline of each ROI. Polar coordinates are expected to be
1031+
(rho, theta), i.e. (radius, polar angle)
1032+
9331033
axis : matplotlib.axes._subplots.AxesSubplot, optional
9341034
Matplotlib axis on which to plot.
935-
colors : _type_, optional
1035+
colors : matplotlib colorspec, optional
9361036
List of colors to iterate through when plotting outlines. If None, will iterate through
9371037
the default pyplot color cycle. Defaults to None.
9381038
polar : bool, optional
@@ -1014,7 +1114,7 @@ def show_dartboard_pair(dartboard_data,
10141114
10151115
For lower-level functions, see:
10161116
`show_dartboard` #
1017-
`_get_interpolated_outlines` # get roi outlines
1117+
`get_interpolated_outlines` # get roi outlines
10181118
`show_outlines_on_dartboard` # plot roi outlines
10191119
10201120
Parameters
@@ -1139,7 +1239,7 @@ def show_dartboard_pair(dartboard_data,
11391239
if not isinstance(roi_color, (list, tuple)):
11401240
roi_color = [roi_color] * len(rois)
11411241
for roi in rois:
1142-
outlines[roi] = _get_interpolated_outlines(overlay,
1242+
outlines[roi] = get_interpolated_outlines(overlay,
11431243
roi,
11441244
geodesic_distances=geodesic_distances,
11451245
resolution=path_resolution,
@@ -1150,6 +1250,10 @@ def show_dartboard_pair(dartboard_data,
11501250
**dartboard_spec)
11511251
for hemi_index in range(2):
11521252
hemi_axis = axes[hemi_index]
1253+
# This may be somewhat confusing. Seems sensible to make the function plot
1254+
# either one ROI at a time or to take a dict as input to obviate the need
1255+
# for mapping to an array (and a totally different format than any other
1256+
# function provides) here.
11531257
outlines_to_plot = np.array([v for v in outlines.values()])[:, hemi_index]
11541258
if isinstance(dartboard_spec['max_radii'], tuple):
11551259
rmax = dartboard_spec['max_radii'][hemi_index]
@@ -1171,21 +1275,27 @@ def draw_mask_outlines(
11711275
outer_line=True, as_overlay=True, **plot_kwargs):
11721276
"""Given eccentricity-angle masks, fits and draws outlines of bins on the cortex.
11731277
1174-
Args:
1175-
masks np.ndarray: Vertex masks for each bin and hemisphere, such as the output of
1176-
get_eccentricity_angle_masks. Shape should be (2, eccentricities, angles, vertices).
1177-
axis (matplotlib.axes._subplots.AxesSubplot, optional): Matplotlb axis on which to plot.
1178-
eccentricities (bool, optional): Whether to draw outlines for separate eccentricities.
1179-
Defaults to True.
1180-
angles (bool, optional): Whether to draw outlines for separate angles.
1181-
Defaults to True.
1182-
outer_line (bool, optional): Whether to draw outline of outermost eccentricity.
1183-
Defaults to True.
1184-
as_overlay (bool, optional): Whether to create new axis on top of existing axis, to avoid
1185-
interfering with previously-plotted data. Defaults to False.
1186-
1187-
Returns:
1188-
matplotlib.axes._subplots.AxesSubplot: Matplotlib axis in which data is plotted.
1278+
Parameters
1279+
----------
1280+
masks : np.ndarray
1281+
Vertex masks for each bin and hemisphere, such as the output of get_eccentricity_angle_masks.
1282+
Shape should be (2, eccentricities, angles, vertices).
1283+
axis : matplotlib.axes._subplots.AxesSubplot, optional
1284+
Matplotlib axis on which to plot.
1285+
eccentricities : bool, optional
1286+
Whether to draw outlines for separate eccentricities. Defaults to True.
1287+
angles : bool, optional
1288+
Whether to draw outlines for separate angles. Defaults to True.
1289+
outer_line : bool, optional
1290+
Whether to draw the outline of the outermost eccentricity. Defaults to True.
1291+
as_overlay : bool, optional
1292+
Whether to create a new axis on top of the existing axis to avoid interfering with
1293+
previously-plotted data. Defaults to False.
1294+
1295+
Returns
1296+
-------
1297+
matplotlib.axes._subplots.AxesSubplot
1298+
Matplotlib axis in which data is plotted.
11891299
"""
11901300
if axis is None:
11911301
_, axis = plt.subplots()
@@ -1526,8 +1636,9 @@ def get_dartboard_data(vertex_obj,
15261636
be used, e.g. ('STS', 'nearest') for the nearest point to the center_roi that
15271637
falls on STS.
15281638
1529-
Important: anchors must be specified counter clockwise from the RIGHT with
1530-
respect to the RIGHT hemisphere:
1639+
Important: anchors must be specified counter clockwise from the RIGHT (per mathematical
1640+
convention that rightward is zero degrees) with respect to the LEFT (alphabetically
1641+
first) hemisphere:
15311642
2
15321643
/\
15331644
3 <- center -> 1
@@ -1675,8 +1786,9 @@ def dartboard_on_flatmap(vertex_data,
16751786
be used, e.g. ('STS', 'nearest') for the nearest point to the center_roi that
16761787
falls on STS.
16771788
1678-
Important: anchors must be specified counter clockwise from the RIGHT with
1679-
respect to the RIGHT hemisphere:
1789+
Important: anchors must be specified counter clockwise from the RIGHT (per mathematical
1790+
convention that rightward is zero degrees) with respect to the LEFT (alphabetically
1791+
first) hemisphere:
16801792
2
16811793
/\
16821794
3 <- center -> 1
@@ -1842,3 +1954,17 @@ def dartboard_on_flatmap(vertex_data,
18421954
max_radii[0], max_radii[0]])
18431955

18441956
return fig
1957+
1958+
1959+
def average_outlines(outlines):
1960+
outlines = np.array(outlines)
1961+
for roi_i in range(outlines.shape[1]):
1962+
for hemi in range(2):
1963+
outlines[:, roi_i, hemi] = np.array(
1964+
pol2cart(*outlines[:, roi_i, hemi].T)).T
1965+
averaged_outlines = np.nanmean(outlines, axis=0)
1966+
for roi_i in range(averaged_outlines.shape[0]):
1967+
for hemi in range(2):
1968+
averaged_outlines[roi_i, hemi] = np.array(
1969+
cart2pol(*averaged_outlines[roi_i, hemi].T)).T
1970+
return averaged_outlines

examples/dartboards/dartboard_low_level_computations_plots.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,12 @@ def mean_nonan(x, axis=None, threshold=0.8):
3838

3939
# Get vertex-wise masks for each dartboard bin (`masks`) and masked data
4040
masks, data = cortex.dartboards.get_dartboard_data(vx, **dartboard_spec)
41+
42+
# Get region of interest outlines. These will take some time to compute
43+
# the first time, and will load from a cache thereafter (and thus be quick)
44+
rois_to_plot = ['FEF','M1F', 'S1H', 'M1M','M1H']
45+
roi_outlines = {}
46+
for roi in rois_to_plot:
47+
roi_outlines[roi] = 0
48+
49+
# Plot masked data in dartboard plot

0 commit comments

Comments
 (0)