"""Plot phasor coordinates and related data.
The ``phasorpy.plot`` module provides functions and classes to visualize
phasor coordinates and related data using the
`matplotlib <https://matplotlib.org/>`_ library.
"""
from __future__ import annotations
__all__ = [
'PhasorPlot',
'PhasorPlotFret',
'plot_phasor',
'plot_phasor_image',
'plot_signal_image',
'plot_polar_frequency',
]
import math
import os
from collections.abc import Sequence
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ._typing import Any, ArrayLike, NDArray, Literal, IO
from matplotlib.axes import Axes
from matplotlib.image import AxesImage
from matplotlib.figure import Figure
import numpy
from matplotlib import pyplot
from matplotlib.font_manager import FontProperties
from matplotlib.gridspec import GridSpec
from matplotlib.lines import Line2D
from matplotlib.patches import Arc, Circle, Ellipse, Polygon
from matplotlib.path import Path
from matplotlib.patheffects import AbstractPathEffect
from matplotlib.widgets import Slider
from ._phasorpy import _intersection_circle_circle, _intersection_circle_line
from ._utils import (
dilate_coordinates,
parse_kwargs,
parse_signal_axis,
phasor_from_polar_scalar,
phasor_to_polar_scalar,
sort_coordinates,
update_kwargs,
)
from .phasor import (
phasor_from_fret_acceptor,
phasor_from_fret_donor,
phasor_from_lifetime,
phasor_semicircle,
phasor_to_apparent_lifetime,
phasor_to_polar,
phasor_transform,
)
GRID_COLOR = '0.5'
GRID_LINESTYLE = ':'
GRID_LINESTYLE_MAJOR = '-'
GRID_LINEWIDH = 1.0
GRID_LINEWIDH_MINOR = 0.5
GRID_FILL = False
[docs]
class PhasorPlot:
"""Phasor plot.
Create publication quality visualizations of phasor coordinates.
Parameters
----------
allquadrants : bool, optional
Show all quandrants of phasor space.
By default, only the first quadrant with universal semicircle is shown.
ax : matplotlib axes, optional
Matplotlib axes used for plotting.
By default, a new subplot axes is created.
frequency : float, optional
Laser pulse or modulation frequency in MHz.
grid : bool, optional, default: True
Display polar grid or semicircle.
**kwargs
Additional properties to set on `ax`.
See Also
--------
phasorpy.plot.plot_phasor
:ref:`sphx_glr_tutorials_api_phasorpy_phasorplot.py`
"""
_ax: Axes
"""Matplotlib axes."""
_limits: tuple[tuple[float, float], tuple[float, float]]
"""Axes limits (xmin, xmax), (ymin, ymax)."""
_full: bool
"""Show all quadrants of phasor space."""
_semicircle_ticks: SemicircleTicks | None
"""Last SemicircleTicks instance created."""
_frequency: float
"""Laser pulse or modulation frequency in MHz."""
def __init__(
self,
/,
allquadrants: bool | None = None,
ax: Axes | None = None,
*,
frequency: float | None = None,
grid: bool = True,
**kwargs: Any,
) -> None:
# initialize empty phasor plot
self._ax = pyplot.subplots()[1] if ax is None else ax
self._ax.format_coord = ( # type: ignore[method-assign]
self._on_format_coord
)
self._semicircle_ticks = None
self._full = bool(allquadrants)
if self._full:
xlim = (-1.05, 1.05)
ylim = (-1.05, 1.05)
xticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0)
yticks: tuple[float, ...] = (-1.0, -0.5, 0.0, 0.5, 1.0)
if grid:
self.polar_grid()
else:
xlim = (-0.05, 1.05)
ylim = (-0.05, 0.7)
xticks = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
yticks = (0.0, 0.2, 0.4, 0.6)
if grid:
self.semicircle(frequency=frequency)
title = 'Phasor plot'
if frequency is not None:
self._frequency = float(frequency)
title += f' ({frequency:g} MHz)'
else:
self._frequency = 0.0
update_kwargs(
kwargs,
title=title,
xlabel='G, real',
ylabel='S, imag',
aspect='equal',
xlim=xlim,
ylim=ylim,
xticks=xticks,
yticks=yticks,
)
self._limits = (kwargs['xlim'], kwargs['ylim'])
self._ax.set(**kwargs)
@property
def ax(self) -> Axes:
"""Matplotlib :py:class:`matplotlib.axes.Axes`."""
return self._ax
@property
def fig(self) -> Figure | None:
"""Matplotlib :py:class:`matplotlib.figure.Figure`."""
try:
# matplotlib >= 3.10.0
return self._ax.get_figure(root=True)
except TypeError:
return self._ax.get_figure() # type: ignore[return-value]
@property
def dataunit_to_point(self) -> float:
"""Factor to convert data to point unit."""
fig = self.fig
assert fig is not None
length = fig.bbox_inches.height * self._ax.get_position().height * 72.0
vrange: float = numpy.diff(self._ax.get_ylim()).item()
return length / vrange
[docs]
def show(self) -> None:
"""Display all open figures. Call :py:func:`matplotlib.pyplot.show`."""
# self.fig.show()
pyplot.show()
[docs]
def save(
self,
file: str | os.PathLike[Any] | IO[bytes] | None,
/,
**kwargs: Any,
) -> None:
"""Save current figure to file.
Parameters
----------
file : str, path-like, or binary file-like
Path or Python file-like object to write the current figure to.
**kwargs
Additional keyword arguments passed to
:py:func:`matplotlib:pyplot.savefig`.
"""
pyplot.savefig(file, **kwargs)
[docs]
def plot(
self,
real: ArrayLike,
imag: ArrayLike,
/,
fmt: str = 'o',
*,
label: str | Sequence[str] | None = None,
**kwargs: Any,
) -> list[Line2D]:
"""Plot imag versus real coordinates as markers and/or lines.
Parameters
----------
real : array_like
Real component of phasor coordinates.
Must be one or two dimensional.
imag : array_like
Imaginary component of phasor coordinates.
Must be of same shape as `real`.
fmt : str, optional, default: 'o'
Matplotlib style format string.
label : str or sequence of str, optional
Plot label.
May be a sequence if phasor coordinates are two dimensional arrays.
**kwargs
Additional parameters passed to
:py:meth:`matplotlib.axes.Axes.plot`.
Returns
-------
list[matplotlib.lines.Line2D]
Lines representing data plotted last.
"""
lines = []
if fmt == 'o':
if 'marker' in kwargs:
fmt = ''
if 'linestyle' not in kwargs and 'ls' not in kwargs:
kwargs['linestyle'] = ''
args = (fmt,) if fmt else ()
ax = self._ax
if label is not None and (
isinstance(label, str) or not isinstance(label, Sequence)
):
label = (label,)
for (
i,
(re, im),
) in enumerate(
zip(
numpy.atleast_2d(numpy.asarray(real)),
numpy.atleast_2d(numpy.asarray(imag)),
)
):
lbl = None
if label is not None:
try:
lbl = label[i]
except IndexError:
pass
lines = ax.plot(re, im, *args, label=lbl, **kwargs)
if label is not None:
ax.legend()
self._reset_limits()
return lines
def _histogram2d(
self,
real: ArrayLike,
imag: ArrayLike,
/,
**kwargs: Any,
) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
"""Return 2D histogram of imag versus real coordinates."""
update_kwargs(kwargs, range=self._limits)
(xmin, xmax), (ymin, ymax) = kwargs['range']
assert xmax > xmin and ymax > ymin
bins = kwargs.get('bins', 128)
if isinstance(bins, int):
assert bins > 0
aspect = (xmax - xmin) / (ymax - ymin)
if aspect > 1:
bins = (bins, max(int(bins / aspect), 1))
else:
bins = (max(int(bins * aspect), 1), bins)
kwargs['bins'] = bins
return numpy.histogram2d(
numpy.asanyarray(real).reshape(-1),
numpy.asanyarray(imag).reshape(-1),
**kwargs,
)
def _reset_limits(self) -> None:
"""Reset axes limits."""
try:
self._ax.set(xlim=self._limits[0], ylim=self._limits[1])
except AttributeError:
pass
[docs]
def hist2d(
self,
real: ArrayLike,
imag: ArrayLike,
/,
**kwargs: Any,
) -> None:
"""Plot 2D histogram of imag versus real coordinates.
Parameters
----------
real : array_like
Real component of phasor coordinates.
imag : array_like
Imaginary component of phasor coordinates.
Must be of same shape as `real`.
**kwargs
Additional parameters passed to :py:meth:`numpy.histogram2d`
and :py:meth:`matplotlib.axes.Axes.pcolormesh`.
"""
kwargs_hist2d = parse_kwargs(
kwargs, 'bins', 'range', 'density', 'weights'
)
h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d)
update_kwargs(kwargs, cmap='Blues', norm='log')
cmin = kwargs.pop('cmin', 1)
cmax = kwargs.pop('cmax', None)
if cmin is not None:
h[h < cmin] = None
if cmax is not None:
h[h > cmax] = None
self._ax.pcolormesh(xedges, yedges, h.T, **kwargs)
self._reset_limits()
[docs]
def contour(
self,
real: ArrayLike,
imag: ArrayLike,
/,
**kwargs: Any,
) -> None:
"""Plot contours of imag versus real coordinates (not implemented).
Parameters
----------
real : array_like
Real component of phasor coordinates.
imag : array_like
Imaginary component of phasor coordinates.
Must be of same shape as `real`.
**kwargs
Additional parameters passed to :py:func:`numpy.histogram2d`
and :py:meth:`matplotlib.axes.Axes.contour`.
"""
update_kwargs(kwargs, cmap='Blues', norm='log')
kwargs_hist2d = parse_kwargs(
kwargs, 'bins', 'range', 'density', 'weights'
)
h, xedges, yedges = self._histogram2d(real, imag, **kwargs_hist2d)
xedges = xedges[:-1] + ((xedges[1] - xedges[0]) / 2.0)
yedges = yedges[:-1] + ((yedges[1] - yedges[0]) / 2.0)
self._ax.contour(xedges, yedges, h.T, **kwargs)
self._reset_limits()
[docs]
def imshow(
self,
image: ArrayLike,
/,
**kwargs: Any,
) -> None:
"""Plot an image, for example, a 2D histogram (not implemented).
Parameters
----------
image : array_like
Image to display.
**kwargs
Additional parameters passed to
:py:meth:`matplotlib.axes.Axes.imshow`.
"""
raise NotImplementedError
[docs]
def components(
self,
real: ArrayLike,
imag: ArrayLike,
/,
fraction: ArrayLike | None = None,
labels: Sequence[str] | None = None,
label_offset: float | None = None,
**kwargs: Any,
) -> None:
"""Plot linear combinations of phasor coordinates or ranges thereof.
Parameters
----------
real : (N,) array_like
Real component of phasor coordinates.
imag : (N,) array_like
Imaginary component of phasor coordinates.
fraction : (N,) array_like, optional
Weight associated with each component.
If None (default), outline the polygon area of possible linear
combinations of components.
Else, draw lines from the component coordinates to the weighted
average.
labels : Sequence of str, optional
Text label for each component.
label_offset : float, optional
Distance of text label to component coordinate.
**kwargs
Additional parameters passed to
:py:class:`matplotlib.patches.Polygon`,
:py:class:`matplotlib.lines.Line2D`, or
:py:class:`matplotlib.axes.Axes.annotate`
"""
# TODO: use convex hull for outline
# TODO: improve automatic placement of labels
# TODO: catch more annotate properties?
real, imag, indices = sort_coordinates(real, imag)
label_ = kwargs.pop('label', None)
marker = kwargs.pop('marker', None)
color = kwargs.pop('color', None)
fontsize = kwargs.pop('fontsize', 12)
fontweight = kwargs.pop('fontweight', 'bold')
horizontalalignment = kwargs.pop('horizontalalignment', 'center')
verticalalignment = kwargs.pop('verticalalignment', 'center')
if label_offset is None:
label_offset = numpy.diff(self._ax.get_xlim()).item() * 0.04
if labels is not None:
if len(labels) != real.size:
raise ValueError(
f'number labels={len(labels)} != components={real.size}'
)
labels = [labels[i] for i in indices]
textposition = dilate_coordinates(real, imag, label_offset)
for label, re, im, x, y in zip(labels, real, imag, *textposition):
if not label:
continue
self._ax.annotate(
label,
(re, im),
xytext=(x, y),
color=color,
fontsize=fontsize,
fontweight=fontweight,
horizontalalignment=horizontalalignment,
verticalalignment=verticalalignment,
)
if fraction is None:
update_kwargs(
kwargs,
edgecolor=GRID_COLOR if color is None else color,
linestyle=GRID_LINESTYLE,
linewidth=GRID_LINEWIDH,
fill=GRID_FILL,
)
self._ax.add_patch(Polygon(numpy.vstack((real, imag)).T, **kwargs))
if marker is not None:
self._ax.plot(
real,
imag,
marker=marker,
linestyle='',
color=color,
label=label_,
)
if label_ is not None:
self._ax.legend()
return
fraction = numpy.asarray(fraction)[indices]
update_kwargs(
kwargs,
color=GRID_COLOR if color is None else color,
linestyle=GRID_LINESTYLE,
linewidth=GRID_LINEWIDH,
)
center_re, center_im = numpy.average(
numpy.vstack((real, imag)), axis=-1, weights=fraction
)
for re, im in zip(real, imag):
self._ax.add_line(
Line2D([center_re, re], [center_im, im], **kwargs)
)
if marker is not None:
self._ax.plot(real, imag, marker=marker, linestyle='', color=color)
self._ax.plot(
center_re,
center_im,
marker=marker,
linestyle='',
color=color,
label=label_,
)
if label_ is not None:
self._ax.legend()
[docs]
def line(
self,
real: ArrayLike,
imag: ArrayLike,
/,
**kwargs: Any,
) -> list[Line2D]:
"""Draw grid line.
Parameters
----------
real : array_like, shape (n, )
Real components of line start and end coordinates.
imag : array_like, shape (n, )
Imaginary components of line start and end coordinates.
**kwargs
Additional parameters passed to
:py:class:`matplotlib.lines.Line2D`.
Returns
-------
list[matplotlib.lines.Line2D]
List containing plotted line.
"""
update_kwargs(
kwargs,
color=GRID_COLOR,
linestyle=GRID_LINESTYLE,
linewidth=GRID_LINEWIDH,
)
return [self._ax.add_line(Line2D(real, imag, **kwargs))]
[docs]
def circle(
self,
real: float,
imag: float,
/,
radius: float,
**kwargs: Any,
) -> None:
"""Draw grid circle of radius around center.
Parameters
----------
real : float
Real component of circle center coordinate.
imag : float
Imaginary component of circle center coordinate.
radius : float
Circle radius.
**kwargs
Additional parameters passed to
:py:class:`matplotlib.patches.Circle`.
"""
update_kwargs(
kwargs,
color=GRID_COLOR,
linestyle=GRID_LINESTYLE,
linewidth=GRID_LINEWIDH,
fill=GRID_FILL,
)
self._ax.add_patch(Circle((real, imag), radius, **kwargs))
[docs]
def cursor(
self,
real: float,
imag: float,
/,
real_limit: float | None = None,
imag_limit: float | None = None,
radius: float | None = None,
radius_minor: float | None = None,
angle: float | None = None,
align_semicircle: bool = False,
**kwargs: Any,
) -> None:
"""Plot phase and modulation grid lines and arcs at phasor coordinates.
Parameters
----------
real : float
Real component of phasor coordinate.
imag : float
Imaginary component of phasor coordinate.
real_limit : float, optional
Real component of limiting phasor coordinate.
imag_limit : float, optional
Imaginary component of limiting phasor coordinate.
radius : float, optional
Radius of circle limiting phase and modulation grid lines and arcs.
radius_minor : float, optional
Radius of elliptic cursor along semi-minor axis.
By default, `radius_minor` is equal to `radius`, that is,
the ellipse is circular.
angle : float, optional
Rotation angle of semi-major axis of elliptic cursor in radians.
If None (default), orient ellipse cursor according to
`align_semicircle`.
align_semicircle : bool, optional
Determines elliptic cursor orientation if `angle` is not provided.
If true, align the minor axis of the ellipse with the closest
tangent on the universal semicircle, else align to the unit circle.
**kwargs
Additional parameters passed to
:py:class:`matplotlib.lines.Line2D`,
:py:class:`matplotlib.patches.Circle`,
:py:class:`matplotlib.patches.Ellipse`, or
:py:class:`matplotlib.patches.Arc`.
See Also
--------
phasorpy.plot.PhasorPlot.polar_cursor
"""
if real_limit is not None and imag_limit is not None:
return self.polar_cursor(
*phasor_to_polar_scalar(real, imag),
*phasor_to_polar_scalar(real_limit, imag_limit),
radius=radius,
radius_minor=radius_minor,
angle=angle,
align_semicircle=align_semicircle,
**kwargs,
)
return self.polar_cursor(
*phasor_to_polar_scalar(real, imag),
radius=radius,
radius_minor=radius_minor,
angle=angle,
align_semicircle=align_semicircle,
# _circle_only=True,
**kwargs,
)
[docs]
def polar_cursor(
self,
phase: float | None = None,
modulation: float | None = None,
phase_limit: float | None = None,
modulation_limit: float | None = None,
radius: float | None = None,
radius_minor: float | None = None,
angle: float | None = None,
align_semicircle: bool = False,
**kwargs: Any,
) -> None:
"""Plot phase and modulation grid lines and arcs.
Parameters
----------
phase : float, optional
Angular component of polar coordinate in radians.
modulation : float, optional
Radial component of polar coordinate.
phase_limit : float, optional
Angular component of limiting polar coordinate (in radians).
Modulation grid arcs are drawn between `phase` and `phase_limit`.
modulation_limit : float, optional
Radial component of limiting polar coordinate.
Phase grid lines are drawn from `modulation` to `modulation_limit`.
radius : float, optional
Radius of circle limiting phase and modulation grid lines and arcs.
radius_minor : float, optional
Radius of elliptic cursor along semi-minor axis.
By default, `radius_minor` is equal to `radius`, that is,
the ellipse is circular.
angle : float, optional
Rotation angle of semi-major axis of elliptic cursor in radians.
If None (default), orient ellipse cursor according to
`align_semicircle`.
align_semicircle : bool, optional
Determines elliptic cursor orientation if `angle` is not provided.
If true, align the minor axis of the ellipse with the closest
tangent on the universal semicircle, else align to the unit circle.
**kwargs
Additional parameters passed to
:py:class:`matplotlib.lines.Line2D`,
:py:class:`matplotlib.patches.Circle`,
:py:class:`matplotlib.patches.Ellipse`, or
:py:class:`matplotlib.patches.Arc`.
See Also
--------
phasorpy.plot.PhasorPlot.cursor
"""
update_kwargs(
kwargs,
color=GRID_COLOR,
linestyle=GRID_LINESTYLE,
linewidth=GRID_LINEWIDH,
fill=GRID_FILL,
)
_circle_only = kwargs.pop('_circle_only', False)
ax = self._ax
if radius is not None and phase is not None and modulation is not None:
x = modulation * math.cos(phase)
y = modulation * math.sin(phase)
if radius_minor is not None and radius_minor != radius:
if angle is None:
if align_semicircle:
angle = math.atan2(y, x - 0.5)
else:
angle = phase
angle = math.degrees(angle)
ax.add_patch(
Ellipse(
(x, y),
radius * 2,
radius_minor * 2,
angle=angle,
**kwargs,
)
)
# TODO: implement gridlines intersecting with ellipse
return None
ax.add_patch(Circle((x, y), radius, **kwargs))
if _circle_only:
return None
del kwargs['fill']
x0, y0, x1, y1 = _intersection_circle_line(
x, y, radius, 0, 0, x, y
)
ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs))
x0, y0, x1, y1 = _intersection_circle_circle(
0, 0, modulation, x, y, radius
)
ax.add_patch(
Arc(
(0, 0),
modulation * 2,
modulation * 2,
theta1=math.degrees(math.atan2(y0, x0)),
theta2=math.degrees(math.atan2(y1, x1)),
fill=False,
**kwargs,
)
)
return None
del kwargs['fill']
for phi in (phase, phase_limit):
if phi is not None:
if modulation is not None and modulation_limit is not None:
x0 = modulation * math.cos(phi)
y0 = modulation * math.sin(phi)
x1 = modulation_limit * math.cos(phi)
y1 = modulation_limit * math.sin(phi)
else:
x0 = 0
y0 = 0
x1 = math.cos(phi)
y1 = math.sin(phi)
ax.add_line(Line2D((x0, x1), (y0, y1), **kwargs))
for mod in (modulation, modulation_limit):
if mod is not None:
if phase is not None and phase_limit is not None:
theta1 = math.degrees(min(phase, phase_limit))
theta2 = math.degrees(max(phase, phase_limit))
else:
theta1 = 0.0
theta2 = 360.0 if self._full else 90.0
ax.add_patch(
Arc(
(0, 0),
mod * 2,
mod * 2,
theta1=theta1,
theta2=theta2,
fill=False, # filling arc objects is not supported
**kwargs,
)
)
return None
[docs]
def polar_grid(self, **kwargs: Any) -> None:
"""Draw polar coordinate system.
Parameters
----------
**kwargs
Parameters passed to
:py:class:`matplotlib.patches.Circle` and
:py:class:`matplotlib.lines.Line2D`.
"""
ax = self._ax
# major gridlines
kwargs_copy = kwargs.copy()
update_kwargs(
kwargs,
color=GRID_COLOR,
linestyle=GRID_LINESTYLE_MAJOR,
linewidth=GRID_LINEWIDH,
# fill=GRID_FILL,
)
ax.add_line(Line2D([-1, 1], [0, 0], **kwargs))
ax.add_line(Line2D([0, 0], [-1, 1], **kwargs))
ax.add_patch(Circle((0, 0), 1, fill=False, **kwargs))
# minor gridlines
kwargs = kwargs_copy
update_kwargs(
kwargs,
color=GRID_COLOR,
linestyle=GRID_LINESTYLE,
linewidth=GRID_LINEWIDH_MINOR,
)
for r in (1 / 3, 2 / 3):
ax.add_patch(Circle((0, 0), r, fill=False, **kwargs))
for a in (3, 6):
x = math.cos(math.pi / a)
y = math.sin(math.pi / a)
ax.add_line(Line2D([-x, x], [-y, y], **kwargs))
ax.add_line(Line2D([-x, x], [y, -y], **kwargs))
[docs]
def semicircle(
self,
frequency: float | None = None,
*,
polar_reference: tuple[float, float] | None = None,
phasor_reference: tuple[float, float] | None = None,
lifetime: Sequence[float] | None = None,
labels: Sequence[str] | None = None,
show_circle: bool = True,
use_lines: bool = False,
**kwargs: Any,
) -> list[Line2D]:
"""Draw universal semicircle.
Parameters
----------
frequency : float, optional
Laser pulse or modulation frequency in MHz.
polar_reference : (float, float), optional, default: (0, 1)
Polar coordinates of zero lifetime.
phasor_reference : (float, float), optional, default: (1, 0)
Phasor coordinates of zero lifetime.
Alternative to `polar_reference`.
lifetime : sequence of float, optional
Single component lifetimes at which to draw ticks and labels.
Only applies when `frequency` is specified.
labels : sequence of str, optional
Tick labels. By default, the values of `lifetime`.
Only applies when `frequency` and `lifetime` are specified.
show_circle : bool, optional, default: True
Draw universal semicircle.
use_lines : bool, optional, default: False
Draw universal semicircle using lines instead of arc.
**kwargs
Additional parameters passed to
:py:class:`matplotlib.lines.Line2D` or
:py:class:`matplotlib.patches.Arc` and
:py:meth:`matplotlib.axes.Axes.plot`.
Returns
-------
list[matplotlib.lines.Line2D]
Lines representing plotted semicircle and ticks.
"""
if frequency is not None:
self._frequency = float(frequency)
update_kwargs(
kwargs,
color=GRID_COLOR,
linestyle=GRID_LINESTYLE_MAJOR,
linewidth=GRID_LINEWIDH,
)
if phasor_reference is not None:
polar_reference = phasor_to_polar_scalar(*phasor_reference)
if polar_reference is None:
polar_reference = (0.0, 1.0)
if phasor_reference is None:
phasor_reference = phasor_from_polar_scalar(*polar_reference)
ax = self._ax
lines = []
if show_circle:
if use_lines:
lines = [
ax.add_line(
Line2D(
*phasor_transform(
*phasor_semicircle(), *polar_reference
),
**kwargs,
)
)
]
else:
ax.add_patch(
Arc(
(phasor_reference[0] / 2, phasor_reference[1] / 2),
polar_reference[1],
polar_reference[1],
theta1=math.degrees(polar_reference[0]),
theta2=math.degrees(polar_reference[0]) + 180.0,
fill=False,
**kwargs,
)
)
if frequency is not None and polar_reference == (0.0, 1.0):
# draw ticks and labels
lifetime, labels = _semicircle_ticks(frequency, lifetime, labels)
self._semicircle_ticks = SemicircleTicks(labels=labels)
lines.extend(
ax.plot(
*phasor_transform(
*phasor_from_lifetime(frequency, lifetime),
*polar_reference,
),
path_effects=[self._semicircle_ticks],
**kwargs,
)
)
self._reset_limits()
return lines
def _on_format_coord(self, x: float, y: float) -> str:
"""Callback function to update coordinates displayed in toolbar."""
phi, mod = phasor_to_polar_scalar(x, y)
ret = [
f'[{x:4.2f}, {y:4.2f}]',
f'[{math.degrees(phi):.0f}°, {mod * 100:.0f}%]',
]
if x > 0.0 and y > 0.0 and self._frequency > 0.0:
tp, tm = phasor_to_apparent_lifetime(x, y, self._frequency)
ret.append(f'[{tp:.2f}, {tm:.2f} ns]')
return ' '.join(reversed(ret))
[docs]
class PhasorPlotFret(PhasorPlot):
"""FRET phasor plot.
Plot Förster Resonance Energy Transfer efficiency trajectories
of donor and acceptor channels in phasor space.
Parameters
----------
frequency : array_like
Laser pulse or modulation frequency in MHz.
donor_lifetime : array_like
Lifetime of donor without FRET in ns.
acceptor_lifetime : array_like
Lifetime of acceptor in ns.
fret_efficiency : array_like, optional, default 0
FRET efficiency in range [0..1].
donor_freting : array_like, optional, default 1
Fraction of donors participating in FRET. Range [0..1].
donor_bleedthrough : array_like, optional, default 0
Weight of donor fluorescence in acceptor channel
relative to fluorescence of fully sensitized acceptor.
A weight of 1 means the fluorescence from donor and fully sensitized
acceptor are equal.
The background in the donor channel does not bleed through.
acceptor_bleedthrough : array_like, optional, default 0
Weight of fluorescence from directly excited acceptor
relative to fluorescence of fully sensitized acceptor.
A weight of 1 means the fluorescence from directly excited acceptor
and fully sensitized acceptor are equal.
acceptor_background : array_like, optional, default 0
Weight of background fluorescence in acceptor channel
relative to fluorescence of fully sensitized acceptor.
A weight of 1 means the fluorescence of background and fully
sensitized acceptor are equal.
donor_background : array_like, optional, default 0
Weight of background fluorescence in donor channel
relative to fluorescence of donor without FRET.
A weight of 1 means the fluorescence of background and donor
without FRET are equal.
background_real : array_like, optional, default 0
Real component of background fluorescence phasor coordinate
at `frequency`.
background_imag : array_like, optional, default 0
Imaginary component of background fluorescence phasor coordinate
at `frequency`.
ax : matplotlib axes, optional
Matplotlib axes used for plotting.
By default, a new subplot axes is created.
Cannot be used with `interactive` mode.
interactive : bool, optional, default: False
Use matplotlib slider widgets to interactively control parameters.
**kwargs
Additional parameters passed to :py:class:`phasorpy.plot.PhasorPlot`.
See Also
--------
phasorpy.phasor.phasor_from_fret_donor
phasorpy.phasor.phasor_from_fret_acceptor
:ref:`sphx_glr_tutorials_api_phasorpy_fret.py`
"""
_fret_efficiencies: NDArray[Any]
_frequency_slider: Slider
_donor_lifetime_slider: Slider
_acceptor_lifetime_slider: Slider
_fret_efficiency_slider: Slider
_donor_freting_slider: Slider
_donor_bleedthrough_slider: Slider
_acceptor_bleedthrough_slider: Slider
_acceptor_background_slider: Slider
_donor_background_slider: Slider
_background_real_slider: Slider
_background_imag_slider: Slider
_donor_line: Line2D
_donor_only_line: Line2D
_donor_fret_line: Line2D
_donor_trajectory_line: Line2D
_donor_semicircle_line: Line2D
_donor_donor_line: Line2D
_donor_background_line: Line2D
_acceptor_line: Line2D
_acceptor_only_line: Line2D
_acceptor_trajectory_line: Line2D
_acceptor_semicircle_line: Line2D
_acceptor_background_line: Line2D
_background_line: Line2D
_donor_semicircle_ticks: SemicircleTicks | None
def __init__(
self,
*,
frequency: float = 60.0,
donor_lifetime: float = 4.2,
acceptor_lifetime: float = 3.0,
fret_efficiency: float = 0.5,
donor_freting: float = 1.0,
donor_bleedthrough: float = 0.0,
acceptor_bleedthrough: float = 0.0,
acceptor_background: float = 0.0,
donor_background: float = 0.0,
background_real: float = 0.0,
background_imag: float = 0.0,
ax: Axes | None = None,
interactive: bool = False,
**kwargs: Any,
) -> None:
update_kwargs(
kwargs,
title='FRET phasor plot',
xlim=[-0.2, 1.1],
ylim=[-0.1, 0.8],
)
kwargs['allquadrants'] = False
kwargs['grid'] = False
if ax is not None:
interactive = False
else:
fig = pyplot.figure()
ax = fig.add_subplot()
if interactive:
w, h = fig.get_size_inches()
fig.set_size_inches(w, h * 1.66)
fig.subplots_adjust(bottom=0.45)
fcm = fig.canvas.manager
if fcm is not None:
fcm.set_window_title(kwargs['title'])
super().__init__(ax=ax, **kwargs)
self._fret_efficiencies = numpy.linspace(0.0, 1.0, 101)
donor_real, donor_imag = phasor_from_lifetime(
frequency, donor_lifetime
)
donor_fret_real, donor_fret_imag = phasor_from_lifetime(
frequency, donor_lifetime * (1.0 - fret_efficiency)
)
acceptor_real, acceptor_imag = phasor_from_lifetime(
frequency, acceptor_lifetime
)
donor_trajectory_real, donor_trajectory_imag = phasor_from_fret_donor(
frequency,
donor_lifetime,
fret_efficiency=self._fret_efficiencies,
donor_freting=donor_freting,
donor_background=donor_background,
background_real=background_real,
background_imag=background_imag,
)
(
acceptor_trajectory_real,
acceptor_trajectory_imag,
) = phasor_from_fret_acceptor(
frequency,
donor_lifetime,
acceptor_lifetime,
fret_efficiency=self._fret_efficiencies,
donor_freting=donor_freting,
donor_bleedthrough=donor_bleedthrough,
acceptor_bleedthrough=acceptor_bleedthrough,
acceptor_background=acceptor_background,
background_real=background_real,
background_imag=background_imag,
)
# add plots
lines = self.semicircle(frequency=frequency)
self._donor_semicircle_line = lines[0]
self._donor_semicircle_ticks = self._semicircle_ticks
lines = self.semicircle(
phasor_reference=(float(acceptor_real), float(acceptor_imag)),
use_lines=True,
)
self._acceptor_semicircle_line = lines[0]
if donor_freting < 1.0 and donor_background == 0.0:
lines = self.line(
[donor_real, donor_fret_real],
[donor_imag, donor_fret_imag],
)
else:
lines = self.line([0.0, 0.0], [0.0, 0.0])
self._donor_donor_line = lines[0]
if acceptor_background > 0.0:
lines = self.line(
[float(acceptor_real), float(background_real)],
[float(acceptor_imag), float(background_imag)],
)
else:
lines = self.line([0.0, 0.0], [0.0, 0.0])
self._acceptor_background_line = lines[0]
if donor_background > 0.0:
lines = self.line(
[float(donor_real), float(background_real)],
[float(donor_imag), float(background_imag)],
)
else:
lines = self.line([0.0, 0.0], [0.0, 0.0])
self._donor_background_line = lines[0]
lines = self.plot(
donor_trajectory_real,
donor_trajectory_imag,
'-',
color='tab:green',
)
self._donor_trajectory_line = lines[0]
lines = self.plot(
acceptor_trajectory_real,
acceptor_trajectory_imag,
'-',
color='tab:red',
)
self._acceptor_trajectory_line = lines[0]
lines = self.plot(
donor_real,
donor_imag,
'.',
color='tab:green',
)
self._donor_only_line = lines[0]
lines = self.plot(
donor_real,
donor_imag,
'.',
color='tab:green',
)
self._donor_fret_line = lines[0]
lines = self.plot(
acceptor_real,
acceptor_imag,
'.',
color='tab:red',
)
self._acceptor_only_line = lines[0]
lines = self.plot(
donor_trajectory_real[int(fret_efficiency * 100.0)],
donor_trajectory_imag[int(fret_efficiency * 100.0)],
'o',
color='tab:green',
label='Donor',
)
self._donor_line = lines[0]
lines = self.plot(
acceptor_trajectory_real[int(fret_efficiency * 100.0)],
acceptor_trajectory_imag[int(fret_efficiency * 100.0)],
'o',
color='tab:red',
label='Acceptor',
)
self._acceptor_line = lines[0]
lines = self.plot(
background_real,
background_imag,
'o',
color='black',
label='Background',
)
self._background_line = lines[0]
if not interactive:
return
# add sliders
axes = []
for i in range(11):
axes.append(fig.add_axes((0.33, 0.05 + i * 0.03, 0.45, 0.01)))
self._frequency_slider = Slider(
ax=axes[10],
label='Frequency ',
valfmt=' %.0f MHz',
valmin=10,
valmax=200,
valstep=1,
valinit=frequency,
)
self._frequency_slider.on_changed(self._on_semicircle_changed)
self._donor_lifetime_slider = Slider(
ax=axes[9],
label='Donor lifetime ',
valfmt=' %.1f ns',
valmin=0.1,
valmax=16.0,
valstep=0.1,
valinit=donor_lifetime,
# facecolor='tab:green',
handle_style={'edgecolor': 'tab:green'},
)
self._donor_lifetime_slider.on_changed(self._on_changed)
self._acceptor_lifetime_slider = Slider(
ax=axes[8],
label='Acceptor lifetime ',
valfmt=' %.1f ns',
valmin=0.1,
valmax=16.0,
valstep=0.1,
valinit=acceptor_lifetime,
# facecolor='tab:red',
handle_style={'edgecolor': 'tab:red'},
)
self._acceptor_lifetime_slider.on_changed(self._on_semicircle_changed)
self._fret_efficiency_slider = Slider(
ax=axes[7],
label='FRET efficiency ',
valfmt=' %.2f',
valmin=0.0,
valmax=1.0,
valstep=0.01,
valinit=fret_efficiency,
)
self._fret_efficiency_slider.on_changed(self._on_changed)
self._donor_freting_slider = Slider(
ax=axes[6],
label='Donors FRETing ',
valfmt=' %.2f',
valmin=0.0,
valmax=1.0,
valstep=0.01,
valinit=donor_freting,
# facecolor='tab:green',
handle_style={'edgecolor': 'tab:green'},
)
self._donor_freting_slider.on_changed(self._on_changed)
self._donor_bleedthrough_slider = Slider(
ax=axes[5],
label='Donor bleedthrough ',
valfmt=' %.2f',
valmin=0.0,
valmax=5.0,
valstep=0.01,
valinit=donor_bleedthrough,
# facecolor='tab:red',
handle_style={'edgecolor': 'tab:red'},
)
self._donor_bleedthrough_slider.on_changed(self._on_changed)
self._acceptor_bleedthrough_slider = Slider(
ax=axes[4],
label='Acceptor bleedthrough ',
valfmt=' %.2f',
valmin=0.0,
valmax=5.0,
valstep=0.01,
valinit=acceptor_bleedthrough,
# facecolor='tab:red',
handle_style={'edgecolor': 'tab:red'},
)
self._acceptor_bleedthrough_slider.on_changed(self._on_changed)
self._acceptor_background_slider = Slider(
ax=axes[3],
label='Acceptor background ',
valfmt=' %.2f',
valmin=0.0,
valmax=5.0,
valstep=0.01,
valinit=acceptor_background,
# facecolor='tab:red',
handle_style={'edgecolor': 'tab:red'},
)
self._acceptor_background_slider.on_changed(self._on_changed)
self._donor_background_slider = Slider(
ax=axes[2],
label='Donor background ',
valfmt=' %.2f',
valmin=0.0,
valmax=5.0,
valstep=0.01,
valinit=donor_background,
# facecolor='tab:green',
handle_style={'edgecolor': 'tab:green'},
)
self._donor_background_slider.on_changed(self._on_changed)
self._background_real_slider = Slider(
ax=axes[1],
label='Background real ',
valfmt=' %.2f',
valmin=0.0,
valmax=1.0,
valstep=0.01,
valinit=background_real,
)
self._background_real_slider.on_changed(self._on_changed)
self._background_imag_slider = Slider(
ax=axes[0],
label='Background imag ',
valfmt=' %.2f',
valmin=0.0,
valmax=0.6,
valstep=0.01,
valinit=background_imag,
)
self._background_imag_slider.on_changed(self._on_changed)
def _on_semicircle_changed(self, value: Any) -> None:
"""Callback function to update semicircles."""
self._frequency = frequency = self._frequency_slider.val
acceptor_lifetime = self._acceptor_lifetime_slider.val
if self._donor_semicircle_ticks is not None:
lifetime, labels = _semicircle_ticks(frequency)
self._donor_semicircle_ticks.labels = labels
self._donor_semicircle_line.set_data(
*phasor_transform(*phasor_from_lifetime(frequency, lifetime))
)
self._acceptor_semicircle_line.set_data(
*phasor_transform(
*phasor_semicircle(),
*phasor_to_polar(
*phasor_from_lifetime(frequency, acceptor_lifetime)
),
)
)
self._on_changed(value)
def _on_changed(self, value: Any) -> None:
"""Callback function to update plot with current slider values."""
frequency = self._frequency_slider.val
donor_lifetime = self._donor_lifetime_slider.val
acceptor_lifetime = self._acceptor_lifetime_slider.val
fret_efficiency = self._fret_efficiency_slider.val
donor_freting = self._donor_freting_slider.val
donor_bleedthrough = self._donor_bleedthrough_slider.val
acceptor_bleedthrough = self._acceptor_bleedthrough_slider.val
acceptor_background = self._acceptor_background_slider.val
donor_background = self._donor_background_slider.val
background_real = self._background_real_slider.val
background_imag = self._background_imag_slider.val
e = int(self._fret_efficiency_slider.val * 100)
donor_real, donor_imag = phasor_from_lifetime(
frequency, donor_lifetime
)
donor_fret_real, donor_fret_imag = phasor_from_lifetime(
frequency, donor_lifetime * (1.0 - fret_efficiency)
)
acceptor_real, acceptor_imag = phasor_from_lifetime(
frequency, acceptor_lifetime
)
donor_trajectory_real, donor_trajectory_imag = phasor_from_fret_donor(
frequency,
donor_lifetime,
fret_efficiency=self._fret_efficiencies,
donor_freting=donor_freting,
donor_background=donor_background,
background_real=background_real,
background_imag=background_imag,
)
(
acceptor_trajectory_real,
acceptor_trajectory_imag,
) = phasor_from_fret_acceptor(
frequency,
donor_lifetime,
acceptor_lifetime,
fret_efficiency=self._fret_efficiencies,
donor_freting=donor_freting,
donor_bleedthrough=donor_bleedthrough,
acceptor_bleedthrough=acceptor_bleedthrough,
acceptor_background=acceptor_background,
background_real=background_real,
background_imag=background_imag,
)
if donor_background > 0.0:
self._donor_background_line.set_data(
[float(donor_real), float(background_real)],
[float(donor_imag), float(background_imag)],
)
else:
self._donor_background_line.set_data([0.0, 0.0], [0.0, 0.0])
if donor_freting < 1.0 and donor_background == 0.0:
self._donor_donor_line.set_data(
[donor_real, donor_fret_real],
[donor_imag, donor_fret_imag],
)
else:
self._donor_donor_line.set_data([0.0, 0.0], [0.0, 0.0])
if acceptor_background > 0.0:
self._acceptor_background_line.set_data(
[float(acceptor_real), float(background_real)],
[float(acceptor_imag), float(background_imag)],
)
else:
self._acceptor_background_line.set_data([0.0, 0.0], [0.0, 0.0])
self._background_line.set_data([background_real], [background_imag])
self._donor_only_line.set_data([donor_real], [donor_imag])
self._donor_fret_line.set_data([donor_fret_real], [donor_fret_imag])
self._donor_trajectory_line.set_data(
donor_trajectory_real, donor_trajectory_imag
)
self._donor_line.set_data(
[donor_trajectory_real[e]], [donor_trajectory_imag[e]]
)
self._acceptor_only_line.set_data([acceptor_real], [acceptor_imag])
self._acceptor_trajectory_line.set_data(
acceptor_trajectory_real, acceptor_trajectory_imag
)
self._acceptor_line.set_data(
[acceptor_trajectory_real[e]], [acceptor_trajectory_imag[e]]
)
class SemicircleTicks(AbstractPathEffect):
"""Draw ticks on universal semicircle.
Parameters
----------
size : float, optional
Length of tick in dots.
The default is ``rcParams['xtick.major.size']``.
labels : sequence of str, optional
Tick labels for each vertex in path.
**kwargs
Extra keywords passed to matplotlib's
:py:meth:`matplotlib.patheffects.AbstractPathEffect._update_gc`.
"""
_size: float # tick length
_labels: tuple[str, ...] # tick labels
_gc: dict[str, Any] # keywords passed to _update_gc
def __init__(
self,
size: float | None = None,
labels: Sequence[str] | None = None,
**kwargs: Any,
) -> None:
super().__init__((0.0, 0.0))
if size is None:
self._size = pyplot.rcParams['xtick.major.size']
else:
self._size = size
if labels is None or not labels:
self._labels = ()
else:
self._labels = tuple(labels)
self._gc = kwargs
@property
def labels(self) -> tuple[str, ...]:
"""Tick labels."""
return self._labels
@labels.setter
def labels(self, value: Sequence[str] | None, /) -> None:
if value is None or not value:
self._labels = ()
else:
self._labels = tuple(value)
def draw_path(
self,
renderer: Any,
gc: Any,
tpath: Any,
affine: Any,
rgbFace: Any = None,
) -> None:
"""Draw path with updated gc."""
gc0 = renderer.new_gc()
gc0.copy_properties(gc)
# TODO: this uses private methods of the base class
gc0 = self._update_gc(gc0, self._gc) # type: ignore[attr-defined]
trans = affine
trans += self._offset_transform(renderer) # type: ignore[attr-defined]
font = FontProperties()
# approximate half size of 'x'
fontsize = renderer.points_to_pixels(font.get_size_in_points()) / 4
size = renderer.points_to_pixels(self._size)
origin = affine.transform([[0.5, 0.0]])
transpath = affine.transform_path(tpath)
polys = transpath.to_polygons(closed_only=False)
for p in polys:
# coordinates of tick ends
t = p - origin
t /= numpy.hypot(t[:, 0], t[:, 1])[:, numpy.newaxis]
d = t.copy()
t *= size
t += p
xyt = numpy.empty((2 * p.shape[0], 2))
xyt[0::2] = p
xyt[1::2] = t
renderer.draw_path(
gc0,
Path(xyt, numpy.tile([Path.MOVETO, Path.LINETO], p.shape[0])),
affine.inverted() + trans,
rgbFace,
)
if not self._labels:
continue
# coordinates of labels
t = d * size * 2.5
t += p
if renderer.flipy():
h = renderer.get_canvas_width_height()[1]
else:
h = 0.0
for s, (x, y), (dx, _) in zip(self._labels, t, d):
# TODO: get rendered text size from matplotlib.text.Text?
# this did not work:
# Text(d[i,0], h - d[i,1], label, ha='center', va='center')
x = x + fontsize * len(s.split()[0]) * (dx - 1.0)
y = h - y + fontsize
renderer.draw_text(gc0, x, y, s, font, 0.0)
gc0.restore()
[docs]
def plot_phasor(
real: ArrayLike,
imag: ArrayLike,
/,
*,
style: Literal['plot', 'hist2d', 'contour'] | None = None,
allquadrants: bool | None = None,
frequency: float | None = None,
show: bool = True,
**kwargs: Any,
) -> None:
"""Plot phasor coordinates.
A simplified interface to the :py:class:`PhasorPlot` class.
Parameters
----------
real : array_like
Real component of phasor coordinates.
imag : array_like
Imaginary component of phasor coordinates.
Must be of same shape as `real`.
style : {'plot', 'hist2d', 'contour'}, optional
Method used to plot phasor coordinates.
By default, if the number of coordinates are less than 65536
and the arrays are less than three-dimensional, `'plot'` style is used,
else `'hist2d'`.
allquadrants : bool, optional
Show all quadrants of phasor space.
By default, only the first quadrant is shown.
frequency : float, optional
Frequency of phasor plot.
If provided, the universal semicircle is labeled with reference
lifetimes.
show : bool, optional, default: True
Display figure.
**kwargs
Additional parguments passed to :py:class:`PhasorPlot`,
:py:meth:`PhasorPlot.plot`, :py:meth:`PhasorPlot.hist2d`, or
:py:meth:`PhasorPlot.contour` depending on `style`.
See Also
--------
phasorpy.plot.PhasorPlot
:ref:`sphx_glr_tutorials_api_phasorpy_phasorplot.py`
"""
init_kwargs = parse_kwargs(
kwargs,
'ax',
'title',
'xlabel',
'ylabel',
'xlim',
'ylim',
'xticks',
'yticks',
'grid',
)
real = numpy.asanyarray(real)
imag = numpy.asanyarray(imag)
plot = PhasorPlot(
frequency=frequency, allquadrants=allquadrants, **init_kwargs
)
if style is None:
style = 'plot' if real.size < 65536 and real.ndim < 3 else 'hist2d'
if style == 'plot':
plot.plot(real, imag, **kwargs)
elif style == 'hist2d':
plot.hist2d(real, imag, **kwargs)
elif style == 'contour':
plot.contour(real, imag, **kwargs)
else:
raise ValueError(f'invalid {style=}')
if show:
plot.show()
[docs]
def plot_phasor_image(
mean: ArrayLike | None,
real: ArrayLike,
imag: ArrayLike,
*,
harmonics: int | None = None,
percentile: float | None = None,
title: str | None = None,
show: bool = True,
**kwargs: Any,
) -> None:
"""Plot phasor coordinates as images.
Preview phasor coordinates from time-resolved or hyperspectral
image stacks as returned by :py:func:`phasorpy.phasor.phasor_from_signal`.
The last two axes are assumed to be the image axes.
Harmonics, if any, are in the first axes of `real` and `imag`.
Other axes are averaged for display.
Parameters
----------
mean : array_like
Image average. Must be two or more dimensional, or None.
real : array_like
Image of real component of phasor coordinates.
The last dimensions must match shape of `mean`.
imag : array_like
Image of imaginary component of phasor coordinates.
Must be same shape as `real`.
harmonics : int, optional
Number of harmonics to display.
If `mean` is None, a nonzero value indicates the presence of harmonics
in the first axes of `mean` and `real`. Else, the presence of harmonics
is determined from the shapes of `mean` and `real`.
By default, up to 4 harmonics are displayed.
percentile : float, optional
The (q, 100-q) percentiles of image data are covered by colormaps.
By default, the complete value range of `mean` is covered,
for `real` and `imag` the range [-1..1].
title : str, optional
Figure title.
show : bool, optional, default: True
Display figure.
**kwargs
Additional arguments passed to :func:`matplotlib.pyplot.imshow`.
Raises
------
ValueError
The shapes of `mean`, `real`, and `image` do not match.
Percentile is out of range.
"""
update_kwargs(kwargs, interpolation='nearest')
cmap = kwargs.pop('cmap', None)
shape = None
if mean is not None:
mean = numpy.asarray(mean)
if mean.ndim < 2:
raise ValueError(f'not an image {mean.ndim=} < 2')
shape = mean.shape
mean = mean.reshape(-1, *mean.shape[-2:])
if mean.shape[0] == 1:
mean = mean[0]
else:
mean = numpy.nanmean(mean, axis=0)
real = numpy.asarray(real)
imag = numpy.asarray(imag)
if real.shape != imag.shape:
raise ValueError(f'{real.shape=} != {imag.shape=}')
if real.ndim < 2:
raise ValueError(f'not an image {real.ndim=} < 2')
if (shape is not None and real.shape[1:] == shape) or (
shape is None and harmonics
):
# first image dimension contains harmonics
if real.ndim < 3:
raise ValueError(f'not a multi-harmonic image {real.shape=}')
nh = real.shape[0] # number harmonics
elif shape is None or shape == real.shape:
# single harmonic
nh = 1
else:
raise ValueError(f'shape mismatch {real.shape[1:]=} != {shape}')
real = real.reshape(nh, -1, *real.shape[-2:])
imag = imag.reshape(nh, -1, *imag.shape[-2:])
if real.shape[1] == 1:
real = real[:, 0]
imag = imag[:, 0]
else:
real = numpy.nanmean(real, axis=1)
imag = numpy.nanmean(imag, axis=1)
# for MyPy
assert isinstance(mean, numpy.ndarray) or mean is None
assert isinstance(real, numpy.ndarray)
assert isinstance(imag, numpy.ndarray)
# limit number of displayed harmonics
nh = min(4 if harmonics is None else harmonics, nh)
# create figure with size depending on image aspect and number of harmonics
fig = pyplot.figure(layout='constrained')
w, h = fig.get_size_inches()
aspect = min(1.0, max(0.5, real.shape[-2] / real.shape[-1]))
fig.set_size_inches(w, h * 0.4 * aspect * nh + h * 0.25 * aspect)
gs = GridSpec(nh, 2 if mean is None else 3, figure=fig)
if title:
fig.suptitle(title)
if mean is not None:
_imshow(
fig.add_subplot(gs[0, 0]),
mean,
percentile=percentile,
vmin=None,
vmax=None,
cmap=cmap,
axis=True,
title='mean',
**kwargs,
)
if percentile is None:
vmin = -1.0
vmax = 1.0
if cmap is None:
cmap = 'coolwarm_r'
else:
vmin = None
vmax = None
for h in range(nh):
axs = []
ax = fig.add_subplot(gs[h, -2])
axs.append(ax)
_imshow(
ax,
real[h],
percentile=percentile,
vmin=vmin,
vmax=vmax,
cmap=cmap,
axis=mean is None and h == 0,
colorbar=percentile is not None,
title=None if h else 'G, real',
**kwargs,
)
ax = fig.add_subplot(gs[h, -1])
axs.append(ax)
pos = _imshow(
ax,
imag[h],
percentile=percentile,
vmin=vmin,
vmax=vmax,
cmap=cmap,
axis=False,
colorbar=percentile is not None,
title=None if h else 'S, imag',
**kwargs,
)
if percentile is None and h == 0:
fig.colorbar(pos, ax=axs, shrink=0.4, location='bottom')
if show:
pyplot.show()
[docs]
def plot_signal_image(
signal: ArrayLike,
/,
*,
axis: int | str | None = None,
percentile: float | Sequence[float] | None = None,
title: str | None = None,
show: bool = True,
**kwargs: Any,
) -> None:
"""Plot average image and signal along axis.
Preview time-resolved or hyperspectral image stacks to be anayzed with
:py:func:`phasorpy.phasor.phasor_from_signal`.
The last two axes, excluding `axis`, are assumed to be the image axes.
Other axes are averaged for image display.
Parameters
----------
signal : array_like
Image stack. Must be three or more dimensional.
axis : int or str, optional
Axis over which phasor coordinates would be computed.
By default, the 'H' or 'C' axes if signal contains such dimension
names, else the last axis (-1).
percentile : float or [float, float], optional
The [q, 100-q] percentiles of image data are covered by colormaps.
By default, the complete value range of `mean` is covered,
for `real` and `imag` the range [-1..1].
title : str, optional
Figure title.
show : bool, optional, default: True
Display figure.
**kwargs
Additional arguments passed to :func:`matplotlib.pyplot.imshow`.
Raises
------
ValueError
Signal is not an image stack.
Percentile is out of range.
"""
# TODO: add option to separate channels?
# TODO: add option to plot non-images?
axis, axis_label = parse_signal_axis(signal, axis)
if (
axis_label
and hasattr(signal, 'coords')
and axis_label in signal.coords
):
axis_coords = signal.coords[axis_label]
else:
axis_coords = None
update_kwargs(kwargs, interpolation='nearest')
signal = numpy.asarray(signal)
if signal.ndim < 3:
raise ValueError(f'not an image stack {signal.ndim=} < 3')
axis %= signal.ndim
# for MyPy
assert isinstance(signal, numpy.ndarray)
fig = pyplot.figure(layout='constrained')
if title:
fig.suptitle(title)
w, h = fig.get_size_inches()
fig.set_size_inches(w, h * 0.7)
gs = GridSpec(1, 2, figure=fig, width_ratios=(1, 1))
# histogram
axes = list(range(signal.ndim))
del axes[axis]
ax = fig.add_subplot(gs[0, 1])
if axis_coords is not None:
ax.set_title(f'{axis=} {axis_label!r}')
ax.plot(axis_coords, numpy.nanmean(signal, axis=tuple(axes)))
else:
ax.set_title(f'{axis=}')
ax.plot(numpy.nanmean(signal, axis=tuple(axes)))
# image
axes = list(sorted(axes[:-2] + [axis]))
ax = fig.add_subplot(gs[0, 0])
_imshow(
ax,
numpy.nanmean(signal, axis=tuple(axes)),
percentile=percentile,
shrink=0.5,
title='mean',
)
if show:
pyplot.show()
[docs]
def plot_polar_frequency(
frequency: ArrayLike,
phase: ArrayLike,
modulation: ArrayLike,
*,
ax: Axes | None = None,
title: str | None = None,
show: bool = True,
**kwargs: Any,
) -> None:
"""Plot phase and modulation verus frequency.
Parameters
----------
frequency : array_like, shape (n, )
Laser pulse or modulation frequency in MHz.
phase : array_like
Angular component of polar coordinates in radians.
modulation : array_like
Radial component of polar coordinates.
ax : matplotlib axes, optional
Matplotlib axes used for plotting.
By default, a new subplot axes is created.
title : str, optional
Figure title. The default is "Multi-frequency plot".
show : bool, optional, default: True
Display figure.
**kwargs
Additional arguments passed to :py:func:`matplotlib.pyplot.plot`.
"""
# TODO: make this customizable: labels, colors, ...
if ax is None:
ax = pyplot.subplots()[1]
if title is None:
title = 'Multi-frequency plot'
if title:
ax.set_title(title)
ax.set_xscale('log', base=10)
ax.set_xlabel('Frequency (MHz)')
phase = numpy.asarray(phase)
if phase.ndim < 2:
phase = phase.reshape(-1, 1)
modulation = numpy.asarray(modulation)
if modulation.ndim < 2:
modulation = modulation.reshape(-1, 1)
ax.set_ylabel('Phase (°)', color='tab:blue')
ax.set_yticks([0.0, 30.0, 60.0, 90.0])
for phi in phase.T:
ax.plot(frequency, numpy.rad2deg(phi), color='tab:blue', **kwargs)
ax = ax.twinx()
ax.set_ylabel('Modulation (%)', color='tab:red')
ax.set_yticks([0.0, 25.0, 50.0, 75.0, 100.0])
for mod in modulation.T:
ax.plot(frequency, mod * 100, color='tab:red', **kwargs)
if show:
pyplot.show()
def _imshow(
ax: Axes,
image: NDArray[Any],
/,
*,
percentile: float | Sequence[float] | None = None,
vmin: float | None = None,
vmax: float | None = None,
colorbar: bool = True,
shrink: float | None = None,
axis: bool = True,
title: str | None = None,
**kwargs: Any,
) -> AxesImage:
"""Plot image array.
Convenience wrapper around :py:func:`matplotlib.pyplot.imshow`.
"""
update_kwargs(kwargs, interpolation='none')
if percentile is not None:
if isinstance(percentile, Sequence):
percentile = percentile[0], percentile[1]
else:
# percentile = max(0.0, min(50, percentile))
percentile = percentile, 100.0 - percentile
if (
percentile[0] >= percentile[1]
or percentile[0] < 0
or percentile[1] > 100
):
raise ValueError(f'{percentile=} out of range')
vmin, vmax = numpy.percentile(image, percentile)
pos = ax.imshow(image, vmin=vmin, vmax=vmax, **kwargs)
if colorbar:
if percentile is not None and vmin is not None and vmax is not None:
ticks = vmin, vmax
else:
ticks = None
fig = ax.get_figure()
if fig is not None:
if shrink is None:
shrink = 0.8
fig.colorbar(pos, shrink=shrink, location='bottom', ticks=ticks)
if title:
ax.set_title(title)
if not axis:
ax.set_axis_off()
# ax.set_anchor('C')
return pos
def _semicircle_ticks(
frequency: float,
lifetime: Sequence[float] | None = None,
labels: Sequence[str] | None = None,
) -> tuple[tuple[float, ...], tuple[str, ...]]:
"""Return semicircle tick lifetimes and labels at frequency."""
if lifetime is None:
lifetime = [0.0] + [
2**t
for t in range(-8, 32)
if phasor_from_lifetime(frequency, 2**t)[1] >= 0.18
]
unit = 'ns'
else:
unit = ''
if labels is None:
labels = [f'{tau:g}' for tau in lifetime]
try:
labels[2] = f'{labels[2]} {unit}'
except IndexError:
pass
return tuple(lifetime), tuple(labels)