2. Phasor method for Bi-exponential FLI Data processing#

A simulated, full 3-D fluorescence lifetime image dataset of a two-well plate is generated (using the same simulator as the “Whole Image Simulation” example), and the phasor method is used to process the data. Each well is assigned a different set of lifetime / FRET parameters.

This example walks through:

  • Simulating the two-well FLI data

  • The phasor computation

  • Phasor calibration with the IRF (if a known dye is used, it can also be used for calibration here — not shown in this example)

  • 3-harmonics computation

  • Phasor plot (with pixel counts)

  • Mapping phasor color to pixel color (pixel-wise phasor overlay) and pixel-wise, intensity-weighted phasor color overlay

  • Phasor lifetime computation

  • Phasor plot of different harmonics

  • Curve fitting and fraction computation (based on user-provided lifetime values)

Author - Vikas

## importing the modules required for this work
import sys

import numpy as np

sys.path.insert(0, "ex_helper")  # local helper for synthetic image generation

from sim_general_image import ROIMaskGenerator

from pyfli.analysis.utils import random_true_pixel
from pyfli.analyticalWorkflow import AnalyticalHelpers
from pyfli.data_cc import Normalization
from pyfli.data_text import MessageDisplay
from pyfli.data_vnp import ColorProcessor, DataViewer
from pyfli.io import DataOperations
from pyfli.phasor.phasorS import PhasorAnalyzer
from pyfli.simulator import FLIModelImageGenerator
# Path to the local IRF file (e.g. an SPCImage-exported .txt calibration file)
IRF_PATH = "<select file/folder path>"
loader = DataOperations(irf_path=IRF_PATH)
irf_data = loader.load_irf()

gate_delay = 12.5 / irf_data.shape[2]
num_gates = irf_data.shape[2]
freq = AnalyticalHelpers(
    laser_period=12.5, gate_delay=gate_delay, num_gate=num_gates
).freq_computation()
INFO:pyfli:Initiating IRF load from: <select file/folder path>
# select the type of decay model to simulate
MODEL_TYPE = "bi-exponential"
if MODEL_TYPE == "bi-exponential":
    mono_fraction = 0.0
elif MODEL_TYPE == "mono-exponential":
    mono_fraction = 1.0
else:
    mono_fraction = 0.4  # fraction of mono-exponential model in sampled data

# Your constant, default configuration
BASE_CONFIG = {
    # Modular Noise
    "jitter": False,  # offset artifact (due to jitter)
    "dcr_on": True,  # Dark Count Rate (thermal background)
    "poisson": False,  # Shot noise — Large detector only
    "qe_on": True,  # Quantum efficiency scaling
    "read_noise_on": False,  # Gaussian read noise — Large detector only
    # Sensor
    "sensor_type": "discrete",  # "continuous" | "discrete"
    "bit": 12,
    "dcr": 0.08,  # Mean dark counts per bin
    "laser_feq": freq[1],  # Laser repetition rate (MHz) → period = 12.5 ns
    "round_on": True,  # Round photon counts to integers — Macro_sim only
    "clip_on": True,  # Clip at bit-depth ceiling — Macro_sim: max_adc_val; TCSPC: max_bin_count
    # Fluorescence Physics (FLI / FRET)
    "tau2": (1, 1),  # τ₂ ~ TruncNormal(mu=1 ns, sigma=0.5 ns)
    "tau2_dist": "beta",  # if dist "beta" or "normal" (default)
    "tau2_beta_range": (2.5, 0.1),
    "efficiency": (1, 1),  # FRET efficiency E ~ Beta(2, 5)  → [0.1, 1.0]
    "A1_fraction": (1, 1),  # Amplitude fraction A₁ ~ Beta(2, 5) → [0.05, 0.95]
    "photo_count": (2, 5),  # Peak intensity ~ Beta(2, 5) × max_adc - large detectors
    "mono_fraction": mono_fraction,  # Fraction of pixels forced mono-exponential (0.0 = all bi-exp)
    "n_cycles": (1_500_000, 2_000_000),  # Accumulation cycles — for photon counter
}
def get_config(**kwargs):
    """Create a config by overriding defaults with specific test parameters."""
    config = BASE_CONFIG.copy()
    config.update(kwargs)
    return config
# Shared plotting / phasor configuration used by the two-well example below
colorset = "rainbow"
freq_hz = freq[1] * 1e6
jet_m_well = ColorProcessor().lowest_zero("jet")
bg_color = "black"  # either "black" or "white"
ph_cols_ = "viridis_r"

2.1. Two wells example#

A two-well plate is simulated, with each well assigned a different set of ground-truth lifetime / FRET parameters.

from sim_general_image import WellPlateShape

h_well, w_well = 128, 256
generator_well = ROIMaskGenerator((h_well, w_well), top=10, bottom=10, left=10, right=10)
TwoWells = WellPlateShape(rows=1, cols=2, gap=0.15)

custom_intensities_well = [ 0.6, 1.0]
_ = generator_well.plot_preview(
    TwoWells,
    bit_depth=10,
    intensities=custom_intensities_well,
    show=True,
)
../_images/68e513c73d05e0b29964a578c427233d5cac99f087932e56eba86899ad7e6ef6.png
ROI0_well = {}
ROI1_well = get_config(
    tau2_beta_range=(0.1, 1.8),
    efficiency = (50, 2),  
    A1_fraction = (7, 13),
)
ROI2_well = get_config(
    tau2_beta_range=(0.05, 0.8),
    efficiency = (1000, 1000),  
    A1_fraction = (15, 5),
)
img_intensity_well = generator_well.generate_intensity_image(
    TwoWells, intensities=custom_intensities_well
)
img_cluster_well = generator_well.generate_cluster_mask(TwoWells)
b_bool_mask_well = generator_well.generate_binary_mask(TwoWells)

simulated_img_well = FLIModelImageGenerator(
    irf_data=irf_data[120, 40, :],
    intensity_image=img_intensity_well,
    roi_mask=img_cluster_well,
    roi_params=[ROI0_well, ROI1_well, ROI2_well],
    method="PHOTON_COUNTER",
    verbose=True,
    bool_mask=b_bool_mask_well,
)

gt_data_well = simulated_img_well.generate_image()
INFO:pyfli:Generating PHOTON_COUNTER FLI Image [128x256x256]...
                                                                          

Check the decay, IRF, and fit at a randomly selected pixel.

_decay_well = gt_data_well["raw_data"]["decay"]
_irf_well = gt_data_well["raw_data"]["irf"]
x_well, y_well = random_true_pixel(b_bool_mask_well)
TRs_well = gt_data_well["results"]["TR_maps"]
maps_well = gt_data_well["results"]["maps"]
print(f"the non-zero pixel selected for probing is ({x_well}, {y_well})")
irf_norm_well = Normalization(_irf_well).norm_scale(_decay_well)
DataViewer().plot_fli_px(
    data_list=[_decay_well, irf_norm_well, TRs_well["fit_map"], TRs_well["residual_map"]],
    pixel=(x_well, y_well),
    mode=[0, 1, 2],
    mode2=[1],
    names=["decay", "irf", "fit"],
    cmap=jet_m_well,
)
_ = MessageDisplay().get_pixel_summary(data_maps=maps_well, px=(x_well, y_well))
the non-zero pixel selected for probing is (78, 64)
../_images/65cca6b55b1be4519407c310f0fd9d1e5658a26d68144f4637ced3f0b0abe5e0.png
INFO:pyfli:
  Pixel (78, 64)
  ─────────────────────
  A         11175.6943
  α         0.3627
  τ₁        0.0493
  τ₂        1.8539
  R²        —
  Red.χ²    —
  Raw.χ²    —
  v-shift   0.0000
  h-shift   —
  ─────────────────────

Distribution of the simulated ground-truth parameters across the image, before computing phasors.

import matplotlib.pyplot as plt
import seaborn as sns

res_well = gt_data_well["results"]["maps"]

b_bool_mask_well_bool = b_bool_mask_well.astype(bool)
valid_px_well = np.argwhere(b_bool_mask_well)
rng_well = np.random.default_rng(0)
sample_idx_well = rng_well.choice(len(valid_px_well), size=min(300, len(valid_px_well)), replace=False)
sample_px_well = valid_px_well[sample_idx_well]
decay_sample_well = _decay_well[sample_px_well[:, 0], sample_px_well[:, 1], :]
irf_sample_well = _irf_well[sample_px_well[:, 0], sample_px_well[:, 1], :]
photon_count_sample_well = res_well["photon_count_map"][sample_px_well[:, 0], sample_px_well[:, 1]]

tau1_valid_well = res_well["tau1_map"][b_bool_mask_well_bool]
tau2_valid_well = res_well["tau2_map"][b_bool_mask_well_bool]
alpha1_valid_well = res_well["alpha1_map"][b_bool_mask_well_bool]
tau_mean_valid_well = res_well["tau_mean_map"][b_bool_mask_well_bool]
efficiency_valid_well = res_well["fret_efficiency_map"][b_bool_mask_well_bool]
photon_count_valid_well = res_well["photon_count_map"][b_bool_mask_well_bool]
tau_dif_well = tau2_valid_well - tau1_valid_well

fig, axes = plt.subplots(2, 5, figsize=(15, 8))

axes[0, 0].plot(decay_sample_well.T)
axes[0, 0].set_title("decay")
axes[0, 0].set_xlabel("bins")
axes[0, 0].set_ylabel("Photon Counts")

axes[0, 1].plot((irf_sample_well * photon_count_sample_well[:, None]).T)
axes[0, 1].set_title("irf * photon_count")
axes[0, 1].set_xlabel("bins")
axes[0, 1].set_ylabel("Photon Counts")

sns.histplot(data=tau_mean_valid_well, ax=axes[0, 2], kde=True, stat="density", color="tab:orange", alpha=0.4)
axes[0, 2].set_title("mean tau")
axes[0, 2].set_xlabel("Lifetime (ns)")

sns.histplot(data=tau_dif_well, ax=axes[0, 3], kde=True, stat="density", color="tab:orange", alpha=0.4)
axes[0, 3].set_title("Δ tau")
axes[0, 3].set_xlabel("Lifetime (ns)")

sns.histplot(data=efficiency_valid_well, ax=axes[0, 4], kde=True, stat="density", color="tab:green", alpha=0.4)
axes[0, 4].set_title("FRET Efficiency")
axes[0, 4].set_xlabel("Efficiency")

sns.histplot(data=tau1_valid_well, ax=axes[1, 0], kde=True, stat="density", color="tab:green", alpha=0.4)
axes[1, 0].set_title("tau1")
axes[1, 0].set_xlabel("Lifetime (ns)")

sns.histplot(data=tau2_valid_well, ax=axes[1, 1], kde=True, stat="density", color="tab:red", alpha=0.4)
axes[1, 1].set_title("tau2")
axes[1, 1].set_xlabel("Lifetime (ns)")

sns.histplot(data=alpha1_valid_well, ax=axes[1, 2], kde=True, stat="density", color="tab:purple", alpha=0.4)
axes[1, 2].set_title("alpha1")
axes[1, 2].set_xlabel("Amplitude Fraction")
axes[1, 2].set_xlim(0.0, 1.0)

axes[1, 3].scatter(tau_dif_well, alpha1_valid_well, s=8, alpha=0.3)
axes[1, 3].set_xlabel("Δ tau")
axes[1, 3].set_ylabel("fraction")

sns.histplot(data=photon_count_valid_well, ax=axes[1, 4], kde=True, stat="density", color="tab:purple", alpha=0.4)
axes[1, 4].set_title("photon_count")
axes[1, 4].set_xlabel("photons")

plt.tight_layout()
plt.show()

2.1.1. Phasor computation and visualization#

time_axis_ns_well = np.linspace(0, gate_delay * num_gates, _irf_well.shape[2])
phasor_well = PhasorAnalyzer(
    frequency_hz=freq_hz, time_axis_ns=time_axis_ns_well, n_harmonics=3
)
G_well, S_well = phasor_well.create_phasor_gpu(_decay_well)
Gc_well, Sc_well = phasor_well.calibrate_pixelwise(G_well, S_well, _irf_well)
tau_map_ns_well = phasor_well.compute_lifetime(Gc_well[0], Sc_well[0])
im1_well = phasor_well.plot_phasor_diagram(
    Gc_well[0],
    Sc_well[0],
    mask=b_bool_mask_well,
    hexbin_color=colorset,
    figsize=(7, 3),
    half_circle=True,
    kdeplot=True,
    kde_color="red",
    kde_levels=3,
    kde_linewidths=1,
    kde_alpha=0.5,
)
../_images/bedfb3254afe806faf7cadd3dd0699fcbc9219a497ca4a5f184b475ca69bfbf4.png
im2_well = phasor_well.plot_overlay_subplots(
    _decay_well,
    Gc_well[0],
    Sc_well[0],
    mask=b_bool_mask_well,
    colormaps=["plasma", "jet", "turbo_r", ph_cols_],
    figsize=(13, 7),
    xlim=(0, 1.1),
    bg_color=bg_color,
    transpose=False,
    kdeplot=False,
)
../_images/eca6a19521dd8e8f9303219dad388515ceb63c577b8989344272f90c688fd874.png

2.2. Additional steps to plot different harmonics#

im3_well = phasor_well.plot_phasor_harmonics(
    Gc_well,
    Sc_well,
    harmonics=(1, 2, 3),
    mask=b_bool_mask_well,
    hexbin_color=colorset,
    figsize=(15, 3),
)
../_images/0d75202a822ae340a452465490a7d4a6cbaf05e951726c9903bc2b2a88b7c194.png

2.2.1. Phasor of fitting based on the guessed lifetime#

Selecting the mono-exponential model for reconstructing the fit generates a general set of fit values.

x_well, y_well = random_true_pixel(b_bool_mask_well)
fitting_type_well = "mono-exponential"  # "mono-exponential" or "bi-exponential"

if fitting_type_well == "mono-exponential":
    tau_ns_well = 0.8
    phasor_well.plot_pixel_fit_single_exp(
        _irf_well, _decay_well, tau_map_ns_well, y_well, x_well, log_scale=False
    )
else:
    tau1_ns_well, tau2_ns_well = 0.8, 1.4
    A1_well, A2_well = phasor_well.compute_fractions(
        Gc_well[0], Sc_well[0], tau1_ns_well, tau2_ns_well
    )
    reconstructed_decay_well = phasor_well.analyze_biexponential_and_reconstruct(
        Gc_well[0], Sc_well[0], _irf_well, tau1_ns=tau1_ns_well, tau2_ns=tau2_ns_well, plot=True
    )
    print(f"Reconstructed decay shape: {reconstructed_decay_well.shape}")
    phasor_well.plot_pixel_fit(
        _irf_well, _decay_well, reconstructed_decay_well, y_well, x_well, log_scale=False
    )
../_images/d03df37cf4bd2ad2e521387e8e48306134305f37e072ab1ba9bf89eb12ac5e20.png

Selecting the bi-exponential model for reconstructing the fit generates a general set of fit values.

x_well, y_well = random_true_pixel(b_bool_mask_well)
fitting_type_well = "bi-exponential"  # "mono-exponential" or "bi-exponential"

if fitting_type_well == "mono-exponential":
    tau_ns_well = 1.0
    phasor_well.plot_pixel_fit_single_exp(
        _irf_well, _decay_well, tau_map_ns_well, y_well, x_well, log_scale=False
    )
else:
    tau1_ns_well, tau2_ns_well = 0.22, 2
    A1_well, A2_well = phasor_well.compute_fractions(
        Gc_well[0], Sc_well[0], tau1_ns_well, tau2_ns_well
    )
    reconstructed_decay_well = phasor_well.analyze_biexponential_and_reconstruct(
        Gc_well[0], Sc_well[0], _irf_well, tau1_ns=tau1_ns_well, tau2_ns=tau2_ns_well, plot=True
    )
    print(f"Reconstructed decay shape: {reconstructed_decay_well.shape}")
    phasor_well.plot_pixel_fit(
        _irf_well, _decay_well, reconstructed_decay_well, y_well, x_well, log_scale=False
    )
Reconstructed decay shape: (128, 256, 256)
../_images/84485b441a8f88c1821c89d1588f18bf8bf4a30d8d9f0f0712280dabe14eca47.png ../_images/46973654ec5ee9762a8f77014bfd94701e3faefe25d47f43fc6fed2a6995d063.png ../_images/7a29cce089bd533dac5865d570f5fb408000c6408b0088aec01521b9c0bf8bff.png