phasor-handler 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phasor_handler/__init__.py +9 -0
- phasor_handler/app.py +249 -0
- phasor_handler/img/icons/chevron-down.svg +3 -0
- phasor_handler/img/icons/chevron-up.svg +3 -0
- phasor_handler/img/logo.ico +0 -0
- phasor_handler/models/dir_manager.py +100 -0
- phasor_handler/scripts/contrast.py +131 -0
- phasor_handler/scripts/convert.py +155 -0
- phasor_handler/scripts/meta_reader.py +467 -0
- phasor_handler/scripts/plot.py +110 -0
- phasor_handler/scripts/register.py +86 -0
- phasor_handler/themes/__init__.py +8 -0
- phasor_handler/themes/dark_theme.py +330 -0
- phasor_handler/tools/__init__.py +1 -0
- phasor_handler/tools/check_stylesheet.py +15 -0
- phasor_handler/tools/misc.py +20 -0
- phasor_handler/widgets/__init__.py +5 -0
- phasor_handler/widgets/analysis/components/__init__.py +9 -0
- phasor_handler/widgets/analysis/components/bnc.py +426 -0
- phasor_handler/widgets/analysis/components/circle_roi.py +850 -0
- phasor_handler/widgets/analysis/components/image_view.py +667 -0
- phasor_handler/widgets/analysis/components/meta_info.py +481 -0
- phasor_handler/widgets/analysis/components/roi_list.py +659 -0
- phasor_handler/widgets/analysis/components/trace_plot.py +621 -0
- phasor_handler/widgets/analysis/view.py +1735 -0
- phasor_handler/widgets/conversion/view.py +83 -0
- phasor_handler/widgets/registration/view.py +110 -0
- phasor_handler/workers/__init__.py +2 -0
- phasor_handler/workers/analysis_worker.py +0 -0
- phasor_handler/workers/histogram_worker.py +55 -0
- phasor_handler/workers/registration_worker.py +242 -0
- phasor_handler-2.2.0.dist-info/METADATA +134 -0
- phasor_handler-2.2.0.dist-info/RECORD +37 -0
- phasor_handler-2.2.0.dist-info/WHEEL +5 -0
- phasor_handler-2.2.0.dist-info/entry_points.txt +5 -0
- phasor_handler-2.2.0.dist-info/licenses/LICENSE.md +21 -0
- phasor_handler-2.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Phasor Handler - Two-Photon Phasor Imaging Data Processor
|
|
3
|
+
|
|
4
|
+
A PyQt6 GUI toolbox for processing two-photon phasor imaging data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "2.2.0"
|
|
8
|
+
__author__ = "Josia Shemuel"
|
|
9
|
+
__all__ = ["app", "widgets", "workers", "models", "scripts", "tools", "themes"]
|
phasor_handler/app.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import subprocess
|
|
3
|
+
from PyQt6.QtWidgets import (
|
|
4
|
+
QApplication, QMainWindow, QPushButton, QFileDialog, QMessageBox,
|
|
5
|
+
QListView, QTreeView, QAbstractItemView, QTabWidget
|
|
6
|
+
)
|
|
7
|
+
from PyQt6.QtGui import QFileSystemModel, QIcon
|
|
8
|
+
from PyQt6.QtCore import Qt
|
|
9
|
+
from PyQt6.QtCore import QThread
|
|
10
|
+
|
|
11
|
+
from .widgets import ConversionWidget, RegistrationWidget, AnalysisWidget
|
|
12
|
+
from .tools import misc
|
|
13
|
+
from .workers import RegistrationWorker
|
|
14
|
+
from .models.dir_manager import DirManager
|
|
15
|
+
from .themes import apply_dark_theme
|
|
16
|
+
import qdarktheme
|
|
17
|
+
|
|
18
|
+
class MainWindow(QMainWindow):
|
|
19
|
+
def __init__(self):
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.setWindowTitle("Phasor Handler v2.0")
|
|
22
|
+
self.setWindowIcon(QIcon('img/logo.ico'))
|
|
23
|
+
# self.setMinimumSize(1400, 1000)
|
|
24
|
+
# central directory manager (shared with widgets)
|
|
25
|
+
self.dir_manager = DirManager()
|
|
26
|
+
# expose legacy attribute for compatibility
|
|
27
|
+
self.selected_dirs = self.dir_manager.list()
|
|
28
|
+
# keep the local list synced when dir_manager changes
|
|
29
|
+
self.dir_manager.directoriesChanged.connect(lambda lst: setattr(self, 'selected_dirs', list(lst)))
|
|
30
|
+
|
|
31
|
+
# Create tab widget and add sub-widgets
|
|
32
|
+
self.tabs = QTabWidget()
|
|
33
|
+
self.tabs.addTab(ConversionWidget(self), "Conversion")
|
|
34
|
+
self.tabs.addTab(RegistrationWidget(self), "Registration")
|
|
35
|
+
# AnalysisWidget exposes compatible attributes on the main window
|
|
36
|
+
self.tabs.addTab(AnalysisWidget(self), "Analysis")
|
|
37
|
+
self.tabs.currentChanged.connect(self.on_tab_changed)
|
|
38
|
+
self.setCentralWidget(self.tabs)
|
|
39
|
+
|
|
40
|
+
def _init_roi_state(self):
|
|
41
|
+
"""Initialize ROI and CNB (contrast/brightness) state on the window instance."""
|
|
42
|
+
self._roi_center = None
|
|
43
|
+
self._roi_radius = None
|
|
44
|
+
self._roi_overlay_pixmap = None
|
|
45
|
+
|
|
46
|
+
# CNB (contrast & brightness) state
|
|
47
|
+
self._current_qimage = None
|
|
48
|
+
self._current_image_np = None
|
|
49
|
+
self._cnb_window = None
|
|
50
|
+
|
|
51
|
+
# ImageJ-like: min/max intensity (applied before contrast)
|
|
52
|
+
self._cnb_min = None
|
|
53
|
+
self._cnb_max = None
|
|
54
|
+
|
|
55
|
+
# contrast multiplier around midpoint (1.0 = no change)
|
|
56
|
+
self._cnb_contrast = 1.0
|
|
57
|
+
|
|
58
|
+
# Master switch to disable/enable CNB functionality (default: disabled)
|
|
59
|
+
self._cnb_active = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def add_dirs_dialog(self, tab):
|
|
63
|
+
dialog = QFileDialog(self)
|
|
64
|
+
dialog.setWindowTitle('Select One or More Directories')
|
|
65
|
+
dialog.setFileMode(QFileDialog.FileMode.Directory)
|
|
66
|
+
dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
|
|
67
|
+
for view in dialog.findChildren((QListView, QTreeView)):
|
|
68
|
+
if isinstance(view.model(), QFileSystemModel):
|
|
69
|
+
view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
70
|
+
if dialog.exec():
|
|
71
|
+
selected_paths = dialog.selectedFiles()
|
|
72
|
+
# delegate to the manager
|
|
73
|
+
self.dir_manager.add(selected_paths)
|
|
74
|
+
# refresh views (manager emits signal which can call refresh_dir_lists)
|
|
75
|
+
self.refresh_dir_lists()
|
|
76
|
+
dialog.deleteLater()
|
|
77
|
+
|
|
78
|
+
def remove_selected_dirs(self, tab):
|
|
79
|
+
widget = self.conv_list_widget if tab == 'conversion' else self.reg_list_widget if tab == "registration" else self.analysis_list_widget
|
|
80
|
+
selected_items = widget.selectedItems()
|
|
81
|
+
if not selected_items:
|
|
82
|
+
return
|
|
83
|
+
to_remove = []
|
|
84
|
+
for item in selected_items:
|
|
85
|
+
# Get the full path from UserRole, fallback to text for compatibility
|
|
86
|
+
full_path = item.data(Qt.ItemDataRole.UserRole)
|
|
87
|
+
if full_path is None:
|
|
88
|
+
full_path = item.text()
|
|
89
|
+
to_remove.append(full_path)
|
|
90
|
+
# update model; UI will refresh on signal
|
|
91
|
+
self.dir_manager.remove(to_remove)
|
|
92
|
+
self.refresh_dir_lists()
|
|
93
|
+
|
|
94
|
+
def refresh_dir_lists(self):
|
|
95
|
+
# Clear only if the widgets exist and are valid
|
|
96
|
+
if hasattr(self, 'conv_list_widget'):
|
|
97
|
+
self.conv_list_widget.clear()
|
|
98
|
+
if hasattr(self, 'reg_list_widget'):
|
|
99
|
+
try:
|
|
100
|
+
self.reg_list_widget.clear()
|
|
101
|
+
except RuntimeError:
|
|
102
|
+
# Widget was deleted; recreate the Registration tab if needed
|
|
103
|
+
pass
|
|
104
|
+
if hasattr(self, 'analysis_list_widget'):
|
|
105
|
+
self.analysis_list_widget.clear()
|
|
106
|
+
|
|
107
|
+
from PyQt6.QtWidgets import QListWidgetItem
|
|
108
|
+
for full_path, display_name in self.dir_manager.get_display_names():
|
|
109
|
+
if hasattr(self, 'conv_list_widget'):
|
|
110
|
+
item = QListWidgetItem(display_name)
|
|
111
|
+
item.setToolTip(full_path)
|
|
112
|
+
item.setData(Qt.ItemDataRole.UserRole, full_path)
|
|
113
|
+
self.conv_list_widget.addItem(item)
|
|
114
|
+
if hasattr(self, 'reg_list_widget'):
|
|
115
|
+
try:
|
|
116
|
+
item = QListWidgetItem(display_name)
|
|
117
|
+
item.setToolTip(full_path)
|
|
118
|
+
item.setData(Qt.ItemDataRole.UserRole, full_path)
|
|
119
|
+
self.reg_list_widget.addItem(item)
|
|
120
|
+
except RuntimeError:
|
|
121
|
+
pass
|
|
122
|
+
if hasattr(self, 'analysis_list_widget'):
|
|
123
|
+
item = QListWidgetItem(display_name)
|
|
124
|
+
item.setToolTip(full_path)
|
|
125
|
+
item.setData(Qt.ItemDataRole.UserRole, full_path)
|
|
126
|
+
self.analysis_list_widget.addItem(item)
|
|
127
|
+
|
|
128
|
+
def on_tab_changed(self, idx):
|
|
129
|
+
# When switching to analysis tab, refresh its directory list
|
|
130
|
+
tab_text = self.tabs.tabText(idx)
|
|
131
|
+
if tab_text == "Analysis":
|
|
132
|
+
if hasattr(self, 'analysis_list_widget'):
|
|
133
|
+
self.analysis_list_widget.clear()
|
|
134
|
+
from PyQt6.QtWidgets import QListWidgetItem
|
|
135
|
+
for full_path, display_name in self.dir_manager.get_display_names():
|
|
136
|
+
item = QListWidgetItem(display_name)
|
|
137
|
+
item.setToolTip(full_path)
|
|
138
|
+
item.setData(Qt.ItemDataRole.UserRole, full_path)
|
|
139
|
+
self.analysis_list_widget.addItem(item)
|
|
140
|
+
|
|
141
|
+
def run_conversion_script(self):
|
|
142
|
+
if not self.selected_dirs:
|
|
143
|
+
QMessageBox.warning(self, "No Directories", "Please add at least one directory to the list before running.")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
mode = self.mode_combo.currentText().lower()
|
|
147
|
+
self.conv_log.clear()
|
|
148
|
+
self.conv_log.append(f"--- Starting Batch Conversion in '{mode}' mode ---\n")
|
|
149
|
+
for i, conv_dir in enumerate(self.selected_dirs):
|
|
150
|
+
self.conv_log.append(f"Processing ({i+1}/{len(self.selected_dirs)}): {conv_dir}")
|
|
151
|
+
# 1. Run convert.py
|
|
152
|
+
cmd = [sys.executable, "scripts/convert.py", str(conv_dir), "--mode", mode]
|
|
153
|
+
try:
|
|
154
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0)
|
|
155
|
+
for line in proc.stdout:
|
|
156
|
+
self.conv_log.append(line.rstrip())
|
|
157
|
+
QApplication.processEvents()
|
|
158
|
+
retcode = proc.wait()
|
|
159
|
+
if retcode != 0:
|
|
160
|
+
self.conv_log.append(f"FAILED to convert: {conv_dir}\n")
|
|
161
|
+
else:
|
|
162
|
+
self.conv_log.append("--- Conversion done ---\n")
|
|
163
|
+
except Exception as e:
|
|
164
|
+
self.conv_log.append(f"FAILED to convert: {conv_dir} (Error: {e})\n")
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# 2. Run meta_reader.py
|
|
168
|
+
meta_cmd = [sys.executable, "scripts/meta_reader.py", "-f", str(conv_dir)]
|
|
169
|
+
self.conv_log.append(f"\n[meta_reader] Reading metadata for: {conv_dir}")
|
|
170
|
+
try:
|
|
171
|
+
meta_proc = subprocess.Popen(meta_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0)
|
|
172
|
+
for line in meta_proc.stdout:
|
|
173
|
+
self.conv_log.append(line.rstrip())
|
|
174
|
+
QApplication.processEvents()
|
|
175
|
+
meta_retcode = meta_proc.wait()
|
|
176
|
+
if meta_retcode != 0:
|
|
177
|
+
self.conv_log.append(f"FAILED to read metadata: {conv_dir}\n")
|
|
178
|
+
else:
|
|
179
|
+
self.conv_log.append("--- Metadata read done ---\n")
|
|
180
|
+
except Exception as e:
|
|
181
|
+
self.conv_log.append(f"FAILED to read metadata: {conv_dir} (Error: {e})\n")
|
|
182
|
+
continue
|
|
183
|
+
self.conv_log.append("--- Batch Conversion Finished ---")
|
|
184
|
+
|
|
185
|
+
def run_registration_script(self):
|
|
186
|
+
# Gather inputs and start a background worker so the GUI doesn't block
|
|
187
|
+
selected_dirs = []
|
|
188
|
+
for i in range(self.reg_list_widget.count()):
|
|
189
|
+
item = self.reg_list_widget.item(i)
|
|
190
|
+
if item is not None:
|
|
191
|
+
# Get full path from UserRole, fallback to text for compatibility
|
|
192
|
+
full_path = item.data(Qt.ItemDataRole.UserRole)
|
|
193
|
+
if full_path is None:
|
|
194
|
+
full_path = item.text()
|
|
195
|
+
selected_dirs.append(full_path)
|
|
196
|
+
if not selected_dirs:
|
|
197
|
+
QMessageBox.warning(self, "No Directories", "Please add at least one directory to the list before running registration.")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
params = {}
|
|
201
|
+
for name, edit in zip(self.param_names, self.param_edits):
|
|
202
|
+
value = edit.text().strip()
|
|
203
|
+
if value:
|
|
204
|
+
params[name] = value
|
|
205
|
+
|
|
206
|
+
# Disable UI controls while running
|
|
207
|
+
# Find the run button by text (safe because we created it nearby)
|
|
208
|
+
run_btn = None
|
|
209
|
+
for w in self.findChildren(QPushButton):
|
|
210
|
+
if w.text().startswith("Run Registration"):
|
|
211
|
+
run_btn = w
|
|
212
|
+
break
|
|
213
|
+
if run_btn:
|
|
214
|
+
run_btn.setEnabled(False)
|
|
215
|
+
|
|
216
|
+
self.reg_log.clear()
|
|
217
|
+
|
|
218
|
+
# Create thread and worker
|
|
219
|
+
self._reg_thread = QThread()
|
|
220
|
+
self._reg_worker = RegistrationWorker(selected_dirs, params, self.combine_checkbox.isChecked())
|
|
221
|
+
self._reg_worker.moveToThread(self._reg_thread)
|
|
222
|
+
# Connect signals
|
|
223
|
+
self._reg_thread.started.connect(self._reg_worker.run)
|
|
224
|
+
self._reg_worker.log.connect(lambda s: (self.reg_log.append(s), QApplication.processEvents()))
|
|
225
|
+
def _on_finished():
|
|
226
|
+
if run_btn:
|
|
227
|
+
run_btn.setEnabled(True)
|
|
228
|
+
self._reg_thread.quit()
|
|
229
|
+
self._reg_thread.wait()
|
|
230
|
+
self._reg_worker.deleteLater()
|
|
231
|
+
del self._reg_worker
|
|
232
|
+
del self._reg_thread
|
|
233
|
+
|
|
234
|
+
self._reg_worker.finished.connect(_on_finished)
|
|
235
|
+
self._reg_worker.error.connect(lambda e: self.reg_log.append(f"ERROR: {e}"))
|
|
236
|
+
self._reg_thread.start()
|
|
237
|
+
|
|
238
|
+
def main():
|
|
239
|
+
app = QApplication(sys.argv)
|
|
240
|
+
try:
|
|
241
|
+
qdarktheme.setup_theme("auto") # or your own apply_dark_theme()
|
|
242
|
+
except Exception:
|
|
243
|
+
pass # fall back to default if theme package missing
|
|
244
|
+
window = MainWindow()
|
|
245
|
+
window.showMaximized()
|
|
246
|
+
sys.exit(app.exec())
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
main()
|
|
Binary file
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from PyQt6.QtCore import QObject, pyqtSignal
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DirManager(QObject):
|
|
6
|
+
"""Manage a shared list of directories and notify listeners on changes.
|
|
7
|
+
|
|
8
|
+
This keeps directory logic separate from GUI widgets. Listeners should
|
|
9
|
+
connect to `directoriesChanged` and call `list()` to get the current value.
|
|
10
|
+
"""
|
|
11
|
+
directoriesChanged = pyqtSignal(list)
|
|
12
|
+
|
|
13
|
+
def __init__(self, dirs=None, parent=None):
|
|
14
|
+
super().__init__(parent)
|
|
15
|
+
self._dirs = list(dirs) if dirs else []
|
|
16
|
+
|
|
17
|
+
def add(self, paths):
|
|
18
|
+
changed = False
|
|
19
|
+
for p in paths:
|
|
20
|
+
if p not in self._dirs:
|
|
21
|
+
self._dirs.append(p)
|
|
22
|
+
changed = True
|
|
23
|
+
if changed:
|
|
24
|
+
self.directoriesChanged.emit(self.list())
|
|
25
|
+
|
|
26
|
+
def remove(self, paths):
|
|
27
|
+
changed = False
|
|
28
|
+
for p in paths:
|
|
29
|
+
if p in self._dirs:
|
|
30
|
+
self._dirs.remove(p)
|
|
31
|
+
changed = True
|
|
32
|
+
if changed:
|
|
33
|
+
self.directoriesChanged.emit(self.list())
|
|
34
|
+
|
|
35
|
+
def clear(self):
|
|
36
|
+
if self._dirs:
|
|
37
|
+
self._dirs = []
|
|
38
|
+
self.directoriesChanged.emit([])
|
|
39
|
+
|
|
40
|
+
def list(self):
|
|
41
|
+
return list(self._dirs)
|
|
42
|
+
|
|
43
|
+
def get_display_names(self):
|
|
44
|
+
"""Return a list of (full_path, display_name) tuples.
|
|
45
|
+
|
|
46
|
+
Display names show just the folder stem unless there are duplicates,
|
|
47
|
+
in which case enough parent folders are included to distinguish them.
|
|
48
|
+
"""
|
|
49
|
+
if not self._dirs:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
# Convert paths to Path objects for easier manipulation
|
|
53
|
+
paths = [Path(d) for d in self._dirs]
|
|
54
|
+
|
|
55
|
+
# Start with just stems
|
|
56
|
+
display_map = {}
|
|
57
|
+
for i, p in enumerate(paths):
|
|
58
|
+
stem = p.name
|
|
59
|
+
if stem not in display_map:
|
|
60
|
+
display_map[stem] = []
|
|
61
|
+
display_map[stem].append((i, p))
|
|
62
|
+
|
|
63
|
+
# For duplicates, add parent folders until unique
|
|
64
|
+
result = {}
|
|
65
|
+
for stem, path_list in display_map.items():
|
|
66
|
+
if len(path_list) == 1:
|
|
67
|
+
# No duplicates, use just the stem
|
|
68
|
+
idx, path = path_list[0]
|
|
69
|
+
result[idx] = stem
|
|
70
|
+
else:
|
|
71
|
+
# Duplicates found, need to differentiate
|
|
72
|
+
# Start with 2 levels (parent/stem) and increase until unique
|
|
73
|
+
max_depth = max(len(p.parts) for _, p in path_list)
|
|
74
|
+
|
|
75
|
+
for depth in range(2, max_depth + 1):
|
|
76
|
+
temp_names = {}
|
|
77
|
+
all_unique = True
|
|
78
|
+
|
|
79
|
+
for idx, path in path_list:
|
|
80
|
+
# Get the last 'depth' parts of the path
|
|
81
|
+
parts = path.parts[-depth:] if len(path.parts) >= depth else path.parts
|
|
82
|
+
display_name = str(Path(*parts))
|
|
83
|
+
|
|
84
|
+
if display_name in temp_names:
|
|
85
|
+
all_unique = False
|
|
86
|
+
break
|
|
87
|
+
temp_names[display_name] = idx
|
|
88
|
+
|
|
89
|
+
if all_unique:
|
|
90
|
+
# Found unique names at this depth
|
|
91
|
+
for display_name, idx in temp_names.items():
|
|
92
|
+
result[idx] = display_name
|
|
93
|
+
break
|
|
94
|
+
else:
|
|
95
|
+
# If we still have duplicates after max depth, use full paths
|
|
96
|
+
for idx, path in path_list:
|
|
97
|
+
result[idx] = str(path)
|
|
98
|
+
|
|
99
|
+
# Return in original order
|
|
100
|
+
return [(self._dirs[i], result[i]) for i in range(len(self._dirs))]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Contrast and brightness helpers (ImageJ-style).
|
|
2
|
+
|
|
3
|
+
Public functions:
|
|
4
|
+
- ij_auto_contrast(img, saturated=0.35) -> np.ndarray (float32 0..1)
|
|
5
|
+
- compute_cnb_min_max(img) -> (min, max)
|
|
6
|
+
- apply_cnb_to_uint8(img, lo, hi, contrast=1.0) -> np.ndarray (uint8)
|
|
7
|
+
- qimage_from_uint8(img_u8) -> QImage
|
|
8
|
+
|
|
9
|
+
Notes:
|
|
10
|
+
- ImageJ’s “Auto” = clip ~saturated% at each tail, then linear map to [0..1].
|
|
11
|
+
- For composites: call ij_auto_contrast() per channel, then compose RGB yourself.
|
|
12
|
+
"""
|
|
13
|
+
from typing import Tuple
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _finite(a: np.ndarray) -> np.ndarray:
|
|
18
|
+
"""Return a flattened view of finite values only (ignore NaN/inf)."""
|
|
19
|
+
a = np.asarray(a)
|
|
20
|
+
if not np.issubdtype(a.dtype, np.number):
|
|
21
|
+
a = a.astype(np.float32, copy=False)
|
|
22
|
+
a = a.ravel()
|
|
23
|
+
if np.issubdtype(a.dtype, np.floating):
|
|
24
|
+
a = a[np.isfinite(a)]
|
|
25
|
+
return a
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ij_auto_contrast(img, saturated: float = 0.35) -> np.ndarray:
|
|
29
|
+
"""ImageJ-like auto contrast for a SINGLE-channel image.
|
|
30
|
+
Returns a float32 array scaled to 0..1.
|
|
31
|
+
|
|
32
|
+
Implementation detail:
|
|
33
|
+
- Uses percentiles (saturated% low, saturated% high) to set lo/hi.
|
|
34
|
+
- Works for 8/16-bit integers and floats (NaN/inf ignored).
|
|
35
|
+
"""
|
|
36
|
+
a = np.asarray(img)
|
|
37
|
+
if a.size == 0:
|
|
38
|
+
return a.astype(np.float32)
|
|
39
|
+
|
|
40
|
+
vals = _finite(a)
|
|
41
|
+
if vals.size == 0:
|
|
42
|
+
return np.zeros_like(a, dtype=np.float32)
|
|
43
|
+
|
|
44
|
+
# Symmetric tail clip (ImageJ “Auto” default ≈ 0.35% each tail)
|
|
45
|
+
low_p = float(saturated)
|
|
46
|
+
high_p = 100.0 - float(saturated)
|
|
47
|
+
|
|
48
|
+
lo, hi = np.percentile(vals, [low_p, high_p])
|
|
49
|
+
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
|
|
50
|
+
# Degenerate data (flat image, NaNs, etc.)
|
|
51
|
+
out = np.zeros_like(a, dtype=np.float32)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
out = (a.astype(np.float32) - float(lo)) / (float(hi) - float(lo))
|
|
55
|
+
np.clip(out, 0.0, 1.0, out=out)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def compute_cnb_min_max(img: np.ndarray) -> Tuple[float, float]:
|
|
60
|
+
"""Compute min/max display window from data (handles grayscale or RGB).
|
|
61
|
+
For RGB, uses luminance (like a typical preview), but in ImageJ you’d
|
|
62
|
+
normally calculate per channel. Use this mainly for a default guess.
|
|
63
|
+
"""
|
|
64
|
+
if img is None:
|
|
65
|
+
return 0.0, 255.0
|
|
66
|
+
a = np.asarray(img)
|
|
67
|
+
if a.size == 0:
|
|
68
|
+
return 0.0, 0.0
|
|
69
|
+
|
|
70
|
+
if a.ndim == 3 and a.shape[2] >= 3:
|
|
71
|
+
# Perceived luminance (ITU-R BT.601)
|
|
72
|
+
lum = (0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]).ravel()
|
|
73
|
+
vals = _finite(lum)
|
|
74
|
+
else:
|
|
75
|
+
vals = _finite(a)
|
|
76
|
+
|
|
77
|
+
if vals.size == 0:
|
|
78
|
+
return 0.0, 0.0
|
|
79
|
+
|
|
80
|
+
return float(vals.min()), float(vals.max())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def apply_cnb_to_uint8(img: np.ndarray, lo: float, hi: float, contrast: float = 1.0) -> np.ndarray:
|
|
84
|
+
"""Apply min/max (window) mapping and contrast around mid-gray; return uint8.
|
|
85
|
+
|
|
86
|
+
- img: input array (H,W) or (H,W,C). For ImageJ behavior, pass a SINGLE channel
|
|
87
|
+
(e.g., green or red) and compose RGB yourself afterwards.
|
|
88
|
+
- lo, hi: display window [lo..hi] in source units (like ImageJ’s min/max).
|
|
89
|
+
- contrast: multiplier around 0.5 after normalization (1.0 = no change).
|
|
90
|
+
|
|
91
|
+
Returns uint8 of same shape (alpha dropped if present).
|
|
92
|
+
"""
|
|
93
|
+
a = np.asarray(img).astype(np.float32, copy=False)
|
|
94
|
+
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
|
|
95
|
+
# fallback to trivial map
|
|
96
|
+
if a.size == 0:
|
|
97
|
+
return np.zeros_like(a, dtype=np.uint8)
|
|
98
|
+
vmin, vmax = float(np.nanmin(a)), float(np.nanmax(a))
|
|
99
|
+
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmax <= vmin:
|
|
100
|
+
out = np.zeros_like(a, dtype=np.uint8)
|
|
101
|
+
return out
|
|
102
|
+
lo, hi = vmin, vmax
|
|
103
|
+
|
|
104
|
+
# Window/level mapping
|
|
105
|
+
out = (a - float(lo)) / (float(hi) - float(lo))
|
|
106
|
+
# Contrast around midpoint (display-domain, same feel as IJ slider)
|
|
107
|
+
out = 0.5 + (out - 0.5) * float(contrast)
|
|
108
|
+
np.clip(out, 0.0, 1.0, out=out)
|
|
109
|
+
|
|
110
|
+
img_u8 = (out * 255.0).astype(np.uint8)
|
|
111
|
+
if img_u8.ndim == 3 and img_u8.shape[2] >= 4:
|
|
112
|
+
img_u8 = img_u8[..., :3] # drop alpha if any
|
|
113
|
+
return img_u8
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def qimage_from_uint8(img_u8):
|
|
117
|
+
"""Build a PyQt6 QImage from a uint8 numpy array without channel swapping.
|
|
118
|
+
Accepts 2D (grayscale) or 3D (RGB) arrays. Caller owns array lifetime while QImage lives.
|
|
119
|
+
"""
|
|
120
|
+
from PyQt6.QtGui import QImage
|
|
121
|
+
|
|
122
|
+
img_u8 = np.ascontiguousarray(img_u8) # ensure row stride is compact/consistent
|
|
123
|
+
h, w = img_u8.shape[:2]
|
|
124
|
+
|
|
125
|
+
if img_u8.ndim == 2 or (img_u8.ndim == 3 and img_u8.shape[2] == 1):
|
|
126
|
+
fmt = QImage.Format.Format_Grayscale8
|
|
127
|
+
return QImage(img_u8.data, w, h, img_u8.strides[0], fmt)
|
|
128
|
+
else:
|
|
129
|
+
# Expect RGB order; no BGR swap!
|
|
130
|
+
fmt = QImage.Format.Format_RGB888
|
|
131
|
+
return QImage(img_u8.data, w, h, img_u8.strides[0], fmt)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
convert.py
|
|
4
|
+
|
|
5
|
+
Given a SINGLE directory (e.g., StreamingPhasorCapture-...), this script
|
|
6
|
+
finds and concatenates .npy stacks for Ch0 and Ch1.
|
|
7
|
+
|
|
8
|
+
It saves the output TIFF inside the INPUT directory.
|
|
9
|
+
|
|
10
|
+
The default mode is 'interleaved': [Ch0[0], Ch1[0], Ch0[1], Ch1[1], ...]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from typing import List, Dict
|
|
18
|
+
import numpy as np
|
|
19
|
+
import tifffile
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def natural_key(s: str):
|
|
23
|
+
"""Natural sort key: 'file10' after 'file2'."""
|
|
24
|
+
return [int(text) if text.isdigit() else text.lower()
|
|
25
|
+
for text in re.split(r'(\d+)', s)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def list_channel_files(folder_path: str, prefix: str, ext: str = ".npy") -> List[str]:
|
|
29
|
+
"""Finds all files in a folder with a given prefix and extension."""
|
|
30
|
+
files = [fn for fn in os.listdir(folder_path)
|
|
31
|
+
if fn.startswith(prefix) and fn.endswith(ext)]
|
|
32
|
+
files.sort(key=natural_key)
|
|
33
|
+
return [os.path.join(folder_path, fn) for fn in files]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_and_concat(files: List[str]) -> np.ndarray:
|
|
37
|
+
"""Load .npy arrays and concatenate along axis=0 (time)."""
|
|
38
|
+
arrays = []
|
|
39
|
+
for fp in files:
|
|
40
|
+
try:
|
|
41
|
+
arr = np.load(fp, mmap_mode="r")
|
|
42
|
+
arrays.append(arr)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"[WARN] Skipping '{fp}': {e}", file=sys.stderr)
|
|
45
|
+
if not arrays:
|
|
46
|
+
return None
|
|
47
|
+
# Verify all image stacks have the same XY dimensions
|
|
48
|
+
ref_shape = arrays[0].shape[1:]
|
|
49
|
+
for i, a in enumerate(arrays):
|
|
50
|
+
if a.shape[1:] != ref_shape:
|
|
51
|
+
raise ValueError(f"Shape mismatch in {files[i]}: {a.shape} vs {arrays[0].shape}")
|
|
52
|
+
return np.concatenate(arrays, axis=0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def subfolder_basename(folder_path: str) -> str:
|
|
56
|
+
"""Generates a base filename from the folder path."""
|
|
57
|
+
folder_name = os.path.basename(os.path.normpath(folder_path))
|
|
58
|
+
parts = folder_name.split("-")
|
|
59
|
+
return "-".join(parts[:2]) if len(parts) >= 2 else folder_name
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def write_tiff(out_path: str, data: np.ndarray, force_dtype: str = "uint16"):
|
|
63
|
+
"""Writes a NumPy array to a BigTIFF file."""
|
|
64
|
+
if data is None:
|
|
65
|
+
raise ValueError("No image data to write.")
|
|
66
|
+
if force_dtype:
|
|
67
|
+
data = data.astype(force_dtype, copy=False)
|
|
68
|
+
tifffile.imwrite(out_path, data, bigtiff=True)
|
|
69
|
+
print(f"[OK] Saved {out_path} "
|
|
70
|
+
f"(frames={data.shape[0]}, shape={data.shape[1:]}, dtype={data.dtype})")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def process_single_folder(folder_path: str,
|
|
74
|
+
ch0_prefix: str = "ImageData_Ch0",
|
|
75
|
+
ch1_prefix: str = "ImageData_Ch1",
|
|
76
|
+
ext: str = ".npy",
|
|
77
|
+
mode: str = "interleaved",
|
|
78
|
+
dtype: str = "uint16"):
|
|
79
|
+
"""Processes .npy files in a single directory."""
|
|
80
|
+
if not os.path.isdir(folder_path):
|
|
81
|
+
raise NotADirectoryError(f"Input path is not a valid directory: {folder_path}")
|
|
82
|
+
|
|
83
|
+
ch0_files = list_channel_files(folder_path, ch0_prefix, ext)
|
|
84
|
+
ch1_files = list_channel_files(folder_path, ch1_prefix, ext)
|
|
85
|
+
|
|
86
|
+
if not ch0_files and not ch1_files:
|
|
87
|
+
print(f"[INFO] No '{ch0_prefix}*' or '{ch1_prefix}*' files found in {folder_path}.")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
ch_data: Dict[str, np.ndarray] = {}
|
|
91
|
+
if ch0_files:
|
|
92
|
+
ch0_result = load_and_concat(ch0_files)
|
|
93
|
+
if ch0_result is not None:
|
|
94
|
+
ch_data["Ch0"] = ch0_result
|
|
95
|
+
if ch1_files:
|
|
96
|
+
ch1_result = load_and_concat(ch1_files)
|
|
97
|
+
if ch1_result is not None:
|
|
98
|
+
ch_data["Ch1"] = ch1_result
|
|
99
|
+
|
|
100
|
+
if len(ch_data) == 0:
|
|
101
|
+
print(f"[WARN] No usable image data loaded from {folder_path}.")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# Verify that channel spatial dimensions match
|
|
105
|
+
shapes = {k: v.shape[1:] for k, v in ch_data.items() if v is not None}
|
|
106
|
+
if len(set(shapes.values())) > 1:
|
|
107
|
+
raise ValueError(f"Channel spatial shape mismatch in '{folder_path}': {shapes}")
|
|
108
|
+
|
|
109
|
+
save_array = None
|
|
110
|
+
|
|
111
|
+
if mode == "interleaved":
|
|
112
|
+
if "Ch0" in ch_data and "Ch1" in ch_data:
|
|
113
|
+
ch0, ch1 = ch_data["Ch0"], ch_data["Ch1"]
|
|
114
|
+
if ch0.shape[0] != ch1.shape[0]:
|
|
115
|
+
raise ValueError(f"Frame count mismatch: Ch0 has {ch0.shape[0]} frames, Ch1 has {ch1.shape[0]}")
|
|
116
|
+
n_frames = ch0.shape[0]
|
|
117
|
+
interleaved = np.empty((n_frames * 2, *ch0.shape[1:]), dtype=ch0.dtype)
|
|
118
|
+
interleaved[0::2] = ch0
|
|
119
|
+
interleaved[1::2] = ch1
|
|
120
|
+
save_array = interleaved
|
|
121
|
+
else:
|
|
122
|
+
save_array = ch_data.get("Ch0")
|
|
123
|
+
if save_array is None:
|
|
124
|
+
save_array = ch_data.get("Ch1")
|
|
125
|
+
elif mode == "block":
|
|
126
|
+
concat_list = [ch_data[k] for k in ("Ch0", "Ch1") if k in ch_data]
|
|
127
|
+
save_array = np.concatenate(concat_list, axis=0)
|
|
128
|
+
else:
|
|
129
|
+
raise ValueError(f"Unknown mode: {mode}")
|
|
130
|
+
|
|
131
|
+
base = subfolder_basename(folder_path)
|
|
132
|
+
# The output TIFF is saved directly inside the input directory
|
|
133
|
+
out_path = os.path.join(folder_path, f"{base}.tif")
|
|
134
|
+
write_tiff(out_path, save_array, force_dtype=dtype)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main():
|
|
138
|
+
p = argparse.ArgumentParser(description="Convert .npy stacks from a single Phasor directory to a TIFF.")
|
|
139
|
+
p.add_argument("directory", help="Path to the single 'StreamingPhasorCapture*' folder containing .npy files.")
|
|
140
|
+
p.add_argument("--mode", choices=["interleaved", "block"], default="interleaved",
|
|
141
|
+
help="Channel arrangement. Default is 'interleaved'.")
|
|
142
|
+
args = p.parse_args()
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
process_single_folder(
|
|
146
|
+
folder_path=os.path.abspath(args.directory),
|
|
147
|
+
mode=args.mode
|
|
148
|
+
)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
print(f"[FATAL] {e}", file=sys.stderr)
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|