2. SwissSPAD2 Data Processing#
Apply detector-specific processing to a real SwissSPAD2 acquisition with PyFLI.
The processing pipeline preserves a raw reference acquisition and separately loads a processed dataset using explicitly configured corrections. This makes it possible to compare detector output before and after processing.
This notebook walks through:
configuring the SwissSPAD2 processing pipeline,
loading an unprocessed reference acquisition,
applying pile-up correction when requested,
applying background subtraction and hot-pixel correction to HDF5 folder acquisitions when requested,
applying temporal alignment and folding when requested,
comparing raw and processed intensity images,
comparing raw and processed temporal responses,
applying an optional PyFLI mask, and
reviewing the complete processing metadata.
import os
from pathlib import Path
from pprint import pprint
import matplotlib.pyplot as plt
import numpy as np
from pyfli.data_cc import DataPreprocessing
from pyfli.io import Detector
2.1. Configure processing#
Machine-specific paths and acquisition-specific processing choices are read from environment variables.
Required variables:
PYFLI_SS2_DATA_PATHPYFLI_SS2_BIT_SIZE
Processing switches default to disabled. At least one processing operation must be explicitly enabled.
Supported switches:
PYFLI_SS2_PILE_UPPYFLI_SS2_FOLDPYFLI_SS2_SUB_BGPYFLI_SS2_HOT_PIXELPYFLI_SS2_MAKE_HP_MAP
Optional paths:
PYFLI_SS2_IRF_PATHPYFLI_SS2_BG_PATHPYFLI_SS2_MASK_PATHPYFLI_SS2_HP_PATH
Optional acquisition and folding parameters:
PYFLI_SS2_GATE_COUNTPYFLI_SS2_FOLD_REPETITIONSPYFLI_SS2_PERIOD_BINSPYFLI_SS2_PHASE_SHIFTPYFLI_SS2_DETECTOR_FREQ_MHZPYFLI_SS2_LASER_FREQ_MHZPYFLI_SS2_THRESHOLD_SIGMAPYFLI_SS2_FOLDER_MODE
Background subtraction and detector-derived hot-pixel correction currently apply to HDF5 folder acquisitions. Native BIN acquisitions support the shared pile-up and temporal-folding pipeline.
def require_env(name: str) -> str:
value = os.environ.get(name)
if value is None or not value.strip():
raise RuntimeError(
f"Environment variable '{name}' must be set before running this notebook."
)
return value.strip()
def require_path(name: str) -> Path:
path = Path(require_env(name)).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(
f"Path configured by '{name}' does not exist: {path}"
)
return path
def optional_path(name: str) -> Path | None:
value = os.environ.get(name)
if value is None or not value.strip():
return None
path = Path(value.strip()).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(
f"Path configured by '{name}' does not exist: {path}"
)
return path
def env_bool(
name: str,
default: bool = False,
) -> bool:
value = os.environ.get(name)
if value is None or not value.strip():
return default
normalized = value.strip().lower()
true_values = {
"1",
"true",
"yes",
"on",
}
false_values = {
"0",
"false",
"no",
"off",
}
if normalized in true_values:
return True
if normalized in false_values:
return False
raise ValueError(
f"Environment variable '{name}' must be one of "
f"{sorted(true_values | false_values)}, got '{value}'."
)
def optional_int(name: str) -> int | None:
value = os.environ.get(name)
if value is None or not value.strip():
return None
return int(value.strip())
def optional_float(name: str) -> float | None:
value = os.environ.get(name)
if value is None or not value.strip():
return None
return float(value.strip())
DATA_PATH = require_path(
"PYFLI_SS2_DATA_PATH"
)
BIT_SIZE = int(
require_env(
"PYFLI_SS2_BIT_SIZE"
)
)
IRF_PATH = optional_path(
"PYFLI_SS2_IRF_PATH"
)
BG_PATH = optional_path(
"PYFLI_SS2_BG_PATH"
)
MASK_PATH = optional_path(
"PYFLI_SS2_MASK_PATH"
)
HP_PATH = optional_path(
"PYFLI_SS2_HP_PATH"
)
PILE_UP = env_bool(
"PYFLI_SS2_PILE_UP",
default=False,
)
FOLD = env_bool(
"PYFLI_SS2_FOLD",
default=False,
)
SUB_BG = env_bool(
"PYFLI_SS2_SUB_BG",
default=False,
)
HOT_PIXEL = env_bool(
"PYFLI_SS2_HOT_PIXEL",
default=False,
)
MAKE_HP_MAP = env_bool(
"PYFLI_SS2_MAKE_HP_MAP",
default=True,
)
EXPECTED_GATE_COUNT = optional_int(
"PYFLI_SS2_GATE_COUNT"
)
FOLD_REPETITIONS = optional_int(
"PYFLI_SS2_FOLD_REPETITIONS"
)
PERIOD_BINS = optional_int(
"PYFLI_SS2_PERIOD_BINS"
)
PHASE_SHIFT = optional_int(
"PYFLI_SS2_PHASE_SHIFT"
)
DETECTOR_FREQUENCY_MHZ = optional_float(
"PYFLI_SS2_DETECTOR_FREQ_MHZ"
)
LASER_FREQUENCY_MHZ = optional_float(
"PYFLI_SS2_LASER_FREQ_MHZ"
)
threshold_value = optional_float(
"PYFLI_SS2_THRESHOLD_SIGMA"
)
THRESHOLD_SIGMA = (
5.0
if threshold_value is None
else threshold_value
)
FOLDER_MODE = os.environ.get(
"PYFLI_SS2_FOLDER_MODE",
"sum",
).strip().lower()
if BIT_SIZE < 1:
raise ValueError(
f"PYFLI_SS2_BIT_SIZE must be positive, got {BIT_SIZE}."
)
if THRESHOLD_SIGMA <= 0:
raise ValueError(
f"PYFLI_SS2_THRESHOLD_SIGMA must be positive, got {THRESHOLD_SIGMA}."
)
if FOLDER_MODE not in {
"sum",
"mean",
}:
raise ValueError(
"PYFLI_SS2_FOLDER_MODE must be 'sum' or 'mean', "
f"got '{FOLDER_MODE}'."
)
if not any(
(
PILE_UP,
FOLD,
SUB_BG,
HOT_PIXEL,
)
):
raise RuntimeError(
"At least one SwissSPAD2 processing operation must be explicitly enabled "
"through PYFLI_SS2_PILE_UP, PYFLI_SS2_FOLD, PYFLI_SS2_SUB_BG, "
"or PYFLI_SS2_HOT_PIXEL."
)
if SUB_BG and BG_PATH is None:
raise RuntimeError(
"PYFLI_SS2_BG_PATH must be configured when PYFLI_SS2_SUB_BG is enabled."
)
if HOT_PIXEL and MAKE_HP_MAP and BG_PATH is None:
raise RuntimeError(
"PYFLI_SS2_BG_PATH must be configured when automatic hot-pixel "
"mapping is enabled."
)
if HOT_PIXEL and not MAKE_HP_MAP and HP_PATH is None:
raise RuntimeError(
"PYFLI_SS2_HP_PATH must be configured when hot-pixel correction is "
"enabled with PYFLI_SS2_MAKE_HP_MAP disabled."
)
if (
DETECTOR_FREQUENCY_MHZ is None
) != (
LASER_FREQUENCY_MHZ is None
):
raise RuntimeError(
"PYFLI_SS2_DETECTOR_FREQ_MHZ and PYFLI_SS2_LASER_FREQ_MHZ "
"must either both be configured or both be omitted."
)
fold_parameters_present = any(
value is not None
for value in (
FOLD_REPETITIONS,
PERIOD_BINS,
PHASE_SHIFT,
DETECTOR_FREQUENCY_MHZ,
LASER_FREQUENCY_MHZ,
)
)
if fold_parameters_present and not FOLD:
raise RuntimeError(
"Folding parameters were configured while PYFLI_SS2_FOLD is disabled."
)
if EXPECTED_GATE_COUNT is not None and EXPECTED_GATE_COUNT < 1:
raise ValueError(
f"PYFLI_SS2_GATE_COUNT must be positive, got {EXPECTED_GATE_COUNT}."
)
if FOLD_REPETITIONS is not None and FOLD_REPETITIONS < 2:
raise ValueError(
"PYFLI_SS2_FOLD_REPETITIONS must be at least 2."
)
if PERIOD_BINS is not None and PERIOD_BINS < 2:
raise ValueError(
"PYFLI_SS2_PERIOD_BINS must be at least 2."
)
print(f"Data path: {DATA_PATH}")
print(f"Bit size: {BIT_SIZE}")
print(f"Pile-up correction: {PILE_UP}")
print(f"Temporal folding: {FOLD}")
print(f"Background subtraction: {SUB_BG}")
print(f"Hot-pixel correction: {HOT_PIXEL}")
print(f"Automatic hot-pixel map: {MAKE_HP_MAP}")
if IRF_PATH is not None:
print(f"IRF path: {IRF_PATH}")
if BG_PATH is not None:
print(f"Background path: {BG_PATH}")
if MASK_PATH is not None:
print(f"Mask path: {MASK_PATH}")
if HP_PATH is not None:
print(f"Hot-pixel mask path: {HP_PATH}")
2.2. Load an unprocessed reference#
The same acquisition is first loaded without detector corrections. The resulting cube is retained as a reference for direct comparison with the processed result.
raw_config = {
"input_format": "auto",
"bit_depth": BIT_SIZE,
"pile_up": False,
"fold": False,
"hdf5_folder_mode": FOLDER_MODE,
}
if EXPECTED_GATE_COUNT is not None:
raw_config[
"ss2_expected_gate_count"
] = EXPECTED_GATE_COUNT
raw_loader = Detector(
data_path=str(DATA_PATH),
bit_size=BIT_SIZE,
)
raw_dataset = raw_loader.SS2(
name="SwissSPAD2_raw",
sub_bg=False,
pile_up=False,
hot_pixel=False,
make_hp_map=False,
config=raw_config,
)
raw_decay = np.asarray(
raw_dataset["raw_data"]["decay"]
)
raw_processing = raw_dataset[
"metadata"
][
"processing"
]
input_format = raw_processing[
"input_format"
]
if raw_decay.ndim != 3:
raise RuntimeError(
f"Raw SwissSPAD2 data must be three-dimensional, got {raw_decay.shape}."
)
if not np.all(
np.isfinite(raw_decay)
):
raise RuntimeError(
"Raw SwissSPAD2 data contains non-finite values."
)
print(f"Input format: {input_format}")
print(f"Raw shape: {raw_decay.shape}")
print(f"Raw dtype: {raw_decay.dtype}")
folder_processing_requested = (
SUB_BG
or HOT_PIXEL
)
if folder_processing_requested:
if input_format != "hdf5":
raise RuntimeError(
"Background subtraction and hot-pixel correction require "
"SwissSPAD2 HDF5 folder input in the current detector pipeline. "
f"The acquisition resolved to '{input_format}'."
)
if not DATA_PATH.is_dir():
raise RuntimeError(
"Background subtraction and hot-pixel correction require "
"an HDF5 acquisition directory rather than a single HDF5 file."
)
if BG_PATH is not None and not BG_PATH.is_dir():
raise RuntimeError(
"PYFLI_SS2_BG_PATH must be an HDF5 directory when folder "
"processing is enabled."
)
if (
input_format == "hdf5"
and DATA_PATH.is_dir()
and IRF_PATH is not None
and not IRF_PATH.is_dir()
):
raise RuntimeError(
"PYFLI_SS2_IRF_PATH must be an HDF5 directory when the data "
"acquisition is an HDF5 directory."
)
2.3. Apply detector processing#
The processing configuration is assembled only from explicitly supplied acquisition parameters. PyFLI applies detector corrections before optional temporal folding and records the processing choices in the returned metadata.
processed_config = {
"input_format": "auto",
"bit_depth": BIT_SIZE,
"pile_up": PILE_UP,
"fold": FOLD,
"hdf5_folder_mode": FOLDER_MODE,
}
if EXPECTED_GATE_COUNT is not None:
processed_config[
"ss2_expected_gate_count"
] = EXPECTED_GATE_COUNT
if FOLD_REPETITIONS is not None:
processed_config[
"fold_repetitions"
] = FOLD_REPETITIONS
if PERIOD_BINS is not None:
processed_config[
"period_bins"
] = PERIOD_BINS
if PHASE_SHIFT is not None:
processed_config[
"phase_shift"
] = PHASE_SHIFT
if DETECTOR_FREQUENCY_MHZ is not None:
processed_config[
"detector_frequency_mhz"
] = DETECTOR_FREQUENCY_MHZ
if LASER_FREQUENCY_MHZ is not None:
processed_config[
"laser_frequency_mhz"
] = LASER_FREQUENCY_MHZ
processed_loader = Detector(
data_path=str(DATA_PATH),
irf_path=(
str(IRF_PATH)
if IRF_PATH is not None
else None
),
bg_path=(
str(BG_PATH)
if BG_PATH is not None
else None
),
mask_path=(
str(MASK_PATH)
if MASK_PATH is not None
else None
),
hp_path=(
str(HP_PATH)
if HP_PATH is not None
else None
),
bit_size=BIT_SIZE,
)
processed_dataset = processed_loader.SS2(
name="SwissSPAD2_processed",
sub_bg=SUB_BG,
pile_up=PILE_UP,
hot_pixel=HOT_PIXEL,
make_hp_map=MAKE_HP_MAP,
threshold_sigma=THRESHOLD_SIGMA,
config=processed_config,
)
processed_decay = np.asarray(
processed_dataset["raw_data"]["decay"]
)
processed_irf = processed_dataset[
"raw_data"
][
"irf"
]
processed_background = processed_dataset[
"raw_data"
][
"background"
]
processed_mask = processed_dataset[
"raw_data"
][
"mask"
]
processing = processed_dataset[
"metadata"
][
"processing"
]
if processed_decay.ndim != 3:
raise RuntimeError(
"Processed SwissSPAD2 data must be three-dimensional, "
f"got {processed_decay.shape}."
)
if not np.all(
np.isfinite(processed_decay)
):
raise RuntimeError(
"Processed SwissSPAD2 data contains non-finite values."
)
print(f"Processed shape: {processed_decay.shape}")
print(f"Processed dtype: {processed_decay.dtype}")
print(f"Pile-up applied: {processing['pile_up']}")
print(f"Temporal folding applied: {processing['fold']}")
print(f"Background subtraction applied: {processing['sub_bg']}")
print(f"Hot-pixel correction applied: {processing['hot_pixel']}")
2.4. Compare integrated intensity#
The raw and processed acquisitions are displayed independently because detector corrections can change the count scale.
raw_intensity = np.sum(
raw_decay,
axis=-1,
dtype=np.float64,
)
processed_intensity = np.sum(
processed_decay,
axis=-1,
dtype=np.float64,
)
fig, axes = plt.subplots(
1,
2,
figsize=(12, 5),
)
raw_image = axes[0].imshow(
raw_intensity,
cmap="turbo",
origin="upper",
)
axes[0].set_title("Raw intensity")
axes[0].set_xlabel("X pixel")
axes[0].set_ylabel("Y pixel")
fig.colorbar(
raw_image,
ax=axes[0],
fraction=0.046,
pad=0.04,
label="Integrated counts",
)
processed_image = axes[1].imshow(
processed_intensity,
cmap="turbo",
origin="upper",
)
axes[1].set_title("Processed intensity")
axes[1].set_xlabel("X pixel")
axes[1].set_ylabel("Y pixel")
fig.colorbar(
processed_image,
ax=axes[1],
fraction=0.046,
pad=0.04,
label="Integrated counts",
)
fig.tight_layout()
plt.show()
2.5. Compare temporal responses#
The spatially integrated raw and processed temporal responses are shown separately so changes in gate count or count scale introduced by folding and detector corrections remain explicit.
raw_trace = np.sum(
raw_decay,
axis=(0, 1),
dtype=np.float64,
)
processed_trace = np.sum(
processed_decay,
axis=(0, 1),
dtype=np.float64,
)
fig, axes = plt.subplots(
2,
1,
figsize=(10, 7),
)
axes[0].plot(
np.arange(raw_trace.size),
raw_trace,
)
axes[0].set_title("Raw temporal response")
axes[0].set_xlabel("Gate index")
axes[0].set_ylabel("Integrated counts")
axes[0].grid(alpha=0.25)
axes[1].plot(
np.arange(processed_trace.size),
processed_trace,
)
axes[1].set_title("Processed temporal response")
axes[1].set_xlabel("Gate index")
axes[1].set_ylabel("Integrated counts")
axes[1].grid(alpha=0.25)
fig.tight_layout()
plt.show()
2.6. Apply the dataset mask#
When a mask is supplied to Detector, PyFLI packages it with the acquisition. DataPreprocessing can then apply the same spatial mask to the processed decay cube before analysis.
if processed_mask is not None:
mask = np.asarray(
processed_mask,
dtype=bool,
)
if mask.shape != processed_decay.shape[:2]:
raise RuntimeError(
f"Mask shape {mask.shape} does not match processed spatial shape "
f"{processed_decay.shape[:2]}."
)
(
masked_decay,
) = DataPreprocessing(
processed_decay
).apply_mask(
mask=mask
)
masked_intensity = np.sum(
masked_decay,
axis=-1,
dtype=np.float64,
)
fig, axes = plt.subplots(
1,
2,
figsize=(11, 5),
)
axes[0].imshow(
mask,
cmap="gray",
origin="upper",
)
axes[0].set_title("Analysis mask")
axes[0].set_xlabel("X pixel")
axes[0].set_ylabel("Y pixel")
masked_image = axes[1].imshow(
masked_intensity,
cmap="turbo",
origin="upper",
)
axes[1].set_title("Masked processed intensity")
axes[1].set_xlabel("X pixel")
axes[1].set_ylabel("Y pixel")
fig.colorbar(
masked_image,
ax=axes[1],
fraction=0.046,
pad=0.04,
label="Integrated counts",
)
fig.tight_layout()
plt.show()
else:
masked_decay = processed_decay
print(
"No analysis mask was supplied with this acquisition. "
"The processed decay cube is retained without spatial masking."
)
2.7. Inspect the IRF#
When an instrument response acquisition is supplied, it is loaded through the same detector-specific SwissSPAD2 path. The integrated IRF and decay traces are peak-normalized here only for visual comparison of their temporal positions.
if processed_irf is not None:
processed_irf = np.asarray(
processed_irf
)
if processed_irf.shape != processed_decay.shape:
raise RuntimeError(
f"Processed IRF shape {processed_irf.shape} does not match "
f"processed decay shape {processed_decay.shape}."
)
irf_trace = np.sum(
processed_irf,
axis=(0, 1),
dtype=np.float64,
)
decay_trace = np.sum(
processed_decay,
axis=(0, 1),
dtype=np.float64,
)
irf_peak = np.max(
irf_trace
)
decay_peak = np.max(
decay_trace
)
if irf_peak <= 0:
raise RuntimeError(
"The integrated IRF has no positive signal."
)
if decay_peak <= 0:
raise RuntimeError(
"The integrated processed decay has no positive signal."
)
normalized_irf = irf_trace / irf_peak
normalized_decay = decay_trace / decay_peak
fig, ax = plt.subplots(figsize=(9, 4))
ax.plot(
np.arange(normalized_decay.size),
normalized_decay,
label="Decay",
)
ax.plot(
np.arange(normalized_irf.size),
normalized_irf,
label="IRF",
)
ax.set_title("Normalized decay and IRF")
ax.set_xlabel("Gate index")
ax.set_ylabel("Normalized intensity")
ax.legend()
ax.grid(alpha=0.25)
fig.tight_layout()
plt.show()
else:
print(
"No IRF acquisition was supplied for this processing run."
)
2.8. Inspect the processed background#
When background subtraction is enabled for an HDF5 folder acquisition, PyFLI packages the background used by the processing pipeline with the returned dataset.
if processed_background is not None:
processed_background = np.asarray(
processed_background
)
if processed_background.shape != processed_decay.shape:
raise RuntimeError(
f"Background shape {processed_background.shape} does not match "
f"processed decay shape {processed_decay.shape}."
)
background_intensity = np.sum(
processed_background,
axis=-1,
dtype=np.float64,
)
fig, ax = plt.subplots(figsize=(7, 6))
image = ax.imshow(
background_intensity,
cmap="turbo",
origin="upper",
)
fig.colorbar(
image,
ax=ax,
label="Integrated background counts",
)
ax.set_title("SwissSPAD2 background")
ax.set_xlabel("X pixel")
ax.set_ylabel("Y pixel")
fig.tight_layout()
plt.show()
else:
print(
"No processed background cube is packaged with this acquisition."
)
2.9. Review processing metadata#
The processing metadata records the resolved input format, detector settings, correction state, folding information, and underlying SPAD reader metadata. This provides a reproducible record of how the returned decay cube was produced.
pprint(
processing,
sort_dicts=False,
)
2.10. Summary#
The SwissSPAD2 acquisition has been processed through the detector-specific PyFLI pipeline and is now available in the standard PyFLI dataset structure.
The processed decay, optional IRF, optional background, and optional mask can be passed to downstream PyFLI lifetime-analysis workflows without detector-specific file handling.