flimkit 0.12.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.
- flimkit/FLIM/__init__.py +0 -0
- flimkit/FLIM/assemble.py +254 -0
- flimkit/FLIM/batch.py +681 -0
- flimkit/FLIM/bg_tools.py +51 -0
- flimkit/FLIM/fit_tools.py +244 -0
- flimkit/FLIM/fitters.py +1471 -0
- flimkit/FLIM/irf_tools.py +617 -0
- flimkit/FLIM/models.py +391 -0
- flimkit/GPU/__init__.py +85 -0
- flimkit/GPU/_base.py +391 -0
- flimkit/GPU/cuda.py +10 -0
- flimkit/GPU/mlx_backend.py +381 -0
- flimkit/GPU/mps.py +10 -0
- flimkit/GPU/rocm.py +10 -0
- flimkit/GPU/torch_backend.py +385 -0
- flimkit/UI/app_state.py +10 -0
- flimkit/UI/controller.py +139 -0
- flimkit/UI/expert_settings.py +248 -0
- flimkit/UI/fit_help.py +206 -0
- flimkit/UI/fov_preview.py +1085 -0
- flimkit/UI/gui.py +3919 -0
- flimkit/UI/icon.icns +0 -0
- flimkit/UI/icon.ico +0 -0
- flimkit/UI/icon.png +0 -0
- flimkit/UI/irf_widget.py +103 -0
- flimkit/UI/mode_controller.py +118 -0
- flimkit/UI/modes/__init__.py +0 -0
- flimkit/UI/modes/base.py +3 -0
- flimkit/UI/modes/batch_mode.py +312 -0
- flimkit/UI/modes/fov_mode.py +164 -0
- flimkit/UI/modes/irf_mode.py +80 -0
- flimkit/UI/modes/phasor_mode.py +131 -0
- flimkit/UI/modes/stitch_mode.py +254 -0
- flimkit/UI/phasor_panel.py +1087 -0
- flimkit/UI/progress_window.py +113 -0
- flimkit/UI/project_panel.py +262 -0
- flimkit/UI/results_panel.py +332 -0
- flimkit/UI/roi_tools.py +794 -0
- flimkit/UI/utils.py +217 -0
- flimkit/__init__.py +0 -0
- flimkit/_version.py +41 -0
- flimkit/cli.py +120 -0
- flimkit/configs.py +148 -0
- flimkit/dialogs.py +46 -0
- flimkit/formats/BH/__init__.py +0 -0
- flimkit/formats/BH/reader.py +296 -0
- flimkit/formats/BH/writer.py +86 -0
- flimkit/formats/ISS/__init__.py +0 -0
- flimkit/formats/ISS/fdflim.py +86 -0
- flimkit/formats/ISS/image.py +114 -0
- flimkit/formats/ISS/reader.py +223 -0
- flimkit/formats/PS/__init__.py +0 -0
- flimkit/formats/PS/reader.py +202 -0
- flimkit/formats/PTU/__init__.py +0 -0
- flimkit/formats/PTU/decode.py +27 -0
- flimkit/formats/PTU/phu.py +85 -0
- flimkit/formats/PTU/reader.py +235 -0
- flimkit/formats/PTU/series.py +258 -0
- flimkit/formats/PTU/stitch.py +1182 -0
- flimkit/formats/PTU/tools.py +94 -0
- flimkit/formats/__init__.py +2 -0
- flimkit/formats/flim_file.py +232 -0
- flimkit/formats/phasor.py +132 -0
- flimkit/formats/signal.py +170 -0
- flimkit/image/tools.py +124 -0
- flimkit/interactive.py +1857 -0
- flimkit/mpl_backend.py +22 -0
- flimkit/phasor/__init__.py +40 -0
- flimkit/phasor/filters.py +127 -0
- flimkit/phasor/fret.py +654 -0
- flimkit/phasor/interactive.py +556 -0
- flimkit/phasor/peaks.py +186 -0
- flimkit/phasor/signal.py +90 -0
- flimkit/phasor_launcher.py +314 -0
- flimkit/plugins/__init__.py +137 -0
- flimkit/plugins/bindings.py +116 -0
- flimkit/plugins/builtin/__init__.py +3 -0
- flimkit/plugins/builtin/core_tools.py +28 -0
- flimkit/plugins/loader.py +371 -0
- flimkit/plugins/registry.py +406 -0
- flimkit/project.py +197 -0
- flimkit/synth.py +145 -0
- flimkit/utils/__init__.py +0 -0
- flimkit/utils/batch_fit.py +301 -0
- flimkit/utils/config_manager.py +119 -0
- flimkit/utils/config_snapshot.py +30 -0
- flimkit/utils/crash_handler.py +183 -0
- flimkit/utils/display.py +197 -0
- flimkit/utils/enhanced_outputs.py +345 -0
- flimkit/utils/fancy.py +103 -0
- flimkit/utils/lifetime_image.py +243 -0
- flimkit/utils/misc.py +111 -0
- flimkit/utils/plotting.py +190 -0
- flimkit/utils/roi.py +370 -0
- flimkit/utils/session.py +51 -0
- flimkit/utils/update_check.py +198 -0
- flimkit/utils/xlsx_tools.py +97 -0
- flimkit/utils/xml_utils.py +219 -0
- flimkit-0.12.0.dist-info/METADATA +356 -0
- flimkit-0.12.0.dist-info/RECORD +104 -0
- flimkit-0.12.0.dist-info/WHEEL +5 -0
- flimkit-0.12.0.dist-info/entry_points.txt +2 -0
- flimkit-0.12.0.dist-info/licenses/LICENSE.md +11 -0
- flimkit-0.12.0.dist-info/top_level.txt +1 -0
flimkit/UI/gui.py
ADDED
|
@@ -0,0 +1,3919 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import inspect
|
|
6
|
+
import threading
|
|
7
|
+
import argparse
|
|
8
|
+
import tkinter as tk
|
|
9
|
+
from tkinter import ttk, filedialog, messagebox, scrolledtext, simpledialog
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
import matplotlib
|
|
15
|
+
from flimkit.mpl_backend import select_backend
|
|
16
|
+
select_backend()
|
|
17
|
+
matplotlib.rcParams.update({
|
|
18
|
+
'text.color': 'white',
|
|
19
|
+
'axes.labelcolor': 'white',
|
|
20
|
+
'xtick.color': 'white',
|
|
21
|
+
'ytick.color': 'white',
|
|
22
|
+
'axes.titlecolor': 'white',
|
|
23
|
+
})
|
|
24
|
+
import matplotlib.image as mpimg
|
|
25
|
+
import numpy as np
|
|
26
|
+
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
|
27
|
+
from matplotlib.figure import Figure
|
|
28
|
+
from flimkit.UI.progress_window import ProgressWindow
|
|
29
|
+
from flimkit.UI.phasor_panel import PhasorViewPanel
|
|
30
|
+
from flimkit.utils import display
|
|
31
|
+
from flimkit.utils.roi import RoiManager
|
|
32
|
+
from flimkit.UI.roi_tools import RoiAnalysisPanel
|
|
33
|
+
from flimkit.UI.project_panel import ProjectBrowserPanel
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import TKinterModernThemes as TKMT
|
|
37
|
+
HAS_TKMT = True
|
|
38
|
+
except ImportError:
|
|
39
|
+
HAS_TKMT = False
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from tkinterdnd2 import DND_FILES, DND_TEXT
|
|
43
|
+
HAS_DND = True
|
|
44
|
+
except ImportError:
|
|
45
|
+
HAS_DND = False
|
|
46
|
+
|
|
47
|
+
GUI_MODE = False
|
|
48
|
+
|
|
49
|
+
from flimkit.UI.utils import (
|
|
50
|
+
PAD,
|
|
51
|
+
_C,
|
|
52
|
+
_reconstruct_dict_from_session,
|
|
53
|
+
_safe_array_from_json,
|
|
54
|
+
_parse_summary,
|
|
55
|
+
_Redirect,
|
|
56
|
+
_FileRedirect,
|
|
57
|
+
_FileTailer,
|
|
58
|
+
_browse_file,
|
|
59
|
+
_browse_dir,
|
|
60
|
+
_row,
|
|
61
|
+
_section,
|
|
62
|
+
_tog,
|
|
63
|
+
_flt,
|
|
64
|
+
_thresh,
|
|
65
|
+
_enable_dnd,
|
|
66
|
+
)
|
|
67
|
+
from flimkit.UI.progress_window import ProgressWindowManager
|
|
68
|
+
from flimkit.UI.irf_widget import IRFWidget
|
|
69
|
+
from flimkit.UI.expert_settings import ExpertSettingsDialog, _EXPERT_DEFAULTS
|
|
70
|
+
from flimkit.UI.fov_preview import FOVPreviewPanel
|
|
71
|
+
from flimkit.UI.results_panel import ResultsPanel
|
|
72
|
+
from flimkit.UI.app_state import AppState
|
|
73
|
+
from flimkit.UI.mode_controller import ModeController
|
|
74
|
+
from flimkit.UI.controller import FLIMKitController
|
|
75
|
+
|
|
76
|
+
class _UIBuilder:
|
|
77
|
+
|
|
78
|
+
def __getattr__(self, nam):
|
|
79
|
+
state = self.__dict__.get('state')
|
|
80
|
+
if state is not None and nam in state.__dict__:
|
|
81
|
+
return state.__dict__[nam]
|
|
82
|
+
raise AttributeError(nam)
|
|
83
|
+
|
|
84
|
+
def _make_scroll_frame(self, parent: ttk.Frame) -> tuple:
|
|
85
|
+
outer = ttk.Frame(parent)
|
|
86
|
+
outer.columnconfigure(0, weight=1)
|
|
87
|
+
outer.rowconfigure(0, weight=1)
|
|
88
|
+
canvas = tk.Canvas(outer, highlightthickness=0, borderwidth=0)
|
|
89
|
+
vbar = ttk.Scrollbar(outer, orient='vertical', command=canvas.yview)
|
|
90
|
+
canvas.configure(yscrollcommand=vbar.set)
|
|
91
|
+
canvas.grid(row=0, column=0, sticky='nsew')
|
|
92
|
+
vbar.grid(row=0, column=1, sticky='ns')
|
|
93
|
+
inner = ttk.Frame(canvas, padding=10)
|
|
94
|
+
window_id = canvas.create_window((0, 0), window=inner, anchor='nw')
|
|
95
|
+
|
|
96
|
+
def _on_inner_configure(_evt=None):
|
|
97
|
+
canvas.configure(scrollregion=canvas.bbox('all'))
|
|
98
|
+
inner_height = inner.winfo_reqheight()
|
|
99
|
+
canvas_height = canvas.winfo_height()
|
|
100
|
+
if canvas_height > 1:
|
|
101
|
+
canvas.itemconfigure(window_id, height=max(inner_height, canvas_height))
|
|
102
|
+
canvas.itemconfigure(window_id, width=canvas.winfo_width() if canvas.winfo_width() > 1 else None)
|
|
103
|
+
|
|
104
|
+
def _on_canvas_configure(evt):
|
|
105
|
+
if evt.width > 1:
|
|
106
|
+
canvas.itemconfigure(window_id, width=evt.width)
|
|
107
|
+
|
|
108
|
+
def _on_mousewheel(evt):
|
|
109
|
+
if evt.num == 5 or evt.delta < 0:
|
|
110
|
+
canvas.yview_scroll(3, 'units')
|
|
111
|
+
elif evt.num == 4 or evt.delta > 0:
|
|
112
|
+
canvas.yview_scroll(-3, 'units')
|
|
113
|
+
inner.bind('<Configure>', _on_inner_configure)
|
|
114
|
+
canvas.bind('<Configure>', _on_canvas_configure)
|
|
115
|
+
canvas.bind('<MouseWheel>', _on_mousewheel)
|
|
116
|
+
canvas.bind('<Button-4>', _on_mousewheel)
|
|
117
|
+
canvas.bind('<Button-5>', _on_mousewheel)
|
|
118
|
+
inner.bind('<MouseWheel>', _on_mousewheel)
|
|
119
|
+
inner.bind('<Button-4>', _on_mousewheel)
|
|
120
|
+
inner.bind('<Button-5>', _on_mousewheel)
|
|
121
|
+
inner._canvas = canvas
|
|
122
|
+
inner._window_id = window_id
|
|
123
|
+
outer._canvas = canvas
|
|
124
|
+
outer._window_id = window_id
|
|
125
|
+
return outer, inner
|
|
126
|
+
|
|
127
|
+
def _fit_window_to_screen(self):
|
|
128
|
+
self.root.update_idletasks()
|
|
129
|
+
sw = max(1, int(self.root.winfo_screenwidth()))
|
|
130
|
+
sh = max(1, int(self.root.winfo_screenheight()))
|
|
131
|
+
max_w = max(1200, sw - 40)
|
|
132
|
+
max_h = max(700, sh - 40)
|
|
133
|
+
req_w = int(self.root.winfo_reqwidth())
|
|
134
|
+
req_h = int(self.root.winfo_reqheight())
|
|
135
|
+
cur_w = int(self.root.winfo_width())
|
|
136
|
+
cur_h = int(self.root.winfo_height())
|
|
137
|
+
target_w = min(max(cur_w, req_w, 1200), max_w)
|
|
138
|
+
target_h = min(max(cur_h, req_h, 800), max_h)
|
|
139
|
+
x = int(self.root.winfo_x())
|
|
140
|
+
y = int(self.root.winfo_y())
|
|
141
|
+
x = min(max(0, x), max(0, sw - target_w))
|
|
142
|
+
y = min(max(0, y), max(0, sh - target_h))
|
|
143
|
+
self.root.maxsize(sw, sh)
|
|
144
|
+
self.root.geometry(f'{target_w}x{target_h}+{x}+{y}')
|
|
145
|
+
|
|
146
|
+
def _set_pane_positions(self):
|
|
147
|
+
try:
|
|
148
|
+
self.root.update_idletasks()
|
|
149
|
+
main_width = self._main_paned.winfo_width()
|
|
150
|
+
if main_width < 100:
|
|
151
|
+
return
|
|
152
|
+
left_width = int(main_width * 0.6)
|
|
153
|
+
self._main_paned.sashpos(0, left_width)
|
|
154
|
+
project_width = int(main_width * 0.15)
|
|
155
|
+
self._left_paned.sashpos(0, project_width)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
print(f'[Layout] Could not set pane positions: {e}')
|
|
158
|
+
|
|
159
|
+
def run_with_progress(self, task_fn, task_name='Working...', on_done=None, output_dir=None):
|
|
160
|
+
from flimkit.utils.crash_handler import log_event
|
|
161
|
+
log_event(f'Task started: {task_name}')
|
|
162
|
+
win = ProgressWindow(self.root, task_name=task_name)
|
|
163
|
+
cancel_event = win.cancelled
|
|
164
|
+
|
|
165
|
+
def progress_callback(i, total):
|
|
166
|
+
win.set_progress(i, maximum=total)
|
|
167
|
+
if cancel_event.is_set():
|
|
168
|
+
win.set_status('Cancelling...')
|
|
169
|
+
|
|
170
|
+
def worker():
|
|
171
|
+
orig_stdout, orig_stderr = sys.stdout, sys.stderr
|
|
172
|
+
redir = _Redirect(self._res.log, self._buf, root=self.root)
|
|
173
|
+
redir_err = _Redirect(self._res.log, self._buf, root=self.root, is_stderr=True)
|
|
174
|
+
sys.stdout = redir
|
|
175
|
+
sys.stderr = redir_err
|
|
176
|
+
try:
|
|
177
|
+
result = task_fn(progress_callback, cancel_event)
|
|
178
|
+
self.root.after(0, lambda: win.close())
|
|
179
|
+
if on_done:
|
|
180
|
+
self.root.after(0, lambda: on_done(result))
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
import traceback
|
|
183
|
+
traceback.print_exc()
|
|
184
|
+
from flimkit.utils.crash_handler import log_exception
|
|
185
|
+
log_exception(f'run_with_progress: {task_name}')
|
|
186
|
+
self.root.after(0, lambda e=exc: win.set_status(f'Error: {e}'))
|
|
187
|
+
self.root.after(0, lambda: win.btn_cancel.config(text='Close', command=win.close))
|
|
188
|
+
finally:
|
|
189
|
+
if hasattr(redir, 'close'):
|
|
190
|
+
redir.close()
|
|
191
|
+
else:
|
|
192
|
+
redir.flush()
|
|
193
|
+
if hasattr(redir_err, 'close'):
|
|
194
|
+
redir_err.close()
|
|
195
|
+
else:
|
|
196
|
+
redir_err.flush()
|
|
197
|
+
sys.stdout = orig_stdout
|
|
198
|
+
sys.stderr = orig_stderr
|
|
199
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
200
|
+
|
|
201
|
+
def _run_plugin_startups(self):
|
|
202
|
+
from flimkit import plugins
|
|
203
|
+
plugins.ensure_loaded()
|
|
204
|
+
for entry, exc in plugins.run_startups(self):
|
|
205
|
+
print(f'[Plugin] startup {entry.id} ({entry.source}) raised '
|
|
206
|
+
f'{type(exc).__name__}: {exc}')
|
|
207
|
+
|
|
208
|
+
def _build_plugin_menu(self, menu, path):
|
|
209
|
+
from flimkit import plugins
|
|
210
|
+
items = []
|
|
211
|
+
subs = {}
|
|
212
|
+
for t in plugins.tools('/'.join(path)):
|
|
213
|
+
rest = t.menu_path[len(path):]
|
|
214
|
+
if rest:
|
|
215
|
+
if rest[0] not in subs or t.order < subs[rest[0]]:
|
|
216
|
+
subs[rest[0]] = t.order
|
|
217
|
+
else:
|
|
218
|
+
items.append((t.order, t.label, t))
|
|
219
|
+
for name, order in subs.items():
|
|
220
|
+
items.append((order, name, None))
|
|
221
|
+
for order, label, t in sorted(items, key=lambda e: (e[0], e[1])):
|
|
222
|
+
if t is None:
|
|
223
|
+
sub = tk.Menu(menu, tearoff=0)
|
|
224
|
+
menu.add_cascade(label=label, menu=sub)
|
|
225
|
+
self._build_plugin_menu(sub, path + (label,))
|
|
226
|
+
else:
|
|
227
|
+
menu.add_command(label=label,
|
|
228
|
+
command=lambda t=t: self._run_plugin_tool(t))
|
|
229
|
+
|
|
230
|
+
def _run_plugin_tool(self, t):
|
|
231
|
+
try:
|
|
232
|
+
t.callback(self)
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
import traceback
|
|
235
|
+
traceback.print_exc()
|
|
236
|
+
messagebox.showerror('Tool failed',
|
|
237
|
+
f'{t.label} ({t.source}) raised {type(exc).__name__}: {exc}')
|
|
238
|
+
|
|
239
|
+
def _build_menu_bar(self):
|
|
240
|
+
from flimkit import plugins
|
|
241
|
+
plugins.ensure_loaded()
|
|
242
|
+
menubar = tk.Menu(self.root)
|
|
243
|
+
self.root.config(menu=menubar)
|
|
244
|
+
file_menu = tk.Menu(menubar, tearoff=0)
|
|
245
|
+
menubar.add_cascade(label='File', menu=file_menu)
|
|
246
|
+
file_menu.add_command(label='Restore NPZ...', command=self._menu_restore_npz)
|
|
247
|
+
file_menu.add_command(label='Save NPZ', command=self._menu_save_npz)
|
|
248
|
+
file_menu.add_command(label='Save NPZ As...', command=self._menu_save_npz_as)
|
|
249
|
+
file_menu.add_separator()
|
|
250
|
+
file_menu.add_command(label='Open Project Folder...', command=self._menu_open_project_folder)
|
|
251
|
+
file_menu.add_separator()
|
|
252
|
+
export_menu = tk.Menu(file_menu, tearoff=0)
|
|
253
|
+
file_menu.add_cascade(label='Export', menu=export_menu)
|
|
254
|
+
export_menu.add_command(label='Export Summed Fit CSV', command=self._menu_export_fit_csv)
|
|
255
|
+
export_menu.add_command(label='Export ROI Table CSV', command=self._menu_export_roi_csv)
|
|
256
|
+
export_menu.add_command(label='Export ROI as GeoJSON', command=self._menu_export_roi_geojson)
|
|
257
|
+
export_menu.add_command(label='Export All ROIs as GeoJSON', command=self._menu_export_all_rois_geojson)
|
|
258
|
+
file_menu.add_command(label='Import GeoJSON...', command=self._menu_import_geojson)
|
|
259
|
+
file_menu.add_separator()
|
|
260
|
+
self._recent_files_menu = tk.Menu(file_menu, tearoff=0)
|
|
261
|
+
file_menu.add_cascade(label='Recent Files', menu=self._recent_files_menu)
|
|
262
|
+
self._recent_files = self._load_recent_list()
|
|
263
|
+
self._update_recent_files_menu()
|
|
264
|
+
file_menu.add_separator()
|
|
265
|
+
file_menu.add_command(label='Preferences...', command=self._menu_preferences)
|
|
266
|
+
file_menu.add_separator()
|
|
267
|
+
file_menu.add_command(label='Exit', command=self.root.quit)
|
|
268
|
+
edit_menu = tk.Menu(menubar, tearoff=0)
|
|
269
|
+
menubar.add_cascade(label='Edit', menu=edit_menu)
|
|
270
|
+
edit_menu.add_command(label='Undo', command=self._menu_undo, accelerator='Ctrl+Z')
|
|
271
|
+
edit_menu.add_command(label='Redo', command=self._menu_redo, accelerator='Ctrl+Shift+Z')
|
|
272
|
+
edit_menu.add_separator()
|
|
273
|
+
edit_menu.add_command(label='Reset', command=self._menu_reset)
|
|
274
|
+
self.root.bind('<Control-z>', lambda e: self._menu_undo())
|
|
275
|
+
self.root.bind('<Control-Shift-Z>', lambda e: self._menu_redo())
|
|
276
|
+
tools_menu = tk.Menu(menubar, tearoff=0)
|
|
277
|
+
menubar.add_cascade(label='Tools', menu=tools_menu)
|
|
278
|
+
self._build_plugin_menu(tools_menu, ('Tools',))
|
|
279
|
+
help_menu = tk.Menu(menubar, tearoff=0)
|
|
280
|
+
menubar.add_cascade(label='Help', menu=help_menu)
|
|
281
|
+
help_menu.add_command(label='About', command=self._menu_about)
|
|
282
|
+
help_menu.add_command(label='Documentation', command=self._menu_documentation)
|
|
283
|
+
help_menu.add_command(label='Check for Updates', command=self._menu_check_updates)
|
|
284
|
+
help_menu.add_command(label='Plugins...', command=self._menu_plugins)
|
|
285
|
+
help_menu.add_separator()
|
|
286
|
+
help_menu.add_command(label='View Error Logs', command=self._menu_view_error_logs)
|
|
287
|
+
help_menu.add_command(label='Export Error Logs', command=self._menu_export_error_logs)
|
|
288
|
+
self.root.after(800, self._maybe_prompt_user_plugins)
|
|
289
|
+
_RECENT_FILE = os.path.join(os.path.expanduser('~'), '.flimkit', 'recent.json')
|
|
290
|
+
_MAX_RECENT = 10
|
|
291
|
+
|
|
292
|
+
def _load_recent_list(self):
|
|
293
|
+
try:
|
|
294
|
+
with open(self._RECENT_FILE, 'r') as f:
|
|
295
|
+
import json
|
|
296
|
+
data = json.load(f)
|
|
297
|
+
return [e for e in data if isinstance(e, dict) and 'path' in e and 'type' in e]
|
|
298
|
+
except (FileNotFoundError, ValueError):
|
|
299
|
+
return []
|
|
300
|
+
|
|
301
|
+
def _save_recent_list(self):
|
|
302
|
+
import json
|
|
303
|
+
os.makedirs(os.path.dirname(self._RECENT_FILE), exist_ok=True)
|
|
304
|
+
with open(self._RECENT_FILE, 'w') as f:
|
|
305
|
+
json.dump(self._recent_files, f, indent=2)
|
|
306
|
+
|
|
307
|
+
def _update_recent_files_menu(self):
|
|
308
|
+
self._recent_files_menu.delete(0, tk.END)
|
|
309
|
+
if self._recent_files:
|
|
310
|
+
for entry in self._recent_files:
|
|
311
|
+
path = entry['path']
|
|
312
|
+
kind = entry.get('type', 'file')
|
|
313
|
+
prefix = '[Project] ' if kind == 'project' else ''
|
|
314
|
+
label = f'{prefix}{path}'
|
|
315
|
+
self._recent_files_menu.add_command(
|
|
316
|
+
label=label,
|
|
317
|
+
command=lambda e=entry: self._load_recent_item(e)
|
|
318
|
+
)
|
|
319
|
+
self._recent_files_menu.add_separator()
|
|
320
|
+
self._recent_files_menu.add_command(label='Clear Recent', command=self._clear_recent_files)
|
|
321
|
+
else:
|
|
322
|
+
self._recent_files_menu.add_command(label='(No recent items)', state='disabled')
|
|
323
|
+
|
|
324
|
+
def _load_recent_item(self, entry):
|
|
325
|
+
path = entry['path']
|
|
326
|
+
kind = entry.get('type', 'file')
|
|
327
|
+
if kind == 'project':
|
|
328
|
+
if os.path.isdir(path):
|
|
329
|
+
print(f'[Menu] Opening recent project: {path}')
|
|
330
|
+
if hasattr(self, '_proj_browser') and self._proj_browser:
|
|
331
|
+
self._proj_browser.load_folder(path)
|
|
332
|
+
self._add_to_recent(path, 'project')
|
|
333
|
+
else:
|
|
334
|
+
print(f'[Menu] Project folder not found: {path}')
|
|
335
|
+
else:
|
|
336
|
+
if os.path.isfile(path):
|
|
337
|
+
print(f'[Menu] Loading recent file: {path}')
|
|
338
|
+
self.sv_ptu.set(path)
|
|
339
|
+
self._add_to_recent(path, 'file')
|
|
340
|
+
else:
|
|
341
|
+
print(f'[Menu] File not found: {path}')
|
|
342
|
+
|
|
343
|
+
def _add_to_recent(self, filepath, kind='file'):
|
|
344
|
+
path_str = str(filepath)
|
|
345
|
+
self._recent_files = [e for e in self._recent_files if e['path'] != path_str]
|
|
346
|
+
self._recent_files.insert(0, {'path': path_str, 'type': kind})
|
|
347
|
+
if len(self._recent_files) > self._MAX_RECENT:
|
|
348
|
+
self._recent_files = self._recent_files[:self._MAX_RECENT]
|
|
349
|
+
self._update_recent_files_menu()
|
|
350
|
+
self._save_recent_list()
|
|
351
|
+
|
|
352
|
+
def _clear_recent_files(self):
|
|
353
|
+
self._recent_files = []
|
|
354
|
+
self._update_recent_files_menu()
|
|
355
|
+
self._save_recent_list()
|
|
356
|
+
|
|
357
|
+
def _current_scan_stem(self) -> str:
|
|
358
|
+
if hasattr(self, 'sv_ptu'):
|
|
359
|
+
p = self.sv_ptu.get().strip()
|
|
360
|
+
if p:
|
|
361
|
+
return Path(p).stem
|
|
362
|
+
return ''
|
|
363
|
+
|
|
364
|
+
def _menu_restore_npz(self):
|
|
365
|
+
if self._res and hasattr(self._res, '_load_fitted_data'):
|
|
366
|
+
self._res._load_fitted_data()
|
|
367
|
+
|
|
368
|
+
def _menu_save_npz(self):
|
|
369
|
+
if self._res and hasattr(self._res, '_on_save_npz_clicked'):
|
|
370
|
+
self._res._on_save_npz_clicked()
|
|
371
|
+
|
|
372
|
+
def _menu_save_npz_as(self):
|
|
373
|
+
import shutil
|
|
374
|
+
from tkinter import filedialog, messagebox
|
|
375
|
+
scan = self._current_scan_stem()
|
|
376
|
+
src = getattr(self._res, '_current_npz_path', None) if self._res else None
|
|
377
|
+
if not src or not Path(src).exists():
|
|
378
|
+
messagebox.showwarning('Save NPZ As', 'No session file to save. Run a fit first.')
|
|
379
|
+
return
|
|
380
|
+
npz_file = filedialog.asksaveasfilename(
|
|
381
|
+
title='Save NPZ As',
|
|
382
|
+
initialfile=f'{scan}.roi_session.npz' if scan else '',
|
|
383
|
+
defaultextension='.npz',
|
|
384
|
+
filetypes=[('NPZ files', '*.npz'), ('All files', '*.*')])
|
|
385
|
+
if npz_file:
|
|
386
|
+
try:
|
|
387
|
+
shutil.copy2(src, npz_file)
|
|
388
|
+
self._res._current_npz_path = npz_file
|
|
389
|
+
messagebox.showinfo('Saved', f'Session saved to:\n{Path(npz_file).name}')
|
|
390
|
+
except Exception as e:
|
|
391
|
+
messagebox.showerror('Save Error', f'Could not save: {e}')
|
|
392
|
+
|
|
393
|
+
def _menu_open_project_folder(self):
|
|
394
|
+
from tkinter import filedialog
|
|
395
|
+
folder = filedialog.askdirectory(title='Select Project Folder')
|
|
396
|
+
if folder:
|
|
397
|
+
print(f'[Menu] Opening project folder: {folder}')
|
|
398
|
+
if hasattr(self, '_proj_browser') and self._proj_browser:
|
|
399
|
+
self._proj_browser.load_folder(folder)
|
|
400
|
+
self._add_to_recent(folder, 'project')
|
|
401
|
+
|
|
402
|
+
def _menu_export_fit_csv(self):
|
|
403
|
+
print('[Menu] Export Fit CSV')
|
|
404
|
+
if hasattr(self, '_res') and self._res:
|
|
405
|
+
self._res._export_summed_csv()
|
|
406
|
+
|
|
407
|
+
def _menu_export_roi_csv(self):
|
|
408
|
+
print('[Menu] Export ROI CSV')
|
|
409
|
+
if self._roi_analysis_panel:
|
|
410
|
+
self._roi_analysis_panel._export_all_rois_csv()
|
|
411
|
+
|
|
412
|
+
def _menu_export_roi_geojson(self):
|
|
413
|
+
print('[Menu] Export ROI GeoJSON')
|
|
414
|
+
if self._roi_analysis_panel:
|
|
415
|
+
self._roi_analysis_panel._export_selected_region()
|
|
416
|
+
|
|
417
|
+
def _menu_export_all_rois_geojson(self):
|
|
418
|
+
print('[Menu] Export All ROIs GeoJSON')
|
|
419
|
+
if self._roi_analysis_panel:
|
|
420
|
+
self._roi_analysis_panel._export_all_rois_geojson()
|
|
421
|
+
|
|
422
|
+
def _menu_import_geojson(self):
|
|
423
|
+
print('[Menu] Import GeoJSON')
|
|
424
|
+
if self._roi_analysis_panel:
|
|
425
|
+
self._roi_analysis_panel._import_rois_geojson()
|
|
426
|
+
|
|
427
|
+
def _menu_preferences(self):
|
|
428
|
+
from flimkit.utils.config_manager import cfg
|
|
429
|
+
prefs = cfg.get_section('preferences')
|
|
430
|
+
pref_win = tk.Toplevel(self.root)
|
|
431
|
+
pref_win.title('Preferences')
|
|
432
|
+
pref_win.geometry('500x400')
|
|
433
|
+
pref_win.resizable(False, False)
|
|
434
|
+
main_frame = ttk.Frame(pref_win, padding=10)
|
|
435
|
+
main_frame.pack(fill=tk.BOTH, expand=True)
|
|
436
|
+
ttk.Label(main_frame, text='FLIMKit Preferences', font=('TkDefaultFont', 12, 'bold')).pack(anchor='w', pady=(0, 10))
|
|
437
|
+
note = ttk.Notebook(main_frame)
|
|
438
|
+
note.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
|
439
|
+
disp_frame = ttk.Frame(note, padding=10)
|
|
440
|
+
note.add(disp_frame, text='Display')
|
|
441
|
+
ttk.Label(disp_frame, text='Colormap:', font=('TkDefaultFont', 10)).pack(anchor='w', pady=(5, 0))
|
|
442
|
+
cmap_var = tk.StringVar(value=prefs.get('colormap', 'viridis'))
|
|
443
|
+
ttk.Combobox(disp_frame, textvariable=cmap_var,
|
|
444
|
+
values=['viridis', 'plasma', 'gray', 'jet'], state='readonly').pack(anchor='w', pady=(0, 10))
|
|
445
|
+
ttk.Label(disp_frame, text='Font Size:', font=('TkDefaultFont', 10)).pack(anchor='w', pady=(5, 0))
|
|
446
|
+
font_var = tk.IntVar(value=prefs.get('font_size', 9))
|
|
447
|
+
ttk.Spinbox(disp_frame, from_=8, to=14, textvariable=font_var, width=10).pack(anchor='w', pady=(0, 10))
|
|
448
|
+
anal_frame = ttk.Frame(note, padding=10)
|
|
449
|
+
note.add(anal_frame, text='Analysis')
|
|
450
|
+
ttk.Label(anal_frame, text='Default Number of Exponents:', font=('TkDefaultFont', 10)).pack(anchor='w', pady=(5, 0))
|
|
451
|
+
exp_var = tk.IntVar(value=prefs.get('default_nexp', 2))
|
|
452
|
+
ttk.Spinbox(anal_frame, from_=1, to=5, textvariable=exp_var, width=10).pack(anchor='w', pady=(0, 10))
|
|
453
|
+
ttk.Label(anal_frame, text='Export Format:', font=('TkDefaultFont', 10)).pack(anchor='w', pady=(5, 0))
|
|
454
|
+
fmt_var = tk.StringVar(value=prefs.get('export_format', 'CSV'))
|
|
455
|
+
ttk.Combobox(anal_frame, textvariable=fmt_var,
|
|
456
|
+
values=['CSV', 'Excel', 'NumPy'], state='readonly').pack(anchor='w', pady=(0, 10))
|
|
457
|
+
files_frame = ttk.Frame(note, padding=10)
|
|
458
|
+
note.add(files_frame, text='Files')
|
|
459
|
+
ttk.Label(files_frame, text='Output Directory:', font=('TkDefaultFont', 10)).pack(anchor='w', pady=(5, 0))
|
|
460
|
+
saved_outdir = prefs.get('output_directory', '') or os.path.expanduser('~/FLIMKit/output')
|
|
461
|
+
output_var = tk.StringVar(value=saved_outdir)
|
|
462
|
+
ttk.Entry(files_frame, textvariable=output_var, width=40).pack(anchor='w', pady=(0, 5))
|
|
463
|
+
ttk.Button(files_frame, text='Browse...', width=10,
|
|
464
|
+
command=lambda: output_var.set(filedialog.askdirectory())).pack(anchor='w', pady=(0, 10))
|
|
465
|
+
ttk.Label(files_frame, text='Auto-save NPZ:', font=('TkDefaultFont', 10)).pack(anchor='w', pady=(5, 0))
|
|
466
|
+
autosave_var = tk.BooleanVar(value=prefs.get('auto_save_npz', True))
|
|
467
|
+
ttk.Checkbutton(files_frame, text='Enable auto-save', variable=autosave_var).pack(anchor='w', pady=(0, 10))
|
|
468
|
+
from flimkit import plugins
|
|
469
|
+
plug_frame = ttk.Frame(note, padding=10)
|
|
470
|
+
note.add(plug_frame, text='Plugins')
|
|
471
|
+
enabled_var = tk.BooleanVar(value=plugins.plugins_enabled())
|
|
472
|
+
ttk.Checkbutton(plug_frame, text='Load plugins at startup',
|
|
473
|
+
variable=enabled_var).pack(anchor='w', pady=(5, 0))
|
|
474
|
+
user_var = tk.BooleanVar(value=plugins.user_plugins_allowed())
|
|
475
|
+
ttk.Checkbutton(plug_frame, text=f'Load from {plugins.user_dir()}',
|
|
476
|
+
variable=user_var).pack(anchor='w', pady=(0, 10))
|
|
477
|
+
ttk.Label(plug_frame, text='Extra plugin folders, one per line:',
|
|
478
|
+
font=('TkDefaultFont', 10)).pack(anchor='w', pady=(5, 0))
|
|
479
|
+
paths_box = tk.Text(plug_frame, height=4, width=44)
|
|
480
|
+
paths_box.pack(anchor='w', pady=(0, 5))
|
|
481
|
+
paths_box.insert('1.0', '\n'.join(plugins.config_dirs()))
|
|
482
|
+
ttk.Button(plug_frame, text='Add folder...', width=14,
|
|
483
|
+
command=lambda: self._add_plugin_folder(paths_box)).pack(anchor='w')
|
|
484
|
+
ttk.Label(plug_frame, text='Changes take effect on the next start.',
|
|
485
|
+
foreground='grey').pack(anchor='w', pady=(8, 0))
|
|
486
|
+
btn_frame = ttk.Frame(main_frame)
|
|
487
|
+
btn_frame.pack(fill=tk.X, pady=(0, 0))
|
|
488
|
+
|
|
489
|
+
def save_prefs():
|
|
490
|
+
plugins.set_plugins_enabled(enabled_var.get())
|
|
491
|
+
plugins.allow_user_plugins(user_var.get())
|
|
492
|
+
plugins.set_config_dirs(
|
|
493
|
+
[line.strip() for line in paths_box.get('1.0', 'end').splitlines()])
|
|
494
|
+
cfg.update_section('preferences', {
|
|
495
|
+
'colormap': cmap_var.get(),
|
|
496
|
+
'font_size': font_var.get(),
|
|
497
|
+
'default_nexp': exp_var.get(),
|
|
498
|
+
'export_format': fmt_var.get(),
|
|
499
|
+
'output_directory': output_var.get(),
|
|
500
|
+
'auto_save_npz': autosave_var.get(),
|
|
501
|
+
})
|
|
502
|
+
print(f'[Preferences] Saved to {cfg._CONFIG_FILE if hasattr(cfg, '_CONFIG_FILE') else '~/.flimkit/config.yaml'}')
|
|
503
|
+
pref_win.destroy()
|
|
504
|
+
ttk.Button(btn_frame, text='Save', command=save_prefs).pack(side='right', padx=5)
|
|
505
|
+
ttk.Button(btn_frame, text='Cancel', command=pref_win.destroy).pack(side='right', padx=5)
|
|
506
|
+
|
|
507
|
+
def _add_plugin_folder(self, paths_box):
|
|
508
|
+
chosen = filedialog.askdirectory()
|
|
509
|
+
if not chosen:
|
|
510
|
+
return
|
|
511
|
+
current = paths_box.get('1.0', 'end').strip()
|
|
512
|
+
paths_box.delete('1.0', 'end')
|
|
513
|
+
paths_box.insert('1.0', (current + '\n' + chosen).strip())
|
|
514
|
+
|
|
515
|
+
def _menu_undo(self):
|
|
516
|
+
print('[Menu] Undo')
|
|
517
|
+
|
|
518
|
+
def _menu_redo(self):
|
|
519
|
+
print('[Menu] Redo')
|
|
520
|
+
|
|
521
|
+
def _menu_reset(self):
|
|
522
|
+
from tkinter import messagebox
|
|
523
|
+
if messagebox.askyesno('Reset', 'Clear all regions and results? This cannot be undone.'):
|
|
524
|
+
print('[Menu] Resetting analysis...')
|
|
525
|
+
if self._roi_analysis_panel and self._roi_analysis_panel.fov_preview:
|
|
526
|
+
roi_manager = self._roi_analysis_panel.fov_preview._roi_manager
|
|
527
|
+
region_ids = [r['id'] for r in roi_manager.get_all_regions()]
|
|
528
|
+
for region_id in region_ids:
|
|
529
|
+
roi_manager.remove_region(region_id)
|
|
530
|
+
self._roi_analysis_panel._refresh_region_list()
|
|
531
|
+
self._roi_analysis_panel.fov_preview._redraw_region_overlays()
|
|
532
|
+
if self._res:
|
|
533
|
+
self._res._tv.delete(*self._res._tv.get_children())
|
|
534
|
+
messagebox.showinfo('Reset Complete', 'Analysis cleared.')
|
|
535
|
+
print('[Menu] Reset complete.')
|
|
536
|
+
|
|
537
|
+
def _menu_irf_builder(self):
|
|
538
|
+
print('[Menu] Machine IRF Builder')
|
|
539
|
+
if hasattr(self, '_switch_form'):
|
|
540
|
+
self._switch_form('irf')
|
|
541
|
+
|
|
542
|
+
def _menu_synth_generator(self):
|
|
543
|
+
print('[Menu] Generate Synthetic PTU')
|
|
544
|
+
from flimkit import synth
|
|
545
|
+
dlg = tk.Toplevel(self.root)
|
|
546
|
+
dlg.title('Generate Synthetic PTU')
|
|
547
|
+
dlg.resizable(False, False)
|
|
548
|
+
dlg.transient(self.root)
|
|
549
|
+
dlg.grab_set()
|
|
550
|
+
fields = [
|
|
551
|
+
('Lifetime(s) τ, ns (comma for multi-exp)', 'tau', '4.1'),
|
|
552
|
+
('Amplitudes (comma, blank = equal)', 'amps', ''),
|
|
553
|
+
('Photons (comma = a series)', 'photons', '1e5'),
|
|
554
|
+
('Laser period, ns', 'period', '50'),
|
|
555
|
+
('TCSPC bin width, ps', 'res', '25'),
|
|
556
|
+
('IRF FWHM, ns', 'irf_fwhm', '0.15'),
|
|
557
|
+
('IRF centre, ns', 'irf_center', '2.0'),
|
|
558
|
+
('Reflection at, ns (blank = none)', 'refl_ns', ''),
|
|
559
|
+
('Reflection fraction', 'refl_frac', '0.02'),
|
|
560
|
+
('Pile-up, photons/pulse (blank = none)', 'pileup', ''),
|
|
561
|
+
('Image side, px', 'image', '16'),
|
|
562
|
+
('Base name', 'name', 'synth'),
|
|
563
|
+
]
|
|
564
|
+
vals = {}
|
|
565
|
+
frm = ttk.Frame(dlg, padding=12)
|
|
566
|
+
frm.pack(fill='both', expand=True)
|
|
567
|
+
for i, (label, key, default) in enumerate(fields):
|
|
568
|
+
ttk.Label(frm, text=label).grid(row=i, column=0, sticky='w', pady=2, padx=(0, 8))
|
|
569
|
+
v = tk.StringVar(value=default)
|
|
570
|
+
ttk.Entry(frm, textvariable=v, width=22).grid(row=i, column=1, sticky='ew', pady=2)
|
|
571
|
+
vals[key] = v
|
|
572
|
+
also_sdt = tk.BooleanVar(value=False)
|
|
573
|
+
ttk.Checkbutton(frm, text='Also write Becker & Hickl .sdt', variable=also_sdt).grid(
|
|
574
|
+
row=len(fields), column=0, columnspan=2, sticky='w', pady=(6, 0))
|
|
575
|
+
status = tk.StringVar(value='')
|
|
576
|
+
ttk.Label(frm, textvariable=status, foreground='grey').grid(
|
|
577
|
+
row=len(fields) + 1, column=0, columnspan=2, sticky='w', pady=(8, 0))
|
|
578
|
+
|
|
579
|
+
def _floats(s):
|
|
580
|
+
return [float(x) for x in str(s).split(',') if x.strip()]
|
|
581
|
+
|
|
582
|
+
def do_generate():
|
|
583
|
+
from tkinter import filedialog
|
|
584
|
+
out_dir = filedialog.askdirectory(title='Choose output folder for the PTUs')
|
|
585
|
+
if not out_dir:
|
|
586
|
+
return
|
|
587
|
+
try:
|
|
588
|
+
taus = _floats(vals['tau'].get())
|
|
589
|
+
tau_arg = taus[0] if len(taus) == 1 else taus
|
|
590
|
+
amps = _floats(vals['amps'].get()) or None
|
|
591
|
+
res_ns = float(vals['res'].get()) / 1000.0
|
|
592
|
+
n_bins = int(round(float(vals['period'].get()) / res_ns))
|
|
593
|
+
refl = None
|
|
594
|
+
if vals['refl_ns'].get().strip():
|
|
595
|
+
refl = dict(center_ns=float(vals['refl_ns'].get()),
|
|
596
|
+
frac=float(vals['refl_frac'].get()), width_ns=0.15)
|
|
597
|
+
pileup = float(vals['pileup'].get()) if vals['pileup'].get().strip() else None
|
|
598
|
+
side = int(vals['image'].get())
|
|
599
|
+
common = dict(tau_ns=tau_arg, amps=amps, n_bins=n_bins, tcspc_res_ns=res_ns,
|
|
600
|
+
irf_fwhm_ns=float(vals['irf_fwhm'].get()),
|
|
601
|
+
irf_center_ns=float(vals['irf_center'].get()), pileup_pp=pileup)
|
|
602
|
+
photons = _floats(vals['photons'].get())
|
|
603
|
+
if len(photons) == 1:
|
|
604
|
+
synth.generate(out_dir, name=vals['name'].get(), ny=side, nx=side,
|
|
605
|
+
n_photons=photons[0], reflection=refl,
|
|
606
|
+
sdt=also_sdt.get(), **common)
|
|
607
|
+
n_files = 1
|
|
608
|
+
else:
|
|
609
|
+
synth.generate_series(out_dir, photons, name=vals['name'].get(),
|
|
610
|
+
with_reflection=refl is not None, reflection=refl,
|
|
611
|
+
ny=side, nx=side, sdt=also_sdt.get(), **common)
|
|
612
|
+
n_files = len(photons)
|
|
613
|
+
fmt = 'PTU + SDT' if also_sdt.get() else 'PTU'
|
|
614
|
+
messagebox.showinfo('Synthetic data',
|
|
615
|
+
f'Wrote {n_files} sample(s) ({fmt}) + IRF + truth JSON to\n{out_dir}')
|
|
616
|
+
dlg.destroy()
|
|
617
|
+
except Exception as e:
|
|
618
|
+
import traceback
|
|
619
|
+
traceback.print_exc()
|
|
620
|
+
status.set(f'Error: {e}')
|
|
621
|
+
|
|
622
|
+
btns = ttk.Frame(dlg)
|
|
623
|
+
btns.pack(fill='x', padx=12, pady=(0, 12))
|
|
624
|
+
ttk.Button(btns, text='Generate...', command=do_generate).pack(side='left', padx=4)
|
|
625
|
+
ttk.Button(btns, text='Cancel', command=dlg.destroy).pack(side='left', padx=4)
|
|
626
|
+
dlg.update_idletasks()
|
|
627
|
+
|
|
628
|
+
def _menu_batch_processing(self, mode: str = 'tiled'):
|
|
629
|
+
print(f'[Menu] Batch Processing → {mode}')
|
|
630
|
+
self.sv_batch_mode.set(mode)
|
|
631
|
+
self._batch_mode_changed()
|
|
632
|
+
if hasattr(self, '_switch_form'):
|
|
633
|
+
self._switch_form('batch')
|
|
634
|
+
|
|
635
|
+
def _menu_about(self):
|
|
636
|
+
from flimkit._version import __version__
|
|
637
|
+
about_text = f'''FLIMKit Analysis GUI
|
|
638
|
+
|
|
639
|
+
Version: {__version__}
|
|
640
|
+
|
|
641
|
+
A comprehensive FLIM data analysis platform with:
|
|
642
|
+
• Single FOV & tile stitching
|
|
643
|
+
• ROI-based lifetime analysis
|
|
644
|
+
• Machine IRF calibration
|
|
645
|
+
• Batch processing
|
|
646
|
+
• GeoJSON & CSV export
|
|
647
|
+
|
|
648
|
+
Built with Python, Tkinter, NumPy, and SciPy.
|
|
649
|
+
|
|
650
|
+
Designed, developed, and maintained by Alex Hunt.
|
|
651
|
+
Anthropic's Claude AI assisted with parts of the GUI implementation.
|
|
652
|
+
'''
|
|
653
|
+
messagebox.showinfo('About FLIMKit', about_text)
|
|
654
|
+
|
|
655
|
+
def _menu_documentation(self):
|
|
656
|
+
import webbrowser
|
|
657
|
+
import os
|
|
658
|
+
doc_file = os.path.join(os.path.dirname(__file__), '../../documentation.md')
|
|
659
|
+
if os.path.exists(doc_file):
|
|
660
|
+
webbrowser.open('file://' + os.path.realpath(doc_file))
|
|
661
|
+
else:
|
|
662
|
+
messagebox.showinfo('Documentation', 'See README.md in the project root for documentation.')
|
|
663
|
+
|
|
664
|
+
def _menu_check_updates(self):
|
|
665
|
+
status_win = tk.Toplevel(self.root)
|
|
666
|
+
status_win.title('Checking for Updates')
|
|
667
|
+
status_win.geometry('380x120')
|
|
668
|
+
status_win.resizable(False, False)
|
|
669
|
+
ttk.Label(
|
|
670
|
+
status_win,
|
|
671
|
+
text='Checking git status and latest available release...',
|
|
672
|
+
wraplength=340,
|
|
673
|
+
justify='left',
|
|
674
|
+
).pack(padx=16, pady=(18, 10), anchor='w')
|
|
675
|
+
pb = ttk.Progressbar(status_win, mode='indeterminate')
|
|
676
|
+
pb.pack(fill='x', padx=16, pady=(0, 14))
|
|
677
|
+
pb.start(12)
|
|
678
|
+
|
|
679
|
+
def _worker():
|
|
680
|
+
try:
|
|
681
|
+
from flimkit.utils.update_check import (
|
|
682
|
+
check_installation_freshness,
|
|
683
|
+
format_update_report,
|
|
684
|
+
)
|
|
685
|
+
report = format_update_report(
|
|
686
|
+
check_installation_freshness(timeout=3.0, do_fetch=True)
|
|
687
|
+
)
|
|
688
|
+
err = None
|
|
689
|
+
except Exception as exc:
|
|
690
|
+
report = ''
|
|
691
|
+
err = str(exc)
|
|
692
|
+
|
|
693
|
+
def _done():
|
|
694
|
+
try:
|
|
695
|
+
pb.stop()
|
|
696
|
+
status_win.destroy()
|
|
697
|
+
except Exception:
|
|
698
|
+
pass
|
|
699
|
+
if err is not None:
|
|
700
|
+
messagebox.showerror('Update Check', f'Update check failed: {err}')
|
|
701
|
+
return
|
|
702
|
+
win = tk.Toplevel(self.root)
|
|
703
|
+
win.title('Update Check')
|
|
704
|
+
win.geometry('760x420')
|
|
705
|
+
text_widget = scrolledtext.ScrolledText(win, wrap=tk.WORD)
|
|
706
|
+
text_widget.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)
|
|
707
|
+
text_widget.insert(tk.END, report)
|
|
708
|
+
text_widget.config(state=tk.DISABLED)
|
|
709
|
+
self.root.after(0, _done)
|
|
710
|
+
threading.Thread(target=_worker, daemon=True).start()
|
|
711
|
+
|
|
712
|
+
def _plugin_report_text(self):
|
|
713
|
+
from flimkit import plugins
|
|
714
|
+
lines = []
|
|
715
|
+
for result in plugins.load_report():
|
|
716
|
+
if result.ok:
|
|
717
|
+
lines.append(f'loaded {result.source} ({result.n_registered} registration(s))')
|
|
718
|
+
else:
|
|
719
|
+
lines.append(f'FAILED {result.source}')
|
|
720
|
+
if not lines:
|
|
721
|
+
lines.append('No plugins loaded.')
|
|
722
|
+
home = plugins.user_dir()
|
|
723
|
+
lines.append('')
|
|
724
|
+
lines.append(f'User plugin folder: {home}')
|
|
725
|
+
if plugins.user_plugins_allowed():
|
|
726
|
+
lines.append('User plugins are enabled.')
|
|
727
|
+
else:
|
|
728
|
+
waiting = plugins.pending_user_plugins()
|
|
729
|
+
lines.append(f'User plugins are disabled, {len(waiting)} file(s) waiting there.')
|
|
730
|
+
for name in plugins.skipped():
|
|
731
|
+
lines.append(f'skipped {name} (disabled)')
|
|
732
|
+
for directory in plugins.extra_dirs():
|
|
733
|
+
lines.append(f'FLIMKIT_PLUGIN_PATH: {directory}')
|
|
734
|
+
for result in plugins.failures():
|
|
735
|
+
lines.append('')
|
|
736
|
+
lines.append(f'--- {result.source} ---')
|
|
737
|
+
lines.append(result.error.strip())
|
|
738
|
+
return '\n'.join(lines)
|
|
739
|
+
|
|
740
|
+
def _menu_plugins(self):
|
|
741
|
+
from flimkit import plugins
|
|
742
|
+
from tkinter.scrolledtext import ScrolledText
|
|
743
|
+
win = tk.Toplevel(self.root)
|
|
744
|
+
win.title('Plugins')
|
|
745
|
+
win.geometry('700x520')
|
|
746
|
+
text_widget = ScrolledText(win, wrap=tk.WORD, height=14)
|
|
747
|
+
text_widget.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
|
|
748
|
+
text_widget.insert(tk.END, self._plugin_report_text())
|
|
749
|
+
text_widget.config(state=tk.DISABLED)
|
|
750
|
+
box = ttk.LabelFrame(win, text='Load on next start')
|
|
751
|
+
box.pack(fill=tk.X, padx=4, pady=4)
|
|
752
|
+
off = plugins.disabled_plugins()
|
|
753
|
+
names = sorted({plugins.short_name(r.source) for r in plugins.load_report()} | set(off))
|
|
754
|
+
for name in names:
|
|
755
|
+
var = tk.BooleanVar(value=name not in off)
|
|
756
|
+
ttk.Checkbutton(
|
|
757
|
+
box, text=name, variable=var,
|
|
758
|
+
command=lambda n=name, v=var: plugins.set_plugin_disabled(n, v.get() == False),
|
|
759
|
+
).pack(anchor='w', padx=6)
|
|
760
|
+
row = ttk.Frame(win)
|
|
761
|
+
row.pack(fill=tk.X, padx=4, pady=(0, 6))
|
|
762
|
+
if plugins.user_plugins_allowed() == False:
|
|
763
|
+
ttk.Button(row, text='Enable user plugins',
|
|
764
|
+
command=lambda: self._enable_user_plugins(win)).pack(side=tk.LEFT)
|
|
765
|
+
ttk.Button(row, text='Close', command=win.destroy).pack(side=tk.RIGHT)
|
|
766
|
+
|
|
767
|
+
def _maybe_prompt_user_plugins(self):
|
|
768
|
+
from flimkit import plugins
|
|
769
|
+
from flimkit.utils.config_manager import cfg
|
|
770
|
+
if cfg.get('plugins.prompted', False) == True:
|
|
771
|
+
return
|
|
772
|
+
if not plugins.pending_user_plugins():
|
|
773
|
+
return
|
|
774
|
+
cfg.set('plugins.prompted', True)
|
|
775
|
+
cfg.save()
|
|
776
|
+
self._enable_user_plugins()
|
|
777
|
+
|
|
778
|
+
def _enable_user_plugins(self, win=None):
|
|
779
|
+
from flimkit import plugins
|
|
780
|
+
agreed = messagebox.askyesno(
|
|
781
|
+
'Enable user plugins',
|
|
782
|
+
f'Every .py file in {plugins.user_dir()} will be imported at startup.\n\n'
|
|
783
|
+
'A plugin is ordinary Python with the same access to your machine as '
|
|
784
|
+
'FLIMKit itself. There is no sandbox. Only enable this for files you '
|
|
785
|
+
'trust.\n\nEnable and restart to load them?')
|
|
786
|
+
if agreed == False:
|
|
787
|
+
return
|
|
788
|
+
plugins.allow_user_plugins(True)
|
|
789
|
+
if win is not None:
|
|
790
|
+
win.destroy()
|
|
791
|
+
messagebox.showinfo('Enabled', 'User plugins load on the next start of FLIMKit.')
|
|
792
|
+
|
|
793
|
+
def _menu_view_error_logs(self):
|
|
794
|
+
from flimkit.utils.crash_handler import build_export_report, get_log_dir
|
|
795
|
+
import glob
|
|
796
|
+
log_dir = get_log_dir()
|
|
797
|
+
log_files = glob.glob(os.path.join(log_dir, '*.log')) if os.path.exists(log_dir) else []
|
|
798
|
+
if log_files:
|
|
799
|
+
try:
|
|
800
|
+
report = build_export_report(include_all_sessions=False)
|
|
801
|
+
from tkinter.scrolledtext import ScrolledText
|
|
802
|
+
win = tk.Toplevel(self.root)
|
|
803
|
+
win.title('Error Logs - Current Session')
|
|
804
|
+
win.geometry('700x500')
|
|
805
|
+
text_widget = ScrolledText(win, wrap=tk.WORD)
|
|
806
|
+
text_widget.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
|
|
807
|
+
text_widget.insert(tk.END, report)
|
|
808
|
+
text_widget.config(state=tk.DISABLED)
|
|
809
|
+
except Exception as e:
|
|
810
|
+
messagebox.showerror('Error', f'Could not read log file: {e}')
|
|
811
|
+
else:
|
|
812
|
+
messagebox.showinfo('No Logs', 'No error logs found.')
|
|
813
|
+
|
|
814
|
+
def _menu_export_error_logs(self):
|
|
815
|
+
from flimkit.utils.crash_handler import build_export_report, get_log_dir
|
|
816
|
+
import glob
|
|
817
|
+
log_dir = get_log_dir()
|
|
818
|
+
log_files = glob.glob(os.path.join(log_dir, '*.log')) if os.path.exists(log_dir) else []
|
|
819
|
+
if not log_files:
|
|
820
|
+
messagebox.showwarning('No Logs', 'No error logs found to export.')
|
|
821
|
+
return
|
|
822
|
+
export_file = filedialog.asksaveasfilename(
|
|
823
|
+
title='Save Error Report',
|
|
824
|
+
defaultextension='.log',
|
|
825
|
+
filetypes=[('Log files', '*.log'), ('Text files', '*.txt'), ('All files', '*.*')]
|
|
826
|
+
)
|
|
827
|
+
if export_file:
|
|
828
|
+
try:
|
|
829
|
+
report = build_export_report(include_all_sessions=True)
|
|
830
|
+
with open(export_file, 'w', encoding='utf-8') as out_f:
|
|
831
|
+
out_f.write(report)
|
|
832
|
+
messagebox.showinfo('Export Success', f'Error report exported to:\n{export_file}')
|
|
833
|
+
print(f'[Menu] Error report exported to: {export_file}')
|
|
834
|
+
except Exception as e:
|
|
835
|
+
messagebox.showerror('Export Error', f'Failed to export logs: {e}')
|
|
836
|
+
|
|
837
|
+
def _find_scroll_canvas(self, widget):
|
|
838
|
+
w = widget
|
|
839
|
+
for _ in range(30):
|
|
840
|
+
try:
|
|
841
|
+
if hasattr(w, '_canvas'):
|
|
842
|
+
return w._canvas
|
|
843
|
+
w = w.master
|
|
844
|
+
if w is None:
|
|
845
|
+
break
|
|
846
|
+
except Exception:
|
|
847
|
+
break
|
|
848
|
+
return None
|
|
849
|
+
|
|
850
|
+
def _setup_global_scroll(self):
|
|
851
|
+
def _scroll(evt):
|
|
852
|
+
try:
|
|
853
|
+
widget = self.root.winfo_containing(evt.x_root, evt.y_root)
|
|
854
|
+
if widget is None:
|
|
855
|
+
return
|
|
856
|
+
canvas = self._find_scroll_canvas(widget)
|
|
857
|
+
if canvas is None:
|
|
858
|
+
return
|
|
859
|
+
sr = canvas.cget('scrollregion')
|
|
860
|
+
if not sr:
|
|
861
|
+
return
|
|
862
|
+
delta = evt.delta if hasattr(evt, 'delta') else 0
|
|
863
|
+
if evt.num == 5 or delta < 0:
|
|
864
|
+
canvas.yview_scroll(3, 'units')
|
|
865
|
+
elif evt.num == 4 or delta > 0:
|
|
866
|
+
canvas.yview_scroll(-3, 'units')
|
|
867
|
+
except Exception:
|
|
868
|
+
pass
|
|
869
|
+
for seq in ('<MouseWheel>', '<Button-4>', '<Button-5>'):
|
|
870
|
+
self.root.bind_all(seq, _scroll, add='+')
|
|
871
|
+
|
|
872
|
+
def _setup_global_dnd(self):
|
|
873
|
+
if not HAS_DND:
|
|
874
|
+
return
|
|
875
|
+
try:
|
|
876
|
+
from tkinterdnd2 import DND_FILES, DND_TEXT
|
|
877
|
+
except ImportError:
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
def _clean(data: str) -> str:
|
|
881
|
+
data = data.strip()
|
|
882
|
+
if data.startswith('{'):
|
|
883
|
+
end = data.find('}')
|
|
884
|
+
if end != -1:
|
|
885
|
+
return data[1:end].strip()
|
|
886
|
+
parts = data.split()
|
|
887
|
+
if parts:
|
|
888
|
+
return parts[0]
|
|
889
|
+
return data
|
|
890
|
+
|
|
891
|
+
def _register(widget):
|
|
892
|
+
try:
|
|
893
|
+
widget.drop_target_register(DND_FILES, DND_TEXT)
|
|
894
|
+
tv_name = widget.cget('textvariable')
|
|
895
|
+
if tv_name:
|
|
896
|
+
def _drop(evt, w=widget):
|
|
897
|
+
path = _clean(evt.data)
|
|
898
|
+
try:
|
|
899
|
+
w.delete(0, 'end')
|
|
900
|
+
w.insert(0, path)
|
|
901
|
+
w.event_generate('<<Modified>>')
|
|
902
|
+
except Exception:
|
|
903
|
+
pass
|
|
904
|
+
widget.dnd_bind('<<Drop>>', _drop)
|
|
905
|
+
except Exception:
|
|
906
|
+
pass
|
|
907
|
+
|
|
908
|
+
def _walk(widget):
|
|
909
|
+
try:
|
|
910
|
+
cls = widget.winfo_class()
|
|
911
|
+
if cls in ('Entry', 'TEntry'):
|
|
912
|
+
_register(widget)
|
|
913
|
+
for child in widget.winfo_children():
|
|
914
|
+
_walk(child)
|
|
915
|
+
except Exception:
|
|
916
|
+
pass
|
|
917
|
+
_walk(self.root)
|
|
918
|
+
print('[DnD] Drop targets registered on all Entry widgets')
|
|
919
|
+
|
|
920
|
+
def _init_ui(self):
|
|
921
|
+
from flimkit.utils.crash_handler import install_tk_error_handler
|
|
922
|
+
install_tk_error_handler(self.root)
|
|
923
|
+
_enable_dnd(self.root)
|
|
924
|
+
self.state = AppState()
|
|
925
|
+
self._mode_controller = ModeController(self)
|
|
926
|
+
self._controller = FLIMKitController(self)
|
|
927
|
+
self._buf: list = []
|
|
928
|
+
self._current_session_file = None
|
|
929
|
+
self._current_npz_path = None
|
|
930
|
+
self._last_loaded_ptu = None
|
|
931
|
+
self._last_loaded_xlif = None
|
|
932
|
+
self._ptu_after_id = None
|
|
933
|
+
self._xlif_after_id = None
|
|
934
|
+
self._form_buttons = {}
|
|
935
|
+
self._form_frames = {}
|
|
936
|
+
self._form_inner_frames = {}
|
|
937
|
+
self._build_menu_bar()
|
|
938
|
+
self._mode_toolbar = ttk.Frame(self.root)
|
|
939
|
+
self._mode_toolbar.grid(row=0, column=0, sticky='ew', padx=10, pady=(2, 0))
|
|
940
|
+
self._mode_toolbar.columnconfigure(0, weight=1)
|
|
941
|
+
ttk.Label(self._mode_toolbar, text='Mode:', font=('TkDefaultFont', 9, 'bold')).pack(side='left', padx=(0, 10))
|
|
942
|
+
self.state.current_mode = tk.StringVar(value='fov')
|
|
943
|
+
btn_fov = ttk.Button(self._mode_toolbar, text='Single FOV Fit', width=16,
|
|
944
|
+
command=lambda: self._switch_form('fov'))
|
|
945
|
+
btn_fov.pack(side='left', padx=2)
|
|
946
|
+
self._form_buttons['fov'] = btn_fov
|
|
947
|
+
btn_stitch = ttk.Button(self._mode_toolbar, text='Tile Stitch/Fit', width=16,
|
|
948
|
+
command=lambda: self._switch_form('stitch'))
|
|
949
|
+
btn_stitch.pack(side='left', padx=2)
|
|
950
|
+
self._form_buttons['stitch'] = btn_stitch
|
|
951
|
+
btn_phasor = ttk.Button(self._mode_toolbar, text='Phasor Analysis', width=16,
|
|
952
|
+
command=lambda: self._switch_form('phasor'))
|
|
953
|
+
btn_phasor.pack(side='left', padx=2)
|
|
954
|
+
self._form_buttons['phasor'] = btn_phasor
|
|
955
|
+
ttk.Separator(self._mode_toolbar, orient='vertical').pack(side='left', fill='y', padx=10, pady=2)
|
|
956
|
+
self.state.mode_status = tk.StringVar(value='Current: Single FOV Fit')
|
|
957
|
+
ttk.Label(self._mode_toolbar, textvariable=self.mode_status, foreground='grey').pack(side='left', padx=10)
|
|
958
|
+
self._form_labels = {'fov': 'Single FOV Fit', 'stitch': 'Tile Stitch/Fit', 'phasor': 'Phasor Analysis'}
|
|
959
|
+
ttk.Separator(self.root, orient='horizontal').grid(row=1, column=0, sticky='ew', pady=(2, 0))
|
|
960
|
+
self._main_paned = ttk.PanedWindow(self.root, orient='horizontal')
|
|
961
|
+
self._main_paned.grid(row=2, column=0, sticky='nsew', padx=4, pady=(2, 4))
|
|
962
|
+
self.root.columnconfigure(0, weight=1)
|
|
963
|
+
self.root.rowconfigure(0, weight=0, minsize=0)
|
|
964
|
+
self.root.rowconfigure(1, weight=0, minsize=0)
|
|
965
|
+
self.root.rowconfigure(2, weight=1)
|
|
966
|
+
self._left_paned = ttk.PanedWindow(self._main_paned, orient='horizontal')
|
|
967
|
+
self._main_paned.add(self._left_paned, weight=3)
|
|
968
|
+
btn_frame = ttk.Frame(self._left_paned)
|
|
969
|
+
self._left_paned.add(btn_frame, weight=1)
|
|
970
|
+
btn_frame.columnconfigure(0, weight=1)
|
|
971
|
+
btn_frame.rowconfigure(0, weight=1)
|
|
972
|
+
self._proj_browser = ProjectBrowserPanel(btn_frame, app=self, width=170)
|
|
973
|
+
self._proj_browser.frame.grid(row=0, column=0, sticky='nsew')
|
|
974
|
+
content_paned = ttk.PanedWindow(self._left_paned, orient='vertical')
|
|
975
|
+
self._left_paned.add(content_paned, weight=2)
|
|
976
|
+
form_wrapper = ttk.Frame(content_paned)
|
|
977
|
+
content_paned.add(form_wrapper, weight=3)
|
|
978
|
+
form_wrapper.columnconfigure(0, weight=1)
|
|
979
|
+
form_wrapper.rowconfigure(0, weight=1)
|
|
980
|
+
self._analysis_tabs = ttk.Notebook(form_wrapper)
|
|
981
|
+
self._analysis_tabs.grid(row=0, column=0, sticky='nsew')
|
|
982
|
+
self._analysis_tabs.grid_remove()
|
|
983
|
+
fit_settings_outer = ttk.Frame(self._analysis_tabs)
|
|
984
|
+
self._analysis_tabs.add(fit_settings_outer, text=' Fit Settings ')
|
|
985
|
+
fit_settings_outer.columnconfigure(0, weight=1)
|
|
986
|
+
fit_settings_outer.rowconfigure(0, weight=1)
|
|
987
|
+
self._fit_settings_tab = fit_settings_outer
|
|
988
|
+
roi_frame = ttk.Frame(self._analysis_tabs, padding=4)
|
|
989
|
+
self._analysis_tabs.add(roi_frame, text=' ROI Analysis ')
|
|
990
|
+
roi_frame.columnconfigure(0, weight=1)
|
|
991
|
+
roi_frame.rowconfigure(0, weight=1)
|
|
992
|
+
self._roi_analysis_panel = RoiAnalysisPanel(roi_frame)
|
|
993
|
+
self._roi_analysis_panel.app = self
|
|
994
|
+
self._roi_analysis_panel.grid(row=0, column=0, sticky='nsew')
|
|
995
|
+
self._roi_analysis_frame = roi_frame
|
|
996
|
+
self._stitch_tabs = ttk.Notebook(form_wrapper)
|
|
997
|
+
self._stitch_tabs.grid(row=0, column=0, sticky='nsew')
|
|
998
|
+
self._stitch_tabs.grid_remove()
|
|
999
|
+
stitch_settings_outer = ttk.Frame(self._stitch_tabs)
|
|
1000
|
+
self._stitch_tabs.add(stitch_settings_outer, text=' Fit Settings ')
|
|
1001
|
+
stitch_settings_outer.columnconfigure(0, weight=1)
|
|
1002
|
+
stitch_settings_outer.rowconfigure(0, weight=1)
|
|
1003
|
+
self._stitch_settings_tab = stitch_settings_outer
|
|
1004
|
+
stitch_roi_frame = ttk.Frame(self._stitch_tabs, padding=4)
|
|
1005
|
+
self._stitch_tabs.add(stitch_roi_frame, text=' ROI Analysis ')
|
|
1006
|
+
stitch_roi_frame.columnconfigure(0, weight=1)
|
|
1007
|
+
stitch_roi_frame.rowconfigure(0, weight=1)
|
|
1008
|
+
self._stitch_roi_analysis_frame = stitch_roi_frame
|
|
1009
|
+
self._form_content_frame = form_wrapper
|
|
1010
|
+
for _fid in ('batch', 'irf'):
|
|
1011
|
+
_outer, _inner = self._make_scroll_frame(form_wrapper)
|
|
1012
|
+
_outer.grid(row=0, column=0, sticky='nsew')
|
|
1013
|
+
_outer.grid_remove()
|
|
1014
|
+
self._form_inner_frames[_fid] = (_outer, _inner)
|
|
1015
|
+
self._form_frames[_fid] = (_outer, _inner)
|
|
1016
|
+
form_list = [
|
|
1017
|
+
('fov', 'Single FOV Fit'),
|
|
1018
|
+
('stitch', 'Tile Stitch/Fit'),
|
|
1019
|
+
('phasor', 'Phasor Analysis'),
|
|
1020
|
+
('batch', 'Batch Processing'),
|
|
1021
|
+
('irf', 'Machine IRF Builder'),
|
|
1022
|
+
]
|
|
1023
|
+
for form_id, form_label in form_list:
|
|
1024
|
+
if form_id == 'fov':
|
|
1025
|
+
outer, inner = self._make_scroll_frame(self._fit_settings_tab)
|
|
1026
|
+
elif form_id == 'stitch':
|
|
1027
|
+
outer, inner = self._make_scroll_frame(self._stitch_settings_tab)
|
|
1028
|
+
else:
|
|
1029
|
+
outer, inner = self._make_scroll_frame(form_wrapper)
|
|
1030
|
+
outer.grid(row=0, column=0, sticky='nsew')
|
|
1031
|
+
outer.grid_remove()
|
|
1032
|
+
self._form_inner_frames[form_id] = (outer, inner)
|
|
1033
|
+
self._form_frames[form_id] = (outer, inner)
|
|
1034
|
+
results_frame = ttk.Frame(content_paned)
|
|
1035
|
+
content_paned.add(results_frame, weight=1)
|
|
1036
|
+
results_frame.columnconfigure(0, weight=1)
|
|
1037
|
+
results_frame.rowconfigure(0, weight=1)
|
|
1038
|
+
self._res = ResultsPanel(results_frame, root=self.root)
|
|
1039
|
+
self._res.grid(row=0, column=0, sticky='nsew')
|
|
1040
|
+
self._res.set_export_callback(self._show_export_dialog)
|
|
1041
|
+
self._res.set_load_callback(self._load_fitted_data_from_file)
|
|
1042
|
+
self._res.set_save_npz_callback(self._save_npz_quick)
|
|
1043
|
+
from flimkit.utils.config_manager import cfg as _cfg_mgr
|
|
1044
|
+
_saved_expert = _cfg_mgr.get_section('expert')
|
|
1045
|
+
_is_default = all(_saved_expert.get(k) == v for k, v in _EXPERT_DEFAULTS.items())
|
|
1046
|
+
self._expert_overrides: dict = {} if _is_default else _saved_expert
|
|
1047
|
+
self._build_fov_tab()
|
|
1048
|
+
self._build_stitch_tab()
|
|
1049
|
+
self._build_phasor_tab()
|
|
1050
|
+
self._build_batch_tab()
|
|
1051
|
+
self._build_machine_irf_tab()
|
|
1052
|
+
preview_frame = ttk.LabelFrame(self._main_paned, text=' FOV Preview ', padding=4)
|
|
1053
|
+
self._main_paned.add(preview_frame, weight=2)
|
|
1054
|
+
preview_frame.columnconfigure(0, weight=1)
|
|
1055
|
+
preview_frame.rowconfigure(0, weight=1)
|
|
1056
|
+
self._fov_preview = FOVPreviewPanel(preview_frame)
|
|
1057
|
+
self._fov_preview.grid(row=0, column=0, sticky='nsew')
|
|
1058
|
+
self._roi_analysis_panel.fov_preview = self._fov_preview
|
|
1059
|
+
self._fov_preview._roi_analysis_panel = self._roi_analysis_panel
|
|
1060
|
+
self._roi_analysis_panel.run_with_progress = self.run_with_progress
|
|
1061
|
+
self._roi_analysis_panel.get_fit_params = self._get_roi_fit_params
|
|
1062
|
+
self._phasor_panel = PhasorViewPanel(preview_frame, max_cursors=6)
|
|
1063
|
+
self._phasor_panel.on_change = self._on_phasor_change
|
|
1064
|
+
self._phasor_panel.frame.grid(row=0, column=0, sticky='nsew')
|
|
1065
|
+
self._phasor_panel.frame.grid_remove()
|
|
1066
|
+
self._phasor_panel.run_with_progress = self.run_with_progress
|
|
1067
|
+
self._phasor_panel.get_fit_params = self._get_roi_fit_params
|
|
1068
|
+
self._preview_frame_label = preview_frame
|
|
1069
|
+
self._switch_form('fov')
|
|
1070
|
+
redir = _Redirect(self._res.log, self._buf, root=self.root)
|
|
1071
|
+
sys.stdout = redir
|
|
1072
|
+
sys.stderr = redir
|
|
1073
|
+
self.root.after_idle(self._fit_window_to_screen)
|
|
1074
|
+
self.root.after_idle(self._set_pane_positions)
|
|
1075
|
+
self._setup_global_scroll()
|
|
1076
|
+
self.root.after(500, self._setup_global_dnd)
|
|
1077
|
+
self.root.protocol('WM_DELETE_WINDOW', self._on_close)
|
|
1078
|
+
self._set_window_icon()
|
|
1079
|
+
|
|
1080
|
+
def _refresh_scrollable_frame(self, form_id: str):
|
|
1081
|
+
if form_id not in self._form_inner_frames:
|
|
1082
|
+
return
|
|
1083
|
+
outer, inner = self._form_inner_frames[form_id]
|
|
1084
|
+
if not hasattr(outer, '_canvas'):
|
|
1085
|
+
return
|
|
1086
|
+
if hasattr(outer, '_refresh_scheduled') and outer._refresh_scheduled:
|
|
1087
|
+
return
|
|
1088
|
+
outer._refresh_scheduled = True
|
|
1089
|
+
|
|
1090
|
+
def do_refresh():
|
|
1091
|
+
try:
|
|
1092
|
+
self.root.update_idletasks()
|
|
1093
|
+
if form_id in ('fov', 'stitch'):
|
|
1094
|
+
self._fit_settings_tab.update_idletasks()
|
|
1095
|
+
self._analysis_tabs.update_idletasks()
|
|
1096
|
+
if not hasattr(outer, '_canvas') or not hasattr(outer, '_window_id'):
|
|
1097
|
+
return
|
|
1098
|
+
canvas = outer._canvas
|
|
1099
|
+
window_id = outer._window_id
|
|
1100
|
+
outer.update()
|
|
1101
|
+
canvas_width = canvas.winfo_width()
|
|
1102
|
+
canvas_height = canvas.winfo_height()
|
|
1103
|
+
if canvas_width > 1:
|
|
1104
|
+
canvas.itemconfigure(window_id, width=canvas_width)
|
|
1105
|
+
inner.update()
|
|
1106
|
+
inner_height = inner.winfo_reqheight()
|
|
1107
|
+
if inner_height > 1:
|
|
1108
|
+
canvas.itemconfigure(window_id, height=inner_height)
|
|
1109
|
+
bbox = canvas.bbox('all')
|
|
1110
|
+
if bbox:
|
|
1111
|
+
canvas.configure(scrollregion=bbox)
|
|
1112
|
+
else:
|
|
1113
|
+
h = max(canvas_height, inner_height, 300) if inner_height > 1 else 500
|
|
1114
|
+
w = max(canvas_width, 300) if canvas_width > 1 else 400
|
|
1115
|
+
canvas.configure(scrollregion=(0, 0, w, h))
|
|
1116
|
+
canvas.yview_moveto(0)
|
|
1117
|
+
canvas.update()
|
|
1118
|
+
except Exception:
|
|
1119
|
+
pass
|
|
1120
|
+
finally:
|
|
1121
|
+
outer._refresh_scheduled = False
|
|
1122
|
+
self.root.after(200, do_refresh)
|
|
1123
|
+
|
|
1124
|
+
def _switch_form(self, form_id: str):
|
|
1125
|
+
self._mode_controller.switch(form_id)
|
|
1126
|
+
|
|
1127
|
+
def _set_window_icon(self):
|
|
1128
|
+
base_path = Path(sys._MEIPASS) if hasattr(sys, '_MEIPASS') else Path(__file__).parent
|
|
1129
|
+
icon_paths = [
|
|
1130
|
+
base_path / 'flimkit' / 'icon.png',
|
|
1131
|
+
base_path / 'icon.png',
|
|
1132
|
+
Path(__file__).parent / 'flimkit' / 'icon.png',
|
|
1133
|
+
Path(__file__).parent / 'icon.png',
|
|
1134
|
+
]
|
|
1135
|
+
for icon_path in icon_paths:
|
|
1136
|
+
if icon_path.exists():
|
|
1137
|
+
try:
|
|
1138
|
+
from PIL import Image, ImageTk
|
|
1139
|
+
icon_img = Image.open(str(icon_path))
|
|
1140
|
+
icon_img.thumbnail((32, 32), Image.Resampling.LANCZOS)
|
|
1141
|
+
photo = ImageTk.PhotoImage(icon_img)
|
|
1142
|
+
self.root.iconphoto(False, photo)
|
|
1143
|
+
self.root._icon_photo = photo
|
|
1144
|
+
break
|
|
1145
|
+
except Exception as e:
|
|
1146
|
+
print(f'Warning: Could not load icon from {icon_path}: {e}')
|
|
1147
|
+
continue
|
|
1148
|
+
|
|
1149
|
+
def _on_close(self):
|
|
1150
|
+
sys.stdout = sys.__stdout__
|
|
1151
|
+
sys.stderr = sys.__stderr__
|
|
1152
|
+
self.root.destroy()
|
|
1153
|
+
|
|
1154
|
+
def _capture_form_state(self) -> dict:
|
|
1155
|
+
try:
|
|
1156
|
+
state = {
|
|
1157
|
+
'active_form': getattr(self, '_current_form', 'fov'),
|
|
1158
|
+
'ptu_file': self.sv_ptu.get() if hasattr(self, 'sv_ptu') else '',
|
|
1159
|
+
'xlsx_file': self.sv_xlsx.get() if hasattr(self, 'sv_xlsx') else '',
|
|
1160
|
+
'irf_method': self._irf_fov.sv_method.get() if hasattr(self, '_irf_fov') else 'irf_xlsx',
|
|
1161
|
+
'irf_file': self._irf_fov.sv_path.get() if hasattr(self, '_irf_fov') else '',
|
|
1162
|
+
'nexp_fov': self.iv_nexp_fov.get() if hasattr(self, 'iv_nexp_fov') else 3,
|
|
1163
|
+
'fit_model_fov': self.sv_fit_model_fov.get() if hasattr(self, 'sv_fit_model_fov') else 'discrete',
|
|
1164
|
+
'ncomp_dist_fov': self.iv_ncomp_dist_fov.get() if hasattr(self, 'iv_ncomp_dist_fov') else 1,
|
|
1165
|
+
'tau_fit_lo': self.sv_tau_fit_lo.get() if hasattr(self, 'sv_tau_fit_lo') else '0.1',
|
|
1166
|
+
'tau_fit_hi': self.sv_tau_fit_hi.get() if hasattr(self, 'sv_tau_fit_hi') else '10.0',
|
|
1167
|
+
'nexp_st': self.iv_nexp_st.get() if hasattr(self, 'iv_nexp_st') else 3,
|
|
1168
|
+
'fit_model_st': self.sv_fit_model_st.get() if hasattr(self, 'sv_fit_model_st') else 'discrete',
|
|
1169
|
+
'ncomp_dist_st': self.iv_ncomp_dist_st.get() if hasattr(self, 'iv_ncomp_dist_st') else 1,
|
|
1170
|
+
'nexp_batch': self.iv_nexp_batch.get() if hasattr(self, 'iv_nexp_batch') else 3,
|
|
1171
|
+
'register': self.bv_register.get() if hasattr(self, 'bv_register') else False,
|
|
1172
|
+
'channel': self.sv_channel_focus.get() if hasattr(self, 'sv_channel_focus') else 'auto',
|
|
1173
|
+
'threshold': self.sv_int_threshold.get() if hasattr(self, 'sv_int_threshold') else '5',
|
|
1174
|
+
'out_fov': self.sv_out_fov.get() if hasattr(self, 'sv_out_fov') else '',
|
|
1175
|
+
'mode_fov': self.sv_mode_fov.get() if hasattr(self, 'sv_mode_fov') else 'both',
|
|
1176
|
+
'tau_min_fov': self.sv_tau_min_fov.get() if hasattr(self, 'sv_tau_min_fov') else '',
|
|
1177
|
+
'tau_max_fov': self.sv_tau_max_fov.get() if hasattr(self, 'sv_tau_max_fov') else '',
|
|
1178
|
+
'thr_fov_en': self.bv_thr_fov.get() if hasattr(self, 'bv_thr_fov') else False,
|
|
1179
|
+
'thr_fov_val': self.sv_thr_fov.get() if hasattr(self, 'sv_thr_fov') else '5',
|
|
1180
|
+
'cell_mask': self.bv_cell.get() if hasattr(self, 'bv_cell') else False,
|
|
1181
|
+
'correct_pileup': self.bv_correct_pileup.get() if hasattr(self, 'bv_correct_pileup') else False,
|
|
1182
|
+
'correct_pileup_st': self.bv_correct_pileup_st.get() if hasattr(self, 'bv_correct_pileup_st') else False,
|
|
1183
|
+
'tvb_ptu_fov': self.sv_tvb_ptu_fov.get() if hasattr(self, 'sv_tvb_ptu_fov') else '',
|
|
1184
|
+
'tvb_ptu_st': self.sv_tvb_ptu_st.get() if hasattr(self, 'sv_tvb_ptu_st') else '',
|
|
1185
|
+
'tvb_ptu_batch': self.sv_batch_tvb_ptu.get() if hasattr(self, 'sv_batch_tvb_ptu') else '',
|
|
1186
|
+
'xlif_file': self.sv_xlif.get() if hasattr(self, 'sv_xlif') else '',
|
|
1187
|
+
'ptu_dir': self.sv_ptu_dir.get() if hasattr(self, 'sv_ptu_dir') else '',
|
|
1188
|
+
'out_st': self.sv_out_st.get() if hasattr(self, 'sv_out_st') else '',
|
|
1189
|
+
'pipeline': self.sv_pipeline.get() if hasattr(self, 'sv_pipeline') else 'stitch_only',
|
|
1190
|
+
'bv_rotate': self.bv_rotate.get() if hasattr(self, 'bv_rotate') else True,
|
|
1191
|
+
'bv_perpix': self.bv_perpix.get() if hasattr(self, 'bv_perpix') else False,
|
|
1192
|
+
'tau_lo': self.sv_tau_lo.get() if hasattr(self, 'sv_tau_lo') else '',
|
|
1193
|
+
'tau_hi': self.sv_tau_hi.get() if hasattr(self, 'sv_tau_hi') else '',
|
|
1194
|
+
'int_lo': self.sv_int_lo.get() if hasattr(self, 'sv_int_lo') else '',
|
|
1195
|
+
'int_hi': self.sv_int_hi.get() if hasattr(self, 'sv_int_hi') else '',
|
|
1196
|
+
'thr_st_en': self.bv_thr_st.get() if hasattr(self, 'bv_thr_st') else False,
|
|
1197
|
+
'thr_st_val': self.sv_thr_st.get() if hasattr(self, 'sv_thr_st') else '',
|
|
1198
|
+
'bv_register': self.bv_register.get() if hasattr(self, 'bv_register') else True,
|
|
1199
|
+
'reg_max_shift': self.sv_reg_max_shift.get() if hasattr(self, 'sv_reg_max_shift') else '120',
|
|
1200
|
+
'irf_st_method': self._irf_st.sv_method.get() if hasattr(self, '_irf_st') else 'irf_xlsx',
|
|
1201
|
+
'irf_st_path': self._irf_st.sv_path.get() if hasattr(self, '_irf_st') else '',
|
|
1202
|
+
'tile_irf_dir': self.sv_tile_irf_dir.get() if hasattr(self, 'sv_tile_irf_dir') else '',
|
|
1203
|
+
'expert_overrides': self._expert_overrides if hasattr(self, '_expert_overrides') else {},
|
|
1204
|
+
}
|
|
1205
|
+
print(f'[Session] Captured form state: active_form={state.get('active_form')}')
|
|
1206
|
+
return state
|
|
1207
|
+
except Exception as e:
|
|
1208
|
+
print(f'[Session] Could not capture form state: {e}')
|
|
1209
|
+
return {}
|
|
1210
|
+
|
|
1211
|
+
def _restore_form_state(self, state: dict):
|
|
1212
|
+
try:
|
|
1213
|
+
if 'irf_method' in state and hasattr(self, '_irf_fov'):
|
|
1214
|
+
self._irf_fov.sv_method.set(state['irf_method'])
|
|
1215
|
+
if 'irf_file' in state and hasattr(self, '_irf_fov'):
|
|
1216
|
+
self._irf_fov.sv_path.set(state['irf_file'])
|
|
1217
|
+
if 'nexp_fov' in state and hasattr(self, 'iv_nexp_fov'):
|
|
1218
|
+
self.iv_nexp_fov.set(state['nexp_fov'])
|
|
1219
|
+
if 'fit_model_fov' in state and hasattr(self, 'sv_fit_model_fov'):
|
|
1220
|
+
self.sv_fit_model_fov.set(state['fit_model_fov'])
|
|
1221
|
+
if 'ncomp_dist_fov' in state and hasattr(self, 'iv_ncomp_dist_fov'):
|
|
1222
|
+
self.iv_ncomp_dist_fov.set(state['ncomp_dist_fov'])
|
|
1223
|
+
if 'nexp_st' in state and hasattr(self, 'iv_nexp_st'):
|
|
1224
|
+
self.iv_nexp_st.set(state['nexp_st'])
|
|
1225
|
+
if 'fit_model_st' in state and hasattr(self, 'sv_fit_model_st'):
|
|
1226
|
+
self.sv_fit_model_st.set(state['fit_model_st'])
|
|
1227
|
+
if 'ncomp_dist_st' in state and hasattr(self, 'iv_ncomp_dist_st'):
|
|
1228
|
+
self.iv_ncomp_dist_st.set(state['ncomp_dist_st'])
|
|
1229
|
+
if 'nexp_batch' in state and hasattr(self, 'iv_nexp_batch'):
|
|
1230
|
+
self.iv_nexp_batch.set(state['nexp_batch'])
|
|
1231
|
+
if 'tau_fit_lo' in state and hasattr(self, 'sv_tau_fit_lo'):
|
|
1232
|
+
self.sv_tau_fit_lo.set(state['tau_fit_lo'])
|
|
1233
|
+
if 'tau_fit_hi' in state and hasattr(self, 'sv_tau_fit_hi'):
|
|
1234
|
+
self.sv_tau_fit_hi.set(state['tau_fit_hi'])
|
|
1235
|
+
if 'out_fov' in state and hasattr(self, 'sv_out_fov'): self.sv_out_fov.set(state['out_fov'])
|
|
1236
|
+
if 'mode_fov' in state and hasattr(self, 'sv_mode_fov'): self.sv_mode_fov.set(state['mode_fov'])
|
|
1237
|
+
if 'tau_min_fov' in state and hasattr(self, 'sv_tau_min_fov'): self.sv_tau_min_fov.set(state['tau_min_fov'])
|
|
1238
|
+
if 'tau_max_fov' in state and hasattr(self, 'sv_tau_max_fov'): self.sv_tau_max_fov.set(state['tau_max_fov'])
|
|
1239
|
+
if 'thr_fov_en' in state and hasattr(self, 'bv_thr_fov'): self.bv_thr_fov.set(state['thr_fov_en'])
|
|
1240
|
+
if 'thr_fov_val' in state and hasattr(self, 'sv_thr_fov'): self.sv_thr_fov.set(state['thr_fov_val'])
|
|
1241
|
+
if 'cell_mask' in state and hasattr(self, 'bv_cell'): self.bv_cell.set(state['cell_mask'])
|
|
1242
|
+
if 'correct_pileup' in state and hasattr(self, 'bv_correct_pileup'): self.bv_correct_pileup.set(state['correct_pileup'])
|
|
1243
|
+
if 'correct_pileup_st' in state and hasattr(self, 'bv_correct_pileup_st'): self.bv_correct_pileup_st.set(state['correct_pileup_st'])
|
|
1244
|
+
if 'tvb_ptu_fov' in state and hasattr(self, 'sv_tvb_ptu_fov'): self.sv_tvb_ptu_fov.set(state['tvb_ptu_fov'])
|
|
1245
|
+
if 'tvb_ptu_st' in state and hasattr(self, 'sv_tvb_ptu_st'): self.sv_tvb_ptu_st.set(state['tvb_ptu_st'])
|
|
1246
|
+
if 'tvb_ptu_batch' in state and hasattr(self, 'sv_batch_tvb_ptu'): self.sv_batch_tvb_ptu.set(state['tvb_ptu_batch'])
|
|
1247
|
+
if 'register' in state and hasattr(self, 'bv_register'):
|
|
1248
|
+
self.bv_register.set(state['register'])
|
|
1249
|
+
if 'channel' in state and hasattr(self, 'sv_channel_focus'):
|
|
1250
|
+
self.sv_channel_focus.set(state['channel'])
|
|
1251
|
+
if 'threshold' in state and hasattr(self, 'sv_int_threshold'):
|
|
1252
|
+
self.sv_int_threshold.set(state['threshold'])
|
|
1253
|
+
if 'out_st' in state and hasattr(self, 'sv_out_st'):
|
|
1254
|
+
self.sv_out_st.set(state['out_st'])
|
|
1255
|
+
if 'bv_rotate' in state and hasattr(self, 'bv_rotate'):
|
|
1256
|
+
self.bv_rotate.set(state['bv_rotate'])
|
|
1257
|
+
if 'tau_lo' in state and hasattr(self, 'sv_tau_lo'):
|
|
1258
|
+
self.sv_tau_lo.set(state['tau_lo'])
|
|
1259
|
+
if 'tau_hi' in state and hasattr(self, 'sv_tau_hi'):
|
|
1260
|
+
self.sv_tau_hi.set(state['tau_hi'])
|
|
1261
|
+
if 'int_lo' in state and hasattr(self, 'sv_int_lo'):
|
|
1262
|
+
self.sv_int_lo.set(state['int_lo'])
|
|
1263
|
+
if 'int_hi' in state and hasattr(self, 'sv_int_hi'):
|
|
1264
|
+
self.sv_int_hi.set(state['int_hi'])
|
|
1265
|
+
if 'thr_st_en' in state and hasattr(self, 'bv_thr_st'):
|
|
1266
|
+
self.bv_thr_st.set(state['thr_st_en'])
|
|
1267
|
+
if 'thr_st_val' in state and hasattr(self, 'sv_thr_st'):
|
|
1268
|
+
self.sv_thr_st.set(state['thr_st_val'])
|
|
1269
|
+
if 'bv_register' in state and hasattr(self, 'bv_register'):
|
|
1270
|
+
self.bv_register.set(state['bv_register'])
|
|
1271
|
+
if 'reg_max_shift' in state and hasattr(self, 'sv_reg_max_shift'):
|
|
1272
|
+
self.sv_reg_max_shift.set(state['reg_max_shift'])
|
|
1273
|
+
if 'tile_irf_dir' in state and hasattr(self, 'sv_tile_irf_dir'):
|
|
1274
|
+
self.sv_tile_irf_dir.set(state['tile_irf_dir'])
|
|
1275
|
+
if 'irf_st_method' in state and hasattr(self, '_irf_st'):
|
|
1276
|
+
self._irf_st.sv_method.set(state['irf_st_method'])
|
|
1277
|
+
self._irf_st._update()
|
|
1278
|
+
if 'irf_st_path' in state and hasattr(self, '_irf_st'):
|
|
1279
|
+
self._irf_st.sv_path.set(state['irf_st_path'])
|
|
1280
|
+
if hasattr(self, '_irf_fov'):
|
|
1281
|
+
self._irf_fov._update()
|
|
1282
|
+
if 'pipeline' in state and hasattr(self, 'sv_pipeline'):
|
|
1283
|
+
self.sv_pipeline.set(state['pipeline'])
|
|
1284
|
+
self._pipeline_changed()
|
|
1285
|
+
if 'bv_perpix' in state and hasattr(self, 'bv_perpix'):
|
|
1286
|
+
self.bv_perpix.set(state['bv_perpix'])
|
|
1287
|
+
self._perpix_toggled()
|
|
1288
|
+
if 'expert_overrides' in state and hasattr(self, '_expert_overrides'):
|
|
1289
|
+
ex = state['expert_overrides']
|
|
1290
|
+
if isinstance(ex, dict):
|
|
1291
|
+
self._expert_overrides = ex
|
|
1292
|
+
self._update_expert_banners()
|
|
1293
|
+
if 'active_form' in state:
|
|
1294
|
+
_form = state['active_form']
|
|
1295
|
+
if isinstance(_form, int):
|
|
1296
|
+
_form = [None, 'fov', 'stitch', 'batch', 'irf', 'phasor'][_form] or 'fov'
|
|
1297
|
+
if _form in self._form_buttons:
|
|
1298
|
+
self._switch_form(_form)
|
|
1299
|
+
print(f'[Session] Restored form state')
|
|
1300
|
+
except Exception as e:
|
|
1301
|
+
print(f'[Session] Could not restore form state: {e}')
|
|
1302
|
+
import traceback
|
|
1303
|
+
traceback.print_exc()
|
|
1304
|
+
|
|
1305
|
+
def _save_roi_progress(self, path: str, fit_result: dict, summary_rows: list):
|
|
1306
|
+
try:
|
|
1307
|
+
from pathlib import Path
|
|
1308
|
+
import json
|
|
1309
|
+
from datetime import datetime
|
|
1310
|
+
import numpy as np
|
|
1311
|
+
base_path = Path(path)
|
|
1312
|
+
if base_path.is_file():
|
|
1313
|
+
session_file = base_path.parent / f'{base_path.stem}.roi_session.npz'
|
|
1314
|
+
else:
|
|
1315
|
+
session_file = base_path / 'roi_session.npz'
|
|
1316
|
+
form_state = self._capture_form_state()
|
|
1317
|
+
session_data = {
|
|
1318
|
+
'timestamp': datetime.now().isoformat(),
|
|
1319
|
+
'source': str(path),
|
|
1320
|
+
'form_state_json': json.dumps(form_state, default=str),
|
|
1321
|
+
}
|
|
1322
|
+
print(f'[Session] Saving fit_result keys: {list(fit_result.keys())}')
|
|
1323
|
+
for key, val in fit_result.items():
|
|
1324
|
+
if isinstance(val, np.ndarray):
|
|
1325
|
+
session_data[key] = val
|
|
1326
|
+
print(f' ✓ Saved array: {key} {val.shape}')
|
|
1327
|
+
elif isinstance(val, dict):
|
|
1328
|
+
try:
|
|
1329
|
+
hoisted = {}
|
|
1330
|
+
json_safe = {}
|
|
1331
|
+
for k2, v2 in val.items():
|
|
1332
|
+
if isinstance(v2, np.ndarray):
|
|
1333
|
+
arr_key = f'{key}_arr_{k2}'
|
|
1334
|
+
hoisted[arr_key] = v2
|
|
1335
|
+
print(f' ✓ Hoisted array from {key}: {k2} {v2.shape}')
|
|
1336
|
+
else:
|
|
1337
|
+
json_safe[k2] = v2
|
|
1338
|
+
session_data[f'{key}_json'] = json.dumps(json_safe, default=str)
|
|
1339
|
+
session_data.update(hoisted)
|
|
1340
|
+
print(f' ✓ Saved dict: {key} ({len(hoisted)} arrays hoisted)')
|
|
1341
|
+
except Exception as e:
|
|
1342
|
+
print(f' ✗ Could not save dict {key}: {e}')
|
|
1343
|
+
elif isinstance(val, (list, tuple)):
|
|
1344
|
+
try:
|
|
1345
|
+
arr = np.array(val)
|
|
1346
|
+
if arr.dtype != object:
|
|
1347
|
+
session_data[key] = arr
|
|
1348
|
+
print(f' ✓ Saved list: {key} {arr.shape}')
|
|
1349
|
+
except Exception:
|
|
1350
|
+
pass
|
|
1351
|
+
elif val is not None and not callable(val):
|
|
1352
|
+
try:
|
|
1353
|
+
session_data[key] = val
|
|
1354
|
+
except Exception:
|
|
1355
|
+
pass
|
|
1356
|
+
if summary_rows:
|
|
1357
|
+
params, values, units = zip(*summary_rows)
|
|
1358
|
+
session_data['summary_params'] = np.array(params, dtype=object)
|
|
1359
|
+
session_data['summary_values'] = np.array(values, dtype=object)
|
|
1360
|
+
session_data['summary_units'] = np.array(units, dtype=object)
|
|
1361
|
+
if self._fov_preview._ptu_path:
|
|
1362
|
+
session_data['fov_ptu_path'] = self._fov_preview._ptu_path
|
|
1363
|
+
if self._fov_preview._lifetime_map is not None:
|
|
1364
|
+
session_data['fov_lifetime_map'] = self._fov_preview._lifetime_map
|
|
1365
|
+
if self._fov_preview._intensity_map is not None:
|
|
1366
|
+
session_data['fov_intensity_map'] = self._fov_preview._intensity_map
|
|
1367
|
+
session_data['fov_color_scale'] = json.dumps(self._fov_preview._flim_color_scale)
|
|
1368
|
+
session_data['fov_n_exp'] = self._fov_preview._n_exp
|
|
1369
|
+
session_data['fov_regions'] = self._fov_preview._roi_manager.to_json()
|
|
1370
|
+
np.savez_compressed(session_file, **session_data)
|
|
1371
|
+
print(f'✓ Session saved: {session_file}')
|
|
1372
|
+
print(f' Saved {len(session_data)} items (fit results + form state + FOV preview)')
|
|
1373
|
+
self._current_session_file = str(session_file)
|
|
1374
|
+
except Exception as e:
|
|
1375
|
+
import traceback
|
|
1376
|
+
print(f'[Save Error] {e}')
|
|
1377
|
+
traceback.print_exc()
|
|
1378
|
+
|
|
1379
|
+
def _load_roi_session(self, session_path: str) -> dict:
|
|
1380
|
+
try:
|
|
1381
|
+
import numpy as np
|
|
1382
|
+
data = np.load(session_path, allow_pickle=True)
|
|
1383
|
+
loaded = {}
|
|
1384
|
+
for key in data.files:
|
|
1385
|
+
val = data[key]
|
|
1386
|
+
if isinstance(val, np.ndarray):
|
|
1387
|
+
if val.ndim == 0:
|
|
1388
|
+
val = val.item()
|
|
1389
|
+
elif val.dtype == object:
|
|
1390
|
+
val = val.tolist()
|
|
1391
|
+
loaded[key] = val
|
|
1392
|
+
print(f'✓ Loaded session from {session_path}')
|
|
1393
|
+
self._current_session_file = session_path
|
|
1394
|
+
return loaded
|
|
1395
|
+
except Exception as e:
|
|
1396
|
+
print(f'[Load Session Error] {e}')
|
|
1397
|
+
import traceback
|
|
1398
|
+
traceback.print_exc()
|
|
1399
|
+
return {}
|
|
1400
|
+
|
|
1401
|
+
def _auto_load_session_for_ptu(self, ptu_path: str):
|
|
1402
|
+
try:
|
|
1403
|
+
from pathlib import Path
|
|
1404
|
+
import json
|
|
1405
|
+
import numpy as np
|
|
1406
|
+
ptu_path = Path(ptu_path)
|
|
1407
|
+
session_file = ptu_path.parent / f'{ptu_path.stem}.roi_session.npz'
|
|
1408
|
+
if session_file.exists():
|
|
1409
|
+
print(f'[Auto-Load] Found session for PTU: {session_file.name}')
|
|
1410
|
+
session_data = self._load_roi_session(str(session_file))
|
|
1411
|
+
if not session_data:
|
|
1412
|
+
return
|
|
1413
|
+
if 'form_state_json' in session_data:
|
|
1414
|
+
try:
|
|
1415
|
+
form_state_str = session_data['form_state_json']
|
|
1416
|
+
if isinstance(form_state_str, np.ndarray):
|
|
1417
|
+
form_state_str = form_state_str.item()
|
|
1418
|
+
if isinstance(form_state_str, bytes):
|
|
1419
|
+
form_state_str = form_state_str.decode('utf-8')
|
|
1420
|
+
form_state = json.loads(form_state_str)
|
|
1421
|
+
self._restore_form_state(form_state)
|
|
1422
|
+
except Exception as e:
|
|
1423
|
+
print(f'[Auto-Load] Could not restore form state: {e}')
|
|
1424
|
+
import traceback
|
|
1425
|
+
traceback.print_exc()
|
|
1426
|
+
try:
|
|
1427
|
+
params = session_data.get('summary_params', [])
|
|
1428
|
+
values = session_data.get('summary_values', [])
|
|
1429
|
+
units = session_data.get('summary_units', [])
|
|
1430
|
+
if isinstance(params, np.ndarray):
|
|
1431
|
+
params = params.tolist()
|
|
1432
|
+
if isinstance(values, np.ndarray):
|
|
1433
|
+
values = values.tolist()
|
|
1434
|
+
if isinstance(units, np.ndarray):
|
|
1435
|
+
units = units.tolist()
|
|
1436
|
+
rows = []
|
|
1437
|
+
for param, val, unit in zip(params, values, units):
|
|
1438
|
+
if isinstance(param, bytes):
|
|
1439
|
+
param = param.decode('utf-8')
|
|
1440
|
+
if isinstance(val, bytes):
|
|
1441
|
+
val = val.decode('utf-8')
|
|
1442
|
+
if isinstance(unit, bytes):
|
|
1443
|
+
unit = unit.decode('utf-8')
|
|
1444
|
+
rows.append((str(param), str(val), str(unit)))
|
|
1445
|
+
if rows:
|
|
1446
|
+
self._res.populate_summary(rows)
|
|
1447
|
+
if 'fov_regions' in session_data:
|
|
1448
|
+
try:
|
|
1449
|
+
import json as _json_roi
|
|
1450
|
+
regions_json = session_data['fov_regions']
|
|
1451
|
+
if isinstance(regions_json, bytes):
|
|
1452
|
+
regions_json = regions_json.decode('utf-8')
|
|
1453
|
+
self._fov_preview._load_regions_from_json(regions_json)
|
|
1454
|
+
if self._roi_analysis_panel is not None:
|
|
1455
|
+
self._roi_analysis_panel._refresh_region_list()
|
|
1456
|
+
except Exception as _e:
|
|
1457
|
+
print(f'[Auto-Load] Could not restore regions: {_e}')
|
|
1458
|
+
if 'fov_intensity_map' in session_data and 'fov_lifetime_map' in session_data:
|
|
1459
|
+
intensity = session_data['fov_intensity_map']
|
|
1460
|
+
lifetime = session_data['fov_lifetime_map']
|
|
1461
|
+
if isinstance(intensity, np.ndarray):
|
|
1462
|
+
self._fov_preview._intensity_map = intensity
|
|
1463
|
+
if isinstance(lifetime, np.ndarray):
|
|
1464
|
+
self._fov_preview._lifetime_map = lifetime
|
|
1465
|
+
if 'fov_color_scale' in session_data:
|
|
1466
|
+
try:
|
|
1467
|
+
import json
|
|
1468
|
+
cs = session_data['fov_color_scale']
|
|
1469
|
+
if isinstance(cs, bytes):
|
|
1470
|
+
cs = cs.decode('utf-8')
|
|
1471
|
+
self._fov_preview._flim_color_scale = json.loads(cs)
|
|
1472
|
+
except Exception:
|
|
1473
|
+
pass
|
|
1474
|
+
if 'fov_n_exp' in session_data:
|
|
1475
|
+
n_exp = session_data['fov_n_exp']
|
|
1476
|
+
if isinstance(n_exp, (np.integer, int)):
|
|
1477
|
+
self._fov_preview._n_exp = int(n_exp)
|
|
1478
|
+
try:
|
|
1479
|
+
self._fov_preview._update_flim_display()
|
|
1480
|
+
except Exception as _e:
|
|
1481
|
+
print(f'[Auto-Load] Could not render FLIM display: {_e}')
|
|
1482
|
+
try:
|
|
1483
|
+
ax_decay = self._fov_preview._ax_decay
|
|
1484
|
+
ax_decay.clear()
|
|
1485
|
+
if 'decay' in session_data and 'time_ns' in session_data:
|
|
1486
|
+
decay = session_data['decay']
|
|
1487
|
+
time_ns = session_data['time_ns']
|
|
1488
|
+
if isinstance(decay, np.ndarray) and isinstance(time_ns, np.ndarray):
|
|
1489
|
+
ax_decay.semilogy(time_ns, decay, 'o-', color='steelblue',
|
|
1490
|
+
linewidth=1.5, markersize=3, label='Measured', alpha=0.7)
|
|
1491
|
+
irf = session_data.get('irf_prompt')
|
|
1492
|
+
if irf is not None and isinstance(irf, np.ndarray) and irf.max() > 0:
|
|
1493
|
+
self._fov_preview._irf_prompt = irf
|
|
1494
|
+
irf_scaled = (irf / irf.max()) * decay.max() * 0.2
|
|
1495
|
+
ax_decay.semilogy(time_ns[:len(irf)], np.maximum(irf_scaled, 1e-2),
|
|
1496
|
+
color='orange', linewidth=2.0, label='IRF', alpha=0.8)
|
|
1497
|
+
gs = _reconstruct_dict_from_session(session_data, 'global_summary')
|
|
1498
|
+
model = gs.get('model')
|
|
1499
|
+
if model is not None and isinstance(model, str):
|
|
1500
|
+
model = _safe_array_from_json(model)
|
|
1501
|
+
if model is not None and len(model) > 0:
|
|
1502
|
+
ax_decay.semilogy(time_ns, model, color='red', linewidth=2.0,
|
|
1503
|
+
label='Fitted', alpha=0.8)
|
|
1504
|
+
ax_decay.legend(fontsize=8, loc='upper right', labelcolor='black')
|
|
1505
|
+
ax_resid = self._fov_preview._ax_resid
|
|
1506
|
+
ax_resid.clear()
|
|
1507
|
+
ax_resid.set_facecolor('white')
|
|
1508
|
+
if model is not None and len(model) == len(decay):
|
|
1509
|
+
with np.errstate(invalid='ignore', divide='ignore'):
|
|
1510
|
+
resid = np.where(model > 0,
|
|
1511
|
+
(decay - model) / np.sqrt(model),
|
|
1512
|
+
0.0)
|
|
1513
|
+
self._fov_preview._cached_resid_data = (time_ns.copy(), resid)
|
|
1514
|
+
ax_resid.plot(time_ns, resid, color='steelblue', linewidth=1.0)
|
|
1515
|
+
ax_resid.axhline(0, color='red', linewidth=1.0,
|
|
1516
|
+
linestyle='--', alpha=0.8)
|
|
1517
|
+
ax_resid.set_ylabel('Resid. (σ)', fontsize=7, color='white')
|
|
1518
|
+
chi2_r = gs.get('reduced_chi2_tail')
|
|
1519
|
+
if chi2_r is not None:
|
|
1520
|
+
ax_resid.annotate(
|
|
1521
|
+
f'χ²_r = {chi2_r:.3f}',
|
|
1522
|
+
xy=(0.98, 0.85), xycoords='axes fraction',
|
|
1523
|
+
ha='right', va='top', fontsize=7,
|
|
1524
|
+
color='white',
|
|
1525
|
+
bbox=dict(boxstyle='round,pad=0.2',
|
|
1526
|
+
fc='#333333', alpha=0.7),
|
|
1527
|
+
)
|
|
1528
|
+
ax_resid.set_xlabel('Time (ns)', color='white')
|
|
1529
|
+
ax_resid.tick_params(labelsize=7, colors='white')
|
|
1530
|
+
ax_resid.grid(True, alpha=0.3)
|
|
1531
|
+
ax_decay.set_title('Summed Decay (reloaded)', fontsize=10, fontweight='bold', color='white')
|
|
1532
|
+
ax_decay.set_xlabel('Time (ns)', color='white')
|
|
1533
|
+
ax_decay.set_ylabel('Photon Count', color='white')
|
|
1534
|
+
ax_decay.grid(True, alpha=0.3)
|
|
1535
|
+
ax_decay.tick_params(labelsize=8, colors='white')
|
|
1536
|
+
_SESSION_ONLY_KEYS = {
|
|
1537
|
+
'timestamp', 'source', 'form_state_json', 'fov_ptu_path',
|
|
1538
|
+
'fov_lifetime_map', 'fov_intensity_map', 'fov_color_scale',
|
|
1539
|
+
'fov_n_exp', 'fov_regions', 'summary_params', 'summary_values',
|
|
1540
|
+
'summary_units', 'decay', 'time_ns', 'irf_prompt',
|
|
1541
|
+
}
|
|
1542
|
+
fit_result_for_export = {
|
|
1543
|
+
k: v for k, v in session_data.items()
|
|
1544
|
+
if k not in _SESSION_ONLY_KEYS
|
|
1545
|
+
and not k.endswith('_json')
|
|
1546
|
+
and isinstance(v, np.ndarray)
|
|
1547
|
+
and v.ndim == 2
|
|
1548
|
+
}
|
|
1549
|
+
if not fit_result_for_export:
|
|
1550
|
+
if isinstance(session_data.get('fov_intensity_map'), np.ndarray):
|
|
1551
|
+
fit_result_for_export['intensity'] = session_data['fov_intensity_map']
|
|
1552
|
+
if isinstance(session_data.get('fov_lifetime_map'), np.ndarray):
|
|
1553
|
+
fit_result_for_export['lifetime'] = session_data['fov_lifetime_map']
|
|
1554
|
+
self._res.set_fit_result(
|
|
1555
|
+
fit_result_for_export,
|
|
1556
|
+
str(ptu_path.parent),
|
|
1557
|
+
npz_path=str(session_file),
|
|
1558
|
+
scan_name=self._current_scan_stem(),
|
|
1559
|
+
)
|
|
1560
|
+
self._res._status.set('✓ Session restored - ready to export or re-fit')
|
|
1561
|
+
self._fov_preview._ctrl_frame.grid()
|
|
1562
|
+
self._fov_preview._canvas_mpl.draw_idle()
|
|
1563
|
+
print('[Auto-Load] ✓ Session restored')
|
|
1564
|
+
except Exception as e:
|
|
1565
|
+
print(f'[Auto-Load] Could not redraw FOV: {e}')
|
|
1566
|
+
except Exception as e:
|
|
1567
|
+
print(f'[Auto-Load] Could not restore results: {e}')
|
|
1568
|
+
import traceback
|
|
1569
|
+
traceback.print_exc()
|
|
1570
|
+
finally:
|
|
1571
|
+
try:
|
|
1572
|
+
if self._roi_analysis_panel is not None:
|
|
1573
|
+
self._roi_analysis_panel._refresh_region_list()
|
|
1574
|
+
except Exception:
|
|
1575
|
+
pass
|
|
1576
|
+
else:
|
|
1577
|
+
print(f'[Auto-Load] No session found for {ptu_path.name}')
|
|
1578
|
+
except Exception as e:
|
|
1579
|
+
print(f'[Auto-Load Error] {e}')
|
|
1580
|
+
|
|
1581
|
+
def _auto_load_session_for_stitch(self, output_dir: str):
|
|
1582
|
+
try:
|
|
1583
|
+
from pathlib import Path
|
|
1584
|
+
import json
|
|
1585
|
+
import numpy as np
|
|
1586
|
+
session_file = Path(output_dir) / 'roi_session.npz'
|
|
1587
|
+
if not session_file.exists():
|
|
1588
|
+
return
|
|
1589
|
+
print(f'[Auto-Load] Found tile session: {session_file.name}')
|
|
1590
|
+
session_data = np.load(session_file, allow_pickle=True)
|
|
1591
|
+
loaded = {key: session_data[key] for key in session_data.files}
|
|
1592
|
+
if 'form_state_json' in loaded:
|
|
1593
|
+
try:
|
|
1594
|
+
form_state_str = loaded['form_state_json']
|
|
1595
|
+
if isinstance(form_state_str, np.ndarray):
|
|
1596
|
+
form_state_str = form_state_str.item()
|
|
1597
|
+
if isinstance(form_state_str, bytes):
|
|
1598
|
+
form_state_str = form_state_str.decode('utf-8')
|
|
1599
|
+
form_state = json.loads(form_state_str)
|
|
1600
|
+
self._restore_form_state(form_state)
|
|
1601
|
+
except Exception as e:
|
|
1602
|
+
print(f'[Auto-Load] Could not restore form state: {e}')
|
|
1603
|
+
if 'summary_params' in loaded:
|
|
1604
|
+
params = loaded['summary_params']
|
|
1605
|
+
values = loaded['summary_values']
|
|
1606
|
+
units = loaded['summary_units']
|
|
1607
|
+
if isinstance(params, np.ndarray):
|
|
1608
|
+
params = params.tolist()
|
|
1609
|
+
if isinstance(values, np.ndarray):
|
|
1610
|
+
values = values.tolist()
|
|
1611
|
+
if isinstance(units, np.ndarray):
|
|
1612
|
+
units = units.tolist()
|
|
1613
|
+
rows = []
|
|
1614
|
+
for p, v, u in zip(params, values, units):
|
|
1615
|
+
if isinstance(p, bytes): p = p.decode('utf-8')
|
|
1616
|
+
if isinstance(v, bytes): v = v.decode('utf-8')
|
|
1617
|
+
if isinstance(u, bytes): u = u.decode('utf-8')
|
|
1618
|
+
rows.append((str(p), str(v), str(u)))
|
|
1619
|
+
if rows:
|
|
1620
|
+
self._res.populate_summary(rows)
|
|
1621
|
+
if 'fov_color_scale' in loaded:
|
|
1622
|
+
try:
|
|
1623
|
+
cs = loaded['fov_color_scale']
|
|
1624
|
+
if isinstance(cs, bytes):
|
|
1625
|
+
cs = cs.decode('utf-8')
|
|
1626
|
+
self._fov_preview._flim_color_scale = json.loads(cs)
|
|
1627
|
+
except Exception:
|
|
1628
|
+
pass
|
|
1629
|
+
if 'fov_n_exp' in loaded:
|
|
1630
|
+
n_exp = loaded['fov_n_exp']
|
|
1631
|
+
if isinstance(n_exp, (np.integer, int)):
|
|
1632
|
+
self._fov_preview._n_exp = int(n_exp)
|
|
1633
|
+
if 'fov_regions' in loaded:
|
|
1634
|
+
regions_json = loaded['fov_regions']
|
|
1635
|
+
if isinstance(regions_json, np.ndarray):
|
|
1636
|
+
regions_json = regions_json.item()
|
|
1637
|
+
if isinstance(regions_json, bytes):
|
|
1638
|
+
regions_json = regions_json.decode('utf-8')
|
|
1639
|
+
if regions_json:
|
|
1640
|
+
self._fov_preview._load_regions_from_json(regions_json)
|
|
1641
|
+
if self._roi_analysis_panel:
|
|
1642
|
+
self._roi_analysis_panel._refresh_region_list()
|
|
1643
|
+
fit_result = {}
|
|
1644
|
+
for key, val in loaded.items():
|
|
1645
|
+
if key in ('summary_params', 'summary_values', 'summary_units',
|
|
1646
|
+
'form_state_json', 'fov_regions', 'fov_color_scale', 'fov_n_exp'):
|
|
1647
|
+
continue
|
|
1648
|
+
fit_result[key] = val
|
|
1649
|
+
if 'global_summary_json' in fit_result:
|
|
1650
|
+
fit_result['global_summary'] = _reconstruct_dict_from_session(fit_result, 'global_summary')
|
|
1651
|
+
if self._fov_preview._intensity_map is None:
|
|
1652
|
+
self._fov_preview.load_stitched_roi(output_dir)
|
|
1653
|
+
self._fov_preview.display_fit_results(None, fit_result)
|
|
1654
|
+
self._fov_preview._canvas_mpl.draw_idle()
|
|
1655
|
+
self._res.set_fit_result(fit_result, output_dir, npz_path=str(session_file),
|
|
1656
|
+
scan_name=self._current_scan_stem())
|
|
1657
|
+
self._res._status.set('✓ Session restored - ready to export or re-fit')
|
|
1658
|
+
self._fov_preview._ctrl_frame.grid()
|
|
1659
|
+
self._res._export_btn.configure(state='normal')
|
|
1660
|
+
print('[Auto-Load] Tile session fully restored')
|
|
1661
|
+
except Exception as e:
|
|
1662
|
+
print(f'[Auto-Load] Error loading tile session: {e}')
|
|
1663
|
+
import traceback
|
|
1664
|
+
traceback.print_exc()
|
|
1665
|
+
|
|
1666
|
+
def load_roi_fit(self, npz_path: str) -> dict:
|
|
1667
|
+
try:
|
|
1668
|
+
import numpy as np
|
|
1669
|
+
data = np.load(npz_path, allow_pickle=True)
|
|
1670
|
+
loaded = {}
|
|
1671
|
+
for key in data.files:
|
|
1672
|
+
val = data[key]
|
|
1673
|
+
if hasattr(val, 'dtype') and val.dtype == object:
|
|
1674
|
+
val = val.tolist()
|
|
1675
|
+
loaded[key] = val
|
|
1676
|
+
print(f'✓ Loaded ROI fit from {npz_path}')
|
|
1677
|
+
return loaded
|
|
1678
|
+
except Exception as e:
|
|
1679
|
+
print(f'✗ Failed to load ROI fit: {e}')
|
|
1680
|
+
return {}
|
|
1681
|
+
|
|
1682
|
+
def _save_npz_quick(self, output_dir: str):
|
|
1683
|
+
try:
|
|
1684
|
+
from pathlib import Path
|
|
1685
|
+
import shutil
|
|
1686
|
+
import numpy as np
|
|
1687
|
+
session_source = None
|
|
1688
|
+
if self._current_session_file:
|
|
1689
|
+
session_source = Path(self._current_session_file)
|
|
1690
|
+
if not session_source.exists():
|
|
1691
|
+
session_source = None
|
|
1692
|
+
if not session_source:
|
|
1693
|
+
ptu_path = self._fov_preview._ptu_path if self._fov_preview else None
|
|
1694
|
+
if ptu_path is not None:
|
|
1695
|
+
if isinstance(ptu_path, np.ndarray):
|
|
1696
|
+
ptu_path = ptu_path.item() if ptu_path.ndim == 0 else str(ptu_path[0])
|
|
1697
|
+
if isinstance(ptu_path, bytes):
|
|
1698
|
+
ptu_path = ptu_path.decode('utf-8')
|
|
1699
|
+
ptu_path = str(ptu_path)
|
|
1700
|
+
if ptu_path:
|
|
1701
|
+
base_path = Path(ptu_path)
|
|
1702
|
+
if base_path.is_file():
|
|
1703
|
+
candidate = base_path.parent / f'{base_path.stem}.roi_session.npz'
|
|
1704
|
+
if candidate.exists():
|
|
1705
|
+
session_source = candidate
|
|
1706
|
+
if not session_source or not session_source.exists():
|
|
1707
|
+
messagebox.showwarning('No session data', 'No saved session (.roi_session.npz) found.\n\nRun a fit first to create a session.')
|
|
1708
|
+
return
|
|
1709
|
+
output_path = Path(filedialog.askdirectory(
|
|
1710
|
+
title='Save Session File',
|
|
1711
|
+
initialdir=output_dir))
|
|
1712
|
+
if not output_path or output_path == Path():
|
|
1713
|
+
return
|
|
1714
|
+
session_dest = output_path / session_source.name
|
|
1715
|
+
if session_source.samefile(session_dest) if session_dest.exists() else session_source == session_dest:
|
|
1716
|
+
messagebox.showinfo('Already Saved',
|
|
1717
|
+
f'Session already saved at:\n{session_source}')
|
|
1718
|
+
return
|
|
1719
|
+
if session_dest.exists():
|
|
1720
|
+
response = messagebox.askyesno('File Exists',
|
|
1721
|
+
f'File already exists:\n{session_dest.name}\n\nOverride?')
|
|
1722
|
+
if not response:
|
|
1723
|
+
return
|
|
1724
|
+
session_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1725
|
+
shutil.copy2(session_source, session_dest)
|
|
1726
|
+
messagebox.showinfo('Success', f'Session saved:\n{session_dest.name}\nat {output_path}')
|
|
1727
|
+
print(f'✓ Session saved: {session_dest}')
|
|
1728
|
+
except Exception as e:
|
|
1729
|
+
print(f'[Save Session Error] {e}')
|
|
1730
|
+
import traceback
|
|
1731
|
+
traceback.print_exc()
|
|
1732
|
+
messagebox.showerror('Error', f'Failed to save session:\n{str(e)}')
|
|
1733
|
+
|
|
1734
|
+
def _load_fitted_data_from_file(self, npz_path: str, suppress_popups: bool = False):
|
|
1735
|
+
try:
|
|
1736
|
+
import numpy as np
|
|
1737
|
+
import json
|
|
1738
|
+
from pathlib import Path
|
|
1739
|
+
fit_result = self.load_roi_fit(npz_path)
|
|
1740
|
+
if not fit_result:
|
|
1741
|
+
if not suppress_popups:
|
|
1742
|
+
messagebox.showerror('Load Error', f'Failed to load fitted data from:\n{npz_path}')
|
|
1743
|
+
return
|
|
1744
|
+
params = fit_result.get('summary_params', [])
|
|
1745
|
+
values = fit_result.get('summary_values', [])
|
|
1746
|
+
units = fit_result.get('summary_units', [])
|
|
1747
|
+
rows = []
|
|
1748
|
+
if isinstance(params, np.ndarray): params = params.tolist()
|
|
1749
|
+
if isinstance(values, np.ndarray): values = values.tolist()
|
|
1750
|
+
if isinstance(units, np.ndarray): units = units.tolist()
|
|
1751
|
+
for param, val, unit in zip(params, values, units):
|
|
1752
|
+
if isinstance(param, bytes): param = param.decode('utf-8')
|
|
1753
|
+
if isinstance(val, bytes): val = val.decode('utf-8')
|
|
1754
|
+
if isinstance(unit, bytes): unit = unit.decode('utf-8')
|
|
1755
|
+
rows.append((str(param), str(val), str(unit)))
|
|
1756
|
+
try:
|
|
1757
|
+
if rows:
|
|
1758
|
+
self._res.populate_summary(rows)
|
|
1759
|
+
except Exception:
|
|
1760
|
+
import traceback
|
|
1761
|
+
traceback.print_exc()
|
|
1762
|
+
try:
|
|
1763
|
+
if 'fov_ptu_path' in fit_result:
|
|
1764
|
+
ptu_path = fit_result['fov_ptu_path']
|
|
1765
|
+
if isinstance(ptu_path, bytes):
|
|
1766
|
+
ptu_path = ptu_path.decode('utf-8')
|
|
1767
|
+
elif isinstance(ptu_path, np.ndarray):
|
|
1768
|
+
ptu_path = ptu_path.item() if ptu_path.ndim == 0 else str(ptu_path[0])
|
|
1769
|
+
if isinstance(ptu_path, bytes):
|
|
1770
|
+
ptu_path = ptu_path.decode('utf-8')
|
|
1771
|
+
self._fov_preview._ptu_path = str(ptu_path) if ptu_path else None
|
|
1772
|
+
if 'fov_lifetime_map' in fit_result:
|
|
1773
|
+
lifetime = fit_result['fov_lifetime_map']
|
|
1774
|
+
if isinstance(lifetime, np.ndarray):
|
|
1775
|
+
self._fov_preview._lifetime_map = lifetime
|
|
1776
|
+
if 'fov_intensity_map' in fit_result:
|
|
1777
|
+
intensity = fit_result['fov_intensity_map']
|
|
1778
|
+
if isinstance(intensity, np.ndarray):
|
|
1779
|
+
self._fov_preview._intensity_map = intensity
|
|
1780
|
+
if 'fov_color_scale' in fit_result:
|
|
1781
|
+
try:
|
|
1782
|
+
cs = fit_result['fov_color_scale']
|
|
1783
|
+
if isinstance(cs, bytes):
|
|
1784
|
+
cs = cs.decode('utf-8')
|
|
1785
|
+
self._fov_preview._flim_color_scale = json.loads(cs)
|
|
1786
|
+
except Exception:
|
|
1787
|
+
pass
|
|
1788
|
+
except Exception:
|
|
1789
|
+
import traceback
|
|
1790
|
+
traceback.print_exc()
|
|
1791
|
+
import sys
|
|
1792
|
+
sys.stdout.flush()
|
|
1793
|
+
if 'fov_regions' in fit_result:
|
|
1794
|
+
try:
|
|
1795
|
+
regions_json = fit_result['fov_regions']
|
|
1796
|
+
if isinstance(regions_json, np.ndarray):
|
|
1797
|
+
regions_json = regions_json.item() if regions_json.ndim == 0 else regions_json[0]
|
|
1798
|
+
if isinstance(regions_json, bytes):
|
|
1799
|
+
regions_json = regions_json.decode('utf-8')
|
|
1800
|
+
self._fov_preview._load_regions_from_json(regions_json)
|
|
1801
|
+
if self._roi_analysis_panel is not None:
|
|
1802
|
+
self._roi_analysis_panel._refresh_region_list()
|
|
1803
|
+
except Exception:
|
|
1804
|
+
import traceback
|
|
1805
|
+
traceback.print_exc()
|
|
1806
|
+
if 'fov_n_exp' in fit_result:
|
|
1807
|
+
n_exp = fit_result['fov_n_exp']
|
|
1808
|
+
if isinstance(n_exp, (np.integer, int)):
|
|
1809
|
+
self._fov_preview._n_exp = int(n_exp)
|
|
1810
|
+
try:
|
|
1811
|
+
if self._fov_preview._lifetime_map is not None and self._fov_preview._intensity_map is not None:
|
|
1812
|
+
from flimkit.utils import display
|
|
1813
|
+
intensity = self._fov_preview._intensity_map
|
|
1814
|
+
lifetime = self._fov_preview._lifetime_map
|
|
1815
|
+
ax_img = self._fov_preview._ax_img
|
|
1816
|
+
ax_flim = self._fov_preview._ax_flim
|
|
1817
|
+
ax_cbar = self._fov_preview._ax_cbar
|
|
1818
|
+
fig = self._fov_preview._fig
|
|
1819
|
+
ax_img.clear()
|
|
1820
|
+
intensity_clipped = np.clip(intensity, 0, np.percentile(intensity, 99))
|
|
1821
|
+
ax_img.imshow(intensity_clipped, cmap='inferno', origin='upper')
|
|
1822
|
+
ax_img.set_title('Intensity Image', fontsize=10, fontweight='bold')
|
|
1823
|
+
ax_img.set_xlabel('X (pixels)')
|
|
1824
|
+
ax_img.set_ylabel('Y (pixels)')
|
|
1825
|
+
cs = self._fov_preview._flim_color_scale
|
|
1826
|
+
scaled = display.apply_color_scale(
|
|
1827
|
+
lifetime,
|
|
1828
|
+
vmin=cs.get('vmin'),
|
|
1829
|
+
vmax=cs.get('vmax'),
|
|
1830
|
+
gamma=cs.get('gamma', 1.0),
|
|
1831
|
+
)
|
|
1832
|
+
cmap_obj = display.get_colormap(cs.get('cmap', 'viridis'))
|
|
1833
|
+
cmap_obj.set_bad(color='black')
|
|
1834
|
+
ax_flim.clear()
|
|
1835
|
+
ax_cbar.clear()
|
|
1836
|
+
im = ax_flim.imshow(scaled, cmap=cmap_obj, origin='upper', vmin=0, vmax=1)
|
|
1837
|
+
ax_flim.set_title('FLIM Lifetime (τ-weighted)', fontsize=10, fontweight='bold')
|
|
1838
|
+
ax_flim.set_xlabel('X (pixels)')
|
|
1839
|
+
ax_flim.set_ylabel('Y (pixels)')
|
|
1840
|
+
valid = lifetime[~np.isnan(lifetime)]
|
|
1841
|
+
if valid.size > 0:
|
|
1842
|
+
vmin_cb = cs.get('vmin') or float(np.nanmin(valid))
|
|
1843
|
+
vmax_cb = cs.get('vmax') or float(np.nanmax(valid))
|
|
1844
|
+
cbar = fig.colorbar(im, cax=ax_cbar)
|
|
1845
|
+
cbar.set_label('τ (ns)', fontsize=8)
|
|
1846
|
+
ticks = np.linspace(0, 1, 5)
|
|
1847
|
+
cbar.set_ticks(ticks)
|
|
1848
|
+
cbar.set_ticklabels([f'{vmin_cb + t*(vmax_cb - vmin_cb):.2f}' for t in ticks], fontsize=7)
|
|
1849
|
+
ax_decay = self._fov_preview._ax_decay
|
|
1850
|
+
ax_decay.clear()
|
|
1851
|
+
decay = fit_result.get('decay')
|
|
1852
|
+
time_ns = fit_result.get('time_ns')
|
|
1853
|
+
if decay is not None and time_ns is not None:
|
|
1854
|
+
ax_decay.semilogy(time_ns, decay, 'o-', color='steelblue',
|
|
1855
|
+
linewidth=1.5, markersize=3, label='Measured', alpha=0.7)
|
|
1856
|
+
irf = fit_result.get('irf_prompt')
|
|
1857
|
+
if irf is not None and len(irf) > 0 and irf.max() > 0:
|
|
1858
|
+
irf_scaled = (irf / irf.max()) * decay.max() * 0.2
|
|
1859
|
+
ax_decay.semilogy(time_ns[:len(irf)], np.maximum(irf_scaled, 1e-2),
|
|
1860
|
+
color='orange', linewidth=2.0, label='IRF', alpha=0.8)
|
|
1861
|
+
gs = _reconstruct_dict_from_session(fit_result, 'global_summary')
|
|
1862
|
+
model = gs.get('model')
|
|
1863
|
+
if model is not None and isinstance(model, str):
|
|
1864
|
+
model = _safe_array_from_json(model)
|
|
1865
|
+
if model is not None and len(model) > 0:
|
|
1866
|
+
ax_decay.semilogy(time_ns, model, color='red', linewidth=2.0,
|
|
1867
|
+
label='Fitted', alpha=0.8)
|
|
1868
|
+
ax_decay.legend(fontsize=8, loc='upper right', labelcolor='black')
|
|
1869
|
+
ax_decay.set_title('Summed Decay', fontsize=10, fontweight='bold', color='white')
|
|
1870
|
+
ax_decay.set_xlabel('Time (ns)', color='white')
|
|
1871
|
+
ax_decay.set_ylabel('Photon Count', color='white')
|
|
1872
|
+
ax_decay.grid(True, alpha=0.3)
|
|
1873
|
+
ax_decay.tick_params(labelsize=8, colors='white')
|
|
1874
|
+
self._fov_preview._redraw_region_overlays()
|
|
1875
|
+
self._fov_preview._ctrl_frame.grid()
|
|
1876
|
+
self._fov_preview._canvas_mpl.draw_idle()
|
|
1877
|
+
print(f'[Load] Restored FOV preview from cached data')
|
|
1878
|
+
except Exception as e:
|
|
1879
|
+
import traceback
|
|
1880
|
+
print(f'[Load] Could not redraw FOV preview: {e}')
|
|
1881
|
+
traceback.print_exc()
|
|
1882
|
+
if 'form_state_json' in fit_result:
|
|
1883
|
+
try:
|
|
1884
|
+
fs = fit_result['form_state_json']
|
|
1885
|
+
if isinstance(fs, np.ndarray): fs = fs.item()
|
|
1886
|
+
if isinstance(fs, bytes): fs = fs.decode()
|
|
1887
|
+
form_state = json.loads(fs)
|
|
1888
|
+
ptu_in_session = form_state.get('ptu_file', '').strip()
|
|
1889
|
+
self._last_loaded_ptu = (
|
|
1890
|
+
ptu_in_session
|
|
1891
|
+
if ptu_in_session
|
|
1892
|
+
else (self.sv_ptu.get().strip() if hasattr(self, 'sv_ptu') else '')
|
|
1893
|
+
)
|
|
1894
|
+
self._restore_form_state(form_state)
|
|
1895
|
+
except Exception as e:
|
|
1896
|
+
print(f'[Load] Could not restore form state: {e}')
|
|
1897
|
+
output_dir = str(Path(npz_path).parent)
|
|
1898
|
+
self._res.set_fit_result(fit_result, output_dir, npz_path=npz_path,
|
|
1899
|
+
scan_name=self._current_scan_stem())
|
|
1900
|
+
if not suppress_popups:
|
|
1901
|
+
messagebox.showinfo('Success', f'Loaded fitted data from:\n{Path(npz_path).name}')
|
|
1902
|
+
except Exception as e:
|
|
1903
|
+
import traceback
|
|
1904
|
+
traceback.print_exc()
|
|
1905
|
+
if not suppress_popups:
|
|
1906
|
+
messagebox.showerror('Error', f'Failed to load fitted data:\n{str(e)}')
|
|
1907
|
+
|
|
1908
|
+
def _show_export_dialog(self, image_dict: dict, output_dir: str):
|
|
1909
|
+
try:
|
|
1910
|
+
print(f'[Export Dialog] Starting with {len(image_dict)} items')
|
|
1911
|
+
if not image_dict or not any(isinstance(v, np.ndarray) for v in image_dict.values()):
|
|
1912
|
+
messagebox.showinfo('No images', 'No fit images available to export.')
|
|
1913
|
+
return
|
|
1914
|
+
available_images = {}
|
|
1915
|
+
for k, v in image_dict.items():
|
|
1916
|
+
if isinstance(v, np.ndarray):
|
|
1917
|
+
if (v.ndim == 2) or (v.ndim == 3 and v.shape[2] == 3):
|
|
1918
|
+
available_images[k] = v
|
|
1919
|
+
print(f'[Export] Found image: {k} shape={v.shape}')
|
|
1920
|
+
if not available_images:
|
|
1921
|
+
messagebox.showinfo('No images', 'No valid FLIM result images found to export.')
|
|
1922
|
+
return
|
|
1923
|
+
print(f'[Export] {len(available_images)} images available: {list(available_images.keys())}')
|
|
1924
|
+
dlg = tk.Toplevel(self.root)
|
|
1925
|
+
dlg.title('Export Results')
|
|
1926
|
+
dlg.resizable(True, True)
|
|
1927
|
+
dlg.minsize(560, 400)
|
|
1928
|
+
dlg.transient(self.root)
|
|
1929
|
+
dlg.grab_set()
|
|
1930
|
+
bv_scalebar = tk.BooleanVar(value=True)
|
|
1931
|
+
bv_annotations = tk.BooleanVar(value=True)
|
|
1932
|
+
image_vars = {key: tk.BooleanVar(value=True) for key in available_images}
|
|
1933
|
+
ttk.Label(dlg, text='Export Results', font=('TkDefaultFont', 11, 'bold')).pack(pady=10)
|
|
1934
|
+
img_frame = ttk.LabelFrame(dlg, text='Images to Export', padding=10)
|
|
1935
|
+
img_frame.pack(fill='both', expand=True, padx=20, pady=5)
|
|
1936
|
+
sorted_images = sorted(available_images.keys())
|
|
1937
|
+
n_cols = 3
|
|
1938
|
+
n_rows = (len(sorted_images) + n_cols - 1) // n_cols
|
|
1939
|
+
for col in range(n_cols):
|
|
1940
|
+
img_frame.columnconfigure(col, weight=1)
|
|
1941
|
+
for idx, key in enumerate(sorted_images):
|
|
1942
|
+
row = idx % n_rows
|
|
1943
|
+
col = idx // n_rows
|
|
1944
|
+
ttk.Checkbutton(img_frame, text=key.replace('_', ' ').title(),
|
|
1945
|
+
variable=image_vars[key]).grid(row=row, column=col, sticky='w', padx=5, pady=2)
|
|
1946
|
+
sel_btn_frame = ttk.Frame(img_frame)
|
|
1947
|
+
sel_btn_frame.grid(row=n_rows, column=0, columnspan=n_cols, sticky='w', pady=(10, 0))
|
|
1948
|
+
def select_all():
|
|
1949
|
+
for v in image_vars.values():
|
|
1950
|
+
v.set(True)
|
|
1951
|
+
def select_none():
|
|
1952
|
+
for v in image_vars.values():
|
|
1953
|
+
v.set(False)
|
|
1954
|
+
ttk.Button(sel_btn_frame, text='All', command=select_all, width=8).pack(side='left', padx=2)
|
|
1955
|
+
ttk.Button(sel_btn_frame, text='None', command=select_none, width=8).pack(side='left', padx=2)
|
|
1956
|
+
opt_frame = ttk.LabelFrame(dlg, text='Rendering Options', padding=10)
|
|
1957
|
+
opt_frame.pack(fill='x', padx=20, pady=5)
|
|
1958
|
+
ttk.Checkbutton(opt_frame, text='Include scale bar', variable=bv_scalebar).pack(anchor='w', pady=3)
|
|
1959
|
+
ttk.Checkbutton(opt_frame, text='Include ROI annotations', variable=bv_annotations).pack(anchor='w', pady=3)
|
|
1960
|
+
fmt_frame = ttk.LabelFrame(dlg, text='Image Format', padding=10)
|
|
1961
|
+
fmt_frame.pack(fill='x', padx=20, pady=5)
|
|
1962
|
+
bv_format = tk.StringVar(value='png')
|
|
1963
|
+
ttk.Radiobutton(fmt_frame, text='PNG (smaller file size, web-friendly)',
|
|
1964
|
+
variable=bv_format, value='png').pack(anchor='w', pady=3)
|
|
1965
|
+
ttk.Radiobutton(fmt_frame, text='OME-TIFF (lossless, metadata-rich)',
|
|
1966
|
+
variable=bv_format, value='ometiff').pack(anchor='w', pady=3)
|
|
1967
|
+
loc_frame = ttk.LabelFrame(dlg, text='Save Location', padding=10)
|
|
1968
|
+
loc_frame.pack(fill='x', padx=20, pady=5)
|
|
1969
|
+
export_path = tk.StringVar(value=output_dir)
|
|
1970
|
+
ttk.Label(loc_frame, text='Path:').pack(side='left')
|
|
1971
|
+
ttk.Entry(loc_frame, textvariable=export_path, width=40).pack(side='left', padx=5, fill='x', expand=True)
|
|
1972
|
+
|
|
1973
|
+
def browse_folder():
|
|
1974
|
+
from tkinter import filedialog
|
|
1975
|
+
folder = filedialog.askdirectory(initialdir=output_dir, title='Select export folder')
|
|
1976
|
+
if folder:
|
|
1977
|
+
export_path.set(folder)
|
|
1978
|
+
print(f'[Export] Save location changed to: {folder}')
|
|
1979
|
+
ttk.Button(loc_frame, text='Browse', command=browse_folder, width=8).pack(side='left', padx=2)
|
|
1980
|
+
|
|
1981
|
+
def do_export():
|
|
1982
|
+
try:
|
|
1983
|
+
selected_images = {k: v for k, v in available_images.items()
|
|
1984
|
+
if image_vars[k].get()}
|
|
1985
|
+
if not selected_images:
|
|
1986
|
+
messagebox.showwarning('No selection', 'Please select at least one image to export.')
|
|
1987
|
+
return
|
|
1988
|
+
export_dir = export_path.get()
|
|
1989
|
+
if not export_dir.strip():
|
|
1990
|
+
messagebox.showwarning('No path', 'Please select an export directory.')
|
|
1991
|
+
return
|
|
1992
|
+
fmt = bv_format.get()
|
|
1993
|
+
print(f'[Export] Exporting {len(selected_images)} images in {fmt.upper()} format to {export_dir}')
|
|
1994
|
+
self._export_images(selected_images, export_dir,
|
|
1995
|
+
with_scalebar=bv_scalebar.get(),
|
|
1996
|
+
with_annotations=bv_annotations.get(),
|
|
1997
|
+
format=fmt)
|
|
1998
|
+
dlg.destroy()
|
|
1999
|
+
messagebox.showinfo('Success', f'Results exported to\n{export_dir}')
|
|
2000
|
+
except Exception as e:
|
|
2001
|
+
print(f'[Export Error] {e}')
|
|
2002
|
+
import traceback
|
|
2003
|
+
traceback.print_exc()
|
|
2004
|
+
messagebox.showerror('Export Error', f'Failed to export:\n{str(e)}')
|
|
2005
|
+
btn_frame = ttk.Frame(dlg)
|
|
2006
|
+
btn_frame.pack(pady=15, fill='x', padx=20)
|
|
2007
|
+
ttk.Button(btn_frame, text='💾 Export', command=do_export).pack(side='left', padx=5, fill='x', expand=True)
|
|
2008
|
+
ttk.Button(btn_frame, text='Cancel', command=dlg.destroy).pack(side='left', padx=5)
|
|
2009
|
+
dlg.update_idletasks()
|
|
2010
|
+
req_w = max(560, dlg.winfo_reqwidth() + 24)
|
|
2011
|
+
req_h = min(dlg.winfo_reqheight() + 24, int(dlg.winfo_screenheight() * 0.85))
|
|
2012
|
+
dlg.geometry(f'{req_w}x{req_h}')
|
|
2013
|
+
except Exception as e:
|
|
2014
|
+
print(f'[Export Dialog Error] {e}')
|
|
2015
|
+
import traceback
|
|
2016
|
+
traceback.print_exc()
|
|
2017
|
+
messagebox.showerror('Dialog Error', f'Failed to create export dialog:\n{str(e)}')
|
|
2018
|
+
|
|
2019
|
+
def _export_images(self, image_dict: dict, output_dir: str,
|
|
2020
|
+
with_scalebar: bool = True, with_annotations: bool = True,
|
|
2021
|
+
format: str = 'png'):
|
|
2022
|
+
try:
|
|
2023
|
+
from pathlib import Path
|
|
2024
|
+
import numpy as np
|
|
2025
|
+
import matplotlib.pyplot as plt
|
|
2026
|
+
fmt = format.lower()
|
|
2027
|
+
print(f'[Export Images] Exporting {len(image_dict)} images in {fmt.upper()} format to {output_dir}')
|
|
2028
|
+
output_path = Path(output_dir)
|
|
2029
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
2030
|
+
exported_count = 0
|
|
2031
|
+
pixel_size_um = self._get_pixel_size_um()
|
|
2032
|
+
if with_scalebar and pixel_size_um is None:
|
|
2033
|
+
print('[Export] No pixel size available - scale bar will be omitted')
|
|
2034
|
+
with_scalebar = False
|
|
2035
|
+
if fmt == 'ometiff':
|
|
2036
|
+
try:
|
|
2037
|
+
import tifffile
|
|
2038
|
+
if 'intensity' in image_dict and isinstance(image_dict['intensity'], np.ndarray):
|
|
2039
|
+
try:
|
|
2040
|
+
intensity = image_dict['intensity']
|
|
2041
|
+
intensity_16bit = (intensity / intensity.max() * 65535).astype(np.uint16) if intensity.max() > 0 else intensity.astype(np.uint16)
|
|
2042
|
+
output_file = output_path / 'intensity.ome.tiff'
|
|
2043
|
+
tifffile.imwrite(output_file, intensity_16bit, photometric='minisblack',
|
|
2044
|
+
metadata={'description': 'FLIM Intensity Image'})
|
|
2045
|
+
print(f'✓ Exported OME-TIFF intensity: {output_file.name} ({intensity.shape})')
|
|
2046
|
+
exported_count += 1
|
|
2047
|
+
except Exception as e:
|
|
2048
|
+
print(f'[Export] Error exporting intensity TIFF: {e}')
|
|
2049
|
+
if 'lifetime' in image_dict and isinstance(image_dict['lifetime'], np.ndarray):
|
|
2050
|
+
try:
|
|
2051
|
+
lifetime = image_dict['lifetime']
|
|
2052
|
+
lifetime = np.nan_to_num(lifetime, nan=0.0)
|
|
2053
|
+
lifetime_32bit = lifetime.astype(np.float32)
|
|
2054
|
+
output_file = output_path / 'lifetime.ome.tiff'
|
|
2055
|
+
tifffile.imwrite(output_file, lifetime_32bit, photometric='minisblack',
|
|
2056
|
+
metadata={'description': 'FLIM Lifetime Map (ns)'})
|
|
2057
|
+
print(f'✓ Exported OME-TIFF lifetime: {output_file.name} ({lifetime.shape})')
|
|
2058
|
+
exported_count += 1
|
|
2059
|
+
except Exception as e:
|
|
2060
|
+
print(f'[Export] Error exporting lifetime TIFF: {e}')
|
|
2061
|
+
except ImportError:
|
|
2062
|
+
print(f'[Export] tifffile not installed, falling back to PNG')
|
|
2063
|
+
fmt = 'png'
|
|
2064
|
+
if fmt == 'png':
|
|
2065
|
+
if 'intensity' in image_dict and isinstance(image_dict['intensity'], np.ndarray):
|
|
2066
|
+
try:
|
|
2067
|
+
intensity = image_dict['intensity']
|
|
2068
|
+
print(f'[Export] Intensity shape: {intensity.shape}')
|
|
2069
|
+
h, w = intensity.shape
|
|
2070
|
+
fig = plt.figure(figsize=(w/100, h/100), dpi=100, facecolor='black', edgecolor='black')
|
|
2071
|
+
ax = fig.add_axes([0, 0, 1, 1])
|
|
2072
|
+
ax.set_facecolor('black')
|
|
2073
|
+
intensity_clipped = np.clip(intensity, 0, np.percentile(intensity, 99))
|
|
2074
|
+
im = ax.imshow(intensity_clipped, cmap='inferno', origin='upper', aspect='auto')
|
|
2075
|
+
ax.axis('off')
|
|
2076
|
+
if with_scalebar:
|
|
2077
|
+
self._draw_scale_bar(ax, w, h, pixel_size_um)
|
|
2078
|
+
output_file = output_path / 'intensity.png'
|
|
2079
|
+
fig.savefig(output_file, dpi=100, bbox_inches='tight', pad_inches=0, facecolor='black')
|
|
2080
|
+
plt.close(fig)
|
|
2081
|
+
print(f'✓ Exported PNG intensity: {output_file.name} ({intensity.shape})')
|
|
2082
|
+
exported_count += 1
|
|
2083
|
+
except Exception as e:
|
|
2084
|
+
print(f'[Export] Error exporting intensity PNG: {e}')
|
|
2085
|
+
import traceback
|
|
2086
|
+
traceback.print_exc()
|
|
2087
|
+
if 'lifetime' in image_dict and isinstance(image_dict['lifetime'], np.ndarray):
|
|
2088
|
+
try:
|
|
2089
|
+
lifetime = image_dict['lifetime']
|
|
2090
|
+
print(f'[Export] Lifetime shape: {lifetime.shape}')
|
|
2091
|
+
h, w = lifetime.shape[:2]
|
|
2092
|
+
fig = plt.figure(figsize=(w/100, h/100), dpi=100, facecolor='black', edgecolor='black')
|
|
2093
|
+
ax = fig.add_axes([0, 0, 1, 1])
|
|
2094
|
+
ax.set_facecolor('black')
|
|
2095
|
+
im = ax.imshow(lifetime, cmap='viridis', origin='upper', aspect='auto')
|
|
2096
|
+
ax.axis('off')
|
|
2097
|
+
if with_scalebar:
|
|
2098
|
+
self._draw_scale_bar(ax, w, h, pixel_size_um)
|
|
2099
|
+
if with_annotations and self._fov_preview._roi_manager.get_all_regions():
|
|
2100
|
+
from flimkit.utils.roi import get_rectangle_patch, get_ellipse_patch, get_polygon_patch
|
|
2101
|
+
for region in self._fov_preview._roi_manager.get_all_regions():
|
|
2102
|
+
region_id = region['id']
|
|
2103
|
+
tool_type = region['tool']
|
|
2104
|
+
coords = region['coords']
|
|
2105
|
+
color = self._fov_preview._roi_manager.get_color(region_id)
|
|
2106
|
+
try:
|
|
2107
|
+
if tool_type == 'rect':
|
|
2108
|
+
patch = get_rectangle_patch(coords, edgecolor=color, linewidth=2)
|
|
2109
|
+
elif tool_type == 'ellipse':
|
|
2110
|
+
patch = get_ellipse_patch(coords, edgecolor=color, linewidth=2)
|
|
2111
|
+
elif tool_type in ('polygon', 'freehand'):
|
|
2112
|
+
patch = get_polygon_patch(coords, edgecolor=color, linewidth=2)
|
|
2113
|
+
else:
|
|
2114
|
+
continue
|
|
2115
|
+
ax.add_patch(patch)
|
|
2116
|
+
except Exception as e:
|
|
2117
|
+
print(f'[Export] Could not add ROI {region_id}: {e}')
|
|
2118
|
+
output_file = output_path / 'lifetime.png'
|
|
2119
|
+
fig.savefig(output_file, dpi=100, bbox_inches='tight', pad_inches=0, facecolor='black')
|
|
2120
|
+
plt.close(fig)
|
|
2121
|
+
print(f'✓ Exported PNG lifetime: {output_file.name} ({lifetime.shape})')
|
|
2122
|
+
exported_count += 1
|
|
2123
|
+
except Exception as e:
|
|
2124
|
+
print(f'[Export] Error exporting lifetime PNG: {e}')
|
|
2125
|
+
import traceback
|
|
2126
|
+
traceback.print_exc()
|
|
2127
|
+
try:
|
|
2128
|
+
if self._fov_preview is not None and hasattr(self._fov_preview, '_ax_decay'):
|
|
2129
|
+
ax_decay = self._fov_preview._ax_decay
|
|
2130
|
+
fig, ax = plt.subplots(figsize=(14, 8), dpi=150, facecolor='white', edgecolor='white')
|
|
2131
|
+
ax.set_facecolor('white')
|
|
2132
|
+
for line in ax_decay.get_lines():
|
|
2133
|
+
ax.plot(line.get_xdata(), line.get_ydata(),
|
|
2134
|
+
color=line.get_color(), linewidth=2.5,
|
|
2135
|
+
marker=line.get_marker(), markersize=line.get_markersize(),
|
|
2136
|
+
label=line.get_label(), alpha=line.get_alpha())
|
|
2137
|
+
ax.set_yscale('log')
|
|
2138
|
+
ax.set_title('Summed Decay - Measured, IRF, and Fitted', fontsize=16, fontweight='bold', color='black')
|
|
2139
|
+
ax.set_xlabel('Time (ns)', fontsize=13, color='black')
|
|
2140
|
+
ax.set_ylabel('Photon Count', fontsize=13, color='black')
|
|
2141
|
+
ax.tick_params(colors='black')
|
|
2142
|
+
ax.legend(fontsize=12, loc='upper right', framealpha=0.9, labelcolor='black')
|
|
2143
|
+
ax.grid(True, alpha=0.3, color='gray')
|
|
2144
|
+
output_file = output_path / 'summed_decay.png'
|
|
2145
|
+
fig.savefig(output_file, dpi=150, bbox_inches='tight', facecolor='white')
|
|
2146
|
+
plt.close(fig)
|
|
2147
|
+
print(f'✓ Exported decay plot: {output_file.name}')
|
|
2148
|
+
exported_count += 1
|
|
2149
|
+
except Exception as e:
|
|
2150
|
+
print(f'[Export] Error exporting decay: {e}')
|
|
2151
|
+
import traceback
|
|
2152
|
+
traceback.print_exc()
|
|
2153
|
+
print(f'✓ Export complete: {exported_count} high-resolution images to {output_path}')
|
|
2154
|
+
try:
|
|
2155
|
+
import subprocess
|
|
2156
|
+
subprocess.Popen(['open', str(output_path)])
|
|
2157
|
+
except Exception as e:
|
|
2158
|
+
print(f'[Export] Could not open folder: {e}')
|
|
2159
|
+
except Exception as e:
|
|
2160
|
+
print(f'✗ Export images error: {e}')
|
|
2161
|
+
import traceback
|
|
2162
|
+
traceback.print_exc()
|
|
2163
|
+
|
|
2164
|
+
def _get_pixel_size_um(self) -> 'float | None':
|
|
2165
|
+
try:
|
|
2166
|
+
from pathlib import Path
|
|
2167
|
+
if getattr(self, '_current_form', None) == 'stitch':
|
|
2168
|
+
xlif = self.sv_xlif.get().strip() if hasattr(self, 'sv_xlif') else ''
|
|
2169
|
+
if xlif:
|
|
2170
|
+
from flimkit.utils.xml_utils import get_pixel_size
|
|
2171
|
+
basename = getattr(self, 'sv_ptu_basename', None)
|
|
2172
|
+
basename = basename.get().strip() if basename is not None else Path(xlif).stem
|
|
2173
|
+
pixel_size_m, _ = get_pixel_size(Path(xlif), basename or Path(xlif).stem)
|
|
2174
|
+
if pixel_size_m and pixel_size_m > 0:
|
|
2175
|
+
return pixel_size_m * 1e6
|
|
2176
|
+
ptu_path = getattr(self._fov_preview, '_ptu_path', None)
|
|
2177
|
+
if ptu_path and Path(ptu_path).exists():
|
|
2178
|
+
from flimkit.formats import FLIMFile
|
|
2179
|
+
ptu = FLIMFile(str(ptu_path), verbose=False)
|
|
2180
|
+
pix_res = ptu.tags.get('ImgHdr_PixRes', 0)
|
|
2181
|
+
if pix_res and float(pix_res) > 0:
|
|
2182
|
+
return float(pix_res) * 1e6
|
|
2183
|
+
except Exception as e:
|
|
2184
|
+
print(f'[Export] Could not determine pixel size: {e}')
|
|
2185
|
+
return None
|
|
2186
|
+
|
|
2187
|
+
@staticmethod
|
|
2188
|
+
def _draw_scale_bar(ax, img_w_px: int, img_h_px: int, pixel_size_um: float):
|
|
2189
|
+
from matplotlib.patches import FancyBboxPatch
|
|
2190
|
+
fov_um = img_w_px * pixel_size_um
|
|
2191
|
+
target = fov_um * 0.20
|
|
2192
|
+
nice = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
|
|
2193
|
+
bar_um = nice[0]
|
|
2194
|
+
for n in nice:
|
|
2195
|
+
if n <= target:
|
|
2196
|
+
bar_um = n
|
|
2197
|
+
else:
|
|
2198
|
+
break
|
|
2199
|
+
bar_px = bar_um / pixel_size_um
|
|
2200
|
+
bar_h = max(3, img_h_px * 0.015)
|
|
2201
|
+
margin_x = img_w_px * 0.03
|
|
2202
|
+
margin_y = img_h_px * 0.03
|
|
2203
|
+
x0 = img_w_px - margin_x - bar_px
|
|
2204
|
+
y0 = img_h_px - margin_y - bar_h
|
|
2205
|
+
label = f'{bar_um} µm'
|
|
2206
|
+
fontsize = max(7, min(14, img_h_px * 0.035))
|
|
2207
|
+
pad_x = bar_px * 0.08
|
|
2208
|
+
pad_y = fontsize * 1.8
|
|
2209
|
+
bg = FancyBboxPatch(
|
|
2210
|
+
(x0 - pad_x, y0 - pad_y),
|
|
2211
|
+
bar_px + 2 * pad_x,
|
|
2212
|
+
bar_h + pad_y + margin_y * 0.5,
|
|
2213
|
+
boxstyle='round,pad=4',
|
|
2214
|
+
facecolor='black', edgecolor='none', alpha=0.55,
|
|
2215
|
+
zorder=9,
|
|
2216
|
+
)
|
|
2217
|
+
ax.add_patch(bg)
|
|
2218
|
+
from matplotlib.patches import Rectangle
|
|
2219
|
+
bar = Rectangle((x0, y0), bar_px, bar_h,
|
|
2220
|
+
facecolor='white', edgecolor='none', zorder=10)
|
|
2221
|
+
ax.add_patch(bar)
|
|
2222
|
+
ax.text(x0 + bar_px / 2, y0 - fontsize * 0.35,
|
|
2223
|
+
label, color='white', fontsize=fontsize,
|
|
2224
|
+
ha='center', va='bottom', zorder=10)
|
|
2225
|
+
|
|
2226
|
+
def _export_npz_fit(self, fit_result: dict, output_dir: str, ptu_path: str = None):
|
|
2227
|
+
try:
|
|
2228
|
+
from pathlib import Path
|
|
2229
|
+
import shutil
|
|
2230
|
+
npz_source = None
|
|
2231
|
+
if ptu_path:
|
|
2232
|
+
base_path = Path(ptu_path)
|
|
2233
|
+
if base_path.is_file():
|
|
2234
|
+
npz_source = base_path.parent / f'{base_path.stem}.roi_fit.npz'
|
|
2235
|
+
if not npz_source or not npz_source.exists():
|
|
2236
|
+
source = fit_result.get('source')
|
|
2237
|
+
if isinstance(source, bytes):
|
|
2238
|
+
source = source.decode('utf-8')
|
|
2239
|
+
if source:
|
|
2240
|
+
base_path = Path(source)
|
|
2241
|
+
if base_path.is_file():
|
|
2242
|
+
npz_source = base_path.parent / f'{base_path.stem}.roi_fit.npz'
|
|
2243
|
+
else:
|
|
2244
|
+
npz_source = base_path / 'roi_fit.npz'
|
|
2245
|
+
if npz_source and npz_source.exists():
|
|
2246
|
+
output_path = Path(output_dir)
|
|
2247
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
2248
|
+
npz_dest = output_path / 'fit_result.npz'
|
|
2249
|
+
shutil.copy2(npz_source, npz_dest)
|
|
2250
|
+
print(f'✓ NPZ fit data copied to export: {npz_dest.name}')
|
|
2251
|
+
else:
|
|
2252
|
+
print(f'[Export] Could not find existing NPZ file to copy')
|
|
2253
|
+
except Exception as e:
|
|
2254
|
+
print(f'✗ NPZ export error: {e}')
|
|
2255
|
+
import traceback
|
|
2256
|
+
traceback.print_exc()
|
|
2257
|
+
|
|
2258
|
+
def _build_fov_tab(self):
|
|
2259
|
+
from flimkit.UI.modes.fov_mode import FovMode
|
|
2260
|
+
FovMode(self).build()
|
|
2261
|
+
|
|
2262
|
+
def _build_stitch_tab(self):
|
|
2263
|
+
from flimkit.UI.modes.stitch_mode import StitchMode
|
|
2264
|
+
StitchMode(self).build()
|
|
2265
|
+
|
|
2266
|
+
def _build_stitch_fit(self, parent):
|
|
2267
|
+
from flimkit.UI.modes.stitch_mode import StitchMode
|
|
2268
|
+
StitchMode(self).build_fit(parent)
|
|
2269
|
+
|
|
2270
|
+
def _apply_expert_overrides(self, a):
|
|
2271
|
+
ex = self._expert_overrides
|
|
2272
|
+
if not ex:
|
|
2273
|
+
return
|
|
2274
|
+
if 'optimizer' in ex:
|
|
2275
|
+
a.optimizer = ex['optimizer']
|
|
2276
|
+
if 'de_population' in ex:
|
|
2277
|
+
a.de_population = ex['de_population']
|
|
2278
|
+
if 'de_maxiter' in ex:
|
|
2279
|
+
a.de_maxiter = ex['de_maxiter']
|
|
2280
|
+
if 'lm_restarts' in ex:
|
|
2281
|
+
a.restarts = ex['lm_restarts']
|
|
2282
|
+
if 'binning_factor' in ex:
|
|
2283
|
+
a.binning = ex['binning_factor']
|
|
2284
|
+
if 'n_workers' in ex:
|
|
2285
|
+
a.workers = ex['n_workers']
|
|
2286
|
+
if 'min_photons' in ex:
|
|
2287
|
+
a.min_photons = ex['min_photons']
|
|
2288
|
+
if 'cost_function' in ex:
|
|
2289
|
+
a.cost_function = ex['cost_function']
|
|
2290
|
+
if 'channels' in ex:
|
|
2291
|
+
a.channel = ex['channels']
|
|
2292
|
+
if 'irf_fwhm' in ex and ex['irf_fwhm'] is not None:
|
|
2293
|
+
a.irf_fwhm = ex['irf_fwhm']
|
|
2294
|
+
if 'irf_align' in ex:
|
|
2295
|
+
a.irf_align = ex['irf_align']
|
|
2296
|
+
if 'irf_shift_bins' in ex:
|
|
2297
|
+
a.irf_shift_bins = ex['irf_shift_bins']
|
|
2298
|
+
if 'free_tau_perpixel' in ex:
|
|
2299
|
+
a.free_tau_perpixel = ex['free_tau_perpixel']
|
|
2300
|
+
if 'align_irf' in ex:
|
|
2301
|
+
a.align_irf = ex['align_irf']
|
|
2302
|
+
if 'fit_start_ns' in ex:
|
|
2303
|
+
a.fit_start_ns = ex['fit_start_ns']
|
|
2304
|
+
if 'fit_end_ns' in ex:
|
|
2305
|
+
a.fit_end_ns = ex['fit_end_ns']
|
|
2306
|
+
if 'exclude_ns' in ex:
|
|
2307
|
+
a.exclude_ns = ex['exclude_ns'] or None
|
|
2308
|
+
if 'fit_t0' in ex:
|
|
2309
|
+
a.fit_t0 = ex['fit_t0']
|
|
2310
|
+
|
|
2311
|
+
def _open_expert_settings(self):
|
|
2312
|
+
from flimkit.utils.config_manager import cfg
|
|
2313
|
+
saved = cfg.get_section('expert')
|
|
2314
|
+
merged = dict(saved)
|
|
2315
|
+
merged.update(self._expert_overrides)
|
|
2316
|
+
dlg = ExpertSettingsDialog(self.root, merged)
|
|
2317
|
+
self.root.wait_window(dlg)
|
|
2318
|
+
if dlg.result is not None:
|
|
2319
|
+
is_default = all(
|
|
2320
|
+
dlg.result.get(k) == v for k, v in _EXPERT_DEFAULTS.items()
|
|
2321
|
+
)
|
|
2322
|
+
if is_default:
|
|
2323
|
+
self._expert_overrides = {}
|
|
2324
|
+
else:
|
|
2325
|
+
self._expert_overrides = dlg.result
|
|
2326
|
+
cfg.update_section('expert', dlg.result)
|
|
2327
|
+
if hasattr(self, '_proj_browser') and self._proj_browser and self._proj_browser._project:
|
|
2328
|
+
self._proj_browser._project.config['expert'] = dlg.result
|
|
2329
|
+
self._proj_browser._project.save()
|
|
2330
|
+
cfg.load_project_overrides(self._proj_browser._project.config)
|
|
2331
|
+
self._update_expert_banners()
|
|
2332
|
+
|
|
2333
|
+
def _update_expert_banners(self):
|
|
2334
|
+
active = bool(self._expert_overrides)
|
|
2335
|
+
for banner in (self._expert_banner_fov, self._expert_banner_st,
|
|
2336
|
+
self._expert_banner_batch):
|
|
2337
|
+
if active:
|
|
2338
|
+
banner.grid()
|
|
2339
|
+
else:
|
|
2340
|
+
banner.grid_remove()
|
|
2341
|
+
|
|
2342
|
+
def _pipeline_changed(self):
|
|
2343
|
+
mode = self.sv_pipeline.get()
|
|
2344
|
+
if mode == 'stitch_only':
|
|
2345
|
+
self._fit_frame.grid_remove()
|
|
2346
|
+
self._btn_st.configure(text='▶ Run Tile Stitch')
|
|
2347
|
+
self._btn_expert_st.pack_forget()
|
|
2348
|
+
self._expert_banner_st.grid_remove()
|
|
2349
|
+
elif mode == 'stitch_fit':
|
|
2350
|
+
self._fit_frame.grid()
|
|
2351
|
+
self._tile_extras_frame.grid_remove()
|
|
2352
|
+
self._btn_st.configure(text='▶ Run Stitch + Fit')
|
|
2353
|
+
self._btn_expert_st.pack(side='left', padx=4, before=self._btn_st)
|
|
2354
|
+
self._update_expert_banners()
|
|
2355
|
+
elif mode == 'series_fit':
|
|
2356
|
+
self._fit_frame.grid()
|
|
2357
|
+
self._tile_extras_frame.grid_remove()
|
|
2358
|
+
if hasattr(self, '_series_frame'):
|
|
2359
|
+
self._series_frame.grid()
|
|
2360
|
+
self._btn_st.configure(text='▶ Run Series Fit')
|
|
2361
|
+
self._btn_expert_st.pack(side='left', padx=4, before=self._btn_st)
|
|
2362
|
+
self._update_expert_banners()
|
|
2363
|
+
else:
|
|
2364
|
+
self._fit_frame.grid()
|
|
2365
|
+
self._tile_extras_frame.grid()
|
|
2366
|
+
self._btn_st.configure(text='▶ Run Per-Tile Fit')
|
|
2367
|
+
self._btn_expert_st.pack(side='left', padx=4, before=self._btn_st)
|
|
2368
|
+
self._update_expert_banners()
|
|
2369
|
+
if mode != 'series_fit' and hasattr(self, '_series_frame'):
|
|
2370
|
+
self._series_frame.grid_remove()
|
|
2371
|
+
self._update_form_scrollbar('stitch')
|
|
2372
|
+
self.root.after_idle(self._fit_window_to_screen)
|
|
2373
|
+
|
|
2374
|
+
def _perpix_toggled(self):
|
|
2375
|
+
if self.bv_perpix.get():
|
|
2376
|
+
self._pxf.grid()
|
|
2377
|
+
else:
|
|
2378
|
+
self._pxf.grid_remove()
|
|
2379
|
+
self._update_form_scrollbar('stitch')
|
|
2380
|
+
self.root.after_idle(self._fit_window_to_screen)
|
|
2381
|
+
|
|
2382
|
+
def _update_form_scrollbar(self, form_id: str):
|
|
2383
|
+
if form_id not in self._form_inner_frames:
|
|
2384
|
+
return
|
|
2385
|
+
try:
|
|
2386
|
+
outer, inner = self._form_inner_frames[form_id]
|
|
2387
|
+
if not hasattr(outer, '_canvas'):
|
|
2388
|
+
return
|
|
2389
|
+
canvas = outer._canvas
|
|
2390
|
+
window_id = outer._window_id
|
|
2391
|
+
|
|
2392
|
+
def _refresh():
|
|
2393
|
+
try:
|
|
2394
|
+
inner.update()
|
|
2395
|
+
except Exception:
|
|
2396
|
+
pass
|
|
2397
|
+
bbox = canvas.bbox('all')
|
|
2398
|
+
if bbox:
|
|
2399
|
+
canvas.configure(scrollregion=bbox)
|
|
2400
|
+
new_h = inner.winfo_reqheight()
|
|
2401
|
+
canvas_h = canvas.winfo_height()
|
|
2402
|
+
target_h = max(new_h, canvas_h if canvas_h > 1 else 0)
|
|
2403
|
+
if target_h > 0:
|
|
2404
|
+
canvas.itemconfigure(window_id, height=target_h)
|
|
2405
|
+
cw = canvas.winfo_width()
|
|
2406
|
+
if cw > 1:
|
|
2407
|
+
canvas.itemconfigure(window_id, width=cw)
|
|
2408
|
+
self.root.after_idle(lambda: self.root.after(80, _refresh))
|
|
2409
|
+
except Exception as e:
|
|
2410
|
+
print(f'[Scrollbar] {form_id}: {e}')
|
|
2411
|
+
|
|
2412
|
+
def _build_batch_tab(self):
|
|
2413
|
+
from flimkit.UI.modes.batch_mode import BatchMode
|
|
2414
|
+
BatchMode(self).build()
|
|
2415
|
+
|
|
2416
|
+
def _batch_mode_changed(self):
|
|
2417
|
+
mode = self.sv_batch_mode.get()
|
|
2418
|
+
labels = {
|
|
2419
|
+
'tiled': 'Mode: Multi-Tile ROI Fit',
|
|
2420
|
+
'fov': 'Mode: Single FOV Fit',
|
|
2421
|
+
'timelapse': 'Mode: Timelapse Fit',
|
|
2422
|
+
}
|
|
2423
|
+
self._batch_mode_label.configure(text=labels.get(mode, ''))
|
|
2424
|
+
if mode == 'tiled':
|
|
2425
|
+
self._batch_xlif_fr.grid()
|
|
2426
|
+
self._batch_freg.grid()
|
|
2427
|
+
else:
|
|
2428
|
+
self._batch_xlif_fr.grid_remove()
|
|
2429
|
+
self._batch_freg.grid_remove()
|
|
2430
|
+
if mode == 'timelapse':
|
|
2431
|
+
self._batch_tl_fr.grid()
|
|
2432
|
+
else:
|
|
2433
|
+
self._batch_tl_fr.grid_remove()
|
|
2434
|
+
btn_texts = {
|
|
2435
|
+
'tiled': '▶ Run Batch ROI Fit',
|
|
2436
|
+
'fov': '▶ Run Batch FOV Fit',
|
|
2437
|
+
'timelapse': '▶ Run Timelapse Fit',
|
|
2438
|
+
}
|
|
2439
|
+
self._btn_batch.configure(text=btn_texts.get(mode, '▶ Run'))
|
|
2440
|
+
help_texts = {
|
|
2441
|
+
'tiled': 'One sub-folder per ROI created inside the output base dir.',
|
|
2442
|
+
'fov': 'One sub-folder per PTU file created inside the output base dir.',
|
|
2443
|
+
'timelapse': 'One sub-folder per (region, series, z) group inside the output base dir.',
|
|
2444
|
+
}
|
|
2445
|
+
self._batch_io_help.configure(text=help_texts.get(mode, ''))
|
|
2446
|
+
|
|
2447
|
+
def _dispatch_batch(self):
|
|
2448
|
+
mode = self.sv_batch_mode.get()
|
|
2449
|
+
if mode == 'tiled':
|
|
2450
|
+
self._run_batch()
|
|
2451
|
+
elif mode == 'timelapse':
|
|
2452
|
+
self._run_timelapse_batch()
|
|
2453
|
+
else:
|
|
2454
|
+
self._run_batch_fov()
|
|
2455
|
+
|
|
2456
|
+
def _resolve_batch_out_dir(self, ptu_dir: str) -> str:
|
|
2457
|
+
if self.bv_batch_save_beside.get():
|
|
2458
|
+
out = str(Path(ptu_dir) / 'save')
|
|
2459
|
+
Path(out).mkdir(parents=True, exist_ok=True)
|
|
2460
|
+
return out
|
|
2461
|
+
return self.sv_batch_out_dir.get().strip()
|
|
2462
|
+
|
|
2463
|
+
def _run_timelapse_batch(self):
|
|
2464
|
+
ptu_dir = self.sv_batch_ptu_dir.get().strip()
|
|
2465
|
+
if not ptu_dir or not Path(ptu_dir).is_dir():
|
|
2466
|
+
messagebox.showerror('Missing input', 'Please select a valid PTU folder.')
|
|
2467
|
+
return
|
|
2468
|
+
out_dir = self._resolve_batch_out_dir(ptu_dir)
|
|
2469
|
+
if not out_dir:
|
|
2470
|
+
messagebox.showerror('Missing input', 'Please specify an output directory.')
|
|
2471
|
+
return
|
|
2472
|
+
from flimkit.utils.batch_fit import group_timelapse_files
|
|
2473
|
+
groups = group_timelapse_files(ptu_dir)
|
|
2474
|
+
if not groups:
|
|
2475
|
+
messagebox.showerror(
|
|
2476
|
+
'No timelapse PTUs',
|
|
2477
|
+
f'No files matching region_tX[_sY][_zZ].ptu found in:\n{ptu_dir}'
|
|
2478
|
+
)
|
|
2479
|
+
return
|
|
2480
|
+
cfg = _C()
|
|
2481
|
+
mirf = self.sv_batch_mirf.get().strip() or str(cfg['MACHINE_IRF_DEFAULT_PATH'])
|
|
2482
|
+
n_exp = self.iv_nexp_batch.get()
|
|
2483
|
+
tau_min = float(self.sv_batch_tau_min.get() or cfg['Tau_min'])
|
|
2484
|
+
tau_max = float(self.sv_batch_tau_max.get() or cfg['Tau_max'])
|
|
2485
|
+
thr = _thresh(self.bv_batch_thr, self.sv_batch_thr)
|
|
2486
|
+
correct_pileup = self.bv_batch_correct_pileup.get()
|
|
2487
|
+
save_stack = self.bv_batch_save_stack.get()
|
|
2488
|
+
save_lifetime = self.bv_batch_save_lifetime.get()
|
|
2489
|
+
save_rgb = self.bv_batch_save_rgb.get()
|
|
2490
|
+
save_intensity = self.bv_batch_save_intensity.get()
|
|
2491
|
+
save_npy = self.bv_batch_save_npy.get()
|
|
2492
|
+
save_ind = self.bv_batch_save_ind.get()
|
|
2493
|
+
tau_lo = _flt(self.sv_batch_tau_lo) or cfg['TAU_DISPLAY_MIN']
|
|
2494
|
+
tau_hi = _flt(self.sv_batch_tau_hi) or cfg['TAU_DISPLAY_MAX']
|
|
2495
|
+
gamma = float(self.sv_batch_gamma.get() or 0.4)
|
|
2496
|
+
int_max = _flt(self.sv_batch_int_max) or None
|
|
2497
|
+
pool_positions = self.bv_tl_pool_positions.get()
|
|
2498
|
+
compute_bound_fraction = self.bv_tl_bound_fraction.get()
|
|
2499
|
+
expert_overrides = dict(self._expert_overrides)
|
|
2500
|
+
ref_tau1 = ref_tau2 = ref_tau3 = None
|
|
2501
|
+
if self.bv_tl_fix_tau.get():
|
|
2502
|
+
_raw = [self.sv_tl_tau1.get().strip(),
|
|
2503
|
+
self.sv_tl_tau2.get().strip(),
|
|
2504
|
+
self.sv_tl_tau3.get().strip()][:n_exp]
|
|
2505
|
+
_parsed = []
|
|
2506
|
+
for i, _t in enumerate(_raw):
|
|
2507
|
+
if not _t:
|
|
2508
|
+
_parsed.append(None)
|
|
2509
|
+
continue
|
|
2510
|
+
try:
|
|
2511
|
+
_parsed.append(float(_t))
|
|
2512
|
+
except ValueError:
|
|
2513
|
+
messagebox.showerror('Bad value', f"τ{i+1} must be a number (got '{_t}').")
|
|
2514
|
+
return
|
|
2515
|
+
if any(v is None for v in _parsed):
|
|
2516
|
+
messagebox.showerror(
|
|
2517
|
+
'Missing τ',
|
|
2518
|
+
f'Fix reference τ is on - please enter all {n_exp} τ value(s), '
|
|
2519
|
+
'or untick it to fit τ from the pooled decay.')
|
|
2520
|
+
return
|
|
2521
|
+
ref_tau1, ref_tau2, ref_tau3 = (_parsed + [None, None, None])[:3]
|
|
2522
|
+
n_groups = len(groups)
|
|
2523
|
+
n_frames = sum(len(v) for v in groups.values())
|
|
2524
|
+
from flimkit.FLIM.batch import fit_timelapse
|
|
2525
|
+
|
|
2526
|
+
def task(progress_callback, cancel_event):
|
|
2527
|
+
a = argparse.Namespace(
|
|
2528
|
+
nexp=n_exp,
|
|
2529
|
+
tau_min=tau_min,
|
|
2530
|
+
tau_max=tau_max,
|
|
2531
|
+
estimate_irf='machine_irf',
|
|
2532
|
+
machine_irf=mirf,
|
|
2533
|
+
irf_fwhm=expert_overrides.get('irf_fwhm', cfg['IRF_FWHM']),
|
|
2534
|
+
irf_bins=cfg['IRF_BINS'],
|
|
2535
|
+
irf_fit_width=cfg['IRF_FIT_WIDTH'],
|
|
2536
|
+
optimizer=expert_overrides.get('optimizer', 'de'),
|
|
2537
|
+
restarts=expert_overrides.get('lm_restarts', cfg['lm_restarts']),
|
|
2538
|
+
de_population=expert_overrides.get('de_population', cfg['de_population']),
|
|
2539
|
+
de_maxiter=expert_overrides.get('de_maxiter', cfg['de_maxiter']),
|
|
2540
|
+
workers=expert_overrides.get('n_workers', cfg['n_workers']),
|
|
2541
|
+
no_polish=False,
|
|
2542
|
+
channel=expert_overrides.get('channels', cfg['channels']),
|
|
2543
|
+
min_photons=expert_overrides.get('min_photons', cfg['MIN_PHOTONS_PERPIX']),
|
|
2544
|
+
correct_pileup=correct_pileup,
|
|
2545
|
+
cost_function='poisson',
|
|
2546
|
+
save_stack=save_stack,
|
|
2547
|
+
save_lifetime=save_lifetime,
|
|
2548
|
+
save_rgb=save_rgb,
|
|
2549
|
+
save_intensity=save_intensity,
|
|
2550
|
+
save_npy=save_npy,
|
|
2551
|
+
save_ind=save_ind,
|
|
2552
|
+
tau_display_min=tau_lo,
|
|
2553
|
+
tau_display_max=tau_hi,
|
|
2554
|
+
gamma=gamma,
|
|
2555
|
+
intensity_display_max=int_max,
|
|
2556
|
+
no_plots=False,
|
|
2557
|
+
intensity_threshold=thr,
|
|
2558
|
+
)
|
|
2559
|
+
return fit_timelapse(
|
|
2560
|
+
ptu_dir=ptu_dir,
|
|
2561
|
+
output_dir=out_dir,
|
|
2562
|
+
args=a,
|
|
2563
|
+
ref_tau1_ns=ref_tau1,
|
|
2564
|
+
ref_tau2_ns=ref_tau2,
|
|
2565
|
+
ref_tau3_ns=ref_tau3,
|
|
2566
|
+
channel=expert_overrides.get('channels', cfg['channels']),
|
|
2567
|
+
pool_positions=pool_positions,
|
|
2568
|
+
compute_bound_fraction=compute_bound_fraction,
|
|
2569
|
+
progress_callback=progress_callback,
|
|
2570
|
+
cancel_event=cancel_event,
|
|
2571
|
+
)
|
|
2572
|
+
|
|
2573
|
+
def on_done(result):
|
|
2574
|
+
self._set_buttons('normal')
|
|
2575
|
+
self._res.set_status(f'✓ Timelapse complete - {n_groups} group(s), {n_frames} frames.')
|
|
2576
|
+
self._res.load_images(out_dir)
|
|
2577
|
+
self._set_buttons('disabled')
|
|
2578
|
+
self.run_with_progress(
|
|
2579
|
+
task,
|
|
2580
|
+
task_name=f'Timelapse Fit ({n_groups} group(s), {n_frames} frames)',
|
|
2581
|
+
on_done=on_done,
|
|
2582
|
+
output_dir=out_dir,
|
|
2583
|
+
)
|
|
2584
|
+
|
|
2585
|
+
def _run_batch_fov(self):
|
|
2586
|
+
ptu_dir = self.sv_batch_ptu_dir.get().strip()
|
|
2587
|
+
if not ptu_dir or not Path(ptu_dir).is_dir():
|
|
2588
|
+
messagebox.showerror('Missing input', 'Please select a valid PTU folder.')
|
|
2589
|
+
return
|
|
2590
|
+
out_dir = self._resolve_batch_out_dir(ptu_dir)
|
|
2591
|
+
if not out_dir:
|
|
2592
|
+
messagebox.showerror('Missing input', 'Please specify an output directory.')
|
|
2593
|
+
return
|
|
2594
|
+
from flimkit.formats import supported_extensions
|
|
2595
|
+
_globs = tuple('*' + e for e in supported_extensions())
|
|
2596
|
+
ptu_files = sorted(p for ext in _globs for p in Path(ptu_dir).glob(ext))
|
|
2597
|
+
if not ptu_files:
|
|
2598
|
+
messagebox.showerror('No FLIM files', f'No FLIM files found in:\n{ptu_dir}')
|
|
2599
|
+
return
|
|
2600
|
+
cfg = _C()
|
|
2601
|
+
mirf = self.sv_batch_mirf.get().strip() or str(cfg['MACHINE_IRF_DEFAULT_PATH'])
|
|
2602
|
+
_batch_model = self.sv_fit_model_batch.get()
|
|
2603
|
+
n_exp = self.iv_nexp_batch.get() if _batch_model in ('discrete', 'tail') else 2
|
|
2604
|
+
tau_min = float(self.sv_batch_tau_min.get() or cfg['Tau_min'])
|
|
2605
|
+
tau_max = float(self.sv_batch_tau_max.get() or cfg['Tau_max'])
|
|
2606
|
+
tau_lo = _flt(self.sv_batch_tau_lo) or cfg['TAU_DISPLAY_MIN'] or 0.0
|
|
2607
|
+
tau_hi = _flt(self.sv_batch_tau_hi) or cfg['TAU_DISPLAY_MAX'] or 10.0
|
|
2608
|
+
save_npy = self.bv_batch_save_npy.get()
|
|
2609
|
+
thr = _thresh(self.bv_batch_thr, self.sv_batch_thr)
|
|
2610
|
+
correct_pileup = self.bv_batch_correct_pileup.get()
|
|
2611
|
+
tvb_ptu_batch = (self.sv_batch_tvb_ptu.get().strip() or None) if hasattr(self, 'sv_batch_tvb_ptu') else None
|
|
2612
|
+
expert_overrides = dict(self._expert_overrides)
|
|
2613
|
+
from flimkit.interactive import _run_flim_fit
|
|
2614
|
+
import gc, csv as csv_mod
|
|
2615
|
+
|
|
2616
|
+
def task(progress_callback, cancel_event):
|
|
2617
|
+
csv_path = Path(out_dir) / 'batch_fov_fit_summary.csv'
|
|
2618
|
+
header_written = False
|
|
2619
|
+
n_total = len(ptu_files)
|
|
2620
|
+
for idx, ptu_path in enumerate(ptu_files):
|
|
2621
|
+
if cancel_event.is_set():
|
|
2622
|
+
print('\nBatch cancelled.')
|
|
2623
|
+
break
|
|
2624
|
+
progress_callback(idx, n_total)
|
|
2625
|
+
stem = ptu_path.stem
|
|
2626
|
+
fov_out = Path(out_dir) / stem.replace(' ', '_')
|
|
2627
|
+
fov_out.mkdir(parents=True, exist_ok=True)
|
|
2628
|
+
print(f'\n{'='*50}\n [{idx+1}/{n_total}] {stem}\n{'='*50}')
|
|
2629
|
+
try:
|
|
2630
|
+
a = argparse.Namespace(
|
|
2631
|
+
ptu=str(ptu_path),
|
|
2632
|
+
xlsx=None,
|
|
2633
|
+
debug_xlsx=False,
|
|
2634
|
+
print_config=False,
|
|
2635
|
+
irf=None,
|
|
2636
|
+
irf_xlsx=None,
|
|
2637
|
+
estimate_irf='machine_irf',
|
|
2638
|
+
no_xlsx_irf=True,
|
|
2639
|
+
machine_irf=mirf,
|
|
2640
|
+
irf_bins=cfg['IRF_BINS'],
|
|
2641
|
+
irf_fit_width=cfg['IRF_FIT_WIDTH'],
|
|
2642
|
+
irf_fwhm=expert_overrides.get('irf_fwhm', cfg['IRF_FWHM']),
|
|
2643
|
+
nexp=n_exp,
|
|
2644
|
+
dist_type=_batch_model,
|
|
2645
|
+
dist_n_components=(self.iv_ncomp_dist_batch.get() if _batch_model not in ('discrete', 'tail') else 1),
|
|
2646
|
+
fit_t0=expert_overrides.get('fit_t0', False),
|
|
2647
|
+
tau_min=tau_min,
|
|
2648
|
+
tau_max=tau_max,
|
|
2649
|
+
mode='both',
|
|
2650
|
+
binning=expert_overrides.get('binning_factor', cfg['binning_factor']),
|
|
2651
|
+
min_photons=expert_overrides.get('min_photons', cfg['MIN_PHOTONS_PERPIX']),
|
|
2652
|
+
optimizer=expert_overrides.get('optimizer', cfg['Optimizer']),
|
|
2653
|
+
restarts=expert_overrides.get('lm_restarts', cfg['lm_restarts']),
|
|
2654
|
+
de_population=expert_overrides.get('de_population', cfg['de_population']),
|
|
2655
|
+
de_maxiter=expert_overrides.get('de_maxiter', cfg['de_maxiter']),
|
|
2656
|
+
workers=expert_overrides.get('n_workers', cfg['n_workers']),
|
|
2657
|
+
no_polish=False,
|
|
2658
|
+
channel=expert_overrides.get('channels', cfg['channels']),
|
|
2659
|
+
out=str(fov_out / stem),
|
|
2660
|
+
no_plots=True,
|
|
2661
|
+
cell_mask=False,
|
|
2662
|
+
correct_pileup=correct_pileup,
|
|
2663
|
+
intensity_threshold=thr,
|
|
2664
|
+
irf_align=expert_overrides.get('irf_align', 'steepest_rise'),
|
|
2665
|
+
irf_shift_bins=expert_overrides.get('irf_shift_bins', 2),
|
|
2666
|
+
tvb_ptu=tvb_ptu_batch,
|
|
2667
|
+
tvb_channel=None,
|
|
2668
|
+
)
|
|
2669
|
+
result = _run_flim_fit(a)
|
|
2670
|
+
summary = result.get('global_summary', {})
|
|
2671
|
+
pu_pct = result.get('pileup_pct')
|
|
2672
|
+
cr_mhz = result.get('count_rate_mhz')
|
|
2673
|
+
if not save_npy:
|
|
2674
|
+
for f_ in fov_out.glob('*.npy'):
|
|
2675
|
+
if not f_.name.endswith('_time_axis_ns.npy'):
|
|
2676
|
+
f_.unlink(missing_ok=True)
|
|
2677
|
+
gc.collect()
|
|
2678
|
+
row = {'fov': stem, 'status': 'OK',
|
|
2679
|
+
'pileup_pct': pu_pct if pu_pct is not None else '',
|
|
2680
|
+
'count_rate_mhz': cr_mhz if cr_mhz is not None else '',
|
|
2681
|
+
**summary}
|
|
2682
|
+
print(f' OK: {stem}')
|
|
2683
|
+
except Exception as exc:
|
|
2684
|
+
import traceback; traceback.print_exc()
|
|
2685
|
+
row = {'fov': stem,
|
|
2686
|
+
'status': f'ERROR: {type(exc).__name__}: {str(exc)[:80]}'}
|
|
2687
|
+
with open(csv_path, 'a', newline='') as fh:
|
|
2688
|
+
writer = csv_mod.DictWriter(fh, fieldnames=list(row.keys()))
|
|
2689
|
+
if not header_written:
|
|
2690
|
+
writer.writeheader(); header_written = True
|
|
2691
|
+
writer.writerow(row)
|
|
2692
|
+
progress_callback(n_total, n_total)
|
|
2693
|
+
print(f'\nBatch FOV complete. CSV: {csv_path}')
|
|
2694
|
+
|
|
2695
|
+
def on_done(result):
|
|
2696
|
+
self._set_buttons('normal')
|
|
2697
|
+
self._res.set_status('✓ Batch FOV complete.')
|
|
2698
|
+
self._res.load_images(out_dir)
|
|
2699
|
+
self._set_buttons('disabled')
|
|
2700
|
+
self.run_with_progress(
|
|
2701
|
+
task, task_name=f'Batch FOV Fit ({len(ptu_files)} PTUs)',
|
|
2702
|
+
on_done=on_done, output_dir=out_dir)
|
|
2703
|
+
|
|
2704
|
+
def _run_batch(self):
|
|
2705
|
+
xlif_dir = self.sv_batch_xlif_dir.get().strip()
|
|
2706
|
+
ptu_dir = self.sv_batch_ptu_dir.get().strip()
|
|
2707
|
+
for val, name in [(xlif_dir, 'XLIF folder'), (ptu_dir, 'PTU folder')]:
|
|
2708
|
+
if not val or not Path(val).is_dir():
|
|
2709
|
+
messagebox.showerror('Missing input', f'Please select a valid {name}.')
|
|
2710
|
+
return
|
|
2711
|
+
out_dir = self._resolve_batch_out_dir(ptu_dir)
|
|
2712
|
+
if not out_dir:
|
|
2713
|
+
messagebox.showerror('Missing input', 'Please specify an output directory.')
|
|
2714
|
+
return
|
|
2715
|
+
xlif_files = sorted(Path(xlif_dir).glob('*.xlif'))
|
|
2716
|
+
if not xlif_files:
|
|
2717
|
+
messagebox.showerror('No XLIF files', f'No .xlif files found in:\n{xlif_dir}')
|
|
2718
|
+
return
|
|
2719
|
+
cfg = _C()
|
|
2720
|
+
mirf = self.sv_batch_mirf.get().strip() or str(cfg['MACHINE_IRF_DEFAULT_PATH'])
|
|
2721
|
+
_batch_model = self.sv_fit_model_batch.get()
|
|
2722
|
+
n_exp = self.iv_nexp_batch.get() if _batch_model in ('discrete', 'tail') else 2
|
|
2723
|
+
tau_min = float(self.sv_batch_tau_min.get() or cfg['Tau_min'])
|
|
2724
|
+
tau_max = float(self.sv_batch_tau_max.get() or cfg['Tau_max'])
|
|
2725
|
+
tau_lo = _flt(self.sv_batch_tau_lo) or cfg['TAU_DISPLAY_MIN'] or 0.0
|
|
2726
|
+
tau_hi = _flt(self.sv_batch_tau_hi) or cfg['TAU_DISPLAY_MAX'] or 10.0
|
|
2727
|
+
save_lifetime = self.bv_batch_save_lifetime.get()
|
|
2728
|
+
save_rgb = self.bv_batch_save_rgb.get()
|
|
2729
|
+
save_npy = self.bv_batch_save_npy.get()
|
|
2730
|
+
save_ind = self.bv_batch_save_ind.get()
|
|
2731
|
+
gamma = float(self.sv_batch_gamma.get() or 0.4)
|
|
2732
|
+
int_max = _flt(self.sv_batch_int_max) or None
|
|
2733
|
+
tau_weighting = self.sv_batch_tau_weighting.get()
|
|
2734
|
+
tau_key = 'tau_mean_int' if tau_weighting == 'intensity' else 'tau_mean_amp'
|
|
2735
|
+
register = self.bv_batch_register.get()
|
|
2736
|
+
reg_shift = int(self.sv_batch_reg_shift.get() or 120)
|
|
2737
|
+
thr = _thresh(self.bv_batch_thr, self.sv_batch_thr)
|
|
2738
|
+
correct_pileup = self.bv_batch_correct_pileup.get()
|
|
2739
|
+
tvb_ptu_batch = (self.sv_batch_tvb_ptu.get().strip() or None) if hasattr(self, 'sv_batch_tvb_ptu') else None
|
|
2740
|
+
expert_overrides = dict(self._expert_overrides)
|
|
2741
|
+
from flimkit.formats.PTU.stitch import fit_flim_tiles
|
|
2742
|
+
from flimkit.FLIM.assemble import (derive_global_tau, save_assembled_maps,
|
|
2743
|
+
assemble_tile_maps)
|
|
2744
|
+
from flimkit.utils.lifetime_image import make_lifetime_image, make_component_rgb_tiff
|
|
2745
|
+
import gc, csv as csv_mod
|
|
2746
|
+
|
|
2747
|
+
def task(progress_callback, cancel_event):
|
|
2748
|
+
csv_path = Path(out_dir) / 'batch_roi_fit_summary.csv'
|
|
2749
|
+
header_written = False
|
|
2750
|
+
n_total = len(xlif_files)
|
|
2751
|
+
for idx, xlif_path in enumerate(xlif_files):
|
|
2752
|
+
if cancel_event.is_set():
|
|
2753
|
+
print('\nBatch cancelled.')
|
|
2754
|
+
break
|
|
2755
|
+
progress_callback(idx, n_total)
|
|
2756
|
+
ptu_basename = xlif_path.stem
|
|
2757
|
+
roi_clean = ptu_basename.replace(' ', '_')
|
|
2758
|
+
roi_out = Path(out_dir) / roi_clean
|
|
2759
|
+
roi_out.mkdir(parents=True, exist_ok=True)
|
|
2760
|
+
print(f'\n{'='*50}\n [{idx+1}/{n_total}] {ptu_basename}\n{'='*50}')
|
|
2761
|
+
try:
|
|
2762
|
+
fit_args = argparse.Namespace(
|
|
2763
|
+
nexp=n_exp, dist_type=_batch_model,
|
|
2764
|
+
dist_n_components=(self.iv_ncomp_dist_batch.get() if _batch_model not in ('discrete', 'tail') else 1),
|
|
2765
|
+
fit_t0=expert_overrides.get('fit_t0', False),
|
|
2766
|
+
tau_min=tau_min, tau_max=tau_max,
|
|
2767
|
+
optimizer=expert_overrides.get('optimizer', 'de'),
|
|
2768
|
+
restarts=expert_overrides.get('lm_restarts', 1),
|
|
2769
|
+
de_population=expert_overrides.get('de_population', cfg['de_population']),
|
|
2770
|
+
de_maxiter=expert_overrides.get('de_maxiter', cfg['de_maxiter']),
|
|
2771
|
+
workers=expert_overrides.get('n_workers', cfg['n_workers']),
|
|
2772
|
+
binning=expert_overrides.get('binning_factor', 1),
|
|
2773
|
+
min_photons=expert_overrides.get('min_photons', cfg['MIN_PHOTONS_PERPIX']),
|
|
2774
|
+
channel=expert_overrides.get('channels', cfg['channels']),
|
|
2775
|
+
irf_fwhm=expert_overrides.get('irf_fwhm', cfg['IRF_FWHM']),
|
|
2776
|
+
intensity_threshold=thr,
|
|
2777
|
+
correct_pileup=correct_pileup,
|
|
2778
|
+
register_tiles=register,
|
|
2779
|
+
reg_max_shift_px=reg_shift,
|
|
2780
|
+
machine_irf=mirf,
|
|
2781
|
+
tau_display_min=tau_lo,
|
|
2782
|
+
tau_display_max=tau_hi,
|
|
2783
|
+
intensity_display_min=0.0,
|
|
2784
|
+
intensity_display_max=None,
|
|
2785
|
+
irf_xlsx_dir=None, irf_xlsx_map=None,
|
|
2786
|
+
ptu_basename=ptu_basename,
|
|
2787
|
+
xlif=str(xlif_path),
|
|
2788
|
+
ptu_dir=ptu_dir,
|
|
2789
|
+
output_dir=str(roi_out),
|
|
2790
|
+
no_plots=True, cell_mask=False,
|
|
2791
|
+
debug_xlsx=False, print_config=False,
|
|
2792
|
+
irf=None, irf_xlsx=None,
|
|
2793
|
+
estimate_irf='machine_irf',
|
|
2794
|
+
no_xlsx_irf=True,
|
|
2795
|
+
tvb_ptu=tvb_ptu_batch,
|
|
2796
|
+
tvb_channel=None,
|
|
2797
|
+
)
|
|
2798
|
+
(tile_results, canvas_h, canvas_w, _, _, _, _, _, _) = fit_flim_tiles(
|
|
2799
|
+
xlif_path=xlif_path, ptu_dir=Path(ptu_dir),
|
|
2800
|
+
output_dir=roi_out, args=fit_args,
|
|
2801
|
+
ptu_basename=ptu_basename, rotate_tiles=True, verbose=True,
|
|
2802
|
+
)
|
|
2803
|
+
if not tile_results:
|
|
2804
|
+
row = {'roi': ptu_basename, 'status': 'No tiles fitted'}
|
|
2805
|
+
else:
|
|
2806
|
+
canvas = assemble_tile_maps(tile_results, canvas_h, canvas_w, n_exp)
|
|
2807
|
+
del tile_results; gc.collect()
|
|
2808
|
+
summary = derive_global_tau(canvas, n_exp=n_exp)
|
|
2809
|
+
save_assembled_maps(
|
|
2810
|
+
canvas=canvas, global_summary=summary,
|
|
2811
|
+
output_dir=roi_out, roi_name=roi_clean, n_exp=n_exp,
|
|
2812
|
+
tau_display_min=tau_lo, tau_display_max=tau_hi,
|
|
2813
|
+
intensity_display_max=int_max,
|
|
2814
|
+
tau_weighting=('int' if tau_weighting == 'intensity' else 'amp'),
|
|
2815
|
+
)
|
|
2816
|
+
if save_lifetime:
|
|
2817
|
+
make_lifetime_image(
|
|
2818
|
+
canvas=canvas, output_dir=roi_out, roi_name=roi_clean,
|
|
2819
|
+
tau_min_ns=tau_lo, tau_max_ns=tau_hi,
|
|
2820
|
+
smooth_sigma_px=0.0, gamma=gamma, verbose=False,
|
|
2821
|
+
tau_key=tau_key,
|
|
2822
|
+
)
|
|
2823
|
+
if save_rgb:
|
|
2824
|
+
make_component_rgb_tiff(
|
|
2825
|
+
canvas=canvas, output_dir=roi_out,
|
|
2826
|
+
roi_name=roi_clean, n_exp=n_exp, verbose=False,
|
|
2827
|
+
)
|
|
2828
|
+
if not save_npy:
|
|
2829
|
+
for f_ in roi_out.glob('*.npy'):
|
|
2830
|
+
if not f_.name.endswith('_time_axis_ns.npy'):
|
|
2831
|
+
f_.unlink(missing_ok=True)
|
|
2832
|
+
del canvas; gc.collect()
|
|
2833
|
+
row = {'roi': ptu_basename, 'status': 'OK', **summary}
|
|
2834
|
+
print(f' OK: {ptu_basename}')
|
|
2835
|
+
except Exception as exc:
|
|
2836
|
+
import traceback; traceback.print_exc()
|
|
2837
|
+
row = {'roi': ptu_basename,
|
|
2838
|
+
'status': f'ERROR: {type(exc).__name__}: {str(exc)[:80]}'}
|
|
2839
|
+
with open(csv_path, 'a', newline='') as fh:
|
|
2840
|
+
writer = csv_mod.DictWriter(fh, fieldnames=list(row.keys()))
|
|
2841
|
+
if not header_written:
|
|
2842
|
+
writer.writeheader(); header_written = True
|
|
2843
|
+
writer.writerow(row)
|
|
2844
|
+
progress_callback(n_total, n_total)
|
|
2845
|
+
print(f'\nBatch complete. CSV: {csv_path}')
|
|
2846
|
+
|
|
2847
|
+
def on_done(result):
|
|
2848
|
+
self._set_buttons('normal')
|
|
2849
|
+
self._res.set_status('✓ Batch complete.')
|
|
2850
|
+
self._res.load_images(out_dir)
|
|
2851
|
+
self._set_buttons('disabled')
|
|
2852
|
+
self.run_with_progress(
|
|
2853
|
+
task, task_name=f'Batch ROI Fit ({len(xlif_files)} ROIs)', on_done=on_done, output_dir=out_dir)
|
|
2854
|
+
|
|
2855
|
+
def _build_machine_irf_tab(self):
|
|
2856
|
+
from flimkit.UI.modes.irf_mode import IrfMode
|
|
2857
|
+
IrfMode(self).build()
|
|
2858
|
+
|
|
2859
|
+
def _build_phasor_tab(self):
|
|
2860
|
+
from flimkit.UI.modes.phasor_mode import PhasorMode
|
|
2861
|
+
PhasorMode(self).build()
|
|
2862
|
+
|
|
2863
|
+
def _ph_mode_changed(self):
|
|
2864
|
+
if self.sv_ph_mode.get() == 'new':
|
|
2865
|
+
self._ph_new.grid()
|
|
2866
|
+
self._ph_sess.grid_remove()
|
|
2867
|
+
else:
|
|
2868
|
+
self._ph_new.grid_remove()
|
|
2869
|
+
self._ph_sess.grid()
|
|
2870
|
+
|
|
2871
|
+
def _run_ph_find_peaks(self):
|
|
2872
|
+
panel = self._phasor_panel
|
|
2873
|
+
if panel._real is None:
|
|
2874
|
+
messagebox.showwarning('No data', 'Load a PTU file first.')
|
|
2875
|
+
return
|
|
2876
|
+
try:
|
|
2877
|
+
sigma = float(self.sv_ph_pk_sigma.get() or 3.0)
|
|
2878
|
+
thresh = float(self.sv_ph_pk_thresh.get() or 0.10)
|
|
2879
|
+
min_ph = float(self.sv_ph_minph.get() or 0.01)
|
|
2880
|
+
except ValueError:
|
|
2881
|
+
messagebox.showerror('Invalid input',
|
|
2882
|
+
'Sigma and threshold must be numeric.')
|
|
2883
|
+
return
|
|
2884
|
+
from flimkit.phasor.peaks import find_phasor_peaks, print_peaks
|
|
2885
|
+
peaks = find_phasor_peaks(
|
|
2886
|
+
panel._real, panel._imag, panel._mean, panel._freq,
|
|
2887
|
+
min_photons=min_ph, sigma=sigma, threshold_frac=thresh)
|
|
2888
|
+
print_peaks(peaks)
|
|
2889
|
+
panel.overlay_peaks(peaks)
|
|
2890
|
+
self._res.set_status(
|
|
2891
|
+
f'✓ Found {peaks['n_peaks']} peak(s) in phasor histogram.')
|
|
2892
|
+
|
|
2893
|
+
def _run_ph_fret_overlay(self):
|
|
2894
|
+
panel = self._phasor_panel
|
|
2895
|
+
if panel._real is None:
|
|
2896
|
+
messagebox.showwarning('No data', 'Load a PTU file first.')
|
|
2897
|
+
return
|
|
2898
|
+
try:
|
|
2899
|
+
tau_d = float(self.sv_ph_fret_taud.get())
|
|
2900
|
+
taua_str = self.sv_ph_fret_taua.get().strip()
|
|
2901
|
+
tau_a = float(taua_str) if taua_str else None
|
|
2902
|
+
fretting = float(self.sv_ph_fret_fretting.get() or 1.0)
|
|
2903
|
+
except ValueError:
|
|
2904
|
+
messagebox.showerror('Invalid input', 'Lifetimes must be numeric.')
|
|
2905
|
+
return
|
|
2906
|
+
from flimkit.phasor.fret import predict_fret_trajectory
|
|
2907
|
+
traj = predict_fret_trajectory(
|
|
2908
|
+
panel._freq, tau_d,
|
|
2909
|
+
acceptor_lifetime=tau_a,
|
|
2910
|
+
donor_fretting=fretting)
|
|
2911
|
+
panel.overlay_fret_trajectory(traj)
|
|
2912
|
+
label = f'τ_D={tau_d} ns'
|
|
2913
|
+
if tau_a:
|
|
2914
|
+
label += f', τ_A={tau_a} ns'
|
|
2915
|
+
self._res.set_status(f'✓ FRET trajectory overlaid ({label}).')
|
|
2916
|
+
|
|
2917
|
+
def _run_ph_fit_fret(self):
|
|
2918
|
+
panel = self._phasor_panel
|
|
2919
|
+
if panel._real is None:
|
|
2920
|
+
messagebox.showwarning('No data', 'Load a PTU file first.')
|
|
2921
|
+
return
|
|
2922
|
+
try:
|
|
2923
|
+
tau_d = float(self.sv_ph_fret_taud.get())
|
|
2924
|
+
fretting = float(self.sv_ph_fret_fretting.get() or 1.0)
|
|
2925
|
+
min_ph = float(self.sv_ph_minph.get() or 0.01)
|
|
2926
|
+
except ValueError:
|
|
2927
|
+
messagebox.showerror('Invalid input', 'Lifetimes must be numeric.')
|
|
2928
|
+
return
|
|
2929
|
+
from flimkit.phasor.fret import (
|
|
2930
|
+
FRETChannelData, FRETModelParameters, fit_donor_fret)
|
|
2931
|
+
donor = FRETChannelData(
|
|
2932
|
+
panel._real, panel._imag, panel._mean,
|
|
2933
|
+
panel._freq, min_photons=min_ph)
|
|
2934
|
+
params = FRETModelParameters(
|
|
2935
|
+
donor_lifetime=tau_d,
|
|
2936
|
+
donor_fretting=fretting)
|
|
2937
|
+
try:
|
|
2938
|
+
result = fit_donor_fret(donor, params)
|
|
2939
|
+
result.print_summary()
|
|
2940
|
+
self._res.set_status(
|
|
2941
|
+
f'✓ FRET fit: E={result.fret_efficiency:.3f} '
|
|
2942
|
+
f'fretting={result.donor_fretting:.3f}')
|
|
2943
|
+
except Exception as exc:
|
|
2944
|
+
messagebox.showerror('FRET fit failed', str(exc))
|
|
2945
|
+
|
|
2946
|
+
def _on_fov_ptu_changed(self, var, index, mode):
|
|
2947
|
+
ptu_path = self.sv_ptu.get().strip()
|
|
2948
|
+
if not ptu_path:
|
|
2949
|
+
return
|
|
2950
|
+
if ptu_path == self._last_loaded_ptu:
|
|
2951
|
+
return
|
|
2952
|
+
if getattr(self, '_loading_ptu', False):
|
|
2953
|
+
return
|
|
2954
|
+
self._last_loaded_ptu = ptu_path
|
|
2955
|
+
import os
|
|
2956
|
+
from flimkit.formats import file_modality
|
|
2957
|
+
if os.path.exists(ptu_path):
|
|
2958
|
+
modality = file_modality(ptu_path)
|
|
2959
|
+
if modality != 'time':
|
|
2960
|
+
msgs = {
|
|
2961
|
+
'frequency': 'ISS .ifli is frequency-domain (FD-FLIM) data. It has no decay to fit; open it in the Phasor tab instead.',
|
|
2962
|
+
'intensity': 'ISS .ifi is an intensity image with no lifetime data; FOV loading for it is not wired up yet.',
|
|
2963
|
+
}
|
|
2964
|
+
messagebox.showinfo('Not supported yet',
|
|
2965
|
+
msgs.get(modality, f'Unsupported file for FOV fitting:\n{ptu_path}'))
|
|
2966
|
+
return
|
|
2967
|
+
self._loading_ptu = True
|
|
2968
|
+
self._add_to_recent(ptu_path, 'file')
|
|
2969
|
+
if hasattr(self, 'sv_out_fov'):
|
|
2970
|
+
self.sv_out_fov.set(Path(ptu_path).stem)
|
|
2971
|
+
|
|
2972
|
+
def load():
|
|
2973
|
+
try:
|
|
2974
|
+
self._fov_preview.load_fov(ptu_path)
|
|
2975
|
+
self._auto_load_session_for_ptu(ptu_path)
|
|
2976
|
+
finally:
|
|
2977
|
+
self._loading_ptu = False
|
|
2978
|
+
if self._ptu_after_id is not None:
|
|
2979
|
+
self.root.after_cancel(self._ptu_after_id)
|
|
2980
|
+
self._ptu_after_id = self.root.after(100, load)
|
|
2981
|
+
|
|
2982
|
+
def _cancel_pending_scan_loads(self):
|
|
2983
|
+
if self._ptu_after_id is not None:
|
|
2984
|
+
self.root.after_cancel(self._ptu_after_id)
|
|
2985
|
+
self._ptu_after_id = None
|
|
2986
|
+
if self._xlif_after_id is not None:
|
|
2987
|
+
self.root.after_cancel(self._xlif_after_id)
|
|
2988
|
+
self._xlif_after_id = None
|
|
2989
|
+
self._loading_ptu = False
|
|
2990
|
+
self._loading_xlif = False
|
|
2991
|
+
|
|
2992
|
+
def _on_xlif_changed(self, var, index, mode):
|
|
2993
|
+
xlif_path = self.sv_xlif.get().strip()
|
|
2994
|
+
if not xlif_path:
|
|
2995
|
+
return
|
|
2996
|
+
if xlif_path == self._last_loaded_xlif:
|
|
2997
|
+
return
|
|
2998
|
+
if getattr(self, '_loading_xlif', False):
|
|
2999
|
+
return
|
|
3000
|
+
self._last_loaded_xlif = xlif_path
|
|
3001
|
+
self._loading_xlif = True
|
|
3002
|
+
stem = Path(xlif_path).stem
|
|
3003
|
+
output_dir = None
|
|
3004
|
+
session_file = None
|
|
3005
|
+
if hasattr(self, '_proj_browser') and self._proj_browser._project:
|
|
3006
|
+
rec = self._proj_browser._project.scans.get(stem)
|
|
3007
|
+
if rec and rec.out_st:
|
|
3008
|
+
out_path = Path(rec.out_st)
|
|
3009
|
+
roi_name = stem.replace(' ', '_')
|
|
3010
|
+
if out_path.name == roi_name:
|
|
3011
|
+
output_dir = str(out_path)
|
|
3012
|
+
else:
|
|
3013
|
+
output_dir = str(out_path / roi_name)
|
|
3014
|
+
session_file = Path(output_dir) / 'roi_session.npz'
|
|
3015
|
+
if not session_file.exists():
|
|
3016
|
+
session_file = None
|
|
3017
|
+
if output_dir is None:
|
|
3018
|
+
ptu_dir = self.sv_ptu_dir.get().strip()
|
|
3019
|
+
out_base = self.sv_out_st.get().strip()
|
|
3020
|
+
if ptu_dir and out_base:
|
|
3021
|
+
roi_name = stem.replace(' ', '_')
|
|
3022
|
+
output_dir = str(Path(out_base) / roi_name)
|
|
3023
|
+
if output_dir is None:
|
|
3024
|
+
self._loading_xlif = False
|
|
3025
|
+
return
|
|
3026
|
+
if self._xlif_after_id is not None:
|
|
3027
|
+
self.root.after_cancel(self._xlif_after_id)
|
|
3028
|
+
_sf = session_file
|
|
3029
|
+
def _do_xlif_load():
|
|
3030
|
+
try:
|
|
3031
|
+
self._fov_preview.load_stitched_roi(output_dir)
|
|
3032
|
+
if _sf and _sf.exists():
|
|
3033
|
+
self._auto_load_session_for_stitch(output_dir)
|
|
3034
|
+
finally:
|
|
3035
|
+
self._loading_xlif = False
|
|
3036
|
+
self._xlif_after_id = self.root.after(100, _do_xlif_load)
|
|
3037
|
+
|
|
3038
|
+
def _get_roi_fit_params(self) -> dict:
|
|
3039
|
+
cfg = _C()
|
|
3040
|
+
irf = self._irf_fov.get_args(xlsx_fallback=self.sv_xlsx.get().strip())
|
|
3041
|
+
params = {
|
|
3042
|
+
'ptu_path': self.sv_ptu.get().strip() or None,
|
|
3043
|
+
'n_exp': self.iv_nexp_fov.get(),
|
|
3044
|
+
'tau_min': float(self.sv_tau_min_fov.get() or cfg['Tau_min']),
|
|
3045
|
+
'tau_max': float(self.sv_tau_max_fov.get() or cfg['Tau_max']),
|
|
3046
|
+
'cost_function': cfg.get('cost_function', 'poisson'),
|
|
3047
|
+
'channel': cfg['channels'],
|
|
3048
|
+
'irf': irf['irf'],
|
|
3049
|
+
'irf_xlsx': irf['irf_xlsx'],
|
|
3050
|
+
'estimate_irf': irf['estimate_irf'],
|
|
3051
|
+
'machine_irf': irf.get('machine_irf') or str(cfg['MACHINE_IRF_DEFAULT_PATH']),
|
|
3052
|
+
'irf_bins': cfg['IRF_BINS'],
|
|
3053
|
+
'irf_fit_width': cfg['IRF_FIT_WIDTH'],
|
|
3054
|
+
'irf_fwhm': cfg['IRF_FWHM'],
|
|
3055
|
+
'irf_align': 'steepest_rise',
|
|
3056
|
+
'irf_shift_bins': 2,
|
|
3057
|
+
}
|
|
3058
|
+
expert = self._expert_overrides
|
|
3059
|
+
if expert:
|
|
3060
|
+
if 'cost_function' in expert:
|
|
3061
|
+
params['cost_function'] = expert['cost_function']
|
|
3062
|
+
if 'channels' in expert:
|
|
3063
|
+
params['channel'] = expert['channels']
|
|
3064
|
+
return params
|
|
3065
|
+
|
|
3066
|
+
def _on_fov_analysis_changed(self):
|
|
3067
|
+
mode = self.sv_fov_analysis.get()
|
|
3068
|
+
if mode == 'zstack':
|
|
3069
|
+
self._fov_single_fr.grid_remove()
|
|
3070
|
+
self._fov_zstack_fr.grid()
|
|
3071
|
+
self._btn_fov.configure(text='▶ Run Z-stack Fit')
|
|
3072
|
+
self._on_zstack_dir_changed()
|
|
3073
|
+
else:
|
|
3074
|
+
self._fov_zstack_fr.grid_remove()
|
|
3075
|
+
self._fov_single_fr.grid()
|
|
3076
|
+
self._btn_fov.configure(text='▶ Run Single-FOV Fit')
|
|
3077
|
+
if self._fov_preview is not None:
|
|
3078
|
+
self._fov_preview._hide_zstack()
|
|
3079
|
+
|
|
3080
|
+
def _on_zstack_dir_changed(self, *_):
|
|
3081
|
+
if getattr(self, 'sv_fov_analysis', None) is None \
|
|
3082
|
+
or self.sv_fov_analysis.get() != 'zstack':
|
|
3083
|
+
return
|
|
3084
|
+
d = self.sv_zstack_dir.get().strip()
|
|
3085
|
+
if not d or not Path(d).is_dir() or self._fov_preview is None:
|
|
3086
|
+
return
|
|
3087
|
+
from flimkit.utils.batch_fit import group_zstack_files
|
|
3088
|
+
try:
|
|
3089
|
+
groups = group_zstack_files(d)
|
|
3090
|
+
except Exception:
|
|
3091
|
+
return
|
|
3092
|
+
if not groups:
|
|
3093
|
+
return
|
|
3094
|
+
zslices = next(iter(groups.values()))
|
|
3095
|
+
descs = [{'z': z, 'ptu_path': str(p)} for z, p in sorted(zslices.items())]
|
|
3096
|
+
self._fov_preview.display_zstack(descs)
|
|
3097
|
+
|
|
3098
|
+
def _run_fov(self):
|
|
3099
|
+
if getattr(self, 'sv_fov_analysis', None) is not None \
|
|
3100
|
+
and self.sv_fov_analysis.get() == 'zstack':
|
|
3101
|
+
self._run_zstack_fov()
|
|
3102
|
+
return
|
|
3103
|
+
ptu = self.sv_ptu.get().strip()
|
|
3104
|
+
if not ptu or not Path(ptu).exists():
|
|
3105
|
+
messagebox.showerror('Missing input', 'Please select a valid PTU file.')
|
|
3106
|
+
return
|
|
3107
|
+
from flimkit.interactive import _run_flim_fit
|
|
3108
|
+
a = self._controller.fov_args()
|
|
3109
|
+
out_dir = str(Path(a.out).parent)
|
|
3110
|
+
self._launch(
|
|
3111
|
+
lambda progress_callback=None, cancel_event=None: _run_flim_fit(a, progress_callback, cancel_event),
|
|
3112
|
+
output_dir=out_dir,
|
|
3113
|
+
ptu_path=ptu,
|
|
3114
|
+
task_name='Single-FOV Fit'
|
|
3115
|
+
)
|
|
3116
|
+
|
|
3117
|
+
def _populate_zstack_summary_from_dir(self, group_dir):
|
|
3118
|
+
import json
|
|
3119
|
+
import numpy as np
|
|
3120
|
+
if self._res is None:
|
|
3121
|
+
return
|
|
3122
|
+
group_dir = Path(group_dir)
|
|
3123
|
+
ref = {}
|
|
3124
|
+
for rf in group_dir.glob('*_reference_fit.json'):
|
|
3125
|
+
try:
|
|
3126
|
+
ref = json.loads(rf.read_text())
|
|
3127
|
+
except Exception:
|
|
3128
|
+
ref = {}
|
|
3129
|
+
break
|
|
3130
|
+
rows = []
|
|
3131
|
+
for i, tau in enumerate(ref.get('taus_ns', [])):
|
|
3132
|
+
rows.append((f'τ{i+1} (reference)', f'{float(tau):.3f}', 'ns'))
|
|
3133
|
+
if ref.get('n_slices') is not None:
|
|
3134
|
+
rows.append(('Z-slices', str(ref['n_slices']), ''))
|
|
3135
|
+
if ref.get('total_pooled_photons') is not None:
|
|
3136
|
+
rows.append(('Pooled photons', f"{int(ref['total_pooled_photons']):,}", ''))
|
|
3137
|
+
zj = {}
|
|
3138
|
+
for zf in group_dir.glob('*_zseries.json'):
|
|
3139
|
+
try:
|
|
3140
|
+
zj = json.loads(zf.read_text())
|
|
3141
|
+
except Exception:
|
|
3142
|
+
zj = {}
|
|
3143
|
+
break
|
|
3144
|
+
def _mean(key):
|
|
3145
|
+
vals = [v for v in zj.get(key, []) if isinstance(v, (int, float)) and v == v]
|
|
3146
|
+
return float(np.mean(vals)) if vals else None
|
|
3147
|
+
tm = _mean('tau_mean_mean')
|
|
3148
|
+
if tm is not None:
|
|
3149
|
+
rows.append(('Mean τ (slices)', f'{tm:.3f}', 'ns'))
|
|
3150
|
+
npx = [v for v in zj.get('n_pixels_fitted', []) if isinstance(v, (int, float))]
|
|
3151
|
+
if npx:
|
|
3152
|
+
rows.append(('Pixels fitted', f'{int(sum(npx)):,}', ''))
|
|
3153
|
+
cm = _mean('chi2_r_mean')
|
|
3154
|
+
if cm is not None:
|
|
3155
|
+
rows.append(('Mean χ²_r', f'{cm:.3f}', ''))
|
|
3156
|
+
if rows:
|
|
3157
|
+
self._res.populate_summary(rows)
|
|
3158
|
+
|
|
3159
|
+
def _run_zstack_fov(self):
|
|
3160
|
+
ptu_dir = self.sv_zstack_dir.get().strip()
|
|
3161
|
+
if not ptu_dir or not Path(ptu_dir).is_dir():
|
|
3162
|
+
messagebox.showerror('Missing input', 'Please select a valid z-stack folder.')
|
|
3163
|
+
return
|
|
3164
|
+
from flimkit.FLIM.batch import fit_zstack
|
|
3165
|
+
from flimkit.utils.batch_fit import group_zstack_files
|
|
3166
|
+
groups = group_zstack_files(ptu_dir)
|
|
3167
|
+
if not groups:
|
|
3168
|
+
messagebox.showerror(
|
|
3169
|
+
'No z-stack PTUs',
|
|
3170
|
+
f'No files matching region_zX.ptu found in:\n{ptu_dir}')
|
|
3171
|
+
return
|
|
3172
|
+
cfg = _C()
|
|
3173
|
+
params = self._get_roi_fit_params()
|
|
3174
|
+
_out_raw = self.sv_out_fov.get().strip() or 'flim_zstack_out'
|
|
3175
|
+
if Path(_out_raw).parent == Path('.'):
|
|
3176
|
+
out_dir = str(Path(ptu_dir) / _out_raw)
|
|
3177
|
+
else:
|
|
3178
|
+
out_dir = _out_raw
|
|
3179
|
+
expert = dict(self._expert_overrides)
|
|
3180
|
+
correct_pileup = self.bv_correct_pileup.get()
|
|
3181
|
+
n_stacks = len(groups)
|
|
3182
|
+
n_slices = sum(len(v) for v in groups.values())
|
|
3183
|
+
|
|
3184
|
+
def task(progress_callback, cancel_event):
|
|
3185
|
+
a = argparse.Namespace(
|
|
3186
|
+
nexp=params['n_exp'],
|
|
3187
|
+
tau_min=params['tau_min'],
|
|
3188
|
+
tau_max=params['tau_max'],
|
|
3189
|
+
estimate_irf=params['estimate_irf'],
|
|
3190
|
+
machine_irf=params['machine_irf'],
|
|
3191
|
+
irf_fwhm=params['irf_fwhm'],
|
|
3192
|
+
irf_bins=params['irf_bins'],
|
|
3193
|
+
irf_fit_width=params['irf_fit_width'],
|
|
3194
|
+
optimizer=expert.get('optimizer', 'de'),
|
|
3195
|
+
restarts=expert.get('lm_restarts', cfg['lm_restarts']),
|
|
3196
|
+
de_population=expert.get('de_population', cfg['de_population']),
|
|
3197
|
+
de_maxiter=expert.get('de_maxiter', cfg['de_maxiter']),
|
|
3198
|
+
workers=expert.get('n_workers', cfg['n_workers']),
|
|
3199
|
+
no_polish=False,
|
|
3200
|
+
channel=params['channel'],
|
|
3201
|
+
min_photons=expert.get('min_photons', cfg['MIN_PHOTONS_PERPIX']),
|
|
3202
|
+
correct_pileup=correct_pileup,
|
|
3203
|
+
cost_function=params['cost_function'],
|
|
3204
|
+
save_stack=True,
|
|
3205
|
+
no_plots=False,
|
|
3206
|
+
)
|
|
3207
|
+
return fit_zstack(
|
|
3208
|
+
ptu_dir=ptu_dir,
|
|
3209
|
+
output_dir=out_dir,
|
|
3210
|
+
args=a,
|
|
3211
|
+
channel=params['channel'],
|
|
3212
|
+
compute_bound_fraction=False,
|
|
3213
|
+
progress_callback=progress_callback,
|
|
3214
|
+
cancel_event=cancel_event,
|
|
3215
|
+
)
|
|
3216
|
+
|
|
3217
|
+
def on_done(result):
|
|
3218
|
+
self._set_buttons('normal')
|
|
3219
|
+
self._res.set_status(
|
|
3220
|
+
f'✓ Z-stack complete - {n_stacks} stack(s), {n_slices} slices.')
|
|
3221
|
+
first = next(iter(result.values())) if isinstance(result, dict) and result else None
|
|
3222
|
+
if first is not None:
|
|
3223
|
+
from flimkit.utils.display import load_zstack_display_slices
|
|
3224
|
+
try:
|
|
3225
|
+
slices = load_zstack_display_slices(first['group_dir'], ptu_dir=ptu_dir)
|
|
3226
|
+
if slices:
|
|
3227
|
+
self._fov_preview.display_zstack(slices)
|
|
3228
|
+
except Exception as exc:
|
|
3229
|
+
print(f'[Z-stack] Could not load fitted stack into preview: {exc}')
|
|
3230
|
+
self._populate_zstack_summary_from_dir(first['group_dir'])
|
|
3231
|
+
self._res.load_images(first.get('preview_dir') or out_dir)
|
|
3232
|
+
else:
|
|
3233
|
+
self._res.load_images(out_dir)
|
|
3234
|
+
if hasattr(self, '_proj_browser') and self._proj_browser and self._proj_browser._project:
|
|
3235
|
+
first_key = next(iter(result.keys())) if isinstance(result, dict) and result else None
|
|
3236
|
+
if first_key is not None:
|
|
3237
|
+
self._proj_browser.on_fit_done(
|
|
3238
|
+
first_key, out_st=out_dir, ptu_dir=ptu_dir)
|
|
3239
|
+
self._set_buttons('disabled')
|
|
3240
|
+
self.run_with_progress(
|
|
3241
|
+
task,
|
|
3242
|
+
task_name=f'Z-stack Fit ({n_stacks} stack(s), {n_slices} slices)',
|
|
3243
|
+
on_done=on_done,
|
|
3244
|
+
output_dir=out_dir,
|
|
3245
|
+
)
|
|
3246
|
+
|
|
3247
|
+
def _run_stitch(self):
|
|
3248
|
+
xlif = self.sv_xlif.get().strip()
|
|
3249
|
+
ptu_dir = self.sv_ptu_dir.get().strip()
|
|
3250
|
+
out_base = self.sv_out_st.get().strip()
|
|
3251
|
+
pipeline = self.sv_pipeline.get()
|
|
3252
|
+
required = [(ptu_dir, 'PTU directory'), (out_base, 'Output directory')]
|
|
3253
|
+
if pipeline != 'series_fit':
|
|
3254
|
+
required.insert(0, (xlif, 'XLIF file'))
|
|
3255
|
+
for val, name in required:
|
|
3256
|
+
if not val:
|
|
3257
|
+
messagebox.showerror('Missing input', f'Please specify the {name}.')
|
|
3258
|
+
return
|
|
3259
|
+
pool_stride = 10
|
|
3260
|
+
if pipeline == 'series_fit':
|
|
3261
|
+
try:
|
|
3262
|
+
pool_stride = max(1, int(self.sv_pool_stride.get() or 10))
|
|
3263
|
+
except ValueError:
|
|
3264
|
+
messagebox.showerror('Invalid input',
|
|
3265
|
+
'Pool decay every N timepoints must be a whole number.')
|
|
3266
|
+
return
|
|
3267
|
+
from flimkit.formats.PTU.stitch import stitch_flim_tiles
|
|
3268
|
+
a = self._controller.stitch_args()
|
|
3269
|
+
|
|
3270
|
+
def on_done(result):
|
|
3271
|
+
self._set_buttons('normal')
|
|
3272
|
+
self._res.set_status('✓ Complete.')
|
|
3273
|
+
self._res._nb.select(0)
|
|
3274
|
+
captured = ''.join(self._buf)
|
|
3275
|
+
rows = _parse_summary(captured)
|
|
3276
|
+
if pipeline == 'tile_fit' and isinstance(result, dict):
|
|
3277
|
+
fit_result = result
|
|
3278
|
+
global_summary = fit_result.get('global_summary', {})
|
|
3279
|
+
global_popt = fit_result.get('global_popt')
|
|
3280
|
+
if global_summary:
|
|
3281
|
+
extracted_rows = self._extract_summary_rows(global_summary, global_popt)
|
|
3282
|
+
if extracted_rows:
|
|
3283
|
+
rows = extracted_rows
|
|
3284
|
+
try:
|
|
3285
|
+
self._fov_preview.load_stitched_roi(a.output_dir)
|
|
3286
|
+
except Exception as e:
|
|
3287
|
+
print(f'[Warning] Could not load stitched image: {e}')
|
|
3288
|
+
try:
|
|
3289
|
+
self._fov_preview.display_fit_results(None, fit_result)
|
|
3290
|
+
except Exception as e:
|
|
3291
|
+
import traceback
|
|
3292
|
+
print(f'[Warning] Could not display fit results: {e}')
|
|
3293
|
+
traceback.print_exc()
|
|
3294
|
+
try:
|
|
3295
|
+
self._save_roi_progress(str(a.output_dir), fit_result, rows or [])
|
|
3296
|
+
npz_path = Path(a.output_dir) / 'roi_session.npz'
|
|
3297
|
+
self._res.set_fit_result(
|
|
3298
|
+
fit_result, str(a.output_dir),
|
|
3299
|
+
npz_path=str(npz_path) if npz_path.exists() else None,
|
|
3300
|
+
scan_name=self._current_scan_stem(),
|
|
3301
|
+
)
|
|
3302
|
+
if hasattr(self, '_proj_browser'):
|
|
3303
|
+
xlif_stem = Path(a.xlif).stem if hasattr(a, 'xlif') else None
|
|
3304
|
+
if xlif_stem:
|
|
3305
|
+
self._proj_browser.on_fit_done(
|
|
3306
|
+
xlif_stem,
|
|
3307
|
+
out_st = str(Path(a.output_dir).parent),
|
|
3308
|
+
ptu_dir = getattr(a, 'ptu_dir', None),
|
|
3309
|
+
)
|
|
3310
|
+
except Exception as e:
|
|
3311
|
+
import traceback
|
|
3312
|
+
print(f'[Warning] Could not save NPZ: {e}')
|
|
3313
|
+
traceback.print_exc()
|
|
3314
|
+
try:
|
|
3315
|
+
if hasattr(self, '_stitch_roi_panel'):
|
|
3316
|
+
self._stitch_roi_panel._refresh_region_list()
|
|
3317
|
+
except Exception:
|
|
3318
|
+
pass
|
|
3319
|
+
elif pipeline == 'series_fit' and isinstance(result, dict):
|
|
3320
|
+
planes = result.get('planes', [])
|
|
3321
|
+
taus = result.get('consensus_taus_ns', [])
|
|
3322
|
+
print(f"\n {len(planes)} plane(s) written under {a.output_dir}")
|
|
3323
|
+
print(f" Manifest: {result.get('base', '')}_series_index.json")
|
|
3324
|
+
if taus:
|
|
3325
|
+
print(f" Consensus τ = {[f'{t:.3f}' for t in taus]} ns")
|
|
3326
|
+
if planes:
|
|
3327
|
+
self._res.set_status(
|
|
3328
|
+
f'✓ {len(planes)} planes fitted - drag the slider to browse them')
|
|
3329
|
+
try:
|
|
3330
|
+
self._fov_preview.display_series(planes, a.output_dir)
|
|
3331
|
+
except Exception as e:
|
|
3332
|
+
print(f'Warning: Could not load series planes: {e}')
|
|
3333
|
+
else:
|
|
3334
|
+
try:
|
|
3335
|
+
self._fov_preview.load_stitched_roi(a.output_dir)
|
|
3336
|
+
except Exception as e:
|
|
3337
|
+
print(f'Warning: Could not load stitched image: {e}')
|
|
3338
|
+
if rows:
|
|
3339
|
+
self._res.populate_summary(rows)
|
|
3340
|
+
|
|
3341
|
+
def task(progress_callback, cancel_event):
|
|
3342
|
+
if pipeline == 'stitch_only':
|
|
3343
|
+
return stitch_flim_tiles(
|
|
3344
|
+
xlif_path=a.xlif,
|
|
3345
|
+
ptu_dir=a.ptu_dir,
|
|
3346
|
+
output_dir=a.output_dir,
|
|
3347
|
+
ptu_basename=a.ptu_basename,
|
|
3348
|
+
rotate_tiles=a.rotate_tiles,
|
|
3349
|
+
register_tiles=a.register_tiles,
|
|
3350
|
+
reg_max_shift_px=a.reg_max_shift_px,
|
|
3351
|
+
verbose=True,
|
|
3352
|
+
progress_callback=progress_callback,
|
|
3353
|
+
cancel_event=cancel_event,
|
|
3354
|
+
)
|
|
3355
|
+
elif pipeline == 'stitch_fit':
|
|
3356
|
+
from flimkit.interactive import _run_stitch_and_fit
|
|
3357
|
+
return _run_stitch_and_fit(a, progress_callback=progress_callback,
|
|
3358
|
+
cancel_event=cancel_event)
|
|
3359
|
+
elif pipeline == 'series_fit':
|
|
3360
|
+
from flimkit.formats.PTU.stitch import fit_flim_series
|
|
3361
|
+
return fit_flim_series(
|
|
3362
|
+
ptu_dir=a.ptu_dir,
|
|
3363
|
+
output_dir=a.output_dir,
|
|
3364
|
+
args=a,
|
|
3365
|
+
rotate_tiles=a.rotate_tiles,
|
|
3366
|
+
xlif_path=a.xlif or None,
|
|
3367
|
+
pool_stride=pool_stride,
|
|
3368
|
+
verbose=True,
|
|
3369
|
+
progress_callback=progress_callback,
|
|
3370
|
+
cancel_event=cancel_event,
|
|
3371
|
+
)
|
|
3372
|
+
else:
|
|
3373
|
+
from flimkit.interactive import _run_tile_fit
|
|
3374
|
+
return _run_tile_fit(a, progress_callback=progress_callback,
|
|
3375
|
+
cancel_event=cancel_event)
|
|
3376
|
+
self._set_buttons('disabled')
|
|
3377
|
+
task_name = {'stitch_only': 'Stitching', 'stitch_fit': 'Stitch + Fit',
|
|
3378
|
+
'tile_fit': 'Per-Tile Fit', 'series_fit': 'Series Fit'}[pipeline]
|
|
3379
|
+
self.run_with_progress(task, task_name=task_name, on_done=on_done, output_dir=a.output_dir)
|
|
3380
|
+
|
|
3381
|
+
def _run_phasor(self):
|
|
3382
|
+
try:
|
|
3383
|
+
min_ph = float(self.sv_ph_minph.get() or 0.01)
|
|
3384
|
+
max_cur = int(self.sv_ph_maxc.get() or 6)
|
|
3385
|
+
except ValueError:
|
|
3386
|
+
messagebox.showerror('Invalid input',
|
|
3387
|
+
'Min photons and max cursors must be numeric.')
|
|
3388
|
+
return
|
|
3389
|
+
self._phasor_panel.max_cursors = max_cur
|
|
3390
|
+
if self.sv_ph_mode.get() == 'session':
|
|
3391
|
+
sess = self.sv_ph_session.get().strip()
|
|
3392
|
+
if not sess or not Path(sess).exists():
|
|
3393
|
+
messagebox.showerror('Missing input',
|
|
3394
|
+
'Please select a valid .npz session file.')
|
|
3395
|
+
return
|
|
3396
|
+
|
|
3397
|
+
def _worker():
|
|
3398
|
+
from flimkit.phasor_launcher import load_session
|
|
3399
|
+
return load_session(sess)
|
|
3400
|
+
|
|
3401
|
+
def _done(result):
|
|
3402
|
+
if isinstance(result, Exception):
|
|
3403
|
+
messagebox.showerror('Session load error', str(result))
|
|
3404
|
+
return
|
|
3405
|
+
self._phasor_panel.load_session(result, min_photons=min_ph)
|
|
3406
|
+
self._res.set_status('✓ Phasor session loaded.')
|
|
3407
|
+
self._phasor_thread(_worker, _done, status=' Loading session...')
|
|
3408
|
+
else:
|
|
3409
|
+
ptu = self.sv_ph_ptu.get().strip()
|
|
3410
|
+
if not ptu or not Path(ptu).exists():
|
|
3411
|
+
messagebox.showerror('Missing input',
|
|
3412
|
+
'Please select a valid PTU file.')
|
|
3413
|
+
return
|
|
3414
|
+
try:
|
|
3415
|
+
channel = self._resolve_phasor_channel(ptu)
|
|
3416
|
+
except Exception as exc:
|
|
3417
|
+
messagebox.showerror('Phasor error', str(exc), parent=self.root)
|
|
3418
|
+
return
|
|
3419
|
+
if channel is None:
|
|
3420
|
+
self._res.set_status(' Phasor load cancelled.')
|
|
3421
|
+
return
|
|
3422
|
+
xlsx_irf = self.sv_ph_irf.get().strip() or None
|
|
3423
|
+
mach_irf = self.sv_ph_mirf.get().strip() or None
|
|
3424
|
+
irf_path = xlsx_irf or mach_irf
|
|
3425
|
+
|
|
3426
|
+
from flimkit.formats import file_modality
|
|
3427
|
+
modality = file_modality(ptu)
|
|
3428
|
+
|
|
3429
|
+
def _worker():
|
|
3430
|
+
if modality == 'frequency':
|
|
3431
|
+
from flimkit.phasor.signal import process_ifli
|
|
3432
|
+
return process_ifli(ptu, channel=channel)
|
|
3433
|
+
from flimkit.phasor_launcher import _process_ptu
|
|
3434
|
+
return _process_ptu(ptu, irf_path=irf_path, channel=channel)
|
|
3435
|
+
|
|
3436
|
+
def _done(result):
|
|
3437
|
+
if isinstance(result, Exception):
|
|
3438
|
+
messagebox.showerror('Phasor error', str(result))
|
|
3439
|
+
return
|
|
3440
|
+
frequency = result.get('frequency') or 0.0
|
|
3441
|
+
if not frequency:
|
|
3442
|
+
frequency = self._prompt_frequency(ptu) or 0.0
|
|
3443
|
+
result['frequency'] = frequency
|
|
3444
|
+
self._phasor_panel.set_data(
|
|
3445
|
+
result['real_cal'],
|
|
3446
|
+
result['imag_cal'],
|
|
3447
|
+
result['mean'],
|
|
3448
|
+
frequency,
|
|
3449
|
+
display_image=result.get('display_image'),
|
|
3450
|
+
min_photons=min_ph,
|
|
3451
|
+
)
|
|
3452
|
+
self._phasor_panel._ptu_path = ptu
|
|
3453
|
+
self._phasor_panel._channel = channel
|
|
3454
|
+
self._auto_save_phasor(ptu)
|
|
3455
|
+
self._res.set_status(
|
|
3456
|
+
f'✓ Phasor data loaded from channel {channel} - click the phasor to place cursors.')
|
|
3457
|
+
self._phasor_thread(_worker, _done,
|
|
3458
|
+
status=' Loading PTU and computing phasors...')
|
|
3459
|
+
|
|
3460
|
+
def _resolve_phasor_channel(self, ptu_path: str) -> Optional[int]:
|
|
3461
|
+
from flimkit.phasor_launcher import get_ptu_active_channels
|
|
3462
|
+
active_channels = get_ptu_active_channels(ptu_path)
|
|
3463
|
+
if not active_channels:
|
|
3464
|
+
raise ValueError('No photon channels found in PTU file')
|
|
3465
|
+
if len(active_channels) == 1:
|
|
3466
|
+
return active_channels[0]
|
|
3467
|
+
return self._prompt_phasor_channel(ptu_path, active_channels)
|
|
3468
|
+
|
|
3469
|
+
def _prompt_phasor_channel(
|
|
3470
|
+
self,
|
|
3471
|
+
ptu_path: str,
|
|
3472
|
+
active_channels: list[int],
|
|
3473
|
+
) -> Optional[int]:
|
|
3474
|
+
available = ', '.join(str(channel) for channel in active_channels)
|
|
3475
|
+
prompt = (
|
|
3476
|
+
f'Multiple photon channels were detected in:\n{Path(ptu_path).name}\n\n'
|
|
3477
|
+
f'Available channels: {available}\n\n'
|
|
3478
|
+
'Enter the channel to use for phasor analysis:'
|
|
3479
|
+
)
|
|
3480
|
+
while True:
|
|
3481
|
+
selected = simpledialog.askinteger(
|
|
3482
|
+
'Select PTU Channel',
|
|
3483
|
+
prompt,
|
|
3484
|
+
parent=self.root,
|
|
3485
|
+
minvalue=min(active_channels),
|
|
3486
|
+
maxvalue=max(active_channels),
|
|
3487
|
+
)
|
|
3488
|
+
if selected is None:
|
|
3489
|
+
return None
|
|
3490
|
+
if selected in active_channels:
|
|
3491
|
+
return selected
|
|
3492
|
+
messagebox.showerror(
|
|
3493
|
+
'Invalid channel',
|
|
3494
|
+
f'Channel {selected} is not present in this PTU file.\n\n'
|
|
3495
|
+
f'Available channels: {available}',
|
|
3496
|
+
parent=self.root,
|
|
3497
|
+
)
|
|
3498
|
+
|
|
3499
|
+
def _prompt_frequency(self, path: str) -> Optional[float]:
|
|
3500
|
+
prompt = (
|
|
3501
|
+
f'No laser repetition frequency is stored in:\n{Path(path).name}\n\n'
|
|
3502
|
+
'This format carries no frequency metadata, so the universal circle\n'
|
|
3503
|
+
'and lifetime overlays cannot be drawn without it.\n\n'
|
|
3504
|
+
'Enter the modulation frequency in MHz (leave blank to skip):'
|
|
3505
|
+
)
|
|
3506
|
+
return simpledialog.askfloat(
|
|
3507
|
+
'Enter Frequency (MHz)',
|
|
3508
|
+
prompt,
|
|
3509
|
+
parent=self.root,
|
|
3510
|
+
minvalue=0.0,
|
|
3511
|
+
)
|
|
3512
|
+
|
|
3513
|
+
def _phasor_thread(self, worker_fn, done_cb, *, status=' Working...'):
|
|
3514
|
+
self._btn_ph.configure(state='disabled')
|
|
3515
|
+
self._res.set_status(status)
|
|
3516
|
+
|
|
3517
|
+
def _run():
|
|
3518
|
+
try:
|
|
3519
|
+
result = worker_fn()
|
|
3520
|
+
except Exception as exc:
|
|
3521
|
+
import traceback
|
|
3522
|
+
traceback.print_exc()
|
|
3523
|
+
result = exc
|
|
3524
|
+
self.root.after(0, lambda: _finish(result))
|
|
3525
|
+
|
|
3526
|
+
def _finish(result):
|
|
3527
|
+
self._btn_ph.configure(state='normal')
|
|
3528
|
+
done_cb(result)
|
|
3529
|
+
t = threading.Thread(target=_run, daemon=True)
|
|
3530
|
+
t.start()
|
|
3531
|
+
|
|
3532
|
+
def _auto_save_phasor(self, ptu_path: str):
|
|
3533
|
+
try:
|
|
3534
|
+
sd = self._phasor_panel.get_session_dict()
|
|
3535
|
+
if sd.get('real_cal') is None:
|
|
3536
|
+
return
|
|
3537
|
+
p = Path(ptu_path)
|
|
3538
|
+
save_path = str(p.parent / f'{p.stem}_phasor.npz')
|
|
3539
|
+
from flimkit.phasor_launcher import save_session
|
|
3540
|
+
save_session(
|
|
3541
|
+
save_path,
|
|
3542
|
+
real_cal=sd['real_cal'],
|
|
3543
|
+
imag_cal=sd['imag_cal'],
|
|
3544
|
+
mean=sd['mean'],
|
|
3545
|
+
frequency=sd['frequency'],
|
|
3546
|
+
cursors=sd['cursors'],
|
|
3547
|
+
params=sd['params'],
|
|
3548
|
+
ptu_file=ptu_path,
|
|
3549
|
+
display_image=sd.get('display_image'),
|
|
3550
|
+
)
|
|
3551
|
+
print(f'[Phasor] Auto-saved session → {save_path}')
|
|
3552
|
+
if hasattr(self, '_proj_browser'):
|
|
3553
|
+
self._proj_browser.on_phasor_done(p.stem)
|
|
3554
|
+
except Exception as e:
|
|
3555
|
+
print(f'[Phasor] Auto-save failed: {e}')
|
|
3556
|
+
|
|
3557
|
+
def _on_phasor_change(self, panel):
|
|
3558
|
+
ptu = self.sv_ph_ptu.get().strip() if hasattr(self, 'sv_ph_ptu') else ''
|
|
3559
|
+
if ptu and Path(ptu).exists():
|
|
3560
|
+
self._auto_save_phasor(ptu)
|
|
3561
|
+
|
|
3562
|
+
def _restore_phasor_session(self, npz_path: str):
|
|
3563
|
+
try:
|
|
3564
|
+
min_ph = float(self.sv_ph_minph.get() or 0.01)
|
|
3565
|
+
except ValueError:
|
|
3566
|
+
min_ph = 0.01
|
|
3567
|
+
def _worker():
|
|
3568
|
+
from flimkit.phasor_launcher import load_session
|
|
3569
|
+
return load_session(npz_path)
|
|
3570
|
+
def _done(result):
|
|
3571
|
+
if isinstance(result, Exception):
|
|
3572
|
+
print(f'[Phasor] Could not restore session: {result}')
|
|
3573
|
+
return
|
|
3574
|
+
self._phasor_panel.load_session(result, min_photons=min_ph)
|
|
3575
|
+
self._res.set_status('✓ Phasor session restored.')
|
|
3576
|
+
self._phasor_thread(_worker, _done, status=' Restoring phasor session...')
|
|
3577
|
+
|
|
3578
|
+
def _run_build_machine_irf(self):
|
|
3579
|
+
src_dir = self.sv_mirf_src.get().strip()
|
|
3580
|
+
out_dir = self.sv_mirf_out_dir.get().strip()
|
|
3581
|
+
out_name = self.sv_mirf_name.get().strip()
|
|
3582
|
+
anchor = self.sv_mirf_anchor.get().strip()
|
|
3583
|
+
reducer = self.sv_mirf_reducer.get().strip()
|
|
3584
|
+
if not src_dir or not Path(src_dir).exists():
|
|
3585
|
+
messagebox.showerror('Missing input', 'Please select a valid PTU/XLSX source folder.')
|
|
3586
|
+
return
|
|
3587
|
+
if not out_dir:
|
|
3588
|
+
messagebox.showerror('Missing input', 'Please select an output directory.')
|
|
3589
|
+
return
|
|
3590
|
+
if not out_name:
|
|
3591
|
+
messagebox.showerror('Missing input', 'Please enter an output base filename.')
|
|
3592
|
+
return
|
|
3593
|
+
from flimkit.FLIM.irf_tools import build_machine_irf_from_folder
|
|
3594
|
+
|
|
3595
|
+
def task_fn():
|
|
3596
|
+
Path(out_dir).mkdir(parents=True, exist_ok=True)
|
|
3597
|
+
return build_machine_irf_from_folder(
|
|
3598
|
+
folder=src_dir,
|
|
3599
|
+
align_anchor=anchor,
|
|
3600
|
+
reducer=reducer,
|
|
3601
|
+
save=True,
|
|
3602
|
+
confirm_save=True,
|
|
3603
|
+
output_name=out_name,
|
|
3604
|
+
output_dir=out_dir,
|
|
3605
|
+
verbose=True,
|
|
3606
|
+
)
|
|
3607
|
+
|
|
3608
|
+
def on_done_irf(result):
|
|
3609
|
+
self._set_buttons('normal')
|
|
3610
|
+
self._res.set_status('✓ Machine IRF built.')
|
|
3611
|
+
if result is None or not isinstance(result, dict):
|
|
3612
|
+
return
|
|
3613
|
+
irf = result.get('irf')
|
|
3614
|
+
meta = result.get('metadata', {})
|
|
3615
|
+
if irf is None:
|
|
3616
|
+
return
|
|
3617
|
+
tcspc_ns = float(meta.get('tcspc_res_ns_mean', 0.05))
|
|
3618
|
+
import numpy as np
|
|
3619
|
+
time_ns = np.arange(len(irf)) * tcspc_ns
|
|
3620
|
+
preview_parent = self._preview_frame_label
|
|
3621
|
+
if not hasattr(self, '_irf_plot_frame'):
|
|
3622
|
+
import tkinter as _tk
|
|
3623
|
+
from tkinter import ttk as _ttk
|
|
3624
|
+
from matplotlib.figure import Figure
|
|
3625
|
+
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
|
3626
|
+
self._irf_plot_frame = _ttk.Frame(preview_parent)
|
|
3627
|
+
self._irf_plot_frame.grid(row=0, column=0, sticky='nsew')
|
|
3628
|
+
self._irf_fig = Figure(figsize=(6, 4), dpi=100, facecolor='#1e1e1e')
|
|
3629
|
+
self._irf_ax = self._irf_fig.add_subplot(111)
|
|
3630
|
+
self._irf_canvas_mpl = FigureCanvasTkAgg(self._irf_fig, master=self._irf_plot_frame)
|
|
3631
|
+
self._irf_canvas_mpl.get_tk_widget().pack(fill='both', expand=True)
|
|
3632
|
+
ax = self._irf_ax
|
|
3633
|
+
ax.clear()
|
|
3634
|
+
ax.set_facecolor('#1e1e1e')
|
|
3635
|
+
self._irf_fig.patch.set_facecolor('#1e1e1e')
|
|
3636
|
+
n_pairs = meta.get('n_pairs', '?')
|
|
3637
|
+
anchor = meta.get('align_anchor', '')
|
|
3638
|
+
reducer = meta.get('reducer', '')
|
|
3639
|
+
ax.plot(time_ns, irf, color='#00d4ff', linewidth=2, label=f'Machine IRF ({reducer})')
|
|
3640
|
+
ax.set_xlabel('Time (ns)', color='white')
|
|
3641
|
+
ax.set_ylabel('Amplitude (normalised)', color='white')
|
|
3642
|
+
ax.set_title(f'Machine IRF | {n_pairs} pairs | anchor={anchor}',
|
|
3643
|
+
color='white', fontsize=10)
|
|
3644
|
+
ax.tick_params(colors='white')
|
|
3645
|
+
ax.spines[:].set_color('#555')
|
|
3646
|
+
import numpy as _np
|
|
3647
|
+
half_max = irf.max() / 2
|
|
3648
|
+
above = _np.where(irf >= half_max)[0]
|
|
3649
|
+
if len(above) > 1:
|
|
3650
|
+
fwhm_ns = (above[-1] - above[0]) * tcspc_ns
|
|
3651
|
+
ax.axhline(half_max, color='#ff9900', linewidth=1,
|
|
3652
|
+
linestyle='', alpha=0.7, label=f'FWHM={fwhm_ns*1000:.0f} ps')
|
|
3653
|
+
ax.axvspan(above[0]*tcspc_ns, above[-1]*tcspc_ns,
|
|
3654
|
+
alpha=0.12, color='#ff9900')
|
|
3655
|
+
ax.legend(fontsize=8, facecolor='#2a2a2a', edgecolor='#555', labelcolor='white')
|
|
3656
|
+
self._irf_canvas_mpl.draw_idle()
|
|
3657
|
+
self._irf_plot_frame.grid()
|
|
3658
|
+
self._fov_preview.frame.grid_remove()
|
|
3659
|
+
self._preview_frame_label.configure(text=' Machine IRF Builder ')
|
|
3660
|
+
self._launch(task_fn, output_dir=out_dir, task_name='Building Machine IRF',
|
|
3661
|
+
_on_done_override=on_done_irf)
|
|
3662
|
+
|
|
3663
|
+
def _launch(self, fn, output_dir=None, ptu_path=None, task_name='Working...',
|
|
3664
|
+
_on_done_override=None):
|
|
3665
|
+
self._buf.clear()
|
|
3666
|
+
self._set_buttons('disabled')
|
|
3667
|
+
self._res.set_status(' Running...')
|
|
3668
|
+
self._res._nb.select(0)
|
|
3669
|
+
win_manager = ProgressWindowManager(self.root)
|
|
3670
|
+
win = ProgressWindow(self.root, task_name=task_name)
|
|
3671
|
+
cancel_event = win.cancelled
|
|
3672
|
+
|
|
3673
|
+
def progress_callback(i, total):
|
|
3674
|
+
win.set_progress(i, maximum=total)
|
|
3675
|
+
if cancel_event.is_set():
|
|
3676
|
+
win.set_status('Cancelling...')
|
|
3677
|
+
|
|
3678
|
+
def _worker():
|
|
3679
|
+
orig_stdout, orig_stderr = sys.stdout, sys.stderr
|
|
3680
|
+
redir = _Redirect(self._res.log, self._buf, root=self.root)
|
|
3681
|
+
redir_err = _Redirect(self._res.log, self._buf, root=self.root, is_stderr=True)
|
|
3682
|
+
sys.stdout = redir
|
|
3683
|
+
sys.stderr = redir_err
|
|
3684
|
+
try:
|
|
3685
|
+
sig = inspect.signature(fn)
|
|
3686
|
+
if 'progress_callback' in sig.parameters or 'cancel_event' in sig.parameters:
|
|
3687
|
+
if 'progress_window_manager' in sig.parameters:
|
|
3688
|
+
result = fn(progress_callback=progress_callback, cancel_event=cancel_event,
|
|
3689
|
+
progress_window_manager=win_manager)
|
|
3690
|
+
else:
|
|
3691
|
+
result = fn(progress_callback=progress_callback, cancel_event=cancel_event)
|
|
3692
|
+
else:
|
|
3693
|
+
result = fn()
|
|
3694
|
+
captured = ''.join(self._buf)
|
|
3695
|
+
rows = _parse_summary(captured)
|
|
3696
|
+
if _on_done_override is not None:
|
|
3697
|
+
self.root.after(0, lambda r=result: _on_done_override(r))
|
|
3698
|
+
else:
|
|
3699
|
+
self.root.after(0, lambda: self._on_done(rows, output_dir, result, ptu_path))
|
|
3700
|
+
except Exception as exc:
|
|
3701
|
+
import traceback
|
|
3702
|
+
traceback.print_exc()
|
|
3703
|
+
from flimkit.utils.crash_handler import log_exception
|
|
3704
|
+
log_exception(f'_launch: {task_name}')
|
|
3705
|
+
self.root.after(0, lambda e=exc: self._res.set_status(f'✗ Error: {e}'))
|
|
3706
|
+
finally:
|
|
3707
|
+
self.root.after(0, lambda: win.close())
|
|
3708
|
+
self.root.after(0, lambda: win_manager.close_all())
|
|
3709
|
+
if hasattr(redir, 'close'):
|
|
3710
|
+
redir.close()
|
|
3711
|
+
else:
|
|
3712
|
+
redir.flush()
|
|
3713
|
+
if hasattr(redir_err, 'close'):
|
|
3714
|
+
redir_err.close()
|
|
3715
|
+
else:
|
|
3716
|
+
redir_err.flush()
|
|
3717
|
+
sys.stdout = orig_stdout
|
|
3718
|
+
sys.stderr = orig_stderr
|
|
3719
|
+
self.root.after(0, lambda: self._set_buttons('normal'))
|
|
3720
|
+
threading.Thread(target=_worker, daemon=True).start()
|
|
3721
|
+
|
|
3722
|
+
def _on_done(self, rows, output_dir, fit_result=None, ptu_path=None):
|
|
3723
|
+
self._res.set_status('✓ Finished.')
|
|
3724
|
+
if fit_result is not None:
|
|
3725
|
+
global_summary = fit_result.get('global_summary')
|
|
3726
|
+
global_popt = fit_result.get('global_popt')
|
|
3727
|
+
if global_summary is not None:
|
|
3728
|
+
extracted_rows = self._extract_summary_rows(global_summary, global_popt)
|
|
3729
|
+
if extracted_rows:
|
|
3730
|
+
rows = extracted_rows
|
|
3731
|
+
if rows:
|
|
3732
|
+
self._res.populate_summary(rows)
|
|
3733
|
+
if fit_result is not None:
|
|
3734
|
+
try:
|
|
3735
|
+
self._fov_preview.display_fit_results(ptu_path, fit_result)
|
|
3736
|
+
if hasattr(self, '_roi_analysis_panel'):
|
|
3737
|
+
self._roi_analysis_panel._refresh_region_list()
|
|
3738
|
+
except Exception as e:
|
|
3739
|
+
import traceback
|
|
3740
|
+
traceback.print_exc()
|
|
3741
|
+
sug = fit_result.get('suggested_binning') if fit_result else None
|
|
3742
|
+
if sug:
|
|
3743
|
+
messagebox.showwarning(
|
|
3744
|
+
'Photon-starved image',
|
|
3745
|
+
'Most pixels have too few photons for a reliable per-pixel fit, so '
|
|
3746
|
+
'the FLIM image is mostly empty.\n\n'
|
|
3747
|
+
f'Try binning {sug}×{sug} (Expert settings → binning), or use '
|
|
3748
|
+
'the summed-fit result / phasor analysis for this image.')
|
|
3749
|
+
npz_file_path = None
|
|
3750
|
+
if ptu_path or output_dir:
|
|
3751
|
+
try:
|
|
3752
|
+
self._save_roi_progress(ptu_path or output_dir, fit_result, rows or [])
|
|
3753
|
+
from pathlib import Path
|
|
3754
|
+
base_path = Path(ptu_path) if ptu_path else Path(output_dir)
|
|
3755
|
+
if base_path.is_file():
|
|
3756
|
+
npz_file_path = str(base_path.parent / f'{base_path.stem}.roi_session.npz')
|
|
3757
|
+
else:
|
|
3758
|
+
npz_file_path = str(base_path / 'roi_session.npz')
|
|
3759
|
+
except Exception as e:
|
|
3760
|
+
print(f'[Info] Could not save ROI progress: {e}')
|
|
3761
|
+
self._res.set_fit_result(fit_result or {}, output_dir, npz_path=npz_file_path,
|
|
3762
|
+
scan_name=Path(ptu_path).stem if ptu_path else self._current_scan_stem())
|
|
3763
|
+
if hasattr(self, '_proj_browser'):
|
|
3764
|
+
stem = Path(ptu_path).stem if ptu_path else None
|
|
3765
|
+
prefix = self.sv_out_fov.get().strip() if hasattr(self, 'sv_out_fov') else None
|
|
3766
|
+
if stem:
|
|
3767
|
+
self._proj_browser.on_fit_done(stem, output_prefix=prefix)
|
|
3768
|
+
|
|
3769
|
+
def _extract_summary_rows(self, global_summary: dict, global_popt=None) -> list:
|
|
3770
|
+
if not global_summary:
|
|
3771
|
+
return []
|
|
3772
|
+
rows = []
|
|
3773
|
+
tau_centers = global_summary.get('tau_centers_ns')
|
|
3774
|
+
if tau_centers is not None:
|
|
3775
|
+
import numpy as _np
|
|
3776
|
+
tau_centers = list(_np.atleast_1d(tau_centers))
|
|
3777
|
+
widths_ns = list(_np.atleast_1d(global_summary.get('widths_ns', [])))
|
|
3778
|
+
fwhms_ns = list(_np.atleast_1d(global_summary.get('fwhms_ns', [])))
|
|
3779
|
+
amps_d = list(_np.atleast_1d(global_summary.get('amps', [])))
|
|
3780
|
+
fracs_d = list(_np.atleast_1d(global_summary.get('fractions', [])))
|
|
3781
|
+
dist_type = global_summary.get('dist_type', 'gaussian')
|
|
3782
|
+
dist_label = dist_type.capitalize()
|
|
3783
|
+
width_label = 'σ' if dist_type == 'gaussian' else 'Γ (FWHM)'
|
|
3784
|
+
for i in range(len(tau_centers)):
|
|
3785
|
+
rows.append((f'τ̄{i+1} ({dist_label} center)', f'{tau_centers[i]:.4f}', 'ns'))
|
|
3786
|
+
if i < len(widths_ns):
|
|
3787
|
+
rows.append((f'{width_label}{i+1}', f'{widths_ns[i]:.4f}', 'ns'))
|
|
3788
|
+
if i < len(fwhms_ns):
|
|
3789
|
+
rows.append((f'FWHM{i+1}', f'{fwhms_ns[i]:.4f}', 'ns'))
|
|
3790
|
+
if i < len(amps_d):
|
|
3791
|
+
rows.append((f'g{i+1} (amplitude)', f'{amps_d[i]:.3e}', ''))
|
|
3792
|
+
if i < len(fracs_d):
|
|
3793
|
+
rows.append((f'f{i+1} (int. frac.)', f'{fracs_d[i]:.4f}', ''))
|
|
3794
|
+
taus = global_summary.get('taus_ns', []) if tau_centers is None else None
|
|
3795
|
+
amps = global_summary.get('amps', []) if tau_centers is None else None
|
|
3796
|
+
fracs = global_summary.get('fractions', []) if tau_centers is None else None
|
|
3797
|
+
if taus is not None and len(taus) > 0:
|
|
3798
|
+
import numpy as np
|
|
3799
|
+
taus = list(np.atleast_1d(taus))
|
|
3800
|
+
amps = list(np.atleast_1d(amps)) if amps is not None else []
|
|
3801
|
+
fracs = list(np.atleast_1d(fracs)) if fracs is not None else []
|
|
3802
|
+
intens = list(np.atleast_1d(global_summary.get('intensities', [])))
|
|
3803
|
+
ifracs = list(np.atleast_1d(global_summary.get('intensity_fractions', [])))
|
|
3804
|
+
for i in range(len(taus)):
|
|
3805
|
+
rows.append((f'τ{i+1}', f'{taus[i]:.4f}', 'ns'))
|
|
3806
|
+
if i < len(amps):
|
|
3807
|
+
rows.append((f'α{i+1}', f'{amps[i]:.3e}', ''))
|
|
3808
|
+
if i < len(fracs):
|
|
3809
|
+
rows.append((f'f{i+1} (amp frac)', f'{fracs[i]:.4f}', ''))
|
|
3810
|
+
if i < len(intens):
|
|
3811
|
+
rows.append((f'I{i+1} (intensity)', f'{intens[i]:.3e}', 'cts'))
|
|
3812
|
+
if i < len(ifracs):
|
|
3813
|
+
rows.append((f'f{i+1} (int frac)', f'{ifracs[i]:.4f}', ''))
|
|
3814
|
+
for key, label in [
|
|
3815
|
+
('tau_mean_amp_ns', 'τ_mean (amp-weighted)'),
|
|
3816
|
+
('tau_mean_int_ns', 'τ_mean (int-weighted)'),
|
|
3817
|
+
]:
|
|
3818
|
+
v = global_summary.get(key)
|
|
3819
|
+
if v is not None:
|
|
3820
|
+
rows.append((label, f'{v:.4f}', 'ns'))
|
|
3821
|
+
tau_global = global_summary.get('tau_mean_amp_global_ns')
|
|
3822
|
+
if tau_global is not None:
|
|
3823
|
+
rows.append(('τ_mean amp-wtd (global)', f'{tau_global:.4f}', 'ns'))
|
|
3824
|
+
tau_std = global_summary.get('tau_std_amp_global_ns')
|
|
3825
|
+
if tau_std is not None:
|
|
3826
|
+
rows.append(('τ σ (pixel distrib.)', f'{tau_std:.4f}', 'ns'))
|
|
3827
|
+
tau_med = global_summary.get('tau_median_amp_global_ns')
|
|
3828
|
+
if tau_med is not None:
|
|
3829
|
+
rows.append(('τ_median (amp-wtd)', f'{tau_med:.4f}', 'ns'))
|
|
3830
|
+
n_px = global_summary.get('n_pixels_fitted')
|
|
3831
|
+
if n_px is not None:
|
|
3832
|
+
rows.append(('Pixels fitted', f'{n_px:,}', ''))
|
|
3833
|
+
k = 1
|
|
3834
|
+
while True:
|
|
3835
|
+
tau_k = global_summary.get(f'tau{k}_mean_ns')
|
|
3836
|
+
if tau_k is None:
|
|
3837
|
+
break
|
|
3838
|
+
rows.append((f'τ{k} mean', f'{tau_k:.4f}', 'ns'))
|
|
3839
|
+
a_k = global_summary.get(f'a{k}_mean_frac')
|
|
3840
|
+
if a_k is not None:
|
|
3841
|
+
rows.append((f'f{k} mean (amp frac)', f'{a_k:.4f}', ''))
|
|
3842
|
+
k += 1
|
|
3843
|
+
i_sum = global_summary.get('i_sum')
|
|
3844
|
+
if i_sum is not None:
|
|
3845
|
+
rows.append(('I_sum', f'{i_sum:.3e}', 'cts'))
|
|
3846
|
+
a_sum = global_summary.get('a_sum')
|
|
3847
|
+
if a_sum is not None:
|
|
3848
|
+
rows.append(('A_sum', f'{a_sum:.3e}', ''))
|
|
3849
|
+
bg_fit = global_summary.get('bg_fit')
|
|
3850
|
+
if bg_fit is not None:
|
|
3851
|
+
rows.append(('Background (fitted)', f'{bg_fit:.2f}', 'cts/bin'))
|
|
3852
|
+
t0_ns = global_summary.get('t0_ns')
|
|
3853
|
+
if t0_ns is not None:
|
|
3854
|
+
rows.append(('t0 (lifetime offset)', f'{t0_ns:.4f}', 'ns'))
|
|
3855
|
+
irf_shift = global_summary.get('irf_shift_bins')
|
|
3856
|
+
if irf_shift is not None:
|
|
3857
|
+
rows.append(('IRF shift', f'{irf_shift:.3f}', 'bins'))
|
|
3858
|
+
irf_sigma = global_summary.get('irf_sigma_bins')
|
|
3859
|
+
if irf_sigma is not None:
|
|
3860
|
+
rows.append(('IRF σ (broadening)', f'{irf_sigma:.3f}', 'bins'))
|
|
3861
|
+
irf_fwhm = global_summary.get('irf_fwhm_eff_ns')
|
|
3862
|
+
if irf_fwhm is not None:
|
|
3863
|
+
rows.append(('IRF FWHM (eff.)', f'{irf_fwhm:.4f}', 'ns'))
|
|
3864
|
+
chi2_r_tail = global_summary.get('reduced_chi2_tail')
|
|
3865
|
+
if chi2_r_tail is not None:
|
|
3866
|
+
rows.append(('χ²_r(tail) Neyman', f'{chi2_r_tail:.4f}', ''))
|
|
3867
|
+
chi2_p_tail = global_summary.get('reduced_chi2_tail_pearson')
|
|
3868
|
+
if chi2_p_tail is not None:
|
|
3869
|
+
rows.append(('χ²_r(tail) Pearson', f'{chi2_p_tail:.4f}', ''))
|
|
3870
|
+
return rows
|
|
3871
|
+
|
|
3872
|
+
def _set_buttons(self, state):
|
|
3873
|
+
for btn in (self._btn_fov, self._btn_st, self._btn_ph):
|
|
3874
|
+
btn.configure(state=state)
|
|
3875
|
+
|
|
3876
|
+
if HAS_TKMT:
|
|
3877
|
+
class FLIMKitGUIThemed(TKMT.ThemedTKinterFrame, _UIBuilder):
|
|
3878
|
+
def __init__(self, theme='sun-valley', mode='dark'):
|
|
3879
|
+
super().__init__('FLIMkit Analysis GUI', theme, mode,
|
|
3880
|
+
usecommandlineargs=True, useconfigfile=True)
|
|
3881
|
+
self.root = self.master
|
|
3882
|
+
self.root.minsize(760, 700)
|
|
3883
|
+
self._init_ui()
|
|
3884
|
+
self._run_plugin_startups()
|
|
3885
|
+
self.run(cleanresize=False)
|
|
3886
|
+
|
|
3887
|
+
class FLIMKitGUIFallback(_UIBuilder):
|
|
3888
|
+
def __init__(self, root):
|
|
3889
|
+
self.root = root
|
|
3890
|
+
self.root.title('FLIMkit Analysis GUI')
|
|
3891
|
+
self.root.minsize(760, 700)
|
|
3892
|
+
self._init_ui()
|
|
3893
|
+
self._run_plugin_startups()
|
|
3894
|
+
self.root.mainloop()
|
|
3895
|
+
|
|
3896
|
+
def launch_gui():
|
|
3897
|
+
global GUI_MODE
|
|
3898
|
+
GUI_MODE = True
|
|
3899
|
+
from flimkit.utils.crash_handler import init_crash_handler
|
|
3900
|
+
init_crash_handler()
|
|
3901
|
+
from flimkit import plugins
|
|
3902
|
+
plugins.ensure_loaded()
|
|
3903
|
+
if HAS_TKMT:
|
|
3904
|
+
app = FLIMKitGUIThemed(theme='sun-valley', mode='dark')
|
|
3905
|
+
else:
|
|
3906
|
+
if HAS_DND:
|
|
3907
|
+
from tkinterdnd2 import Tk
|
|
3908
|
+
root = Tk()
|
|
3909
|
+
else:
|
|
3910
|
+
root = tk.Tk()
|
|
3911
|
+
style = ttk.Style(root)
|
|
3912
|
+
for theme_name in ('clam', 'alt', 'default'):
|
|
3913
|
+
if theme_name in style.theme_names():
|
|
3914
|
+
style.theme_use(theme_name)
|
|
3915
|
+
break
|
|
3916
|
+
app = FLIMKitGUIFallback(root)
|
|
3917
|
+
|
|
3918
|
+
if __name__ == '__main__':
|
|
3919
|
+
launch_gui()
|