Source code for pyfli.phasor.phasorS.phasor_simple_plots

"""
Provide plotting mixins for phasor maps, overlays, pixel fits, and harmonic
visualizations.

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:`PhasorPlotsMixin`.
"""

from typing import Any

import matplotlib.gridspec as gridspec
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns
import torch
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Normalize

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


[docs] class PhasorPlotsMixin: # Plotting and color-mapping methods for PhasorAnalyzer. """ Add plotting methods to phasor analyzers. The mixin renders phasor diagrams, color maps, overlays, pixel fits, harmonic views, and traceable analysis panels without owning acquisition state. """
[docs] def phasor_colormap( self, G: np.ndarray, S: np.ndarray, intensity: np.ndarray | None = None, colormap: str = "viridis", ) -> Any: """ Run the phasor colormap routine. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. intensity : np.ndarray | None Intensity map used to weight or color phasor output. colormap : str Colormap used to convert phasor coordinates to colors. Returns ------- Any Object produced by phasor colormap. """ G_col = G[0] if G.ndim == 3 else G S_col = S[0] if S.ndim == 3 else S phasor_val = np.sqrt(G_col**2 + S_col**2) p_min, p_max = np.nanmin(phasor_val), np.nanmax(phasor_val) phasor_val = (phasor_val - p_min) / (p_max - p_min + self.eps) colors = plt.colormaps[colormap](phasor_val)[:, :, :3] if intensity is not None: denom = intensity.max() - intensity.min() + self.eps int_norm = (intensity - intensity.min()) / denom colors = colors * int_norm[:, :, np.newaxis] return colors
[docs] def phasor_radial_color( self, G: np.ndarray, S: np.ndarray, colormap: str = "viridis", norm_color: bool = False, half_circle: bool = True, ) -> Any: """ Run the phasor radial color routine. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. colormap : str Colormap used to convert phasor coordinates to colors. norm_color : bool Whether phasor colors are normalized before display. half_circle : bool Whether to draw only the upper half of the universal phasor circle. Returns ------- Any Object produced by phasor radial color. """ G_col = G[0] if G.ndim == 3 else np.asarray(G) S_col = S[0] if S.ndim == 3 else np.asarray(S) # Angle and radius relative to universal circle centre (0.5, 0) dG = G_col - 0.5 dS = S_col phi = np.arctan2(dS, dG) r = np.sqrt(dG**2 + dS**2) # Normalise angle to [0, 1] for colormap lookup if norm_color: phi_min = np.nanmin(phi) phi_max = np.nanmax(phi) if phi_max <= phi_min: phi_max = phi_min + 1.0 H = np.clip((phi - phi_min) / (phi_max - phi_min + self.eps), 0, 1) else: phi_lo = 0.0 if half_circle else -np.pi phi_hi = np.pi H = np.clip((phi - phi_lo) / (phi_hi - phi_lo + self.eps), 0, 1) # Base colour from named colormap base_rgb = plt.colormaps[colormap](H)[..., :3] norm_r = r / 0.5 V = np.clip(norm_r, 0, 1) # Inside/on circle: dim proportionally toward centre colors = base_rgb * V[..., np.newaxis] # Outside circle: restore brightness, blend toward white (desaturate) outside = norm_r > 1.0 if outside.any(): sat = np.clip(1.0 / (norm_r[outside] + self.eps), 0, 1) colors[outside] = base_rgb[outside] * sat[:, np.newaxis] + ( 1.0 - sat[:, np.newaxis] ) nan_mask = np.isnan(phi) | np.isnan(r) if nan_mask.any(): colors[nan_mask] = 0.0 return colors
[docs] def plot_phasor_diagram( self, G: np.ndarray, S: np.ndarray, mask: np.ndarray | None = None, colors: Any | None = None, hexbin_color: np.ndarray | None = None, ax: Any | None = None, figsize: tuple[int, ...] = (8, 3), half_circle: bool = True, title: str = "Phasor Diagram", xlim: tuple[float, ...] = (-0.1, 1.1), ylim: tuple[float, ...] = (0.0, 0.6), kdeplot: bool = False, kde_color: str = "white", kde_levels: int = 5, kde_linewidths: float = 1, kde_alpha: float = 0.5, ) -> np.ndarray: """ Plot phasor diagram. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. mask : np.ndarray | None Boolean or labeled mask selecting pixels for the operation. colors : Any | None Color sequence used for plotted sources or groups. hexbin_color : np.ndarray | None Optional values used to color phasor hexbin density. ax : Any | None Matplotlib axes object on which the plot is drawn. figsize : tuple[int, ...] Figure size passed to Matplotlib. half_circle : bool Whether to draw only the upper half of the universal phasor circle. title : str Title displayed on the generated plot. xlim : tuple[float, ...] X-axis limits for the phasor plot. ylim : tuple[float, ...] Y-axis limits for the phasor plot. kdeplot : bool Whether to overlay a KDE density contour on the phasor points. kde_color : str Color used for the KDE density contour lines. kde_levels : int Number of contour levels drawn for the KDE overlay. kde_linewidths : float Line width of the KDE contour lines. kde_alpha : float Opacity of the KDE contour lines. Returns ------- np.ndarray Matplotlib figure or axes containing the phasor diagram. """ created_fig = ax is None if created_fig: fig, ax = plt.subplots(figsize=figsize) else: fig = ax.get_figure() ug, us = _universal_circle_xy(half_circle=half_circle) ax.plot(ug, us, "k--") G_2d = G[0] if (np.ndim(G) == 3) else np.asarray(G) S_2d = S[0] if (np.ndim(S) == 3) else np.asarray(S) g_flat = np.ravel(G_2d) s_flat = np.ravel(S_2d) if mask is not None: mask_flat = np.ravel(mask).astype( bool ) # uint8 masks must be bool; int array would integer-index otherwise g_plot = g_flat[mask_flat] s_plot = s_flat[mask_flat] else: g_plot = g_flat s_plot = s_flat valid = ~np.isnan(g_plot) & ~np.isnan(s_plot) g_plot = g_plot[valid] s_plot = s_plot[valid] if colors is None: cmap_to_use = hexbin_color if hexbin_color is not None else "autumn" hb = ax.hexbin( g_plot, s_plot, gridsize=[800, 400], cmap=cmap_to_use, mincnt=1 ) fig.colorbar(hb, ax=ax).set_label("Pixel Count") else: if isinstance(colors, str): c_vals = g_plot path = ax.scatter( g_plot, s_plot, cmap=colors, c=c_vals, s=8, marker="o" ) fig.colorbar(path, ax=ax).set_label("Phasor G Value") else: c_flat = np.reshape(colors, (-1, 3)) if mask is not None: c_plot = c_flat[mask_flat][valid] else: c_plot = c_flat[valid] ax.scatter(g_plot, s_plot, c=c_plot, s=8, marker="o") if kdeplot: sns.kdeplot( x=g_plot, y=s_plot, ax=ax, levels=kde_levels, color=kde_color, linewidths=kde_linewidths, alpha=kde_alpha, ) G_mark, S_mark = self.lifetime_to_phasor(_TAU_MARKS_NS, self.frequency) _draw_lifetime_ticks( ax, G_mark, S_mark, color="black", lw=4, fontsize=10, show_units=True ) _style_phasor_ax(ax, title=title, xlim=xlim, ylim=ylim, half_circle=half_circle) _add_frequency_label(ax, self.frequency) if created_fig: plt.tight_layout() return fig
[docs] def plot_map( self, image: np.ndarray, scales: list[Any] = [0, 2], title: str = "", ax: Any | None = None, figsize: tuple[int, ...] = (8, 6), ) -> np.ndarray: """ Plot map. Parameters ---------- image : np.ndarray Image array displayed or processed by the routine. scales : list[Any] Display limits used when plotting an image. title : str Title displayed on the generated plot. ax : Any | None Matplotlib axes object on which the plot is drawn. figsize : tuple[int, ...] Figure size passed to Matplotlib. Returns ------- np.ndarray Matplotlib figure or axes containing the rendered map. """ created_fig = ax is None if created_fig: fig, ax = plt.subplots(figsize=figsize) else: fig = ax.get_figure() im = ax.imshow( np.clip(image, scales[0], scales[1]), origin="upper", cmap="viridis" ) fig.colorbar(im, ax=ax).set_label("Lifetime (ns)") ax.set_title(title) ax.set_xlabel("X") ax.set_ylabel("Y") ax.grid(False) if created_fig: plt.tight_layout() return fig
[docs] def plot_phasor_overlay( self, decay: np.ndarray, G: np.ndarray, S: np.ndarray, colormap: str = "viridis", ax: Any | None = None, figsize: tuple[int, ...] = (8, 8), ) -> np.ndarray: """ Plot phasor overlay. Parameters ---------- decay : np.ndarray Time-resolved decay signal or decay cube. G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. colormap : str Colormap used to convert phasor coordinates to colors. ax : Any | None Matplotlib axes object on which the plot is drawn. figsize : tuple[int, ...] Figure size passed to Matplotlib. Returns ------- np.ndarray Matplotlib figure or axes containing the phasor overlay. """ created_fig = ax is None if created_fig: fig, ax = plt.subplots(figsize=figsize) else: fig = ax.get_figure() intensity_img = self.generate_intensity_image(decay) phasor_colors = self.phasor_colormap(G, S, colormap=colormap) int_norm = (intensity_img - intensity_img.min()) / ( intensity_img.max() - intensity_img.min() + self.eps ) overlay = np.stack([int_norm] * 3, axis=2) * phasor_colors ax.imshow(overlay, origin="upper") ax.set_title("Intensity + Phasor Color Overlay") ax.axis("off") if created_fig: plt.tight_layout() return fig
[docs] def plot_pure_phasor_map( self, G: np.ndarray, S: np.ndarray, decay: np.ndarray, noise_removed: bool = True, colormap: str = "viridis", ax: Any | None = None, figsize: tuple[int, ...] = (4, 4), ) -> np.ndarray: """ Plot pure phasor map. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. decay : np.ndarray Time-resolved decay signal or decay cube. noise_removed : bool Whether noise-filtered phasor points are shown. colormap : str Colormap used to convert phasor coordinates to colors. ax : Any | None Matplotlib axes object on which the plot is drawn. figsize : tuple[int, ...] Figure size passed to Matplotlib. Returns ------- np.ndarray Matplotlib figure or axes containing the pure phasor map. """ phasor_colors = self.phasor_colormap(G, S, colormap=colormap) if phasor_colors.shape[-1] == 4: phasor_colors = phasor_colors[..., :3] if noise_removed: intensity_img = self.generate_intensity_image(decay) i_min, i_max = intensity_img.min(), intensity_img.max() denom = (i_max - i_min) if (i_max - i_min) != 0 else 1 int_norm = (intensity_img - i_min) / denom final_mask = int_norm > 0.1 else: final_mask = np.ones(phasor_colors.shape[:2], dtype=bool) pure_overlay = np.zeros_like(phasor_colors) pure_overlay[final_mask] = phasor_colors[final_mask] created_fig = ax is None if created_fig: fig, ax = plt.subplots(figsize=figsize) else: fig = ax.get_figure() ax.imshow(pure_overlay, origin="upper") ax.set_title(f"Pure Phasor Map (Noise Removed: {noise_removed})") ax.axis("off") if created_fig: plt.tight_layout() return fig
[docs] def plot_overlay_subplots( self, decay: np.ndarray, G: np.ndarray, S: np.ndarray, mask: np.ndarray | None = None, colormaps: list[Any] = ["jet", "jet", "viridis", "jet"], noise_removed: bool = True, figsize: tuple[int, ...] = (15, 10), half_circle: bool = True, xlim: tuple[float, ...] = (-0.1, 1.1), ylim: tuple[float, ...] = (0.0, 0.6), bg_color: str = "black", transpose: bool = False, kdeplot: bool = True, kde_levels: int = 3, ) -> np.ndarray: """ Plot overlay subplots. Parameters ---------- decay : np.ndarray Time-resolved decay signal or decay cube. G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. mask : np.ndarray | None Boolean or labeled mask selecting pixels for the operation. colormaps : list[Any] Colormap specifications used for overlay panels. noise_removed : bool Whether noise-filtered phasor points are shown. figsize : tuple[int, ...] Figure size passed to Matplotlib. half_circle : bool Whether to draw only the upper half of the universal phasor circle. xlim : tuple[float, ...] X-axis limits for the phasor plot. ylim : tuple[float, ...] Y-axis limits for the phasor plot. bg_color : str Background color used behind the phasor overlay. transpose : bool Whether image-like arrays are transposed before display. kdeplot : bool Whether to overlay a KDE density contour on the color-scatter phasor panel. kde_levels : int Number of contour levels drawn for the KDE overlay. Returns ------- np.ndarray Matplotlib figure or axes containing overlay subplot panels. """ _t = (lambda a: np.swapaxes(a, 0, 1)) if transpose else (lambda a: a) G_2d = G[0] if G.ndim == 3 else G S_2d = S[0] if S.ndim == 3 else S intensity_img = self.generate_intensity_image(decay) phasor_colors_raw = self.phasor_radial_color( G_2d, S_2d, colormap=colormaps[2], half_circle=half_circle ) if mask is None: if noise_removed: int_norm = (intensity_img - intensity_img.min()) / ( intensity_img.max() - intensity_img.min() + self.eps ) active_mask = int_norm > 0.1 else: active_mask = np.ones(G_2d.shape, dtype=bool) else: active_mask = mask.astype(bool) bg_val = 0.0 if bg_color == "black" else 1.0 def _resolve_cmap(spec: Any) -> np.ndarray: """ Run the resolve cmap routine. Parameters ---------- spec : Any Colormap specification resolved by the plotting helper. Returns ------- np.ndarray Resolved Matplotlib colormap object. """ cmap = plt.colormaps[spec] if isinstance(spec, str) else spec cmap = cmap.copy() cmap.set_bad(bg_color) return cmap cmap1 = _resolve_cmap(colormaps[0]) cmap3 = _resolve_cmap(colormaps[1]) fig = plt.figure(figsize=figsize) gs = gridspec.GridSpec(2, 3, figure=fig) # (0,0) Intensity — inactive pixels → NaN → bg_color ax1 = fig.add_subplot(gs[0, 0]) int_masked = np.where(active_mask, intensity_img.astype(float), np.nan) im1 = ax1.imshow(_t(int_masked), origin="upper", cmap=cmap1) ax1.set_title("Intensity") ax1.axis("off") fig.colorbar(im1, ax=ax1, fraction=0.046, pad=0.04) # (0,1) Phasor colour projection — inactive pixels → bg_val ax2 = fig.add_subplot(gs[0, 1]) pure_overlay = np.full_like(phasor_colors_raw, bg_val) pure_overlay[active_mask] = phasor_colors_raw[active_mask] ax2.imshow(_t(pure_overlay), origin="upper") ax2.set_title("Phasor Color Projections") ax2.axis("off") # (1,0) Lifetime map — inactive pixels → NaN → bg_color ax3 = fig.add_subplot(gs[1, 0]) tau_map_ns = np.clip(self.compute_lifetime(G_2d, S_2d), 0, None) tau_masked = np.where(active_mask, tau_map_ns, np.nan) im3 = ax3.imshow(_t(tau_masked), origin="upper", cmap=cmap3) ax3.set_title("Lifetime Map (ns)") ax3.axis("off") fig.colorbar(im3, ax=ax3, fraction=0.046, pad=0.04).set_label("ns") # (1,1) Intensity-weighted overlay — inactive pixels → bg_val ax4 = fig.add_subplot(gs[1, 1]) int_norm_3d = (intensity_img - intensity_img.min()) / ( intensity_img.max() - intensity_img.min() + self.eps ) weighted_overlay = np.full_like(phasor_colors_raw, bg_val) weighted_overlay[active_mask] = ( np.stack([int_norm_3d] * 3, axis=2) * phasor_colors_raw )[active_mask] ax4.imshow(_t(weighted_overlay), origin="upper") ax4.set_title("Intensity-weighted Overlay") ax4.axis("off") mask_flat = np.ravel(active_mask) g_plot = np.ravel(G_2d)[mask_flat] s_plot = np.ravel(S_2d)[mask_flat] c_plot = np.reshape(phasor_colors_raw, (-1, 3))[mask_flat] valid = ~np.isnan(g_plot) & ~np.isnan(s_plot) g_v, s_v, c_v = g_plot[valid], s_plot[valid], c_plot[valid] ug, us = _universal_circle_xy(half_circle=half_circle) try: G_mark, S_mark = self.lifetime_to_phasor(_TAU_MARKS_NS, self.frequency) except Exception: G_mark = S_mark = None # scatter color by phasor_radial_color ax5 = fig.add_subplot(gs[0, 2]) ax5.plot(ug, us, "k--", alpha=0.8, zorder=1) if len(g_v): ax5.scatter(g_v, s_v, c=c_v, s=2, alpha=0.6, edgecolors="none", zorder=2) if kdeplot: sns.kdeplot( x=g_v, y=s_v, ax=ax5, levels=kde_levels, color="w", linewidths=1, zorder=3, ) if G_mark is not None: _draw_lifetime_ticks(ax5, G_mark, S_mark, color="black", lw=2, fontsize=9) _style_phasor_ax( ax5, title="Phasor (color)", xlim=xlim, ylim=ylim, half_circle=half_circle ) _add_frequency_label(ax5, self.frequency) # ax6 (1,2): phasor hexbin density plot ax6 = fig.add_subplot(gs[1, 2]) ax6.plot(ug, us, "k--", alpha=0.8, zorder=1) if len(g_v): hb = ax6.hexbin( g_v, s_v, gridsize=[800, 400], cmap=colormaps[3], mincnt=1, zorder=2 ) fig.colorbar(hb, ax=ax6, fraction=0.046, pad=0.04).set_label("Pixel count") if G_mark is not None: _draw_lifetime_ticks(ax6, G_mark, S_mark, color="black", lw=2, fontsize=9) _style_phasor_ax( ax6, title="Phasor (density)", xlim=xlim, ylim=ylim, half_circle=half_circle ) _add_frequency_label(ax6, self.frequency) plt.tight_layout() return fig
[docs] def plot_pixel_fit( self, irf: np.ndarray, decay: np.ndarray, reconstructed_decay: np.ndarray, x: np.ndarray, y: np.ndarray, log_scale: bool = True, ax: Any | None = None, figsize: tuple[int, ...] = (10, 6), ) -> np.ndarray: """ Plot pixel fit. Parameters ---------- irf : np.ndarray Instrument response function aligned with the decay signal. decay : np.ndarray Time-resolved decay signal or decay cube. reconstructed_decay : np.ndarray Model decay reconstructed from fitted parameters. x : np.ndarray Input array, coordinate, or signal being transformed. y : np.ndarray Observed signal, target data, or coordinate array. log_scale : bool Whether the decay axis is drawn on a logarithmic scale. ax : Any | None Matplotlib axes object on which the plot is drawn. figsize : tuple[int, ...] Figure size passed to Matplotlib. Returns ------- np.ndarray Matplotlib figure or axes containing the pixel fit. """ irf_trace = irf[y, x, :] if irf.ndim == 3 else np.asarray(irf) raw_trace = decay[y, x, :] fit_trace = reconstructed_decay[y, x, :] traces_np = np.stack([irf_trace, raw_trace, fit_trace], axis=0).astype( np.float32 ) traces_t = torch.tensor(traces_np, device=self.device) maxvals = traces_t.amax(dim=1, keepdim=True).clamp(min=self.eps) norm_t = (traces_t / maxvals).cpu().numpy() irf_norm, raw_norm, fit_norm = norm_t[0], norm_t[1], norm_t[2] created_fig = ax is None if created_fig: fig, ax = plt.subplots(figsize=figsize) else: fig = ax.get_figure() ax.plot(self.time_axis_ns, irf_norm, "k--", alpha=0.5, label="IRF (Normalized)") ax.plot( self.time_axis_ns, raw_norm, "ro", markersize=4, alpha=0.6, label=f"Raw Decay (Pixel {x},{y})", ) ax.plot(self.time_axis_ns, fit_norm, "b-", lw=2, label="Reconstructed Fit") if log_scale: ax.set_yscale("log") ax.set_ylim(1e-3, 1.2) ax.set_ylabel("Normalized Intensity (Log Scale)") else: ax.set_ylabel("Normalized Intensity (Linear Scale)") ax.set_xlabel("Time (ns)") ax.set_title( f"Decay Analysis at Pixel (X: {x}, Y: {y}) [device: {self.device}]" ) ax.legend() ax.grid(True, which="both", linestyle="--", alpha=0.5) if created_fig: plt.tight_layout() return fig
[docs] def plot_pixel_fit_single_exp( self, irf: np.ndarray, decay: np.ndarray, tau_ns: np.ndarray, x: np.ndarray, y: np.ndarray, log_scale: bool = True, ax: Any | None = None, figsize: tuple[int, ...] = (10, 6), ) -> np.ndarray: """ Plot pixel fit single exp. Parameters ---------- irf : np.ndarray Instrument response function aligned with the decay signal. decay : np.ndarray Time-resolved decay signal or decay cube. tau_ns : np.ndarray Lifetime value in nanoseconds. x : np.ndarray Input array, coordinate, or signal being transformed. y : np.ndarray Observed signal, target data, or coordinate array. log_scale : bool Whether the decay axis is drawn on a logarithmic scale. ax : Any | None Matplotlib axes object on which the plot is drawn. figsize : tuple[int, ...] Figure size passed to Matplotlib. Returns ------- np.ndarray Matplotlib figure or axes containing the single-exponential pixel fit. """ if isinstance(tau_ns, (torch.Tensor, np.ndarray)): if tau_ns.ndim >= 2: tau_val = tau_ns[y, x] else: tau_val = tau_ns if torch.is_tensor(tau_val): tau_val = tau_val.item() else: tau_val = tau_ns t_ns_t = torch.tensor( self.t_s_np * 1e9, dtype=torch.float32, device=self.device ) model_t = torch.exp(-t_ns_t / tau_val).unsqueeze(0) irf_trace_np = irf[y, x, :] if irf.ndim == 3 else np.asarray(irf) irf_trace_t = torch.tensor( irf_trace_np.astype(np.float32), device=self.device ).unsqueeze(0) irf_norm_t = irf_trace_t / irf_trace_t.sum(dim=1, keepdim=True).clamp( min=self.eps ) fit_t = self._convolve_batch(model_t, irf_norm_t) fit_trace_np = fit_t.squeeze(0).cpu().numpy() raw_trace_np = decay[y, x, :] traces_np = np.stack([irf_trace_np, raw_trace_np, fit_trace_np], axis=0).astype( np.float32 ) traces_t = torch.tensor(traces_np, device=self.device) maxvals = traces_t.amax(dim=1, keepdim=True).clamp(min=self.eps) norm_t = (traces_t / maxvals).cpu().numpy() irf_norm, raw_norm, fit_norm = norm_t[0], norm_t[1], norm_t[2] created_fig = ax is None if created_fig: fig, ax = plt.subplots(figsize=figsize) else: fig = ax.get_figure() ax.plot(self.time_axis_ns, irf_norm, "k--", alpha=0.5, label="IRF (Normalized)") ax.plot( self.time_axis_ns, raw_norm, "ro", markersize=4, alpha=0.6, label=f"Raw Decay (Pixel {x},{y})", ) ax.plot( self.time_axis_ns, fit_norm, "b-", lw=2, label=f"Single-Exp Fit τ = {tau_val} ns", ) if log_scale: ax.set_yscale("log") ax.set_ylim(1e-3, 1.2) ax.set_ylabel("Normalized Intensity (Log Scale)") else: ax.set_ylabel("Normalized Intensity (Linear Scale)") ax.set_xlabel("Time (ns)") ax.set_title( f"Single-Exponential Decay at Pixel (X: {x}, Y: {y}) " f"τ = {tau_val} ns [device: {self.device}]" ) ax.legend() ax.grid(True, which="both", linestyle="--", alpha=0.5) if created_fig: plt.tight_layout() return fig
[docs] def plot_phasor_harmonics( self, G: np.ndarray, S: np.ndarray, harmonics: tuple[int, ...] = (1, 2, 3, 4), mask: np.ndarray | None = None, colors: Any | None = None, hexbin_color: np.ndarray | None = None, figsize: tuple[int, ...] = (22, 5), axes: Any | None = None, half_circle: bool = True, xlim: tuple[float, ...] = (-0.1, 1.1), ylim: tuple[float, ...] = (0.0, 0.6), ) -> np.ndarray: """ Plot phasor harmonics. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. harmonics : tuple[int, ...] Harmonic indices included in the phasor plot. mask : np.ndarray | None Boolean or labeled mask selecting pixels for the operation. colors : Any | None Color sequence used for plotted sources or groups. hexbin_color : np.ndarray | None Optional values used to color phasor hexbin density. figsize : tuple[int, ...] Figure size passed to Matplotlib. axes : Any | None Matplotlib axes collection used for drawing subplots. half_circle : bool Whether to draw only the upper half of the universal phasor circle. xlim : tuple[float, ...] X-axis limits for the phasor plot. ylim : tuple[float, ...] Y-axis limits for the phasor plot. Returns ------- np.ndarray Matplotlib figure or axes containing phasor harmonics. """ G = np.asarray(G) S = np.asarray(S) n_panels = len(harmonics) created_fig = axes is None if created_fig: fig, axes = plt.subplots(1, n_panels, figsize=figsize) if n_panels == 1: axes = [axes] else: fig = axes[0].get_figure() mask_flat = np.ravel(mask).astype(bool) if mask is not None else None for ax, k in zip(axes, harmonics): if G.ndim == 3 and k <= G.shape[0]: g_panel = G[k - 1] s_panel = S[k - 1] else: g_panel = G[0] if G.ndim == 3 else G s_panel = S[0] if S.ndim == 3 else S ug, us = _universal_circle_xy(half_circle=half_circle) ax.plot(ug, us, "k--", lw=1.2) g_flat = np.ravel(g_panel) s_flat = np.ravel(s_panel) if mask_flat is not None: g_plot = g_flat[mask_flat] s_plot = s_flat[mask_flat] else: g_plot = g_flat s_plot = s_flat valid = ~np.isnan(g_plot) & ~np.isnan(s_plot) g_plot = g_plot[valid] s_plot = s_plot[valid] if colors is None: cmap_to_use = hexbin_color if hexbin_color is not None else "jet" hb = ax.hexbin( g_plot, s_plot, gridsize=[800, 400], cmap=cmap_to_use, mincnt=1 ) fig.colorbar(hb, ax=ax, fraction=0.046, pad=0.04).set_label( "Pixel Count" ) else: if isinstance(colors, str): path = ax.scatter( g_plot, s_plot, cmap=colors, c=g_plot, s=8, marker="o" ) fig.colorbar(path, ax=ax, fraction=0.046, pad=0.04).set_label( "Phasor G Value" ) else: # Bug fix 2: if colors has a harmonic axis (n, H, W, 3) use slice k-1; # otherwise treat as a single (H, W, 3) array shared across all harmonics colors_arr = np.asarray(colors) if colors_arr.ndim == 4 and k - 1 < colors_arr.shape[0]: c_panel = colors_arr[k - 1] else: c_panel = colors_arr c_flat = np.reshape(c_panel, (-1, 3)) if mask_flat is not None: c_plot = c_flat[mask_flat][valid] else: c_plot = c_flat[valid] ax.scatter(g_plot, s_plot, c=c_plot, s=8, marker="o") G_mark, S_mark = self.lifetime_to_phasor(_TAU_MARKS_NS, k * self.frequency) _draw_lifetime_ticks( ax, G_mark, S_mark, color="black", lw=2, fontsize=7, show_units=(k == harmonics[0]), tick_length=0.03, text_offset=0.05, ) _style_phasor_ax( ax, title=rf"Harmonic {k} ($\omega_{{{k}}}$)", xlim=xlim, ylim=ylim, half_circle=half_circle, ) _add_frequency_label(ax, k * self.frequency) if created_fig: fig.suptitle( "Phasor Diagram — Multiple Harmonics", fontsize=12, fontweight="bold", y=1.01, ) plt.tight_layout() return fig
[docs] def plot_traceable_analysis( self, G: np.ndarray, S: np.ndarray, mask: np.ndarray | None = None, colormap: str = "viridis", figsize: tuple[int, ...] = (14, 6), axes: Any | None = None, half_circle: bool = True, xlim: tuple[float, ...] = (-0.1, 1.1), ylim: tuple[float, ...] = (0.0, 0.6), ) -> np.ndarray: """ Plot traceable analysis. Parameters ---------- G : np.ndarray Phasor real coordinate. S : np.ndarray Phasor imaginary coordinate or shift amount. mask : np.ndarray | None Boolean or labeled mask selecting pixels for the operation. colormap : str Colormap used to convert phasor coordinates to colors. figsize : tuple[int, ...] Figure size passed to Matplotlib. axes : Any | None Matplotlib axes collection used for drawing subplots. half_circle : bool Whether to draw only the upper half of the universal phasor circle. xlim : tuple[float, ...] X-axis limits for the phasor plot. ylim : tuple[float, ...] Y-axis limits for the phasor plot. Returns ------- np.ndarray Matplotlib figure or axes containing traceable phasor analysis panels. """ G_2d = G[0] if G.ndim == 3 else G S_2d = S[0] if S.ndim == 3 else S phasor_colors_raw = self.phasor_radial_color( G_2d, S_2d, half_circle=half_circle ) if mask is None: active_mask = np.ones(G_2d.shape, dtype=bool) else: active_mask = mask.astype(bool) created_fig = axes is None if created_fig: fig, axes = plt.subplots(1, 2, figsize=figsize) else: fig = axes[0].get_figure() pure_overlay = np.zeros_like(phasor_colors_raw) pure_overlay[active_mask] = phasor_colors_raw[active_mask] axes[0].imshow(pure_overlay, origin="upper") axes[0].set_title("Phasor Color Projections") axes[0].axis("off") phi = np.arctan2(S_2d, G_2d) first_q = phi[(G_2d > 0) & (S_2d > 0)] phi_min_val = float(np.nanmin(first_q)) if first_q.size > 0 else 0.0 phi_max_val = float(np.nanmax(first_q)) if first_q.size > 0 else 0.5 omega = 2 * np.pi * self.frequency tau_min = np.tan(np.clip(phi_min_val, 1e-6, np.pi / 2 - 0.05)) / omega * 1e9 tau_max = np.tan(np.clip(phi_max_val, 1e-6, np.pi / 2 - 0.05)) / omega * 1e9 sm = ScalarMappable( cmap=plt.colormaps[colormap], norm=Normalize(vmin=tau_min, vmax=tau_max) ) sm.set_array([]) cbar = fig.colorbar(sm, ax=axes[0], fraction=0.046, pad=0.04) cbar.set_label("Lifetime (ns)") ug, us = _universal_circle_xy(half_circle=half_circle) axes[1].plot(ug, us, "k--", alpha=0.8, zorder=1) mask_flat = np.ravel(active_mask) g_plot = np.ravel(G_2d)[mask_flat] s_plot = np.ravel(S_2d)[mask_flat] c_plot = np.reshape(phasor_colors_raw, (-1, 3))[mask_flat] valid_data = ~np.isnan(g_plot) & ~np.isnan(s_plot) g_plot, s_plot, c_plot = ( g_plot[valid_data], s_plot[valid_data], c_plot[valid_data], ) if len(g_plot) > 0: axes[1].scatter( g_plot, s_plot, c=c_plot, s=10, alpha=0.8, edgecolors="none", zorder=2 ) else: axes[1].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, self.frequency) _draw_lifetime_ticks( axes[1], G_mark, S_mark, color="black", lw=2, fontsize=9 ) except Exception: pass _style_phasor_ax( axes[1], title="Phasor Distribution", xlim=xlim, ylim=ylim, half_circle=half_circle, ) _add_frequency_label(axes[1], self.frequency) if created_fig: plt.tight_layout() return fig