wellerlab 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.
- wellerlab/__init__.py +17 -0
- wellerlab/metabo/__init__.py +3 -0
- wellerlab/metabo/metabo_core.py +282 -0
- wellerlab/metabo/widgets/__init__.py +17 -0
- wellerlab/metabo/widgets/_dialogs.py +37 -0
- wellerlab/metabo/widgets/_plot.py +194 -0
- wellerlab/metabo/widgets/icons/FeatureFilter.svg +6 -0
- wellerlab/metabo/widgets/icons/FeatureImport.svg +6 -0
- wellerlab/metabo/widgets/icons/Heatmap.svg +6 -0
- wellerlab/metabo/widgets/icons/Preprocess.svg +6 -0
- wellerlab/metabo/widgets/icons/UnivariateStats.svg +6 -0
- wellerlab/metabo/widgets/icons/Volcano.svg +6 -0
- wellerlab/metabo/widgets/owfeaturefilter.py +137 -0
- wellerlab/metabo/widgets/owfeatureimport.py +118 -0
- wellerlab/metabo/widgets/owheatmap.py +267 -0
- wellerlab/metabo/widgets/owpreprocess.py +164 -0
- wellerlab/metabo/widgets/owunivariate.py +185 -0
- wellerlab/metabo/widgets/owvolcano.py +512 -0
- wellerlab/nmr/__init__.py +14 -0
- wellerlab/nmr/widgets/__init__.py +17 -0
- wellerlab/nmr/widgets/icons/NMRBaseline.svg +6 -0
- wellerlab/nmr/widgets/icons/NMRBinning.svg +6 -0
- wellerlab/nmr/widgets/icons/NMRExclude.svg +6 -0
- wellerlab/nmr/widgets/icons/NMRFilter.svg +6 -0
- wellerlab/nmr/widgets/icons/NMRNormalize.svg +6 -0
- wellerlab/nmr/widgets/icons/NMRReference.svg +6 -0
- wellerlab/nmr/widgets/nmr_utils.py +74 -0
- wellerlab/nmr/widgets/ownmrbaseline.py +149 -0
- wellerlab/nmr/widgets/ownmrbinning.py +124 -0
- wellerlab/nmr/widgets/ownmrexclude.py +145 -0
- wellerlab/nmr/widgets/ownmrfilter.py +126 -0
- wellerlab/nmr/widgets/ownmrnormalize.py +134 -0
- wellerlab/nmr/widgets/ownmrreference.py +145 -0
- wellerlab/pca/__init__.py +16 -0
- wellerlab/pca/pca_analysis.py +246 -0
- wellerlab/pca/widgets/__init__.py +7 -0
- wellerlab/pca/widgets/icons/PCAPro.svg +6 -0
- wellerlab/pca/widgets/owpcawell.py +563 -0
- wellerlab/plsda/__init__.py +29 -0
- wellerlab/plsda/opls_core.py +377 -0
- wellerlab/plsda/oplsda_learner.py +209 -0
- wellerlab/plsda/plsda_learner.py +120 -0
- wellerlab/plsda/widgets/__init__.py +8 -0
- wellerlab/plsda/widgets/icons/OPLSDA.svg +6 -0
- wellerlab/plsda/widgets/icons/PLSDA.svg +6 -0
- wellerlab/plsda/widgets/owoplsda.py +506 -0
- wellerlab/plsda/widgets/owplsda.py +192 -0
- wellerlab/widgets/__init__.py +17 -0
- wellerlab-0.1.0.dist-info/METADATA +83 -0
- wellerlab-0.1.0.dist-info/RECORD +53 -0
- wellerlab-0.1.0.dist-info/WHEEL +5 -0
- wellerlab-0.1.0.dist-info/entry_points.txt +2 -0
- wellerlab-0.1.0.dist-info/top_level.txt +1 -0
wellerlab/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WellerLab — the complete Orange3 tool suite of the Weller lab.
|
|
3
|
+
|
|
4
|
+
One distribution, one Orange category ("Weller Lab"):
|
|
5
|
+
|
|
6
|
+
wellerlab.metabo MetaboAnalyst-style feature-table statistics
|
|
7
|
+
(import, preprocess, filter, univariate, heatmap, volcano)
|
|
8
|
+
wellerlab.plsda PLS-DA and OPLS-DA (S-plot, VIP, Q2, permutation)
|
|
9
|
+
wellerlab.pca PCA Pro
|
|
10
|
+
wellerlab.nmr NMR preprocessing (baseline, binning, exclusion,
|
|
11
|
+
filter, normalization, reference)
|
|
12
|
+
|
|
13
|
+
The numerical cores (``metabo.metabo_core``, ``plsda.opls_core``) are free of
|
|
14
|
+
Qt/Orange and can be imported and tested headlessly.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
MetaboAnalyst-style univariate statistics core for GC-MS / GC-IMS
|
|
5
|
+
feature tables (Compound Discoverer exports).
|
|
6
|
+
|
|
7
|
+
This module is the analytics heart of the wellerlab.metabo add-on. It is
|
|
8
|
+
deliberately free of any Qt / Orange dependency so it can be unit-tested
|
|
9
|
+
headlessly with plain numpy/scipy. The widgets in `orangemetabo/widgets/`
|
|
10
|
+
are thin GUI wrappers around the functions here.
|
|
11
|
+
|
|
12
|
+
Pipeline (matches the validated ground truth `cv_anova_alle_97_features.csv`,
|
|
13
|
+
97/97 features on F, p and FDR_BH):
|
|
14
|
+
|
|
15
|
+
load -> sum-normalise -> log2 -> autoscale (z-score per feature)
|
|
16
|
+
-> univariate statistics (one-way ANOVA / Welch t / Kruskal-Wallis)
|
|
17
|
+
-> Benjamini-Hochberg FDR
|
|
18
|
+
|
|
19
|
+
All functions are pure: they take arrays / lists and return DataFrames or
|
|
20
|
+
arrays. No file I/O except the optional `load_feature_table` reader.
|
|
21
|
+
"""
|
|
22
|
+
import csv
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pandas as pd
|
|
25
|
+
from scipy import stats
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --------------------------------------------------------------------------
|
|
29
|
+
# Feature-table reader (Compound Discoverer 2-header semicolon CSV)
|
|
30
|
+
# --------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def load_feature_table(path):
|
|
33
|
+
"""Read a Compound Discoverer feature-table CSV.
|
|
34
|
+
|
|
35
|
+
Expected layout (semicolon-separated, UTF-8-BOM):
|
|
36
|
+
row 0: "" , "Area: <sample>.raw", ...
|
|
37
|
+
row 1: "" , "<group>", ...
|
|
38
|
+
row 2+: "<feature name>", values...
|
|
39
|
+
|
|
40
|
+
Returns (feature_names: list[str], samples: list[str],
|
|
41
|
+
groups: list[str], X: np.ndarray[features x samples]).
|
|
42
|
+
"""
|
|
43
|
+
with open(path, encoding="utf-8-sig", newline="") as f:
|
|
44
|
+
rows = list(csv.reader(f, delimiter=";"))
|
|
45
|
+
samples = [h.replace("Area: ", "").replace(".raw", "").strip()
|
|
46
|
+
for h in rows[0][1:]]
|
|
47
|
+
groups = [g.strip() for g in rows[1][1:]]
|
|
48
|
+
feat = [r[0].strip() for r in rows[2:]]
|
|
49
|
+
X = np.array(
|
|
50
|
+
[[float(v) for v in r[1:1 + len(samples)]] for r in rows[2:]],
|
|
51
|
+
dtype=float,
|
|
52
|
+
)
|
|
53
|
+
return feat, samples, groups, X
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------------------------
|
|
57
|
+
# Preprocessing
|
|
58
|
+
# --------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def normalize_sum(X):
|
|
61
|
+
"""Total-area (sum) normalisation: rescale each sample column so that
|
|
62
|
+
every column has the mean of the column totals."""
|
|
63
|
+
col_sums = X.sum(axis=0)
|
|
64
|
+
return X / col_sums * col_sums.mean()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def log2_transform(X):
|
|
68
|
+
"""log2 with a guard against non-positive values (clip to tiny eps)."""
|
|
69
|
+
eps = np.finfo(float).tiny
|
|
70
|
+
return np.log2(np.clip(X, eps, None))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def impute_low(X, threshold=0.05, method="knn", k=3):
|
|
74
|
+
"""Impute below-threshold values (fraction of column min or a hard cap).
|
|
75
|
+
|
|
76
|
+
Methods:
|
|
77
|
+
'constant' : replace with the column minimum (or 0).
|
|
78
|
+
'min' : replace with the column minimum.
|
|
79
|
+
'knn' : replace with the mean of the k nearest samples in the
|
|
80
|
+
Euclidean distance of the non-imputed rows.
|
|
81
|
+
Returns (X_imputed, imputed_mask).
|
|
82
|
+
"""
|
|
83
|
+
X = np.asarray(X, dtype=float).copy()
|
|
84
|
+
col_min = X.min(axis=0)
|
|
85
|
+
mask = X < (col_min * (1 + threshold))
|
|
86
|
+
if not mask.any():
|
|
87
|
+
return X, mask
|
|
88
|
+
if method in ("constant", "min"):
|
|
89
|
+
rows, cols = np.nonzero(mask)
|
|
90
|
+
X[rows, cols] = col_min[cols]
|
|
91
|
+
return X, mask
|
|
92
|
+
# knn: distance between samples (columns) over all feature rows;
|
|
93
|
+
# impute each flagged value with the mean of its k nearest samples.
|
|
94
|
+
n_rows, n_cols = X.shape
|
|
95
|
+
d = np.sqrt(((X[:, :, None] - X[:, None, :]) ** 2).sum(axis=0)) # c x c
|
|
96
|
+
np.fill_diagonal(d, np.inf)
|
|
97
|
+
order = d.argsort(axis=1)
|
|
98
|
+
for c in range(n_cols):
|
|
99
|
+
bad = np.nonzero(mask[:, c])[0]
|
|
100
|
+
if bad.size == 0:
|
|
101
|
+
continue
|
|
102
|
+
for r in bad:
|
|
103
|
+
# prefer samples that are not flagged on this same feature
|
|
104
|
+
nb = order[c, :max(k, 1)]
|
|
105
|
+
ok = [j for j in nb if not (mask[r, j] and j != c)]
|
|
106
|
+
if not ok:
|
|
107
|
+
X[r, c] = col_min[c]
|
|
108
|
+
else:
|
|
109
|
+
X[r, c] = X[r, ok].mean()
|
|
110
|
+
return X, mask
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def scale_rows(X, method="autoscale"):
|
|
114
|
+
"""Per-feature (row) scaling.
|
|
115
|
+
|
|
116
|
+
'autoscale' : z-score (mean 0, std 1, ddof=1)
|
|
117
|
+
'pareto' : (x - mean) / sqrt(std)
|
|
118
|
+
'none' : unchanged
|
|
119
|
+
Constant rows are returned as zeros (division guard).
|
|
120
|
+
"""
|
|
121
|
+
X = np.asarray(X, dtype=float)
|
|
122
|
+
mean = X.mean(axis=1, keepdims=True)
|
|
123
|
+
if method == "none":
|
|
124
|
+
return X
|
|
125
|
+
std = X.std(axis=1, keepdims=True, ddof=1)
|
|
126
|
+
denom = np.where(std > 0, np.sqrt(std) if method == "pareto" else std, 1.0)
|
|
127
|
+
return (X - mean) / denom
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------
|
|
131
|
+
# Multiple-testing correction
|
|
132
|
+
# --------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def bh_fdr(p):
|
|
135
|
+
"""Benjamini-Hochberg FDR for an array of p-values."""
|
|
136
|
+
p = np.asarray(p, dtype=float)
|
|
137
|
+
m = len(p)
|
|
138
|
+
if m == 0:
|
|
139
|
+
return p
|
|
140
|
+
order = p.argsort()
|
|
141
|
+
ranked = p[order]
|
|
142
|
+
q = np.minimum.accumulate((ranked * m / np.arange(1, m + 1))[::-1])[::-1]
|
|
143
|
+
qf = np.empty(m)
|
|
144
|
+
qf[order] = np.clip(q, 0, 1)
|
|
145
|
+
return qf
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --------------------------------------------------------------------------
|
|
149
|
+
# Univariate statistics
|
|
150
|
+
# --------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def _groups_arrays(X, groups, levels):
|
|
153
|
+
"""Yield the per-group column slices for every feature row."""
|
|
154
|
+
gi = {lv: np.array(groups) == lv for lv in levels}
|
|
155
|
+
return [X[:, idx] for idx in gi.values()]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def univariate(X, groups, method="anova", base=None, treats=None,
|
|
159
|
+
feature_names=None):
|
|
160
|
+
"""Run the selected univariate test for every feature row.
|
|
161
|
+
|
|
162
|
+
method:
|
|
163
|
+
'anova' : one-way ANOVA across all levels (>= 2 levels, >= 2 per level).
|
|
164
|
+
'welch' : Welch two-sample t-test, `treats` combined vs `base`.
|
|
165
|
+
'kruskal': one-way Kruskal-Wallis across all levels.
|
|
166
|
+
|
|
167
|
+
feature_names: optional list of row labels (same length as X rows);
|
|
168
|
+
when given, the `Feature` column holds these names, else row indices.
|
|
169
|
+
|
|
170
|
+
Returns (DataFrame, levels). The DataFrame has columns
|
|
171
|
+
Feature, stat, p, (log2FC for welch), FDR_BH,
|
|
172
|
+
sorted by ascending p.
|
|
173
|
+
"""
|
|
174
|
+
X = np.asarray(X, dtype=float)
|
|
175
|
+
groups = list(groups)
|
|
176
|
+
levels = list(dict.fromkeys(groups))
|
|
177
|
+
n = X.shape[0]
|
|
178
|
+
names = list(feature_names) if feature_names is not None else list(range(n))
|
|
179
|
+
res = []
|
|
180
|
+
if method in ("anova", "kruskal"):
|
|
181
|
+
for i in range(n):
|
|
182
|
+
cols = [X[i, np.array(groups) == lv] for lv in levels]
|
|
183
|
+
if any(c.size < 2 for c in cols):
|
|
184
|
+
stat, p = np.nan, np.nan
|
|
185
|
+
elif method == "anova":
|
|
186
|
+
stat, p = stats.f_oneway(*cols)
|
|
187
|
+
else:
|
|
188
|
+
stat, p = stats.kruskal(*cols)
|
|
189
|
+
res.append(dict(Feature=names[i], stat=stat, p=p))
|
|
190
|
+
elif method == "welch":
|
|
191
|
+
if base not in levels or not treats:
|
|
192
|
+
raise ValueError("Welch test needs `base` and non-empty `treats`.")
|
|
193
|
+
b = np.array(groups) == base
|
|
194
|
+
a = np.isin(np.array(groups), treats)
|
|
195
|
+
for i in range(n):
|
|
196
|
+
if X[i, a].size < 2 or X[i, b].size < 2:
|
|
197
|
+
stat, p = np.nan, np.nan
|
|
198
|
+
fc = np.nan
|
|
199
|
+
else:
|
|
200
|
+
stat, p = stats.ttest_ind(X[i, a], X[i, b], equal_var=False)
|
|
201
|
+
fc = X[i, a].mean() - X[i, b].mean()
|
|
202
|
+
res.append(dict(Feature=names[i], stat=stat, p=p, log2FC=fc))
|
|
203
|
+
else:
|
|
204
|
+
raise ValueError(f"unknown method {method!r}")
|
|
205
|
+
|
|
206
|
+
df = pd.DataFrame(res)
|
|
207
|
+
df["p"] = pd.Series(df["p"]).astype(float)
|
|
208
|
+
df = df.sort_values("p", na_position="last").reset_index(drop=True)
|
|
209
|
+
df["FDR_BH"] = bh_fdr(df["p"].values)
|
|
210
|
+
return df, levels
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def add_group_means(df, X, groups, levels, feature_names=None):
|
|
214
|
+
"""Append per-group mean columns (on the supplied, usually scaled, X).
|
|
215
|
+
|
|
216
|
+
`df` must contain a `Feature` column. When `feature_names` is given it
|
|
217
|
+
maps feature names to their row index in X; otherwise `Feature` values
|
|
218
|
+
are assumed to already be row indices.
|
|
219
|
+
"""
|
|
220
|
+
X = np.asarray(X, dtype=float)
|
|
221
|
+
groups = np.asarray(groups)
|
|
222
|
+
out = df.copy()
|
|
223
|
+
if feature_names is not None:
|
|
224
|
+
name_to_row = {n: i for i, n in enumerate(feature_names)}
|
|
225
|
+
else:
|
|
226
|
+
name_to_row = None
|
|
227
|
+
for lv in levels:
|
|
228
|
+
vals = []
|
|
229
|
+
for f in out["Feature"]:
|
|
230
|
+
i = name_to_row.get(f) if name_to_row is not None else f
|
|
231
|
+
vals.append(
|
|
232
|
+
X[i, groups == lv].mean() if (groups == lv).any() else np.nan)
|
|
233
|
+
out[f"mean_{lv}"] = vals
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def format_p(v):
|
|
238
|
+
"""Human-friendly p-value string (scientific below 1e-4)."""
|
|
239
|
+
if v is None or (isinstance(v, float) and np.isnan(v)):
|
|
240
|
+
return "NA"
|
|
241
|
+
if v < 1e-4:
|
|
242
|
+
return f"{v:.2e}"
|
|
243
|
+
return f"{v:.4f}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# --------------------------------------------------------------------------
|
|
247
|
+
# Volcano (contrast derived from per-group means)
|
|
248
|
+
# --------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def volcano_table(df, group_a, group_b, fdr_col="FDR_BH", alpha=0.05, fc=1.0):
|
|
251
|
+
"""Build a volcano table for the contrast `group_a` vs `group_b`.
|
|
252
|
+
|
|
253
|
+
Operates on a univariate results frame (as produced by `univariate` +
|
|
254
|
+
`add_group_means`): it needs a `Feature` column, the two `mean_<group>`
|
|
255
|
+
columns and an FDR column. Group means live in the (log2) space the data
|
|
256
|
+
was supplied in, so the fold change is their difference:
|
|
257
|
+
|
|
258
|
+
log2FC = mean_a - mean_b (positive => higher in group_a)
|
|
259
|
+
|
|
260
|
+
Returns a DataFrame with columns
|
|
261
|
+
Feature, log2FC, FDR_BH, neglog10FDR, direction,
|
|
262
|
+
where direction is "up" (FDR < alpha and log2FC >= fc),
|
|
263
|
+
"down"(FDR < alpha and log2FC <= -fc),
|
|
264
|
+
"ns" otherwise.
|
|
265
|
+
"""
|
|
266
|
+
ca, cb = f"mean_{group_a}", f"mean_{group_b}"
|
|
267
|
+
for c in ("Feature", ca, cb, fdr_col):
|
|
268
|
+
if c not in df.columns:
|
|
269
|
+
raise ValueError(f"volcano needs column {c!r}")
|
|
270
|
+
lfc = df[ca].to_numpy(float) - df[cb].to_numpy(float)
|
|
271
|
+
fdr = df[fdr_col].to_numpy(float)
|
|
272
|
+
out = pd.DataFrame({
|
|
273
|
+
"Feature": df["Feature"].to_numpy(),
|
|
274
|
+
"log2FC": lfc,
|
|
275
|
+
"FDR_BH": fdr,
|
|
276
|
+
"neglog10FDR": -np.log10(np.clip(fdr, np.finfo(float).tiny, None)),
|
|
277
|
+
})
|
|
278
|
+
sig = fdr < alpha
|
|
279
|
+
out["direction"] = np.where(
|
|
280
|
+
sig & (lfc >= fc), "up",
|
|
281
|
+
np.where(sig & (lfc <= -fc), "down", "ns"))
|
|
282
|
+
return out
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Widget definitions for wellerlab.metabo."""
|
|
2
|
+
|
|
3
|
+
from .owfeatureimport import OWFeatureImport # noqa: F401
|
|
4
|
+
from .owpreprocess import OWMetaboPreprocess # noqa: F401
|
|
5
|
+
from .owfeaturefilter import OWFeatureFilter # noqa: F401
|
|
6
|
+
from .owunivariate import OWUnivariateStats # noqa: F401
|
|
7
|
+
from .owheatmap import OWMetaboHeatmap # noqa: F401
|
|
8
|
+
from .owvolcano import OWVolcano # noqa: F401
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"OWFeatureImport",
|
|
12
|
+
"OWMetaboPreprocess",
|
|
13
|
+
"OWFeatureFilter",
|
|
14
|
+
"OWUnivariateStats",
|
|
15
|
+
"OWMetaboHeatmap",
|
|
16
|
+
"OWVolcano",
|
|
17
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Small Qt dialog helpers shared by the Metabo widgets.
|
|
2
|
+
|
|
3
|
+
Orange's ``Orange.widgets.utils.filedialogs`` API is not stable across
|
|
4
|
+
releases: the ``OpenFileDialog`` / ``SaveFileDialog`` classes used by older
|
|
5
|
+
widgets no longer exist in current Orange (they were replaced by the
|
|
6
|
+
``open_filename_dialog`` / ``open_filename_dialog_save`` functions). To stay
|
|
7
|
+
working on every Orange version the widgets call plain ``QFileDialog``
|
|
8
|
+
directly through these two helpers — one idiom, no version-specific imports.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def open_feature_table(parent, start_dir=""):
|
|
13
|
+
"""Ask for a feature-table CSV/TXT. Returns a path or None (cancelled)."""
|
|
14
|
+
from AnyQt.QtWidgets import QFileDialog
|
|
15
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
16
|
+
parent, "Open feature table", start_dir or "",
|
|
17
|
+
"Feature table (*.csv *.txt *.tsv);;All files (*)")
|
|
18
|
+
return path or None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def save_figure(parent, fig, kind="PNG"):
|
|
22
|
+
"""Ask for a path and write figure `fig` as PNG (dpi 300) or SVG.
|
|
23
|
+
|
|
24
|
+
`kind` is 'PNG' or 'SVG' (case-insensitive). Returns the path written,
|
|
25
|
+
or None if the user cancelled.
|
|
26
|
+
"""
|
|
27
|
+
from AnyQt.QtWidgets import QFileDialog
|
|
28
|
+
kind = kind.upper()
|
|
29
|
+
ext = ".png" if kind == "PNG" else ".svg"
|
|
30
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
31
|
+
parent, f"Save figure ({kind})", "", f"{kind} (*{ext});;All files (*)")
|
|
32
|
+
if not path:
|
|
33
|
+
return None
|
|
34
|
+
if not path.lower().endswith(ext):
|
|
35
|
+
path += ext
|
|
36
|
+
fig.savefig(path, dpi=300 if kind == "PNG" else None, facecolor="white")
|
|
37
|
+
return path
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Shared matplotlib-canvas helpers for the Metabo widgets.
|
|
2
|
+
|
|
3
|
+
Orange's own plots are pyqtgraph based and get pan/zoom/reset for free; the
|
|
4
|
+
Metabo heatmap and volcano are matplotlib canvases, so the interaction is
|
|
5
|
+
implemented here explicitly — and, importantly, it survives redraws (a plain
|
|
6
|
+
matplotlib canvas autoscales on every draw, which silently discards the user's
|
|
7
|
+
zoom).
|
|
8
|
+
|
|
9
|
+
Interaction provided by `PlotCanvas`:
|
|
10
|
+
|
|
11
|
+
* wheel over the plot zooms around the cursor,
|
|
12
|
+
* dragging with the left button pans,
|
|
13
|
+
* a left click (no drag) calls `on_click(axes, xdata, ydata)` — used by the
|
|
14
|
+
volcano for point selection, with a pixel tolerance so edge points work,
|
|
15
|
+
* `reset_view()` restores the automatic view; `after_draw()` keeps the
|
|
16
|
+
user's zoom across redraws.
|
|
17
|
+
|
|
18
|
+
`attach_toolbar()` adds matplotlib's navigation toolbar (save / home / zoom
|
|
19
|
+
rect / pan) and `draggable()` makes a legend movable.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from typing import Callable, Optional
|
|
24
|
+
from AnyQt.QtCore import Qt
|
|
25
|
+
from matplotlib.backends.backend_qtagg import (
|
|
26
|
+
FigureCanvasQTAgg, NavigationToolbar2QT)
|
|
27
|
+
from matplotlib.figure import Figure
|
|
28
|
+
|
|
29
|
+
CLICK_TOLERANCE = 12 # px radius for click selection
|
|
30
|
+
DRAG_THRESHOLD = 3 # px before a press counts as a pan drag
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PlotCanvas(FigureCanvasQTAgg):
|
|
34
|
+
def __init__(self, figsize=(8, 5), dpi=100):
|
|
35
|
+
self.fig = Figure(figsize=figsize, dpi=dpi)
|
|
36
|
+
super().__init__(self.fig)
|
|
37
|
+
self.setFocusPolicy(Qt.StrongFocus)
|
|
38
|
+
self.main_axes = None # the axes the view is kept for
|
|
39
|
+
self.home = None # (xlim, ylim) of the automatic view
|
|
40
|
+
self.view = None # the user's current view (None = home)
|
|
41
|
+
self.on_click: Optional[Callable] = None # callback(axes, xdata, ydata)
|
|
42
|
+
self.toolbar = None
|
|
43
|
+
self._press = None
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------ view
|
|
46
|
+
def after_draw(self, ax, new_data=False):
|
|
47
|
+
"""Call after every redraw. Keeps the user's zoom unless data changed."""
|
|
48
|
+
axis = ax or (self.fig.axes[0] if self.fig.axes else None)
|
|
49
|
+
if axis is None:
|
|
50
|
+
return
|
|
51
|
+
self.main_axes = axis
|
|
52
|
+
if new_data or self.home is None:
|
|
53
|
+
self.home = (axis.get_xlim(), axis.get_ylim())
|
|
54
|
+
self.view = None
|
|
55
|
+
if self.view is not None:
|
|
56
|
+
axis.set_xlim(*self.view[0])
|
|
57
|
+
axis.set_ylim(*self.view[1])
|
|
58
|
+
|
|
59
|
+
def reset_view(self):
|
|
60
|
+
"""Back to the automatic (home) view; also clears any toolbar mode."""
|
|
61
|
+
if self.toolbar is not None:
|
|
62
|
+
self.toolbar.mode = ""
|
|
63
|
+
try:
|
|
64
|
+
self.toolbar._update_buttons_checked()
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
self.view = None
|
|
68
|
+
if self.home is not None and self.main_axes is not None:
|
|
69
|
+
self.main_axes.set_xlim(*self.home[0])
|
|
70
|
+
self.main_axes.set_ylim(*self.home[1])
|
|
71
|
+
self.draw_idle()
|
|
72
|
+
|
|
73
|
+
def _remember(self, ax):
|
|
74
|
+
if ax is self.main_axes:
|
|
75
|
+
self.view = (ax.get_xlim(), ax.get_ylim())
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------------------- helpers
|
|
78
|
+
def _axes_at(self, x, y):
|
|
79
|
+
for ax in reversed(self.fig.axes):
|
|
80
|
+
if ax.bbox.contains(x, y):
|
|
81
|
+
return ax
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _steps(event):
|
|
86
|
+
if not event.pixelDelta().isNull():
|
|
87
|
+
return event.pixelDelta().y()
|
|
88
|
+
return event.angleDelta().y() / 120.0
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------ wheel zoom
|
|
91
|
+
def zoom_at(self, ax, x, y, steps):
|
|
92
|
+
"""Zoom `ax` around the display point (x, y) by `steps` wheel notches."""
|
|
93
|
+
cx, cy = ax.transData.inverted().transform((x, y))
|
|
94
|
+
k = 0.8 ** steps # wheel up (steps>0) zooms in
|
|
95
|
+
xl, yl = ax.get_xlim(), ax.get_ylim()
|
|
96
|
+
ax.set_xlim(cx + (xl[0] - cx) * k, cx + (xl[1] - cx) * k)
|
|
97
|
+
ax.set_ylim(cy + (yl[0] - cy) * k, cy + (yl[1] - cy) * k)
|
|
98
|
+
self._remember(ax)
|
|
99
|
+
|
|
100
|
+
def wheelEvent(self, event):
|
|
101
|
+
x, y = self.mouseEventCoords(event)
|
|
102
|
+
ax = self._axes_at(x, y)
|
|
103
|
+
steps = self._steps(event)
|
|
104
|
+
if ax is None or not steps:
|
|
105
|
+
super().wheelEvent(event)
|
|
106
|
+
return
|
|
107
|
+
self.zoom_at(ax, x, y, steps)
|
|
108
|
+
self.draw_idle()
|
|
109
|
+
event.accept() # don't let a parent scroll
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------- pan drag / click sel
|
|
112
|
+
def mousePressEvent(self, event):
|
|
113
|
+
if event.button() == Qt.LeftButton:
|
|
114
|
+
x, y = self.mouseEventCoords(event)
|
|
115
|
+
ax = self._axes_at(x, y)
|
|
116
|
+
if ax is not None:
|
|
117
|
+
self._press = (x, y, ax, ax.get_xlim(), ax.get_ylim(), False)
|
|
118
|
+
super().mousePressEvent(event)
|
|
119
|
+
|
|
120
|
+
def pan_by(self, ax, x0, y0, x1, y1, xl0, yl0):
|
|
121
|
+
"""Pan `ax` so the display point (x0, y0) follows the cursor to (x1, y1)."""
|
|
122
|
+
inv = ax.transData.inverted()
|
|
123
|
+
dx = inv.transform((x0, y0))[0] - inv.transform((x1, y1))[0]
|
|
124
|
+
dy = inv.transform((x0, y0))[1] - inv.transform((x1, y1))[1]
|
|
125
|
+
ax.set_xlim(xl0[0] + dx, xl0[1] + dx)
|
|
126
|
+
ax.set_ylim(yl0[0] + dy, yl0[1] + dy)
|
|
127
|
+
self._remember(ax)
|
|
128
|
+
|
|
129
|
+
def mouseMoveEvent(self, event):
|
|
130
|
+
if self._press is not None and (event.buttons() & Qt.LeftButton):
|
|
131
|
+
x0, y0, ax, xl0, yl0, moved = self._press
|
|
132
|
+
x, y = self.mouseEventCoords(event)
|
|
133
|
+
if moved or abs(x - x0) + abs(y - y0) > DRAG_THRESHOLD:
|
|
134
|
+
self.pan_by(ax, x0, y0, x, y, xl0, yl0)
|
|
135
|
+
self._press = (x0, y0, ax, xl0, yl0, True)
|
|
136
|
+
self.draw_idle()
|
|
137
|
+
super().mouseMoveEvent(event)
|
|
138
|
+
|
|
139
|
+
def mouseReleaseEvent(self, event):
|
|
140
|
+
press, self._press = self._press, None
|
|
141
|
+
if (event.button() == Qt.LeftButton and press is not None
|
|
142
|
+
and not press[5] and self.on_click is not None):
|
|
143
|
+
ax = press[2]
|
|
144
|
+
x, y = self.mouseEventCoords(event)
|
|
145
|
+
self._remember(ax)
|
|
146
|
+
self.on_click(ax, *ax.transData.inverted().transform((x, y)))
|
|
147
|
+
super().mouseReleaseEvent(event)
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------- nearest feature
|
|
150
|
+
def nearest_point(self, ax, xdata, ydata, offsets):
|
|
151
|
+
"""Index of the scatter point nearest to (xdata, ydata), or None when
|
|
152
|
+
nothing is within CLICK_TOLERANCE pixels (so edge points are clickable)."""
|
|
153
|
+
if offsets is None or len(offsets) == 0:
|
|
154
|
+
return None
|
|
155
|
+
px, py = ax.transData.transform((xdata, ydata))
|
|
156
|
+
disp = ax.transData.transform(np.asarray(offsets, dtype=float))
|
|
157
|
+
d = np.hypot(disp[:, 0] - px, disp[:, 1] - py)
|
|
158
|
+
i = int(np.argmin(d))
|
|
159
|
+
return i if d[i] <= CLICK_TOLERANCE else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def attach_toolbar(box, canvas, parent):
|
|
163
|
+
"""Add a slim matplotlib toolbar to `box`: Home (= reset view) and Save.
|
|
164
|
+
|
|
165
|
+
The pan/zoom/back/forward/subplots actions are removed: panning and zooming
|
|
166
|
+
are done directly on the canvas (`PlotCanvas`), and matplotlib's modal
|
|
167
|
+
tools would swallow the left button, so point selection became impossible
|
|
168
|
+
until the user figured out how to leave the mode again.
|
|
169
|
+
"""
|
|
170
|
+
toolbar = NavigationToolbar2QT(canvas, parent)
|
|
171
|
+
acts = getattr(toolbar, "_actions", {})
|
|
172
|
+
for key in ("back", "forward", "pan", "zoom",
|
|
173
|
+
"configure_subplots", "edit_parameters"):
|
|
174
|
+
action = acts.get(key)
|
|
175
|
+
if action is not None:
|
|
176
|
+
toolbar.removeAction(action)
|
|
177
|
+
home = acts.get("home")
|
|
178
|
+
if home is not None:
|
|
179
|
+
home.setToolTip("Reset view")
|
|
180
|
+
try:
|
|
181
|
+
home.triggered.disconnect()
|
|
182
|
+
except (TypeError, RuntimeError):
|
|
183
|
+
pass
|
|
184
|
+
home.triggered.connect(lambda *_: canvas.reset_view())
|
|
185
|
+
box.layout().addWidget(toolbar)
|
|
186
|
+
canvas.toolbar = toolbar
|
|
187
|
+
return toolbar
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def draggable(legend):
|
|
191
|
+
"""Make a matplotlib legend movable by dragging."""
|
|
192
|
+
if legend is not None:
|
|
193
|
+
legend.set_draggable(True)
|
|
194
|
+
return legend
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
|
2
|
+
<rect width="48" height="48" rx="8" fill="#1F4E79"/>
|
|
3
|
+
<g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
|
|
4
|
+
<text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
|
|
5
|
+
fill-opacity="0.85" text-anchor="middle">FIL</text>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
|
2
|
+
<rect width="48" height="48" rx="8" fill="#1F4E79"/>
|
|
3
|
+
<g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
|
|
4
|
+
<text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
|
|
5
|
+
fill-opacity="0.85" text-anchor="middle">IMP</text>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
|
2
|
+
<rect width="48" height="48" rx="8" fill="#1F4E79"/>
|
|
3
|
+
<g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
|
|
4
|
+
<text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
|
|
5
|
+
fill-opacity="0.85" text-anchor="middle">HM</text>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
|
2
|
+
<rect width="48" height="48" rx="8" fill="#1F4E79"/>
|
|
3
|
+
<g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
|
|
4
|
+
<text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
|
|
5
|
+
fill-opacity="0.85" text-anchor="middle">PRE</text>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
|
2
|
+
<rect width="48" height="48" rx="8" fill="#1F4E79"/>
|
|
3
|
+
<g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
|
|
4
|
+
<text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
|
|
5
|
+
fill-opacity="0.85" text-anchor="middle">UNI</text>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
|
2
|
+
<rect width="48" height="48" rx="8" fill="#1F4E79"/>
|
|
3
|
+
<g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
|
|
4
|
+
<text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
|
|
5
|
+
fill-opacity="0.85" text-anchor="middle">VOL</text>
|
|
6
|
+
</svg>
|