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.
Files changed (76) hide show
  1. pyfli/__init__.py +39 -0
  2. pyfli/scripts/__init__.py +34 -0
  3. pyfli/scripts/analytical_methods/__init__.py +5 -0
  4. pyfli/scripts/analytical_methods/am_utils.py +15 -0
  5. pyfli/scripts/analytical_methods/laguerre_deconvolution.py +508 -0
  6. pyfli/scripts/analytical_methods/nlsf.py +1476 -0
  7. pyfli/scripts/analytical_methods/phasor_simple.py +869 -0
  8. pyfli/scripts/dataCC/IRF_process.py +98 -0
  9. pyfli/scripts/dataCC/__init__.py +6 -0
  10. pyfli/scripts/dataCC/dataCC_utils.py +73 -0
  11. pyfli/scripts/dataCC/norm.py +121 -0
  12. pyfli/scripts/dataCC/subsetdataset.py +46 -0
  13. pyfli/scripts/dataIO/__init__.py +6 -0
  14. pyfli/scripts/dataIO/dataIO_utils.py +46 -0
  15. pyfli/scripts/dataIO/dataoperations.py +218 -0
  16. pyfli/scripts/dataIO/dataops_static.py +144 -0
  17. pyfli/scripts/dataIO/detectorImport.py +318 -0
  18. pyfli/scripts/dataIO/flim_decay_cube.py +913 -0
  19. pyfli/scripts/dataIO/processed_DataOperation.py +235 -0
  20. pyfli/scripts/dataVnP/__init__.py +4 -0
  21. pyfli/scripts/dataVnP/colorProcess.py +13 -0
  22. pyfli/scripts/dataVnP/dv_multiPlotter.py +355 -0
  23. pyfli/scripts/dataVnP/mdataViz.py +275 -0
  24. pyfli/scripts/data_saving.py +78 -0
  25. pyfli/scripts/data_text/__init__.py +2 -0
  26. pyfli/scripts/data_text/msg_display.py +98 -0
  27. pyfli/scripts/phasor/__init__.py +51 -0
  28. pyfli/scripts/phasor/config.py +172 -0
  29. pyfli/scripts/phasor/lifetimes.py +228 -0
  30. pyfli/scripts/phasor/locus.py +186 -0
  31. pyfli/scripts/phasor/main.py +167 -0
  32. pyfli/scripts/phasor/phasors.py +377 -0
  33. pyfli/scripts/phasor/plot.py +289 -0
  34. pyfli/scripts/phasor/test_phasor_flim.py +320 -0
  35. pyfli/scripts/roiMaker/__init__.py +4 -0
  36. pyfli/scripts/roiMaker/roi_maker.py +210 -0
  37. pyfli/scripts/simulator/__init__.py +13 -0
  38. pyfli/scripts/simulator/batch_sim.py +81 -0
  39. pyfli/scripts/simulator/calibration_engine.py +179 -0
  40. pyfli/scripts/simulator/distributions.py +44 -0
  41. pyfli/scripts/simulator/main_factory.py +146 -0
  42. pyfli/scripts/simulator/noise_models.py +41 -0
  43. pyfli/scripts/simulator/sim_helper.py +30 -0
  44. pyfli/scripts/simulator/sim_image_generator.py +114 -0
  45. pyfli/scripts/simulator/sim_stat_test.py +122 -0
  46. pyfli/scripts/simulator/simulator_engine.py +91 -0
  47. pyfli/scripts/simulatorPhysics.py +581 -0
  48. pyfli/scripts/singleshot/singleshot.py +92 -0
  49. pyfli/scripts/solver/__init__.py +11 -0
  50. pyfli/scripts/solver/base_fitter.py +159 -0
  51. pyfli/scripts/solver/base_static.py +159 -0
  52. pyfli/scripts/solver/binned_fliFitter.py +114 -0
  53. pyfli/scripts/solver/comparison.py +121 -0
  54. pyfli/scripts/solver/flicpuFitter.py +228 -0
  55. pyfli/scripts/solver/fligpuFitter.py +265 -0
  56. pyfli/scripts/solver/globalFitter.py +217 -0
  57. pyfli/scripts/solver/mleFitter.py +143 -0
  58. pyfli/scripts/solver/solver_utils.py +34 -0
  59. pyfli/scripts/ss_helpers.py +42 -0
  60. pyfli/scripts/stat_tests.py +273 -0
  61. pyfli/scripts/utils_common.py +402 -0
  62. pyfli/spAnalysis/__init__.py +12 -0
  63. pyfli/spAnalysis/base_reconstructor.py +109 -0
  64. pyfli/spAnalysis/basis.py +41 -0
  65. pyfli/spAnalysis/main.py +43 -0
  66. pyfli/spAnalysis/simulator/__init__.py +3 -0
  67. pyfli/spAnalysis/simulator/measurement_sim.py +74 -0
  68. pyfli/spAnalysis/simulator/pattern_gen.py +75 -0
  69. pyfli/spAnalysis/simulator/reconstructor.py +104 -0
  70. pyfli/spAnalysis/solvers.py +73 -0
  71. pyfli/spAnalysis/spad_solvers.py +77 -0
  72. pyfli_lib-0.1.0.dist-info/METADATA +118 -0
  73. pyfli_lib-0.1.0.dist-info/RECORD +76 -0
  74. pyfli_lib-0.1.0.dist-info/WHEEL +5 -0
  75. pyfli_lib-0.1.0.dist-info/licenses/LICENSE +43 -0
  76. 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,5 @@
1
+ ### inside analytical_methods
2
+ from .phasor_simple import PhasorAnalyzer
3
+ from .nlsf import FLIFitter, PoissonLikelihoodFitter, FLIAnalysisSuite
4
+ from .am_utils import AnalyticalHelpers
5
+ from .laguerre_deconvolution import LaguerreFLI
@@ -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}")