eelsnmf-gui 0.0.4__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.
- eelsnmf_gui/__init__.py +38 -0
- eelsnmf_gui/_analysis.py +401 -0
- eelsnmf_gui/_export.py +179 -0
- eelsnmf_gui/_file_dialog.py +148 -0
- eelsnmf_gui/_labels.py +29 -0
- eelsnmf_gui/_nmf.py +237 -0
- eelsnmf_gui/_plotting.py +985 -0
- eelsnmf_gui/_preprocess.py +318 -0
- eelsnmf_gui/_processing.py +150 -0
- eelsnmf_gui/_results.py +139 -0
- eelsnmf_gui/_util.py +34 -0
- eelsnmf_gui/_widget.py +2929 -0
- eelsnmf_gui/cli.py +47 -0
- eelsnmf_gui/session.py +762 -0
- eelsnmf_gui-0.0.4.dist-info/METADATA +140 -0
- eelsnmf_gui-0.0.4.dist-info/RECORD +22 -0
- eelsnmf_gui-0.0.4.dist-info/WHEEL +5 -0
- eelsnmf_gui-0.0.4.dist-info/entry_points.txt +2 -0
- eelsnmf_gui-0.0.4.dist-info/licenses/LICENSE +28 -0
- eelsnmf_gui-0.0.4.dist-info/scm_file_list.json +13 -0
- eelsnmf_gui-0.0.4.dist-info/scm_version.json +8 -0
- eelsnmf_gui-0.0.4.dist-info/top_level.txt +1 -0
eelsnmf_gui/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
__version__ = version("eelsnmf-gui")
|
|
5
|
+
except PackageNotFoundError: # running from source without installation
|
|
6
|
+
__version__ = "unknown"
|
|
7
|
+
|
|
8
|
+
from ._results import (
|
|
9
|
+
BeamParams,
|
|
10
|
+
DecoBundle,
|
|
11
|
+
EdgeCollection,
|
|
12
|
+
EdgeSeries,
|
|
13
|
+
ImageCollection,
|
|
14
|
+
MaskParams,
|
|
15
|
+
MaskResult,
|
|
16
|
+
PreprocessedData,
|
|
17
|
+
PreprocessedSI,
|
|
18
|
+
ProcessFoldersResult,
|
|
19
|
+
ProcessingResult,
|
|
20
|
+
)
|
|
21
|
+
from ._widget import EELSProcessingWidget
|
|
22
|
+
from .session import Session
|
|
23
|
+
|
|
24
|
+
__all__ = (
|
|
25
|
+
"EELSProcessingWidget",
|
|
26
|
+
"Session",
|
|
27
|
+
"BeamParams",
|
|
28
|
+
"MaskParams",
|
|
29
|
+
"PreprocessedData",
|
|
30
|
+
"PreprocessedSI",
|
|
31
|
+
"MaskResult",
|
|
32
|
+
"DecoBundle",
|
|
33
|
+
"ProcessingResult",
|
|
34
|
+
"ProcessFoldersResult",
|
|
35
|
+
"EdgeCollection",
|
|
36
|
+
"EdgeSeries",
|
|
37
|
+
"ImageCollection",
|
|
38
|
+
)
|
eelsnmf_gui/_analysis.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Dict, List, Tuple
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ._results import EdgeCollection, EdgeSeries, MaskResult
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
Smoother = Callable[[np.ndarray], np.ndarray]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def default_smoother(arr: np.ndarray) -> np.ndarray:
|
|
19
|
+
"""Whittaker smoother from the EML package (imported lazily)."""
|
|
20
|
+
from EELSNMF.experimental import EML # lazy heavy import
|
|
21
|
+
|
|
22
|
+
return EML.we_smooth(arr)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_index(ax: np.ndarray, v):
|
|
26
|
+
"""Find nearest axis indices for one value or an iterable of values."""
|
|
27
|
+
if hasattr(v, "__iter__"):
|
|
28
|
+
return [abs(ax - iv).argmin() for iv in v]
|
|
29
|
+
return abs(ax - v).argmin()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _concat(chunks: List[np.ndarray]) -> np.ndarray:
|
|
33
|
+
"""Concatenate chunks into a single array, preserving empty cases."""
|
|
34
|
+
if len(chunks) == 0:
|
|
35
|
+
return np.array([])
|
|
36
|
+
if len(chunks) == 1:
|
|
37
|
+
return chunks[0]
|
|
38
|
+
return np.concatenate(chunks)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Edge contribution metrics
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class EdgeTotals:
|
|
48
|
+
"""Per-component edge contribution metrics."""
|
|
49
|
+
|
|
50
|
+
leave_out_loss: np.ndarray
|
|
51
|
+
power: np.ndarray
|
|
52
|
+
explained_variance: np.ndarray
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def edge_totals(
|
|
56
|
+
deco,
|
|
57
|
+
fine_structure_ranges: Dict[str, Tuple[float, float]],
|
|
58
|
+
) -> EdgeTotals | None:
|
|
59
|
+
"""Per-component edge contribution metrics from a fitted decomposition.
|
|
60
|
+
|
|
61
|
+
*deco* must expose X, G, W, H, get_model(), n_components and
|
|
62
|
+
model._edge_slices (the EELSNMF decomposition object does).
|
|
63
|
+
"""
|
|
64
|
+
if not hasattr(deco, "model") or not hasattr(deco.model, "_edge_slices"):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
n_components = int(getattr(deco, "n_components", 0))
|
|
68
|
+
if n_components <= 0:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
edge_slices = deco.model._edge_slices
|
|
72
|
+
X = np.asarray(deco.X)
|
|
73
|
+
model = np.asarray(deco.get_model())
|
|
74
|
+
norm_x = float((X**2).sum())
|
|
75
|
+
residual = X - model
|
|
76
|
+
|
|
77
|
+
G = np.asarray(deco.G)
|
|
78
|
+
W = np.asarray(deco.W)
|
|
79
|
+
H = np.asarray(deco.H)
|
|
80
|
+
|
|
81
|
+
loo = np.zeros(n_components, dtype=float)
|
|
82
|
+
power = np.zeros(n_components, dtype=float)
|
|
83
|
+
ev = np.zeros(n_components, dtype=float)
|
|
84
|
+
|
|
85
|
+
for edge in fine_structure_ranges:
|
|
86
|
+
if edge not in edge_slices:
|
|
87
|
+
continue
|
|
88
|
+
idxs = edge_slices[edge]
|
|
89
|
+
for k in range(n_components):
|
|
90
|
+
wk = G[:, idxs] @ W[idxs, k]
|
|
91
|
+
hk = H[k, :]
|
|
92
|
+
|
|
93
|
+
comp_sq = np.sum(wk**2) * np.sum(hk**2)
|
|
94
|
+
|
|
95
|
+
power_vals = comp_sq / norm_x if norm_x else 0.0
|
|
96
|
+
|
|
97
|
+
interaction_x = (wk.T @ X) @ hk.T
|
|
98
|
+
evk = (2 * interaction_x - comp_sq) / norm_x if norm_x else 0.0
|
|
99
|
+
|
|
100
|
+
interaction_residual = wk.T @ residual @ hk.T
|
|
101
|
+
ck = (comp_sq + 2 * interaction_residual) / norm_x if norm_x else 0.0
|
|
102
|
+
|
|
103
|
+
loo[k] += ck
|
|
104
|
+
power[k] += power_vals
|
|
105
|
+
ev[k] += evk
|
|
106
|
+
|
|
107
|
+
return EdgeTotals(
|
|
108
|
+
leave_out_loss=loo, power=power, explained_variance=ev
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def order_by_power(
|
|
113
|
+
totals: EdgeTotals | None, *, n_components: int
|
|
114
|
+
) -> List[int]:
|
|
115
|
+
"""Component indices ordered by decreasing power (identity fallback)."""
|
|
116
|
+
if totals is None or not np.any(totals.power):
|
|
117
|
+
return list(range(n_components))
|
|
118
|
+
return np.argsort(totals.power)[::-1].tolist()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def summary_rows(totals: EdgeTotals, order: Sequence[int]) -> List[Dict[str, float]]:
|
|
122
|
+
"""Summary table rows for components listed in *order*."""
|
|
123
|
+
totals_sum = {
|
|
124
|
+
"Leave-Out Loss": float(np.sum(totals.leave_out_loss)),
|
|
125
|
+
"Power": float(np.sum(totals.power)),
|
|
126
|
+
"Explained Variance": float(np.sum(totals.explained_variance)),
|
|
127
|
+
}
|
|
128
|
+
rows: List[Dict[str, float]] = []
|
|
129
|
+
for position, comp_idx in enumerate(order):
|
|
130
|
+
rows.append(
|
|
131
|
+
{
|
|
132
|
+
"Component": position,
|
|
133
|
+
"Leave-Out Loss": float(totals.leave_out_loss[comp_idx]),
|
|
134
|
+
"Relative Leave-Out Loss (%)": (
|
|
135
|
+
100.0
|
|
136
|
+
* totals.leave_out_loss[comp_idx]
|
|
137
|
+
/ totals_sum["Leave-Out Loss"]
|
|
138
|
+
if totals_sum["Leave-Out Loss"]
|
|
139
|
+
else 0.0
|
|
140
|
+
),
|
|
141
|
+
"Power": float(totals.power[comp_idx]),
|
|
142
|
+
"Relative Power (%)": (
|
|
143
|
+
100.0 * totals.power[comp_idx] / totals_sum["Power"]
|
|
144
|
+
if totals_sum["Power"]
|
|
145
|
+
else 0.0
|
|
146
|
+
),
|
|
147
|
+
"Explained Variance": float(
|
|
148
|
+
totals.explained_variance[comp_idx]
|
|
149
|
+
),
|
|
150
|
+
"Relative Explained Variance (%)": (
|
|
151
|
+
100.0
|
|
152
|
+
* totals.explained_variance[comp_idx]
|
|
153
|
+
/ totals_sum["Explained Variance"]
|
|
154
|
+
if totals_sum["Explained Variance"]
|
|
155
|
+
else 0.0
|
|
156
|
+
),
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
return rows
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Edge spectra extraction
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def extract_edge_collection(
|
|
168
|
+
deco,
|
|
169
|
+
fine_structure_ranges: Dict[str, Tuple[float, float]],
|
|
170
|
+
n_components: int,
|
|
171
|
+
*,
|
|
172
|
+
order: Sequence[int] | None = None,
|
|
173
|
+
smoother: Smoother | None = None,
|
|
174
|
+
) -> EdgeCollection:
|
|
175
|
+
"""Extract per-edge spectra for each component, combined and by label."""
|
|
176
|
+
if smoother is None:
|
|
177
|
+
smoother = default_smoother
|
|
178
|
+
if order is None:
|
|
179
|
+
order = list(range(n_components))
|
|
180
|
+
|
|
181
|
+
by_label: Dict[str, EdgeSeries] = {}
|
|
182
|
+
for edge_label in fine_structure_ranges:
|
|
183
|
+
ii, ff = find_index(
|
|
184
|
+
deco.energy_axis, deco.fine_structure_ranges[edge_label]
|
|
185
|
+
)
|
|
186
|
+
x_axis = deco.energy_axis[ii:ff]
|
|
187
|
+
|
|
188
|
+
raw: List[np.ndarray] = []
|
|
189
|
+
smoothed: List[np.ndarray] = []
|
|
190
|
+
norm: List[np.ndarray] = []
|
|
191
|
+
for comp_idx in order:
|
|
192
|
+
edge_raw = np.asarray(
|
|
193
|
+
deco.get_edge_from_component(comp_idx, edge_label).data
|
|
194
|
+
)
|
|
195
|
+
edge_smoothed = np.asarray(smoother(edge_raw.copy()))[ii:ff]
|
|
196
|
+
denom = edge_smoothed.max() - edge_smoothed.min()
|
|
197
|
+
if denom == 0:
|
|
198
|
+
edge_norm = np.zeros_like(edge_smoothed)
|
|
199
|
+
else:
|
|
200
|
+
edge_norm = (edge_smoothed - edge_smoothed.min()) / denom
|
|
201
|
+
|
|
202
|
+
raw.append(edge_raw[ii:ff])
|
|
203
|
+
smoothed.append(edge_smoothed)
|
|
204
|
+
norm.append(edge_norm)
|
|
205
|
+
|
|
206
|
+
by_label[edge_label] = EdgeSeries(
|
|
207
|
+
x=x_axis, raw=raw, smoothed=smoothed, norm=norm
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return EdgeCollection(
|
|
211
|
+
combined=_combine_edges(by_label, n_components),
|
|
212
|
+
by_label=by_label,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _combine_edges(by_label: Dict[str, EdgeSeries], n_components: int) -> EdgeSeries:
|
|
217
|
+
"""Concatenate per-edge spectra sorted by edge onset energy."""
|
|
218
|
+
ordered = sorted(
|
|
219
|
+
by_label.values(),
|
|
220
|
+
key=lambda edge: edge.x[0] if len(edge.x) > 0 else np.inf,
|
|
221
|
+
)
|
|
222
|
+
return EdgeSeries(
|
|
223
|
+
x=_concat([edge.x for edge in ordered]),
|
|
224
|
+
raw=[_concat([edge.raw[i] for edge in ordered]) for i in range(n_components)],
|
|
225
|
+
smoothed=[
|
|
226
|
+
_concat([edge.smoothed[i] for edge in ordered])
|
|
227
|
+
for i in range(n_components)
|
|
228
|
+
],
|
|
229
|
+
norm=[
|
|
230
|
+
_concat([edge.norm[i] for edge in ordered])
|
|
231
|
+
for i in range(n_components)
|
|
232
|
+
],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Component intensity maps
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _edge_weights(
|
|
242
|
+
deco,
|
|
243
|
+
fine_structure_ranges: Dict[str, Tuple[float, float]],
|
|
244
|
+
) -> np.ndarray:
|
|
245
|
+
"""Per-component spectral weights restricted to fine-structure ranges."""
|
|
246
|
+
edge_slices = deco.model._edge_slices
|
|
247
|
+
idxs_list = [
|
|
248
|
+
edge_slices[edge]
|
|
249
|
+
for edge in fine_structure_ranges
|
|
250
|
+
if edge in edge_slices
|
|
251
|
+
]
|
|
252
|
+
if idxs_list:
|
|
253
|
+
weights = np.zeros(deco.W.shape[1], dtype=float)
|
|
254
|
+
for slc in idxs_list:
|
|
255
|
+
weights += (deco.G[:, slc] @ deco.W[slc, :]).sum(0)
|
|
256
|
+
else:
|
|
257
|
+
weights = (deco.G @ deco.W).sum(0)
|
|
258
|
+
return weights
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def expand_masked_components(
|
|
262
|
+
values: np.ndarray,
|
|
263
|
+
mask_flat: np.ndarray,
|
|
264
|
+
shapes: Sequence[Tuple[int, int]],
|
|
265
|
+
) -> List[List[np.ndarray]]:
|
|
266
|
+
"""Restore masked component vectors back to per-SI 2D images."""
|
|
267
|
+
n_components, _ = values.shape
|
|
268
|
+
total_pixels = sum(h * w for h, w in shapes)
|
|
269
|
+
full = np.zeros((n_components, total_pixels), dtype=values.dtype)
|
|
270
|
+
full[:, mask_flat] = values
|
|
271
|
+
|
|
272
|
+
images: List[List[np.ndarray]] = [[] for _ in range(n_components)]
|
|
273
|
+
offset = 0
|
|
274
|
+
for height, width in shapes:
|
|
275
|
+
count = height * width
|
|
276
|
+
for comp_idx in range(n_components):
|
|
277
|
+
chunk = full[comp_idx, offset : offset + count]
|
|
278
|
+
images[comp_idx].append(chunk.reshape((height, width)))
|
|
279
|
+
offset += count
|
|
280
|
+
return images
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def component_maps(
|
|
284
|
+
deco,
|
|
285
|
+
list_of_si,
|
|
286
|
+
fine_structure_ranges: Dict[str, Tuple[float, float]],
|
|
287
|
+
n_components: int,
|
|
288
|
+
*,
|
|
289
|
+
order: Sequence[int],
|
|
290
|
+
mask_result: MaskResult | None,
|
|
291
|
+
) -> List[List[np.ndarray]]:
|
|
292
|
+
"""Normalized (uncolored) component intensity maps, one list per SI."""
|
|
293
|
+
|
|
294
|
+
weights = _edge_weights(deco, fine_structure_ranges)
|
|
295
|
+
normalized = np.asarray(deco.H) * weights[:, None]
|
|
296
|
+
|
|
297
|
+
spatial_dims = list(list_of_si.spatial_dim_list)
|
|
298
|
+
if len(spatial_dims) > 1:
|
|
299
|
+
# Normalize per SI (or per masked chunk) so every image is comparable.
|
|
300
|
+
normalized = normalized.copy()
|
|
301
|
+
filled = 0
|
|
302
|
+
sizes = (
|
|
303
|
+
list(mask_result.counts) if mask_result is not None else spatial_dims
|
|
304
|
+
)
|
|
305
|
+
for size in sizes:
|
|
306
|
+
chunk = normalized[:, filled : filled + size]
|
|
307
|
+
denom = float(np.max(np.max(chunk, axis=1)))
|
|
308
|
+
if denom > 0:
|
|
309
|
+
normalized[:, filled : filled + size] = chunk / denom
|
|
310
|
+
filled += size
|
|
311
|
+
else:
|
|
312
|
+
normalized = normalized / max(normalized.max(axis=1).tolist())
|
|
313
|
+
|
|
314
|
+
ordered = normalized[list(order)]
|
|
315
|
+
if mask_result is not None:
|
|
316
|
+
return expand_masked_components(
|
|
317
|
+
ordered, mask_result.flat, mask_result.shapes
|
|
318
|
+
)
|
|
319
|
+
return [list_of_si.fold_array(ordered[i]) for i in range(n_components)]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def component_maps_from_deco(deco, fine_structure_ranges):
|
|
323
|
+
"""Rebuild normalized component maps from a loaded deco object.
|
|
324
|
+
|
|
325
|
+
Only possible for single-SI, unmasked decos (the per-SI split and the
|
|
326
|
+
masked-pixel bookkeeping are not stored in the deco). Returns a list per
|
|
327
|
+
component containing one (ny, nx) image, or None when reconstruction is
|
|
328
|
+
not possible.
|
|
329
|
+
"""
|
|
330
|
+
n_components = int(getattr(deco, "n_components", 0) or 0)
|
|
331
|
+
if n_components < 1:
|
|
332
|
+
return None
|
|
333
|
+
shape = getattr(deco, "spatial_shape", None)
|
|
334
|
+
if shape is None or len(shape) != 2:
|
|
335
|
+
return None
|
|
336
|
+
n_pix = int(shape[0]) * int(shape[1])
|
|
337
|
+
H = np.asarray(deco.H)
|
|
338
|
+
if H.ndim != 2 or H.shape[1] != n_pix:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
weights = _edge_weights(deco, fine_structure_ranges)
|
|
342
|
+
normalized = np.asarray(deco.H) * weights[:, None]
|
|
343
|
+
normalized = normalized / max(normalized.max(axis=1).tolist())
|
|
344
|
+
return [[normalized[i].reshape(shape)] for i in range(n_components)]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def apply_colors(
|
|
348
|
+
maps: List[List[np.ndarray]],
|
|
349
|
+
colors: Sequence[np.ndarray],
|
|
350
|
+
) -> Tuple[List[np.ndarray], List[List[np.ndarray]]]:
|
|
351
|
+
"""Mix component maps into RGB images; also color each component map."""
|
|
352
|
+
n_components = len(maps)
|
|
353
|
+
mixed: List[np.ndarray] = []
|
|
354
|
+
n_images = len(maps[0]) if maps else 0
|
|
355
|
+
for idx in range(n_images):
|
|
356
|
+
rgb = None
|
|
357
|
+
for comp_idx in range(n_components):
|
|
358
|
+
contribution = maps[comp_idx][idx][..., None] * np.asarray(
|
|
359
|
+
colors[comp_idx]
|
|
360
|
+
)
|
|
361
|
+
rgb = contribution if rgb is None else rgb + contribution
|
|
362
|
+
mixed.append(np.clip(rgb, 0.0, 1.0))
|
|
363
|
+
|
|
364
|
+
colored = [
|
|
365
|
+
[
|
|
366
|
+
np.clip(img[..., None] * np.asarray(colors[i]), 0.0, 1.0)
|
|
367
|
+
for img in comp_maps
|
|
368
|
+
]
|
|
369
|
+
for i, comp_maps in enumerate(maps)
|
|
370
|
+
]
|
|
371
|
+
return mixed, colored
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def zero_outside_masks(
|
|
375
|
+
mixed: List[np.ndarray],
|
|
376
|
+
original: List[np.ndarray] | None,
|
|
377
|
+
colored_maps: List[List[np.ndarray]],
|
|
378
|
+
per_si_masks: Sequence[np.ndarray],
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Zero out image values where the mask is False (in place)."""
|
|
381
|
+
for idx, mask in enumerate(per_si_masks):
|
|
382
|
+
if original is not None:
|
|
383
|
+
original[idx] = np.where(mask, original[idx], 0.0)
|
|
384
|
+
mixed[idx] = np.where(mask[..., None], mixed[idx], 0.0)
|
|
385
|
+
for comp_idx in range(len(colored_maps)):
|
|
386
|
+
colored_maps[comp_idx][idx] = np.where(
|
|
387
|
+
mask[..., None], colored_maps[comp_idx][idx], 0.0
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ---------------------------------------------------------------------------
|
|
392
|
+
# Joint-result helpers
|
|
393
|
+
# ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def map_images_to_labels(images, keys, labels: Sequence[str]):
|
|
397
|
+
"""Reorder per-key images to match label order from folder names."""
|
|
398
|
+
if images is None or keys is None:
|
|
399
|
+
return []
|
|
400
|
+
key_map = {os.path.basename(k): img for k, img in zip(keys, images)}
|
|
401
|
+
return [key_map.get(label) for label in labels]
|
eelsnmf_gui/_export.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import Dict, List
|
|
6
|
+
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from scipy.ndimage import zoom
|
|
11
|
+
|
|
12
|
+
from ._results import EdgeSeries
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _upscale_nearest(img: np.ndarray, scale: int = 10) -> np.ndarray:
|
|
16
|
+
"""Upscale an image using nearest-neighbor sampling."""
|
|
17
|
+
if scale <= 1:
|
|
18
|
+
return img
|
|
19
|
+
|
|
20
|
+
if img.ndim == 2:
|
|
21
|
+
return zoom(img, (scale, scale), order=0)
|
|
22
|
+
|
|
23
|
+
if img.ndim == 3 and img.shape[-1] in (3, 4):
|
|
24
|
+
return zoom(img, (scale, scale, 1), order=0)
|
|
25
|
+
|
|
26
|
+
return img
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _save_images(
|
|
30
|
+
list_imgs: Sequence[Sequence[np.ndarray]],
|
|
31
|
+
labels: Sequence[str],
|
|
32
|
+
output_dir: str,
|
|
33
|
+
*,
|
|
34
|
+
cmap: str | None = None,
|
|
35
|
+
scale: int = 10,
|
|
36
|
+
):
|
|
37
|
+
"""Save a set of images to disk with optional upscaling and colormap."""
|
|
38
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
39
|
+
if len(list_imgs) > 0 and isinstance(list_imgs[0], np.ndarray):
|
|
40
|
+
# One image per label: wrap each image individually.
|
|
41
|
+
list_imgs = [[img] for img in list_imgs]
|
|
42
|
+
for label, imgs in zip(labels, list_imgs):
|
|
43
|
+
if imgs is None:
|
|
44
|
+
continue
|
|
45
|
+
for j, img in enumerate(imgs):
|
|
46
|
+
img_filename = (
|
|
47
|
+
f"{label}_image{j:02d}.png"
|
|
48
|
+
if len(imgs) > 1
|
|
49
|
+
else f"{label}.png"
|
|
50
|
+
)
|
|
51
|
+
img_path = os.path.join(output_dir, img_filename)
|
|
52
|
+
upscaled = _upscale_nearest(np.asarray(img), scale=scale)
|
|
53
|
+
if cmap is None:
|
|
54
|
+
plt.imsave(img_path, upscaled)
|
|
55
|
+
else:
|
|
56
|
+
plt.imsave(img_path, upscaled, cmap=cmap)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_colored_images(
|
|
60
|
+
list_imgs: Sequence[Sequence[np.ndarray]],
|
|
61
|
+
labels: Sequence[str],
|
|
62
|
+
output_dir: str,
|
|
63
|
+
):
|
|
64
|
+
"""Save mixed-color component images to disk."""
|
|
65
|
+
_save_images(list_imgs, labels, output_dir, cmap=None, scale=10)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_original_images(
|
|
69
|
+
list_imgs_original: Sequence[Sequence[np.ndarray]],
|
|
70
|
+
labels: Sequence[str],
|
|
71
|
+
output_dir: str,
|
|
72
|
+
):
|
|
73
|
+
"""Save original grayscale images to disk."""
|
|
74
|
+
_save_images(list_imgs_original, labels, output_dir, cmap="gray", scale=10)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_mask_images(
|
|
78
|
+
list_masks: Sequence[Sequence[np.ndarray]],
|
|
79
|
+
labels: Sequence[str],
|
|
80
|
+
output_dir: str,
|
|
81
|
+
):
|
|
82
|
+
"""Save mask images to disk."""
|
|
83
|
+
_save_images(list_masks, labels, output_dir, cmap="gray", scale=10)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_component_images(
|
|
87
|
+
list_imgs_component: Sequence[Sequence[np.ndarray]],
|
|
88
|
+
labels: Sequence[str],
|
|
89
|
+
output_dir: str,
|
|
90
|
+
component_label: str,
|
|
91
|
+
):
|
|
92
|
+
"""Save per-component images into a component subdirectory."""
|
|
93
|
+
component_dir = os.path.join(output_dir, component_label)
|
|
94
|
+
_save_images(
|
|
95
|
+
list_imgs_component, labels, component_dir, cmap=None, scale=10
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def save_summary_plot(fig, output_path: str, dpi: int = 300):
|
|
100
|
+
"""Save a matplotlib figure to disk."""
|
|
101
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
102
|
+
fig.savefig(output_path, dpi=dpi)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_edges_csv_data(
|
|
106
|
+
energy_axis: np.ndarray,
|
|
107
|
+
edge_raw: Sequence[np.ndarray],
|
|
108
|
+
edge_smoothed: Sequence[np.ndarray],
|
|
109
|
+
edge_smoothed_norm: Sequence[np.ndarray],
|
|
110
|
+
) -> Dict[str, np.ndarray]:
|
|
111
|
+
"""Assemble columnar edge data for CSV export."""
|
|
112
|
+
data: Dict[str, np.ndarray] = {"Energy (eV)": energy_axis}
|
|
113
|
+
for idx, values in enumerate(edge_raw):
|
|
114
|
+
data[f"Component {idx + 1}"] = values
|
|
115
|
+
for idx, values in enumerate(edge_smoothed):
|
|
116
|
+
data[f"Component {idx + 1} Smoothed"] = values
|
|
117
|
+
for idx, values in enumerate(edge_smoothed_norm):
|
|
118
|
+
data[f"Component {idx + 1} Smoothed (Norm)"] = values
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def export_edges_to_csv(
|
|
123
|
+
full_energy_axis: np.ndarray,
|
|
124
|
+
edge_raw: Sequence[np.ndarray],
|
|
125
|
+
edge_smoothed: Sequence[np.ndarray],
|
|
126
|
+
edge_smoothed_norm: Sequence[np.ndarray],
|
|
127
|
+
output_path: str,
|
|
128
|
+
):
|
|
129
|
+
"""Export combined edge data to a CSV file."""
|
|
130
|
+
data = _build_edges_csv_data(
|
|
131
|
+
full_energy_axis,
|
|
132
|
+
edge_raw,
|
|
133
|
+
edge_smoothed,
|
|
134
|
+
edge_smoothed_norm,
|
|
135
|
+
)
|
|
136
|
+
df = pd.DataFrame(data)
|
|
137
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
138
|
+
df.to_csv(output_path, index=False)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def export_edges_by_label_to_csv(
|
|
142
|
+
edges_by_label: Dict[str, EdgeSeries],
|
|
143
|
+
output_dir: str,
|
|
144
|
+
*,
|
|
145
|
+
filename_prefix: str = "",
|
|
146
|
+
):
|
|
147
|
+
"""Export per-label edge data to CSV files in a directory."""
|
|
148
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
149
|
+
for label, edge in edges_by_label.items():
|
|
150
|
+
data = _build_edges_csv_data(
|
|
151
|
+
edge.x,
|
|
152
|
+
edge.raw,
|
|
153
|
+
edge.smoothed,
|
|
154
|
+
edge.norm,
|
|
155
|
+
)
|
|
156
|
+
df = pd.DataFrame(data)
|
|
157
|
+
filename = f"{filename_prefix}{label}.csv"
|
|
158
|
+
df.to_csv(os.path.join(output_dir, filename), index=False)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def export_edge_contributions_summary_to_csv(
|
|
162
|
+
summary_rows: List[Dict[str, float]],
|
|
163
|
+
output_path: str,
|
|
164
|
+
):
|
|
165
|
+
"""Export edge contribution summaries to a CSV file."""
|
|
166
|
+
if not summary_rows:
|
|
167
|
+
return
|
|
168
|
+
adjusted_rows: List[Dict[str, float]] = []
|
|
169
|
+
for row in summary_rows:
|
|
170
|
+
updated = dict(row)
|
|
171
|
+
if "Component" in updated:
|
|
172
|
+
try:
|
|
173
|
+
updated["Component"] = int(updated["Component"]) + 1
|
|
174
|
+
except (TypeError, ValueError):
|
|
175
|
+
pass
|
|
176
|
+
adjusted_rows.append(updated)
|
|
177
|
+
df = pd.DataFrame(adjusted_rows)
|
|
178
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
179
|
+
df.to_csv(output_path, index=False)
|