datamapplot 0.1.0__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.
- datamapplot/__init__.py +277 -0
- datamapplot/medoids.py +103 -0
- datamapplot/overlap_computations.py +160 -0
- datamapplot/palette_handling.py +224 -0
- datamapplot/plot_rendering.py +587 -0
- datamapplot/text_placement.py +324 -0
- datamapplot-0.1.0.dist-info/LICENSE +21 -0
- datamapplot-0.1.0.dist-info/METADATA +119 -0
- datamapplot-0.1.0.dist-info/RECORD +11 -0
- datamapplot-0.1.0.dist-info/WHEEL +5 -0
- datamapplot-0.1.0.dist-info/top_level.txt +1 -0
datamapplot/__init__.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import textwrap
|
|
4
|
+
|
|
5
|
+
from matplotlib import pyplot as plt
|
|
6
|
+
|
|
7
|
+
from datamapplot.palette_handling import (
|
|
8
|
+
palette_from_datamap,
|
|
9
|
+
palette_from_cmap_and_datamap,
|
|
10
|
+
deep_palette,
|
|
11
|
+
pastel_palette,
|
|
12
|
+
)
|
|
13
|
+
from datamapplot.plot_rendering import render_plot
|
|
14
|
+
from datamapplot.medoids import medoid
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_plot(
|
|
18
|
+
data_map_coords,
|
|
19
|
+
labels,
|
|
20
|
+
*,
|
|
21
|
+
title=None,
|
|
22
|
+
sub_title=None,
|
|
23
|
+
noise_label="Unlabelled",
|
|
24
|
+
noise_color="#999999",
|
|
25
|
+
color_label_text=True,
|
|
26
|
+
label_wrap_width=16,
|
|
27
|
+
label_color_map=None,
|
|
28
|
+
figsize=(12, 12),
|
|
29
|
+
dynamic_label_size=False,
|
|
30
|
+
dpi=plt.rcParams["figure.dpi"],
|
|
31
|
+
force_matplotlib=False,
|
|
32
|
+
darkmode=False,
|
|
33
|
+
highlight_labels=None,
|
|
34
|
+
palette_hue_shift=0.0,
|
|
35
|
+
palette_hue_radius_dependence=1.0,
|
|
36
|
+
use_medoids=False,
|
|
37
|
+
cmap=None,
|
|
38
|
+
**render_plot_kwds,
|
|
39
|
+
):
|
|
40
|
+
"""Create a static plot from ``data_map_coords`` with text labels provided by ``labels``.
|
|
41
|
+
This is the primary function for DataMapPlot and provides the easiest interface to the
|
|
42
|
+
static plotting functionality. This function provides a number of options, but also
|
|
43
|
+
passes any further keyword options through to the lower level ``render_plot`` function
|
|
44
|
+
so be sure to check the documentation for ``render_plot`` to discover further keyword
|
|
45
|
+
arguments that can be used here as well.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
data_map_coords: ndarray of floats of shape (n_samples, 2)
|
|
50
|
+
The 2D coordinates for the data map. Usually this is produced via a
|
|
51
|
+
dimension reduction technique such as UMAP, t-SNE, PacMAP, PyMDE etc.
|
|
52
|
+
|
|
53
|
+
labels: ndarray of strings (object) of shape (n_samples,)
|
|
54
|
+
A string label each data point in the data map. There should ideally by
|
|
55
|
+
only up to 64 unique labels. Noise or unlabelled points should have the
|
|
56
|
+
same label as ``noise_label``, which is "Unlabelled" by default.
|
|
57
|
+
|
|
58
|
+
title: str or None (optional, default=None)
|
|
59
|
+
A title for the plot. If ``None`` then no title is used for the plot.
|
|
60
|
+
The title should be succint; three to seven words.
|
|
61
|
+
|
|
62
|
+
sub_title: str or None (optional, default=None)
|
|
63
|
+
A sub-title for the plot. If ``None`` then no sub-title is used for the plot.
|
|
64
|
+
The sub-title can be significantly longer then the title and provide more information\
|
|
65
|
+
about the plot and data sources.
|
|
66
|
+
|
|
67
|
+
noise_label: str (optional, default="Unlabelled")
|
|
68
|
+
The string used in the ``labels`` array to identify the unlabelled or noise points
|
|
69
|
+
in the dataset.
|
|
70
|
+
|
|
71
|
+
noise_color: str (optional, default="#999999")
|
|
72
|
+
The colour to use for unlabelled or noise points in the data map. This should usually
|
|
73
|
+
be a muted or neutral colour to distinguish background points from the labelled clusters.
|
|
74
|
+
|
|
75
|
+
color_label_text: bool (optional, default=True)
|
|
76
|
+
Whether to use colours for the text labels generated in the plot. If ``False`` then
|
|
77
|
+
the text labels will default to either black or white depending on ``darkmode``.
|
|
78
|
+
|
|
79
|
+
label_wrap_width: int (optional, default=16)
|
|
80
|
+
The number of characters to apply text-wrapping at when creating text labels for
|
|
81
|
+
display in the plot. Note that long words will not be broken, so you can choose
|
|
82
|
+
relatively small values if you want tight text-wrapping.
|
|
83
|
+
|
|
84
|
+
label_color_map: dict or None (optional, default=None)
|
|
85
|
+
A colour mapping to use to colour points/clusters in the data map. The mapping should
|
|
86
|
+
be keyed by the unique cluster labels in ``labels`` and take values that are hex-string
|
|
87
|
+
representations of colours. If ``None`` then a colour mapping will be auto-generated.
|
|
88
|
+
|
|
89
|
+
figsize: (int, int) (optional, default=(12,12))
|
|
90
|
+
How big to make the figure in inches (actual pixel size will depend on ``dpi``).
|
|
91
|
+
|
|
92
|
+
dynamic_label_size: bool (optional, default=False)
|
|
93
|
+
Whether to dynamically resize the text labels based on the relative sizes of the
|
|
94
|
+
clusters. This can be useful to help highlight larger clusters.
|
|
95
|
+
|
|
96
|
+
dpi: int (optional, default=plt.rcParams["figure.dpi"])
|
|
97
|
+
The dots-per-inch setting usd when rendering the plot.
|
|
98
|
+
|
|
99
|
+
force_matplotlib: bool (optional, default=False)
|
|
100
|
+
Force using matplotlib instead of datashader for rendering the scatterplot of the
|
|
101
|
+
data map. This can be useful if you wish to have a different marker_type, or variably
|
|
102
|
+
sized markers based on a marker_size_array, neither of which are supported by the
|
|
103
|
+
datashader based renderer.
|
|
104
|
+
|
|
105
|
+
darkmode: bool (optional, default=False)
|
|
106
|
+
Whether to render the plot in darkmode (with a dark background) or not.
|
|
107
|
+
|
|
108
|
+
highlight_labels: list of str or None (optional, default=None)
|
|
109
|
+
A list of unique labels that should have their text highlighted in the resulting plot.
|
|
110
|
+
Arguments supported by ``render_plot`` can allow for control over how highlighted labels
|
|
111
|
+
are rendered. By default they are simply rendered in bold text.
|
|
112
|
+
|
|
113
|
+
palette_hue_shift: float (optional, default=0.0)
|
|
114
|
+
A setting, in degrees clockwise, to shift the hue channel when generating a colour
|
|
115
|
+
palette and color_mapping for the labels.
|
|
116
|
+
|
|
117
|
+
palette_hue_radius_dependence: float (optional, default=1.0)
|
|
118
|
+
A setting that determines how dependent on the radius the hue channel is. Larger
|
|
119
|
+
values will result in more hue variation where there are more outlying points.
|
|
120
|
+
|
|
121
|
+
use_medoids: bool (optional, default=False)
|
|
122
|
+
Whether to use medoids instead of centroids to determine the "location" of the cluster,
|
|
123
|
+
both for the label indicator line, and for palette colouring. Note that medoids are
|
|
124
|
+
more computationally expensive, especially for large plots, so use with some caution.
|
|
125
|
+
|
|
126
|
+
cmap: matplotlib cmap or None (optional, default=None)
|
|
127
|
+
A linear matplotlib cmap colour map to use as the base for a generated colour mapping.
|
|
128
|
+
This *should* be a matplotlib cmap that is smooth and linear, and cyclic
|
|
129
|
+
(see the colorcet package for some good options). If not a cyclic cmap it will be
|
|
130
|
+
"made" cyclic by reflecting it. If ``None`` then a custom method will be used instead.
|
|
131
|
+
|
|
132
|
+
**render_plot_kwds
|
|
133
|
+
All opther keyword arguments are passed through the ``render_plot`` which provides
|
|
134
|
+
significant further control over the aesthetics of the plot.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
|
|
139
|
+
fig: matplotlib.Figure
|
|
140
|
+
The figure that the resulting plot is rendered to.
|
|
141
|
+
|
|
142
|
+
ax: matpolotlib.Axes
|
|
143
|
+
The axes contained within the figure that the plot is rendered to.
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
cluster_label_vector = np.asarray(labels)
|
|
147
|
+
unique_non_noise_labels = [
|
|
148
|
+
label for label in np.unique(cluster_label_vector) if label != noise_label
|
|
149
|
+
]
|
|
150
|
+
if use_medoids:
|
|
151
|
+
label_locations = np.asarray(
|
|
152
|
+
[
|
|
153
|
+
medoid(data_map_coords[cluster_label_vector == i])
|
|
154
|
+
for i in unique_non_noise_labels
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
label_locations = np.asarray(
|
|
159
|
+
[
|
|
160
|
+
data_map_coords[cluster_label_vector == i].mean(axis=0)
|
|
161
|
+
for i in unique_non_noise_labels
|
|
162
|
+
]
|
|
163
|
+
)
|
|
164
|
+
label_text = [
|
|
165
|
+
textwrap.fill(x, width=label_wrap_width, break_long_words=False)
|
|
166
|
+
for x in unique_non_noise_labels
|
|
167
|
+
]
|
|
168
|
+
if highlight_labels is not None:
|
|
169
|
+
highlight_labels = [
|
|
170
|
+
textwrap.fill(x, width=label_wrap_width, break_long_words=False)
|
|
171
|
+
for x in highlight_labels
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
# If we don't have a color map, generate one
|
|
175
|
+
if label_color_map is None:
|
|
176
|
+
if cmap is None:
|
|
177
|
+
palette = palette_from_datamap(
|
|
178
|
+
data_map_coords,
|
|
179
|
+
label_locations,
|
|
180
|
+
hue_shift=palette_hue_shift,
|
|
181
|
+
radius_weight_power=palette_hue_radius_dependence,
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
palette = palette_from_cmap_and_datamap(
|
|
185
|
+
cmap,
|
|
186
|
+
data_map_coords,
|
|
187
|
+
label_locations,
|
|
188
|
+
radius_weight_power=palette_hue_radius_dependence,
|
|
189
|
+
)
|
|
190
|
+
label_to_index_map = {
|
|
191
|
+
name: index for index, name in enumerate(unique_non_noise_labels)
|
|
192
|
+
}
|
|
193
|
+
color_list = [
|
|
194
|
+
palette[label_to_index_map[x]] if x in label_to_index_map else noise_color
|
|
195
|
+
for x in cluster_label_vector
|
|
196
|
+
]
|
|
197
|
+
label_color_map = {
|
|
198
|
+
x: (
|
|
199
|
+
palette[label_to_index_map[x]]
|
|
200
|
+
if x in label_to_index_map
|
|
201
|
+
else noise_color
|
|
202
|
+
)
|
|
203
|
+
for x in np.unique(cluster_label_vector)
|
|
204
|
+
}
|
|
205
|
+
else:
|
|
206
|
+
color_list = [
|
|
207
|
+
label_color_map[x] if x != noise_label else noise_color
|
|
208
|
+
for x in cluster_label_vector
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
# Darken and reduce chroma of label colors to get text labels
|
|
212
|
+
if color_label_text:
|
|
213
|
+
if darkmode:
|
|
214
|
+
label_text_colors = pastel_palette(
|
|
215
|
+
[label_color_map[x] for x in unique_non_noise_labels]
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
label_text_colors = deep_palette(
|
|
219
|
+
[label_color_map[x] for x in unique_non_noise_labels]
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
label_text_colors = None
|
|
223
|
+
|
|
224
|
+
if dynamic_label_size:
|
|
225
|
+
font_scale_factor = np.sqrt(figsize[0] * figsize[1])
|
|
226
|
+
cluster_sizes = np.sqrt(pd.Series(cluster_label_vector).value_counts())
|
|
227
|
+
label_size_adjustments = cluster_sizes - cluster_sizes.min()
|
|
228
|
+
label_size_adjustments /= label_size_adjustments.max()
|
|
229
|
+
label_size_adjustments *= (
|
|
230
|
+
render_plot_kwds.get("label_font_size", font_scale_factor) + 2
|
|
231
|
+
)
|
|
232
|
+
label_size_adjustments = dict(label_size_adjustments - 2)
|
|
233
|
+
label_size_adjustments = [
|
|
234
|
+
label_size_adjustments[x] for x in unique_non_noise_labels
|
|
235
|
+
]
|
|
236
|
+
else:
|
|
237
|
+
label_size_adjustments = [0.0] * len(unique_non_noise_labels)
|
|
238
|
+
|
|
239
|
+
# Heuristics for point size and alpha values
|
|
240
|
+
n_points = data_map_coords.shape[0]
|
|
241
|
+
if data_map_coords.shape[0] < 100_000 or force_matplotlib:
|
|
242
|
+
magic_number = np.clip(128 * 4 ** (-np.log10(n_points)), 0.05, 64)
|
|
243
|
+
point_scale_factor = np.sqrt(figsize[0] * figsize[1])
|
|
244
|
+
point_size = magic_number * (point_scale_factor / 2)
|
|
245
|
+
alpha = np.clip(magic_number, 0.05, 1)
|
|
246
|
+
else:
|
|
247
|
+
point_size = int(np.sqrt(figsize[0] * figsize[1]) * dpi) // 2048
|
|
248
|
+
alpha = 1.0
|
|
249
|
+
|
|
250
|
+
if "point_size" in render_plot_kwds:
|
|
251
|
+
point_size = render_plot_kwds.pop("point_size")
|
|
252
|
+
|
|
253
|
+
if "alpha" in render_plot_kwds:
|
|
254
|
+
alpha = render_plot_kwds.pop("alpha")
|
|
255
|
+
|
|
256
|
+
fig, ax = render_plot(
|
|
257
|
+
data_map_coords,
|
|
258
|
+
color_list,
|
|
259
|
+
label_text,
|
|
260
|
+
label_locations,
|
|
261
|
+
title=title,
|
|
262
|
+
sub_title=sub_title,
|
|
263
|
+
point_size=point_size,
|
|
264
|
+
alpha=alpha,
|
|
265
|
+
label_colors=None if not color_label_text else label_text_colors,
|
|
266
|
+
highlight_colors=[label_color_map[x] for x in unique_non_noise_labels],
|
|
267
|
+
figsize=figsize,
|
|
268
|
+
noise_color=noise_color,
|
|
269
|
+
label_size_adjustments=label_size_adjustments,
|
|
270
|
+
dpi=dpi,
|
|
271
|
+
force_matplotlib=force_matplotlib,
|
|
272
|
+
darkmode=darkmode,
|
|
273
|
+
highlight_labels=highlight_labels,
|
|
274
|
+
**render_plot_kwds,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return fig, ax
|
datamapplot/medoids.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import numba
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@numba.njit(
|
|
6
|
+
[
|
|
7
|
+
"f4(f4[::1],f4[::1])",
|
|
8
|
+
numba.types.float32(
|
|
9
|
+
numba.types.Array(numba.types.float32, 1, "C", readonly=True),
|
|
10
|
+
numba.types.Array(numba.types.float32, 1, "C", readonly=True),
|
|
11
|
+
),
|
|
12
|
+
],
|
|
13
|
+
fastmath=True,
|
|
14
|
+
locals={
|
|
15
|
+
"result": numba.types.float32,
|
|
16
|
+
"diff": numba.types.float32,
|
|
17
|
+
"dim": numba.types.intp,
|
|
18
|
+
"i": numba.types.uint16,
|
|
19
|
+
},
|
|
20
|
+
)
|
|
21
|
+
def euclidean(x, y):
|
|
22
|
+
r"""Squared euclidean distance.
|
|
23
|
+
|
|
24
|
+
.. math::
|
|
25
|
+
D(x, y) = \sum_i (x_i - y_i)^2
|
|
26
|
+
"""
|
|
27
|
+
result = 0.0
|
|
28
|
+
dim = x.shape[0]
|
|
29
|
+
for i in range(dim):
|
|
30
|
+
diff = x[i] - y[i]
|
|
31
|
+
result += diff * diff
|
|
32
|
+
|
|
33
|
+
return np.sqrt(result)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@numba.njit(parallel=True, nogil=True)
|
|
37
|
+
def chunked_parallel_pairwise_distances(X, Y=None, metric=euclidean, chunk_size=16):
|
|
38
|
+
if Y is None:
|
|
39
|
+
XX, symmetrical = X, True
|
|
40
|
+
row_size = col_size = X.shape[0]
|
|
41
|
+
else:
|
|
42
|
+
XX, symmetrical = Y, False
|
|
43
|
+
row_size, col_size = X.shape[0], Y.shape[0]
|
|
44
|
+
|
|
45
|
+
result = np.zeros((row_size, col_size), dtype=np.float32)
|
|
46
|
+
n_row_chunks = (row_size // chunk_size) + 1
|
|
47
|
+
for chunk_idx in numba.prange(n_row_chunks):
|
|
48
|
+
n = chunk_idx * chunk_size
|
|
49
|
+
chunk_end_n = min(n + chunk_size, row_size)
|
|
50
|
+
m_start = n if symmetrical else 0
|
|
51
|
+
for m in range(m_start, col_size, chunk_size):
|
|
52
|
+
chunk_end_m = min(m + chunk_size, col_size)
|
|
53
|
+
for i in range(n, chunk_end_n):
|
|
54
|
+
for j in range(m, chunk_end_m):
|
|
55
|
+
result[i, j] = metric(X[i], XX[j])
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@numba.njit()
|
|
60
|
+
def pull_arms(data, arms, num_pulls_per_arm, estimates, pull_counts):
|
|
61
|
+
other_candidates = np.random.choice(
|
|
62
|
+
data.shape[0], size=num_pulls_per_arm, replace=False
|
|
63
|
+
).astype(np.int32)
|
|
64
|
+
data_arm = data[arms]
|
|
65
|
+
data_other = data[other_candidates]
|
|
66
|
+
|
|
67
|
+
distance_sums = np.sum(
|
|
68
|
+
chunked_parallel_pairwise_distances(data_arm, data_other), axis=1
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
estimates *= pull_counts
|
|
72
|
+
estimates += distance_sums
|
|
73
|
+
pull_counts += num_pulls_per_arm
|
|
74
|
+
estimates /= pull_counts
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@numba.njit()
|
|
78
|
+
def medoid(data, arm_budget=20):
|
|
79
|
+
pull_counts = np.zeros(data.shape[0], dtype=np.int32)
|
|
80
|
+
pull_budget = arm_budget * data.shape[0]
|
|
81
|
+
estimates = np.zeros(data.shape[0], dtype=np.float32)
|
|
82
|
+
current_active_arms = np.arange(data.shape[0])
|
|
83
|
+
n_rounds = int(np.ceil(np.log2(data.shape[0])))
|
|
84
|
+
|
|
85
|
+
while current_active_arms.shape[0] > 1:
|
|
86
|
+
num_pulls_per_arm = max(
|
|
87
|
+
1,
|
|
88
|
+
int(
|
|
89
|
+
min(
|
|
90
|
+
data.shape[0],
|
|
91
|
+
np.floor(pull_budget / (current_active_arms.shape[0] * n_rounds)),
|
|
92
|
+
)
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
pull_arms(data, current_active_arms, num_pulls_per_arm, estimates, pull_counts)
|
|
96
|
+
|
|
97
|
+
median = np.median(estimates)
|
|
98
|
+
mask = estimates <= median
|
|
99
|
+
current_active_arms = current_active_arms[mask]
|
|
100
|
+
estimates = estimates[mask]
|
|
101
|
+
pull_counts = pull_counts[mask]
|
|
102
|
+
|
|
103
|
+
return data[current_active_arms[0]]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import io
|
|
3
|
+
from matplotlib import pyplot as plt
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from matplotlib.backend_bases import _get_renderer as matplot_get_renderer
|
|
7
|
+
except ImportError:
|
|
8
|
+
matplot_get_renderer = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ccw(a, b, c):
|
|
12
|
+
return (c[1] - a[1]) * (b[0] - a[0]) > (b[1] - a[1]) * (c[0] - a[0])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def intersect(a, b, c, d):
|
|
16
|
+
return ccw(a, c, d) != ccw(b, c, d) and ccw(a, b, c) != ccw(a, b, d)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# From bioframe (https://github.com/open2c/bioframe)
|
|
20
|
+
def arange_multi(starts, stops):
|
|
21
|
+
lengths = stops - starts
|
|
22
|
+
|
|
23
|
+
if np.isscalar(starts):
|
|
24
|
+
starts = np.full(len(stops), starts)
|
|
25
|
+
cat_start = np.repeat(starts, lengths)
|
|
26
|
+
cat_counter = np.arange(lengths.sum()) - np.repeat(
|
|
27
|
+
lengths.cumsum() - lengths, lengths
|
|
28
|
+
)
|
|
29
|
+
cat_range = cat_start + cat_counter
|
|
30
|
+
return cat_range
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# From bioframe (https://github.com/open2c/bioframe)
|
|
34
|
+
def overlap_intervals(starts1, ends1, starts2, ends2, closed=False, sort=False):
|
|
35
|
+
starts1 = np.asarray(starts1)
|
|
36
|
+
ends1 = np.asarray(ends1)
|
|
37
|
+
starts2 = np.asarray(starts2)
|
|
38
|
+
ends2 = np.asarray(ends2)
|
|
39
|
+
|
|
40
|
+
# Concatenate intervals lists
|
|
41
|
+
n1 = len(starts1)
|
|
42
|
+
n2 = len(starts2)
|
|
43
|
+
ids1 = np.arange(0, n1)
|
|
44
|
+
ids2 = np.arange(0, n2)
|
|
45
|
+
|
|
46
|
+
# Sort all intervals together
|
|
47
|
+
order1 = np.lexsort([ends1, starts1])
|
|
48
|
+
order2 = np.lexsort([ends2, starts2])
|
|
49
|
+
starts1, ends1, ids1 = starts1[order1], ends1[order1], ids1[order1]
|
|
50
|
+
starts2, ends2, ids2 = starts2[order2], ends2[order2], ids2[order2]
|
|
51
|
+
|
|
52
|
+
# Find interval overlaps
|
|
53
|
+
match_2in1_starts = np.searchsorted(starts2, starts1, "left")
|
|
54
|
+
match_2in1_ends = np.searchsorted(starts2, ends1, "right" if closed else "left")
|
|
55
|
+
# "right" is intentional here to avoid duplication
|
|
56
|
+
match_1in2_starts = np.searchsorted(starts1, starts2, "right")
|
|
57
|
+
match_1in2_ends = np.searchsorted(starts1, ends2, "right" if closed else "left")
|
|
58
|
+
|
|
59
|
+
# Ignore self-overlaps
|
|
60
|
+
match_2in1_mask = match_2in1_ends > match_2in1_starts
|
|
61
|
+
match_1in2_mask = match_1in2_ends > match_1in2_starts
|
|
62
|
+
match_2in1_starts, match_2in1_ends = (
|
|
63
|
+
match_2in1_starts[match_2in1_mask],
|
|
64
|
+
match_2in1_ends[match_2in1_mask],
|
|
65
|
+
)
|
|
66
|
+
match_1in2_starts, match_1in2_ends = (
|
|
67
|
+
match_1in2_starts[match_1in2_mask],
|
|
68
|
+
match_1in2_ends[match_1in2_mask],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Generate IDs of pairs of overlapping intervals
|
|
72
|
+
overlap_ids = np.block(
|
|
73
|
+
[
|
|
74
|
+
[
|
|
75
|
+
np.repeat(ids1[match_2in1_mask], match_2in1_ends - match_2in1_starts)[
|
|
76
|
+
:, None
|
|
77
|
+
],
|
|
78
|
+
ids2[arange_multi(match_2in1_starts, match_2in1_ends)][:, None],
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
ids1[arange_multi(match_1in2_starts, match_1in2_ends)][:, None],
|
|
82
|
+
np.repeat(ids2[match_1in2_mask], match_1in2_ends - match_1in2_starts)[
|
|
83
|
+
:, None
|
|
84
|
+
],
|
|
85
|
+
],
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if sort:
|
|
90
|
+
# Sort overlaps according to the 1st
|
|
91
|
+
overlap_ids = overlap_ids[np.lexsort([overlap_ids[:, 1], overlap_ids[:, 0]])]
|
|
92
|
+
|
|
93
|
+
return overlap_ids
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# From adjustText (https://github.com/Phyla/adjustText)
|
|
97
|
+
def get_renderer(fig):
|
|
98
|
+
# If the backend support get_renderer() or renderer, use that.
|
|
99
|
+
if hasattr(fig.canvas, "get_renderer"):
|
|
100
|
+
return fig.canvas.get_renderer()
|
|
101
|
+
|
|
102
|
+
if hasattr(fig.canvas, "renderer"):
|
|
103
|
+
return fig.canvas.renderer
|
|
104
|
+
|
|
105
|
+
# Otherwise, if we have the matplotlib function available, use that.
|
|
106
|
+
if matplot_get_renderer:
|
|
107
|
+
return matplot_get_renderer(fig)
|
|
108
|
+
|
|
109
|
+
# No dice, try and guess.
|
|
110
|
+
# Write the figure to a temp location, and then retrieve whichever
|
|
111
|
+
# render was used (doesn't work in all matplotlib versions).
|
|
112
|
+
fig.canvas.print_figure(io.BytesIO())
|
|
113
|
+
try:
|
|
114
|
+
return fig._cachedRenderer
|
|
115
|
+
|
|
116
|
+
except AttributeError:
|
|
117
|
+
# No luck.
|
|
118
|
+
# We're out of options.
|
|
119
|
+
raise ValueError("Unable to determine renderer") from None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# From adjustText (https://github.com/Phyla/adjustText)
|
|
123
|
+
def get_bboxes(objs, r=None, expand=(1, 1), ax=None):
|
|
124
|
+
ax = ax or plt.gca()
|
|
125
|
+
r = r or get_renderer(ax.get_figure())
|
|
126
|
+
return [i.get_window_extent(r).expanded(*expand) for i in objs]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# From adjustText (https://github.com/Phyla/adjustText)
|
|
130
|
+
def get_2d_coordinates(objs, expand=(1.0, 1.0)):
|
|
131
|
+
try:
|
|
132
|
+
ax = objs[0].axes
|
|
133
|
+
except:
|
|
134
|
+
ax = objs.axes
|
|
135
|
+
bboxes = get_bboxes(objs, get_renderer(ax.get_figure()), expand, ax)
|
|
136
|
+
xs = [
|
|
137
|
+
(ax.convert_xunits(bbox.xmin), ax.convert_yunits(bbox.xmax)) for bbox in bboxes
|
|
138
|
+
]
|
|
139
|
+
ys = [
|
|
140
|
+
(ax.convert_xunits(bbox.ymin), ax.convert_yunits(bbox.ymax)) for bbox in bboxes
|
|
141
|
+
]
|
|
142
|
+
coords = np.hstack([np.array(xs), np.array(ys)])
|
|
143
|
+
return coords
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def text_line_overlaps(text_locations, label_locations, text_bounding_boxes):
|
|
147
|
+
result = []
|
|
148
|
+
for i, box in enumerate(text_bounding_boxes):
|
|
149
|
+
for j in range(text_locations.shape[0]):
|
|
150
|
+
if i == j:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
if intersect(
|
|
154
|
+
text_locations[j], label_locations[j], box[[0, 2]], box[[1, 3]]
|
|
155
|
+
) or intersect(
|
|
156
|
+
text_locations[j], label_locations[j], box[[0, 3]], box[[1, 2]]
|
|
157
|
+
):
|
|
158
|
+
result.append((i, j))
|
|
159
|
+
|
|
160
|
+
return result
|