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,248 @@
1
+ from __future__ import annotations
2
+
3
+ import tkinter as tk
4
+ from tkinter import ttk, messagebox
5
+
6
+ from flimkit.UI.utils import _C
7
+ from flimkit.UI.fit_help import help_button
8
+ from typing import Optional
9
+
10
+ _EXPERT_DEFAULTS = {
11
+ 'binning_factor': 1,
12
+ 'optimizer': 'de',
13
+ 'lm_restarts': 8,
14
+ 'de_population': 30,
15
+ 'de_maxiter': 5000,
16
+ 'n_workers': -1,
17
+ 'cost_function': 'poisson',
18
+ 'channels': '',
19
+ 'min_photons': 10,
20
+ 'irf_fwhm': None,
21
+ 'irf_align': 'steepest_rise',
22
+ 'irf_shift_bins': 2,
23
+ 'free_tau_perpixel': False,
24
+ 'align_irf': False,
25
+ 'fit_start_ns': None,
26
+ 'fit_end_ns': None,
27
+ 'exclude_ns': '',
28
+ 'fit_t0': False,
29
+ }
30
+
31
+
32
+ class ExpertSettingsDialog(tk.Toplevel):
33
+
34
+ def __init__(self, parent, current: dict):
35
+ super().__init__(parent)
36
+ self.title('Expert Fit Settings')
37
+ self.resizable(False, False)
38
+ self.transient(parent)
39
+ self.grab_set()
40
+
41
+ self.result: Optional[dict] = None
42
+ cfg = _C()
43
+
44
+ vals = dict(_EXPERT_DEFAULTS)
45
+ vals.update({
46
+ 'binning_factor': cfg['binning_factor'],
47
+ 'optimizer': cfg['Optimizer'],
48
+ 'lm_restarts': cfg['lm_restarts'],
49
+ 'de_population': cfg['de_population'],
50
+ 'de_maxiter': cfg['de_maxiter'],
51
+ 'n_workers': cfg['n_workers'],
52
+ 'min_photons': cfg['MIN_PHOTONS_PERPIX'],
53
+ })
54
+ vals.update(current)
55
+
56
+ PAD = {'padx': 4, 'pady': 3}
57
+ row = 0
58
+ f = ttk.Frame(self, padding=12)
59
+ f.pack(fill='both', expand=True)
60
+
61
+ opt_label = ttk.Frame(f)
62
+ opt_label.grid(row=row, column=0, sticky='w', **PAD)
63
+ ttk.Label(opt_label, text='Optimizer:').pack(side='left')
64
+ help_button(opt_label, 'optimizer').pack(side='left', padx=(4, 0))
65
+ self._sv_optimizer = tk.StringVar(value=vals['optimizer'])
66
+ opt_frame = ttk.Frame(f)
67
+ opt_frame.grid(row=row, column=1, columnspan=3, sticky='w', **PAD)
68
+ ttk.Radiobutton(opt_frame, text='Differential Evolution (DE)',
69
+ variable=self._sv_optimizer, value='de').pack(side='left', padx=(0, 8))
70
+ ttk.Radiobutton(opt_frame, text='Levenberg-Marquardt (LM)',
71
+ variable=self._sv_optimizer, value='lm_multistart').pack(side='left')
72
+
73
+ row += 1
74
+ ttk.Label(f, text='DE population:').grid(row=row, column=0, sticky='w', **PAD)
75
+ self._sv_de_pop = tk.StringVar(value=str(vals['de_population']))
76
+ ttk.Entry(f, textvariable=self._sv_de_pop, width=8).grid(row=row, column=1, sticky='w', **PAD)
77
+ ttk.Label(f, text='DE max iterations:').grid(row=row, column=2, sticky='w', **PAD)
78
+ self._sv_de_maxiter = tk.StringVar(value=str(vals['de_maxiter']))
79
+ ttk.Entry(f, textvariable=self._sv_de_maxiter, width=8).grid(row=row, column=3, sticky='w', **PAD)
80
+
81
+ row += 1
82
+ ttk.Label(f, text='LM random restarts:').grid(row=row, column=0, sticky='w', **PAD)
83
+ self._sv_lm_restarts = tk.StringVar(value=str(vals['lm_restarts']))
84
+ ttk.Entry(f, textvariable=self._sv_lm_restarts, width=8).grid(row=row, column=1, sticky='w', **PAD)
85
+
86
+ row += 1
87
+ ttk.Label(f, text='Spatial binning (NxN):').grid(row=row, column=0, sticky='w', **PAD)
88
+ self._sv_binning = tk.StringVar(value=str(vals['binning_factor']))
89
+ ttk.Entry(f, textvariable=self._sv_binning, width=8).grid(row=row, column=1, sticky='w', **PAD)
90
+ ttk.Label(f, text='(1 = no binning)', foreground='grey').grid(row=row, column=2, columnspan=2, sticky='w', **PAD)
91
+
92
+ row += 1
93
+ ttk.Label(f, text='CPU workers:').grid(row=row, column=0, sticky='w', **PAD)
94
+ self._sv_workers = tk.StringVar(value=str(vals['n_workers']))
95
+ ttk.Entry(f, textvariable=self._sv_workers, width=8).grid(row=row, column=1, sticky='w', **PAD)
96
+ ttk.Label(f, text='(-1 = all cores)', foreground='grey').grid(row=row, column=2, columnspan=2, sticky='w', **PAD)
97
+
98
+ row += 1
99
+ ttk.Label(f, text='Min photons/pixel:').grid(row=row, column=0, sticky='w', **PAD)
100
+ self._sv_min_ph = tk.StringVar(value=str(vals['min_photons']))
101
+ ttk.Entry(f, textvariable=self._sv_min_ph, width=8).grid(row=row, column=1, sticky='w', **PAD)
102
+
103
+ row += 1
104
+ ttk.Label(f, text='Cost function:').grid(row=row, column=0, sticky='w', **PAD)
105
+ self._sv_cost = tk.StringVar(value=vals['cost_function'])
106
+ cf_frame = ttk.Frame(f)
107
+ cf_frame.grid(row=row, column=1, columnspan=3, sticky='w', **PAD)
108
+ ttk.Radiobutton(cf_frame, text='Poisson deviance',
109
+ variable=self._sv_cost, value='poisson').pack(side='left', padx=(0, 8))
110
+ ttk.Radiobutton(cf_frame, text='Chi² (legacy)',
111
+ variable=self._sv_cost, value='chi2').pack(side='left')
112
+
113
+ row += 1
114
+ ttk.Label(f, text='Channel filter:').grid(row=row, column=0, sticky='w', **PAD)
115
+ self._sv_channels = tk.StringVar(value=str(vals.get('channels', '') or ''))
116
+ ttk.Entry(f, textvariable=self._sv_channels, width=12).grid(row=row, column=1, sticky='w', **PAD)
117
+ ttk.Label(f, text='(blank = all channels)', foreground='grey').grid(row=row, column=2, columnspan=2, sticky='w', **PAD)
118
+
119
+ row += 1
120
+ ttk.Label(f, text='IRF FWHM (ns):').grid(row=row, column=0, sticky='w', **PAD)
121
+ _irf_fwhm_val = vals.get('irf_fwhm')
122
+ self._sv_irf_fwhm = tk.StringVar(value='' if _irf_fwhm_val is None else str(_irf_fwhm_val))
123
+ ttk.Entry(f, textvariable=self._sv_irf_fwhm, width=12).grid(row=row, column=1, sticky='w', **PAD)
124
+ ttk.Label(f, text='(blank = 1 bin auto, e.g. 0.097)', foreground='grey').grid(row=row, column=2, columnspan=2, sticky='w', **PAD)
125
+
126
+ row += 1
127
+ ttk.Label(f, text='IRF alignment:').grid(row=row, column=0, sticky='w', **PAD)
128
+ self._sv_irf_align = tk.StringVar(value=vals.get('irf_align', 'steepest_rise'))
129
+ align_frame = ttk.Frame(f)
130
+ align_frame.grid(row=row, column=1, columnspan=3, sticky='w', **PAD)
131
+ ttk.Radiobutton(align_frame, text='Steepest rise (recommended)',
132
+ variable=self._sv_irf_align, value='steepest_rise').pack(side='left', padx=(0, 8))
133
+ ttk.Radiobutton(align_frame, text='Decay peak (legacy)',
134
+ variable=self._sv_irf_align, value='decay_peak').pack(side='left')
135
+
136
+ row += 1
137
+ ttk.Label(f, text='IRF shift bound (±bins):').grid(row=row, column=0, sticky='w', **PAD)
138
+ self._sv_irf_shift = tk.StringVar(value=str(vals.get('irf_shift_bins', 2)))
139
+ ttk.Entry(f, textvariable=self._sv_irf_shift, width=8).grid(row=row, column=1, sticky='w', **PAD)
140
+ ttk.Label(f, text='(2 = recommended; 5 = legacy)', foreground='grey').grid(row=row, column=2, columnspan=2, sticky='w', **PAD)
141
+
142
+ row += 1
143
+ self._bv_align_irf = tk.BooleanVar(value=bool(vals.get('align_irf', False)))
144
+ ttk.Checkbutton(f, text='Align measured IRF peak to the decay rising edge '
145
+ '(for a scatter PTU or .pck from a separate acquisition)',
146
+ variable=self._bv_align_irf).grid(
147
+ row=row, column=0, columnspan=4, sticky='w', **PAD)
148
+
149
+ row += 1
150
+ _start_val = vals.get('fit_start_ns')
151
+ _end_val = vals.get('fit_end_ns')
152
+ ttk.Label(f, text='Fit window start (ns):').grid(row=row, column=0, sticky='w', **PAD)
153
+ self._sv_fit_start = tk.StringVar(value='' if _start_val is None else str(_start_val))
154
+ ttk.Entry(f, textvariable=self._sv_fit_start, width=8).grid(row=row, column=1, sticky='w', **PAD)
155
+ ttk.Label(f, text='Fit window end (ns):').grid(row=row, column=2, sticky='w', **PAD)
156
+ self._sv_fit_end = tk.StringVar(value='' if _end_val is None else str(_end_val))
157
+ ttk.Entry(f, textvariable=self._sv_fit_end, width=8).grid(row=row, column=3, sticky='w', **PAD)
158
+
159
+ row += 1
160
+ ttk.Label(f, text='Exclude bands (ns):').grid(row=row, column=0, sticky='w', **PAD)
161
+ self._sv_exclude = tk.StringVar(value=str(vals.get('exclude_ns', '') or ''))
162
+ ttk.Entry(f, textvariable=self._sv_exclude, width=20).grid(row=row, column=1, sticky='w', **PAD)
163
+ ttk.Label(f, text='(blank = none, e.g. 7.2-8.8 or 7.2-8.8,11.0-11.5)',
164
+ foreground='grey').grid(row=row, column=2, columnspan=2, sticky='w', **PAD)
165
+
166
+ row += 1
167
+ self._bv_free_tau = tk.BooleanVar(value=bool(vals.get('free_tau_perpixel', False)))
168
+ ttk.Checkbutton(f, text='Free τ per pixel (slower - reveals τ spatial variation for n_exp > 1)',
169
+ variable=self._bv_free_tau).grid(
170
+ row=row, column=0, columnspan=4, sticky='w', **PAD)
171
+
172
+ row += 1
173
+ self._bv_fit_t0 = tk.BooleanVar(value=bool(vals.get('fit_t0', False)))
174
+ ttk.Checkbutton(f, text='Free t0 (tail fit only - correlated with the amplitudes, leave off unless they matter)',
175
+ variable=self._bv_fit_t0).grid(
176
+ row=row, column=0, columnspan=4, sticky='w', **PAD)
177
+
178
+ row += 1
179
+ btn_frame = ttk.Frame(f)
180
+ btn_frame.grid(row=row, column=0, columnspan=4, pady=(12, 0))
181
+ ttk.Button(btn_frame, text='Confirm', command=self._confirm).pack(side='left', padx=4)
182
+ ttk.Button(btn_frame, text='Reset Defaults', command=self._reset).pack(side='left', padx=4)
183
+ ttk.Button(btn_frame, text='Cancel', command=self.destroy).pack(side='left', padx=4)
184
+
185
+ self.protocol('WM_DELETE_WINDOW', self.destroy)
186
+ self.update_idletasks()
187
+ pw, ph = parent.winfo_width(), parent.winfo_height()
188
+ px, py = parent.winfo_rootx(), parent.winfo_rooty()
189
+ w, h = self.winfo_width(), self.winfo_height()
190
+ self.geometry(f"+{px + (pw - w) // 2}+{py + (ph - h) // 2}")
191
+
192
+ def _collect(self) -> dict:
193
+ from flimkit.interactive import parse_exclude_ns
194
+ ch = self._sv_channels.get().strip()
195
+ _fwhm_s = self._sv_irf_fwhm.get().strip()
196
+ _start_s = self._sv_fit_start.get().strip()
197
+ _end_s = self._sv_fit_end.get().strip()
198
+ _excl_s = self._sv_exclude.get().strip()
199
+ parse_exclude_ns(_excl_s or None)
200
+ return {
201
+ 'optimizer': self._sv_optimizer.get(),
202
+ 'de_population': int(self._sv_de_pop.get() or 30),
203
+ 'de_maxiter': int(self._sv_de_maxiter.get() or 5000),
204
+ 'lm_restarts': int(self._sv_lm_restarts.get() or 8),
205
+ 'binning_factor': int(self._sv_binning.get() or 1),
206
+ 'n_workers': int(self._sv_workers.get() or -1),
207
+ 'min_photons': int(self._sv_min_ph.get() or 10),
208
+ 'cost_function': self._sv_cost.get(),
209
+ 'channels': int(ch) if ch.isdigit() else (None if ch == '' else ch),
210
+ 'irf_fwhm': float(_fwhm_s) if _fwhm_s else None,
211
+ 'irf_align': self._sv_irf_align.get(),
212
+ 'irf_shift_bins': int(self._sv_irf_shift.get() or 2),
213
+ 'align_irf': self._bv_align_irf.get(),
214
+ 'free_tau_perpixel': self._bv_free_tau.get(),
215
+ 'fit_t0': self._bv_fit_t0.get(),
216
+ 'fit_start_ns': float(_start_s) if _start_s else None,
217
+ 'fit_end_ns': float(_end_s) if _end_s else None,
218
+ 'exclude_ns': _excl_s,
219
+ }
220
+
221
+ def _confirm(self):
222
+ try:
223
+ self.result = self._collect()
224
+ except ValueError as e:
225
+ messagebox.showerror('Invalid value', str(e), parent=self)
226
+ return
227
+ self.destroy()
228
+
229
+ def _reset(self):
230
+ d = _EXPERT_DEFAULTS
231
+ self._sv_optimizer.set(d['optimizer'])
232
+ self._sv_de_pop.set(str(d['de_population']))
233
+ self._sv_de_maxiter.set(str(d['de_maxiter']))
234
+ self._sv_lm_restarts.set(str(d['lm_restarts']))
235
+ self._sv_binning.set(str(d['binning_factor']))
236
+ self._sv_workers.set(str(d['n_workers']))
237
+ self._sv_min_ph.set(str(d['min_photons']))
238
+ self._sv_cost.set(d['cost_function'])
239
+ self._sv_channels.set('')
240
+ self._sv_irf_fwhm.set('')
241
+ self._sv_irf_align.set('steepest_rise')
242
+ self._sv_irf_shift.set('2')
243
+ self._bv_align_irf.set(False)
244
+ self._bv_free_tau.set(False)
245
+ self._bv_fit_t0.set(False)
246
+ self._sv_fit_start.set('')
247
+ self._sv_fit_end.set('')
248
+ self._sv_exclude.set('')
flimkit/UI/fit_help.py ADDED
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import tkinter as tk
4
+ from tkinter import ttk
5
+ from tkinter import font as tkfont
6
+
7
+ TOPIC_ORDER = ('fit_model', 'components', 'fitting_mode', 'optimizer',
8
+ 'irf', 'masking')
9
+
10
+ FIT_HELP = {
11
+ 'fit_model': ('Fit model', [
12
+ ('n-exp',
13
+ 'A sum of N discrete exponentials, convolved with the IRF before it is '
14
+ 'compared to the data. This is the default and the right choice for most '
15
+ 'samples. It assumes every molecule in a pixel sits in one of N '
16
+ 'well-defined states, each with a single lifetime.'),
17
+ ('Gaussian dist.',
18
+ 'One continuous distribution of lifetimes instead of a discrete set. The '
19
+ 'fit returns a centre and a width rather than a list of taus. Use it when '
20
+ 'the fluorophore sits in a heterogeneous environment and a discrete fit '
21
+ 'needs an implausible number of components to converge.'),
22
+ ('Lorentzian dist.',
23
+ 'The same idea with a Lorentzian shape, which has heavier tails than a '
24
+ 'Gaussian. It tolerates a small sub-population well away from the centre '
25
+ 'without dragging the centre towards it.'),
26
+ ('n-exp tail',
27
+ 'Fits discrete exponentials past the peak of the decay and uses no IRF at '
28
+ 'all. Fast, and it removes the IRF as a source of error, but it cannot '
29
+ 'recover components shorter than roughly the IRF width, so short taus come '
30
+ 'out biased. The IRF panel disappears when this is selected.'),
31
+ ], 'Distribution fits are slower than discrete ones: each evaluation integrates '
32
+ 'over a 200-point lifetime grid.'),
33
+
34
+ 'components': ('Components', [
35
+ ('n-exp models (1, 2, 3)',
36
+ 'How many discrete exponentials to fit. Start at 1 and add a component '
37
+ 'only if the residuals show structure. Each extra component adds two free '
38
+ 'parameters, and three components need a lot of photons before the taus '
39
+ 'are separable.'),
40
+ ('Distribution models (unimodal, bimodal)',
41
+ 'How many distributions to sum. Unimodal is one centre and one width. '
42
+ 'Bimodal fits two, for a sample with two distinct populations that are '
43
+ 'each internally heterogeneous.'),
44
+ ], 'A lower chi-squared alone does not justify an extra component. More '
45
+ 'parameters will always fit better. Check whether the recovered taus are '
46
+ 'physically distinct and whether the residuals actually improved.'),
47
+
48
+ 'fitting_mode': ('Fitting mode', [
49
+ ('Full',
50
+ 'Fits the summed decay for the whole field of view and then fits every '
51
+ 'pixel individually. This is what produces the FLIM image and the lifetime '
52
+ 'maps.'),
53
+ ('Fast',
54
+ 'Fits the summed decay only. No per-pixel fitting, so no FLIM image and no '
55
+ 'lifetime maps, but it finishes in seconds rather than minutes. Use it to '
56
+ 'check that the IRF, the model and the component count are sensible before '
57
+ 'committing to a full run.'),
58
+ ], 'Per-pixel fitting is the expensive step. Binning and the minimum '
59
+ 'photons-per-pixel threshold both live in Expert Fit Settings.'),
60
+
61
+ 'optimizer': ('Optimizer', [
62
+ ('Differential Evolution (DE)',
63
+ 'A global search over the whole bounded parameter space, so it does not '
64
+ 'depend on a starting guess and will not settle into a local minimum near '
65
+ 'one. Slower than LM. Lifetimes are searched in log space, the population '
66
+ 'is Sobol-initialised, and a Levenberg-Marquardt polish is run at the end '
67
+ 'to sharpen the result.'),
68
+ ('Levenberg-Marquardt (LM)',
69
+ 'A local gradient-based fit, run from several starting points and the best '
70
+ 'result kept. The first start is log-spaced across the lifetime bounds and '
71
+ 'the rest are random. Much faster than DE, but with few restarts it can '
72
+ 'still miss the global minimum on a multi-exponential decay.'),
73
+ ], 'Both are seeded, so repeating a fit on the same data gives the same answer. '
74
+ 'DE population, DE iterations and LM restart count are all set in Expert Fit '
75
+ 'Settings.'),
76
+
77
+ 'irf': ('Instrument Response Function (IRF)', [
78
+ ('Analytical model (LAS X export)',
79
+ 'Builds the IRF from the LAS X export named in Input Files. This is the '
80
+ 'default for Leica FALCON data, where the microscope has already '
81
+ 'characterised its own response.'),
82
+ ('Machine IRF (.npy pre-built)',
83
+ 'A prompt measured once for the instrument and saved to disk, reused for '
84
+ 'every dataset from that microscope. Build one with the IRF tab. Use this '
85
+ 'when the optical path has not changed since it was measured.'),
86
+ ('Machine IRF + full sigma broadening',
87
+ 'The same stored prompt, but the fit is allowed to broaden it with a '
88
+ 'Gaussian of up to 3.0 bins. Use it when the stored IRF is narrower than '
89
+ 'the real response, for example after a change of objective or pinhole.'),
90
+ ('Machine IRF + half sigma broadening',
91
+ 'Broadening capped at 0.5 bins. A tighter leash for when the stored IRF is '
92
+ 'close to right and a free sigma would start absorbing the decay itself.'),
93
+ ('Measured IRF file (scatter PTU or .pck)',
94
+ 'A prompt measured alongside this dataset, usually a scattering solution '
95
+ 'or a reflecting surface. The most defensible option when you have one: it '
96
+ 'carries the same optical path, detector and timing as the sample.'),
97
+ ('Estimate from decay - raw',
98
+ 'Takes 21 bins centred on the peak of the decay itself and uses them as '
99
+ 'the prompt, after subtracting a background estimated from the pre-peak '
100
+ 'bins. A last resort. It cannot separate the instrument response from the '
101
+ 'fastest part of the decay, so short lifetimes come out too long.'),
102
+ ('Estimate from decay - parametric',
103
+ 'Fits an analytical pulse shape, amplitude times t/t0 times exp(-t/t0), to '
104
+ 'a 1.5 ns window around the peak. Smoother and less noisy than raw '
105
+ 'extraction, but it inherits the same problem and falls back to raw '
106
+ 'extraction if the fit fails.'),
107
+ ('Gaussian (fallback)',
108
+ 'A plain Gaussian prompt of the configured width. For when nothing better '
109
+ 'is available and you need a number rather than an answer.'),
110
+ ], 'The IRF is the largest single source of systematic error in a reconvolution '
111
+ 'fit, and it matters most for the shortest component. If tau_1 comes out '
112
+ 'near the IRF width, treat it as unresolved rather than measured. Picking '
113
+ 'the n-exp tail model removes the IRF from the fit entirely.'),
114
+
115
+ 'masking': ('Masking & Thresholding', [
116
+ ('Apply cell mask (Cellpose-SAM)',
117
+ 'Segments cells in the intensity image and fits only inside them. Removes '
118
+ 'background pixels that would otherwise contribute noise-dominated '
119
+ 'lifetimes to the summary statistics.'),
120
+ ('Intensity threshold (min photons/px)',
121
+ 'Skips any pixel with fewer than this many photons. A per-pixel fit on a '
122
+ 'few dozen photons is dominated by shot noise, so the resulting lifetime '
123
+ 'is close to meaningless and will smear the histogram. Leave blank for no '
124
+ 'threshold.'),
125
+ ('Coates pile-up correction',
126
+ 'At high count rates the detector preferentially records early photons, '
127
+ 'which biases lifetimes short. The Coates correction inverts that '
128
+ 'distortion on the measured decay. The count rate and photons-per-pulse '
129
+ 'are printed at the start of every fit, and the checkbox is worth ticking '
130
+ 'above about 5 percent.'),
131
+ ('Time-varying background PTU',
132
+ 'A separately measured background acquisition. Its decay shape is fitted '
133
+ 'as an extra component with a free scale, so a background that is itself '
134
+ 'time-varying, such as detector afterpulsing or room light, is subtracted '
135
+ 'in shape rather than as a flat offset.'),
136
+ ], 'The Coates correction rescales the counts, so the corrected decay is no '
137
+ 'longer Poisson-distributed while the default cost function still assumes it '
138
+ 'is. The reported chi-squared is unreliable when the correction is on, even '
139
+ 'though the recovered lifetimes are not.'),
140
+ }
141
+
142
+
143
+ class FitHelpWindow(tk.Toplevel):
144
+
145
+ def __init__(self, parent, topic=None):
146
+ super().__init__(parent)
147
+ self.title('Choosing your fit settings')
148
+ self.transient(parent)
149
+ self.geometry('620x520')
150
+ self.minsize(420, 300)
151
+ frame = ttk.Frame(self, padding=(12, 10))
152
+ frame.pack(fill='both', expand=True)
153
+ self._text = tk.Text(frame, wrap='word', relief='flat', padx=8, pady=6,
154
+ borderwidth=0, highlightthickness=0)
155
+ bar = ttk.Scrollbar(frame, orient='vertical', command=self._text.yview)
156
+ self._text.configure(yscrollcommand=bar.set)
157
+ self._text.pack(side='left', fill='both', expand=True)
158
+ bar.pack(side='right', fill='y')
159
+ self._configure_tags()
160
+ self._marks = {}
161
+ self._fill()
162
+ self._text.configure(state='disabled')
163
+ ttk.Button(self, text='Close', command=self.destroy).pack(pady=(0, 10))
164
+ self.bind('<Escape>', lambda _e: self.destroy())
165
+ if topic in self._marks:
166
+ self.after(50, lambda: self._text.see(self._marks[topic]))
167
+
168
+ def _configure_tags(self):
169
+ base = tkfont.nametofont('TkDefaultFont')
170
+ size = base.cget('size')
171
+ family = base.cget('family')
172
+ self._text.tag_configure('h1', font=(family, abs(size) + 3, 'bold'),
173
+ spacing1=14, spacing3=6)
174
+ self._text.tag_configure('h2', font=(family, abs(size), 'bold'),
175
+ spacing1=8, spacing3=2, lmargin1=8, lmargin2=8)
176
+ self._text.tag_configure('body', spacing3=4, lmargin1=8, lmargin2=8)
177
+ self._text.tag_configure('note', foreground='#777', spacing1=6, spacing3=8,
178
+ lmargin1=8, lmargin2=8)
179
+
180
+ def _fill(self):
181
+ for key in TOPIC_ORDER:
182
+ title, entries, note = FIT_HELP[key]
183
+ self._marks[key] = f'mark_{key}'
184
+ self._text.mark_set(self._marks[key], 'end-1c')
185
+ self._text.mark_gravity(self._marks[key], 'left')
186
+ self._text.insert('end', title + '\n', 'h1')
187
+ for name, body in entries:
188
+ self._text.insert('end', name + '\n', 'h2')
189
+ self._text.insert('end', body + '\n', 'body')
190
+ self._text.insert('end', note + '\n', 'note')
191
+
192
+
193
+ def show_fit_help(parent, topic=None):
194
+ win = FitHelpWindow(parent, topic=topic)
195
+ win.focus_set()
196
+ return win
197
+
198
+
199
+ def help_button(parent, topic):
200
+ btn = ttk.Label(parent, text='ⓘ', foreground='#4a7ebb', cursor='hand2',
201
+ takefocus=True)
202
+ def _open(_evt=None):
203
+ show_fit_help(parent.winfo_toplevel(), topic)
204
+ for seq in ('<Button-1>', '<Return>', '<space>'):
205
+ btn.bind(seq, _open)
206
+ return btn