Skip to content

Commit bed4700

Browse files
alex1075claude
andcommitted
Add tail-fit model and fix IRF/fit-window gaps from user testing
Tail fit: bare exponentials past the peak, ignoring the IRF, matching the LAS X n-exponential tail fit. Selectable as --fit-model tail with an optional free t0. Fixes from NUS testing: - validate_installation.py failed Tile Stitching and Complete Workflow because mock_data imported ptu_writer as a top-level module, which only resolves under pytest. Now 10/10 when run as a script. - .pck IRF files were dispatched only from fit_cli, so the GUI and interactive paths sent them to the scatter-PTU reader. Added irf_from_measured_file and routed all three entry points through it. - The IRF browse dialog filtered on FLIM data extensions plus xlsx, so .pck files were never listed. - Fit window and exclusion bands were command-line only. Added start, end and exclusion fields to Expert Settings, validated on confirm, wired into the FOV and stitch paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 80b0442 commit bed4700

16 files changed

Lines changed: 1021 additions & 95 deletions

fit_cli.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import matplotlib
66
import argparse
77
from flimkit.formats import FLIMFile
8-
from flimkit.FLIM.irf_tools import gaussian_irf_from_fwhm, irf_from_scatter_ptu, irf_from_pck, irf_from_xlsx, irf_from_xlsx_analytical, estimate_irf_from_decay_parametric, estimate_irf_from_decay_raw, reconstruct_irf_from_decay, compare_irfs, machine_irf_prompt
9-
from flimkit.FLIM.fitters import fit_summed, fit_per_pixel, MIN_PHOTONS_PERPIX
8+
from flimkit.FLIM.irf_tools import gaussian_irf_from_fwhm, irf_from_scatter_ptu, irf_from_pck, irf_from_measured_file, irf_from_xlsx, irf_from_xlsx_analytical, estimate_irf_from_decay_parametric, estimate_irf_from_decay_raw, reconstruct_irf_from_decay, compare_irfs, machine_irf_prompt
9+
from flimkit.FLIM.fitters import fit_summed, fit_per_pixel, fit_summed_tail, MIN_PHOTONS_PERPIX
1010
from flimkit.utils.plotting import plot_summed, plot_pixel_maps, plot_lifetime_histogram
1111
from flimkit.utils.misc import print_summary
1212
from flimkit.utils.xlsx_tools import load_xlsx
@@ -52,6 +52,14 @@ def single_FOV_flim_fit_cli():
5252
help='IRF FWHM in ns. Default: 1 bin width from PTU '
5353
'(e.g. 0.097 ns for 97 ps bins). Override for other systems.')
5454
ap.add_argument('--nexp', type=int, default=n_exp, choices=[1, 2, 3])
55+
ap.add_argument('--fit-model', choices=['reconv', 'tail'], default='reconv',
56+
help="'reconv' (default) convolves the model with the IRF. "
57+
"'tail' fits bare exponentials past the peak and ignores "
58+
'the IRF entirely (Leica LAS X n-exponential tail fit).')
59+
ap.add_argument('--fit-t0', action='store_true',
60+
help='Tail fit only: let the lifetime offset t0 float instead of '
61+
'pinning it to the decay peak. Correlated with the amplitudes, '
62+
'so leave it off unless the amplitudes matter more than stability.')
5563
ap.add_argument('--tau-min', type=float, default=Tau_min, help='ns')
5664
ap.add_argument('--tau-max', type=float, default=Tau_max, help='ns')
5765
ap.add_argument('--mode', default=D_mode,
@@ -180,10 +188,7 @@ def single_FOV_flim_fit_cli():
180188
print(f"\n[4] Building IRF")
181189
sigma_max = MACHINE_IRF_SIGMA_MAX_FULL
182190
if args.irf is not None:
183-
if str(args.irf).lower().endswith('.pck'):
184-
irf_prompt = irf_from_pck(args.irf, ptu.n_bins, channel=args.channel)
185-
else:
186-
irf_prompt = irf_from_scatter_ptu(args.irf, ptu, channel=args.channel)
191+
irf_prompt = irf_from_measured_file(args.irf, ptu, channel=args.channel)
187192
strategy = 'scatter_ptu'
188193
has_tail = False
189194
fit_sigma = False
@@ -268,7 +273,25 @@ def single_FOV_flim_fit_cli():
268273
fit_tvb = tvb_profile is not None
269274
global_popt = None
270275
global_summary = None
276+
_tail_fit = args.fit_model == 'tail'
271277
def _run_summed():
278+
if _tail_fit:
279+
return fit_summed_tail(
280+
decay, ptu.tcspc_res, ptu.n_bins,
281+
fit_bg, args.nexp, args.tau_min, args.tau_max,
282+
fit_t0=args.fit_t0,
283+
optimizer=args.optimizer,
284+
n_restarts=args.restarts,
285+
de_popsize=args.de_population,
286+
de_maxiter=args.de_maxiter,
287+
workers=args.workers,
288+
polish=not args.no_polish,
289+
cost_function=args.cost_function,
290+
tvb_profile=tvb_profile, fit_tvb=fit_tvb,
291+
fit_start_ns=args.fit_start_ns,
292+
fit_end_ns=args.fit_end_ns,
293+
exclude_ns=parse_exclude_ns(args.exclude_ns),
294+
)
272295
return fit_summed(
273296
decay, ptu.tcspc_res, ptu.n_bins,
274297
irf_prompt, has_tail, fit_bg, fit_sigma,
@@ -287,7 +310,8 @@ def _run_summed():
287310
exclude_ns=parse_exclude_ns(args.exclude_ns),
288311
)
289312
if args.mode in ('summed', 'both'):
290-
print(f"\n[5] Summed decay fit ({args.nexp}-exp, optimizer={args.optimizer})")
313+
print(f"\n[5] Summed decay fit ({args.nexp}-exp"
314+
f"{' tail' if _tail_fit else ''}, optimizer={args.optimizer})")
291315
global_popt, global_summary = _run_summed()
292316
print_summary(global_summary, strategy, args.nexp)
293317
if not args.no_plots:
@@ -327,6 +351,9 @@ def _run_summed():
327351
fit_idx=global_summary.get('fit_idx'),
328352
free_tau=getattr(args, 'free_tau', False),
329353
tvb_profile=tvb_profile, fit_tvb=fit_tvb,
354+
fit_model=args.fit_model,
355+
fit_t0=args.fit_t0 if _tail_fit else False,
356+
t0_fixed=(global_summary['t0_ns'] * 1e-9) if _tail_fit else 0.0,
330357
)
331358
roi_name = Path(args.ptu).stem
332359
save_weighted_tau_images(

flimkit/FLIM/fit_tools.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,51 @@ def find_fit_start(decay: np.ndarray, irf_prompt: np.ndarray,
7070
return fit_start
7171

7272

73+
def find_tail_fit_start(decay, peak_bin, n_bins, frac=0.9):
74+
d = np.asarray(decay, dtype=float)
75+
peak_val = float(d[peak_bin]) if 0 <= peak_bin < n_bins else 0.0
76+
if peak_val <= 0:
77+
return min(peak_bin + 1, n_bins - 1)
78+
below = np.where(d[peak_bin:] < frac * peak_val)[0]
79+
if len(below) == 0:
80+
return min(peak_bin + 1, n_bins - 1)
81+
return int(min(peak_bin + below[0], n_bins - 1))
82+
83+
def _build_bounds_tail(n_exp, tau_min, tau_max, decay_peak, fit_bg,
84+
bg_init=0.0, bg_upper=None,
85+
fit_t0=False, t0_init=0.0, t0_range=0.0,
86+
fit_tvb=False, tvb_init=0.0, tvb_upper=None):
87+
lo = [tau_min] * n_exp + [0.0] * n_exp
88+
hi = [tau_max] * n_exp + [10 * decay_peak] * n_exp
89+
if fit_t0:
90+
lo += [t0_init - t0_range]; hi += [t0_init + t0_range]
91+
if fit_bg:
92+
_bg_hi = bg_upper if bg_upper is not None else bg_init * 1.5 + 10
93+
lo += [0.0]; hi += [_bg_hi]
94+
if fit_tvb:
95+
_tvb_hi = tvb_upper if tvb_upper is not None else tvb_init * 1.5 + 10
96+
lo += [0.0]; hi += [_tvb_hi]
97+
return lo, hi
98+
99+
def _pack_p0_tail(n_exp, tau_min, tau_max, decay_peak, fit_bg, bg_init,
100+
tau_override=None, fit_t0=False, t0_init=0.0,
101+
fit_tvb=False, tvb_init=0.0):
102+
if tau_override is not None:
103+
taus0 = np.asarray(tau_override)
104+
else:
105+
tmin = max(tau_min, 1e-14) * 1.001
106+
tmax = tau_max * 0.999
107+
taus0 = np.logspace(np.log10(tmin), np.log10(tmax), n_exp)
108+
amps0 = np.full(n_exp, decay_peak / n_exp)
109+
base = np.concatenate([taus0, amps0])
110+
if fit_t0:
111+
base = np.concatenate([base, [t0_init]])
112+
if fit_bg:
113+
base = np.concatenate([base, [bg_init]])
114+
if fit_tvb:
115+
base = np.concatenate([base, [tvb_init]])
116+
return base
117+
73118
def find_fit_end(decay, peak_bin, tau_max_s, tcspc_res, n_bins):
74119
candidate = min(n_bins, peak_bin + int(6.0 * tau_max_s / tcspc_res))
75120
search_start = int(0.82 * n_bins)

0 commit comments

Comments
 (0)