Source code for pyfli.simulator.calibration.pixel_selection

"""Separate sample pixels from pixels that only record detector noise / background."""

from dataclasses import asdict

import numpy as np
from scipy import ndimage
from skimage.filters import threshold_otsu

from pyfli import logging

from .config import PixelSelectionCriteria
from .summary_stats import background_bins, inactive_bins


def _otsu(values: np.ndarray, nbins: int = 256) -> tuple[float, float]:
    """Otsu threshold (``skimage.filters.threshold_otsu``) and separability eta = between-class / total variance."""
    values = np.asarray(values, dtype=float)
    if values.size == 0 or np.ptp(values) == 0:
        return float(values[0]) if values.size else 0.0, 0.0
    thr = float(threshold_otsu(values, nbins=nbins))
    lo, hi = values[values <= thr], values[values > thr]
    var_t = values.var()
    if var_t <= 0 or not len(lo) or not len(hi):
        return thr, 0.0
    w0 = len(lo) / len(values)
    return thr, float(w0 * (1 - w0) * (lo.mean() - hi.mean()) ** 2 / var_t)


[docs] def select_signal_pixels( decay: np.ndarray, user_mask: np.ndarray | None = None, criteria: PixelSelectionCriteria | None = None, adc_ceiling: float | None = None, ) -> tuple[np.ndarray, dict]: """ Returns ``(mask, report)``. ``report`` records how many pixels each test removed, the background bins and the per-bin background level, for the calibration log. """ c = criteria or PixelSelectionCriteria() H, W, T = decay.shape total = decay.sum(-1) finite = np.isfinite(total) & (total > 0) # pooled decay from the brightest pixels defines the background bins and the peak position bright = total >= np.percentile(total[finite], 90) if finite.any() else finite pooled = decay[bright].sum(0) bg_bins = background_bins(pooled) active = np.ones(T, dtype=bool) active[inactive_bins(decay.sum((0, 1)))] = False n_act = int(active.sum()) b_px = decay[..., bg_bins].mean(-1) signal = total - n_act * b_px with np.errstate(divide="ignore", invalid="ignore"): snr = np.where(total > 0, signal / np.sqrt(total), 0) mean_level = np.where(total > 0, total / n_act, 1) resid = (decay[..., active] - mean_level[..., None]) ** 2 chi2_flat = (resid / np.maximum(mean_level, 1e-3)[..., None]).sum(-1) / (n_act - 1) flat_thr = 1 + c.flatness_sigma * np.sqrt(2.0 / (n_act - 1)) smooth = ndimage.uniform_filter1d(decay, 3, axis=-1, mode="wrap") peak_px = smooth.argmax(-1) peak_ref = int(np.argmax(ndimage.uniform_filter1d(pooled, 3, mode="wrap"))) win = c.peak_window_bins or max(T // 8, 4) dpk = np.abs(peak_px - peak_ref) dpk = np.minimum(dpk, T - dpk) # periodic distance med3 = ndimage.median_filter(total, size=3) hot = total > c.hot_pixel_factor * np.maximum(med3, 1) if adc_ceiling is not None: hot |= (decay >= adc_ceiling).any(-1) tests = { "finite_nonzero": finite, "signal_photons": signal >= c.min_signal_photons, "snr": snr >= c.snr_min, "not_flat": chi2_flat >= flat_thr, "peak_position": dpk <= win, "not_hot": ~hot, } auto = np.logical_and.reduce(list(tests.values())) # Otsu on *all* pixels (sample + background): a bimodal histogram means a clear background population log_sig = np.log10(np.clip(signal, 1, None)) otsu_thr, eta = _otsu(log_sig[finite]) if finite.sum() > 100 else (None, 0.0) use_otsu = c.use_otsu == "on" or ( c.use_otsu == "auto" and eta >= c.otsu_min_separability ) if use_otsu and otsu_thr is not None: tests["otsu"] = log_sig >= otsu_thr auto &= tests["otsu"] if c.min_component_size > 1 and auto.any(): lab, n = ndimage.label(auto) sizes = ndimage.sum(auto, lab, index=np.arange(1, n + 1)) auto = np.isin(lab, 1 + np.flatnonzero(sizes >= c.min_component_size)) if user_mask is not None: mask = user_mask & finite if c.refine_user_mask: mask &= auto mode = "user+auto" if c.refine_user_mask else "user" else: mask = auto mode = "auto" report = { "mode": mode, "n_selected": int(mask.sum()), "n_total": int(H * W), "rejected_by_test": {k: int((~v).sum()) for k, v in tests.items()}, "otsu": { "threshold_log10_signal": otsu_thr, "separability": eta, "applied": bool(use_otsu), }, "background_bins": [int(bg_bins[0]), int(bg_bins[-1])] if len(bg_bins) else [], "background_per_bin_dark_pixels": float(np.median(b_px[~mask & finite])) if (~mask & finite).any() else 0.0, "background_per_bin_selected": float(np.median(b_px[mask])) if mask.any() else 0.0, "peak_bin": peak_ref, "criteria": asdict(c), } if user_mask is not None: inter = (user_mask & auto).sum() report["agreement_user_vs_auto_iou"] = float( inter / max((user_mask | auto).sum(), 1) ) logging.info( f"Pixel selection ({mode}): {report['n_selected']} / {H * W} pixels kept; " f"rejected per test: {report['rejected_by_test']}" ) if mask.sum() < 200: logging.warning( "Fewer than 200 sample pixels selected: relax the criteria or check the data." ) return mask, report