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 +24 -0
- pyathina-0.0.1/PKG-INFO +46 -0
- pyathina-0.0.1/README.md +31 -0
- pyathina-0.0.1/pyproject.toml +21 -0
- pyathina-0.0.1/setup.cfg +4 -0
- pyathina-0.0.1/src/pyAthina/__init__.py +10 -0
- pyathina-0.0.1/src/pyAthina/__main__.py +4 -0
- pyathina-0.0.1/src/pyAthina/app.py +453 -0
- pyathina-0.0.1/src/pyAthina/io.py +88 -0
- pyathina-0.0.1/src/pyAthina/main.py +28 -0
- pyathina-0.0.1/src/pyAthina/models.py +47 -0
- pyathina-0.0.1/src/pyAthina/processing.py +150 -0
- pyathina-0.0.1/src/pyAthina/signals.py +12 -0
- pyathina-0.0.1/src/pyAthina/viewer.py +115 -0
- pyathina-0.0.1/src/pyAthina/workers.py +63 -0
- pyathina-0.0.1/src/pyAthina.egg-info/PKG-INFO +46 -0
- pyathina-0.0.1/src/pyAthina.egg-info/SOURCES.txt +18 -0
- pyathina-0.0.1/src/pyAthina.egg-info/dependency_links.txt +1 -0
- pyathina-0.0.1/src/pyAthina.egg-info/top_level.txt +2 -0
- pyathina-0.0.1/src/run_pyAthina.py +4 -0
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
|
+
-------------------------------------------------------
|
pyathina-0.0.1/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
pyathina-0.0.1/README.md
ADDED
|
@@ -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"
|
pyathina-0.0.1/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|