flimkit 0.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flimkit/FLIM/__init__.py +0 -0
- flimkit/FLIM/assemble.py +254 -0
- flimkit/FLIM/batch.py +681 -0
- flimkit/FLIM/bg_tools.py +51 -0
- flimkit/FLIM/fit_tools.py +244 -0
- flimkit/FLIM/fitters.py +1471 -0
- flimkit/FLIM/irf_tools.py +617 -0
- flimkit/FLIM/models.py +391 -0
- flimkit/GPU/__init__.py +85 -0
- flimkit/GPU/_base.py +391 -0
- flimkit/GPU/cuda.py +10 -0
- flimkit/GPU/mlx_backend.py +381 -0
- flimkit/GPU/mps.py +10 -0
- flimkit/GPU/rocm.py +10 -0
- flimkit/GPU/torch_backend.py +385 -0
- flimkit/UI/app_state.py +10 -0
- flimkit/UI/controller.py +139 -0
- flimkit/UI/expert_settings.py +248 -0
- flimkit/UI/fit_help.py +206 -0
- flimkit/UI/fov_preview.py +1085 -0
- flimkit/UI/gui.py +3919 -0
- flimkit/UI/icon.icns +0 -0
- flimkit/UI/icon.ico +0 -0
- flimkit/UI/icon.png +0 -0
- flimkit/UI/irf_widget.py +103 -0
- flimkit/UI/mode_controller.py +118 -0
- flimkit/UI/modes/__init__.py +0 -0
- flimkit/UI/modes/base.py +3 -0
- flimkit/UI/modes/batch_mode.py +312 -0
- flimkit/UI/modes/fov_mode.py +164 -0
- flimkit/UI/modes/irf_mode.py +80 -0
- flimkit/UI/modes/phasor_mode.py +131 -0
- flimkit/UI/modes/stitch_mode.py +254 -0
- flimkit/UI/phasor_panel.py +1087 -0
- flimkit/UI/progress_window.py +113 -0
- flimkit/UI/project_panel.py +262 -0
- flimkit/UI/results_panel.py +332 -0
- flimkit/UI/roi_tools.py +794 -0
- flimkit/UI/utils.py +217 -0
- flimkit/__init__.py +0 -0
- flimkit/_version.py +41 -0
- flimkit/cli.py +120 -0
- flimkit/configs.py +148 -0
- flimkit/dialogs.py +46 -0
- flimkit/formats/BH/__init__.py +0 -0
- flimkit/formats/BH/reader.py +296 -0
- flimkit/formats/BH/writer.py +86 -0
- flimkit/formats/ISS/__init__.py +0 -0
- flimkit/formats/ISS/fdflim.py +86 -0
- flimkit/formats/ISS/image.py +114 -0
- flimkit/formats/ISS/reader.py +223 -0
- flimkit/formats/PS/__init__.py +0 -0
- flimkit/formats/PS/reader.py +202 -0
- flimkit/formats/PTU/__init__.py +0 -0
- flimkit/formats/PTU/decode.py +27 -0
- flimkit/formats/PTU/phu.py +85 -0
- flimkit/formats/PTU/reader.py +235 -0
- flimkit/formats/PTU/series.py +258 -0
- flimkit/formats/PTU/stitch.py +1182 -0
- flimkit/formats/PTU/tools.py +94 -0
- flimkit/formats/__init__.py +2 -0
- flimkit/formats/flim_file.py +232 -0
- flimkit/formats/phasor.py +132 -0
- flimkit/formats/signal.py +170 -0
- flimkit/image/tools.py +124 -0
- flimkit/interactive.py +1857 -0
- flimkit/mpl_backend.py +22 -0
- flimkit/phasor/__init__.py +40 -0
- flimkit/phasor/filters.py +127 -0
- flimkit/phasor/fret.py +654 -0
- flimkit/phasor/interactive.py +556 -0
- flimkit/phasor/peaks.py +186 -0
- flimkit/phasor/signal.py +90 -0
- flimkit/phasor_launcher.py +314 -0
- flimkit/plugins/__init__.py +137 -0
- flimkit/plugins/bindings.py +116 -0
- flimkit/plugins/builtin/__init__.py +3 -0
- flimkit/plugins/builtin/core_tools.py +28 -0
- flimkit/plugins/loader.py +371 -0
- flimkit/plugins/registry.py +406 -0
- flimkit/project.py +197 -0
- flimkit/synth.py +145 -0
- flimkit/utils/__init__.py +0 -0
- flimkit/utils/batch_fit.py +301 -0
- flimkit/utils/config_manager.py +119 -0
- flimkit/utils/config_snapshot.py +30 -0
- flimkit/utils/crash_handler.py +183 -0
- flimkit/utils/display.py +197 -0
- flimkit/utils/enhanced_outputs.py +345 -0
- flimkit/utils/fancy.py +103 -0
- flimkit/utils/lifetime_image.py +243 -0
- flimkit/utils/misc.py +111 -0
- flimkit/utils/plotting.py +190 -0
- flimkit/utils/roi.py +370 -0
- flimkit/utils/session.py +51 -0
- flimkit/utils/update_check.py +198 -0
- flimkit/utils/xlsx_tools.py +97 -0
- flimkit/utils/xml_utils.py +219 -0
- flimkit-0.12.0.dist-info/METADATA +356 -0
- flimkit-0.12.0.dist-info/RECORD +104 -0
- flimkit-0.12.0.dist-info/WHEEL +5 -0
- flimkit-0.12.0.dist-info/entry_points.txt +2 -0
- flimkit-0.12.0.dist-info/licenses/LICENSE.md +11 -0
- flimkit-0.12.0.dist-info/top_level.txt +1 -0
flimkit/UI/roi_tools.py
ADDED
|
@@ -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)
|