phasor-handler 2.2.1__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.
Files changed (37) hide show
  1. phasor_handler/__init__.py +9 -0
  2. phasor_handler/app.py +249 -0
  3. phasor_handler/img/icons/chevron-down.svg +3 -0
  4. phasor_handler/img/icons/chevron-up.svg +3 -0
  5. phasor_handler/img/logo.ico +0 -0
  6. phasor_handler/models/dir_manager.py +100 -0
  7. phasor_handler/scripts/contrast.py +131 -0
  8. phasor_handler/scripts/convert.py +155 -0
  9. phasor_handler/scripts/meta_reader.py +467 -0
  10. phasor_handler/scripts/plot.py +110 -0
  11. phasor_handler/scripts/register.py +86 -0
  12. phasor_handler/themes/__init__.py +8 -0
  13. phasor_handler/themes/dark_theme.py +330 -0
  14. phasor_handler/tools/__init__.py +1 -0
  15. phasor_handler/tools/check_stylesheet.py +15 -0
  16. phasor_handler/tools/misc.py +20 -0
  17. phasor_handler/widgets/__init__.py +5 -0
  18. phasor_handler/widgets/analysis/components/__init__.py +9 -0
  19. phasor_handler/widgets/analysis/components/bnc.py +426 -0
  20. phasor_handler/widgets/analysis/components/circle_roi.py +850 -0
  21. phasor_handler/widgets/analysis/components/image_view.py +667 -0
  22. phasor_handler/widgets/analysis/components/meta_info.py +481 -0
  23. phasor_handler/widgets/analysis/components/roi_list.py +659 -0
  24. phasor_handler/widgets/analysis/components/trace_plot.py +621 -0
  25. phasor_handler/widgets/analysis/view.py +1735 -0
  26. phasor_handler/widgets/conversion/view.py +83 -0
  27. phasor_handler/widgets/registration/view.py +110 -0
  28. phasor_handler/workers/__init__.py +2 -0
  29. phasor_handler/workers/analysis_worker.py +0 -0
  30. phasor_handler/workers/histogram_worker.py +55 -0
  31. phasor_handler/workers/registration_worker.py +242 -0
  32. phasor_handler-2.2.1.dist-info/METADATA +134 -0
  33. phasor_handler-2.2.1.dist-info/RECORD +37 -0
  34. phasor_handler-2.2.1.dist-info/WHEEL +5 -0
  35. phasor_handler-2.2.1.dist-info/entry_points.txt +5 -0
  36. phasor_handler-2.2.1.dist-info/licenses/LICENSE.md +21 -0
  37. phasor_handler-2.2.1.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()
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
2
+ <path fill="white" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
2
+ <path fill="white" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/>
3
+ </svg>
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()