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
@@ -0,0 +1,794 @@
1
+ import json
2
+ from typing import List
3
+ import numpy as np
4
+ from flimkit.utils.roi import (
5
+ RoiManager,
6
+ _COLORS,
7
+ get_rectangle_patch,
8
+ get_ellipse_patch,
9
+ get_polygon_patch,
10
+ )
11
+
12
+ def _show_roi_fit_result_standalone(result: dict):
13
+ _show_fit_result_window(result)
14
+
15
+ def _show_fit_result_window(result: dict):
16
+ import tkinter as tk
17
+ from tkinter import ttk
18
+ import numpy as np
19
+ import matplotlib.pyplot as plt
20
+ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
21
+ summary = result['summary']
22
+ decay = result['decay']
23
+ time_ns = result['time_ns']
24
+ irf_prompt = result['irf_prompt']
25
+ model = summary.get('model')
26
+ taus = summary.get('taus_ns', [])
27
+ amps = summary.get('amps', [])
28
+ chi2 = summary.get('reduced_chi2_tail')
29
+ win = tk.Toplevel()
30
+ win.title(f"ROI Fit - {result['region_name']}")
31
+ win.geometry('660x560')
32
+ win.resizable(True, True)
33
+ fig, (ax_d, ax_r) = plt.subplots(
34
+ 2, 1, figsize=(6.2, 4.2),
35
+ gridspec_kw={'height_ratios': [3, 1]}, sharex=True)
36
+ fig.patch.set_facecolor('#2b2b2b')
37
+ for ax in (ax_d, ax_r):
38
+ ax.set_facecolor('#1e1e1e')
39
+ ax.tick_params(colors='white', labelsize=8)
40
+ ax.xaxis.label.set_color('white')
41
+ ax.yaxis.label.set_color('white')
42
+ for spine in ax.spines.values():
43
+ spine.set_color('#555555')
44
+ ax_d.semilogy(time_ns, decay, 'o-', color='steelblue',
45
+ linewidth=1.2, markersize=2, label='Decay', alpha=0.8)
46
+ if irf_prompt is not None and irf_prompt.max() > 0:
47
+ irf_sc = (irf_prompt / irf_prompt.max()) * decay.max() * 0.15
48
+ ax_d.semilogy(time_ns[:len(irf_prompt)], np.maximum(irf_sc, 1e-2),
49
+ color='orange', linewidth=1.5,
50
+ label=f'IRF ({result['irf_source']})', alpha=0.7)
51
+ if model is not None and len(model) == len(decay):
52
+ ax_d.semilogy(time_ns, model, color='red', linewidth=2.0,
53
+ label='Fit', alpha=0.9)
54
+ ax_d.legend(fontsize=7, loc='upper right', labelcolor='white',
55
+ facecolor='#333333', edgecolor='#555555')
56
+ title_bits = [result['region_name']]
57
+ if len(taus) > 0:
58
+ title_bits.append(' '.join(f'τ{i+1}={t:.3f}ns' for i, t in enumerate(taus)))
59
+ if chi2 is not None:
60
+ title_bits.append(f'χ²_r={chi2:.3f}')
61
+ ax_d.set_title(' | '.join(title_bits), fontsize=8, color='white')
62
+ ax_d.set_ylabel('Photon Count', color='white', fontsize=8)
63
+ if model is not None and len(model) == len(decay):
64
+ with np.errstate(invalid='ignore', divide='ignore'):
65
+ resid = np.where(model > 0, (decay - model) / np.sqrt(model), 0.0)
66
+ ax_r.plot(time_ns, resid, color='steelblue', linewidth=0.9)
67
+ ax_r.axhline(0, color='red', linewidth=1.0, linestyle='--', alpha=0.7)
68
+ if chi2 is not None:
69
+ ax_r.annotate(f'χ²_r = {chi2:.3f}',
70
+ xy=(0.98, 0.85), xycoords='axes fraction',
71
+ ha='right', va='top', fontsize=7, color='white',
72
+ bbox=dict(boxstyle='round,pad=0.2',
73
+ fc='#333333', alpha=0.7))
74
+ ax_r.set_ylabel('Resid. (σ)', color='white', fontsize=7)
75
+ ax_r.set_xlabel('Time (ns)', color='white', fontsize=8)
76
+ plt.tight_layout(pad=0.8)
77
+ canvas = FigureCanvasTkAgg(fig, master=win)
78
+ canvas.draw()
79
+ canvas.get_tk_widget().pack(fill='both', expand=True, padx=6, pady=6)
80
+ tbl = ttk.Frame(win, padding=4)
81
+ tbl.pack(fill='x', padx=6, pady=(0, 4))
82
+ cols = ('Parameter', 'Value', 'Unit')
83
+ n_rows = (len(taus) + len(amps)
84
+ + (1 if chi2 is not None else 0)
85
+ + (1 if len(taus) > 0 and len(amps) > 0 else 0))
86
+ tv = ttk.Treeview(tbl, columns=cols, show='headings',
87
+ height=min(8, max(1, n_rows)))
88
+ for col, w in zip(cols, (200, 110, 60)):
89
+ tv.heading(col, text=col, anchor='w')
90
+ tv.column(col, width=w, anchor='w')
91
+ rows = []
92
+ for i, tau in enumerate(taus):
93
+ rows.append((f'τ{i+1}', f'{tau:.4f}', 'ns'))
94
+ for i, amp in enumerate(amps):
95
+ rows.append((f'A{i+1} (amplitude)', f'{amp:.4f}', ''))
96
+ if len(taus) > 0 and len(amps) > 0 and np.sum(amps) > 0:
97
+ tau_mean = float(np.dot(taus, amps) / np.sum(amps))
98
+ rows.append(('τ_mean (amplitude-weighted)', f'{tau_mean:.4f}', 'ns'))
99
+ if chi2 is not None:
100
+ rows.append(('χ²_r (tail)', f'{chi2:.4f}', ''))
101
+ for i, row in enumerate(rows):
102
+ tv.insert('', 'end', values=row,
103
+ tags=('odd' if i % 2 else 'even',))
104
+ tv.tag_configure('odd', background='#f5f7fa', foreground='#1a1a1a')
105
+ tv.tag_configure('even', background='#ffffff', foreground='#1a1a1a')
106
+ tv.pack(fill='x')
107
+ btn_row = ttk.Frame(win, padding=4)
108
+ btn_row.pack(fill='x', padx=6, pady=(0, 6))
109
+ ttk.Button(btn_row, text='Close',
110
+ command=lambda: (plt.close(fig), win.destroy())).pack(side='right')
111
+ win.protocol('WM_DELETE_WINDOW', lambda: (plt.close(fig), win.destroy()))
112
+
113
+ def _ask_roi_fit_options(params: dict):
114
+ import tkinter as tk
115
+ from tkinter import ttk
116
+ result = {}
117
+ dlg = tk.Toplevel()
118
+ dlg.title('ROI Fit Options')
119
+ dlg.resizable(False, False)
120
+ dlg.grab_set()
121
+ pad = dict(padx=8, pady=4)
122
+ ttk.Label(dlg, text='Fit parameters for this ROI',
123
+ font=('TkDefaultFont', 10, 'bold')).grid(
124
+ row=0, column=0, columnspan=2, sticky='w', **pad)
125
+ ttk.Label(dlg, text='Components (n_exp):').grid(row=1, column=0, sticky='w', **pad)
126
+ sv_nexp = tk.IntVar(value=int(params.get('n_exp', 1)))
127
+ nexp_frame = ttk.Frame(dlg)
128
+ nexp_frame.grid(row=1, column=1, sticky='w', pady=4)
129
+ for n, lbl in [(1, '1-exp'), (2, '2-exp'), (3, '3-exp')]:
130
+ ttk.Radiobutton(nexp_frame, text=lbl, variable=sv_nexp, value=n).pack(
131
+ side='left', padx=(0, 6))
132
+ ttk.Label(dlg, text='τ_min (ns):').grid(row=2, column=0, sticky='w', **pad)
133
+ sv_tau_min = tk.StringVar(value=str(params.get('tau_min', 0.1)))
134
+ ttk.Entry(dlg, textvariable=sv_tau_min, width=10).grid(
135
+ row=2, column=1, sticky='w', **pad)
136
+ ttk.Label(dlg, text='τ_max (ns):').grid(row=3, column=0, sticky='w', **pad)
137
+ sv_tau_max = tk.StringVar(value=str(params.get('tau_max', 25.0)))
138
+ ttk.Entry(dlg, textvariable=sv_tau_max, width=10).grid(
139
+ row=3, column=1, sticky='w', **pad)
140
+ ttk.Label(dlg, text='Cost function:').grid(row=4, column=0, sticky='w', **pad)
141
+ sv_cost = tk.StringVar(value=params.get('cost_function', 'poisson'))
142
+ cost_frame = ttk.Frame(dlg)
143
+ cost_frame.grid(row=4, column=1, sticky='w', pady=4)
144
+ ttk.Radiobutton(cost_frame, text='Poisson deviance',
145
+ variable=sv_cost, value='poisson').pack(side='left', padx=(0, 6))
146
+ ttk.Radiobutton(cost_frame, text='Pearson χ²',
147
+ variable=sv_cost, value='chi2').pack(side='left')
148
+ ttk.Separator(dlg, orient='horizontal').grid(
149
+ row=5, column=0, columnspan=2, sticky='ew', padx=8, pady=6)
150
+ btn_frame = ttk.Frame(dlg)
151
+ btn_frame.grid(row=6, column=0, columnspan=2, sticky='e', padx=8, pady=(0, 8))
152
+
153
+ def _ok():
154
+ try:
155
+ tau_min = float(sv_tau_min.get())
156
+ tau_max = float(sv_tau_max.get())
157
+ except ValueError:
158
+ tk.messagebox.showerror('Invalid Input',
159
+ 'τ_min and τ_max must be numbers.', parent=dlg)
160
+ return
161
+ if tau_min <= 0 or tau_max <= tau_min:
162
+ tk.messagebox.showerror('Invalid Input',
163
+ 'Need 0 < τ_min < τ_max.', parent=dlg)
164
+ return
165
+ result['ok'] = True
166
+ result['n_exp'] = sv_nexp.get()
167
+ result['tau_min'] = tau_min
168
+ result['tau_max'] = tau_max
169
+ result['cost_function'] = sv_cost.get()
170
+ dlg.destroy()
171
+
172
+ def _cancel():
173
+ dlg.destroy()
174
+ ttk.Button(btn_frame, text='Cancel', command=_cancel).pack(side='left', padx=(0, 4))
175
+ ttk.Button(btn_frame, text='Run Fit', command=_ok, style='Accent.TButton').pack(side='left')
176
+ dlg.update_idletasks()
177
+ w, h = dlg.winfo_reqwidth(), dlg.winfo_reqheight()
178
+ sw = dlg.winfo_screenwidth()
179
+ sh = dlg.winfo_screenheight()
180
+ dlg.geometry(f'{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}')
181
+ dlg.wait_window()
182
+ if not result.get('ok'):
183
+ return None
184
+ merged = dict(params)
185
+ merged['n_exp'] = result['n_exp']
186
+ merged['tau_min'] = result['tau_min']
187
+ merged['tau_max'] = result['tau_max']
188
+ merged['cost_function'] = result['cost_function']
189
+ return merged
190
+
191
+ class RoiAnalysisPanel:
192
+
193
+ def __init__(self, parent, fov_preview=None):
194
+ import tkinter as tk
195
+ from tkinter import ttk
196
+ self.frame = ttk.Frame(parent, padding=4)
197
+ self.frame.columnconfigure(0, weight=1)
198
+ self.frame.rowconfigure(2, weight=1)
199
+ self.fov_preview = fov_preview
200
+ self.app = None
201
+ self._current_mode = tk.StringVar(value='select')
202
+ self._region_counter = 0
203
+ self._last_fit_results: dict = {}
204
+ toolbar = ttk.LabelFrame(self.frame, text='Drawing Mode', padding=4)
205
+ toolbar.grid(row=0, column=0, sticky='ew', pady=(0, 4))
206
+ for i in range(3):
207
+ toolbar.columnconfigure(i, weight=1)
208
+ self._btn_select = ttk.Button(toolbar, text='◯ Select', width=12,
209
+ command=lambda: self._set_mode('select'))
210
+ self._btn_select.grid(row=0, column=0, sticky='ew', padx=2, pady=2)
211
+ self._btn_rect = ttk.Button(toolbar, text='▭ Rectangle', width=12,
212
+ command=lambda: self._set_mode('rect'))
213
+ self._btn_rect.grid(row=0, column=1, sticky='ew', padx=2, pady=2)
214
+ self._btn_ellipse = ttk.Button(toolbar, text='○ Ellipse', width=12,
215
+ command=lambda: self._set_mode('ellipse'))
216
+ self._btn_ellipse.grid(row=0, column=2, sticky='ew', padx=2, pady=2)
217
+ self._btn_polygon = ttk.Button(toolbar, text='◇ Polygon', width=12,
218
+ command=lambda: self._set_mode('polygon'))
219
+ self._btn_polygon.grid(row=1, column=0, sticky='ew', padx=2, pady=2)
220
+ self._btn_freehand = ttk.Button(toolbar, text='✏ Freehand', width=12,
221
+ command=lambda: self._set_mode('freehand'))
222
+ self._btn_freehand.grid(row=1, column=1, sticky='ew', padx=2, pady=2)
223
+ ttk.Button(toolbar, text='Clear All', width=12,
224
+ command=self._clear_all_regions).grid(row=1, column=2, sticky='ew', padx=2, pady=2)
225
+ list_frame = ttk.LabelFrame(self.frame, text='Regions', padding=4)
226
+ list_frame.grid(row=1, column=0, sticky='nsew', pady=(0, 4))
227
+ list_frame.columnconfigure(0, weight=1)
228
+ list_frame.rowconfigure(0, weight=1)
229
+ cols = ('Name', 'Type', 'τ_mean (ns)', 'τ_med (ns)', 'τ_sd (ns)', 'Photons', 'σ_photons')
230
+ self._tree = ttk.Treeview(list_frame, columns=cols, height=6, show='tree headings')
231
+ self._tree.grid(row=0, column=0, sticky='nsew')
232
+ self._tree.column('#0', width=0, stretch=False)
233
+ self._tree.column('Name', anchor='w', width=100)
234
+ self._tree.column('Type', anchor='center', width=55)
235
+ self._tree.column('τ_mean (ns)', anchor='center', width=72)
236
+ self._tree.column('τ_med (ns)', anchor='center', width=72)
237
+ self._tree.column('τ_sd (ns)', anchor='center', width=70)
238
+ self._tree.column('Photons', anchor='center', width=65)
239
+ self._tree.column('σ_photons', anchor='center', width=72)
240
+ self._tree.heading('#0', text='', anchor='w')
241
+ self._tree.heading('Name', text='Name', anchor='w')
242
+ self._tree.heading('Type', text='Type', anchor='center')
243
+ self._tree.heading('τ_mean (ns)', text='τ_mean (ns)', anchor='center')
244
+ self._tree.heading('τ_med (ns)', text='τ_med (ns)', anchor='center')
245
+ self._tree.heading('τ_sd (ns)', text='τ_sd (ns)', anchor='center')
246
+ self._tree.heading('Photons', text='Photons', anchor='center')
247
+ self._tree.heading('σ_photons', text='σ_photons', anchor='center')
248
+ self._tree.bind('<Double-1>', self._on_region_double_click)
249
+ self._tree.bind('<Delete>', self._on_delete_key)
250
+ self._tree.bind('<<TreeviewSelect>>', self._on_region_selection_change)
251
+ scrollbar = ttk.Scrollbar(list_frame, orient='vertical', command=self._tree.yview)
252
+ scrollbar.grid(row=0, column=1, sticky='ns')
253
+ self._tree.configure(yscroll=scrollbar.set)
254
+ actions_frame = ttk.Frame(self.frame)
255
+ actions_frame.grid(row=2, column=0, sticky='ew', pady=4)
256
+ for i in range(3):
257
+ actions_frame.columnconfigure(i, weight=1)
258
+ ttk.Button(actions_frame, text='Delete Selected', width=16,
259
+ command=self._delete_selected_region).grid(row=0, column=0, sticky='ew', padx=2, pady=2)
260
+ ttk.Button(actions_frame, text='Rename...', width=16,
261
+ command=self._rename_selected_region).grid(row=0, column=1, sticky='ew', padx=2, pady=2)
262
+ ttk.Button(actions_frame, text='Import from GeoJSON', width=18,
263
+ command=self._import_rois_geojson).grid(row=0, column=2, sticky='ew', padx=2, pady=2)
264
+ ttk.Button(actions_frame, text='Export as CSV', width=16,
265
+ command=self._export_all_rois_csv).grid(row=1, column=0, sticky='ew', padx=2, pady=2)
266
+ ttk.Button(actions_frame, text='Export as GeoJSON', width=18,
267
+ command=self._export_selected_region).grid(row=1, column=1, sticky='ew', padx=2, pady=2)
268
+ ttk.Button(actions_frame, text='Export All as GeoJSON', width=20,
269
+ command=self._export_all_rois_geojson).grid(row=1, column=2, sticky='ew', padx=2, pady=2)
270
+ ttk.Button(actions_frame, text='⚗ Fit ROI Decay', width=16,
271
+ command=self._fit_roi_decay).grid(row=2, column=0, columnspan=2,
272
+ sticky='ew', padx=2, pady=(6, 2))
273
+ ttk.Button(actions_frame, text='View Fit', width=12,
274
+ command=self._view_last_fit_result).grid(row=2, column=2,
275
+ sticky='ew', padx=2, pady=(6, 2))
276
+ self._add_plugin_buttons(actions_frame, start_row=3)
277
+ self._status = tk.StringVar(value='Ready - Select drawing mode or click regions to add')
278
+ ttk.Label(self.frame, textvariable=self._status, foreground='grey',
279
+ font=('Courier', 8)).grid(row=3, column=0, sticky='w', padx=2, pady=2)
280
+
281
+ def _add_plugin_buttons(self, parent, start_row):
282
+ from tkinter import ttk
283
+ try:
284
+ from flimkit import plugins
285
+ buttons = plugins.panel_buttons('roi')
286
+ except Exception:
287
+ return
288
+ for index, spec in enumerate(buttons):
289
+ row = start_row + index // 3
290
+ column = index % 3
291
+ ttk.Button(parent, text=spec.label, width=18,
292
+ command=lambda spec=spec: self._run_plugin_button(spec)).grid(
293
+ row=row, column=column, sticky='ew', padx=2, pady=2)
294
+
295
+ def _run_plugin_button(self, spec):
296
+ from tkinter import messagebox
297
+ if self.app is None:
298
+ messagebox.showerror(
299
+ spec.label,
300
+ f'{spec.label} is not connected to the FLIMKit window yet.')
301
+ return
302
+ try:
303
+ spec.callback(self.app)
304
+ except Exception as exc:
305
+ messagebox.showerror(
306
+ spec.label,
307
+ f'{spec.label} ({spec.source}) raised {type(exc).__name__}: {exc}')
308
+
309
+ def _set_mode(self, mode: str):
310
+ self._current_mode.set(mode)
311
+ if self.fov_preview:
312
+ self.fov_preview._drawing_mode.set(mode)
313
+ self._status.set(f'Mode: {mode.upper()} - Draw on FLIM image')
314
+ print(f'[ROI] Drawing mode: {mode}')
315
+
316
+ def _clear_all_regions(self):
317
+ if self.fov_preview:
318
+ self.fov_preview._roi_manager.clear_all()
319
+ self.fov_preview._redraw_region_overlays()
320
+ self.fov_preview._save_regions_update()
321
+ self._refresh_region_list()
322
+ self._status.set('All regions cleared')
323
+
324
+ def _on_region_double_click(self, event):
325
+ selected = self._tree.selection()
326
+ if selected:
327
+ self._rename_selected_region()
328
+
329
+ def _on_delete_key(self, event):
330
+ self._delete_selected_region()
331
+
332
+ def _on_region_selection_change(self, event):
333
+ if getattr(self, '_refreshing', False):
334
+ return
335
+ selected = self._tree.selection()
336
+ if selected:
337
+ item = selected[0]
338
+ region_id = int(item)
339
+ if self.fov_preview:
340
+ self.fov_preview._roi_manager.select_region(region_id)
341
+ self.fov_preview._redraw_region_overlays()
342
+ else:
343
+ if self.fov_preview:
344
+ self.fov_preview._roi_manager.select_region(None)
345
+ self.fov_preview._redraw_region_overlays()
346
+
347
+ def _delete_selected_region(self):
348
+ selected = self._tree.selection()
349
+ if not selected:
350
+ return
351
+ item = selected[0]
352
+ region_id = int(item)
353
+ if self.fov_preview:
354
+ self.fov_preview._roi_manager.remove_region(region_id)
355
+ self.fov_preview._redraw_region_overlays()
356
+ self.fov_preview._save_regions_update()
357
+ self._refresh_region_list()
358
+ self._status.set(f'Deleted region {region_id}')
359
+
360
+ def _rename_selected_region(self):
361
+ import tkinter as tk
362
+ from tkinter import simpledialog
363
+ selected = self._tree.selection()
364
+ if not selected:
365
+ return
366
+ item = selected[0]
367
+ region_id = int(item)
368
+ old_name = self._tree.item(item, 'values')[0]
369
+ new_name = simpledialog.askstring('Rename Region',
370
+ f'Enter new name for region:',
371
+ initialvalue=old_name)
372
+ if new_name:
373
+ if self.fov_preview:
374
+ self.fov_preview._roi_manager.update_region(region_id, name=new_name)
375
+ self.fov_preview._save_regions_update()
376
+ self._refresh_region_list()
377
+ self._status.set(f"Renamed to '{new_name}'")
378
+
379
+ def _get_fov_stem(self) -> str:
380
+ if self.fov_preview and hasattr(self.fov_preview, '_ptu_path'):
381
+ p = self.fov_preview._ptu_path
382
+ if p:
383
+ from pathlib import Path
384
+ return Path(p).stem
385
+ return ''
386
+
387
+ def _export_selected_region(self):
388
+ import json
389
+ from pathlib import Path
390
+ from tkinter import filedialog, messagebox
391
+ selected = self._tree.selection()
392
+ if not selected:
393
+ messagebox.showwarning('No Selection', 'Select a region first')
394
+ return
395
+ item = selected[0]
396
+ region_id = int(item)
397
+ if not self.fov_preview or not self.fov_preview._roi_manager:
398
+ return
399
+ regions = self.fov_preview._roi_manager.get_all_regions()
400
+ region = next((r for r in regions if r.get('id') == region_id), None)
401
+ if not region:
402
+ messagebox.showerror('Error', 'Region not found')
403
+ return
404
+ name = region.get('name', '')
405
+ init_name = f'{name}.geojson' if name else None
406
+ geojson_file = filedialog.asksaveasfilename(
407
+ title='Export Region as GeoJSON',
408
+ initialfile=init_name,
409
+ defaultextension='.geojson',
410
+ filetypes=[('GeoJSON files', '*.geojson'), ('JSON files', '*.json'), ('All files', '*.*')])
411
+ if not geojson_file:
412
+ return
413
+ try:
414
+ payload = self.fov_preview._roi_manager.to_geojson([region_id])
415
+ feature = payload['features'][0]
416
+ with open(geojson_file, 'w', encoding='utf-8') as f:
417
+ json.dump(feature, f, indent=2)
418
+ messagebox.showinfo('Export Success', f"Region exported to:\n{Path(geojson_file).name}")
419
+ print(f'[Export] Region GeoJSON: {geojson_file}')
420
+ except Exception as e:
421
+ import traceback
422
+ messagebox.showerror('Export Error', f'Failed to export: {e}')
423
+ traceback.print_exc()
424
+
425
+ def _export_all_rois_csv(self):
426
+ import csv
427
+ from pathlib import Path
428
+ from tkinter import filedialog, messagebox
429
+ if not self.fov_preview or not self.fov_preview._roi_manager.regions:
430
+ messagebox.showwarning('No Data', 'No regions to export.')
431
+ return
432
+ fov_stem = self._get_fov_stem()
433
+ init_name = f'{fov_stem}_roi_data.csv' if fov_stem else 'roi_data.csv'
434
+ csv_file = filedialog.asksaveasfilename(
435
+ title='Export ROI Data',
436
+ initialfile=init_name,
437
+ defaultextension='.csv',
438
+ filetypes=[('CSV files', '*.csv'), ('All files', '*.*')])
439
+ if not csv_file:
440
+ return
441
+ try:
442
+ regions = self.fov_preview._roi_manager.get_all_regions()
443
+ rows = []
444
+ max_exp = 0
445
+ for region in regions:
446
+ taus_fit = region.get('statistics', {}).get('taus_ns_fit', [])
447
+ if taus_fit:
448
+ max_exp = max(max_exp, len(taus_fit))
449
+ for region in regions:
450
+ region_id = region.get('id', '')
451
+ name = region.get('name', '')
452
+ tool = region.get('tool', '')
453
+ stats = region.get('statistics', {})
454
+ tau_mean = stats.get('tau_mean', 'N/A')
455
+ tau_median = stats.get('tau_median', 'N/A')
456
+ tau_stdev = stats.get('tau_stdev', 'N/A')
457
+ photon_count = stats.get('photon_count', 'N/A')
458
+ photon_stdev = stats.get('photon_stdev', 'N/A')
459
+ tau_mean_fit = stats.get('tau_mean_fit', 'N/A')
460
+ taus_fit = stats.get('taus_ns_fit', [])
461
+ amps_fit = stats.get('amps_fit', [])
462
+ chi2_r_fit = stats.get('chi2_r_fit', 'N/A')
463
+ row = [region_id, name, tool,
464
+ tau_mean, tau_median, tau_stdev, photon_count, photon_stdev,
465
+ tau_mean_fit, chi2_r_fit]
466
+ for k in range(max_exp):
467
+ row.append(taus_fit[k] if k < len(taus_fit) else 'N/A')
468
+ row.append(amps_fit[k] if k < len(amps_fit) else 'N/A')
469
+ rows.append(row)
470
+ if not rows:
471
+ messagebox.showwarning('No Data', 'No regions to export.')
472
+ return
473
+ header = ['ID', 'Name', 'Type',
474
+ 'Tau_mean_ns', 'Tau_median_ns', 'Tau_stdev_ns',
475
+ 'Photon_count', 'Photon_stdev',
476
+ 'Tau_mean_fit_ns', 'Chi2_r_fit']
477
+ for k in range(1, max_exp + 1):
478
+ header += [f'Tau{k}_fit_ns', f'Amp{k}_fit']
479
+ with open(csv_file, 'w', newline='', encoding='utf-8') as f:
480
+ writer = csv.writer(f)
481
+ writer.writerow(header)
482
+ writer.writerows(rows)
483
+ messagebox.showinfo('Export Success', f"ROI data exported to:\n{Path(csv_file).name}")
484
+ print(f'[Export] ROI CSV: {csv_file}')
485
+ except Exception as e:
486
+ import traceback
487
+ messagebox.showerror('Export Error', f'Failed to export: {e}')
488
+ traceback.print_exc()
489
+
490
+ def _export_all_rois_geojson(self):
491
+ import json
492
+ from pathlib import Path
493
+ from tkinter import filedialog, messagebox
494
+ if not self.fov_preview or not self.fov_preview._roi_manager.regions:
495
+ messagebox.showwarning('No Data', 'No regions to export.')
496
+ return
497
+ fov_stem = self._get_fov_stem()
498
+ init_name = f'{fov_stem}_all_rois.geojson' if fov_stem else 'all_rois.geojson'
499
+ geojson_file = filedialog.asksaveasfilename(
500
+ title='Export ROI Data as GeoJSON',
501
+ initialfile=init_name,
502
+ defaultextension='.geojson',
503
+ filetypes=[('GeoJSON files', '*.geojson'), ('JSON files', '*.json'), ('All files', '*.*')])
504
+ if not geojson_file:
505
+ return
506
+ try:
507
+ payload = self.fov_preview._roi_manager.to_geojson()
508
+ features = payload['features']
509
+ if not features:
510
+ messagebox.showwarning('No Data', 'No regions to export.')
511
+ return
512
+ with open(geojson_file, 'w', encoding='utf-8') as f:
513
+ json.dump(payload, f, indent=2)
514
+ messagebox.showinfo('Export Success', f"ROI data exported to:\n{Path(geojson_file).name}\n({len(features)} regions)")
515
+ print(f'[Export] ROI GeoJSON: {geojson_file}')
516
+ except Exception as e:
517
+ import traceback
518
+ messagebox.showerror('Export Error', f'Failed to export: {e}')
519
+ traceback.print_exc()
520
+
521
+ def _import_rois_geojson(self):
522
+ import json
523
+ from tkinter import filedialog, messagebox
524
+ if not self.fov_preview or not self.fov_preview._roi_manager:
525
+ messagebox.showwarning('Not Ready', 'FOV preview not initialized')
526
+ return
527
+ geojson_file = filedialog.askopenfilename(
528
+ title='Import ROI Data from GeoJSON',
529
+ filetypes=[('GeoJSON files', '*.geojson'), ('JSON files', '*.json'), ('All files', '*.*')])
530
+ if not geojson_file:
531
+ return
532
+ try:
533
+ with open(geojson_file, 'r', encoding='utf-8') as f:
534
+ data = json.load(f)
535
+ imported_ids = self.fov_preview._roi_manager.add_geojson(data)
536
+ imported_count = len(imported_ids)
537
+ if imported_count > 0:
538
+ self.fov_preview._redraw_region_overlays()
539
+ self.fov_preview._save_regions_update()
540
+ self._refresh_region_list()
541
+ messagebox.showinfo('Import Success', f"Imported {imported_count} region(s) from {data.get('type', 'GeoJSON file')}")
542
+ else:
543
+ messagebox.showwarning('Import Failed', 'No regions could be imported')
544
+ except json.JSONDecodeError as e:
545
+ messagebox.showerror('JSON Error', f'Invalid JSON file: {e}')
546
+ except Exception as e:
547
+ import traceback
548
+ messagebox.showerror('Import Error', f'Failed to import: {e}')
549
+ traceback.print_exc()
550
+
551
+ def _refresh_region_list(self):
552
+ import tkinter as tk
553
+ self._refreshing = True
554
+ try:
555
+ selected_id = None
556
+ sel = self._tree.selection()
557
+ if sel:
558
+ selected_id = sel[0]
559
+ for item in self._tree.get_children():
560
+ self._tree.delete(item)
561
+ if not self.fov_preview or not self.fov_preview._roi_manager:
562
+ return
563
+ for region in self.fov_preview._roi_manager.get_all_regions():
564
+ region_id = region['id']
565
+ name = region['name']
566
+ tool_type = region['tool'].upper()
567
+ color = self.fov_preview._roi_manager.get_color(region_id)
568
+ tau_mean = '-'
569
+ tau_med = '-'
570
+ tau_stdev = '-'
571
+ photon_count = '-'
572
+ photon_stdev = '-'
573
+ if self.fov_preview._lifetime_map is not None:
574
+ try:
575
+ mask = self.fov_preview._roi_manager.compute_region_mask(
576
+ region_id, self.fov_preview._lifetime_map.shape
577
+ )
578
+ if mask is not None:
579
+ lifetime_in_region = self.fov_preview._lifetime_map[mask]
580
+ valid = lifetime_in_region[~np.isnan(lifetime_in_region)]
581
+ if valid.size > 0:
582
+ tau_mean_val = float(np.mean(valid))
583
+ tau_med_val = float(np.median(valid))
584
+ tau_stdev_val = float(np.std(valid))
585
+ intensity_map = self.fov_preview._intensity_map
586
+ if intensity_map is not None and intensity_map.shape == self.fov_preview._lifetime_map.shape:
587
+ intensity_in_region = intensity_map[mask]
588
+ photon_count_val = int(intensity_in_region.sum())
589
+ photon_stdev_val = float(np.std(intensity_in_region))
590
+ else:
591
+ photon_count_val = int(valid.size)
592
+ photon_stdev_val = float(np.sqrt(photon_count_val))
593
+ tau_mean = f'{tau_mean_val:.2f}'
594
+ tau_med = f'{tau_med_val:.2f}'
595
+ tau_stdev = f'{tau_stdev_val:.2f}'
596
+ photon_count = str(photon_count_val)
597
+ photon_stdev = f'{photon_stdev_val:.1f}'
598
+ region['statistics'] = {
599
+ 'tau_mean': tau_mean_val,
600
+ 'tau_median': tau_med_val,
601
+ 'tau_stdev': tau_stdev_val,
602
+ 'photon_count': photon_count_val,
603
+ 'photon_stdev': photon_stdev_val,
604
+ }
605
+ except Exception as e:
606
+ print(f'[ROI] Could not compute stats: {e}')
607
+ values = (name, tool_type, tau_mean, tau_med, tau_stdev, photon_count, photon_stdev)
608
+ self._tree.insert('', 'end', iid=str(region_id), values=values, tags=(f'color_{region_id}',))
609
+ self._tree.tag_configure(f'color_{region_id}', foreground=color)
610
+ if selected_id is not None and str(region_id) == selected_id:
611
+ self._tree.selection_set(str(region_id))
612
+ self._tree.update_idletasks()
613
+ finally:
614
+ self._refreshing = False
615
+ def add_region_from_drawing(self, tool_type: str, coords: List[List[float]]):
616
+ if not self.fov_preview:
617
+ return
618
+ self._region_counter += 1
619
+ name = f'{tool_type.capitalize()}-{self._region_counter}'
620
+ region_id = self.fov_preview._roi_manager.add_region(name, tool_type, coords)
621
+ self.fov_preview._redraw_region_overlays()
622
+ self.fov_preview._save_regions_update()
623
+ self._refresh_region_list()
624
+ self._status.set(f'Added region: {name}')
625
+
626
+ def _fit_roi_decay(self):
627
+ import tkinter as tk
628
+ from tkinter import messagebox
629
+ from pathlib import Path
630
+ if not self.fov_preview:
631
+ return
632
+ try:
633
+ selected = self._tree.selection()
634
+ if not selected:
635
+ messagebox.showwarning('No Region', 'Select a region in the list first.')
636
+ return
637
+ selected_ids = []
638
+ for iid in selected:
639
+ try:
640
+ selected_ids.append(int(iid))
641
+ except ValueError:
642
+ pass
643
+ all_regions = self.fov_preview._roi_manager.get_all_regions()
644
+ regions = [r for r in all_regions if r['id'] in selected_ids]
645
+ if not regions:
646
+ messagebox.showwarning('Region Not Found',
647
+ 'The selected region could not be found. '
648
+ 'Try refreshing the region list.')
649
+ return
650
+ region_id = regions[0]['id']
651
+ region_name = (regions[0]['name'] if len(regions) == 1
652
+ else f'{len(regions)} regions (merged)')
653
+ if not callable(getattr(self, 'get_fit_params', None)) or \
654
+ not callable(getattr(self, 'run_with_progress', None)):
655
+ messagebox.showwarning('Not Ready',
656
+ 'Run a whole-FOV fit first - fit parameters '
657
+ 'are needed to re-fit the ROI decay.')
658
+ return
659
+ params = self.get_fit_params()
660
+ ptu_path = params.get('ptu_path') or getattr(self.fov_preview, '_ptu_path', None)
661
+ if not ptu_path or not Path(ptu_path).exists():
662
+ messagebox.showwarning('No PTU',
663
+ 'No PTU file loaded - select a PTU file and run '
664
+ 'a fit before using ROI decay fitting.')
665
+ return
666
+ except Exception as _setup_exc:
667
+ messagebox.showerror('Fit ROI Decay - Setup Error',
668
+ f"Could not prepare parameters:\n{_setup_exc}")
669
+ import traceback as _tb
670
+ _tb.print_exc()
671
+ return
672
+ region_name = (regions[0].get('name', f'Region {region_id}')
673
+ if len(regions) == 1
674
+ else f'{len(regions)} regions (merged)')
675
+ irf_cached = getattr(self.fov_preview, '_irf_prompt', None)
676
+ params = _ask_roi_fit_options(params)
677
+ if params is None:
678
+ return
679
+
680
+ def task(progress_callback=None, cancel_event=None):
681
+ from flimkit.formats import FLIMFile
682
+ from flimkit.FLIM.fitters import fit_summed
683
+ ptu = FLIMFile(ptu_path, verbose=False)
684
+ n_bins = ptu.n_bins
685
+ tcspc_res = ptu.tcspc_res
686
+ channel = params.get('channel')
687
+ if progress_callback:
688
+ progress_callback(1, 4)
689
+ stack = ptu.pixel_stack(channel=channel, binning=1)
690
+ img_shape = (stack.shape[0], stack.shape[1])
691
+ union_mask = np.zeros(img_shape, dtype=bool)
692
+ for r in regions:
693
+ m = self.fov_preview._roi_manager.compute_region_mask(r['id'], img_shape)
694
+ if m is not None:
695
+ union_mask |= m
696
+ if not union_mask.any():
697
+ raise ValueError('ROI mask is empty - region(s) may be outside the image bounds.')
698
+ roi_decay = stack[union_mask].sum(axis=0).astype(float)
699
+ if roi_decay.max() == 0:
700
+ raise ValueError('ROI contains no photons.')
701
+ if progress_callback:
702
+ progress_callback(2, 4)
703
+ irf_prompt = irf_cached
704
+ if irf_prompt is None or len(irf_prompt) != n_bins:
705
+ from flimkit.FLIM.irf_tools import gaussian_irf
706
+ decay_peak = int(np.argmax(roi_decay))
707
+ fwhm_bins = max(1.0, 0.2e-9 / tcspc_res)
708
+ irf_prompt = gaussian_irf(n_bins, decay_peak, fwhm_bins)
709
+ irf_source = 'gaussian (no IRF cached)'
710
+ else:
711
+ irf_source = 'from main fit'
712
+ if progress_callback:
713
+ progress_callback(3, 4)
714
+ popt, summary = fit_summed(
715
+ roi_decay, tcspc_res, n_bins, irf_prompt,
716
+ has_tail=False, fit_bg=True, fit_sigma=False,
717
+ n_exp=params['n_exp'],
718
+ tau_min_ns=params['tau_min'],
719
+ tau_max_ns=params['tau_max'],
720
+ cost_function=params['cost_function'],
721
+ )
722
+ if progress_callback:
723
+ progress_callback(4, 4)
724
+ return {
725
+ 'region_id': region_id,
726
+ 'region_name': region_name,
727
+ 'region_ids': [r['id'] for r in regions],
728
+ 'decay': roi_decay,
729
+ 'time_ns': ptu.time_ns,
730
+ 'irf_prompt': irf_prompt,
731
+ 'irf_source': irf_source,
732
+ 'popt': popt,
733
+ 'summary': summary,
734
+ 'n_exp': params['n_exp'],
735
+ }
736
+
737
+ def on_done(result):
738
+ if result is None:
739
+ return
740
+ summary = result['summary']
741
+ taus = list(summary.get('taus_ns', []))
742
+ amps = list(summary.get('amps', []))
743
+ tau_mean_fit = (float(np.dot(taus, amps) / np.sum(amps))
744
+ if len(taus) > 0 and len(amps) > 0 else None)
745
+ for rid in result.get('region_ids', [result['region_id']]):
746
+ region_obj = next((r for r in self.fov_preview._roi_manager.get_all_regions()
747
+ if r['id'] == rid), None)
748
+ if region_obj is not None:
749
+ stats = region_obj.get('statistics', {})
750
+ stats['tau_mean_fit'] = tau_mean_fit
751
+ stats['taus_ns_fit'] = taus
752
+ stats['amps_fit'] = amps
753
+ stats['chi2_r_fit'] = summary.get('reduced_chi2_tail')
754
+ region_obj['statistics'] = stats
755
+ key = tuple(sorted(result.get('region_ids', [result['region_id']])))
756
+ self._last_fit_results[key] = result
757
+ self._refresh_region_list()
758
+ self._show_roi_fit_result(result)
759
+ self.run_with_progress(
760
+ task,
761
+ task_name=f'ROI Decay Fit - {region_name}',
762
+ on_done=on_done,
763
+ )
764
+
765
+ def _view_last_fit_result(self):
766
+ from tkinter import messagebox
767
+ if not self.fov_preview:
768
+ return
769
+ selected = self._tree.selection()
770
+ if not selected:
771
+ messagebox.showwarning('No Region', 'Select a region in the list first.')
772
+ return
773
+ selected_ids = []
774
+ for iid in selected:
775
+ try:
776
+ selected_ids.append(int(iid))
777
+ except ValueError:
778
+ pass
779
+ key = tuple(sorted(selected_ids))
780
+ result = self._last_fit_results.get(key)
781
+ if result is None:
782
+ messagebox.showinfo(
783
+ 'No Fit Cached',
784
+ 'No fit result cached for this selection.\n'
785
+ 'Run \u22cf Fit ROI Decay first, or reload the session and refit '
786
+ 'to regenerate the plot (numeric stats are saved in the .npz).')
787
+ return
788
+ self._show_roi_fit_result(result)
789
+
790
+ def _show_roi_fit_result(self, result: dict):
791
+ _show_fit_result_window(result)
792
+
793
+ def grid(self, **kw):
794
+ self.frame.grid(**kw)