pyfli-lib 0.1.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.
- pyfli/__init__.py +39 -0
- pyfli/scripts/__init__.py +34 -0
- pyfli/scripts/analytical_methods/__init__.py +5 -0
- pyfli/scripts/analytical_methods/am_utils.py +15 -0
- pyfli/scripts/analytical_methods/laguerre_deconvolution.py +508 -0
- pyfli/scripts/analytical_methods/nlsf.py +1476 -0
- pyfli/scripts/analytical_methods/phasor_simple.py +869 -0
- pyfli/scripts/dataCC/IRF_process.py +98 -0
- pyfli/scripts/dataCC/__init__.py +6 -0
- pyfli/scripts/dataCC/dataCC_utils.py +73 -0
- pyfli/scripts/dataCC/norm.py +121 -0
- pyfli/scripts/dataCC/subsetdataset.py +46 -0
- pyfli/scripts/dataIO/__init__.py +6 -0
- pyfli/scripts/dataIO/dataIO_utils.py +46 -0
- pyfli/scripts/dataIO/dataoperations.py +218 -0
- pyfli/scripts/dataIO/dataops_static.py +144 -0
- pyfli/scripts/dataIO/detectorImport.py +318 -0
- pyfli/scripts/dataIO/flim_decay_cube.py +913 -0
- pyfli/scripts/dataIO/processed_DataOperation.py +235 -0
- pyfli/scripts/dataVnP/__init__.py +4 -0
- pyfli/scripts/dataVnP/colorProcess.py +13 -0
- pyfli/scripts/dataVnP/dv_multiPlotter.py +355 -0
- pyfli/scripts/dataVnP/mdataViz.py +275 -0
- pyfli/scripts/data_saving.py +78 -0
- pyfli/scripts/data_text/__init__.py +2 -0
- pyfli/scripts/data_text/msg_display.py +98 -0
- pyfli/scripts/phasor/__init__.py +51 -0
- pyfli/scripts/phasor/config.py +172 -0
- pyfli/scripts/phasor/lifetimes.py +228 -0
- pyfli/scripts/phasor/locus.py +186 -0
- pyfli/scripts/phasor/main.py +167 -0
- pyfli/scripts/phasor/phasors.py +377 -0
- pyfli/scripts/phasor/plot.py +289 -0
- pyfli/scripts/phasor/test_phasor_flim.py +320 -0
- pyfli/scripts/roiMaker/__init__.py +4 -0
- pyfli/scripts/roiMaker/roi_maker.py +210 -0
- pyfli/scripts/simulator/__init__.py +13 -0
- pyfli/scripts/simulator/batch_sim.py +81 -0
- pyfli/scripts/simulator/calibration_engine.py +179 -0
- pyfli/scripts/simulator/distributions.py +44 -0
- pyfli/scripts/simulator/main_factory.py +146 -0
- pyfli/scripts/simulator/noise_models.py +41 -0
- pyfli/scripts/simulator/sim_helper.py +30 -0
- pyfli/scripts/simulator/sim_image_generator.py +114 -0
- pyfli/scripts/simulator/sim_stat_test.py +122 -0
- pyfli/scripts/simulator/simulator_engine.py +91 -0
- pyfli/scripts/simulatorPhysics.py +581 -0
- pyfli/scripts/singleshot/singleshot.py +92 -0
- pyfli/scripts/solver/__init__.py +11 -0
- pyfli/scripts/solver/base_fitter.py +159 -0
- pyfli/scripts/solver/base_static.py +159 -0
- pyfli/scripts/solver/binned_fliFitter.py +114 -0
- pyfli/scripts/solver/comparison.py +121 -0
- pyfli/scripts/solver/flicpuFitter.py +228 -0
- pyfli/scripts/solver/fligpuFitter.py +265 -0
- pyfli/scripts/solver/globalFitter.py +217 -0
- pyfli/scripts/solver/mleFitter.py +143 -0
- pyfli/scripts/solver/solver_utils.py +34 -0
- pyfli/scripts/ss_helpers.py +42 -0
- pyfli/scripts/stat_tests.py +273 -0
- pyfli/scripts/utils_common.py +402 -0
- pyfli/spAnalysis/__init__.py +12 -0
- pyfli/spAnalysis/base_reconstructor.py +109 -0
- pyfli/spAnalysis/basis.py +41 -0
- pyfli/spAnalysis/main.py +43 -0
- pyfli/spAnalysis/simulator/__init__.py +3 -0
- pyfli/spAnalysis/simulator/measurement_sim.py +74 -0
- pyfli/spAnalysis/simulator/pattern_gen.py +75 -0
- pyfli/spAnalysis/simulator/reconstructor.py +104 -0
- pyfli/spAnalysis/solvers.py +73 -0
- pyfli/spAnalysis/spad_solvers.py +77 -0
- pyfli_lib-0.1.0.dist-info/METADATA +118 -0
- pyfli_lib-0.1.0.dist-info/RECORD +76 -0
- pyfli_lib-0.1.0.dist-info/WHEEL +5 -0
- pyfli_lib-0.1.0.dist-info/licenses/LICENSE +43 -0
- pyfli_lib-0.1.0.dist-info/top_level.txt +1 -0
pyfli/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#### inside "pyfli.__init__.py"
|
|
2
|
+
__version__ = "0.1.17"
|
|
3
|
+
|
|
4
|
+
# Pulling everything from the scripts gatekeeper
|
|
5
|
+
from .scripts import (DataOperations, IRFAligner, DataViewer,
|
|
6
|
+
AlliGprocessedImport, BHprocessedImport, PyFliprocessedImport,
|
|
7
|
+
HardSimulator, HardestSimulator, DatasetPlotter,
|
|
8
|
+
PhasorAnalyzer, FLIFitter, PoissonLikelihoodFitter, FLIAnalysisSuite,
|
|
9
|
+
Plotter, DLModelComparator, DataPreprocessing,
|
|
10
|
+
BaseFLIFitter, Fli_CPUProcessor, Fli_GPUProcessor, MLEFLIFitter,
|
|
11
|
+
GlobalFLIFitter, ROIMaker, AnalyticalHelpers, DataIO_utils, Colorprocess,
|
|
12
|
+
Macro_sim, TCSPC_sim, FLIImageGenerator, FLICalibrator, FLIValidator,Normalization,
|
|
13
|
+
recovery_plot, random_true_pixel, data_masking, save_plot,
|
|
14
|
+
Msg_display, FittingComparator, Detector,
|
|
15
|
+
BinnedFliFitter, FliBinner, ROIoperations, Batch_sim, DataSaver,
|
|
16
|
+
load_flim_data, collapse_to_xyt, plot_xyt,
|
|
17
|
+
LaguerreFLI)
|
|
18
|
+
|
|
19
|
+
from .spAnalysis import (BasisPatterns, MeasurementSimulator, Reconstructor)
|
|
20
|
+
|
|
21
|
+
__all__ = ['DataOperations', 'IRFAligner', 'DataViewer',
|
|
22
|
+
'AlliGprocessedImport', 'BHprocessedImport', 'PyFliprocessedImport',
|
|
23
|
+
'HardSimulator', 'HardestSimulator', 'DatasetPlotter',
|
|
24
|
+
'PhasorAnalyzer', 'FLIFitter', 'PoissonLikelihoodFitter', 'FLIAnalysisSuite',
|
|
25
|
+
'Plotter', 'DLModelComparator', 'DataPreprocessing',
|
|
26
|
+
'BaseFLIFitter', 'Fli_CPUProcessor', 'Fli_GPUProcessor',
|
|
27
|
+
'MLEFLIFitter', 'GlobalFLIFitter', 'ROIMaker',
|
|
28
|
+
'AnalyticalHelpers', 'DataIO_utils',
|
|
29
|
+
'Colorprocess', 'Macro_sim', 'TCSPC_sim', 'FLIImageGenerator',
|
|
30
|
+
'recovery_plot', 'random_true_pixel', 'save_plot',
|
|
31
|
+
'FLICalibrator', 'FLIValidator', 'Normalization',
|
|
32
|
+
'Msg_display', 'FittingComparator',
|
|
33
|
+
'data_masking', 'Detector', 'BinnedFliFitter', 'FliBinner',
|
|
34
|
+
'ROIoperations', 'Batch_sim', 'DataSaver', 'LaguerreFLI',
|
|
35
|
+
# this is for SPAnalysis
|
|
36
|
+
'BasisPatterns', 'MeasurementSimulator', 'Reconstructor',
|
|
37
|
+
'load_flim_data', 'collapse_to_xyt', 'plot_xyt'
|
|
38
|
+
]
|
|
39
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#### inside "scripts.__init__.py"
|
|
2
|
+
from .dataIO import (DataOperations, AlliGprocessedImport,
|
|
3
|
+
BHprocessedImport, PyFliprocessedImport, DatasetPlotter, DataIO_utils,
|
|
4
|
+
Detector, load_flim_data, collapse_to_xyt, plot_xyt )
|
|
5
|
+
from .analytical_methods import (PhasorAnalyzer, FLIFitter, PoissonLikelihoodFitter,
|
|
6
|
+
FLIAnalysisSuite, AnalyticalHelpers,
|
|
7
|
+
LaguerreFLI)
|
|
8
|
+
from .dataCC import IRFAligner, DataPreprocessing, Normalization, ROIoperations
|
|
9
|
+
from .dataVnP import DataViewer, Plotter, DLModelComparator, Colorprocess
|
|
10
|
+
from .roiMaker import ROIMaker
|
|
11
|
+
from .solver import (BaseFLIFitter, Fli_CPUProcessor, Fli_GPUProcessor,
|
|
12
|
+
MLEFLIFitter, GlobalFLIFitter, FittingComparator,
|
|
13
|
+
BinnedFliFitter, FliBinner)
|
|
14
|
+
from .simulator import (Macro_sim, TCSPC_sim, FLIImageGenerator, FLICalibrator, FLIValidator, Batch_sim)
|
|
15
|
+
from .data_text import Msg_display
|
|
16
|
+
|
|
17
|
+
from .simulatorPhysics import HardSimulator, HardestSimulator
|
|
18
|
+
from .utils_common import recovery_plot, random_true_pixel, data_masking, save_plot
|
|
19
|
+
from .data_saving import DataSaver
|
|
20
|
+
|
|
21
|
+
# This allows: from pyfli.scripts import DataViewer
|
|
22
|
+
__all__ = ["DataOperations", "IRFAligner", "DataViewer", "AlliGprocessedImport",
|
|
23
|
+
"BHprocessedImport", "PyFliprocessedImport", "DatasetPlotter", "HardSimulator",
|
|
24
|
+
"HardestSimulator", "FLIFitter", "PoissonLikelihoodFitter", "FLIAnalysisSuite",
|
|
25
|
+
"PhasorAnalyzer", "Plotter", "DLModelComparator", "DataPreprocessing",
|
|
26
|
+
"BaseFLIFitter", "Fli_CPUProcessor", "Fli_GPUProcessor", "MLEFLIFitter", "GlobalFLIFitter",
|
|
27
|
+
"ROIMaker", "AnalyticalHelpers", "DataIO_utils", "Colorprocess",
|
|
28
|
+
"Macro_sim", "TCSPC_sim", "FLIImageGenerator", "recovery_plot", "random_true_pixel", "save_plot",
|
|
29
|
+
"FLICalibrator", "FLIValidator", "Normalization", "Msg_display", "FittingComparator",
|
|
30
|
+
"data_masking", "Detector", "BinnedFliFitter", "FliBinner", "ROIoperations",
|
|
31
|
+
"Batch_sim", "DataSaver", "load_flim_data", "collapse_to_xyt", "plot_xyt",
|
|
32
|
+
"LaguerreFLI"
|
|
33
|
+
]
|
|
34
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
class AnalyticalHelpers:
|
|
4
|
+
def __init__(self, laser_period = 12.5, gate_delay=None, num_gate = None):
|
|
5
|
+
self.laser_period = laser_period
|
|
6
|
+
self.gate_delay = gate_delay
|
|
7
|
+
self.num_gate = num_gate
|
|
8
|
+
|
|
9
|
+
def freq_computation(self):
|
|
10
|
+
freq = 1000.0/self.laser_period # laser_period in ns; freq in Hz
|
|
11
|
+
if self.gate_delay is None or self.num_gate is None:
|
|
12
|
+
effective_freq = freq
|
|
13
|
+
else:
|
|
14
|
+
effective_freq = 1000.0/(self.num_gate*self.gate_delay) # frequency is computed in the MHz if the gate delays are in ns
|
|
15
|
+
return [freq, effective_freq]
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Optional, Union
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy.optimize import least_squares, minimize_scalar, nnls
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LaguerreFLI:
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
n_components: int = 2,
|
|
11
|
+
n_laguerre: Optional[int] = None,
|
|
12
|
+
alpha: float = 0.85,
|
|
13
|
+
dt: float = 1.0,
|
|
14
|
+
auto_alpha: bool = False,
|
|
15
|
+
nonneg: bool = True,
|
|
16
|
+
taus_init: Optional[np.ndarray] = None,):
|
|
17
|
+
|
|
18
|
+
if n_components < 1:
|
|
19
|
+
raise ValueError("n_components must be >= 1.")
|
|
20
|
+
if not (0.0 < alpha < 1.0):
|
|
21
|
+
raise ValueError("alpha must lie strictly in (0, 1).")
|
|
22
|
+
if dt <= 0:
|
|
23
|
+
raise ValueError("dt must be positive.")
|
|
24
|
+
|
|
25
|
+
self.n_components = int(n_components)
|
|
26
|
+
self.n_laguerre = (
|
|
27
|
+
int(n_laguerre) if n_laguerre is not None else max(4, 2 * n_components)
|
|
28
|
+
)
|
|
29
|
+
if self.n_laguerre < self.n_components:
|
|
30
|
+
raise ValueError("n_laguerre must be >= n_components.")
|
|
31
|
+
|
|
32
|
+
self.alpha = float(alpha)
|
|
33
|
+
self.dt = float(dt)
|
|
34
|
+
self.auto_alpha = bool(auto_alpha)
|
|
35
|
+
self.nonneg = bool(nonneg)
|
|
36
|
+
self.taus_init = (
|
|
37
|
+
np.asarray(taus_init, dtype=float) if taus_init is not None else None
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Results
|
|
41
|
+
self.basis_: Optional[np.ndarray] = None
|
|
42
|
+
self.V_: Optional[np.ndarray] = None
|
|
43
|
+
self.coeffs_: Optional[np.ndarray] = None
|
|
44
|
+
self.taus_: Optional[np.ndarray] = None
|
|
45
|
+
self.amplitudes_: Optional[np.ndarray] = None
|
|
46
|
+
self.fractions_: Optional[np.ndarray] = None
|
|
47
|
+
self.tau_mean_: Optional[np.ndarray] = None
|
|
48
|
+
self.reconstructed_: Optional[np.ndarray] = None
|
|
49
|
+
self.residuals_: Optional[np.ndarray] = None
|
|
50
|
+
self.fit_curve_: Optional[np.ndarray] = None # (X, Y, T) measurement-space fit
|
|
51
|
+
self.residual_curve_: Optional[np.ndarray] = None # (X, Y, T) measurement-space residuals
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Building the discrete Laguerre basis
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _discrete_laguerre_basis(T: int, alpha: float, L: int) -> np.ndarray:
|
|
57
|
+
"""
|
|
58
|
+
Generate the (L, T) discrete Laguerre function matrix via the
|
|
59
|
+
recursive definition from the LET literature.
|
|
60
|
+
"""
|
|
61
|
+
b = np.zeros((L, T), dtype=np.float64)
|
|
62
|
+
n = np.arange(T)
|
|
63
|
+
b[0] = np.sqrt(1.0 - alpha) * alpha ** (n / 2.0)
|
|
64
|
+
|
|
65
|
+
sa = np.sqrt(alpha)
|
|
66
|
+
s1ma = np.sqrt(1.0 - alpha)
|
|
67
|
+
for j in range(1, L):
|
|
68
|
+
for k in range(T):
|
|
69
|
+
bj_km1 = b[j, k - 1] if k > 0 else 0.0
|
|
70
|
+
bjm1_k = b[j - 1, k]
|
|
71
|
+
bjm1_km1 = b[j - 1, k - 1] if k > 0 else 0.0
|
|
72
|
+
b[j, k] = sa * bj_km1 + s1ma * bjm1_k - sa * bjm1_km1
|
|
73
|
+
return b
|
|
74
|
+
|
|
75
|
+
# Pre-computing V[:, j] = IRF(n) * b_j(n)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _convolve_with_irf(basis: np.ndarray, irf: np.ndarray) -> np.ndarray:
|
|
79
|
+
"""Compute the (T, L) IRF-convolved-basis matrix, truncated causally."""
|
|
80
|
+
L, T = basis.shape
|
|
81
|
+
irf = np.asarray(irf, dtype=np.float64).ravel()
|
|
82
|
+
s = irf.sum()
|
|
83
|
+
if s > 0:
|
|
84
|
+
irf = irf / s # area-normalize
|
|
85
|
+
V = np.empty((T, L), dtype=np.float64)
|
|
86
|
+
for j in range(L):
|
|
87
|
+
V[:, j] = np.convolve(irf, basis[j], mode="full")[:T]
|
|
88
|
+
return V
|
|
89
|
+
|
|
90
|
+
# Solving V c = y for every pixel
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _nnls_safe(V: np.ndarray, y: np.ndarray, maxiter: int) -> np.ndarray:
|
|
93
|
+
"""NNLS with a graceful OLS fallback on non-convergence."""
|
|
94
|
+
try:
|
|
95
|
+
c, _ = nnls(V, y, maxiter=maxiter)
|
|
96
|
+
return c
|
|
97
|
+
except RuntimeError:
|
|
98
|
+
# Fallback: clip OLS solution to non-negative orthant.
|
|
99
|
+
c, *_ = np.linalg.lstsq(V, y, rcond=None)
|
|
100
|
+
return np.clip(c, 0.0, None)
|
|
101
|
+
|
|
102
|
+
def _solve_coefficients(self, V: np.ndarray, Y2d: np.ndarray) -> np.ndarray:
|
|
103
|
+
"""
|
|
104
|
+
Solve for Laguerre coefficients c for a stack of pixel decays.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
V : (T, L) IRF-convolved basis matrix.
|
|
109
|
+
Y2d : (T, P) decay traces for P = X*Y pixels.
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
C : (L, P) Laguerre coefficients.
|
|
114
|
+
"""
|
|
115
|
+
if self.nonneg:
|
|
116
|
+
L = V.shape[1]
|
|
117
|
+
P = Y2d.shape[1]
|
|
118
|
+
maxiter = 50 * L # generous cap for small L
|
|
119
|
+
C = np.zeros((L, P), dtype=np.float64)
|
|
120
|
+
for p in range(P):
|
|
121
|
+
C[:, p] = self._nnls_safe(V, Y2d[:, p], maxiter)
|
|
122
|
+
return C
|
|
123
|
+
# Plain OLS, fully vectorized across pixels.
|
|
124
|
+
VtV = V.T @ V
|
|
125
|
+
VtY = V.T @ Y2d
|
|
126
|
+
return np.linalg.solve(VtV, VtY)
|
|
127
|
+
|
|
128
|
+
# minimizing residual for optimal alpha on the average decay
|
|
129
|
+
def _optimize_alpha(
|
|
130
|
+
self, avg_decay: np.ndarray, avg_irf: np.ndarray, T: int
|
|
131
|
+
) -> float:
|
|
132
|
+
def obj(a):
|
|
133
|
+
if not (1e-3 < a < 0.999):
|
|
134
|
+
return 1e30
|
|
135
|
+
B = self._discrete_laguerre_basis(T, float(a), self.n_laguerre)
|
|
136
|
+
V = self._convolve_with_irf(B, avg_irf)
|
|
137
|
+
if self.nonneg:
|
|
138
|
+
c = self._nnls_safe(V, avg_decay, 50 * self.n_laguerre)
|
|
139
|
+
else:
|
|
140
|
+
c, *_ = np.linalg.lstsq(V, avg_decay, rcond=None)
|
|
141
|
+
return float(((V @ c - avg_decay) ** 2).sum())
|
|
142
|
+
|
|
143
|
+
res = minimize_scalar(
|
|
144
|
+
obj, bounds=(0.05, 0.98), method="bounded", options={"xatol": 1e-3}
|
|
145
|
+
)
|
|
146
|
+
return float(res.x)
|
|
147
|
+
|
|
148
|
+
# Global N-exponential fit on averaged deconvolved decay
|
|
149
|
+
|
|
150
|
+
def _estimate_global_taus(self, h_avg: np.ndarray) -> np.ndarray:
|
|
151
|
+
"""
|
|
152
|
+
Non-linear least squares fit of N exponentials to the spatially
|
|
153
|
+
averaged, IRF-free decay. Returns N lifetimes (ascending).
|
|
154
|
+
"""
|
|
155
|
+
T = h_avg.shape[0]
|
|
156
|
+
n = np.arange(T)
|
|
157
|
+
N = self.n_components
|
|
158
|
+
|
|
159
|
+
if self.taus_init is not None and self.taus_init.size == N:
|
|
160
|
+
tau0 = self.taus_init.astype(float).copy()
|
|
161
|
+
else:
|
|
162
|
+
span = T * self.dt
|
|
163
|
+
# Spread initial guesses geometrically across plausible range.
|
|
164
|
+
tau0 = np.geomspace(max(0.05 * span, self.dt),
|
|
165
|
+
max(0.5 * span, 2 * self.dt), N)
|
|
166
|
+
|
|
167
|
+
def residual(params):
|
|
168
|
+
taus = np.maximum(np.abs(params), 1e-6)
|
|
169
|
+
E = np.exp(-n[:, None] * self.dt / taus[None, :])
|
|
170
|
+
a = self._nnls_safe(E, h_avg, 200 * N) # non-negative amplitudes
|
|
171
|
+
return E @ a - h_avg
|
|
172
|
+
|
|
173
|
+
res = least_squares(residual, tau0, method="lm", max_nfev=2000)
|
|
174
|
+
taus = np.sort(np.abs(res.x)) # ascending: fast -> slow
|
|
175
|
+
return taus
|
|
176
|
+
|
|
177
|
+
# Per-pixel exponential fit on the IRF-free decay stack
|
|
178
|
+
|
|
179
|
+
def _fit_pixel_exponentials(
|
|
180
|
+
self, h_stack: np.ndarray, tau_init: np.ndarray
|
|
181
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
182
|
+
"""
|
|
183
|
+
Fit N exponentials to every pixel's IRF-free decay independently.
|
|
184
|
+
|
|
185
|
+
Uses global tau_init as a warm start so per-pixel fits converge
|
|
186
|
+
quickly without starting from scratch.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
taus_map : (X, Y, N) per-pixel lifetimes, ascending within each pixel
|
|
191
|
+
amps_map : (X, Y, N) per-pixel amplitudes
|
|
192
|
+
"""
|
|
193
|
+
X, Y, T = h_stack.shape
|
|
194
|
+
N = self.n_components
|
|
195
|
+
n = np.arange(T)
|
|
196
|
+
|
|
197
|
+
taus_map = np.zeros((X, Y, N), dtype=np.float64)
|
|
198
|
+
amps_map = np.zeros((X, Y, N), dtype=np.float64)
|
|
199
|
+
|
|
200
|
+
for x in range(X):
|
|
201
|
+
for y in range(Y):
|
|
202
|
+
h = h_stack[x, y, :]
|
|
203
|
+
if h.sum() < 1e-10:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
def residual(params, h=h):
|
|
207
|
+
taus = np.maximum(np.abs(params), 1e-6)
|
|
208
|
+
E = np.exp(-n[:, None] * self.dt / taus[None, :])
|
|
209
|
+
a = self._nnls_safe(E, h, 200 * N)
|
|
210
|
+
return E @ a - h
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
res = least_squares(
|
|
214
|
+
residual, tau_init.copy(), method="lm", max_nfev=500
|
|
215
|
+
)
|
|
216
|
+
taus_px = np.sort(np.abs(res.x))
|
|
217
|
+
except Exception:
|
|
218
|
+
taus_px = np.sort(np.abs(tau_init))
|
|
219
|
+
|
|
220
|
+
E_px = np.exp(-n[:, None] * self.dt / taus_px[None, :])
|
|
221
|
+
a_px = self._nnls_safe(E_px, h, 200 * N)
|
|
222
|
+
|
|
223
|
+
taus_map[x, y, :] = taus_px
|
|
224
|
+
amps_map[x, y, :] = a_px
|
|
225
|
+
|
|
226
|
+
return taus_map, amps_map
|
|
227
|
+
|
|
228
|
+
# Public API
|
|
229
|
+
|
|
230
|
+
def fit(self,
|
|
231
|
+
decay: np.ndarray,
|
|
232
|
+
irf: np.ndarray,) -> "LaguerreFLI":
|
|
233
|
+
|
|
234
|
+
"""
|
|
235
|
+
Fit the LET model to a FLI image cube.
|
|
236
|
+
|
|
237
|
+
Parameters
|
|
238
|
+
----------
|
|
239
|
+
decay : ndarray, shape (X, Y, T) or (T,)
|
|
240
|
+
Measured fluorescence decay per pixel. A 1-D trace is treated
|
|
241
|
+
as a single pixel.
|
|
242
|
+
irf : ndarray, shape (T,) or (X, Y, T)
|
|
243
|
+
Instrument response function. Pass a 1-D vector to use one
|
|
244
|
+
global IRF for the whole image (much faster). Pass a 3-D array
|
|
245
|
+
to use per-pixel IRFs (e.g. for spatially varying systems).
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
self
|
|
250
|
+
"""
|
|
251
|
+
decay = np.asarray(decay, dtype=np.float64)
|
|
252
|
+
irf = np.asarray(irf, dtype=np.float64)
|
|
253
|
+
|
|
254
|
+
# ---- shape bookkeeping --------------------------------------- #
|
|
255
|
+
if decay.ndim == 1:
|
|
256
|
+
decay = decay[None, None, :]
|
|
257
|
+
if decay.ndim != 3:
|
|
258
|
+
raise ValueError("decay must have shape (X, Y, T) or (T,).")
|
|
259
|
+
X, Y, T = decay.shape
|
|
260
|
+
|
|
261
|
+
per_pixel_irf = (irf.ndim == 3)
|
|
262
|
+
if per_pixel_irf:
|
|
263
|
+
if irf.shape != decay.shape:
|
|
264
|
+
raise ValueError("per-pixel IRF must match decay shape (X, Y, T).")
|
|
265
|
+
elif irf.ndim == 1:
|
|
266
|
+
if irf.shape[0] != T:
|
|
267
|
+
raise ValueError("IRF length must match decay's time axis.")
|
|
268
|
+
else:
|
|
269
|
+
raise ValueError("irf must have shape (T,) or (X, Y, T).")
|
|
270
|
+
|
|
271
|
+
avg_decay = decay.reshape(-1, T).mean(axis=0)
|
|
272
|
+
avg_irf = irf.reshape(-1, T).mean(axis=0) if per_pixel_irf else irf
|
|
273
|
+
|
|
274
|
+
# ---- (optional) optimize alpha ------------------------------- #
|
|
275
|
+
if self.auto_alpha:
|
|
276
|
+
self.alpha = self._optimize_alpha(avg_decay, avg_irf, T)
|
|
277
|
+
|
|
278
|
+
# ---- Step 2: build basis ------------------------------------- #
|
|
279
|
+
self.basis_ = self._discrete_laguerre_basis(T, self.alpha, self.n_laguerre)
|
|
280
|
+
|
|
281
|
+
# ---- Step 3: solve for coefficients per pixel ---------------- #
|
|
282
|
+
Y2d = decay.reshape(-1, T).T # (T, P)
|
|
283
|
+
if not per_pixel_irf:
|
|
284
|
+
self.V_ = self._convolve_with_irf(self.basis_, avg_irf)
|
|
285
|
+
C = self._solve_coefficients(self.V_, Y2d) # (L, P)
|
|
286
|
+
else:
|
|
287
|
+
P = Y2d.shape[1]
|
|
288
|
+
C = np.zeros((self.n_laguerre, P), dtype=np.float64)
|
|
289
|
+
irf_2d = irf.reshape(-1, T)
|
|
290
|
+
maxiter = 50 * self.n_laguerre
|
|
291
|
+
for p in range(P):
|
|
292
|
+
Vp = self._convolve_with_irf(self.basis_, irf_2d[p])
|
|
293
|
+
if self.nonneg:
|
|
294
|
+
C[:, p] = self._nnls_safe(Vp, Y2d[:, p], maxiter)
|
|
295
|
+
else:
|
|
296
|
+
C[:, p], *_ = np.linalg.lstsq(Vp, Y2d[:, p], rcond=None)
|
|
297
|
+
|
|
298
|
+
self.coeffs_ = C.T.reshape(X, Y, self.n_laguerre)
|
|
299
|
+
|
|
300
|
+
# ---- Reconstruct IRF-free decay h(n) = B c ------------------- #
|
|
301
|
+
h_stack = (self.basis_.T @ C).T.reshape(X, Y, T)
|
|
302
|
+
self.reconstructed_ = h_stack
|
|
303
|
+
|
|
304
|
+
# Residuals in measurement space (only when V_ is a single matrix)
|
|
305
|
+
if self.V_ is not None:
|
|
306
|
+
model_y = (self.V_ @ C).T.reshape(X, Y, T)
|
|
307
|
+
self.fit_curve_ = model_y
|
|
308
|
+
self.residual_curve_ = decay - model_y
|
|
309
|
+
self.residuals_ = (self.residual_curve_ ** 2).sum(axis=-1)
|
|
310
|
+
|
|
311
|
+
# ---- Step 4: per-pixel exponential fit on IRF-free decays ------- #
|
|
312
|
+
h_avg = h_stack.reshape(-1, T).mean(axis=0)
|
|
313
|
+
taus_init = self._estimate_global_taus(h_avg) # warm-start
|
|
314
|
+
|
|
315
|
+
# taus_ and amplitudes_ are now both pixel-wise maps
|
|
316
|
+
self.taus_, A = self._fit_pixel_exponentials(h_stack, taus_init)
|
|
317
|
+
self.amplitudes_ = A # (X, Y, N)
|
|
318
|
+
total = A.sum(axis=-1, keepdims=True)
|
|
319
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
320
|
+
self.fractions_ = np.where(total > 0, A / total, 0.0)
|
|
321
|
+
|
|
322
|
+
# ---- Method B: intensity-weighted mean lifetime -------------- #
|
|
323
|
+
n_idx = np.arange(T)
|
|
324
|
+
num = (h_stack * (n_idx * self.dt)).sum(axis=-1)
|
|
325
|
+
den = h_stack.sum(axis=-1)
|
|
326
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
327
|
+
self.tau_mean_ = np.where(den > 0, num / den, 0.0)
|
|
328
|
+
|
|
329
|
+
return self
|
|
330
|
+
|
|
331
|
+
# ------------------------------------------------------------------ #
|
|
332
|
+
# Convenience accessors
|
|
333
|
+
# ------------------------------------------------------------------ #
|
|
334
|
+
def get_parameters(self, data_name: str = "LaguerreFLI_Dataset") -> dict:
|
|
335
|
+
"""
|
|
336
|
+
Return all fitted quantities in the standardized package dictionary format,
|
|
337
|
+
compatible with Fli_CPUProcessor and Fli_GPUProcessor outputs.
|
|
338
|
+
|
|
339
|
+
Structure
|
|
340
|
+
---------
|
|
341
|
+
{
|
|
342
|
+
'name': str,
|
|
343
|
+
'method': str,
|
|
344
|
+
'results': {
|
|
345
|
+
'maps': {
|
|
346
|
+
'tau1_map' .. 'tauN_map' : (X, Y) per-pixel lifetimes,
|
|
347
|
+
'alpha1_map' .. 'alphaN_map': (X, Y) per-pixel fractional intensities,
|
|
348
|
+
'Area_map' : (X, Y) total signal amplitude,
|
|
349
|
+
'tau_mean_map' : (X, Y) intensity-weighted mean lifetime,
|
|
350
|
+
'chi2_or_deviance_map' : (X, Y) sum-of-squared residuals,
|
|
351
|
+
'pixel_health_map' : (X, Y) 1 = valid fit, 0 = empty/failed,
|
|
352
|
+
},
|
|
353
|
+
'error_maps': (X, Y, 2*N) zeros (Laguerre gives no formal uncertainties),
|
|
354
|
+
'TR_maps': {
|
|
355
|
+
'fit_map' : (X, Y, T) measurement-space fitted curve,
|
|
356
|
+
'residual_map': (X, Y, T) measurement-space residuals,
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
"""
|
|
361
|
+
if self.coeffs_ is None:
|
|
362
|
+
raise RuntimeError("Call .fit(decay, irf) first.")
|
|
363
|
+
|
|
364
|
+
N = self.n_components
|
|
365
|
+
X, Y = self.tau_mean_.shape
|
|
366
|
+
|
|
367
|
+
tau_maps = {f'tau{i+1}_map': self.taus_[..., i].astype(np.float32) for i in range(N)}
|
|
368
|
+
alpha_maps = {f'alpha{i+1}_map': self.fractions_[..., i].astype(np.float32) for i in range(N)}
|
|
369
|
+
|
|
370
|
+
pixel_health = (self.amplitudes_.sum(axis=-1) > 0).astype(np.float32)
|
|
371
|
+
ssr = (self.residuals_ if self.residuals_ is not None
|
|
372
|
+
else np.zeros((X, Y), dtype=np.float32))
|
|
373
|
+
|
|
374
|
+
maps = {
|
|
375
|
+
**tau_maps,
|
|
376
|
+
**alpha_maps,
|
|
377
|
+
'Area_map': self.amplitudes_.sum(axis=-1).astype(np.float32),
|
|
378
|
+
'tau_mean_map': self.tau_mean_.astype(np.float32),
|
|
379
|
+
'chi2_or_deviance_map': ssr.astype(np.float32),
|
|
380
|
+
'pixel_health_map': pixel_health,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
error_maps = np.zeros((X, Y, 2 * N), dtype=np.float32)
|
|
384
|
+
|
|
385
|
+
fit_map = (self.fit_curve_ if self.fit_curve_ is not None
|
|
386
|
+
else self.reconstructed_).astype(np.float32)
|
|
387
|
+
res_map = (self.residual_curve_ if self.residual_curve_ is not None
|
|
388
|
+
else np.zeros_like(fit_map)).astype(np.float32)
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
'name': data_name,
|
|
392
|
+
'method': f'LaguerreFLI_{N}exp',
|
|
393
|
+
'results': {
|
|
394
|
+
'maps': maps,
|
|
395
|
+
'error_maps': error_maps,
|
|
396
|
+
'TR_maps': {
|
|
397
|
+
'fit_map': fit_map,
|
|
398
|
+
'residual_map': res_map,
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
def save_results(self, dataset: dict, folder: str = "results") -> None:
|
|
404
|
+
"""Save the structured dataset to HDF5 with compression (mirrors solver interface)."""
|
|
405
|
+
import h5py, os
|
|
406
|
+
if dataset is None:
|
|
407
|
+
return
|
|
408
|
+
if not os.path.exists(folder):
|
|
409
|
+
os.makedirs(folder)
|
|
410
|
+
h5_path = os.path.join(folder, f"{dataset['name']}_results.h5")
|
|
411
|
+
with h5py.File(h5_path, "w") as f:
|
|
412
|
+
f.attrs['method'] = dataset['method']
|
|
413
|
+
res_grp = f.create_group("results")
|
|
414
|
+
|
|
415
|
+
maps_grp = res_grp.create_group("maps")
|
|
416
|
+
for k, v in dataset['results']['maps'].items():
|
|
417
|
+
maps_grp.create_dataset(k, data=v, compression="gzip", compression_opts=4)
|
|
418
|
+
|
|
419
|
+
err_grp = res_grp.create_group("error_maps")
|
|
420
|
+
err_grp.create_dataset("errors", data=dataset['results']['error_maps'],
|
|
421
|
+
compression="gzip", compression_opts=4)
|
|
422
|
+
|
|
423
|
+
tr_grp = res_grp.create_group("TR_maps")
|
|
424
|
+
for k, v in dataset['results']['TR_maps'].items():
|
|
425
|
+
tr_grp.create_dataset(k, data=v, compression="gzip", compression_opts=4)
|
|
426
|
+
|
|
427
|
+
print(f"Analysis complete. Results saved to: {h5_path}")
|
|
428
|
+
|
|
429
|
+
def load_map(self, h5_path: str, map_name: str = "tau1_map") -> Optional[np.ndarray]:
|
|
430
|
+
"""Reload a specific parameter map from a saved HDF5 file."""
|
|
431
|
+
import h5py
|
|
432
|
+
with h5py.File(h5_path, "r") as f:
|
|
433
|
+
key = f"results/maps/{map_name}"
|
|
434
|
+
if key in f:
|
|
435
|
+
return f[key][()]
|
|
436
|
+
print(f"Map '{map_name}' not found in {h5_path}")
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
def predict(self) -> np.ndarray:
|
|
440
|
+
"""Return the IRF-free reconstructed decay h(n) per pixel."""
|
|
441
|
+
if self.reconstructed_ is None:
|
|
442
|
+
raise RuntimeError("Call .fit(decay, irf) first.")
|
|
443
|
+
return self.reconstructed_
|
|
444
|
+
|
|
445
|
+
def __repr__(self) -> str:
|
|
446
|
+
return (
|
|
447
|
+
f"LaguerreFLI(n_components={self.n_components}, "
|
|
448
|
+
f"n_laguerre={self.n_laguerre}, alpha={self.alpha:.3f}, "
|
|
449
|
+
f"dt={self.dt}, nonneg={self.nonneg})"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---------------------------------------------------------------------- #
|
|
454
|
+
# Minimal self-test with synthetic data
|
|
455
|
+
# ---------------------------------------------------------------------- #
|
|
456
|
+
if __name__ == "__main__":
|
|
457
|
+
rng = np.random.default_rng(0)
|
|
458
|
+
|
|
459
|
+
# Synthetic FLI cube: 16 x 16 pixels, 256 time bins, dt = 0.05 ns
|
|
460
|
+
X, Y, T = 16, 16, 256
|
|
461
|
+
dt = 0.05
|
|
462
|
+
|
|
463
|
+
# Ground-truth bi-exponential lifetimes (ns)
|
|
464
|
+
tau_true = np.array([0.5, 2.5])
|
|
465
|
+
|
|
466
|
+
# Random per-pixel amplitudes
|
|
467
|
+
a1 = rng.uniform(0.2, 0.8, size=(X, Y))
|
|
468
|
+
a2 = 1.0 - a1
|
|
469
|
+
n = np.arange(T)
|
|
470
|
+
h_true = (
|
|
471
|
+
a1[..., None] * np.exp(-n * dt / tau_true[0])
|
|
472
|
+
+ a2[..., None] * np.exp(-n * dt / tau_true[1])
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Gaussian IRF, FWHM ~ 0.2 ns, centered at bin 20
|
|
476
|
+
t = np.arange(T) * dt
|
|
477
|
+
irf = np.exp(-0.5 * ((t - 1.0) / 0.08) ** 2)
|
|
478
|
+
irf /= irf.sum()
|
|
479
|
+
|
|
480
|
+
# Convolve and add Poisson-like noise
|
|
481
|
+
y_clean = np.zeros_like(h_true)
|
|
482
|
+
for i in range(X):
|
|
483
|
+
for j in range(Y):
|
|
484
|
+
y_clean[i, j] = np.convolve(irf, h_true[i, j], mode="full")[:T]
|
|
485
|
+
photons = 5000
|
|
486
|
+
y_meas = rng.poisson(y_clean * photons).astype(float) / photons
|
|
487
|
+
|
|
488
|
+
# Fit a bi-exponential model
|
|
489
|
+
model = LaguerreFLI(
|
|
490
|
+
n_components=2, n_laguerre=5, alpha=0.85, dt=dt,
|
|
491
|
+
auto_alpha=True, nonneg=True,
|
|
492
|
+
)
|
|
493
|
+
model.fit(y_meas, irf)
|
|
494
|
+
|
|
495
|
+
params = model.get_parameters(data_name="SyntheticFLI")
|
|
496
|
+
maps = params['results']['maps']
|
|
497
|
+
|
|
498
|
+
print(model)
|
|
499
|
+
print(f" method = {params['method']}")
|
|
500
|
+
print(f" optimal alpha = {model.alpha:.3f}")
|
|
501
|
+
print(f" mean recovered taus (ns)= {model.taus_.mean(axis=(0, 1))}")
|
|
502
|
+
print(f" true taus (ns) = {tau_true}")
|
|
503
|
+
print(f" mean recovered a1 = {maps['alpha1_map'].mean():.3f}")
|
|
504
|
+
print(f" mean true a1 = {a1.mean():.3f}")
|
|
505
|
+
print(f" mean tau_mean (Method B) = {maps['tau_mean_map'].mean():.3f} ns")
|
|
506
|
+
print(f" output maps : {list(maps.keys())}")
|
|
507
|
+
print(f" TR_maps shapes : fit={params['results']['TR_maps']['fit_map'].shape}, "
|
|
508
|
+
f"res={params['results']['TR_maps']['residual_map'].shape}")
|