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.
@@ -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
+ )
@@ -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)