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.
- flimkit/FLIM/__init__.py +0 -0
- flimkit/FLIM/assemble.py +254 -0
- flimkit/FLIM/batch.py +681 -0
- flimkit/FLIM/bg_tools.py +51 -0
- flimkit/FLIM/fit_tools.py +244 -0
- flimkit/FLIM/fitters.py +1471 -0
- flimkit/FLIM/irf_tools.py +617 -0
- flimkit/FLIM/models.py +391 -0
- flimkit/GPU/__init__.py +85 -0
- flimkit/GPU/_base.py +391 -0
- flimkit/GPU/cuda.py +10 -0
- flimkit/GPU/mlx_backend.py +381 -0
- flimkit/GPU/mps.py +10 -0
- flimkit/GPU/rocm.py +10 -0
- flimkit/GPU/torch_backend.py +385 -0
- flimkit/UI/app_state.py +10 -0
- flimkit/UI/controller.py +139 -0
- flimkit/UI/expert_settings.py +248 -0
- flimkit/UI/fit_help.py +206 -0
- flimkit/UI/fov_preview.py +1085 -0
- flimkit/UI/gui.py +3919 -0
- flimkit/UI/icon.icns +0 -0
- flimkit/UI/icon.ico +0 -0
- flimkit/UI/icon.png +0 -0
- flimkit/UI/irf_widget.py +103 -0
- flimkit/UI/mode_controller.py +118 -0
- flimkit/UI/modes/__init__.py +0 -0
- flimkit/UI/modes/base.py +3 -0
- flimkit/UI/modes/batch_mode.py +312 -0
- flimkit/UI/modes/fov_mode.py +164 -0
- flimkit/UI/modes/irf_mode.py +80 -0
- flimkit/UI/modes/phasor_mode.py +131 -0
- flimkit/UI/modes/stitch_mode.py +254 -0
- flimkit/UI/phasor_panel.py +1087 -0
- flimkit/UI/progress_window.py +113 -0
- flimkit/UI/project_panel.py +262 -0
- flimkit/UI/results_panel.py +332 -0
- flimkit/UI/roi_tools.py +794 -0
- flimkit/UI/utils.py +217 -0
- flimkit/__init__.py +0 -0
- flimkit/_version.py +41 -0
- flimkit/cli.py +120 -0
- flimkit/configs.py +148 -0
- flimkit/dialogs.py +46 -0
- flimkit/formats/BH/__init__.py +0 -0
- flimkit/formats/BH/reader.py +296 -0
- flimkit/formats/BH/writer.py +86 -0
- flimkit/formats/ISS/__init__.py +0 -0
- flimkit/formats/ISS/fdflim.py +86 -0
- flimkit/formats/ISS/image.py +114 -0
- flimkit/formats/ISS/reader.py +223 -0
- flimkit/formats/PS/__init__.py +0 -0
- flimkit/formats/PS/reader.py +202 -0
- flimkit/formats/PTU/__init__.py +0 -0
- flimkit/formats/PTU/decode.py +27 -0
- flimkit/formats/PTU/phu.py +85 -0
- flimkit/formats/PTU/reader.py +235 -0
- flimkit/formats/PTU/series.py +258 -0
- flimkit/formats/PTU/stitch.py +1182 -0
- flimkit/formats/PTU/tools.py +94 -0
- flimkit/formats/__init__.py +2 -0
- flimkit/formats/flim_file.py +232 -0
- flimkit/formats/phasor.py +132 -0
- flimkit/formats/signal.py +170 -0
- flimkit/image/tools.py +124 -0
- flimkit/interactive.py +1857 -0
- flimkit/mpl_backend.py +22 -0
- flimkit/phasor/__init__.py +40 -0
- flimkit/phasor/filters.py +127 -0
- flimkit/phasor/fret.py +654 -0
- flimkit/phasor/interactive.py +556 -0
- flimkit/phasor/peaks.py +186 -0
- flimkit/phasor/signal.py +90 -0
- flimkit/phasor_launcher.py +314 -0
- flimkit/plugins/__init__.py +137 -0
- flimkit/plugins/bindings.py +116 -0
- flimkit/plugins/builtin/__init__.py +3 -0
- flimkit/plugins/builtin/core_tools.py +28 -0
- flimkit/plugins/loader.py +371 -0
- flimkit/plugins/registry.py +406 -0
- flimkit/project.py +197 -0
- flimkit/synth.py +145 -0
- flimkit/utils/__init__.py +0 -0
- flimkit/utils/batch_fit.py +301 -0
- flimkit/utils/config_manager.py +119 -0
- flimkit/utils/config_snapshot.py +30 -0
- flimkit/utils/crash_handler.py +183 -0
- flimkit/utils/display.py +197 -0
- flimkit/utils/enhanced_outputs.py +345 -0
- flimkit/utils/fancy.py +103 -0
- flimkit/utils/lifetime_image.py +243 -0
- flimkit/utils/misc.py +111 -0
- flimkit/utils/plotting.py +190 -0
- flimkit/utils/roi.py +370 -0
- flimkit/utils/session.py +51 -0
- flimkit/utils/update_check.py +198 -0
- flimkit/utils/xlsx_tools.py +97 -0
- flimkit/utils/xml_utils.py +219 -0
- flimkit-0.12.0.dist-info/METADATA +356 -0
- flimkit-0.12.0.dist-info/RECORD +104 -0
- flimkit-0.12.0.dist-info/WHEEL +5 -0
- flimkit-0.12.0.dist-info/entry_points.txt +2 -0
- flimkit-0.12.0.dist-info/licenses/LICENSE.md +11 -0
- 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)
|