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,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))