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.
Files changed (104) hide show
  1. flimkit/FLIM/__init__.py +0 -0
  2. flimkit/FLIM/assemble.py +254 -0
  3. flimkit/FLIM/batch.py +681 -0
  4. flimkit/FLIM/bg_tools.py +51 -0
  5. flimkit/FLIM/fit_tools.py +244 -0
  6. flimkit/FLIM/fitters.py +1471 -0
  7. flimkit/FLIM/irf_tools.py +617 -0
  8. flimkit/FLIM/models.py +391 -0
  9. flimkit/GPU/__init__.py +85 -0
  10. flimkit/GPU/_base.py +391 -0
  11. flimkit/GPU/cuda.py +10 -0
  12. flimkit/GPU/mlx_backend.py +381 -0
  13. flimkit/GPU/mps.py +10 -0
  14. flimkit/GPU/rocm.py +10 -0
  15. flimkit/GPU/torch_backend.py +385 -0
  16. flimkit/UI/app_state.py +10 -0
  17. flimkit/UI/controller.py +139 -0
  18. flimkit/UI/expert_settings.py +248 -0
  19. flimkit/UI/fit_help.py +206 -0
  20. flimkit/UI/fov_preview.py +1085 -0
  21. flimkit/UI/gui.py +3919 -0
  22. flimkit/UI/icon.icns +0 -0
  23. flimkit/UI/icon.ico +0 -0
  24. flimkit/UI/icon.png +0 -0
  25. flimkit/UI/irf_widget.py +103 -0
  26. flimkit/UI/mode_controller.py +118 -0
  27. flimkit/UI/modes/__init__.py +0 -0
  28. flimkit/UI/modes/base.py +3 -0
  29. flimkit/UI/modes/batch_mode.py +312 -0
  30. flimkit/UI/modes/fov_mode.py +164 -0
  31. flimkit/UI/modes/irf_mode.py +80 -0
  32. flimkit/UI/modes/phasor_mode.py +131 -0
  33. flimkit/UI/modes/stitch_mode.py +254 -0
  34. flimkit/UI/phasor_panel.py +1087 -0
  35. flimkit/UI/progress_window.py +113 -0
  36. flimkit/UI/project_panel.py +262 -0
  37. flimkit/UI/results_panel.py +332 -0
  38. flimkit/UI/roi_tools.py +794 -0
  39. flimkit/UI/utils.py +217 -0
  40. flimkit/__init__.py +0 -0
  41. flimkit/_version.py +41 -0
  42. flimkit/cli.py +120 -0
  43. flimkit/configs.py +148 -0
  44. flimkit/dialogs.py +46 -0
  45. flimkit/formats/BH/__init__.py +0 -0
  46. flimkit/formats/BH/reader.py +296 -0
  47. flimkit/formats/BH/writer.py +86 -0
  48. flimkit/formats/ISS/__init__.py +0 -0
  49. flimkit/formats/ISS/fdflim.py +86 -0
  50. flimkit/formats/ISS/image.py +114 -0
  51. flimkit/formats/ISS/reader.py +223 -0
  52. flimkit/formats/PS/__init__.py +0 -0
  53. flimkit/formats/PS/reader.py +202 -0
  54. flimkit/formats/PTU/__init__.py +0 -0
  55. flimkit/formats/PTU/decode.py +27 -0
  56. flimkit/formats/PTU/phu.py +85 -0
  57. flimkit/formats/PTU/reader.py +235 -0
  58. flimkit/formats/PTU/series.py +258 -0
  59. flimkit/formats/PTU/stitch.py +1182 -0
  60. flimkit/formats/PTU/tools.py +94 -0
  61. flimkit/formats/__init__.py +2 -0
  62. flimkit/formats/flim_file.py +232 -0
  63. flimkit/formats/phasor.py +132 -0
  64. flimkit/formats/signal.py +170 -0
  65. flimkit/image/tools.py +124 -0
  66. flimkit/interactive.py +1857 -0
  67. flimkit/mpl_backend.py +22 -0
  68. flimkit/phasor/__init__.py +40 -0
  69. flimkit/phasor/filters.py +127 -0
  70. flimkit/phasor/fret.py +654 -0
  71. flimkit/phasor/interactive.py +556 -0
  72. flimkit/phasor/peaks.py +186 -0
  73. flimkit/phasor/signal.py +90 -0
  74. flimkit/phasor_launcher.py +314 -0
  75. flimkit/plugins/__init__.py +137 -0
  76. flimkit/plugins/bindings.py +116 -0
  77. flimkit/plugins/builtin/__init__.py +3 -0
  78. flimkit/plugins/builtin/core_tools.py +28 -0
  79. flimkit/plugins/loader.py +371 -0
  80. flimkit/plugins/registry.py +406 -0
  81. flimkit/project.py +197 -0
  82. flimkit/synth.py +145 -0
  83. flimkit/utils/__init__.py +0 -0
  84. flimkit/utils/batch_fit.py +301 -0
  85. flimkit/utils/config_manager.py +119 -0
  86. flimkit/utils/config_snapshot.py +30 -0
  87. flimkit/utils/crash_handler.py +183 -0
  88. flimkit/utils/display.py +197 -0
  89. flimkit/utils/enhanced_outputs.py +345 -0
  90. flimkit/utils/fancy.py +103 -0
  91. flimkit/utils/lifetime_image.py +243 -0
  92. flimkit/utils/misc.py +111 -0
  93. flimkit/utils/plotting.py +190 -0
  94. flimkit/utils/roi.py +370 -0
  95. flimkit/utils/session.py +51 -0
  96. flimkit/utils/update_check.py +198 -0
  97. flimkit/utils/xlsx_tools.py +97 -0
  98. flimkit/utils/xml_utils.py +219 -0
  99. flimkit-0.12.0.dist-info/METADATA +356 -0
  100. flimkit-0.12.0.dist-info/RECORD +104 -0
  101. flimkit-0.12.0.dist-info/WHEEL +5 -0
  102. flimkit-0.12.0.dist-info/entry_points.txt +2 -0
  103. flimkit-0.12.0.dist-info/licenses/LICENSE.md +11 -0
  104. 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()