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/interactive.py ADDED
@@ -0,0 +1,1857 @@
1
+ import argparse
2
+ import numpy as np
3
+ from pathlib import Path
4
+ from tqdm import tqdm
5
+ import matplotlib
6
+
7
+ tqdm.disable = True
8
+
9
+ from .configs import (
10
+ n_exp, Tau_min, Tau_max, D_mode, binning_factor, MIN_PHOTONS_PERPIX, Optimizer, lm_restarts, de_population, de_maxiter, n_workers, OUT_NAME, Estimate_IRF, IRF_BINS, IRF_FIT_WIDTH, IRF_FWHM, channels, config_message, INTENSITY_THRESHOLD,
11
+ TAU_DISPLAY_MIN, TAU_DISPLAY_MAX, INTENSITY_DISPLAY_MIN, INTENSITY_DISPLAY_MAX,
12
+ MACHINE_IRF_DEFAULT_PATH, MACHINE_IRF_FIT_BG, MACHINE_IRF_FIT_SIGMA, MACHINE_IRF_FIT_TAIL,
13
+ MACHINE_IRF_SIGMA_MAX_FULL, MACHINE_IRF_SIGMA_MAX_HALF)
14
+ from flimkit.formats import FLIMFile
15
+ from .formats.PTU.stitch import stitch_flim_tiles, load_flim_for_fitting
16
+ from .utils.xml_utils import parse_xlif_tile_positions
17
+ from .FLIM.fit_tools import find_irf_peak_bin
18
+ from .FLIM.irf_tools import irf_from_scatter_ptu, irf_from_measured_file, align_irf_to_bin, gaussian_irf_from_fwhm, compare_irfs, estimate_irf_from_decay_parametric, estimate_irf_from_decay_raw, irf_from_xlsx, irf_from_xlsx_analytical, machine_irf_prompt
19
+ from .FLIM.fitters import (fit_summed, fit_per_pixel, fit_summed_dist,
20
+ fit_per_pixel_dist, fit_summed_tail)
21
+ from .utils.xlsx_tools import load_irf_export
22
+ from .utils.misc import print_summary
23
+ from .utils.plotting import plot_summed, plot_pixel_maps, plot_lifetime_histogram
24
+ from .utils.enhanced_outputs import (
25
+ save_fit_summary_txt,
26
+ save_weighted_tau_images,
27
+ save_individual_tau_maps,
28
+ create_complete_output_package
29
+ )
30
+ from .utils.lifetime_image import make_lifetime_image
31
+ from .image.tools import make_intensity_image, make_cell_mask, apply_intensity_threshold, pick_intensity_threshold
32
+ from ._version import fitter_version
33
+
34
+ def _cancelled(cancel_event):
35
+ if cancel_event is not None and cancel_event.is_set():
36
+ print('\nCancelled.')
37
+ return True
38
+ return False
39
+
40
+ def _make_operation_progress_callback(operation_name, progress_window_manager):
41
+ if progress_window_manager is None:
42
+ return None
43
+ window_id = progress_window_manager.create_progress_window(task_name=operation_name)
44
+ def callback(current, total):
45
+ progress_window_manager.update_progress(window_id, current, total)
46
+ return callback
47
+ def _load_machine_irf_prompt(machine_irf_path, n_bins, decay_peak_bin):
48
+ irf_path = Path(machine_irf_path) if machine_irf_path else Path(MACHINE_IRF_DEFAULT_PATH)
49
+ if not irf_path.exists():
50
+ raise FileNotFoundError(f'Machine IRF file not found: {irf_path}')
51
+ irf_prompt = np.asarray(np.load(str(irf_path)), dtype=float).ravel()
52
+ if irf_prompt.size == 0:
53
+ raise ValueError(f'Machine IRF file is empty: {irf_path}')
54
+ irf_prompt = np.maximum(irf_prompt, 0.0)
55
+ if irf_prompt.size > n_bins:
56
+ irf_prompt = irf_prompt[:n_bins]
57
+ elif irf_prompt.size < n_bins:
58
+ padded = np.zeros(n_bins, dtype=float)
59
+ padded[:irf_prompt.size] = irf_prompt
60
+ irf_prompt = padded
61
+ s = irf_prompt.sum()
62
+ if s <= 0:
63
+ raise ValueError(f'Machine IRF has zero total intensity after processing: {irf_path}')
64
+ irf_prompt /= s
65
+ current_peak = int(np.argmax(irf_prompt))
66
+ shift = int(decay_peak_bin) - current_peak
67
+ if shift != 0:
68
+ irf_prompt = np.roll(irf_prompt, shift)
69
+ strategy = f'machine_irf ({irf_path.name}) peak_aligned_to_decay'
70
+ return irf_prompt, strategy
71
+
72
+ def parse_exclude_ns(spec):
73
+ if not spec:
74
+ return None
75
+ if not isinstance(spec, str):
76
+ return spec or None
77
+ bands = []
78
+ for part in spec.split(','):
79
+ part = part.strip()
80
+ if not part:
81
+ continue
82
+ try:
83
+ lo, hi = part.split('-')
84
+ lo, hi = float(lo), float(hi)
85
+ except ValueError:
86
+ raise ValueError(f'bad --exclude-ns band {part!r}; expected "lo-hi" in ns')
87
+ if hi <= lo:
88
+ raise ValueError(f'bad --exclude-ns band {part!r}; hi must exceed lo')
89
+ bands.append((lo, hi))
90
+ return bands or None
91
+
92
+ def _align_measured_irf(args, irf_prompt, irf_peak_bin, n_bins, tcspc_res):
93
+ offset = int(np.argmax(irf_prompt)) - int(irf_peak_bin)
94
+ if getattr(args, 'align_irf', False):
95
+ irf_prompt, shift = align_irf_to_bin(irf_prompt, irf_peak_bin, n_bins)
96
+ print(f' Measured IRF aligned: shifted {shift:+d} bins '
97
+ f'({shift * tcspc_res * 1e9:+.3f} ns) onto the decay rising edge')
98
+ return irf_prompt, 'measured_irf (aligned to decay)'
99
+ if abs(offset) > 10:
100
+ print(f' WARNING: measured IRF peak sits {offset:+d} bins '
101
+ f'({offset * tcspc_res * 1e9:+.2f} ns) from the decay rising edge, and the '
102
+ f'fit can only shift it by ±{getattr(args, "irf_shift_bins", 2)} bins.')
103
+ print(f' Lifetimes from this fit will be wrong. Enable IRF alignment '
104
+ f'(--align-irf) if the IRF came from a separate acquisition.')
105
+ return irf_prompt, 'measured_irf'
106
+
107
+ def apply_point_mode_limits(args, ptu):
108
+ if getattr(ptu, 'is_image', True):
109
+ return False
110
+ print(' Point measurement: no scan image, fitting the summed decay only.')
111
+ if getattr(args, 'mode', 'summed') != 'summed':
112
+ print(f' mode={args.mode} -> summed (per-pixel needs an image)')
113
+ args.mode = 'summed'
114
+ if getattr(args, 'cell_mask', False):
115
+ print(' cell mask ignored (needs an image)')
116
+ args.cell_mask = False
117
+ if getattr(args, 'intensity_threshold', None) is not None:
118
+ print(' intensity threshold ignored (needs an image)')
119
+ args.intensity_threshold = None
120
+ return True
121
+
122
+ def yes_no_question(question):
123
+ import inquirer
124
+ questions = [inquirer.List('yesno',
125
+ message=question,
126
+ choices=['Yes', 'No'])]
127
+ answer = inquirer.prompt(questions)
128
+ return 'y' if answer['yesno'] == 'Yes' else 'n'
129
+ def stitch_tiles_inquire():
130
+ print('\nROI Reconstruction: Tile Stitching')
131
+ import inquirer
132
+ xlif_path = input('Enter path to XLIF metadata file: ').strip()
133
+ if not xlif_path or not Path(xlif_path).exists():
134
+ raise ValueError(f'XLIF file not found: {xlif_path}')
135
+ ptu_dir = input('Enter directory containing PTU tiles: ').strip()
136
+ if not ptu_dir or not Path(ptu_dir).exists():
137
+ raise ValueError(f'PTU directory not found: {ptu_dir}')
138
+ base_output = input('Enter base output directory (a subfolder named after the ROI will be created inside it): ').strip()
139
+ if not base_output:
140
+ raise ValueError('Output directory is required')
141
+ ptu_basename = Path(xlif_path).stem
142
+ roi_clean = ptu_basename.replace(' ', '_')
143
+ output_dir = str(Path(base_output) / roi_clean)
144
+ print(f' Data will be saved in: {output_dir}')
145
+ rotate_q = yes_no_question('Apply 90° clockwise rotation to tiles? (Recommended for FLIM microscope data)')
146
+ rotate_tiles = (rotate_q == 'y')
147
+ args = argparse.Namespace()
148
+ args.xlif = xlif_path
149
+ args.ptu_dir = ptu_dir
150
+ args.output_dir = output_dir
151
+ args.ptu_basename = ptu_basename
152
+ args.rotate_tiles = rotate_tiles
153
+ return args
154
+ def stitch_and_fit_inquire():
155
+ import inquirer
156
+ print('\n ROI Reconstruction + FLIM Fitting')
157
+ print('\nStep 1: Tile Stitching Setup')
158
+ stitch_args = stitch_tiles_inquire()
159
+ print('\nStep 2: FLIM Fitting Setup')
160
+ print('\nIRF estimation options:')
161
+ print(" 1. 'irf_xlsx' - analytical Gaussian+tail fit from LAS X export (recommended)")
162
+ print(" 2. 'file' - measured IRF image (scatter PTU)")
163
+ print(" 3. 'machine_irf' - prebuilt machine IRF (.npy), peak-aligned to decay")
164
+ print(" 4. 'machine_irf_sigma_full' - machine IRF + full σ broadening (σ≤3.0)")
165
+ print(" 5. 'machine_irf_sigma_half' - machine IRF + half σ broadening (σ≤0.5, recommended)")
166
+ print(" 6. 'raw' - non-parametric IRF from raw decay")
167
+ print(" 7. 'parametric' - fit Gaussian + exponential tail")
168
+ print(" 8. 'gaussian' - simple Gaussian IRF (fastest)")
169
+ method_q = [inquirer.List('method',
170
+ message='Choose IRF estimation method',
171
+ choices=['irf_xlsx', 'file', 'machine_irf', 'machine_irf_sigma_full', 'machine_irf_sigma_half', 'raw', 'parametric', 'gaussian'])]
172
+ estimate_irf = inquirer.prompt(method_q)['method']
173
+ irf_path = None
174
+ irf_xlsx_path = None
175
+ machine_irf_path = None
176
+ if estimate_irf == 'irf_xlsx':
177
+ irf_xlsx_path = input('Enter path to IRF export (.xlsx or .csv): ').strip()
178
+ if not irf_xlsx_path or not Path(irf_xlsx_path).exists():
179
+ print(' Warning: IRF export not found, falling back to Gaussian')
180
+ estimate_irf = 'gaussian'
181
+ irf_xlsx_path = None
182
+ elif estimate_irf == 'file':
183
+ irf_path = input('Enter path to IRF PTU file: ').strip()
184
+ if not irf_path or not Path(irf_path).exists():
185
+ print(' Warning: IRF file not found, falling back to Gaussian')
186
+ estimate_irf = 'gaussian'
187
+ irf_path = None
188
+ elif estimate_irf == 'machine_irf' or estimate_irf.startswith('machine_irf_sigma'):
189
+ _default_machine = str(MACHINE_IRF_DEFAULT_PATH)
190
+ machine_irf_path = input(
191
+ f'Enter path to machine IRF .npy (Enter for default: {_default_machine}): '
192
+ ).strip()
193
+ machine_irf_path = machine_irf_path if machine_irf_path else _default_machine
194
+ if not Path(machine_irf_path).exists():
195
+ print(' Warning: machine IRF file not found, falling back to Gaussian')
196
+ estimate_irf = 'gaussian'
197
+ machine_irf_path = None
198
+ nexp_q = [inquirer.List('nexp',
199
+ message='Number of exponential components',
200
+ choices=['1', '2', '3'],
201
+ default='2')]
202
+ nexp = int(inquirer.prompt(nexp_q)['nexp'])
203
+ perpixel_q = yes_no_question('Perform per-pixel fitting? (slower but gives lifetime maps)')
204
+ fit_per_pixel_mode = (perpixel_q == 'y')
205
+ save_individual_q = yes_no_question('Save individual component maps? (tau1, tau2, a1, a2) (default: No)')
206
+ save_individual = (save_individual_q == 'y')
207
+ optimizer_choice = 'de'
208
+ print(f'\n[Auto] Using optimizer: de (log-tau + Sobol, robust global search)')
209
+ if fit_per_pixel_mode:
210
+ tau_range_q = yes_no_question('Set a lifetime display range for tau images? (e.g. 0-5 ns)')
211
+ if tau_range_q == 'y':
212
+ _def_lo = TAU_DISPLAY_MIN
213
+ _def_hi = TAU_DISPLAY_MAX
214
+ tau_min_display = input(f" Min lifetime (ns, Enter={'0' if _def_lo is None else _def_lo}): ").strip()
215
+ tau_max_display = input(f" Max lifetime (ns, Enter={'no limit' if _def_hi is None else _def_hi}): ").strip()
216
+ tau_min_display = float(tau_min_display) if tau_min_display else (_def_lo if _def_lo is not None else 0.0)
217
+ tau_max_display = float(tau_max_display) if tau_max_display else _def_hi
218
+ else:
219
+ tau_min_display = TAU_DISPLAY_MIN
220
+ tau_max_display = TAU_DISPLAY_MAX
221
+ int_range_q = yes_no_question('Set an intensity display range for exported images?')
222
+ if int_range_q == 'y':
223
+ _idef_lo = INTENSITY_DISPLAY_MIN
224
+ _idef_hi = INTENSITY_DISPLAY_MAX
225
+ int_min_display = input(f" Min intensity (Enter={'no limit' if _idef_lo is None else _idef_lo}): ").strip()
226
+ int_max_display = input(f" Max intensity (Enter={'no limit' if _idef_hi is None else _idef_hi}): ").strip()
227
+ int_min_display = float(int_min_display) if int_min_display else _idef_lo
228
+ int_max_display = float(int_max_display) if int_max_display else _idef_hi
229
+ else:
230
+ int_min_display = INTENSITY_DISPLAY_MIN
231
+ int_max_display = INTENSITY_DISPLAY_MAX
232
+ else:
233
+ tau_min_display = None
234
+ tau_max_display = None
235
+ int_min_display = None
236
+ int_max_display = None
237
+ thresh_q = yes_no_question('Apply an intensity threshold to exclude low-signal pixels?')
238
+ if thresh_q == 'y':
239
+ thresh_mode_q = [inquirer.List('tmode',
240
+ message='Choose threshold method',
241
+ choices=['Enter a value', 'Pick interactively (slider)'],
242
+ default='Pick interactively (slider)')]
243
+ thresh_mode = inquirer.prompt(thresh_mode_q)['tmode']
244
+ if thresh_mode == 'Enter a value':
245
+ val = input(' Min photon intensity per pixel: ').strip()
246
+ intensity_threshold = int(val) if val else None
247
+ intensity_threshold_interactive = False
248
+ else:
249
+ intensity_threshold = 'interactive'
250
+ intensity_threshold_interactive = True
251
+ else:
252
+ intensity_threshold = None
253
+ intensity_threshold_interactive = False
254
+ args = argparse.Namespace()
255
+ args.xlif = stitch_args.xlif
256
+ args.ptu_dir = stitch_args.ptu_dir
257
+ args.output_dir = stitch_args.output_dir
258
+ args.ptu_basename = stitch_args.ptu_basename
259
+ args.rotate_tiles = stitch_args.rotate_tiles
260
+ args.irf = irf_path
261
+ args.irf_xlsx = irf_xlsx_path
262
+ args.machine_irf = machine_irf_path
263
+ args.estimate_irf = estimate_irf
264
+ args.nexp = nexp
265
+ args.tau_min = Tau_min
266
+ args.tau_max = Tau_max
267
+ args.mode = 'both' if fit_per_pixel_mode else 'summed'
268
+ args.binning = binning_factor
269
+ args.min_photons = MIN_PHOTONS_PERPIX
270
+ args.optimizer = optimizer_choice
271
+ args.restarts = lm_restarts
272
+ args.de_population = de_population
273
+ args.de_maxiter = de_maxiter
274
+ args.workers = n_workers
275
+ args.no_polish = False
276
+ args.channel = channels
277
+ args.irf_fwhm = IRF_FWHM
278
+ args.irf_bins = IRF_BINS
279
+ args.irf_fit_width = IRF_FIT_WIDTH
280
+ args.no_plots = False
281
+ args.intensity_threshold = intensity_threshold
282
+ args.save_individual = save_individual
283
+ args.save_weighted = True
284
+ args.tau_display_min = tau_min_display
285
+ args.tau_display_max = tau_max_display
286
+ args.intensity_display_min = int_min_display
287
+ args.intensity_display_max = int_max_display
288
+ return args
289
+ def _run_tile_stitch(args):
290
+ print(f"\n{'='*60}")
291
+ print(f' TILE STITCHING')
292
+ print(f"{'='*60}")
293
+ result = stitch_flim_tiles(
294
+ xlif_path=Path(args.xlif),
295
+ ptu_dir=Path(args.ptu_dir),
296
+ output_dir=Path(args.output_dir),
297
+ ptu_basename=args.ptu_basename,
298
+ rotate_tiles=args.rotate_tiles,
299
+ register_tiles=getattr(args, 'register_tiles', True),
300
+ reg_max_shift_px=getattr(args, 'reg_max_shift_px', 80),
301
+ verbose=True,
302
+ )
303
+ if result['tiles_processed'] == 0:
304
+ raise RuntimeError('No tiles were successfully stitched!')
305
+ print(f"\n{'='*60}")
306
+ print(f' STITCHING COMPLETE')
307
+ print(f"{'='*60}")
308
+ print(f"Processed: {result['tiles_processed']}/{result['tiles_processed'] + result['tiles_skipped']} tiles")
309
+ print(f'\nOutputs:')
310
+ print(f" Intensity: {result['intensity_path']}")
311
+ print(f" FLIM data: {result['flim_path']}")
312
+ print(f" Time axis: {result['time_axis_path']}")
313
+ print(f" Metadata: {result['metadata_path']}")
314
+ return result
315
+ def _run_stitch_and_fit(args, progress_callback=None, cancel_event=None, progress_window_manager=None):
316
+ print(f"\n{'='*60}")
317
+ print(f' STEP 1: TILE STITCHING')
318
+ print(f"{'='*60}")
319
+ stitch_progress_cb = _make_operation_progress_callback(
320
+ 'Tile Stitching', progress_window_manager) or progress_callback
321
+ stitch_result = stitch_flim_tiles(
322
+ xlif_path=Path(args.xlif),
323
+ ptu_dir=Path(args.ptu_dir),
324
+ output_dir=Path(args.output_dir),
325
+ ptu_basename=args.ptu_basename,
326
+ rotate_tiles=args.rotate_tiles,
327
+ register_tiles=getattr(args, 'register_tiles', True),
328
+ reg_max_shift_px=getattr(args, 'reg_max_shift_px', 80),
329
+ verbose=True,
330
+ progress_callback=stitch_progress_cb,
331
+ cancel_event=cancel_event,
332
+ )
333
+ if stitch_result['tiles_processed'] == 0:
334
+ raise RuntimeError('No tiles were successfully stitched!')
335
+ print(f"\nStitching complete: {stitch_result['tiles_processed']} tiles processed")
336
+ roi_name = args.ptu_basename.replace(' ', '_')
337
+ if _cancelled(cancel_event):
338
+ return None
339
+ print(f"\n{'='*60}")
340
+ print(f' STEP 2: LOADING STITCHED DATA')
341
+ print(f"{'='*60}")
342
+ stack, tcspc_res, n_bins = load_flim_for_fitting(
343
+ Path(args.output_dir),
344
+ load_to_memory=False
345
+ )
346
+ print(f' Mapped: {stack.shape} @ {tcspc_res*1e12:.2f} ps/bin')
347
+ weight_candidates = sorted(Path(args.output_dir).glob('*_weight_map.npy'))
348
+ if weight_candidates:
349
+ weight_map = np.load(str(weight_candidates[0])).astype(np.float32)
350
+ weight_map = np.clip(weight_map, 1.0, None)
351
+ print(f' Loaded weight map: max overlap = {int(weight_map.max())} tiles')
352
+ else:
353
+ weight_map = None
354
+ print(' No weight map found, overlap normalisation skipped')
355
+ class StitchedData:
356
+ def __init__(self, stack, tcspc_res, n_bins, weight_map=None):
357
+ self.n_bins = n_bins
358
+ self.tcspc_res = tcspc_res
359
+ self.time_ns = np.arange(n_bins) * tcspc_res * 1e9
360
+ self.photon_channel = None
361
+ self._stack = stack
362
+ self._weight_map = weight_map
363
+ def summed_decay(self, channel=None):
364
+ return self._stack.sum(axis=(0, 1))
365
+ def pixel_stack(self, channel=None, binning=1):
366
+ ny, nx, nt = self._stack.shape
367
+ if binning == 1:
368
+ if self._weight_map is not None:
369
+ out = np.empty((ny, nx, nt), dtype=np.float32)
370
+ CHUNK = 64
371
+ for r0 in tqdm(range(0, ny, CHUNK),
372
+ desc=' Normalising overlap (pixel stack)',
373
+ unit='chunk', disable=True):
374
+ r1 = min(r0 + CHUNK, ny)
375
+ w = self._weight_map[r0:r1, :, np.newaxis]
376
+ out[r0:r1] = self._stack[r0:r1].astype(np.float32) / w
377
+ return out
378
+ else:
379
+ return self._stack
380
+ else:
381
+ new_ny = ny // binning
382
+ new_nx = nx // binning
383
+ binned = np.zeros((new_ny, new_nx, nt), dtype=np.float32)
384
+ for i in tqdm(range(new_ny), desc=' Binning rows', disable=True):
385
+ for j in range(new_nx):
386
+ patch = self._stack[
387
+ i*binning:(i+1)*binning,
388
+ j*binning:(j+1)*binning, :
389
+ ].astype(np.float32)
390
+ binned[i, j, :] = patch.sum(axis=(0, 1))
391
+ if self._weight_map is not None:
392
+ w = self._weight_map[
393
+ i*binning:(i+1)*binning,
394
+ j*binning:(j+1)*binning
395
+ ].mean()
396
+ if w > 0:
397
+ binned[i, j, :] /= w
398
+ return binned
399
+ ptu = StitchedData(stack, tcspc_res, n_bins, weight_map=weight_map)
400
+ CHUNK_ROWS = 64
401
+ ny, nx = stack.shape[:2]
402
+ print(f'\n Building tissue mask from stitched intensity (chunked)...')
403
+ intensity_2d = np.zeros((ny, nx), dtype=np.float64)
404
+ for r0 in tqdm(range(0, ny, CHUNK_ROWS), desc=' Building intensity', unit='chunk', disable=True):
405
+ r1 = min(r0 + CHUNK_ROWS, ny)
406
+ intensity_2d[r0:r1] = stack[r0:r1].sum(axis=2)
407
+ tissue_mask = make_cell_mask(intensity_2d,
408
+ save_mask=True,
409
+ path=str(Path(args.output_dir) / roi_name))
410
+ n_tissue = int(tissue_mask.sum())
411
+ n_total = tissue_mask.size
412
+ print(f' Tissue mask: {n_tissue:,} / {n_total:,} pixels '
413
+ f'({100*n_tissue/n_total:.1f}%) are tissue')
414
+ _int_thr = getattr(args, 'intensity_threshold', None)
415
+ if _int_thr is not None:
416
+ print(f'\n Applying intensity threshold...')
417
+ if _int_thr == 'interactive':
418
+ _int_thr = pick_intensity_threshold(intensity_2d)
419
+ else:
420
+ _int_thr = int(_int_thr)
421
+ int_mask = apply_intensity_threshold(intensity_2d, _int_thr)
422
+ tissue_mask = tissue_mask & int_mask
423
+ n_after = int(tissue_mask.sum())
424
+ print(f' Intensity threshold: {_int_thr} photons → '
425
+ f'{n_after:,}/{n_total:,} pixels kept ({100*n_after/n_total:.1f}%)')
426
+ print(f'\n Building masked summed decay (chunked)...')
427
+ decay = np.zeros(n_bins, dtype=np.float64)
428
+ for r0 in tqdm(range(0, ny, CHUNK_ROWS), desc=' Building decay', unit='chunk', disable=True):
429
+ r1 = min(r0 + CHUNK_ROWS, ny)
430
+ chunk = stack[r0:r1].astype(np.float32)
431
+ row_mask = tissue_mask[r0:r1]
432
+ chunk[~row_mask] = 0
433
+ decay += chunk.sum(axis=(0, 1))
434
+ del chunk
435
+ print(f' Total photons (tissue): {decay.sum():,.0f}')
436
+ if decay.sum() == 0:
437
+ print(' \u26a0 WARNING: tissue mask produced zero photons '
438
+ '(mask may be inverted or canvas gaps only). '
439
+ 'Falling back to full-canvas summed decay.')
440
+ decay = np.zeros(n_bins, dtype=np.float64)
441
+ for r0 in range(0, ny, CHUNK_ROWS):
442
+ r1 = min(r0 + CHUNK_ROWS, ny)
443
+ decay += stack[r0:r1].astype(np.float64).sum(axis=(0, 1))
444
+ print(f' Total photons (full canvas): {decay.sum():,.0f}')
445
+ if _cancelled(cancel_event):
446
+ return None
447
+ print(f"\n{'='*60}")
448
+ print(f' STEP 3: FLIM FITTING')
449
+ print(f"{'='*60}")
450
+ print(f' {args.nexp}-exp | {args.mode} | optimizer={args.optimizer}')
451
+ irf_peak_bin = find_irf_peak_bin(decay)
452
+ decay_peak_bin = int(np.argmax(decay))
453
+ print(f'\nSummed decay (tissue-masked):')
454
+ print(f' {decay.sum():,.0f} photons | peak={decay.max():,.0f} at bin {decay_peak_bin}')
455
+ print(f' IRF peak (steepest rise): bin {irf_peak_bin} ({irf_peak_bin * tcspc_res * 1e9:.3f} ns)')
456
+ print(f'\nBuilding IRF (method: {args.estimate_irf})...')
457
+ sigma_max = MACHINE_IRF_SIGMA_MAX_FULL
458
+ if args.irf is not None and Path(args.irf).exists():
459
+ irf_prompt = irf_from_measured_file(args.irf, ptu, channel=args.channel)
460
+ irf_prompt, strategy = _align_measured_irf(
461
+ args, irf_prompt, irf_peak_bin, n_bins, tcspc_res)
462
+ has_tail = False
463
+ fit_sigma = False
464
+ fit_bg = True
465
+ elif getattr(args, 'irf_xlsx', None) is not None:
466
+ print(f' IRF: fitting analytical model to: {args.irf_xlsx}')
467
+ if not Path(args.irf_xlsx).exists():
468
+ raise FileNotFoundError(f'IRF export file not found: {args.irf_xlsx}')
469
+ irf_ref = load_irf_export(args.irf_xlsx, debug=False)
470
+ if irf_ref['irf_t'] is None or irf_ref['irf_c'] is None:
471
+ raise ValueError(f'No IRF columns found in export: {args.irf_xlsx}')
472
+ irf_prompt, irf_params = irf_from_xlsx_analytical(
473
+ irf_ref, n_bins, tcspc_res, verbose=True)
474
+ irf_current_peak = int(np.argmax(irf_prompt))
475
+ pre_shift = irf_peak_bin - irf_current_peak
476
+ if pre_shift != 0:
477
+ x = np.arange(n_bins, dtype=float)
478
+ irf_prompt = np.interp(x - pre_shift, x, irf_prompt, left=0.0, right=0.0)
479
+ s = irf_prompt.sum()
480
+ if s > 0:
481
+ irf_prompt /= s
482
+ print(f' Pre-shifted IRF by {pre_shift:+d} bins '
483
+ f'(from bin {irf_current_peak} → {irf_peak_bin})')
484
+ strategy = (f'irf_xlsx_analytical ({Path(args.irf_xlsx).name}) '
485
+ f"FWHM={irf_params['fwhm_ns']*1000:.1f}ps "
486
+ f"tail_amp={irf_params['tail_amp']:.3f} "
487
+ f"tail_tau={irf_params['tail_tau_ns']:.3f}ns")
488
+ has_tail = False
489
+ fit_sigma = False
490
+ fit_bg = True
491
+ elif args.estimate_irf in ('machine_irf', 'machine_irf_sigma_full', 'machine_irf_sigma_half'):
492
+ _align_bin = irf_peak_bin if getattr(args, 'irf_align', 'steepest_rise') == 'steepest_rise' else decay_peak_bin
493
+ _align_lbl = 'steepest_rise' if _align_bin == irf_peak_bin else 'decay_peak'
494
+ irf_prompt, strategy, has_tail, fit_bg, fit_sigma, sigma_max = machine_irf_prompt(
495
+ getattr(args, 'machine_irf', None), n_bins, _align_bin, args.estimate_irf, _align_lbl)
496
+ print(f' IRF: {strategy}')
497
+ elif args.estimate_irf == 'raw':
498
+ from .FLIM.irf_tools import estimate_irf_from_decay_raw
499
+ irf_prompt = estimate_irf_from_decay_raw(
500
+ decay, tcspc_res, n_bins, n_irf_bins=args.irf_bins)
501
+ strategy = f'estimated_raw (bins={args.irf_bins})'
502
+ has_tail = True
503
+ fit_sigma = True
504
+ fit_bg = True
505
+ print(f' IRF: {strategy}')
506
+ elif args.estimate_irf == 'parametric':
507
+ from .FLIM.irf_tools import estimate_irf_from_decay_parametric
508
+ irf_prompt = estimate_irf_from_decay_parametric(
509
+ decay, tcspc_res, n_bins, fit_window_width_ns=args.irf_fit_width)
510
+ strategy = f'estimated_parametric (width={args.irf_fit_width}ns)'
511
+ has_tail = True
512
+ fit_sigma = True
513
+ fit_bg = True
514
+ print(f' IRF: {strategy}')
515
+ else:
516
+ fwhm_ns = args.irf_fwhm if args.irf_fwhm is not None else tcspc_res * 1e9
517
+ irf_prompt = gaussian_irf_from_fwhm(n_bins, tcspc_res, fwhm_ns, decay_peak_bin)
518
+ strategy = f'gaussian FWHM={fwhm_ns*1000:.1f}ps'
519
+ has_tail = False
520
+ fit_sigma = False
521
+ fit_bg = True
522
+ print(f' IRF: {strategy}')
523
+ print(f' Flags: has_tail={has_tail} fit_sigma={fit_sigma} fit_bg={fit_bg}')
524
+ _dist_type = getattr(args, 'dist_type', 'discrete')
525
+ _dist_nc = getattr(args, 'dist_n_components', 1)
526
+ if _dist_type == 'tail':
527
+ print(f'\nFitting summed decay ({args.nexp}-exp tail, optimizer={args.optimizer})...')
528
+ global_popt, global_summary = fit_summed_tail(
529
+ decay, tcspc_res, n_bins,
530
+ fit_bg, args.nexp, args.tau_min, args.tau_max,
531
+ fit_t0=getattr(args, 'fit_t0', False),
532
+ optimizer=args.optimizer,
533
+ n_restarts=args.restarts,
534
+ de_popsize=args.de_population,
535
+ de_maxiter=args.de_maxiter,
536
+ workers=args.workers,
537
+ polish=not args.no_polish,
538
+ cost_function=getattr(args, 'cost_function', 'poisson'),
539
+ )
540
+ elif _dist_type != 'discrete':
541
+ print(f'\nFitting summed decay ({_dist_type} dist., {_dist_nc}-component, optimizer={args.optimizer})...')
542
+ global_popt, global_summary = fit_summed_dist(
543
+ decay, tcspc_res, n_bins, irf_prompt,
544
+ _dist_nc, _dist_type, fit_bg, fit_sigma,
545
+ args.tau_min, args.tau_max,
546
+ optimizer=args.optimizer,
547
+ n_restarts=args.restarts,
548
+ de_popsize=args.de_population,
549
+ de_maxiter=args.de_maxiter,
550
+ workers=args.workers,
551
+ polish=not args.no_polish,
552
+ cost_function=getattr(args, 'cost_function', 'poisson'),
553
+ sigma_max=sigma_max,
554
+ irf_shift_bins=getattr(args, 'irf_shift_bins', 2),
555
+ fit_start_ns=getattr(args, 'fit_start_ns', None),
556
+ fit_end_ns=getattr(args, 'fit_end_ns', None),
557
+ exclude_ns=parse_exclude_ns(getattr(args, 'exclude_ns', None)),
558
+ )
559
+ else:
560
+ print(f'\nFitting summed decay ({args.nexp}-exp, optimizer={args.optimizer})...')
561
+ global_popt, global_summary = fit_summed(
562
+ decay, tcspc_res, n_bins,
563
+ irf_prompt, has_tail, fit_bg, fit_sigma,
564
+ args.nexp, args.tau_min, args.tau_max,
565
+ optimizer=args.optimizer,
566
+ n_restarts=args.restarts,
567
+ de_popsize=args.de_population,
568
+ de_maxiter=args.de_maxiter,
569
+ workers=args.workers,
570
+ polish=not args.no_polish,
571
+ cost_function=getattr(args, 'cost_function', 'poisson'),
572
+ sigma_max=sigma_max,
573
+ irf_shift_bins=getattr(args, 'irf_shift_bins', 2),
574
+ fit_start_ns=getattr(args, 'fit_start_ns', None),
575
+ fit_end_ns=getattr(args, 'fit_end_ns', None),
576
+ exclude_ns=parse_exclude_ns(getattr(args, 'exclude_ns', None)),
577
+ )
578
+ print_summary(global_summary, strategy, args.nexp)
579
+ metadata = {
580
+ 'canvas_shape': stack.shape[:2],
581
+ 'total_photons': int(decay.sum()),
582
+ 'tiles_processed': stitch_result['tiles_processed'],
583
+ }
584
+ save_fit_summary_txt(
585
+ global_summary,
586
+ Path(args.output_dir) / f'{roi_name}_fit_summary.txt',
587
+ n_exp=args.nexp,
588
+ strategy=strategy,
589
+ metadata=metadata
590
+ )
591
+ if not args.no_plots:
592
+ matplotlib.use('Agg')
593
+ output_name = Path(args.output_dir) / 'fit_results'
594
+ print(f'\nGenerating plots...')
595
+ plot_summed(
596
+ decay, global_summary, ptu, None,
597
+ args.nexp, strategy, str(output_name),
598
+ irf_prompt=irf_prompt
599
+ )
600
+ pixel_maps = None
601
+ if args.mode in ('perPixel', 'both') and not _cancelled(cancel_event):
602
+ print(f'\nBuilding pixel stack (binning={args.binning}×{args.binning})...')
603
+ pixel_stack = ptu.pixel_stack(channel=None, binning=args.binning)
604
+ if tissue_mask is not None:
605
+ print(f' Background pixels will be skipped via min_photons threshold')
606
+ print(f'Per-pixel fitting (min_photons={args.min_photons})...')
607
+ perpixel_progress_cb = _make_operation_progress_callback(
608
+ 'Per-pixel fitting', progress_window_manager) or progress_callback
609
+ if _dist_type not in ('discrete', 'tail'):
610
+ pixel_maps = fit_per_pixel_dist(
611
+ pixel_stack, tcspc_res, n_bins, irf_prompt,
612
+ global_popt, _dist_nc, _dist_type,
613
+ fit_bg=fit_bg, fit_sigma=fit_sigma,
614
+ min_photons=args.min_photons,
615
+ tau_min_ns=args.tau_min,
616
+ tau_max_ns=args.tau_max,
617
+ fit_idx=global_summary.get('fit_idx'),
618
+ progress_callback=perpixel_progress_cb,
619
+ )
620
+ else:
621
+ _is_tail = _dist_type == 'tail'
622
+ pixel_maps = fit_per_pixel(
623
+ pixel_stack, tcspc_res, n_bins,
624
+ irf_prompt, has_tail, fit_bg, fit_sigma,
625
+ global_popt, args.nexp,
626
+ min_photons=args.min_photons,
627
+ tau_min_ns=args.tau_min,
628
+ tau_max_ns=args.tau_max,
629
+ correct_pileup=getattr(args, 'correct_pileup', False),
630
+ n_sync=getattr(ptu, 'n_sync', None),
631
+ fit_idx=global_summary.get('fit_idx'),
632
+ progress_callback=perpixel_progress_cb,
633
+ free_tau=getattr(args, 'free_tau_perpixel', False),
634
+ fit_model='tail' if _is_tail else 'reconv',
635
+ fit_t0=getattr(args, 'fit_t0', False) if _is_tail else False,
636
+ t0_fixed=(global_summary['t0_ns'] * 1e-9) if _is_tail else 0.0,
637
+ )
638
+ if getattr(args, 'save_weighted', True):
639
+ save_weighted_tau_images(
640
+ pixel_maps,
641
+ Path(args.output_dir),
642
+ roi_name=roi_name,
643
+ n_exp=args.nexp,
644
+ save_intensity=True,
645
+ save_amplitude=True,
646
+ tau_display_min=getattr(args, 'tau_display_min', None),
647
+ tau_display_max=getattr(args, 'tau_display_max', None),
648
+ intensity_display_min=getattr(args, 'intensity_display_min', None),
649
+ intensity_display_max=getattr(args, 'intensity_display_max', None),
650
+ target_shape=(ny, nx),
651
+ )
652
+ if getattr(args, 'save_individual', False):
653
+ save_individual_tau_maps(
654
+ pixel_maps,
655
+ Path(args.output_dir),
656
+ roi_name=roi_name,
657
+ n_exp=args.nexp
658
+ )
659
+ if not args.no_plots:
660
+ matplotlib.use('Agg')
661
+ print(f'Generating pixel maps...')
662
+ plot_pixel_maps(pixel_maps, args.nexp, str(output_name), binning=args.binning)
663
+ plot_lifetime_histogram(pixel_maps, args.nexp, str(output_name))
664
+ print(f"\n{'='*60}")
665
+ print(f' WORKFLOW COMPLETE')
666
+ print(f"{'='*60}")
667
+ print(f'Stitched data: {args.output_dir}')
668
+ print('\nDone!\n')
669
+ def single_FOV_flim_fit_inquire():
670
+ print('\n Interactive FLIM Fit Setup')
671
+ import inquirer
672
+ ptu_path = input('Enter path to PTU file for this FOV: ').strip().strip('\'"').strip()
673
+ if not ptu_path:
674
+ raise ValueError('PTU file path is required.')
675
+ xlsxq = yes_no_question('Do you have a LAS X export for this FOV? (Recommended for pixel size info)')
676
+ if xlsxq == 'y':
677
+ xlif_path = input('Enter path to LAS X export (.xlsx or .csv): ').strip()
678
+ irf_xlsxq = yes_no_question('Do you want to use the IRF from this export? (Recommended if available)')
679
+ if irf_xlsxq == 'y':
680
+ use_xlsx_irf = True
681
+ estimate_irf = 'none'
682
+ irf_path = None
683
+ else:
684
+ use_xlsx_irf = False
685
+ print('\nIRF estimation options:')
686
+ print(" 1. 'file' - measured IRF image (scatter PTU)")
687
+ print(" 2. 'machine_irf' - prebuilt machine IRF (.npy)")
688
+ print(" 3. 'machine_irf_sigma_full' - machine IRF + full σ broadening (σ≤3.0)")
689
+ print(" 4. 'machine_irf_sigma_half' - machine IRF + half σ broadening (σ≤0.5)")
690
+ print(" 5. 'raw' - non-parametric IRF from raw decay")
691
+ print(" 6. 'parametric' - fit Gaussian + exponential tail to rising edge")
692
+ print(" 7. 'none' - do not estimate IRF (not recommended)")
693
+ method_q = [inquirer.List('method',
694
+ message='Choose IRF estimation method',
695
+ choices=['file', 'machine_irf', 'machine_irf_sigma_full', 'machine_irf_sigma_half', 'raw', 'parametric', 'none'])]
696
+ estimate_irf = inquirer.prompt(method_q)['method']
697
+ if estimate_irf == 'file':
698
+ irf_path = input('Enter path to IRF PTU file: ').strip()
699
+ machine_irf_path = None
700
+ elif estimate_irf == 'machine_irf' or estimate_irf.startswith('machine_irf_sigma'):
701
+ _default_machine = str(MACHINE_IRF_DEFAULT_PATH)
702
+ machine_irf_path = input(
703
+ f'Enter path to machine IRF .npy (Enter for default: {_default_machine}): '
704
+ ).strip()
705
+ machine_irf_path = machine_irf_path if machine_irf_path else _default_machine
706
+ irf_path = None
707
+ else:
708
+ irf_path = None
709
+ machine_irf_path = None
710
+ else:
711
+ xlif_path = None
712
+ use_xlsx_irf = False
713
+ print('\nNo LAS X export, IRF must be estimated or provided separately.')
714
+ method_q = [inquirer.List('method',
715
+ message='Choose IRF estimation method',
716
+ choices=['file', 'machine_irf', 'machine_irf_sigma_full', 'machine_irf_sigma_half', 'raw', 'parametric', 'none'])]
717
+ estimate_irf = inquirer.prompt(method_q)['method']
718
+ if estimate_irf == 'file':
719
+ irf_path = input('Enter path to IRF PTU file: ').strip()
720
+ machine_irf_path = None
721
+ elif estimate_irf == 'machine_irf' or estimate_irf.startswith('machine_irf_sigma'):
722
+ _default_machine = str(MACHINE_IRF_DEFAULT_PATH)
723
+ machine_irf_path = input(
724
+ f'Enter path to machine IRF .npy (Enter for default: {_default_machine}): '
725
+ ).strip()
726
+ machine_irf_path = machine_irf_path if machine_irf_path else _default_machine
727
+ irf_path = None
728
+ else:
729
+ irf_path = None
730
+ machine_irf_path = None
731
+ import argparse
732
+ args = argparse.Namespace()
733
+ args.ptu = ptu_path
734
+ args.xlsx = xlif_path
735
+ args.no_xlsx_irf = (xlif_path is not None and not use_xlsx_irf)
736
+ args.irf = irf_path
737
+ args.machine_irf = machine_irf_path if 'machine_irf_path' in locals() else None
738
+ args.irf_xlsx = xlif_path if use_xlsx_irf else None
739
+ args.estimate_irf = estimate_irf
740
+ args.irf_bins = IRF_BINS
741
+ args.irf_fit_width = IRF_FIT_WIDTH
742
+ args.irf_fwhm = IRF_FWHM
743
+ args.nexp = n_exp
744
+ args.tau_min = Tau_min
745
+ args.tau_max = Tau_max
746
+ args.mode = D_mode
747
+ args.binning = binning_factor
748
+ args.min_photons = MIN_PHOTONS_PERPIX
749
+ args.optimizer = Optimizer
750
+ args.restarts = lm_restarts
751
+ args.de_population = de_population
752
+ args.de_maxiter = de_maxiter
753
+ args.workers = n_workers
754
+ args.no_polish = False
755
+ args.channel = channels
756
+ args.out = OUT_NAME
757
+ args.no_plots = False
758
+ args.debug_xlsx = False
759
+ args.print_config = False
760
+ mask_q = yes_no_question('Apply cell mask to filter background before fitting?')
761
+ args.cell_mask = (mask_q == 'y')
762
+ thresh_q = yes_no_question('Apply an intensity threshold to exclude low-signal pixels?')
763
+ if thresh_q == 'y':
764
+ thresh_mode_q = [inquirer.List('tmode',
765
+ message='Choose threshold method',
766
+ choices=['Enter a value', 'Pick interactively (slider)'],
767
+ default='Pick interactively (slider)')]
768
+ thresh_mode = inquirer.prompt(thresh_mode_q)['tmode']
769
+ if thresh_mode == 'Enter a value':
770
+ val = input(' Min photon intensity per pixel: ').strip()
771
+ args.intensity_threshold = int(val) if val else None
772
+ else:
773
+ args.intensity_threshold = 'interactive'
774
+ else:
775
+ args.intensity_threshold = None
776
+ return args
777
+ def _recommended_photons(n_exp, dist_type='discrete'):
778
+ if dist_type not in ('discrete', 'tail'):
779
+ return 500
780
+ return {1: 100, 2: 500}.get(int(n_exp), 1000)
781
+ def _bin_intensity(intensity, k):
782
+ if k <= 1:
783
+ return intensity
784
+ ny, nx = intensity.shape
785
+ h, w = ny // k, nx // k
786
+ if h == 0 or w == 0:
787
+ return np.array([[intensity.sum()]])
788
+ return intensity[:h*k, :w*k].reshape(h, k, w, k).sum(axis=(1, 3))
789
+ def _photon_budget_warning(stack, n_exp, min_photons, cur_binning,
790
+ dist_type='discrete', max_binning=32):
791
+ intensity = stack.sum(axis=2)
792
+ signal = intensity[intensity > 0]
793
+ n_sig = int(signal.size)
794
+ if n_sig == 0:
795
+ print(' [!] No photons in any pixel - per-pixel map cannot be built.')
796
+ return None
797
+ rec = max(_recommended_photons(n_exp, dist_type), int(min_photons))
798
+ n_fit = int((intensity >= min_photons).sum())
799
+ frac = n_fit / n_sig
800
+ med = float(np.median(signal))
801
+ floor_bin = None
802
+ qual_bin = None
803
+ for k in (1, 2, 4, 8, 16, 32):
804
+ b = cur_binning * k
805
+ if k != 1 and b > max_binning:
806
+ break
807
+ sig = _bin_intensity(intensity, k)
808
+ sig = sig[sig > 0]
809
+ if sig.size == 0:
810
+ continue
811
+ m = float(np.median(sig))
812
+ if floor_bin is None and m >= min_photons:
813
+ floor_bin = b
814
+ if qual_bin is None and m >= rec:
815
+ qual_bin = b
816
+ if n_fit > 0 and frac >= 0.2:
817
+ if med < rec:
818
+ print(f' [i] Median signal pixel has {med:.0f} photons (< ~{rec} recommended '
819
+ f'for {n_exp}-exp); fits may be noisy.')
820
+ return None
821
+ if n_fit == 0:
822
+ print(f' [!] PHOTON-STARVED: 0/{n_sig:,} signal pixels reach min_photons={min_photons} '
823
+ f'at binning={cur_binning}.')
824
+ print(f' The per-pixel lifetime map will be entirely empty (NaN).')
825
+ else:
826
+ print(f' [!] PHOTON-STARVED: only {n_fit:,}/{n_sig:,} signal pixels ({100*frac:.1f}%) reach '
827
+ f'min_photons={min_photons} at binning={cur_binning}.')
828
+ print(f' Most of the lifetime map will be empty (NaN).')
829
+ print(f' Brightest pixel = {int(intensity.max())} photons, median signal pixel = {med:.0f}.')
830
+ if floor_bin is not None:
831
+ print(f' Suggest binning {floor_bin}x{floor_bin} so most signal pixels clear '
832
+ f'min_photons={min_photons}.')
833
+ if qual_bin is not None and qual_bin != floor_bin:
834
+ print(f' Use binning {qual_bin}x{qual_bin} for ~{rec} photons/pixel '
835
+ f'(stable {n_exp}-exp fit).')
836
+ if floor_bin is None:
837
+ print(f' Even {max_binning}x{max_binning} binning is too sparse for per-pixel fitting; '
838
+ f'use fast-FLIM (mean arrival time) or phasor analysis instead.')
839
+ return qual_bin or floor_bin
840
+ def _run_flim_fit(args, progress_callback=None, cancel_event=None, progress_window_manager=None):
841
+ _dt = getattr(args, 'dist_type', 'discrete')
842
+ if _dt == 'discrete':
843
+ _model_label = f"{args.nexp}-exp"
844
+ elif _dt == 'tail':
845
+ _model_label = f"{args.nexp}-exp tail"
846
+ else:
847
+ _model_label = f"{getattr(args, 'dist_n_components', 1)}-comp {_dt} dist."
848
+ print(f"\n{'='*60}")
849
+ print(f' flim_fit_v{fitter_version} | {_model_label} | mode={args.mode} | optimizer={args.optimizer}')
850
+ print(f"{'='*60}")
851
+ print(f'\n[1] PTU: {args.ptu}')
852
+ ptu = FLIMFile(args.ptu, verbose=True)
853
+ fwhm_ns = args.irf_fwhm if args.irf_fwhm is not None else ptu.tcspc_res * 1e9
854
+ print(f' IRF FWHM: {fwhm_ns*1000:.2f} ps '
855
+ f"({'from --irf-fwhm' if args.irf_fwhm is not None else 'default: 1 bin'})")
856
+ apply_point_mode_limits(args, ptu)
857
+ cell_mask = None
858
+ if getattr(args, 'cell_mask', False):
859
+ print(f'\n[1b] Building cell mask')
860
+ intensity_img = make_intensity_image(args.ptu, rotate_90_cw=False, save_image=False)
861
+ cell_mask = make_cell_mask(intensity_img, save_mask=True, path=args.out)
862
+ n_cell_px = int(cell_mask.sum())
863
+ n_total_px = cell_mask.size
864
+ print(f' Cell mask: {n_cell_px:,} / {n_total_px:,} pixels '
865
+ f'({100*n_cell_px/n_total_px:.1f}% of FOV)')
866
+ intensity_mask = None
867
+ _int_thr = getattr(args, 'intensity_threshold', None)
868
+ if _int_thr is not None:
869
+ print(f'\n[1c] Intensity threshold')
870
+ if cell_mask is None:
871
+ intensity_img = make_intensity_image(args.ptu, rotate_90_cw=False, save_image=False)
872
+ if _int_thr == 'interactive':
873
+ _int_thr = pick_intensity_threshold(intensity_img)
874
+ else:
875
+ _int_thr = int(_int_thr)
876
+ intensity_mask = apply_intensity_threshold(intensity_img, _int_thr)
877
+ n_kept = int(intensity_mask.sum())
878
+ n_total_px = intensity_mask.size
879
+ print(f' Intensity threshold: {_int_thr} photons → '
880
+ f'{n_kept:,}/{n_total_px:,} pixels kept ({100*n_kept/n_total_px:.1f}%)')
881
+ if cell_mask is not None:
882
+ cell_mask = cell_mask & intensity_mask
883
+ print(f' Combined with cell mask: {int(cell_mask.sum()):,} pixels kept')
884
+ else:
885
+ cell_mask = intensity_mask
886
+ print(f"\n[2] Building summed decay (channel={args.channel or 'auto'})")
887
+ if cell_mask is not None:
888
+ stack_for_decay = ptu.raw_pixel_stack(channel=args.channel)
889
+ stack_for_decay[~cell_mask] = 0
890
+ decay = stack_for_decay.sum(axis=(0, 1))
891
+ del stack_for_decay
892
+ print(f' (Using cell-masked photons only)')
893
+ else:
894
+ decay = ptu.summed_decay(channel=args.channel)
895
+ irf_peak_bin = find_irf_peak_bin(decay)
896
+ decay_peak_bin = int(np.argmax(decay))
897
+ print(f' {decay.sum():,.0f} photons | peak={decay.max():,.0f} '
898
+ f'at bin {decay_peak_bin} ({ptu.time_ns[decay_peak_bin]:.3f} ns)')
899
+ print(f' IRF peak (steepest rise): bin {irf_peak_bin} '
900
+ f'({irf_peak_bin * ptu.tcspc_res * 1e9:.3f} ns)')
901
+ pu = ptu.photons_per_pulse
902
+ n_sync = getattr(ptu, 'n_sync', None)
903
+ _pileup_pct = None
904
+ _count_rate_mhz = None
905
+ if pu is not None:
906
+ acq_s = getattr(ptu, 'acq_time_s', None) or 0.0
907
+ cr_mhz = ((decay.sum() / acq_s) / 1e6) if acq_s > 0 else 0.0
908
+ pu_pct = pu * 100
909
+ _pileup_pct = round(pu_pct, 2)
910
+ _count_rate_mhz = round(cr_mhz, 4)
911
+ pu_warn = ' HIGH - use --correct-pileup' if pu_pct > 5 else ''
912
+ print(f' Sync rate: {ptu.sync_rate/1e6:.2f} MHz '
913
+ f'Acq: {acq_s:.1f} s '
914
+ f'Count rate: {cr_mhz:.3f} MHz '
915
+ f'Photons/pulse: {pu:.4f} ({pu_pct:.2f}%){pu_warn}')
916
+ if getattr(args, 'correct_pileup', False):
917
+ if not n_sync:
918
+ raise ValueError('--correct-pileup requested but this file exposes no '
919
+ 'excitation-pulse count (N_sync); Coates is unavailable here')
920
+ from flimkit.FLIM.fit_tools import coates_pileup_correction, estimate_bg
921
+ bg_pre = estimate_bg(decay, irf_peak_bin)
922
+ decay_no_bg = np.maximum(decay - bg_pre, 0.0)
923
+ decay_raw_sum = decay.sum()
924
+ decay = coates_pileup_correction(decay_no_bg, n_sync) + bg_pre
925
+ print(f' Coates pile-up correction applied (bg={bg_pre:.1f} cts/bin fixed): '
926
+ f'{decay_raw_sum:,.0f} → {decay.sum():,.0f} photons (corrected)')
927
+ xlsx = None
928
+ if args.xlsx is not None and Path(args.xlsx).exists():
929
+ print(f'\n[3] LAS X export: {args.xlsx}')
930
+ xlsx = load_irf_export(args.xlsx, debug=args.debug_xlsx)
931
+ if xlsx['fit_t'] is not None and xlsx['fit_c'] is not None:
932
+ print(f" FLIM microscope fit present, peak = {xlsx['fit_c'].max():.0f} cts")
933
+ elif xlsx['fit_t'] is not None:
934
+ print(f' FLIM microscope fit_t present but fit_c absent')
935
+ else:
936
+ print(f'\n[3] No LAS X export provided or file not found')
937
+ print(f'\n[4] Building IRF')
938
+ sigma_max = MACHINE_IRF_SIGMA_MAX_FULL
939
+ if args.irf is not None:
940
+ irf_prompt = irf_from_measured_file(args.irf, ptu, channel=args.channel)
941
+ irf_prompt, strategy = _align_measured_irf(
942
+ args, irf_prompt, irf_peak_bin, ptu.n_bins, ptu.tcspc_res)
943
+ has_tail = False
944
+ fit_sigma = False
945
+ fit_bg = True
946
+ elif args.irf_xlsx is not None:
947
+ print(f' IRF: fitting analytical model to: {args.irf_xlsx}')
948
+ if not Path(args.irf_xlsx).exists():
949
+ raise FileNotFoundError(f'IRF export file not found: {args.irf_xlsx}')
950
+ irf_ref = load_irf_export(args.irf_xlsx, debug=False)
951
+ if irf_ref['irf_t'] is None or irf_ref['irf_c'] is None:
952
+ raise ValueError(f'No IRF columns found in export: {args.irf_xlsx}')
953
+ irf_prompt, irf_params = irf_from_xlsx_analytical(
954
+ irf_ref, ptu.n_bins, ptu.tcspc_res, verbose=True)
955
+ irf_current_peak = int(np.argmax(irf_prompt))
956
+ pre_shift = irf_peak_bin - irf_current_peak
957
+ if pre_shift != 0:
958
+ x = np.arange(ptu.n_bins, dtype=float)
959
+ irf_prompt = np.interp(x - pre_shift, x, irf_prompt, left=0.0, right=0.0)
960
+ s = irf_prompt.sum()
961
+ if s > 0:
962
+ irf_prompt /= s
963
+ print(f' Pre-shifted IRF by {pre_shift:+d} bins '
964
+ f'(from bin {irf_current_peak} → {irf_peak_bin})')
965
+ strategy = (f'irf_xlsx_analytical ({Path(args.irf_xlsx).name}) '
966
+ f"FWHM={irf_params['fwhm_ns']*1000:.1f}ps "
967
+ f"tail_amp={irf_params['tail_amp']:.3f} "
968
+ f"tail_tau={irf_params['tail_tau_ns']:.3f}ns")
969
+ has_tail = False
970
+ fit_sigma = False
971
+ fit_bg = True
972
+ print(f' IRF peak bin after pre-shift = {np.argmax(irf_prompt)}')
973
+ elif args.estimate_irf in ('machine_irf', 'machine_irf_sigma_full', 'machine_irf_sigma_half'):
974
+ _align_bin = irf_peak_bin if getattr(args, 'irf_align', 'steepest_rise') == 'steepest_rise' else decay_peak_bin
975
+ _align_lbl = 'steepest_rise' if _align_bin == irf_peak_bin else 'decay_peak'
976
+ irf_prompt, strategy, has_tail, fit_bg, fit_sigma, sigma_max = machine_irf_prompt(
977
+ getattr(args, 'machine_irf', None), ptu.n_bins, _align_bin, args.estimate_irf, _align_lbl)
978
+ print(f' IRF: {strategy}')
979
+ elif xlsx is not None and xlsx['irf_t'] is not None and not args.no_xlsx_irf:
980
+ irf_prompt = irf_from_xlsx(xlsx, ptu.n_bins, ptu.tcspc_res)
981
+ above = np.where(irf_prompt >= irf_prompt.max() / 2)[0]
982
+ fwhm_xlsx = (above[-1] - above[0]) * ptu.tcspc_res * 1e9 if len(above) > 1 else 0
983
+ print(f' IRF: xlsx prompt peak bin={int(np.argmax(irf_prompt))} '
984
+ f'FWHM={fwhm_xlsx:.3f} ns + tail + σ as free params')
985
+ strategy = 'xlsx'
986
+ has_tail = True
987
+ fit_sigma = True
988
+ fit_bg = True
989
+ elif args.estimate_irf != 'none':
990
+ if args.estimate_irf == 'raw':
991
+ irf_prompt = estimate_irf_from_decay_raw(
992
+ decay, ptu.tcspc_res, ptu.n_bins, n_irf_bins=args.irf_bins)
993
+ strategy = 'estimated_raw'
994
+ else:
995
+ irf_prompt = estimate_irf_from_decay_parametric(
996
+ decay, ptu.tcspc_res, ptu.n_bins,
997
+ fit_window_width_ns=args.irf_fit_width)
998
+ strategy = 'estimated_parametric'
999
+ has_tail = False
1000
+ fit_sigma = True
1001
+ fit_bg = True
1002
+ print(f' IRF: {strategy} + σ as free param (no tail: degenerate with decay)')
1003
+ else:
1004
+ fwhm_ns = args.irf_fwhm if args.irf_fwhm is not None else ptu.tcspc_res * 1e9
1005
+ irf_prompt = gaussian_irf_from_fwhm(ptu.n_bins, ptu.tcspc_res, fwhm_ns, decay_peak_bin)
1006
+ strategy = f'gaussian FWHM={fwhm_ns*1000:.1f}ps'
1007
+ has_tail = False
1008
+ fit_sigma = False
1009
+ fit_bg = True
1010
+ print(f' IRF: {strategy}')
1011
+ if not args.no_plots and xlsx is not None:
1012
+ matplotlib.use('Agg')
1013
+ print(f'\n[4b] IRF comparison')
1014
+ compare_irfs(irf_prompt, xlsx, ptu.tcspc_res, ptu.n_bins, strategy, args.out)
1015
+ global_popt = None
1016
+ global_summary = None
1017
+ pixel_maps = None
1018
+ _suggested_binning = None
1019
+ _dist_type = getattr(args, 'dist_type', 'discrete')
1020
+ _dist_nc = getattr(args, 'dist_n_components', 1)
1021
+ tvb_profile = None
1022
+ _tvb_ptu = getattr(args, 'tvb_ptu', None)
1023
+ if _tvb_ptu:
1024
+ from flimkit.FLIM.bg_tools import tvb_from_reference_ptu
1025
+ _tvb_chan = getattr(args, 'tvb_channel', None)
1026
+ if _tvb_chan is None:
1027
+ _tvb_chan = getattr(args, 'channel', None)
1028
+ print(f'\n[4c] Time-varying background from: {_tvb_ptu}')
1029
+ tvb_profile = tvb_from_reference_ptu(_tvb_ptu, ptu, channel=_tvb_chan)
1030
+ fit_tvb = tvb_profile is not None
1031
+ def _run_summed():
1032
+ if _dist_type == 'tail':
1033
+ return fit_summed_tail(
1034
+ decay, ptu.tcspc_res, ptu.n_bins,
1035
+ fit_bg, args.nexp, args.tau_min, args.tau_max,
1036
+ fit_t0=getattr(args, 'fit_t0', False),
1037
+ optimizer=args.optimizer,
1038
+ n_restarts=args.restarts,
1039
+ de_popsize=args.de_population,
1040
+ de_maxiter=args.de_maxiter,
1041
+ workers=args.workers,
1042
+ polish=not args.no_polish,
1043
+ cost_function=getattr(args, 'cost_function', 'poisson'),
1044
+ fit_start_ns=getattr(args, 'fit_start_ns', None),
1045
+ fit_end_ns=getattr(args, 'fit_end_ns', None),
1046
+ exclude_ns=parse_exclude_ns(getattr(args, 'exclude_ns', None)),
1047
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb,
1048
+ )
1049
+ if _dist_type != 'discrete':
1050
+ return fit_summed_dist(
1051
+ decay, ptu.tcspc_res, ptu.n_bins, irf_prompt,
1052
+ _dist_nc, _dist_type, fit_bg, fit_sigma,
1053
+ args.tau_min, args.tau_max,
1054
+ optimizer=args.optimizer,
1055
+ n_restarts=args.restarts,
1056
+ de_popsize=args.de_population,
1057
+ de_maxiter=args.de_maxiter,
1058
+ workers=args.workers,
1059
+ polish=not args.no_polish,
1060
+ cost_function=getattr(args, 'cost_function', 'poisson'),
1061
+ sigma_max=sigma_max,
1062
+ irf_shift_bins=getattr(args, 'irf_shift_bins', 2),
1063
+ fit_start_ns=getattr(args, 'fit_start_ns', None),
1064
+ fit_end_ns=getattr(args, 'fit_end_ns', None),
1065
+ exclude_ns=parse_exclude_ns(getattr(args, 'exclude_ns', None)),
1066
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb,
1067
+ )
1068
+ return fit_summed(
1069
+ decay, ptu.tcspc_res, ptu.n_bins,
1070
+ irf_prompt, has_tail, fit_bg, fit_sigma,
1071
+ args.nexp, args.tau_min, args.tau_max,
1072
+ optimizer=args.optimizer,
1073
+ n_restarts=args.restarts,
1074
+ de_popsize=args.de_population,
1075
+ de_maxiter=args.de_maxiter,
1076
+ workers=args.workers,
1077
+ polish=not args.no_polish,
1078
+ cost_function=getattr(args, 'cost_function', 'poisson'),
1079
+ sigma_max=sigma_max,
1080
+ irf_shift_bins=getattr(args, 'irf_shift_bins', 2),
1081
+ fit_start_ns=getattr(args, 'fit_start_ns', None),
1082
+ fit_end_ns=getattr(args, 'fit_end_ns', None),
1083
+ exclude_ns=parse_exclude_ns(getattr(args, 'exclude_ns', None)),
1084
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb,
1085
+ )
1086
+ if args.mode in ('summed', 'both'):
1087
+ print(f'\n[5] Summed decay fit ({args.nexp}-exp, optimizer={args.optimizer})')
1088
+ global_popt, global_summary = _run_summed()
1089
+ print_summary(global_summary, strategy, args.nexp)
1090
+ if not args.no_plots:
1091
+ matplotlib.use('Agg')
1092
+ print(f'\n[6] Plotting')
1093
+ plot_summed(decay, global_summary, ptu, xlsx,
1094
+ args.nexp, strategy, args.out,
1095
+ irf_prompt=irf_prompt)
1096
+ if args.mode in ('perPixel', 'both'):
1097
+ if global_popt is None:
1098
+ print(f'\n[5] Running summed fit first (τ needed for per-pixel)')
1099
+ global_popt, global_summary = _run_summed()
1100
+ print_summary(global_summary, strategy, args.nexp)
1101
+ print(f'\n[7] Building pixel stack (binning={args.binning}×{args.binning})')
1102
+ stack = ptu.pixel_stack(channel=ptu.photon_channel, binning=args.binning)
1103
+ if cell_mask is not None:
1104
+ import cv2
1105
+ sy, sx = stack.shape[:2]
1106
+ if cell_mask.shape != (sy, sx):
1107
+ mask_resized = cv2.resize(cell_mask.astype(np.uint8), (sx, sy),
1108
+ interpolation=cv2.INTER_NEAREST) > 0
1109
+ else:
1110
+ mask_resized = cell_mask
1111
+ stack[~mask_resized] = 0
1112
+ print(f' Applied cell mask to pixel stack')
1113
+ _suggested_binning = _photon_budget_warning(
1114
+ stack, args.nexp, args.min_photons, args.binning, dist_type=_dist_type)
1115
+ print(f'\n[8] Per-pixel fitting (min_photons={args.min_photons})')
1116
+ perpixel_progress_cb = _make_operation_progress_callback(
1117
+ 'Per-pixel fitting', progress_window_manager) or progress_callback
1118
+ if _dist_type not in ('discrete', 'tail'):
1119
+ pixel_maps = fit_per_pixel_dist(
1120
+ stack, ptu.tcspc_res, ptu.n_bins, irf_prompt,
1121
+ global_popt, _dist_nc, _dist_type,
1122
+ fit_bg=fit_bg, fit_sigma=fit_sigma,
1123
+ min_photons=args.min_photons,
1124
+ tau_min_ns=args.tau_min,
1125
+ tau_max_ns=args.tau_max,
1126
+ fit_idx=global_summary.get('fit_idx'),
1127
+ progress_callback=perpixel_progress_cb,
1128
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb,
1129
+ )
1130
+ else:
1131
+ _is_tail = _dist_type == 'tail'
1132
+ pixel_maps = fit_per_pixel(
1133
+ stack, ptu.tcspc_res, ptu.n_bins,
1134
+ irf_prompt, has_tail, fit_bg, fit_sigma,
1135
+ global_popt, args.nexp,
1136
+ min_photons=args.min_photons,
1137
+ tau_min_ns=args.tau_min,
1138
+ tau_max_ns=args.tau_max,
1139
+ correct_pileup=getattr(args, 'correct_pileup', False),
1140
+ n_sync=getattr(ptu, 'n_sync', None),
1141
+ fit_idx=global_summary.get('fit_idx'),
1142
+ progress_callback=perpixel_progress_cb,
1143
+ free_tau=getattr(args, 'free_tau_perpixel', False),
1144
+ tvb_profile=tvb_profile, fit_tvb=fit_tvb,
1145
+ fit_model='tail' if _is_tail else 'reconv',
1146
+ fit_t0=getattr(args, 'fit_t0', False) if _is_tail else False,
1147
+ t0_fixed=(global_summary['t0_ns'] * 1e-9) if _is_tail else 0.0,
1148
+ )
1149
+ if not args.no_plots:
1150
+ matplotlib.use('Agg')
1151
+ print(f'\n[9] Plotting pixel maps')
1152
+ plot_pixel_maps(pixel_maps, args.nexp, args.out, binning=args.binning)
1153
+ plot_lifetime_histogram(pixel_maps, args.nexp, args.out)
1154
+ print('\nDone.\n')
1155
+ return {
1156
+ 'pixel_maps': pixel_maps if args.mode in ('perPixel', 'both') else None,
1157
+ 'global_summary': global_summary,
1158
+ 'global_popt': global_popt,
1159
+ 'tcspc_res': ptu.tcspc_res,
1160
+ 'n_bins': ptu.n_bins,
1161
+ 'strategy': strategy,
1162
+ 'irf_prompt': irf_prompt,
1163
+ 'time_ns': ptu.time_ns,
1164
+ 'decay': decay,
1165
+ 'pileup_pct': _pileup_pct,
1166
+ 'count_rate_mhz': _count_rate_mhz,
1167
+ 'suggested_binning': _suggested_binning,
1168
+ }
1169
+ def single_FOV_flim_fit(interactive=False):
1170
+ if interactive:
1171
+ args = single_FOV_flim_fit_inquire()
1172
+ else:
1173
+ ap = argparse.ArgumentParser(
1174
+ description='FLIM reconvolution fit, PTU + optional LAS X export'
1175
+ )
1176
+ ap.add_argument('--ptu', default=None, required=True)
1177
+ ap.add_argument('--xlsx', '--analysis-export', dest='xlsx', default=None)
1178
+ ap.add_argument('--no-xlsx-irf', action='store_true')
1179
+ ap.add_argument('--debug-xlsx', action='store_true')
1180
+ ap.add_argument('--irf', default=None)
1181
+ ap.add_argument('--irf-xlsx', '--irf-export', dest='irf_xlsx', default=None)
1182
+ ap.add_argument('--estimate-irf', choices=['raw', 'parametric', 'machine_irf', 'none'],
1183
+ default=Estimate_IRF)
1184
+ ap.add_argument('--machine-irf', default=str(MACHINE_IRF_DEFAULT_PATH))
1185
+ ap.add_argument('--irf-bins', type=int, default=IRF_BINS)
1186
+ ap.add_argument('--irf-fit-width', type=float, default=IRF_FIT_WIDTH)
1187
+ ap.add_argument('--irf-fwhm', type=float, default=IRF_FWHM)
1188
+ ap.add_argument('--nexp', type=int, default=n_exp, choices=[1, 2, 3])
1189
+ ap.add_argument('--tau-min', type=float, default=Tau_min)
1190
+ ap.add_argument('--tau-max', type=float, default=Tau_max)
1191
+ ap.add_argument('--mode', default=D_mode, choices=['summed', 'perPixel', 'both'])
1192
+ ap.add_argument('--binning', type=int, default=binning_factor)
1193
+ ap.add_argument('--min-photons', type=int, default=MIN_PHOTONS_PERPIX)
1194
+ ap.add_argument('--optimizer', choices=['lm_multistart', 'de'], default=Optimizer)
1195
+ ap.add_argument('--restarts', type=int, default=lm_restarts)
1196
+ ap.add_argument('--de-population', type=int, default=de_population)
1197
+ ap.add_argument('--de-maxiter', type=int, default=de_maxiter)
1198
+ ap.add_argument('--workers', type=int, default=n_workers)
1199
+ ap.add_argument('--no-polish', action='store_true')
1200
+ ap.add_argument('--channel', type=int, default=channels)
1201
+ ap.add_argument('--out', default=OUT_NAME)
1202
+ ap.add_argument('--no-plots', action='store_true')
1203
+ ap.add_argument('--intensity-threshold', default=INTENSITY_THRESHOLD)
1204
+ ap.add_argument('--print-config', action='store_true')
1205
+ args = ap.parse_args()
1206
+ if args.print_config:
1207
+ print(config_message)
1208
+ return
1209
+ _run_flim_fit(args)
1210
+ def stitch_tiles(interactive=False):
1211
+ if interactive:
1212
+ args = stitch_tiles_inquire()
1213
+ _run_tile_stitch(args)
1214
+ else:
1215
+ ap = argparse.ArgumentParser(
1216
+ description='Stitch FLIM PTU tiles into mosaic using XLIF metadata'
1217
+ )
1218
+ ap.add_argument('--xlif', required=True)
1219
+ ap.add_argument('--ptu-dir', required=True)
1220
+ ap.add_argument('--output-dir', required=True)
1221
+ ap.add_argument('--ptu-basename', default=None)
1222
+ ap.add_argument('--rotate-tiles', action='store_true', default=True)
1223
+ ap.add_argument('--no-rotate', action='store_true')
1224
+ args = ap.parse_args()
1225
+ if args.no_rotate:
1226
+ args.rotate_tiles = False
1227
+ if args.ptu_basename is None:
1228
+ args.ptu_basename = Path(args.xlif).stem
1229
+ _run_tile_stitch(args)
1230
+ def stitch_and_fit(interactive=False):
1231
+ if interactive:
1232
+ print('\n Stitched ROI Fitting')
1233
+ import inquirer
1234
+ pipeline_q = [inquirer.List(
1235
+ 'pipeline',
1236
+ message='Which fitting pipeline?',
1237
+ choices=[
1238
+ 'Per-tile (recommended) - fit each tile independently, no IRF mismatch',
1239
+ 'Global (legacy) - stitch then fit summed decay',
1240
+ ]
1241
+ )]
1242
+ pipeline = inquirer.prompt(pipeline_q)['pipeline']
1243
+ if pipeline.startswith('Per-tile'):
1244
+ args = tile_fit_inquire()
1245
+ _run_tile_fit(args)
1246
+ else:
1247
+ args = stitch_and_fit_inquire()
1248
+ _run_stitch_and_fit(args)
1249
+ else:
1250
+ ap = argparse.ArgumentParser(description='Stitch FLIM tiles and perform fitting')
1251
+ ap.add_argument('--xlif', required=True)
1252
+ ap.add_argument('--ptu-dir', required=True)
1253
+ ap.add_argument('--output-dir', required=True)
1254
+ ap.add_argument('--ptu-basename', default=None)
1255
+ ap.add_argument('--rotate-tiles', action='store_true', default=True)
1256
+ ap.add_argument('--no-rotate', action='store_true')
1257
+ ap.add_argument('--irf', default=None)
1258
+ ap.add_argument('--estimate-irf', choices=['raw', 'parametric', 'machine_irf', 'gaussian'],
1259
+ default='gaussian')
1260
+ ap.add_argument('--machine-irf', default=str(MACHINE_IRF_DEFAULT_PATH))
1261
+ ap.add_argument('--nexp', type=int, default=n_exp, choices=[1, 2, 3])
1262
+ ap.add_argument('--tau-min', type=float, default=Tau_min)
1263
+ ap.add_argument('--tau-max', type=float, default=Tau_max)
1264
+ ap.add_argument('--mode', default=D_mode, choices=['summed', 'perPixel', 'both'])
1265
+ ap.add_argument('--binning', type=int, default=binning_factor)
1266
+ ap.add_argument('--min-photons', type=int, default=MIN_PHOTONS_PERPIX)
1267
+ ap.add_argument('--optimizer', choices=['lm_multistart', 'de'], default=Optimizer)
1268
+ ap.add_argument('--restarts', type=int, default=lm_restarts)
1269
+ ap.add_argument('--de-population', type=int, default=de_population)
1270
+ ap.add_argument('--de-maxiter', type=int, default=de_maxiter)
1271
+ ap.add_argument('--workers', type=int, default=n_workers)
1272
+ ap.add_argument('--no-polish', action='store_true')
1273
+ ap.add_argument('--channel', type=int, default=channels)
1274
+ ap.add_argument('--irf-fwhm', type=float, default=IRF_FWHM)
1275
+ ap.add_argument('--irf-bins', type=int, default=IRF_BINS)
1276
+ ap.add_argument('--irf-fit-width', type=float, default=IRF_FIT_WIDTH)
1277
+ ap.add_argument('--no-plots', action='store_true')
1278
+ ap.add_argument('--save-individual', action='store_true')
1279
+ ap.add_argument('--no-save-weighted', action='store_true')
1280
+ ap.add_argument('--intensity-threshold', default=INTENSITY_THRESHOLD)
1281
+ args = ap.parse_args()
1282
+ if args.no_rotate:
1283
+ args.rotate_tiles = False
1284
+ if args.ptu_basename is None:
1285
+ args.ptu_basename = Path(args.xlif).stem
1286
+ args.save_weighted = not args.no_save_weighted
1287
+ _run_stitch_and_fit(args)
1288
+ def tile_fit_inquire():
1289
+ print('\n Per-Tile FLIM Fitting')
1290
+ print('Fits each tile independently with its own IRF, then assembles')
1291
+ print('the results into a full-ROI canvas. Avoids inter-tile IRF mismatch.\n')
1292
+ import inquirer
1293
+ stitch_args = stitch_tiles_inquire()
1294
+ print('\nFitting Parameters')
1295
+ print('\nIRF estimation options:')
1296
+ print(" 1. 'parametric' - fit Gaussian + exponential tail to rising edge (default)")
1297
+ print(" 2. 'raw' - non-parametric IRF from raw decay")
1298
+ print(" 3. 'machine_irf' - prebuilt machine IRF (.npy), reused for all tiles")
1299
+ print(" 4. 'machine_irf_sigma_full' - machine IRF + full σ broadening (σ≤3.0)")
1300
+ print(" 5. 'machine_irf_sigma_half' - machine IRF + half σ broadening (σ≤0.5, recommended)")
1301
+ print(" 6. 'irf_xlsx_dir' - per-tile xlsx directory (fill in once format is known)")
1302
+ method_q = [inquirer.List('method',
1303
+ message='Choose IRF method',
1304
+ choices=['parametric', 'raw', 'machine_irf', 'machine_irf_sigma_full', 'machine_irf_sigma_half', 'irf_xlsx_dir'])]
1305
+ estimate_irf = inquirer.prompt(method_q)['method']
1306
+ irf_xlsx_dir = None
1307
+ machine_irf_path = None
1308
+ if estimate_irf == 'machine_irf' or estimate_irf.startswith('machine_irf_sigma'):
1309
+ _default_machine = str(MACHINE_IRF_DEFAULT_PATH)
1310
+ machine_irf_path = input(
1311
+ f'Enter path to machine IRF .npy (Enter for default: {_default_machine}): '
1312
+ ).strip()
1313
+ machine_irf_path = machine_irf_path if machine_irf_path else _default_machine
1314
+ if not Path(machine_irf_path).exists():
1315
+ print(' Warning: machine IRF file not found, falling back to parametric')
1316
+ estimate_irf = 'parametric'
1317
+ machine_irf_path = None
1318
+ if estimate_irf == 'irf_xlsx_dir':
1319
+ irf_xlsx_dir = input('Enter path to directory containing per-tile IRF xlsx files: ').strip()
1320
+ if not irf_xlsx_dir or not Path(irf_xlsx_dir).exists():
1321
+ print(' Warning: directory not found, falling back to parametric')
1322
+ estimate_irf = 'parametric'
1323
+ irf_xlsx_dir = None
1324
+ nexp_q = [inquirer.List('nexp',
1325
+ message='Number of exponential components',
1326
+ choices=['1', '2', '3'],
1327
+ default='2')]
1328
+ nexp = int(inquirer.prompt(nexp_q)['nexp'])
1329
+ thresh_q = yes_no_question('Apply intensity threshold to exclude low-signal pixels?')
1330
+ if thresh_q == 'y':
1331
+ val = input(' Min photon intensity per pixel (Enter for none): ').strip()
1332
+ intensity_threshold = int(val) if val else None
1333
+ else:
1334
+ intensity_threshold = None
1335
+ tau_range_q = yes_no_question('Set lifetime display range for tau images?')
1336
+ if tau_range_q == 'y':
1337
+ tau_min_display = input(f' Min tau (ns, Enter=auto): ').strip()
1338
+ tau_max_display = input(f' Max tau (ns, Enter=auto): ').strip()
1339
+ tau_min_display = float(tau_min_display) if tau_min_display else None
1340
+ tau_max_display = float(tau_max_display) if tau_max_display else None
1341
+ else:
1342
+ tau_min_display = TAU_DISPLAY_MIN
1343
+ tau_max_display = TAU_DISPLAY_MAX
1344
+ args = argparse.Namespace()
1345
+ args.xlif = stitch_args.xlif
1346
+ args.ptu_dir = stitch_args.ptu_dir
1347
+ args.output_dir = stitch_args.output_dir
1348
+ args.ptu_basename = stitch_args.ptu_basename
1349
+ args.rotate_tiles = stitch_args.rotate_tiles
1350
+ args.register_tiles = True
1351
+ args.reg_max_shift_px = 120
1352
+ args.estimate_irf = estimate_irf
1353
+ args.irf_xlsx_dir = irf_xlsx_dir
1354
+ args.machine_irf = machine_irf_path
1355
+ args.irf = None
1356
+ args.irf_xlsx = None
1357
+ args.irf_bins = IRF_BINS
1358
+ args.irf_fit_width = IRF_FIT_WIDTH
1359
+ args.irf_fwhm = IRF_FWHM
1360
+ args.nexp = nexp
1361
+ args.tau_min = Tau_min
1362
+ args.tau_max = Tau_max
1363
+ args.mode = 'both'
1364
+ args.binning = binning_factor
1365
+ args.min_photons = MIN_PHOTONS_PERPIX
1366
+ args.optimizer = 'de'
1367
+ args.restarts = lm_restarts
1368
+ args.de_population = de_population
1369
+ args.de_maxiter = de_maxiter
1370
+ args.workers = n_workers
1371
+ args.no_polish = False
1372
+ args.channel = channels
1373
+ args.no_plots = True
1374
+ args.cell_mask = False
1375
+ args.intensity_threshold = intensity_threshold
1376
+ args.no_xlsx_irf = True
1377
+ args.debug_xlsx = False
1378
+ args.print_config = False
1379
+ args.xlsx = None
1380
+ args.out = None
1381
+ args.tau_display_min = tau_min_display
1382
+ args.tau_display_max = tau_max_display
1383
+ return args
1384
+ def _run_tile_fit(args, progress_callback=None, cancel_event=None, progress_window_manager=None):
1385
+ from .formats.PTU.stitch import fit_flim_tiles
1386
+ from .FLIM.assemble import assemble_tile_maps, derive_global_tau, save_assembled_maps
1387
+ roi_name = args.ptu_basename.replace(' ', '_')
1388
+ print(f"\n{'='*60}")
1389
+ print(f' STEP 1: PER-TILE FITTING')
1390
+ print(f"{'='*60}")
1391
+ (tile_results, canvas_height, canvas_width, corrected_positions,
1392
+ pooled_decay, pooled_irf, tcspc_ref, global_popt, global_summary) = fit_flim_tiles(
1393
+ xlif_path = Path(args.xlif),
1394
+ ptu_dir = Path(args.ptu_dir),
1395
+ output_dir = Path(args.output_dir),
1396
+ args = args,
1397
+ ptu_basename = args.ptu_basename,
1398
+ rotate_tiles = args.rotate_tiles,
1399
+ irf_xlsx_dir = getattr(args, 'irf_xlsx_dir', None),
1400
+ irf_xlsx_map = getattr(args, 'irf_xlsx_map', None),
1401
+ verbose = True,
1402
+ progress_callback = progress_callback,
1403
+ cancel_event = cancel_event,
1404
+ )
1405
+ if not tile_results:
1406
+ raise RuntimeError('No tiles were successfully fitted.')
1407
+ print(f"\n{'='*60}")
1408
+ print(f' STEP 2: ASSEMBLING CANVAS')
1409
+ print(f"{'='*60}")
1410
+ canvas = assemble_tile_maps(
1411
+ tile_results = tile_results,
1412
+ canvas_height = canvas_height,
1413
+ canvas_width = canvas_width,
1414
+ n_exp = args.nexp,
1415
+ cancel_event = cancel_event,
1416
+ )
1417
+ _binning = getattr(args, 'binning', 1)
1418
+ if _binning > 1:
1419
+ try:
1420
+ import cv2 as _cv2
1421
+ _th = canvas_height * _binning
1422
+ _tw = canvas_width * _binning
1423
+ def _up(arr):
1424
+ if arr is None or not hasattr(arr, 'ndim'): return arr
1425
+ return _cv2.resize(arr.astype('float32'), (_tw, _th),
1426
+ interpolation=_cv2.INTER_NEAREST)
1427
+ canvas = {k: _up(v) for k, v in canvas.items()}
1428
+ canvas_height = _th
1429
+ canvas_width = _tw
1430
+ print(f' ↑ Canvas upsampled ×{_binning} → {_th}×{_tw} px (full tile resolution)')
1431
+ except Exception as _upe:
1432
+ print(f' Canvas upsample failed: {_upe} - TIFFs will be at binned resolution')
1433
+ print(f"\n{'='*60}")
1434
+ print(f' STEP 3: GLOBAL TAU SUMMARY')
1435
+ print(f"{'='*60}")
1436
+ _consensus_summary = global_summary
1437
+ global_summary = derive_global_tau(canvas, n_exp=args.nexp)
1438
+ for _key in ('model', 'reduced_chi2', 'reduced_chi2_tail',
1439
+ 'reduced_chi2_tail_pearson', 'reduced_chi2_pearson',
1440
+ 'calibrated_chi2_pearson', 'calibrated_chi2_tail_pearson'):
1441
+ if _key in _consensus_summary:
1442
+ global_summary[_key] = _consensus_summary[_key]
1443
+ n_px = global_summary.get('n_pixels_fitted', 0)
1444
+ tau_mean = global_summary.get('tau_mean_amp_global_ns', float('nan'))
1445
+ tau_std = global_summary.get('tau_std_amp_global_ns', float('nan'))
1446
+ tau_med = global_summary.get('tau_median_amp_global_ns', float('nan'))
1447
+ print(f"\n{'─'*60}")
1448
+ print(f' Per-tile fit: {args.nexp}-exp | {n_px:,} pixels')
1449
+ print(f"{'─'*60}")
1450
+ for k in range(1, args.nexp + 1):
1451
+ tau_k = global_summary.get(f'tau{k}_mean_ns', float('nan'))
1452
+ a_k = global_summary.get(f'a{k}_mean_frac', float('nan'))
1453
+ print(f' τ{k} = {tau_k:8.4f} ns α{k} = {a_k:.3e} f{k} = {a_k:.4f}')
1454
+ print(f' τ_mean (amplitude-weighted) = {tau_mean:.4f} ns')
1455
+ print(f' τ_mean (median, amp-wtd) = {tau_med:.4f} ns')
1456
+ print(f' τ σ (pixel distribution) = {tau_std:.4f} ns')
1457
+ print(f' n pixels fitted = {n_px}')
1458
+ print(f' Optimizer: per-pixel (per-tile fit)')
1459
+ if _cancelled(cancel_event):
1460
+ return None
1461
+ print(f"\n{'='*60}")
1462
+ print(f' STEP 4: SAVING OUTPUTS')
1463
+ print(f"{'='*60}")
1464
+ save_assembled_maps(
1465
+ canvas = canvas,
1466
+ global_summary = global_summary,
1467
+ output_dir = Path(args.output_dir),
1468
+ roi_name = roi_name,
1469
+ n_exp = args.nexp,
1470
+ tau_display_min = getattr(args, 'tau_display_min', None),
1471
+ tau_display_max = getattr(args, 'tau_display_max', None),
1472
+ intensity_display_min = getattr(args, 'intensity_display_min', None),
1473
+ intensity_display_max = getattr(args, 'intensity_display_max', None),
1474
+ )
1475
+ if _cancelled(cancel_event):
1476
+ return None
1477
+ print(f"\n{'='*60}")
1478
+ print(f' STEP 5: LIFETIME IMAGE')
1479
+ print(f"{'='*60}")
1480
+ tau_min_img = getattr(args, 'tau_display_min', None) or 0.0
1481
+ tau_max_img = getattr(args, 'tau_display_max', None) or 5.0
1482
+ make_lifetime_image(
1483
+ canvas = canvas,
1484
+ output_dir = Path(args.output_dir),
1485
+ roi_name = roi_name,
1486
+ tau_min_ns = tau_min_img,
1487
+ tau_max_ns = tau_max_img,
1488
+ smooth_sigma_px = 2.0,
1489
+ verbose = True,
1490
+ )
1491
+ print(f"\n{'='*60}")
1492
+ print(f' PER-TILE FITTING COMPLETE')
1493
+ print(f"{'='*60}\n")
1494
+ import numpy as np
1495
+ n_bins_pooled = len(pooled_decay)
1496
+ time_ns = np.arange(n_bins_pooled) * tcspc_ref * 1e9
1497
+ return {
1498
+ 'canvas': canvas,
1499
+ 'global_summary': global_summary,
1500
+ 'global_popt': global_popt,
1501
+ 'irf_prompt': pooled_irf,
1502
+ 'time_ns': time_ns,
1503
+ 'decay': pooled_decay,
1504
+ 'tcspc_res': tcspc_ref,
1505
+ 'n_bins': n_bins_pooled,
1506
+ }
1507
+ def _run_timelapse_fit(args, progress_callback=None, cancel_event=None):
1508
+ from .FLIM.batch import fit_timelapse
1509
+ print(f"\n{'='*60}")
1510
+ print(f' TIMELAPSE FLIM FITTING')
1511
+ print(f"{'='*60}")
1512
+ print(f' Input: {args.ptu_dir}')
1513
+ print(f' Output: {args.output_dir}')
1514
+ print(f' Model: {args.nexp}-exp | optimizer={args.optimizer}')
1515
+ pool = getattr(args, 'pool_positions', False)
1516
+ print(f' Positions: {'pooled' if pool else 'independent'}')
1517
+ return fit_timelapse(
1518
+ ptu_dir=args.ptu_dir,
1519
+ output_dir=args.output_dir,
1520
+ args=args,
1521
+ ref_tau1_ns=getattr(args, 'ref_tau1', None),
1522
+ ref_tau2_ns=getattr(args, 'ref_tau2', None),
1523
+ ref_tau3_ns=getattr(args, 'ref_tau3', None),
1524
+ channel=getattr(args, 'channel', None),
1525
+ pool_positions=pool,
1526
+ compute_bound_fraction=getattr(args, 'bound_fraction', False),
1527
+ progress_callback=progress_callback,
1528
+ cancel_event=cancel_event,
1529
+ )
1530
+ def timelapse_flim_fit(interactive=False):
1531
+ if interactive:
1532
+ import inquirer
1533
+ print('\n Timelapse FLIM Fit')
1534
+ print('Expected filename pattern: region_tX[_sY][_zZ].ptu\n')
1535
+ ptu_dir = input('Enter folder containing timelapse PTU files: ').strip()
1536
+ if not ptu_dir or not Path(ptu_dir).exists():
1537
+ raise ValueError(f'PTU folder not found: {ptu_dir}')
1538
+ output_dir = input('Enter output directory: ').strip()
1539
+ if not output_dir:
1540
+ raise ValueError('Output directory is required.')
1541
+ nexp_q = [inquirer.List('nexp',
1542
+ message='Number of exponential components',
1543
+ choices=['1', '2', '3'],
1544
+ default='2')]
1545
+ nexp = int(inquirer.prompt(nexp_q)['nexp'])
1546
+ supplied_q = yes_no_question('Provide reference τ values instead of fitting from pooled data?')
1547
+ ref_tau1 = None
1548
+ ref_tau2 = None
1549
+ if supplied_q == 'y':
1550
+ ref_tau1 = float(input(' τ₁ (ns): ').strip())
1551
+ if nexp >= 2:
1552
+ ref_tau2 = float(input(' τ₂ (ns): ').strip())
1553
+ print('\nIRF options:')
1554
+ print(" 1. 'gaussian' - simple Gaussian (fastest)")
1555
+ print(" 2. 'parametric' - fit Gaussian+tail to rising edge")
1556
+ print(" 3. 'machine_irf' - prebuilt machine IRF (.npy)")
1557
+ print(" 4. 'machine_irf_sigma_half'- machine IRF + σ≤0.5 (recommended)")
1558
+ irf_q = [inquirer.List('irf',
1559
+ message='IRF method',
1560
+ choices=['gaussian', 'parametric', 'machine_irf',
1561
+ 'machine_irf_sigma_half', 'machine_irf_sigma_full',
1562
+ 'raw'])]
1563
+ estimate_irf = inquirer.prompt(irf_q)['irf']
1564
+ machine_irf = None
1565
+ if estimate_irf.startswith('machine_irf'):
1566
+ _default = str(MACHINE_IRF_DEFAULT_PATH)
1567
+ machine_irf = input(
1568
+ f'Path to machine IRF .npy (Enter for default: {_default}): ').strip()
1569
+ machine_irf = machine_irf or _default
1570
+ save_stack_q = yes_no_question('Save 4D stacks (T, H, W) for each map? (uses more disk)')
1571
+ no_plots_q = yes_no_question('Skip summary plots?')
1572
+ args = argparse.Namespace()
1573
+ args.ptu_dir = ptu_dir
1574
+ args.output_dir = output_dir
1575
+ args.nexp = nexp
1576
+ args.tau_min = Tau_min
1577
+ args.tau_max = Tau_max
1578
+ args.ref_tau1 = ref_tau1
1579
+ args.ref_tau2 = ref_tau2
1580
+ args.estimate_irf = estimate_irf
1581
+ args.machine_irf = machine_irf
1582
+ args.irf_fwhm = IRF_FWHM
1583
+ args.irf_bins = IRF_BINS
1584
+ args.irf_fit_width = IRF_FIT_WIDTH
1585
+ args.optimizer = 'de'
1586
+ args.restarts = lm_restarts
1587
+ args.de_population = de_population
1588
+ args.de_maxiter = de_maxiter
1589
+ args.workers = n_workers
1590
+ args.no_polish = False
1591
+ args.cost_function = 'poisson'
1592
+ args.channel = channels
1593
+ args.min_photons = MIN_PHOTONS_PERPIX
1594
+ args.correct_pileup = False
1595
+ args.fit_start_ns = None
1596
+ args.fit_end_ns = None
1597
+ args.exclude_ns = None
1598
+ args.save_stack = (save_stack_q == 'y')
1599
+ args.no_plots = (no_plots_q == 'y')
1600
+ _run_timelapse_fit(args)
1601
+ else:
1602
+ ap = argparse.ArgumentParser(
1603
+ description='Timelapse FLIM batch fitting from region_tX[_sY][_zZ].ptu files'
1604
+ )
1605
+ ap.add_argument('--ptu-dir', required=True,
1606
+ help='Folder containing timelapse PTU files')
1607
+ ap.add_argument('--output-dir', required=True,
1608
+ help='Base output directory')
1609
+ ap.add_argument('--nexp', type=int, default=n_exp, choices=[1, 2, 3])
1610
+ ap.add_argument('--tau-min', type=float, default=Tau_min)
1611
+ ap.add_argument('--tau-max', type=float, default=Tau_max)
1612
+ ap.add_argument('--ref-tau1', type=float, default=None,
1613
+ help='Reference τ₁ (ns); skips pooled global fit')
1614
+ ap.add_argument('--ref-tau2', type=float, default=None,
1615
+ help='Reference τ₂ (ns); required with --ref-tau1 for nexp≥2')
1616
+ ap.add_argument('--estimate-irf',
1617
+ choices=['gaussian', 'parametric', 'raw',
1618
+ 'machine_irf', 'machine_irf_sigma_full',
1619
+ 'machine_irf_sigma_half'],
1620
+ default='gaussian')
1621
+ ap.add_argument('--machine-irf', default=str(MACHINE_IRF_DEFAULT_PATH))
1622
+ ap.add_argument('--irf-fwhm', type=float, default=IRF_FWHM)
1623
+ ap.add_argument('--irf-bins', type=int, default=IRF_BINS)
1624
+ ap.add_argument('--irf-fit-width', type=float, default=IRF_FIT_WIDTH)
1625
+ ap.add_argument('--optimizer', choices=['lm_multistart', 'de'], default='de')
1626
+ ap.add_argument('--restarts', type=int, default=lm_restarts)
1627
+ ap.add_argument('--de-population', type=int, default=de_population)
1628
+ ap.add_argument('--de-maxiter', type=int, default=de_maxiter)
1629
+ ap.add_argument('--workers', type=int, default=n_workers)
1630
+ ap.add_argument('--no-polish', action='store_true')
1631
+ ap.add_argument('--channel', type=int, default=channels)
1632
+ ap.add_argument('--min-photons', type=int, default=MIN_PHOTONS_PERPIX)
1633
+ ap.add_argument('--correct-pileup', action='store_true')
1634
+ ap.add_argument('--fit-start-ns', type=float, default=None,
1635
+ help='fit window start in ns (default: auto from IRF onset)')
1636
+ ap.add_argument('--fit-end-ns', type=float, default=None,
1637
+ help='fit window end in ns (default: auto)')
1638
+ ap.add_argument('--exclude-ns', type=str, default=None,
1639
+ help='bands to exclude from the fit, eg "7.2-8.8" or '
1640
+ '"7.2-8.8,11.0-11.5" (for reflection peaks mid-decay)')
1641
+ ap.add_argument('--cost-function', choices=['chi2', 'poisson'], default='poisson')
1642
+ ap.add_argument('--no-stack', action='store_true',
1643
+ help='Skip saving 4D (T,H,W) stacks')
1644
+ ap.add_argument('--no-plots', action='store_true')
1645
+ args = ap.parse_args()
1646
+ args.save_stack = not args.no_stack
1647
+ _run_timelapse_fit(args)
1648
+
1649
+ def _run_zstack_fit(args, progress_callback=None, cancel_event=None):
1650
+ from .FLIM.batch import fit_zstack
1651
+ print(f"\n{'='*60}")
1652
+ print(f' Z-STACK FLIM FITTING')
1653
+ print(f"{'='*60}")
1654
+ print(f' Input: {args.ptu_dir}')
1655
+ print(f' Output: {args.output_dir}')
1656
+ print(f' Model: {args.nexp}-exp | optimizer={args.optimizer}')
1657
+ print(f' Each z-stack fitted as one FOV (shared τ, per-slice maps)')
1658
+ return fit_zstack(
1659
+ ptu_dir=args.ptu_dir,
1660
+ output_dir=args.output_dir,
1661
+ args=args,
1662
+ ref_tau1_ns=getattr(args, 'ref_tau1', None),
1663
+ ref_tau2_ns=getattr(args, 'ref_tau2', None),
1664
+ ref_tau3_ns=getattr(args, 'ref_tau3', None),
1665
+ channel=getattr(args, 'channel', None),
1666
+ compute_bound_fraction=getattr(args, 'bound_fraction', False),
1667
+ progress_callback=progress_callback,
1668
+ cancel_event=cancel_event,
1669
+ )
1670
+
1671
+ def zstack_flim_fit(interactive=False):
1672
+ if interactive:
1673
+ import inquirer
1674
+ print('\n Z-stack FLIM Fit')
1675
+ print('Expected filename pattern: region_zX.ptu (one PTU per z-slice)\n')
1676
+ ptu_dir = input('Enter folder containing z-stack PTU files: ').strip()
1677
+ if not ptu_dir or not Path(ptu_dir).exists():
1678
+ raise ValueError(f'PTU folder not found: {ptu_dir}')
1679
+ output_dir = input('Enter output directory: ').strip()
1680
+ if not output_dir:
1681
+ raise ValueError('Output directory is required.')
1682
+ nexp_q = [inquirer.List('nexp',
1683
+ message='Number of exponential components',
1684
+ choices=['1', '2', '3'],
1685
+ default='2')]
1686
+ nexp = int(inquirer.prompt(nexp_q)['nexp'])
1687
+ supplied_q = yes_no_question('Provide reference τ values instead of fitting from the pooled stack?')
1688
+ ref_tau1 = None
1689
+ ref_tau2 = None
1690
+ if supplied_q == 'y':
1691
+ ref_tau1 = float(input(' τ₁ (ns): ').strip())
1692
+ if nexp >= 2:
1693
+ ref_tau2 = float(input(' τ₂ (ns): ').strip())
1694
+ print('\nIRF options:')
1695
+ print(" 1. 'gaussian' - simple Gaussian (fastest)")
1696
+ print(" 2. 'parametric' - fit Gaussian+tail to rising edge")
1697
+ print(" 3. 'machine_irf' - prebuilt machine IRF (.npy)")
1698
+ print(" 4. 'machine_irf_sigma_half'- machine IRF + σ≤0.5 (recommended)")
1699
+ irf_q = [inquirer.List('irf',
1700
+ message='IRF method',
1701
+ choices=['gaussian', 'parametric', 'machine_irf',
1702
+ 'machine_irf_sigma_half', 'machine_irf_sigma_full',
1703
+ 'raw'])]
1704
+ estimate_irf = inquirer.prompt(irf_q)['irf']
1705
+ machine_irf = None
1706
+ if estimate_irf.startswith('machine_irf'):
1707
+ _default = str(MACHINE_IRF_DEFAULT_PATH)
1708
+ machine_irf = input(
1709
+ f'Path to machine IRF .npy (Enter for default: {_default}): ').strip()
1710
+ machine_irf = machine_irf or _default
1711
+ bound_q = yes_no_question('Compute bound fraction α₂/(α₁+α₂)?')
1712
+ save_stack_q = yes_no_question('Save (Z, H, W) stacks for each map? (uses more disk)')
1713
+ no_plots_q = yes_no_question('Skip z-series plots?')
1714
+ args = argparse.Namespace()
1715
+ args.ptu_dir = ptu_dir
1716
+ args.output_dir = output_dir
1717
+ args.nexp = nexp
1718
+ args.tau_min = Tau_min
1719
+ args.tau_max = Tau_max
1720
+ args.ref_tau1 = ref_tau1
1721
+ args.ref_tau2 = ref_tau2
1722
+ args.estimate_irf = estimate_irf
1723
+ args.machine_irf = machine_irf
1724
+ args.irf_fwhm = IRF_FWHM
1725
+ args.irf_bins = IRF_BINS
1726
+ args.irf_fit_width = IRF_FIT_WIDTH
1727
+ args.optimizer = 'de'
1728
+ args.restarts = lm_restarts
1729
+ args.de_population = de_population
1730
+ args.de_maxiter = de_maxiter
1731
+ args.workers = n_workers
1732
+ args.no_polish = False
1733
+ args.cost_function = 'poisson'
1734
+ args.channel = channels
1735
+ args.min_photons = MIN_PHOTONS_PERPIX
1736
+ args.correct_pileup = False
1737
+ args.fit_start_ns = None
1738
+ args.fit_end_ns = None
1739
+ args.exclude_ns = None
1740
+ args.bound_fraction = (bound_q == 'y')
1741
+ args.save_stack = (save_stack_q == 'y')
1742
+ args.no_plots = (no_plots_q == 'y')
1743
+ _run_zstack_fit(args)
1744
+ else:
1745
+ ap = argparse.ArgumentParser(
1746
+ description='Z-stack FLIM batch fitting from region_zX.ptu files '
1747
+ '(one z-stack fitted as one FOV)'
1748
+ )
1749
+ ap.add_argument('--ptu-dir', required=True,
1750
+ help='Folder containing z-stack PTU files')
1751
+ ap.add_argument('--output-dir', required=True,
1752
+ help='Base output directory')
1753
+ ap.add_argument('--nexp', type=int, default=n_exp, choices=[1, 2, 3])
1754
+ ap.add_argument('--tau-min', type=float, default=Tau_min)
1755
+ ap.add_argument('--tau-max', type=float, default=Tau_max)
1756
+ ap.add_argument('--ref-tau1', type=float, default=None,
1757
+ help='Reference τ₁ (ns); skips pooled global fit')
1758
+ ap.add_argument('--ref-tau2', type=float, default=None,
1759
+ help='Reference τ₂ (ns); required with --ref-tau1 for nexp≥2')
1760
+ ap.add_argument('--estimate-irf',
1761
+ choices=['gaussian', 'parametric', 'raw',
1762
+ 'machine_irf', 'machine_irf_sigma_full',
1763
+ 'machine_irf_sigma_half'],
1764
+ default='gaussian')
1765
+ ap.add_argument('--machine-irf', default=str(MACHINE_IRF_DEFAULT_PATH))
1766
+ ap.add_argument('--irf-fwhm', type=float, default=IRF_FWHM)
1767
+ ap.add_argument('--irf-bins', type=int, default=IRF_BINS)
1768
+ ap.add_argument('--irf-fit-width', type=float, default=IRF_FIT_WIDTH)
1769
+ ap.add_argument('--optimizer', choices=['lm_multistart', 'de'], default='de')
1770
+ ap.add_argument('--restarts', type=int, default=lm_restarts)
1771
+ ap.add_argument('--de-population', type=int, default=de_population)
1772
+ ap.add_argument('--de-maxiter', type=int, default=de_maxiter)
1773
+ ap.add_argument('--workers', type=int, default=n_workers)
1774
+ ap.add_argument('--no-polish', action='store_true')
1775
+ ap.add_argument('--channel', type=int, default=channels)
1776
+ ap.add_argument('--min-photons', type=int, default=MIN_PHOTONS_PERPIX)
1777
+ ap.add_argument('--correct-pileup', action='store_true')
1778
+ ap.add_argument('--fit-start-ns', type=float, default=None,
1779
+ help='fit window start in ns (default: auto from IRF onset)')
1780
+ ap.add_argument('--fit-end-ns', type=float, default=None,
1781
+ help='fit window end in ns (default: auto)')
1782
+ ap.add_argument('--exclude-ns', type=str, default=None,
1783
+ help='bands to exclude from the fit, eg "7.2-8.8" or '
1784
+ '"7.2-8.8,11.0-11.5" (for reflection peaks mid-decay)')
1785
+ ap.add_argument('--cost-function', choices=['chi2', 'poisson'], default='poisson')
1786
+ ap.add_argument('--bound-fraction', action='store_true',
1787
+ help='Compute bound fraction α₂/(α₁+α₂)')
1788
+ ap.add_argument('--no-stack', action='store_true',
1789
+ help='Skip saving (Z,H,W) stacks')
1790
+ ap.add_argument('--no-plots', action='store_true')
1791
+ args = ap.parse_args()
1792
+ args.save_stack = not args.no_stack
1793
+ _run_zstack_fit(args)
1794
+
1795
+ def tile_fit(interactive=False):
1796
+ if interactive:
1797
+ args = tile_fit_inquire()
1798
+ _run_tile_fit(args)
1799
+ else:
1800
+ ap = argparse.ArgumentParser(
1801
+ description='Per-tile FLIM fitting fits each tile independently, '
1802
+ 'assembles canvas maps, derives global tau.'
1803
+ )
1804
+ ap.add_argument('--xlif', required=True)
1805
+ ap.add_argument('--ptu-dir', required=True)
1806
+ ap.add_argument('--output-dir', required=True)
1807
+ ap.add_argument('--ptu-basename', default=None)
1808
+ ap.add_argument('--rotate-tiles', action='store_true', default=True)
1809
+ ap.add_argument('--no-rotate', action='store_true')
1810
+ ap.add_argument('--no-register', action='store_true',
1811
+ help='Disable phase-correlation Y registration between tile columns')
1812
+ ap.add_argument('--reg-max-shift', type=int, default=120,
1813
+ help='Max Y search range for registration (px)')
1814
+ ap.add_argument('--irf-xlsx-dir', default=None)
1815
+ ap.add_argument('--estimate-irf', default='parametric',
1816
+ choices=['parametric', 'raw', 'machine_irf'])
1817
+ ap.add_argument('--machine-irf', default=str(MACHINE_IRF_DEFAULT_PATH))
1818
+ ap.add_argument('--nexp', type=int, default=n_exp, choices=[1, 2, 3])
1819
+ ap.add_argument('--tau-min', type=float, default=Tau_min)
1820
+ ap.add_argument('--tau-max', type=float, default=Tau_max)
1821
+ ap.add_argument('--binning', type=int, default=binning_factor)
1822
+ ap.add_argument('--min-photons', type=int, default=MIN_PHOTONS_PERPIX)
1823
+ ap.add_argument('--optimizer', default='de', choices=['lm_multistart', 'de'])
1824
+ ap.add_argument('--restarts', type=int, default=lm_restarts)
1825
+ ap.add_argument('--de-population',type=int, default=de_population)
1826
+ ap.add_argument('--de-maxiter', type=int, default=de_maxiter)
1827
+ ap.add_argument('--workers', type=int, default=n_workers)
1828
+ ap.add_argument('--no-polish', action='store_true')
1829
+ ap.add_argument('--channel', type=int, default=channels)
1830
+ ap.add_argument('--irf-fwhm', type=float, default=IRF_FWHM)
1831
+ ap.add_argument('--irf-bins', type=int, default=IRF_BINS)
1832
+ ap.add_argument('--irf-fit-width',type=float, default=IRF_FIT_WIDTH)
1833
+ ap.add_argument('--intensity-threshold', default=None)
1834
+ ap.add_argument('--tau-display-min', type=float, default=None)
1835
+ ap.add_argument('--tau-display-max', type=float, default=None)
1836
+ args = ap.parse_args()
1837
+ if args.no_rotate:
1838
+ args.rotate_tiles = False
1839
+ if args.ptu_basename is None:
1840
+ args.ptu_basename = Path(args.xlif).stem
1841
+ args.register_tiles = not args.no_register
1842
+ args.reg_max_shift_px = args.reg_max_shift
1843
+ args.irf = None
1844
+ args.irf_xlsx = None
1845
+ args.irf_xlsx_map = None
1846
+ args.irf_xlsx_dir = args.irf_xlsx_dir
1847
+ args.machine_irf = args.machine_irf
1848
+ args.mode = 'both'
1849
+ args.no_plots = True
1850
+ args.cell_mask = False
1851
+ args.no_xlsx_irf = True
1852
+ args.debug_xlsx = False
1853
+ args.print_config = False
1854
+ args.xlsx = None
1855
+ args.out = None
1856
+ args.no_polish = args.no_polish
1857
+ _run_tile_fit(args)