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
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.ndimage import gaussian_filter1d
|
|
3
|
+
from scipy.optimize import curve_fit
|
|
4
|
+
import matplotlib
|
|
5
|
+
from datetime import timezone
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
import json
|
|
10
|
+
from ..formats.PTU.reader import PTUFile, read_pck
|
|
11
|
+
from ..formats import FLIMFile
|
|
12
|
+
from ..utils.xlsx_tools import load_xlsx
|
|
13
|
+
from ..configs import MACHINE_IRF_DIR as _DEFAULT_MACHINE_IRF_DIR
|
|
14
|
+
from ..configs import (
|
|
15
|
+
MACHINE_IRF_FIT_BG, MACHINE_IRF_FIT_SIGMA, MACHINE_IRF_FIT_TAIL,
|
|
16
|
+
MACHINE_IRF_SIGMA_MAX_FULL, MACHINE_IRF_SIGMA_MAX_HALF,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def machine_irf_prompt(machine_irf_path, n_bins, align_bin, variant='machine_irf',
|
|
20
|
+
align_label=None):
|
|
21
|
+
from ..interactive import _load_machine_irf_prompt
|
|
22
|
+
irf_prompt, strategy = _load_machine_irf_prompt(machine_irf_path, n_bins, align_bin)
|
|
23
|
+
if align_label:
|
|
24
|
+
strategy += f' align={align_label}'
|
|
25
|
+
has_tail = MACHINE_IRF_FIT_TAIL
|
|
26
|
+
fit_bg = MACHINE_IRF_FIT_BG
|
|
27
|
+
fit_sigma = MACHINE_IRF_FIT_SIGMA
|
|
28
|
+
sigma_max = MACHINE_IRF_SIGMA_MAX_FULL
|
|
29
|
+
if variant == 'machine_irf_sigma_full':
|
|
30
|
+
fit_sigma = True
|
|
31
|
+
sigma_max = MACHINE_IRF_SIGMA_MAX_FULL
|
|
32
|
+
strategy += ' + σ≤{:.1f}'.format(sigma_max)
|
|
33
|
+
elif variant == 'machine_irf_sigma_half':
|
|
34
|
+
fit_sigma = True
|
|
35
|
+
sigma_max = MACHINE_IRF_SIGMA_MAX_HALF
|
|
36
|
+
strategy += ' + σ≤{:.1f}'.format(sigma_max)
|
|
37
|
+
return irf_prompt, strategy, has_tail, fit_bg, fit_sigma, sigma_max
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
matplotlib.use('Agg', force=True)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def _leading_edge_crossing(arr: np.ndarray, frac: float = 0.5) -> int:
|
|
45
|
+
arr = np.asarray(arr, dtype=float)
|
|
46
|
+
peak = int(np.argmax(arr))
|
|
47
|
+
thr = float(arr[peak]) * frac
|
|
48
|
+
above = np.where(arr[:peak + 1] >= thr)[0]
|
|
49
|
+
return int(above[0]) if len(above) else 0
|
|
50
|
+
|
|
51
|
+
def _max_slope_bin(arr: np.ndarray) -> int:
|
|
52
|
+
arr = np.asarray(arr, dtype=float)
|
|
53
|
+
if arr.size < 2:
|
|
54
|
+
return 0
|
|
55
|
+
return int(np.argmax(np.diff(arr)))
|
|
56
|
+
|
|
57
|
+
def _extract_landmarks(arr: np.ndarray) -> dict:
|
|
58
|
+
arr = np.asarray(arr, dtype=float)
|
|
59
|
+
arr = np.maximum(arr, 0.0)
|
|
60
|
+
s = arr.sum()
|
|
61
|
+
if s > 0:
|
|
62
|
+
arr = arr / s
|
|
63
|
+
return {
|
|
64
|
+
'peak': int(np.argmax(arr)),
|
|
65
|
+
'halfmax': _leading_edge_crossing(arr, 0.5),
|
|
66
|
+
'onset10': _leading_edge_crossing(arr, 0.1),
|
|
67
|
+
'slope': _max_slope_bin(arr),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def discover_ptu_xlsx_pairs(folder: str | Path) -> list[tuple[str, Path, Path]]:
|
|
71
|
+
base = Path(folder)
|
|
72
|
+
if not base.exists():
|
|
73
|
+
raise FileNotFoundError(f"Folder not found: {base}")
|
|
74
|
+
pairs: list[tuple[str, Path, Path]] = []
|
|
75
|
+
for ptu_path in sorted(base.glob('*.ptu')):
|
|
76
|
+
if ptu_path.name.startswith('._'):
|
|
77
|
+
continue
|
|
78
|
+
name = ptu_path.stem
|
|
79
|
+
xlsx_path = base / f"{name}.xlsx"
|
|
80
|
+
if xlsx_path.exists() and not xlsx_path.name.startswith('._'):
|
|
81
|
+
pairs.append((name, ptu_path, xlsx_path))
|
|
82
|
+
return pairs
|
|
83
|
+
|
|
84
|
+
def build_machine_irf_from_folder(
|
|
85
|
+
folder: str | Path,
|
|
86
|
+
align_anchor: str = 'peak',
|
|
87
|
+
reducer: str = 'median',
|
|
88
|
+
save: bool = False,
|
|
89
|
+
confirm_save: bool = False,
|
|
90
|
+
output_name: str = 'machine_irf_default',
|
|
91
|
+
output_dir: str | Path | None = None,
|
|
92
|
+
verbose: bool = True,
|
|
93
|
+
) -> dict:
|
|
94
|
+
if align_anchor not in {'peak', 'halfmax', 'onset10', 'slope'}:
|
|
95
|
+
raise ValueError('align_anchor must be one of: peak, halfmax, onset10, slope')
|
|
96
|
+
if reducer not in {'median', 'mean'}:
|
|
97
|
+
raise ValueError('reducer must be one of: median, mean')
|
|
98
|
+
pairs = discover_ptu_xlsx_pairs(folder)
|
|
99
|
+
if len(pairs) < 2:
|
|
100
|
+
raise ValueError('Need at least 2 PTU/XLSX pairs to build a machine IRF.')
|
|
101
|
+
irfs = []
|
|
102
|
+
peaks = []
|
|
103
|
+
nbins_all = []
|
|
104
|
+
tcspc_all = []
|
|
105
|
+
for name, ptu_path, xlsx_path in pairs:
|
|
106
|
+
ptu_f = FLIMFile(str(ptu_path), verbose=False)
|
|
107
|
+
xlsx = load_xlsx(str(xlsx_path))
|
|
108
|
+
irf = irf_from_xlsx(xlsx, ptu_f.n_bins, ptu_f.tcspc_res)
|
|
109
|
+
irfs.append(irf)
|
|
110
|
+
peaks.append(int(np.argmax(irf)))
|
|
111
|
+
nbins_all.append(ptu_f.n_bins)
|
|
112
|
+
tcspc_all.append(ptu_f.tcspc_res)
|
|
113
|
+
common_nbins = int(min(nbins_all))
|
|
114
|
+
irfs = [v[:common_nbins] for v in irfs]
|
|
115
|
+
marks = [_extract_landmarks(v) for v in irfs]
|
|
116
|
+
ref_anchor = int(np.median([m[align_anchor] for m in marks]))
|
|
117
|
+
aligned = np.zeros((len(irfs), common_nbins), dtype=float)
|
|
118
|
+
for i, (irf, m) in enumerate(zip(irfs, marks)):
|
|
119
|
+
shift = ref_anchor - int(m[align_anchor])
|
|
120
|
+
aligned[i] = np.roll(irf, shift)
|
|
121
|
+
if reducer == 'median':
|
|
122
|
+
machine_irf = np.median(aligned, axis=0)
|
|
123
|
+
else:
|
|
124
|
+
machine_irf = aligned.mean(axis=0)
|
|
125
|
+
machine_irf = np.maximum(machine_irf, 0.0)
|
|
126
|
+
s = machine_irf.sum()
|
|
127
|
+
if s <= 0:
|
|
128
|
+
raise ValueError('Machine IRF aggregation produced all zeros.')
|
|
129
|
+
machine_irf /= s
|
|
130
|
+
out_paths = None
|
|
131
|
+
if save:
|
|
132
|
+
if not confirm_save:
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
'Save requested but confirm_save=False. '
|
|
135
|
+
'Set confirm_save=True after explicit user confirmation.'
|
|
136
|
+
)
|
|
137
|
+
if output_dir is None:
|
|
138
|
+
output_dir = _DEFAULT_MACHINE_IRF_DIR
|
|
139
|
+
out_dir = Path(output_dir)
|
|
140
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
npy_path = out_dir / f"{output_name}.npy"
|
|
142
|
+
csv_path = out_dir / f"{output_name}.csv"
|
|
143
|
+
meta_path = out_dir / f"{output_name}_meta.json"
|
|
144
|
+
np.save(npy_path, machine_irf.astype(np.float64))
|
|
145
|
+
np.savetxt(csv_path, machine_irf.astype(np.float64), delimiter=',')
|
|
146
|
+
meta = {
|
|
147
|
+
'created_utc': datetime.now(timezone.utc).isoformat(),
|
|
148
|
+
'source_folder': str(Path(folder).resolve()),
|
|
149
|
+
'n_pairs': len(pairs),
|
|
150
|
+
'pair_names': [name for name, _, _ in pairs],
|
|
151
|
+
'align_anchor': align_anchor,
|
|
152
|
+
'reducer': reducer,
|
|
153
|
+
'common_nbins': common_nbins,
|
|
154
|
+
'tcspc_res_ns_mean': float(np.mean(tcspc_all) * 1e9),
|
|
155
|
+
'machine_landmarks': _extract_landmarks(machine_irf),
|
|
156
|
+
}
|
|
157
|
+
meta_path.write_text(json.dumps(meta, indent=2))
|
|
158
|
+
out_paths = {
|
|
159
|
+
'npy': str(npy_path),
|
|
160
|
+
'csv': str(csv_path),
|
|
161
|
+
'meta_json': str(meta_path),
|
|
162
|
+
}
|
|
163
|
+
meta = {
|
|
164
|
+
'n_pairs': len(pairs),
|
|
165
|
+
'pair_names': [name for name, _, _ in pairs],
|
|
166
|
+
'align_anchor': align_anchor,
|
|
167
|
+
'reducer': reducer,
|
|
168
|
+
'common_nbins': common_nbins,
|
|
169
|
+
'landmarks': _extract_landmarks(machine_irf),
|
|
170
|
+
'peak_bins_before_alignment': peaks,
|
|
171
|
+
}
|
|
172
|
+
if verbose:
|
|
173
|
+
print(f"Machine IRF built from {len(pairs)} pairs")
|
|
174
|
+
print(f" anchor={align_anchor}, reducer={reducer}, n_bins={common_nbins}")
|
|
175
|
+
print(f" landmarks={meta['landmarks']}")
|
|
176
|
+
if out_paths:
|
|
177
|
+
print(' saved:')
|
|
178
|
+
for _, p in out_paths.items():
|
|
179
|
+
print(f" {p}")
|
|
180
|
+
return {
|
|
181
|
+
'irf': machine_irf,
|
|
182
|
+
'pairs': pairs,
|
|
183
|
+
'metadata': meta,
|
|
184
|
+
'save_paths': out_paths,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
def gaussian_irf_from_fwhm(n_bins: int,
|
|
188
|
+
tcspc_res: float,
|
|
189
|
+
fwhm_ns: float,
|
|
190
|
+
peak_bin: int) -> np.ndarray:
|
|
191
|
+
t = np.arange(n_bins, dtype=float) * tcspc_res * 1e9
|
|
192
|
+
t0 = peak_bin * tcspc_res * 1e9
|
|
193
|
+
irf = np.exp(-(t - t0)**2 * 4.0 * np.log(2) / fwhm_ns**2)
|
|
194
|
+
return irf / irf.sum()
|
|
195
|
+
|
|
196
|
+
def irf_from_scatter_ptu(path: str, ptu_ref: PTUFile,
|
|
197
|
+
channel: int | None = None) -> np.ndarray:
|
|
198
|
+
scatter = FLIMFile(path, verbose=False)
|
|
199
|
+
decay = scatter.summed_decay(channel=channel)
|
|
200
|
+
s = decay.sum()
|
|
201
|
+
if s == 0 and channel is not None:
|
|
202
|
+
decay = scatter.summed_decay(channel=None)
|
|
203
|
+
s = decay.sum()
|
|
204
|
+
if s > 0:
|
|
205
|
+
print(f" Scatter PTU has no photons on channel {channel}; "
|
|
206
|
+
f"using auto-detected channel {scatter.photon_channel} instead")
|
|
207
|
+
if s == 0:
|
|
208
|
+
raise ValueError(f"Scatter PTU {path!r} has no photons.")
|
|
209
|
+
n = ptu_ref.n_bins
|
|
210
|
+
if decay.size < n:
|
|
211
|
+
decay = np.concatenate([decay, np.zeros(n - decay.size)])
|
|
212
|
+
else:
|
|
213
|
+
decay = decay[:n]
|
|
214
|
+
s = decay.sum()
|
|
215
|
+
if s == 0:
|
|
216
|
+
raise ValueError(f"Scatter PTU {path!r} has no photons in the first {n} bins.")
|
|
217
|
+
print(f" IRF from scatter PTU: {s:,.0f} photons")
|
|
218
|
+
return decay / s
|
|
219
|
+
|
|
220
|
+
def irf_from_pck(path: str, n_bins: int, channel=None) -> np.ndarray:
|
|
221
|
+
hist, tags = read_pck(path)
|
|
222
|
+
if channel is None:
|
|
223
|
+
channel = int(np.argmax(hist.sum(axis=1)))
|
|
224
|
+
if channel >= hist.shape[0]:
|
|
225
|
+
raise ValueError(f'Channel {channel} not in .pck (has {hist.shape[0]} channel(s))')
|
|
226
|
+
full = hist[channel].astype(float)
|
|
227
|
+
peak = int(np.argmax(full))
|
|
228
|
+
if peak >= n_bins:
|
|
229
|
+
raise ValueError(f'.pck IRF peak (bin {peak}) is beyond the {n_bins}-bin target grid; '
|
|
230
|
+
f'the .pck ({full.size} bins) and the data have different TCSPC resolution.')
|
|
231
|
+
if full.size < n_bins:
|
|
232
|
+
irf = np.concatenate([full, np.zeros(n_bins - full.size)])
|
|
233
|
+
else:
|
|
234
|
+
irf = full[:n_bins]
|
|
235
|
+
s = irf.sum()
|
|
236
|
+
if s == 0:
|
|
237
|
+
raise ValueError(f'.pck IRF channel {channel} has no counts.')
|
|
238
|
+
print(f' IRF from .pck: channel {channel}, {int(s):,} photons, peak bin {peak}')
|
|
239
|
+
return irf / s
|
|
240
|
+
|
|
241
|
+
def align_irf_to_bin(irf_prompt: np.ndarray, target_bin: int,
|
|
242
|
+
n_bins: int) -> tuple[np.ndarray, int]:
|
|
243
|
+
current = int(np.argmax(irf_prompt))
|
|
244
|
+
shift = int(target_bin) - current
|
|
245
|
+
if shift == 0:
|
|
246
|
+
return irf_prompt, 0
|
|
247
|
+
x = np.arange(n_bins, dtype=float)
|
|
248
|
+
shifted = np.interp(x - shift, x, irf_prompt, left=0.0, right=0.0)
|
|
249
|
+
s = shifted.sum()
|
|
250
|
+
if s > 0:
|
|
251
|
+
shifted = shifted / s
|
|
252
|
+
return shifted, shift
|
|
253
|
+
|
|
254
|
+
def irf_from_measured_file(path: str, ptu_ref: PTUFile,
|
|
255
|
+
channel: int | None = None) -> np.ndarray:
|
|
256
|
+
if str(path).lower().endswith('.pck'):
|
|
257
|
+
return irf_from_pck(path, ptu_ref.n_bins, channel=channel)
|
|
258
|
+
return irf_from_scatter_ptu(path, ptu_ref, channel=channel)
|
|
259
|
+
|
|
260
|
+
def irf_from_xlsx_analytical(xlsx: dict, n_bins: int, tcspc_res: float,
|
|
261
|
+
verbose: bool = True) -> tuple[np.ndarray, dict]:
|
|
262
|
+
if xlsx['irf_t'] is None or xlsx['irf_c'] is None:
|
|
263
|
+
raise ValueError('XLSX does not contain IRF columns.')
|
|
264
|
+
t_pts = np.array(xlsx['irf_t'], dtype=float)
|
|
265
|
+
c_pts = np.maximum(np.array(xlsx['irf_c'], dtype=float), 0.0)
|
|
266
|
+
mask = c_pts > c_pts.max() * 1e-3
|
|
267
|
+
if mask.sum() < 3:
|
|
268
|
+
raise ValueError('Fewer than 3 non-negligible IRF points in xlsx - '
|
|
269
|
+
'cannot fit analytical model.')
|
|
270
|
+
t_fit = t_pts[mask]
|
|
271
|
+
c_fit = c_pts[mask]
|
|
272
|
+
t0_guess = t_pts[np.argmax(c_pts)]
|
|
273
|
+
|
|
274
|
+
def _model(t, t0, fwhm, tail_amp, tail_tau, A):
|
|
275
|
+
gauss = np.exp(-4.0 * np.log(2) * (t - t0)**2 / fwhm**2)
|
|
276
|
+
tail = np.where(t >= t0,
|
|
277
|
+
tail_amp * np.exp(-(t - t0) / np.maximum(tail_tau, 0.01)),
|
|
278
|
+
0.0)
|
|
279
|
+
return A * (gauss + tail)
|
|
280
|
+
try:
|
|
281
|
+
popt, _ = curve_fit(
|
|
282
|
+
_model, t_fit, c_fit,
|
|
283
|
+
p0 = [t0_guess, 0.15, 0.05, 0.5, c_pts.max()],
|
|
284
|
+
bounds=([t0_guess - 0.5, 0.05, 0.0, 0.05, 0],
|
|
285
|
+
[t0_guess + 0.5, 0.5, 2.0, 10.0, c_pts.max() * 2]),
|
|
286
|
+
maxfev=20000
|
|
287
|
+
)
|
|
288
|
+
t0, fwhm, tail_amp, tail_tau, A = popt
|
|
289
|
+
except Exception as e:
|
|
290
|
+
raise RuntimeError(f"Analytical IRF fit failed: {e}. "
|
|
291
|
+
f"Try --irf-xlsx with a higher-count IRF export.") from e
|
|
292
|
+
tcspc_ns = tcspc_res * 1e9
|
|
293
|
+
t_full = np.arange(n_bins, dtype=float) * tcspc_ns
|
|
294
|
+
irf_full = np.maximum(_model(t_full, t0, fwhm, tail_amp, tail_tau, A), 0.0)
|
|
295
|
+
s = irf_full.sum()
|
|
296
|
+
if s == 0:
|
|
297
|
+
raise ValueError('Analytical IRF evaluates to zero on bin grid.')
|
|
298
|
+
irf_norm = irf_full / s
|
|
299
|
+
params = dict(t0_ns=t0, fwhm_ns=fwhm, tail_amp=tail_amp, tail_tau_ns=tail_tau)
|
|
300
|
+
if verbose:
|
|
301
|
+
print(f" Analytical IRF fit (FLIM microscope model):")
|
|
302
|
+
print(f" t0 = {t0:.4f} ns (bin {t0/tcspc_ns:.2f})")
|
|
303
|
+
print(f" FWHM = {fwhm*1000:.2f} ps")
|
|
304
|
+
print(f" tail_amp = {tail_amp:.4f}")
|
|
305
|
+
print(f" tail_tau = {tail_tau:.4f} ns")
|
|
306
|
+
above = np.where(irf_norm >= irf_norm.max() / 2)[0]
|
|
307
|
+
fwhm_meas = (above[-1] - above[0]) * tcspc_ns if len(above) > 1 else fwhm
|
|
308
|
+
print(f" FWHM (measured on grid) = {fwhm_meas*1000:.2f} ps")
|
|
309
|
+
print(f" Peak bin = {np.argmax(irf_norm)}")
|
|
310
|
+
return irf_norm, params
|
|
311
|
+
|
|
312
|
+
def irf_from_xlsx(xlsx: dict, n_bins: int, tcspc_res: float) -> np.ndarray:
|
|
313
|
+
if xlsx['irf_t'] is None or xlsx['irf_c'] is None:
|
|
314
|
+
raise ValueError('XLSX does not contain IRF columns.')
|
|
315
|
+
tcspc_ns = tcspc_res * 1e9
|
|
316
|
+
t_full = np.arange(n_bins, dtype=float) * tcspc_ns
|
|
317
|
+
t_pts = np.array(xlsx['irf_t'], dtype=float)
|
|
318
|
+
c_pts = np.array(xlsx['irf_c'], dtype=float)
|
|
319
|
+
c_pts = np.maximum(c_pts, 0.0)
|
|
320
|
+
order = np.argsort(t_pts)
|
|
321
|
+
t_pts, c_pts = t_pts[order], c_pts[order]
|
|
322
|
+
irf_interp = np.interp(t_full, t_pts, c_pts, left=0.0, right=0.0)
|
|
323
|
+
s = irf_interp.sum()
|
|
324
|
+
if s == 0:
|
|
325
|
+
raise ValueError('xlsx IRF is all zeros after interpolation.')
|
|
326
|
+
return irf_interp / s
|
|
327
|
+
|
|
328
|
+
def gaussian_irf(n_bins: int, peak_bin: int, fwhm_bins: float) -> np.ndarray:
|
|
329
|
+
bins = np.arange(n_bins, dtype=float)
|
|
330
|
+
sigma = fwhm_bins / 2.3548
|
|
331
|
+
irf = np.exp(-0.5 * ((bins - peak_bin) / sigma)**2)
|
|
332
|
+
return irf / irf.sum()
|
|
333
|
+
|
|
334
|
+
def reconstruct_irf_from_decay(decay: np.ndarray,
|
|
335
|
+
tcspc_res: float,
|
|
336
|
+
n_bins: int,
|
|
337
|
+
noise_floor: float = 50,
|
|
338
|
+
noise_frac: float = 0.001,
|
|
339
|
+
max_bap: int = 2,
|
|
340
|
+
verbose: bool = False) -> np.ndarray:
|
|
341
|
+
decay = np.asarray(decay, dtype=float)
|
|
342
|
+
if decay.size < 3 or decay.max() <= 0:
|
|
343
|
+
raise ValueError('Decay histogram is empty or all zeros.')
|
|
344
|
+
peak_idx = int(np.argmax(decay))
|
|
345
|
+
peak_val = decay[peak_idx]
|
|
346
|
+
threshold = max(noise_floor, noise_frac * peak_val)
|
|
347
|
+
start_idx = peak_idx
|
|
348
|
+
while start_idx > 0 and decay[start_idx - 1] > threshold:
|
|
349
|
+
start_idx -= 1
|
|
350
|
+
cut_idx = peak_idx
|
|
351
|
+
prev_val = peak_val
|
|
352
|
+
for i in range(1, max_bap + 1):
|
|
353
|
+
next_idx = peak_idx + i
|
|
354
|
+
if next_idx >= n_bins:
|
|
355
|
+
break
|
|
356
|
+
next_val = decay[next_idx]
|
|
357
|
+
if next_val <= 0:
|
|
358
|
+
break
|
|
359
|
+
cut_idx = next_idx
|
|
360
|
+
prev_val = next_val
|
|
361
|
+
irf_full = np.zeros(n_bins, dtype=float)
|
|
362
|
+
for src in range(start_idx, cut_idx + 1):
|
|
363
|
+
if 0 <= src < n_bins:
|
|
364
|
+
irf_full[src] = decay[src]
|
|
365
|
+
total = irf_full.sum()
|
|
366
|
+
if total == 0:
|
|
367
|
+
raise ValueError('Reconstructed IRF has zero counts - check decay quality.')
|
|
368
|
+
irf_norm = irf_full / total
|
|
369
|
+
if verbose:
|
|
370
|
+
tcspc_ns = tcspc_res * 1e9
|
|
371
|
+
bap = cut_idx - peak_idx
|
|
372
|
+
n_rising = peak_idx - start_idx
|
|
373
|
+
above = np.where(irf_norm >= irf_norm.max() / 2)[0]
|
|
374
|
+
fwhm = (above[-1] - above[0]) * tcspc_ns if len(above) > 1 else tcspc_ns
|
|
375
|
+
print(f" IRF reconstructed from decay rising edge:")
|
|
376
|
+
print(f" Peak bin (decay) = {peak_idx} → IRF peak bin = {peak_idx}")
|
|
377
|
+
print(f" Rising edge = {n_rising} bins")
|
|
378
|
+
print(f" Bins after peak = {bap}")
|
|
379
|
+
print(f" IRF extent = bins {start_idx}..{cut_idx} "
|
|
380
|
+
f"({cut_idx - start_idx + 1} bins)")
|
|
381
|
+
print(f" FWHM (grid) = {fwhm * 1000:.1f} ps")
|
|
382
|
+
return irf_norm
|
|
383
|
+
|
|
384
|
+
def estimate_irf_from_decay_raw(decay, tcspc_res, n_bins,
|
|
385
|
+
n_irf_bins=21, bg_est_pre=5) -> np.ndarray:
|
|
386
|
+
peak_bin = int(np.argmax(decay))
|
|
387
|
+
bg_end = max(0, peak_bin - bg_est_pre)
|
|
388
|
+
bg = float(np.median(decay[:bg_end])) if bg_end > 0 \
|
|
389
|
+
else float(np.median(decay[-30:]))
|
|
390
|
+
decay_sub = np.maximum(decay - bg, 0.0)
|
|
391
|
+
half = n_irf_bins // 2
|
|
392
|
+
start = max(0, peak_bin - half)
|
|
393
|
+
end = min(n_bins, peak_bin + half + 1)
|
|
394
|
+
irf_raw = decay_sub[start:end].copy()
|
|
395
|
+
total = irf_raw.sum()
|
|
396
|
+
if total == 0:
|
|
397
|
+
raise ValueError('Extracted IRF region has zero counts.')
|
|
398
|
+
irf_full = np.zeros(n_bins, dtype=float)
|
|
399
|
+
irf_full[start:end] = irf_raw / total
|
|
400
|
+
return irf_full
|
|
401
|
+
|
|
402
|
+
def _irf_parametric(t, t0, amplitude):
|
|
403
|
+
return amplitude * (t / t0) * np.exp(-t / t0)
|
|
404
|
+
|
|
405
|
+
def estimate_irf_from_decay_parametric(decay, tcspc_res, n_bins,
|
|
406
|
+
fit_window_width_ns=1.5,
|
|
407
|
+
bg_est_pre=5) -> np.ndarray:
|
|
408
|
+
peak_bin = int(np.argmax(decay))
|
|
409
|
+
bg_end = max(0, peak_bin - bg_est_pre)
|
|
410
|
+
bg = float(np.median(decay[:bg_end])) if bg_end > 0 \
|
|
411
|
+
else float(np.median(decay[-30:]))
|
|
412
|
+
decay_sub = np.maximum(decay - bg, 0.0)
|
|
413
|
+
time_ns = np.arange(n_bins) * tcspc_res * 1e9
|
|
414
|
+
t_peak_ns = time_ns[peak_bin]
|
|
415
|
+
start_ns = max(0, t_peak_ns - fit_window_width_ns / 2)
|
|
416
|
+
end_ns = min(time_ns[-1], t_peak_ns + fit_window_width_ns / 2)
|
|
417
|
+
sb = np.searchsorted(time_ns, start_ns, side='left')
|
|
418
|
+
eb = np.searchsorted(time_ns, end_ns, side='right')
|
|
419
|
+
if eb - sb < 3:
|
|
420
|
+
raise ValueError('Fit window too narrow.')
|
|
421
|
+
t_fit = time_ns[sb:eb] - time_ns[sb]
|
|
422
|
+
y_fit = decay_sub[sb:eb]
|
|
423
|
+
pk = np.argmax(y_fit)
|
|
424
|
+
try:
|
|
425
|
+
popt, _ = curve_fit(_irf_parametric, t_fit, y_fit,
|
|
426
|
+
p0=[t_fit[pk]/2.0 if pk > 0 else 1.0, y_fit[pk]],
|
|
427
|
+
bounds=([0.01, 0], [10.0, np.inf]))
|
|
428
|
+
t0, amp = popt
|
|
429
|
+
except Exception as e:
|
|
430
|
+
print(f"Parametric fit failed: {e}, falling back to raw extraction.")
|
|
431
|
+
return estimate_irf_from_decay_raw(decay, tcspc_res, n_bins)
|
|
432
|
+
t_full_ns = time_ns - time_ns[sb]
|
|
433
|
+
irf_full = np.maximum(_irf_parametric(t_full_ns, t0, amp), 0.0)
|
|
434
|
+
total = irf_full.sum()
|
|
435
|
+
return irf_full / total if total > 0 else np.zeros(n_bins)
|
|
436
|
+
|
|
437
|
+
def build_full_irf(irf_prompt: np.ndarray,
|
|
438
|
+
shift_bins: float,
|
|
439
|
+
sigma_bins: float,
|
|
440
|
+
tail_amp: float,
|
|
441
|
+
tail_tau_bins: float,
|
|
442
|
+
n_bins: int) -> np.ndarray:
|
|
443
|
+
peak_bin = int(np.argmax(irf_prompt))
|
|
444
|
+
bins = np.arange(n_bins, dtype=float)
|
|
445
|
+
tail = np.where(
|
|
446
|
+
bins >= peak_bin,
|
|
447
|
+
tail_amp * np.exp(-(bins - peak_bin) / max(tail_tau_bins, 0.1)),
|
|
448
|
+
0.0
|
|
449
|
+
)
|
|
450
|
+
irf_aug = irf_prompt + tail
|
|
451
|
+
s = irf_aug.sum()
|
|
452
|
+
if s > 0:
|
|
453
|
+
irf_aug /= s
|
|
454
|
+
x_orig = np.arange(n_bins, dtype=float)
|
|
455
|
+
irf_shifted = np.interp(x_orig - shift_bins, x_orig, irf_aug,
|
|
456
|
+
left=0.0, right=0.0)
|
|
457
|
+
if sigma_bins > 0.05:
|
|
458
|
+
irf_shifted = gaussian_filter1d(irf_shifted, sigma=sigma_bins)
|
|
459
|
+
s2 = irf_shifted.sum()
|
|
460
|
+
if s2 > 0:
|
|
461
|
+
irf_shifted /= s2
|
|
462
|
+
return irf_shifted
|
|
463
|
+
|
|
464
|
+
def _fwhm_ns(irf: np.ndarray, tcspc_res: float) -> float:
|
|
465
|
+
pk = irf.max()
|
|
466
|
+
if pk <= 0:
|
|
467
|
+
return np.nan
|
|
468
|
+
above = np.where(irf >= pk / 2)[0]
|
|
469
|
+
if len(above) > 1:
|
|
470
|
+
return (above[-1] - above[0]) * tcspc_res * 1e9
|
|
471
|
+
integral = irf.sum() * tcspc_res * 1e9
|
|
472
|
+
fwhm_est = integral * np.sqrt(4 * np.log(2) / np.pi)
|
|
473
|
+
return float(fwhm_est)
|
|
474
|
+
|
|
475
|
+
def compare_irfs(irf_estimated: np.ndarray,
|
|
476
|
+
xlsx: dict | None,
|
|
477
|
+
tcspc_res: float,
|
|
478
|
+
n_bins: int,
|
|
479
|
+
strategy: str,
|
|
480
|
+
out_prefix: str) -> dict | None:
|
|
481
|
+
t_ns = np.arange(n_bins, dtype=float) * tcspc_res * 1e9
|
|
482
|
+
irf_xlsx_embedded = None
|
|
483
|
+
if xlsx is not None and xlsx.get('irf_t') is not None and xlsx.get('irf_c') is not None:
|
|
484
|
+
irf_raw = np.zeros(n_bins)
|
|
485
|
+
for t, c in zip(xlsx['irf_t'], xlsx['irf_c']):
|
|
486
|
+
idx = int(round(t / (tcspc_res * 1e9)))
|
|
487
|
+
if 0 <= idx < n_bins:
|
|
488
|
+
irf_raw[idx] += max(c, 0.0)
|
|
489
|
+
s = irf_raw.sum()
|
|
490
|
+
if s > 0:
|
|
491
|
+
irf_xlsx_embedded = irf_raw / s
|
|
492
|
+
if irf_xlsx_embedded is None:
|
|
493
|
+
print(' IRF comparison skipped - no xlsx IRF available.')
|
|
494
|
+
return None
|
|
495
|
+
est = irf_estimated / irf_estimated.sum()
|
|
496
|
+
ref = irf_xlsx_embedded / irf_xlsx_embedded.sum()
|
|
497
|
+
peak_est_bin = int(np.argmax(est))
|
|
498
|
+
peak_ref_bin = int(np.argmax(ref))
|
|
499
|
+
shift_bins = peak_est_bin - peak_ref_bin
|
|
500
|
+
x = np.arange(n_bins, dtype=float)
|
|
501
|
+
est_aligned = np.interp(x + shift_bins, x, est, left=0.0, right=0.0)
|
|
502
|
+
s = est_aligned.sum()
|
|
503
|
+
if s > 0:
|
|
504
|
+
est_aligned /= s
|
|
505
|
+
|
|
506
|
+
def _metrics(a, b, label):
|
|
507
|
+
support = (a > 1e-8) | (b > 1e-8)
|
|
508
|
+
a_s, b_s = a[support], b[support]
|
|
509
|
+
if len(a_s) > 1 and a_s.std() > 0 and b_s.std() > 0:
|
|
510
|
+
r = float(np.corrcoef(a_s, b_s)[0, 1])
|
|
511
|
+
else:
|
|
512
|
+
r = np.nan
|
|
513
|
+
rmse = float(np.sqrt(np.mean((a - b)**2)))
|
|
514
|
+
bc = float(np.sum(np.sqrt(a * b)))
|
|
515
|
+
return dict(label=label, pearson_r=r, rmse=rmse,
|
|
516
|
+
overlap_score=max(0.0, 1.0 - rmse), bhattacharyya=bc)
|
|
517
|
+
m_raw = _metrics(est, ref, 'raw (unaligned)')
|
|
518
|
+
m_aligned = _metrics(est_aligned, ref, 'aligned (peak-shift corrected)')
|
|
519
|
+
fwhm_est = _fwhm_ns(est, tcspc_res)
|
|
520
|
+
fwhm_ref = _fwhm_ns(ref, tcspc_res)
|
|
521
|
+
peak_est_ns = peak_est_bin * tcspc_res * 1e9
|
|
522
|
+
peak_ref_ns = peak_ref_bin * tcspc_res * 1e9
|
|
523
|
+
metrics = dict(
|
|
524
|
+
fwhm_estimated_ns = fwhm_est,
|
|
525
|
+
fwhm_xlsx_ns = fwhm_ref,
|
|
526
|
+
peak_estimated_ns = peak_est_ns,
|
|
527
|
+
peak_xlsx_ns = peak_ref_ns,
|
|
528
|
+
peak_shift_ns = shift_bins * tcspc_res * 1e9,
|
|
529
|
+
peak_shift_bins = shift_bins,
|
|
530
|
+
raw = m_raw,
|
|
531
|
+
aligned = m_aligned,
|
|
532
|
+
)
|
|
533
|
+
print(f"\n IRF Comparison ({strategy.split('peak_bin')[0].strip()} vs xlsx)")
|
|
534
|
+
print(f" {'Metric':<28} {'Estimated':>12} {'xlsx':>12}")
|
|
535
|
+
print(f" {'─'*54}")
|
|
536
|
+
print(f" {'FWHM (ns)':<28} {fwhm_est:>12.4f} {fwhm_ref:>12.4f}")
|
|
537
|
+
print(f" {'Peak position (ns)':<28} {peak_est_ns:>12.4f} {peak_ref_ns:>12.4f}")
|
|
538
|
+
print(f" {'Peak shift (est − xlsx)':<28} "
|
|
539
|
+
f"{shift_bins * tcspc_res * 1e9:>+11.4f} ns ({shift_bins:+d} bins)")
|
|
540
|
+
print(f" {'─'*54}")
|
|
541
|
+
for m in (m_raw, m_aligned):
|
|
542
|
+
print(f" [{m['label']}]")
|
|
543
|
+
print(f" {'Pearson r':<26} {m['pearson_r']:>12.4f}")
|
|
544
|
+
print(f" {'RMSE (normalised)':<26} {m['rmse']:>12.6f}")
|
|
545
|
+
print(f" {'Overlap score (1−RMSE)':<26} {m['overlap_score']:>12.4f}")
|
|
546
|
+
print(f" {'Bhattacharyya coeff.':<26} {m['bhattacharyya']:>12.4f}")
|
|
547
|
+
bc_a = m_aligned['bhattacharyya']
|
|
548
|
+
if bc_a >= 0.99:
|
|
549
|
+
print(f"\n Excellent shape match after alignment (BC={bc_a:.4f})")
|
|
550
|
+
print(f" → Use --irf-fwhm with adjusted peak; shape is correct.")
|
|
551
|
+
elif bc_a >= 0.90:
|
|
552
|
+
print(f"\n ~ Acceptable shape match after alignment (BC={bc_a:.4f})")
|
|
553
|
+
print(f" → Shape is reasonable but consider --xlsx for fitting.")
|
|
554
|
+
else:
|
|
555
|
+
print(f"\n Poor shape match even after alignment (BC={bc_a:.4f})")
|
|
556
|
+
print(f" → FWHM or IRF model is wrong. Use --xlsx for fitting.")
|
|
557
|
+
if abs(shift_bins) >= 2:
|
|
558
|
+
print(f" Peak misaligned by {shift_bins:+d} bins ({shift_bins*tcspc_res*1e12:+.0f} ps) "
|
|
559
|
+
f"- IRF peak bin estimate may be off.")
|
|
560
|
+
plt.rcParams.update({'figure.dpi': 130, 'font.size': 10,
|
|
561
|
+
'axes.spines.top': False, 'axes.spines.right': False,
|
|
562
|
+
'text.color': 'black', 'axes.labelcolor': 'black',
|
|
563
|
+
'xtick.color': 'black', 'ytick.color': 'black',
|
|
564
|
+
'axes.titlecolor': 'black'})
|
|
565
|
+
fig, axes = plt.subplots(2, 3, figsize=(15, 8))
|
|
566
|
+
fig.suptitle('IRF Comparison - Estimated vs FLIM microscope xlsx',
|
|
567
|
+
fontsize=11, fontweight='bold')
|
|
568
|
+
support = (est > 1e-8) | (ref > 1e-8)
|
|
569
|
+
idx_sup = np.where(support)[0]
|
|
570
|
+
if len(idx_sup):
|
|
571
|
+
x_lo = max(0, idx_sup[0] - 10) * tcspc_res * 1e9
|
|
572
|
+
x_hi = min(n_bins-1, idx_sup[-1] + 10) * tcspc_res * 1e9
|
|
573
|
+
else:
|
|
574
|
+
x_lo, x_hi = t_ns[0], t_ns[-1]
|
|
575
|
+
row_labels = ['Unaligned', 'Peak-aligned']
|
|
576
|
+
for row, (e_plot, m) in enumerate([(est, m_raw), (est_aligned, m_aligned)]):
|
|
577
|
+
diff = e_plot - ref
|
|
578
|
+
axes[row, 0].plot(t_ns, ref, 'b-', lw=2, label='xlsx IRF')
|
|
579
|
+
axes[row, 0].plot(t_ns, e_plot, 'r--', lw=1.8, label='estimated')
|
|
580
|
+
axes[row, 0].set_xlim(x_lo, x_hi)
|
|
581
|
+
axes[row, 0].set_ylabel('Normalised amplitude')
|
|
582
|
+
axes[row, 0].set_title(f"{row_labels[row]} - linear")
|
|
583
|
+
axes[row, 0].legend(fontsize=8)
|
|
584
|
+
if row == 1:
|
|
585
|
+
axes[row, 0].set_xlabel('Time (ns)')
|
|
586
|
+
axes[row, 1].semilogy(t_ns, np.clip(ref, 1e-8, None), 'b-', lw=2)
|
|
587
|
+
axes[row, 1].semilogy(t_ns, np.clip(e_plot, 1e-8, None), 'r--', lw=1.8)
|
|
588
|
+
axes[row, 1].set_xlim(x_lo, x_hi)
|
|
589
|
+
axes[row, 1].set_title(f"{row_labels[row]} - log")
|
|
590
|
+
if row == 1:
|
|
591
|
+
axes[row, 1].set_xlabel('Time (ns)')
|
|
592
|
+
axes[row, 2].fill_between(t_ns, diff, where=diff >= 0,
|
|
593
|
+
alpha=0.6, color='#e63946', label='est > xlsx')
|
|
594
|
+
axes[row, 2].fill_between(t_ns, diff, where=diff < 0,
|
|
595
|
+
alpha=0.6, color='#457b9d', label='est < xlsx')
|
|
596
|
+
axes[row, 2].axhline(0, color='k', lw=0.8, ls='--')
|
|
597
|
+
axes[row, 2].set_xlim(x_lo, x_hi)
|
|
598
|
+
axes[row, 2].set_ylabel('Δ (estimated − xlsx)')
|
|
599
|
+
axes[row, 2].set_title(f"Difference RMSE={m['rmse']:.5f}")
|
|
600
|
+
axes[row, 2].legend(fontsize=8)
|
|
601
|
+
if row == 1:
|
|
602
|
+
axes[row, 2].set_xlabel('Time (ns)')
|
|
603
|
+
txt = (f"Pearson r = {m['pearson_r']:.4f}\n"
|
|
604
|
+
f"BC = {m['bhattacharyya']:.4f}\n"
|
|
605
|
+
f"FWHM est = {fwhm_est:.4f} ns\n"
|
|
606
|
+
f"FWHM xlsx = {fwhm_ref:.4f} ns")
|
|
607
|
+
if row == 0:
|
|
608
|
+
txt += f"\nΔpeak = {shift_bins*tcspc_res*1e12:+.0f} ps ({shift_bins:+d} bins)"
|
|
609
|
+
axes[row, 2].text(0.97, 0.97, txt, transform=axes[row, 2].transAxes,
|
|
610
|
+
va='top', ha='right', fontsize=8, family='monospace',
|
|
611
|
+
bbox=dict(boxstyle='round,pad=0.3', fc='#f7f7f7', alpha=0.9))
|
|
612
|
+
plt.tight_layout()
|
|
613
|
+
out = f"{out_prefix}_irf_comparison.png"
|
|
614
|
+
plt.savefig(out, dpi=150, bbox_inches='tight')
|
|
615
|
+
plt.close()
|
|
616
|
+
print(f" Saved: {out}")
|
|
617
|
+
return metrics
|