diff --git a/pgmpl/axes.py b/pgmpl/axes.py index 6e78320..2577ef1 100644 --- a/pgmpl/axes.py +++ b/pgmpl/axes.py @@ -24,9 +24,11 @@ # pgmpl # noinspection PyUnresolvedReferences import pgmpl.__init__ # __init__ does setup stuff like making sure a QApp exists -from pgmpl.translate import plotkw_translator, color_translator, setup_pen_kw, color_map_translator, dealias +from pgmpl.translate import color_translator, color_map_translator +from pgmpl.tools import setup_pen_kw, plotkw_translator from pgmpl.legend import Legend from pgmpl.util import printd, tolist, is_numeric +from pgmpl.tools import dealias from pgmpl.text import Text from pgmpl.contour import QuadContourSet @@ -146,6 +148,34 @@ def _make_custom_verts(verts): Symbols[key] = pg.arrayToQPath(verts_x, verts_y, connect='all') return key + @staticmethod + def _interpret_xy_scatter_data(*args, **kwargs): + x, y = (list(args) + [None] * (2 - len(args)))[:3] + data = kwargs.pop('data', None) + if data is not None: + x = data.get('x') + y = data.get('y') + kwargs['s'] = data.get('s', None) + kwargs['c'] = data.get('c', None) + kwargs['edgecolors'] = data.get('edgecolors', None) + kwargs['linewidths'] = data.get('linewidths', None) + # The following keywords are apparently valid within `data`, + # but they'd conflict with `c`, so they've been neglected: color facecolor facecolors + return x, y, kwargs + + @staticmethod + def _setup_scatter_symbol_pen(brush_edges, linewidths): + """Sets up the pen for drawing symbols on scatter plot""" + sympen_kw = [{'color': cc} for cc in brush_edges] + if linewidths is not None: + n = len(brush_edges) + if (len(tolist(linewidths)) == 1) and (n > 1): + # Make list of lw the same length as x for cases where only one setting value was provided + linewidths = tolist(linewidths) * n + for i in range(n): + sympen_kw[i]['width'] = linewidths[i] + return [pg.mkPen(**spkw) for spkw in sympen_kw] + def scatter(self, x=None, y=None, **kwargs): """ Translates arguments and keywords for matplotlib.axes.Axes.scatter() method so they can be passed to pyqtgraph. @@ -198,41 +228,24 @@ def scatter(self, x=None, y=None, **kwargs): :return: plotItem instance created by plot() """ - data = kwargs.pop('data', None) - linewidths = kwargs.pop('linewidths', None) - if data is not None: - x = data.get('x') - y = data.get('y') - kwargs['s'] = data.get('s', None) - kwargs['c'] = data.get('c', None) - kwargs['edgecolors'] = data.get('edgecolors', None) - linewidths = data.get('linewidths', None) - # The following keywords are apparently valid within `data`, - # but they'd conflict with `c`, so they've been neglected: color facecolor facecolors + x, y, kwargs = self._interpret_xy_scatter_data(x, y, **kwargs) n = len(x) - + linewidths = kwargs.pop('linewidths', None) brush_colors, brush_edges = self._prep_scatter_colors(n, **kwargs) for popit in ['cmap', 'norm', 'vmin', 'vmax', 'alpha', 'edgecolors', 'c']: kwargs.pop(popit, None) # Make sure all the color keywords are gone now that they've been used. - # Make the lists of symbol settings the same length as x for cases where only one setting value was provided - if linewidths is not None and (len(tolist(linewidths)) == 1) and (n > 1): - linewidths = tolist(linewidths) * n - # Catch & translate other keywords kwargs['markersize'] = kwargs.pop('s', 10) kwargs.setdefault('marker', 'o') plotkw = plotkw_translator(**kwargs) # Fill in keywords we already prepared - sympen_kw = [{'color': cc} for cc in brush_edges] - if linewidths is not None: - for i in range(n): - sympen_kw[i]['width'] = linewidths[i] + plotkw['symbolPen'] = self._setup_scatter_symbol_pen(brush_edges, linewidths) plotkw['pen'] = None plotkw['symbolBrush'] = [pg.mkBrush(color=cc) for cc in brush_colors] - plotkw['symbolPen'] = [pg.mkPen(**spkw) for spkw in sympen_kw] + plotkw['symbol'] = plotkw.get('symbol', None) or self._make_custom_verts(kwargs.pop('verts', None)) return super(Axes, self).plot(x=x, y=y, **plotkw) @@ -430,7 +443,7 @@ def _draw_errbar_caps(self, x, y, **capkw): self._errbar_ycap_mark(x, y, yerr, **capkw) @staticmethod - def _sanitize_errbar_data(x, y=None, xerr=None, yerr=None, mask=None): + def _sanitize_errbar_data(*args, mask=None): """ Helper function for errorbar. Does not map to a matplotlib method. @@ -448,6 +461,9 @@ def _sanitize_errbar_data(x, y=None, xerr=None, yerr=None, mask=None): :return: tuple of sanitized x, y, xerr, yerr """ + x, y, xerr, yerr = (list(args) + [None] * (4 - len(args)))[:5] + + def prep(v): """ Prepares a value so it has the appropriate dimensions with proper filtering to respect errorevery keyword @@ -468,32 +484,48 @@ def prep(v): return prep(x), prep(y), prep(xerr), prep(yerr) - def errorbar(self, x=None, y=None, yerr=None, xerr=None, **kwargs): + def _interpret_xy_errorbar_data(self, *args, **kwargs): """ - Imitates matplotlib.axes.Axes.errorbar - - https://matplotlib.org/api/_as_gen/matplotlib.axes.Axes.errorbar.html + Interprets x, and y arguments and xerr, yerr, and data keywords - :return: pyqtgraph.ErrorBarItem instance - Does not include the line through nominal values as would be included in matplotlib's errorbar; this is - drawn, but it is a separate object. + :return: tuple containing + x, y, xerr, yerr """ - kwargs = dealias(**kwargs) data = kwargs.pop('data', None) + x, y, xerr, yerr = (list(args) + [None] * (4 - len(args)))[:5] if data is not None: x = data.get('x', None) y = data.get('y', None) xerr = data.get('xerr', None) yerr = data.get('yerr', None) + return x, y, xerr, yerr - # Separate keywords into those that affect a line through the data and those that affect the errorbars + def _process_errorbar_keywords(self, **kwargs): + """Separate keywords affecting error bars from those affecting nominal values & translate to pyqtgraph""" ekwargs = copy.deepcopy(kwargs) if kwargs.get('ecolor', None) is not None: ekwargs['color'] = kwargs.pop('ecolor') if kwargs.get('elinewidth', None) is not None: ekwargs['linewidth'] = kwargs.pop('elinewidth') epgkw = plotkw_translator(**ekwargs) + return epgkw + + def errorbar(self, x=None, y=None, yerr=None, xerr=None, **kwargs): + """ + Imitates matplotlib.axes.Axes.errorbar + + https://matplotlib.org/api/_as_gen/matplotlib.axes.Axes.errorbar.html + + :return: pyqtgraph.ErrorBarItem instance + Does not include the line through nominal values as would be included in matplotlib's errorbar; this is + drawn, but it is a separate object. + """ + kwargs = dealias(**kwargs) + x, y, xerr, yerr = self._interpret_xy_errorbar_data(x, y, xerr, yerr, **kwargs) + + # Separate keywords into those that affect a line through the data and those that affect the errorbars + epgkw = self._process_errorbar_keywords(**kwargs) w = np.array([True if i % int(round(kwargs.pop('errorevery', 1))) == 0 else False for i in range(len(np.atleast_1d(x)))]) @@ -502,7 +534,7 @@ def errorbar(self, x=None, y=None, yerr=None, xerr=None, **kwargs): self.plot(x, y, **kwargs) # Draw the errorbars - xp, yp, xerrp, yerrp = self._sanitize_errbar_data(x, y, xerr, yerr, w) + xp, yp, xerrp, yerrp = self._sanitize_errbar_data(x, y, xerr, yerr, mask=w) errb = pg.ErrorBarItem( x=xp, y=yp, height=0 if yerr is None else yerrp*2, width=0 if xerr is None else xerrp*2, **epgkw @@ -707,41 +739,47 @@ def __init__(self, x=None, **kwargs): self.cmap = kwargs.pop('cmap', None) self.norm = kwargs.pop('norm', None) self.alpha = kwargs.pop('alpha', None) - vmin = kwargs.pop('vmin', None) - vmax = kwargs.pop('vmax', None) - origin = kwargs.pop('origin', None) if data is not None: x = data['x'] if len(data.keys()) > 1: warnings.warn('Axes.imshow does not extract keywords from data yet (just x).') - xs = copy.copy(x) - + self.vmin = kwargs.pop('vmin', x.min()) + self.vmax = kwargs.pop('vmax', x.max()) self.check_inputs(**kwargs) + self._set_up_imange_extent(x=copy.copy(x), **kwargs) + + def _set_up_imange_extent(self, x, **kwargs): + """ + Handles setup of image extent, translate, and scale + """ + origin = kwargs.pop('origin', None) if origin in ['upper', None]: - xs = xs[::-1] + x = x[::-1] extent = kwargs.pop('extent', None) or (-0.5, x.shape[1]-0.5, -(x.shape[0]-0.5), -(0-0.5)) else: extent = kwargs.pop('extent', None) or (-0.5, x.shape[1]-0.5, -0.5, x.shape[0]-0.5) - if len(np.shape(xs)) == 3: - xs = np.transpose(xs, (2, 0, 1)) + if len(np.shape(x)) == 3: + x = np.transpose(x, (2, 0, 1)) else: - xs = np.array(color_map_translator( - xs.flatten(), cmap=self.cmap, norm=self.norm, vmin=vmin, vmax=vmax, clip=kwargs.pop('clip', False), - ncol=kwargs.pop('N', 256), alpha=self.alpha, - )).T.reshape([4] + tolist(xs.shape)) - - super(AxesImage, self).__init__(np.transpose(xs)) - if extent is not None: - self.resetTransform() - self.translate(extent[0], extent[2]) - self.scale((extent[1] - extent[0]) / self.width(), (extent[3] - extent[2]) / self.height()) - - self.vmin = vmin or x.min() - self.vmax = vmax or x.max() + x = np.array(color_map_translator( + x.flatten(), + cmap=self.cmap, + norm=self.norm, + vmin=self.vmin, + vmax=self.vmax, + clip=kwargs.pop('clip', False), + ncol=kwargs.pop('N', 256), + alpha=self.alpha, + )).T.reshape([4] + tolist(x.shape)) + + super(AxesImage, self).__init__(np.transpose(x)) + self.resetTransform() + self.translate(extent[0], extent[2]) + self.scale(int(round((extent[1] - extent[0]) / self.width())), int(round((extent[3] - extent[2]) / self.height()))) @staticmethod def check_inputs(**kw): diff --git a/pgmpl/contour.py b/pgmpl/contour.py index aaa1748..64c1491 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -23,7 +23,8 @@ # pgmpl # noinspection PyUnresolvedReferences import pgmpl.__init__ # __init__ does setup stuff like making sure a QApp exists -from pgmpl.translate import setup_pen_kw, color_map_translator +from pgmpl.translate import color_map_translator +from pgmpl.tools import setup_pen_kw from pgmpl.util import printd, tolist @@ -120,7 +121,7 @@ def draw_unfilled(self): x0, y0, x1, y1 = self.x.min(), self.y.min(), self.x.max(), self.y.max() for contour in contours: contour.translate(x0, y0) # https://stackoverflow.com/a/51109935/6605826 - contour.scale((x1 - x0) / np.shape(self.z)[0], (y1 - y0) / np.shape(self.z)[1]) + contour.scale(int(round((x1 - x0) / np.shape(self.z)[0])), int(round((y1 - y0) / np.shape(self.z)[1]))) self.ax.addItem(contour) diff --git a/pgmpl/figure.py b/pgmpl/figure.py index 2e91424..c020037 100644 --- a/pgmpl/figure.py +++ b/pgmpl/figure.py @@ -47,7 +47,7 @@ def __init__(self, **kw): self.resizeEvent = self.resize_event dpi = rcParams['figure.dpi'] if dpi is None else dpi figsize = rcParams['figure.figsize'] if figsize is None else figsize - self.width, self.height = np.array(figsize)*dpi + self.width, self.height = (np.array(figsize)*dpi).astype(int) self.resize(self.width, self.height) for init_to_none in ['axes', 'suptitle_label']: setattr(self, init_to_none, None) @@ -92,6 +92,7 @@ def resize_event(self, event): def set_subplotpars(self, pars): """ Sets margins and spacing between Axes. Not a direct matplotlib imitation. + :param pars: SubplotParams instance The subplotpars keyword to __init__ goes straight to here. """ @@ -99,11 +100,10 @@ def set_subplotpars(self, pars): # Either no pars were provided or the layout has already been set to None because the figure is closing. # Don't do any margin adjustments. return - if pars is not None: - self.margins = { - 'left': pars.left, 'top': pars.top, 'right': pars.right, 'bottom': pars.bottom, - 'hspace': pars.hspace, 'wspace': pars.wspace, - } + self.margins = { + 'left': pars.left, 'top': pars.top, 'right': pars.right, 'bottom': pars.bottom, + 'hspace': pars.hspace, 'wspace': pars.wspace, + } if self.margins is not None: if self.tight: self.layout.setContentsMargins( @@ -148,9 +148,21 @@ def add_subplot(self, nrows, ncols, index, **kwargs): self.refresh_suptitle() return ax + def _try_remove_from_layout(self, obj): + """ + Try to remove an item from the layout, catching the naughty `Exception` + + :param obj: object + """ + if obj is not None: + # noinspection PyBroadException + try: + self.layout.removeItem(obj) + except Exception: # pyqtgraph raises this type, so we can't be narrower + pass + def colorbar(self, mappable, cax=None, ax=None, **kwargs): - if ax is None: - ax = self.add_subplot(1, 1, 1) if self.axes is None else np.atleast_1d(self.axes).flatten()[-1] + ax = ax or self.gca() if cax is None: orientation = kwargs.get('orientation', 'vertical') row = int(np.floor((ax.index - 1) / ax.ncols)) @@ -165,11 +177,7 @@ def colorbar(self, mappable, cax=None, ax=None, **kwargs): else: sub_layout.layout.setColumnFixedWidth(1, 50) # https://stackoverflow.com/a/36897295/6605826 - # noinspection PyBroadException - try: - self.layout.removeItem(ax) - except Exception: - pass + self._try_remove_from_layout(ax) self.layout.addItem(sub_layout, row + 1, col) return Colorbar(cax, mappable, **kwargs) @@ -180,12 +188,7 @@ def suptitle(self, t, **kwargs): self.refresh_suptitle() def refresh_suptitle(self): - if self.suptitle_label is not None: - # noinspection PyBroadException - try: - self.layout.removeItem(self.suptitle_label) - except Exception: # pyqtgraph raises this type, so we can't be narrower - pass + self._try_remove_from_layout(self.suptitle_label) self.suptitle_label = self.layout.addLabel(self.suptitle_text, 0, 0, 1, self.fig_colspan) def closeEvent(self, event): @@ -199,16 +202,17 @@ def closeEvent(self, event): event.accept() return - def gca(self): + def gca(self, **kwargs): """ Imitation of matplotlib gca() + :return: Current axes for this figure, creating them if necessary """ self._deleted_axes_protection('gca') if self.axes is not None: ax = list(flatten(np.atleast_1d(self.axes)))[-1] if self.axes is None: - ax = self.add_subplot(1, 1, 1) + ax = self.add_subplot(1, 1, 1, **kwargs) return ax def close(self): diff --git a/pgmpl/text.py b/pgmpl/text.py index b151cb8..c9ca4b1 100644 --- a/pgmpl/text.py +++ b/pgmpl/text.py @@ -14,7 +14,8 @@ # Plotting imports import pyqtgraph as pg -from pgmpl.translate import color_translator, dealias +from pgmpl.translate import color_translator +from pgmpl.tools import dealias class Text(pg.TextItem): diff --git a/pgmpl/tools.py b/pgmpl/tools.py new file mode 100644 index 0000000..dd07f25 --- /dev/null +++ b/pgmpl/tools.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python +# # -*- coding: utf-8 -*- + +""" +Contains tools for helping set up matplotlib keywords, defaults, and drawing objects, like PyQt pens. + +Also has the more complicated plot keyword translator that uses basic translations and the tools. +""" + +# Basic imports +from __future__ import print_function, division +import os +import numpy as np +import copy + +# Plotting imports +import pyqtgraph as pg +from matplotlib import rcParams + +# pgmpl imports +from pgmpl.util import printd +from pgmpl.translate import color_translator, style_translator, symbol_translator + + +def dealias(**kws): + """ + Checks for alias of a keyword (like 'ls' for linestyle) and updates keywords so that the primary is defined. + + That is, if kws contains 'ls', the result will contain 'linestyle' with the value defined by 'ls'. This + eliminates the need to check through all the aliases later; other functions can rely on finding the primary + keyword. Also, the aliases are all removed so they don't confuse other functions. + + :param kws: keywords to dealias + + :return: dict + Dictionary with all aliases replaced by primary keywords (ls is replaced by linestyle, for example) + """ + alias_lists = { # If there is more than one alias, then the first one in the list is used + 'linewidth': ['lw'], + 'linestyle': ['ls'], + 'markeredgewidth': ['mew'], + 'markeredgecolor': ['mec'], + 'markerfacecolor': ['mfc'], + 'markersize': ['ms'], + 'markerfacecoloralt': ['mfcalt'], + 'antialiased': ['aa'], + 'color': ['c'], + 'edgecolor': ['ec'], + 'facecolor': ['fc'], + 'verticalalignment': ['va'], + 'horizontalalignment': ['ha'], + 'fake_test_keyword_with_two_aliases': ['test_alias1', 'test_alias2'] + } + for primary, aliases in list(alias_lists.items()): # https://stackoverflow.com/a/13998534/6605826 + for alias in aliases: + if alias in kws: + if primary not in kws.keys(): + kws[primary] = kws.pop(alias) + printd(" assigned kws['{}'] = kws.pop('{}')".format(primary, alias)) + else: + kws.pop(alias) + printd(' did not asssign {}'.format(primary)) + return kws + + +def symbol_edge_setup(pgkw, plotkw): + """ + Manage keywords related to symbol edges + + :param pgkw: Dictionary of new keywords to pass to pyqtgraph functions + + :param plotkw: Dictionary of matplotlib style keywords (translation in progress) + """ + default_mec = plotkw.get('color', None) if plotkw.get('marker', '') in ['x', '+', '.', ',', '|', '_'] else None + mec = plotkw.pop('markeredgecolor', default_mec) + mew = plotkw.pop('markeredgewidth', None) + symbol = symbol_translator(**plotkw) + if symbol is not None: + pgkw['symbol'] = symbol + penkw = {} + + if mec is not None: + penkw['color'] = mec + if mew is not None: + penkw['width'] = mew + if 'alpha' in plotkw: + penkw['alpha'] = plotkw.pop('alpha') + if len(penkw.keys()): + pgkw['symbolPen'] = setup_pen_kw(**penkw) + + # Handle fill + brushkw = {} + brush_color = color_translator(**plotkw) + if brush_color is not None: + brushkw['color'] = brush_color + if len(brushkw.keys()): + pgkw['symbolBrush'] = pg.mkBrush(**brushkw) + else: + pgkw.pop('symbolSize', None) # This isn't used when symbol is undefined, but it can cause problems, so remove. + printd('plotkw symbol = {}; symbol = {}'.format(plotkw.get('symbol', 'no symbol defined'), symbol), level=1) + + +def setup_pen_kw(penkw={}, **kw): + """ + Builds a pyqtgraph pen (object containing color, linestyle, etc. information) from Matplotlib keywords. + Please dealias first. + + :param penkw: dict + Dictionary of pre-translated pyqtgraph keywords to pass to pen + + :param kw: dict + Dictionary of Matplotlib style plot keywords in which line plot relevant settings may be specified. The entire + set of mpl plot keywords may be passed in, although only the keywords related to displaying line plots will be + used here. + + :return: pyqtgraph pen instance + A pen which can be input with the pen keyword to many pyqtgraph functions + """ + + # Move the easy keywords over directly + direct_translations_pen = { # plotkw: pgkw + 'linewidth': 'width', + } + for direct in direct_translations_pen: + penkw[direct_translations_pen[direct]] = kw.pop(direct, None) + + # Handle colors and styles + penkw['color'] = color_translator(**kw) + penkw['style'] = style_translator(**kw) + + # Prune values of None + penkw = {k: v for k, v in penkw.items() if v is not None} + + return pg.mkPen(**penkw) if len(penkw.keys()) else None + + +def defaults_from_rcparams(plotkw): + """ + Given a dictionary of Matplotlib style plotting keywords, any missing keywords (color, linestyle, etc.) will be + added using defaults determined by Matplotlib's rcParams. Please dealias plotkw first. + + :param plotkw: dict + Dictionary of Matplotlib style plot keywords + + :return: dict + Input dictionary with missing keywords filled in using defaults + """ + params = { # If you have a parameter that can't be assigned simply by just splitting after ., then set it up here. + # 'lines.linestyle': 'linestyle', # This can go in simples instead, but here's how it would go for example. + } + simples = ['lines.linewidth', 'lines.marker', 'lines.markeredgewidth', 'lines.markersize', 'lines.linestyle'] + for simple in simples: + params[simple] = '.'.join(simple.split('.')[1:]) + + for param in params.keys(): + if not params[param] in plotkw.keys(): + # Keyword is missing + plotkw[params[param]] = rcParams[param] + printd(" assigned plotkw['{}'] = rcParams[{}] = {}".format(params[param], param, rcParams[param]), + level=2) + else: + printd(" keywords {} exists in plotkw; no need to assign default from rcParams['{}']".format( + params[param], param), level=2) + + return plotkw + + +def plotkw_translator(**plotkw): + """ + Translates matplotlib plot keyword dictionary into a keyword dictionary suitable for pyqtgraph plot functions + + :param plotkw: dict + Dictionary of matplotlib plot() keywords + + :return: dict + Dictionary of pyqtgraph plot keywords + """ + + pgkw = {} + plotkw = dealias(**copy.deepcopy(plotkw)) # Copy: Don't break the original in case it's needed for other calls + plotkw = defaults_from_rcparams(plotkw) + + # First define the pen ----------------------------------------------------------------------------------------- + pen = setup_pen_kw(**plotkw) + if pen is not None: + pgkw['pen'] = pen + + # Next, translate symbol related keywords ---------------------------------------------------------------------- + + direct_translations = { # mpl style plotkw: pg style pgkw + 'markersize': 'symbolSize', + 'pg_label': 'label', # Not a real mpl keyword, but otherwise there would be no way to access pg's label + 'label': 'name', + } + for direct in direct_translations: + if direct in plotkw: + pgkw[direct_translations[direct]] = plotkw.pop(direct) + + # Handle symbol edge + symbol_edge_setup(pgkw, plotkw) + + # Pass through other keywords + late_pops = ['color', 'alpha', 'linewidth', 'marker', 'linestyle'] + for late_pop in late_pops: + # Didn't pop yet because used in a few places or popping above is inside of an if and may not have happened + plotkw.pop(late_pop, None) + plotkw.update(pgkw) + + return plotkw diff --git a/pgmpl/translate.py b/pgmpl/translate.py index a79df77..df367dd 100644 --- a/pgmpl/translate.py +++ b/pgmpl/translate.py @@ -3,6 +3,8 @@ """ Utilities for translating Matplotlib style keywords into PyQtGraph keywords + +This file is for simple translations. Complicated or compound tools go in tools. """ # Basic imports @@ -18,8 +20,8 @@ from pyqtgraph import QtCore import pyqtgraph as pg import matplotlib.cm -from matplotlib import rcParams from matplotlib.colors import Normalize +from pyqtgraph.graphicsItems.ScatterPlotItem import Symbols try: from matplotlib.colors import to_rgba except ImportError: # Older Matplotlib versions were organized differently @@ -28,7 +30,7 @@ # pgmpl imports from pgmpl.util import printd, tolist -from pyqtgraph.graphicsItems.ScatterPlotItem import Symbols + # Install custom symbols theta = np.linspace(0, 2 * np.pi, 36) @@ -46,80 +48,6 @@ Symbols[symb] = custom_symbols[symb] -def dealias(**kws): - """ - Checks for alias of a keyword (like 'ls' for linestyle) and updates keywords so that the primary is defined. - That is, if kws contains 'ls', the result will contain 'linestyle' with the value defined by 'lw'. This - eliminates the need to check through all the aliases later; other functions can rely on finding the primary - keyword. Also, the aliases are all removed so they don't confuse other functions. - - :param kws: keywords to dealias - - :return: dict - Dictionary with all aliases replaced by primary keywords (ls is replaced by linestyle, for example) - """ - missing_value_mark = 'This value is missing; it is not just None. We want to allow for the possibility that ' \ - 'keyword=None is treated differently than a missing keyword in some function, because a ' \ - 'function might not accept unrecognized keywords.' - alias_lists = { # If there is more than one alias, then the first one in the list is used - 'linewidth': ['lw'], - 'linestyle': ['ls'], - 'markeredgewith': ['mew'], - 'markeredgecolor': ['mec'], - 'markerfacecolor': ['mfc'], - 'markersize': ['ms'], - 'markerfacecoloralt': ['mfcalt'], - 'antialiased': ['aa'], - 'color': ['c'], - 'edgecolor': ['ec'], - 'facecolor': ['fc'], - 'verticalalignment': ['va'], - 'horizontalalignment': ['ha'], - } - for primary, aliases in list(alias_lists.items()): # https://stackoverflow.com/a/13998534/6605826 - aliasv = {alias: kws.pop(alias, missing_value_mark) for alias in aliases} - not_missing = [v != missing_value_mark for v in aliasv.values()] - if primary not in kws.keys() and any(not_missing): - # The aliases only need be considered if the primary is missing. - aliasu = np.atleast_1d(list(aliasv.keys()))[np.atleast_1d(not_missing)][0] - kws[primary] = aliasv[aliasu] - printd(" assigned kws['{}'] = kws.pop('{}')".format(primary, aliasu)) - else: - printd(' did not asssign {}'.format(primary)) - return kws - - -def defaults_from_rcparams(plotkw): - """ - Given a dictionary of Matplotlib style plotting keywords, any missing keywords (color, linestyle, etc.) will be - added using defaults determined by Matplotlib's rcParams. Please dealias plotkw first. - - :param plotkw: dict - Dictionary of Matplotlib style plot keywords - - :return: dict - Input dictionary with missing keywords filled in using defaults - """ - params = { # If you have a parameter that can't be assigned simply by just splitting after ., then set it up here. - # 'lines.linestyle': 'linestyle', # This can go in simples instead, but here's how it would go for example. - } - simples = ['lines.linewidth', 'lines.marker', 'lines.markeredgewidth', 'lines.markersize', 'lines.linestyle'] - for simple in simples: - params[simple] = '.'.join(simple.split('.')[1:]) - - for param in params.keys(): - if not params[param] in plotkw.keys(): - # Keyword is missing - plotkw[params[param]] = rcParams[param] - printd(" assigned plotkw['{}'] = rcParams[{}] = {}".format(params[param], param, rcParams[param]), - level=2) - else: - printd(" keywords {} exists in plotkw; no need to assign default from rcParams['{}']".format( - params[param], param), level=2) - - return plotkw - - def color_translator(**kw): """ Translates colors specified in the Matplotlib system into pyqtgraph color descriptions @@ -227,122 +155,3 @@ def symbol_translator(**kw): 'd': 'd', 's': 's', 'p': 'p', 'h': 'h', '_': '_', '|': '|', 'None': None, 'none': None, None: None, }.get(kw.get('marker', None), 'o') return pyqt_symbol - - -def symbol_edge_setup(pgkw, plotkw): - """ - Manage keywords related to symbol edges - - :param pgkw: Dictionary of new keywords to pass to pyqtgraph functions - - :param plotkw: Dictionary of matplotlib style keywords (translation in progress) - """ - default_mec = plotkw.get('color', None) if plotkw.get('marker', '') in ['x', '+', '.', ',', '|', '_'] else None - mec = plotkw.pop('markeredgecolor', default_mec) - mew = plotkw.pop('markeredgewidth', None) - symbol = symbol_translator(**plotkw) - if symbol is not None: - pgkw['symbol'] = symbol - penkw = {} - - if mec is not None: - penkw['color'] = mec - if mew is not None: - penkw['width'] = mew - if 'alpha' in plotkw: - penkw['alpha'] = plotkw.pop('alpha') - if len(penkw.keys()): - pgkw['symbolPen'] = setup_pen_kw(**penkw) - - # Handle fill - brushkw = {} - brush_color = color_translator(**plotkw) - if brush_color is not None: - brushkw['color'] = brush_color - if len(brushkw.keys()): - pgkw['symbolBrush'] = pg.mkBrush(**brushkw) - else: - pgkw.pop('symbolSize', None) # This isn't used when symbol is undefined, but it can cause problems, so remove. - printd('plotkw symbol = {}; symbol = {}'.format(plotkw.get('symbol', 'no symbol defined'), symbol), level=1) - - -def setup_pen_kw(penkw={}, **kw): - """ - Builds a pyqtgraph pen (object containing color, linestyle, etc. information) from Matplotlib keywords. - Please dealias first. - - :param penkw: dict - Dictionary of pre-translated pyqtgraph keywords to pass to pen - - :param kw: dict - Dictionary of Matplotlib style plot keywords in which line plot relevant settings may be specified. The entire - set of mpl plot keywords may be passed in, although only the keywords related to displaying line plots will be - used here. - - :return: pyqtgraph pen instance - A pen which can be input with the pen keyword to many pyqtgraph functions - """ - - # Move the easy keywords over directly - direct_translations_pen = { # plotkw: pgkw - 'linewidth': 'width', - } - for direct in direct_translations_pen: - if direct in kw and kw[direct] is not None: - penkw[direct_translations_pen[direct]] = kw[direct] - - # Handle colors - newc = color_translator(**kw) - if newc is not None: - penkw['color'] = newc # If no color information was defined, leave this alone to allow default colors - - # Line style - news = style_translator(**kw) - if news is not None: - penkw['style'] = news - - return pg.mkPen(**penkw) if len(penkw.keys()) else None - - -def plotkw_translator(**plotkw): - """ - Translates matplotlib plot keyword dictionary into a keyword dictionary suitable for pyqtgraph plot functions - - :param plotkw: dict - Dictionary of matplotlib plot() keywords - - :return: dict - Dictionary of pyqtgraph plot keywords - """ - - pgkw = {} - plotkw = dealias(**copy.deepcopy(plotkw)) # Copy: Don't break the original in case it's needed for other calls - plotkw = defaults_from_rcparams(plotkw) - - # First define the pen ----------------------------------------------------------------------------------------- - pen = setup_pen_kw(**plotkw) - if pen is not None: - pgkw['pen'] = pen - - # Next, translate symbol related keywords ---------------------------------------------------------------------- - - direct_translations = { # mpl style plotkw: pg style pgkw - 'markersize': 'symbolSize', - 'pg_label': 'label', # Not a real mpl keyword, but otherwise there would be no way to access pg's label - 'label': 'name', - } - for direct in direct_translations: - if direct in plotkw: - pgkw[direct_translations[direct]] = plotkw.pop(direct) - - # Handle symbol edge - symbol_edge_setup(pgkw, plotkw) - - # Pass through other keywords - late_pops = ['color', 'alpha', 'linewidth', 'marker', 'linestyle'] - for late_pop in late_pops: - # Didn't pop yet because used in a few places or popping above is inside of an if and may not have happened - plotkw.pop(late_pop, None) - plotkw.update(pgkw) - - return plotkw diff --git a/pgmpl/util.py b/pgmpl/util.py index 3b8d44e..d4b175d 100644 --- a/pgmpl/util.py +++ b/pgmpl/util.py @@ -50,4 +50,3 @@ def is_numeric(value): return True except TypeError: return False - diff --git a/tests/test_axes.py b/tests/test_axes.py index bf1b257..4b961f3 100755 --- a/tests/test_axes.py +++ b/tests/test_axes.py @@ -11,11 +11,17 @@ import unittest import numpy as np import warnings +import pyqtgraph # pgmpl from pgmpl import __init__ # __init__ does setup stuff like making sure a QApp exists from pgmpl.axes import Axes +pgv = list(pyqtgraph.__version__.split('.')) +pgv + [0] * (3 - len(pgv)) +pgv_major, pgv_minor, pgv_patch = pgv +pgvmm = f'{pgv_major}.{pgv_minor}' + class TestPgmplAxes(unittest.TestCase): """ @@ -92,7 +98,7 @@ def test_axes_imshow_warnings(self): a = self.rgb2d ax = Axes() - warnings_expected = 8 + warnings_expected = {'0.10': 8, '0.11': 8, '0.12': 10}[pgvmm] with warnings.catch_warnings(record=True) as w: # Cause all warnings to always be triggered. warnings.simplefilter("always") @@ -101,10 +107,15 @@ def test_axes_imshow_warnings(self): data={'x': a, 'unrecognized': 'thingy'}, shape=np.shape(a), imlim=55, interpolation='nearest', filternorm=2, filterrad=5.0, resample=True, url='google.com', blah=True) # 8 warnings # Verify that warnings were made. - assert len(w) == warnings_expected + warnlist = '\n'.join([f"{i+1}: {ww.message} in {ww.filename}:{ww.lineno}" for i, ww in enumerate(w)]) + if len(w) != warnings_expected: + print(f'\nExpected {warnings_expected} warnings and detected {len(w)} warnings:\n{warnlist}\n') + self.assertEqual(len(w), warnings_expected, 'Number of warnings does not match expectation') assert isinstance(img, AxesImage) # It should still return the instance using the implemented keywords. - self.printv(' test_axes_imshow_warnings: tried to call Axes.imshow instance using unimplemented keywords ' - 'and got {}/{} warnings. img = {}'.format(len(w), warnings_expected, img)) + self.printv( + ' test_axes_imshow_warnings: tried to call Axes.imshow instance using unimplemented ' + 'keywords and got {}/{} warnings. img = {}'.format(len(w), warnings_expected, img) + ) def test_axes_warnings(self): ax = Axes() diff --git a/tests/test_figure.py b/tests/test_figure.py index c496e65..fd9df8b 100755 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -94,6 +94,32 @@ def test_fig_colorbar(self): fig.colorbar(img) fig.close() + def test_catch_deleted_axes(self): + """Set up the case where gca() is used when Qt has deleted the axes and test robustness""" + import sip + # Add an axes instance to a figure + fig = Figure() + ax = fig.add_subplot(1, 1, 1) + # Confirm that gca() recovers this set of axes with no problem and that the figure knows about them + ax2 = fig.gca() + self.assertIs(ax, ax2) + self.assertIsNotNone(fig.axes) + # Try to delete the axes in Qt, not going through pgmpl in a way that would tell it of the deletion + sip.delete(ax) + # Check that fig's reference to the deleted axes is erased by the _deleted_axes_protection() method + fig._deleted_axes_protection('testing') + self.assertIsNone(fig.axes) + + # Try again, less directly + ax = fig.add_subplot(1, 1, 1) + ax2 = fig.gca() + self.assertIs(ax, ax2) + self.assertIsNotNone(fig.axes) + sip.delete(ax) + # Confirm that gca() can't find the old axes anymore, because the deleted axes protection found the problem + ax3 = fig.gca() + self.assertIsNot(ax, ax3) + def setUp(self): test_id = self.id() test_name = '.'.join(test_id.split('.')[-2:]) diff --git a/tests/test_translate.py b/tests/test_translate.py index a81fbaa..2c7ef78 100755 --- a/tests/test_translate.py +++ b/tests/test_translate.py @@ -17,9 +17,8 @@ # pgmpl from pgmpl import __init__ # __init__ does setup stuff like making sure a QApp exists from pgmpl.util import set_debug -from pgmpl.translate import defaults_from_rcparams, color_translator, style_translator, symbol_translator, \ - setup_pen_kw, plotkw_translator, dealias, color_map_translator - +from pgmpl.translate import color_translator, style_translator, symbol_translator, color_map_translator +from pgmpl.tools import defaults_from_rcparams, setup_pen_kw, plotkw_translator, dealias class TestPgmplTranslate(unittest.TestCase): """ @@ -111,9 +110,24 @@ def test_plotkw_translator(self): newk[i] = plotkw_translator(**self.plot_kw_tests[i]) def test_dealias(self): - test_dict = {'lw': 5, 'ls': '--', 'mec': 'r', 'markeredgewidth': 1, 'blah': 0} - correct_answer = {'linewidth': 5, 'linestyle': '--', 'markeredgecolor': 'r', 'markeredgewidth': 1, 'blah': 0} + test_dict = { + 'lw': 5, + 'ls': '--', + 'mec': 'r', + 'markeredgewidth': 1, 'mew': 2, # The primary and the alias are defined; alias should be ignored + 'blah': 0, + 'test_alias1': 1, 'test_alias2': 2, # Two aliases are defined for the same primary; should ignore 2nd + } + correct_answer = { + 'linewidth': 5, + 'linestyle': '--', + 'markeredgecolor': 'r', + 'markeredgewidth': 1, + 'blah': 0, + 'fake_test_keyword_with_two_aliases': 1, + } test_answer = dealias(**test_dict) + self.assertEqual(correct_answer, test_answer) assert correct_answer == test_answer # https://stackoverflow.com/a/5635309/6605826 assert dealias(lw=8) == {'linewidth': 8}