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,617 @@
1
+ import numpy as np
2
+ from scipy.ndimage import gaussian_filter1d
3
+ from scipy.optimize import curve_fit
4
+ import matplotlib
5
+ from datetime import timezone
6
+ import matplotlib.pyplot as plt
7
+ from pathlib import Path
8
+ from datetime import datetime
9
+ import json
10
+ from ..formats.PTU.reader import PTUFile, read_pck
11
+ from ..formats import FLIMFile
12
+ from ..utils.xlsx_tools import load_xlsx
13
+ from ..configs import MACHINE_IRF_DIR as _DEFAULT_MACHINE_IRF_DIR
14
+ from ..configs import (
15
+ MACHINE_IRF_FIT_BG, MACHINE_IRF_FIT_SIGMA, MACHINE_IRF_FIT_TAIL,
16
+ MACHINE_IRF_SIGMA_MAX_FULL, MACHINE_IRF_SIGMA_MAX_HALF,
17
+ )
18
+
19
+ def machine_irf_prompt(machine_irf_path, n_bins, align_bin, variant='machine_irf',
20
+ align_label=None):
21
+ from ..interactive import _load_machine_irf_prompt
22
+ irf_prompt, strategy = _load_machine_irf_prompt(machine_irf_path, n_bins, align_bin)
23
+ if align_label:
24
+ strategy += f' align={align_label}'
25
+ has_tail = MACHINE_IRF_FIT_TAIL
26
+ fit_bg = MACHINE_IRF_FIT_BG
27
+ fit_sigma = MACHINE_IRF_FIT_SIGMA
28
+ sigma_max = MACHINE_IRF_SIGMA_MAX_FULL
29
+ if variant == 'machine_irf_sigma_full':
30
+ fit_sigma = True
31
+ sigma_max = MACHINE_IRF_SIGMA_MAX_FULL
32
+ strategy += ' + σ≤{:.1f}'.format(sigma_max)
33
+ elif variant == 'machine_irf_sigma_half':
34
+ fit_sigma = True
35
+ sigma_max = MACHINE_IRF_SIGMA_MAX_HALF
36
+ strategy += ' + σ≤{:.1f}'.format(sigma_max)
37
+ return irf_prompt, strategy, has_tail, fit_bg, fit_sigma, sigma_max
38
+
39
+ try:
40
+ matplotlib.use('Agg', force=True)
41
+ except Exception:
42
+ pass
43
+
44
+ def _leading_edge_crossing(arr: np.ndarray, frac: float = 0.5) -> int:
45
+ arr = np.asarray(arr, dtype=float)
46
+ peak = int(np.argmax(arr))
47
+ thr = float(arr[peak]) * frac
48
+ above = np.where(arr[:peak + 1] >= thr)[0]
49
+ return int(above[0]) if len(above) else 0
50
+
51
+ def _max_slope_bin(arr: np.ndarray) -> int:
52
+ arr = np.asarray(arr, dtype=float)
53
+ if arr.size < 2:
54
+ return 0
55
+ return int(np.argmax(np.diff(arr)))
56
+
57
+ def _extract_landmarks(arr: np.ndarray) -> dict:
58
+ arr = np.asarray(arr, dtype=float)
59
+ arr = np.maximum(arr, 0.0)
60
+ s = arr.sum()
61
+ if s > 0:
62
+ arr = arr / s
63
+ return {
64
+ 'peak': int(np.argmax(arr)),
65
+ 'halfmax': _leading_edge_crossing(arr, 0.5),
66
+ 'onset10': _leading_edge_crossing(arr, 0.1),
67
+ 'slope': _max_slope_bin(arr),
68
+ }
69
+
70
+ def discover_ptu_xlsx_pairs(folder: str | Path) -> list[tuple[str, Path, Path]]:
71
+ base = Path(folder)
72
+ if not base.exists():
73
+ raise FileNotFoundError(f"Folder not found: {base}")
74
+ pairs: list[tuple[str, Path, Path]] = []
75
+ for ptu_path in sorted(base.glob('*.ptu')):
76
+ if ptu_path.name.startswith('._'):
77
+ continue
78
+ name = ptu_path.stem
79
+ xlsx_path = base / f"{name}.xlsx"
80
+ if xlsx_path.exists() and not xlsx_path.name.startswith('._'):
81
+ pairs.append((name, ptu_path, xlsx_path))
82
+ return pairs
83
+
84
+ def build_machine_irf_from_folder(
85
+ folder: str | Path,
86
+ align_anchor: str = 'peak',
87
+ reducer: str = 'median',
88
+ save: bool = False,
89
+ confirm_save: bool = False,
90
+ output_name: str = 'machine_irf_default',
91
+ output_dir: str | Path | None = None,
92
+ verbose: bool = True,
93
+ ) -> dict:
94
+ if align_anchor not in {'peak', 'halfmax', 'onset10', 'slope'}:
95
+ raise ValueError('align_anchor must be one of: peak, halfmax, onset10, slope')
96
+ if reducer not in {'median', 'mean'}:
97
+ raise ValueError('reducer must be one of: median, mean')
98
+ pairs = discover_ptu_xlsx_pairs(folder)
99
+ if len(pairs) < 2:
100
+ raise ValueError('Need at least 2 PTU/XLSX pairs to build a machine IRF.')
101
+ irfs = []
102
+ peaks = []
103
+ nbins_all = []
104
+ tcspc_all = []
105
+ for name, ptu_path, xlsx_path in pairs:
106
+ ptu_f = FLIMFile(str(ptu_path), verbose=False)
107
+ xlsx = load_xlsx(str(xlsx_path))
108
+ irf = irf_from_xlsx(xlsx, ptu_f.n_bins, ptu_f.tcspc_res)
109
+ irfs.append(irf)
110
+ peaks.append(int(np.argmax(irf)))
111
+ nbins_all.append(ptu_f.n_bins)
112
+ tcspc_all.append(ptu_f.tcspc_res)
113
+ common_nbins = int(min(nbins_all))
114
+ irfs = [v[:common_nbins] for v in irfs]
115
+ marks = [_extract_landmarks(v) for v in irfs]
116
+ ref_anchor = int(np.median([m[align_anchor] for m in marks]))
117
+ aligned = np.zeros((len(irfs), common_nbins), dtype=float)
118
+ for i, (irf, m) in enumerate(zip(irfs, marks)):
119
+ shift = ref_anchor - int(m[align_anchor])
120
+ aligned[i] = np.roll(irf, shift)
121
+ if reducer == 'median':
122
+ machine_irf = np.median(aligned, axis=0)
123
+ else:
124
+ machine_irf = aligned.mean(axis=0)
125
+ machine_irf = np.maximum(machine_irf, 0.0)
126
+ s = machine_irf.sum()
127
+ if s <= 0:
128
+ raise ValueError('Machine IRF aggregation produced all zeros.')
129
+ machine_irf /= s
130
+ out_paths = None
131
+ if save:
132
+ if not confirm_save:
133
+ raise RuntimeError(
134
+ 'Save requested but confirm_save=False. '
135
+ 'Set confirm_save=True after explicit user confirmation.'
136
+ )
137
+ if output_dir is None:
138
+ output_dir = _DEFAULT_MACHINE_IRF_DIR
139
+ out_dir = Path(output_dir)
140
+ out_dir.mkdir(parents=True, exist_ok=True)
141
+ npy_path = out_dir / f"{output_name}.npy"
142
+ csv_path = out_dir / f"{output_name}.csv"
143
+ meta_path = out_dir / f"{output_name}_meta.json"
144
+ np.save(npy_path, machine_irf.astype(np.float64))
145
+ np.savetxt(csv_path, machine_irf.astype(np.float64), delimiter=',')
146
+ meta = {
147
+ 'created_utc': datetime.now(timezone.utc).isoformat(),
148
+ 'source_folder': str(Path(folder).resolve()),
149
+ 'n_pairs': len(pairs),
150
+ 'pair_names': [name for name, _, _ in pairs],
151
+ 'align_anchor': align_anchor,
152
+ 'reducer': reducer,
153
+ 'common_nbins': common_nbins,
154
+ 'tcspc_res_ns_mean': float(np.mean(tcspc_all) * 1e9),
155
+ 'machine_landmarks': _extract_landmarks(machine_irf),
156
+ }
157
+ meta_path.write_text(json.dumps(meta, indent=2))
158
+ out_paths = {
159
+ 'npy': str(npy_path),
160
+ 'csv': str(csv_path),
161
+ 'meta_json': str(meta_path),
162
+ }
163
+ meta = {
164
+ 'n_pairs': len(pairs),
165
+ 'pair_names': [name for name, _, _ in pairs],
166
+ 'align_anchor': align_anchor,
167
+ 'reducer': reducer,
168
+ 'common_nbins': common_nbins,
169
+ 'landmarks': _extract_landmarks(machine_irf),
170
+ 'peak_bins_before_alignment': peaks,
171
+ }
172
+ if verbose:
173
+ print(f"Machine IRF built from {len(pairs)} pairs")
174
+ print(f" anchor={align_anchor}, reducer={reducer}, n_bins={common_nbins}")
175
+ print(f" landmarks={meta['landmarks']}")
176
+ if out_paths:
177
+ print(' saved:')
178
+ for _, p in out_paths.items():
179
+ print(f" {p}")
180
+ return {
181
+ 'irf': machine_irf,
182
+ 'pairs': pairs,
183
+ 'metadata': meta,
184
+ 'save_paths': out_paths,
185
+ }
186
+
187
+ def gaussian_irf_from_fwhm(n_bins: int,
188
+ tcspc_res: float,
189
+ fwhm_ns: float,
190
+ peak_bin: int) -> np.ndarray:
191
+ t = np.arange(n_bins, dtype=float) * tcspc_res * 1e9
192
+ t0 = peak_bin * tcspc_res * 1e9
193
+ irf = np.exp(-(t - t0)**2 * 4.0 * np.log(2) / fwhm_ns**2)
194
+ return irf / irf.sum()
195
+
196
+ def irf_from_scatter_ptu(path: str, ptu_ref: PTUFile,
197
+ channel: int | None = None) -> np.ndarray:
198
+ scatter = FLIMFile(path, verbose=False)
199
+ decay = scatter.summed_decay(channel=channel)
200
+ s = decay.sum()
201
+ if s == 0 and channel is not None:
202
+ decay = scatter.summed_decay(channel=None)
203
+ s = decay.sum()
204
+ if s > 0:
205
+ print(f" Scatter PTU has no photons on channel {channel}; "
206
+ f"using auto-detected channel {scatter.photon_channel} instead")
207
+ if s == 0:
208
+ raise ValueError(f"Scatter PTU {path!r} has no photons.")
209
+ n = ptu_ref.n_bins
210
+ if decay.size < n:
211
+ decay = np.concatenate([decay, np.zeros(n - decay.size)])
212
+ else:
213
+ decay = decay[:n]
214
+ s = decay.sum()
215
+ if s == 0:
216
+ raise ValueError(f"Scatter PTU {path!r} has no photons in the first {n} bins.")
217
+ print(f" IRF from scatter PTU: {s:,.0f} photons")
218
+ return decay / s
219
+
220
+ def irf_from_pck(path: str, n_bins: int, channel=None) -> np.ndarray:
221
+ hist, tags = read_pck(path)
222
+ if channel is None:
223
+ channel = int(np.argmax(hist.sum(axis=1)))
224
+ if channel >= hist.shape[0]:
225
+ raise ValueError(f'Channel {channel} not in .pck (has {hist.shape[0]} channel(s))')
226
+ full = hist[channel].astype(float)
227
+ peak = int(np.argmax(full))
228
+ if peak >= n_bins:
229
+ raise ValueError(f'.pck IRF peak (bin {peak}) is beyond the {n_bins}-bin target grid; '
230
+ f'the .pck ({full.size} bins) and the data have different TCSPC resolution.')
231
+ if full.size < n_bins:
232
+ irf = np.concatenate([full, np.zeros(n_bins - full.size)])
233
+ else:
234
+ irf = full[:n_bins]
235
+ s = irf.sum()
236
+ if s == 0:
237
+ raise ValueError(f'.pck IRF channel {channel} has no counts.')
238
+ print(f' IRF from .pck: channel {channel}, {int(s):,} photons, peak bin {peak}')
239
+ return irf / s
240
+
241
+ def align_irf_to_bin(irf_prompt: np.ndarray, target_bin: int,
242
+ n_bins: int) -> tuple[np.ndarray, int]:
243
+ current = int(np.argmax(irf_prompt))
244
+ shift = int(target_bin) - current
245
+ if shift == 0:
246
+ return irf_prompt, 0
247
+ x = np.arange(n_bins, dtype=float)
248
+ shifted = np.interp(x - shift, x, irf_prompt, left=0.0, right=0.0)
249
+ s = shifted.sum()
250
+ if s > 0:
251
+ shifted = shifted / s
252
+ return shifted, shift
253
+
254
+ def irf_from_measured_file(path: str, ptu_ref: PTUFile,
255
+ channel: int | None = None) -> np.ndarray:
256
+ if str(path).lower().endswith('.pck'):
257
+ return irf_from_pck(path, ptu_ref.n_bins, channel=channel)
258
+ return irf_from_scatter_ptu(path, ptu_ref, channel=channel)
259
+
260
+ def irf_from_xlsx_analytical(xlsx: dict, n_bins: int, tcspc_res: float,
261
+ verbose: bool = True) -> tuple[np.ndarray, dict]:
262
+ if xlsx['irf_t'] is None or xlsx['irf_c'] is None:
263
+ raise ValueError('XLSX does not contain IRF columns.')
264
+ t_pts = np.array(xlsx['irf_t'], dtype=float)
265
+ c_pts = np.maximum(np.array(xlsx['irf_c'], dtype=float), 0.0)
266
+ mask = c_pts > c_pts.max() * 1e-3
267
+ if mask.sum() < 3:
268
+ raise ValueError('Fewer than 3 non-negligible IRF points in xlsx - '
269
+ 'cannot fit analytical model.')
270
+ t_fit = t_pts[mask]
271
+ c_fit = c_pts[mask]
272
+ t0_guess = t_pts[np.argmax(c_pts)]
273
+
274
+ def _model(t, t0, fwhm, tail_amp, tail_tau, A):
275
+ gauss = np.exp(-4.0 * np.log(2) * (t - t0)**2 / fwhm**2)
276
+ tail = np.where(t >= t0,
277
+ tail_amp * np.exp(-(t - t0) / np.maximum(tail_tau, 0.01)),
278
+ 0.0)
279
+ return A * (gauss + tail)
280
+ try:
281
+ popt, _ = curve_fit(
282
+ _model, t_fit, c_fit,
283
+ p0 = [t0_guess, 0.15, 0.05, 0.5, c_pts.max()],
284
+ bounds=([t0_guess - 0.5, 0.05, 0.0, 0.05, 0],
285
+ [t0_guess + 0.5, 0.5, 2.0, 10.0, c_pts.max() * 2]),
286
+ maxfev=20000
287
+ )
288
+ t0, fwhm, tail_amp, tail_tau, A = popt
289
+ except Exception as e:
290
+ raise RuntimeError(f"Analytical IRF fit failed: {e}. "
291
+ f"Try --irf-xlsx with a higher-count IRF export.") from e
292
+ tcspc_ns = tcspc_res * 1e9
293
+ t_full = np.arange(n_bins, dtype=float) * tcspc_ns
294
+ irf_full = np.maximum(_model(t_full, t0, fwhm, tail_amp, tail_tau, A), 0.0)
295
+ s = irf_full.sum()
296
+ if s == 0:
297
+ raise ValueError('Analytical IRF evaluates to zero on bin grid.')
298
+ irf_norm = irf_full / s
299
+ params = dict(t0_ns=t0, fwhm_ns=fwhm, tail_amp=tail_amp, tail_tau_ns=tail_tau)
300
+ if verbose:
301
+ print(f" Analytical IRF fit (FLIM microscope model):")
302
+ print(f" t0 = {t0:.4f} ns (bin {t0/tcspc_ns:.2f})")
303
+ print(f" FWHM = {fwhm*1000:.2f} ps")
304
+ print(f" tail_amp = {tail_amp:.4f}")
305
+ print(f" tail_tau = {tail_tau:.4f} ns")
306
+ above = np.where(irf_norm >= irf_norm.max() / 2)[0]
307
+ fwhm_meas = (above[-1] - above[0]) * tcspc_ns if len(above) > 1 else fwhm
308
+ print(f" FWHM (measured on grid) = {fwhm_meas*1000:.2f} ps")
309
+ print(f" Peak bin = {np.argmax(irf_norm)}")
310
+ return irf_norm, params
311
+
312
+ def irf_from_xlsx(xlsx: dict, n_bins: int, tcspc_res: float) -> np.ndarray:
313
+ if xlsx['irf_t'] is None or xlsx['irf_c'] is None:
314
+ raise ValueError('XLSX does not contain IRF columns.')
315
+ tcspc_ns = tcspc_res * 1e9
316
+ t_full = np.arange(n_bins, dtype=float) * tcspc_ns
317
+ t_pts = np.array(xlsx['irf_t'], dtype=float)
318
+ c_pts = np.array(xlsx['irf_c'], dtype=float)
319
+ c_pts = np.maximum(c_pts, 0.0)
320
+ order = np.argsort(t_pts)
321
+ t_pts, c_pts = t_pts[order], c_pts[order]
322
+ irf_interp = np.interp(t_full, t_pts, c_pts, left=0.0, right=0.0)
323
+ s = irf_interp.sum()
324
+ if s == 0:
325
+ raise ValueError('xlsx IRF is all zeros after interpolation.')
326
+ return irf_interp / s
327
+
328
+ def gaussian_irf(n_bins: int, peak_bin: int, fwhm_bins: float) -> np.ndarray:
329
+ bins = np.arange(n_bins, dtype=float)
330
+ sigma = fwhm_bins / 2.3548
331
+ irf = np.exp(-0.5 * ((bins - peak_bin) / sigma)**2)
332
+ return irf / irf.sum()
333
+
334
+ def reconstruct_irf_from_decay(decay: np.ndarray,
335
+ tcspc_res: float,
336
+ n_bins: int,
337
+ noise_floor: float = 50,
338
+ noise_frac: float = 0.001,
339
+ max_bap: int = 2,
340
+ verbose: bool = False) -> np.ndarray:
341
+ decay = np.asarray(decay, dtype=float)
342
+ if decay.size < 3 or decay.max() <= 0:
343
+ raise ValueError('Decay histogram is empty or all zeros.')
344
+ peak_idx = int(np.argmax(decay))
345
+ peak_val = decay[peak_idx]
346
+ threshold = max(noise_floor, noise_frac * peak_val)
347
+ start_idx = peak_idx
348
+ while start_idx > 0 and decay[start_idx - 1] > threshold:
349
+ start_idx -= 1
350
+ cut_idx = peak_idx
351
+ prev_val = peak_val
352
+ for i in range(1, max_bap + 1):
353
+ next_idx = peak_idx + i
354
+ if next_idx >= n_bins:
355
+ break
356
+ next_val = decay[next_idx]
357
+ if next_val <= 0:
358
+ break
359
+ cut_idx = next_idx
360
+ prev_val = next_val
361
+ irf_full = np.zeros(n_bins, dtype=float)
362
+ for src in range(start_idx, cut_idx + 1):
363
+ if 0 <= src < n_bins:
364
+ irf_full[src] = decay[src]
365
+ total = irf_full.sum()
366
+ if total == 0:
367
+ raise ValueError('Reconstructed IRF has zero counts - check decay quality.')
368
+ irf_norm = irf_full / total
369
+ if verbose:
370
+ tcspc_ns = tcspc_res * 1e9
371
+ bap = cut_idx - peak_idx
372
+ n_rising = peak_idx - start_idx
373
+ above = np.where(irf_norm >= irf_norm.max() / 2)[0]
374
+ fwhm = (above[-1] - above[0]) * tcspc_ns if len(above) > 1 else tcspc_ns
375
+ print(f" IRF reconstructed from decay rising edge:")
376
+ print(f" Peak bin (decay) = {peak_idx} → IRF peak bin = {peak_idx}")
377
+ print(f" Rising edge = {n_rising} bins")
378
+ print(f" Bins after peak = {bap}")
379
+ print(f" IRF extent = bins {start_idx}..{cut_idx} "
380
+ f"({cut_idx - start_idx + 1} bins)")
381
+ print(f" FWHM (grid) = {fwhm * 1000:.1f} ps")
382
+ return irf_norm
383
+
384
+ def estimate_irf_from_decay_raw(decay, tcspc_res, n_bins,
385
+ n_irf_bins=21, bg_est_pre=5) -> np.ndarray:
386
+ peak_bin = int(np.argmax(decay))
387
+ bg_end = max(0, peak_bin - bg_est_pre)
388
+ bg = float(np.median(decay[:bg_end])) if bg_end > 0 \
389
+ else float(np.median(decay[-30:]))
390
+ decay_sub = np.maximum(decay - bg, 0.0)
391
+ half = n_irf_bins // 2
392
+ start = max(0, peak_bin - half)
393
+ end = min(n_bins, peak_bin + half + 1)
394
+ irf_raw = decay_sub[start:end].copy()
395
+ total = irf_raw.sum()
396
+ if total == 0:
397
+ raise ValueError('Extracted IRF region has zero counts.')
398
+ irf_full = np.zeros(n_bins, dtype=float)
399
+ irf_full[start:end] = irf_raw / total
400
+ return irf_full
401
+
402
+ def _irf_parametric(t, t0, amplitude):
403
+ return amplitude * (t / t0) * np.exp(-t / t0)
404
+
405
+ def estimate_irf_from_decay_parametric(decay, tcspc_res, n_bins,
406
+ fit_window_width_ns=1.5,
407
+ bg_est_pre=5) -> np.ndarray:
408
+ peak_bin = int(np.argmax(decay))
409
+ bg_end = max(0, peak_bin - bg_est_pre)
410
+ bg = float(np.median(decay[:bg_end])) if bg_end > 0 \
411
+ else float(np.median(decay[-30:]))
412
+ decay_sub = np.maximum(decay - bg, 0.0)
413
+ time_ns = np.arange(n_bins) * tcspc_res * 1e9
414
+ t_peak_ns = time_ns[peak_bin]
415
+ start_ns = max(0, t_peak_ns - fit_window_width_ns / 2)
416
+ end_ns = min(time_ns[-1], t_peak_ns + fit_window_width_ns / 2)
417
+ sb = np.searchsorted(time_ns, start_ns, side='left')
418
+ eb = np.searchsorted(time_ns, end_ns, side='right')
419
+ if eb - sb < 3:
420
+ raise ValueError('Fit window too narrow.')
421
+ t_fit = time_ns[sb:eb] - time_ns[sb]
422
+ y_fit = decay_sub[sb:eb]
423
+ pk = np.argmax(y_fit)
424
+ try:
425
+ popt, _ = curve_fit(_irf_parametric, t_fit, y_fit,
426
+ p0=[t_fit[pk]/2.0 if pk > 0 else 1.0, y_fit[pk]],
427
+ bounds=([0.01, 0], [10.0, np.inf]))
428
+ t0, amp = popt
429
+ except Exception as e:
430
+ print(f"Parametric fit failed: {e}, falling back to raw extraction.")
431
+ return estimate_irf_from_decay_raw(decay, tcspc_res, n_bins)
432
+ t_full_ns = time_ns - time_ns[sb]
433
+ irf_full = np.maximum(_irf_parametric(t_full_ns, t0, amp), 0.0)
434
+ total = irf_full.sum()
435
+ return irf_full / total if total > 0 else np.zeros(n_bins)
436
+
437
+ def build_full_irf(irf_prompt: np.ndarray,
438
+ shift_bins: float,
439
+ sigma_bins: float,
440
+ tail_amp: float,
441
+ tail_tau_bins: float,
442
+ n_bins: int) -> np.ndarray:
443
+ peak_bin = int(np.argmax(irf_prompt))
444
+ bins = np.arange(n_bins, dtype=float)
445
+ tail = np.where(
446
+ bins >= peak_bin,
447
+ tail_amp * np.exp(-(bins - peak_bin) / max(tail_tau_bins, 0.1)),
448
+ 0.0
449
+ )
450
+ irf_aug = irf_prompt + tail
451
+ s = irf_aug.sum()
452
+ if s > 0:
453
+ irf_aug /= s
454
+ x_orig = np.arange(n_bins, dtype=float)
455
+ irf_shifted = np.interp(x_orig - shift_bins, x_orig, irf_aug,
456
+ left=0.0, right=0.0)
457
+ if sigma_bins > 0.05:
458
+ irf_shifted = gaussian_filter1d(irf_shifted, sigma=sigma_bins)
459
+ s2 = irf_shifted.sum()
460
+ if s2 > 0:
461
+ irf_shifted /= s2
462
+ return irf_shifted
463
+
464
+ def _fwhm_ns(irf: np.ndarray, tcspc_res: float) -> float:
465
+ pk = irf.max()
466
+ if pk <= 0:
467
+ return np.nan
468
+ above = np.where(irf >= pk / 2)[0]
469
+ if len(above) > 1:
470
+ return (above[-1] - above[0]) * tcspc_res * 1e9
471
+ integral = irf.sum() * tcspc_res * 1e9
472
+ fwhm_est = integral * np.sqrt(4 * np.log(2) / np.pi)
473
+ return float(fwhm_est)
474
+
475
+ def compare_irfs(irf_estimated: np.ndarray,
476
+ xlsx: dict | None,
477
+ tcspc_res: float,
478
+ n_bins: int,
479
+ strategy: str,
480
+ out_prefix: str) -> dict | None:
481
+ t_ns = np.arange(n_bins, dtype=float) * tcspc_res * 1e9
482
+ irf_xlsx_embedded = None
483
+ if xlsx is not None and xlsx.get('irf_t') is not None and xlsx.get('irf_c') is not None:
484
+ irf_raw = np.zeros(n_bins)
485
+ for t, c in zip(xlsx['irf_t'], xlsx['irf_c']):
486
+ idx = int(round(t / (tcspc_res * 1e9)))
487
+ if 0 <= idx < n_bins:
488
+ irf_raw[idx] += max(c, 0.0)
489
+ s = irf_raw.sum()
490
+ if s > 0:
491
+ irf_xlsx_embedded = irf_raw / s
492
+ if irf_xlsx_embedded is None:
493
+ print(' IRF comparison skipped - no xlsx IRF available.')
494
+ return None
495
+ est = irf_estimated / irf_estimated.sum()
496
+ ref = irf_xlsx_embedded / irf_xlsx_embedded.sum()
497
+ peak_est_bin = int(np.argmax(est))
498
+ peak_ref_bin = int(np.argmax(ref))
499
+ shift_bins = peak_est_bin - peak_ref_bin
500
+ x = np.arange(n_bins, dtype=float)
501
+ est_aligned = np.interp(x + shift_bins, x, est, left=0.0, right=0.0)
502
+ s = est_aligned.sum()
503
+ if s > 0:
504
+ est_aligned /= s
505
+
506
+ def _metrics(a, b, label):
507
+ support = (a > 1e-8) | (b > 1e-8)
508
+ a_s, b_s = a[support], b[support]
509
+ if len(a_s) > 1 and a_s.std() > 0 and b_s.std() > 0:
510
+ r = float(np.corrcoef(a_s, b_s)[0, 1])
511
+ else:
512
+ r = np.nan
513
+ rmse = float(np.sqrt(np.mean((a - b)**2)))
514
+ bc = float(np.sum(np.sqrt(a * b)))
515
+ return dict(label=label, pearson_r=r, rmse=rmse,
516
+ overlap_score=max(0.0, 1.0 - rmse), bhattacharyya=bc)
517
+ m_raw = _metrics(est, ref, 'raw (unaligned)')
518
+ m_aligned = _metrics(est_aligned, ref, 'aligned (peak-shift corrected)')
519
+ fwhm_est = _fwhm_ns(est, tcspc_res)
520
+ fwhm_ref = _fwhm_ns(ref, tcspc_res)
521
+ peak_est_ns = peak_est_bin * tcspc_res * 1e9
522
+ peak_ref_ns = peak_ref_bin * tcspc_res * 1e9
523
+ metrics = dict(
524
+ fwhm_estimated_ns = fwhm_est,
525
+ fwhm_xlsx_ns = fwhm_ref,
526
+ peak_estimated_ns = peak_est_ns,
527
+ peak_xlsx_ns = peak_ref_ns,
528
+ peak_shift_ns = shift_bins * tcspc_res * 1e9,
529
+ peak_shift_bins = shift_bins,
530
+ raw = m_raw,
531
+ aligned = m_aligned,
532
+ )
533
+ print(f"\n IRF Comparison ({strategy.split('peak_bin')[0].strip()} vs xlsx)")
534
+ print(f" {'Metric':<28} {'Estimated':>12} {'xlsx':>12}")
535
+ print(f" {'─'*54}")
536
+ print(f" {'FWHM (ns)':<28} {fwhm_est:>12.4f} {fwhm_ref:>12.4f}")
537
+ print(f" {'Peak position (ns)':<28} {peak_est_ns:>12.4f} {peak_ref_ns:>12.4f}")
538
+ print(f" {'Peak shift (est − xlsx)':<28} "
539
+ f"{shift_bins * tcspc_res * 1e9:>+11.4f} ns ({shift_bins:+d} bins)")
540
+ print(f" {'─'*54}")
541
+ for m in (m_raw, m_aligned):
542
+ print(f" [{m['label']}]")
543
+ print(f" {'Pearson r':<26} {m['pearson_r']:>12.4f}")
544
+ print(f" {'RMSE (normalised)':<26} {m['rmse']:>12.6f}")
545
+ print(f" {'Overlap score (1−RMSE)':<26} {m['overlap_score']:>12.4f}")
546
+ print(f" {'Bhattacharyya coeff.':<26} {m['bhattacharyya']:>12.4f}")
547
+ bc_a = m_aligned['bhattacharyya']
548
+ if bc_a >= 0.99:
549
+ print(f"\n Excellent shape match after alignment (BC={bc_a:.4f})")
550
+ print(f" → Use --irf-fwhm with adjusted peak; shape is correct.")
551
+ elif bc_a >= 0.90:
552
+ print(f"\n ~ Acceptable shape match after alignment (BC={bc_a:.4f})")
553
+ print(f" → Shape is reasonable but consider --xlsx for fitting.")
554
+ else:
555
+ print(f"\n Poor shape match even after alignment (BC={bc_a:.4f})")
556
+ print(f" → FWHM or IRF model is wrong. Use --xlsx for fitting.")
557
+ if abs(shift_bins) >= 2:
558
+ print(f" Peak misaligned by {shift_bins:+d} bins ({shift_bins*tcspc_res*1e12:+.0f} ps) "
559
+ f"- IRF peak bin estimate may be off.")
560
+ plt.rcParams.update({'figure.dpi': 130, 'font.size': 10,
561
+ 'axes.spines.top': False, 'axes.spines.right': False,
562
+ 'text.color': 'black', 'axes.labelcolor': 'black',
563
+ 'xtick.color': 'black', 'ytick.color': 'black',
564
+ 'axes.titlecolor': 'black'})
565
+ fig, axes = plt.subplots(2, 3, figsize=(15, 8))
566
+ fig.suptitle('IRF Comparison - Estimated vs FLIM microscope xlsx',
567
+ fontsize=11, fontweight='bold')
568
+ support = (est > 1e-8) | (ref > 1e-8)
569
+ idx_sup = np.where(support)[0]
570
+ if len(idx_sup):
571
+ x_lo = max(0, idx_sup[0] - 10) * tcspc_res * 1e9
572
+ x_hi = min(n_bins-1, idx_sup[-1] + 10) * tcspc_res * 1e9
573
+ else:
574
+ x_lo, x_hi = t_ns[0], t_ns[-1]
575
+ row_labels = ['Unaligned', 'Peak-aligned']
576
+ for row, (e_plot, m) in enumerate([(est, m_raw), (est_aligned, m_aligned)]):
577
+ diff = e_plot - ref
578
+ axes[row, 0].plot(t_ns, ref, 'b-', lw=2, label='xlsx IRF')
579
+ axes[row, 0].plot(t_ns, e_plot, 'r--', lw=1.8, label='estimated')
580
+ axes[row, 0].set_xlim(x_lo, x_hi)
581
+ axes[row, 0].set_ylabel('Normalised amplitude')
582
+ axes[row, 0].set_title(f"{row_labels[row]} - linear")
583
+ axes[row, 0].legend(fontsize=8)
584
+ if row == 1:
585
+ axes[row, 0].set_xlabel('Time (ns)')
586
+ axes[row, 1].semilogy(t_ns, np.clip(ref, 1e-8, None), 'b-', lw=2)
587
+ axes[row, 1].semilogy(t_ns, np.clip(e_plot, 1e-8, None), 'r--', lw=1.8)
588
+ axes[row, 1].set_xlim(x_lo, x_hi)
589
+ axes[row, 1].set_title(f"{row_labels[row]} - log")
590
+ if row == 1:
591
+ axes[row, 1].set_xlabel('Time (ns)')
592
+ axes[row, 2].fill_between(t_ns, diff, where=diff >= 0,
593
+ alpha=0.6, color='#e63946', label='est > xlsx')
594
+ axes[row, 2].fill_between(t_ns, diff, where=diff < 0,
595
+ alpha=0.6, color='#457b9d', label='est < xlsx')
596
+ axes[row, 2].axhline(0, color='k', lw=0.8, ls='--')
597
+ axes[row, 2].set_xlim(x_lo, x_hi)
598
+ axes[row, 2].set_ylabel('Δ (estimated − xlsx)')
599
+ axes[row, 2].set_title(f"Difference RMSE={m['rmse']:.5f}")
600
+ axes[row, 2].legend(fontsize=8)
601
+ if row == 1:
602
+ axes[row, 2].set_xlabel('Time (ns)')
603
+ txt = (f"Pearson r = {m['pearson_r']:.4f}\n"
604
+ f"BC = {m['bhattacharyya']:.4f}\n"
605
+ f"FWHM est = {fwhm_est:.4f} ns\n"
606
+ f"FWHM xlsx = {fwhm_ref:.4f} ns")
607
+ if row == 0:
608
+ txt += f"\nΔpeak = {shift_bins*tcspc_res*1e12:+.0f} ps ({shift_bins:+d} bins)"
609
+ axes[row, 2].text(0.97, 0.97, txt, transform=axes[row, 2].transAxes,
610
+ va='top', ha='right', fontsize=8, family='monospace',
611
+ bbox=dict(boxstyle='round,pad=0.3', fc='#f7f7f7', alpha=0.9))
612
+ plt.tight_layout()
613
+ out = f"{out_prefix}_irf_comparison.png"
614
+ plt.savefig(out, dpi=150, bbox_inches='tight')
615
+ plt.close()
616
+ print(f" Saved: {out}")
617
+ return metrics