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
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
import tkinter as tk
|
|
2
|
+
from tkinter import ttk, filedialog, messagebox
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
import matplotlib
|
|
8
|
+
from flimkit.mpl_backend import select_backend
|
|
9
|
+
select_backend()
|
|
10
|
+
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
|
|
11
|
+
from matplotlib.figure import Figure
|
|
12
|
+
from matplotlib.patches import Ellipse, Polygon as MplPolygon
|
|
13
|
+
from matplotlib.path import Path as MplPath
|
|
14
|
+
|
|
15
|
+
from phasorpy.cursor import mask_from_elliptic_cursor, pseudo_color
|
|
16
|
+
from phasorpy.lifetime import phasor_to_apparent_lifetime, phasor_semicircle_intersect
|
|
17
|
+
from phasorpy.component import phasor_component_fraction
|
|
18
|
+
from phasorpy.plot import PhasorPlot
|
|
19
|
+
|
|
20
|
+
_COLORS = ['#d62728', '#1f77b4', '#2ca02c', '#ff7f0e', '#9467bd', '#8c564b']
|
|
21
|
+
|
|
22
|
+
def _hex_to_rgb01(h):
|
|
23
|
+
h = h.lstrip('#')
|
|
24
|
+
return (int(h[0:2], 16) / 255.0,
|
|
25
|
+
int(h[2:4], 16) / 255.0,
|
|
26
|
+
int(h[4:6], 16) / 255.0)
|
|
27
|
+
|
|
28
|
+
def _tau_phi_scalar(g, s, freq_mhz):
|
|
29
|
+
"""Return τ_φ (ns) for a single (G, S) point.
|
|
30
|
+
|
|
31
|
+
phasorpy.phasor_to_apparent_lifetime returns a scalar (not array) when
|
|
32
|
+
the inputs are Python floats - avoids the float(1-element-array) TypeError.
|
|
33
|
+
"""
|
|
34
|
+
tau_phi, _ = phasor_to_apparent_lifetime(float(g), float(s), freq_mhz)
|
|
35
|
+
return float(tau_phi)
|
|
36
|
+
|
|
37
|
+
class PhasorViewPanel:
|
|
38
|
+
|
|
39
|
+
def __init__(self, parent, max_cursors=6):
|
|
40
|
+
self.max_cursors = max_cursors
|
|
41
|
+
self.on_change = None
|
|
42
|
+
|
|
43
|
+
self._real = None
|
|
44
|
+
self._imag = None
|
|
45
|
+
self._real_raw = None
|
|
46
|
+
self._imag_raw = None
|
|
47
|
+
self._mean = None
|
|
48
|
+
self._disp = None
|
|
49
|
+
self._freq = 80.0
|
|
50
|
+
self._valid = None
|
|
51
|
+
|
|
52
|
+
self._filter_method = tk.StringVar(value='none')
|
|
53
|
+
self._filter_sigma = tk.DoubleVar(value=1.0)
|
|
54
|
+
self._filter_size = tk.IntVar(value=3)
|
|
55
|
+
|
|
56
|
+
self._cursors = []
|
|
57
|
+
self._cursor_artists = []
|
|
58
|
+
|
|
59
|
+
self._fret_trajectory = None
|
|
60
|
+
self._peak_results = None
|
|
61
|
+
|
|
62
|
+
self._mode_var = tk.StringVar(value='ellipse')
|
|
63
|
+
self._poly_pts = []
|
|
64
|
+
self._poly_line = None
|
|
65
|
+
|
|
66
|
+
self._drag_idx = None
|
|
67
|
+
self._drag_last = (0.0, 0.0)
|
|
68
|
+
|
|
69
|
+
self._ptu_path = None
|
|
70
|
+
self._channel = None
|
|
71
|
+
self._last_fit_result = None
|
|
72
|
+
self.run_with_progress = None
|
|
73
|
+
self.get_fit_params = None
|
|
74
|
+
|
|
75
|
+
self._radius = tk.DoubleVar(value=0.05)
|
|
76
|
+
self._ratio = tk.DoubleVar(value=0.60)
|
|
77
|
+
|
|
78
|
+
self.frame = ttk.Frame(parent)
|
|
79
|
+
self.frame.columnconfigure(0, weight=1)
|
|
80
|
+
self.frame.rowconfigure(1, weight=1)
|
|
81
|
+
|
|
82
|
+
self._build_controls()
|
|
83
|
+
self._build_figure()
|
|
84
|
+
|
|
85
|
+
self._status_var = tk.StringVar(
|
|
86
|
+
value='Load a PTU file to begin phasor analysis.')
|
|
87
|
+
ttk.Label(self.frame, textvariable=self._status_var,
|
|
88
|
+
foreground='grey', font=('Courier', 8)).grid(
|
|
89
|
+
row=2, column=0, sticky='w', padx=4, pady=(0, 2))
|
|
90
|
+
|
|
91
|
+
def _build_controls(self):
|
|
92
|
+
ctrl = ttk.Frame(self.frame)
|
|
93
|
+
ctrl.grid(row=0, column=0, sticky='ew', padx=4, pady=(4, 2))
|
|
94
|
+
|
|
95
|
+
row0 = ttk.Frame(ctrl)
|
|
96
|
+
row0.pack(side='top', fill='x')
|
|
97
|
+
|
|
98
|
+
ttk.Button(row0, text='✕ Clear all',
|
|
99
|
+
command=self._on_clear).pack(side='left', padx=(0, 4))
|
|
100
|
+
ttk.Button(row0, text='↩ Undo',
|
|
101
|
+
command=self._on_undo).pack(side='left', padx=(0, 8))
|
|
102
|
+
ttk.Button(row0, text='Save session',
|
|
103
|
+
command=self._on_save).pack(side='left', padx=(0, 12))
|
|
104
|
+
|
|
105
|
+
ttk.Separator(row0, orient='vertical').pack(
|
|
106
|
+
side='left', fill='y', padx=(0, 8))
|
|
107
|
+
ttk.Label(row0, text='Mode:').pack(side='left')
|
|
108
|
+
ttk.Radiobutton(row0, text='Ellipse', variable=self._mode_var,
|
|
109
|
+
value='ellipse',
|
|
110
|
+
command=self._on_mode_change).pack(
|
|
111
|
+
side='left', padx=(2, 2))
|
|
112
|
+
ttk.Radiobutton(row0, text='Polygon', variable=self._mode_var,
|
|
113
|
+
value='poly',
|
|
114
|
+
command=self._on_mode_change).pack(
|
|
115
|
+
side='left', padx=(0, 8))
|
|
116
|
+
|
|
117
|
+
row_params = ttk.Frame(ctrl)
|
|
118
|
+
row_params.pack(side='top', fill='x', pady=(2, 0))
|
|
119
|
+
self._ellipse_ctrl_frame = ttk.Frame(row_params)
|
|
120
|
+
self._ellipse_ctrl_frame.pack(side='left')
|
|
121
|
+
ttk.Label(self._ellipse_ctrl_frame, text='Radius:').pack(side='left')
|
|
122
|
+
ttk.Scale(self._ellipse_ctrl_frame, variable=self._radius,
|
|
123
|
+
from_=0.01, to=0.15, orient='horizontal', length=90,
|
|
124
|
+
command=lambda _: self._on_param_change()).pack(
|
|
125
|
+
side='left', padx=(2, 4))
|
|
126
|
+
self._radius_lbl = ttk.Label(self._ellipse_ctrl_frame,
|
|
127
|
+
text='0.050', width=5)
|
|
128
|
+
self._radius_lbl.pack(side='left', padx=(0, 14))
|
|
129
|
+
|
|
130
|
+
ttk.Label(self._ellipse_ctrl_frame, text='Minor/major:').pack(
|
|
131
|
+
side='left')
|
|
132
|
+
ttk.Scale(self._ellipse_ctrl_frame, variable=self._ratio,
|
|
133
|
+
from_=0.10, to=1.00, orient='horizontal', length=70,
|
|
134
|
+
command=lambda _: self._on_param_change()).pack(
|
|
135
|
+
side='left', padx=(2, 4))
|
|
136
|
+
self._ratio_lbl = ttk.Label(self._ellipse_ctrl_frame,
|
|
137
|
+
text='0.60', width=4)
|
|
138
|
+
self._ratio_lbl.pack(side='left')
|
|
139
|
+
|
|
140
|
+
self._radius.trace_add('write', lambda *_: self._update_param_labels())
|
|
141
|
+
self._ratio.trace_add('write', lambda *_: self._update_param_labels())
|
|
142
|
+
|
|
143
|
+
row1 = ttk.Frame(ctrl)
|
|
144
|
+
row1.pack(side='top', fill='x', pady=(2, 0))
|
|
145
|
+
ttk.Button(row1, text='⚗ Fit Cursor Decay',
|
|
146
|
+
command=self._fit_cursor_decay).pack(side='left', padx=(0, 4))
|
|
147
|
+
ttk.Button(row1, text='View Fit',
|
|
148
|
+
command=self._view_last_fit_result).pack(side='left')
|
|
149
|
+
|
|
150
|
+
row_filt = ttk.Frame(ctrl)
|
|
151
|
+
row_filt.pack(side='top', fill='x', pady=(2, 0))
|
|
152
|
+
ttk.Label(row_filt, text='Phasor filter:').pack(side='left')
|
|
153
|
+
ttk.Combobox(
|
|
154
|
+
row_filt, textvariable=self._filter_method,
|
|
155
|
+
values=['none', 'gaussian', 'median', 'wavelet'],
|
|
156
|
+
state='readonly', width=9,
|
|
157
|
+
).pack(side='left', padx=(2, 6))
|
|
158
|
+
self._filter_method.trace_add('write', lambda *_: self._on_filter_method_change())
|
|
159
|
+
|
|
160
|
+
self._filt_sigma_frame = ttk.Frame(row_filt)
|
|
161
|
+
self._filt_sigma_frame.pack(side='left')
|
|
162
|
+
ttk.Label(self._filt_sigma_frame, text='σ:').pack(side='left')
|
|
163
|
+
ttk.Spinbox(self._filt_sigma_frame, textvariable=self._filter_sigma,
|
|
164
|
+
from_=0.5, to=10.0, increment=0.5, width=5).pack(side='left', padx=(2, 6))
|
|
165
|
+
|
|
166
|
+
self._filt_size_frame = ttk.Frame(row_filt)
|
|
167
|
+
self._filt_size_frame.pack(side='left')
|
|
168
|
+
ttk.Label(self._filt_size_frame, text='size:').pack(side='left')
|
|
169
|
+
ttk.Spinbox(self._filt_size_frame, textvariable=self._filter_size,
|
|
170
|
+
from_=3, to=15, increment=2, width=4).pack(side='left', padx=(2, 6))
|
|
171
|
+
self._filt_size_frame.pack_forget()
|
|
172
|
+
|
|
173
|
+
ttk.Button(row_filt, text='Apply',
|
|
174
|
+
command=self._on_filter_apply).pack(side='left', padx=(0, 4))
|
|
175
|
+
ttk.Button(row_filt, text='Reset',
|
|
176
|
+
command=self._on_filter_reset).pack(side='left')
|
|
177
|
+
|
|
178
|
+
def _update_param_labels(self):
|
|
179
|
+
self._radius_lbl.configure(text=f"{self._radius.get():.3f}")
|
|
180
|
+
self._ratio_lbl.configure(text=f"{self._ratio.get():.2f}")
|
|
181
|
+
|
|
182
|
+
def _on_filter_method_change(self):
|
|
183
|
+
method = self._filter_method.get()
|
|
184
|
+
if method == 'gaussian':
|
|
185
|
+
self._filt_sigma_frame.pack(side='left')
|
|
186
|
+
self._filt_size_frame.pack_forget()
|
|
187
|
+
elif method in ('median', 'wavelet'):
|
|
188
|
+
self._filt_sigma_frame.pack_forget()
|
|
189
|
+
self._filt_size_frame.pack(side='left')
|
|
190
|
+
else:
|
|
191
|
+
self._filt_sigma_frame.pack_forget()
|
|
192
|
+
self._filt_size_frame.pack_forget()
|
|
193
|
+
|
|
194
|
+
def _on_filter_apply(self):
|
|
195
|
+
if self._real_raw is None:
|
|
196
|
+
return
|
|
197
|
+
method = self._filter_method.get()
|
|
198
|
+
if method == 'none':
|
|
199
|
+
return
|
|
200
|
+
from flimkit.phasor.filters import phasor_filter
|
|
201
|
+
kwargs = {}
|
|
202
|
+
if method == 'gaussian':
|
|
203
|
+
kwargs['sigma'] = float(self._filter_sigma.get())
|
|
204
|
+
elif method in ('median', 'wavelet'):
|
|
205
|
+
kwargs['size'] = int(self._filter_size.get())
|
|
206
|
+
try:
|
|
207
|
+
self._real, self._imag = phasor_filter(
|
|
208
|
+
self._real_raw.copy(), self._imag_raw.copy(), method,
|
|
209
|
+
mean=self._mean, **kwargs)
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
messagebox.showerror('Filter error', str(exc))
|
|
212
|
+
return
|
|
213
|
+
self._redraw_phasor()
|
|
214
|
+
self._redraw_cursors()
|
|
215
|
+
self._canvas.draw_idle()
|
|
216
|
+
self._status_var.set(
|
|
217
|
+
f"Phasor filter applied: {method} "
|
|
218
|
+
+ (f"(\u03c3={kwargs.get('sigma', '')})" if method == 'gaussian'
|
|
219
|
+
else f"(size={kwargs.get('size', '')})"))
|
|
220
|
+
|
|
221
|
+
def _on_filter_reset(self):
|
|
222
|
+
if self._real_raw is None:
|
|
223
|
+
return
|
|
224
|
+
self._real = self._real_raw.copy()
|
|
225
|
+
self._imag = self._imag_raw.copy()
|
|
226
|
+
self._filter_method.set('none')
|
|
227
|
+
self._redraw_phasor()
|
|
228
|
+
self._redraw_cursors()
|
|
229
|
+
self._canvas.draw_idle()
|
|
230
|
+
self._status_var.set('Phasor filter reset.')
|
|
231
|
+
|
|
232
|
+
def _build_figure(self):
|
|
233
|
+
fig_frame = ttk.Frame(self.frame)
|
|
234
|
+
fig_frame.grid(row=1, column=0, sticky='nsew')
|
|
235
|
+
fig_frame.columnconfigure(0, weight=1)
|
|
236
|
+
fig_frame.rowconfigure(0, weight=1)
|
|
237
|
+
|
|
238
|
+
self._fig = Figure(figsize=(5, 7), dpi=100, facecolor='black')
|
|
239
|
+
gs = self._fig.add_gridspec(
|
|
240
|
+
2, 1, height_ratios=[1, 1.8],
|
|
241
|
+
hspace=0.38, left=0.10, right=0.95, top=0.95, bottom=0.07)
|
|
242
|
+
self._ax_img = self._fig.add_subplot(gs[0])
|
|
243
|
+
self._ax_ph = self._fig.add_subplot(gs[1])
|
|
244
|
+
|
|
245
|
+
self._draw_placeholder()
|
|
246
|
+
|
|
247
|
+
self._canvas = FigureCanvasTkAgg(self._fig, master=fig_frame)
|
|
248
|
+
self._canvas.get_tk_widget().grid(row=0, column=0, sticky='nsew')
|
|
249
|
+
|
|
250
|
+
tb_frame = ttk.Frame(fig_frame)
|
|
251
|
+
tb_frame.grid(row=1, column=0, sticky='ew')
|
|
252
|
+
self._toolbar = NavigationToolbar2Tk(self._canvas, tb_frame,
|
|
253
|
+
pack_toolbar=True)
|
|
254
|
+
self._toolbar.update()
|
|
255
|
+
|
|
256
|
+
self._cid = self._canvas.mpl_connect('button_press_event',
|
|
257
|
+
self._on_click)
|
|
258
|
+
self._canvas.mpl_connect('motion_notify_event', self._on_motion)
|
|
259
|
+
self._canvas.mpl_connect('button_release_event', self._on_release)
|
|
260
|
+
self._canvas.mpl_connect('key_press_event', self._on_key)
|
|
261
|
+
|
|
262
|
+
def _draw_placeholder(self):
|
|
263
|
+
for ax in (self._ax_img, self._ax_ph):
|
|
264
|
+
ax.set_facecolor('black')
|
|
265
|
+
ax.tick_params(left=False, bottom=False,
|
|
266
|
+
labelleft=False, labelbottom=False)
|
|
267
|
+
self._ax_img.set_title(
|
|
268
|
+
'FOV image (pseudo-colour overlay once cursors are placed)',
|
|
269
|
+
fontsize=9, color='#999999')
|
|
270
|
+
self._ax_ph.set_title(
|
|
271
|
+
'Phasor plot - load a file to begin',
|
|
272
|
+
fontsize=9, color='#999999')
|
|
273
|
+
|
|
274
|
+
def set_data(self,
|
|
275
|
+
real_cal,
|
|
276
|
+
imag_cal,
|
|
277
|
+
mean,
|
|
278
|
+
frequency,
|
|
279
|
+
display_image=None,
|
|
280
|
+
min_photons=0.01):
|
|
281
|
+
"""Load phasor arrays and render the phasor histogram.
|
|
282
|
+
Must be called from the Tk main thread.
|
|
283
|
+
"""
|
|
284
|
+
self._real_raw = np.asarray(real_cal, dtype=float).squeeze()
|
|
285
|
+
self._imag_raw = np.asarray(imag_cal, dtype=float).squeeze()
|
|
286
|
+
self._real = self._real_raw.copy()
|
|
287
|
+
self._imag = self._imag_raw.copy()
|
|
288
|
+
self._mean = np.asarray(mean, dtype=float).squeeze()
|
|
289
|
+
self._disp = (np.asarray(display_image, dtype=float).squeeze()
|
|
290
|
+
if display_image is not None else self._mean.copy())
|
|
291
|
+
self._freq = float(frequency)
|
|
292
|
+
self._valid = (self._mean >= min_photons) & ~np.isnan(self._real)
|
|
293
|
+
|
|
294
|
+
self._cursors.clear()
|
|
295
|
+
self._cursor_artists.clear()
|
|
296
|
+
|
|
297
|
+
self._redraw_phasor()
|
|
298
|
+
self._redraw_image(masks=None)
|
|
299
|
+
self._canvas.draw_idle()
|
|
300
|
+
|
|
301
|
+
n_valid = int(self._valid.sum())
|
|
302
|
+
self._status_var.set(
|
|
303
|
+
f"✓ {n_valid} valid pixels | {frequency:.1f} MHz "
|
|
304
|
+
f"| click phasor to place cursor")
|
|
305
|
+
|
|
306
|
+
def load_session(self, session, min_photons=0.01):
|
|
307
|
+
self.set_data(
|
|
308
|
+
session['real_cal'], session['imag_cal'],
|
|
309
|
+
session['mean'], session['frequency'],
|
|
310
|
+
display_image=session.get('display_image'),
|
|
311
|
+
min_photons=min_photons,
|
|
312
|
+
)
|
|
313
|
+
for c in session.get('cursors', []):
|
|
314
|
+
if c.get('type') == 'poly':
|
|
315
|
+
self._cursors.append(dict(
|
|
316
|
+
type='poly',
|
|
317
|
+
vertices=[(float(v[0]), float(v[1]))
|
|
318
|
+
for v in c['vertices']],
|
|
319
|
+
color=c['color']))
|
|
320
|
+
else:
|
|
321
|
+
self._cursors.append(dict(
|
|
322
|
+
type='ellipse',
|
|
323
|
+
center_g=float(c['center_g']),
|
|
324
|
+
center_s=float(c['center_s']),
|
|
325
|
+
color=c['color']))
|
|
326
|
+
p = session.get('params', {})
|
|
327
|
+
if 'radius' in p:
|
|
328
|
+
self._radius.set(float(p['radius']))
|
|
329
|
+
if 'radius_minor' in p and p.get('radius', 0) > 0:
|
|
330
|
+
self._ratio.set(float(p['radius_minor']) / float(p['radius']))
|
|
331
|
+
if self._cursors:
|
|
332
|
+
self._redraw_cursors()
|
|
333
|
+
self._analyse()
|
|
334
|
+
|
|
335
|
+
def get_session_dict(self):
|
|
336
|
+
r = self._radius.get()
|
|
337
|
+
rm = r * self._ratio.get()
|
|
338
|
+
cursors = []
|
|
339
|
+
for c in self._cursors:
|
|
340
|
+
if c.get('type', 'ellipse') == 'poly':
|
|
341
|
+
cursors.append(dict(type='poly',
|
|
342
|
+
vertices=c['vertices'],
|
|
343
|
+
color=c['color']))
|
|
344
|
+
else:
|
|
345
|
+
cursors.append(dict(type='ellipse',
|
|
346
|
+
center_g=c['center_g'],
|
|
347
|
+
center_s=c['center_s'],
|
|
348
|
+
color=c['color']))
|
|
349
|
+
return dict(
|
|
350
|
+
real_cal=self._real,
|
|
351
|
+
imag_cal=self._imag,
|
|
352
|
+
mean=self._mean,
|
|
353
|
+
frequency=self._freq,
|
|
354
|
+
display_image=self._disp,
|
|
355
|
+
cursors=cursors,
|
|
356
|
+
params=dict(radius=r, radius_minor=rm, angle_mode='semicircle'),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _redraw_phasor(self):
|
|
360
|
+
self._ax_ph.cla()
|
|
361
|
+
if self._real is None:
|
|
362
|
+
return
|
|
363
|
+
g = self._real[self._valid]
|
|
364
|
+
s = self._imag[self._valid]
|
|
365
|
+
|
|
366
|
+
pp = PhasorPlot(ax=self._ax_ph, frequency=self._freq)
|
|
367
|
+
pp.hist2d(g, s, cmap='inferno', bins=256)
|
|
368
|
+
self._ax_ph.set_facecolor('black')
|
|
369
|
+
self._ax_ph.set_title(
|
|
370
|
+
f"Phasor ({self._freq:.1f} MHz) - click to place cursor",
|
|
371
|
+
fontsize=9)
|
|
372
|
+
self._redraw_fret_overlay()
|
|
373
|
+
self._redraw_peaks_overlay()
|
|
374
|
+
|
|
375
|
+
def _redraw_cursors(self):
|
|
376
|
+
for art in self._cursor_artists:
|
|
377
|
+
try:
|
|
378
|
+
art.remove()
|
|
379
|
+
except Exception:
|
|
380
|
+
pass
|
|
381
|
+
self._cursor_artists.clear()
|
|
382
|
+
|
|
383
|
+
if self._poly_line is not None:
|
|
384
|
+
for art in self._poly_line:
|
|
385
|
+
try:
|
|
386
|
+
art.remove()
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
self._poly_line = None
|
|
390
|
+
|
|
391
|
+
r = self._radius.get()
|
|
392
|
+
r_min = r * self._ratio.get()
|
|
393
|
+
ax = self._ax_ph
|
|
394
|
+
|
|
395
|
+
for i, cur in enumerate(self._cursors):
|
|
396
|
+
col = cur['color']
|
|
397
|
+
ctype = cur.get('type', 'ellipse')
|
|
398
|
+
|
|
399
|
+
if ctype == 'poly':
|
|
400
|
+
verts = cur['vertices']
|
|
401
|
+
poly_patch = MplPolygon(
|
|
402
|
+
verts, closed=True,
|
|
403
|
+
facecolor=col, alpha=0.18,
|
|
404
|
+
edgecolor=col, linewidth=2,
|
|
405
|
+
linestyle='--', zorder=8)
|
|
406
|
+
ax.add_patch(poly_patch)
|
|
407
|
+
self._cursor_artists.append(poly_patch)
|
|
408
|
+
cx = float(np.mean([v[0] for v in verts]))
|
|
409
|
+
cy = float(np.mean([v[1] for v in verts]))
|
|
410
|
+
else:
|
|
411
|
+
cg, cs = cur['center_g'], cur['center_s']
|
|
412
|
+
ang = float(np.degrees(np.arctan2(cs, cg - 0.5) + np.pi / 2.0))
|
|
413
|
+
ell = Ellipse((cg, cs), 2 * r, 2 * r_min,
|
|
414
|
+
angle=ang, facecolor=col, alpha=0.18,
|
|
415
|
+
edgecolor=col, linewidth=2,
|
|
416
|
+
linestyle='--', zorder=8)
|
|
417
|
+
ax.add_patch(ell)
|
|
418
|
+
self._cursor_artists.append(ell)
|
|
419
|
+
cx, cy = cg, cs
|
|
420
|
+
|
|
421
|
+
(dot,) = ax.plot(cx, cy, 'o', color=col, ms=7, zorder=10)
|
|
422
|
+
self._cursor_artists.append(dot)
|
|
423
|
+
txt = ax.text(cx + 0.015, cy + 0.015, f'C{i+1}',
|
|
424
|
+
color=col, fontsize=9, fontweight='bold', zorder=11)
|
|
425
|
+
self._cursor_artists.append(txt)
|
|
426
|
+
|
|
427
|
+
if len(self._cursors) >= 2:
|
|
428
|
+
c0 = self._cursors[0]
|
|
429
|
+
c1 = self._cursors[1]
|
|
430
|
+
if (c0.get('type', 'ellipse') == 'ellipse' and
|
|
431
|
+
c1.get('type', 'ellipse') == 'ellipse'):
|
|
432
|
+
g0, s0 = c0['center_g'], c0['center_s']
|
|
433
|
+
g1, s1 = c1['center_g'], c1['center_s']
|
|
434
|
+
gi0, si0, gi1, si1 = phasor_semicircle_intersect(g0, s0, g1, s1)
|
|
435
|
+
if not np.isnan(float(gi0)):
|
|
436
|
+
(ln,) = ax.plot([float(gi0), float(gi1)],
|
|
437
|
+
[float(si0), float(si1)],
|
|
438
|
+
'w-', lw=1.5, alpha=0.7, zorder=7)
|
|
439
|
+
(pt1,) = ax.plot(float(gi0), float(si0),
|
|
440
|
+
'g*', ms=11, zorder=12)
|
|
441
|
+
(pt2,) = ax.plot(float(gi1), float(si1),
|
|
442
|
+
'm*', ms=11, zorder=12)
|
|
443
|
+
self._cursor_artists.extend([ln, pt1, pt2])
|
|
444
|
+
|
|
445
|
+
if self._poly_pts:
|
|
446
|
+
self._update_poly_artist()
|
|
447
|
+
|
|
448
|
+
def _redraw_image(self, masks):
|
|
449
|
+
self._ax_img.cla()
|
|
450
|
+
self._ax_img.set_facecolor('black')
|
|
451
|
+
disp = np.asarray(self._disp) if self._disp is not None else None
|
|
452
|
+
if disp is None or disp.ndim != 2 or min(disp.shape) < 2:
|
|
453
|
+
self._ax_img.text(0.5, 0.5, 'No image available\n(point measurement)',
|
|
454
|
+
ha='center', va='center', color='white', fontsize=9,
|
|
455
|
+
transform=self._ax_img.transAxes)
|
|
456
|
+
self._ax_img.set_title('FOV image', fontsize=9)
|
|
457
|
+
self._ax_img.set_xticks([])
|
|
458
|
+
self._ax_img.set_yticks([])
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
if masks is not None and len(masks) > 0 and self._cursors:
|
|
462
|
+
colors_rgb = np.array([_hex_to_rgb01(c['color'])
|
|
463
|
+
for c in self._cursors])
|
|
464
|
+
mask_list = [masks[i] for i in range(len(self._cursors))]
|
|
465
|
+
pc = pseudo_color(*mask_list,
|
|
466
|
+
intensity=self._disp,
|
|
467
|
+
colors=colors_rgb)
|
|
468
|
+
self._ax_img.imshow(pc, origin='upper', interpolation='nearest')
|
|
469
|
+
counts = ', '.join(
|
|
470
|
+
f'C{i+1}:{int(masks[i].sum())}px'
|
|
471
|
+
for i in range(len(self._cursors)))
|
|
472
|
+
title = f"FOV overlay ({counts})"
|
|
473
|
+
else:
|
|
474
|
+
self._ax_img.imshow(self._disp, cmap='inferno',
|
|
475
|
+
origin='upper', interpolation='nearest')
|
|
476
|
+
title = 'FOV image (place cursors on phasor to colourize)'
|
|
477
|
+
|
|
478
|
+
self._ax_img.set_title(title, fontsize=9)
|
|
479
|
+
self._ax_img.set_xlabel('X (px)', fontsize=8)
|
|
480
|
+
self._ax_img.set_ylabel('Y (px)', fontsize=8)
|
|
481
|
+
self._ax_img.tick_params(labelsize=7)
|
|
482
|
+
|
|
483
|
+
def overlay_fret_trajectory(self, traj):
|
|
484
|
+
self._fret_trajectory = traj
|
|
485
|
+
self._redraw_phasor()
|
|
486
|
+
self._redraw_cursors()
|
|
487
|
+
self._canvas.draw_idle()
|
|
488
|
+
|
|
489
|
+
def clear_fret_overlay(self):
|
|
490
|
+
self._fret_trajectory = None
|
|
491
|
+
self._peak_results = None
|
|
492
|
+
self._redraw_phasor()
|
|
493
|
+
self._redraw_cursors()
|
|
494
|
+
self._canvas.draw_idle()
|
|
495
|
+
|
|
496
|
+
def _redraw_fret_overlay(self):
|
|
497
|
+
if self._fret_trajectory is None:
|
|
498
|
+
return
|
|
499
|
+
traj = self._fret_trajectory
|
|
500
|
+
dg = traj['donor_g']
|
|
501
|
+
ds = traj['donor_s']
|
|
502
|
+
self._ax_ph.plot(dg, ds, color='cyan', lw=2, alpha=0.85, zorder=6)
|
|
503
|
+
self._ax_ph.plot(dg[0], ds[0], 'co', ms=7, zorder=7)
|
|
504
|
+
self._ax_ph.plot(dg[-1], ds[-1], 'c^', ms=7, zorder=7)
|
|
505
|
+
if traj.get('acceptor_g') is not None:
|
|
506
|
+
ag = traj['acceptor_g']
|
|
507
|
+
as_ = traj['acceptor_s']
|
|
508
|
+
self._ax_ph.plot(ag, as_, color='magenta', lw=2, alpha=0.85, zorder=6)
|
|
509
|
+
self._ax_ph.plot(ag[0], as_[0], 'mo', ms=7, zorder=7)
|
|
510
|
+
self._ax_ph.plot(ag[-1], as_[-1], 'm^', ms=7, zorder=7)
|
|
511
|
+
|
|
512
|
+
def overlay_peaks(self, peaks):
|
|
513
|
+
self._peak_results = peaks
|
|
514
|
+
self._redraw_phasor()
|
|
515
|
+
self._redraw_cursors()
|
|
516
|
+
self._canvas.draw_idle()
|
|
517
|
+
|
|
518
|
+
def _redraw_peaks_overlay(self):
|
|
519
|
+
if self._peak_results is None:
|
|
520
|
+
return
|
|
521
|
+
peaks = self._peak_results
|
|
522
|
+
for i in range(peaks['n_peaks']):
|
|
523
|
+
g = peaks['peak_g'][i]
|
|
524
|
+
s = peaks['peak_s'][i]
|
|
525
|
+
tau = float(peaks['tau_phase'][i])
|
|
526
|
+
self._ax_ph.plot(g, s, 'y*', ms=14, zorder=15,
|
|
527
|
+
markeredgecolor='k', markeredgewidth=0.5)
|
|
528
|
+
self._ax_ph.text(g + 0.015, s + 0.015, f'{tau:.1f} ns',
|
|
529
|
+
color='yellow', fontsize=8,
|
|
530
|
+
fontweight='bold', zorder=16)
|
|
531
|
+
|
|
532
|
+
def _analyse(self):
|
|
533
|
+
if self._real is None or not self._cursors:
|
|
534
|
+
self._redraw_image(None)
|
|
535
|
+
self._canvas.draw_idle()
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
r = self._radius.get()
|
|
539
|
+
r_min = r * self._ratio.get()
|
|
540
|
+
n = len(self._cursors)
|
|
541
|
+
|
|
542
|
+
mask_list = []
|
|
543
|
+
for cur in self._cursors:
|
|
544
|
+
if cur.get('type', 'ellipse') == 'poly':
|
|
545
|
+
m = self._mask_from_polygon(cur['vertices'])
|
|
546
|
+
else:
|
|
547
|
+
cg = np.array([cur['center_g']])
|
|
548
|
+
cs = np.array([cur['center_s']])
|
|
549
|
+
m = mask_from_elliptic_cursor(
|
|
550
|
+
self._real, self._imag, cg, cs,
|
|
551
|
+
radius=r, radius_minor=r_min, angle='semicircle')
|
|
552
|
+
if m.ndim > self._real.ndim:
|
|
553
|
+
m = m[0]
|
|
554
|
+
mask_list.append(m & self._valid)
|
|
555
|
+
masks = np.stack(mask_list, axis=0)
|
|
556
|
+
|
|
557
|
+
self._redraw_image(masks)
|
|
558
|
+
|
|
559
|
+
print(f"\n{'─' * 50}")
|
|
560
|
+
for ci in range(n):
|
|
561
|
+
m = masks[ci]
|
|
562
|
+
n_px = int(m.sum())
|
|
563
|
+
if n_px == 0:
|
|
564
|
+
print(f" C{ci+1}: no pixels selected")
|
|
565
|
+
continue
|
|
566
|
+
tau_phi, _ = phasor_to_apparent_lifetime(
|
|
567
|
+
self._real[m], self._imag[m], self._freq)
|
|
568
|
+
med = float(np.nanmedian(tau_phi))
|
|
569
|
+
lo = float(np.nanpercentile(tau_phi, 5))
|
|
570
|
+
hi = float(np.nanpercentile(tau_phi, 95))
|
|
571
|
+
print(f" C{ci+1} ({self._cursors[ci]['color']}): "
|
|
572
|
+
f"{n_px} px | τ_φ = {lo:.2f}-{hi:.2f} ns "
|
|
573
|
+
f"(median {med:.2f} ns)")
|
|
574
|
+
|
|
575
|
+
ellipse_curs = [c for c in self._cursors
|
|
576
|
+
if c.get('type', 'ellipse') == 'ellipse']
|
|
577
|
+
if len(ellipse_curs) >= 2:
|
|
578
|
+
g0, s0 = ellipse_curs[0]['center_g'], ellipse_curs[0]['center_s']
|
|
579
|
+
g1, s1 = ellipse_curs[1]['center_g'], ellipse_curs[1]['center_s']
|
|
580
|
+
gi0, si0, gi1, si1 = phasor_semicircle_intersect(g0, s0, g1, s1)
|
|
581
|
+
if not np.isnan(float(gi0)):
|
|
582
|
+
tau1 = _tau_phi_scalar(gi0, si0, self._freq)
|
|
583
|
+
tau2 = _tau_phi_scalar(gi1, si1, self._freq)
|
|
584
|
+
combined = np.any(masks, axis=0)
|
|
585
|
+
frac = phasor_component_fraction(
|
|
586
|
+
self._real[combined],
|
|
587
|
+
self._imag[combined],
|
|
588
|
+
np.array([float(gi0), float(gi1)]),
|
|
589
|
+
np.array([float(si0), float(si1)]),
|
|
590
|
+
)
|
|
591
|
+
print(f"\n ↳ 2-component (C1↔C2 ellipse):")
|
|
592
|
+
print(f" τ₁ = {tau1:.3f} ns "
|
|
593
|
+
f"τ₂ = {tau2:.3f} ns "
|
|
594
|
+
f"mean frac(C1) = {float(np.mean(frac)):.3f}")
|
|
595
|
+
|
|
596
|
+
print(f"{'─' * 50}")
|
|
597
|
+
self._canvas.draw_idle()
|
|
598
|
+
|
|
599
|
+
def _mask_from_polygon(self, vertices):
|
|
600
|
+
verts = np.asarray(vertices, dtype=float)
|
|
601
|
+
pts = np.column_stack([self._real.ravel(), self._imag.ravel()])
|
|
602
|
+
inside = MplPath(verts).contains_points(pts)
|
|
603
|
+
return inside.reshape(self._real.shape)
|
|
604
|
+
|
|
605
|
+
def _on_click(self, event):
|
|
606
|
+
if self._real is None:
|
|
607
|
+
return
|
|
608
|
+
if self._toolbar.mode != '':
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
if event.button == 3 and self._mode_var.get() == 'poly':
|
|
612
|
+
self._on_click_poly(event)
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
if event.button != 1:
|
|
616
|
+
return
|
|
617
|
+
if event.inaxes is not self._ax_ph:
|
|
618
|
+
return
|
|
619
|
+
if event.xdata is None or event.ydata is None:
|
|
620
|
+
return
|
|
621
|
+
|
|
622
|
+
if not self._poly_pts:
|
|
623
|
+
hit = self._hit_test(event.xdata, event.ydata)
|
|
624
|
+
if hit is not None:
|
|
625
|
+
self._drag_idx = hit
|
|
626
|
+
self._drag_last = (event.xdata, event.ydata)
|
|
627
|
+
self._status_var.set(f"Dragging C{hit + 1} - release to drop.")
|
|
628
|
+
return
|
|
629
|
+
|
|
630
|
+
if self._mode_var.get() == 'poly':
|
|
631
|
+
self._on_click_poly(event)
|
|
632
|
+
else:
|
|
633
|
+
self._on_click_ellipse(event)
|
|
634
|
+
|
|
635
|
+
def _on_click_ellipse(self, event):
|
|
636
|
+
if len(self._cursors) >= self.max_cursors:
|
|
637
|
+
self._status_var.set(
|
|
638
|
+
f"Max {self.max_cursors} cursors - clear or undo first.")
|
|
639
|
+
return
|
|
640
|
+
idx = len(self._cursors) % len(_COLORS)
|
|
641
|
+
self._cursors.append(dict(
|
|
642
|
+
type='ellipse',
|
|
643
|
+
center_g=event.xdata,
|
|
644
|
+
center_s=event.ydata,
|
|
645
|
+
color=_COLORS[idx]))
|
|
646
|
+
self._redraw_cursors()
|
|
647
|
+
self._analyse()
|
|
648
|
+
self._notify_change()
|
|
649
|
+
self._status_var.set(
|
|
650
|
+
f"{len(self._cursors)} cursor(s) | "
|
|
651
|
+
f"G={event.xdata:.4f} S={event.ydata:.4f}")
|
|
652
|
+
|
|
653
|
+
def _on_click_poly(self, event):
|
|
654
|
+
if event.button == 3:
|
|
655
|
+
if len(self._poly_pts) >= 3:
|
|
656
|
+
self._commit_polygon()
|
|
657
|
+
elif self._poly_pts:
|
|
658
|
+
self._cancel_polygon()
|
|
659
|
+
return
|
|
660
|
+
if event.button != 1:
|
|
661
|
+
return
|
|
662
|
+
if event.inaxes is not self._ax_ph:
|
|
663
|
+
return
|
|
664
|
+
if event.xdata is None or event.ydata is None:
|
|
665
|
+
return
|
|
666
|
+
if not self._poly_pts and len(self._cursors) >= self.max_cursors:
|
|
667
|
+
self._status_var.set(
|
|
668
|
+
f"Max {self.max_cursors} cursors - clear or undo first.")
|
|
669
|
+
return
|
|
670
|
+
self._poly_pts.append((event.xdata, event.ydata))
|
|
671
|
+
self._update_poly_artist()
|
|
672
|
+
n = len(self._poly_pts)
|
|
673
|
+
self._status_var.set(
|
|
674
|
+
f"Polygon: {n} vert{'ex' if n == 1 else 'ices'} "
|
|
675
|
+
f"- right-click or Enter to close (need ≥ 3)")
|
|
676
|
+
|
|
677
|
+
def _on_motion(self, event):
|
|
678
|
+
|
|
679
|
+
if self._drag_idx is not None:
|
|
680
|
+
if event.inaxes is self._ax_ph and event.xdata is not None:
|
|
681
|
+
self._do_drag(event.xdata, event.ydata)
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
if (event.inaxes is self._ax_ph and event.xdata is not None
|
|
685
|
+
and not self._poly_pts):
|
|
686
|
+
hit = self._hit_test(event.xdata, event.ydata)
|
|
687
|
+
self._canvas.get_tk_widget().configure(
|
|
688
|
+
cursor='fleur' if hit is not None else '')
|
|
689
|
+
|
|
690
|
+
if not self._poly_pts:
|
|
691
|
+
return
|
|
692
|
+
if event.inaxes is not self._ax_ph:
|
|
693
|
+
return
|
|
694
|
+
if event.xdata is None or event.ydata is None:
|
|
695
|
+
return
|
|
696
|
+
self._update_poly_artist(live_pt=(event.xdata, event.ydata))
|
|
697
|
+
self._canvas.draw_idle()
|
|
698
|
+
|
|
699
|
+
def _on_release(self, event):
|
|
700
|
+
if self._drag_idx is None:
|
|
701
|
+
return
|
|
702
|
+
idx = self._drag_idx
|
|
703
|
+
|
|
704
|
+
if event.inaxes is self._ax_ph and event.xdata is not None:
|
|
705
|
+
self._do_drag(event.xdata, event.ydata)
|
|
706
|
+
self._drag_idx = None
|
|
707
|
+
self._canvas.get_tk_widget().configure(cursor='')
|
|
708
|
+
self._redraw_phasor()
|
|
709
|
+
self._redraw_cursors()
|
|
710
|
+
self._analyse()
|
|
711
|
+
self._notify_change()
|
|
712
|
+
self._status_var.set(
|
|
713
|
+
f"C{idx + 1} moved. {len(self._cursors)} cursor(s) active.")
|
|
714
|
+
|
|
715
|
+
def _do_drag(self, g: float, s: float):
|
|
716
|
+
dx = g - self._drag_last[0]
|
|
717
|
+
dy = s - self._drag_last[1]
|
|
718
|
+
cur = self._cursors[self._drag_idx]
|
|
719
|
+
if cur.get('type', 'ellipse') == 'poly':
|
|
720
|
+
cur['vertices'] = [(v[0] + dx, v[1] + dy)
|
|
721
|
+
for v in cur['vertices']]
|
|
722
|
+
else:
|
|
723
|
+
cur['center_g'] += dx
|
|
724
|
+
cur['center_s'] += dy
|
|
725
|
+
self._drag_last = (g, s)
|
|
726
|
+
self._redraw_cursors()
|
|
727
|
+
self._canvas.draw_idle()
|
|
728
|
+
|
|
729
|
+
def _hit_test(self, g, s):
|
|
730
|
+
r = self._radius.get()
|
|
731
|
+
for i, cur in enumerate(self._cursors):
|
|
732
|
+
if cur.get('type', 'ellipse') == 'poly':
|
|
733
|
+
verts = np.asarray(cur['vertices'], dtype=float)
|
|
734
|
+
if MplPath(verts).contains_point((g, s)):
|
|
735
|
+
return i
|
|
736
|
+
else:
|
|
737
|
+
tol = max(r * 1.2, 0.04)
|
|
738
|
+
if np.hypot(g - cur['center_g'], s - cur['center_s']) < tol:
|
|
739
|
+
return i
|
|
740
|
+
return None
|
|
741
|
+
|
|
742
|
+
def _on_key(self, event):
|
|
743
|
+
if event.key == 'escape':
|
|
744
|
+
self._cancel_polygon()
|
|
745
|
+
elif event.key in ('enter', 'return'):
|
|
746
|
+
if len(self._poly_pts) >= 3:
|
|
747
|
+
self._commit_polygon()
|
|
748
|
+
|
|
749
|
+
def _on_mode_change(self):
|
|
750
|
+
if self._mode_var.get() == 'poly':
|
|
751
|
+
self._ellipse_ctrl_frame.pack_forget()
|
|
752
|
+
self._status_var.set(
|
|
753
|
+
'Polygon mode: left-click to add vertices, '
|
|
754
|
+
'right-click or Enter to close (need ≥ 3), Esc to cancel.')
|
|
755
|
+
else:
|
|
756
|
+
self._cancel_polygon()
|
|
757
|
+
self._ellipse_ctrl_frame.pack(side='left')
|
|
758
|
+
self._status_var.set('Ellipse mode: click phasor to place cursor.')
|
|
759
|
+
|
|
760
|
+
def _update_poly_artist(self, live_pt=None):
|
|
761
|
+
if self._poly_line is not None:
|
|
762
|
+
for art in self._poly_line:
|
|
763
|
+
try:
|
|
764
|
+
art.remove()
|
|
765
|
+
except Exception:
|
|
766
|
+
pass
|
|
767
|
+
self._poly_line = None
|
|
768
|
+
pts = list(self._poly_pts)
|
|
769
|
+
if live_pt is not None:
|
|
770
|
+
pts = pts + [live_pt]
|
|
771
|
+
if not pts:
|
|
772
|
+
return
|
|
773
|
+
idx = len(self._cursors) % len(_COLORS)
|
|
774
|
+
col = _COLORS[idx]
|
|
775
|
+
artists = []
|
|
776
|
+
if len(pts) >= 2:
|
|
777
|
+
xs = [p[0] for p in pts] + [pts[0][0]]
|
|
778
|
+
ys = [p[1] for p in pts] + [pts[0][1]]
|
|
779
|
+
(line,) = self._ax_ph.plot(xs, ys, color=col, lw=1.5,
|
|
780
|
+
linestyle='--', alpha=0.85, zorder=9)
|
|
781
|
+
artists.append(line)
|
|
782
|
+
for p in self._poly_pts:
|
|
783
|
+
(dot,) = self._ax_ph.plot(p[0], p[1], 'o', color=col,
|
|
784
|
+
ms=4, zorder=10)
|
|
785
|
+
artists.append(dot)
|
|
786
|
+
if live_pt is not None:
|
|
787
|
+
(tip,) = self._ax_ph.plot(live_pt[0], live_pt[1], '+',
|
|
788
|
+
color=col, ms=8, zorder=10)
|
|
789
|
+
artists.append(tip)
|
|
790
|
+
self._poly_line = artists
|
|
791
|
+
|
|
792
|
+
def _commit_polygon(self):
|
|
793
|
+
if len(self._poly_pts) < 3:
|
|
794
|
+
return
|
|
795
|
+
if self._poly_line is not None:
|
|
796
|
+
for art in self._poly_line:
|
|
797
|
+
try:
|
|
798
|
+
art.remove()
|
|
799
|
+
except Exception:
|
|
800
|
+
pass
|
|
801
|
+
self._poly_line = None
|
|
802
|
+
idx = len(self._cursors) % len(_COLORS)
|
|
803
|
+
self._cursors.append(dict(
|
|
804
|
+
type='poly',
|
|
805
|
+
vertices=list(self._poly_pts),
|
|
806
|
+
color=_COLORS[idx]))
|
|
807
|
+
self._poly_pts.clear()
|
|
808
|
+
self._redraw_cursors()
|
|
809
|
+
self._analyse()
|
|
810
|
+
self._notify_change()
|
|
811
|
+
n = len(self._cursors)
|
|
812
|
+
self._status_var.set(
|
|
813
|
+
f"{n} region(s) | polygon C{n} committed. "
|
|
814
|
+
f"Left-click to start another.")
|
|
815
|
+
|
|
816
|
+
def _cancel_polygon(self):
|
|
817
|
+
if self._poly_line is not None:
|
|
818
|
+
for art in self._poly_line:
|
|
819
|
+
try:
|
|
820
|
+
art.remove()
|
|
821
|
+
except Exception:
|
|
822
|
+
pass
|
|
823
|
+
self._poly_line = None
|
|
824
|
+
self._poly_pts.clear()
|
|
825
|
+
self._canvas.draw_idle()
|
|
826
|
+
self._status_var.set(
|
|
827
|
+
'Polygon cancelled. Left-click to start a new one.')
|
|
828
|
+
|
|
829
|
+
def _build_cursor_union_mask(self):
|
|
830
|
+
"""Return a boolean (Y×X) union mask for all current cursors.
|
|
831
|
+
|
|
832
|
+
Returns None if there are no cursors or no phasor data loaded.
|
|
833
|
+
"""
|
|
834
|
+
if self._real is None or not self._cursors:
|
|
835
|
+
return None
|
|
836
|
+
r = self._radius.get()
|
|
837
|
+
r_min = r * self._ratio.get()
|
|
838
|
+
union = np.zeros_like(self._real, dtype=bool)
|
|
839
|
+
for cur in self._cursors:
|
|
840
|
+
if cur.get('type', 'ellipse') == 'poly':
|
|
841
|
+
m = self._mask_from_polygon(cur['vertices'])
|
|
842
|
+
else:
|
|
843
|
+
cg = np.array([cur['center_g']])
|
|
844
|
+
cs = np.array([cur['center_s']])
|
|
845
|
+
m = mask_from_elliptic_cursor(
|
|
846
|
+
self._real, self._imag, cg, cs,
|
|
847
|
+
radius=r, radius_minor=r_min, angle='semicircle')
|
|
848
|
+
if m.ndim > self._real.ndim:
|
|
849
|
+
m = m[0]
|
|
850
|
+
union |= (m & self._valid)
|
|
851
|
+
return union
|
|
852
|
+
|
|
853
|
+
def _fit_cursor_decay(self):
|
|
854
|
+
from tkinter import messagebox
|
|
855
|
+
from pathlib import Path
|
|
856
|
+
from flimkit.UI.roi_tools import _ask_roi_fit_options
|
|
857
|
+
|
|
858
|
+
if self._real is None:
|
|
859
|
+
messagebox.showwarning('No Phasor Data',
|
|
860
|
+
'Load a PTU file and compute phasors first.')
|
|
861
|
+
return
|
|
862
|
+
if not self._cursors:
|
|
863
|
+
messagebox.showwarning('No Cursors',
|
|
864
|
+
'Place at least one cursor on the phasor plot first.')
|
|
865
|
+
return
|
|
866
|
+
if not self._ptu_path or not Path(self._ptu_path).exists():
|
|
867
|
+
messagebox.showwarning('No PTU',
|
|
868
|
+
'PTU file path not found.\n'
|
|
869
|
+
'Re-load the phasor data and try again.')
|
|
870
|
+
return
|
|
871
|
+
if not callable(getattr(self, 'run_with_progress', None)) or \
|
|
872
|
+
not callable(getattr(self, 'get_fit_params', None)):
|
|
873
|
+
messagebox.showwarning('Not Ready',
|
|
874
|
+
'Fitting callbacks are not wired.\n'
|
|
875
|
+
'Run a whole-FOV fit first to initialise parameters.')
|
|
876
|
+
return
|
|
877
|
+
|
|
878
|
+
params = self.get_fit_params()
|
|
879
|
+
params['ptu_path'] = self._ptu_path
|
|
880
|
+
params['channel'] = self._channel
|
|
881
|
+
|
|
882
|
+
params = _ask_roi_fit_options(params)
|
|
883
|
+
if params is None:
|
|
884
|
+
return
|
|
885
|
+
|
|
886
|
+
ptu_path = self._ptu_path
|
|
887
|
+
channel = self._channel
|
|
888
|
+
irf_cached = params.get('irf_prompt') or params.get('irf')
|
|
889
|
+
n_cursors = len(self._cursors)
|
|
890
|
+
label = (f"Cursor {self._cursors[0]['color']}"
|
|
891
|
+
if n_cursors == 1
|
|
892
|
+
else f"{n_cursors} cursors (union)")
|
|
893
|
+
|
|
894
|
+
union_mask_snapshot = self._build_cursor_union_mask()
|
|
895
|
+
if union_mask_snapshot is None or not union_mask_snapshot.any():
|
|
896
|
+
messagebox.showwarning('Empty Selection',
|
|
897
|
+
'The current cursor(s) select no valid pixels.')
|
|
898
|
+
return
|
|
899
|
+
|
|
900
|
+
def task(progress_callback=None, cancel_event=None):
|
|
901
|
+
import numpy as _np
|
|
902
|
+
from flimkit.formats import FLIMFile
|
|
903
|
+
from flimkit.FLIM.fitters import fit_summed
|
|
904
|
+
|
|
905
|
+
ptu = FLIMFile(ptu_path, verbose=False)
|
|
906
|
+
n_bins = ptu.n_bins
|
|
907
|
+
tcspc_res = ptu.tcspc_res
|
|
908
|
+
|
|
909
|
+
if progress_callback:
|
|
910
|
+
progress_callback(1, 4)
|
|
911
|
+
|
|
912
|
+
native_h, native_w = ptu.n_y, ptu.n_x
|
|
913
|
+
ph_h, ph_w = union_mask_snapshot.shape
|
|
914
|
+
if native_h % ph_h != 0 or native_w % ph_w != 0:
|
|
915
|
+
raise ValueError(
|
|
916
|
+
f"PTU native size {native_h}×{native_w} is not an integer "
|
|
917
|
+
f"multiple of phasor size {ph_h}×{ph_w}.")
|
|
918
|
+
inferred_binning = native_h // ph_h
|
|
919
|
+
stack = ptu.pixel_stack(channel=channel, binning=inferred_binning)
|
|
920
|
+
gated_decay = stack[union_mask_snapshot].sum(axis=0).astype(float)
|
|
921
|
+
if gated_decay.max() == 0:
|
|
922
|
+
raise ValueError('Cursor gate contains no photons.')
|
|
923
|
+
|
|
924
|
+
if progress_callback:
|
|
925
|
+
progress_callback(2, 4)
|
|
926
|
+
|
|
927
|
+
irf_prompt = irf_cached
|
|
928
|
+
if (irf_prompt is None
|
|
929
|
+
or not hasattr(irf_prompt, '__len__')
|
|
930
|
+
or len(irf_prompt) != n_bins):
|
|
931
|
+
from flimkit.FLIM.irf_tools import gaussian_irf
|
|
932
|
+
peak_bin = int(_np.argmax(gated_decay))
|
|
933
|
+
fwhm_bins = max(1.0, 0.2e-9 / tcspc_res)
|
|
934
|
+
irf_prompt = gaussian_irf(n_bins, peak_bin, fwhm_bins)
|
|
935
|
+
irf_source = 'gaussian (no IRF cached)'
|
|
936
|
+
else:
|
|
937
|
+
irf_source = 'from main fit'
|
|
938
|
+
|
|
939
|
+
if progress_callback:
|
|
940
|
+
progress_callback(3, 4)
|
|
941
|
+
|
|
942
|
+
popt, summary = fit_summed(
|
|
943
|
+
decay = gated_decay,
|
|
944
|
+
tcspc_res = tcspc_res,
|
|
945
|
+
n_bins = n_bins,
|
|
946
|
+
irf_prompt = irf_prompt,
|
|
947
|
+
has_tail = False,
|
|
948
|
+
fit_bg = True,
|
|
949
|
+
fit_sigma = False,
|
|
950
|
+
n_exp = params['n_exp'],
|
|
951
|
+
tau_min_ns = params['tau_min'],
|
|
952
|
+
tau_max_ns = params['tau_max'],
|
|
953
|
+
cost_function = params['cost_function'],
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
if progress_callback:
|
|
957
|
+
progress_callback(4, 4)
|
|
958
|
+
|
|
959
|
+
return {
|
|
960
|
+
'region_name': label,
|
|
961
|
+
'region_id': -1,
|
|
962
|
+
'region_ids': [],
|
|
963
|
+
'decay': gated_decay,
|
|
964
|
+
'time_ns': ptu.time_ns,
|
|
965
|
+
'irf_prompt': irf_prompt,
|
|
966
|
+
'irf_source': irf_source,
|
|
967
|
+
'popt': popt,
|
|
968
|
+
'summary': summary,
|
|
969
|
+
'n_exp': params['n_exp'],
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
def on_done(result):
|
|
973
|
+
if result is None:
|
|
974
|
+
return
|
|
975
|
+
self._last_fit_result = result
|
|
976
|
+
from flimkit.UI.roi_tools import _show_roi_fit_result_standalone
|
|
977
|
+
_show_roi_fit_result_standalone(result)
|
|
978
|
+
|
|
979
|
+
self.run_with_progress(
|
|
980
|
+
task_fn = task,
|
|
981
|
+
task_name = f"Fitting cursor-gated decay ({label})...",
|
|
982
|
+
on_done = on_done,
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
def _view_last_fit_result(self):
|
|
986
|
+
from tkinter import messagebox
|
|
987
|
+
if self._last_fit_result is None:
|
|
988
|
+
messagebox.showinfo(
|
|
989
|
+
'No Fit Cached',
|
|
990
|
+
'No fit result cached yet.\n'
|
|
991
|
+
'Run ⚗ Fit Cursor Decay first.')
|
|
992
|
+
return
|
|
993
|
+
from flimkit.UI.roi_tools import _show_roi_fit_result_standalone
|
|
994
|
+
_show_roi_fit_result_standalone(self._last_fit_result)
|
|
995
|
+
|
|
996
|
+
def _on_clear(self):
|
|
997
|
+
if self._real is None:
|
|
998
|
+
return
|
|
999
|
+
self._cursors.clear()
|
|
1000
|
+
self._fret_trajectory = None
|
|
1001
|
+
self._peak_results = None
|
|
1002
|
+
self._poly_pts.clear()
|
|
1003
|
+
if self._poly_line is not None:
|
|
1004
|
+
for art in self._poly_line:
|
|
1005
|
+
try:
|
|
1006
|
+
art.remove()
|
|
1007
|
+
except Exception:
|
|
1008
|
+
pass
|
|
1009
|
+
self._poly_line = None
|
|
1010
|
+
self._redraw_phasor()
|
|
1011
|
+
self._redraw_image(masks=None)
|
|
1012
|
+
self._canvas.draw_idle()
|
|
1013
|
+
self._notify_change()
|
|
1014
|
+
self._status_var.set('Cleared. Click phasor to place cursors.')
|
|
1015
|
+
|
|
1016
|
+
def _on_undo(self):
|
|
1017
|
+
if self._real is None:
|
|
1018
|
+
return
|
|
1019
|
+
if self._poly_pts:
|
|
1020
|
+
self._poly_pts.pop()
|
|
1021
|
+
if self._poly_pts:
|
|
1022
|
+
self._update_poly_artist()
|
|
1023
|
+
else:
|
|
1024
|
+
if self._poly_line is not None:
|
|
1025
|
+
for art in self._poly_line:
|
|
1026
|
+
try:
|
|
1027
|
+
art.remove()
|
|
1028
|
+
except Exception:
|
|
1029
|
+
pass
|
|
1030
|
+
self._poly_line = None
|
|
1031
|
+
self._canvas.draw_idle()
|
|
1032
|
+
n = len(self._poly_pts)
|
|
1033
|
+
self._status_var.set(
|
|
1034
|
+
f"Polygon: {n} vert{'ex' if n == 1 else 'ices'} "
|
|
1035
|
+
f"(vertex removed).")
|
|
1036
|
+
return
|
|
1037
|
+
if not self._cursors:
|
|
1038
|
+
return
|
|
1039
|
+
self._cursors.pop()
|
|
1040
|
+
self._redraw_phasor()
|
|
1041
|
+
self._redraw_cursors()
|
|
1042
|
+
self._analyse()
|
|
1043
|
+
self._notify_change()
|
|
1044
|
+
self._status_var.set(
|
|
1045
|
+
f"{len(self._cursors)} cursor(s) | last cursor removed.")
|
|
1046
|
+
|
|
1047
|
+
def _notify_change(self):
|
|
1048
|
+
if self.on_change is not None:
|
|
1049
|
+
try:
|
|
1050
|
+
self.on_change(self)
|
|
1051
|
+
except Exception as e:
|
|
1052
|
+
print(f"[PhasorViewPanel] on_change callback error: {e}")
|
|
1053
|
+
|
|
1054
|
+
def _on_param_change(self):
|
|
1055
|
+
if self._cursors and self._real is not None:
|
|
1056
|
+
self._redraw_phasor()
|
|
1057
|
+
self._redraw_cursors()
|
|
1058
|
+
self._analyse()
|
|
1059
|
+
self._notify_change()
|
|
1060
|
+
|
|
1061
|
+
def _on_save(self):
|
|
1062
|
+
if self._real is None:
|
|
1063
|
+
messagebox.showwarning('No data', 'No phasor data loaded yet.')
|
|
1064
|
+
return
|
|
1065
|
+
path = filedialog.asksaveasfilename(
|
|
1066
|
+
title='Save phasor session',
|
|
1067
|
+
defaultextension='.npz',
|
|
1068
|
+
initialfile='phasor_session.npz',
|
|
1069
|
+
filetypes=[('NumPy archive', '*.npz'), ('All files', '*')])
|
|
1070
|
+
if not path:
|
|
1071
|
+
return
|
|
1072
|
+
try:
|
|
1073
|
+
from flimkit.phasor_launcher import save_session
|
|
1074
|
+
sd = self.get_session_dict()
|
|
1075
|
+
save_session(
|
|
1076
|
+
path,
|
|
1077
|
+
real_cal=sd['real_cal'],
|
|
1078
|
+
imag_cal=sd['imag_cal'],
|
|
1079
|
+
mean=sd['mean'],
|
|
1080
|
+
frequency=sd['frequency'],
|
|
1081
|
+
cursors=sd['cursors'],
|
|
1082
|
+
params=sd['params'],
|
|
1083
|
+
display_image=sd.get('display_image'),
|
|
1084
|
+
)
|
|
1085
|
+
self._status_var.set(f"Session saved → {Path(path).name}")
|
|
1086
|
+
except Exception as exc:
|
|
1087
|
+
messagebox.showerror('Save failed', str(exc))
|