From fb06733f82f9c201eab5b77e06954cfe48e52400 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Thu, 24 May 2018 22:15:11 -0700 Subject: [PATCH 01/30] Move 2D test data to top level of Axes unittest --- pgmpl/axes.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/pgmpl/axes.py b/pgmpl/axes.py index 7bd4c1c..e598aa5 100644 --- a/pgmpl/axes.py +++ b/pgmpl/axes.py @@ -50,6 +50,7 @@ def __init__(self, **kwargs): self.setXLink(sharex) if sharey is not None: self.setYLink(sharey) + self._hold = kwargs.pop('hold', True) def clear(self): printd(' Clearing Axes instance {}...'.format(self)) @@ -474,6 +475,7 @@ def set_yscale(self, value, **kwargs): class AxesImage(pg.ImageItem): + """Powers Axes.imshow""" def __init__( self, x, cmap=None, norm=None, interpolation=None, alpha=None, vmin=None, vmax=None, origin=None, extent=None, shape=None, filternorm=1, filterrad=4.0, imlim=None, resample=None, url=None, @@ -686,6 +688,13 @@ class TestPgmplAxes(unittest.TestCase): y = x**2 + 2.5 z = x**3 - x**2 * 1.444 + rgb2d = np.zeros((8, 8, 3)) + rgb2d[0, 0, :] = 0.9 + rgb2d[4, 4, :] = 1 + rgb2d[3, 2, 0] = 0.5 + rgb2d[2, 3, 1] = 0.7 + rgb2d[3, 3, 2] = 0.6 + def test_axes_init(self): ax = Axes() if self.verbose: @@ -707,12 +716,7 @@ def test_axes_scatter(self): verts=[(0, 0), (0.5, 0.5), (0, 0.5), (-0.5, 0), (0, -0.5), (0.5, -0.5)]) def test_axes_imshow(self): - a = np.zeros((8, 8, 3)) - a[0, 0, :] = 0.9 - a[4, 4, :] = 1 - a[3, 2, 0] = 0.5 - a[2, 3, 1] = 0.7 - a[3, 3, 2] = 0.6 + a = self.rgb2d ax = Axes() ax.imshow(a) ax1 = Axes() From 972870bdbf2313d9787866852f974d026693ffd8 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Thu, 24 May 2018 22:16:33 -0700 Subject: [PATCH 02/30] Start adding contour functions - Mention #22 --- pgmpl/axes.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pgmpl/axes.py b/pgmpl/axes.py index e598aa5..a64cda5 100644 --- a/pgmpl/axes.py +++ b/pgmpl/axes.py @@ -168,6 +168,22 @@ def imshow(self, x, aspect=None, **kwargs): self.addItem(img) return img + def contour(self, *args, **kwargs): + printd(' pgmpl.axes.Axes.contour()...') + if not self._hold: + self.clear() + kwargs['filled'] = False + contours = mcontour.QuadContourSet(self, *args, **kwargs) + return contours + + def contourf(self, *args, **kwargs): + printd(' pgmpl.axes.Axes.contourf()...') + if not self._hold: + self.clear() + kwargs['filled'] = True + contours = mcontour.QuadContourSet(self, *args, **kwargs) + return contours + def set_xlabel(self, label): """Imitates basic use of matplotlib.axes.Axes.set_xlabel()""" self.setLabel('bottom', text=label) @@ -724,6 +740,18 @@ def test_axes_imshow(self): if self.verbose: print('test_axes_imshow: ax = {}, ax1 = {}'.format(ax, ax1)) + def test_axes_contour(self): + a = sum(self.rgb2d, 2) * 10 + levels = [0, 0.5, 1.2, 5, 9, 10, 20, 30] + print('shape(a) = {}'.format(np.shape(a))) + ax = Axes() + ax1 = Axes() + ax.contour(a) + ax1.contourf(a) + if self.verbose: + print('test_axes_contour: ax = {}, contours = {}, ax1 = {}, contourfs = {}'.format( + ax, contours, ax1, contourfs)) + def test_axes_err(self): ax = Axes() yerr = self.y*0.1 From 4e5b9cbbed25351ad8d5fb04e4db3d0df29ffbec Mon Sep 17 00:00:00 2001 From: David Eldon Date: Thu, 28 Jun 2018 14:59:32 -0700 Subject: [PATCH 03/30] Add some contour junk --- pgmpl/axes.py | 5 +-- pgmpl/contour.py | 71 +++++++++++++++++++++++++++++++++++++++++++ tests/test_contour.py | 27 ++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 pgmpl/contour.py create mode 100644 tests/test_contour.py diff --git a/pgmpl/axes.py b/pgmpl/axes.py index 3ec41ee..9ef3b51 100644 --- a/pgmpl/axes.py +++ b/pgmpl/axes.py @@ -27,6 +27,7 @@ from translate import plotkw_translator, color_translator, setup_pen_kw, color_map_translator, dealias from util import printd, tolist, is_numeric from text import Text +from contour import QuadContourSet class Axes(pg.PlotItem): @@ -186,7 +187,7 @@ def contour(self, *args, **kwargs): if not self._hold: self.clear() kwargs['filled'] = False - contours = mcontour.QuadContourSet(self, *args, **kwargs) + contours = QuadContourSet(self, *args, **kwargs) return contours def contourf(self, *args, **kwargs): @@ -194,7 +195,7 @@ def contourf(self, *args, **kwargs): if not self._hold: self.clear() kwargs['filled'] = True - contours = mcontour.QuadContourSet(self, *args, **kwargs) + contours = QuadContourSet(self, *args, **kwargs) return contours def set_xlabel(self, label): diff --git a/pgmpl/contour.py b/pgmpl/contour.py new file mode 100644 index 0000000..0a3231c --- /dev/null +++ b/pgmpl/contour.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python +# # -*- coding: utf-8 -*- + +""" +Imitates matplotlib.contour but using PyQtGraph to make the plots. + +Classes and methods imitate Matplotlib counterparts as closely as possible, so please see Matplotlib documentation for +more information. +""" + + +class ContourSet(object): + + def __init__(self, ax, *args, **kwargs): + self.ax = ax + pop_default_none = [ + 'levels', 'linewidths', 'linestyles', 'alpha', 'origin', 'extent', 'cmap', 'colors', 'norm', 'vmin', 'vmax', + 'antialiased', 'locator', + ] + for pdn in pop_default_none: + self.__setattr__(pdn, kwargs.pop(pdn, None)) + self.filled = kwargs.pop('filled', False) + self.hatches = kwargs.pop('hatches', [None]) + self.extend = kwargs.pop('extend', 'neither') + if self.antialiased is None and self.filled: + self.antialiased = False + self.nchunk = kwargs.pop('nchunk', 0) + kwargs = self._process_args(*args, **kwargs) + + return + + def _process_args(self, *args, **kwargs): + """ + Process *args* and *kwargs*; override in derived classes. + + Must set self.levels, self.zmin and self.zmax, and update axes limits. + Adapted from matplotlib.contour.ContourSet + """ + self.levels = args[0] + self.allsegs = args[1] + self.allkinds = len(args) > 2 and args[2] or None + self.zmax = np.max(self.levels) + self.zmin = np.min(self.levels) + # self._auto = False + # + # # Check lengths of levels and allsegs. + # if self.filled: + # if len(self.allsegs) != len(self.levels) - 1: + # raise ValueError('must be one less number of segments as ' + # 'levels') + # else: + # if len(self.allsegs) != len(self.levels): + # raise ValueError('must be same number of segments as levels') + # + # # Check length of allkinds. + # if self.allkinds is not None and len(self.allkinds) != len(self.allsegs): + # raise ValueError('allkinds has different length to allsegs') + # + # # Determine x,y bounds and update axes data limits. + # flatseglist = [s for seg in self.allsegs for s in seg] + # points = np.concatenate(flatseglist, axis=0) + # self._mins = points.min(axis=0) + # self._maxs = points.max(axis=0) + + return kwargs + + +class QuadContourSet(ContourSet): + """blah""" +# def __init__(self, **kwargs): +# super(QuadContourSet, self).__init__(**kwargs) diff --git a/tests/test_contour.py b/tests/test_contour.py new file mode 100644 index 0000000..647409a --- /dev/null +++ b/tests/test_contour.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# # -*- coding: utf-8 -*- + +""" +Test script for contour.py +""" + +# Basic imports +from __future__ import print_function, division +import os +import unittest +import numpy as np +import warnings + +# pgmpl +from pgmpl import __init__ # __init__ does setup stuff like making sure a QApp exists +from pgmpl.axes import Axes +from pgmpl.contour import QuadContourSet, ContourSet + + +class TestPgmplContour(unittest.TestCase): + + verbose = int(os.environ.get('PGMPL_TEST_VERBOSE', '0')) + + +if __name__ == '__main__': + unittest.main() From f4b7445e4e1143f5a8f0a8c6cb6d6b2bf3b69c13 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 29 Jun 2018 12:53:47 -0700 Subject: [PATCH 04/30] Contour setup stuff --- pgmpl/axes.py | 1 + pgmpl/contour.py | 68 ++++++++++++++++++++++++++++++++++++++++--- tests/test_axes.py | 12 ++++---- tests/test_contour.py | 34 ++++++++++++++++++++++ 4 files changed, 105 insertions(+), 10 deletions(-) mode change 100644 => 100755 tests/test_contour.py diff --git a/pgmpl/axes.py b/pgmpl/axes.py index 24b0f22..418881d 100644 --- a/pgmpl/axes.py +++ b/pgmpl/axes.py @@ -45,6 +45,7 @@ def __init__(self, **kwargs): tmp = self.prop_cycle() self.cyc = defaultdict(lambda: next(tmp)) self.prop_cycle_index = 0 + self._hold = False if self.sharex is not None: self.setXLink(self.sharex) if self.sharey is not None: diff --git a/pgmpl/contour.py b/pgmpl/contour.py index 0a3231c..cc192f9 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -9,6 +9,26 @@ """ +# Basic imports +from __future__ import print_function, division +import warnings +import copy + +# Calculation imports +import numpy as np + +# Plotting imports +import pyqtgraph as pg +from matplotlib import rcParams +from collections import defaultdict + +# pgmpl +# noinspection PyUnresolvedReferences +import __init__ # __init__ does setup stuff like making sure a QApp exists +from translate import plotkw_translator, color_translator, setup_pen_kw, color_map_translator, dealias +from util import printd, tolist, is_numeric + + class ContourSet(object): def __init__(self, ax, *args, **kwargs): @@ -25,10 +45,50 @@ def __init__(self, ax, *args, **kwargs): if self.antialiased is None and self.filled: self.antialiased = False self.nchunk = kwargs.pop('nchunk', 0) - kwargs = self._process_args(*args, **kwargs) + self.x, self.y, self.z, self.levels = self.choose_xyz_levels(*args) return + def auto_pick_levels(self, z, nlvl=None): + """ + Pick contour levels automatically + :param z: 2D array + :param nlvl: int or None + Number of levels; set to some arbitrary default if None + :return: array + """ + nlvl = 5 if nlvl is None else nlvl + self.vmin = z.min() if self.vmin is None else self.vmin + self.vmax = z.max() if self.vmax is None else self.vmax + return np.linspace(self.vmin, self.vmax, nlvl) + + def choose_xyz_levels(self, *args): + """ + Interprets args to pick the contour value Z, the X,Y coordinates, and the contour levels. + :param args: list of arguments received by __init__ + Could be [z] or [x, y, z] or [z, L] or [x, y, z, L], and L could be an array of levels or a number of levels + :return: tuple of arrays for x, y, z, and levels + """ + x = y = lvlinfo = None + + if len(args) == 1: + z = args[0] + elif len(args) == 2: + z, lvlinfo = args + elif len(args) == 3: + x, y, z = args + elif len(args) == 4: + x, y, z, lvlinfo = args + else: + raise TypeError('choose_xyz_levels takes 1, 2, 3, or 4 arguments. Got {} arguments.'.format(len(args))) + + levels = lvlinfo if ((lvlinfo is not None) and np.iterable(lvlinfo)) else self.auto_pick_levels(z, lvlinfo) + + if x is None: + x, y = np.arange(np.shape(z)[0]), np.arange(np.shape(z)[1]) + + return x, y, z, levels + def _process_args(self, *args, **kwargs): """ Process *args* and *kwargs*; override in derived classes. @@ -66,6 +126,6 @@ def _process_args(self, *args, **kwargs): class QuadContourSet(ContourSet): - """blah""" -# def __init__(self, **kwargs): -# super(QuadContourSet, self).__init__(**kwargs) + """Provided to make this thing follow the same sort of structure as matplotlib""" + def __init__(self, *args, **kwargs): + super(QuadContourSet, self).__init__(*args, **kwargs) diff --git a/tests/test_axes.py b/tests/test_axes.py index f59b10e..10595fa 100755 --- a/tests/test_axes.py +++ b/tests/test_axes.py @@ -80,18 +80,18 @@ def test_axes_imshow(self): def test_axes_contour(self): a = sum(self.rgb2d, 2) * 10 levels = [0, 0.5, 1.2, 5, 9, 10, 20, 30] - print('shape(a) = {}'.format(np.shape(a))) ax = Axes() - ax1 = Axes() ax.contour(a) + ax1 = Axes() ax1.contourf(a) - if self.verbose: - print('test_axes_contour: ax = {}, contours = {}, ax1 = {}, contourfs = {}'.format( - ax, contours, ax1, contourfs)) + ax2 = Axes() + ax2.contour(a, levels) + ax3 = Axes() + ax3.contour(a, 3) def test_axes_imshow_warnings(self): from pgmpl.axes import AxesImage - a = self.imgdat1 + a = self.rgb2d ax = Axes() warnings_expected = 8 diff --git a/tests/test_contour.py b/tests/test_contour.py old mode 100644 new mode 100755 index 647409a..c0939b0 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -15,6 +15,7 @@ # pgmpl from pgmpl import __init__ # __init__ does setup stuff like making sure a QApp exists from pgmpl.axes import Axes +from pgmpl.pyplot import subplots from pgmpl.contour import QuadContourSet, ContourSet @@ -22,6 +23,39 @@ class TestPgmplContour(unittest.TestCase): verbose = int(os.environ.get('PGMPL_TEST_VERBOSE', '0')) + x = np.linspace(0, 1.8, 30) + y = np.linspace(0, 2.1, 25) + z = (x[:, np.newaxis] - 0.94)**2 + (y[np.newaxis, :] - 1.2)**2 + 1.145 + levels = [1, 1.5, 2, 2.5, 3] + nlvl = len(levels) + + def printv(self, *args): + if self.verbose: + print(*args) + + def test_contour(self): + fig, axs = subplots(4, 2) + axs[0, 0].contour(self.z) + axs[0, 1].contour(-self.z) + axs[1, 0].contour(self.z, self.levels) + axs[1, 1].contour(self.z, self.nlvl) + axs[2, 0].contour(self.x, self.y, self.z) + axs[3, 0].contour(self.x, self.y, self.z, self.levels) + axs[3, 1].contour(self.x, self.y, self.z, self.nlvl) + + def test_contourf(self): + ax = Axes() + ax.contourf(self.z) + + def setUp(self): + test_id = self.id() + test_name = '.'.join(test_id.split('.')[-2:]) + self.printv('{}...'.format(test_name)) + + def tearDown(self): + test_name = '.'.join(self.id().split('.')[-2:]) + self.printv(' {} done.'.format(test_name)) + if __name__ == '__main__': unittest.main() From 18b60bb8e353d462abec8c7ace93a68f31f81d76 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 29 Jun 2018 13:18:08 -0700 Subject: [PATCH 05/30] Very basic semi-working contour --- pgmpl/axes.py | 5 ---- pgmpl/contour.py | 56 ++++++++++++++++--------------------------- tests/test_contour.py | 14 ++++++++++- 3 files changed, 34 insertions(+), 41 deletions(-) mode change 100755 => 100644 tests/test_contour.py diff --git a/pgmpl/axes.py b/pgmpl/axes.py index 418881d..5ce67da 100644 --- a/pgmpl/axes.py +++ b/pgmpl/axes.py @@ -45,7 +45,6 @@ def __init__(self, **kwargs): tmp = self.prop_cycle() self.cyc = defaultdict(lambda: next(tmp)) self.prop_cycle_index = 0 - self._hold = False if self.sharex is not None: self.setXLink(self.sharex) if self.sharey is not None: @@ -183,16 +182,12 @@ def imshow(self, x=None, aspect=None, **kwargs): def contour(self, *args, **kwargs): printd(' pgmpl.axes.Axes.contour()...') - if not self._hold: - self.clear() kwargs['filled'] = False contours = QuadContourSet(self, *args, **kwargs) return contours def contourf(self, *args, **kwargs): printd(' pgmpl.axes.Axes.contourf()...') - if not self._hold: - self.clear() kwargs['filled'] = True contours = QuadContourSet(self, *args, **kwargs) return contours diff --git a/pgmpl/contour.py b/pgmpl/contour.py index cc192f9..d6d52c8 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -46,9 +46,15 @@ def __init__(self, ax, *args, **kwargs): self.antialiased = False self.nchunk = kwargs.pop('nchunk', 0) self.x, self.y, self.z, self.levels = self.choose_xyz_levels(*args) + self.auto_range(self.z) + self.draw() return + def auto_range(self, z): + self.vmin = z.min() if self.vmin is None else self.vmin + self.vmax = z.max() if self.vmax is None else self.vmax + def auto_pick_levels(self, z, nlvl=None): """ Pick contour levels automatically @@ -58,8 +64,7 @@ def auto_pick_levels(self, z, nlvl=None): :return: array """ nlvl = 5 if nlvl is None else nlvl - self.vmin = z.min() if self.vmin is None else self.vmin - self.vmax = z.max() if self.vmax is None else self.vmax + self.auto_range(z) return np.linspace(self.vmin, self.vmax, nlvl) def choose_xyz_levels(self, *args): @@ -89,40 +94,21 @@ def choose_xyz_levels(self, *args): return x, y, z, levels - def _process_args(self, *args, **kwargs): - """ - Process *args* and *kwargs*; override in derived classes. + def draw(self): + if self.filled: + self.draw_filled() + else: + self.draw_unfilled() - Must set self.levels, self.zmin and self.zmax, and update axes limits. - Adapted from matplotlib.contour.ContourSet - """ - self.levels = args[0] - self.allsegs = args[1] - self.allkinds = len(args) > 2 and args[2] or None - self.zmax = np.max(self.levels) - self.zmin = np.min(self.levels) - # self._auto = False - # - # # Check lengths of levels and allsegs. - # if self.filled: - # if len(self.allsegs) != len(self.levels) - 1: - # raise ValueError('must be one less number of segments as ' - # 'levels') - # else: - # if len(self.allsegs) != len(self.levels): - # raise ValueError('must be same number of segments as levels') - # - # # Check length of allkinds. - # if self.allkinds is not None and len(self.allkinds) != len(self.allsegs): - # raise ValueError('allkinds has different length to allsegs') - # - # # Determine x,y bounds and update axes data limits. - # flatseglist = [s for seg in self.allsegs for s in seg] - # points = np.concatenate(flatseglist, axis=0) - # self._mins = points.min(axis=0) - # self._maxs = points.max(axis=0) - - return kwargs + def draw_filled(self): + printd(' not ready yet lol') + + def draw_unfilled(self): + contours = [pg.IsocurveItem(data=self.z, level=level, pen='r') for level in self.levels] + for contour in contours: + contour.setParentItem(self.ax) + contour.setZValue(10) + self.ax.addItem(contour) class QuadContourSet(ContourSet): diff --git a/tests/test_contour.py b/tests/test_contour.py old mode 100755 new mode 100644 index c0939b0..5b7ca79 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -27,7 +27,7 @@ class TestPgmplContour(unittest.TestCase): y = np.linspace(0, 2.1, 25) z = (x[:, np.newaxis] - 0.94)**2 + (y[np.newaxis, :] - 1.2)**2 + 1.145 levels = [1, 1.5, 2, 2.5, 3] - nlvl = len(levels) + nlvl = len(levels) * 4 def printv(self, *args): if self.verbose: @@ -35,12 +35,24 @@ def printv(self, *args): def test_contour(self): fig, axs = subplots(4, 2) + axs[0, 0].set_title('z') axs[0, 0].contour(self.z) + axs[0, 1].set_title('-z') axs[0, 1].contour(-self.z) + + axs[1, 0].set_title('z, levels') axs[1, 0].contour(self.z, self.levels) + axs[1, 1].set_title('z, nlvl') axs[1, 1].contour(self.z, self.nlvl) + + axs[2, 0].set_title('x, y, z') axs[2, 0].contour(self.x, self.y, self.z) + axs[2, 0].set_title('x, y, -z') + axs[2, 1].contour(self.x, self.y, -self.z) + + axs[3, 0].set_title('x, y, z, levels') axs[3, 0].contour(self.x, self.y, self.z, self.levels) + axs[3, 1].set_title('x, y, z, nlvl') axs[3, 1].contour(self.x, self.y, self.z, self.nlvl) def test_contourf(self): From 34bd3e4f6db1f7cb3b36f8c9dd651803768f6b5d Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 29 Jun 2018 14:01:47 -0700 Subject: [PATCH 06/30] Add test of contour errors --- tests/test_contour.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_contour.py b/tests/test_contour.py index 5b7ca79..b6441d5 100644 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -59,6 +59,10 @@ def test_contourf(self): ax = Axes() ax.contourf(self.z) + def test_contour_errors(self): + ax = Axes() + self.assertRaises(TypeError, ax.contour, self.x, self.y, self.z, self.levels, self.nlvl) + def setUp(self): test_id = self.id() test_name = '.'.join(test_id.split('.')[-2:]) From 42d8b74a6c35b0b0d5854415a0636d0c2918d9fe Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 29 Jun 2018 14:12:10 -0700 Subject: [PATCH 07/30] Move legend to new file --- pgmpl/axes.py | 141 +----------------------------------- pgmpl/legend.py | 165 +++++++++++++++++++++++++++++++++++++++++++ tests/test_axes.py | 36 ---------- tests/test_legend.py | 88 +++++++++++++++++++++++ 4 files changed, 254 insertions(+), 176 deletions(-) create mode 100644 pgmpl/legend.py create mode 100755 tests/test_legend.py diff --git a/pgmpl/axes.py b/pgmpl/axes.py index 5ce67da..4346f16 100644 --- a/pgmpl/axes.py +++ b/pgmpl/axes.py @@ -25,6 +25,7 @@ # noinspection PyUnresolvedReferences import __init__ # __init__ does setup stuff like making sure a QApp exists from translate import plotkw_translator, color_translator, setup_pen_kw, color_map_translator, dealias +from legend import Legend from util import printd, tolist, is_numeric from text import Text from contour import QuadContourSet @@ -582,143 +583,3 @@ def check_inputs(**kw): warnings.warn('Axes.imshow ignores changes to keywords filternorm and filterrad.') if len(kw.keys()): warnings.warn('Axes.imshow got unhandled keywords: {}'.format(kw.keys())) - - -class Legend: - """ - Post-generated legend for pgmpl.axes.Axes. This is not a direct imitation of Matplotlib's Legend as it has to - accept events from pyqtgraph. It also has to bridge the gap between Matplotlib style calling legend after plotting - and pyqtgraph style calling legend first. - - The call method is supposed to imitate matplotlib.axes.Axes.legend(), though. Bind a Legend class instance to an - Axes instance as Axes.legend = Legend() and then call Axes.legend() as in matplotlib. - The binding is done in Axes class __init__. - """ - def __init__(self, ax=None): - """ - :param ax: Axes instance - Reference to the plot axes to which this legend is attached (required). - """ - # noinspection PyProtectedMember - from pyqtgraph.graphicsItems.ViewBox.ViewBox import ChildGroup - self.ax = ax - # pyqtgraph legends just don't work with some items yet. Avoid errors by trying to use these classes as handles: - self.unsupported_item_classes = [ - pg.graphicsItems.FillBetweenItem.FillBetweenItem, - pg.InfiniteLine, - ChildGroup, - ] - # File "/lib/python2.7/site-packages/pyqtgraph/graphicsItems/LegendItem.py", line 149, in paint - # opts = self.item.opts - # AttributeError: 'InfiniteLine' object has no attribute 'opts' - self.items_added = [] - self.drag_enabled = True - self.leg = None - - def supported(self, item): - """Quick test for whether or not item (which is some kind of plot object) is supported by this legend class""" - return not any([isinstance(item, uic) for uic in self.unsupported_item_classes]) - - @staticmethod - def handle_info(handles, comment=None): - """For debugging: prints information on legend handles""" - if comment is not None: - printd(comment) - for i, handle in enumerate(tolist(handles)): - printd(' {i:02d} handle name: {name:}, class: {cls:}, isVisible: {isvis:}'.format( - i=i, - name=handle.name() if hasattr(handle, 'name') else None, - cls=handle.__class__ if hasattr(handle, '__class__') else ' not found ', - isvis=handle.isVisible() if hasattr(handle, 'isVisible') else None, - )) - - def get_visible_handles(self): - """ - :return: List of legend handles for visible plot items - """ - handles = self.ax.getViewBox().allChildren() - self.handle_info(handles, comment='handles from allChildren') - return [item for item in handles if hasattr(item, 'isVisible') and item.isVisible()] - - @staticmethod - def _cleanup_legend_labels(handles, labels): - nlab = len(np.atleast_1d(labels)) - if labels is not None and nlab == 1: - labels = tolist(labels)*len(handles) - elif labels is not None and nlab == len(handles): - labels = tolist(labels) - else: - handles = [item for item in handles if hasattr(item, 'name') and item.name() is not None] - labels = [item.name() for item in handles] - return handles, labels - - def __call__(self, handles=None, labels=None, **kw): - """ - Adds a legend to the plot axes. This class should be added to axes as they are created so that calling it acts - like a method of the class and adds a legend, imitating matplotlib legend calling. - """ - printd(' custom legend call') - self.leg = self.ax.addLegend() - # ax.addLegend modifies ax.legend, so we have to put it back in order to - # preserve a reference to pgmpl.axes.Legend. - self.ax.legend = self - - handles = tolist(handles if handles is not None else self.get_visible_handles()) - - for handle, label in zip(*self._cleanup_legend_labels(handles, labels)): - if self.supported(handle): - self.leg.addItem(handle, label) - - self.check_call_kw(**kw) - - return self - - @staticmethod - def check_call_kw(**kw): - """Checks keywords passed to Legend.__call__ and warns about unsupported ones""" - unhandled_kws = dict( - loc=None, numpoints=None, markerscale=None, markerfirst=True, scatterpoints=None, scatteryoffsets=None, - prop=None, fontsize=None, borderpad=None, labelspacing=None, handlelength=None, handleheight=None, - handletextpad=None, borderaxespad=None, columnspacing=None, ncol=1, mode=None, fancybox=None, shadow=None, - title=None, framealpha=None, edgecolor=None, facecolor=None, bbox_to_anchor=None, bbox_transform=None, - frameon=None, handler_map=None, - ) - for unhandled in unhandled_kws.keys(): - if unhandled in kw.keys(): - kw.pop(unhandled) - warnings.warn('pgmpl.axes.Legend.__call__ got unhandled keyword: {}. ' - 'This keyword might be implemented later.'.format(unhandled)) - if len(kw.keys()): - warnings.warn('pgmpl.axes.Legend.__call__ got unrecognized keywords: {}'.format(kw.keys())) - - def addItem(self, item, name=None): - """ - pyqtgraph calls this method of legend and so it must be provided. - :param item: plot object instance - :param name: string - """ - self.items_added += [(item, name)] # This could be used in place of the search for items, maybe. - return None - - def draggable(self, on_off=True): - """ - Provided for compatibility with matplotlib legends, which have this method. - pyqtgraph legends are always draggable. - :param on_off: bool - Throws a warning if user attempts to disable draggability - """ - self.drag_enabled = on_off - if not on_off: - warnings.warn( - 'Draggable switch is not enabled yet. The draggable() method is provided to prevent failures when ' - 'plotting routines are converted from matplotlib. pyqtgraph legends are draggable by default.' - ) - return None - - def clear(self): - """Removes the legend from Axes instance""" - printd(' Clearing legend {}...'.format(self.leg)) - try: - self.leg.scene().removeItem(self.leg) # https://stackoverflow.com/a/42794442/6605826 - except AttributeError: - printd(' Could not clear legend (maybe it is already invisible?') diff --git a/pgmpl/legend.py b/pgmpl/legend.py new file mode 100644 index 0000000..fcd2fcb --- /dev/null +++ b/pgmpl/legend.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python +# # -*- coding: utf-8 -*- + +""" +Imitates matplotlib.legend but using PyQtGraph. + +Classes and methods imitate Matplotlib counterparts as closely as possible, so please see Matplotlib documentation for +more information. +""" + +# Basic imports +from __future__ import print_function, division +import warnings +import copy + +# Calculation imports +import numpy as np + +# Plotting imports +import pyqtgraph as pg + +# pgmpl +# noinspection PyUnresolvedReferences +import __init__ # __init__ does setup stuff like making sure a QApp exists +from util import printd, tolist, is_numeric + + +class Legend: + """ + Post-generated legend for pgmpl.axes.Axes. This is not a direct imitation of Matplotlib's Legend as it has to + accept events from pyqtgraph. It also has to bridge the gap between Matplotlib style calling legend after plotting + and pyqtgraph style calling legend first. + + The call method is supposed to imitate matplotlib.axes.Axes.legend(), though. Bind a Legend class instance to an + Axes instance as Axes.legend = Legend() and then call Axes.legend() as in matplotlib. + The binding is done in Axes class __init__. + """ + def __init__(self, ax=None): + """ + :param ax: Axes instance + Reference to the plot axes to which this legend is attached (required). + """ + # noinspection PyProtectedMember + from pyqtgraph.graphicsItems.ViewBox.ViewBox import ChildGroup + self.ax = ax + # pyqtgraph legends just don't work with some items yet. Avoid errors by trying to use these classes as handles: + self.unsupported_item_classes = [ + pg.graphicsItems.FillBetweenItem.FillBetweenItem, + pg.InfiniteLine, + ChildGroup, + ] + # File "/lib/python2.7/site-packages/pyqtgraph/graphicsItems/LegendItem.py", line 149, in paint + # opts = self.item.opts + # AttributeError: 'InfiniteLine' object has no attribute 'opts' + self.items_added = [] + self.drag_enabled = True + self.leg = None + + def supported(self, item): + """Quick test for whether or not item (which is some kind of plot object) is supported by this legend class""" + return not any([isinstance(item, uic) for uic in self.unsupported_item_classes]) + + @staticmethod + def handle_info(handles, comment=None): + """For debugging: prints information on legend handles""" + if comment is not None: + printd(comment) + for i, handle in enumerate(tolist(handles)): + printd(' {i:02d} handle name: {name:}, class: {cls:}, isVisible: {isvis:}'.format( + i=i, + name=handle.name() if hasattr(handle, 'name') else None, + cls=handle.__class__ if hasattr(handle, '__class__') else ' not found ', + isvis=handle.isVisible() if hasattr(handle, 'isVisible') else None, + )) + + def get_visible_handles(self): + """ + :return: List of legend handles for visible plot items + """ + handles = self.ax.getViewBox().allChildren() + self.handle_info(handles, comment='handles from allChildren') + return [item for item in handles if hasattr(item, 'isVisible') and item.isVisible()] + + @staticmethod + def _cleanup_legend_labels(handles, labels): + nlab = len(np.atleast_1d(labels)) + if labels is not None and nlab == 1: + labels = tolist(labels)*len(handles) + elif labels is not None and nlab == len(handles): + labels = tolist(labels) + else: + handles = [item for item in handles if hasattr(item, 'name') and item.name() is not None] + labels = [item.name() for item in handles] + return handles, labels + + def __call__(self, handles=None, labels=None, **kw): + """ + Adds a legend to the plot axes. This class should be added to axes as they are created so that calling it acts + like a method of the class and adds a legend, imitating matplotlib legend calling. + """ + printd(' custom legend call') + self.leg = self.ax.addLegend() + # ax.addLegend modifies ax.legend, so we have to put it back in order to + # preserve a reference to pgmpl.axes.Legend. + self.ax.legend = self + + handles = tolist(handles if handles is not None else self.get_visible_handles()) + + for handle, label in zip(*self._cleanup_legend_labels(handles, labels)): + if self.supported(handle): + self.leg.addItem(handle, label) + + self.check_call_kw(**kw) + + return self + + @staticmethod + def check_call_kw(**kw): + """Checks keywords passed to Legend.__call__ and warns about unsupported ones""" + unhandled_kws = dict( + loc=None, numpoints=None, markerscale=None, markerfirst=True, scatterpoints=None, scatteryoffsets=None, + prop=None, fontsize=None, borderpad=None, labelspacing=None, handlelength=None, handleheight=None, + handletextpad=None, borderaxespad=None, columnspacing=None, ncol=1, mode=None, fancybox=None, shadow=None, + title=None, framealpha=None, edgecolor=None, facecolor=None, bbox_to_anchor=None, bbox_transform=None, + frameon=None, handler_map=None, + ) + for unhandled in unhandled_kws.keys(): + if unhandled in kw.keys(): + kw.pop(unhandled) + warnings.warn('pgmpl.axes.Legend.__call__ got unhandled keyword: {}. ' + 'This keyword might be implemented later.'.format(unhandled)) + if len(kw.keys()): + warnings.warn('pgmpl.axes.Legend.__call__ got unrecognized keywords: {}'.format(kw.keys())) + + def addItem(self, item, name=None): + """ + pyqtgraph calls this method of legend and so it must be provided. + :param item: plot object instance + :param name: string + """ + self.items_added += [(item, name)] # This could be used in place of the search for items, maybe. + return None + + def draggable(self, on_off=True): + """ + Provided for compatibility with matplotlib legends, which have this method. + pyqtgraph legends are always draggable. + :param on_off: bool + Throws a warning if user attempts to disable draggability + """ + self.drag_enabled = on_off + if not on_off: + warnings.warn( + 'Draggable switch is not enabled yet. The draggable() method is provided to prevent failures when ' + 'plotting routines are converted from matplotlib. pyqtgraph legends are draggable by default.' + ) + return None + + def clear(self): + """Removes the legend from Axes instance""" + printd(' Clearing legend {}...'.format(self.leg)) + try: + self.leg.scene().removeItem(self.leg) # https://stackoverflow.com/a/42794442/6605826 + except AttributeError: + printd(' Could not clear legend (maybe it is already invisible?') diff --git a/tests/test_axes.py b/tests/test_axes.py index 10595fa..aa66872 100755 --- a/tests/test_axes.py +++ b/tests/test_axes.py @@ -206,42 +206,6 @@ def test_axes_clear(self): ax.clear() # Should add something to try to get the number of objects on the test and assert that there are none - def test_Legend(self): - """Tests both the legend method of Axes and the Legend class implicitly""" - ax = Axes() - line = ax.plot(self.x, self.y, label='y(x) plot') - leg = ax.legend() - leg.addItem(line, name='yx plot') - leg.draggable() - leg.clear() - ax2 = Axes() - ax2.plot(self.x, self.y, color='r', label='y(x) plot red') - ax2.plot(self.x, -self.y, color='b', label='y(x) plot blue') - ax2.legend(labels='blah') - - self.printv('test_axes_Legend: ax = {}, leg = {}'.format(ax, leg)) - - def test_Legend_warnings(self): - ax = Axes() - ax.plot(self.x, self.y, label='y(x) plot') - leg = ax.legend() - - # Test warnings - warnings_expected = 5 - with warnings.catch_warnings(record=True) as w: - # Cause all warnings to always be triggered. - warnings.simplefilter("always") - # Trigger warnings. - leg.draggable(False) # 1 warning - # Trigger more warnings: - ax.legend(blah='unrecognized keyword should make warning', borderaxespad=5) # 2 warnings - ax.legend(loc=0) # 1 warning - ax.legend(loc=4) # 1 warning - # Verify that warnings were made. - self.printv(' test_axes_Legend: triggered a warning from Legend and got {}/{} warnings. leg = {}'.format( - len(w), warnings_expected, leg)) - assert len(w) == warnings_expected - def setUp(self): test_id = self.id() test_name = '.'.join(test_id.split('.')[-2:]) diff --git a/tests/test_legend.py b/tests/test_legend.py new file mode 100755 index 0000000..c45f8b2 --- /dev/null +++ b/tests/test_legend.py @@ -0,0 +1,88 @@ +# Basic imports +from __future__ import print_function, division +import os +import unittest +import numpy as np +import warnings + +# pgmpl +from pgmpl import __init__ # __init__ does setup stuff like making sure a QApp exists +from pgmpl.axes import Axes +from pgmpl.legend import Legend + + +class TestPgmplAxes(unittest.TestCase): + """ + Most test functions simply test one method of Axes. test_axes_plot tests Axes.plot(), for example. + More complicated behaviors will be mentioned in function docstrings. + """ + + verbose = int(os.environ.get('PGMPL_TEST_VERBOSE', '0')) + + x = np.linspace(0, 1.8, 30) + y = x**2 + 2.5 + z = x**3 - x**2 * 1.444 + + rgb2d = np.zeros((8, 8, 3)) + rgb2d[0, 0, :] = 0.9 + rgb2d[4, 4, :] = 1 + rgb2d[3, 2, 0] = 0.5 + rgb2d[2, 3, 1] = 0.7 + rgb2d[3, 3, 2] = 0.6 + + x1 = x + x2 = np.linspace(0, 2.1, 25) + two_d_data = (x1[:, np.newaxis] - 0.94)**2 + (x2[np.newaxis, :] - 1.2)**2 + + def printv(self, *args): + if self.verbose: + print(*args) + + def test_Legend(self): + """Tests both the legend method of Axes and the Legend class implicitly""" + ax = Axes() + line = ax.plot(self.x, self.y, label='y(x) plot') + leg = ax.legend() + leg.addItem(line, name='yx plot') + leg.draggable() + leg.clear() + ax2 = Axes() + ax2.plot(self.x, self.y, color='r', label='y(x) plot red') + ax2.plot(self.x, -self.y, color='b', label='y(x) plot blue') + ax2.legend(labels='blah') + + self.printv('test_axes_Legend: ax = {}, leg = {}'.format(ax, leg)) + + def test_Legend_warnings(self): + ax = Axes() + ax.plot(self.x, self.y, label='y(x) plot') + leg = ax.legend() + + # Test warnings + warnings_expected = 5 + with warnings.catch_warnings(record=True) as w: + # Cause all warnings to always be triggered. + warnings.simplefilter("always") + # Trigger warnings. + leg.draggable(False) # 1 warning + # Trigger more warnings: + ax.legend(blah='unrecognized keyword should make warning', borderaxespad=5) # 2 warnings + ax.legend(loc=0) # 1 warning + ax.legend(loc=4) # 1 warning + # Verify that warnings were made. + self.printv(' test_axes_Legend: triggered a warning from Legend and got {}/{} warnings. leg = {}'.format( + len(w), warnings_expected, leg)) + assert len(w) == warnings_expected + + def setUp(self): + test_id = self.id() + test_name = '.'.join(test_id.split('.')[-2:]) + self.printv('{}...'.format(test_name)) + + def tearDown(self): + test_name = '.'.join(self.id().split('.')[-2:]) + self.printv(' {} done.'.format(test_name)) + + +if __name__ == '__main__': + unittest.main() From df79f292eb7bd826268c6f7ff5dea361841ea39f Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 29 Jun 2018 14:21:28 -0700 Subject: [PATCH 08/30] Fix names in test_legend.py --- tests/test_legend.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/test_legend.py b/tests/test_legend.py index c45f8b2..765edb9 100755 --- a/tests/test_legend.py +++ b/tests/test_legend.py @@ -1,3 +1,10 @@ +#!/usr/bin/env python +# # -*- coding: utf-8 -*- + +""" +Test script for legend.py +""" + # Basic imports from __future__ import print_function, division import os @@ -11,11 +18,7 @@ from pgmpl.legend import Legend -class TestPgmplAxes(unittest.TestCase): - """ - Most test functions simply test one method of Axes. test_axes_plot tests Axes.plot(), for example. - More complicated behaviors will be mentioned in function docstrings. - """ +class TestPgmplLegend(unittest.TestCase): verbose = int(os.environ.get('PGMPL_TEST_VERBOSE', '0')) @@ -39,7 +42,11 @@ def printv(self, *args): print(*args) def test_Legend(self): - """Tests both the legend method of Axes and the Legend class implicitly""" + leg = Legend() + assert isinstance(leg, Legend) + + def test_axes_legend(self): + """Test Legend class through axes method legend""" ax = Axes() line = ax.plot(self.x, self.y, label='y(x) plot') leg = ax.legend() @@ -53,7 +60,7 @@ def test_Legend(self): self.printv('test_axes_Legend: ax = {}, leg = {}'.format(ax, leg)) - def test_Legend_warnings(self): + def test_axes_legend_warnings(self): ax = Axes() ax.plot(self.x, self.y, label='y(x) plot') leg = ax.legend() From 65a55910569215e145fb8e444ea0f3ba5feb3ecb Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 29 Jun 2018 15:06:50 -0700 Subject: [PATCH 09/30] Translate and scale contours --- pgmpl/contour.py | 5 +++-- tests/test_contour.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index d6d52c8..93ea574 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -105,9 +105,10 @@ def draw_filled(self): def draw_unfilled(self): contours = [pg.IsocurveItem(data=self.z, level=level, pen='r') for level in self.levels] + x0, y0, x1, y1 = self.x.min(), self.y.min(), self.x.max(), self.y.max() for contour in contours: - contour.setParentItem(self.ax) - contour.setZValue(10) + 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]) self.ax.addItem(contour) diff --git a/tests/test_contour.py b/tests/test_contour.py index b6441d5..ac9db3e 100644 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -24,8 +24,8 @@ class TestPgmplContour(unittest.TestCase): verbose = int(os.environ.get('PGMPL_TEST_VERBOSE', '0')) x = np.linspace(0, 1.8, 30) - y = np.linspace(0, 2.1, 25) - z = (x[:, np.newaxis] - 0.94)**2 + (y[np.newaxis, :] - 1.2)**2 + 1.145 + y = np.linspace(1, 3.1, 25) + z = (x[:, np.newaxis] - 0.94)**2 + (y[np.newaxis, :] - 2.2)**2 + 1.145 levels = [1, 1.5, 2, 2.5, 3] nlvl = len(levels) * 4 From 0c72e2acdd5a001baa923649f9092aee41992e19 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 29 Jun 2018 22:24:22 -0700 Subject: [PATCH 10/30] Color map for contour() --- pgmpl/contour.py | 10 ++++++++-- tests/test_contour.py | 0 2 files changed, 8 insertions(+), 2 deletions(-) mode change 100644 => 100755 tests/test_contour.py diff --git a/pgmpl/contour.py b/pgmpl/contour.py index 93ea574..555040e 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -8,7 +8,6 @@ more information. """ - # Basic imports from __future__ import print_function, division import warnings @@ -95,6 +94,13 @@ def choose_xyz_levels(self, *args): return x, y, z, levels def draw(self): + if self.colors is None: + # Assign color map + self.colors = color_map_translator( + self.levels, **{a: self.__getattribute__(a) for a in ['alpha', 'cmap', 'norm', 'vmin', 'vmax']}) + else: + self.colors = tolist(self.colors) * np.ceil(len(self.levels)/len(tolist(self.colors))) + if self.filled: self.draw_filled() else: @@ -104,7 +110,7 @@ def draw_filled(self): printd(' not ready yet lol') def draw_unfilled(self): - contours = [pg.IsocurveItem(data=self.z, level=level, pen='r') for level in self.levels] + contours = [pg.IsocurveItem(data=self.z, level=lvl, pen=self.colors[i]) for i, lvl in enumerate(self.levels)] 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 diff --git a/tests/test_contour.py b/tests/test_contour.py old mode 100644 new mode 100755 From 9fbc916c24a9f2a0bcb6cbdefcbd57738a065adc Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 29 Jun 2018 22:54:28 -0700 Subject: [PATCH 11/30] Handling of linestyles and linewidths in contour --- pgmpl/contour.py | 19 +++++++++++++++---- pgmpl/translate.py | 7 ++++--- tests/test_contour.py | 10 +++++----- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index 555040e..d208f3f 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -51,8 +51,9 @@ def __init__(self, ax, *args, **kwargs): return def auto_range(self, z): - self.vmin = z.min() if self.vmin is None else self.vmin - self.vmax = z.max() if self.vmax is None else self.vmax + pad = (z.max() - z.min()) * 0.025 + self.vmin = z.min()+pad if self.vmin is None else self.vmin + self.vmax = z.max()-pad if self.vmax is None else self.vmax def auto_pick_levels(self, z, nlvl=None): """ @@ -93,13 +94,21 @@ def choose_xyz_levels(self, *args): return x, y, z, levels + def extl(self, v): + """ + Casts input argument as a list and ensures it is at least as long as levels + :param v: Some variable + :return: List of values for variable v; at least as long as self.levels + """ + return tolist(v) * int(np.ceil(len(self.levels) / len(tolist(v)))) + def draw(self): if self.colors is None: # Assign color map self.colors = color_map_translator( self.levels, **{a: self.__getattribute__(a) for a in ['alpha', 'cmap', 'norm', 'vmin', 'vmax']}) else: - self.colors = tolist(self.colors) * np.ceil(len(self.levels)/len(tolist(self.colors))) + self.colors = self.extl(self.colors) if self.filled: self.draw_filled() @@ -110,7 +119,9 @@ def draw_filled(self): printd(' not ready yet lol') def draw_unfilled(self): - contours = [pg.IsocurveItem(data=self.z, level=lvl, pen=self.colors[i]) for i, lvl in enumerate(self.levels)] + lws, lss = self.extl(self.linewidths), self.extl(self.linestyles) + pens = [setup_pen_kw(penkw=dict(color=self.colors[i]), linestyle=lss[i], linewidth=lws[i]) for i in range(len(self.levels))] + contours = [pg.IsocurveItem(data=self.z, level=lvl, pen=pens[i]) for i, lvl in enumerate(self.levels)] 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 diff --git a/pgmpl/translate.py b/pgmpl/translate.py index 97960c4..9ab4bef 100644 --- a/pgmpl/translate.py +++ b/pgmpl/translate.py @@ -221,11 +221,13 @@ def symbol_edge_setup(pgkw, plotkw): printd('plotkw symbol = {}; symbol = {}'.format(plotkw.get('symbol', 'no symbol defined'), symbol), level=1) -def setup_pen_kw(**kw): +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 @@ -233,14 +235,13 @@ def setup_pen_kw(**kw): :return: pyqtgraph pen instance A pen which can be input with the pen keyword to many pyqtgraph functions """ - penkw = {} # Move the easy keywords over directly direct_translations_pen = { # plotkw: pgkw 'linewidth': 'width', } for direct in direct_translations_pen: - if direct in kw: + if direct in kw and kw[direct] is not None: penkw[direct_translations_pen[direct]] = kw[direct] # Handle colors diff --git a/tests/test_contour.py b/tests/test_contour.py index ac9db3e..a08d675 100755 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -26,7 +26,7 @@ class TestPgmplContour(unittest.TestCase): x = np.linspace(0, 1.8, 30) y = np.linspace(1, 3.1, 25) z = (x[:, np.newaxis] - 0.94)**2 + (y[np.newaxis, :] - 2.2)**2 + 1.145 - levels = [1, 1.5, 2, 2.5, 3] + levels = [1.2, 1.5, 2, 2.5, 2.95] nlvl = len(levels) * 4 def printv(self, *args): @@ -38,22 +38,22 @@ def test_contour(self): axs[0, 0].set_title('z') axs[0, 0].contour(self.z) axs[0, 1].set_title('-z') - axs[0, 1].contour(-self.z) + axs[0, 1].contour(-self.z, linestyles=['--', '-.', ':']) axs[1, 0].set_title('z, levels') axs[1, 0].contour(self.z, self.levels) axs[1, 1].set_title('z, nlvl') - axs[1, 1].contour(self.z, self.nlvl) + axs[1, 1].contour(self.z, self.nlvl, linewidths=[3, 2, 1]) axs[2, 0].set_title('x, y, z') axs[2, 0].contour(self.x, self.y, self.z) axs[2, 0].set_title('x, y, -z') - axs[2, 1].contour(self.x, self.y, -self.z) + axs[2, 1].contour(self.x, self.y, -self.z, linewidths=[4, 2, 1]) axs[3, 0].set_title('x, y, z, levels') axs[3, 0].contour(self.x, self.y, self.z, self.levels) axs[3, 1].set_title('x, y, z, nlvl') - axs[3, 1].contour(self.x, self.y, self.z, self.nlvl) + axs[3, 1].contour(self.x, self.y, self.z, self.nlvl, linestyles=['-', '--', '-.', ':']) def test_contourf(self): ax = Axes() From d2b4584d1e4d4205aee263f29e93cd40e2408318 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 29 Jun 2018 23:01:55 -0700 Subject: [PATCH 12/30] Cleanup --- pgmpl/contour.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index d208f3f..0ddce63 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -18,8 +18,6 @@ # Plotting imports import pyqtgraph as pg -from matplotlib import rcParams -from collections import defaultdict # pgmpl # noinspection PyUnresolvedReferences @@ -120,7 +118,8 @@ def draw_filled(self): def draw_unfilled(self): lws, lss = self.extl(self.linewidths), self.extl(self.linestyles) - pens = [setup_pen_kw(penkw=dict(color=self.colors[i]), linestyle=lss[i], linewidth=lws[i]) for i in range(len(self.levels))] + pens = [setup_pen_kw(penkw=dict(color=self.colors[i]), linestyle=lss[i], linewidth=lws[i]) + for i in range(len(self.levels))] contours = [pg.IsocurveItem(data=self.z, level=lvl, pen=pens[i]) for i, lvl in enumerate(self.levels)] x0, y0, x1, y1 = self.x.min(), self.y.min(), self.x.max(), self.y.max() for contour in contours: From fff6d2e3d4cf5ba6fede93b6bab85b2fdce18610 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 29 Jun 2018 23:21:28 -0700 Subject: [PATCH 13/30] Test manual colors to contour (instead of cmap) --- tests/test_contour.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_contour.py b/tests/test_contour.py index a08d675..7756278 100755 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -48,7 +48,7 @@ def test_contour(self): axs[2, 0].set_title('x, y, z') axs[2, 0].contour(self.x, self.y, self.z) axs[2, 0].set_title('x, y, -z') - axs[2, 1].contour(self.x, self.y, -self.z, linewidths=[4, 2, 1]) + axs[2, 1].contour(self.x, self.y, -self.z, colors=['r', 'g', 'b']) axs[3, 0].set_title('x, y, z, levels') axs[3, 0].contour(self.x, self.y, self.z, self.levels) From 790718636e94e247d025ca28a2bdd21d44719823 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Sat, 30 Jun 2018 13:23:59 -0700 Subject: [PATCH 14/30] WIP: working on filled contours --- pgmpl/contour.py | 80 ++++++++++++++++++++++++++++++++++++++++++- tests/test_contour.py | 7 ++-- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index 0ddce63..310a5f9 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -18,6 +18,7 @@ # Plotting imports import pyqtgraph as pg +from pyqtgraph import functions as fn # pgmpl # noinspection PyUnresolvedReferences @@ -113,8 +114,85 @@ def draw(self): else: self.draw_unfilled() + def isocurve2plotcurve(self, curve): + """ + Converts an IsocuveItem instance to a PlotCurveItem instance so it can be used with FillBetweenItem + + FAILS because the curves aren't sorted properly to allow good connections between segments. That is, a contour + can break where it intersects the edge of the plot/data range, and restart later where it re-enters. These entry + and exit points are reconnected arbitrarily or incorrectly. + + :param curve: IsocurveItem instance + :return: PlotCurveItem with the same path + """ + curve.generatePath() + new_curve = pg.PlotCurveItem() + new_curve.path = curve.path + return new_curve + + def _detect_direction(self, x, y): + """ + Tries to decide whether path goes CCW or not + + UNFINISHED. Failing because I didn't subtract out a reference center point from x and y first. Might work. + + :param x: array of numbers + :param y: array of numbers + :return: True if path appears to run CCW + """ + theta = np.arctan2(y, x) + dth = np.diff(theta) + print('theta = ', theta) + print('max|dtheta| = ', abs(dth).max()) + mdth = np.median(dth) + jumps = (np.sign(dth) == -np.sign(mdth)) & (abs(dth) > (2*abs(mdth))) + for jump in np.where(jumps)[0]: + dth[jump] -= 2*np.pi * np.sign(dth[jump]) + print('jumps', np.where(jumps)[0], dth[jumps]) + print('max|dtheta| = ', abs(dth).max()) + print('int(dtheta) = ', np.append(theta[0], np.cumsum(dth))) + print('\n') + + def _join_lines(self, lines, bounds=None): + """ + Joins segments of a broken path. Use for contours which cross out of bounds and back in. Has to find the right + direction of path segments and join them in the right order. + + UNFINISHED. Still working on determining the order correctly. + + :param lines: List of path segments. e.g. [[(x, y), (x,y)], [(x, y), (x, y)]] + :param bounds: List of 4 numbers giving the [left, right, bottom, top] edges of the plot/data range + :return: Flattened list of correctly joined path segments e.g. [(x, y), (x,y), (x, y), (x, y)] + """ + bounds = [self.x.min(), self.x.max(), self.y.min(), self.y.max()] if bounds is None else bounds + for line in lines: + x, y = map(list, zip(*line)) + self._detect_direction(x, y) + def draw_filled(self): - printd(' not ready yet lol') + invis = setup_pen_kw(color='k', alpha=0) + contours = [self.isocurve2plotcurve(pg.IsocurveItem(data=self.z, level=lvl, pen=invis)) + for i, lvl in enumerate(self.levels)] + for i in range(len(self.levels)-1): + fill = pg.FillBetweenItem(contours[i], contours[i+1], brush=pg.mkBrush(color=self.colors[i])) + #self.ax.addItem(fill) # THIS DOESN'T WORK RIGHT because isocurve2plotcurve doesn't stitch path segments + # together in the right order. + #self.draw_unfilled() # Temporary for debugging + + pens = [setup_pen_kw(penkw=dict(color=self.colors[i])) for i in range(len(self.levels))] + for i in range(len(self.levels)): + lines = fn.isocurve(self.z, self.levels[i], connected=True, extendToEdge=True)[::-1] + self._join_lines(lines) + + # TESTING: doesn't work. Just scratch / brainstorming here. + oneline = [point for line in lines for point in line] + #print(oneline) + #x, y = [item[0] for item in oneline] + x, y = map(list, zip(*oneline)) + curve = pg.PlotDataItem(x, y, pen=pens[i]) + self.ax.addItem(curve) + #print(x, y) + #print('\n\n') def draw_unfilled(self): lws, lss = self.extl(self.linewidths), self.extl(self.linestyles) diff --git a/tests/test_contour.py b/tests/test_contour.py index 7756278..9dc1914 100755 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -56,8 +56,11 @@ def test_contour(self): axs[3, 1].contour(self.x, self.y, self.z, self.nlvl, linestyles=['-', '--', '-.', ':']) def test_contourf(self): - ax = Axes() - ax.contourf(self.z) + fig, axs = subplots(4, 2) + axs[0, 0].set_title('z') + axs[0, 0].contourf(self.z) + # import pgmpl # for testing only; delete later + # pgmpl.app.exec_() # for testing only; delete later def test_contour_errors(self): ax = Axes() From 04ac2a29062e162c3c5d62fd81a3c3c646391c77 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Mon, 2 Jul 2018 08:27:10 -0700 Subject: [PATCH 15/30] Fix up utilities to support filled contours --- pgmpl/contour.py | 57 +++++++++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index 310a5f9..7eab10e 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -114,7 +114,8 @@ def draw(self): else: self.draw_unfilled() - def isocurve2plotcurve(self, curve): + @staticmethod + def _isocurve2plotcurve(curve): """ Converts an IsocuveItem instance to a PlotCurveItem instance so it can be used with FillBetweenItem @@ -134,44 +135,51 @@ def _detect_direction(self, x, y): """ Tries to decide whether path goes CCW or not - UNFINISHED. Failing because I didn't subtract out a reference center point from x and y first. Might work. - :param x: array of numbers :param y: array of numbers :return: True if path appears to run CCW """ - theta = np.arctan2(y, x) + + if len(x) < 2: + return True # No need to reverse if 0 or 1 element + + x0, y0 = np.mean([self.x.min(), self.x.max()]), np.mean([self.y.min(), self.y.max()]) + theta = np.arctan2(y-y0, x-x0) + + # Find theta increment dth = np.diff(theta) - print('theta = ', theta) - print('max|dtheta| = ', abs(dth).max()) + + # Unwrap theta increment so there are no jumps as theta loops around mdth = np.median(dth) jumps = (np.sign(dth) == -np.sign(mdth)) & (abs(dth) > (2*abs(mdth))) for jump in np.where(jumps)[0]: dth[jump] -= 2*np.pi * np.sign(dth[jump]) - print('jumps', np.where(jumps)[0], dth[jumps]) - print('max|dtheta| = ', abs(dth).max()) - print('int(dtheta) = ', np.append(theta[0], np.cumsum(dth))) - print('\n') - def _join_lines(self, lines, bounds=None): + return np.mean(dth) > 0 + + def _join_lines(self, lines): """ Joins segments of a broken path. Use for contours which cross out of bounds and back in. Has to find the right direction of path segments and join them in the right order. - UNFINISHED. Still working on determining the order correctly. - :param lines: List of path segments. e.g. [[(x, y), (x,y)], [(x, y), (x, y)]] - :param bounds: List of 4 numbers giving the [left, right, bottom, top] edges of the plot/data range :return: Flattened list of correctly joined path segments e.g. [(x, y), (x,y), (x, y), (x, y)] """ - bounds = [self.x.min(), self.x.max(), self.y.min(), self.y.max()] if bounds is None else bounds - for line in lines: - x, y = map(list, zip(*line)) - self._detect_direction(x, y) + for i in range(len(lines)): + # Make sure all segments run CCW within themselves + x, y = map(list, zip(*lines[i])) + if not self._detect_direction(x, y): + lines[i] = lines[i][::-1] + # Make sure the set of start points of each segment runs CCW + x1 = np.array([line[0][0] for line in lines]) + y1 = np.array([line[0][1] for line in lines]) + if not self._detect_direction(x1, y1): + lines = lines[::-1] + return [point for line in lines for point in line] def draw_filled(self): invis = setup_pen_kw(color='k', alpha=0) - contours = [self.isocurve2plotcurve(pg.IsocurveItem(data=self.z, level=lvl, pen=invis)) + contours = [self._isocurve2plotcurve(pg.IsocurveItem(data=self.z, level=lvl, pen=invis)) for i, lvl in enumerate(self.levels)] for i in range(len(self.levels)-1): fill = pg.FillBetweenItem(contours[i], contours[i+1], brush=pg.mkBrush(color=self.colors[i])) @@ -182,15 +190,20 @@ def draw_filled(self): pens = [setup_pen_kw(penkw=dict(color=self.colors[i])) for i in range(len(self.levels))] for i in range(len(self.levels)): lines = fn.isocurve(self.z, self.levels[i], connected=True, extendToEdge=True)[::-1] - self._join_lines(lines) + oneline = self._join_lines(lines) # TESTING: doesn't work. Just scratch / brainstorming here. - oneline = [point for line in lines for point in line] + #oneline = [point for line in lines for point in line] #print(oneline) #x, y = [item[0] for item in oneline] x, y = map(list, zip(*oneline)) curve = pg.PlotDataItem(x, y, pen=pens[i]) self.ax.addItem(curve) + if i > 0: + fill = pg.FillBetweenItem(curve, prev_curve, brush=pg.mkBrush(color=self.colors[i])) + #self.ax.addItem(fill) # doesn't work + prev_curve = curve + #print(x, y) #print('\n\n') @@ -201,7 +214,7 @@ def draw_unfilled(self): contours = [pg.IsocurveItem(data=self.z, level=lvl, pen=pens[i]) for i, lvl in enumerate(self.levels)] 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.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]) self.ax.addItem(contour) From c3d647f72ef7a38213f31cfbe1ffe2eba5e29dcc Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 3 Aug 2018 21:56:02 -0700 Subject: [PATCH 16/30] Method for closing and scaling contour paths - Allows for the right edges for polygons --- pgmpl/contour.py | 81 +++++++++++++++++++++++++++++++++---------- tests/test_contour.py | 10 ++++-- 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index 7eab10e..019f31a 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -131,6 +131,20 @@ def _isocurve2plotcurve(curve): new_curve.path = curve.path return new_curve + def _scale_contour_lines(self, lines): + """ + Translates and stretches contour lines + :param lines: A list of path segments. e.g. [[(x, y), (x, y)], [(x, y), (x, y)]] + :return: A list of path segments, shifted and stretched to fit the x, y data range. + """ + x0, y0, x1, y1 = self.x.min(), self.y.min(), self.x.max(), self.y.max() + for i in range(len(lines)): + for j in range(len(lines[i])): + newx = lines[i][j][0] * (x1 - x0) / np.shape(self.z)[0] + x0 + newy = lines[i][j][1] * (y1 - y0) / np.shape(self.z)[1] + y0 + lines[i][j] = (newx, newy) + return lines + def _detect_direction(self, x, y): """ Tries to decide whether path goes CCW or not @@ -163,7 +177,7 @@ def _join_lines(self, lines): direction of path segments and join them in the right order. :param lines: List of path segments. e.g. [[(x, y), (x,y)], [(x, y), (x, y)]] - :return: Flattened list of correctly joined path segments e.g. [(x, y), (x,y), (x, y), (x, y)] + :return: Flattened list of correctly joined path segments e.g. [(x, y), (x,y), (x, y), (x, y)], running CCW """ for i in range(len(lines)): # Make sure all segments run CCW within themselves @@ -177,36 +191,67 @@ def _join_lines(self, lines): lines = lines[::-1] return [point for line in lines for point in line] - def draw_filled(self): - invis = setup_pen_kw(color='k', alpha=0) - contours = [self._isocurve2plotcurve(pg.IsocurveItem(data=self.z, level=lvl, pen=invis)) - for i, lvl in enumerate(self.levels)] - for i in range(len(self.levels)-1): - fill = pg.FillBetweenItem(contours[i], contours[i+1], brush=pg.mkBrush(color=self.colors[i])) - #self.ax.addItem(fill) # THIS DOESN'T WORK RIGHT because isocurve2plotcurve doesn't stitch path segments - # together in the right order. - #self.draw_unfilled() # Temporary for debugging + def _close_curve(self, line): + """ + Closes a curve which may be open between the two end points because it intersects the edge of the plot + :param line: List of vertices along a CCW path. e.g. [(x, y), (x, y), ...] such as output by _join_lines() + :return: List of vertices along a closed CCW path + """ + + if all(np.atleast_1d(line[0] == line[-1])): + # Path is already closed; return it with no changes + return line + # Determine which endpoints are on which edges + edge0 = np.append(line[0][0] == np.array([self.x.min(), self.x.max()]), + line[0][1] == np.array([self.y.min(), self.y.max()])) + edge1 = np.append(line[-1][0] == np.array([self.x.min(), self.x.max()]), + line[-1][1] == np.array([self.y.min(), self.y.max()])) + same_edge = edge0 == edge1 + + if (not any(edge0) and not any(edge1)) or all(same_edge): + # Endpoints are not on the edges, or are on the same edge. Just close the loop. + newline = line + [line[0]] + else: + # Endpoints are not on the same edge; complicated closure + + # Get the boundary path + boundary = [ + (self.x.min(), self.y.min()), + (self.x.max(), self.y.min()), + (self.x.max(), self.y.max()), + (self.x.min(), self.y.max()), + ] + # Find which boundary path points are between the endpoints of the curve + x0, y0 = np.mean([self.x.min(), self.x.max()]), np.mean([self.y.min(), self.y.max()]) + theta0 = np.arctan2(line[0][1] - y0, line[0][0] - x0) + theta1 = np.arctan2(line[-1][1] - y0, line[-1][0] - x0) + thetab = np.array([np.arctan2(b[1] - y0, b[0] - x0) for b in boundary]) + # Make sure the thing wraps the right way; we are continuing from point -1 back to point 0 + theta0 = theta0 + 2*np.pi if theta0 < theta1 else theta0 + thetab = np.array([b + (2*np.pi if b < theta1 else 0) for b in thetab]) + # Add in corners of the data range boundary to complete the curve + newline = line + tolist(np.array(boundary)[thetab < theta0]) + # And finally, close it + newline += [line[0]] + return newline + + def draw_filled(self): pens = [setup_pen_kw(penkw=dict(color=self.colors[i])) for i in range(len(self.levels))] for i in range(len(self.levels)): lines = fn.isocurve(self.z, self.levels[i], connected=True, extendToEdge=True)[::-1] + lines = self._scale_contour_lines(lines) oneline = self._join_lines(lines) + oneline = self._close_curve(oneline) - # TESTING: doesn't work. Just scratch / brainstorming here. - #oneline = [point for line in lines for point in line] - #print(oneline) - #x, y = [item[0] for item in oneline] x, y = map(list, zip(*oneline)) curve = pg.PlotDataItem(x, y, pen=pens[i]) self.ax.addItem(curve) if i > 0: fill = pg.FillBetweenItem(curve, prev_curve, brush=pg.mkBrush(color=self.colors[i])) - #self.ax.addItem(fill) # doesn't work + self.ax.addItem(fill) # doesn't work well prev_curve = curve - #print(x, y) - #print('\n\n') - def draw_unfilled(self): lws, lss = self.extl(self.linewidths), self.extl(self.linestyles) pens = [setup_pen_kw(penkw=dict(color=self.colors[i]), linestyle=lss[i], linewidth=lws[i]) diff --git a/tests/test_contour.py b/tests/test_contour.py index 9dc1914..2d4dd16 100755 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -35,6 +35,7 @@ def printv(self, *args): def test_contour(self): fig, axs = subplots(4, 2) + fig.suptitle('TestPgmplContour.test_contour') axs[0, 0].set_title('z') axs[0, 0].contour(self.z) axs[0, 1].set_title('-z') @@ -57,10 +58,15 @@ def test_contour(self): def test_contourf(self): fig, axs = subplots(4, 2) + fig.suptitle('TestPgmplContour.test_contourf') axs[0, 0].set_title('z') axs[0, 0].contourf(self.z) - # import pgmpl # for testing only; delete later - # pgmpl.app.exec_() # for testing only; delete later + + axs[2, 0].set_title('x, y, z') + axs[2, 0].contourf(self.x, self.y, self.z) + + import pgmpl # for testing only; delete later + pgmpl.app.exec_() # for testing only; delete later def test_contour_errors(self): ax = Axes() From bc85bde951e1662617e0b3773ab8954ed35894ee Mon Sep 17 00:00:00 2001 From: David Eldon Date: Fri, 3 Aug 2018 22:09:32 -0700 Subject: [PATCH 17/30] Fix contourf test --- tests/test_contour.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_contour.py b/tests/test_contour.py index 2d4dd16..4da55c4 100755 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -65,8 +65,8 @@ def test_contourf(self): axs[2, 0].set_title('x, y, z') axs[2, 0].contourf(self.x, self.y, self.z) - import pgmpl # for testing only; delete later - pgmpl.app.exec_() # for testing only; delete later + #import pgmpl # for testing only; delete later + #pgmpl.app.exec_() # for testing only; delete later def test_contour_errors(self): ax = Axes() From 3ff8b063349a2ce74f0d85c672c549271f94a1a6 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Sat, 4 Aug 2018 09:44:11 -0700 Subject: [PATCH 18/30] Switch orientation of z input to contour for consistency w/ matplotlib --- pgmpl/contour.py | 4 ++-- tests/test_contour.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) mode change 100755 => 100644 tests/test_contour.py diff --git a/pgmpl/contour.py b/pgmpl/contour.py index 019f31a..a06037d 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -89,9 +89,9 @@ def choose_xyz_levels(self, *args): levels = lvlinfo if ((lvlinfo is not None) and np.iterable(lvlinfo)) else self.auto_pick_levels(z, lvlinfo) if x is None: - x, y = np.arange(np.shape(z)[0]), np.arange(np.shape(z)[1]) + x, y = np.arange(np.shape(z)[1]), np.arange(np.shape(z)[0]) - return x, y, z, levels + return x, y, z.T, levels def extl(self, v): """ diff --git a/tests/test_contour.py b/tests/test_contour.py old mode 100755 new mode 100644 index 4da55c4..8a25680 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -25,7 +25,7 @@ class TestPgmplContour(unittest.TestCase): x = np.linspace(0, 1.8, 30) y = np.linspace(1, 3.1, 25) - z = (x[:, np.newaxis] - 0.94)**2 + (y[np.newaxis, :] - 2.2)**2 + 1.145 + z = (x[np.newaxis, :] - 0.94)**2 + (y[:, np.newaxis] - 2.2)**2 + 1.145 levels = [1.2, 1.5, 2, 2.5, 2.95] nlvl = len(levels) * 4 From cc3fd1513ffadd723aeaed1b437b5a82c0bd4b52 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Sat, 4 Aug 2018 09:45:34 -0700 Subject: [PATCH 19/30] Rename internal methods in contour --- pgmpl/contour.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index a06037d..fe295e7 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -93,7 +93,7 @@ def choose_xyz_levels(self, *args): return x, y, z.T, levels - def extl(self, v): + def _extl(self, v): """ Casts input argument as a list and ensures it is at least as long as levels :param v: Some variable @@ -107,14 +107,14 @@ def draw(self): self.colors = color_map_translator( self.levels, **{a: self.__getattribute__(a) for a in ['alpha', 'cmap', 'norm', 'vmin', 'vmax']}) else: - self.colors = self.extl(self.colors) + self.colors = self._extl(self.colors) if self.filled: self.draw_filled() else: self.draw_unfilled() - @staticmethod + @staticmethod ############### delete during cleanup #################################################### def _isocurve2plotcurve(curve): """ Converts an IsocuveItem instance to a PlotCurveItem instance so it can be used with FillBetweenItem @@ -253,7 +253,7 @@ def draw_filled(self): prev_curve = curve def draw_unfilled(self): - lws, lss = self.extl(self.linewidths), self.extl(self.linestyles) + lws, lss = self._extl(self.linewidths), self._extl(self.linestyles) pens = [setup_pen_kw(penkw=dict(color=self.colors[i]), linestyle=lss[i], linewidth=lws[i]) for i in range(len(self.levels))] contours = [pg.IsocurveItem(data=self.z, level=lvl, pen=pens[i]) for i, lvl in enumerate(self.levels)] From 18036d90c56b7f1a2c935510100e239a123513b6 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Sat, 4 Aug 2018 09:47:29 -0700 Subject: [PATCH 20/30] Fill center of first contour --- pgmpl/contour.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index fe295e7..66492a2 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -247,7 +247,15 @@ def draw_filled(self): x, y = map(list, zip(*oneline)) curve = pg.PlotDataItem(x, y, pen=pens[i]) self.ax.addItem(curve) - if i > 0: + if i == 0: + x0 = np.mean([point[0] for point in oneline]) + y0 = np.mean([point[1] for point in oneline]) + xc = [x0, x0 + 1e-12] + yc = [y0, y0 + 1e-12] + curve_c = pg.PlotDataItem(xc, yc, pen=pens[i]) + fill = pg.FillBetweenItem(curve, curve_c, brush=pg.mkBrush(color=self.colors[i])) + self.ax.addItem(fill) + else: # i > 0: fill = pg.FillBetweenItem(curve, prev_curve, brush=pg.mkBrush(color=self.colors[i])) self.ax.addItem(fill) # doesn't work well prev_curve = curve From abc462b68a91c490af3a9c5d762b9a1b8a716402 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Sat, 4 Aug 2018 09:48:08 -0700 Subject: [PATCH 21/30] Change default level count and padding to try to match mpl better --- pgmpl/contour.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index 66492a2..ac626da 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -52,7 +52,7 @@ def __init__(self, ax, *args, **kwargs): def auto_range(self, z): pad = (z.max() - z.min()) * 0.025 self.vmin = z.min()+pad if self.vmin is None else self.vmin - self.vmax = z.max()-pad if self.vmax is None else self.vmax + self.vmax = z.max() + (pad if self.filled else -pad) if self.vmax is None else self.vmax def auto_pick_levels(self, z, nlvl=None): """ @@ -62,7 +62,7 @@ def auto_pick_levels(self, z, nlvl=None): Number of levels; set to some arbitrary default if None :return: array """ - nlvl = 5 if nlvl is None else nlvl + nlvl = (8 if self.filled else 7) if nlvl is None else nlvl self.auto_range(z) return np.linspace(self.vmin, self.vmax, nlvl) From 70a79bdb0b7857359ff603c72992a5e4a18af953 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Sat, 4 Aug 2018 09:49:23 -0700 Subject: [PATCH 22/30] Empty line defaults to boundary --- pgmpl/contour.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index ac626da..d49f5c5 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -45,6 +45,14 @@ def __init__(self, ax, *args, **kwargs): self.nchunk = kwargs.pop('nchunk', 0) self.x, self.y, self.z, self.levels = self.choose_xyz_levels(*args) self.auto_range(self.z) + + # Get the boundary path + self.boundary = [ + (self.x.min(), self.y.min()), + (self.x.max(), self.y.min()), + (self.x.max(), self.y.max()), + (self.x.min(), self.y.max()), + ] self.draw() return @@ -198,6 +206,10 @@ def _close_curve(self, line): :return: List of vertices along a closed CCW path """ + if not line: + # Empty line; nothing to do + return self.boundary + if all(np.atleast_1d(line[0] == line[-1])): # Path is already closed; return it with no changes return line @@ -215,23 +227,16 @@ def _close_curve(self, line): else: # Endpoints are not on the same edge; complicated closure - # Get the boundary path - boundary = [ - (self.x.min(), self.y.min()), - (self.x.max(), self.y.min()), - (self.x.max(), self.y.max()), - (self.x.min(), self.y.max()), - ] # Find which boundary path points are between the endpoints of the curve x0, y0 = np.mean([self.x.min(), self.x.max()]), np.mean([self.y.min(), self.y.max()]) theta0 = np.arctan2(line[0][1] - y0, line[0][0] - x0) theta1 = np.arctan2(line[-1][1] - y0, line[-1][0] - x0) - thetab = np.array([np.arctan2(b[1] - y0, b[0] - x0) for b in boundary]) + thetab = np.array([np.arctan2(b[1] - y0, b[0] - x0) for b in self.boundary]) # Make sure the thing wraps the right way; we are continuing from point -1 back to point 0 theta0 = theta0 + 2*np.pi if theta0 < theta1 else theta0 thetab = np.array([b + (2*np.pi if b < theta1 else 0) for b in thetab]) # Add in corners of the data range boundary to complete the curve - newline = line + tolist(np.array(boundary)[thetab < theta0]) + newline = line + tolist(np.array(self.boundary)[thetab < theta0]) # And finally, close it newline += [line[0]] return newline From f46506cd7a0fa9bfdeb5de6e16c8324ca6eba712 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Sat, 4 Aug 2018 11:09:30 -0700 Subject: [PATCH 23/30] New idea for joining segments - Doesn't quite work yet --- pgmpl/contour.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index d49f5c5..ed99e08 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -179,6 +179,12 @@ def _detect_direction(self, x, y): return np.mean(dth) > 0 + def _find_which_edge(self, xy_point): + return np.where([xy_point[0] == self.x.max(), xy_point[1] == self.y.max(), + xy_point[0] == self.x.min(), xy_point[1] == self.y.min()])[0][0] + # return np.append(xy_point[0] == np.array([self.x.min(), self.x.max()]), + # xy_point[1] == np.array([self.y.min(), self.y.max()])) + def _join_lines(self, lines): """ Joins segments of a broken path. Use for contours which cross out of bounds and back in. Has to find the right @@ -197,10 +203,58 @@ def _join_lines(self, lines): y1 = np.array([line[0][1] for line in lines]) if not self._detect_direction(x1, y1): lines = lines[::-1] - return [point for line in lines for point in line] + #return self._close_curve([point for line in lines for point in line]) + + #edges0 = [self._find_which_edge(line[0]) for line in lines] + #edges1 = [self._find_which_edge(line[-1]) for line in lines] + #same_edge = [all(edge0 == edge1) for edge0, edge1 in zip(edges0, edges1)] + if not lines: + return self.boundary - def _close_curve(self, line): + corners = np.roll(self.boundary, 2) + + def get_more_corners(next_e0_, edge_): + corners_ = [] + if (next_e0_ - edge_) >= 1: + corners_ += [corners[edge_]] + print('Add corner {}'.format(corners[edge_])) + if (next_e0_ - edge_) >= 2: + corners_ += [corners[edge_+1]] + print('Add corner {}'.format(corners[edge_+1])) + if (next_e0_ - edge_) >= 3: + corners_ += [corners[edge_+2]] + print('Add corner {}'.format(corners[edge_+2])) + return corners_ + + oneline = lines.pop(0) + if not lines: + return oneline + + while len(lines): + # Find which edge each segment starts/ends on + edge = self._find_which_edge(oneline[-1]) + edge0 = np.array([self._find_which_edge(line[0]) for line in lines]) + #edge1 = [self._find_which_edge(line[1]) for line in lines] + if not any(edge0 >= edge): #or any(edge1 >= edge)): + edge -= 4 + #eligible = np.array(lines)[edge0 >= edge] + eligible = [line for line, e0 in zip(lines, edge0) if e0 >= edge] + next_line = eligible[edge0[edge0 >= edge].argmin()] + next_e0 = edge0[edge0 >= edge].min() + print('edge: {}, next_edge start: {}'.format(edge, next_e0)) + oneline += get_more_corners(next_e0, edge) + + lines.remove(next_line) + oneline += next_line + + oneline += get_more_corners(self._find_which_edge(oneline[0]), self._find_which_edge(oneline[-1])) + oneline += [oneline[0]] + #print('oneline ', oneline) + return oneline + + def _close_curve(self, line): # Remove during cleanup ----------------------------------- """ + OLD IDEA; DON'T USE ANYMORE Closes a curve which may be open between the two end points because it intersects the edge of the plot :param line: List of vertices along a CCW path. e.g. [(x, y), (x, y), ...] such as output by _join_lines() :return: List of vertices along a closed CCW path From 499ccc1b48d72564677786d30db96cf5cf531201 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Sun, 5 Aug 2018 09:25:56 -0700 Subject: [PATCH 24/30] Fix contourf plan --- pgmpl/contour.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index ed99e08..284a01c 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -95,6 +95,8 @@ def choose_xyz_levels(self, *args): raise TypeError('choose_xyz_levels takes 1, 2, 3, or 4 arguments. Got {} arguments.'.format(len(args))) levels = lvlinfo if ((lvlinfo is not None) and np.iterable(lvlinfo)) else self.auto_pick_levels(z, lvlinfo) +# levels = np.append(np.append(-np.inf, levels), np.inf) if self.filled else levels + #levels = np.append(levels, np.inf) if self.filled else levels if x is None: x, y = np.arange(np.shape(z)[1]), np.arange(np.shape(z)[0]) @@ -211,19 +213,21 @@ def _join_lines(self, lines): if not lines: return self.boundary - corners = np.roll(self.boundary, 2) + corners = np.roll(self.boundary, -2) def get_more_corners(next_e0_, edge_): + print('edge: {}, next_edge start: {}'.format(edge_, next_e0_)) corners_ = [] if (next_e0_ - edge_) >= 1: - corners_ += [corners[edge_]] - print('Add corner {}'.format(corners[edge_])) + print('new corner = ', corners[edge_]) + corners_ += [tuple(corners[edge_]), tuple(corners[edge_+1])] + print('Add corner {}'.format(corners[edge_], corners[edge_+1])) if (next_e0_ - edge_) >= 2: - corners_ += [corners[edge_+1]] - print('Add corner {}'.format(corners[edge_+1])) - if (next_e0_ - edge_) >= 3: - corners_ += [corners[edge_+2]] + corners_ += [tuple(corners[edge_+2])] print('Add corner {}'.format(corners[edge_+2])) + if (next_e0_ - edge_) >= 3: + corners_ += [tuple(corners[edge_+3])] + print('Add corner {}'.format(corners[edge_+3])) return corners_ oneline = lines.pop(0) @@ -241,15 +245,16 @@ def get_more_corners(next_e0_, edge_): eligible = [line for line, e0 in zip(lines, edge0) if e0 >= edge] next_line = eligible[edge0[edge0 >= edge].argmin()] next_e0 = edge0[edge0 >= edge].min() - print('edge: {}, next_edge start: {}'.format(edge, next_e0)) oneline += get_more_corners(next_e0, edge) lines.remove(next_line) oneline += next_line - oneline += get_more_corners(self._find_which_edge(oneline[0]), self._find_which_edge(oneline[-1])) + more_corners = get_more_corners(self._find_which_edge(oneline[0]), self._find_which_edge(oneline[-1])) + print('more corners = ', more_corners) + oneline += more_corners oneline += [oneline[0]] - #print('oneline ', oneline) + print('oneline ', oneline) return oneline def _close_curve(self, line): # Remove during cleanup ----------------------------------- @@ -297,14 +302,14 @@ def _close_curve(self, line): # Remove during cleanup ------------------------- def draw_filled(self): pens = [setup_pen_kw(penkw=dict(color=self.colors[i])) for i in range(len(self.levels))] + use_pen = pg.mkPen(color='k') for i in range(len(self.levels)): lines = fn.isocurve(self.z, self.levels[i], connected=True, extendToEdge=True)[::-1] lines = self._scale_contour_lines(lines) oneline = self._join_lines(lines) - oneline = self._close_curve(oneline) x, y = map(list, zip(*oneline)) - curve = pg.PlotDataItem(x, y, pen=pens[i]) + curve = pg.PlotDataItem(x, y, pen=use_pen)#pens[i]) self.ax.addItem(curve) if i == 0: x0 = np.mean([point[0] for point in oneline]) From 908ecc14397557c2e81249709f5d7b7067268208 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Sun, 5 Aug 2018 10:31:38 -0700 Subject: [PATCH 25/30] Improve contourf --- pgmpl/contour.py | 50 ++++++++++++++++++++++++------------------- tests/test_contour.py | 8 ++++++- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index 284a01c..7b5991c 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -70,7 +70,7 @@ def auto_pick_levels(self, z, nlvl=None): Number of levels; set to some arbitrary default if None :return: array """ - nlvl = (8 if self.filled else 7) if nlvl is None else nlvl + nlvl = 8 if nlvl is None else nlvl self.auto_range(z) return np.linspace(self.vmin, self.vmax, nlvl) @@ -95,8 +95,6 @@ def choose_xyz_levels(self, *args): raise TypeError('choose_xyz_levels takes 1, 2, 3, or 4 arguments. Got {} arguments.'.format(len(args))) levels = lvlinfo if ((lvlinfo is not None) and np.iterable(lvlinfo)) else self.auto_pick_levels(z, lvlinfo) -# levels = np.append(np.append(-np.inf, levels), np.inf) if self.filled else levels - #levels = np.append(levels, np.inf) if self.filled else levels if x is None: x, y = np.arange(np.shape(z)[1]), np.arange(np.shape(z)[0]) @@ -182,10 +180,13 @@ def _detect_direction(self, x, y): return np.mean(dth) > 0 def _find_which_edge(self, xy_point): - return np.where([xy_point[0] == self.x.max(), xy_point[1] == self.y.max(), - xy_point[0] == self.x.min(), xy_point[1] == self.y.min()])[0][0] - # return np.append(xy_point[0] == np.array([self.x.min(), self.x.max()]), - # xy_point[1] == np.array([self.y.min(), self.y.max()])) + print(xy_point, 'xy_point') + a = np.where([xy_point[0] == self.x.max(), xy_point[1] == self.y.max(), + xy_point[0] == self.x.min(), xy_point[1] == self.y.min()])[0] + if len(a): + return a[0] + else: + return 4 def _join_lines(self, lines): """ @@ -205,13 +206,10 @@ def _join_lines(self, lines): y1 = np.array([line[0][1] for line in lines]) if not self._detect_direction(x1, y1): lines = lines[::-1] - #return self._close_curve([point for line in lines for point in line]) - #edges0 = [self._find_which_edge(line[0]) for line in lines] - #edges1 = [self._find_which_edge(line[-1]) for line in lines] - #same_edge = [all(edge0 == edge1) for edge0, edge1 in zip(edges0, edges1)] if not lines: - return self.boundary + print('return early because not lines:', lines) + return tolist(np.array(self.boundary)[np.array([0, 1, 2, 3, 0])]) corners = np.roll(self.boundary, -2) @@ -226,22 +224,20 @@ def get_more_corners(next_e0_, edge_): corners_ += [tuple(corners[edge_+2])] print('Add corner {}'.format(corners[edge_+2])) if (next_e0_ - edge_) >= 3: - corners_ += [tuple(corners[edge_+3])] print('Add corner {}'.format(corners[edge_+3])) + corners_ += [tuple(corners[edge_+3])]#, tuple(corners[edge_])] return corners_ oneline = lines.pop(0) - if not lines: - return oneline + #if not lines: + # return oneline while len(lines): # Find which edge each segment starts/ends on edge = self._find_which_edge(oneline[-1]) edge0 = np.array([self._find_which_edge(line[0]) for line in lines]) - #edge1 = [self._find_which_edge(line[1]) for line in lines] - if not any(edge0 >= edge): #or any(edge1 >= edge)): + if not any(edge0 >= edge): edge -= 4 - #eligible = np.array(lines)[edge0 >= edge] eligible = [line for line, e0 in zip(lines, edge0) if e0 >= edge] next_line = eligible[edge0[edge0 >= edge].argmin()] next_e0 = edge0[edge0 >= edge].min() @@ -250,11 +246,15 @@ def get_more_corners(next_e0_, edge_): lines.remove(next_line) oneline += next_line - more_corners = get_more_corners(self._find_which_edge(oneline[0]), self._find_which_edge(oneline[-1])) + e0 = self._find_which_edge(oneline[0]) + e1 = self._find_which_edge(oneline[-1]) + if e1 > e0: + e1 -= 4 + print('e0, e1', e0, e1) + more_corners = get_more_corners(e0, e1) print('more corners = ', more_corners) - oneline += more_corners + oneline += get_more_corners(e0, e1) oneline += [oneline[0]] - print('oneline ', oneline) return oneline def _close_curve(self, line): # Remove during cleanup ----------------------------------- @@ -304,12 +304,17 @@ def draw_filled(self): pens = [setup_pen_kw(penkw=dict(color=self.colors[i])) for i in range(len(self.levels))] use_pen = pg.mkPen(color='k') for i in range(len(self.levels)): + print('level # {}, @ {}'.format(i, self.levels[i])) lines = fn.isocurve(self.z, self.levels[i], connected=True, extendToEdge=True)[::-1] lines = self._scale_contour_lines(lines) oneline = self._join_lines(lines) + print('oneline ', oneline) x, y = map(list, zip(*oneline)) curve = pg.PlotDataItem(x, y, pen=use_pen)#pens[i]) + #if i == len(self.levels)-1: + # self.ax.addItem(curve, pen=pg.mkPen(color='r')) + #if i == len(self.levels)-2: self.ax.addItem(curve) if i == 0: x0 = np.mean([point[0] for point in oneline]) @@ -320,8 +325,9 @@ def draw_filled(self): fill = pg.FillBetweenItem(curve, curve_c, brush=pg.mkBrush(color=self.colors[i])) self.ax.addItem(fill) else: # i > 0: + #elif i == len(self.levels)-1: fill = pg.FillBetweenItem(curve, prev_curve, brush=pg.mkBrush(color=self.colors[i])) - self.ax.addItem(fill) # doesn't work well + self.ax.addItem(fill) prev_curve = curve def draw_unfilled(self): diff --git a/tests/test_contour.py b/tests/test_contour.py index 8a25680..62a3925 100644 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -23,9 +23,15 @@ class TestPgmplContour(unittest.TestCase): verbose = int(os.environ.get('PGMPL_TEST_VERBOSE', '0')) + x0 = 0.94 + y0 = 2.2 + z0 = 1.145 + slant = .0 x = np.linspace(0, 1.8, 30) y = np.linspace(1, 3.1, 25) - z = (x[np.newaxis, :] - 0.94)**2 + (y[:, np.newaxis] - 2.2)**2 + 1.145 + dx = x[np.newaxis, :] - x0 + dy = y[:, np.newaxis] - y0 + z = dx**2 + dy**2 + z0 + slant*(dx+dy)**2 levels = [1.2, 1.5, 2, 2.5, 2.95] nlvl = len(levels) * 4 From 1032576d37e384053074c6404782b7b0ac3dfd46 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Tue, 18 Sep 2018 14:21:56 -0700 Subject: [PATCH 26/30] Expand testing of contourf --- tests/examples.py | 14 ++++++++ tests/test_contour.py | 83 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 2 deletions(-) mode change 100644 => 100755 tests/test_contour.py diff --git a/tests/examples.py b/tests/examples.py index fd90f4e..e01bc5a 100755 --- a/tests/examples.py +++ b/tests/examples.py @@ -167,6 +167,20 @@ def twod_demo(): return fig, axs +def contour_demo(): + """ + Test contour-based plot methods + """ + x = np.linspace(0, 1.8, 30) + y = np.linspace(1, 3.1, 25) + z = (x[:, np.newaxis] - 0.94)**2 + (y[np.newaxis, :] - 2.2)**2 + 1.145 + levels = [1.2, 1.5, 2, 2.5, 2.95] + nlvl = len(levels) * 4 + + fig, axs = plt.subplots(2, 2) + return fig, axs + + def open_examples(close_after=False, start_event=True): print('pgmpl examples...') pgmpl.util.set_debug(0) diff --git a/tests/test_contour.py b/tests/test_contour.py old mode 100644 new mode 100755 index 62a3925..0d1644f --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -25,13 +25,19 @@ class TestPgmplContour(unittest.TestCase): x0 = 0.94 y0 = 2.2 + x02 = x0 - 0.45 + y02 = y0 - 0.7 z0 = 1.145 slant = .0 x = np.linspace(0, 1.8, 30) y = np.linspace(1, 3.1, 25) dx = x[np.newaxis, :] - x0 dy = y[:, np.newaxis] - y0 + dx2 = x[np.newaxis, :] - x02 + dy2 = y[:, np.newaxis] - y02 z = dx**2 + dy**2 + z0 + slant*(dx+dy)**2 + z2 = dx2**2 + dy2**2 + (slant + 0.56) * (dx2 + dy2)**2 + z3 = dx**2 + dy**2 + (slant - 0.56) * (dx + dy)**2 levels = [1.2, 1.5, 2, 2.5, 2.95] nlvl = len(levels) * 4 @@ -62,17 +68,90 @@ def test_contour(self): axs[3, 1].set_title('x, y, z, nlvl') axs[3, 1].contour(self.x, self.y, self.z, self.nlvl, linestyles=['-', '--', '-.', ':']) + # Repeat with matplotlib to check whether the same figure is produced + import matplotlib + from matplotlib import pyplot + fig, axs = pyplot.subplots(4, 2) + fig.suptitle('TestPgmplContour.test_contour') + axs[0, 0].set_title('z') + axs[0, 0].contour(self.z) + axs[0, 1].set_title('-z') + axs[0, 1].contour(-self.z, linestyles=['--', '-.', ':']) + + axs[1, 0].set_title('z, levels') + axs[1, 0].contour(self.z, self.levels) + axs[1, 1].set_title('z, nlvl') + axs[1, 1].contour(self.z, self.nlvl, linewidths=[3, 2, 1]) + + axs[2, 0].set_title('x, y, z') + axs[2, 0].contour(self.x, self.y, self.z) + axs[2, 0].set_title('x, y, -z') + axs[2, 1].contour(self.x, self.y, -self.z, colors=['r', 'g', 'b']) + + axs[3, 0].set_title('x, y, z, levels') + axs[3, 0].contour(self.x, self.y, self.z, self.levels) + axs[3, 1].set_title('x, y, z, nlvl') + axs[3, 1].contour(self.x, self.y, self.z, self.nlvl, linestyles=['-', '--', '-.', ':']) + + # # Uncomment these to see figures during manual testing and development: + # pyplot.show() + # import pgmpl + # pgmpl.app.exec_() + def test_contourf(self): fig, axs = subplots(4, 2) fig.suptitle('TestPgmplContour.test_contourf') axs[0, 0].set_title('z') axs[0, 0].contourf(self.z) + axs[0, 1].set_title('z, nlvl') + axs[0, 1].contourf(self.z, self.nlvl) + + axs[1, 0].set_title('z2') + axs[1, 0].contourf(self.z2) + + axs[1, 1].set_title('z3') + axs[1, 1].contourf(self.z3) + axs[2, 0].set_title('x, y, z') axs[2, 0].contourf(self.x, self.y, self.z) - #import pgmpl # for testing only; delete later - #pgmpl.app.exec_() # for testing only; delete later + axs[3, 0].set_title('x, y, z2') + axs[3, 0].contourf(self.x, self.y, self.z2) + + axs[3, 1].set_title('x, y, z3') + axs[3, 1].contourf(self.x, self.y, self.z3) + + # Repeat with matplotlib to check whether the same figure is produced + import matplotlib + from matplotlib import pyplot + figm, axsm = pyplot.subplots(4, 2) + figm.suptitle('TestPgmplContour.test_contourf') + axsm[0, 0].set_title('z') + axsm[0, 0].contourf(self.z) + + axsm[0, 1].set_title('z, nlvl') + axsm[0, 1].contourf(self.z, self.nlvl) + + axsm[1, 0].set_title('z2') + axsm[1, 0].contourf(self.z2) + + axsm[1, 1].set_title('z3') + axsm[1, 1].contourf(self.z3) + + axsm[2, 0].set_title('x, y, z') + axsm[2, 0].contourf(self.x, self.y, self.z) + + axsm[3, 0].set_title('x, y, z2') + axsm[3, 0].contourf(self.x, self.y, self.z2) + + axsm[3, 1].set_title('x, y, z3') + axsm[3, 1].contourf(self.x, self.y, self.z3) + + # # Uncomment these to see figures during manual testing and development: + # pyplot.show() + # import pgmpl + # pgmpl.app.exec_() def test_contour_errors(self): ax = Axes() From 2db7e97c65125932117e2b2b3946197f6691198c Mon Sep 17 00:00:00 2001 From: David Eldon Date: Tue, 18 Sep 2018 14:24:36 -0700 Subject: [PATCH 27/30] Adjust contourf test --- tests/test_contour.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) mode change 100755 => 100644 tests/test_contour.py diff --git a/tests/test_contour.py b/tests/test_contour.py old mode 100755 new mode 100644 index 0d1644f..c9cb96f --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -36,8 +36,8 @@ class TestPgmplContour(unittest.TestCase): dx2 = x[np.newaxis, :] - x02 dy2 = y[:, np.newaxis] - y02 z = dx**2 + dy**2 + z0 + slant*(dx+dy)**2 - z2 = dx2**2 + dy2**2 + (slant + 0.56) * (dx2 + dy2)**2 - z3 = dx**2 + dy**2 + (slant - 0.56) * (dx + dy)**2 + z2 = dx2**2 + dy2**2 + (slant + 1.2) * (dx2 + dy2)**2 + z3 = dx**2 + dy**2 + (slant - 1.2) * (dx + dy)**2 levels = [1.2, 1.5, 2, 2.5, 2.95] nlvl = len(levels) * 4 @@ -116,6 +116,9 @@ def test_contourf(self): axs[2, 0].set_title('x, y, z') axs[2, 0].contourf(self.x, self.y, self.z) + axs[2, 1].set_title('x, y, z, nlvl') + axs[2, 1].contourf(self.x, self.y, self.z, self.nlvl) + axs[3, 0].set_title('x, y, z2') axs[3, 0].contourf(self.x, self.y, self.z2) @@ -142,6 +145,9 @@ def test_contourf(self): axsm[2, 0].set_title('x, y, z') axsm[2, 0].contourf(self.x, self.y, self.z) + axsm[2, 1].set_title('x, y, z, nlvl') + axsm[2, 1].contourf(self.x, self.y, self.z, self.nlvl) + axsm[3, 0].set_title('x, y, z2') axsm[3, 0].contourf(self.x, self.y, self.z2) From 4eda739dfef10d4626787d5286aedb2210e8b9a3 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Tue, 18 Sep 2018 14:27:50 -0700 Subject: [PATCH 28/30] Flip sign in one of the test `z`s to make contour test more interesting --- tests/test_contour.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_contour.py b/tests/test_contour.py index c9cb96f..c790049 100644 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -36,7 +36,7 @@ class TestPgmplContour(unittest.TestCase): dx2 = x[np.newaxis, :] - x02 dy2 = y[:, np.newaxis] - y02 z = dx**2 + dy**2 + z0 + slant*(dx+dy)**2 - z2 = dx2**2 + dy2**2 + (slant + 1.2) * (dx2 + dy2)**2 + z2 = -(dx2**2 + dy2**2 + (slant + 1.2) * (dx2 + dy2)**2) z3 = dx**2 + dy**2 + (slant - 1.2) * (dx + dy)**2 levels = [1.2, 1.5, 2, 2.5, 2.95] nlvl = len(levels) * 4 From 025fa0860a1967050849ede89dc63e203b8982e8 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Tue, 18 Sep 2018 14:49:39 -0700 Subject: [PATCH 29/30] Attempt to fix filled contours --- pgmpl/contour.py | 59 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index 7b5991c..fc0d233 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -303,6 +303,8 @@ def _close_curve(self, line): # Remove during cleanup ------------------------- def draw_filled(self): pens = [setup_pen_kw(penkw=dict(color=self.colors[i])) for i in range(len(self.levels))] use_pen = pg.mkPen(color='k') + curves = [None] * (len(self.levels)) + joined_lines = [None] * (len(self.levels)) for i in range(len(self.levels)): print('level # {}, @ {}'.format(i, self.levels[i])) lines = fn.isocurve(self.z, self.levels[i], connected=True, extendToEdge=True)[::-1] @@ -310,25 +312,52 @@ def draw_filled(self): oneline = self._join_lines(lines) print('oneline ', oneline) + joined_lines[i] = oneline x, y = map(list, zip(*oneline)) - curve = pg.PlotDataItem(x, y, pen=use_pen)#pens[i]) + curves[i] = pg.PlotDataItem(x, y, pen=use_pen)#pens[i]) #if i == len(self.levels)-1: # self.ax.addItem(curve, pen=pg.mkPen(color='r')) #if i == len(self.levels)-2: - self.ax.addItem(curve) - if i == 0: - x0 = np.mean([point[0] for point in oneline]) - y0 = np.mean([point[1] for point in oneline]) - xc = [x0, x0 + 1e-12] - yc = [y0, y0 + 1e-12] - curve_c = pg.PlotDataItem(xc, yc, pen=pens[i]) - fill = pg.FillBetweenItem(curve, curve_c, brush=pg.mkBrush(color=self.colors[i])) - self.ax.addItem(fill) - else: # i > 0: - #elif i == len(self.levels)-1: - fill = pg.FillBetweenItem(curve, prev_curve, brush=pg.mkBrush(color=self.colors[i])) - self.ax.addItem(fill) - prev_curve = curve + + # self.ax.addItem(curve) + # if i == 0: + # x0 = np.mean([point[0] for point in oneline]) + # y0 = np.mean([point[1] for point in oneline]) + # xc = [x0, x0 + 1e-12] + # yc = [y0, y0 + 1e-12] + # curve_c = pg.PlotDataItem(xc, yc, pen=pens[i]) + # fill = pg.FillBetweenItem(curve, curve_c, brush=pg.mkBrush(color=self.colors[i])) + # self.ax.addItem(fill) + # else: # i > 0: + # #elif i == len(self.levels)-1: + # fill = pg.FillBetweenItem(curve, prev_curve, brush=pg.mkBrush(color=self.colors[i])) + # self.ax.addItem(fill) + # prev_curve = curve + + # Get the curves at the edges of the array + x0 = np.mean([point[0] for point in joined_lines[1]]) + y0 = np.mean([point[1] for point in joined_lines[1]]) + dx0 = np.std([point[0] for point in joined_lines[1]]) + dy0 = np.std([point[1] for point in joined_lines[1]]) + x1 = np.mean([point[0] for point in joined_lines[-2]]) + y1 = np.mean([point[1] for point in joined_lines[-2]]) + dx1 = np.std([point[0] for point in joined_lines[-2]]) + dy1 = np.std([point[1] for point in joined_lines[-2]]) + if (dx1**2 + dy1**2) > (dx0**2 + dy0**2): + curves = [pg.PlotDataItem([x0, x0 + 1e-12], [y0, y0 + 1e-12], pen=pens[0])] + curves + #curves[-1] = pg.PlotDataItem( + # np.array([self.x.min(), self.x.max()])[np.array([0, 0, 1, 1, 0])], + # np.array([self.y.min(), self.y.max()])[np.array([0, 1, 1, 0, 0])], pen=pens[-1]) + else: + curves = curves + [pg.PlotDataItem([x1, x1 + 1e-12], [y1, y1 + 1e-12], pen=pens[-1])] + #curves[0] = pg.PlotDataItem( + # np.array([self.x.min(), self.x.max()])[0, 0, 1, 1, 0], + # np.array([self.y.min(), self.y.max()])[0, 1, 1, 0, 0], pen=pens[0]) + + for j in range(len(self.levels)): + i = len(self.levels)-j-1 + fill = pg.FillBetweenItem(curves[i], curves[i+1], brush=pg.mkBrush(color=self.colors[i])) + self.ax.addItem(fill) def draw_unfilled(self): lws, lss = self._extl(self.linewidths), self._extl(self.linestyles) From ffe3879418143d63acdbc8b5c1bca56636a059a9 Mon Sep 17 00:00:00 2001 From: David Eldon Date: Tue, 19 May 2020 06:32:25 -0700 Subject: [PATCH 30/30] Stuff I had uncommitted locally; probably just dev trash to remove - Cleanup later - This is all getting squashed anyway --- contour_example_gl.py | 100 ++++++++++++++++++++++++++++++++++++++++++ contour_sample.py | 42 ++++++++++++++++++ contour_sample2.py | 32 ++++++++++++++ pgmpl/contour.py | 17 ------- test.sh | 2 +- test3.6.sh | 14 ++++++ tests/test_contour.py | 8 ++-- 7 files changed, 193 insertions(+), 22 deletions(-) create mode 100755 contour_example_gl.py create mode 100644 contour_sample.py create mode 100644 contour_sample2.py create mode 100755 test3.6.sh mode change 100644 => 100755 tests/test_contour.py diff --git a/contour_example_gl.py b/contour_example_gl.py new file mode 100755 index 0000000..6653f7a --- /dev/null +++ b/contour_example_gl.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +""" +This example demonstrates the use of GLSurfacePlotItem. +""" + + +## Add path to library (just for examples; you do not need this) +#import initExample + +from pyqtgraph.Qt import QtCore, QtGui +import pyqtgraph as pg +import pyqtgraph.opengl as gl +import numpy as np + +## Create a GL View widget to display data +app = QtGui.QApplication([]) +w = gl.GLViewWidget() +w.show() +w.setWindowTitle('pyqtgraph example: GLSurfacePlot') +w.setCameraPosition(distance=50) + +## Add a grid to the view +g = gl.GLGridItem() +g.scale(2,2,1) +g.setDepthValue(10) # draw grid after surfaces since they may be translucent +w.addItem(g) + + +## Simple surface plot example +## x, y values are not specified, so assumed to be 0:50 +z = pg.gaussianFilter(np.random.normal(size=(50,50)), (1,1)) +p1 = gl.GLSurfacePlotItem(z=z, shader='shaded', color=(0.5, 0.5, 1, 1)) +p1.scale(16./49., 16./49., 1.0) +p1.translate(-18, 2, 0) +w.addItem(p1) + + +## Saddle example with x and y specified +x = np.linspace(-8, 8, 50) +y = np.linspace(-8, 8, 50) +z = 0.1 * ((x.reshape(50,1) ** 2) - (y.reshape(1,50) ** 2)) +p2 = gl.GLSurfacePlotItem(x=x, y=y, z=z, shader='normalColor') +p2.translate(-10,-10,0) +w.addItem(p2) + + +## Manually specified colors +z = pg.gaussianFilter(np.random.normal(size=(50,50)), (1,1)) +x = np.linspace(-12, 12, 50) +y = np.linspace(-12, 12, 50) +colors = np.ones((50,50,4), dtype=float) +colors[...,0] = np.clip(np.cos(((x.reshape(50,1) ** 2) + (y.reshape(1,50) ** 2)) ** 0.5), 0, 1) +colors[...,1] = colors[...,0] + +p3 = gl.GLSurfacePlotItem(z=z, colors=colors.reshape(50*50,4), shader='shaded', smooth=False) +p3.scale(16./49., 16./49., 1.0) +p3.translate(2, -18, 0) +w.addItem(p3) + + + + +## Animated example +## compute surface vertex data +cols = 90 +rows = 100 +x = np.linspace(-8, 8, cols+1).reshape(cols+1,1) +y = np.linspace(-8, 8, rows+1).reshape(1,rows+1) +d = (x**2 + y**2) * 0.1 +d2 = d ** 0.5 + 0.1 + +## precompute height values for all frames +phi = np.arange(0, np.pi*2, np.pi/20.) +z = np.sin(d[np.newaxis,...] + phi.reshape(phi.shape[0], 1, 1)) / d2[np.newaxis,...] + + +## create a surface plot, tell it to use the 'heightColor' shader +## since this does not require normal vectors to render (thus we +## can set computeNormals=False to save time when the mesh updates) +p4 = gl.GLSurfacePlotItem(x=x[:,0], y = y[0,:], shader='heightColor', computeNormals=False, smooth=False) +p4.shader()['colorMap'] = np.array([0.2, 2, 0.5, 0.2, 1, 1, 0.2, 0, 2]) +p4.translate(10, 10, 0) +w.addItem(p4) + +index = 0 +def update(): + global p4, z, index + index -= 1 + p4.setData(z=z[index%z.shape[0]]) + +timer = QtCore.QTimer() +timer.timeout.connect(update) +timer.start(30) + +## Start Qt event loop unless running in interactive mode. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() + diff --git a/contour_sample.py b/contour_sample.py new file mode 100644 index 0000000..02ddca1 --- /dev/null +++ b/contour_sample.py @@ -0,0 +1,42 @@ +from pyqtgraph.Qt import QtGui, QtCore +import numpy as np +import pyqtgraph as pg +import sys + +# Setup +app = QtGui.QApplication([]) +pg.setConfigOption('background', 'w') +pg.setConfigOption('foreground', 'k') + +win = pg.PlotWidget() +layout = pg.GraphicsLayout() +win.setCentralItem(layout) +ax = pg.PlotItem() +layout.addItem(ax) + +# Generate data +x = np.linspace(10, 16.28, 30) +y = x[:] +xx, yy = np.meshgrid(x, y) +z = np.sin(xx) + np.cos(yy) + +# Add data +ax.setXRange(x.min(), x.max()) +ax.setYRange(y.min(), y.max()) + +c = pg.IsocurveItem(data=z, level=0.5, pen='r', axisOrder='row-major') +img = pg.ImageItem(z, axisOrder='row-major') +img.translate(x.min(), y.min()) +img.scale((x.max() - x.min()) / img.width(), (y.max() - y.min()) / img.height()) +ax.addItem(img) + +# c.setParentItem(img) +# https://stackoverflow.com/a/51109935/6605826 +c.translate(x.min(), y.min()) +c.scale((x.max() - x.min()) / np.shape(z)[0], (y.max() - y.min()) / np.shape(z)[1]) + +ax.addItem(c) + +# Finish up +win.show() +sys.exit(app.exec_()) diff --git a/contour_sample2.py b/contour_sample2.py new file mode 100644 index 0000000..06345fa --- /dev/null +++ b/contour_sample2.py @@ -0,0 +1,32 @@ +from pyqtgraph.Qt import QtGui, QtCore +import numpy as np +import pyqtgraph as pg +import sys + +# Setup +app = QtGui.QApplication([]) +pg.setConfigOption('background', 'w') +pg.setConfigOption('foreground', 'k') + +win = pg.PlotWidget() +layout = pg.GraphicsLayout() +win.setCentralItem(layout) +ax = pg.PlotItem() +layout.addItem(ax) + +# Generate data +x = np.linspace(0, 6.28, 30) +y = x[:] +xx, yy = np.meshgrid(x, y) +z = np.sin(xx) + np.cos(yy) + +# Add data +#ax.setXRange(x.min(), x.max()) +#ax.setYRange(y.min(), y.max()) +c = pg.IsocurveItem(data=z, level=0.5, pen='r', axisOrder='row-major') +# c.setParentItem(ax) # This doesn't work, of course +ax.addItem(c) + +# Finish up +win.show() +sys.exit(app.exec_()) diff --git a/pgmpl/contour.py b/pgmpl/contour.py index fc0d233..8124c17 100644 --- a/pgmpl/contour.py +++ b/pgmpl/contour.py @@ -122,23 +122,6 @@ def draw(self): else: self.draw_unfilled() - @staticmethod ############### delete during cleanup #################################################### - def _isocurve2plotcurve(curve): - """ - Converts an IsocuveItem instance to a PlotCurveItem instance so it can be used with FillBetweenItem - - FAILS because the curves aren't sorted properly to allow good connections between segments. That is, a contour - can break where it intersects the edge of the plot/data range, and restart later where it re-enters. These entry - and exit points are reconnected arbitrarily or incorrectly. - - :param curve: IsocurveItem instance - :return: PlotCurveItem with the same path - """ - curve.generatePath() - new_curve = pg.PlotCurveItem() - new_curve.path = curve.path - return new_curve - def _scale_contour_lines(self, lines): """ Translates and stretches contour lines diff --git a/test.sh b/test.sh index 195ad3d..310df8c 100755 --- a/test.sh +++ b/test.sh @@ -1,2 +1,2 @@ #!/bin/bash -python2.7 -m unittest discover --pattern=*.py -s tests +python3.7 -m unittest discover --pattern=*.py -s tests diff --git a/test3.6.sh b/test3.6.sh new file mode 100755 index 0000000..0d855f5 --- /dev/null +++ b/test3.6.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Script for launching tests in python 3 environment while otherwise working in python 2 + +# Run the test +export NEWPYTHONPATH=/lib/python/python3.6/site-packages/ +export PYTHONPATH=$NEWPYTHONPATH +python3.6 -m unittest discover --pattern=*.py -s tests +export PYTHONPATH=$OLDPYTHONPATH + + +# More information +# using pip with specific python versions: +# https://stackoverflow.com/a/33964956/6605826 +# sudo python3.6 -m pip install -r requirements.txt diff --git a/tests/test_contour.py b/tests/test_contour.py old mode 100644 new mode 100755 index c790049..d34a0de --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -154,10 +154,10 @@ def test_contourf(self): axsm[3, 1].set_title('x, y, z3') axsm[3, 1].contourf(self.x, self.y, self.z3) - # # Uncomment these to see figures during manual testing and development: - # pyplot.show() - # import pgmpl - # pgmpl.app.exec_() + # Uncomment these to see figures during manual testing and development: + pyplot.show() + import pgmpl + pgmpl.app.exec_() def test_contour_errors(self): ax = Axes()