"""Translate completed matplotlib figures into pyqtgraph scenes.
This module preserves the geometry and statistics already computed by a
matplotlib panel while changing the renderer used for the saved file. Artist
types listed in :data:`CARRIED` are translated; an unsupported artist marks
the translation incomplete and causes the caller to retain the original
matplotlib output.
Scene exports support headless rendering when Qt is available and otherwise
fall back to matplotlib. Output names follow :func:`spacr.plot.figure_path`,
saved files are announced through :func:`spacr.figure_sink.publish_file`, and
print colours are resolved by :func:`spacr.figure_style.export_colour` from
each artist's role.
"""
from __future__ import annotations
import math
import os
import threading
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Sequence, Tuple
#: Matplotlib artist types this module can carry into a pyqtgraph scene.
#:
#: A census of the whole QC suite produced nothing outside this set. It is a
#: WHITELIST rather than a blacklist deliberately: an artist nobody thought
#: about is a piece of the picture that would go missing, and a figure that is
#: quietly missing its reference line is worse than one drawn in the old
#: library.
CARRIED = (
"Line2D", "Text", "Annotation", "Rectangle", "PathCollection",
"LineCollection", "PolyCollection", "AxesImage", "Legend",
"Spine", "XAxis", "YAxis", "XTick", "YTick", "AxesSubplot", "Axes",
)
#: Artists that are part of the axes rather than of the plot, and are skipped
#: without that counting as an incomplete translation.
IGNORED = ("Spine", "XAxis", "YAxis", "XTick", "YTick")
#: Renderer names, shared with :mod:`spacr.figures.fast_render`.
RENDERERS = ("auto", "pyqtgraph", "matplotlib")
#: Points-per-inch, for turning a matplotlib font size into a pixel size.
_POINTS_PER_INCH = 72.0
#: How wide a colour bar column is, in pixels. A key, not a panel.
COLORBAR_PX = 96
#: A QApplication this module had to start, kept alive. Qt destroys the
#: application the moment its last Python reference goes, and an export that
#: happens after that is a segfault rather than an exception.
_APPLICATION = None
@dataclass
[docs]
class SceneReport:
"""What the translation could and could not carry.
``complete`` is the only decision a caller has to read, and it is the
whole contract: a scene operation that cannot produce a faithful file is
not a picture of the panel, so the caller writes the matplotlib page
instead. The rest is for the message, because "it fell back" without
naming the cause is a report nobody can act on.
:param axes: number of matplotlib axes traversed and represented, including
colour-bar axes.
:param items: number of data, annotation, legend, and colour-bar items
successfully added to the scene.
:param missing: blocking capability or failure labels, including
unsupported artists, renderer unavailability, and runtime or exporter
failures; any entry makes ``complete`` false.
:param data_colours: deduplicated normalized data-mark colours retained for
the post-export contrast check; chrome, separators, and colour-map
ramps are excluded.
:param notes: human-readable diagnostic details accompanying failures or
fallback decisions; notes do not independently determine ``complete``.
"""
axes: int = 0
items: int = 0
missing: List[str] = field(default_factory=list)
data_colours: List[str] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@property
[docs]
def complete(self) -> bool:
"""Return whether no blocking translation or export failure was recorded."""
return not self.missing
[docs]
def reason(self) -> str:
"""Return one sentence naming what stopped scene output."""
if self.complete:
return ""
counts: Dict[str, int] = {}
for name in self.missing:
counts[name] = counts.get(name, 0) + 1
named = ", ".join(f"{name} x{count}" for name, count
in sorted(counts.items()))
return f"pyqtgraph cannot yet carry {named}"
[docs]
def requested_renderer() -> str:
"""The renderer ``SPACR_FIGURE_RENDERER`` asks for, or ``'auto'``.
An unrecognised value is ``'auto'`` rather than an error, for the reason
:func:`spacr.figure_style.figure_save_mode` gives: a run must not lose its
figures over a misspelt environment variable.
"""
asked = os.environ.get("SPACR_FIGURE_RENDERER", "").strip().lower()
return asked if asked in RENDERERS else "auto"
[docs]
def scene_renderer(force: Optional[str] = None) -> Tuple[str, str]:
"""``(renderer, reason)`` for a generated figure with NO interactive twin.
THIS IS A DIFFERENT QUESTION FROM THE ONE
:func:`spacr.figures.fast_render.renderer_for` ANSWERS, and the difference
is why there are two functions rather than one with a flag. There, the
question is "is there a live widget to render, so the file can BE the
tab", and the answer must never be guessed -- two attempts to detect a
live GUI were built and both were wrong (a QApplication exists because
matplotlib's QtAgg backend made one; a module is imported because a test
imported it). Here there is no widget to find and nothing to disagree
with: the only question is whether this machine can paint with pyqtgraph
at all, and that is answerable by trying it.
So ``auto`` means "pyqtgraph if it is available here". A machine without
Qt writes the matplotlib page it always wrote, and says so.
:param force: one of :data:`RENDERERS`, overruling the environment.
:returns: ``('pyqtgraph', '')`` or ``('matplotlib', why)``. The reason is
never empty for matplotlib, because "why does this figure not look
like the others" is the question a user asks of it.
"""
choice = str(force).strip().lower() if force else requested_renderer()
if choice not in RENDERERS:
choice = "auto"
if choice == "matplotlib":
return "matplotlib", "matplotlib was asked for"
if choice == "auto":
blocked = _the_gallery_could_not_show_it()
if blocked:
return "matplotlib", blocked
available, why = pyqtgraph_ready()
if not available:
return "matplotlib", why
return "pyqtgraph", ""
def _the_gallery_could_not_show_it() -> str:
"""Why a rendered file would be invisible in this process, or ``''``.
Matplotlib figures and completed scene files use separate publication
sinks. If a figure sink is active but no file sink is installed, choose
matplotlib so the result remains visible in the gallery. Headless runs,
which install neither sink, may continue to use the scene renderer.
"""
from ..figure_sink import file_sink, sink
if sink() is not None and file_sink() is None:
return ("a figure sink is attached and no file sink is, so a rendered "
"file would not reach the gallery (spacr/qt/bridge.py installs "
"only set_sink)")
return ""
[docs]
def pyqtgraph_ready() -> Tuple[bool, str]:
"""``(ok, reason)``: can a scene be built and exported here and now?
Starting a ``QApplication`` is licensed by this being the renderer that
was chosen, not by a guess about a GUI. The offscreen platform is set only
when there is no display, and it is MEASURED to work: with it,
``ImageExporter`` writes a PNG and a ``QPdfWriter`` writes a real vector
PDF on a machine with no display at all.
"""
global _APPLICATION
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
try:
import pyqtgraph # noqa: F401
from PySide6.QtWidgets import QApplication
except Exception as error: # noqa: BLE001
return False, f"pyqtgraph is unavailable here: {error}"
try:
if QApplication.instance() is None:
if not os.environ.get("DISPLAY") and not os.environ.get(
"WAYLAND_DISPLAY"):
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
_APPLICATION = QApplication.instance() or QApplication([])
except Exception as error: # noqa: BLE001
return False, f"no QApplication could be started: {error}"
if threading.current_thread() is not threading.main_thread():
return False, (
"a pyqtgraph scene is made of Qt widgets and this is not the GUI "
f"thread (it is {threading.current_thread().name!r}); a widget "
"built here would live on a thread that is about to end. The "
"matplotlib page is written instead.")
return True, ""
def _hex(colour) -> Optional[str]:
"""A matplotlib colour as ``'#RRGGBB'``, or None when it is not paint.
``'none'``, a fully transparent RGBA and anything unreadable all answer
None, which every caller treats as "there is nothing to draw here". That
is the safe direction: inventing a colour for an artist that had none puts
ink on the page that the panel deliberately left off it.
"""
if colour is None:
return None
try:
import matplotlib.colors as mcolors
rgba = mcolors.to_rgba(colour)
except Exception: # noqa: BLE001
return None
if rgba[3] <= 0.0:
return None
return "#%02X%02X%02X" % tuple(int(round(channel * 255))
for channel in rgba[:3])
def _alpha(colour, artist_alpha=None) -> int:
"""The 0-255 alpha of ``colour``, combined with an artist-level alpha."""
try:
import matplotlib.colors as mcolors
value = float(mcolors.to_rgba(colour)[3])
except Exception: # noqa: BLE001
value = 1.0
if artist_alpha is not None:
try:
value *= float(artist_alpha)
except (TypeError, ValueError):
pass
return max(0, min(255, int(round(value * 255))))
class _Look:
"""Apply one saved-figure appearance consistently to translated artists.
Delegate colour decisions to :func:`spacr.figure_style.export_colour`.
Each translation path assigns its artist's chrome, data, reference, or
figure-ground role.
"""
def __init__(self, mode=None, dpi: float = 100.0):
"""Initialize export appearance and its point-to-pixel scale at ``dpi``.
:param mode: which saved appearance to apply; ``None`` takes the
saved default from :func:`~spacr.figure_style.saved_figure_appearance`.
:param dpi: the FIGURE'S OWN dpi, and so the exchange rate between
the points matplotlib carries and the pixels a pyqtgraph scene
is drawn in. Wrong here and nothing raises -- every line width,
marker and font is simply off by the ratio.
"""
from ..figure_style import saved_figure_appearance
self.look = saved_figure_appearance(mode)
self.data_colours: List[str] = []
self.scale = max(float(dpi), 1.0) / _POINTS_PER_INCH
def px(self, points, minimum: float = 0.1) -> float:
"""A matplotlib size in points, as scene pixels."""
try:
return max(float(points) * self.scale, minimum)
except (TypeError, ValueError):
return minimum
@property
def ground(self) -> Optional[str]:
"""Return the export ground, or ``None`` when none replaces it."""
return self.look.ground
def paint(self, colour: Optional[str], kind: str,
record: bool = True) -> Optional[str]:
"""The colour to paint one artist, having asked the shared rule.
:param record: whether a data colour joins the legibility check.
``False`` for a SEPARATOR -- the white edge a histogram draws
between its bars is not a mark anybody has to find, and counting
it fired the "this colour will not read on paper" warning on every
figure in the suite. A warning that fires on everything is a
warning nobody reads, which is the rule the floor itself was
chosen by.
"""
if colour is None:
return None
if kind == "data":
if record:
self.data_colours.append(colour)
return colour
from ..figure_style import export_colour
return export_colour(colour, kind, self.look) or colour
def _dash(style) -> Optional[Sequence[float]]:
"""A pyqtgraph dash pattern for a matplotlib linestyle, or None for solid."""
patterns = {"--": [4, 3], "-.": [5, 2, 1, 2], ":": [1, 2]}
if isinstance(style, str):
return patterns.get(style)
try:
_, sequence = style
return [max(float(v), 0.1) for v in sequence] if sequence else None
except Exception: # noqa: BLE001
return None
def _pen(colour, width, style, alpha, look, kind):
"""A QPen, or None when the artist has nothing to draw with."""
import pyqtgraph as pg
painted = look.paint(_hex(colour), kind)
if painted is None:
return None
pen = pg.mkPen(color=pg.mkColor(painted), width=look.px(width or 1.0))
colour_object = pen.color()
colour_object.setAlpha(_alpha(colour, alpha))
pen.setColor(colour_object)
dash = _dash(style)
if dash:
pen.setDashPattern([max(v, 0.1) for v in dash])
return pen
def _reference_line(artist, axes) -> Optional[str]:
"""``'h'``, ``'v'`` or None: is this Line2D a reference line?
THE TEST IS THE TRANSFORM, NOT THE COLOUR, and it is the same one
:func:`spacr.plot._chrome` uses on the matplotlib side. ``axhline`` and
``axvline`` draw in a blended axes/data transform; a plotted series draws
in ``ax.transData`` itself. That is a property of what the line MEANS, so
it survives a user restyle -- and it is the only way to tell a zero line
from a fitted curve, both of which are ``Line2D``.
"""
try:
if artist.get_transform() is axes.transData:
return None
except Exception: # noqa: BLE001
return None
data = artist.get_xydata()
if data is None or len(data) < 2:
return None
xs = {round(float(point[0]), 12) for point in data}
ys = {round(float(point[1]), 12) for point in data}
if len(ys) == 1 and len(xs) > 1:
return "h"
if len(xs) == 1 and len(ys) > 1:
return "v"
return None
def _add_line(plot, artist, axes, look) -> int:
"""Translate one ``Line2D``. Returns the number of items added."""
import numpy as np
import pyqtgraph as pg
reference = _reference_line(artist, axes)
alpha = artist.get_alpha()
if reference:
data = artist.get_xydata()
pen = _pen(artist.get_color(), artist.get_linewidth(),
artist.get_linestyle(), alpha, look, "chrome")
if pen is None:
return 0
position = (float(data[0][1]) if reference == "h"
else float(data[0][0]))
line = pg.InfiniteLine(pos=position,
angle=0 if reference == "h" else 90, pen=pen)
line.setZValue(float(artist.get_zorder() or 0))
plot.addItem(line)
return 1
data = np.asarray(artist.get_xydata(), dtype=float)
if data.size == 0:
return 0
xs, ys = data[:, 0], data[:, 1]
marker = artist.get_marker()
style = artist.get_linestyle()
added = 0
if style not in ("None", " ", "", None):
pen = _pen(artist.get_color(), artist.get_linewidth(), style, alpha,
look, "data")
if pen is not None:
label = artist.get_label()
curve = pg.PlotDataItem(
x=xs, y=ys, pen=pen,
name=(label if label and not str(label).startswith("_")
else None))
curve.setZValue(float(artist.get_zorder() or 0))
plot.addItem(curve)
added += 1
if marker not in ("None", " ", "", None):
brush_colour = look.paint(_hex(artist.get_markerfacecolor()), "data")
size = look.px(artist.get_markersize() or 4.0, minimum=1.0)
points = pg.ScatterPlotItem(
x=xs, y=ys, size=size, pen=None,
brush=pg.mkBrush(brush_colour) if brush_colour else None)
points.setZValue(float(artist.get_zorder() or 0))
plot.addItem(points)
added += 1
return added
def _add_path_collection(plot, artist, look) -> int:
"""Translate a ``scatter``.
matplotlib's ``s`` is an AREA in points squared and pyqtgraph's ``size``
is a DIAMETER, so the conversion is a square root rather than a scale
factor. Getting that wrong is not subtle -- an s=18 scatter drawn at
diameter 18 is a panel of overlapping blobs.
"""
import numpy as np
import pyqtgraph as pg
offsets = np.asarray(artist.get_offsets(), dtype=float)
if offsets.size == 0:
return 0
sizes = np.asarray(artist.get_sizes(), dtype=float)
if sizes.size == 0:
sizes = np.array([20.0])
diameters = np.sqrt(np.maximum(sizes, 0.0)) * look.scale
if diameters.size == 1:
diameters = np.repeat(diameters, len(offsets))
elif diameters.size < len(offsets):
diameters = np.resize(diameters, len(offsets))
faces = artist.get_facecolor()
alpha = artist.get_alpha()
brushes = []
for index in range(len(offsets)):
face = faces[index % len(faces)] if len(faces) else None
painted = look.paint(_hex(face), "data")
if painted is None:
brushes.append(None)
continue
brush = pg.mkBrush(painted)
colour = brush.color()
colour.setAlpha(_alpha(face, alpha))
brush.setColor(colour)
brushes.append(brush)
edges = artist.get_edgecolor()
pen = None
if len(edges):
edge = look.paint(_hex(edges[0]), "data", record=False)
widths = artist.get_linewidth()
width = look.px(np.ravel(widths)[0] if np.size(widths) else 1.0)
pen = pg.mkPen(edge, width=width) if edge else None
points = pg.ScatterPlotItem(x=offsets[:, 0], y=offsets[:, 1],
size=diameters, brush=brushes, pen=pen)
points.setZValue(float(artist.get_zorder() or 0))
plot.addItem(points)
return 1
def _add_line_collection(plot, artist, look) -> int:
"""Translate ``vlines`` / ``hlines``.
ONE ITEM, NOT ONE PER SEGMENT. A Cook's-distance stem plot on a real
screen is one segment per well; adding four hundred ``PlotDataItem``\\ s
makes an export that takes minutes and a PDF nobody can open.
``connect='pairs'`` draws them as a single disconnected curve.
"""
import numpy as np
import pyqtgraph as pg
segments = artist.get_segments()
if not len(segments):
return 0
xs: List[float] = []
ys: List[float] = []
for segment in segments:
points = np.asarray(segment, dtype=float)
if len(points) < 2:
continue
xs.extend([float(points[0][0]), float(points[-1][0])])
ys.extend([float(points[0][1]), float(points[-1][1])])
if not xs:
return 0
colours = artist.get_colors()
widths = np.ravel(artist.get_linewidth())
pen = _pen(colours[0] if len(colours) else None,
float(widths[0]) if widths.size else 1.0,
"-", artist.get_alpha(), look, "data")
if pen is None:
return 0
curve = pg.PlotDataItem(x=np.asarray(xs), y=np.asarray(ys), pen=pen,
connect="pairs")
curve.setZValue(float(artist.get_zorder() or 0))
plot.addItem(curve)
return 1
def _add_poly_collection(plot, artist, look) -> int:
"""Translate ``fill_between`` as a filled polygon."""
import numpy as np
import pyqtgraph as pg
from PySide6.QtCore import QPointF
from PySide6.QtGui import QPolygonF
from PySide6.QtWidgets import QGraphicsPolygonItem
added = 0
faces = artist.get_facecolor()
alpha = artist.get_alpha()
for index, path in enumerate(artist.get_paths()):
vertices = np.asarray(path.vertices, dtype=float)
if len(vertices) < 3:
continue
face = faces[index % len(faces)] if len(faces) else None
painted = look.paint(_hex(face), "data")
if painted is None:
continue
polygon = QPolygonF([QPointF(float(x), float(y)) for x, y in vertices])
item = QGraphicsPolygonItem(polygon)
brush = pg.mkBrush(painted)
colour = brush.color()
colour.setAlpha(_alpha(face, alpha))
brush.setColor(colour)
item.setBrush(brush)
item.setPen(pg.mkPen(None))
item.setZValue(float(artist.get_zorder() or 0))
plot.addItem(item)
added += 1
return added
def _add_rectangles(plot, rectangles, axes, look) -> int:
"""Translate the bars of a histogram or a bar chart.
One :class:`pyqtgraph.BarGraphItem` for the whole set rather than one
graphics item per bar: a twenty-bin p-value histogram is cheap either way,
but the response distribution of a real screen is not, and the difference
is visible in the export time.
"""
import numpy as np
import pyqtgraph as pg
x0, x1, y0, y1, brushes, pens = [], [], [], [], [], []
for rectangle in rectangles:
width = float(rectangle.get_width())
height = float(rectangle.get_height())
if width == 0.0 and height == 0.0:
continue
left, bottom = float(rectangle.get_x()), float(rectangle.get_y())
if rectangle.get_data_transform() is not axes.transData:
corner = _in_data_coordinates(_FractionPoint(left, bottom), axes)
far = _in_data_coordinates(
_FractionPoint(left + width, bottom + height), axes)
if corner is None or far is None:
continue
left, bottom = corner
width, height = far[0] - corner[0], far[1] - corner[1]
left, right = _clamp(left, left + width, *sorted(axes.get_xlim()))
bottom, top = _clamp(bottom, bottom + height, *sorted(axes.get_ylim()))
if right <= left or top < bottom:
continue
x0.append(left)
x1.append(right)
y0.append(bottom)
y1.append(top)
face = look.paint(_hex(rectangle.get_facecolor()), "data")
brush = pg.mkBrush(face) if face else pg.mkBrush(None)
if face:
colour = brush.color()
colour.setAlpha(_alpha(rectangle.get_facecolor(),
rectangle.get_alpha()))
brush.setColor(colour)
brushes.append(brush)
edge = look.paint(_hex(rectangle.get_edgecolor()), "data", record=False)
pens.append(pg.mkPen(edge, width=look.px(rectangle.get_linewidth() or 0.5))
if edge else pg.mkPen(None))
if not x0:
return 0
bars = pg.BarGraphItem(x0=np.asarray(x0), x1=np.asarray(x1),
y0=np.asarray(y0), y1=np.asarray(y1),
brushes=brushes, pens=pens)
plot.addItem(bars)
return 1
def _clamp(low, high, floor, ceiling) -> Tuple[float, float]:
"""``(low, high)`` held inside ``[floor, ceiling]``."""
return max(min(low, ceiling), floor), min(max(high, floor), ceiling)
def _anchor(text) -> Tuple[float, float]:
"""A pyqtgraph anchor for a matplotlib alignment pair."""
horizontal = {"left": 0.0, "center": 0.5, "right": 1.0}
vertical = {"top": 0.0, "center": 0.5, "center_baseline": 0.5,
"baseline": 1.0, "bottom": 1.0}
return (horizontal.get(text.get_horizontalalignment(), 0.0),
vertical.get(text.get_verticalalignment(), 1.0))
class _FractionPoint:
"""A point in axes fraction, shaped like the bit of ``Text``
:func:`_in_data_coordinates` reads.
A tiny adapter rather than a second copy of the conversion: the log
handling in there is the part that is easy to get subtly wrong, and one
figure with a rectangle placed by one rule and its label by another is the
kind of disagreement this whole module exists to remove.
"""
def __init__(self, x, y):
"""Store one point's ``x`` and ``y`` axes-fraction coordinates.
:param x: horizontal position as a fraction of the axes, 0 at the
left spine and 1 at the right.
:param y: vertical position, 0 at the bottom.
FRACTIONS, NOT DATA VALUES: :meth:`get_transform` returns ``None``
so the conversion takes its axes-fraction fallback, which is the
whole point of the adapter.
"""
self._position = (x, y)
def get_position(self):
"""Return the stored ``(x, y)`` axes-fraction coordinates."""
return self._position
def get_transform(self):
"""Return ``None`` so conversion uses its axes-fraction fallback."""
return None
def _in_data_coordinates(text, axes) -> Optional[Tuple[float, float]]:
"""Where a Text sits, in DATA coordinates, or None if it cannot be placed.
A panel's annotation is positioned in axes fraction (``transform=
ax.transAxes``) so it stays in the corner whatever the data does.
pyqtgraph has no axes-fraction transform for an item inside a ViewBox, so
the fraction is converted against the range the panel itself settled on --
which is exact, because the range is set from the same limits and then
frozen.
"""
import numpy as np
x, y = (float(value) for value in text.get_position())
try:
transform = text.get_transform()
if transform is axes.transData:
return x, y
except Exception: # noqa: BLE001
return x, y
try:
if (type(text).__name__ == "Annotation"
and type(transform).__name__ == "IdentityTransform"):
axes.figure.canvas.draw()
transform = text.get_transform()
display = transform.transform((x, y))
converted = axes.transData.inverted().transform(display)
if np.all(np.isfinite(converted)):
return float(converted[0]), float(converted[1])
except Exception: # noqa: BLE001
pass
(left, right), (bottom, top) = axes.get_xlim(), axes.get_ylim()
if axes.get_xscale() == "log":
left, right = math.log10(max(left, 1e-300)), math.log10(max(right, 1e-300))
x = 10 ** (left + x * (right - left))
else:
x = left + x * (right - left)
if axes.get_yscale() == "log":
bottom, top = (math.log10(max(bottom, 1e-300)),
math.log10(max(top, 1e-300)))
y = 10 ** (bottom + y * (top - bottom))
else:
y = bottom + y * (top - bottom)
if not (np.isfinite(x) and np.isfinite(y)):
return None
return x, y
#: The mathtext this translator understands, as plain Unicode. Deliberately
#: SHORT: a panel writes its labels for a reader, and the whole QC suite uses
#: exactly one mathtext string. Anything outside this table makes the
#: translation incomplete and the panel falls back to matplotlib -- a label
#: that comes out as raw TeX is a figure with a bug printed on its axis, which
#: is worse than a figure drawn in the old library.
_MATHTEXT = {
r"\mathrm": "", r"\mathbf": "", r"\mathit": "", r"\text": "",
r"\rm": "", r"\,": " ", r"\;": " ", r"\ ": " ",
r"\times": "\u00d7", r"\pm": "\u00b1", r"\leq": "\u2264",
r"\geq": "\u2265", r"\neq": "\u2260", r"\approx": "\u2248",
r"\infty": "\u221e", r"\cdot": "\u00b7",
r"\alpha": "\u03b1", r"\beta": "\u03b2", r"\gamma": "\u03b3",
r"\delta": "\u03b4", r"\Delta": "\u0394", r"\lambda": "\u03bb",
r"\mu": "\u03bc", r"\pi": "\u03c0", r"\rho": "\u03c1",
r"\sigma": "\u03c3", r"\Sigma": "\u03a3", r"\chi": "\u03c7",
r"\tau": "\u03c4", r"\phi": "\u03c6", r"\theta": "\u03b8",
r"\log": "log", r"\ln": "ln", r"\exp": "exp", r"\max": "max",
r"\min": "min", r"\mathdefault": "", r"\times10": "\u00d710",
}
#: Digits and signs that have a Unicode superscript, for ``^`` runs.
_SUPERSCRIPT = str.maketrans("0123456789+-=()n",
"\u2070\u00b9\u00b2\u00b3\u2074\u2075\u2076\u2077\u2078\u2079"
"\u207a\u207b\u207c\u207d\u207e\u207f")
#: The same for ``_`` runs.
_SUBSCRIPT = str.maketrans("0123456789+-=()",
"\u2080\u2081\u2082\u2083\u2084\u2085\u2086\u2087\u2088\u2089"
"\u208a\u208b\u208c\u208d\u208e")
def _plain_text(raw) -> Tuple[str, bool]:
"""``(text, understood)`` for a label that may be matplotlib mathtext.
pyqtgraph draws Qt rich text and has no mathtext at all, so an untouched
``$\\sqrt{|\\mathrm{standardised\\ residual}|}$`` reaches the file as exactly
those characters -- measured on `scale_location`, the variance
homogeneity panel of this suite.
The vocabulary is :data:`_MATHTEXT`, ``\\sqrt`` and the sub/superscript
runs, and NOTHING ELSE IS GUESSED: a construct outside it comes back
``understood=False``, which makes the whole translation incomplete and
sends the panel back to matplotlib. Half a formula silently rewritten is a
figure that says something the panel did not.
Example:
>>> _plain_text("residual")
('residual', True)
>>> _plain_text(r"$x^2$")
('x\u00b2', True)
>>> _plain_text(r"$\\frac{a}{b}$")[1]
False
"""
import re
text = str(raw)
if "$" not in text:
return text, True
body = text.replace("$", "")
for token, replacement in _MATHTEXT.items():
body = body.replace(token, replacement)
for _ in range(6):
stripped = re.sub(r"(?<![A-Za-z^_])\{([^{}]*)\}", r"\1", body)
if stripped == body:
break
body = stripped
body = re.sub(r"\\sqrt\s*\{([^{}]*)\}", "\u221a(\\1)", body)
def _script(match, table):
"""Translate an entire captured regex script run or none of it.
:param match: regex match whose first group is a super/subscript run.
:param table: Unicode translation mapping for that script position.
:returns: the fully translated run when every character is supported;
otherwise the original matched source unchanged, allowing the
caller's leftover-marker check to reject unsupported mathtext.
"""
run = match.group(1)
converted = run.translate(table)
return converted if all(
character in table for character in map(ord, run)) else match.group(0)
body = re.sub(r"\^\{([^{}]*)\}", lambda m: _script(m, _SUPERSCRIPT), body)
body = re.sub(r"_\{([^{}]*)\}", lambda m: _script(m, _SUBSCRIPT), body)
body = re.sub(r"\^([0-9A-Za-z+\-])", lambda m: _script(m, _SUPERSCRIPT), body)
body = re.sub(r"_([0-9A-Za-z+\-])", lambda m: _script(m, _SUBSCRIPT), body)
body = re.sub(r"\{([^{}]*)\}", r"\1", body)
understood = not any(character in body for character in "\\{}^_")
return body, understood
def _font(points, look, *, bold: bool = False, italic: bool = False):
"""A QFont whose height is the matplotlib point size, IN PIXELS.
``setPointSizeF`` would be the obvious call and it is the wrong one: Qt
resolves a point size against the SCREEN's logical DPI (96 here), while
matplotlib resolves it against the FIGURE's (140 in this suite). The same
number then means two different sizes, and the export comes out with type
a third too small for the picture it is on. A pixel size has no such
ambiguity, because the scene is measured in pixels.
"""
from PySide6.QtGui import QFont
font = QFont()
font.setPixelSize(max(1, int(round(look.px(points or 8.0, minimum=1.0)))))
font.setWeight(QFont.Weight(75 if bold else 50))
font.setItalic(bool(italic))
return font
def _add_text(plot, artist, axes, look, report) -> int:
"""Translate one ``Text`` or ``Annotation``.
Titles, axis labels, tick labels, legend text, and annotations are treated
as figure chrome. Their colours therefore pass through the print-colour
resolver instead of being copied as data colours.
"""
import pyqtgraph as pg
body, understood = _plain_text(artist.get_text())
if not body or not body.strip():
return 0
if not understood:
report.missing.append("mathtext this translator does not know")
return 0
position = _in_data_coordinates(artist, axes)
if position is None:
return 0
painted = look.paint(_hex(artist.get_color()), "chrome")
if painted is None:
return 0
fill = None
border = None
box = getattr(artist, "get_bbox_patch", lambda: None)()
if box is not None:
face = look.paint(_hex(box.get_facecolor()), "ground")
if face:
fill = pg.mkBrush(face)
edge = look.paint(_hex(box.get_edgecolor()), "chrome")
if edge:
border = pg.mkPen(edge, width=look.px(box.get_linewidth() or 0.6))
item = pg.TextItem(text=body, color=painted, anchor=_anchor(artist),
fill=fill, border=border,
angle=-float(artist.get_rotation() or 0.0))
try:
item.setFont(_font(artist.get_fontsize(), look,
bold=str(artist.get_fontweight()) in ("bold",
"heavy"),
italic=str(artist.get_style()) == "italic"))
except Exception: # noqa: BLE001
pass
item.setPos(*position)
item.setZValue(float(artist.get_zorder() or 5))
plot.addItem(item, ignoreBounds=True)
return 1
def _add_image(plot, artist, look) -> int:
"""Translate an ``imshow``, colour map and all.
Preserve the colour map because it encodes data values and must not be
inverted by print styling.
"""
import numpy as np
import pyqtgraph as pg
from PySide6.QtCore import QRectF
array = np.asarray(artist.get_array(), dtype=float)
if array.size == 0:
return 0
low, high = artist.get_clim()
colour_map = artist.get_cmap()
lut = (np.asarray(colour_map(np.linspace(0.0, 1.0, 256))) * 255).astype(
np.uint8)
image = pg.ImageItem(array.T)
image.setLookupTable(lut)
image.setLevels((float(low), float(high)))
left, right, bottom, top = artist.get_extent()
image.setRect(QRectF(float(min(left, right)), float(min(bottom, top)),
abs(float(right - left)), abs(float(top - bottom))))
image.setZValue(float(artist.get_zorder() or 0))
plot.addItem(image)
return 1
def _axis_pen(look):
"""Return an export-scaled 0.8-point pen in resolved chrome colour."""
import pyqtgraph as pg
ink = look.paint("#FFFFFF", "chrome") or "#222222"
return pg.mkPen(ink, width=look.px(0.8))
def _css_size(points, look) -> str:
"""A matplotlib point size as the CSS pyqtgraph's label markup wants.
In ``px``, for the reason :func:`_font` gives: a point in Qt's rich text
is a point at the screen's DPI, and this scene is measured at the
figure's.
"""
return f"{max(1, int(round(look.px(points or 8.0, minimum=1.0))))}px"
def _configure_axes(plot, axes, look, report) -> None:
"""Titles, labels, ranges, ticks and scales: the chrome, as configuration.
The axis ink is asked of the same shared rule as everything else, with the
kind fixed at ``chrome``, so a suite saved from a dark session comes out
with dark axes on a light page and a suite saved from a light one is
untouched.
"""
ink = look.paint("#FFFFFF", "chrome") or "#222222"
for name, matplotlib_axis in (("bottom", axes.xaxis), ("left", axes.yaxis)):
axis = plot.getAxis(name)
axis.setPen(_axis_pen(look))
axis.setTextPen(_axis_pen(look))
try:
ticks = matplotlib_axis.get_ticklabels()
axis.setStyle(tickFont=_font(
ticks[0].get_fontsize() if ticks else 6.0, look))
except Exception: # noqa: BLE001
pass
plot.showAxis("top", False)
plot.showAxis("right", False)
plot.showGrid(x=False, y=False)
plot.setMenuEnabled(False)
for name, raw, size in (
("title", axes.get_title(), axes.title.get_fontsize()),
("bottom", axes.get_xlabel(), axes.xaxis.label.get_fontsize()),
("left", axes.get_ylabel(), axes.yaxis.label.get_fontsize())):
if not raw:
continue
body, understood = _plain_text(raw)
if not understood:
report.missing.append("mathtext this translator does not know")
continue
if name == "title":
plot.setTitle(body, color=ink, size=_css_size(size, look))
else:
plot.setLabel(name, body, color=ink,
**{"font-size": _css_size(size, look)})
if not axes.axison:
plot.hideAxis("bottom")
plot.hideAxis("left")
log_x = axes.get_xscale() == "log"
log_y = axes.get_yscale() == "log"
if log_x or log_y:
plot.setLogMode(x=log_x, y=log_y)
(left, right), (bottom, top) = axes.get_xlim(), axes.get_ylim()
if top < bottom:
plot.getViewBox().invertY(True)
bottom, top = top, bottom
if log_x:
left, right = (math.log10(max(left, 1e-300)),
math.log10(max(right, 1e-300)))
if log_y:
bottom, top = (math.log10(max(bottom, 1e-300)),
math.log10(max(top, 1e-300)))
plot.setXRange(left, right, padding=0)
plot.setYRange(bottom, top, padding=0)
plot.getViewBox().disableAutoRange()
_carry_ticks(plot, axes)
def _carry_ticks(plot, axes) -> None:
"""Copy explicit tick labels across.
A CATEGORICAL AXIS IS NOT A NUMERIC ONE WITH NICE LABELS. The screen-level
panels put plate and row names on the axis with ``set_xticklabels``, and a
renderer that re-derives ticks from the range draws 0, 1, 2 where the panel
wrote plate1, plate2, plate3 -- a different figure, silently.
"""
for name, matplotlib_axis in (("bottom", axes.xaxis), ("left", axes.yaxis)):
try:
locations = list(matplotlib_axis.get_ticklocs())
labels = [tick.get_text()
for tick in matplotlib_axis.get_ticklabels()]
except Exception: # noqa: BLE001
continue
if not labels or len(labels) != len(locations):
continue
if not any(label and not _looks_numeric(label) for label in labels):
continue
converted = [_plain_text(label) for label in labels]
if not all(understood for _, understood in converted):
continue
plot.getAxis(name).setTicks(
[[(float(position), body)
for position, (body, _) in zip(locations, converted) if body]])
def _looks_numeric(label: str) -> bool:
"""Return whether ``label`` parses as a float after minus normalization."""
try:
float(str(label).replace("−", "-"))
return True
except ValueError:
return False
def _is_chrome_artist(artist) -> bool:
"""True for a spine, an axis or a tick: configuration, not content.
Checked by TYPE rather than by class NAME, because matplotlib subclasses
its own chrome -- a colour bar's frame is a ``_ColorbarSpine``, which is a
Spine and does not answer to the name. A name test dropped exactly one
panel of the suite for that reason, measured, which is how this function
came to exist.
"""
from matplotlib.axis import Axis, Tick
from matplotlib.spines import Spine
return isinstance(artist, (Spine, Axis, Tick))
def _colorbar_of(axes):
"""The ``Colorbar`` this axes IS, or None. matplotlib stamps it on the axes."""
return getattr(axes, "_colorbar", None)
def _translate_colorbar(plot, axes, look, report: SceneReport) -> None:
"""Translate a colour bar as the ramp it is, rather than as its mesh.
A matplotlib colour bar is a ``QuadMesh`` of 256 quadrilaterals plus a
``_ColorbarSpine``, and translating it quad by quad would be 256 graphics
items drawing a gradient that one image draws exactly. So the RAMP is read
off the mappable's own colour map -- the same object the panel handed to
``imshow``, so the bar and the image cannot come out keyed differently,
which is the one failure a colour bar has.
"""
import numpy as np
import pyqtgraph as pg
from PySide6.QtCore import QRectF
bar = _colorbar_of(axes)
ink = look.paint("#FFFFFF", "chrome") or "#222222"
low, high = (float(value) for value in bar.mappable.get_clim())
ramp = np.linspace(low, high, 256).reshape(1, 256)
lut = (np.asarray(bar.mappable.get_cmap()(np.linspace(0, 1, 256))) * 255
).astype(np.uint8)
image = pg.ImageItem(ramp)
image.setLookupTable(lut)
image.setLevels((low, high))
image.setRect(QRectF(0.0, low, 1.0, high - low))
plot.addItem(image)
report.items += 1
plot.showAxis("left", False)
plot.showAxis("bottom", False)
plot.showAxis("top", False)
plot.showAxis("right", True)
right = plot.getAxis("right")
right.setPen(_axis_pen(look))
right.setTextPen(_axis_pen(look))
plot.setXRange(0, 1, padding=0)
plot.setYRange(low, high, padding=0)
plot.getViewBox().disableAutoRange()
plot.setMenuEnabled(False)
label = bar.ax.get_ylabel() or bar.ax.get_xlabel()
if label:
plot.setLabel("right", label, color=ink,
**{"font-size": _css_size(
bar.ax.yaxis.label.get_fontsize(), look)})
try:
ticks = bar.ax.yaxis.get_ticklabels()
right.setStyle(tickFont=_font(
ticks[0].get_fontsize() if ticks else 6.0, look))
except Exception: # noqa: BLE001
pass
def _translate_axes(plot, axes, look, report: SceneReport) -> None:
"""Put everything on one matplotlib ``Axes`` into one pyqtgraph plot."""
from matplotlib import collections as mpl_collections
from matplotlib.collections import LineCollection, PathCollection, PolyCollection
from matplotlib.image import AxesImage
from matplotlib.legend import Legend
from matplotlib.lines import Line2D
from matplotlib.patches import Rectangle
from matplotlib.text import Annotation, Text
fill_between_collection = getattr(
mpl_collections, "FillBetweenPolyCollection", None)
if _colorbar_of(axes) is not None:
_translate_colorbar(plot, axes, look, report)
return
_configure_axes(plot, axes, look, report)
rectangles = []
for artist in axes.get_children():
name = type(artist).__name__
if artist is axes.patch:
continue
if name in IGNORED or _is_chrome_artist(artist):
continue
if isinstance(artist, Line2D):
report.items += _add_line(plot, artist, axes, look)
elif isinstance(artist, Text):
if (isinstance(artist, Annotation)
and getattr(artist, "arrow_patch", None) is not None):
report.missing.append("Annotation arrow")
report.notes.append(
"The annotation arrow cannot yet be carried by pyqtgraph."
)
continue
report.items += _add_text(plot, artist, axes, look, report)
elif isinstance(artist, Rectangle):
rectangles.append(artist)
elif isinstance(artist, PathCollection):
report.items += _add_path_collection(plot, artist, look)
elif isinstance(artist, LineCollection):
report.items += _add_line_collection(plot, artist, look)
elif (type(artist) is PolyCollection
or (fill_between_collection is not None
and isinstance(artist, fill_between_collection))):
report.items += _add_poly_collection(plot, artist, look)
elif isinstance(artist, AxesImage):
report.items += _add_image(plot, artist, look)
elif isinstance(artist, Legend):
report.items += _add_legend(plot, artist, look)
elif name not in CARRIED:
report.missing.append(name)
if rectangles:
report.items += _add_rectangles(plot, rectangles, axes, look)
def _add_legend(plot, legend, look) -> int:
"""Translate a legend as its text, beside a swatch of each entry's colour.
pyqtgraph's own ``LegendItem`` is built by naming curves as they are
added, which cannot express a legend matplotlib assembled from handles the
panel passed in by hand -- and three of these panels do exactly that. So
the entries are read off the finished legend, which is the same rule the
rest of this module follows: translate what was drawn, not what was meant.
"""
import pyqtgraph as pg
texts = legend.get_texts()
if not texts:
return 0
entries = []
for text, handle in zip(texts, legend.legend_handles):
body = text.get_text()
if body:
entries.append((str(body), text, handle))
if not entries:
return 0
ink = look.paint(_hex(entries[0][1].get_color()), "chrome")
item = pg.LegendItem(offset=(30, 20), labelTextColor=ink or "#222222",
labelTextSize="7pt")
item.setParentItem(plot.getViewBox())
for entry, _text, handle in entries:
colour = None
for getter in ("get_color", "get_facecolor", "get_edgecolor"):
try:
colour = _hex(getattr(handle, getter)())
except Exception: # noqa: BLE001
colour = None
if colour:
break
sample = pg.PlotDataItem(
[0, 1], [0, 0],
pen=pg.mkPen(look.paint(colour, "data") or "#B4B4B4", width=2))
item.addItem(sample, entry)
return 1
def _grid_position(axes, index: int) -> Tuple[int, int]:
"""``(row, column)`` for one axes on the page.
Read off the subplot spec where there is one, which is what keeps a 2x3
diagnostic sheet a 2x3 sheet. A figure whose axes were placed by hand falls
back to one row, which is honest: this module does not attempt to
reconstruct a hand-placed layout, and a sheet in the wrong order would be a
worse answer than a sheet in a line.
THE SPEC HAS TO BE THE TOPMOST ONE. ``Figure.colorbar`` replaces its
mappable's subplot spec with one from a NEW gridspec nested inside the old
cell, so a panel that was cell 10 of a 6x4 page answers ``(1, 2, 0, 0)``
afterwards -- cell 0. On the combined QC page that put the correlation
panel on top of the first panel, and Qt said so:
``QGridLayoutEngine::addItem: Can't add ... at cell (0, 0) because it's
already taken``. ``get_topmost_subplotspec`` answers in the page's own
grid, which is the grid the reader sees.
"""
try:
spec = axes.get_subplotspec().get_topmost_subplotspec()
_, columns, start, _ = spec.get_geometry()
return int(start // columns), int(start % columns)
except Exception: # noqa: BLE001
return 0, index
def _positions(figure) -> List[Tuple[int, int, int]]:
"""``(row, column, colspan)`` per axes, on a grid of HALF columns.
EVERY PANEL SPANS TWO SUB-COLUMNS AND A COLOUR BAR TAKES ONE OF THEM. A
colour bar has no cell of its own on the page it came from -- matplotlib
takes its room out of the panel it keys -- so giving it a full column of
its own either overlaps the next panel or doubles the page's width. The
half-column grid gives it exactly the room it had, in the place it had it,
and costs a page with no colour bars nothing at all.
A colour bar is also placed beside WHAT IT KEYS rather than where its own
spec says, for the reason :func:`_grid_position` records: the spec belongs
to a gridspec nested inside the panel's cell and answers in its
coordinates.
"""
plain = [_grid_position(axes, index)
for index, axes in enumerate(figure.axes)]
positions = [(row, column * 2, 2) for row, column in plain]
for index, axes in enumerate(figure.axes):
bar = _colorbar_of(axes)
if bar is None:
continue
try:
parent = list(figure.axes).index(bar.mappable.axes)
except (ValueError, AttributeError): # noqa: BLE001
continue
row, column, _ = positions[parent]
positions[parent] = (row, column, 1)
positions[index] = (row, column + 1, 1)
return positions
def _columns(figure) -> int:
"""How many sub-columns the figure's axes occupy."""
return max([column + span for _, column, span in _positions(figure)] + [0])
[docs]
def build_scene(figure, *, mode=None, dpi=None):
"""Translate a matplotlib ``Figure`` into a pyqtgraph scene.
:param figure: a drawn matplotlib Figure. It is NOT modified: everything
here is a read.
:param mode: a :data:`spacr.figure_style.SAVE_MODES` value, or None to ask
the preference.
:param dpi: output resolution used to size the scene. By default, use the
figure's current resolution.
:returns: ``(widget, report)``. The widget is a
``pyqtgraph.GraphicsLayoutWidget`` holding one plot per axes; the
:class:`SceneReport` says whether the translation was COMPLETE, and a
caller must not write an incomplete one.
"""
import pyqtgraph as pg
width, height = (float(value) for value in figure.get_size_inches())
dpi = float(dpi if dpi is not None else (figure.get_dpi() or 100.0))
look = _Look(mode, dpi=dpi)
widget = pg.GraphicsLayoutWidget(size=(int(width * dpi), int(height * dpi)))
widget.resize(int(width * dpi), int(height * dpi))
if look.ground:
widget.setBackground(look.ground)
else:
widget.setBackground(None)
report = SceneReport()
positions = _positions(figure)
title = getattr(figure, "_suptitle", None)
offset = 1 if title is not None and title.get_text() else 0
for index, axes in enumerate(figure.axes):
row, column, span = positions[index]
plot = widget.addPlot(row=row + offset, col=column, colspan=span)
_translate_axes(plot, axes, look, report)
if _colorbar_of(axes) is not None:
widget.ci.layout.setColumnFixedWidth(column, COLORBAR_PX)
report.axes += 1
if offset:
ink = look.paint(_hex(title.get_color()), "chrome") or "#222222"
widget.addLabel(str(title.get_text()), row=0, col=0,
colspan=max(1, _columns(figure)), color=ink,
size=_css_size(title.get_fontsize(), look),
bold=str(title.get_fontweight()) in ("bold", "heavy"))
_lay_out(widget)
report.data_colours = list(dict.fromkeys(look.data_colours))
return widget, report
def _lay_out(widget) -> None:
"""Lay the scene out AND paint it once, before anything measures it.
THE PAINT IS NOT OPTIONAL AND THAT IS THE SURPRISE. A widget that was
never shown has never been laid out, and -- worse -- a pyqtgraph
``AxisItem`` learns how wide it needs to be while it PAINTS: ``textWidth``
is measured in ``generateDrawSpecs``, and until then the axis reserves the
default. Measured on the QC suite with only a layout pass: the x-axis
label sat on top of the tick labels and a categorical y axis showed its
tick MARKS with no room for a single character of text. Grabbing the
widget once forces the paint, and the second ``activate`` spends the
measurement the paint just made.
This is the same trap ``FastPlot.snapshot`` records for a plot on a page
nobody has raised, and it has the same shape: ask for the work rather than
assume it happened.
"""
from PySide6.QtWidgets import QApplication
try:
application = QApplication.instance()
widget.ci.layout.activate()
widget.grab()
if application is not None:
application.processEvents()
widget.ci.layout.activate()
except Exception: # noqa: BLE001
pass
[docs]
def export_scene(widget, path) -> Optional[str]:
"""Write a built scene to ``path``. PDF, SVG and PNG, by the name.
THE EXPORTER IS THE ONE THE TABS USE. ``FastPlot._export_pdf`` and
``_export_svg`` are classmethods over a plot item, and both go through
``_paint_scene``, which turns pyqtgraph's export mode on for every mark
before painting -- without it a ScatterPlotItem copies its cached marker
PIXMAPS into the vector file, and a PDF full of little bitmaps of a dot is
a PDF that claims to be vector and is not. Writing a second exporter here
would recreate the same exporter duplication one level down.
:param widget: built pyqtgraph scene widget to export.
:param path: destination path; its suffix selects PDF, SVG, or raster
export.
"""
from ..qt.widgets.fast_plots import FastPlot
item = widget.ci
name = str(path).lower()
folder = os.path.dirname(os.path.abspath(str(path)))
os.makedirs(folder, exist_ok=True)
if name.endswith(".pdf"):
FastPlot._export_pdf(item, path)
elif name.endswith(".svg"):
FastPlot._export_svg(item, path)
else:
from pyqtgraph import exporters
exporter = exporters.ImageExporter(item)
exporter.export(str(path))
return str(path) if os.path.exists(str(path)) else None
def _warn_about_data_colours(report: SceneReport) -> None:
"""Warn about data colours with poor contrast on the saved page.
Name offending colours without replacing them. Use the figure-style
contrast floor, which is tuned to flag colours less legible than the
house-style reference greys without warning on every figure.
"""
from ..figure_style import illegible_colour_warning, illegible_colours, saved_figure_appearance
look = saved_figure_appearance()
if not look.flip or not report.data_colours:
return
offenders = illegible_colours(report.data_colours,
look.ground or "#FFFFFF")
if offenders:
print(illegible_colour_warning(offenders))
__all__ = ["CARRIED", "COLORBAR_PX", "RENDERERS", "SceneReport",
"build_scene", "export_scene", "pyqtgraph_ready", "render_figure",
"requested_renderer", "scene_renderer", "write_figure"]