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,1085 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import re
4
+ import json
5
+ import time
6
+ import inspect
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ import tkinter as tk
10
+ from tkinter import ttk, filedialog, messagebox, scrolledtext, simpledialog
11
+ import numpy as np
12
+ import matplotlib
13
+ import matplotlib.image as mpimg
14
+ from matplotlib.figure import Figure
15
+ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
16
+ from flimkit.utils import display
17
+ from flimkit.utils.roi import RoiManager
18
+ from flimkit.UI.roi_tools import RoiAnalysisPanel
19
+
20
+ class FOVPreviewPanel:
21
+ def __init__(self, parent):
22
+ self.frame = ttk.Frame(parent)
23
+ self.frame.columnconfigure(0, weight=1)
24
+ self.frame.rowconfigure(0, weight=1)
25
+ from matplotlib.gridspec import GridSpec
26
+ self._fig = Figure(figsize=(10, 8), dpi=100, facecolor='black')
27
+ self._decay_visible = True
28
+ self._display_mode = 'flim'
29
+ gs = GridSpec(3, 3, figure=self._fig, height_ratios=[1, 0.6, 0.3], width_ratios=[1, 1, 0.05], hspace=0.38, wspace=0.15)
30
+ self._ax_img = self._fig.add_subplot(gs[0, 0])
31
+ self._ax_flim = self._fig.add_subplot(gs[0, 1])
32
+ self._ax_cbar = self._fig.add_subplot(gs[0, 2])
33
+ self._ax_decay = self._fig.add_subplot(gs[1, :])
34
+ self._ax_resid = self._fig.add_subplot(gs[2, :], sharex=self._ax_decay)
35
+ for _ax in (self._ax_img, self._ax_flim):
36
+ _ax.set_facecolor('black')
37
+ self._ax_decay.set_facecolor('white')
38
+ self._ax_decay.tick_params(colors='white')
39
+ self._ax_decay.xaxis.label.set_color('white')
40
+ self._ax_decay.yaxis.label.set_color('white')
41
+ self._ax_decay.title.set_color('white')
42
+ self._ax_resid.set_facecolor('white')
43
+ self._ax_resid.tick_params(colors='white')
44
+ self._ax_resid.xaxis.label.set_color('white')
45
+ self._ax_resid.yaxis.label.set_color('white')
46
+ self._strip_image_axes(self._ax_img)
47
+ self._strip_image_axes(self._ax_flim)
48
+ self._canvas_mpl = FigureCanvasTkAgg(self._fig, master=self.frame)
49
+ self._canvas_mpl.get_tk_widget().grid(row=0, column=0, sticky='nsew')
50
+ self._status = tk.StringVar(value='No FOV loaded')
51
+ ttk.Label(self.frame, textvariable=self._status, foreground='grey', font=('Courier', 8)).grid(
52
+ row=1, column=0, sticky='w', padx=4, pady=(2, 4))
53
+ ctrl_frame = ttk.LabelFrame(self.frame, text='FLIM Color Scale', padding=4)
54
+ ctrl_frame.grid(row=2, column=0, sticky='ew', padx=4, pady=(0, 4))
55
+ ctrl_frame.columnconfigure(1, weight=1)
56
+ ctrl_frame.grid_remove()
57
+ self._ctrl_frame = ctrl_frame
58
+ zbar = ttk.Frame(self.frame)
59
+ zbar.grid(row=3, column=0, sticky='ew', padx=4, pady=(0, 4))
60
+ zbar.columnconfigure(1, weight=1)
61
+ self._z_label = tk.StringVar(value='z 1/1')
62
+ ttk.Label(zbar, textvariable=self._z_label, width=12).grid(row=0, column=0, sticky='w')
63
+ self._z_slider_updating = False
64
+ self._z_slider = ttk.Scale(zbar, from_=0, to=0, orient='horizontal',
65
+ command=self._on_z_slider)
66
+ self._z_slider.grid(row=0, column=1, sticky='ew', padx=4)
67
+ self._zbar = zbar
68
+ zbar.grid_remove()
69
+ self._zstack = None
70
+ self._series = None
71
+ self._z_i = 0
72
+ ttk.Label(ctrl_frame, text='τ range (ns):').grid(row=0, column=0, sticky='w')
73
+ ttk.Label(ctrl_frame, text='Min:').grid(row=0, column=1, sticky='w', padx=(10, 2))
74
+ self._sv_tau_min = tk.StringVar()
75
+ ttk.Entry(ctrl_frame, textvariable=self._sv_tau_min, width=6).grid(row=0, column=2, sticky='w', padx=2)
76
+ ttk.Label(ctrl_frame, text='Max:').grid(row=0, column=3, sticky='w', padx=(10, 2))
77
+ self._sv_tau_max = tk.StringVar()
78
+ ttk.Entry(ctrl_frame, textvariable=self._sv_tau_max, width=6).grid(row=0, column=4, sticky='w', padx=2)
79
+ ttk.Button(ctrl_frame, text='Auto', width=6, command=self._auto_detect_scale).grid(row=0, column=5, sticky='w', padx=2)
80
+ ttk.Label(ctrl_frame, text='Γ:').grid(row=1, column=0, sticky='w')
81
+ self._sv_gamma = tk.StringVar(value='1.0')
82
+ ttk.Entry(ctrl_frame, textvariable=self._sv_gamma, width=6).grid(row=1, column=2, sticky='w', padx=2)
83
+ ttk.Label(ctrl_frame, text='Colormap:').grid(row=1, column=3, sticky='w', padx=(10, 2))
84
+ self._sv_cmap = tk.StringVar(value='viridis')
85
+ self._cmap_combo = ttk.Combobox(ctrl_frame, textvariable=self._sv_cmap,
86
+ state='readonly', width=10)
87
+ self._cmap_combo.grid(row=1, column=4, sticky='w', padx=2)
88
+ self._cmap_combo['values'] = list(display.COLORMAPS.keys())
89
+ ttk.Button(ctrl_frame, text='Update', width=8, command=self._update_flim_display).grid(row=1, column=5, sticky='w', padx=2)
90
+ self._bv_show_decay = tk.BooleanVar(value=True)
91
+ ttk.Checkbutton(ctrl_frame, text='Show Decay Plot',
92
+ variable=self._bv_show_decay,
93
+ command=self._toggle_decay).grid(
94
+ row=2, column=0, columnspan=3, sticky='w', pady=(4, 0))
95
+ self._sv_display_mode = tk.StringVar(value='flim')
96
+ dm_frame = ttk.Frame(ctrl_frame)
97
+ dm_frame.grid(row=2, column=3, columnspan=3, sticky='w', pady=(4, 0))
98
+ ttk.Label(dm_frame, text='View:').pack(side='left', padx=(0, 4))
99
+ ttk.Radiobutton(dm_frame, text='FLIM', variable=self._sv_display_mode,
100
+ value='flim', command=self._on_display_mode_changed).pack(side='left')
101
+ ttk.Radiobutton(dm_frame, text='Intensity', variable=self._sv_display_mode,
102
+ value='intensity', command=self._on_display_mode_changed).pack(side='left')
103
+ self._sv_tau_weighting = tk.StringVar(value='amplitude')
104
+ wt_frame = ttk.Frame(ctrl_frame)
105
+ wt_frame.grid(row=3, column=0, columnspan=6, sticky='w', pady=(4, 0))
106
+ ttk.Label(wt_frame, text='τ weighting:').pack(side='left', padx=(0, 4))
107
+ ttk.Radiobutton(wt_frame, text='Amplitude', variable=self._sv_tau_weighting,
108
+ value='amplitude', command=self._on_weighting_changed).pack(side='left')
109
+ ttk.Radiobutton(wt_frame, text='Intensity', variable=self._sv_tau_weighting,
110
+ value='intensity', command=self._on_weighting_changed).pack(side='left')
111
+ self._ptu_path = None
112
+ self._lifetime_map = None
113
+ self._pixel_maps = None
114
+ self._intensity_map = None
115
+ self._flim_cbar = None
116
+ self._flim_color_scale = {
117
+ 'vmin': None,
118
+ 'vmax': None,
119
+ 'gamma': 1.0,
120
+ 'cmap': 'viridis',
121
+ }
122
+ self._n_exp = 1
123
+ self._irf_prompt = None
124
+ self._roi_manager = RoiManager()
125
+ self._roi_patches = {}
126
+ self._drawing_mode = tk.StringVar(value='select')
127
+ self._is_drawing = False
128
+ self._draw_coords = []
129
+ self._temp_line = None
130
+ self._mouse_press_event = None
131
+ self._roi_analysis_panel = None
132
+ self._roi_drag = None
133
+ self._setup_drawing_events()
134
+ self._cached_decay_lines = []
135
+ self._cached_decay_title = ''
136
+ self._cached_decay_yscale = 'log'
137
+ self._cached_resid_data = None
138
+ self._setup_zoom()
139
+ def load_fov(self, ptu_path: Optional[str], _keep_zstack=False):
140
+ if not _keep_zstack:
141
+ self._hide_zstack()
142
+ self._lifetime_map = None
143
+ self._pixel_maps = None
144
+ self._cached_resid_data = None
145
+ if not ptu_path or not Path(ptu_path).exists():
146
+ self._clear()
147
+ self._status.set('Invalid PTU file')
148
+ return
149
+ try:
150
+ self._ptu_path = ptu_path
151
+ from flimkit.formats import FLIMFile
152
+ import numpy as np
153
+ ptu = FLIMFile(ptu_path, verbose=False)
154
+ is_image = getattr(ptu, 'is_image', True)
155
+ decay = ptu.summed_decay(channel=None)
156
+ time_ns = ptu.time_ns
157
+ self._ax_img.clear()
158
+ if is_image:
159
+ stack = ptu.pixel_stack(channel=None, binning=1)
160
+ intensity = stack.sum(axis=2)
161
+ intensity_clipped = np.clip(intensity, 0, np.percentile(intensity, 99))
162
+ self._ax_img.imshow(intensity_clipped, cmap='inferno', origin='upper')
163
+ else:
164
+ intensity = None
165
+ self._ax_img.text(0.5, 0.5, 'Point measurement\nno image to reconstruct',
166
+ ha='center', va='center', transform=self._ax_img.transAxes,
167
+ fontsize=9, color='white')
168
+ self._ax_img.set_title('Intensity', fontsize=9, fontweight='bold', color='white')
169
+ self._strip_image_axes(self._ax_img)
170
+ self._ax_flim.clear()
171
+ self._ax_flim.text(0.5, 0.5,
172
+ 'Waiting for fit...' if is_image else 'No FLIM image\n(point measurement)',
173
+ ha='center', va='center',
174
+ transform=self._ax_flim.transAxes, fontsize=9, color='white')
175
+ self._ax_flim.set_title('FLIM Lifetime', fontsize=10, fontweight='bold', color='white')
176
+ self._ax_decay.clear()
177
+ self._ax_decay.set_facecolor('white')
178
+ self._ax_decay.semilogy(time_ns, decay, color='steelblue', linewidth=1.5)
179
+ self._ax_decay.set_title('Summed Decay', fontsize=10, fontweight='bold', color='white')
180
+ self._ax_decay.set_xlabel('Time (ns)', color='white')
181
+ self._ax_decay.set_ylabel('Photon Count', color='white')
182
+ self._ax_decay.grid(True, alpha=0.3)
183
+ self._ax_decay.tick_params(labelsize=8, colors='white')
184
+ self._ax_resid.clear()
185
+ self._ax_resid.set_facecolor('white')
186
+ self._ax_resid.tick_params(labelsize=7, colors='white')
187
+ self._ax_resid.grid(True, alpha=0.3)
188
+ self._cached_resid_data = None
189
+ self._redraw_region_overlays()
190
+ self._canvas_mpl.draw_idle()
191
+ n_photons = int(decay.sum())
192
+ if is_image:
193
+ img_shape = intensity.shape
194
+ self._status.set(f"✓ {Path(ptu_path).name} | {img_shape[0]}×{img_shape[1]}px | {n_photons} photons")
195
+ else:
196
+ self._status.set(f"✓ {Path(ptu_path).name} | point measurement, no image | {n_photons} photons")
197
+ except Exception as e:
198
+ self._clear()
199
+ self._status.set(f"Error loading FOV: {str(e)[:50]}")
200
+ def display_fit_results(self, ptu_path: str, fit_result: dict, _keep_zstack=False):
201
+ if not _keep_zstack:
202
+ self._hide_zstack()
203
+ try:
204
+ from flimkit.formats import FLIMFile
205
+ import numpy as np
206
+ global_summary = fit_result.get('global_summary', {})
207
+ global_popt = fit_result.get('global_popt')
208
+ irf_prompt = fit_result.get('irf_prompt')
209
+ if irf_prompt is not None:
210
+ self._irf_prompt = irf_prompt
211
+ time_ns_from_result = fit_result.get('time_ns')
212
+ decay_from_result = fit_result.get('decay')
213
+ canvas = fit_result.get('canvas')
214
+ if decay_from_result is not None and time_ns_from_result is not None:
215
+ decay = decay_from_result
216
+ time_ns = time_ns_from_result
217
+ else:
218
+ if ptu_path and Path(ptu_path).exists():
219
+ ptu = FLIMFile(ptu_path, verbose=False)
220
+ decay = ptu.summed_decay(channel=None)
221
+ time_ns = ptu.time_ns
222
+ else:
223
+ decay = None
224
+ time_ns = None
225
+ intensity = None
226
+ if canvas is not None and 'intensity' in canvas:
227
+ intensity = canvas['intensity']
228
+ elif 'intensity' in fit_result:
229
+ intensity = fit_result['intensity']
230
+ elif ptu_path and Path(ptu_path).exists():
231
+ ptu = FLIMFile(ptu_path, verbose=False)
232
+ if getattr(ptu, 'is_image', True):
233
+ stack = ptu.pixel_stack(channel=None, binning=1)
234
+ intensity = stack.sum(axis=2)
235
+ if intensity is None:
236
+ intensity = np.ones((512, 512), dtype=np.float32)
237
+ from flimkit.utils.display import compute_weighted_lifetime
238
+ pixel_maps = fit_result.get('pixel_maps')
239
+ if pixel_maps is None and canvas is not None:
240
+ pixel_maps = {k: v for k, v in canvas.items()
241
+ if k not in ('intensity', 'coverage')}
242
+ nexp = global_summary.get('n_exp', len(global_summary.get('taus_ns', [])))
243
+ if nexp == 0:
244
+ nexp = sum(1 for k in range(1, 4) if f'tau{k}_mean_ns' in global_summary)
245
+ lifetime_map = None
246
+ if pixel_maps and nexp > 0:
247
+ try:
248
+ lifetime_map = compute_weighted_lifetime(
249
+ pixel_maps, intensity, n_exp=nexp,
250
+ weighting=self._sv_tau_weighting.get(),
251
+ )
252
+ except Exception as e:
253
+ print(f" - Warning: Could not compute lifetime map: {e}")
254
+ lifetime_map = None
255
+ if (lifetime_map is not None and intensity is not None
256
+ and lifetime_map.shape != intensity.shape[:2]):
257
+ try:
258
+ import cv2 as _cv2
259
+ th, tw = intensity.shape[:2]
260
+ lifetime_map = _cv2.resize(
261
+ lifetime_map.astype(np.float32), (tw, th),
262
+ interpolation=_cv2.INTER_NEAREST)
263
+ except Exception as _upe:
264
+ print(f" - Could not upsample lifetime_map: {_upe}")
265
+ self._lifetime_map = lifetime_map
266
+ self._pixel_maps = pixel_maps
267
+ self._intensity_map = intensity
268
+ self._n_exp = nexp
269
+ if 'intensity' not in fit_result and intensity is not None:
270
+ fit_result['intensity'] = intensity
271
+ if lifetime_map is not None:
272
+ fit_result['lifetime'] = lifetime_map
273
+ if pixel_maps:
274
+ fit_result['pixel_maps'] = pixel_maps
275
+ taus_fit = global_summary.get('taus_ns', [])
276
+ model = global_summary.get('model')
277
+ self._ax_img.clear()
278
+ intensity_clipped = np.clip(intensity, 0, np.percentile(intensity, 99))
279
+ self._ax_img.imshow(intensity_clipped, cmap='inferno', origin='upper')
280
+ self._ax_img.set_title('Intensity', fontsize=9, fontweight='bold', color='white')
281
+ self._strip_image_axes(self._ax_img)
282
+ self._ax_flim.clear()
283
+ if self._lifetime_map is not None and np.any(~np.isnan(self._lifetime_map)):
284
+ scaled = display.apply_color_scale(
285
+ self._lifetime_map,
286
+ vmin=self._flim_color_scale['vmin'],
287
+ vmax=self._flim_color_scale['vmax'],
288
+ gamma=self._flim_color_scale['gamma'],
289
+ )
290
+ cmap = display.get_colormap(self._flim_color_scale['cmap'])
291
+ cmap.set_bad(color='black')
292
+ im = self._ax_flim.imshow(scaled, cmap=cmap, origin='upper', vmin=0, vmax=1)
293
+ self._ax_flim.set_title('FLIM Lifetime (ns)', fontsize=9, fontweight='bold', color='white')
294
+ self._strip_image_axes(self._ax_flim)
295
+ valid_data = self._lifetime_map[~np.isnan(self._lifetime_map)]
296
+ if valid_data.size > 0:
297
+ data_min = np.min(valid_data)
298
+ data_max = np.max(valid_data)
299
+ self._ax_cbar.clear()
300
+ cbar = self._fig.colorbar(im, cax=self._ax_cbar)
301
+ cbar.set_label(f"τ (ns)", fontsize=8, color='white')
302
+ self._flim_cbar = cbar
303
+ n_ticks = 5
304
+ tick_positions = np.linspace(0, 1, n_ticks)
305
+ tick_values = data_min + tick_positions * (data_max - data_min)
306
+ cbar.set_ticks(tick_positions)
307
+ cbar.set_ticklabels([f"{v:.2f}" for v in tick_values], fontsize=7, color='white')
308
+ cbar.ax.tick_params(colors='white')
309
+ else:
310
+ self._ax_cbar.clear()
311
+ else:
312
+ self._ax_flim.text(0.5, 0.6, 'No FLIM data', ha='center', va='center',
313
+ transform=self._ax_flim.transAxes, fontsize=9, color='white')
314
+ self._ax_flim.text(0.5, 0.35, '(enable per-pixel fitting)', ha='center', va='center',
315
+ transform=self._ax_flim.transAxes, fontsize=8, color='white', style='italic')
316
+ self._ax_flim.set_title('FLIM Lifetime', fontsize=10, fontweight='bold', color='white')
317
+ self._redraw_region_overlays()
318
+ self._ax_decay.clear()
319
+ self._ax_decay.set_facecolor('white')
320
+ if decay is None or len(decay) == 0:
321
+ self._ax_decay.text(0.5, 0.5, 'No decay data', ha='center', va='center',
322
+ transform=self._ax_decay.transAxes)
323
+ else:
324
+ self._ax_decay.semilogy(time_ns, decay, 'o-', color='steelblue',
325
+ linewidth=1.5, markersize=3, label='Measured', alpha=0.7)
326
+ if irf_prompt is not None and len(irf_prompt) > 0:
327
+ irf_max = irf_prompt.max()
328
+ if irf_max > 0:
329
+ irf_scaled = (irf_prompt / irf_max) * decay.max() * 0.2
330
+ irf_time = time_ns[:len(irf_prompt)]
331
+ self._ax_decay.semilogy(irf_time, np.maximum(irf_scaled, 1e-2),
332
+ linewidth=2.0, color='orange', label='IRF', alpha=0.8)
333
+ model = global_summary.get('model')
334
+ if model is not None and len(model) > 0:
335
+ self._ax_decay.semilogy(time_ns, model, linewidth=2.0,
336
+ color='red', label='Fitted', alpha=0.8)
337
+ self._ax_decay.set_title(f"Summed Decay{f' ({nexp}-exp fit)' if nexp > 0 else ''}",
338
+ fontsize=10, fontweight='bold', color='white')
339
+ self._ax_decay.set_xlabel('Time (ns)', color='white')
340
+ self._ax_decay.set_ylabel('Photon Count', color='white')
341
+ if decay is not None and len(decay) > 0:
342
+ self._ax_decay.legend(fontsize=8, loc='upper right', labelcolor='black')
343
+ self._ax_decay.grid(True, alpha=0.3)
344
+ self._ax_decay.tick_params(labelsize=8, colors='white')
345
+ self._ax_resid.clear()
346
+ self._ax_resid.set_facecolor('white')
347
+ model_arr = global_summary.get('model')
348
+ if (decay is not None and len(decay) > 0
349
+ and model_arr is not None
350
+ and len(model_arr) == len(decay)):
351
+ with np.errstate(invalid='ignore', divide='ignore'):
352
+ resid = np.where(model_arr > 0,
353
+ (decay - model_arr) / np.sqrt(model_arr),
354
+ 0.0)
355
+ self._cached_resid_data = (time_ns.copy(), resid)
356
+ self._ax_resid.plot(time_ns, resid, color='steelblue', linewidth=1.0)
357
+ self._ax_resid.axhline(0, color='red', linewidth=1.0,
358
+ linestyle='--', alpha=0.8)
359
+ self._ax_resid.set_ylabel('Resid. (σ)', fontsize=7, color='white')
360
+ chi2_r = global_summary.get('reduced_chi2_tail')
361
+ if chi2_r is not None:
362
+ self._ax_resid.annotate(
363
+ f"χ²_r = {chi2_r:.3f}",
364
+ xy=(0.98, 0.85), xycoords='axes fraction',
365
+ ha='right', va='top', fontsize=7,
366
+ color='white',
367
+ bbox=dict(boxstyle='round,pad=0.2', fc='#333333', alpha=0.7),
368
+ )
369
+ else:
370
+ self._cached_resid_data = None
371
+ self._ax_resid.set_xlabel('Time (ns)', color='white')
372
+ self._ax_resid.tick_params(labelsize=7, colors='white')
373
+ self._ax_resid.grid(True, alpha=0.3)
374
+ self._ctrl_frame.grid()
375
+ self._canvas_mpl.draw_idle()
376
+ status = f"✓ Fit complete"
377
+ chi2_tail = global_summary.get('reduced_chi2_tail')
378
+ if chi2_tail is not None:
379
+ status += f" | χ²_r(tail)={chi2_tail:.3f}"
380
+ if nexp > 0:
381
+ taus = [global_summary.get(f'taus_ns', [])[i] if i < len(global_summary.get('taus_ns', [])) else None
382
+ for i in range(nexp)]
383
+ taus_str = ', '.join([f"{t:.3f}" for t in taus if t is not None])
384
+ status += f" | τ=[{taus_str}] ns"
385
+ self._status.set(status)
386
+ print(f" - Status: {status}")
387
+ except Exception as e:
388
+ import traceback
389
+ print(f"[FOV Preview] Error displaying fit results:")
390
+ traceback.print_exc()
391
+ self._status.set(f"Error: {str(e)[:60]}")
392
+ self._status.set(f"Error displaying fit: {str(e)[:50]}")
393
+ def display_zstack(self, slices, ptu_path=None):
394
+ self._zstack = list(slices) if slices else None
395
+ if not self._zstack:
396
+ self._hide_zstack()
397
+ return
398
+ n = len(self._zstack)
399
+ self._z_slider.configure(to=max(n - 1, 0))
400
+ self._zbar.grid()
401
+ self._z_i = 0
402
+ self._sync_z_slider()
403
+ self._show_zstack_slice(0)
404
+
405
+ def _hide_zstack(self):
406
+ self._zstack = None
407
+ self._series = None
408
+ try:
409
+ self._zbar.grid_remove()
410
+ except Exception:
411
+ pass
412
+
413
+ def display_series(self, planes, output_dir):
414
+ self._zstack = None
415
+ self._series = [dict(p, _root=str(output_dir)) for p in planes] if planes else None
416
+ if not self._series:
417
+ self._hide_zstack()
418
+ return
419
+ self._z_slider.configure(to=max(len(self._series) - 1, 0))
420
+ self._zbar.grid()
421
+ self._z_i = 0
422
+ self._sync_z_slider()
423
+ self._show_series_plane(0)
424
+
425
+ def _series_plane_label(self, desc, i):
426
+ parts = []
427
+ if desc.get('t') is not None:
428
+ parts.append(f"t{desc['t']}")
429
+ if desc.get('z') is not None:
430
+ parts.append(f"z{desc['z']}")
431
+ head = ' '.join(parts) or desc.get('name', '')
432
+ return f"{head} ({i + 1}/{len(self._series)})"
433
+
434
+ def _show_series_plane(self, i):
435
+ desc = self._series[i]
436
+ self._z_label.set(self._series_plane_label(desc, i))
437
+ plane_dir = Path(desc['_root']) / desc.get('dir', desc.get('name', ''))
438
+ keep = self._series
439
+ self.load_stitched_roi(str(plane_dir))
440
+ self._series = keep
441
+ self._zbar.grid()
442
+
443
+ def _sync_z_slider(self):
444
+ self._z_slider_updating = True
445
+ try:
446
+ self._z_slider.set(self._z_i)
447
+ finally:
448
+ self._z_slider_updating = False
449
+
450
+ def _on_z_slider(self, value):
451
+ if self._z_slider_updating:
452
+ return
453
+ stack = self._series or self._zstack
454
+ if not stack:
455
+ return
456
+ i = max(0, min(int(round(float(value))), len(stack) - 1))
457
+ if i == self._z_i:
458
+ return
459
+ self._z_i = i
460
+ if self._series:
461
+ self._show_series_plane(i)
462
+ else:
463
+ self._show_zstack_slice(i)
464
+
465
+ def _show_zstack_slice(self, i):
466
+ desc = self._zstack[i]
467
+ self._z_label.set(f"z {desc.get('z', i + 1)} ({i + 1}/{len(self._zstack)})")
468
+ fit_result = desc.get('fit_result')
469
+ if fit_result is not None:
470
+ self.display_fit_results(desc.get('ptu_path'), fit_result, _keep_zstack=True)
471
+ else:
472
+ self.load_fov(desc.get('ptu_path'), _keep_zstack=True)
473
+ if self._display_mode != 'flim' or not self._decay_visible:
474
+ self._rebuild_layout()
475
+
476
+ def load_stitched_roi(self, output_dir: str):
477
+ if not output_dir:
478
+ self._clear()
479
+ self._status.set('No output directory')
480
+ return
481
+ try:
482
+ from pathlib import Path
483
+ import numpy as np
484
+ import tifffile
485
+ out_path = Path(output_dir)
486
+ intensity_files = sorted(out_path.glob('*_stitched_intensity.tif'))
487
+ if not intensity_files:
488
+ intensity_files = sorted(out_path.glob('*_intensity.tif'))
489
+ if not intensity_files:
490
+ self._clear()
491
+ self._status.set('No stitched image found')
492
+ return
493
+ intensity = tifffile.imread(str(intensity_files[0]))
494
+ self._ax_img.clear()
495
+ intensity_clipped = np.clip(intensity, 0, np.percentile(intensity, 99))
496
+ self._ax_img.imshow(intensity_clipped, cmap='inferno', origin='upper')
497
+ self._ax_img.set_title('Stitched ROI', fontsize=9, fontweight='bold', color='white')
498
+ self._strip_image_axes(self._ax_img)
499
+ lifetime_data = None
500
+ lifetime_min, lifetime_max = None, None
501
+ lifetime_full = sorted(out_path.glob('*_tau_intensity_weighted_fullrange.tif'))
502
+ if lifetime_full:
503
+ try:
504
+ lifetime_data = tifffile.imread(str(lifetime_full[0])).astype(np.float32)
505
+ valid = np.isfinite(lifetime_data)
506
+ if valid.any():
507
+ lifetime_min = float(np.nanmin(lifetime_data[valid]))
508
+ lifetime_max = float(np.nanpercentile(lifetime_data[valid], 98))
509
+ print(f" ✓ Loaded full-range lifetime: {lifetime_min:.2f}-{lifetime_max:.2f} ns")
510
+ except Exception as e:
511
+ print(f" - Could not load full-range lifetime: {e}")
512
+ if lifetime_data is None:
513
+ lifetime_disp = sorted(out_path.glob('*_tau_intensity_weighted.tif'))
514
+ if lifetime_disp:
515
+ try:
516
+ lifetime_data = tifffile.imread(str(lifetime_disp[0])).astype(np.float32)
517
+ lifetime_data = lifetime_data / 65535.0 * 5.0
518
+ lifetime_min, lifetime_max = 0.0, 5.0
519
+ print(f" ✓ Loaded display-scaled lifetime: 0-5 ns")
520
+ except Exception as e:
521
+ print(f" - Could not load display-scaled lifetime: {e}")
522
+ if lifetime_data is not None:
523
+ self._ax_flim.clear()
524
+ if lifetime_min is None or lifetime_max is None or lifetime_max <= lifetime_min:
525
+ lifetime_min = 0.0
526
+ lifetime_max = 5.0
527
+ if lifetime_max <= lifetime_min:
528
+ lifetime_max = lifetime_min + 0.1
529
+ lifetime_norm = np.clip((lifetime_data - lifetime_min) / (lifetime_max - lifetime_min), 0, 1)
530
+ im = self._ax_flim.imshow(lifetime_norm, cmap='viridis', origin='upper', vmin=0, vmax=1)
531
+ self._ax_flim.set_title(f"FLIM Lifetime ({lifetime_min:.2f}-{lifetime_max:.2f} ns)",
532
+ fontsize=9, fontweight='bold', color='white')
533
+ self._strip_image_axes(self._ax_flim)
534
+ self._ax_cbar.clear()
535
+ cbar = self._fig.colorbar(im, cax=self._ax_cbar, label='τ (ns)')
536
+ _min, _max = lifetime_min, lifetime_max
537
+ def _fmt_ns(x, pos):
538
+ return f"{_min + x * (_max - _min):.1f}"
539
+ from matplotlib.ticker import FuncFormatter
540
+ cbar.ax.yaxis.set_major_formatter(FuncFormatter(_fmt_ns))
541
+ cbar.ax.tick_params(labelsize=7)
542
+ else:
543
+ self._ax_flim.clear()
544
+ self._ax_flim.text(0.5, 0.5, 'Lifetime map not available', ha='center', va='center',
545
+ transform=self._ax_flim.transAxes, fontsize=9, color='white')
546
+ self._ax_flim.set_title('FLIM Lifetime', fontsize=10, fontweight='bold', color='white')
547
+ self._ax_decay.clear()
548
+ self._ax_decay.set_facecolor('white')
549
+ self._ax_decay.text(0.5, 0.5, 'Per-tile fit complete ✓',
550
+ ha='center', va='center', transform=self._ax_decay.transAxes,
551
+ fontsize=10, color='forestgreen', fontweight='bold')
552
+ self._canvas_mpl.draw_idle()
553
+ img_shape = intensity.shape
554
+ self._status.set(f"✓ Tile fit | {img_shape[0]}×{img_shape[1]}px")
555
+ except Exception as e:
556
+ import traceback
557
+ traceback.print_exc()
558
+ self._clear()
559
+ self._status.set(f"Error loading stitched: {str(e)[:50]}")
560
+ def _clear(self):
561
+ self._ax_img.clear()
562
+ self._ax_flim.clear()
563
+ self._ax_decay.clear()
564
+ self._ax_decay.set_facecolor('white')
565
+ self._ax_cbar.clear()
566
+ self._flim_cbar = None
567
+ self._ax_img.set_title('No FOV loaded', color='white')
568
+ self._ax_flim.set_title('FLIM Lifetime', color='white')
569
+ self._ax_decay.text(0.5, 0.5, 'Load a PTU file →',
570
+ ha='center', va='center', transform=self._ax_decay.transAxes,
571
+ fontsize=10, color='#888')
572
+ self._ctrl_frame.grid_remove()
573
+ self._canvas_mpl.draw_idle()
574
+ def _on_weighting_changed(self):
575
+ import numpy as np
576
+ if self._pixel_maps is None or self._intensity_map is None:
577
+ return
578
+ from flimkit.utils.display import compute_weighted_lifetime
579
+ try:
580
+ lifetime_map = compute_weighted_lifetime(
581
+ self._pixel_maps, self._intensity_map, n_exp=self._n_exp,
582
+ weighting=self._sv_tau_weighting.get(),
583
+ )
584
+ except Exception as e:
585
+ print(f" - Could not recompute lifetime map: {e}")
586
+ return
587
+ if (lifetime_map is not None
588
+ and lifetime_map.shape != self._intensity_map.shape[:2]):
589
+ try:
590
+ import cv2 as _cv2
591
+ th, tw = self._intensity_map.shape[:2]
592
+ lifetime_map = _cv2.resize(lifetime_map.astype(np.float32), (tw, th),
593
+ interpolation=_cv2.INTER_NEAREST)
594
+ except Exception:
595
+ pass
596
+ self._lifetime_map = lifetime_map
597
+ self._update_flim_display()
598
+ def _auto_detect_scale(self):
599
+ import numpy as np
600
+ if self._lifetime_map is None:
601
+ return
602
+ valid_data = self._lifetime_map[~np.isnan(self._lifetime_map)]
603
+ if valid_data.size > 0:
604
+ vmin = np.percentile(valid_data, 2)
605
+ vmax = np.percentile(valid_data, 98)
606
+ self._sv_tau_min.set(f"{vmin:.2f}")
607
+ self._sv_tau_max.set(f"{vmax:.2f}")
608
+ self._update_flim_display()
609
+ def _update_flim_display(self):
610
+ import numpy as np
611
+ if self._lifetime_map is None or not np.any(~np.isnan(self._lifetime_map)):
612
+ return
613
+ try:
614
+ try:
615
+ vmin = float(self._sv_tau_min.get()) if self._sv_tau_min.get() else None
616
+ except ValueError:
617
+ vmin = None
618
+ try:
619
+ vmax = float(self._sv_tau_max.get()) if self._sv_tau_max.get() else None
620
+ except ValueError:
621
+ vmax = None
622
+ try:
623
+ gamma = float(self._sv_gamma.get())
624
+ if gamma <= 0:
625
+ gamma = 1.0
626
+ except ValueError:
627
+ gamma = 1.0
628
+ cmap_name = self._sv_cmap.get()
629
+ self._flim_color_scale['vmin'] = vmin
630
+ self._flim_color_scale['vmax'] = vmax
631
+ self._flim_color_scale['gamma'] = gamma
632
+ self._flim_color_scale['cmap'] = cmap_name
633
+ self._save_color_scale_update()
634
+ scaled = display.apply_color_scale(
635
+ self._lifetime_map, vmin=vmin, vmax=vmax, gamma=gamma
636
+ )
637
+ self._ax_flim.clear()
638
+ self._ax_cbar.clear()
639
+ self._flim_cbar = None
640
+ cmap = display.get_colormap(cmap_name)
641
+ cmap.set_bad(color='black')
642
+ im = self._ax_flim.imshow(scaled, cmap=cmap, origin='upper', vmin=0, vmax=1)
643
+ self._ax_flim.set_title('FLIM Lifetime (ns)', fontsize=9, fontweight='bold', color='white')
644
+ self._strip_image_axes(self._ax_flim)
645
+ valid_data = self._lifetime_map[~np.isnan(self._lifetime_map)]
646
+ if valid_data.size > 0:
647
+ data_min = vmin if vmin is not None else np.min(valid_data)
648
+ data_max = vmax if vmax is not None else np.max(valid_data)
649
+ self._ax_cbar.clear()
650
+ cbar = self._fig.colorbar(im, cax=self._ax_cbar)
651
+ cbar.set_label('τ (ns)', fontsize=8, color='white')
652
+ self._flim_cbar = cbar
653
+ n_ticks = 5
654
+ tick_positions = np.linspace(0, 1, n_ticks)
655
+ tick_values = data_min + tick_positions * (data_max - data_min)
656
+ cbar.set_ticks(tick_positions)
657
+ cbar.set_ticklabels([f"{v:.2f}" for v in tick_values], fontsize=7, color='white')
658
+ cbar.ax.tick_params(colors='white')
659
+ else:
660
+ self._ax_cbar.clear()
661
+ self._redraw_region_overlays()
662
+ self._canvas_mpl.draw_idle()
663
+ except Exception as e:
664
+ print(f"Error updating FLIM display: {e}")
665
+ def _save_color_scale_update(self):
666
+ try:
667
+ if not self._ptu_path:
668
+ return
669
+ from pathlib import Path
670
+ import json
671
+ import numpy as np
672
+ ptu_path = Path(self._ptu_path)
673
+ session_file = ptu_path.parent / f"{ptu_path.stem}.roi_session.npz"
674
+ if not session_file.exists():
675
+ return
676
+ existing_data = np.load(session_file, allow_pickle=True)
677
+ session_data = {key: existing_data[key].item() if existing_data[key].ndim == 0 else existing_data[key]
678
+ for key in existing_data.files}
679
+ session_data['fov_color_scale'] = json.dumps(self._flim_color_scale)
680
+ np.savez_compressed(session_file, **session_data)
681
+ print(f"[Color Scale] ✓ Saved to {session_file.name}")
682
+ except Exception as e:
683
+ print(f"[Color Scale] Could not save update: {e}")
684
+ def _save_regions_update(self):
685
+ try:
686
+ if not self._ptu_path:
687
+ return
688
+ from pathlib import Path
689
+ import json
690
+ import numpy as np
691
+ from datetime import datetime
692
+ ptu_path = Path(self._ptu_path)
693
+ session_file = ptu_path.parent / f"{ptu_path.stem}.roi_session.npz"
694
+ if session_file.exists():
695
+ existing_data = np.load(session_file, allow_pickle=True)
696
+ session_data = {key: existing_data[key].item() if existing_data[key].ndim == 0 else existing_data[key]
697
+ for key in existing_data.files}
698
+ else:
699
+ session_data = {
700
+ 'timestamp': datetime.now().isoformat(),
701
+ 'source': str(self._ptu_path),
702
+ 'form_state_json': json.dumps({}, default=str),
703
+ }
704
+ if self._lifetime_map is not None:
705
+ session_data['fov_lifetime_map'] = self._lifetime_map
706
+ if self._intensity_map is not None:
707
+ session_data['fov_intensity_map'] = self._intensity_map
708
+ session_data['fov_color_scale'] = json.dumps(self._flim_color_scale)
709
+ session_data['fov_n_exp'] = self._n_exp
710
+ if self._ptu_path:
711
+ session_data['fov_ptu_path'] = self._ptu_path
712
+ session_data['fov_regions'] = self._roi_manager.to_json()
713
+ np.savez_compressed(session_file, **session_data)
714
+ print(f"[ROI Manager] ✓ Saved {len(self._roi_manager.regions)} region(s) to {session_file.name}")
715
+ except Exception as e:
716
+ print(f"[ROI Manager] Could not save regions: {e}")
717
+ def _load_regions_from_json(self, json_str: str):
718
+ try:
719
+ self._roi_manager = RoiManager.from_json(json_str)
720
+ print(f"[ROI Manager] ✓ Loaded {len(self._roi_manager.regions)} region(s)")
721
+ self._redraw_region_overlays()
722
+ except Exception as e:
723
+ print(f"[ROI Manager] Could not load regions: {e}")
724
+ def _redraw_region_overlays(self):
725
+ import matplotlib.patches as mpatches
726
+ from flimkit.utils.roi import get_rectangle_patch, get_ellipse_patch, get_polygon_patch
727
+ target_axes = [ax for ax in (self._ax_flim, self._ax_img) if ax.get_visible()]
728
+ for patches in self._roi_patches.values():
729
+ for patch in (patches if isinstance(patches, list) else [patches]):
730
+ try:
731
+ patch.remove()
732
+ except (ValueError, NotImplementedError):
733
+ pass
734
+ self._roi_patches = {}
735
+ for region in self._roi_manager.get_all_regions():
736
+ region_id = region['id']
737
+ tool_type = region['tool']
738
+ coords = region['coords']
739
+ color = self._roi_manager.get_color(region_id)
740
+ linewidth = 2.5 if region_id == self._roi_manager.get_selected_id() else 1.5
741
+ patches_for_region = []
742
+ for ax in target_axes:
743
+ try:
744
+ if tool_type == 'rect':
745
+ patch = get_rectangle_patch(coords, edgecolor=color, linewidth=linewidth)
746
+ elif tool_type == 'ellipse':
747
+ patch = get_ellipse_patch(coords, edgecolor=color, linewidth=linewidth)
748
+ elif tool_type in ('polygon', 'freehand'):
749
+ patch = get_polygon_patch(coords, edgecolor=color, linewidth=linewidth)
750
+ else:
751
+ continue
752
+ ax.add_patch(patch)
753
+ patches_for_region.append(patch)
754
+ except Exception as e:
755
+ print(f"[ROI] Could not draw region {region_id}: {e}")
756
+ if patches_for_region:
757
+ self._roi_patches[region_id] = patches_for_region
758
+ self._canvas_mpl.draw_idle()
759
+ @staticmethod
760
+ def _strip_image_axes(ax):
761
+ ax.set_xlabel('')
762
+ ax.set_ylabel('')
763
+ ax.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
764
+ def _setup_zoom(self):
765
+ self._zoom_cid = self._canvas_mpl.mpl_connect('scroll_event', self._on_scroll_zoom)
766
+ self._pan_press_cid = self._canvas_mpl.mpl_connect('button_press_event', self._on_pan_press)
767
+ self._pan_release_cid = self._canvas_mpl.mpl_connect('button_release_event', self._on_pan_release)
768
+ self._pan_motion_cid = self._canvas_mpl.mpl_connect('motion_notify_event', self._on_pan_motion)
769
+ self._pan_origin = None
770
+ def _on_scroll_zoom(self, event):
771
+ ax = event.inaxes
772
+ if ax is None or ax not in (self._ax_img, self._ax_flim):
773
+ return
774
+ if event.xdata is None or event.ydata is None:
775
+ return
776
+ base_scale = 1.3
777
+ if event.button == 'up':
778
+ scale_factor = 1 / base_scale
779
+ elif event.button == 'down':
780
+ scale_factor = base_scale
781
+ else:
782
+ return
783
+ xlim = ax.get_xlim()
784
+ ylim = ax.get_ylim()
785
+ x_range = (xlim[1] - xlim[0]) * scale_factor
786
+ y_range = (ylim[1] - ylim[0]) * scale_factor
787
+ ax.set_xlim([event.xdata - x_range * (event.xdata - xlim[0]) / (xlim[1] - xlim[0]),
788
+ event.xdata + x_range * (xlim[1] - event.xdata) / (xlim[1] - xlim[0])])
789
+ ax.set_ylim([event.ydata - y_range * (event.ydata - ylim[0]) / (ylim[1] - ylim[0]),
790
+ event.ydata + y_range * (ylim[1] - event.ydata) / (ylim[1] - ylim[0])])
791
+ self._canvas_mpl.draw_idle()
792
+ def _on_pan_press(self, event):
793
+ if event.button == 1 and self._drawing_mode.get() != 'select':
794
+ return
795
+ ax = event.inaxes
796
+ if ax is None or ax not in self._active_image_axes():
797
+ return
798
+ if event.xdata is None:
799
+ return
800
+ if event.button == 3:
801
+ selected_id = self._roi_manager.get_selected_id()
802
+ if selected_id is None:
803
+ selected_id = self._hit_test_roi(event.xdata, event.ydata, ax)
804
+ if selected_id is not None:
805
+ self._start_roi_drag(selected_id, event.xdata, event.ydata)
806
+ return
807
+ self._pan_origin = (event.xdata, event.ydata, ax)
808
+ def _on_pan_release(self, event):
809
+ if self._roi_drag is not None:
810
+ self._finish_roi_drag()
811
+ self._pan_origin = None
812
+ def _on_pan_motion(self, event):
813
+ if self._roi_drag is not None:
814
+ if event.xdata is not None and event.ydata is not None:
815
+ self._update_roi_drag(event.xdata, event.ydata)
816
+ return
817
+ if self._pan_origin is None:
818
+ return
819
+ ox, oy, ax = self._pan_origin
820
+ if event.inaxes != ax or event.xdata is None:
821
+ return
822
+ dx = ox - event.xdata
823
+ dy = oy - event.ydata
824
+ xlim = ax.get_xlim()
825
+ ylim = ax.get_ylim()
826
+ ax.set_xlim(xlim[0] + dx, xlim[1] + dx)
827
+ ax.set_ylim(ylim[0] + dy, ylim[1] + dy)
828
+ self._canvas_mpl.draw_idle()
829
+ def _hit_test_roi(self, x, y, ax):
830
+ for region_id, patches in self._roi_patches.items():
831
+ for patch in (patches if isinstance(patches, list) else [patches]):
832
+ if patch.axes is ax and patch.contains_point(ax.transData.transform((x, y))):
833
+ return region_id
834
+ return None
835
+ def _start_roi_drag(self, region_id, x, y):
836
+ self._roi_drag = {'id': region_id, 'ox': x, 'oy': y}
837
+ self._roi_manager.select_region(region_id)
838
+ self._redraw_region_overlays()
839
+ if self._roi_analysis_panel:
840
+ self._roi_analysis_panel._refresh_region_list()
841
+ def _update_roi_drag(self, x, y):
842
+ drag = self._roi_drag
843
+ dx = x - drag['ox']
844
+ dy = y - drag['oy']
845
+ region = self._roi_manager.get_region(drag['id'])
846
+ if region is None:
847
+ self._roi_drag = None
848
+ return
849
+ new_coords = [[c[0] + dx, c[1] + dy] for c in region['coords']]
850
+ self._roi_manager.update_region(drag['id'], coords=new_coords)
851
+ drag['ox'] = x
852
+ drag['oy'] = y
853
+ self._redraw_region_overlays()
854
+ def _finish_roi_drag(self):
855
+ self._roi_drag = None
856
+ self._save_regions_update()
857
+ if self._roi_analysis_panel:
858
+ self._roi_analysis_panel._refresh_region_list()
859
+ def _on_display_mode_changed(self):
860
+ new_mode = self._sv_display_mode.get()
861
+ if new_mode == self._display_mode:
862
+ return
863
+ self._display_mode = new_mode
864
+ self._rebuild_layout()
865
+ def _toggle_decay(self):
866
+ show = self._bv_show_decay.get()
867
+ if show == self._decay_visible:
868
+ return
869
+ self._decay_visible = show
870
+ self._rebuild_layout()
871
+ def _rebuild_layout(self):
872
+ from matplotlib.gridspec import GridSpec
873
+ flim_title = self._ax_flim.get_title() if self._ax_flim.get_visible() else 'FLIM Lifetime (ns)'
874
+ img_title = self._ax_img.get_title() if self._ax_img.get_visible() else 'Intensity'
875
+ current_lines = []
876
+ for line in self._ax_decay.get_lines():
877
+ current_lines.append({
878
+ 'x': line.get_xdata().copy(),
879
+ 'y': line.get_ydata().copy(),
880
+ 'color': line.get_color(),
881
+ 'lw': line.get_linewidth(),
882
+ 'label': line.get_label(),
883
+ 'alpha': line.get_alpha(),
884
+ 'marker': line.get_marker(),
885
+ 'ms': line.get_markersize(),
886
+ })
887
+ if current_lines:
888
+ self._cached_decay_lines = current_lines
889
+ self._cached_decay_title = self._ax_decay.get_title()
890
+ self._cached_decay_yscale = self._ax_decay.get_yscale()
891
+ decay_lines = self._cached_decay_lines
892
+ decay_title = self._cached_decay_title
893
+ decay_yscale = self._cached_decay_yscale
894
+ for ax in (self._ax_img, self._ax_flim, self._ax_cbar, self._ax_decay, self._ax_resid):
895
+ ax.remove()
896
+ if self._decay_visible:
897
+ gs = GridSpec(3, 3, figure=self._fig,
898
+ height_ratios=[1, 0.6, 0.3],
899
+ width_ratios=[1, 1, 0.05],
900
+ hspace=0.38, wspace=0.15)
901
+ self._ax_img = self._fig.add_subplot(gs[0, 0])
902
+ self._ax_flim = self._fig.add_subplot(gs[0, 1])
903
+ self._ax_cbar = self._fig.add_subplot(gs[0, 2])
904
+ self._ax_decay = self._fig.add_subplot(gs[1, :])
905
+ self._ax_resid = self._fig.add_subplot(gs[2, :], sharex=self._ax_decay)
906
+ else:
907
+ if self._display_mode == 'intensity':
908
+ gs = GridSpec(1, 1, figure=self._fig)
909
+ self._ax_img = self._fig.add_subplot(gs[0, 0])
910
+ self._ax_flim = self._fig.add_axes([0, 0, 0.01, 0.01])
911
+ self._ax_flim.set_visible(False)
912
+ self._ax_cbar = self._fig.add_axes([0, 0, 0.01, 0.01])
913
+ self._ax_cbar.set_visible(False)
914
+ else:
915
+ gs = GridSpec(1, 2, figure=self._fig,
916
+ width_ratios=[1, 0.05],
917
+ wspace=0.08)
918
+ self._ax_flim = self._fig.add_subplot(gs[0, 0])
919
+ self._ax_cbar = self._fig.add_subplot(gs[0, 1])
920
+ self._ax_img = self._fig.add_axes([0, 0, 0.01, 0.01])
921
+ self._ax_img.set_visible(False)
922
+ self._ax_decay = self._fig.add_axes([0, 0, 0.01, 0.01])
923
+ self._ax_decay.set_visible(False)
924
+ self._ax_resid = self._fig.add_axes([0, 0, 0.01, 0.01])
925
+ self._ax_resid.set_visible(False)
926
+ for _ax in (self._ax_img, self._ax_flim):
927
+ _ax.set_facecolor('black')
928
+ if self._ax_img.get_visible() and self._intensity_map is not None:
929
+ import numpy as np
930
+ intensity_clipped = np.clip(self._intensity_map, 0,
931
+ np.percentile(self._intensity_map, 99))
932
+ self._ax_img.imshow(intensity_clipped, cmap='inferno', origin='upper')
933
+ self._ax_img.set_title(img_title, fontsize=9, fontweight='bold', color='white')
934
+ self._strip_image_axes(self._ax_img)
935
+ elif self._ax_img.get_visible():
936
+ self._ax_img.set_title(img_title, fontsize=9, fontweight='bold', color='white')
937
+ self._strip_image_axes(self._ax_img)
938
+ if self._ax_flim.get_visible():
939
+ if self._lifetime_map is not None:
940
+ import numpy as np
941
+ scaled = display.apply_color_scale(
942
+ self._lifetime_map,
943
+ vmin=self._flim_color_scale['vmin'],
944
+ vmax=self._flim_color_scale['vmax'],
945
+ gamma=self._flim_color_scale['gamma'],
946
+ )
947
+ cmap = display.get_colormap(self._flim_color_scale['cmap'])
948
+ cmap.set_bad(color='black')
949
+ im = self._ax_flim.imshow(scaled, cmap=cmap, origin='upper',
950
+ vmin=0, vmax=1)
951
+ if self._ax_cbar.get_visible():
952
+ self._ax_cbar.clear()
953
+ self._flim_cbar = None
954
+ valid = self._lifetime_map[~np.isnan(self._lifetime_map)]
955
+ if valid.size > 0:
956
+ cs = self._flim_color_scale
957
+ d_min = cs['vmin'] if cs['vmin'] is not None else float(np.min(valid))
958
+ d_max = cs['vmax'] if cs['vmax'] is not None else float(np.max(valid))
959
+ cbar = self._fig.colorbar(im, cax=self._ax_cbar)
960
+ cbar.set_label('τ (ns)', fontsize=8, color='white')
961
+ self._flim_cbar = cbar
962
+ n_ticks = 5
963
+ tp = np.linspace(0, 1, n_ticks)
964
+ tv = d_min + tp * (d_max - d_min)
965
+ cbar.set_ticks(tp)
966
+ cbar.set_ticklabels([f"{v:.2f}" for v in tv], fontsize=7, color='white')
967
+ cbar.ax.tick_params(colors='white')
968
+ self._ax_flim.set_title(flim_title, fontsize=9, fontweight='bold', color='white')
969
+ self._strip_image_axes(self._ax_flim)
970
+ if self._decay_visible and decay_lines:
971
+ for ld in decay_lines:
972
+ self._ax_decay.plot(
973
+ ld['x'], ld['y'],
974
+ color=ld['color'], linewidth=ld['lw'],
975
+ label=ld['label'], alpha=ld['alpha'],
976
+ marker=ld['marker'], markersize=ld['ms'],
977
+ )
978
+ self._ax_decay.set_yscale(decay_yscale)
979
+ self._ax_decay.set_title(decay_title, fontsize=10, fontweight='bold', color='white')
980
+ self._ax_decay.set_xlabel('Time (ns)', color='white')
981
+ self._ax_decay.set_ylabel('Photon Count', color='white')
982
+ self._ax_decay.set_facecolor('white')
983
+ self._ax_decay.tick_params(labelsize=8, colors='white')
984
+ self._ax_decay.grid(True, alpha=0.3)
985
+ if self._decay_visible and self._cached_resid_data is not None:
986
+ t_r, res_r = self._cached_resid_data
987
+ self._ax_resid.set_facecolor('white')
988
+ self._ax_resid.plot(t_r, res_r, color='steelblue', linewidth=1.0)
989
+ self._ax_resid.axhline(0, color='red', linewidth=1.0,
990
+ linestyle='--', alpha=0.8)
991
+ self._ax_resid.set_ylabel('Resid. (σ)', fontsize=7, color='white')
992
+ self._ax_resid.set_xlabel('Time (ns)', color='white')
993
+ self._ax_resid.tick_params(labelsize=7, colors='white')
994
+ self._ax_resid.grid(True, alpha=0.3)
995
+ self._redraw_region_overlays()
996
+ self._setup_drawing_events()
997
+ self._canvas_mpl.draw_idle()
998
+ def _setup_drawing_events(self):
999
+ for cid in getattr(self, '_draw_cids', []):
1000
+ self._canvas_mpl.mpl_disconnect(cid)
1001
+ self._draw_cids = [
1002
+ self._canvas_mpl.mpl_connect('button_press_event', self._on_draw_press),
1003
+ self._canvas_mpl.mpl_connect('motion_notify_event', self._on_draw_motion),
1004
+ self._canvas_mpl.mpl_connect('button_release_event', self._on_draw_release),
1005
+ ]
1006
+ def _active_image_axes(self):
1007
+ return {ax for ax in (self._ax_img, self._ax_flim) if ax.get_visible()}
1008
+ def _on_draw_press(self, event):
1009
+ if event.button != 1:
1010
+ return
1011
+ if not event.inaxes or event.inaxes not in self._active_image_axes():
1012
+ return
1013
+ mode = self._drawing_mode.get()
1014
+ if mode == 'select':
1015
+ return
1016
+ self._is_drawing = True
1017
+ self._draw_coords = [[event.xdata, event.ydata]]
1018
+ self._mouse_press_event = event
1019
+ print(f"[Drawing] Started {mode} at ({event.xdata:.1f}, {event.ydata:.1f})")
1020
+ def _on_draw_motion(self, event):
1021
+ if not self._is_drawing or not event.inaxes or event.inaxes not in self._active_image_axes():
1022
+ return
1023
+ mode = self._drawing_mode.get()
1024
+ if mode in ('rect', 'ellipse') and len(self._draw_coords) > 0:
1025
+ if self._temp_line is not None:
1026
+ try:
1027
+ self._temp_line.remove()
1028
+ except Exception:
1029
+ pass
1030
+ self._temp_line = None
1031
+ x0, y0 = self._draw_coords[0]
1032
+ x1, y1 = event.xdata, event.ydata
1033
+ from matplotlib.patches import Rectangle
1034
+ preview = Rectangle((min(x0, x1), min(y0, y1)),
1035
+ abs(x1 - x0), abs(y1 - y0),
1036
+ edgecolor='cyan', facecolor='none',
1037
+ linewidth=1, linestyle='', alpha=0.5)
1038
+ event.inaxes.add_patch(preview)
1039
+ self._temp_line = preview
1040
+ self._canvas_mpl.draw_idle()
1041
+ elif mode in ('polygon', 'freehand'):
1042
+ self._draw_coords.append([event.xdata, event.ydata])
1043
+ def _on_draw_release(self, event):
1044
+ if not self._is_drawing or not event.inaxes or event.inaxes not in self._active_image_axes():
1045
+ return
1046
+ mode = self._drawing_mode.get()
1047
+ if mode in ('rect', 'ellipse'):
1048
+ if len(self._draw_coords) > 0:
1049
+ self._draw_coords.append([event.xdata, event.ydata])
1050
+ self._finalize_drawing(mode)
1051
+ elif mode == 'polygon':
1052
+ if len(self._draw_coords) >= 3 and event.button == 3:
1053
+ self._finalize_drawing(mode)
1054
+ elif mode == 'freehand':
1055
+ if len(self._draw_coords) >= 3:
1056
+ self._finalize_drawing(mode)
1057
+ if self._temp_line is not None:
1058
+ try:
1059
+ self._temp_line.remove()
1060
+ except Exception:
1061
+ pass
1062
+ self._temp_line = None
1063
+ self._is_drawing = False
1064
+ def _finalize_drawing(self, tool_type: str):
1065
+ if len(self._draw_coords) < 2:
1066
+ print(f"[Drawing] Cancelled {tool_type} (insufficient points)")
1067
+ self._draw_coords = []
1068
+ return
1069
+ try:
1070
+ region_id = self._roi_manager.add_region(
1071
+ f"{tool_type}-{len(self._roi_manager.regions) + 1}",
1072
+ tool_type,
1073
+ self._draw_coords
1074
+ )
1075
+ self._redraw_region_overlays()
1076
+ self._save_regions_update()
1077
+ print(f"[Drawing] Added {tool_type} region {region_id}")
1078
+ if self._roi_analysis_panel:
1079
+ self._roi_analysis_panel._refresh_region_list()
1080
+ except Exception as e:
1081
+ print(f"[Drawing] Error finalizing: {e}")
1082
+ finally:
1083
+ self._draw_coords = []
1084
+ def grid(self, **kw):
1085
+ self.frame.grid(**kw)