Source code for pyfli.phasor.phasorS.phasor_additional_plots

"""
Provide supplementary phasor plots as a :class:`PhasorAnalyzer` subclass.

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 the
class :class:`PhasorAdditionalPlots`.
"""

import colorsys
from typing import Any

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Colormap, Normalize, to_rgb
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
from scipy import ndimage
from scipy.stats import gaussian_kde

from .phasor_simple import PhasorAnalyzer
from .phasor_simple_utils import (
    _TAU_MARKS_NS,
    _add_frequency_label,
    _draw_lifetime_ticks,
    _style_phasor_ax,
    _universal_circle_xy,
)


[docs] class PhasorAdditionalPlots(PhasorAnalyzer): """ Extra phasor visualizations layered on top of :class:`PhasorAnalyzer`. An instance is a full phasor analyzer (compute, calibrate, lifetime conversion, and every :class:`PhasorPlotsMixin` plot) plus: * :meth:`phasorlifetime_to_phasor` -- the phase-lifetime map beside the matching phasor scatter, sharing one colormap so a pixel's color and its phasor point's color are identical. * :meth:`phasor_kde_to_px` -- the filled kernel-density regions of the phasor cloud carried back onto the grayscale intensity image. Construction matches :class:`PhasorAnalyzer` (``frequency_hz``, ``time_axis_ns``, ``n_harmonics``, ``device``). """ # ── helpers ────────────────────────────────────────────────────────────── @staticmethod def _iso_proportion_levels(density: np.ndarray, isoprop: np.ndarray) -> np.ndarray: """ Convert enclosed-probability-mass proportions to iso-density thresholds. This mirrors how :func:`seaborn.kdeplot` turns ``levels`` into contour values: a proportion ``p`` maps to the density threshold whose ``density >= threshold`` region encloses a fraction ``p`` of the mass. Parameters ---------- density : np.ndarray KDE density sampled on a grid. isoprop : np.ndarray Enclosed-mass proportions in ``[0, 1]``. Returns ------- np.ndarray Ascending, unique density thresholds. """ values = np.ravel(density) total = float(values.sum()) if total <= 0: return np.asarray([], dtype=float) sorted_values = np.sort(values)[::-1] cdf = np.cumsum(sorted_values) / total idx = np.searchsorted(cdf, 1.0 - np.asarray(isoprop, dtype=float)) levels = np.take(sorted_values, idx, mode="clip") return np.unique(levels) @staticmethod def _distinct_colors(n: int, cmap_name: str) -> list[tuple[float, float, float]]: """ Return ``n`` visually distinct RGB colors. A qualitative (listed) colormap is used verbatim when it holds enough entries; otherwise evenly spaced full-saturation hues are generated so the colors stay distinct for any ``n``. Parameters ---------- n : int Number of colors requested. cmap_name : str Name of the preferred qualitative colormap. Returns ------- list[tuple[float, float, float]] RGB triples in ``[0, 1]``. """ if n <= 0: return [] cmap = plt.get_cmap(cmap_name) listed = getattr(cmap, "colors", None) if listed is not None and len(listed) >= n: return [to_rgb(listed[i]) for i in range(n)] return [colorsys.hsv_to_rgb((i / n) % 1.0, 0.85, 0.95) for i in range(n)] # ── plots ───────────────────────────────────────────────────────────────
[docs] def phasorlifetime_to_phasor( self, Gc: np.ndarray, Sc: np.ndarray, boolean_mask: np.ndarray | None = None, cmap: str | Colormap = "jet", cmap_scale: tuple[float, float] = (0.0, 5.0), harmonic: int = 0, axes: Any | None = None, figsize: tuple[float, float] = (14, 6), half_circle: bool = True, xlim: tuple[float, float] = (-0.1, 1.1), ylim: tuple[float, float] = (0.0, 0.6), point_size: float = 6.0, background: str = "black", title: str = "Phasor Lifetime", ) -> Any: """ Plot the phase-lifetime map beside its matching phasor scatter. Panel ``(0, 0)`` is the per-pixel phase-lifetime map (:meth:`compute_lifetime` of the calibrated coordinates) shown with ``imshow`` using ``cmap`` and ``vmin, vmax = cmap_scale``; pixels outside ``boolean_mask`` or with an undefined lifetime render in ``background``. Panel ``(0, 1)`` scatters the calibrated phasor points ``(Gc[harmonic], Sc[harmonic])`` for the masked pixels, colored through the *same* ``cmap`` and ``Normalize(vmin, vmax)`` as the map, so a pixel's color and its phasor point's color are identical. Parameters ---------- Gc : np.ndarray Calibrated phasor real coordinate. Either a ``(H, W)`` map or a ``(n_harmonics, H, W)`` stack (the ``harmonic`` slice is used). Sc : np.ndarray Calibrated phasor imaginary coordinate, matching ``Gc``. boolean_mask : np.ndarray | None Boolean ``(H, W)`` mask selecting the pixels to display and scatter. When ``None`` every pixel is used. cmap : str | matplotlib.colors.Colormap Colormap shared by the lifetime map and the phasor scatter. cmap_scale : tuple[float, float] ``(vmin, vmax)`` in nanoseconds; ``vmin, vmax = cmap_scale[0], cmap_scale[1]``. harmonic : int Harmonic slice used when ``Gc``/``Sc`` are 3-D stacks (``0`` is the first harmonic). axes : Any | None Length-2 sequence of Matplotlib axes ``(map_ax, scatter_ax)``. A new 1x2 figure is created when ``None``. figsize : tuple[float, float] Figure size used when a new figure is created. half_circle : bool Whether to draw only the upper half of the universal phasor circle. xlim, ylim : tuple[float, float] Axis limits for the phasor scatter panel. point_size : float Marker size for the phasor scatter points. background : str Color used for masked / undefined pixels in the lifetime map. title : str Base title; panel titles are derived from it. Returns ------- Any The Matplotlib figure containing both panels. """ vmin, vmax = float(cmap_scale[0]), float(cmap_scale[1]) Gc = np.asarray(Gc) Sc = np.asarray(Sc) G_2d = Gc[harmonic] if Gc.ndim == 3 else Gc S_2d = Sc[harmonic] if Sc.ndim == 3 else Sc tau_map_ns = np.asarray(self.compute_lifetime(G_2d, S_2d), dtype=float) if boolean_mask is None: mask_2d = np.ones(tau_map_ns.shape, dtype=bool) else: mask_2d = np.asarray(boolean_mask).astype(bool) # One color pipeline shared by both panels: value -> Normalize -> cmap. # imshow(norm=norm, cmap=cmap_obj) and sm.to_rgba(tau) apply exactly this # same mapping, so a pixel and its phasor point get an identical color. cmap_obj = plt.get_cmap(cmap) if isinstance(cmap, str) else cmap cmap_obj = cmap_obj.copy() cmap_obj.set_bad(background) norm = Normalize(vmin=vmin, vmax=vmax) sm = ScalarMappable(norm=norm, cmap=cmap_obj) sm.set_array([]) created_fig = axes is None if created_fig: fig, axes_arr = plt.subplots(1, 2, figsize=figsize, squeeze=False) ax_map, ax_scatter = axes_arr[0, 0], axes_arr[0, 1] else: axes_flat = np.asarray(axes, dtype=object).ravel() ax_map, ax_scatter = axes_flat[0], axes_flat[1] fig = ax_map.get_figure() # ── (0, 0) phase-lifetime map ────────────────────────────────────── tau_display = np.where(mask_2d, tau_map_ns, np.nan) im = ax_map.imshow( tau_display, origin="upper", cmap=cmap_obj, norm=norm, interpolation="nearest", ) ax_map.set_title(f"{title} Map (ns)") ax_map.axis("off") fig.colorbar(im, ax=ax_map, fraction=0.046, pad=0.04).set_label("Lifetime (ns)") # ── (0, 1) matching phasor scatter ──────────────────────────────── mask_flat = mask_2d.ravel() g_flat = np.ravel(G_2d)[mask_flat] s_flat = np.ravel(S_2d)[mask_flat] tau_flat = np.ravel(tau_map_ns)[mask_flat] valid = np.isfinite(g_flat) & np.isfinite(s_flat) & np.isfinite(tau_flat) g_v, s_v, tau_v = g_flat[valid], s_flat[valid], tau_flat[valid] ug, us = _universal_circle_xy(half_circle=half_circle) ax_scatter.plot(ug, us, "k--", alpha=0.8, zorder=1) if g_v.size: ax_scatter.scatter( g_v, s_v, c=sm.to_rgba(tau_v), s=point_size, edgecolors="none", zorder=2, ) else: ax_scatter.text(0.5, 0.25, "No valid data points", ha="center", color="red") try: g_mark, s_mark = self.lifetime_to_phasor( _TAU_MARKS_NS, (harmonic + 1) * self.frequency ) _draw_lifetime_ticks( ax_scatter, g_mark, s_mark, color="black", lw=2, fontsize=9 ) except Exception: pass _style_phasor_ax( ax_scatter, title=f"{title} — Phasor Scatter", xlim=xlim, ylim=ylim, half_circle=half_circle, ) _add_frequency_label(ax_scatter, (harmonic + 1) * self.frequency) if created_fig: plt.tight_layout() return fig
[docs] def phasor_kde_to_px( self, Gc: np.ndarray, Sc: np.ndarray, decay: np.ndarray, boolean_mask: np.ndarray | None = None, hexbin_color: str = "autumn", harmonic: int = 0, kde_levels: int = 3, kde_color: str = "red", kde_linewidths: float = 1.0, kde_alpha: float = 0.5, kde_thresh: float = 0.05, fill_alpha: float = 0.4, gridsize: int = 200, bw_method: Any | None = None, region_cmap: str = "tab10", peak_footprint: int | None = None, min_peak_height: float = 0.1, max_centers: int | None = 8, axes: Any | None = None, figsize: tuple[float, float] = (14, 5), half_circle: bool = True, xlim: tuple[float, float] = (-0.1, 1.1), ylim: tuple[float, float] = (0.0, 0.6), title: str = "Phasor KDE", ) -> Any: r""" Fill KDE regions on a phasor plot and carry those colors back to pixels. Panel ``(0, 0)`` reproduces the usual phasor diagram (:meth:`PhasorPlotsMixin.plot_phasor_diagram`: hexbin density, universal semicircle, lifetime ticks), overlays the KDE iso-density contour *lines* (like ``kdeplot=True``, in ``kde_color``), and then *fills* the kernel-density regions of the masked phasor cloud. Filled bands run from the innermost (highest density) outward; when the KDE has several modes each mode's territory is filled with its own clearly distinct color, so every ``(mode, level)`` region is a unique color (drawn at ``fill_alpha``). Panel ``(0, 1)`` shows the grayscale intensity image ``np.sum(decay, axis=-1)``. Every masked pixel whose phasor point lands in a filled KDE region is tinted, at ``fill_alpha``, with that region's color, so the phasor-space grouping is projected back onto the image. Parameters ---------- Gc : np.ndarray Calibrated phasor real coordinate, a ``(H, W)`` map or a ``(n_harmonics, H, W)`` stack. Sc : np.ndarray Calibrated phasor imaginary coordinate, matching ``Gc``. decay : np.ndarray Decay cube ``(H, W, T)``; the intensity image is ``np.sum(decay, -1)``. boolean_mask : np.ndarray | None Boolean ``(H, W)`` mask selecting the pixels fed to the KDE and tinted in the overlay. When ``None`` every finite pixel is used. hexbin_color : str Colormap for the phasor hexbin density in panel ``(0, 0)``. harmonic : int Harmonic slice used when ``Gc``/``Sc`` are 3-D stacks. kde_levels : int Number of iso-proportion KDE contour levels (as in ``seaborn.kdeplot``). kde_color : str Color of the KDE contour lines. kde_linewidths : float Width of the KDE contour lines. kde_alpha : float Opacity of the KDE contour lines. kde_thresh : float Lowest enclosed-mass proportion contoured (``seaborn``'s ``thresh``). fill_alpha : float Opacity of the region fills and of the pixel tint in the overlay. gridsize : int Resolution of the square grid the KDE is evaluated on. bw_method : Any | None Bandwidth selector passed to :class:`scipy.stats.gaussian_kde`. region_cmap : str Preferred qualitative colormap for the distinct region colors. peak_footprint : int | None Neighborhood size, in grid cells, for the local-maximum search that locates KDE modes. Defaults to ``max(3, gridsize // 20)``. min_peak_height : float A density local maximum counts as a KDE mode only when it exceeds this fraction of the global peak density (rejects tail noise bumps). max_centers : int | None Cap on the number of KDE modes kept (strongest first); ``None`` keeps all of them. axes : Any | None Length-2 sequence of axes ``(phasor_ax, overlay_ax)``; a new 1x2 figure is created when ``None``. figsize : tuple[float, float] Figure size used when a new figure is created. half_circle : bool Whether to draw only the upper half of the universal phasor circle. xlim, ylim : tuple[float, float] Axis limits for the phasor panel. title : str Base title; panel titles are derived from it. Returns ------- Any The Matplotlib figure containing both panels. """ Gc = np.asarray(Gc) Sc = np.asarray(Sc) G_2d = Gc[harmonic] if Gc.ndim == 3 else Gc S_2d = Sc[harmonic] if Sc.ndim == 3 else Sc if boolean_mask is None: mask_2d = np.ones(G_2d.shape, dtype=bool) else: mask_2d = np.asarray(boolean_mask).astype(bool) intensity = np.asarray(np.sum(decay, axis=-1), dtype=float) created_fig = axes is None if created_fig: fig, axes_arr = plt.subplots(1, 2, figsize=figsize, squeeze=False) ax_phasor, ax_overlay = axes_arr[0, 0], axes_arr[0, 1] else: axes_flat = np.asarray(axes, dtype=object).ravel() ax_phasor, ax_overlay = axes_flat[0], axes_flat[1] fig = ax_phasor.get_figure() # ── (0, 0) base phasor diagram (same look as plot_phasor_diagram) ── self.plot_phasor_diagram( G_2d, S_2d, mask=mask_2d, hexbin_color=hexbin_color, ax=ax_phasor, half_circle=half_circle, title=f"{title} — regions", xlim=xlim, ylim=ylim, kdeplot=False, ) finite_2d = np.isfinite(G_2d) & np.isfinite(S_2d) sel_2d = mask_2d & finite_2d py_idx, px_idx = np.where(sel_2d) g_pts = G_2d[py_idx, px_idx] s_pts = S_2d[py_idx, px_idx] pix_center = np.zeros(G_2d.shape, dtype=int) pix_band = np.zeros(G_2d.shape, dtype=int) region_color: dict[tuple[int, int], tuple[float, float, float]] = {} kde_ok = g_pts.size >= 5 and np.ptp(g_pts) > 0 and np.ptp(s_pts) > 0 kde = None if kde_ok: try: kde = gaussian_kde(np.vstack([g_pts, s_pts]), bw_method=bw_method) except Exception: kde_ok = False if kde_ok and kde is not None: bw_g = float(np.sqrt(kde.covariance[0, 0])) bw_s = float(np.sqrt(kde.covariance[1, 1])) x0, x1 = g_pts.min() - 3.0 * bw_g, g_pts.max() + 3.0 * bw_g y0, y1 = s_pts.min() - 3.0 * bw_s, s_pts.max() + 3.0 * bw_s xs = np.linspace(x0, x1, gridsize) ys = np.linspace(y0, y1, gridsize) grid_x, grid_y = np.meshgrid(xs, ys) dens = kde(np.vstack([grid_x.ravel(), grid_y.ravel()])).reshape( grid_x.shape ) if np.iterable(kde_levels): isoprop = np.asarray(kde_levels, dtype=float) else: isoprop = np.linspace(kde_thresh, 1.0, int(kde_levels)) d_levels = self._iso_proportion_levels(dens, isoprop) if d_levels.size: band = np.digitize(dens, d_levels) # 0 = outside .. k_max = core k_max = int(band.max()) if k_max >= 1: # KDE "centers" are the local maxima of the density inside # the outermost contour; each becomes its own territory. footprint = peak_footprint or max(3, gridsize // 20) floor = max(float(d_levels[0]), min_peak_height * float(dens.max())) is_peak = (ndimage.maximum_filter(dens, size=footprint) == dens) & ( dens > floor ) peak_labels, n_peaks = ndimage.label(is_peak) if n_peaks == 0: centroids = np.asarray( [ndimage.center_of_mass(band >= 1)], dtype=float ).reshape(1, 2) else: coms = np.asarray( ndimage.center_of_mass( is_peak, peak_labels, index=range(1, n_peaks + 1) ), dtype=float, ).reshape(n_peaks, 2) rr = np.clip(np.round(coms[:, 0]), 0, gridsize - 1).astype(int) cc = np.clip(np.round(coms[:, 1]), 0, gridsize - 1).astype(int) strongest = np.argsort(dens[rr, cc])[::-1] if max_centers is not None: strongest = strongest[:max_centers] centroids = coms[strongest] n_centers = int(centroids.shape[0]) # assign each filled grid cell to its nearest center (Voronoi) fy, fx = np.where(band >= 1) dist = np.linalg.norm( np.column_stack([fy, fx])[:, None, :] - centroids[None, :, :], axis=2, ) center_grid = np.zeros(band.shape, dtype=int) center_grid[fy, fx] = np.argmin(dist, axis=1) + 1 # distinct color per (center, band): innermost band of # center 1 first, then outward, then on to the next center palette = self._distinct_colors(n_centers * k_max, region_cmap) region_color = { (c, b): palette[(c - 1) * k_max + (k_max - b)] for c in range(1, n_centers + 1) for b in range(1, k_max + 1) } overlay_rgba = np.zeros((*band.shape, 4), dtype=float) drawn: set[tuple[int, int]] = set() for (c, b), color in region_color.items(): reg = (center_grid == c) & (band == b) if reg.any(): overlay_rgba[reg, :3] = color overlay_rgba[reg, 3] = fill_alpha drawn.add((c, b)) region_color = {k: v for k, v in region_color.items() if k in drawn} ax_phasor.imshow( overlay_rgba, extent=(x0, x1, y0, y1), origin="lower", interpolation="nearest", aspect="auto", zorder=1.5, ) ax_phasor.contour( grid_x, grid_y, dens, levels=d_levels, colors=kde_color, linewidths=kde_linewidths, alpha=kde_alpha, zorder=3, ) ix = np.clip(np.searchsorted(xs, g_pts) - 1, 0, gridsize - 1) iy = np.clip(np.searchsorted(ys, s_pts) - 1, 0, gridsize - 1) pix_band[py_idx, px_idx] = band[iy, ix] pix_center[py_idx, px_idx] = center_grid[iy, ix] # imshow with aspect="auto" drops the phasor framing -- restore it ax_phasor.set_aspect("equal") ax_phasor.set_xlim(*xlim) ax_phasor.set_ylim(*ylim) # ── (0, 1) grayscale intensity image + matching per-region tint ──── i_min, i_max = float(np.nanmin(intensity)), float(np.nanmax(intensity)) gray = (intensity - i_min) / (i_max - i_min + 1e-12) rgb = np.repeat(gray[..., None], 3, axis=2) for (c, b), color in region_color.items(): sel = (pix_center == c) & (pix_band == b) if sel.any(): rgb[sel] = (1.0 - fill_alpha) * rgb[sel] + fill_alpha * np.asarray( color ) ax_overlay.imshow( np.clip(rgb, 0.0, 1.0), origin="upper", interpolation="nearest" ) ax_overlay.set_title(f"{title} → intensity overlay") ax_overlay.axis("off") if region_color: handles = [ Patch( facecolor=(*region_color[(c, b)], max(fill_alpha, 0.6)), edgecolor="none", label=f"center {c} · level {b}", ) for (c, b) in region_color ] ax_overlay.legend( handles=handles, fontsize=7, loc="center left", bbox_to_anchor=(1.01, 0.5), frameon=False, ) if created_fig: plt.tight_layout() return fig
[docs] def multidata_phasor_plot( self, gs_list: list[tuple[np.ndarray, np.ndarray]], labels: list[str] | None = None, colors: Any | None = None, harmonic: int = 0, region_cmap: str = "tab10", ax: Any | None = None, figsize: tuple[float, float] = (8, 6), half_circle: bool = True, xlim: tuple[float, float] = (-0.1, 1.1), ylim: tuple[float, float] = (0.0, 0.6), title: str = "Multi-dataset Phasor", legend: bool = True, ) -> Any: """ Scatter several phasor clouds on one shared phasor diagram. Each ``(G, S)`` pair in ``gs_list`` is raveled to points and drawn on a single diagram produced by :meth:`PhasorPlotsMixin.plot_phasor_diagram` (universal semicircle, lifetime ticks, frequency label, axis styling). Every dataset is handed to that one call as a concatenated point cloud together with a matching per-point RGB array, so each dataset keeps its own distinct color; a legend then maps the colors back to ``labels``. Parameters ---------- gs_list : list[tuple[np.ndarray, np.ndarray]] One ``(G, S)`` pair per dataset. Each array is either a ``(H, W)`` map or a ``(n_harmonics, H, W)`` stack (the ``harmonic`` slice is used); the datasets need not share a shape. labels : list[str] | None Legend label for each dataset. Defaults to ``["dataset 1", ...]``. colors : Any | None Explicit color per dataset (anything :func:`matplotlib.colors.to_rgb` accepts). When ``None``, visually distinct colors are taken from ``region_cmap`` via :meth:`_distinct_colors`. harmonic : int Harmonic slice used for any 3-D ``G``/``S`` stack (``0`` is the first harmonic). region_cmap : str Qualitative colormap the automatic per-dataset colors come from. ax : Any | None Axes to draw into. A new figure/axes is created when ``None``. figsize : tuple[float, float] Figure size used when a new figure is created. half_circle : bool Whether to draw only the upper half of the universal phasor circle. xlim, ylim : tuple[float, float] Axis limits for the phasor panel. title : str Title for the phasor diagram. legend : bool Whether to draw the color-to-label legend. Returns ------- Any The Matplotlib figure containing the phasor diagram. """ if len(gs_list) == 0: raise ValueError("gs_list must contain at least one (G, S) pair") n_sets = len(gs_list) if labels is None: labels = [f"dataset {i + 1}" for i in range(n_sets)] elif len(labels) != n_sets: raise ValueError("labels must have one entry per (G, S) pair") if colors is None: rgb_colors = self._distinct_colors(n_sets, region_cmap) elif len(colors) != n_sets: raise ValueError("colors must have one entry per (G, S) pair") else: rgb_colors = [to_rgb(c) for c in colors] created_fig = ax is None if created_fig: fig, ax = plt.subplots(figsize=figsize) else: fig = ax.get_figure() # Concatenate every dataset into one point cloud plus a matching # per-point RGB array, then let plot_phasor_diagram render it all in a # single call (framing + colored scatter). g_parts: list[np.ndarray] = [] s_parts: list[np.ndarray] = [] c_parts: list[np.ndarray] = [] for (g, s), color in zip(gs_list, rgb_colors): g_arr = np.asarray(g, dtype=float) s_arr = np.asarray(s, dtype=float) g_2d = g_arr[harmonic] if g_arr.ndim == 3 else g_arr s_2d = s_arr[harmonic] if s_arr.ndim == 3 else s_arr g_flat = np.ravel(g_2d) s_flat = np.ravel(s_2d) g_parts.append(g_flat) s_parts.append(s_flat) c_parts.append( np.broadcast_to(np.asarray(color, dtype=float), (g_flat.size, 3)) ) g_all = np.concatenate(g_parts) s_all = np.concatenate(s_parts) c_all = np.concatenate(c_parts, axis=0) self.plot_phasor_diagram( g_all, s_all, colors=c_all, ax=ax, half_circle=half_circle, title=title, xlim=xlim, ylim=ylim, kdeplot=False, ) if legend: handles = [ Line2D( [], [], marker="o", linestyle="none", markerfacecolor=color, markeredgecolor="none", markersize=6, label=label, ) for color, label in zip(rgb_colors, labels) ] ax.legend(handles=handles, loc="best", fontsize=8, frameon=False) if created_fig: plt.tight_layout() return fig