Source code for pyfli.phasor.phasorS.phasor_simple

"""
Implement a compact phasor analyzer for CPU and optional GPU workflows.

This module belongs to :mod:`pyfli.phasor.phasorS` and is part of PyFLI's compact
phasor analyzer for CPU and optional GPU FLI workflows. Public API includes classes
:class:`PhasorAnalyzer`.
"""

from typing import Any

import h5py
import matplotlib.pyplot as plt
import numpy as np
import torch

from pyfli import logging
from pyfli.reconstruction.common_reconstruct import bi_reconstruction_torch

from .phasor_simple_plots import PhasorPlotsMixin


[docs] class PhasorAnalyzer(PhasorPlotsMixin): """ Compute, calibrate, and interpret FLI phasors from decay data. The analyzer supports NumPy and optional Torch execution, multi-harmonic phasors, IRF calibration, lifetime conversion, fractional component estimates, and plotting through the phasor mixin. Parameters ---------- frequency_hz : float Excitation frequency in hertz. time_axis_ns : np.ndarray Time axis for decay samples in nanoseconds. n_harmonics : int Number of phasor harmonics to compute. device : Any | None Execution device, such as a Torch device or device string. """ def __init__( self, frequency_hz: float, time_axis_ns: np.ndarray, n_harmonics: int = 1, device: Any | None = None, ) -> None: self.frequency = float(frequency_hz) self.time_axis_ns = np.asarray(time_axis_ns) self.n_harmonics = int(n_harmonics) self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") self.omega = 2 * np.pi * self.frequency self.t_s_np = self.time_axis_ns * 1e-9 self.t_s_torch = torch.tensor( self.t_s_np, dtype=torch.float32, device=self.device ) self.eps = 1e-12 # ── phasor computation ──────────────────────────────────────────────────── def _phasor_numpy(self, decay: np.ndarray) -> tuple[Any, ...]: """ Run the phasor numpy routine. Parameters ---------- decay : np.ndarray Time-resolved decay signal or decay cube. Returns ------- tuple[Any, ...] Tuple containing NumPy-computed phasor coordinates and intensity values. """ decay = np.asarray(decay, dtype=np.float64) *spatial, T = decay.shape decay_flat = decay.reshape(-1, T) I_sum = np.clip(np.sum(decay_flat, axis=1), self.eps, None) G_all, S_all = [], [] for k in range(1, self.n_harmonics + 1): omega_k = k * self.omega cos_k = np.cos(omega_k * self.t_s_np) sin_k = np.sin(omega_k * self.t_s_np) G_all.append((np.sum(decay_flat * cos_k, axis=1) / I_sum).reshape(spatial)) S_all.append((np.sum(decay_flat * sin_k, axis=1) / I_sum).reshape(spatial)) return np.stack(G_all), np.stack(S_all) def _phasor_torch(self, decay: np.ndarray) -> tuple[Any, ...]: """ Run the phasor torch routine. Parameters ---------- decay : np.ndarray Time-resolved decay signal or decay cube. Returns ------- tuple[Any, ...] Tuple containing Torch-computed phasor coordinates and intensity values. """ decay_t = torch.tensor( np.asarray(decay), dtype=torch.float32, device=self.device ) *spatial, T = decay_t.shape decay_flat = decay_t.reshape(-1, T) I_sum = torch.clamp(torch.sum(decay_flat, dim=1), min=self.eps) G_all, S_all = [], [] for k in range(1, self.n_harmonics + 1): omega_k = k * self.omega cos_k = torch.cos(omega_k * self.t_s_torch) sin_k = torch.sin(omega_k * self.t_s_torch) G_all.append( (torch.sum(decay_flat * cos_k, dim=1) / I_sum).reshape(spatial) ) S_all.append( (torch.sum(decay_flat * sin_k, dim=1) / I_sum).reshape(spatial) ) return torch.stack(G_all), torch.stack(S_all)
[docs] def create_phasor_cpu(self, decay: np.ndarray) -> Any: """ Create phasor cpu. Parameters ---------- decay : np.ndarray Time-resolved decay signal or decay cube. Returns ------- Any Object produced by create phasor CPU. """ return self._phasor_numpy(decay)
[docs] def create_phasor_gpu(self, decay: np.ndarray) -> tuple[Any, ...]: """ Create phasor gpu. Parameters ---------- decay : np.ndarray Time-resolved decay signal or decay cube. Returns ------- tuple[Any, ...] Tuple containing GPU-computed phasor coordinates and intensity values. """ G, S = self._phasor_torch(decay) return G.cpu().numpy(), S.cpu().numpy()
# ── calibration ───────────────────────────────────────────────────────────
[docs] def calibrate( self, G: np.ndarray, S: np.ndarray, irf: np.ndarray ) -> tuple[Any, ...]: """ Run the calibrate routine. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. irf : np.ndarray Instrument response function aligned with the decay signal. Returns ------- tuple[Any, ...] Tuple containing calibrated phasor coordinates and calibration factors. """ G = np.asarray(G) S = np.asarray(S) irf = np.asarray(irf) if irf.ndim == 3: irf = irf.mean(axis=(0, 1)) denom = np.clip(np.sum(irf), self.eps, None) G_irf, S_irf = [], [] for k in range(1, self.n_harmonics + 1): omega_k = k * self.omega G_irf.append(np.sum(irf * np.cos(omega_k * self.t_s_np)) / denom) S_irf.append(np.sum(irf * np.sin(omega_k * self.t_s_np)) / denom) G_irf = np.array(G_irf) S_irf = np.array(S_irf) P = G + 1j * S P_irf = G_irf[:, None, None] + 1j * S_irf[:, None, None] P_irf_abs_sq = np.clip( G_irf[:, None, None] ** 2 + S_irf[:, None, None] ** 2, self.eps, None ) P_true = P * np.conj(P_irf) / P_irf_abs_sq return np.real(P_true), np.imag(P_true)
[docs] def calibrate_pixelwise( self, G: np.ndarray, S: np.ndarray, irf: np.ndarray ) -> tuple[Any, ...]: """ Run the calibrate pixelwise routine. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. irf : np.ndarray Instrument response function aligned with the decay signal. Returns ------- tuple[Any, ...] Tuple containing per-pixel calibrated phasor coordinates and factors. """ G = np.asarray(G, dtype=np.float32) S = np.asarray(S, dtype=np.float32) irf = np.asarray(irf, dtype=np.float32) H, W, T = irf.shape K = self.n_harmonics irf_flat = torch.tensor(irf.reshape(-1, T), device=self.device) I_sum = irf_flat.sum(dim=1, keepdim=True).clamp(min=self.eps) irf_norm = irf_flat / I_sum t_s = self.t_s_torch Gc_list, Sc_list = [], [] for k in range(1, K + 1): omega_k = k * self.omega cos_k = torch.cos( torch.tensor(omega_k, dtype=torch.float32, device=self.device) * t_s ) sin_k = torch.sin( torch.tensor(omega_k, dtype=torch.float32, device=self.device) * t_s ) G_irf_flat = (irf_norm * cos_k).sum(dim=1) S_irf_flat = (irf_norm * sin_k).sum(dim=1) G_irf = G_irf_flat.reshape(H, W) S_irf = S_irf_flat.reshape(H, W) G_meas = torch.tensor(G[k - 1], device=self.device) S_meas = torch.tensor(S[k - 1], device=self.device) denom = (G_irf**2 + S_irf**2).clamp(min=self.eps) Gc_k = (G_meas * G_irf + S_meas * S_irf) / denom Sc_k = (S_meas * G_irf - G_meas * S_irf) / denom Gc_list.append(Gc_k.cpu().numpy()) Sc_list.append(Sc_k.cpu().numpy()) return np.stack(Gc_list), np.stack(Sc_list)
[docs] def calibratre_reference( self, G: np.ndarray, S: np.ndarray, ref_data: np.ndarray, ref_lifetime_ns: float | None = None, ) -> tuple[Any, ...]: """ Run the calibratre reference routine. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. ref_data : np.ndarray Decay trace of a reference sample used for calibration. ref_lifetime_ns : float | None Known lifetime of the reference sample in nanoseconds. When None, the reference is treated as a zero-lifetime instrument response, matching :meth:`calibrate`. Returns ------- tuple[Any, ...] Tuple containing calibrated phasor coordinates and calibration factors. """ G = np.asarray(G) S = np.asarray(S) ref_data = np.asarray(ref_data) if ref_data.ndim == 3: ref_data = ref_data.mean(axis=(0, 1)) denom = np.clip(np.sum(ref_data), self.eps, None) G_ref, S_ref = [], [] for k in range(1, self.n_harmonics + 1): omega_k = k * self.omega G_ref.append(np.sum(ref_data * np.cos(omega_k * self.t_s_np)) / denom) S_ref.append(np.sum(ref_data * np.sin(omega_k * self.t_s_np)) / denom) G_ref = np.array(G_ref) S_ref = np.array(S_ref) if ref_lifetime_ns is not None: harmonic_freqs = self.frequency * np.arange(1, self.n_harmonics + 1) G_theory, S_theory = self.lifetime_to_phasor( ref_lifetime_ns, harmonic_freqs ) else: G_theory = np.ones(self.n_harmonics) S_theory = np.zeros(self.n_harmonics) P = G + 1j * S P_ref = G_ref[:, None, None] + 1j * S_ref[:, None, None] P_theory = G_theory[:, None, None] + 1j * S_theory[:, None, None] P_ref_abs_sq = np.clip( G_ref[:, None, None] ** 2 + S_ref[:, None, None] ** 2, self.eps, None ) P_true = P * np.conj(P_ref) * P_theory / P_ref_abs_sq return np.real(P_true), np.imag(P_true)
[docs] def calibratre_reference_pixelwise( self, G: np.ndarray, S: np.ndarray, ref_data: np.ndarray, ref_lifetime_ns: float | None = None, ) -> tuple[Any, ...]: """ Run the calibratre reference pixelwise routine. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. ref_data : np.ndarray Per-pixel decay cube of a reference sample used for calibration. ref_lifetime_ns : float | None Known lifetime of the reference sample in nanoseconds. When None, the reference is treated as a zero-lifetime instrument response, matching :meth:`calibrate_pixelwise`. Returns ------- tuple[Any, ...] Tuple containing per-pixel calibrated phasor coordinates and factors. """ G = np.asarray(G, dtype=np.float32) S = np.asarray(S, dtype=np.float32) ref_data = np.asarray(ref_data, dtype=np.float32) H, W, T = ref_data.shape K = self.n_harmonics ref_flat = torch.tensor(ref_data.reshape(-1, T), device=self.device) I_sum = ref_flat.sum(dim=1, keepdim=True).clamp(min=self.eps) ref_norm = ref_flat / I_sum t_s = self.t_s_torch Gc_list, Sc_list = [], [] for k in range(1, K + 1): omega_k = k * self.omega cos_k = torch.cos( torch.tensor(omega_k, dtype=torch.float32, device=self.device) * t_s ) sin_k = torch.sin( torch.tensor(omega_k, dtype=torch.float32, device=self.device) * t_s ) G_ref_flat = (ref_norm * cos_k).sum(dim=1) S_ref_flat = (ref_norm * sin_k).sum(dim=1) G_ref = G_ref_flat.reshape(H, W) S_ref = S_ref_flat.reshape(H, W) G_meas = torch.tensor(G[k - 1], device=self.device) S_meas = torch.tensor(S[k - 1], device=self.device) denom = (G_ref**2 + S_ref**2).clamp(min=self.eps) Gc_k = (G_meas * G_ref + S_meas * S_ref) / denom Sc_k = (S_meas * G_ref - G_meas * S_ref) / denom if ref_lifetime_ns is not None: G_theory, S_theory = self.lifetime_to_phasor( ref_lifetime_ns, k * self.frequency ) G_theory = float(G_theory) S_theory = float(S_theory) Gc_k, Sc_k = ( Gc_k * G_theory - Sc_k * S_theory, Gc_k * S_theory + Sc_k * G_theory, ) Gc_list.append(Gc_k.cpu().numpy()) Sc_list.append(Sc_k.cpu().numpy()) return np.stack(Gc_list), np.stack(Sc_list)
# ── lifetime conversion ───────────────────────────────────────────────────
[docs] def lifetime_to_phasor( self, tau_ns: np.ndarray, frequency_hz: float ) -> tuple[Any, ...]: """ Run the lifetime to phasor routine. Parameters ---------- tau_ns : np.ndarray Lifetime value in nanoseconds. frequency_hz : float Excitation frequency in hertz. Returns ------- tuple[Any, ...] Tuple containing phasor coordinates for the supplied lifetime values. """ tau_s = np.asarray(tau_ns) * 1e-9 omega = 2 * np.pi * frequency_hz denom = 1 + (omega * tau_s) ** 2 return 1 / denom, (omega * tau_s) / denom
[docs] def compute_lifetime(self, G: np.ndarray, S: np.ndarray) -> np.ndarray: """ Compute lifetime. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. Returns ------- np.ndarray Lifetime map derived from phasor coordinates. """ G = np.asarray(G, dtype=np.float64) S = np.asarray(S, dtype=np.float64) safe_denom = np.where(np.abs(G) > 1e-4, G * self.omega, np.inf) return np.where(np.abs(G) > 1e-4, S / safe_denom * 1e9, np.nan)
[docs] def compute_modulation_lifetime(self, G: np.ndarray, S: np.ndarray) -> Any: """ Compute modulation lifetime. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. Returns ------- Any Object produced by compute modulation lifetime. """ G = np.asarray(G, dtype=np.float64) S = np.asarray(S, dtype=np.float64) M_sq = np.clip(G**2 + S**2, self.eps, 1.0 - self.eps) return np.sqrt(1.0 / M_sq - 1.0) / self.omega * 1e9
# ── two-component analysis ────────────────────────────────────────────────
[docs] def compute_fractions( self, G: np.ndarray, S: np.ndarray, tau1_ns: np.ndarray, tau2_ns: np.ndarray, mask: np.ndarray | None = None, hexbin_color: np.ndarray | None = None, plot_graph: bool = True, ax: Any | None = None, half_circle: bool = False, ) -> tuple[Any, ...]: """ Compute fractions. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. tau1_ns : np.ndarray Short lifetime component in nanoseconds. tau2_ns : np.ndarray Long lifetime component in nanoseconds. mask : np.ndarray | None Boolean or labeled mask selecting pixels for the operation. hexbin_color : np.ndarray | None Optional values used to color phasor hexbin density. plot_graph : bool Whether the phasor graph should be drawn. ax : Any | None Matplotlib axes object on which the plot is drawn. half_circle : bool Whether to draw only the upper half of the universal phasor circle. Returns ------- tuple[Any, ...] Tuple containing component fractions estimated from phasor geometry. """ g1, s1 = self.lifetime_to_phasor(tau1_ns, self.frequency) g2, s2 = self.lifetime_to_phasor(tau2_ns, self.frequency) if plot_graph: created_fig = ax is None if created_fig: fig, ax = plt.subplots(figsize=(8, 6)) self.plot_phasor_diagram( G, S, mask=mask, colors=None, hexbin_color="jet_r", ax=ax, figsize=(8, 3), half_circle=half_circle, title="Phasor Diagram", xlim=(-0.1, 1.1), ylim=(0.0, 0.6), kdeplot=False, kde_color="white", kde_levels=5, kde_linewidths=1, kde_alpha=0.5, ) ax.plot( [g1, g2], [s1, s2], color="#2C0F02", linestyle="--", lw=2, zorder=10 ) ax.plot(g1, s1, "o", color="#E5D16E", markersize=8, label="...", zorder=11) ax.plot(g2, s2, "o", color="#363D45", markersize=8, label="...", zorder=11) ax.legend(loc="upper right") if created_fig: plt.tight_layout() line_vec_g = g1 - g2 line_vec_s = s1 - s2 line_mag_sq = line_vec_g**2 + line_vec_s**2 + self.eps A1 = np.clip( ((G - g2) * line_vec_g + (S - s2) * line_vec_s) / line_mag_sq, 0, 1 ) return A1, 1 - A1
# ── biexponential reconstruction ────────────────────────────────────────── def _convolve_batch(self, signal: np.ndarray, kernel: np.ndarray) -> Any: """ Run the convolve batch routine. Parameters ---------- signal : np.ndarray Signal batch convolved with the supplied kernel. kernel : np.ndarray Convolution kernel applied to the signal batch. Returns ------- Any Object produced by convolve batch. """ N, T = signal.shape L = 2 * T - 1 nfft = 1 << (L - 1).bit_length() S_fft = torch.fft.rfft(signal, n=nfft, dim=1) K_fft = torch.fft.rfft(kernel, n=nfft, dim=1) out = torch.fft.irfft(S_fft * K_fft, n=nfft, dim=1) return out[:, :T] def _build_model_decay( self, A1: Any, A2: Any, tau1_ns: np.ndarray, tau2_ns: np.ndarray ) -> Any: """ Build model decay. Parameters ---------- A1 : Any Amplitude or fraction of the first exponential component. A2 : Any Amplitude or fraction of the second exponential component. tau1_ns : np.ndarray Short lifetime component in nanoseconds. tau2_ns : np.ndarray Long lifetime component in nanoseconds. Returns ------- Any Object produced by build model decay. """ t_ns = torch.tensor(self.t_s_np * 1e9, dtype=torch.float32, device=self.device) a1 = torch.tensor( A1.ravel(), dtype=torch.float32, device=self.device ).unsqueeze(1) a2 = torch.tensor( A2.ravel(), dtype=torch.float32, device=self.device ).unsqueeze(1) return bi_reconstruction_torch(t_ns, tau1_ns, tau2_ns, a1, a2) def _normalize_irf(self, irf: np.ndarray) -> Any: """ Normalize irf. Parameters ---------- irf : np.ndarray Instrument response function aligned with the decay signal. Returns ------- Any Object produced by normalize IRF. """ irf_flat = np.asarray(irf, dtype=np.float32).reshape(-1, irf.shape[2]) irf_t = torch.tensor(irf_flat, dtype=torch.float32, device=self.device) norms = irf_t.sum(dim=1, keepdim=True).clamp(min=self.eps) return irf_t / norms
[docs] def analyze_biexponential_and_reconstruct( self, G: np.ndarray, S: np.ndarray, irf: np.ndarray, tau1_ns: np.ndarray | None = None, tau2_ns: np.ndarray | None = None, plot: bool = True, axes: Any | None = None, ) -> Any: """ Run the analyze biexponential and reconstruct routine. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. irf : np.ndarray Instrument response function aligned with the decay signal. tau1_ns : np.ndarray | None Short lifetime component in nanoseconds. tau2_ns : np.ndarray | None Long lifetime component in nanoseconds. plot : bool Whether diagnostic plots should be generated. axes : Any | None Matplotlib axes collection used for drawing subplots. Returns ------- Any Object produced by analyze biexponential and reconstruct. """ if tau1_ns is None or tau2_ns is None: return None A1, A2 = self.compute_fractions(G, S, tau1_ns, tau2_ns, plot_graph=False) tau_map_ns = self.compute_lifetime(G, S) if plot: created_fig = axes is None if created_fig: fig, axes = plt.subplots(1, 3, figsize=(18, 5)) else: fig = axes[0].get_figure() im1 = axes[0].imshow(A1, origin="upper", cmap="viridis") axes[0].set_title(f"A1 Map (Fraction of {tau1_ns} ns)") fig.colorbar(im1, ax=axes[0]) im2 = axes[1].imshow(A2, origin="upper", cmap="plasma") axes[1].set_title(f"A2 Map (Fraction of {tau2_ns} ns)") fig.colorbar(im2, ax=axes[1]) im3 = axes[2].imshow( np.clip(tau_map_ns, 0, 5), origin="upper", cmap="magma" ) axes[2].set_title("Phase Lifetime Map (ns)") fig.colorbar(im3, ax=axes[2]) for ax in axes: ax.axis("off") if created_fig: plt.tight_layout() H, W = A1.shape T = irf.shape[2] model_t = self._build_model_decay(A1, A2, tau1_ns, tau2_ns) irf_t = self._normalize_irf(irf) recon_t = self._convolve_batch(model_t, irf_t) reconstructed_decay = recon_t.cpu().numpy().reshape(H, W, T) return reconstructed_decay
# ── utilities ─────────────────────────────────────────────────────────────
[docs] def generate_intensity_image(self, decay: np.ndarray) -> np.ndarray: """ Generate intensity image. Parameters ---------- decay : np.ndarray Time-resolved decay signal or decay cube. Returns ------- np.ndarray Intensity image obtained by integrating the decay along the time axis. """ return np.sum(decay, axis=2)
[docs] def save_phasors_hdf5( self, Gc: Any, Sc: Any, tau_phasor: np.ndarray, save_file: np.ndarray ) -> None: """ Save phasors hdf5. Parameters ---------- Gc : Any Calibrated phasor real coordinate map. Sc : Any Calibrated phasor imaginary coordinate map. tau_phasor : np.ndarray Lifetime map estimated from phasor coordinates. save_file : np.ndarray HDF5 path where phasor results are saved. Returns ------- None No object is returned; the function save phasors hdf5. """ try: with h5py.File(save_file, "w") as hf: hf.create_dataset("Gc", data=Gc, compression="gzip", chunks=True) hf.create_dataset("Sc", data=Sc, compression="gzip", chunks=True) hf.create_dataset( "tau_phasor", data=tau_phasor, compression="gzip", chunks=True ) hf.attrs["n_harmonics"] = Gc.shape[0] hf.attrs["resolution"] = f"{Gc.shape[1]}x{Gc.shape[2]}" logging.info(f"Successfully saved data to {save_file}") except Exception as e: logging.error(f"An error occurred while saving: {e}")