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
flimkit/FLIM/batch.py ADDED
@@ -0,0 +1,681 @@
1
+ import time
2
+ import shutil
3
+ import numpy as np
4
+ import matplotlib
5
+ matplotlib.use('Agg')
6
+ import matplotlib.pyplot as plt
7
+ from pathlib import Path
8
+ from flimkit.formats import FLIMFile
9
+ from ..FLIM.fitters import fit_summed, fit_per_pixel
10
+ from ..FLIM.fit_tools import find_irf_peak_bin
11
+ from ..utils.lifetime_image import make_lifetime_image, make_component_rgb_tiff
12
+ from ..utils.plotting import plot_summed
13
+ from ..configs import (
14
+ MIN_PHOTONS_PERPIX, lm_restarts, de_population, de_maxiter, n_workers,
15
+ )
16
+ from ..utils.batch_fit import (
17
+ group_timelapse_files, group_zstack_files, zstack_group_label,
18
+ pool_decays, build_irf, make_synthetic_popt, compute_redox_metrics,
19
+ save_json, save_series_csv, resolve_tau_display_range,
20
+ save_tile_lifetime_txt, save_map_stacks, plot_metric_summary,
21
+ )
22
+
23
+ def _save_4d_stacks(frame_positions, group_dir, group_label):
24
+ sorted_t = sorted(frame_positions.keys())
25
+ all_s = sorted({s for pos in frame_positions.values() for s in pos})
26
+ for s in all_s:
27
+ save_map_stacks([group_dir / f't{t:04d}' / f's{s}' for t in sorted_t],
28
+ group_dir, f'{group_label}_s{s}')
29
+
30
+ def _iter_effective_groups(groups, pool_positions, output_dir):
31
+ for (region, z), frame_positions in groups.items():
32
+ if pool_positions:
33
+ group_label = f'{region}_z{z}'
34
+ group_dir = output_dir / group_label
35
+ yield group_label, group_dir, frame_positions
36
+ else:
37
+ all_s = sorted({s for pos in frame_positions.values() for s in pos})
38
+ for s in all_s:
39
+ group_label = f'{region}_z{z}_s{s}'
40
+ group_dir = output_dir / group_label
41
+ fp_single = {
42
+ t: {s: pos[s]}
43
+ for t, pos in frame_positions.items()
44
+ if s in pos
45
+ }
46
+ yield group_label, group_dir, fp_single
47
+
48
+ def fit_timelapse(ptu_dir, output_dir, args,
49
+ ref_tau1_ns=None, ref_tau2_ns=None, ref_tau3_ns=None,
50
+ channel=None,
51
+ pool_positions=False,
52
+ compute_bound_fraction=False,
53
+ progress_callback=None,
54
+ cancel_event=None):
55
+ ptu_dir = Path(ptu_dir)
56
+ output_dir = Path(output_dir)
57
+ output_dir.mkdir(parents=True, exist_ok=True)
58
+ groups = group_timelapse_files(ptu_dir)
59
+ if not groups:
60
+ raise ValueError(
61
+ f'No timelapse PTU files found in {ptu_dir}. '
62
+ 'Expected pattern: region_tX[_sY][_zZ].ptu')
63
+ print(f'\nTimelapse groups found:')
64
+ for (region, z), frame_positions in groups.items():
65
+ n_t = len(frame_positions)
66
+ all_s = sorted({s for pos in frame_positions.values() for s in pos})
67
+ t_list = sorted(frame_positions)
68
+ print(f' region={region} z={z} → {n_t} timepoints '
69
+ f'(t={t_list[0]}…{t_list[-1]}) × {len(all_s)} position(s) '
70
+ f'(s={all_s})')
71
+ print(f' Position handling: {'pooled (shared τ)' if pool_positions else 'independent (per-position τ)'}')
72
+ total_frames = sum(
73
+ sum(len(pos) for pos in fp.values())
74
+ for fp in groups.values())
75
+ step = 0
76
+ all_results = {}
77
+ for group_label, group_dir, frame_positions in _iter_effective_groups(
78
+ groups, pool_positions, output_dir):
79
+ if cancel_event is not None and cancel_event.is_set():
80
+ print(' Cancelled.')
81
+ break
82
+ group_dir.mkdir(parents=True, exist_ok=True)
83
+ all_s = sorted({s for pos in frame_positions.values() for s in pos})
84
+ n_t = len(frame_positions)
85
+ print(f'\n{'='*60}')
86
+ print(f' GROUP: {group_label} '
87
+ f'({n_t} timepoints × {len(all_s)} position(s))')
88
+ print(f'{'='*60}')
89
+ n_ts = sum(len(pos) for pos in frame_positions.values())
90
+ print(f'\n[1] Pooling photons across {n_ts} PTU files…')
91
+ pooled_decay, tcspc_res, n_bins = pool_decays(frame_positions, channel=channel)
92
+ print(f' Total photons: {pooled_decay.sum():,.0f}')
93
+ irf_prompt, has_tail, fit_bg, fit_sigma, sigma_max = build_irf(
94
+ pooled_decay, tcspc_res, n_bins, args)
95
+ ref_taus = [ref_tau1_ns, ref_tau2_ns, ref_tau3_ns][:args.nexp]
96
+ use_supplied = all(r is not None for r in ref_taus)
97
+ if use_supplied:
98
+ print(f'\n[2] Using user-supplied τ: '
99
+ + ' '.join(f'τ{i+1}={t} ns' for i, t in enumerate(ref_taus)))
100
+ irf_peak_bin = int(find_irf_peak_bin(pooled_decay))
101
+ global_popt = make_synthetic_popt(
102
+ ref_taus, args.nexp, n_bins,
103
+ irf_peak_bin, fit_sigma, fit_bg, has_tail)
104
+ taus_ns = np.array(ref_taus)
105
+ global_summary = {
106
+ 'taus_ns': taus_ns,
107
+ 'amps': np.ones(args.nexp) / args.nexp,
108
+ 'tau_mean_amp_ns': float(np.mean(taus_ns)),
109
+ }
110
+ else:
111
+ print(f'\n[2] Fitting reference τ from pooled decay ({args.nexp}-exp)…')
112
+ t0 = time.time()
113
+ global_popt, global_summary = fit_summed(
114
+ pooled_decay, tcspc_res, n_bins,
115
+ irf_prompt, has_tail, fit_bg, fit_sigma,
116
+ args.nexp, args.tau_min, args.tau_max,
117
+ optimizer=getattr(args, 'optimizer', 'de'),
118
+ n_restarts=getattr(args, 'restarts', lm_restarts),
119
+ de_popsize=getattr(args, 'de_population', de_population),
120
+ de_maxiter=getattr(args, 'de_maxiter', de_maxiter),
121
+ workers=getattr(args, 'workers', n_workers),
122
+ polish=not getattr(args, 'no_polish', False),
123
+ cost_function=getattr(args, 'cost_function', 'poisson'),
124
+ sigma_max=sigma_max,
125
+ )
126
+ print(f' Reference fit: {time.time() - t0:.1f} s')
127
+ taus_ns = global_summary.get('taus_ns', global_popt[:args.nexp] * 1e9)
128
+ for i, tau in enumerate(taus_ns):
129
+ print(f' τ{i+1} = {tau:.4f} ns (locked for all frames)')
130
+ tau_disp_min, tau_disp_max = resolve_tau_display_range(taus_ns, args)
131
+ print(f' Lifetime display range (fixed for all timepoints): '
132
+ f'{tau_disp_min:.3f}-{tau_disp_max:.3f} ns')
133
+ save_json(group_dir / f'{group_label}_reference_fit.json', {
134
+ 'taus_ns': list(taus_ns),
135
+ 'nexp': args.nexp,
136
+ 'tau_min_ns': args.tau_min,
137
+ 'tau_max_ns': args.tau_max,
138
+ 'total_pooled_photons': float(pooled_decay.sum()),
139
+ 'tcspc_res_s': float(tcspc_res),
140
+ 'n_bins': int(n_bins),
141
+ 'n_timepoints': n_t,
142
+ 'positions': all_s,
143
+ 'estimate_irf': getattr(args, 'estimate_irf', 'gaussian'),
144
+ 'user_supplied_tau': use_supplied,
145
+ 'calibrated_chi2_pearson': global_summary.get(
146
+ 'calibrated_chi2_pearson'),
147
+ 'calibrated_chi2_tail_pearson': global_summary.get(
148
+ 'calibrated_chi2_tail_pearson'),
149
+ })
150
+ print(f'\n[3] Per-frame per-position fitting (α free, τ locked)…')
151
+ per_pos_series = {s: {} for s in all_s}
152
+ for t, positions in sorted(frame_positions.items()):
153
+ if cancel_event is not None and cancel_event.is_set():
154
+ break
155
+ frame_dir = group_dir / f't{t:04d}'
156
+ frame_dir.mkdir(exist_ok=True)
157
+ for s, ptu_path in sorted(positions.items()):
158
+ if cancel_event is not None and cancel_event.is_set():
159
+ break
160
+ step += 1
161
+ if progress_callback is not None:
162
+ progress_callback(step, total_frames)
163
+ print(f'\n t={t} s={s}: {ptu_path.name}')
164
+ t_start = time.time()
165
+ ptu = FLIMFile(str(ptu_path), verbose=False)
166
+ pixel_stack = ptu.raw_pixel_stack(channel=channel)
167
+ if pixel_stack.shape[2] != n_bins:
168
+ nb = pixel_stack.shape[2]
169
+ if nb > n_bins:
170
+ pixel_stack = pixel_stack[:, :, :n_bins]
171
+ else:
172
+ pixel_stack = np.pad(
173
+ pixel_stack, ((0, 0), (0, 0), (0, n_bins - nb)))
174
+ pixel_maps = fit_per_pixel(
175
+ pixel_stack.astype(np.float32),
176
+ tcspc_res, n_bins,
177
+ irf_prompt, has_tail, fit_bg, fit_sigma,
178
+ global_popt, args.nexp,
179
+ min_photons=getattr(args, 'min_photons', MIN_PHOTONS_PERPIX),
180
+ tau_min_ns=args.tau_min,
181
+ tau_max_ns=args.tau_max,
182
+ correct_pileup=getattr(args, 'correct_pileup', False),
183
+ n_sync=getattr(ptu, 'n_sync', None),
184
+ fit_idx=global_summary.get('fit_idx'),
185
+ progress_callback=None,
186
+ free_tau=False,
187
+ )
188
+ redox = compute_redox_metrics(pixel_maps, args.nexp,
189
+ compute_bound_fraction=compute_bound_fraction)
190
+ pos_dir = frame_dir / f's{s}'
191
+ pos_dir.mkdir(exist_ok=True)
192
+ intensity = pixel_maps.get('intensity', pixel_stack.sum(axis=2))
193
+ np.save(str(pos_dir / 'intensity.npy'), intensity.astype(np.float32))
194
+ for map_name in ('alpha_1', 'alpha_2', 'alpha_3', 'tau_mean_amp',
195
+ 'tau_mean_int', 'chi2_r', 'calibrated_chi2_r'):
196
+ if pixel_maps.get(map_name) is not None:
197
+ np.save(str(pos_dir / f'{map_name}.npy'),
198
+ pixel_maps[map_name].astype(np.float32))
199
+ for map_name, arr in redox.items():
200
+ np.save(str(pos_dir / f'{map_name}.npy'), arr)
201
+ roi_name = f'{group_label}_t{t:04d}_s{s}'
202
+ if getattr(args, 'save_lifetime', True):
203
+ try:
204
+ make_lifetime_image(
205
+ canvas=pixel_maps, output_dir=pos_dir, roi_name=roi_name,
206
+ tau_min_ns=tau_disp_min, tau_max_ns=tau_disp_max,
207
+ intensity_percentile_hi=95, tau_key='tau_mean_int', verbose=False,
208
+ )
209
+ except Exception as exc:
210
+ print(f' Warning: lifetime image export failed for {roi_name}: {exc}')
211
+ finally:
212
+ plt.close('all')
213
+ try:
214
+ save_tile_lifetime_txt(
215
+ pos_dir / f'{roi_name}_lifetime.txt', taus_ns, pixel_maps)
216
+ except Exception as exc:
217
+ print(f' Warning: lifetime .txt export failed for {roi_name}: {exc}')
218
+ try:
219
+ tile_decay = pixel_stack.sum(axis=(0, 1)).astype(np.float64)
220
+ tile_popt, tile_summary = fit_summed(
221
+ tile_decay, tcspc_res, n_bins,
222
+ irf_prompt, has_tail, fit_bg, fit_sigma,
223
+ args.nexp, args.tau_min, args.tau_max,
224
+ optimizer=getattr(args, 'optimizer', 'de'),
225
+ n_restarts=getattr(args, 'restarts', lm_restarts),
226
+ de_popsize=getattr(args, 'de_population', de_population),
227
+ de_maxiter=getattr(args, 'de_maxiter', de_maxiter),
228
+ workers=getattr(args, 'workers', n_workers),
229
+ polish=not getattr(args, 'no_polish', False),
230
+ cost_function=getattr(args, 'cost_function', 'poisson'),
231
+ sigma_max=sigma_max,
232
+ )
233
+ plot_summed(
234
+ tile_decay, tile_summary, ptu, None,
235
+ args.nexp, getattr(args, 'estimate_irf', 'gaussian'),
236
+ str(pos_dir / roi_name), irf_prompt=irf_prompt,
237
+ )
238
+ except Exception as exc:
239
+ print(f' Warning: per-tile detail fit plot failed for {roi_name}: {exc}')
240
+ finally:
241
+ plt.close('all')
242
+ if getattr(args, 'save_rgb', True):
243
+ try:
244
+ make_component_rgb_tiff(
245
+ canvas=pixel_maps, output_dir=pos_dir, roi_name=roi_name,
246
+ n_exp=args.nexp, intensity_percentile_hi=95, verbose=False,
247
+ )
248
+ except Exception as exc:
249
+ print(f' Warning: component RGB TIFF export failed for {roi_name}: {exc}')
250
+ if getattr(args, 'save_intensity', True):
251
+ try:
252
+ import tifffile as _tifffile
253
+ int_max_disp = getattr(args, 'intensity_display_max', None)
254
+ i_max = float(int_max_disp) if int_max_disp is not None \
255
+ else float(np.percentile(intensity[intensity > 0], 99.0)
256
+ if (intensity > 0).any() else 1.0)
257
+ i_max = max(i_max, 1e-6)
258
+ intensity_u16 = np.clip(
259
+ intensity.astype(np.float64) / i_max * 65535, 0, 65535
260
+ ).astype(np.uint16)
261
+ _tifffile.imwrite(str(pos_dir / f'{roi_name}_intensity.tif'), intensity_u16)
262
+ except Exception as exc:
263
+ print(f' Warning: intensity TIFF export failed for {roi_name}: {exc}')
264
+ if getattr(args, 'save_ind', False):
265
+ try:
266
+ from ..utils.enhanced_outputs import save_individual_tau_maps
267
+ save_individual_tau_maps(
268
+ pixel_maps, pos_dir, roi_name=roi_name, n_exp=args.nexp)
269
+ except Exception as exc:
270
+ print(f' Warning: individual component map export failed for {roi_name}: {exc}')
271
+ stats = {'t': t, 's': s, 'path': str(ptu_path)}
272
+ tau_map = redox.get('tau_mean')
273
+ if tau_map is None:
274
+ tau_map = pixel_maps.get('tau_mean_amp')
275
+ if tau_map is not None:
276
+ valid = tau_map[np.isfinite(tau_map) & (tau_map > 0)]
277
+ stats['tau_mean_mean'] = float(np.mean(valid)) if valid.size > 0 else float('nan')
278
+ stats['tau_mean_std'] = float(np.std(valid)) if valid.size > 0 else float('nan')
279
+ if 'bound_fraction' in redox:
280
+ bf = redox['bound_fraction']
281
+ valid_bf = bf[np.isfinite(bf)]
282
+ stats['bound_fraction_mean'] = (
283
+ float(np.mean(valid_bf)) if valid_bf.size > 0 else float('nan'))
284
+ stats['bound_fraction_std'] = (
285
+ float(np.std(valid_bf)) if valid_bf.size > 0 else float('nan'))
286
+ n_fitted = int(np.sum(
287
+ np.isfinite(pixel_maps.get('tau_mean_amp', np.array([np.nan])))))
288
+ stats['n_pixels_fitted'] = n_fitted
289
+ for i, tau in enumerate(taus_ns):
290
+ stats[f'tau{i+1}_ns'] = float(tau)
291
+ def _map_mean(name, require_positive=False):
292
+ m = pixel_maps.get(name)
293
+ if m is None:
294
+ return None
295
+ ok = np.isfinite(m)
296
+ if require_positive:
297
+ ok = ok & (m > 0)
298
+ vals = m[ok]
299
+ return float(np.mean(vals)) if vals.size > 0 else float('nan')
300
+ for i in range(args.nexp):
301
+ am = _map_mean(f'alpha_{i+1}')
302
+ if am is not None:
303
+ stats[f'alpha_{i+1}_mean'] = am
304
+ chi_mean = _map_mean('chi2_r', require_positive=True)
305
+ if chi_mean is not None:
306
+ stats['chi2_r_mean'] = chi_mean
307
+ elapsed = time.time() - t_start
308
+ print(f' τ_mean={stats.get('tau_mean_mean', float('nan')):.4f} ns '
309
+ f'bound_frac={stats.get('bound_fraction_mean', float('nan')):.4f} '
310
+ f'n_px={n_fitted:,} ({elapsed:.1f} s)')
311
+ per_pos_series[s][t] = stats
312
+ if getattr(args, 'save_stack', True):
313
+ print(f'\n[4] Saving 4D stacks…')
314
+ _save_4d_stacks(frame_positions, group_dir, group_label)
315
+ if not getattr(args, 'save_npy', True):
316
+ for t in frame_positions:
317
+ for s in frame_positions[t]:
318
+ pos_dir = group_dir / f't{t:04d}' / f's{s}'
319
+ for f_ in pos_dir.glob('*.npy'):
320
+ try:
321
+ f_.unlink(missing_ok=True)
322
+ except Exception as exc:
323
+ print(f' Warning: could not remove {f_}: {exc}')
324
+ if not getattr(args, 'no_plots', False):
325
+ print(f'\n[5] Saving summary plot…')
326
+ plot_metric_summary(
327
+ {f's{s}': v for s, v in per_pos_series.items()},
328
+ group_dir / f'{group_label}_timeseries.png',
329
+ group_label, 'Timepoint')
330
+ for s, s_series in per_pos_series.items():
331
+ save_series_csv(
332
+ s_series,
333
+ group_dir / f'{group_label}_s{s}_timeseries.csv',
334
+ index_name='t', drop_keys=('path',))
335
+ print(f' Saved CSV: {group_label}_s{s}_timeseries.csv')
336
+ _json_keys = (['tau_mean_mean', 'tau_mean_std',
337
+ 'bound_fraction_mean', 'bound_fraction_std',
338
+ 'n_pixels_fitted', 'chi2_r_mean']
339
+ + [f'tau{i+1}_ns' for i in range(args.nexp)]
340
+ + [f'alpha_{i+1}_mean' for i in range(args.nexp)])
341
+ save_json(group_dir / f'{group_label}_timeseries.json', {
342
+ 'positions': all_s,
343
+ 'timepoints': sorted(frame_positions.keys()),
344
+ 'per_position': {
345
+ str(s): {
346
+ 't': sorted(s_data.keys()),
347
+ **{k: [s_data.get(t, {}).get(k) for t in sorted(s_data)]
348
+ for k in _json_keys}
349
+ }
350
+ for s, s_data in per_pos_series.items()
351
+ },
352
+ })
353
+ all_results[(region, z)] = {
354
+ 'group_dir': str(group_dir),
355
+ 'n_timepoints': n_t,
356
+ 'positions': all_s,
357
+ 'taus_ns': list(taus_ns),
358
+ 'per_position_series': per_pos_series,
359
+ }
360
+ print(f'\n Group {group_label} done.')
361
+ print(f'\n{'='*60}')
362
+ print(f' TIMELAPSE COMPLETE → {output_dir}')
363
+ print(f'{'='*60}\n')
364
+ return all_results
365
+
366
+ def fit_zstack(ptu_dir, output_dir, args,
367
+ ref_tau1_ns=None, ref_tau2_ns=None, ref_tau3_ns=None,
368
+ channel=None,
369
+ compute_bound_fraction=False,
370
+ progress_callback=None,
371
+ cancel_event=None):
372
+ ptu_dir = Path(ptu_dir)
373
+ output_dir = Path(output_dir)
374
+ output_dir.mkdir(parents=True, exist_ok=True)
375
+ groups = group_zstack_files(ptu_dir)
376
+ if not groups:
377
+ raise ValueError(
378
+ f'No z-stack PTU files found in {ptu_dir}. '
379
+ 'Expected pattern: region_zX.ptu (optionally region_tX_sY_zX.ptu)')
380
+ print(f'\nZ-stack groups found:')
381
+ for (region, t, s), zslices in groups.items():
382
+ z_list = sorted(zslices)
383
+ print(f' region={region} t={t} s={s} → {len(z_list)} slices '
384
+ f'(z={z_list[0]}…{z_list[-1]})')
385
+ total_slices = sum(len(z) for z in groups.values())
386
+ step = 0
387
+ all_results = {}
388
+ for (region, t, s), zslices in groups.items():
389
+ if cancel_event is not None and cancel_event.is_set():
390
+ print(' Cancelled.')
391
+ break
392
+ group_label = zstack_group_label(region, t, s)
393
+ group_dir = output_dir / group_label
394
+ group_dir.mkdir(parents=True, exist_ok=True)
395
+ preview_dir = group_dir / 'lifetime_preview'
396
+ preview_dir.mkdir(exist_ok=True)
397
+ n_z = len(zslices)
398
+ print(f'\n{'='*60}')
399
+ print(f' STACK: {group_label} ({n_z} z-slices)')
400
+ print(f'{'='*60}')
401
+ print(f'\n[1] Pooling photons across {n_z} z-slices (one FOV)…')
402
+ pool_input = {z: {0: p} for z, p in zslices.items()}
403
+ pooled_decay, tcspc_res, n_bins = pool_decays(pool_input, channel=channel)
404
+ print(f' Total photons: {pooled_decay.sum():,.0f}')
405
+ irf_prompt, has_tail, fit_bg, fit_sigma, sigma_max = build_irf(
406
+ pooled_decay, tcspc_res, n_bins, args)
407
+ ref_taus = [ref_tau1_ns, ref_tau2_ns, ref_tau3_ns][:args.nexp]
408
+ use_supplied = all(r is not None for r in ref_taus)
409
+ if use_supplied:
410
+ print(f'\n[2] Using user-supplied τ: '
411
+ + ' '.join(f'τ{i+1}={t_} ns' for i, t_ in enumerate(ref_taus)))
412
+ irf_peak_bin = int(find_irf_peak_bin(pooled_decay))
413
+ global_popt = make_synthetic_popt(
414
+ ref_taus, args.nexp, n_bins,
415
+ irf_peak_bin, fit_sigma, fit_bg, has_tail)
416
+ taus_ns = np.array(ref_taus)
417
+ global_summary = {'taus_ns': taus_ns, 'n_exp': args.nexp}
418
+ else:
419
+ print(f'\n[2] Fitting reference τ from pooled z-stack ({args.nexp}-exp)…')
420
+ t0 = time.time()
421
+ global_popt, global_summary = fit_summed(
422
+ pooled_decay, tcspc_res, n_bins,
423
+ irf_prompt, has_tail, fit_bg, fit_sigma,
424
+ args.nexp, args.tau_min, args.tau_max,
425
+ optimizer=getattr(args, 'optimizer', 'de'),
426
+ n_restarts=getattr(args, 'restarts', lm_restarts),
427
+ de_popsize=getattr(args, 'de_population', de_population),
428
+ de_maxiter=getattr(args, 'de_maxiter', de_maxiter),
429
+ workers=getattr(args, 'workers', n_workers),
430
+ polish=not getattr(args, 'no_polish', False),
431
+ cost_function=getattr(args, 'cost_function', 'poisson'),
432
+ sigma_max=sigma_max,
433
+ )
434
+ print(f' Reference fit: {time.time() - t0:.1f} s')
435
+ taus_ns = global_summary.get('taus_ns', global_popt[:args.nexp] * 1e9)
436
+ for i, tau in enumerate(taus_ns):
437
+ print(f' τ{i+1} = {tau:.4f} ns (locked for whole stack)')
438
+ tau_disp_min, tau_disp_max = resolve_tau_display_range(taus_ns, args)
439
+ print(f' Lifetime display range (fixed for all slices): '
440
+ f'{tau_disp_min:.3f}-{tau_disp_max:.3f} ns')
441
+ save_json(group_dir / f'{group_label}_reference_fit.json', {
442
+ 'taus_ns': list(taus_ns),
443
+ 'nexp': args.nexp,
444
+ 'tau_min_ns': args.tau_min,
445
+ 'tau_max_ns': args.tau_max,
446
+ 'total_pooled_photons': float(pooled_decay.sum()),
447
+ 'tcspc_res_s': float(tcspc_res),
448
+ 'n_bins': int(n_bins),
449
+ 'n_slices': n_z,
450
+ 'z_slices': sorted(zslices),
451
+ 'estimate_irf': getattr(args, 'estimate_irf', 'gaussian'),
452
+ 'user_supplied_tau': use_supplied,
453
+ 'calibrated_chi2_pearson': global_summary.get(
454
+ 'calibrated_chi2_pearson'),
455
+ 'calibrated_chi2_tail_pearson': global_summary.get(
456
+ 'calibrated_chi2_tail_pearson'),
457
+ })
458
+ n_ref = min(len(pooled_decay), n_bins)
459
+ ref_time_ns = (np.arange(n_ref) + 0.5) * tcspc_res * 1e9
460
+ ref_model = global_summary.get('model')
461
+ np.savez(
462
+ str(group_dir / 'reference_decay.npz'),
463
+ decay=np.asarray(pooled_decay[:n_ref], dtype=np.float64),
464
+ time_ns=ref_time_ns.astype(np.float64),
465
+ model=(np.asarray(ref_model[:n_ref], dtype=np.float64)
466
+ if ref_model is not None else np.array([], dtype=np.float64)),
467
+ irf_prompt=(np.asarray(irf_prompt[:n_ref], dtype=np.float64)
468
+ if irf_prompt is not None else np.array([], dtype=np.float64)),
469
+ taus_ns=np.asarray(list(taus_ns), dtype=np.float64),
470
+ reduced_chi2_tail=np.asarray(
471
+ [global_summary.get('reduced_chi2_tail', float('nan'))], dtype=np.float64),
472
+ calibrated_chi2_pearson=np.asarray(
473
+ [global_summary.get('calibrated_chi2_pearson', float('nan'))],
474
+ dtype=np.float64),
475
+ calibrated_chi2_tail_pearson=np.asarray(
476
+ [global_summary.get('calibrated_chi2_tail_pearson', float('nan'))],
477
+ dtype=np.float64),
478
+ )
479
+ print(f'\n[3] Per-slice per-pixel fitting (α free, τ locked)…')
480
+ z_series = {}
481
+ for z, ptu_path in sorted(zslices.items()):
482
+ if cancel_event is not None and cancel_event.is_set():
483
+ break
484
+ step += 1
485
+ if progress_callback is not None:
486
+ progress_callback(step, total_slices)
487
+ print(f'\n z={z}: {ptu_path.name}')
488
+ t_start = time.time()
489
+ slice_dir = group_dir / f'z{z:04d}'
490
+ slice_dir.mkdir(exist_ok=True)
491
+ ptu = FLIMFile(str(ptu_path), verbose=False)
492
+ pixel_stack = ptu.raw_pixel_stack(channel=channel)
493
+ if pixel_stack.shape[2] != n_bins:
494
+ nb = pixel_stack.shape[2]
495
+ if nb > n_bins:
496
+ pixel_stack = pixel_stack[:, :, :n_bins]
497
+ else:
498
+ pixel_stack = np.pad(
499
+ pixel_stack, ((0, 0), (0, 0), (0, n_bins - nb)))
500
+ pixel_maps = fit_per_pixel(
501
+ pixel_stack.astype(np.float32),
502
+ tcspc_res, n_bins,
503
+ irf_prompt, has_tail, fit_bg, fit_sigma,
504
+ global_popt, args.nexp,
505
+ min_photons=getattr(args, 'min_photons', MIN_PHOTONS_PERPIX),
506
+ tau_min_ns=args.tau_min,
507
+ tau_max_ns=args.tau_max,
508
+ correct_pileup=getattr(args, 'correct_pileup', False),
509
+ n_sync=getattr(ptu, 'n_sync', None),
510
+ fit_idx=global_summary.get('fit_idx'),
511
+ progress_callback=None,
512
+ free_tau=False,
513
+ )
514
+ redox = compute_redox_metrics(pixel_maps, args.nexp,
515
+ compute_bound_fraction=compute_bound_fraction)
516
+ intensity = pixel_maps.get('intensity', pixel_stack.sum(axis=2))
517
+ np.save(str(slice_dir / 'intensity.npy'), intensity.astype(np.float32))
518
+ for map_name in ('alpha_1', 'alpha_2', 'alpha_3', 'tau_mean_amp',
519
+ 'tau_mean_int', 'chi2_r', 'calibrated_chi2_r'):
520
+ if pixel_maps.get(map_name) is not None:
521
+ np.save(str(slice_dir / f'{map_name}.npy'),
522
+ pixel_maps[map_name].astype(np.float32))
523
+ for map_name, arr in redox.items():
524
+ np.save(str(slice_dir / f'{map_name}.npy'), arr)
525
+ roi_name = f'{group_label}_z{z:04d}'
526
+ if getattr(args, 'save_lifetime', True):
527
+ try:
528
+ png_path = make_lifetime_image(
529
+ canvas=pixel_maps, output_dir=slice_dir, roi_name=roi_name,
530
+ tau_min_ns=tau_disp_min, tau_max_ns=tau_disp_max,
531
+ intensity_percentile_hi=95, tau_key='tau_mean_int', verbose=False,
532
+ )
533
+ if png_path is not None and Path(png_path).exists():
534
+ shutil.copyfile(str(png_path), str(preview_dir / f'z{z:04d}.png'))
535
+ except Exception as exc:
536
+ print(f' Warning: lifetime image export failed for {roi_name}: {exc}')
537
+ finally:
538
+ plt.close('all')
539
+ try:
540
+ save_tile_lifetime_txt(
541
+ slice_dir / f'{roi_name}_lifetime.txt', taus_ns, pixel_maps)
542
+ except Exception as exc:
543
+ print(f' Warning: lifetime .txt export failed for {roi_name}: {exc}')
544
+ try:
545
+ tile_decay = pixel_stack.sum(axis=(0, 1)).astype(np.float64)
546
+ tile_popt, tile_summary = fit_summed(
547
+ tile_decay, tcspc_res, n_bins,
548
+ irf_prompt, has_tail, fit_bg, fit_sigma,
549
+ args.nexp, args.tau_min, args.tau_max,
550
+ optimizer=getattr(args, 'optimizer', 'de'),
551
+ n_restarts=getattr(args, 'restarts', lm_restarts),
552
+ de_popsize=getattr(args, 'de_population', de_population),
553
+ de_maxiter=getattr(args, 'de_maxiter', de_maxiter),
554
+ workers=getattr(args, 'workers', n_workers),
555
+ polish=not getattr(args, 'no_polish', False),
556
+ cost_function=getattr(args, 'cost_function', 'poisson'),
557
+ sigma_max=sigma_max,
558
+ )
559
+ plot_summed(
560
+ tile_decay, tile_summary, ptu, None,
561
+ args.nexp, getattr(args, 'estimate_irf', 'gaussian'),
562
+ str(slice_dir / roi_name), irf_prompt=irf_prompt,
563
+ )
564
+ except Exception as exc:
565
+ print(f' Warning: per-slice detail fit plot failed for {roi_name}: {exc}')
566
+ finally:
567
+ plt.close('all')
568
+ if getattr(args, 'save_rgb', True):
569
+ try:
570
+ make_component_rgb_tiff(
571
+ canvas=pixel_maps, output_dir=slice_dir, roi_name=roi_name,
572
+ n_exp=args.nexp, intensity_percentile_hi=95, verbose=False,
573
+ )
574
+ except Exception as exc:
575
+ print(f' Warning: component RGB TIFF export failed for {roi_name}: {exc}')
576
+ if getattr(args, 'save_intensity', True):
577
+ try:
578
+ import tifffile as _tifffile
579
+ int_max_disp = getattr(args, 'intensity_display_max', None)
580
+ i_max = float(int_max_disp) if int_max_disp is not None \
581
+ else float(np.percentile(intensity[intensity > 0], 99.0)
582
+ if (intensity > 0).any() else 1.0)
583
+ i_max = max(i_max, 1e-6)
584
+ intensity_u16 = np.clip(
585
+ intensity.astype(np.float64) / i_max * 65535, 0, 65535
586
+ ).astype(np.uint16)
587
+ _tifffile.imwrite(str(slice_dir / f'{roi_name}_intensity.tif'), intensity_u16)
588
+ except Exception as exc:
589
+ print(f' Warning: intensity TIFF export failed for {roi_name}: {exc}')
590
+ if getattr(args, 'save_ind', False):
591
+ try:
592
+ from ..utils.enhanced_outputs import save_individual_tau_maps
593
+ save_individual_tau_maps(
594
+ pixel_maps, slice_dir, roi_name=roi_name, n_exp=args.nexp)
595
+ except Exception as exc:
596
+ print(f' Warning: individual component map export failed for {roi_name}: {exc}')
597
+ stats = {'z': z, 'path': str(ptu_path)}
598
+ tau_map = redox.get('tau_mean')
599
+ if tau_map is None:
600
+ tau_map = pixel_maps.get('tau_mean_amp')
601
+ if tau_map is not None:
602
+ valid = tau_map[np.isfinite(tau_map) & (tau_map > 0)]
603
+ stats['tau_mean_mean'] = float(np.mean(valid)) if valid.size > 0 else float('nan')
604
+ stats['tau_mean_std'] = float(np.std(valid)) if valid.size > 0 else float('nan')
605
+ if 'bound_fraction' in redox:
606
+ bf = redox['bound_fraction']
607
+ valid_bf = bf[np.isfinite(bf)]
608
+ stats['bound_fraction_mean'] = (
609
+ float(np.mean(valid_bf)) if valid_bf.size > 0 else float('nan'))
610
+ stats['bound_fraction_std'] = (
611
+ float(np.std(valid_bf)) if valid_bf.size > 0 else float('nan'))
612
+ n_fitted = int(np.sum(
613
+ np.isfinite(pixel_maps.get('tau_mean_amp', np.array([np.nan])))))
614
+ stats['n_pixels_fitted'] = n_fitted
615
+ for i, tau in enumerate(taus_ns):
616
+ stats[f'tau{i+1}_ns'] = float(tau)
617
+ def _map_mean(name, require_positive=False):
618
+ m = pixel_maps.get(name)
619
+ if m is None:
620
+ return None
621
+ ok = np.isfinite(m)
622
+ if require_positive:
623
+ ok = ok & (m > 0)
624
+ vals = m[ok]
625
+ return float(np.mean(vals)) if vals.size > 0 else float('nan')
626
+ for i in range(args.nexp):
627
+ am = _map_mean(f'alpha_{i+1}')
628
+ if am is not None:
629
+ stats[f'alpha_{i+1}_mean'] = am
630
+ chi_mean = _map_mean('chi2_r', require_positive=True)
631
+ if chi_mean is not None:
632
+ stats['chi2_r_mean'] = chi_mean
633
+ elapsed = time.time() - t_start
634
+ print(f' τ_mean={stats.get('tau_mean_mean', float('nan')):.4f} ns '
635
+ f'bound_frac={stats.get('bound_fraction_mean', float('nan')):.4f} '
636
+ f'n_px={n_fitted:,} ({elapsed:.1f} s)')
637
+ z_series[z] = stats
638
+ if getattr(args, 'save_stack', True):
639
+ print(f'\n[4] Saving (Z, H, W) stacks…')
640
+ save_map_stacks(
641
+ [group_dir / f'z{z:04d}' for z in sorted(zslices)],
642
+ group_dir, group_label)
643
+ if not getattr(args, 'save_npy', True):
644
+ for z in zslices:
645
+ slice_dir = group_dir / f'z{z:04d}'
646
+ for f_ in slice_dir.glob('*.npy'):
647
+ try:
648
+ f_.unlink(missing_ok=True)
649
+ except Exception as exc:
650
+ print(f' Warning: could not remove {f_}: {exc}')
651
+ if not getattr(args, 'no_plots', False):
652
+ print(f'\n[5] Saving z-series plot…')
653
+ plot_metric_summary(
654
+ {group_label: z_series},
655
+ group_dir / f'{group_label}_zseries.png', group_label, 'Z-slice')
656
+ save_series_csv(
657
+ z_series, group_dir / f'{group_label}_zseries.csv',
658
+ index_name='z', drop_keys=('path', 'z'))
659
+ print(f' Saved CSV: {group_label}_zseries.csv')
660
+ _json_keys = (['tau_mean_mean', 'tau_mean_std',
661
+ 'bound_fraction_mean', 'bound_fraction_std',
662
+ 'n_pixels_fitted', 'chi2_r_mean']
663
+ + [f'tau{i+1}_ns' for i in range(args.nexp)]
664
+ + [f'alpha_{i+1}_mean' for i in range(args.nexp)])
665
+ save_json(group_dir / f'{group_label}_zseries.json', {
666
+ 'z_slices': sorted(z_series.keys()),
667
+ **{k: [z_series.get(z, {}).get(k) for z in sorted(z_series)]
668
+ for k in _json_keys},
669
+ })
670
+ all_results[group_label] = {
671
+ 'group_dir': str(group_dir),
672
+ 'preview_dir': str(preview_dir),
673
+ 'n_slices': n_z,
674
+ 'taus_ns': list(taus_ns),
675
+ 'z_series': z_series,
676
+ }
677
+ print(f'\n Stack {group_label} done.')
678
+ print(f'\n{'='*60}')
679
+ print(f' Z-STACK COMPLETE → {output_dir}')
680
+ print(f'{'='*60}\n')
681
+ return all_results