pyAthina 0.0.1__tar.gz

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.
pyathina-0.0.1/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ Copyright (c) 2024 Evangelos Golias.
2
+ Contact: evangelos.golias@gmail.com
3
+
4
+ Permission is hereby granted, free of charge, to any person
5
+ obtaining a copy of this software and associated documentation
6
+ files (the "Software"), to deal in the Software without
7
+ restriction, including without limitation the rights to use,
8
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the
10
+ Software is furnished to do so, subject to the following
11
+ conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
18
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
20
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
21
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
22
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
23
+ OTHER DEALINGS IN THE SOFTWARE.
24
+ -------------------------------------------------------
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyAthina
3
+ Version: 0.0.1
4
+ Summary: A photoelectron miroscpectroscopy/spectromicroscopy data analyis package
5
+ Author-email: Evangelos Golias <evangelos.golias@gmail.com>
6
+ Project-URL: Homepage, https://gitlab.com/evangelosgolias/athina
7
+ Project-URL: Issues, https://gitlab.com/evangelosgolias/athina/-/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # pyAthina
17
+
18
+ A lightweight PyQt6 + pyqtgraph skeleton for PEEM-style image-stack workflows.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install -r requirements.txt
24
+ ```
25
+
26
+ ## Run
27
+
28
+ Any of these should work from the extracted project folder:
29
+
30
+ ```bash
31
+ python -m pyAthina.main
32
+ ```
33
+
34
+ ```bash
35
+ python -m pyAthina
36
+ ```
37
+
38
+ ```bash
39
+ python run_pyAthina.py
40
+ ```
41
+
42
+ If you `cd` into the `pyAthina/` subfolder itself, you can also run:
43
+
44
+ ```bash
45
+ python main.py
46
+ ```
@@ -0,0 +1,31 @@
1
+ # pyAthina
2
+
3
+ A lightweight PyQt6 + pyqtgraph skeleton for PEEM-style image-stack workflows.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install -r requirements.txt
9
+ ```
10
+
11
+ ## Run
12
+
13
+ Any of these should work from the extracted project folder:
14
+
15
+ ```bash
16
+ python -m pyAthina.main
17
+ ```
18
+
19
+ ```bash
20
+ python -m pyAthina
21
+ ```
22
+
23
+ ```bash
24
+ python run_pyAthina.py
25
+ ```
26
+
27
+ If you `cd` into the `pyAthina/` subfolder itself, you can also run:
28
+
29
+ ```bash
30
+ python main.py
31
+ ```
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["PyQt6", "pyqtgraph>=0.13", "numpy", "scipy","scikit-image","h5py", "tifffile", "setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+ [project]
5
+ name = "pyAthina"
6
+ version = "0.0.1"
7
+ authors = [
8
+ { name="Evangelos Golias", email="evangelos.golias@gmail.com" },
9
+ ]
10
+ description = "A photoelectron miroscpectroscopy/spectromicroscopy data analyis package"
11
+ readme = "README.md"
12
+ requires-python = ">=3.8"
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://gitlab.com/evangelosgolias/athina"
21
+ Issues = "https://gitlab.com/evangelosgolias/athina/-/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,10 @@
1
+ __all__ = [
2
+ "app",
3
+ "io",
4
+ "main",
5
+ "models",
6
+ "processing",
7
+ "signals",
8
+ "viewer",
9
+ "workers",
10
+ ]
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,453 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import numpy as np
7
+ from PyQt6.QtCore import Qt
8
+ from PyQt6.QtWidgets import (
9
+ QCheckBox,
10
+ QDockWidget,
11
+ QFileDialog,
12
+ QFormLayout,
13
+ QHBoxLayout,
14
+ QLabel,
15
+ QMainWindow,
16
+ QMessageBox,
17
+ QPushButton,
18
+ QProgressBar,
19
+ QSlider,
20
+ QSpinBox,
21
+ QVBoxLayout,
22
+ QWidget,
23
+ )
24
+
25
+ from pyAthina.io import load_stack_auto, save_image_tiff
26
+ from pyAthina.models import ImageStack
27
+ from pyAthina.processing import average_stack, average_stack_in_roi
28
+ from pyAthina.signals import PEEMSignals
29
+ from pyAthina.viewer import PEEMImageView
30
+ from pyAthina.workers import AlignmentWorker, create_alignment_thread
31
+
32
+
33
+ class MainWindow(QMainWindow):
34
+ def __init__(self):
35
+ super().__init__()
36
+ self.setWindowTitle("pyAthina")
37
+ self.resize(1450, 920)
38
+
39
+ self.signals = PEEMSignals()
40
+ self.stack: Optional[ImageStack] = None
41
+ self.current_index = 0
42
+ self.last_shifts: np.ndarray | None = None
43
+
44
+ self._alignment_thread = None
45
+ self._alignment_worker = None
46
+ self._alignment_running = False
47
+
48
+ self.viewer = PEEMImageView(self.signals)
49
+ self._build_central_panel()
50
+
51
+ self._build_left_panel()
52
+ self._build_right_panel()
53
+ self._connect_signals()
54
+ self._load_dummy_data()
55
+
56
+ def _build_central_panel(self) -> None:
57
+ central = QWidget()
58
+ layout = QVBoxLayout(central)
59
+ layout.setContentsMargins(6, 6, 6, 6)
60
+
61
+ self.frame_slider = QSlider(Qt.Orientation.Horizontal)
62
+ self.frame_slider.setMinimum(0)
63
+ self.frame_slider.setMaximum(0)
64
+ self.frame_slider.setSingleStep(1)
65
+ self.frame_slider.setPageStep(1)
66
+ self.frame_slider.setTickInterval(1)
67
+ self.frame_slider.setTracking(True)
68
+
69
+ slider_row = QHBoxLayout()
70
+ self.lbl_frame_min = QLabel("0")
71
+ self.lbl_frame_current = QLabel("Frame 0 / 0")
72
+ self.lbl_frame_max = QLabel("0")
73
+ slider_row.addWidget(self.lbl_frame_min)
74
+ slider_row.addWidget(self.frame_slider, stretch=1)
75
+ slider_row.addWidget(self.lbl_frame_max)
76
+
77
+ layout.addWidget(self.viewer, stretch=1)
78
+ layout.addLayout(slider_row)
79
+ layout.addWidget(self.lbl_frame_current)
80
+
81
+ self.setCentralWidget(central)
82
+
83
+ def _build_left_panel(self) -> None:
84
+ dock = QDockWidget("Controls", self)
85
+ dock.setFeatures(
86
+ QDockWidget.DockWidgetFeature.DockWidgetMovable
87
+ | QDockWidget.DockWidgetFeature.DockWidgetFloatable
88
+ )
89
+ panel = QWidget()
90
+ layout = QVBoxLayout(panel)
91
+
92
+ self.btn_load = QPushButton("Load stack")
93
+ self.btn_prev = QPushButton("Previous frame")
94
+ self.btn_next = QPushButton("Next frame")
95
+ self.btn_align = QPushButton("Align stack (threaded)")
96
+ self.btn_average_roi = QPushButton("Show ROI average")
97
+ self.btn_average_full = QPushButton("Show full-stack average")
98
+ self.btn_export_view = QPushButton("Export current view as TIFF")
99
+
100
+ self.spin_frame = QSpinBox()
101
+ self.spin_frame.setMinimum(0)
102
+ self.spin_frame.setMaximum(0)
103
+
104
+ self.spin_reference = QSpinBox()
105
+ self.spin_reference.setMinimum(0)
106
+ self.spin_reference.setMaximum(0)
107
+
108
+ self.spin_upsample = QSpinBox()
109
+ self.spin_upsample.setMinimum(1)
110
+ self.spin_upsample.setMaximum(100)
111
+ self.spin_upsample.setValue(10)
112
+
113
+ self.chk_roi = QCheckBox("Enable ROI")
114
+ self.chk_crosshair = QCheckBox("Enable crosshair")
115
+
116
+ self.progress_align = QProgressBar()
117
+ self.progress_align.setRange(0, 100)
118
+ self.progress_align.setValue(0)
119
+ self.progress_align.setFormat("Idle")
120
+
121
+ form = QFormLayout()
122
+ form.addRow("Frame", self.spin_frame)
123
+ form.addRow("Reference frame", self.spin_reference)
124
+ form.addRow("Upsample factor", self.spin_upsample)
125
+
126
+ layout.addWidget(self.btn_load)
127
+ layout.addLayout(form)
128
+ layout.addWidget(self.btn_prev)
129
+ layout.addWidget(self.btn_next)
130
+ layout.addWidget(self.chk_roi)
131
+ layout.addWidget(self.chk_crosshair)
132
+ layout.addWidget(self.btn_align)
133
+ layout.addWidget(self.progress_align)
134
+ layout.addWidget(self.btn_average_roi)
135
+ layout.addWidget(self.btn_average_full)
136
+ layout.addWidget(self.btn_export_view)
137
+ layout.addStretch()
138
+
139
+ dock.setWidget(panel)
140
+ self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock)
141
+
142
+ def _build_right_panel(self) -> None:
143
+ dock = QDockWidget("Status", self)
144
+ dock.setFeatures(
145
+ QDockWidget.DockWidgetFeature.DockWidgetMovable
146
+ | QDockWidget.DockWidgetFeature.DockWidgetFloatable
147
+ )
148
+ panel = QWidget()
149
+ layout = QFormLayout(panel)
150
+
151
+ self.lbl_stack = QLabel("-")
152
+ self.lbl_shape = QLabel("-")
153
+ self.lbl_mouse = QLabel("x=-, y=-")
154
+ self.lbl_value = QLabel("-")
155
+ self.lbl_roi = QLabel("-")
156
+ self.lbl_crosshair = QLabel("-")
157
+ self.lbl_last_shift = QLabel("-")
158
+
159
+ layout.addRow("Stack", self.lbl_stack)
160
+ layout.addRow("Shape", self.lbl_shape)
161
+ layout.addRow("Mouse", self.lbl_mouse)
162
+ layout.addRow("Pixel value", self.lbl_value)
163
+ layout.addRow("ROI", self.lbl_roi)
164
+ layout.addRow("Crosshair", self.lbl_crosshair)
165
+ layout.addRow("Last shift", self.lbl_last_shift)
166
+
167
+ dock.setWidget(panel)
168
+ self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)
169
+
170
+ def _connect_signals(self) -> None:
171
+ self.btn_load.clicked.connect(self.load_stack_dialog)
172
+ self.btn_prev.clicked.connect(self.prev_frame)
173
+ self.btn_next.clicked.connect(self.next_frame)
174
+ self.btn_align.clicked.connect(self.run_alignment_threaded)
175
+ self.btn_average_roi.clicked.connect(self.show_average_roi)
176
+ self.btn_average_full.clicked.connect(self.show_average_full)
177
+ self.btn_export_view.clicked.connect(self.export_current_view)
178
+ self.spin_frame.valueChanged.connect(self.set_frame)
179
+ self.frame_slider.valueChanged.connect(self.set_frame)
180
+ self.chk_roi.toggled.connect(self.viewer.show_roi)
181
+ self.chk_crosshair.toggled.connect(self.viewer.show_crosshair)
182
+
183
+ self.signals.mouse_moved.connect(self.on_mouse_moved)
184
+ self.signals.left_clicked.connect(self.on_left_clicked)
185
+ self.signals.right_clicked.connect(self.on_right_clicked)
186
+ self.signals.double_clicked.connect(self.on_double_clicked)
187
+ self.signals.roi_changed.connect(self.on_roi_changed)
188
+ self.signals.crosshair_moved.connect(self.on_crosshair_moved)
189
+ self.signals.frame_changed.connect(self.on_frame_changed)
190
+ self.signals.stack_loaded.connect(self.on_stack_loaded)
191
+
192
+ def _load_dummy_data(self) -> None:
193
+ self.set_stack(ImageStack.dummy())
194
+ self.viewer.set_roi(80, 80, 180, 180)
195
+
196
+ def load_stack_dialog(self) -> None:
197
+ path, _ = QFileDialog.getOpenFileName(
198
+ self,
199
+ "Open stack",
200
+ "",
201
+ "Stacks (*.npy *.h5 *.hdf5 *.hdf);;All files (*)",
202
+ )
203
+ if not path:
204
+ return
205
+
206
+ try:
207
+ stack = load_stack_auto(path)
208
+ self.set_stack(stack)
209
+ except Exception as exc:
210
+ QMessageBox.critical(self, "Load error", str(exc))
211
+
212
+ def set_stack(self, stack: ImageStack) -> None:
213
+ self.stack = stack
214
+ self.current_index = 0
215
+ self.last_shifts = None
216
+
217
+ self.spin_frame.blockSignals(True)
218
+ self.spin_reference.blockSignals(True)
219
+ self.frame_slider.blockSignals(True)
220
+ self.spin_frame.setMaximum(stack.n_images - 1)
221
+ self.spin_reference.setMaximum(stack.n_images - 1)
222
+ self.frame_slider.setMaximum(stack.n_images - 1)
223
+ self.spin_frame.setValue(0)
224
+ self.spin_reference.setValue(0)
225
+ self.frame_slider.setValue(0)
226
+ self.spin_frame.blockSignals(False)
227
+ self.spin_reference.blockSignals(False)
228
+ self.frame_slider.blockSignals(False)
229
+ self.lbl_frame_min.setText("0")
230
+ self.lbl_frame_max.setText(str(max(0, stack.n_images - 1)))
231
+ self._update_frame_indicator()
232
+
233
+ self._update_display()
234
+ n, (ny, nx) = stack.n_images, stack.shape2d
235
+ self.signals.stack_loaded.emit(stack.name, n, ny, nx)
236
+
237
+ def set_frame(self, index: int) -> None:
238
+ if self.stack is None:
239
+ return
240
+ index = max(0, min(index, self.stack.n_images - 1))
241
+ self.current_index = index
242
+
243
+ self.spin_frame.blockSignals(True)
244
+ self.frame_slider.blockSignals(True)
245
+ self.spin_frame.setValue(index)
246
+ self.frame_slider.setValue(index)
247
+ self.spin_frame.blockSignals(False)
248
+ self.frame_slider.blockSignals(False)
249
+
250
+ self._update_display()
251
+ self._update_frame_indicator()
252
+ self.signals.frame_changed.emit(index)
253
+
254
+ def prev_frame(self) -> None:
255
+ self.set_frame(self.current_index - 1)
256
+
257
+ def next_frame(self) -> None:
258
+ self.set_frame(self.current_index + 1)
259
+
260
+ def _update_display(self) -> None:
261
+ if self.stack is None:
262
+ return
263
+ self.viewer.set_image(self.stack.data[self.current_index])
264
+ self._update_frame_indicator()
265
+ if self.last_shifts is not None:
266
+ dy, dx = self.last_shifts[self.current_index]
267
+ self.lbl_last_shift.setText(f"dy={dy:.3f}, dx={dx:.3f}")
268
+ else:
269
+ self.lbl_last_shift.setText("-")
270
+
271
+ def _update_frame_indicator(self) -> None:
272
+ if self.stack is None or self.stack.n_images <= 0:
273
+ self.lbl_frame_current.setText("Frame 0 / 0")
274
+ return
275
+ self.lbl_frame_current.setText(
276
+ f"Frame {self.current_index} / {self.stack.n_images - 1}"
277
+ )
278
+
279
+ def _current_roi_or_none(self):
280
+ if self.chk_roi.isChecked():
281
+ return self.viewer.get_roi_bounds()
282
+ return None
283
+
284
+ def _set_controls_enabled(self, enabled: bool) -> None:
285
+ for widget in (
286
+ self.btn_load,
287
+ self.btn_prev,
288
+ self.btn_next,
289
+ self.btn_align,
290
+ self.btn_average_roi,
291
+ self.btn_average_full,
292
+ self.btn_export_view,
293
+ self.spin_frame,
294
+ self.frame_slider,
295
+ self.spin_reference,
296
+ self.spin_upsample,
297
+ self.chk_roi,
298
+ self.chk_crosshair,
299
+ ):
300
+ widget.setEnabled(enabled)
301
+
302
+ def run_alignment_threaded(self) -> None:
303
+ if self.stack is None:
304
+ return
305
+
306
+ if self._alignment_running:
307
+ QMessageBox.information(self, "Alignment running", "An alignment job is already running.")
308
+ return
309
+
310
+ self._alignment_running = True
311
+ self._set_controls_enabled(False)
312
+ self.progress_align.setValue(0)
313
+ self.progress_align.setFormat("Aligning... %p%")
314
+
315
+ reference_index = int(self.spin_reference.value())
316
+ roi = self._current_roi_or_none()
317
+ upsample_factor = int(self.spin_upsample.value())
318
+
319
+ self._alignment_worker = AlignmentWorker(
320
+ stack=self.stack.data,
321
+ reference_index=reference_index,
322
+ roi=roi,
323
+ upsample_factor=upsample_factor,
324
+ )
325
+ self._alignment_thread = create_alignment_thread(self._alignment_worker)
326
+
327
+ self._alignment_worker.progress.connect(self._on_alignment_progress)
328
+ self._alignment_worker.finished.connect(self._on_alignment_finished)
329
+ self._alignment_worker.failed.connect(self._on_alignment_failed)
330
+
331
+ self._alignment_worker.finished.connect(self._cleanup_alignment_thread)
332
+ self._alignment_worker.failed.connect(self._cleanup_alignment_thread)
333
+ self._alignment_thread.finished.connect(self._alignment_thread.deleteLater)
334
+
335
+ self._alignment_thread.start()
336
+ self.statusBar().showMessage("Alignment started in background thread")
337
+
338
+ def _on_alignment_progress(self, done: int, total: int) -> None:
339
+ if total <= 0:
340
+ self.progress_align.setValue(0)
341
+ return
342
+ percent = int(round(100 * done / total))
343
+ self.progress_align.setValue(percent)
344
+ self.statusBar().showMessage(f"Alignment progress: {done}/{total}")
345
+
346
+ def _on_alignment_finished(self, aligned_data, shifts) -> None:
347
+ assert self.stack is not None
348
+
349
+ self.stack = ImageStack(
350
+ data=np.asarray(aligned_data),
351
+ name=f"{self.stack.name} [aligned]",
352
+ source_path=self.stack.source_path,
353
+ )
354
+ self.last_shifts = np.asarray(shifts)
355
+ self._alignment_running = False
356
+ self._set_controls_enabled(True)
357
+ self.progress_align.setValue(100)
358
+ self.progress_align.setFormat("Done")
359
+
360
+ self.spin_frame.blockSignals(True)
361
+ self.frame_slider.blockSignals(True)
362
+ self.spin_frame.setMaximum(self.stack.n_images - 1)
363
+ self.frame_slider.setMaximum(self.stack.n_images - 1)
364
+ self.spin_frame.blockSignals(False)
365
+ self.frame_slider.blockSignals(False)
366
+ self.lbl_frame_max.setText(str(max(0, self.stack.n_images - 1)))
367
+
368
+ self._update_display()
369
+ self.statusBar().showMessage(f"Alignment finished for {self.stack.n_images} frames")
370
+
371
+ def _on_alignment_failed(self, message: str) -> None:
372
+ self._alignment_running = False
373
+ self._set_controls_enabled(True)
374
+ self.progress_align.setValue(0)
375
+ self.progress_align.setFormat("Failed")
376
+ QMessageBox.critical(self, "Alignment error", message)
377
+
378
+ def _cleanup_alignment_thread(self, *_) -> None:
379
+ if self._alignment_thread is not None:
380
+ self._alignment_thread.quit()
381
+ self._alignment_thread.wait()
382
+ self._alignment_thread = None
383
+ self._alignment_worker = None
384
+
385
+ def show_average_roi(self) -> None:
386
+ if self.stack is None:
387
+ return
388
+ roi = self._current_roi_or_none()
389
+ if roi is None:
390
+ QMessageBox.information(self, "ROI required", "Enable the ROI first.")
391
+ return
392
+
393
+ try:
394
+ avg = average_stack_in_roi(self.stack.data, roi)
395
+ except Exception as exc:
396
+ QMessageBox.critical(self, "ROI average error", str(exc))
397
+ return
398
+
399
+ self.viewer.set_image(avg)
400
+ self.statusBar().showMessage("Showing ROI average over the current stack")
401
+
402
+ def show_average_full(self) -> None:
403
+ if self.stack is None:
404
+ return
405
+ avg = average_stack(self.stack.data)
406
+ self.viewer.set_image(avg)
407
+ self.statusBar().showMessage("Showing full-stack average")
408
+
409
+ def export_current_view(self) -> None:
410
+ if self.viewer.current_image is None:
411
+ return
412
+
413
+ path, _ = QFileDialog.getSaveFileName(
414
+ self,
415
+ "Export current view as TIFF",
416
+ str(Path.home() / "pyAthina_export.tif"),
417
+ "TIFF (*.tif *.tiff)",
418
+ )
419
+ if not path:
420
+ return
421
+
422
+ try:
423
+ save_image_tiff(path, self.viewer.current_image)
424
+ self.statusBar().showMessage(f"Saved TIFF: {path}")
425
+ except Exception as exc:
426
+ QMessageBox.critical(self, "Export error", str(exc))
427
+
428
+ def on_mouse_moved(self, x: float, y: float, value: float) -> None:
429
+ self.lbl_mouse.setText(f"x={x:.1f}, y={y:.1f}")
430
+ self.lbl_value.setText("nan" if np.isnan(value) else f"{value:.5g}")
431
+
432
+ def on_left_clicked(self, x: float, y: float) -> None:
433
+ self.statusBar().showMessage(f"Left click at x={x:.1f}, y={y:.1f}")
434
+
435
+ def on_right_clicked(self, x: float, y: float) -> None:
436
+ self.statusBar().showMessage(f"Right click at x={x:.1f}, y={y:.1f}")
437
+
438
+ def on_double_clicked(self, x: float, y: float) -> None:
439
+ self.statusBar().showMessage(f"Double click at x={x:.1f}, y={y:.1f}")
440
+
441
+ def on_roi_changed(self, x: float, y: float, w: float, h: float) -> None:
442
+ self.lbl_roi.setText(f"x={x:.0f}, y={y:.0f}, w={w:.0f}, h={h:.0f}")
443
+
444
+ def on_crosshair_moved(self, x: float, y: float) -> None:
445
+ self.lbl_crosshair.setText(f"x={x:.1f}, y={y:.1f}")
446
+
447
+ def on_frame_changed(self, index: int) -> None:
448
+ self.statusBar().showMessage(f"Frame changed to {index}")
449
+
450
+ def on_stack_loaded(self, name: str, n: int, ny: int, nx: int) -> None:
451
+ self.lbl_stack.setText(name)
452
+ self.lbl_shape.setText(f"{n} × {ny} × {nx}")
453
+ self.statusBar().showMessage(f"Loaded stack: {name}")
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ import h5py
7
+ import numpy as np
8
+ import tifffile
9
+
10
+ from pyAthina.models import ImageStack
11
+
12
+
13
+ _SUPPORTED_STACK_EXTENSIONS = {".npy", ".h5", ".hdf5", ".hdf"}
14
+
15
+
16
+ def _is_numeric_3d_dataset(obj) -> bool:
17
+ return (
18
+ isinstance(obj, h5py.Dataset)
19
+ and obj.ndim == 3
20
+ and np.issubdtype(obj.dtype, np.number)
21
+ )
22
+
23
+
24
+ def list_h5_3d_datasets(path: str | Path) -> List[str]:
25
+ """Return all numeric 3D dataset paths found in an HDF5 file."""
26
+ path = Path(path)
27
+ dataset_paths: List[str] = []
28
+ with h5py.File(path, "r") as h5:
29
+ def visitor(name, obj):
30
+ if _is_numeric_3d_dataset(obj):
31
+ dataset_paths.append(f"/{name}")
32
+
33
+ h5.visititems(visitor)
34
+ return dataset_paths
35
+
36
+
37
+ def choose_h5_dataset(path: str | Path) -> str:
38
+ """Pick the largest numeric 3D dataset in the file."""
39
+ candidates = list_h5_3d_datasets(path)
40
+ if not candidates:
41
+ raise ValueError("No numeric 3D datasets were found in this HDF5 file")
42
+ if len(candidates) == 1:
43
+ return candidates[0]
44
+
45
+ with h5py.File(path, "r") as h5:
46
+ sizes = {name: int(np.prod(h5[name].shape)) for name in candidates}
47
+
48
+ return max(candidates, key=lambda name: sizes[name])
49
+
50
+
51
+ def load_stack_from_h5(
52
+ path: str | Path,
53
+ dataset_path: str | None = None,
54
+ dtype=np.float32,
55
+ ) -> ImageStack:
56
+ path = Path(path)
57
+ chosen_dataset = dataset_path or choose_h5_dataset(path)
58
+
59
+ with h5py.File(path, "r") as h5:
60
+ data = np.asarray(h5[chosen_dataset], dtype=dtype)
61
+
62
+ return ImageStack(data=data, name=f"{path.name}:{chosen_dataset}", source_path=str(path))
63
+
64
+
65
+ def load_stack_from_npy(path: str | Path, dtype=np.float32) -> ImageStack:
66
+ path = Path(path)
67
+ data = np.asarray(np.load(path), dtype=dtype)
68
+ return ImageStack(data=data, name=path.name, source_path=str(path))
69
+
70
+
71
+ def load_stack_auto(path: str | Path, dataset_path: str | None = None, dtype=np.float32) -> ImageStack:
72
+ path = Path(path)
73
+ suffix = path.suffix.lower()
74
+
75
+ if suffix not in _SUPPORTED_STACK_EXTENSIONS:
76
+ raise ValueError(f"Unsupported file type: {suffix}")
77
+
78
+ if suffix == ".npy":
79
+ return load_stack_from_npy(path, dtype=dtype)
80
+ return load_stack_from_h5(path, dataset_path=dataset_path, dtype=dtype)
81
+
82
+
83
+ def save_image_tiff(path: str | Path, image: np.ndarray) -> None:
84
+ tifffile.imwrite(str(path), np.asarray(image))
85
+
86
+
87
+ def save_stack_tiff(path: str | Path, stack: np.ndarray) -> None:
88
+ tifffile.imwrite(str(path), np.asarray(stack))
@@ -0,0 +1,28 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ # Allow both:
5
+ # python -m pyAthina.main
6
+ # and, if the user is inside the pyAthina package directory:
7
+ # python main.py
8
+ package_dir = Path(__file__).resolve().parent
9
+ project_root = package_dir.parent
10
+ if str(project_root) not in sys.path:
11
+ sys.path.insert(0, str(project_root))
12
+
13
+ import pyqtgraph as pg
14
+ from PyQt6.QtWidgets import QApplication
15
+
16
+ from pyAthina.app import MainWindow
17
+
18
+
19
+ def main() -> None:
20
+ pg.setConfigOptions(imageAxisOrder="row-major")
21
+ app = QApplication(sys.argv)
22
+ win = MainWindow()
23
+ win.show()
24
+ sys.exit(app.exec())
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Tuple
5
+
6
+ import numpy as np
7
+
8
+
9
+ @dataclass
10
+ class ImageStack:
11
+ """Container for a 3D image stack with shape (n_images, ny, nx)."""
12
+
13
+ data: np.ndarray
14
+ name: str = "Untitled"
15
+ source_path: str | None = None
16
+
17
+ def __post_init__(self) -> None:
18
+ if self.data.ndim != 3:
19
+ raise ValueError("ImageStack.data must have shape (n_images, ny, nx)")
20
+ if not np.issubdtype(self.data.dtype, np.number):
21
+ raise TypeError("ImageStack.data must be numeric")
22
+
23
+ @property
24
+ def n_images(self) -> int:
25
+ return int(self.data.shape[0])
26
+
27
+ @property
28
+ def shape2d(self) -> Tuple[int, int]:
29
+ return int(self.data.shape[1]), int(self.data.shape[2])
30
+
31
+ @classmethod
32
+ def dummy(cls, n: int = 20, ny: int = 512, nx: int = 512) -> "ImageStack":
33
+ rng = np.random.default_rng(42)
34
+ x = np.linspace(-1, 1, nx)
35
+ y = np.linspace(-1, 1, ny)
36
+ xx, yy = np.meshgrid(x, y)
37
+
38
+ stack = []
39
+ for i in range(n):
40
+ shift_x = 0.15 * np.sin(i / 4)
41
+ shift_y = 0.10 * np.cos(i / 5)
42
+ img = np.exp(-(((xx - shift_x) * 2.5) ** 2 + ((yy - shift_y) * 2.5) ** 2))
43
+ img += 0.25 * np.exp(-(((xx + 0.35) * 7) ** 2 + ((yy - 0.2) * 7) ** 2))
44
+ img += 0.03 * rng.standard_normal((ny, nx))
45
+ stack.append(img.astype(np.float32))
46
+
47
+ return cls(np.stack(stack, axis=0), name="Dummy stack")
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable, Optional, Sequence, Tuple
4
+
5
+ import numpy as np
6
+ from scipy.ndimage import shift as nd_shift
7
+ from skimage.registration import phase_cross_correlation
8
+
9
+
10
+ ROI = Tuple[int, int, int, int]
11
+
12
+
13
+ def clip_roi_to_shape(roi: ROI, shape: Sequence[int]) -> ROI:
14
+ """Clip an ROI (x, y, w, h) to image bounds."""
15
+ x, y, w, h = roi
16
+ ny, nx = int(shape[0]), int(shape[1])
17
+
18
+ x0 = max(0, min(int(x), nx))
19
+ y0 = max(0, min(int(y), ny))
20
+ x1 = max(x0, min(int(x + w), nx))
21
+ y1 = max(y0, min(int(y + h), ny))
22
+
23
+ return x0, y0, x1 - x0, y1 - y0
24
+
25
+
26
+ def crop_to_roi(image: np.ndarray, roi: Optional[ROI]) -> np.ndarray:
27
+ if roi is None:
28
+ return image
29
+ x, y, w, h = clip_roi_to_shape(roi, image.shape)
30
+ if w <= 0 or h <= 0:
31
+ raise ValueError("ROI is empty after clipping to the image bounds")
32
+ return image[y:y + h, x:x + w]
33
+
34
+
35
+ def bin_image(image: np.ndarray, bin_y: int, bin_x: Optional[int] = None) -> np.ndarray:
36
+ if bin_x is None:
37
+ bin_x = bin_y
38
+ if bin_y <= 0 or bin_x <= 0:
39
+ raise ValueError("Binning factors must be positive integers")
40
+
41
+ ny, nx = image.shape
42
+ ny2 = (ny // bin_y) * bin_y
43
+ nx2 = (nx // bin_x) * bin_x
44
+ cropped = image[:ny2, :nx2]
45
+ return cropped.reshape(ny2 // bin_y, bin_y, nx2 // bin_x, bin_x).mean(axis=(1, 3))
46
+
47
+
48
+ def bin_stack(stack: np.ndarray, bin_y: int, bin_x: Optional[int] = None) -> np.ndarray:
49
+ return np.stack([bin_image(img, bin_y, bin_x) for img in stack], axis=0)
50
+
51
+
52
+ def estimate_shift_phase_correlation(
53
+ reference: np.ndarray,
54
+ moving: np.ndarray,
55
+ roi: Optional[ROI] = None,
56
+ upsample_factor: int = 10,
57
+ ) -> Tuple[Tuple[float, float], float, float]:
58
+ ref = crop_to_roi(reference, roi)
59
+ mov = crop_to_roi(moving, roi)
60
+
61
+ shift_yx, error, phasediff = phase_cross_correlation(
62
+ ref,
63
+ mov,
64
+ upsample_factor=max(1, int(upsample_factor)),
65
+ )
66
+
67
+ dy, dx = float(shift_yx[0]), float(shift_yx[1])
68
+ return (dy, dx), float(error), float(phasediff)
69
+
70
+
71
+ def apply_shift(
72
+ image: np.ndarray,
73
+ shift_yx: Tuple[float, float],
74
+ order: int = 1,
75
+ mode: str = "nearest",
76
+ cval: float = 0.0,
77
+ ) -> np.ndarray:
78
+ shifted = nd_shift(
79
+ image,
80
+ shift=shift_yx,
81
+ order=order,
82
+ mode=mode,
83
+ cval=cval,
84
+ prefilter=(order > 1),
85
+ )
86
+ return np.asarray(shifted, dtype=image.dtype)
87
+
88
+
89
+ def align_stack_to_reference(
90
+ stack: np.ndarray,
91
+ reference_index: int = 0,
92
+ roi: Optional[ROI] = None,
93
+ upsample_factor: int = 10,
94
+ interpolation_order: int = 1,
95
+ mode: str = "nearest",
96
+ cval: float = 0.0,
97
+ progress_callback: Optional[Callable[[int, int], None]] = None,
98
+ ) -> Tuple[np.ndarray, np.ndarray]:
99
+ """Align each image in a stack to a single reference image."""
100
+ if stack.ndim != 3:
101
+ raise ValueError("stack must have shape (n_images, ny, nx)")
102
+
103
+ n_images = stack.shape[0]
104
+ if not (0 <= reference_index < n_images):
105
+ raise ValueError("reference_index is out of range")
106
+
107
+ reference = stack[reference_index]
108
+ aligned = np.empty_like(stack)
109
+ shifts = np.zeros((n_images, 2), dtype=np.float64)
110
+
111
+ for i in range(n_images):
112
+ if i == reference_index:
113
+ aligned[i] = stack[i]
114
+ if progress_callback is not None:
115
+ progress_callback(i + 1, n_images)
116
+ continue
117
+
118
+ shift_yx, _, _ = estimate_shift_phase_correlation(
119
+ reference,
120
+ stack[i],
121
+ roi=roi,
122
+ upsample_factor=upsample_factor,
123
+ )
124
+ shifts[i] = shift_yx
125
+ aligned[i] = apply_shift(
126
+ stack[i],
127
+ shift_yx,
128
+ order=interpolation_order,
129
+ mode=mode,
130
+ cval=cval,
131
+ )
132
+
133
+ if progress_callback is not None:
134
+ progress_callback(i + 1, n_images)
135
+
136
+ return aligned, shifts
137
+
138
+
139
+ def average_stack(stack: np.ndarray) -> np.ndarray:
140
+ if stack.ndim != 3:
141
+ raise ValueError("stack must have shape (n_images, ny, nx)")
142
+ return np.mean(stack, axis=0)
143
+
144
+
145
+ def average_stack_in_roi(stack: np.ndarray, roi: ROI) -> np.ndarray:
146
+ cropped = crop_to_roi(stack[0], roi)
147
+ x, y, w, h = clip_roi_to_shape(roi, stack.shape[1:])
148
+ if cropped.size == 0:
149
+ raise ValueError("ROI is empty")
150
+ return np.mean(stack[:, y:y + h, x:x + w], axis=0)
@@ -0,0 +1,12 @@
1
+ from PyQt6.QtCore import QObject, pyqtSignal
2
+
3
+
4
+ class PEEMSignals(QObject):
5
+ mouse_moved = pyqtSignal(float, float, float) # x, y, value
6
+ left_clicked = pyqtSignal(float, float)
7
+ right_clicked = pyqtSignal(float, float)
8
+ double_clicked = pyqtSignal(float, float)
9
+ roi_changed = pyqtSignal(float, float, float, float) # x, y, w, h
10
+ crosshair_moved = pyqtSignal(float, float)
11
+ frame_changed = pyqtSignal(int)
12
+ stack_loaded = pyqtSignal(str, int, int, int) # name, n, ny, nx
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Tuple
4
+
5
+ import numpy as np
6
+ import pyqtgraph as pg
7
+ from PyQt6.QtCore import Qt
8
+
9
+
10
+ class PEEMImageView(pg.GraphicsLayoutWidget):
11
+ """Central image viewer with histogram, ROI and crosshair overlays."""
12
+
13
+ def __init__(self, signals, parent=None):
14
+ super().__init__(parent)
15
+ self.signals = signals
16
+
17
+ self.plot = self.addPlot()
18
+ self.plot.setAspectLocked(True)
19
+ self.plot.invertY(True)
20
+ self.plot.setLabel("left", "y")
21
+ self.plot.setLabel("bottom", "x")
22
+
23
+ self.image_item = pg.ImageItem()
24
+ self.plot.addItem(self.image_item)
25
+
26
+ self.hist = pg.HistogramLUTItem()
27
+ self.hist.setImageItem(self.image_item)
28
+ self.addItem(self.hist)
29
+
30
+ self.roi = pg.ROI([50, 50], [120, 120], movable=True, resizable=True, rotatable=False)
31
+ self.roi.addScaleHandle([1, 1], [0, 0])
32
+ self.roi.addScaleHandle([0, 0], [1, 1])
33
+ self.plot.addItem(self.roi)
34
+ self.roi.hide()
35
+ self.roi.sigRegionChanged.connect(self._emit_roi_changed)
36
+
37
+ self.vline = pg.InfiniteLine(angle=90, movable=True, pen=pg.mkPen(width=1))
38
+ self.hline = pg.InfiniteLine(angle=0, movable=True, pen=pg.mkPen(width=1))
39
+ self.plot.addItem(self.vline)
40
+ self.plot.addItem(self.hline)
41
+ self.vline.hide()
42
+ self.hline.hide()
43
+ self.vline.sigPositionChanged.connect(self._crosshair_changed)
44
+ self.hline.sigPositionChanged.connect(self._crosshair_changed)
45
+
46
+ self.proxy = pg.SignalProxy(
47
+ self.plot.scene().sigMouseMoved,
48
+ rateLimit=60,
49
+ slot=self._mouse_moved,
50
+ )
51
+ self.plot.scene().sigMouseClicked.connect(self._mouse_clicked)
52
+
53
+ self.current_image: np.ndarray | None = None
54
+
55
+ def set_image(self, image: np.ndarray) -> None:
56
+ self.current_image = image
57
+ self.image_item.setImage(image, autoLevels=True)
58
+
59
+ def show_roi(self, visible: bool) -> None:
60
+ self.roi.setVisible(visible)
61
+ if visible:
62
+ self._emit_roi_changed()
63
+
64
+ def show_crosshair(self, visible: bool) -> None:
65
+ self.vline.setVisible(visible)
66
+ self.hline.setVisible(visible)
67
+ if visible and self.current_image is not None:
68
+ ny, nx = self.current_image.shape
69
+ self.vline.setPos(nx / 2)
70
+ self.hline.setPos(ny / 2)
71
+ self._crosshair_changed()
72
+
73
+ def set_roi(self, x: int, y: int, w: int, h: int) -> None:
74
+ self.roi.setPos([x, y])
75
+ self.roi.setSize([w, h])
76
+ self._emit_roi_changed()
77
+
78
+ def get_roi_bounds(self) -> Tuple[int, int, int, int]:
79
+ pos = self.roi.pos()
80
+ size = self.roi.size()
81
+ return int(pos.x()), int(pos.y()), int(size.x()), int(size.y())
82
+
83
+ def _emit_roi_changed(self) -> None:
84
+ x, y, w, h = self.get_roi_bounds()
85
+ self.signals.roi_changed.emit(x, y, w, h)
86
+
87
+ def _crosshair_changed(self) -> None:
88
+ self.signals.crosshair_moved.emit(float(self.vline.value()), float(self.hline.value()))
89
+
90
+ def _mouse_moved(self, evt) -> None:
91
+ pos = evt[0]
92
+ vb = self.plot.vb
93
+ mouse_point = vb.mapSceneToView(pos)
94
+ x = mouse_point.x()
95
+ y = mouse_point.y()
96
+ value = np.nan
97
+
98
+ if self.current_image is not None:
99
+ iy = int(round(y))
100
+ ix = int(round(x))
101
+ if 0 <= iy < self.current_image.shape[0] and 0 <= ix < self.current_image.shape[1]:
102
+ value = float(self.current_image[iy, ix])
103
+
104
+ self.signals.mouse_moved.emit(float(x), float(y), value)
105
+
106
+ def _mouse_clicked(self, event) -> None:
107
+ pos = self.plot.vb.mapSceneToView(event.scenePos())
108
+ x, y = float(pos.x()), float(pos.y())
109
+
110
+ if event.double():
111
+ self.signals.double_clicked.emit(x, y)
112
+ elif event.button() == Qt.MouseButton.LeftButton:
113
+ self.signals.left_clicked.emit(x, y)
114
+ elif event.button() == Qt.MouseButton.RightButton:
115
+ self.signals.right_clicked.emit(x, y)
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import numpy as np
6
+ from PyQt6.QtCore import QObject, QThread, pyqtSignal
7
+
8
+ from pyAthina.processing import ROI, align_stack_to_reference
9
+
10
+
11
+ class AlignmentWorker(QObject):
12
+ """Run drift correction in a worker thread to keep the UI responsive."""
13
+
14
+ progress = pyqtSignal(int, int)
15
+ finished = pyqtSignal(object, object) # aligned_stack, shifts
16
+ failed = pyqtSignal(str)
17
+
18
+ def __init__(
19
+ self,
20
+ stack: np.ndarray,
21
+ reference_index: int,
22
+ roi: Optional[ROI],
23
+ upsample_factor: int,
24
+ interpolation_order: int = 1,
25
+ mode: str = "nearest",
26
+ cval: float = 0.0,
27
+ ) -> None:
28
+ super().__init__()
29
+ self.stack = stack
30
+ self.reference_index = reference_index
31
+ self.roi = roi
32
+ self.upsample_factor = upsample_factor
33
+ self.interpolation_order = interpolation_order
34
+ self.mode = mode
35
+ self.cval = cval
36
+
37
+ def run(self) -> None:
38
+ try:
39
+ aligned, shifts = align_stack_to_reference(
40
+ self.stack,
41
+ reference_index=self.reference_index,
42
+ roi=self.roi,
43
+ upsample_factor=self.upsample_factor,
44
+ interpolation_order=self.interpolation_order,
45
+ mode=self.mode,
46
+ cval=self.cval,
47
+ progress_callback=self._emit_progress,
48
+ )
49
+ except Exception as exc:
50
+ self.failed.emit(str(exc))
51
+ return
52
+
53
+ self.finished.emit(aligned, shifts)
54
+
55
+ def _emit_progress(self, done: int, total: int) -> None:
56
+ self.progress.emit(done, total)
57
+
58
+
59
+ def create_alignment_thread(worker: AlignmentWorker) -> QThread:
60
+ thread = QThread()
61
+ worker.moveToThread(thread)
62
+ thread.started.connect(worker.run)
63
+ return thread
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyAthina
3
+ Version: 0.0.1
4
+ Summary: A photoelectron miroscpectroscopy/spectromicroscopy data analyis package
5
+ Author-email: Evangelos Golias <evangelos.golias@gmail.com>
6
+ Project-URL: Homepage, https://gitlab.com/evangelosgolias/athina
7
+ Project-URL: Issues, https://gitlab.com/evangelosgolias/athina/-/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # pyAthina
17
+
18
+ A lightweight PyQt6 + pyqtgraph skeleton for PEEM-style image-stack workflows.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install -r requirements.txt
24
+ ```
25
+
26
+ ## Run
27
+
28
+ Any of these should work from the extracted project folder:
29
+
30
+ ```bash
31
+ python -m pyAthina.main
32
+ ```
33
+
34
+ ```bash
35
+ python -m pyAthina
36
+ ```
37
+
38
+ ```bash
39
+ python run_pyAthina.py
40
+ ```
41
+
42
+ If you `cd` into the `pyAthina/` subfolder itself, you can also run:
43
+
44
+ ```bash
45
+ python main.py
46
+ ```
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/run_pyAthina.py
5
+ src/pyAthina/__init__.py
6
+ src/pyAthina/__main__.py
7
+ src/pyAthina/app.py
8
+ src/pyAthina/io.py
9
+ src/pyAthina/main.py
10
+ src/pyAthina/models.py
11
+ src/pyAthina/processing.py
12
+ src/pyAthina/signals.py
13
+ src/pyAthina/viewer.py
14
+ src/pyAthina/workers.py
15
+ src/pyAthina.egg-info/PKG-INFO
16
+ src/pyAthina.egg-info/SOURCES.txt
17
+ src/pyAthina.egg-info/dependency_links.txt
18
+ src/pyAthina.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ pyAthina
2
+ run_pyAthina
@@ -0,0 +1,4 @@
1
+ from pyAthina.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()