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,1471 @@
1
+ import time
2
+ import os
3
+ import numpy as np
4
+ from tqdm import tqdm
5
+ tqdm.disable = True
6
+ from scipy.optimize import least_squares, differential_evolution, nnls
7
+ from scipy.stats.distributions import chi2 as chi2_dist
8
+ from ..FLIM.irf_tools import build_full_irf
9
+ from ..FLIM.fit_tools import (estimate_bg, find_fit_start, find_fit_end, _build_bounds,
10
+ _pack_p0, coates_pileup_correction, bins_from_ns, build_fit_idx,
11
+ find_tail_fit_start, _build_bounds_tail, _pack_p0_tail,
12
+ calibrated_chi2, distribution_dof)
13
+ from ..FLIM.models import (reconvolution_model, _DECost, _DECostLogTau,
14
+ _DECostPoisson, _DECostPoissonLogTau,
15
+ dist_reconvolution_model, build_dist_basis_grid,
16
+ _DECostDist, _DECostDistLogParam,
17
+ _DECostDistPoisson, _DECostDistPoissonLogParam,
18
+ tail_model, tail_basis, unpack_tail_params,
19
+ _DECostTail, _DECostTailLogTau,
20
+ _DECostTailPoisson, _DECostTailPoissonLogTau)
21
+ from ..FLIM.fit_tools import (_build_bounds_dist, _pack_p0_dist)
22
+ from ..configs import MIN_PHOTONS_PERPIX
23
+
24
+ _GPU_BACKEND_UNSET = object()
25
+ _gpu_backend_cache = _GPU_BACKEND_UNSET
26
+ _GPU_MAX_DIST_STACK_BYTES = 1_000_000_000
27
+ _FREE_TAU_WARN_PIXELS = 50_000
28
+ _TAU_GRID_POINTS = 1600
29
+
30
+ def tau_grid_points():
31
+ override = os.environ.get('FLIMKIT_TAU_GRID_POINTS')
32
+ if override:
33
+ try:
34
+ return max(2, int(override))
35
+ except ValueError:
36
+ pass
37
+ return _TAU_GRID_POINTS
38
+
39
+ def _init_gpu_backend():
40
+ global _gpu_backend_cache
41
+ if _gpu_backend_cache is not _GPU_BACKEND_UNSET:
42
+ return _gpu_backend_cache
43
+ try:
44
+ from flimkit.GPU import get_backend
45
+ _gpu_backend_cache = get_backend()
46
+ except Exception:
47
+ _gpu_backend_cache = None
48
+ return _gpu_backend_cache
49
+
50
+ def warmup_gpu_backend():
51
+ import threading
52
+ global _gpu_backend_cache
53
+ if _gpu_backend_cache is not _GPU_BACKEND_UNSET:
54
+ return
55
+ def _warmup():
56
+ global _gpu_backend_cache
57
+ if _gpu_backend_cache is not _GPU_BACKEND_UNSET:
58
+ return
59
+ try:
60
+ from flimkit.GPU import get_backend
61
+ _gpu_backend_cache = get_backend()
62
+ except Exception:
63
+ _gpu_backend_cache = None
64
+ threading.Thread(target=_warmup, daemon=True).start()
65
+
66
+ def fit_summed(decay, tcspc_res, n_bins, irf_prompt,
67
+ has_tail, fit_bg, fit_sigma,
68
+ n_exp, tau_min_ns, tau_max_ns,
69
+ optimizer='de', n_restarts=8,
70
+ de_popsize=15, de_maxiter=1000,
71
+ workers=-1, polish=True,
72
+ cost_function='poisson',
73
+ sigma_max=3.0,
74
+ irf_shift_bins=2,
75
+ tvb_profile=None, fit_tvb=False,
76
+ fit_start_ns=None, fit_end_ns=None, exclude_ns=None,
77
+ n_sync=None):
78
+ warmup_gpu_backend()
79
+ tau_min = tau_min_ns * 1e-9
80
+ tau_max = tau_max_ns * 1e-9
81
+ if cost_function not in ('chi2', 'poisson'):
82
+ raise ValueError(f"Unknown cost_function: {cost_function!r}")
83
+ decay_work = decay.astype(float)
84
+ if decay_work.max() == 0:
85
+ raise ValueError('Decay has zero maximum - cannot fit.')
86
+ scale = 1.0
87
+ peak_bin = int(np.argmax(decay_work))
88
+ bg_init = estimate_bg(decay_work, peak_bin)
89
+ bg_fixed = bg_init if not fit_bg else 0.0
90
+ fit_end = find_fit_end(decay_work, peak_bin, tau_max, tcspc_res, n_bins)
91
+ standard_fit_end = int(round(44.9455 / (tcspc_res * 1e9)))
92
+ fit_end = min(fit_end, standard_fit_end)
93
+ fit_start = find_fit_start(decay_work, irf_prompt, tcspc_res)
94
+ fit_start = max(0, min(fit_start, fit_end - 10))
95
+ if fit_end_ns is not None:
96
+ fit_end = min(n_bins, bins_from_ns(fit_end_ns, tcspc_res))
97
+ if fit_start_ns is not None:
98
+ fit_start = max(0, min(bins_from_ns(fit_start_ns, tcspc_res), fit_end - 2))
99
+ exclude_bins = [(bins_from_ns(lo, tcspc_res), bins_from_ns(hi, tcspc_res))
100
+ for lo, hi in (exclude_ns or [])]
101
+ fit_idx = build_fit_idx(fit_start, fit_end, n_bins, exclude_bins)
102
+ bg_upper = max(bg_init * 2.0, bg_init + 10.0)
103
+ if fit_tvb and tvb_profile is None:
104
+ raise ValueError('fit_tvb=True requires a tvb_profile.')
105
+ tvb_init = float(bg_init * n_bins) if fit_tvb else 0.0
106
+ tvb_upper = float(decay_work.sum()) if fit_tvb else None
107
+ if fit_tvb:
108
+ print(f" TVB: free scale on measured profile, init={tvb_init:.1f}, upper={tvb_upper:.1f}")
109
+ print(f" Cost function: {cost_function}")
110
+ print(f" bg initial guess = {bg_init:.3f} cts/bin"
111
+ f", upper bound = {bg_upper:.3f} "
112
+ f"({'free param' if fit_bg else 'fixed'})")
113
+ print(f" σ broadening: {'free param (σ≤' + f'{sigma_max:.1f})' if fit_sigma else 'fixed at 0'}")
114
+ print(f" Fit window: bins {fit_start}-{fit_end} "
115
+ f"({fit_start*tcspc_res*1e9:.2f}-{fit_end*tcspc_res*1e9:.2f} ns), "
116
+ f"{len(fit_idx)} bins fitted"
117
+ f"{' (user-set)' if (fit_start_ns is not None or fit_end_ns is not None) else ' (auto)'}")
118
+ if exclude_bins:
119
+ for (lo_b, hi_b), (lo_n, hi_n) in zip(exclude_bins, exclude_ns):
120
+ print(f" Excluded: bins {lo_b}-{hi_b} ({lo_n:.2f}-{hi_n:.2f} ns)")
121
+ if n_sync:
122
+ print(f" Pile-up: in forward model (N_sync={n_sync:,}), data left raw")
123
+ lo, hi = _build_bounds(n_exp, tau_min, tau_max, decay_work.max(),
124
+ has_tail, fit_bg, fit_sigma,
125
+ bg_init=bg_init, bg_upper=bg_upper,
126
+ sigma_max=sigma_max, irf_shift_bins=irf_shift_bins,
127
+ fit_tvb=fit_tvb, tvb_init=tvb_init, tvb_upper=tvb_upper)
128
+ bounds = list(zip(lo, hi))
129
+ if cost_function == 'chi2':
130
+ weights = np.sqrt(np.maximum(decay_work[fit_idx], 1.0))
131
+ def residuals(params):
132
+ model_vals = reconvolution_model(
133
+ params, tcspc_res, n_bins, irf_prompt,
134
+ n_exp, bg_fixed, has_tail, fit_bg, fit_sigma,
135
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
136
+ return (model_vals[fit_idx]
137
+ - decay_work[fit_idx]) / weights
138
+ else:
139
+ def residuals(params):
140
+ model_vals = reconvolution_model(
141
+ params, tcspc_res, n_bins, irf_prompt,
142
+ n_exp, bg_fixed, has_tail, fit_bg, fit_sigma,
143
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
144
+ n = decay_work[fit_idx]
145
+ m = np.maximum(model_vals[fit_idx], 1e-10)
146
+ dev = m - n
147
+ pos = n > 0
148
+ dev[pos] += n[pos] * np.log(n[pos] / m[pos])
149
+ dev = np.maximum(dev, 0.0)
150
+ r = np.sqrt(2.0 * dev)
151
+ r[m < n] *= -1
152
+ return r
153
+ if optimizer == 'lm_multistart':
154
+ n_log = n_exp
155
+ lo_scaled = np.asarray(lo, dtype=float).copy()
156
+ hi_scaled = np.asarray(hi, dtype=float).copy()
157
+ lo_scaled[:n_log] = np.log10(lo_scaled[:n_log])
158
+ hi_scaled[:n_log] = np.log10(hi_scaled[:n_log])
159
+ def to_scaled(params):
160
+ scaled = np.asarray(params, dtype=float).copy()
161
+ scaled[:n_log] = np.log10(scaled[:n_log])
162
+ return scaled
163
+ def from_scaled(params):
164
+ linear = np.asarray(params, dtype=float).copy()
165
+ linear[:n_log] = 10.0 ** linear[:n_log]
166
+ return linear
167
+ def residuals_scaled(params):
168
+ return residuals(from_scaled(params))
169
+ rng = np.random.default_rng(42)
170
+ best_res = None
171
+ best_cost = np.inf
172
+ for i in range(n_restarts + 1):
173
+ tau_ov = None if i == 0 else np.sort(
174
+ np.exp(rng.uniform(np.log(tau_min*1.001),
175
+ np.log(tau_max*0.999), n_exp)))
176
+ p0 = _pack_p0(n_exp, tau_min, tau_max, float(decay_work.max()),
177
+ has_tail, fit_bg, fit_sigma, bg_init,
178
+ tau_override=tau_ov, fit_tvb=fit_tvb, tvb_init=tvb_init)
179
+ try:
180
+ res = least_squares(residuals_scaled, to_scaled(p0),
181
+ bounds=(lo_scaled, hi_scaled), method='trf',
182
+ max_nfev=50000,
183
+ ftol=1e-13, xtol=1e-13, gtol=1e-13)
184
+ except Exception as exc:
185
+ print(f" Restart {i:2d}: failed ({exc})")
186
+ continue
187
+ tag = 'log-spaced' if i == 0 else 'random '
188
+ if res.cost < best_cost:
189
+ best_cost = res.cost
190
+ best_res = res
191
+ print(f" Restart {i:2d} ({tag}): cost={res.cost:.4e} ← best")
192
+ else:
193
+ print(f" Restart {i:2d} ({tag}): cost={res.cost:.4e}")
194
+ if best_res is None:
195
+ raise RuntimeError('All restarts failed.')
196
+ popt_work = from_scaled(best_res.x)
197
+ message = best_res.message
198
+ elif optimizer == 'de':
199
+ print(f" Differential evolution: popsize={de_popsize}, "
200
+ f"maxiter={de_maxiter}, workers={workers}")
201
+ bounds_log = list(bounds)
202
+ for i in range(n_exp):
203
+ lo_tau, hi_tau = bounds[i]
204
+ bounds_log[i] = (np.log10(lo_tau), np.log10(hi_tau))
205
+ if cost_function == 'poisson':
206
+ cost_fn = _DECostPoissonLogTau(
207
+ tcspc_res, n_bins, irf_prompt, n_exp, bg_fixed,
208
+ has_tail, fit_bg, fit_sigma,
209
+ fit_idx, decay_work,
210
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
211
+ else:
212
+ cost_fn = _DECostLogTau(
213
+ tcspc_res, n_bins, irf_prompt, n_exp, bg_fixed,
214
+ has_tail, fit_bg, fit_sigma,
215
+ fit_idx, decay_work, weights,
216
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
217
+ de_res = differential_evolution(
218
+ cost_fn, bounds=bounds_log,
219
+ maxiter=de_maxiter, popsize=de_popsize,
220
+ workers=workers, seed=42,
221
+ updating='deferred' if workers != 1 else 'immediate',
222
+ init='sobol',
223
+ disp=False)
224
+ popt_work = de_res.x.copy()
225
+ popt_work[:n_exp] = 10.0 ** popt_work[:n_exp]
226
+ message = f"DE success={de_res.success}, fun={de_res.fun:.4e}"
227
+ if polish:
228
+ print(' Running final LM polish...')
229
+ eps = 1e-10
230
+ popt_work = np.clip(popt_work, np.asarray(lo) + eps, np.asarray(hi) - eps)
231
+ try:
232
+ pol = least_squares(residuals, popt_work, bounds=(lo, hi),
233
+ method='trf', max_nfev=5000,
234
+ ftol=1e-13, xtol=1e-13, gtol=1e-13)
235
+ popt_work = pol.x
236
+ message += f"; polished cost={pol.cost:.4e}"
237
+ except ValueError as e:
238
+ print(f" Warning: LM polish failed ({e}) - using DE result")
239
+ else:
240
+ raise ValueError(f"Unknown optimizer: {optimizer!r}")
241
+ popt_original = popt_work.copy()
242
+ summary = _make_summary(popt_original, decay, tcspc_res, n_bins, irf_prompt,
243
+ n_exp, bg_fixed, has_tail, fit_bg, fit_sigma,
244
+ fit_idx, message, tvb_profile=tvb_profile,
245
+ fit_tvb=fit_tvb, n_sync=n_sync)
246
+ return popt_original, summary
247
+
248
+
249
+ def _make_summary(popt, decay, tcspc_res, n_bins, irf_prompt,
250
+ n_exp, bg_fixed, has_tail, fit_bg, fit_sigma,
251
+ fit_idx, message=None,
252
+ tvb_profile=None, fit_tvb=False, n_sync=None):
253
+ fit_start = int(fit_idx[0])
254
+ fit_end = int(fit_idx[-1]) + 1
255
+ taus = popt[:n_exp]
256
+ amps = popt[n_exp:2*n_exp]
257
+ order = np.argsort(-taus)
258
+ taus = taus[order]
259
+ amps = amps[order]
260
+ idx = 2 * n_exp
261
+ shift = popt[idx]; idx += 1
262
+ if fit_sigma:
263
+ sigma = popt[idx]; idx += 1
264
+ else:
265
+ sigma = 0.0
266
+ if fit_bg:
267
+ bg_fit = popt[idx]; idx += 1
268
+ else:
269
+ bg_fit = bg_fixed
270
+ if fit_tvb:
271
+ tvb_scale = popt[idx]; idx += 1
272
+ else:
273
+ tvb_scale = 0.0
274
+ if has_tail:
275
+ tail_amp = popt[idx]
276
+ tail_tau = popt[idx + 1]
277
+ else:
278
+ tail_amp = tail_tau = 0.0
279
+ model = reconvolution_model(popt, tcspc_res, n_bins, irf_prompt,
280
+ n_exp, bg_fixed, has_tail, fit_bg, fit_sigma,
281
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
282
+ d_win = decay[fit_idx].astype(float)
283
+ m_win = model[fit_idx]
284
+ sigma_w = np.sqrt(np.maximum(d_win, 1.0))
285
+ chi2 = float(np.sum(((d_win - m_win) / sigma_w)**2))
286
+ dof = max(len(fit_idx) - len(popt), 1)
287
+ rchi2 = chi2 / dof
288
+ p_val = float(1 - chi2_dist.cdf(chi2, df=dof))
289
+ resid = (decay - model) / np.sqrt(np.maximum(model, 1.0))
290
+ sigma_p = np.sqrt(np.maximum(m_win, 1.0))
291
+ chi2_p = float(np.sum(((d_win - m_win) / sigma_p)**2))
292
+ rchi2_p = chi2_p / dof
293
+ calibrated_chi2_p = calibrated_chi2(d_win, m_win)
294
+ peak_bin_loc = int(fit_idx[np.argmax(decay[fit_idx])])
295
+ tail_start = peak_bin_loc + max(1, int(0.05 * (fit_end - peak_bin_loc)))
296
+ tail_idx = fit_idx[fit_idx >= tail_start]
297
+ d_tail = decay[tail_idx].astype(float)
298
+ m_tail = model[tail_idx]
299
+ sw_tail = np.sqrt(np.maximum(d_tail, 1.0))
300
+ chi2_tail = float(np.sum(((d_tail - m_tail) / sw_tail)**2))
301
+ dof_tail = max(len(tail_idx) - len(popt), 1)
302
+ rchi2_tail = chi2_tail / dof_tail
303
+ sp_tail = np.sqrt(np.maximum(m_tail, 1.0))
304
+ chi2_tail_p = float(np.sum(((d_tail - m_tail) / sp_tail)**2))
305
+ rchi2_tail_p = chi2_tail_p / dof_tail
306
+ calibrated_chi2_tail_p = calibrated_chi2(d_tail, m_tail)
307
+ amp_sum = amps.sum() if amps.sum() > 0 else 1.0
308
+ fracs = amps / amp_sum
309
+ tau_amp = float(np.dot(fracs, taus))
310
+ tau_int = float(np.dot(amps, taus**2) / np.dot(amps, taus))
311
+ above = np.where(irf_prompt >= irf_prompt.max() / 2)[0]
312
+ fwhm_pr = (above[-1] - above[0]) if len(above) > 1 else 1
313
+ fwhm_eff = np.sqrt(fwhm_pr**2 + (2.3548 * sigma)**2) * tcspc_res * 1e9
314
+ return dict(
315
+ tcspc_res = tcspc_res,
316
+ taus_ns = taus * 1e9,
317
+ amps = amps,
318
+ fractions = fracs,
319
+ bg_fit = bg_fit,
320
+ tvb_scale = tvb_scale,
321
+ tau_mean_amp_ns = tau_amp * 1e9,
322
+ tau_mean_int_ns = tau_int * 1e9,
323
+ chi2 = chi2,
324
+ reduced_chi2 = rchi2,
325
+ reduced_chi2_tail = rchi2_tail,
326
+ chi2_pearson = chi2_p,
327
+ reduced_chi2_pearson = rchi2_p,
328
+ reduced_chi2_tail_pearson = rchi2_tail_p,
329
+ calibrated_chi2_pearson = calibrated_chi2_p,
330
+ calibrated_chi2_tail_pearson = calibrated_chi2_tail_p,
331
+ tail_start_bin = tail_start,
332
+ p_val = p_val,
333
+ dof = dof,
334
+ fit_window_bins = (fit_start, fit_end),
335
+ fit_window_ns = (fit_start*tcspc_res*1e9, fit_end*tcspc_res*1e9),
336
+ fit_idx = fit_idx,
337
+ irf_shift_bins = shift,
338
+ irf_sigma_bins = sigma,
339
+ irf_fwhm_eff_ns = fwhm_eff,
340
+ tail_amp = tail_amp,
341
+ tail_tau_ns = tail_tau * tcspc_res * 1e9,
342
+ model = model,
343
+ residuals = resid,
344
+ optimizer_msg = message,
345
+ )
346
+
347
+
348
+ def _basis_rows(taus, t_axis, tcspc_res, n_bins, tail, irf_fft=None, t0=0.0):
349
+ if tail:
350
+ return tail_basis(tcspc_res, n_bins, taus, t0)
351
+ basis = np.stack([np.exp(-t_axis / max(tau, 1e-15)) for tau in taus])
352
+ return np.array([np.real(np.fft.ifft(np.fft.fft(b) * irf_fft)) for b in basis])
353
+
354
+
355
+ def fit_summed_tail(decay, tcspc_res, n_bins,
356
+ fit_bg, n_exp, tau_min_ns, tau_max_ns,
357
+ fit_t0=False, t0_range_bins=5.0,
358
+ optimizer='de', n_restarts=8,
359
+ de_popsize=15, de_maxiter=1000,
360
+ workers=-1, polish=True,
361
+ cost_function='poisson',
362
+ tvb_profile=None, fit_tvb=False,
363
+ fit_start_ns=None, fit_end_ns=None, exclude_ns=None,
364
+ n_sync=None):
365
+ tau_min = tau_min_ns * 1e-9
366
+ tau_max = tau_max_ns * 1e-9
367
+ if cost_function not in ('chi2', 'poisson'):
368
+ raise ValueError(f"Unknown cost_function: {cost_function!r}")
369
+ decay_work = decay.astype(float)
370
+ if decay_work.max() == 0:
371
+ raise ValueError('Decay has zero maximum - cannot fit.')
372
+ peak_bin = int(np.argmax(decay_work))
373
+ bg_init = estimate_bg(decay_work, peak_bin)
374
+ bg_fixed = bg_init if not fit_bg else 0.0
375
+ t0_fixed = peak_bin * tcspc_res
376
+ t0_range = float(t0_range_bins) * tcspc_res
377
+ fit_end = find_fit_end(decay_work, peak_bin, tau_max, tcspc_res, n_bins)
378
+ standard_fit_end = int(round(44.9455 / (tcspc_res * 1e9)))
379
+ fit_end = min(fit_end, standard_fit_end)
380
+ fit_start = find_tail_fit_start(decay_work, peak_bin, n_bins)
381
+ fit_start = max(0, min(fit_start, fit_end - 10))
382
+ if fit_end_ns is not None:
383
+ fit_end = min(n_bins, bins_from_ns(fit_end_ns, tcspc_res))
384
+ if fit_start_ns is not None:
385
+ fit_start = max(0, min(bins_from_ns(fit_start_ns, tcspc_res), fit_end - 2))
386
+ exclude_bins = [(bins_from_ns(lo, tcspc_res), bins_from_ns(hi, tcspc_res))
387
+ for lo, hi in (exclude_ns or [])]
388
+ fit_idx = build_fit_idx(fit_start, fit_end, n_bins, exclude_bins)
389
+ bg_upper = max(bg_init * 2.0, bg_init + 10.0)
390
+ if fit_tvb and tvb_profile is None:
391
+ raise ValueError('fit_tvb=True requires a tvb_profile.')
392
+ tvb_init = float(bg_init * n_bins) if fit_tvb else 0.0
393
+ tvb_upper = float(decay_work.sum()) if fit_tvb else None
394
+ if fit_tvb:
395
+ print(f" TVB: free scale on measured profile, init={tvb_init:.1f}, upper={tvb_upper:.1f}")
396
+ print(f" Cost function: {cost_function}")
397
+ print(f" Tail fit: no IRF used, no reconvolution")
398
+ print(f" t0 = {t0_fixed*1e9:.3f} ns (decay peak, bin {peak_bin})"
399
+ f"{f', free within ±{t0_range_bins:.0f} bins' if fit_t0 else ', fixed'}")
400
+ print(f" bg initial guess = {bg_init:.3f} cts/bin"
401
+ f", upper bound = {bg_upper:.3f} "
402
+ f"({'free param' if fit_bg else 'fixed'})")
403
+ print(f" Fit window: bins {fit_start}-{fit_end} "
404
+ f"({fit_start*tcspc_res*1e9:.2f}-{fit_end*tcspc_res*1e9:.2f} ns), "
405
+ f"{len(fit_idx)} bins fitted"
406
+ f"{' (user-set)' if (fit_start_ns is not None or fit_end_ns is not None) else ' (auto)'}")
407
+ if exclude_bins:
408
+ for (lo_b, hi_b), (lo_n, hi_n) in zip(exclude_bins, exclude_ns):
409
+ print(f" Excluded: bins {lo_b}-{hi_b} ({lo_n:.2f}-{hi_n:.2f} ns)")
410
+ if n_sync:
411
+ print(f" Pile-up: in forward model (N_sync={n_sync:,}), data left raw")
412
+ lo, hi = _build_bounds_tail(n_exp, tau_min, tau_max, decay_work.max(), fit_bg,
413
+ bg_init=bg_init, bg_upper=bg_upper,
414
+ fit_t0=fit_t0, t0_init=t0_fixed, t0_range=t0_range,
415
+ fit_tvb=fit_tvb, tvb_init=tvb_init, tvb_upper=tvb_upper)
416
+ bounds = list(zip(lo, hi))
417
+ def _model_of(params):
418
+ return tail_model(params, tcspc_res, n_bins, n_exp, bg_fixed, fit_bg,
419
+ fit_t0=fit_t0, t0_fixed=t0_fixed,
420
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
421
+ if cost_function == 'chi2':
422
+ weights = np.sqrt(np.maximum(decay_work[fit_idx], 1.0))
423
+ def residuals(params):
424
+ model_vals = _model_of(params)
425
+ return (model_vals[fit_idx] - decay_work[fit_idx]) / weights
426
+ else:
427
+ def residuals(params):
428
+ model_vals = _model_of(params)
429
+ n = decay_work[fit_idx]
430
+ m = np.maximum(model_vals[fit_idx], 1e-10)
431
+ dev = m - n
432
+ pos = n > 0
433
+ dev[pos] += n[pos] * np.log(n[pos] / m[pos])
434
+ dev = np.maximum(dev, 0.0)
435
+ r = np.sqrt(2.0 * dev)
436
+ r[m < n] *= -1
437
+ return r
438
+ if optimizer == 'lm_multistart':
439
+ rng = np.random.default_rng(42)
440
+ best_res = None
441
+ best_cost = np.inf
442
+ for i in range(n_restarts + 1):
443
+ tau_ov = None if i == 0 else np.sort(
444
+ np.exp(rng.uniform(np.log(tau_min*1.001),
445
+ np.log(tau_max*0.999), n_exp)))
446
+ p0 = _pack_p0_tail(n_exp, tau_min, tau_max, float(decay_work.max()),
447
+ fit_bg, bg_init, tau_override=tau_ov,
448
+ fit_t0=fit_t0, t0_init=t0_fixed,
449
+ fit_tvb=fit_tvb, tvb_init=tvb_init)
450
+ try:
451
+ res = least_squares(residuals, p0, bounds=(lo, hi), method='trf',
452
+ max_nfev=50000,
453
+ ftol=1e-13, xtol=1e-13, gtol=1e-13)
454
+ except Exception as exc:
455
+ print(f" Restart {i:2d}: failed ({exc})")
456
+ continue
457
+ tag = 'log-spaced' if i == 0 else 'random '
458
+ if res.cost < best_cost:
459
+ best_cost = res.cost
460
+ best_res = res
461
+ print(f" Restart {i:2d} ({tag}): cost={res.cost:.4e} ← best")
462
+ else:
463
+ print(f" Restart {i:2d} ({tag}): cost={res.cost:.4e}")
464
+ if best_res is None:
465
+ raise RuntimeError('All restarts failed.')
466
+ popt_work = best_res.x
467
+ message = best_res.message
468
+ elif optimizer == 'de':
469
+ print(f" Differential evolution: popsize={de_popsize}, "
470
+ f"maxiter={de_maxiter}, workers={workers}")
471
+ bounds_log = list(bounds)
472
+ for i in range(n_exp):
473
+ lo_tau, hi_tau = bounds[i]
474
+ bounds_log[i] = (np.log10(lo_tau), np.log10(hi_tau))
475
+ if cost_function == 'poisson':
476
+ cost_fn = _DECostTailPoissonLogTau(
477
+ tcspc_res, n_bins, n_exp, bg_fixed, fit_bg,
478
+ fit_idx, decay_work,
479
+ fit_t0=fit_t0, t0_fixed=t0_fixed,
480
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
481
+ else:
482
+ cost_fn = _DECostTailLogTau(
483
+ tcspc_res, n_bins, n_exp, bg_fixed, fit_bg,
484
+ fit_idx, decay_work, weights,
485
+ fit_t0=fit_t0, t0_fixed=t0_fixed,
486
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
487
+ de_res = differential_evolution(
488
+ cost_fn, bounds=bounds_log,
489
+ maxiter=de_maxiter, popsize=de_popsize,
490
+ workers=workers, seed=42,
491
+ updating='deferred' if workers != 1 else 'immediate',
492
+ init='sobol',
493
+ disp=False)
494
+ popt_work = de_res.x.copy()
495
+ popt_work[:n_exp] = 10.0 ** popt_work[:n_exp]
496
+ message = f"DE success={de_res.success}, fun={de_res.fun:.4e}"
497
+ if polish:
498
+ print(' Running final LM polish...')
499
+ eps = 1e-10
500
+ popt_work = np.clip(popt_work, np.asarray(lo) + eps, np.asarray(hi) - eps)
501
+ try:
502
+ pol = least_squares(residuals, popt_work, bounds=(lo, hi),
503
+ method='trf', max_nfev=5000,
504
+ ftol=1e-13, xtol=1e-13, gtol=1e-13)
505
+ popt_work = pol.x
506
+ message += f"; polished cost={pol.cost:.4e}"
507
+ except ValueError as e:
508
+ print(f" Warning: LM polish failed ({e}) - using DE result")
509
+ else:
510
+ raise ValueError(f"Unknown optimizer: {optimizer!r}")
511
+ summary = _make_summary_tail(popt_work, decay, tcspc_res, n_bins,
512
+ n_exp, bg_fixed, fit_bg, fit_idx, message,
513
+ fit_t0=fit_t0, t0_fixed=t0_fixed,
514
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
515
+ return popt_work, summary
516
+
517
+
518
+ def _make_summary_tail(popt, decay, tcspc_res, n_bins,
519
+ n_exp, bg_fixed, fit_bg, fit_idx, message=None,
520
+ fit_t0=False, t0_fixed=0.0,
521
+ tvb_profile=None, fit_tvb=False, n_sync=None):
522
+ fit_start = int(fit_idx[0])
523
+ fit_end = int(fit_idx[-1]) + 1
524
+ taus, amps, t0, bg_fit, tvb_scale = unpack_tail_params(
525
+ popt, n_exp, fit_t0, fit_bg, fit_tvb,
526
+ t0_fixed=t0_fixed, bg_fixed=bg_fixed)
527
+ order = np.argsort(-taus)
528
+ taus = taus[order]
529
+ amps = amps[order]
530
+ model = tail_model(popt, tcspc_res, n_bins, n_exp, bg_fixed, fit_bg,
531
+ fit_t0=fit_t0, t0_fixed=t0_fixed,
532
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
533
+ d_win = decay[fit_idx].astype(float)
534
+ m_win = model[fit_idx]
535
+ sigma_w = np.sqrt(np.maximum(d_win, 1.0))
536
+ chi2 = float(np.sum(((d_win - m_win) / sigma_w)**2))
537
+ dof = max(len(fit_idx) - len(popt), 1)
538
+ rchi2 = chi2 / dof
539
+ p_val = float(1 - chi2_dist.cdf(chi2, df=dof))
540
+ resid = (decay - model) / np.sqrt(np.maximum(model, 1.0))
541
+ sigma_p = np.sqrt(np.maximum(m_win, 1.0))
542
+ chi2_p = float(np.sum(((d_win - m_win) / sigma_p)**2))
543
+ rchi2_p = chi2_p / dof
544
+ calibrated_chi2_p = calibrated_chi2(d_win, m_win)
545
+ amp_sum = amps.sum() if amps.sum() > 0 else 1.0
546
+ fracs = amps / amp_sum
547
+ tau_amp = float(np.dot(fracs, taus))
548
+ tau_int = float(np.dot(amps, taus**2) / np.dot(amps, taus))
549
+ intensities = amps * taus / tcspc_res
550
+ i_sum = float(intensities.sum())
551
+ return dict(
552
+ fit_model = 'tail',
553
+ tcspc_res = tcspc_res,
554
+ taus_ns = taus * 1e9,
555
+ amps = amps,
556
+ fractions = fracs,
557
+ intensities = intensities,
558
+ intensity_fractions = intensities / (i_sum if i_sum > 0 else 1.0),
559
+ i_sum = i_sum,
560
+ a_sum = float(amps.sum()),
561
+ t0_ns = t0 * 1e9,
562
+ bg_fit = bg_fit,
563
+ tvb_scale = tvb_scale,
564
+ tau_mean_amp_ns = tau_amp * 1e9,
565
+ tau_mean_int_ns = tau_int * 1e9,
566
+ chi2 = chi2,
567
+ reduced_chi2 = rchi2,
568
+ reduced_chi2_tail = rchi2,
569
+ chi2_pearson = chi2_p,
570
+ reduced_chi2_pearson = rchi2_p,
571
+ reduced_chi2_tail_pearson = rchi2_p,
572
+ calibrated_chi2_pearson = calibrated_chi2_p,
573
+ calibrated_chi2_tail_pearson = calibrated_chi2_p,
574
+ tail_start_bin = fit_start,
575
+ p_val = p_val,
576
+ dof = dof,
577
+ fit_idx = fit_idx,
578
+ fit_window_bins = (fit_start, fit_end),
579
+ fit_window_ns = (fit_start*tcspc_res*1e9, fit_end*tcspc_res*1e9),
580
+ model = model,
581
+ residuals = resid,
582
+ optimizer_msg = message,
583
+ )
584
+
585
+
586
+ def fit_per_pixel(stack, tcspc_res, n_bins, irf_prompt,
587
+ has_tail, fit_bg, fit_sigma,
588
+ global_popt, n_exp,
589
+ min_photons=MIN_PHOTONS_PERPIX,
590
+ tau_min_ns=None, tau_max_ns=None,
591
+ correct_pileup=False, n_sync=None,
592
+ fit_idx=None,
593
+ progress_callback=None,
594
+ free_tau=False,
595
+ use_gpu='auto',
596
+ gpu_backend=None,
597
+ tvb_profile=None, fit_tvb=False,
598
+ fit_model='reconv', fit_t0=False, t0_fixed=0.0):
599
+ ny, nx, _ = stack.shape
600
+ _tail = fit_model == 'tail'
601
+ if free_tau:
602
+ n_valid = int((stack.sum(axis=2) >= min_photons).sum())
603
+ if n_valid > _FREE_TAU_WARN_PIXELS:
604
+ print(f' [!] free-tau per-pixel on {n_valid:,} pixels is slow (iterative fit each); '
605
+ f'fixed-tau is ~100x faster - untick free-tau or draw a smaller ROI')
606
+ _n_sync_px = 0
607
+ if correct_pileup:
608
+ if not n_sync:
609
+ raise ValueError('pile-up correction requested but this file exposes no '
610
+ 'excitation-pulse count (N_sync); Coates is unavailable here')
611
+ _n_sync_px = int(n_sync / max(ny * nx, 1))
612
+ _max_px = float(stack.sum(axis=2).max())
613
+ if _n_sync_px <= _max_px:
614
+ raise ValueError(
615
+ f'pile-up correction needs more pulses than photons per pixel; got '
616
+ f'{_n_sync_px:,} pulses/px vs {_max_px:,.0f} photons in the brightest pixel. '
617
+ f'Check the N_sync reported by the reader.')
618
+ tvb_on = bool(fit_tvb) and tvb_profile is not None
619
+ if _tail:
620
+ taus_fixed, _, t0_px, _, _ = unpack_tail_params(
621
+ global_popt, n_exp, fit_t0, fit_bg, fit_tvb, t0_fixed=t0_fixed)
622
+ shift = sigma = tamp = 0.0
623
+ ttau = 1.0
624
+ irf_fixed = None
625
+ irf_fft = None
626
+ if fit_idx is None:
627
+ fit_idx = np.arange(int(round(t0_px / tcspc_res)), n_bins, dtype=int)
628
+ else:
629
+ t0_px = 0.0
630
+ idx = 2 * n_exp
631
+ shift = global_popt[idx]; idx += 1
632
+ sigma = global_popt[idx] if fit_sigma else 0.0
633
+ if fit_sigma: idx += 1
634
+ if fit_bg: idx += 1
635
+ tamp = global_popt[idx] if has_tail else 0.0
636
+ ttau = global_popt[idx + 1] if has_tail else 1.0
637
+ taus_fixed = global_popt[:n_exp]
638
+ irf_fixed = build_full_irf(irf_prompt, shift, sigma, tamp, ttau, n_bins)
639
+ irf_fft = np.fft.fft(irf_fixed)
640
+ fit_idx = np.arange(n_bins) if fit_idx is None else np.asarray(fit_idx, dtype=int)
641
+ _windowed = len(fit_idx) != n_bins
642
+ t_axis = np.arange(n_bins, dtype=float) * tcspc_res
643
+ conv_basis = _basis_rows(taus_fixed, t_axis, tcspc_res, n_bins, _tail,
644
+ irf_fft=irf_fft, t0=t0_px)
645
+ A = conv_basis.T
646
+ A_fit = A[fit_idx]
647
+ if _tail and use_gpu is not False:
648
+ print(f' [per-pixel] tail fit uses the CPU path; the projection has to be '
649
+ f'restricted to bins past t0')
650
+ _gpu_windowed_ok = not (_windowed and n_exp == 1 and tvb_on)
651
+ if _windowed and use_gpu is not False and not _tail and n_exp == 1 and tvb_on:
652
+ print(f' [per-pixel] fit window with a time-varying background is not supported '
653
+ f'on the GPU for one-exponential fits, using the CPU path')
654
+ if use_gpu is not False and not _tail and _gpu_windowed_ok:
655
+ _backend = gpu_backend if gpu_backend is not None else (
656
+ None if _gpu_backend_cache is _GPU_BACKEND_UNSET else _gpu_backend_cache
657
+ )
658
+ if _backend is not None:
659
+ if not free_tau or n_exp == 1:
660
+ if n_exp == 1:
661
+ _lo = (tau_min_ns if tau_min_ns is not None
662
+ else max(taus_fixed[0] * 1e9 / 20.0, 0.05)) * 1e-9
663
+ _hi = (tau_max_ns if tau_max_ns is not None
664
+ else min(taus_fixed[0] * 1e9 * 20.0, 45.0)) * 1e-9
665
+ _N_GRID = tau_grid_points()
666
+ _tau_grid = np.logspace(np.log10(_lo), np.log10(_hi), _N_GRID)
667
+ _basis_grid = _basis_rows(_tau_grid, t_axis, tcspc_res, n_bins,
668
+ _tail, irf_fft=irf_fft, t0=t0_px)
669
+ _bb_grid = np.maximum((_basis_grid ** 2).sum(axis=1), 1e-20)
670
+ return _backend.batch_grid_scan_1exp(
671
+ stack, _basis_grid, _bb_grid, _tau_grid,
672
+ min_photons, correct_pileup, _n_sync_px,
673
+ progress_callback,
674
+ tvb_profile=tvb_profile if tvb_on else None,
675
+ fit_tvb=tvb_on,
676
+ fit_idx=fit_idx if _windowed else None,
677
+ )
678
+ else:
679
+ return _backend.batch_fixed_tau(
680
+ stack, A, taus_fixed,
681
+ min_photons, correct_pileup, _n_sync_px,
682
+ progress_callback,
683
+ tvb_profile=tvb_profile if tvb_on else None,
684
+ fit_tvb=tvb_on,
685
+ fit_idx=fit_idx if _windowed else None,
686
+ )
687
+ else:
688
+ _tau_min_s = (tau_min_ns if tau_min_ns is not None
689
+ else taus_fixed.min() * 1e9 * 0.1) * 1e-9
690
+ _tau_max_s = (tau_max_ns if tau_max_ns is not None
691
+ else taus_fixed.max() * 1e9 * 10.0) * 1e-9
692
+ return _backend.batch_free_tau_fit(
693
+ stack, irf_fixed, tcspc_res,
694
+ taus_fixed, _tau_min_s, _tau_max_s,
695
+ n_exp, min_photons, correct_pileup, _n_sync_px,
696
+ tvb_profile=tvb_profile if tvb_on else None,
697
+ fit_tvb=tvb_on,
698
+ fit_idx=fit_idx if _windowed else None,
699
+ )
700
+ if tvb_on:
701
+ B_cpu = np.asarray(tvb_profile, dtype=float)
702
+ A_aug = np.column_stack([A, B_cpu, np.ones(n_bins)])
703
+ maps = dict(
704
+ intensity = stack.sum(axis=2),
705
+ tau_mean_int = np.full((ny, nx), np.nan),
706
+ tau_mean_amp = np.full((ny, nx), np.nan),
707
+ chi2_r = np.full((ny, nx), np.nan),
708
+ calibrated_chi2_r = np.full((ny, nx), np.nan),
709
+ )
710
+ for i in range(n_exp):
711
+ maps[f"alpha_{i+1}"] = np.full((ny, nx), np.nan)
712
+ maps[f"frac_{i+1}"] = np.full((ny, nx), np.nan)
713
+ maps[f"tau_{i+1}"] = (np.full((ny, nx), np.nan)
714
+ if n_exp == 1 or free_tau
715
+ else np.full((ny, nx), taus_fixed[i] * 1e9))
716
+ maps[f"a{i+1}"] = maps[f"alpha_{i+1}"]
717
+ if tvb_on:
718
+ maps['tvb_scale'] = np.full((ny, nx), np.nan)
719
+ fitted = skipped = 0
720
+ t0 = time.time()
721
+ if n_exp == 1:
722
+ _lo = (tau_min_ns if tau_min_ns is not None
723
+ else max(taus_fixed[0] * 1e9 / 20.0, 0.05)) * 1e-9
724
+ _hi = (tau_max_ns if tau_max_ns is not None
725
+ else min(taus_fixed[0] * 1e9 * 20.0, 45.0)) * 1e-9
726
+ _N_GRID = tau_grid_points()
727
+ tau_grid = np.logspace(np.log10(_lo), np.log10(_hi), _N_GRID)
728
+ basis_grid = _basis_rows(tau_grid, t_axis, tcspc_res, n_bins, _tail,
729
+ irf_fft=irf_fft, t0=t0_px)
730
+ basis_fit = basis_grid[:, fit_idx]
731
+ bb_grid = np.maximum((basis_fit ** 2).sum(axis=1), 1e-20)
732
+ if tvb_on:
733
+ _tvb_prof_f = np.asarray(tvb_profile, dtype=float)[fit_idx]
734
+ _tvb_U = np.column_stack([_tvb_prof_f, np.ones(len(fit_idx))])
735
+ _tvb_Up = np.linalg.pinv(_tvb_U)
736
+ _basis_perp = basis_fit - (basis_fit @ _tvb_Up.T) @ _tvb_U.T
737
+ _bb_perp = np.maximum((_basis_perp ** 2).sum(axis=1), 1e-20)
738
+ for yi in tqdm(range(ny), desc=' Per-pixel rows', disable=True):
739
+ if progress_callback is not None:
740
+ progress_callback(yi, ny)
741
+ decay_row = stack[yi, :, :].astype(float)
742
+ ph_counts = decay_row.sum(axis=1)
743
+ valid_xi = np.where(ph_counts >= min_photons)[0]
744
+ skipped += nx - len(valid_xi)
745
+ if len(valid_xi) == 0:
746
+ continue
747
+ if tvb_on:
748
+ dvf = decay_row[valid_xi].astype(float)
749
+ if correct_pileup and _n_sync_px > 0:
750
+ dvf = np.array([coates_pileup_correction(dvf[k], _n_sync_px)
751
+ for k in range(len(valid_xi))])
752
+ dvf_f = dvf[:, fit_idx]
753
+ d_perp = dvf_f - (dvf_f @ _tvb_Up.T) @ _tvb_U.T
754
+ bd_p = d_perp @ _basis_perp.T
755
+ d_sq = (d_perp ** 2).sum(axis=1)
756
+ costs_p = d_sq[:, np.newaxis] - np.maximum(bd_p, 0.0) ** 2 / _bb_perp
757
+ best_g = np.argmin(costs_p, axis=1)
758
+ amp_v = np.maximum(bd_p[np.arange(len(valid_xi)), best_g] / _bb_perp[best_g], 0.0)
759
+ resid_after = dvf_f - amp_v[:, np.newaxis] * basis_fit[best_g]
760
+ vz = resid_after @ _tvb_Up.T
761
+ tvb_v = np.maximum(vz[:, 0], 0.0)
762
+ bg_z = vz[:, 1]
763
+ tau_v = tau_grid[best_g]
764
+ for k, xi in enumerate(valid_xi):
765
+ if amp_v[k] <= 0:
766
+ skipped += 1
767
+ continue
768
+ tau_ns = float(tau_v[k] * 1e9)
769
+ maps['tau_1'][yi, xi] = tau_ns
770
+ maps['tau_mean_amp'][yi, xi] = tau_ns
771
+ maps['tau_mean_int'][yi, xi] = tau_ns
772
+ maps['alpha_1'][yi, xi] = float(amp_v[k])
773
+ maps['frac_1'][yi, xi] = 1.0
774
+ maps['tvb_scale'][yi, xi] = float(tvb_v[k])
775
+ model_px = amp_v[k] * basis_fit[best_g[k]] + tvb_v[k] * _tvb_prof_f + bg_z[k]
776
+ resid = dvf_f[k] - model_px
777
+ chi2_px = float(np.sum(resid ** 2 / np.maximum(model_px, 1.0)))
778
+ maps['chi2_r'][yi, xi] = chi2_px / max(len(fit_idx) - 2, 1)
779
+ maps['calibrated_chi2_r'][yi, xi] = calibrated_chi2(dvf_f[k], model_px)
780
+ fitted += 1
781
+ continue
782
+ dv = decay_row[valid_xi]
783
+ peak_b_v = np.argmax(dv, axis=1)
784
+ bg_v = np.array([estimate_bg(dv[k], int(peak_b_v[k]))
785
+ for k in range(len(valid_xi))])
786
+ dc_v = np.maximum(dv - bg_v[:, np.newaxis], 0.0)
787
+ if correct_pileup and _n_sync_px > 0:
788
+ dc_v = np.array([
789
+ coates_pileup_correction(dc_v[k], _n_sync_px)
790
+ for k in range(len(valid_xi))
791
+ ])
792
+ dc_f = dc_v[:, fit_idx]
793
+ bd = dc_f @ basis_fit.T
794
+ amps_g = np.maximum(bd / bb_grid, 0.0)
795
+ dc_sq = (dc_f ** 2).sum(axis=1)
796
+ costs = dc_sq[:, np.newaxis] - np.maximum(bd, 0.0) ** 2 / bb_grid
797
+ best_g = np.argmin(costs, axis=1)
798
+ tau_v = tau_grid[best_g]
799
+ amp_v = amps_g[np.arange(len(valid_xi)), best_g]
800
+ good = amp_v > 0
801
+ skipped += int((~good).sum())
802
+ for k, xi in enumerate(valid_xi):
803
+ if not good[k]:
804
+ continue
805
+ tau_ns = float(tau_v[k] * 1e9)
806
+ maps['tau_1'][yi, xi] = tau_ns
807
+ maps['tau_mean_amp'][yi, xi] = tau_ns
808
+ maps['tau_mean_int'][yi, xi] = tau_ns
809
+ maps['alpha_1'][yi, xi] = float(amp_v[k])
810
+ maps['frac_1'][yi, xi] = 1.0
811
+ best_b = basis_fit[best_g[k]]
812
+ model_px = float(amp_v[k]) * best_b + bg_v[k]
813
+ resid = dv[k][fit_idx] - model_px
814
+ chi2_px = float(np.sum(resid ** 2 / np.maximum(model_px, 1.0)))
815
+ maps['chi2_r'][yi, xi] = chi2_px / max(len(fit_idx) - 2, 1)
816
+ maps['calibrated_chi2_r'][yi, xi] = calibrated_chi2(dv[k][fit_idx], model_px)
817
+ fitted += 1
818
+ elif not free_tau:
819
+ for yi in tqdm(range(ny), desc=' Per-pixel rows', disable=True):
820
+ if progress_callback is not None:
821
+ progress_callback(yi, ny)
822
+ for xi in range(nx):
823
+ decay_px = stack[yi, xi, :]
824
+ if decay_px.sum() < min_photons:
825
+ skipped += 1
826
+ continue
827
+ if tvb_on:
828
+ dfit = decay_px.astype(float)
829
+ if correct_pileup and _n_sync_px > 0:
830
+ dfit = coates_pileup_correction(dfit, _n_sync_px)
831
+ coeffs_px, _ = nnls(A_aug[fit_idx], dfit[fit_idx])
832
+ amps_px = coeffs_px[:n_exp]
833
+ tvb_px = coeffs_px[n_exp]
834
+ bg_px = coeffs_px[n_exp + 1]
835
+ model_px = A_fit @ amps_px + tvb_px * B_cpu[fit_idx] + bg_px
836
+ else:
837
+ bg_px = estimate_bg(decay_px, int(np.argmax(decay_px)))
838
+ data_corr = np.maximum(decay_px - bg_px, 0.0)
839
+ if correct_pileup and _n_sync_px > 0:
840
+ data_corr = coates_pileup_correction(data_corr, _n_sync_px)
841
+ amps_px, _ = nnls(A_fit, data_corr[fit_idx])
842
+ model_px = A_fit @ amps_px + bg_px
843
+ resid = decay_px[fit_idx] - model_px
844
+ chi2_px = float(np.sum(resid**2 / np.maximum(model_px, 1.0)))
845
+ dof_px = max(len(fit_idx) - n_exp, 1)
846
+ amp_sum = amps_px.sum()
847
+ if amp_sum <= 0:
848
+ skipped += 1
849
+ continue
850
+ fracs_px = amps_px / amp_sum
851
+ taus_ns = taus_fixed * 1e9
852
+ tau_amp = float(np.dot(fracs_px, taus_ns))
853
+ denom = np.dot(amps_px, taus_ns)
854
+ tau_int = float(np.dot(amps_px, taus_ns**2) / denom) \
855
+ if denom > 0 else np.nan
856
+ maps['tau_mean_int'][yi, xi] = tau_int
857
+ maps['tau_mean_amp'][yi, xi] = tau_amp
858
+ maps['chi2_r'][yi, xi] = chi2_px / dof_px
859
+ maps['calibrated_chi2_r'][yi, xi] = calibrated_chi2(
860
+ decay_px[fit_idx], model_px)
861
+ for i in range(n_exp):
862
+ maps[f"alpha_{i+1}"][yi, xi] = amps_px[i]
863
+ maps[f"frac_{i+1}"][yi, xi] = fracs_px[i]
864
+ if tvb_on:
865
+ maps['tvb_scale'][yi, xi] = tvb_px
866
+ fitted += 1
867
+ else:
868
+ tau_min_s = (tau_min_ns if tau_min_ns is not None
869
+ else taus_fixed.min() * 1e9 * 0.1) * 1e-9
870
+ tau_max_s = (tau_max_ns if tau_max_ns is not None
871
+ else taus_fixed.max() * 1e9 * 10.0) * 1e-9
872
+ amp_hi = float(stack.max()) * 10.0
873
+ lo_px = np.array([tau_min_s] * n_exp + [0.0] * n_exp)
874
+ hi_px = np.array([tau_max_s] * n_exp + [amp_hi] * n_exp)
875
+ p0_px = np.concatenate([taus_fixed, np.full(n_exp, float(stack.max()) / n_exp)])
876
+ if tvb_on:
877
+ tvb_hi = float(stack.sum(axis=2).max())
878
+ lo_px = np.concatenate([lo_px, [0.0]])
879
+ hi_px = np.concatenate([hi_px, [tvb_hi]])
880
+ p0_px = np.concatenate([p0_px, [float(stack.max())]])
881
+ for yi in tqdm(range(ny), desc=' Per-pixel rows (free-τ)', disable=True):
882
+ if progress_callback is not None:
883
+ progress_callback(yi, ny)
884
+ for xi in range(nx):
885
+ decay_px = stack[yi, xi, :].astype(float)
886
+ if decay_px.sum() < min_photons:
887
+ skipped += 1
888
+ continue
889
+ bg_px = estimate_bg(decay_px, int(np.argmax(decay_px)))
890
+ data_corr = np.maximum(decay_px - bg_px, 0.0)
891
+ if correct_pileup and _n_sync_px > 0:
892
+ data_corr = coates_pileup_correction(data_corr, _n_sync_px)
893
+ def _make_full(p_px):
894
+ taus_p = p_px[:n_exp]
895
+ amps_p = p_px[n_exp:2 * n_exp]
896
+ if _tail:
897
+ full = list(taus_p) + list(amps_p)
898
+ if tvb_on:
899
+ full.append(p_px[2 * n_exp])
900
+ return full
901
+ full = list(taus_p) + list(amps_p) + [shift]
902
+ if fit_sigma:
903
+ full.append(sigma)
904
+ if tvb_on:
905
+ full.append(p_px[2 * n_exp])
906
+ elif fit_bg:
907
+ full.append(bg_px)
908
+ if has_tail:
909
+ full.extend([tamp, ttau])
910
+ return full
911
+ def _eval_model(full_p, _bg):
912
+ if _tail:
913
+ return tail_model(
914
+ full_p, tcspc_res, n_bins, n_exp,
915
+ 0.0 if tvb_on else _bg, False,
916
+ fit_t0=False, t0_fixed=t0_px,
917
+ tvb_profile=tvb_profile if tvb_on else None,
918
+ fit_tvb=tvb_on)
919
+ if tvb_on:
920
+ return reconvolution_model(
921
+ full_p, tcspc_res, n_bins, irf_prompt,
922
+ n_exp, 0.0, has_tail, False, fit_sigma,
923
+ tvb_profile=tvb_profile, fit_tvb=True)
924
+ return reconvolution_model(
925
+ full_p, tcspc_res, n_bins, irf_prompt,
926
+ n_exp, _bg, has_tail, False, fit_sigma)
927
+ w_px = np.sqrt(np.maximum(decay_px, 1.0))
928
+ def _resid(p_px, _decay=decay_px, _bg=bg_px, _w=w_px):
929
+ model_vals = _eval_model(np.array(_make_full(p_px)), _bg)
930
+ return (model_vals[fit_idx] - _decay[fit_idx]) / _w[fit_idx]
931
+ try:
932
+ res = least_squares(
933
+ _resid, p0_px,
934
+ bounds=(lo_px, hi_px),
935
+ method='trf', max_nfev=500,
936
+ ftol=1e-8, xtol=1e-8, gtol=1e-8)
937
+ p_sol = res.x
938
+ except Exception:
939
+ skipped += 1
940
+ continue
941
+ taus_sol = p_sol[:n_exp]
942
+ amps_sol = p_sol[n_exp:2 * n_exp]
943
+ amp_sum = amps_sol.sum()
944
+ if amp_sum <= 0:
945
+ skipped += 1
946
+ continue
947
+ order = np.argsort(taus_sol)
948
+ taus_sol = taus_sol[order]
949
+ amps_sol = amps_sol[order]
950
+ fracs_px = amps_sol / amp_sum
951
+ taus_ns = taus_sol * 1e9
952
+ tau_amp = float(np.dot(fracs_px, taus_ns))
953
+ denom = np.dot(amps_sol, taus_ns)
954
+ tau_int = float(np.dot(amps_sol, taus_ns**2) / denom) \
955
+ if denom > 0 else np.nan
956
+ full_sol = np.array(_make_full(p_sol))
957
+ model_sol = _eval_model(full_sol, bg_px)
958
+ resid_sol = decay_px[fit_idx] - model_sol[fit_idx]
959
+ chi2_px = float(np.sum(resid_sol**2 / np.maximum(model_sol[fit_idx], 1.0)))
960
+ dof_px = max(len(fit_idx) - 2 * n_exp, 1)
961
+ maps['tau_mean_int'][yi, xi] = tau_int
962
+ maps['tau_mean_amp'][yi, xi] = tau_amp
963
+ maps['chi2_r'][yi, xi] = chi2_px / dof_px
964
+ maps['calibrated_chi2_r'][yi, xi] = calibrated_chi2(
965
+ decay_px[fit_idx], model_sol[fit_idx])
966
+ for i in range(n_exp):
967
+ maps[f"tau_{i+1}"][yi, xi] = taus_ns[i]
968
+ maps[f"alpha_{i+1}"][yi, xi] = amps_sol[i]
969
+ maps[f"frac_{i+1}"][yi, xi] = fracs_px[i]
970
+ if tvb_on:
971
+ maps['tvb_scale'][yi, xi] = float(p_sol[2 * n_exp])
972
+ fitted += 1
973
+ elapsed = time.time() - t0
974
+ return maps
975
+
976
+ def fit_summed_dist(decay, tcspc_res, n_bins, irf_prompt,
977
+ n_components, dist_type,
978
+ fit_bg, fit_sigma,
979
+ tau_min_ns, tau_max_ns,
980
+ optimizer='de', n_restarts=8,
981
+ de_popsize=15, de_maxiter=1000,
982
+ workers=-1, polish=True,
983
+ cost_function='poisson',
984
+ sigma_max=3.0,
985
+ irf_shift_bins=2,
986
+ tvb_profile=None, fit_tvb=False,
987
+ fit_start_ns=None, fit_end_ns=None, exclude_ns=None,
988
+ n_sync=None):
989
+ tau_min = tau_min_ns * 1e-9
990
+ tau_max = tau_max_ns * 1e-9
991
+ if cost_function not in ('chi2', 'poisson'):
992
+ raise ValueError(f"Unknown cost_function: {cost_function!r}")
993
+ decay_work = decay.astype(float)
994
+ if decay_work.max() == 0:
995
+ raise ValueError('Decay has zero maximum - cannot fit.')
996
+ peak_bin = int(np.argmax(decay_work))
997
+ bg_init = estimate_bg(decay_work, peak_bin)
998
+ bg_fixed = bg_init if not fit_bg else 0.0
999
+ fit_end = find_fit_end(decay_work, peak_bin, tau_max, tcspc_res, n_bins)
1000
+ standard_fit_end = int(round(44.9455 / (tcspc_res * 1e9)))
1001
+ fit_end = min(fit_end, standard_fit_end)
1002
+ fit_start = find_fit_start(decay_work, irf_prompt, tcspc_res)
1003
+ fit_start = max(0, min(fit_start, fit_end - 10))
1004
+ if fit_end_ns is not None:
1005
+ fit_end = min(n_bins, bins_from_ns(fit_end_ns, tcspc_res))
1006
+ if fit_start_ns is not None:
1007
+ fit_start = max(0, min(bins_from_ns(fit_start_ns, tcspc_res), fit_end - 2))
1008
+ exclude_bins = [(bins_from_ns(lo, tcspc_res), bins_from_ns(hi, tcspc_res))
1009
+ for lo, hi in (exclude_ns or [])]
1010
+ fit_idx = build_fit_idx(fit_start, fit_end, n_bins, exclude_bins)
1011
+ bg_upper = max(bg_init * 2.0, bg_init + 10.0)
1012
+ if fit_tvb and tvb_profile is None:
1013
+ raise ValueError('fit_tvb=True requires a tvb_profile.')
1014
+ tvb_init = float(bg_init * n_bins) if fit_tvb else 0.0
1015
+ tvb_upper = float(decay_work.sum()) if fit_tvb else None
1016
+ if fit_tvb:
1017
+ print(f" TVB: free scale on measured profile, init={tvb_init:.1f}, upper={tvb_upper:.1f}")
1018
+ print(f" Cost function: {cost_function}")
1019
+ print(f" bg initial guess = {bg_init:.3f} cts/bin, upper bound = {bg_upper:.3f} "
1020
+ f"({'free param' if fit_bg else 'fixed'})")
1021
+ lo, hi = _build_bounds_dist(
1022
+ n_components, tau_min, tau_max, decay_work.max(),
1023
+ fit_bg, fit_sigma, bg_init=bg_init, bg_upper=bg_upper,
1024
+ sigma_max=sigma_max, irf_shift_bins=irf_shift_bins,
1025
+ fit_tvb=fit_tvb, tvb_init=tvb_init, tvb_upper=tvb_upper)
1026
+ bounds = list(zip(lo, hi))
1027
+ if cost_function == 'chi2':
1028
+ weights = np.sqrt(np.maximum(decay_work[fit_idx], 1.0))
1029
+ def residuals(params):
1030
+ model_vals = dist_reconvolution_model(
1031
+ params, tcspc_res, n_bins, irf_prompt,
1032
+ n_components, dist_type, bg_fixed, fit_bg, fit_sigma,
1033
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb)
1034
+ return (model_vals[fit_idx] - decay_work[fit_idx]) / weights
1035
+ else:
1036
+ def residuals(params):
1037
+ model_vals = dist_reconvolution_model(
1038
+ params, tcspc_res, n_bins, irf_prompt,
1039
+ n_components, dist_type, bg_fixed, fit_bg, fit_sigma,
1040
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb)
1041
+ n = decay_work[fit_idx]
1042
+ m = np.maximum(model_vals[fit_idx], 1e-10)
1043
+ dev = m - n
1044
+ pos = n > 0
1045
+ dev[pos] += n[pos] * np.log(n[pos] / m[pos])
1046
+ dev = np.maximum(dev, 0.0)
1047
+ r = np.sqrt(2.0 * dev)
1048
+ r[m < n] *= -1
1049
+ return r
1050
+ if optimizer == 'lm_multistart':
1051
+ rng = np.random.default_rng(42)
1052
+ best_res = None
1053
+ best_cost = np.inf
1054
+ for i in range(n_restarts + 1):
1055
+ if i == 0:
1056
+ p0 = _pack_p0_dist(n_components, tau_min, tau_max,
1057
+ float(decay_work.max()), fit_bg, fit_sigma, bg_init,
1058
+ fit_tvb=fit_tvb, tvb_init=tvb_init)
1059
+ else:
1060
+ tau_ov = np.sort(np.exp(rng.uniform(
1061
+ np.log(tau_min * 1.001), np.log(tau_max * 0.999), n_components)))
1062
+ p0 = _pack_p0_dist(n_components, tau_min, tau_max,
1063
+ float(decay_work.max()), fit_bg, fit_sigma, bg_init,
1064
+ fit_tvb=fit_tvb, tvb_init=tvb_init)
1065
+ p0[:n_components] = tau_ov
1066
+ try:
1067
+ res = least_squares(residuals, p0, bounds=(lo, hi), method='trf',
1068
+ max_nfev=50000, ftol=1e-13, xtol=1e-13, gtol=1e-13)
1069
+ except Exception as exc:
1070
+ print(f" Restart {i:2d}: failed ({exc})")
1071
+ continue
1072
+ if res.cost < best_cost:
1073
+ best_cost = res.cost
1074
+ best_res = res
1075
+ print(f" Restart {i:2d}: cost={res.cost:.4e} ← best")
1076
+ else:
1077
+ print(f" Restart {i:2d}: cost={res.cost:.4e}")
1078
+ if best_res is None:
1079
+ raise RuntimeError('All restarts failed.')
1080
+ popt_work = best_res.x
1081
+ message = best_res.message
1082
+ elif optimizer == 'de':
1083
+ print(f" Differential evolution: popsize={de_popsize}, maxiter={de_maxiter}, workers={workers}")
1084
+ n = n_components
1085
+ bounds_log = list(bounds)
1086
+ for i in range(n):
1087
+ lo_t, hi_t = bounds[i]
1088
+ bounds_log[i] = (np.log10(lo_t), np.log10(hi_t))
1089
+ for i in range(n, 2 * n):
1090
+ lo_w, hi_w = bounds[i]
1091
+ bounds_log[i] = (np.log10(lo_w), np.log10(hi_w))
1092
+ if cost_function == 'poisson':
1093
+ cost_fn = _DECostDistPoissonLogParam(
1094
+ tcspc_res, n_bins, irf_prompt, n_components, dist_type,
1095
+ bg_fixed, fit_bg, fit_sigma, fit_idx, decay_work,
1096
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
1097
+ else:
1098
+ cost_fn = _DECostDistLogParam(
1099
+ tcspc_res, n_bins, irf_prompt, n_components, dist_type,
1100
+ bg_fixed, fit_bg, fit_sigma, fit_idx, decay_work,
1101
+ weights, tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
1102
+ de_res = differential_evolution(
1103
+ cost_fn, bounds=bounds_log,
1104
+ maxiter=de_maxiter, popsize=de_popsize,
1105
+ workers=workers, seed=42,
1106
+ updating='deferred' if workers != 1 else 'immediate',
1107
+ init='sobol', disp=False)
1108
+ popt_work = de_res.x.copy()
1109
+ popt_work[:n] = 10.0 ** popt_work[:n]
1110
+ popt_work[n:2*n] = 10.0 ** popt_work[n:2*n]
1111
+ message = f"DE success={de_res.success}, fun={de_res.fun:.4e}"
1112
+ if polish:
1113
+ print(' Running final LM polish...')
1114
+ eps = 1e-10
1115
+ popt_work = np.clip(popt_work, np.asarray(lo) + eps, np.asarray(hi) - eps)
1116
+ try:
1117
+ pol = least_squares(residuals, popt_work, bounds=(lo, hi), method='trf',
1118
+ max_nfev=5000, ftol=1e-13, xtol=1e-13, gtol=1e-13)
1119
+ popt_work = pol.x
1120
+ message += f"; polished cost={pol.cost:.4e}"
1121
+ except ValueError as e:
1122
+ print(f" Warning: LM polish failed ({e}) - using DE result")
1123
+ else:
1124
+ raise ValueError(f"Unknown optimizer: {optimizer!r}")
1125
+ summary = _make_summary_dist(
1126
+ popt_work, decay, tcspc_res, n_bins, irf_prompt,
1127
+ n_components, dist_type, bg_fixed, fit_bg, fit_sigma,
1128
+ fit_idx, message, tvb_profile=tvb_profile, fit_tvb=fit_tvb, n_sync=n_sync)
1129
+ return popt_work, summary
1130
+
1131
+ def _make_summary_dist(popt, decay, tcspc_res, n_bins, irf_prompt,
1132
+ n_components, dist_type, bg_fixed, fit_bg, fit_sigma,
1133
+ fit_idx, message=None,
1134
+ tvb_profile=None, fit_tvb=False, n_sync=None):
1135
+ fit_start = int(fit_idx[0])
1136
+ fit_end = int(fit_idx[-1]) + 1
1137
+ tau_centers = popt[:n_components]
1138
+ widths = popt[n_components:2 * n_components]
1139
+ amps = popt[2 * n_components:3 * n_components]
1140
+ idx = 3 * n_components
1141
+ shift = popt[idx]; idx += 1
1142
+ sigma = popt[idx] if fit_sigma else 0.0
1143
+ if fit_sigma:
1144
+ idx += 1
1145
+ if fit_bg:
1146
+ bg_fit = popt[idx]; idx += 1
1147
+ else:
1148
+ bg_fit = bg_fixed
1149
+ if fit_tvb:
1150
+ tvb_scale = popt[idx]; idx += 1
1151
+ else:
1152
+ tvb_scale = 0.0
1153
+ model = dist_reconvolution_model(popt, tcspc_res, n_bins, irf_prompt,
1154
+ n_components, dist_type, bg_fixed, fit_bg, fit_sigma,
1155
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb)
1156
+ d_win = decay[fit_idx].astype(float)
1157
+ m_win = model[fit_idx]
1158
+ sigma_w = np.sqrt(np.maximum(d_win, 1.0))
1159
+ chi2 = float(np.sum(((d_win - m_win) / sigma_w) ** 2))
1160
+ dof = max(len(fit_idx) - len(popt), 1)
1161
+ rchi2 = chi2 / dof
1162
+ sigma_p = np.sqrt(np.maximum(m_win, 1.0))
1163
+ chi2_p = float(np.sum(((d_win - m_win) / sigma_p) ** 2))
1164
+ rchi2_p = chi2_p / dof
1165
+ calibrated_chi2_p = calibrated_chi2(d_win, m_win)
1166
+ peak_bin_loc = int(fit_idx[np.argmax(decay[fit_idx])])
1167
+ tail_start = peak_bin_loc + max(1, int(0.05 * (fit_end - peak_bin_loc)))
1168
+ tail_idx = fit_idx[fit_idx >= tail_start]
1169
+ d_tail = decay[tail_idx].astype(float)
1170
+ m_tail = model[tail_idx]
1171
+ sw_tail = np.sqrt(np.maximum(d_tail, 1.0))
1172
+ chi2_tail = float(np.sum(((d_tail - m_tail) / sw_tail) ** 2))
1173
+ dof_tail = max(len(tail_idx) - len(popt), 1)
1174
+ rchi2_tail = chi2_tail / dof_tail
1175
+ sp_tail = np.sqrt(np.maximum(m_tail, 1.0))
1176
+ chi2_tail_p = float(np.sum(((d_tail - m_tail) / sp_tail) ** 2))
1177
+ rchi2_tail_p = chi2_tail_p / dof_tail
1178
+ calibrated_chi2_tail_p = calibrated_chi2(d_tail, m_tail)
1179
+ resid = (decay - model) / np.sqrt(np.maximum(model, 1.0))
1180
+ from ..FLIM.models import _alpha_gaussian, _alpha_lorentzian, _N_QUAD
1181
+ alpha_fn = _alpha_gaussian if dist_type == 'gaussian' else _alpha_lorentzian
1182
+ tau_num_amp = 0.0; tau_den_amp = 0.0
1183
+ tau_num_int = 0.0; tau_den_int = 0.0
1184
+ for i in range(n_components):
1185
+ tau_c = tau_centers[i]; w = widths[i]; a = max(amps[i], 0.0)
1186
+ spread = 4.0 * w if dist_type == 'gaussian' else 8.0 * max(w / 2.0, 1e-15)
1187
+ tau_lo = max(tau_c - spread, 1e-12)
1188
+ tau_hi = max(tau_c + spread, tau_lo + 1e-12)
1189
+ tau_grid = np.linspace(tau_lo, tau_hi, _N_QUAD)
1190
+ alpha = alpha_fn(tau_grid, tau_c, w)
1191
+ alpha = alpha / max(alpha.sum(), 1e-30)
1192
+ tau_num_amp += a * float(np.dot(alpha, tau_grid))
1193
+ tau_den_amp += a
1194
+ tau_num_int += a * float(np.dot(alpha, tau_grid ** 2))
1195
+ tau_den_int += a * float(np.dot(alpha, tau_grid))
1196
+ tau_mean_amp_ns = (tau_num_amp / max(tau_den_amp, 1e-30)) * 1e9
1197
+ tau_mean_int_ns = (tau_num_int / max(tau_den_int, 1e-30)) * 1e9
1198
+ amp_sum = max(amps.sum(), 1e-30)
1199
+ fractions = amps / amp_sum
1200
+ if dist_type == 'gaussian':
1201
+ fwhms_ns = widths * 2.3548 * 1e9
1202
+ else:
1203
+ fwhms_ns = widths * 1e9
1204
+ above = np.where(irf_prompt >= irf_prompt.max() / 2)[0]
1205
+ fwhm_pr = (above[-1] - above[0]) if len(above) > 1 else 1
1206
+ fwhm_eff = np.sqrt(fwhm_pr ** 2 + (2.3548 * sigma) ** 2) * tcspc_res * 1e9
1207
+ return dict(
1208
+ dist_type = dist_type,
1209
+ n_components = n_components,
1210
+ tcspc_res = tcspc_res,
1211
+ tau_centers_ns = tau_centers * 1e9,
1212
+ widths_ns = widths * 1e9,
1213
+ fwhms_ns = fwhms_ns,
1214
+ amps = amps,
1215
+ fractions = fractions,
1216
+ bg_fit = bg_fit,
1217
+ tvb_scale = tvb_scale,
1218
+ tau_mean_amp_ns = tau_mean_amp_ns,
1219
+ tau_mean_int_ns = tau_mean_int_ns,
1220
+ chi2 = chi2,
1221
+ reduced_chi2 = rchi2,
1222
+ reduced_chi2_tail = rchi2_tail,
1223
+ chi2_pearson = chi2_p,
1224
+ reduced_chi2_pearson = rchi2_p,
1225
+ reduced_chi2_tail_pearson = rchi2_tail_p,
1226
+ calibrated_chi2_pearson = calibrated_chi2_p,
1227
+ calibrated_chi2_tail_pearson = calibrated_chi2_tail_p,
1228
+ tail_start_bin = tail_start,
1229
+ dof = dof,
1230
+ fit_window_bins = (fit_start, fit_end),
1231
+ fit_window_ns = (fit_start * tcspc_res * 1e9, fit_end * tcspc_res * 1e9),
1232
+ fit_idx = fit_idx,
1233
+ irf_shift_bins = shift,
1234
+ irf_sigma_bins = sigma,
1235
+ irf_fwhm_eff_ns = fwhm_eff,
1236
+ model = model,
1237
+ residuals = resid,
1238
+ optimizer_msg = message,
1239
+ )
1240
+
1241
+
1242
+ def fit_per_pixel_dist(stack, tcspc_res, n_bins, irf_prompt,
1243
+ global_popt, n_components, dist_type,
1244
+ fit_bg=True, fit_sigma=False,
1245
+ min_photons=MIN_PHOTONS_PERPIX,
1246
+ tau_min_ns=None, tau_max_ns=None,
1247
+ n_tau_grid=50, n_width_grid=30,
1248
+ progress_callback=None,
1249
+ use_gpu='auto',
1250
+ gpu_backend=None,
1251
+ tvb_profile=None, fit_tvb=False,
1252
+ fit_idx=None) -> dict:
1253
+ from ..GPU._base import fit_window
1254
+
1255
+ ny, nx, _ = stack.shape
1256
+ window = fit_window(fit_idx, n_bins)
1257
+ fit_idx = np.arange(n_bins) if window is None else window
1258
+ n_fit = len(fit_idx)
1259
+ tvb_on = bool(fit_tvb) and tvb_profile is not None
1260
+ if tvb_on and n_components != 1:
1261
+ print(' Per-pixel distribution TVB only supported for unimodal (1 component); ignoring TVB here.')
1262
+ tvb_on = False
1263
+ idx = 3 * n_components
1264
+ shift = global_popt[idx]; idx += 1
1265
+ sigma = global_popt[idx] if fit_sigma else 0.0
1266
+ if fit_sigma:
1267
+ idx += 1
1268
+ tau_centers_g = global_popt[:n_components]
1269
+ widths_g = global_popt[n_components:2 * n_components]
1270
+ irf_fixed = build_full_irf(irf_prompt, shift, sigma, 0.0, 1.0, n_bins)
1271
+ tau_lo = max(tau_centers_g.min() / 5.0, 1e-12) if tau_min_ns is None else tau_min_ns * 1e-9
1272
+ tau_hi = tau_centers_g.max() * 5.0 if tau_max_ns is None else tau_max_ns * 1e-9
1273
+ w_lo = widths_g.min() / 5.0
1274
+ w_hi = widths_g.max() * 5.0
1275
+ tau_grid = np.logspace(np.log10(max(tau_lo, 1e-12)), np.log10(tau_hi), n_tau_grid)
1276
+ width_grid = np.logspace(np.log10(max(w_lo, 1e-12)), np.log10(w_hi), n_width_grid)
1277
+ print(f" Building distribution basis grid ({n_tau_grid}×{n_width_grid})...")
1278
+ basis, param_pairs = build_dist_basis_grid(
1279
+ tcspc_res, n_bins, irf_fixed, tau_grid, width_grid, dist_type)
1280
+ basis_fit = basis[:, fit_idx]
1281
+ bb_grid = np.maximum((basis_fit.astype(np.float64) ** 2).sum(axis=1), 1e-20).astype(np.float32)
1282
+ maps = dict(
1283
+ intensity = stack.sum(axis=2),
1284
+ tau_mean_amp = np.full((ny, nx), np.nan),
1285
+ tau_mean_int = np.full((ny, nx), np.nan),
1286
+ chi2_r = np.full((ny, nx), np.nan),
1287
+ calibrated_chi2_r = np.full((ny, nx), np.nan),
1288
+ )
1289
+ for i in range(n_components):
1290
+ maps[f"tau_center_{i+1}"] = np.full((ny, nx), np.nan)
1291
+ maps[f"width_{i+1}"] = np.full((ny, nx), np.nan)
1292
+ maps[f"alpha_{i+1}"] = np.full((ny, nx), np.nan)
1293
+ maps[f"frac_{i+1}"] = np.full((ny, nx), np.nan)
1294
+ if tvb_on:
1295
+ maps['tvb_scale'] = np.full((ny, nx), np.nan)
1296
+ if n_components == 1:
1297
+ backend = None
1298
+ if use_gpu is not False:
1299
+ backend = gpu_backend if gpu_backend is not None else (
1300
+ None if _gpu_backend_cache is _GPU_BACKEND_UNSET else _gpu_backend_cache
1301
+ )
1302
+ if backend is not None and stack.nbytes > _GPU_MAX_DIST_STACK_BYTES:
1303
+ print(f' [per-pixel] {stack.nbytes/1e9:.1f} GB cube exceeds GPU limit '
1304
+ f'({_GPU_MAX_DIST_STACK_BYTES/1e9:.1f} GB); the distribution scan is not blocked, using the CPU path')
1305
+ backend = None
1306
+ if backend is not None:
1307
+ return backend.batch_dist_scan_unimodal(
1308
+ stack, basis_fit, bb_grid, param_pairs,
1309
+ irf_fixed, tcspc_res, n_bins, dist_type,
1310
+ min_photons, progress_callback,
1311
+ tvb_profile=tvb_profile if tvb_on else None,
1312
+ fit_tvb=tvb_on, fit_idx=fit_idx)
1313
+ flat = stack.reshape(ny * nx, n_bins).astype(np.float32)
1314
+ ph_counts = flat.sum(axis=1)
1315
+ valid_idx = np.where(ph_counts >= min_photons)[0]
1316
+ if tvb_on and valid_idx.size > 0:
1317
+ tvb_fit = np.asarray(tvb_profile, dtype=float)[fit_idx]
1318
+ _U = np.column_stack([tvb_fit, np.ones(n_fit)])
1319
+ _Up = np.linalg.pinv(_U)
1320
+ _bp = basis_fit.astype(np.float64) - (basis_fit.astype(np.float64) @ _Up.T) @ _U.T
1321
+ _bbp = np.maximum((_bp ** 2).sum(axis=1), 1e-20)
1322
+ d_valid = flat[valid_idx][:, fit_idx].astype(np.float64)
1323
+ d_perp = d_valid - (d_valid @ _Up.T) @ _U.T
1324
+ bd = d_perp @ _bp.T
1325
+ costs = (d_perp ** 2).sum(axis=1)[:, None] - np.maximum(bd, 0.0) ** 2 / _bbp[None, :]
1326
+ best_g = np.argmin(costs, axis=1)
1327
+ amp_v = np.maximum(bd[np.arange(len(valid_idx)), best_g] / _bbp[best_g], 0.0)
1328
+ basis_best = basis_fit[best_g].astype(np.float64)
1329
+ resid_after = d_valid - amp_v[:, None] * basis_best
1330
+ vz = resid_after @ _Up.T
1331
+ tvb_v = np.maximum(vz[:, 0], 0.0)
1332
+ bg_z = vz[:, 1]
1333
+ tau_v = param_pairs[best_g, 0]
1334
+ w_v = param_pairs[best_g, 1]
1335
+ model_v = amp_v[:, None] * basis_best + tvb_v[:, None] * tvb_fit[None, :] + bg_z[:, None]
1336
+ chi2_v = ((d_valid - model_v) ** 2 / np.maximum(model_v, 1.0)).sum(axis=1)
1337
+ chi2_v /= distribution_dof(n_fit, 1, True)
1338
+ chi2_cal_v = calibrated_chi2(d_valid, model_v, axis=1)
1339
+ for k, fi in enumerate(valid_idx):
1340
+ if amp_v[k] <= 0:
1341
+ continue
1342
+ yy, xx = divmod(int(fi), nx)
1343
+ maps['tau_center_1'][yy, xx] = tau_v[k] * 1e9
1344
+ maps['width_1'][yy, xx] = w_v[k] * 1e9
1345
+ maps['alpha_1'][yy, xx] = amp_v[k]
1346
+ maps['frac_1'][yy, xx] = 1.0
1347
+ maps['tau_mean_amp'][yy, xx] = tau_v[k] * 1e9
1348
+ maps['tau_mean_int'][yy, xx] = (tau_v[k] + w_v[k] ** 2 / max(tau_v[k], 1e-15)) * 1e9
1349
+ maps['chi2_r'][yy, xx] = chi2_v[k]
1350
+ maps['calibrated_chi2_r'][yy, xx] = chi2_cal_v[k]
1351
+ maps['tvb_scale'][yy, xx] = tvb_v[k]
1352
+ return maps
1353
+ t0 = time.time()
1354
+ for row_i in range(ny):
1355
+ if progress_callback is not None:
1356
+ progress_callback(row_i, ny)
1357
+ xi_range = range(nx)
1358
+ for xi in xi_range:
1359
+ flat_idx = row_i * nx + xi
1360
+ if ph_counts[flat_idx] < min_photons:
1361
+ continue
1362
+ d = flat[flat_idx].astype(np.float64)
1363
+ d_fit = d[fit_idx]
1364
+ bg = estimate_bg(d, int(np.argmax(d)))
1365
+ dc = np.maximum(d_fit - bg, 0.0)
1366
+ bd = basis_fit.astype(np.float64) @ dc
1367
+ amps_g = np.maximum(bd / bb_grid.astype(np.float64), 0.0)
1368
+ costs = (dc ** 2).sum() - np.maximum(bd, 0.0) ** 2 / bb_grid.astype(np.float64)
1369
+ best = int(np.argmin(costs))
1370
+ tau_c_px = float(param_pairs[best, 0])
1371
+ w_px = float(param_pairs[best, 1])
1372
+ amp_px = float(amps_g[best])
1373
+ tau_amp_ns = tau_c_px * 1e9
1374
+ tau_int_ns = (tau_c_px + (w_px ** 2) / max(tau_c_px, 1e-15)) * 1e9
1375
+ model_px = amp_px * basis_fit[best].astype(np.float64) + bg
1376
+ resid_px = d_fit - model_px
1377
+ chi2_px = float(np.sum(resid_px ** 2 / np.maximum(model_px, 1.0)))
1378
+ maps['tau_center_1'][row_i, xi] = tau_c_px * 1e9
1379
+ maps['width_1'][row_i, xi] = w_px * 1e9
1380
+ maps['alpha_1'][row_i, xi] = amp_px
1381
+ maps['frac_1'][row_i, xi] = 1.0
1382
+ maps['tau_mean_amp'][row_i, xi] = tau_amp_ns
1383
+ maps['tau_mean_int'][row_i, xi] = tau_int_ns
1384
+ maps['chi2_r'][row_i, xi] = chi2_px / distribution_dof(n_fit, 1, False)
1385
+ maps['calibrated_chi2_r'][row_i, xi] = calibrated_chi2(d_fit, model_px)
1386
+ else:
1387
+ from ..FLIM.fit_tools import estimate_bg as _ebg
1388
+ from concurrent.futures import ThreadPoolExecutor
1389
+ import multiprocessing
1390
+ flat = stack.reshape(ny * nx, n_bins).astype(np.float32)
1391
+ ph_counts = flat.sum(axis=1)
1392
+ tau_lo_s = max(tau_centers_g.min() / 5.0, 1e-12)
1393
+ tau_hi_s = tau_centers_g.max() * 5.0
1394
+ w_lo_s = widths_g.min() / 5.0
1395
+ w_hi_s = widths_g.max() * 5.0
1396
+ fit_stack_max = float(stack[..., fit_idx].max())
1397
+ amp_hi = fit_stack_max * 10.0
1398
+ lo_px = np.array([tau_lo_s] * n_components + [w_lo_s] * n_components + [0.0] * n_components)
1399
+ hi_px = np.array([tau_hi_s] * n_components + [w_hi_s] * n_components + [amp_hi] * n_components)
1400
+ p0_px = np.concatenate([tau_centers_g, widths_g,
1401
+ np.full(n_components, fit_stack_max / n_components)])
1402
+ def _fit_pixel_dist(flat_idx):
1403
+ d = flat[flat_idx].astype(np.float64)
1404
+ d_fit = d[fit_idx]
1405
+ bg = _ebg(d, int(np.argmax(d)))
1406
+ wt_fit = np.sqrt(np.maximum(d_fit, 1.0))
1407
+ def _resid(p):
1408
+ full_p = np.concatenate([p, [shift]])
1409
+ if fit_sigma:
1410
+ full_p = np.concatenate([full_p, [sigma]])
1411
+ if fit_bg:
1412
+ full_p = np.concatenate([full_p, [bg]])
1413
+ m = dist_reconvolution_model(
1414
+ full_p, tcspc_res, n_bins, irf_prompt,
1415
+ n_components, dist_type, bg, False, False)
1416
+ return (m[fit_idx] - d_fit) / wt_fit
1417
+ try:
1418
+ res = least_squares(_resid, p0_px, bounds=(lo_px, hi_px),
1419
+ method='trf', max_nfev=500,
1420
+ ftol=1e-8, xtol=1e-8, gtol=1e-8)
1421
+ return res.x
1422
+ except Exception:
1423
+ return None
1424
+ n_workers = min(ny * nx, max(1, multiprocessing.cpu_count()))
1425
+ valid_flat = [i for i in range(ny * nx) if ph_counts[i] >= min_photons]
1426
+ n_valid = len(valid_flat)
1427
+ t0 = time.time()
1428
+ with ThreadPoolExecutor(max_workers=n_workers) as pool:
1429
+ futures = [pool.submit(_fit_pixel_dist, i) for i in valid_flat]
1430
+ solutions = []
1431
+ for k, f in enumerate(futures):
1432
+ solutions.append(f.result())
1433
+ if progress_callback is not None and k % max(1, n_valid // 200) == 0:
1434
+ progress_callback(k, n_valid)
1435
+ for sol, flat_idx in zip(solutions, valid_flat):
1436
+ if sol is None:
1437
+ continue
1438
+ yi, xi = divmod(flat_idx, nx)
1439
+ tau_cs = sol[:n_components]
1440
+ ws = sol[n_components:2 * n_components]
1441
+ amp_s = sol[2 * n_components:]
1442
+ amp_sum = max(amp_s.sum(), 1e-30)
1443
+ fracs = amp_s / amp_sum
1444
+ tau_amp_ns = float(np.dot(fracs, tau_cs)) * 1e9
1445
+ tau_int_ns = float(np.dot(amp_s, tau_cs ** 2) / max(np.dot(amp_s, tau_cs), 1e-30)) * 1e9
1446
+ maps['tau_mean_amp'][yi, xi] = tau_amp_ns
1447
+ maps['tau_mean_int'][yi, xi] = tau_int_ns
1448
+ d = flat[flat_idx].astype(np.float64)
1449
+ d_fit = d[fit_idx]
1450
+ bg = _ebg(d, int(np.argmax(d)))
1451
+ full_p = np.concatenate([sol, [shift]])
1452
+ if fit_sigma:
1453
+ full_p = np.concatenate([full_p, [sigma]])
1454
+ if fit_bg:
1455
+ full_p = np.concatenate([full_p, [bg]])
1456
+ model_px = dist_reconvolution_model(
1457
+ full_p, tcspc_res, n_bins, irf_prompt,
1458
+ n_components, dist_type, bg, False, False)
1459
+ model_fit = model_px[fit_idx]
1460
+ resid_fit = d_fit - model_fit
1461
+ chi2_px = float(np.sum(resid_fit ** 2 / np.maximum(model_fit, 1.0)))
1462
+ maps['chi2_r'][yi, xi] = chi2_px / distribution_dof(
1463
+ n_fit, n_components, False)
1464
+ maps['calibrated_chi2_r'][yi, xi] = calibrated_chi2(
1465
+ d_fit, model_fit)
1466
+ for i in range(n_components):
1467
+ maps[f"tau_center_{i+1}"][yi, xi] = tau_cs[i] * 1e9
1468
+ maps[f"width_{i+1}"][yi, xi] = ws[i] * 1e9
1469
+ maps[f"alpha_{i+1}"][yi, xi] = amp_s[i]
1470
+ maps[f"frac_{i+1}"][yi, xi] = fracs[i]
1471
+ return maps