From 3b4a07a0efec013146d0de44374f914afa128491 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Mon, 17 Jan 2022 22:56:24 -0700 Subject: [PATCH 01/78] Support for recursive rendering, and filtering. --- matplotview/__init__.py | 57 ++++++++++++++++++++++++++++++++++----- matplotview/_view_axes.py | 54 +++++++++++++++++++++++++++++-------- 2 files changed, 94 insertions(+), 17 deletions(-) diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 9f21dc2..24e5bd2 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -1,6 +1,15 @@ -from matplotview._view_axes import view_wrapper +from typing import Callable, Optional, Iterable +from matplotlib.artist import Artist +from matplotlib.axes import Axes +from matplotview._view_axes import view_wrapper, DEFAULT_RENDER_DEPTH -def view(axes, axes_to_view, image_interpolation="nearest"): +def view( + axes: Axes, + axes_to_view: Axes, + image_interpolation: str = "nearest", + render_depth: int = DEFAULT_RENDER_DEPTH, + filter_function: Optional[Callable[[Artist], bool]] = None +) -> Axes: """ Convert an axes into a view of another axes, displaying the contents of the second axes. @@ -14,18 +23,41 @@ def view(axes, axes_to_view, image_interpolation="nearest"): The axes to display the contents of in the first axes, the 'viewed' axes. - image_interpolation: + image_interpolation: string, default of "nearest" The image interpolation method to use when displaying scaled images from the axes being viewed. Defaults to "nearest". Supported options are 'antialiased', 'nearest', 'bilinear', 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', 'hermite', 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', 'mitchell', 'sinc', 'lanczos', or 'none' + + render_depth: int, positive, defaults to 10 + The number of recursive draws allowed for this view, this can happen + if the view is a child of the axes (such as an inset axes) or if + two views point at each other. Defaults to 10. + + filter_function: callable(Artist) -> bool or None + An optional filter function, which can be used to select what artists + are drawn by the view. If the function returns True, the element is + drawn, otherwise it isn't. Defaults to None, or drawing all artists. """ - return view_wrapper(type(axes)).from_axes(axes, axes_to_view, image_interpolation) + return view_wrapper(type(axes)).from_axes( + axes, axes_to_view, image_interpolation, + render_depth, filter_function + ) -def inset_zoom_axes(axes, bounds, *, image_interpolation="nearest", transform=None, zorder=5, **kwargs): +def inset_zoom_axes( + axes: Axes, + bounds: Iterable, + *, + image_interpolation="nearest", + render_depth: int = DEFAULT_RENDER_DEPTH, + filter_function: Optional[Callable[[Artist], bool]] = None, + transform=None, + zorder=5, + **kwargs +) -> Axes: """ Add a child inset Axes to an Axes, which automatically plots artists contained within the parent Axes. @@ -55,6 +87,16 @@ def inset_zoom_axes(axes, bounds, *, image_interpolation="nearest", transform=No determines the interpolation used when attempting to render a zoomed version of an image. + render_depth: int, positive, defaults to 10 + The number of recursive draws allowed for this view, this can happen + if the view is a child of the axes (such as an inset axes) or if + two views point at each other. Defaults to 10. + + filter_function: callable(Artist) -> bool or None + An optional filter function, which can be used to select what artists + are drawn by the view. If the function returns True, the element is + drawn, otherwise it isn't. Defaults to None, or drawing all artists. + **kwargs Other keyword arguments are passed on to the child `.Axes`. @@ -70,4 +112,7 @@ def inset_zoom_axes(axes, bounds, *, image_interpolation="nearest", transform=No inset_ax = axes.inset_axes( bounds, transform=transform, zorder=zorder, **kwargs ) - return view(inset_ax, axes, image_interpolation) + return view( + inset_ax, axes, image_interpolation, + render_depth, filter_function + ) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index de1adf9..34ed607 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -1,5 +1,5 @@ import itertools -from typing import Type, List +from typing import Type, List, Optional, Callable from matplotlib.axes import Axes from matplotlib.transforms import Bbox import matplotlib.docstring as docstring @@ -7,6 +7,8 @@ from matplotlib.artist import Artist from matplotlib.backend_bases import RendererBase +DEFAULT_RENDER_DEPTH = 10 + class BoundRendererArtist: def __init__(self, artist: Artist, renderer: RendererBase, clip_box: Bbox): self._artist = artist @@ -66,17 +68,17 @@ class ViewAxesImpl(axes_class): require Artists to be plotted twice. """ __module__ = axes_class.__module__ - # The number of allowed recursions in the draw method - MAX_RENDER_DEPTH = 5 def __init__( self, axes_to_view: Axes, *args, image_interpolation: str = "nearest", + render_depth: int = DEFAULT_RENDER_DEPTH, + filter_function: Optional[Callable[[Artist], bool]] = None, **kwargs ): - """ + f""" Construct a new view axes. Parameters @@ -96,6 +98,17 @@ def __init__( 'nearest'. This determines the interpolation used when attempting to render a view of an image. + render_depth: int, positive, defaults to 10 + The number of recursive draws allowed for this view, this can + happen if the view is a child of the axes (such as an inset + axes) or if two views point at each other. Defaults to 10. + + filter_function: callable(Artist) -> bool or None + An optional filter function, which can be used to select what + artists are drawn by the view. If the function returns True, + the element is drawn, otherwise it isn't. Defaults to None, + or drawing all artists. + **kwargs Other optional keyword arguments supported by the Axes constructor this ViewAxes wraps: @@ -108,15 +121,29 @@ def __init__( The new zoom view axes instance... """ super().__init__(axes_to_view.figure, *args, **kwargs) - self._init_vars(axes_to_view, image_interpolation) + self._init_vars( + axes_to_view, image_interpolation, + render_depth, filter_function + ) def _init_vars( self, axes_to_view: Axes, - image_interpolation: str = "nearest" + image_interpolation: str, + render_depth: int, + filter_function: Optional[Callable[[Artist], bool]] ): + if(render_depth < 1): + raise ValueError(f"Render depth of {render_depth} is invalid.") + if(filter_function is None): + filter_function = lambda a: True + if(not callable(filter_function)): + raise ValueError(f"The filter function must be a callable!") + self.__view_axes = axes_to_view self.__image_interpolation = image_interpolation + self.__max_render_depth = render_depth + self.__filter_function = filter_function self._render_depth = 0 self.__scale_lines = True self.__renderer = None @@ -144,7 +171,7 @@ def get_children(self) -> List[Artist]: BoundRendererArtist(a, mock_renderer, axes_box) for a in itertools.chain( self.__view_axes._children, self.__view_axes.child_axes - ) if(a is not self) + ) if(self.__filter_function(a)) ]) return init_list @@ -155,7 +182,7 @@ def draw(self, renderer: RendererBase = None): # It is possible to have two axes which are views of each other # therefore we track the number of recursions and stop drawing # at a certain depth - if(self._render_depth >= self.MAX_RENDER_DEPTH): + if(self._render_depth >= self.__max_render_depth): return self._render_depth += 1 # Set the renderer, causing get_children to return the view's @@ -197,10 +224,15 @@ def from_axes( cls, axes: Axes, axes_to_view: Axes, - image_interpolation: str = "nearest" - ): + image_interpolation: str = "nearest", + render_depth: int = DEFAULT_RENDER_DEPTH, + filter_function: Optional[Callable[[Artist], bool]] = None + ) -> Axes: axes.__class__ = cls - axes._init_vars(axes_to_view, image_interpolation) + axes._init_vars( + axes_to_view, image_interpolation, + render_depth, filter_function + ) return axes new_name = f"{ViewAxesImpl.__name__}[{axes_class.__name__}]" From 6b6945fc886391301dd2089336407cee96e304b2 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 18 Jan 2022 08:03:00 -0700 Subject: [PATCH 02/78] Share render depth between all instances... --- matplotview/_view_axes.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 34ed607..41ea079 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -9,6 +9,7 @@ DEFAULT_RENDER_DEPTH = 10 + class BoundRendererArtist: def __init__(self, artist: Artist, renderer: RendererBase, clip_box: Bbox): self._artist = artist @@ -43,6 +44,10 @@ def draw(self, renderer: RendererBase): self._artist.set_clip_box(clip_box_orig) +class SharedRenderDepth: + current_depth = 0 + + def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: """ Construct a ViewAxes, which subclasses, or wraps a specific Axes subclass. @@ -144,7 +149,6 @@ def _init_vars( self.__image_interpolation = image_interpolation self.__max_render_depth = render_depth self.__filter_function = filter_function - self._render_depth = 0 self.__scale_lines = True self.__renderer = None @@ -182,9 +186,9 @@ def draw(self, renderer: RendererBase = None): # It is possible to have two axes which are views of each other # therefore we track the number of recursions and stop drawing # at a certain depth - if(self._render_depth >= self.__max_render_depth): + if(SharedRenderDepth.current_depth >= self.__max_render_depth): return - self._render_depth += 1 + SharedRenderDepth.current_depth += 1 # Set the renderer, causing get_children to return the view's # children also... self.__renderer = renderer @@ -193,7 +197,7 @@ def draw(self, renderer: RendererBase = None): # Get rid of the renderer... self.__renderer = None - self._render_depth -= 1 + SharedRenderDepth.current_depth -= 1 def get_linescaling(self) -> bool: """ From 707e5107237ec650638d51cce53b630d4e42a295 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 18 Jan 2022 08:12:45 -0700 Subject: [PATCH 03/78] Render depth is stored per-figure... --- matplotview/_view_axes.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 41ea079..015b21e 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -44,10 +44,6 @@ def draw(self, renderer: RendererBase): self._artist.set_clip_box(clip_box_orig) -class SharedRenderDepth: - current_depth = 0 - - def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: """ Construct a ViewAxes, which subclasses, or wraps a specific Axes subclass. @@ -146,6 +142,9 @@ def _init_vars( raise ValueError(f"The filter function must be a callable!") self.__view_axes = axes_to_view + self.figure._current_render_depth = getattr( + self.figure, "_current_render_depth", 0 + ) self.__image_interpolation = image_interpolation self.__max_render_depth = render_depth self.__filter_function = filter_function @@ -186,9 +185,9 @@ def draw(self, renderer: RendererBase = None): # It is possible to have two axes which are views of each other # therefore we track the number of recursions and stop drawing # at a certain depth - if(SharedRenderDepth.current_depth >= self.__max_render_depth): + if(self.figure._current_render_depth >= self.__max_render_depth): return - SharedRenderDepth.current_depth += 1 + self.figure._current_render_depth += 1 # Set the renderer, causing get_children to return the view's # children also... self.__renderer = renderer @@ -197,7 +196,7 @@ def draw(self, renderer: RendererBase = None): # Get rid of the renderer... self.__renderer = None - SharedRenderDepth.current_depth -= 1 + self.figure._current_render_depth -= 1 def get_linescaling(self) -> bool: """ From a0d9f9034e8f9e7d96b767408fcd1517d02d60ae Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 18 Jan 2022 08:24:10 -0700 Subject: [PATCH 04/78] Explain why we attach render depth to the figure... --- matplotview/_view_axes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 015b21e..9455fb6 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -142,6 +142,9 @@ def _init_vars( raise ValueError(f"The filter function must be a callable!") self.__view_axes = axes_to_view + # The current render depth is stored in the figure, so the number + # of recursive draws is even in the case of multiple axes drawing + # each other in the same figure. self.figure._current_render_depth = getattr( self.figure, "_current_render_depth", 0 ) From 4e1eb30a682fc43edf895c47f85dabfa6e03f2aa Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 18 Jan 2022 18:16:47 -0700 Subject: [PATCH 05/78] Adjust default render depth. --- matplotview/__init__.py | 8 ++++---- matplotview/_view_axes.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 24e5bd2..c85415b 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -31,10 +31,10 @@ def view( 'catrom', 'gaussian', 'bessel', 'mitchell', 'sinc', 'lanczos', or 'none' - render_depth: int, positive, defaults to 10 + render_depth: int, positive, defaults to 5 The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if - two views point at each other. Defaults to 10. + two views point at each other. Defaults to 5. filter_function: callable(Artist) -> bool or None An optional filter function, which can be used to select what artists @@ -87,10 +87,10 @@ def inset_zoom_axes( determines the interpolation used when attempting to render a zoomed version of an image. - render_depth: int, positive, defaults to 10 + render_depth: int, positive, defaults to 5 The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if - two views point at each other. Defaults to 10. + two views point at each other. Defaults to 5. filter_function: callable(Artist) -> bool or None An optional filter function, which can be used to select what artists diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 9455fb6..f3bfbd0 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -7,7 +7,7 @@ from matplotlib.artist import Artist from matplotlib.backend_bases import RendererBase -DEFAULT_RENDER_DEPTH = 10 +DEFAULT_RENDER_DEPTH = 5 class BoundRendererArtist: From 35fbf233b067acd48b509a131012bcf71683e0a6 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 18 Jan 2022 18:40:50 -0700 Subject: [PATCH 06/78] Change default render depth, add third example... --- README.md | 47 +++++++++++++++++++++++++++++++++++++-- matplotview/_view_axes.py | 1 - 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ceab1e2..01738fe 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ fig.show() ``` ![First example plot results, two views of the same plot.](https://user-images.githubusercontent.com/47544550/149814592-dd815f95-c3ef-406d-bd7e-504859c836bf.png) -An inset axes example . +An inset axes example. ```python from matplotlib import cbook import matplotlib.pyplot as plt @@ -76,4 +76,47 @@ ax.indicate_inset_zoom(axins, edgecolor="black") fig.show() ``` -![Second example plot results, an inset axes showing a zoom view of an image.](https://user-images.githubusercontent.com/47544550/149814558-c2b1228d-2e5d-41be-86c0-f5dd01d42884.png) \ No newline at end of file +![Second example plot results, an inset axes showing a zoom view of an image.](https://user-images.githubusercontent.com/47544550/149814558-c2b1228d-2e5d-41be-86c0-f5dd01d42884.png) + +Because views support recursive drawing, they can be used to create +fractals also. +```python +import matplotlib.pyplot as plt +import matplotview as mpv +from matplotlib.patches import PathPatch +from matplotlib.path import Path +from matplotlib.transforms import Affine2D + +outside_color = "black" +inner_color = "white" + +t = Affine2D().scale(-0.5) + +outer_triangle = Path.unit_regular_polygon(3) +inner_triangle = t.transform_path(outer_triangle) +b = outer_triangle.get_extents() + +fig, ax = plt.subplots(1) +ax.set_aspect(1) + +ax.add_patch(PathPatch(outer_triangle, fc=outside_color, ec=[0] * 4)) +ax.add_patch(PathPatch(inner_triangle, fc=inner_color, ec=[0] * 4)) +ax.set_xlim(b.x0, b.x1) +ax.set_ylim(b.y0, b.y1) + +ax_locs = [ + [0, 0, 0.5, 0.5], + [0.5, 0, 0.5, 0.5], + [0.25, 0.5, 0.5, 0.5] +] + +for loc in ax_locs: + inax = mpv.inset_zoom_axes(ax, loc, render_depth=6) + inax.set_xlim(b.x0, b.x1) + inax.set_ylim(b.y0, b.y1) + inax.axis("off") + inax.patch.set_visible(False) + +fig.show() +``` +![Third example plot results, a SierpiƄski triangle](https://user-images.githubusercontent.com/47544550/150047401-e9364f0f-becd-45c5-a6f4-062118ce713f.png) \ No newline at end of file diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index f3bfbd0..976b69a 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -9,7 +9,6 @@ DEFAULT_RENDER_DEPTH = 5 - class BoundRendererArtist: def __init__(self, artist: Artist, renderer: RendererBase, clip_box: Bbox): self._artist = artist From 71cf843b7892d01befe0da3dbd89ac49358f6ed7 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 18 Jan 2022 20:58:57 -0700 Subject: [PATCH 07/78] Update version... --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7932fa8..57dd5ee 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import setuptools -VERSION = "0.0.3" +VERSION = "0.0.4" with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() From a574f9cbf529e5060d1cdf5d8c43316112ffd241 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 18 Jan 2022 21:22:51 -0700 Subject: [PATCH 08/78] Fix repo reference. --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 57dd5ee..137cedb 100644 --- a/setup.py +++ b/setup.py @@ -13,9 +13,9 @@ description="A library for creating lightweight views of matplotlib axes.", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/isaacrobinson2000/matplotview", + url="https://github.com/matplotlib/matplotview", project_urls={ - "Bug Tracker": "https://github.com/isaacrobinson2000/matplotview/issues", + "Bug Tracker": "https://github.com/matplotlib/matplotview/issues", }, classifiers=[ 'Development Status :: 3 - Alpha', From aea17cf7c10d8e96c4009c500f46dfcf33c32843 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sun, 23 Jan 2022 23:42:52 -0700 Subject: [PATCH 09/78] Begin work on adding 3d support. --- matplotview/_transform_renderer.py | 6 ++++++ matplotview/_view_axes.py | 17 +++++++++++++++- matplotview/tests/test_inset_zoom.py | 30 +++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index 210f76a..2465708 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -78,6 +78,10 @@ def __init__( f"Invalid Interpolation Mode: {image_interpolation}" ) + @property + def bounding_axes(self): + return self.__bounding_axes + def _scale_gc(self, gc): transfer_transform = self._get_transfer_transform(IdentityTransform()) new_gc = self.__renderer.new_gc() @@ -188,6 +192,8 @@ def draw_path(self, gc, path, transform, rgbFace=None): # Change the clip to the sub-axes box gc.set_clip_rectangle(bbox) + rgbFace = tuple(rgbFace) if(rgbFace is not None) else None + self.__renderer.draw_path(gc, path, IdentityTransform(), rgbFace) def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath): diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 976b69a..61f0ebf 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -10,7 +10,12 @@ DEFAULT_RENDER_DEPTH = 5 class BoundRendererArtist: - def __init__(self, artist: Artist, renderer: RendererBase, clip_box: Bbox): + def __init__( + self, + artist: Artist, + renderer: _TransformRenderer, + clip_box: Bbox + ): self._artist = artist self._renderer = renderer self._clip_box = clip_box @@ -34,6 +39,16 @@ def draw(self, renderer: RendererBase): full_extents = self._artist.get_window_extent(self._renderer) self._artist.set_clip_box(full_extents) + # If we are working with a 3D object, swap out it's axes with + # this zoom axes (swapping out the 3d transform) and reproject it. + if(hasattr(self._artist, "do_3d_projection")): + ax = self._artist.axes + self._artist.axes = None + self._artist.axes = self._renderer.bounding_axes + self._artist.do_3d_projection() + self._artist.axes = None + self._artist.axes = ax + # Check and see if the passed limiting box and extents of the # artist intersect, if not don't bother drawing this artist. if(Bbox.intersection(full_extents, self._clip_box) is not None): diff --git a/matplotview/tests/test_inset_zoom.py b/matplotview/tests/test_inset_zoom.py index a92122e..8f8347e 100644 --- a/matplotview/tests/test_inset_zoom.py +++ b/matplotview/tests/test_inset_zoom.py @@ -104,4 +104,32 @@ def test_plotting_in_view(fig_test, fig_ref): "Interesting", (3, 3), (0, 0), textcoords="axes fraction", arrowprops=arrow_s ) - ax_ref.indicate_inset_zoom(axins_ref, edgecolor="black") \ No newline at end of file + ax_ref.indicate_inset_zoom(axins_ref, edgecolor="black") + + +@check_figures_equal() +def test_3d_view(fig_test, fig_ref): + # The data... + X = Y = np.arange(-5, 5, 0.25) + X, Y = np.meshgrid(X, Y) + Z = np.sin(np.sqrt(X ** 2 + Y ** 2)) + + # Test Case... + ax1_test, ax2_test = fig_test.subplots( + 1, 2, subplot_kw=dict(projection="3d") + ) + ax1_test.plot_surface(X, Y, Z) + view(ax2_test, ax1_test) + ax2_test.set_xlim(-10, 10) + ax2_test.set_ylim(-10, 10) + ax2_test.set_zlim(-2, 2) + + # Reference + ax1_ref, ax2_ref = fig_ref.subplots( + 1, 2, subplot_kw=dict(projection="3d") + ) + ax1_ref.plot_surface(X, Y, Z) + ax2_ref.plot_surface(X, Y, Z) + ax2_ref.set_xlim(-10, 10) + ax2_ref.set_ylim(-10, 10) + ax2_ref.set_zlim(-2, 2) \ No newline at end of file From bc1439f69e5ce37a002d7ac0095bdaeca2e6014b Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Mon, 24 Jan 2022 16:48:39 -0700 Subject: [PATCH 10/78] Move 3d reprojection code to correct zorder handling... --- matplotview/_view_axes.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 61f0ebf..4629351 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -42,12 +42,7 @@ def draw(self, renderer: RendererBase): # If we are working with a 3D object, swap out it's axes with # this zoom axes (swapping out the 3d transform) and reproject it. if(hasattr(self._artist, "do_3d_projection")): - ax = self._artist.axes - self._artist.axes = None - self._artist.axes = self._renderer.bounding_axes - self._artist.do_3d_projection() - self._artist.axes = None - self._artist.axes = ax + self.do_3d_projection() # Check and see if the passed limiting box and extents of the # artist intersect, if not don't bother drawing this artist. @@ -57,6 +52,18 @@ def draw(self, renderer: RendererBase): # Re-enable the clip box... self._artist.set_clip_box(clip_box_orig) + def do_3d_projection(self): + do_3d_projection = getattr(self._artist, "do_3d_projection") + + ax = self._artist.axes + self._artist.axes = None + self._artist.axes = self._renderer.bounding_axes + res = do_3d_projection() + self._artist.axes = None + self._artist.axes = ax + + return res + def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: """ From 6be6808b6180adc41a2be4a9bb899b23b22830f4 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Mon, 24 Jan 2022 16:57:38 -0700 Subject: [PATCH 11/78] Add install section to readme. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 01738fe..fa5aa25 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,13 @@ matplotview provides a simple interface for creating "views" of matplotlib axes, providing a simple way of displaying overviews and zoomed views of data without plotting data twice. +## Installation + +You can install matplotview using pip: +```bash +pip install matplotview +``` + ## Usage matplotview provides two methods, `view`, and `inset_zoom_axes`. The `view` From 19dc1b005874db1cc13755f816e6c11194e82e48 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 25 Jan 2022 16:27:36 -0700 Subject: [PATCH 12/78] Better handling of clipping, fixes for polar projection support. --- matplotview/_transform_renderer.py | 16 +++++++++----- matplotview/_view_axes.py | 31 ++++++++++++++++++---------- matplotview/tests/test_inset_zoom.py | 30 +++++++++++++++++++++++---- 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index 2465708..af0bae5 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -1,5 +1,7 @@ from matplotlib.backend_bases import RendererBase -from matplotlib.transforms import Bbox, IdentityTransform, Affine2D +from matplotlib.patches import Rectangle +from matplotlib.transforms import Bbox, IdentityTransform, Affine2D, \ + TransformedPatchPath from matplotlib.path import Path import matplotlib._image as _image import numpy as np @@ -101,9 +103,7 @@ def _get_axes_display_box(self): Private method, get the bounding box of the child axes in display coordinates. """ - return self.__bounding_axes.patch.get_bbox().transformed( - self.__bounding_axes.transAxes - ) + return self.__bounding_axes.get_window_extent() def _get_transfer_transform(self, orig_transform): """ @@ -191,6 +191,8 @@ def draw_path(self, gc, path, transform, rgbFace=None): # Change the clip to the sub-axes box gc.set_clip_rectangle(bbox) + if(not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) rgbFace = tuple(rgbFace) if(rgbFace is not None) else None @@ -219,12 +221,14 @@ def draw_gouraud_triangle(self, gc, points, colors, transform): gc = self._scale_gc(gc) gc.set_clip_rectangle(bbox) + if(not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) self.__renderer.draw_gouraud_triangle(gc, path.vertices, colors, IdentityTransform()) # Images prove to be especially messy to deal with... - def draw_image(self, gc, x, y, im, transform=None): + def draw_image(self, gc, x, y, im, transform = None): mag = self.get_image_magnification() shift_data_transform = self._get_transfer_transform( IdentityTransform() @@ -273,6 +277,8 @@ def draw_image(self, gc, x, y, im, transform=None): gc = self._scale_gc(gc) gc.set_clip_rectangle(clipped_out_box) + if(not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) x, y = clipped_out_box.x0, clipped_out_box.y0 diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 4629351..27a6681 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -1,5 +1,5 @@ import itertools -from typing import Type, List, Optional, Callable +from typing import Type, List, Optional, Callable, Any from matplotlib.axes import Axes from matplotlib.transforms import Bbox import matplotlib.docstring as docstring @@ -9,7 +9,7 @@ DEFAULT_RENDER_DEPTH = 5 -class BoundRendererArtist: +class _BoundRendererArtist: def __init__( self, artist: Artist, @@ -20,13 +20,13 @@ def __init__( self._renderer = renderer self._clip_box = clip_box - def __getattribute__(self, item): + def __getattribute__(self, item: str) -> Any: try: return super().__getattribute__(item) except AttributeError: return self._artist.__getattribute__(item) - def __setattr__(self, key, value): + def __setattr__(self, key: str, value: Any): try: super().__setattr__(key, value) except AttributeError: @@ -36,8 +36,11 @@ def draw(self, renderer: RendererBase): # Disable the artist defined clip box, as the artist might be visible # under the new renderer even if not on screen... clip_box_orig = self._artist.get_clip_box() + clip_path_orig = self._artist.get_clip_path() + full_extents = self._artist.get_window_extent(self._renderer) - self._artist.set_clip_box(full_extents) + self._artist.set_clip_box(None) + self._artist.set_clip_path(None) # If we are working with a 3D object, swap out it's axes with # this zoom axes (swapping out the 3d transform) and reproject it. @@ -49,16 +52,21 @@ def draw(self, renderer: RendererBase): if(Bbox.intersection(full_extents, self._clip_box) is not None): self._artist.draw(self._renderer) - # Re-enable the clip box... + # Re-enable the clip box... and clip path... self._artist.set_clip_box(clip_box_orig) + self._artist.set_clip_path(clip_path_orig) - def do_3d_projection(self): + def do_3d_projection(self) -> float: + # Get the 3D projection function... do_3d_projection = getattr(self._artist, "do_3d_projection") + # Intentionally replace the axes of the artist with the view axes, + # as the do_3d_projection pulls the 3D transform (M) from the axes. + # Then reproject, and restore the original axes. ax = self._artist.axes - self._artist.axes = None + self._artist.axes = None # Set to None first to avoid exception... self._artist.axes = self._renderer.bounding_axes - res = do_3d_projection() + res = do_3d_projection() # Returns a z-order value... self._artist.axes = None self._artist.axes = ax @@ -195,9 +203,10 @@ def get_children(self) -> List[Artist]: init_list = super().get_children() init_list.extend([ - BoundRendererArtist(a, mock_renderer, axes_box) + _BoundRendererArtist(a, mock_renderer, axes_box) for a in itertools.chain( - self.__view_axes._children, self.__view_axes.child_axes + self.__view_axes._children, + self.__view_axes.child_axes ) if(self.__filter_function(a)) ]) diff --git a/matplotview/tests/test_inset_zoom.py b/matplotview/tests/test_inset_zoom.py index 8f8347e..21efb55 100644 --- a/matplotview/tests/test_inset_zoom.py +++ b/matplotview/tests/test_inset_zoom.py @@ -118,8 +118,9 @@ def test_3d_view(fig_test, fig_ref): ax1_test, ax2_test = fig_test.subplots( 1, 2, subplot_kw=dict(projection="3d") ) - ax1_test.plot_surface(X, Y, Z) + ax1_test.plot_surface(X, Y, Z, cmap="plasma") view(ax2_test, ax1_test) + ax2_test.view_init(elev=80) ax2_test.set_xlim(-10, 10) ax2_test.set_ylim(-10, 10) ax2_test.set_zlim(-2, 2) @@ -128,8 +129,29 @@ def test_3d_view(fig_test, fig_ref): ax1_ref, ax2_ref = fig_ref.subplots( 1, 2, subplot_kw=dict(projection="3d") ) - ax1_ref.plot_surface(X, Y, Z) - ax2_ref.plot_surface(X, Y, Z) + ax1_ref.plot_surface(X, Y, Z, cmap="plasma") + ax2_ref.plot_surface(X, Y, Z, cmap="plasma") + ax2_ref.view_init(elev=80) ax2_ref.set_xlim(-10, 10) ax2_ref.set_ylim(-10, 10) - ax2_ref.set_zlim(-2, 2) \ No newline at end of file + ax2_ref.set_zlim(-2, 2) + +@check_figures_equal() +def test_polar_view(fig_test, fig_ref): + r = np.arange(0, 2, 0.01) + theta = 2 * np.pi * r + + # Test Case with polar coordinate system... + ax_t1, ax_t2 = fig_test.subplots(1, 2, subplot_kw=dict(projection="polar")) + ax_t1.plot(theta, r) + ax_t1.set_rmax(2) + view(ax_t2, ax_t1) + ax_t2.set_linescaling(False) + ax_t2.set_rmax(1) + + # Reference... + ax_r1, ax_r2 = fig_ref.subplots(1, 2, subplot_kw=dict(projection="polar")) + ax_r1.plot(theta, r) + ax_r1.set_rmax(2) + ax_r2.plot(theta, r) + ax_r2.set_rmax(1) From daccf922bc059f3bb5778b2d4b7910187b6e3400 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 25 Jan 2022 16:29:36 -0700 Subject: [PATCH 13/78] Update version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 137cedb..2d31b5f 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import setuptools -VERSION = "0.0.4" +VERSION = "0.0.5" with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() From e9e90a944c442d2f324bee2b7056e0d55d9b5954 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Wed, 26 Jan 2022 15:07:50 -0700 Subject: [PATCH 14/78] Fixes for projections that don't report axes bounds correctly (geographic projections). --- matplotview/_view_axes.py | 7 ++++++- matplotview/tests/test_inset_zoom.py | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 27a6681..e3d8f39 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -49,7 +49,12 @@ def draw(self, renderer: RendererBase): # Check and see if the passed limiting box and extents of the # artist intersect, if not don't bother drawing this artist. - if(Bbox.intersection(full_extents, self._clip_box) is not None): + # First 2 checks are a special case where we received a bad clip box. + # (those can happen when we try to get the bounds of a map projection) + if( + self._clip_box.width == 0 or self._clip_box.height == 0 or + Bbox.intersection(full_extents, self._clip_box) is not None + ): self._artist.draw(self._renderer) # Re-enable the clip box... and clip path... diff --git a/matplotview/tests/test_inset_zoom.py b/matplotview/tests/test_inset_zoom.py index 21efb55..3844ed7 100644 --- a/matplotview/tests/test_inset_zoom.py +++ b/matplotview/tests/test_inset_zoom.py @@ -155,3 +155,29 @@ def test_polar_view(fig_test, fig_ref): ax_r1.set_rmax(2) ax_r2.plot(theta, r) ax_r2.set_rmax(1) + +@check_figures_equal() +def test_map_projection_view(fig_test, fig_ref): + x = np.linspace(-2.5, 2.5, 20) + y = np.linspace(-1, 1, 20) + circ_gen = lambda: plt.Circle((1.5, 0.25), 0.7, ec="black", fc="blue") + + # Test case... + ax_t1 = fig_test.add_subplot(1, 2, 1, projection="hammer") + ax_t2 = fig_test.add_subplot(1, 2, 2, projection="lambert") + ax_t1.grid(True) + ax_t2.grid(True) + ax_t1.plot(x, y) + ax_t1.add_patch(circ_gen()) + view(ax_t2, ax_t1) + ax_t2.set_linescaling(False) + + # Reference... + ax_r1 = fig_ref.add_subplot(1, 2, 1, projection="hammer") + ax_r2 = fig_ref.add_subplot(1, 2, 2, projection="lambert") + ax_r1.grid(True) + ax_r2.grid(True) + ax_r1.plot(x, y) + ax_r1.add_patch(circ_gen()) + ax_r2.plot(x, y) + ax_r2.add_patch(circ_gen()) From f87ac0f8718dea9599cb17e65ee33718d4198bc5 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Fri, 4 Feb 2022 19:20:31 -0700 Subject: [PATCH 15/78] Pickle support. (And Dynamic Inheritance Concept) --- matplotview/_utils.py | 83 +++++++++++++++++++ matplotview/_view_axes.py | 55 ++++++++---- matplotview/tests/test_view_obj.py | 80 ++++++++++++++++++ ...t_inset_zoom.py => test_view_rendering.py} | 2 + 4 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 matplotview/_utils.py create mode 100644 matplotview/tests/test_view_obj.py rename matplotview/tests/{test_inset_zoom.py => test_view_rendering.py} (99%) diff --git a/matplotview/_utils.py b/matplotview/_utils.py new file mode 100644 index 0000000..ee834ec --- /dev/null +++ b/matplotview/_utils.py @@ -0,0 +1,83 @@ +import functools + +def _fix_super_reference(function): + """ + Private utility decorator. Allows a function to be transferred to another + class by dynamically updating the local __class__ attribute of the function + when called. This allows for use of zero argument super in all methods + + Parameters: + ----------- + function + The function to wrap, to allow for the dynamic + """ + @functools.wraps(function) + def run_function(self, *args, **kwargs): + try: + cls_idx = function.__code__.co_freevars.index('__class__') + old_value = function.__closure__[cls_idx].cell_contents + function.__closure__[cls_idx].cell_contents = type(self) + res = function(self, *args, **kwargs) + function.__closure__[cls_idx].cell_contents = old_value + return res + except (AttributeError, ValueError): + return function(self, *args, **kwargs) + + return run_function + + +class _WrappingType(type): + def __new__(mcs, *args, **kwargs): + res = super().__new__(mcs, *args, **kwargs) + + res.__base_wrapping__ = getattr( + res, "__base_wrapping__", res.__bases__[0] + ) + res.__instances__ = getattr(res, "__instances__", {}) + + return res + + def __getitem__(cls, the_type): + if(cls.__instances__ is None): + raise TypeError("Already instantiated wrapper!") + + if(the_type == cls.__base_wrapping__): + return cls + + if(issubclass(super().__class__, _WrappingType)): + return cls._gen_type(super()[the_type]) + + if(not issubclass(the_type, cls.__base_wrapping__)): + raise TypeError( + f"The extension type {the_type} must be a subclass of " + f"{cls.__base_wrapping__}" + ) + + return cls._gen_type(the_type) + + def _gen_type(cls, the_type): + if(the_type not in cls.__instances__): + cls.__instances__[the_type] = _WrappingType( + f"{cls.__name__}[{the_type.__name__}]", + (the_type,), + {"__instances__": None} + ) + cls._copy_attrs_to(cls.__instances__[the_type]) + + return cls.__instances__[the_type] + + def _copy_attrs_to(cls, other): + dont_copy = {"__dict__", "__weakref__", "__instances__"} + + for k, v in cls.__dict__.items(): + if(k not in dont_copy): + setattr( + other, + k, + _fix_super_reference(v) if(hasattr(v, "__code__")) else v + ) + + other.__instances__ = None + + def __iter__(cls): + return NotImplemented diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index e3d8f39..b6062ba 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -1,3 +1,4 @@ +import functools import itertools from typing import Type, List, Optional, Callable, Any from matplotlib.axes import Axes @@ -77,7 +78,12 @@ def do_3d_projection(self) -> float: return res +def _view_from_pickle(builder, args): + res = builder(*args) + res.__class__ = view_wrapper(type(res)) + return res +@functools.lru_cache(None) def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: """ Construct a ViewAxes, which subclasses, or wraps a specific Axes subclass. @@ -95,15 +101,12 @@ def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: The view axes wrapper for a given axes class, capable of display other axes contents... """ - @docstring.interpd - class ViewAxesImpl(axes_class): + class View(axes_class): """ An axes which automatically displays elements of another axes. Does not require Artists to be plotted twice. """ - __module__ = axes_class.__module__ - def __init__( self, axes_to_view: Axes, @@ -113,7 +116,7 @@ def __init__( filter_function: Optional[Callable[[Artist], bool]] = None, **kwargs ): - f""" + """ Construct a new view axes. Parameters @@ -170,10 +173,10 @@ def _init_vars( ): if(render_depth < 1): raise ValueError(f"Render depth of {render_depth} is invalid.") - if(filter_function is None): - filter_function = lambda a: True - if(not callable(filter_function)): - raise ValueError(f"The filter function must be a callable!") + if(filter_function is not None and not callable(filter_function)): + raise ValueError( + f"The filter function must be a callable or None!" + ) self.__view_axes = axes_to_view # The current render depth is stored in the figure, so the number @@ -212,13 +215,14 @@ def get_children(self) -> List[Artist]: for a in itertools.chain( self.__view_axes._children, self.__view_axes.child_axes - ) if(self.__filter_function(a)) + ) if(self.__filter_function is None + or self.__filter_function(a)) ]) return init_list else: return super().get_children() - + def draw(self, renderer: RendererBase = None): # It is possible to have two axes which are views of each other # therefore we track the number of recursions and stop drawing @@ -260,6 +264,26 @@ def set_linescaling(self, value: bool): """ self.__scale_lines = value + def __reduce__(self): + builder, args = super().__reduce__()[:2] + + if(type(self) in args): + builder = super().__new__ + args = (type(self).__bases__[0],) + + return ( + _view_from_pickle, + (builder, args), + self.__getstate__() + ) + + def __getstate__(self): + state = super().__getstate__() + state["__renderer"] = None + # We don't support pickling the filter... + state["__filter_function"] = None + return state + @classmethod def from_axes( cls, @@ -276,10 +300,7 @@ def from_axes( ) return axes - new_name = f"{ViewAxesImpl.__name__}[{axes_class.__name__}]" - ViewAxesImpl.__name__ = ViewAxesImpl.__qualname__ = new_name - - return ViewAxesImpl - + View.__name__ = f"{View.__name__}[{axes_class.__name__}]" + View.__qualname__ = f"{View.__qualname__}[{axes_class.__name__}]" -ViewAxes = view_wrapper(Axes) \ No newline at end of file + return View \ No newline at end of file diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py new file mode 100644 index 0000000..e18d689 --- /dev/null +++ b/matplotview/tests/test_view_obj.py @@ -0,0 +1,80 @@ +import matplotlib.pyplot as plt +import pickle +from matplotview import view, view_wrapper, inset_zoom_axes +import numpy as np + +def to_image(figure): + figure.canvas.draw() + img = np.frombuffer(figure.canvas.tostring_rgb(), dtype=np.uint8) + return img.reshape(figure.canvas.get_width_height()[::-1] + (3,)) + + +def test_obj_comparison(): + from matplotlib.axes import Subplot, Axes + + view_class1 = view_wrapper(Subplot) + view_class2 = view_wrapper(Subplot) + view_class3 = view_wrapper(Axes) + + assert view_class1 is view_class2 + assert view_class1 == view_class2 + assert view_class2 != view_class3 + + +def test_subplot_view_pickle(): + np.random.seed(1) + im_data = np.random.rand(30, 30) + + # Test case... + fig_test, (ax_test1, ax_test2) = plt.subplots(1, 2) + + ax_test1.plot([i for i in range(10)], "r") + ax_test1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_test1.text(10, 10, "Hello World!", size=14) + ax_test1.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + ax_test2 = view(ax_test2, ax_test1) + ax_test2.set_aspect(ax_test1.get_aspect()) + ax_test2.set_xlim(ax_test1.get_xlim()) + ax_test2.set_ylim(ax_test1.get_ylim()) + + img_expected = to_image(fig_test) + + saved_fig = pickle.dumps(fig_test) + plt.clf() + + fig_test = pickle.loads(saved_fig) + img_result = to_image(fig_test) + + assert np.all(img_expected == img_result) + + +def test_zoom_plot_pickle(): + np.random.seed(1) + plt.clf() + im_data = np.random.rand(30, 30) + + # Test Case... + fig_test = plt.gcf() + ax_test = fig_test.gca() + ax_test.plot([i for i in range(10)], "r") + ax_test.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_test.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48]) + axins_test.set_linescaling(False) + axins_test.set_xlim(1, 5) + axins_test.set_ylim(1, 5) + ax_test.indicate_inset_zoom(axins_test, edgecolor="black") + + fig_test.savefig("before.png") + img_expected = to_image(fig_test) + + saved_fig = pickle.dumps(fig_test) + plt.clf() + + fig_test = pickle.loads(saved_fig) + fig_test.savefig("after.png") + img_result = to_image(fig_test) + + assert np.all(img_expected == img_result) \ No newline at end of file diff --git a/matplotview/tests/test_inset_zoom.py b/matplotview/tests/test_view_rendering.py similarity index 99% rename from matplotview/tests/test_inset_zoom.py rename to matplotview/tests/test_view_rendering.py index 3844ed7..5efd76d 100644 --- a/matplotview/tests/test_inset_zoom.py +++ b/matplotview/tests/test_view_rendering.py @@ -136,6 +136,7 @@ def test_3d_view(fig_test, fig_ref): ax2_ref.set_ylim(-10, 10) ax2_ref.set_zlim(-2, 2) + @check_figures_equal() def test_polar_view(fig_test, fig_ref): r = np.arange(0, 2, 0.01) @@ -156,6 +157,7 @@ def test_polar_view(fig_test, fig_ref): ax_r2.plot(theta, r) ax_r2.set_rmax(1) + @check_figures_equal() def test_map_projection_view(fig_test, fig_ref): x = np.linspace(-2.5, 2.5, 20) From 6416366c68849590273e2806b3e7cc9f1f7c54ca Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Fri, 4 Feb 2022 19:22:00 -0700 Subject: [PATCH 16/78] Remove Dynamic Inheritance Concept, replace with closure again... --- matplotview/_utils.py | 83 ------------------------------ matplotview/tests/test_view_obj.py | 2 - 2 files changed, 85 deletions(-) delete mode 100644 matplotview/_utils.py diff --git a/matplotview/_utils.py b/matplotview/_utils.py deleted file mode 100644 index ee834ec..0000000 --- a/matplotview/_utils.py +++ /dev/null @@ -1,83 +0,0 @@ -import functools - -def _fix_super_reference(function): - """ - Private utility decorator. Allows a function to be transferred to another - class by dynamically updating the local __class__ attribute of the function - when called. This allows for use of zero argument super in all methods - - Parameters: - ----------- - function - The function to wrap, to allow for the dynamic - """ - @functools.wraps(function) - def run_function(self, *args, **kwargs): - try: - cls_idx = function.__code__.co_freevars.index('__class__') - old_value = function.__closure__[cls_idx].cell_contents - function.__closure__[cls_idx].cell_contents = type(self) - res = function(self, *args, **kwargs) - function.__closure__[cls_idx].cell_contents = old_value - return res - except (AttributeError, ValueError): - return function(self, *args, **kwargs) - - return run_function - - -class _WrappingType(type): - def __new__(mcs, *args, **kwargs): - res = super().__new__(mcs, *args, **kwargs) - - res.__base_wrapping__ = getattr( - res, "__base_wrapping__", res.__bases__[0] - ) - res.__instances__ = getattr(res, "__instances__", {}) - - return res - - def __getitem__(cls, the_type): - if(cls.__instances__ is None): - raise TypeError("Already instantiated wrapper!") - - if(the_type == cls.__base_wrapping__): - return cls - - if(issubclass(super().__class__, _WrappingType)): - return cls._gen_type(super()[the_type]) - - if(not issubclass(the_type, cls.__base_wrapping__)): - raise TypeError( - f"The extension type {the_type} must be a subclass of " - f"{cls.__base_wrapping__}" - ) - - return cls._gen_type(the_type) - - def _gen_type(cls, the_type): - if(the_type not in cls.__instances__): - cls.__instances__[the_type] = _WrappingType( - f"{cls.__name__}[{the_type.__name__}]", - (the_type,), - {"__instances__": None} - ) - cls._copy_attrs_to(cls.__instances__[the_type]) - - return cls.__instances__[the_type] - - def _copy_attrs_to(cls, other): - dont_copy = {"__dict__", "__weakref__", "__instances__"} - - for k, v in cls.__dict__.items(): - if(k not in dont_copy): - setattr( - other, - k, - _fix_super_reference(v) if(hasattr(v, "__code__")) else v - ) - - other.__instances__ = None - - def __iter__(cls): - return NotImplemented diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py index e18d689..e585f42 100644 --- a/matplotview/tests/test_view_obj.py +++ b/matplotview/tests/test_view_obj.py @@ -67,14 +67,12 @@ def test_zoom_plot_pickle(): axins_test.set_ylim(1, 5) ax_test.indicate_inset_zoom(axins_test, edgecolor="black") - fig_test.savefig("before.png") img_expected = to_image(fig_test) saved_fig = pickle.dumps(fig_test) plt.clf() fig_test = pickle.loads(saved_fig) - fig_test.savefig("after.png") img_result = to_image(fig_test) assert np.all(img_expected == img_result) \ No newline at end of file From f12640d064bbf1c0545572c8a601852e14e380ff Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Fri, 4 Feb 2022 21:18:24 -0700 Subject: [PATCH 17/78] Improve pickling... --- matplotview/_view_axes.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index b6062ba..ab01fd3 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -267,9 +267,13 @@ def set_linescaling(self, value: bool): def __reduce__(self): builder, args = super().__reduce__()[:2] - if(type(self) in args): - builder = super().__new__ - args = (type(self).__bases__[0],) + if(self.__new__ == builder): + builder = super().__new__() + + cls = type(self) + args = tuple( + arg if(arg != cls) else cls.__bases__[0] for arg in args + ) return ( _view_from_pickle, From a2db0299905c8d2259eaefb168c39a719233f619 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 5 Feb 2022 01:52:21 -0700 Subject: [PATCH 18/78] More pickle tests... --- matplotview/tests/test_view_obj.py | 56 ++++++++++++++++-------------- matplotview/tests/utils.py | 41 ++++++++++++++++++++++ 2 files changed, 70 insertions(+), 27 deletions(-) create mode 100644 matplotview/tests/utils.py diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py index e585f42..c6fca4d 100644 --- a/matplotview/tests/test_view_obj.py +++ b/matplotview/tests/test_view_obj.py @@ -1,13 +1,8 @@ import matplotlib.pyplot as plt -import pickle +from matplotview.tests.utils import plotting_test, matches_post_pickle from matplotview import view, view_wrapper, inset_zoom_axes import numpy as np -def to_image(figure): - figure.canvas.draw() - img = np.frombuffer(figure.canvas.tostring_rgb(), dtype=np.uint8) - return img.reshape(figure.canvas.get_width_height()[::-1] + (3,)) - def test_obj_comparison(): from matplotlib.axes import Subplot, Axes @@ -21,12 +16,13 @@ def test_obj_comparison(): assert view_class2 != view_class3 -def test_subplot_view_pickle(): +@plotting_test() +def test_subplot_view_pickle(fig_test): np.random.seed(1) im_data = np.random.rand(30, 30) # Test case... - fig_test, (ax_test1, ax_test2) = plt.subplots(1, 2) + ax_test1, ax_test2 = fig_test.subplots(1, 2) ax_test1.plot([i for i in range(10)], "r") ax_test1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) @@ -38,24 +34,15 @@ def test_subplot_view_pickle(): ax_test2.set_xlim(ax_test1.get_xlim()) ax_test2.set_ylim(ax_test1.get_ylim()) - img_expected = to_image(fig_test) - - saved_fig = pickle.dumps(fig_test) - plt.clf() - - fig_test = pickle.loads(saved_fig) - img_result = to_image(fig_test) + assert matches_post_pickle(fig_test) - assert np.all(img_expected == img_result) - - -def test_zoom_plot_pickle(): +@plotting_test() +def test_zoom_plot_pickle(fig_test): np.random.seed(1) - plt.clf() im_data = np.random.rand(30, 30) + arrow_s = dict(arrowstyle="->") # Test Case... - fig_test = plt.gcf() ax_test = fig_test.gca() ax_test.plot([i for i in range(10)], "r") ax_test.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) @@ -65,14 +52,29 @@ def test_zoom_plot_pickle(): axins_test.set_linescaling(False) axins_test.set_xlim(1, 5) axins_test.set_ylim(1, 5) + axins_test.annotate( + "Interesting", (3, 3), (0, 0), + textcoords="axes fraction", arrowprops=arrow_s + ) ax_test.indicate_inset_zoom(axins_test, edgecolor="black") - img_expected = to_image(fig_test) + assert matches_post_pickle(fig_test) + - saved_fig = pickle.dumps(fig_test) - plt.clf() +@plotting_test() +def test_3d_view_pickle(fig_test): + X = Y = np.arange(-5, 5, 0.25) + X, Y = np.meshgrid(X, Y) + Z = np.sin(np.sqrt(X ** 2 + Y ** 2)) - fig_test = pickle.loads(saved_fig) - img_result = to_image(fig_test) + ax1_test, ax2_test = fig_test.subplots( + 1, 2, subplot_kw=dict(projection="3d") + ) + ax1_test.plot_surface(X, Y, Z, cmap="plasma") + view(ax2_test, ax1_test) + ax2_test.view_init(elev=80) + ax2_test.set_xlim(-10, 10) + ax2_test.set_ylim(-10, 10) + ax2_test.set_zlim(-2, 2) - assert np.all(img_expected == img_result) \ No newline at end of file + assert matches_post_pickle(fig_test) \ No newline at end of file diff --git a/matplotview/tests/utils.py b/matplotview/tests/utils.py new file mode 100644 index 0000000..05409aa --- /dev/null +++ b/matplotview/tests/utils.py @@ -0,0 +1,41 @@ +import functools + +import numpy as np +import matplotlib.pyplot as plt + + +def figure_to_image(figure): + figure.canvas.draw() + img = np.frombuffer(figure.canvas.tostring_rgb(), dtype=np.uint8) + return img.reshape(figure.canvas.get_width_height()[::-1] + (3,)) + + +def matches_post_pickle(figure): + import pickle + img_expected = figure_to_image(figure) + + saved_fig = pickle.dumps(figure) + plt.close("all") + + figure = pickle.loads(saved_fig) + img_result = figure_to_image(figure) + + return np.all(img_expected == img_result) + + +def plotting_test(num_figs = 1, *args, **kwargs): + def plotting_decorator(function): + def test_plotting(): + plt.close("all") + res = function( + *(plt.figure(*args, **kwargs) for __ in range(num_figs)) + ) + plt.close("all") + return res + + return test_plotting + + return plotting_decorator + + + From a7b95c50e08094fcad9cc930dd876aff4d7c7ab4 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 5 Feb 2022 18:02:04 -0700 Subject: [PATCH 19/78] Getters and Setters for View properties, documentation added. It needs tests... --- matplotview/_view_axes.py | 181 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 175 insertions(+), 6 deletions(-) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index ab01fd3..d02ffe5 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -11,6 +11,12 @@ DEFAULT_RENDER_DEPTH = 5 class _BoundRendererArtist: + """ + Provides a temporary wrapper around a given artist, inheriting its + attributes and values, while overloading draw to use a fixed + TransformRenderer. This is used to render an artist to a view without + having to implement a new draw for every Axes type. + """ def __init__( self, artist: Artist, @@ -79,15 +85,20 @@ def do_3d_projection(self) -> float: return res def _view_from_pickle(builder, args): + """ + PRIVATE: Construct a View wrapper axes given an axes builder and class. + """ res = builder(*args) res.__class__ = view_wrapper(type(res)) return res +# Cache classes so grabbing the same type twice leads to actually getting the +# same type (and type comparisons work). @functools.lru_cache(None) def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: """ - Construct a ViewAxes, which subclasses, or wraps a specific Axes subclass. - A ViewAxes can be configured to display the contents of another Axes + Construct a View axes, which subclasses, or wraps a specific Axes subclass. + A View axes can be configured to display the contents of another Axes within the same Figure. Parameters @@ -97,9 +108,9 @@ def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: Returns ------- - ViewAxes: - The view axes wrapper for a given axes class, capable of display - other axes contents... + View[axes_class]: + The view axes wrapper for a given axes class, capable of displaying + another axes contents... """ @docstring.interpd class View(axes_class): @@ -155,7 +166,7 @@ def __init__( Returns ------- - ViewAxes + View The new zoom view axes instance... """ super().__init__(axes_to_view.figure, *args, **kwargs) @@ -240,6 +251,88 @@ def draw(self, renderer: RendererBase = None): self.__renderer = None self.figure._current_render_depth -= 1 + def get_axes_to_view(self) -> Axes: + """ + Get the axes this view will display. + + Returns + ------- + Axes + The axes being viewed. + """ + return self.__view_axes + + def set_axes_to_view(self, ax: Axes): + """ + Set the axes this view will display. + + Parameters + ---------- + ax: Axes + The new axes to be viewed. + """ + self.__view_axes = ax + + def get_image_interpolation(self) -> str: + """ + Get the current image interpolation used for rendering views of + images. Supported options are 'antialiased', 'nearest', 'bilinear', + 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', + 'hermite', 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', + 'mitchell', 'sinc', 'lanczos', or 'none'. The default value is + 'nearest'. + + Returns + ------- + string + The current image interpolation used when rendering views of + images in this view axes. + """ + return self.__image_interpolation + + def set_image_interpolation(self, val: str): + """ + Set the current image interpolation used for rendering views of + images. Supported options are 'antialiased', 'nearest', 'bilinear', + 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', + 'hermite', 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', + 'mitchell', 'sinc', 'lanczos', or 'none'. The default value is + 'nearest'. + + Parameters + ---------- + val: string + A new image interpolation mode. + """ + self.__image_interpolation = val + + def get_max_render_depth(self) -> int: + """ + Get the max recursive rendering depth for this view axes. + + Returns + ------- + int + A positive non-zero integer, the number of recursive redraws + this view axes will allow. + """ + return self.__max_render_depth + + def set_max_render_depth(self, val: int): + """ + Set the max recursive rendering depth for this view axes. + + Parameters + ---------- + val: int + The number of recursive draws of views this view axes will + allow. Zero and negative values are invalid, and will raise a + ValueError. + """ + if(val <= 0): + raise ValueError(f"Render depth must be positive, not {val}.") + self.__max_render_depth = val + def get_linescaling(self) -> bool: """ Get if line width scaling is enabled. @@ -264,6 +357,32 @@ def set_linescaling(self, value: bool): """ self.__scale_lines = value + def get_filter_function(self) -> Optional[Callable[[Artist], bool]]: + """ + Get the current artist filtering function. + + Returns + ------- + function, optional + The filter function, which accepts an artist and returns true + if it should be drawn, otherwise false. Can also be none, + meaning all artists should be drawn from the other axes. + """ + return self.__filter_function + + def set_filter_function(self, f: Optional[Callable[[Artist], bool]]): + """ + Set the artist filtering function. + + Returns + ------- + f: function, optional + A filter function, which accepts an artist and returns true + if it should be drawn, otherwise false. Can also be set to + None, meaning all artists should be drawn from the other axes. + """ + self.__filter_function = f + def __reduce__(self): builder, args = super().__reduce__()[:2] @@ -297,6 +416,56 @@ def from_axes( render_depth: int = DEFAULT_RENDER_DEPTH, filter_function: Optional[Callable[[Artist], bool]] = None ) -> Axes: + """ + Convert an Axes into a View in-place. This is used by public + APIs to construct views, and using this method directly + is not recommended. Instead use `view` which resolves types + automatically. + + Parameters + ---------- + axes: Axes + The axes to convert to a view wrapping the same axes type. + + axes_to_view: `~.axes.Axes` + The axes to create a view of. + + image_interpolation: string + Supported options are 'antialiased', 'nearest', 'bilinear', + 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', + 'hermite', 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', + 'mitchell', 'sinc', 'lanczos', or 'none'. The default value is + 'nearest'. This determines the interpolation used when + attempting to render a view of an image. + + render_depth: int, positive, defaults to 10 + The number of recursive draws allowed for this view, this can + happen if the view is a child of the axes (such as an inset + axes) or if two views point at each other. Defaults to 10. + + filter_function: callable(Artist) -> bool or None + An optional filter function, which can be used to select what + artists are drawn by the view. If the function returns True, + the element is drawn, otherwise it isn't. Defaults to None, + or drawing all artists. + + Returns + ------- + View + The same axes passed in, which is now a View type which wraps + the axes original type (View[axes_original_class]). + + Raises + ------ + TypeError + If the provided axes to convert has an Axes type which does + not match the axes class this view type wraps.ss + """ + if(type(axes) != axes_class): + raise TypeError( + f"Can't convert {type(axes).__name__} to {cls.__name__}" + ) + axes.__class__ = cls axes._init_vars( axes_to_view, image_interpolation, From 2a313fcc59e5a0f618dabd8ed40bcefdcb507002 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sun, 6 Feb 2022 00:10:51 -0700 Subject: [PATCH 20/78] Testing for getters and setters. --- matplotview/tests/test_view_obj.py | 46 ++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py index c6fca4d..dbe1aab 100644 --- a/matplotview/tests/test_view_obj.py +++ b/matplotview/tests/test_view_obj.py @@ -1,6 +1,7 @@ import matplotlib.pyplot as plt +from matplotlib.testing.decorators import check_figures_equal from matplotview.tests.utils import plotting_test, matches_post_pickle -from matplotview import view, view_wrapper, inset_zoom_axes +from matplotview import view, view_wrapper, inset_zoom_axes, DEFAULT_RENDER_DEPTH import numpy as np @@ -16,6 +17,48 @@ def test_obj_comparison(): assert view_class2 != view_class3 +@check_figures_equal(tol=5.6) +def test_getters_and_setters(fig_test, fig_ref): + np.random.seed(1) + im_data1 = np.random.rand(30, 30) + im_data2 = np.random.rand(20, 20) + + ax1, ax2, ax3 = fig_test.subplots(1, 3) + ax1.imshow(im_data1, origin="lower", interpolation="nearest") + ax2.imshow(im_data2, origin="lower", interpolation="nearest") + ax2.plot([i for i in range(10)]) + line = ax2.plot([i for i in range(10, 0, -1)])[0] + view(ax3, ax1) + ax3.set_xlim(0, 30) + ax3.set_ylim(0, 30) + ax3.set_aspect(1) + + # Assert all getters return default or set values... + assert ax3.get_axes_to_view() is ax1 + assert ax3.get_image_interpolation() == "nearest" + assert ax3.get_max_render_depth() == DEFAULT_RENDER_DEPTH + assert ax3.get_linescaling() == True + assert ax3.get_filter_function() is None + + # Attempt setting to different values... + ax3.set_axes_to_view(ax2) + # If this doesn't change pdf backend gets error > 5.6.... + ax3.set_image_interpolation("bicubic") + ax3.set_max_render_depth(10) + ax3.set_linescaling(False) + ax3.set_filter_function(lambda a: a != line) + + # Compare against new thing... + ax1, ax2, ax3 = fig_ref.subplots(1, 3) + ax1.imshow(im_data1, origin="lower", interpolation="nearest") + ax2.imshow(im_data2, origin="lower", interpolation="nearest") + ax2.plot([i for i in range(10)]) + ax2.plot([i for i in range(10, 0, -1)]) + ax3.imshow(im_data2, origin="lower", interpolation="nearest") + ax3.plot([i for i in range(10)]) + ax3.set_xlim(0, 30) + ax3.set_ylim(0, 30) + @plotting_test() def test_subplot_view_pickle(fig_test): np.random.seed(1) @@ -60,7 +103,6 @@ def test_zoom_plot_pickle(fig_test): assert matches_post_pickle(fig_test) - @plotting_test() def test_3d_view_pickle(fig_test): X = Y = np.arange(-5, 5, 0.25) From 33e9c9cc0cc1a0d5aadda1bcd3fdc2e456f1b4ba Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sun, 6 Feb 2022 00:14:59 -0700 Subject: [PATCH 21/78] Update Version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2d31b5f..610b9b4 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import setuptools -VERSION = "0.0.5" +VERSION = "0.1.0" with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() From b32a7f8e0513cdf3e2c1e809ab8ecb6ea82e5e71 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sun, 6 Feb 2022 01:00:10 -0700 Subject: [PATCH 22/78] Have line scaling code fail gracefully for invalid widths, (fixing geo projections). --- matplotview/_transform_renderer.py | 24 +++++++++++++++--------- matplotview/tests/test_view_rendering.py | 2 +- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index af0bae5..4c5659f 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -85,18 +85,24 @@ def bounding_axes(self): return self.__bounding_axes def _scale_gc(self, gc): - transfer_transform = self._get_transfer_transform(IdentityTransform()) - new_gc = self.__renderer.new_gc() - new_gc.copy_properties(gc) + with np.errstate(all='ignore'): + transfer_transform = self._get_transfer_transform( + IdentityTransform() + ) + new_gc = self.__renderer.new_gc() + new_gc.copy_properties(gc) + + unit_box = Bbox.from_bounds(0, 0, 1, 1) + unit_box = transfer_transform.transform_bbox(unit_box) + mult_factor = np.sqrt(unit_box.width * unit_box.height) - unit_box = Bbox.from_bounds(0, 0, 1, 1) - unit_box = transfer_transform.transform_bbox(unit_box) - mult_factor = np.sqrt(unit_box.width * unit_box.height) + if(mult_factor == 0 or (not np.isfinite(mult_factor))): + return new_gc - new_gc.set_linewidth(gc.get_linewidth() * mult_factor) - new_gc._hatch_linewidth = gc.get_hatch_linewidth() * mult_factor + new_gc.set_linewidth(gc.get_linewidth() * mult_factor) + new_gc._hatch_linewidth = gc.get_hatch_linewidth() * mult_factor - return new_gc + return new_gc def _get_axes_display_box(self): """ diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index 5efd76d..22e921b 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -172,7 +172,7 @@ def test_map_projection_view(fig_test, fig_ref): ax_t1.plot(x, y) ax_t1.add_patch(circ_gen()) view(ax_t2, ax_t1) - ax_t2.set_linescaling(False) + #ax_t2.set_linescaling(False) # Reference... ax_r1 = fig_ref.add_subplot(1, 2, 1, projection="hammer") From 7f69ae195b751527adb138385410d3035b56e98b Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sun, 6 Feb 2022 16:28:49 -0700 Subject: [PATCH 23/78] Version push... --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 610b9b4..3989970 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import setuptools -VERSION = "0.1.0" +VERSION = "0.1.1" with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() From 058ae26dacff0fc8da294e8be6c5f26ddba9dc00 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Thu, 24 Feb 2022 22:37:11 -0700 Subject: [PATCH 24/78] Add support for views for multiple axes. --- matplotview/__init__.py | 45 ++-- matplotview/_view_axes.py | 296 +++++++---------------- matplotview/tests/test_view_obj.py | 25 +- matplotview/tests/test_view_rendering.py | 12 +- 4 files changed, 131 insertions(+), 247 deletions(-) diff --git a/matplotview/__init__.py b/matplotview/__init__.py index c85415b..23ab3b2 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -1,14 +1,17 @@ -from typing import Callable, Optional, Iterable +from typing import Optional, Iterable, Type, Union from matplotlib.artist import Artist from matplotlib.axes import Axes -from matplotview._view_axes import view_wrapper, DEFAULT_RENDER_DEPTH +from matplotview._view_axes import view_wrapper, ViewSpecification, DEFAULT_RENDER_DEPTH + +__all__ = ["view", "inset_zoom_axes"] def view( axes: Axes, axes_to_view: Axes, image_interpolation: str = "nearest", render_depth: int = DEFAULT_RENDER_DEPTH, - filter_function: Optional[Callable[[Artist], bool]] = None + filter_set: Optional[Iterable[Union[Type[Artist], Artist]]] = None, + scale_lines: bool = True ) -> Axes: """ Convert an axes into a view of another axes, displaying the contents of @@ -36,15 +39,21 @@ def view( if the view is a child of the axes (such as an inset axes) or if two views point at each other. Defaults to 5. - filter_function: callable(Artist) -> bool or None - An optional filter function, which can be used to select what artists - are drawn by the view. If the function returns True, the element is - drawn, otherwise it isn't. Defaults to None, or drawing all artists. + filter_set: Iterable[Union[Type[Artist], Artist]] or None + An optional filter set, which can be used to select what artists + are drawn by the view. Any artists types in the set are not drawn. + + scale_lines: bool, defaults to True + Specifies if lines should be drawn thicker based on scaling in the + view. """ - return view_wrapper(type(axes)).from_axes( - axes, axes_to_view, image_interpolation, - render_depth, filter_function + view_obj = view_wrapper(type(axes)).from_axes(axes, render_depth) + view_obj.view_specifications[axes_to_view] = ViewSpecification( + image_interpolation, + filter_set, + scale_lines ) + return view_obj def inset_zoom_axes( @@ -53,7 +62,8 @@ def inset_zoom_axes( *, image_interpolation="nearest", render_depth: int = DEFAULT_RENDER_DEPTH, - filter_function: Optional[Callable[[Artist], bool]] = None, + filter_set: Optional[Iterable[Union[Type[Artist], Artist]]] = None, + scale_lines: bool = True, transform=None, zorder=5, **kwargs @@ -92,10 +102,13 @@ def inset_zoom_axes( if the view is a child of the axes (such as an inset axes) or if two views point at each other. Defaults to 5. - filter_function: callable(Artist) -> bool or None - An optional filter function, which can be used to select what artists - are drawn by the view. If the function returns True, the element is - drawn, otherwise it isn't. Defaults to None, or drawing all artists. + filter_set: Iterable[Union[Type[Artist], Artist]] or None + An optional filter set, which can be used to select what artists + are drawn by the view. Any artists types in the set are not drawn. + + scale_lines: bool, defaults to True + Specifies if lines should be drawn thicker based on scaling in the + view. **kwargs Other keyword arguments are passed on to the child `.Axes`. @@ -114,5 +127,5 @@ def inset_zoom_axes( ) return view( inset_ax, axes, image_interpolation, - render_depth, filter_function + render_depth, filter_set, scale_lines ) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index d02ffe5..00ac74d 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -1,12 +1,13 @@ import functools import itertools -from typing import Type, List, Optional, Callable, Any +from typing import Type, List, Optional, Callable, Any, Set, Dict, Union from matplotlib.axes import Axes from matplotlib.transforms import Bbox import matplotlib.docstring as docstring from matplotview._transform_renderer import _TransformRenderer from matplotlib.artist import Artist from matplotlib.backend_bases import RendererBase +from dataclasses import dataclass DEFAULT_RENDER_DEPTH = 5 @@ -92,6 +93,20 @@ def _view_from_pickle(builder, args): res.__class__ = view_wrapper(type(res)) return res + +@dataclass +class ViewSpecification: + image_interpolation: str = "nearest" + filter_set: Optional[Set[Union[Type[Artist], Artist]]] = None + scale_lines: bool = True + + def __post_init__(self): + self.image_interpolation = str(self.image_interpolation) + if(self.filter_set is not None): + self.filter_set = set(self.filter_set) + self.scale_lines = bool(self.scale_lines) + + # Cache classes so grabbing the same type twice leads to actually getting the # same type (and type comparisons work). @functools.lru_cache(None) @@ -120,11 +135,8 @@ class View(axes_class): """ def __init__( self, - axes_to_view: Axes, *args, - image_interpolation: str = "nearest", render_depth: int = DEFAULT_RENDER_DEPTH, - filter_function: Optional[Callable[[Artist], bool]] = None, **kwargs ): """ @@ -139,25 +151,11 @@ def __init__( Additional arguments to be passed to the Axes class this ViewAxes wraps. - image_interpolation: string - Supported options are 'antialiased', 'nearest', 'bilinear', - 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', - 'hermite', 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', - 'mitchell', 'sinc', 'lanczos', or 'none'. The default value is - 'nearest'. This determines the interpolation used when - attempting to render a view of an image. - render_depth: int, positive, defaults to 10 The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if two views point at each other. Defaults to 10. - filter_function: callable(Artist) -> bool or None - An optional filter function, which can be used to select what - artists are drawn by the view. If the function returns True, - the element is drawn, otherwise it isn't. Defaults to None, - or drawing all artists. - **kwargs Other optional keyword arguments supported by the Axes constructor this ViewAxes wraps: @@ -169,70 +167,55 @@ def __init__( View The new zoom view axes instance... """ - super().__init__(axes_to_view.figure, *args, **kwargs) - self._init_vars( - axes_to_view, image_interpolation, - render_depth, filter_function - ) + super().__init__(*args, **kwargs) + self._init_vars(render_depth) - def _init_vars( - self, - axes_to_view: Axes, - image_interpolation: str, - render_depth: int, - filter_function: Optional[Callable[[Artist], bool]] - ): - if(render_depth < 1): - raise ValueError(f"Render depth of {render_depth} is invalid.") - if(filter_function is not None and not callable(filter_function)): - raise ValueError( - f"The filter function must be a callable or None!" - ) - - self.__view_axes = axes_to_view + def _init_vars(self, render_depth: int = DEFAULT_RENDER_DEPTH): + # Initialize the view specs set... + self.__view_specs = {} + self.__renderer = None + self.__max_render_depth = DEFAULT_RENDER_DEPTH + self.set_max_render_depth(render_depth) # The current render depth is stored in the figure, so the number # of recursive draws is even in the case of multiple axes drawing # each other in the same figure. self.figure._current_render_depth = getattr( self.figure, "_current_render_depth", 0 ) - self.__image_interpolation = image_interpolation - self.__max_render_depth = render_depth - self.__filter_function = filter_function - self.__scale_lines = True - self.__renderer = None def get_children(self) -> List[Artist]: # We overload get_children to return artists from the view axes # in addition to this axes when drawing. We wrap the artists # in a BoundRendererArtist, so they are drawn with an alternate # renderer, and therefore to the correct location. - if(self.__renderer is not None): - mock_renderer = _TransformRenderer( - self.__renderer, self.__view_axes.transData, - self.transData, self, self.__image_interpolation, - self.__scale_lines - ) - - x1, x2 = self.get_xlim() - y1, y2 = self.get_ylim() - axes_box = Bbox.from_extents(x1, y1, x2, y2).transformed( - self.__view_axes.transData - ) + child_list = super().get_children() - init_list = super().get_children() - init_list.extend([ - _BoundRendererArtist(a, mock_renderer, axes_box) - for a in itertools.chain( - self.__view_axes._children, - self.__view_axes.child_axes - ) if(self.__filter_function is None - or self.__filter_function(a)) - ]) - - return init_list - else: - return super().get_children() + if(self.__renderer is not None): + for ax, spec in self.view_specifications.items(): + mock_renderer = _TransformRenderer( + self.__renderer, ax.transData, self.transData, + self, spec.image_interpolation, spec.scale_lines + ) + + x1, x2 = self.get_xlim() + y1, y2 = self.get_ylim() + axes_box = Bbox.from_extents(x1, y1, x2, y2).transformed( + ax.transData + ) + + child_list.extend([ + _BoundRendererArtist(a, mock_renderer, axes_box) + for a in itertools.chain( + ax._children, + ax.child_axes + ) if( + (spec.filter_set is None) + or ((a not in spec.filter_set) + and (type(a) not in spec.filter_set)) + ) + ]) + + return child_list def draw(self, renderer: RendererBase = None): # It is possible to have two axes which are views of each other @@ -251,60 +234,27 @@ def draw(self, renderer: RendererBase = None): self.__renderer = None self.figure._current_render_depth -= 1 - def get_axes_to_view(self) -> Axes: - """ - Get the axes this view will display. - - Returns - ------- - Axes - The axes being viewed. - """ - return self.__view_axes - - def set_axes_to_view(self, ax: Axes): - """ - Set the axes this view will display. - - Parameters - ---------- - ax: Axes - The new axes to be viewed. - """ - self.__view_axes = ax + def __reduce__(self): + builder, args = super().__reduce__()[:2] - def get_image_interpolation(self) -> str: - """ - Get the current image interpolation used for rendering views of - images. Supported options are 'antialiased', 'nearest', 'bilinear', - 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', - 'hermite', 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', - 'mitchell', 'sinc', 'lanczos', or 'none'. The default value is - 'nearest'. + if(self.__new__ == builder): + builder = super().__new__() - Returns - ------- - string - The current image interpolation used when rendering views of - images in this view axes. - """ - return self.__image_interpolation + cls = type(self) + args = tuple( + arg if(arg != cls) else cls.__bases__[0] for arg in args + ) - def set_image_interpolation(self, val: str): - """ - Set the current image interpolation used for rendering views of - images. Supported options are 'antialiased', 'nearest', 'bilinear', - 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', - 'hermite', 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', - 'mitchell', 'sinc', 'lanczos', or 'none'. The default value is - 'nearest'. + return ( + _view_from_pickle, + (builder, args), + self.__getstate__() + ) - Parameters - ---------- - val: string - A new image interpolation mode. - """ - self.__image_interpolation = val + def __getstate__(self): + state = super().__getstate__() + state["__renderer"] = None + return state def get_max_render_depth(self) -> int: """ @@ -333,122 +283,42 @@ def set_max_render_depth(self, val: int): raise ValueError(f"Render depth must be positive, not {val}.") self.__max_render_depth = val - def get_linescaling(self) -> bool: - """ - Get if line width scaling is enabled. - - Returns - ------- - bool - If line width scaling is enabled returns True, otherwise False. - """ - return self.__scale_lines - - def set_linescaling(self, value: bool): - """ - Set whether line widths should be scaled when rendering a view of - an axes. - - Parameters - ---------- - value: bool - If true, scale line widths in the view to match zoom level. - Otherwise don't. - """ - self.__scale_lines = value - - def get_filter_function(self) -> Optional[Callable[[Artist], bool]]: + @property + def view_specifications(self) -> Dict[Axes, ViewSpecification]: """ - Get the current artist filtering function. + Get the current view specifications of this view axes. Returns ------- - function, optional - The filter function, which accepts an artist and returns true - if it should be drawn, otherwise false. Can also be none, - meaning all artists should be drawn from the other axes. - """ - return self.__filter_function - - def set_filter_function(self, f: Optional[Callable[[Artist], bool]]): + Dict[Axes, ViewSpecification] + A dictionary of Axes to ViewSpecification objects, listing + all the axes this view looks at and the settings for each + viewing. """ - Set the artist filtering function. - - Returns - ------- - f: function, optional - A filter function, which accepts an artist and returns true - if it should be drawn, otherwise false. Can also be set to - None, meaning all artists should be drawn from the other axes. - """ - self.__filter_function = f - - def __reduce__(self): - builder, args = super().__reduce__()[:2] - - if(self.__new__ == builder): - builder = super().__new__() - - cls = type(self) - args = tuple( - arg if(arg != cls) else cls.__bases__[0] for arg in args - ) - - return ( - _view_from_pickle, - (builder, args), - self.__getstate__() - ) - - def __getstate__(self): - state = super().__getstate__() - state["__renderer"] = None - # We don't support pickling the filter... - state["__filter_function"] = None - return state + return self.__view_specs @classmethod def from_axes( cls, axes: Axes, - axes_to_view: Axes, - image_interpolation: str = "nearest", - render_depth: int = DEFAULT_RENDER_DEPTH, - filter_function: Optional[Callable[[Artist], bool]] = None + render_depth: int = DEFAULT_RENDER_DEPTH ) -> Axes: """ Convert an Axes into a View in-place. This is used by public APIs to construct views, and using this method directly - is not recommended. Instead use `view` which resolves types - automatically. + is not recommended. Instead, use `view` which resolves types + and settings automatically. Parameters ---------- axes: Axes The axes to convert to a view wrapping the same axes type. - axes_to_view: `~.axes.Axes` - The axes to create a view of. - - image_interpolation: string - Supported options are 'antialiased', 'nearest', 'bilinear', - 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', - 'hermite', 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', - 'mitchell', 'sinc', 'lanczos', or 'none'. The default value is - 'nearest'. This determines the interpolation used when - attempting to render a view of an image. - render_depth: int, positive, defaults to 10 The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if two views point at each other. Defaults to 10. - filter_function: callable(Artist) -> bool or None - An optional filter function, which can be used to select what - artists are drawn by the view. If the function returns True, - the element is drawn, otherwise it isn't. Defaults to None, - or drawing all artists. - Returns ------- View @@ -466,11 +336,11 @@ def from_axes( f"Can't convert {type(axes).__name__} to {cls.__name__}" ) + if(isinstance(axes, cls)): + return axes + axes.__class__ = cls - axes._init_vars( - axes_to_view, image_interpolation, - render_depth, filter_function - ) + axes._init_vars(render_depth) return axes View.__name__ = f"{View.__name__}[{axes_class.__name__}]" diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py index dbe1aab..54480cf 100644 --- a/matplotview/tests/test_view_obj.py +++ b/matplotview/tests/test_view_obj.py @@ -1,7 +1,8 @@ import matplotlib.pyplot as plt from matplotlib.testing.decorators import check_figures_equal from matplotview.tests.utils import plotting_test, matches_post_pickle -from matplotview import view, view_wrapper, inset_zoom_axes, DEFAULT_RENDER_DEPTH +from matplotview import view, view_wrapper, inset_zoom_axes, \ + DEFAULT_RENDER_DEPTH, ViewSpecification import numpy as np @@ -34,19 +35,21 @@ def test_getters_and_setters(fig_test, fig_ref): ax3.set_aspect(1) # Assert all getters return default or set values... - assert ax3.get_axes_to_view() is ax1 - assert ax3.get_image_interpolation() == "nearest" + assert ax1 in ax3.view_specifications + assert ax3.view_specifications[ax1].image_interpolation == "nearest" assert ax3.get_max_render_depth() == DEFAULT_RENDER_DEPTH - assert ax3.get_linescaling() == True - assert ax3.get_filter_function() is None + assert ax3.view_specifications[ax1].scale_lines == True + assert ax3.view_specifications[ax1].filter_set is None # Attempt setting to different values... - ax3.set_axes_to_view(ax2) + del ax3.view_specifications[ax1] # If this doesn't change pdf backend gets error > 5.6.... - ax3.set_image_interpolation("bicubic") + ax3.view_specifications[ax2] = ViewSpecification( + "bicubic", + {line}, + False + ) ax3.set_max_render_depth(10) - ax3.set_linescaling(False) - ax3.set_filter_function(lambda a: a != line) # Compare against new thing... ax1, ax2, ax3 = fig_ref.subplots(1, 3) @@ -91,8 +94,8 @@ def test_zoom_plot_pickle(fig_test): ax_test.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) ax_test.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, interpolation="nearest") - axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48]) - axins_test.set_linescaling(False) + axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48], + scale_lines=False) axins_test.set_xlim(1, 5) axins_test.set_ylim(1, 5) axins_test.annotate( diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index 22e921b..e40cde4 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -49,8 +49,8 @@ def test_auto_zoom_inset(fig_test, fig_ref): ax_test.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) ax_test.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, interpolation="nearest") - axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48]) - axins_test.set_linescaling(False) + axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48], + scale_lines=False) axins_test.set_xlim(1, 5) axins_test.set_ylim(1, 5) ax_test.indicate_inset_zoom(axins_test, edgecolor="black") @@ -81,8 +81,8 @@ def test_plotting_in_view(fig_test, fig_ref): ax_test = fig_test.gca() ax_test.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, interpolation="nearest") - axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48]) - axins_test.set_linescaling(False) + axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48], + scale_lines=False) axins_test.set_xlim(1, 5) axins_test.set_ylim(1, 5) axins_test.annotate( @@ -146,8 +146,7 @@ def test_polar_view(fig_test, fig_ref): ax_t1, ax_t2 = fig_test.subplots(1, 2, subplot_kw=dict(projection="polar")) ax_t1.plot(theta, r) ax_t1.set_rmax(2) - view(ax_t2, ax_t1) - ax_t2.set_linescaling(False) + view(ax_t2, ax_t1, scale_lines=False) ax_t2.set_rmax(1) # Reference... @@ -172,7 +171,6 @@ def test_map_projection_view(fig_test, fig_ref): ax_t1.plot(x, y) ax_t1.add_patch(circ_gen()) view(ax_t2, ax_t1) - #ax_t2.set_linescaling(False) # Reference... ax_r1 = fig_ref.add_subplot(1, 2, 1, projection="hammer") From e68ce6df48bd8c03b853607432ed716cd247b70c Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Thu, 24 Feb 2022 23:36:29 -0700 Subject: [PATCH 25/78] Multiview support now fully working, new test for it. --- matplotview/__init__.py | 27 +++++++------ matplotview/_view_axes.py | 49 +++++++++++++++++------- matplotview/tests/test_view_rendering.py | 35 +++++++++++++++++ 3 files changed, 86 insertions(+), 25 deletions(-) diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 23ab3b2..644933e 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -3,13 +3,13 @@ from matplotlib.axes import Axes from matplotview._view_axes import view_wrapper, ViewSpecification, DEFAULT_RENDER_DEPTH -__all__ = ["view", "inset_zoom_axes"] +__all__ = ["view", "inset_zoom_axes", "ViewSpecification"] def view( axes: Axes, axes_to_view: Axes, image_interpolation: str = "nearest", - render_depth: int = DEFAULT_RENDER_DEPTH, + render_depth: Optional[int] = None, filter_set: Optional[Iterable[Union[Type[Artist], Artist]]] = None, scale_lines: bool = True ) -> Axes: @@ -34,14 +34,17 @@ def view( 'catrom', 'gaussian', 'bessel', 'mitchell', 'sinc', 'lanczos', or 'none' - render_depth: int, positive, defaults to 5 + render_depth: optional int, positive, defaults to None The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if - two views point at each other. Defaults to 5. + two views point at each other. If None, uses the default render depth + of 5, unless the axes passed is already a view axes, in which case the + render depth the view already has will be used. filter_set: Iterable[Union[Type[Artist], Artist]] or None An optional filter set, which can be used to select what artists - are drawn by the view. Any artists types in the set are not drawn. + are drawn by the view. Any artists or artist types in the set are not + drawn. scale_lines: bool, defaults to True Specifies if lines should be drawn thicker based on scaling in the @@ -60,12 +63,12 @@ def inset_zoom_axes( axes: Axes, bounds: Iterable, *, - image_interpolation="nearest", - render_depth: int = DEFAULT_RENDER_DEPTH, + image_interpolation: str = "nearest", + render_depth: Optional[int] = None, filter_set: Optional[Iterable[Union[Type[Artist], Artist]]] = None, scale_lines: bool = True, - transform=None, - zorder=5, + transform = None, + zorder: int = 5, **kwargs ) -> Axes: """ @@ -97,10 +100,12 @@ def inset_zoom_axes( determines the interpolation used when attempting to render a zoomed version of an image. - render_depth: int, positive, defaults to 5 + render_depth: optional int, positive, defaults to None The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if - two views point at each other. Defaults to 5. + two views point at each other. If None, uses the default render depth + of 5, unless the axes passed is already a view axes, in which case the + render depth the view already has will be used. filter_set: Iterable[Union[Type[Artist], Artist]] or None An optional filter set, which can be used to select what artists diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 00ac74d..e8a2224 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -106,6 +106,12 @@ def __post_init__(self): self.filter_set = set(self.filter_set) self.scale_lines = bool(self.scale_lines) +class __ViewType: + """ + PRIVATE: A simple identifier class for identifying view types, a view + will inherit from the axes class it is wrapping and this type... + """ + ... # Cache classes so grabbing the same type twice leads to actually getting the # same type (and type comparisons work). @@ -127,8 +133,11 @@ def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: The view axes wrapper for a given axes class, capable of displaying another axes contents... """ + if(issubclass(axes_class, Axes) and issubclass(axes_class, __ViewType)): + return axes_class + @docstring.interpd - class View(axes_class): + class View(axes_class, __ViewType): """ An axes which automatically displays elements of another axes. Does not require Artists to be plotted twice. @@ -151,10 +160,10 @@ def __init__( Additional arguments to be passed to the Axes class this ViewAxes wraps. - render_depth: int, positive, defaults to 10 + render_depth: int, positive, defaults to 5 The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset - axes) or if two views point at each other. Defaults to 10. + axes) or if two views point at each other. Defaults to 5. **kwargs Other optional keyword arguments supported by the Axes @@ -171,10 +180,12 @@ def __init__( self._init_vars(render_depth) def _init_vars(self, render_depth: int = DEFAULT_RENDER_DEPTH): - # Initialize the view specs set... - self.__view_specs = {} + # Initialize the view specs dict... + self.__view_specs = getattr(self, "__view_specs", {}) self.__renderer = None - self.__max_render_depth = DEFAULT_RENDER_DEPTH + self.__max_render_depth = getattr( + self, "__max_render_depth", DEFAULT_RENDER_DEPTH + ) self.set_max_render_depth(render_depth) # The current render depth is stored in the figure, so the number # of recursive draws is even in the case of multiple axes drawing @@ -297,11 +308,14 @@ def view_specifications(self) -> Dict[Axes, ViewSpecification]: """ return self.__view_specs + # Shortcut for easier access... + view_specs = view_specifications + @classmethod def from_axes( cls, axes: Axes, - render_depth: int = DEFAULT_RENDER_DEPTH + render_depth: Optional[int] = None ) -> Axes: """ Convert an Axes into a View in-place. This is used by public @@ -314,10 +328,11 @@ def from_axes( axes: Axes The axes to convert to a view wrapping the same axes type. - render_depth: int, positive, defaults to 10 + render_depth: optional int, positive, defaults to None The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset - axes) or if two views point at each other. Defaults to 10. + axes) or if two views point at each other. If none, use the + default value (5) if the render depth is not already set. Returns ------- @@ -329,18 +344,24 @@ def from_axes( ------ TypeError If the provided axes to convert has an Axes type which does - not match the axes class this view type wraps.ss + not match the axes class this view type wraps. """ + if(isinstance(axes, cls)): + if(render_depth is not None): + axes.set_max_render_depth(render_depth) + return axes + if(type(axes) != axes_class): raise TypeError( f"Can't convert {type(axes).__name__} to {cls.__name__}" ) - if(isinstance(axes, cls)): - return axes - axes.__class__ = cls - axes._init_vars(render_depth) + axes._init_vars( + DEFAULT_RENDER_DEPTH + if(render_depth is None) + else render_depth + ) return axes View.__name__ = f"{View.__name__}[{axes_class.__name__}]" diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index e40cde4..bff6c6d 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -181,3 +181,38 @@ def test_map_projection_view(fig_test, fig_ref): ax_r1.add_patch(circ_gen()) ax_r2.plot(x, y) ax_r2.add_patch(circ_gen()) + + +@check_figures_equal() +def test_double_view(fig_test, fig_ref): + # Test case... + ax_test1, ax_test2, ax_test3 = fig_test.subplots(1, 3) + + ax_test1.add_patch(plt.Circle((1, 1), 1.5, ec="black", fc=(0, 0, 1, 0.5))) + ax_test3.add_patch(plt.Circle((3, 1), 1.5, ec="black", fc=(1, 0, 0, 0.5))) + + ax_test2 = view( + view(ax_test2, ax_test1, scale_lines=False), + ax_test3, scale_lines=False + ) + + ax_test2.set_aspect(1) + ax_test2.set_xlim(-0.5, 4.5) + ax_test2.set_ylim(-0.5, 2.5) + + # Reference... + ax_ref1, ax_ref2, ax_ref3 = fig_ref.subplots(1, 3) + + ax_ref1.add_patch(plt.Circle((1, 1), 1.5, ec="black", fc=(0, 0, 1, 0.5))) + ax_ref3.add_patch(plt.Circle((3, 1), 1.5, ec="black", fc=(1, 0, 0, 0.5))) + + ax_ref2.add_patch(plt.Circle((1, 1), 1.5, ec="black", fc=(0, 0, 1, 0.5))) + ax_ref2.add_patch(plt.Circle((3, 1), 1.5, ec="black", fc=(1, 0, 0, 0.5))) + ax_ref2.set_aspect(1) + ax_ref2.set_xlim(-0.5, 4.5) + ax_ref2.set_ylim(-0.5, 2.5) + + for ax in (ax_test1, ax_test3, ax_ref1, ax_ref3): + ax.set_aspect(1) + ax.relim() + ax.autoscale_view() \ No newline at end of file From 3c5acf760c8258de0d0d0d1fb66b8d834bcf13fe Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sun, 27 Feb 2022 13:28:43 -0700 Subject: [PATCH 26/78] PEP-8 compliance and more tests for multi-view. --- matplotview/__init__.py | 6 ++-- matplotview/_transform_renderer.py | 3 +- matplotview/_view_axes.py | 22 +++++++++----- matplotview/tests/test_view_obj.py | 37 ++++++++++++++++++++---- matplotview/tests/test_view_rendering.py | 9 ++++-- matplotview/tests/utils.py | 7 +---- 6 files changed, 59 insertions(+), 25 deletions(-) diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 644933e..9af8cb7 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -1,10 +1,12 @@ from typing import Optional, Iterable, Type, Union from matplotlib.artist import Artist from matplotlib.axes import Axes -from matplotview._view_axes import view_wrapper, ViewSpecification, DEFAULT_RENDER_DEPTH +from matplotview._view_axes import view_wrapper, ViewSpecification + __all__ = ["view", "inset_zoom_axes", "ViewSpecification"] + def view( axes: Axes, axes_to_view: Axes, @@ -67,7 +69,7 @@ def inset_zoom_axes( render_depth: Optional[int] = None, filter_set: Optional[Iterable[Union[Type[Artist], Artist]]] = None, scale_lines: bool = True, - transform = None, + transform=None, zorder: int = 5, **kwargs ) -> Axes: diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index 4c5659f..59ad786 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -234,7 +234,7 @@ def draw_gouraud_triangle(self, gc, points, colors, transform): IdentityTransform()) # Images prove to be especially messy to deal with... - def draw_image(self, gc, x, y, im, transform = None): + def draw_image(self, gc, x, y, im, transform=None): mag = self.get_image_magnification() shift_data_transform = self._get_transfer_transform( IdentityTransform() @@ -292,4 +292,3 @@ def draw_image(self, gc, x, y, im, transform = None): self.__renderer.draw_image(gc, x, y, out_arr, None) else: self.__renderer.draw_image(gc, x, y, out_arr) - diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index e8a2224..8d82963 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -1,6 +1,6 @@ import functools import itertools -from typing import Type, List, Optional, Callable, Any, Set, Dict, Union +from typing import Type, List, Optional, Any, Set, Dict, Union from matplotlib.axes import Axes from matplotlib.transforms import Bbox import matplotlib.docstring as docstring @@ -11,6 +11,7 @@ DEFAULT_RENDER_DEPTH = 5 + class _BoundRendererArtist: """ Provides a temporary wrapper around a given artist, inheriting its @@ -85,6 +86,7 @@ def do_3d_projection(self) -> float: return res + def _view_from_pickle(builder, args): """ PRIVATE: Construct a View wrapper axes given an axes builder and class. @@ -106,6 +108,7 @@ def __post_init__(self): self.filter_set = set(self.filter_set) self.scale_lines = bool(self.scale_lines) + class __ViewType: """ PRIVATE: A simple identifier class for identifying view types, a view @@ -113,6 +116,7 @@ class __ViewType: """ ... + # Cache classes so grabbing the same type twice leads to actually getting the # same type (and type comparisons work). @functools.lru_cache(None) @@ -201,6 +205,14 @@ def get_children(self) -> List[Artist]: # renderer, and therefore to the correct location. child_list = super().get_children() + def filter_check(artist, filter_set): + if(filter_set is None): + return True + return ( + (artist not in filter_set) + and (type(artist) not in filter_set) + ) + if(self.__renderer is not None): for ax, spec in self.view_specifications.items(): mock_renderer = _TransformRenderer( @@ -219,11 +231,7 @@ def get_children(self) -> List[Artist]: for a in itertools.chain( ax._children, ax.child_axes - ) if( - (spec.filter_set is None) - or ((a not in spec.filter_set) - and (type(a) not in spec.filter_set)) - ) + ) if(filter_check(a, spec.filter_set)) ]) return child_list @@ -367,4 +375,4 @@ def from_axes( View.__name__ = f"{View.__name__}[{axes_class.__name__}]" View.__qualname__ = f"{View.__qualname__}[{axes_class.__name__}]" - return View \ No newline at end of file + return View diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py index 54480cf..6210e63 100644 --- a/matplotview/tests/test_view_obj.py +++ b/matplotview/tests/test_view_obj.py @@ -1,8 +1,8 @@ import matplotlib.pyplot as plt from matplotlib.testing.decorators import check_figures_equal from matplotview.tests.utils import plotting_test, matches_post_pickle -from matplotview import view, view_wrapper, inset_zoom_axes, \ - DEFAULT_RENDER_DEPTH, ViewSpecification +from matplotview import view, inset_zoom_axes, ViewSpecification +from matplotview._view_axes import DEFAULT_RENDER_DEPTH, view_wrapper import numpy as np @@ -38,7 +38,7 @@ def test_getters_and_setters(fig_test, fig_ref): assert ax1 in ax3.view_specifications assert ax3.view_specifications[ax1].image_interpolation == "nearest" assert ax3.get_max_render_depth() == DEFAULT_RENDER_DEPTH - assert ax3.view_specifications[ax1].scale_lines == True + assert ax3.view_specifications[ax1].scale_lines is True assert ax3.view_specifications[ax1].filter_set is None # Attempt setting to different values... @@ -62,6 +62,7 @@ def test_getters_and_setters(fig_test, fig_ref): ax3.set_xlim(0, 30) ax3.set_ylim(0, 30) + @plotting_test() def test_subplot_view_pickle(fig_test): np.random.seed(1) @@ -74,7 +75,7 @@ def test_subplot_view_pickle(fig_test): ax_test1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) ax_test1.text(10, 10, "Hello World!", size=14) ax_test1.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") + interpolation="nearest") ax_test2 = view(ax_test2, ax_test1) ax_test2.set_aspect(ax_test1.get_aspect()) ax_test2.set_xlim(ax_test1.get_xlim()) @@ -82,6 +83,7 @@ def test_subplot_view_pickle(fig_test): assert matches_post_pickle(fig_test) + @plotting_test() def test_zoom_plot_pickle(fig_test): np.random.seed(1) @@ -106,6 +108,7 @@ def test_zoom_plot_pickle(fig_test): assert matches_post_pickle(fig_test) + @plotting_test() def test_3d_view_pickle(fig_test): X = Y = np.arange(-5, 5, 0.25) @@ -122,4 +125,28 @@ def test_3d_view_pickle(fig_test): ax2_test.set_ylim(-10, 10) ax2_test.set_zlim(-2, 2) - assert matches_post_pickle(fig_test) \ No newline at end of file + assert matches_post_pickle(fig_test) + + +@plotting_test() +def test_multiplot_pickle(fig_test): + ax_test1, ax_test2, ax_test3 = fig_test.subplots(1, 3) + + ax_test1.add_patch(plt.Circle((1, 1), 1.5, ec="black", fc=(0, 0, 1, 0.5))) + ax_test3.add_patch(plt.Circle((3, 1), 1.5, ec="black", fc=(1, 0, 0, 0.5))) + + for ax in (ax_test1, ax_test3): + ax.set_aspect(1) + ax.relim() + ax.autoscale_view() + + ax_test2 = view( + view(ax_test2, ax_test1, scale_lines=False), + ax_test3, scale_lines=False + ) + + ax_test2.set_aspect(1) + ax_test2.set_xlim(-0.5, 4.5) + ax_test2.set_ylim(-0.5, 2.5) + + assert matches_post_pickle(fig_test) diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index bff6c6d..0e3ea66 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -3,6 +3,7 @@ from matplotlib.testing.decorators import check_figures_equal from matplotview import view, inset_zoom_axes + @check_figures_equal(tol=6) def test_double_plot(fig_test, fig_ref): np.random.seed(1) @@ -15,7 +16,7 @@ def test_double_plot(fig_test, fig_ref): ax_test1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) ax_test1.text(10, 10, "Hello World!", size=14) ax_test1.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") + interpolation="nearest") ax_test2 = view(ax_test2, ax_test1) ax_test2.set_aspect(ax_test1.get_aspect()) ax_test2.set_xlim(ax_test1.get_xlim()) @@ -161,7 +162,9 @@ def test_polar_view(fig_test, fig_ref): def test_map_projection_view(fig_test, fig_ref): x = np.linspace(-2.5, 2.5, 20) y = np.linspace(-1, 1, 20) - circ_gen = lambda: plt.Circle((1.5, 0.25), 0.7, ec="black", fc="blue") + + def circ_gen(): + return plt.Circle((1.5, 0.25), 0.7, ec="black", fc="blue") # Test case... ax_t1 = fig_test.add_subplot(1, 2, 1, projection="hammer") @@ -215,4 +218,4 @@ def test_double_view(fig_test, fig_ref): for ax in (ax_test1, ax_test3, ax_ref1, ax_ref3): ax.set_aspect(1) ax.relim() - ax.autoscale_view() \ No newline at end of file + ax.autoscale_view() diff --git a/matplotview/tests/utils.py b/matplotview/tests/utils.py index 05409aa..812eb84 100644 --- a/matplotview/tests/utils.py +++ b/matplotview/tests/utils.py @@ -1,5 +1,3 @@ -import functools - import numpy as np import matplotlib.pyplot as plt @@ -23,7 +21,7 @@ def matches_post_pickle(figure): return np.all(img_expected == img_result) -def plotting_test(num_figs = 1, *args, **kwargs): +def plotting_test(num_figs=1, *args, **kwargs): def plotting_decorator(function): def test_plotting(): plt.close("all") @@ -36,6 +34,3 @@ def test_plotting(): return test_plotting return plotting_decorator - - - From 34c4d9aa1a874bcf6efd291129342c66cebf2399 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sun, 27 Feb 2022 13:38:17 -0700 Subject: [PATCH 27/78] Add docs to viewspec dataclass. --- matplotview/__init__.py | 3 ++- matplotview/_view_axes.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 9af8cb7..3047496 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -111,7 +111,8 @@ def inset_zoom_axes( filter_set: Iterable[Union[Type[Artist], Artist]] or None An optional filter set, which can be used to select what artists - are drawn by the view. Any artists types in the set are not drawn. + are drawn by the view. Any artists or artist types in the set are not + drawn. scale_lines: bool, defaults to True Specifies if lines should be drawn thicker based on scaling in the diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 8d82963..65a4640 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -98,6 +98,29 @@ def _view_from_pickle(builder, args): @dataclass class ViewSpecification: + """ + A view specification, or a mutable dataclass containing configuration + options for a view's "viewing" of a different axes. + + Parameters: + ----------- + image_interpolation: string + Supported options are 'antialiased', 'nearest', 'bilinear', + 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', 'hermite', + 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', 'mitchell', + 'sinc', 'lanczos', or 'none'. The default value is 'nearest'. This + determines the interpolation used when attempting to render a + zoomed version of an image. + + filter_set: Iterable[Union[Type[Artist], Artist]] or None + An optional filter set, which can be used to select what artists + are drawn by the view. Any artists or artist types in the set are not + drawn. + + scale_lines: bool, defaults to True + Specifies if lines should be drawn thicker based on scaling in the + view. + """ image_interpolation: str = "nearest" filter_set: Optional[Set[Union[Type[Artist], Artist]]] = None scale_lines: bool = True From 0b965edf9f3037096cd107c569ee2c50d21d2dd9 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sun, 6 Mar 2022 23:30:53 -0700 Subject: [PATCH 28/78] Fix docs. --- matplotview/_view_axes.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 65a4640..dc186d5 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -15,9 +15,9 @@ class _BoundRendererArtist: """ Provides a temporary wrapper around a given artist, inheriting its - attributes and values, while overloading draw to use a fixed + attributes and values, while overriding the draw method to use a fixed TransformRenderer. This is used to render an artist to a view without - having to implement a new draw for every Axes type. + having to implement a new draw method for every Axes type. """ def __init__( self, @@ -146,8 +146,8 @@ class __ViewType: def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: """ Construct a View axes, which subclasses, or wraps a specific Axes subclass. - A View axes can be configured to display the contents of another Axes - within the same Figure. + A View axes can be configured to display the contents of other Axes + (plural) within the same Figure. Parameters ---------- @@ -160,6 +160,7 @@ def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: The view axes wrapper for a given axes class, capable of displaying another axes contents... """ + # If the passed class is a view, simply return it. if(issubclass(axes_class, Axes) and issubclass(axes_class, __ViewType)): return axes_class From 1279af8d4b0976feaa6afd4eca1a12c49a462a92 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sun, 6 Mar 2022 23:31:27 -0700 Subject: [PATCH 29/78] Version update. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3989970..01127d8 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import setuptools -VERSION = "0.1.1" +VERSION = "0.2.0" with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() From 37e335a4ed46d1a407643d5a5cd3c5c27ba9dfbb Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Thu, 17 Mar 2022 08:51:00 -0600 Subject: [PATCH 30/78] Remove unused code in __reduce__. --- matplotview/_view_axes.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index dc186d5..db5b11e 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -280,10 +280,8 @@ def draw(self, renderer: RendererBase = None): def __reduce__(self): builder, args = super().__reduce__()[:2] - if(self.__new__ == builder): - builder = super().__new__() - cls = type(self) + args = tuple( arg if(arg != cls) else cls.__bases__[0] for arg in args ) From f3d1eac8bf55773875672843a1326e93bd0e7a1e Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 12:06:11 -0600 Subject: [PATCH 31/78] Better doc string support. --- matplotview/__init__.py | 36 ++++++++++++++++-------------- matplotview/_docs.py | 20 +++++++++++++++++ matplotview/_transform_renderer.py | 11 +++++---- matplotview/_view_axes.py | 23 +++++++++---------- matplotview/tests/test_view_obj.py | 1 - 5 files changed, 55 insertions(+), 36 deletions(-) create mode 100644 matplotview/_docs.py diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 3047496..a75e812 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -1,12 +1,14 @@ from typing import Optional, Iterable, Type, Union from matplotlib.artist import Artist from matplotlib.axes import Axes -from matplotview._view_axes import view_wrapper, ViewSpecification +from matplotview._view_axes import view_wrapper, ViewSpecification, DEFAULT_RENDER_DEPTH +from matplotview._docs import dynamic_doc_string, get_interpolation_list_str __all__ = ["view", "inset_zoom_axes", "ViewSpecification"] +@dynamic_doc_string(render_depth=DEFAULT_RENDER_DEPTH, interp_list=get_interpolation_list_str()) def view( axes: Axes, axes_to_view: Axes, @@ -28,19 +30,16 @@ def view( The axes to display the contents of in the first axes, the 'viewed' axes. - image_interpolation: string, default of "nearest" + image_interpolation: string, default of '{image_interpolation}' The image interpolation method to use when displaying scaled images - from the axes being viewed. Defaults to "nearest". Supported options - are 'antialiased', 'nearest', 'bilinear', 'bicubic', 'spline16', - 'spline36', 'hanning', 'hamming', 'hermite', 'kaiser', 'quadric', - 'catrom', 'gaussian', 'bessel', 'mitchell', 'sinc', 'lanczos', - or 'none' + from the axes being viewed. Defaults to '{image_interpolation}'. Supported options + are {interp_list}. render_depth: optional int, positive, defaults to None The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if two views point at each other. If None, uses the default render depth - of 5, unless the axes passed is already a view axes, in which case the + of {render_depth}, unless the axes passed is already a view axes, in which case the render depth the view already has will be used. filter_set: Iterable[Union[Type[Artist], Artist]] or None @@ -48,9 +47,14 @@ def view( are drawn by the view. Any artists or artist types in the set are not drawn. - scale_lines: bool, defaults to True + scale_lines: bool, defaults to {scale_lines} Specifies if lines should be drawn thicker based on scaling in the view. + + Returns + ------- + axes + The modified `~.axes.Axes` instance which is now a view. """ view_obj = view_wrapper(type(axes)).from_axes(axes, render_depth) view_obj.view_specifications[axes_to_view] = ViewSpecification( @@ -61,6 +65,7 @@ def view( return view_obj +@dynamic_doc_string(render_depth=DEFAULT_RENDER_DEPTH, interp_list=get_interpolation_list_str()) def inset_zoom_axes( axes: Axes, bounds: Iterable, @@ -69,7 +74,7 @@ def inset_zoom_axes( render_depth: Optional[int] = None, filter_set: Optional[Iterable[Union[Type[Artist], Artist]]] = None, scale_lines: bool = True, - transform=None, + transform = None, zorder: int = 5, **kwargs ) -> Axes: @@ -90,15 +95,12 @@ def inset_zoom_axes( Axes-relative coordinates. zorder: number - Defaults to 5 (same as `.Axes.legend`). Adjust higher or lower + Defaults to {zorder} (same as `.Axes.legend`). Adjust higher or lower to change whether it is above or below data plotted on the parent Axes. image_interpolation: string - Supported options are 'antialiased', 'nearest', 'bilinear', - 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', 'hermite', - 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', 'mitchell', - 'sinc', 'lanczos', or 'none'. The default value is 'nearest'. This + Supported options are {interp_list}. The default value is '{image_interpolation}'. This determines the interpolation used when attempting to render a zoomed version of an image. @@ -106,7 +108,7 @@ def inset_zoom_axes( The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if two views point at each other. If None, uses the default render depth - of 5, unless the axes passed is already a view axes, in which case the + of {render_depth}, unless the axes passed is already a view axes, in which case the render depth the view already has will be used. filter_set: Iterable[Union[Type[Artist], Artist]] or None @@ -114,7 +116,7 @@ def inset_zoom_axes( are drawn by the view. Any artists or artist types in the set are not drawn. - scale_lines: bool, defaults to True + scale_lines: bool, defaults to {scale_lines} Specifies if lines should be drawn thicker based on scaling in the view. diff --git a/matplotview/_docs.py b/matplotview/_docs.py new file mode 100644 index 0000000..4371a8a --- /dev/null +++ b/matplotview/_docs.py @@ -0,0 +1,20 @@ +import inspect +from inspect import signature + + +def dynamic_doc_string(**kwargs): + def convert(func): + default_vals = { + k: v.default for k, v in signature(func).parameters.items() if(v.default is not inspect.Parameter.empty) + } + default_vals.update(kwargs) + func.__doc__ = func.__doc__.format(**default_vals) + + return func + + return convert + + +def get_interpolation_list_str(): + from matplotlib.image import _interpd_ + return ", ".join([f"'{k}'" if(i != len(_interpd_) - 1) else f"or '{k}'" for i, k in enumerate(_interpd_)]) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index 59ad786..ba68ef6 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -6,6 +6,7 @@ import matplotlib._image as _image import numpy as np from matplotlib.image import _interpd_ +from matplotview._docs import dynamic_doc_string, get_interpolation_list_str class _TransformRenderer(RendererBase): @@ -15,6 +16,7 @@ class _TransformRenderer(RendererBase): original renderer. """ + @dynamic_doc_string(interp_list=get_interpolation_list_str()) def __init__( self, base_renderer, @@ -42,7 +44,7 @@ def __init__( transform: `~matplotlib.transforms.Transform` The main transform to be used for plotting all objects once - converted into the mock_transform coordinate space. Typically this + converted into the mock_transform coordinate space. Typically, this is the child axes data coordinate space (transData). bounding_axes: `~matplotlib.axes.Axes` @@ -50,14 +52,11 @@ def __init__( axes will be clipped. image_interpolation: string - Supported options are 'antialiased', 'nearest', 'bilinear', - 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', 'hermite', - 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', 'mitchell', - 'sinc', 'lanczos', or 'none'. The default value is 'nearest'. This + Supported options are {interp_list}. The default value is '{image_interpolation}'. This determines the interpolation used when attempting to render a zoomed version of an image. - scale_linewidths: bool, default is True + scale_linewidths: bool, default is {scale_linewidths} Specifies if line widths should be scaled, in addition to the paths themselves. diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index db5b11e..a54f9d9 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -8,6 +8,7 @@ from matplotlib.artist import Artist from matplotlib.backend_bases import RendererBase from dataclasses import dataclass +from matplotview._docs import dynamic_doc_string, get_interpolation_list_str DEFAULT_RENDER_DEPTH = 5 @@ -96,6 +97,7 @@ def _view_from_pickle(builder, args): return res +@dynamic_doc_string(render_depth=DEFAULT_RENDER_DEPTH, interp_list=get_interpolation_list_str()) @dataclass class ViewSpecification: """ @@ -105,19 +107,16 @@ class ViewSpecification: Parameters: ----------- image_interpolation: string - Supported options are 'antialiased', 'nearest', 'bilinear', - 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', 'hermite', - 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', 'mitchell', - 'sinc', 'lanczos', or 'none'. The default value is 'nearest'. This + Supported options are {interp_list}. The default value is '{image_interpolation}'. This determines the interpolation used when attempting to render a zoomed version of an image. - filter_set: Iterable[Union[Type[Artist], Artist]] or None + filter_set: Iterable[Union[Type[Artist], Artist]] or {filter_set} An optional filter set, which can be used to select what artists are drawn by the view. Any artists or artist types in the set are not drawn. - scale_lines: bool, defaults to True + scale_lines: bool, defaults to {scale_lines} Specifies if lines should be drawn thicker based on scaling in the view. """ @@ -170,6 +169,8 @@ class View(axes_class, __ViewType): An axes which automatically displays elements of another axes. Does not require Artists to be plotted twice. """ + + @dynamic_doc_string() def __init__( self, *args, @@ -181,17 +182,14 @@ def __init__( Parameters ---------- - axes_to_view: `~.axes.Axes` - The axes to create a view of. - *args Additional arguments to be passed to the Axes class this ViewAxes wraps. - render_depth: int, positive, defaults to 5 + render_depth: int, positive, defaults to {render_depth} The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset - axes) or if two views point at each other. Defaults to 5. + axes) or if two views point at each other. Defaults to {render_depth}. **kwargs Other optional keyword arguments supported by the Axes @@ -342,6 +340,7 @@ def view_specifications(self) -> Dict[Axes, ViewSpecification]: view_specs = view_specifications @classmethod + @dynamic_doc_string(render_depth=DEFAULT_RENDER_DEPTH) def from_axes( cls, axes: Axes, @@ -362,7 +361,7 @@ def from_axes( The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if two views point at each other. If none, use the - default value (5) if the render depth is not already set. + default value ({render_depth}) if the render depth is not already set. Returns ------- diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py index 6210e63..3f4c0e0 100644 --- a/matplotview/tests/test_view_obj.py +++ b/matplotview/tests/test_view_obj.py @@ -5,7 +5,6 @@ from matplotview._view_axes import DEFAULT_RENDER_DEPTH, view_wrapper import numpy as np - def test_obj_comparison(): from matplotlib.axes import Subplot, Axes From 17a821213a1ddabfb80bfe3de8f4f252129c8138 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 20:51:30 -0600 Subject: [PATCH 32/78] PEP8 formatting fixes... --- matplotview/__init__.py | 37 +++++++++++++++++++----------- matplotview/_docs.py | 8 +++++-- matplotview/_transform_renderer.py | 6 ++--- matplotview/_view_axes.py | 17 +++++++++----- matplotview/tests/test_view_obj.py | 1 + 5 files changed, 45 insertions(+), 24 deletions(-) diff --git a/matplotview/__init__.py b/matplotview/__init__.py index a75e812..4ac78cb 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -1,14 +1,22 @@ from typing import Optional, Iterable, Type, Union from matplotlib.artist import Artist from matplotlib.axes import Axes -from matplotview._view_axes import view_wrapper, ViewSpecification, DEFAULT_RENDER_DEPTH +from matplotlib.transforms import Transform +from matplotview._view_axes import ( + view_wrapper, + ViewSpecification, + DEFAULT_RENDER_DEPTH +) from matplotview._docs import dynamic_doc_string, get_interpolation_list_str __all__ = ["view", "inset_zoom_axes", "ViewSpecification"] -@dynamic_doc_string(render_depth=DEFAULT_RENDER_DEPTH, interp_list=get_interpolation_list_str()) +@dynamic_doc_string( + render_depth=DEFAULT_RENDER_DEPTH, + interp_list=get_interpolation_list_str() +) def view( axes: Axes, axes_to_view: Axes, @@ -32,15 +40,15 @@ def view( image_interpolation: string, default of '{image_interpolation}' The image interpolation method to use when displaying scaled images - from the axes being viewed. Defaults to '{image_interpolation}'. Supported options - are {interp_list}. + from the axes being viewed. Defaults to '{image_interpolation}'. + Supported options are {interp_list}. render_depth: optional int, positive, defaults to None The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if two views point at each other. If None, uses the default render depth - of {render_depth}, unless the axes passed is already a view axes, in which case the - render depth the view already has will be used. + of {render_depth}, unless the axes passed is already a view axes, in + which case the render depth the view already has will be used. filter_set: Iterable[Union[Type[Artist], Artist]] or None An optional filter set, which can be used to select what artists @@ -65,7 +73,10 @@ def view( return view_obj -@dynamic_doc_string(render_depth=DEFAULT_RENDER_DEPTH, interp_list=get_interpolation_list_str()) +@dynamic_doc_string( + render_depth=DEFAULT_RENDER_DEPTH, + interp_list=get_interpolation_list_str() +) def inset_zoom_axes( axes: Axes, bounds: Iterable, @@ -74,7 +85,7 @@ def inset_zoom_axes( render_depth: Optional[int] = None, filter_set: Optional[Iterable[Union[Type[Artist], Artist]]] = None, scale_lines: bool = True, - transform = None, + transform: Transform = None, zorder: int = 5, **kwargs ) -> Axes: @@ -100,16 +111,16 @@ def inset_zoom_axes( parent Axes. image_interpolation: string - Supported options are {interp_list}. The default value is '{image_interpolation}'. This - determines the interpolation used when attempting to render a - zoomed version of an image. + Supported options are {interp_list}. The default value is + '{image_interpolation}'. This determines the interpolation + used when attempting to render a zoomed version of an image. render_depth: optional int, positive, defaults to None The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if two views point at each other. If None, uses the default render depth - of {render_depth}, unless the axes passed is already a view axes, in which case the - render depth the view already has will be used. + of {render_depth}, unless the axes passed is already a view axes, + in which case the render depth the view already has will be used. filter_set: Iterable[Union[Type[Artist], Artist]] or None An optional filter set, which can be used to select what artists diff --git a/matplotview/_docs.py b/matplotview/_docs.py index 4371a8a..ec5a324 100644 --- a/matplotview/_docs.py +++ b/matplotview/_docs.py @@ -5,7 +5,8 @@ def dynamic_doc_string(**kwargs): def convert(func): default_vals = { - k: v.default for k, v in signature(func).parameters.items() if(v.default is not inspect.Parameter.empty) + k: v.default for k, v in signature(func).parameters.items() + if(v.default is not inspect.Parameter.empty) } default_vals.update(kwargs) func.__doc__ = func.__doc__.format(**default_vals) @@ -17,4 +18,7 @@ def convert(func): def get_interpolation_list_str(): from matplotlib.image import _interpd_ - return ", ".join([f"'{k}'" if(i != len(_interpd_) - 1) else f"or '{k}'" for i, k in enumerate(_interpd_)]) + return ", ".join([ + f"'{k}'" if(i != len(_interpd_) - 1) else f"or '{k}'" + for i, k in enumerate(_interpd_) + ]) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index ba68ef6..ac1aab4 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -52,9 +52,9 @@ def __init__( axes will be clipped. image_interpolation: string - Supported options are {interp_list}. The default value is '{image_interpolation}'. This - determines the interpolation used when attempting to render a - zoomed version of an image. + Supported options are {interp_list}. The default value is + '{image_interpolation}'. This determines the interpolation + used when attempting to render a zoomed version of an image. scale_linewidths: bool, default is {scale_linewidths} Specifies if line widths should be scaled, in addition to the diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index a54f9d9..d505e1a 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -97,7 +97,10 @@ def _view_from_pickle(builder, args): return res -@dynamic_doc_string(render_depth=DEFAULT_RENDER_DEPTH, interp_list=get_interpolation_list_str()) +@dynamic_doc_string( + render_depth=DEFAULT_RENDER_DEPTH, + interp_list=get_interpolation_list_str() +) @dataclass class ViewSpecification: """ @@ -107,9 +110,9 @@ class ViewSpecification: Parameters: ----------- image_interpolation: string - Supported options are {interp_list}. The default value is '{image_interpolation}'. This - determines the interpolation used when attempting to render a - zoomed version of an image. + Supported options are {interp_list}. The default value is + '{image_interpolation}'. This determines the interpolation + used when attempting to render a zoomed version of an image. filter_set: Iterable[Union[Type[Artist], Artist]] or {filter_set} An optional filter set, which can be used to select what artists @@ -189,7 +192,8 @@ def __init__( render_depth: int, positive, defaults to {render_depth} The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset - axes) or if two views point at each other. Defaults to {render_depth}. + axes) or if two views point at each other. Defaults to + {render_depth}. **kwargs Other optional keyword arguments supported by the Axes @@ -361,7 +365,8 @@ def from_axes( The number of recursive draws allowed for this view, this can happen if the view is a child of the axes (such as an inset axes) or if two views point at each other. If none, use the - default value ({render_depth}) if the render depth is not already set. + default value ({render_depth}) if the render depth is not + already set. Returns ------- diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py index 3f4c0e0..6210e63 100644 --- a/matplotview/tests/test_view_obj.py +++ b/matplotview/tests/test_view_obj.py @@ -5,6 +5,7 @@ from matplotview._view_axes import DEFAULT_RENDER_DEPTH, view_wrapper import numpy as np + def test_obj_comparison(): from matplotlib.axes import Subplot, Axes From e7c8202d4db393d591c832544b962a59eb94d771 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 21:15:26 -0600 Subject: [PATCH 33/78] Add github actions. --- .github/workflows/pytest.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/pytest.yml diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..90a57a2 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,31 @@ + +name: Validate Python Code + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + flake8 matplotview --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 matplotview --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest \ No newline at end of file From 507ae915c7faa108dbbd965281625b585b9bff2f Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 21:17:42 -0600 Subject: [PATCH 34/78] Remove 3.6... --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 90a57a2..c7b274c 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9"] + python-version: ["3.7", "3.8", "3.9"] steps: - uses: actions/checkout@v3 From 262174b6c96df120518fd72d3ddf7eb8994eb9e8 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 21:26:49 -0600 Subject: [PATCH 35/78] Add os dependencies. --- .github/workflows/pytest.yml | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c7b274c..96e4ea2 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -17,6 +17,59 @@ jobs: uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} + + - name: Install OS dependencies + run: | + case "${{ runner.os }}" in + Linux) + sudo apt-get update -yy + sudo apt-get install -yy \ + ccache \ + cm-super \ + dvipng \ + ffmpeg \ + fonts-noto-cjk \ + gdb \ + gir1.2-gtk-3.0 \ + graphviz \ + inkscape \ + lcov \ + libcairo2 \ + libcairo2-dev \ + libffi-dev \ + libgeos-dev \ + libgirepository1.0-dev \ + libsdl2-2.0-0 \ + libxkbcommon-x11-0 \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-randr0 \ + libxcb-render-util0 \ + libxcb-xinerama0 \ + lmodern \ + fonts-freefont-otf \ + texlive-pictures \ + pkg-config \ + qtbase5-dev \ + texlive-fonts-recommended \ + texlive-latex-base \ + texlive-latex-extra \ + texlive-latex-recommended \ + texlive-luatex \ + texlive-xetex \ + ttf-wqy-zenhei + if [[ "${{ matrix.os }}" = ubuntu-20.04 ]]; then + sudo apt install -yy libopengl0 + fi + ;; + macOS) + brew install ccache + brew tap homebrew/cask-fonts + brew install font-noto-sans-cjk-sc + ;; + esac + - name: Install dependencies run: | python -m pip install --upgrade pip From 31bed65ecf5bbce9f56ab69e154cd3ceb0f3df26 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 21:36:31 -0600 Subject: [PATCH 36/78] Minimize test dependencies... --- .github/workflows/pytest.yml | 34 +--------------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 96e4ea2..5fea550 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -25,40 +25,8 @@ jobs: sudo apt-get update -yy sudo apt-get install -yy \ ccache \ - cm-super \ - dvipng \ - ffmpeg \ - fonts-noto-cjk \ - gdb \ - gir1.2-gtk-3.0 \ - graphviz \ inkscape \ - lcov \ - libcairo2 \ - libcairo2-dev \ - libffi-dev \ - libgeos-dev \ - libgirepository1.0-dev \ - libsdl2-2.0-0 \ - libxkbcommon-x11-0 \ - libxcb-icccm4 \ - libxcb-image0 \ - libxcb-keysyms1 \ - libxcb-randr0 \ - libxcb-render-util0 \ - libxcb-xinerama0 \ - lmodern \ - fonts-freefont-otf \ - texlive-pictures \ - pkg-config \ - qtbase5-dev \ - texlive-fonts-recommended \ - texlive-latex-base \ - texlive-latex-extra \ - texlive-latex-recommended \ - texlive-luatex \ - texlive-xetex \ - ttf-wqy-zenhei + ghostscript if [[ "${{ matrix.os }}" = ubuntu-20.04 ]]; then sudo apt install -yy libopengl0 fi From 4493540fd945a4196957412cfe73770ab20be8eb Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 21:43:44 -0600 Subject: [PATCH 37/78] Add more OSes to run on. --- .github/workflows/pytest.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 5fea550..dddb518 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -6,9 +6,10 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: + os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.7", "3.8", "3.9"] steps: From caa847c1fe715050996b86c173899c96fc33072c Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 21:45:43 -0600 Subject: [PATCH 38/78] Add more OSes to run on. --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index dddb518..09e3122 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -9,7 +9,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest, macos-latest] python-version: ["3.7", "3.8", "3.9"] steps: From 833992a6fa41debcbc50358055a941ccaa587c1b Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 21:55:01 -0600 Subject: [PATCH 39/78] Add windows separately. --- .github/workflows/pytest.yml | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 09e3122..1e3da29 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -4,7 +4,7 @@ name: Validate Python Code on: [push, pull_request] jobs: - build: + test-mac-linux: runs-on: ${{ matrix.os }} strategy: @@ -33,9 +33,6 @@ jobs: fi ;; macOS) - brew install ccache - brew tap homebrew/cask-fonts - brew install font-noto-sans-cjk-sc ;; esac @@ -48,6 +45,28 @@ jobs: run: | flake8 matplotview --count --select=E9,F63,F7,F82 --show-source --statistics flake8 matplotview --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest + + test-windows: + + runs-on: windows-latest + strategy: + matrix: + python-version: [ "3.7", "3.8", "3.9" ] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with pytest run: | pytest \ No newline at end of file From 241317df5a604422c4fddab2fffe35e98a846b91 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 21:58:17 -0600 Subject: [PATCH 40/78] Fix workflow error. --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 1e3da29..cd234f5 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -66,7 +66,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install -r requirements.txt - name: Test with pytest run: | pytest \ No newline at end of file From f5f2fb80c0bee3edbaf93d47a7409870a3806c28 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 22:00:40 -0600 Subject: [PATCH 41/78] Forgot to install pytest... --- .github/workflows/pytest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index cd234f5..50c7f65 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -66,6 +66,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install pytest pip install -r requirements.txt - name: Test with pytest run: | From 06b66be14e5ad1451ac8de72c627604ba454a598 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sat, 11 Jun 2022 22:08:54 -0600 Subject: [PATCH 42/78] Restrict branches to run actions on. --- .github/workflows/pytest.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 50c7f65..f4bda9b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,7 +1,14 @@ name: Validate Python Code -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: + branches: + - develop + - main jobs: test-mac-linux: From 60a7cb7fee8f80ca3c87f3c89fc7486845613ec3 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Mon, 13 Jun 2022 06:46:53 -0600 Subject: [PATCH 43/78] Better docs. --- matplotview/_transform_renderer.py | 105 +++++++++++++++++++++-------- requirements.txt | 3 +- setup.py | 3 +- 3 files changed, 80 insertions(+), 31 deletions(-) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index ac1aab4..04ea191 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -1,13 +1,18 @@ -from matplotlib.backend_bases import RendererBase +from typing import Tuple, Union +from matplotlib.axes import Axes +from matplotlib.backend_bases import RendererBase, GraphicsContextBase +from matplotlib.font_manager import FontProperties from matplotlib.patches import Rectangle +from matplotlib.texmanager import TexManager from matplotlib.transforms import Bbox, IdentityTransform, Affine2D, \ - TransformedPatchPath + TransformedPatchPath, Transform from matplotlib.path import Path import matplotlib._image as _image import numpy as np from matplotlib.image import _interpd_ from matplotview._docs import dynamic_doc_string, get_interpolation_list_str +ColorTup = Union[None, Tuple[float, float, float, float], Tuple[float, float, float]] class _TransformRenderer(RendererBase): """ @@ -19,12 +24,12 @@ class _TransformRenderer(RendererBase): @dynamic_doc_string(interp_list=get_interpolation_list_str()) def __init__( self, - base_renderer, - mock_transform, - transform, - bounding_axes, - image_interpolation="nearest", - scale_linewidths=True + base_renderer: RendererBase, + mock_transform: Transform, + transform: Transform, + bounding_axes: Axes, + image_interpolation: str = "nearest", + scale_linewidths: bool = True ): """ Constructs a new TransformRender. @@ -80,10 +85,10 @@ def __init__( ) @property - def bounding_axes(self): + def bounding_axes(self) -> Axes: return self.__bounding_axes - def _scale_gc(self, gc): + def _scale_gc(self, gc: GraphicsContextBase) -> GraphicsContextBase: with np.errstate(all='ignore'): transfer_transform = self._get_transfer_transform( IdentityTransform() @@ -103,14 +108,14 @@ def _scale_gc(self, gc): return new_gc - def _get_axes_display_box(self): + def _get_axes_display_box(self) -> Bbox: """ Private method, get the bounding box of the child axes in display coordinates. """ return self.__bounding_axes.get_window_extent() - def _get_transfer_transform(self, orig_transform): + def _get_transfer_transform(self, orig_transform: Transform) -> Transform: """ Private method, returns the transform which translates and scales coordinates as if they were originally plotted on the child axes @@ -141,43 +146,63 @@ def _get_transfer_transform(self, orig_transform): # We copy all of the properties of the renderer we are mocking, so that # artists plot themselves as if they were placed on the original renderer. @property - def height(self): + def height(self) -> int: return self.__renderer.get_canvas_width_height()[1] @property - def width(self): + def width(self) -> int: return self.__renderer.get_canvas_width_height()[0] - def get_text_width_height_descent(self, s, prop, ismath): + def get_text_width_height_descent( + self, + s: str, + prop: FontProperties, + ismath: bool + ) -> Tuple[float, float, float]: return self.__renderer.get_text_width_height_descent(s, prop, ismath) - def get_canvas_width_height(self): + def get_canvas_width_height(self) -> Tuple[float, float]: return self.__renderer.get_canvas_width_height() - def get_texmanager(self): + def get_texmanager(self) -> TexManager: return self.__renderer.get_texmanager() - def get_image_magnification(self): + def get_image_magnification(self) -> float: return self.__renderer.get_image_magnification() - def _get_text_path_transform(self, x, y, s, prop, angle, ismath): - return self.__renderer._get_text_path_transform(x, y, s, prop, angle, - ismath) + def _get_text_path_transform( + self, + x: float, + y: float, + s: str, + prop: FontProperties, + angle: float, + ismath: bool + ) -> Transform: + return self.__renderer._get_text_path_transform( + x, y, s, prop, angle, ismath + ) - def option_scale_image(self): + def option_scale_image(self) -> bool: return False - def points_to_pixels(self, points): + def points_to_pixels(self, points: float) -> float: return self.__renderer.points_to_pixels(points) - def flipy(self): + def flipy(self) -> bool: return self.__renderer.flipy() - def new_gc(self): + def new_gc(self) -> GraphicsContextBase: return self.__renderer.new_gc() # Actual drawing methods below: - def draw_path(self, gc, path, transform, rgbFace=None): + def draw_path( + self, + gc: GraphicsContextBase, + path: Path, + transform: Transform, + rgbFace: ColorTup = None + ): # Convert the path to display coordinates, but if it was originally # drawn on the child axes. path = path.deepcopy() @@ -203,7 +228,16 @@ def draw_path(self, gc, path, transform, rgbFace=None): self.__renderer.draw_path(gc, path, IdentityTransform(), rgbFace) - def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath): + def _draw_text_as_path( + self, + gc: GraphicsContextBase, + x: float, + y: float, + s: str, + prop: FontProperties, + angle: float, + ismath: bool + ): # If the text field is empty, don't even try rendering it... if((s is None) or (s.strip() == "")): return @@ -212,7 +246,13 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath): # checked above... (Above case causes error) super()._draw_text_as_path(gc, x, y, s, prop, angle, ismath) - def draw_gouraud_triangle(self, gc, points, colors, transform): + def draw_gouraud_triangle( + self, + gc: GraphicsContextBase, + points: np.ndarray, + colors: np.ndarray, + transform: Transform + ): # Pretty much identical to draw_path, transform the points and adjust # clip to the child axes bounding box. points = self._get_transfer_transform(transform).transform(points) @@ -233,7 +273,14 @@ def draw_gouraud_triangle(self, gc, points, colors, transform): IdentityTransform()) # Images prove to be especially messy to deal with... - def draw_image(self, gc, x, y, im, transform=None): + def draw_image( + self, + gc: GraphicsContextBase, + x: float, + y: float, + im: np.ndarray, + transform: Transform = None + ): mag = self.get_image_magnification() shift_data_transform = self._get_transfer_transform( IdentityTransform() diff --git a/requirements.txt b/requirements.txt index 564ed46..e685972 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -matplotlib>=3.5.1 \ No newline at end of file +matplotlib>=3.5.1 +numpy \ No newline at end of file diff --git a/setup.py b/setup.py index 01127d8..aa41aa2 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,8 @@ ], license="PSF", install_requires=[ - "matplotlib>=3.5.1" + "matplotlib>=3.5.1", + "numpy" ], packages=["matplotview"], python_requires=">=3.7", From c98e6718116bf19db6bfcda6013e26470bc7f0dd Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Thu, 14 Jul 2022 16:57:19 -0400 Subject: [PATCH 44/78] Flake8 formatting fixes.... --- matplotview/_transform_renderer.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index 04ea191..8c6e747 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -12,7 +12,12 @@ from matplotlib.image import _interpd_ from matplotview._docs import dynamic_doc_string, get_interpolation_list_str -ColorTup = Union[None, Tuple[float, float, float, float], Tuple[float, float, float]] +ColorTup = Union[ + None, + Tuple[float, float, float, float], + Tuple[float, float, float] +] + class _TransformRenderer(RendererBase): """ From 047355f806dbd32bd96928dd02548341f64f3cb4 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Sun, 14 Aug 2022 22:09:09 -0400 Subject: [PATCH 45/78] Add sphinx docs... --- .gitignore | 2 + docs/Makefile | 20 ++++++++ docs/api/index.rst | 13 ++++++ docs/conf.py | 36 +++++++++++++++ docs/examples/index.rst | 16 +++++++ docs/examples/plots/multiple_artist_view.rst | 30 ++++++++++++ docs/examples/plots/sierpinski_triangle.rst | 48 ++++++++++++++++++++ docs/examples/plots/simple_inset_view.rst | 45 ++++++++++++++++++ docs/examples/plots/simplest_example.rst | 25 ++++++++++ docs/index.rst | 23 ++++++++++ docs/installation.rst | 9 ++++ docs/make.bat | 35 ++++++++++++++ matplotview/__init__.py | 48 ++++++++++++++++++-- matplotview/_view_axes.py | 4 +- 14 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/api/index.rst create mode 100644 docs/conf.py create mode 100644 docs/examples/index.rst create mode 100644 docs/examples/plots/multiple_artist_view.rst create mode 100644 docs/examples/plots/sierpinski_triangle.rst create mode 100644 docs/examples/plots/simple_inset_view.rst create mode 100644 docs/examples/plots/simplest_example.rst create mode 100644 docs/index.rst create mode 100644 docs/installation.rst create mode 100644 docs/make.bat diff --git a/.gitignore b/.gitignore index c92265d..0584d03 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ result_images .pytest_cache dist *.egg-info +docs/_build/ +docs/api/generated \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 0000000..f66b580 --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,13 @@ +API +=== + +The public facing functions of matplotview. + +.. autosummary:: + :toctree: generated + + matplotview.view + matplotview.stop_viewing + matplotview.inset_zoom_axes + + diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..10ab5a2 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,36 @@ +from pathlib import Path +import sys + +# Add project root directory to python path... +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +project = 'matplotview' +copyright = '2022, Isaac Robinson' +author = 'Isaac Robinson' +release = '1.0.0' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', + 'numpydoc', + 'matplotlib.sphinxext.mathmpl', + 'matplotlib.sphinxext.plot_directive', +] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'alabaster' +html_static_path = ['_static'] +plot_include_source = True diff --git a/docs/examples/index.rst b/docs/examples/index.rst new file mode 100644 index 0000000..fca9ef7 --- /dev/null +++ b/docs/examples/index.rst @@ -0,0 +1,16 @@ +Examples +======== + +Because of the way matplotview is designed, it can work with any Axes and projection +types, and works with all the default projection modes included in matplotlib. +The following examples showcase using matplotview in several different scenarios and +with different projections. + +.. toctree:: + :maxdepth: 2 + :caption: Examples: + + plots/simplest_example + plots/multiple_artist_view + plots/simple_inset_view + plots/sierpinski_triangle \ No newline at end of file diff --git a/docs/examples/plots/multiple_artist_view.rst b/docs/examples/plots/multiple_artist_view.rst new file mode 100644 index 0000000..0b819b0 --- /dev/null +++ b/docs/examples/plots/multiple_artist_view.rst @@ -0,0 +1,30 @@ +A View With Several Plot Elements +================================= + +A simple example with an assortment of plot elements. + +.. plot:: + + from matplotview import view + import matplotlib.pyplot as plt + import numpy as np + + fig, (ax1, ax2) = plt.subplots(1, 2) + + # Plot a line, circle patch, some text, and an image... + ax1.plot([i for i in range(10)], "r") + ax1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax1.text(10, 10, "Hello World!", size=20) + ax1.imshow(np.random.rand(30, 30), origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + + # Turn axes 2 into a view of axes 1. + view(ax2, ax1) + # Modify the second axes data limits to match the first axes... + ax2.set_aspect(ax1.get_aspect()) + ax2.set_xlim(ax1.get_xlim()) + ax2.set_ylim(ax1.get_ylim()) + + fig.tight_layout() + fig.show() + diff --git a/docs/examples/plots/sierpinski_triangle.rst b/docs/examples/plots/sierpinski_triangle.rst new file mode 100644 index 0000000..5de9477 --- /dev/null +++ b/docs/examples/plots/sierpinski_triangle.rst @@ -0,0 +1,48 @@ +SierpiƄski Triangle With Recursive Views +======================================== + +Matplotview's views support recursive drawing of other views and themselves to a +configurable depth. This feature allows matplotview to be used to generate fractals, +such as a sierpiƄski triangle as shown in the following example. + +.. plot:: + + import matplotlib.pyplot as plt + import matplotview as mpv + from matplotlib.patches import PathPatch + from matplotlib.path import Path + from matplotlib.transforms import Affine2D + + # We'll plot a white upside down triangle inside of black one, and then use + # 3 views to draw all the rest of the recursions of the sierpiƄski triangle. + outside_color = "black" + inner_color = "white" + + t = Affine2D().scale(-0.5) + + outer_triangle = Path.unit_regular_polygon(3) + inner_triangle = t.transform_path(outer_triangle) + b = outer_triangle.get_extents() + + fig, ax = plt.subplots(1) + ax.set_aspect(1) + + ax.add_patch(PathPatch(outer_triangle, fc=outside_color, ec=[0] * 4)) + ax.add_patch(PathPatch(inner_triangle, fc=inner_color, ec=[0] * 4)) + ax.set_xlim(b.x0, b.x1) + ax.set_ylim(b.y0, b.y1) + + ax_locs = [ + [0, 0, 0.5, 0.5], + [0.5, 0, 0.5, 0.5], + [0.25, 0.5, 0.5, 0.5] + ] + + for loc in ax_locs: + inax = mpv.inset_zoom_axes(ax, loc, render_depth=6) + inax.set_xlim(b.x0, b.x1) + inax.set_ylim(b.y0, b.y1) + inax.axis("off") + inax.patch.set_visible(False) + + fig.show() \ No newline at end of file diff --git a/docs/examples/plots/simple_inset_view.rst b/docs/examples/plots/simple_inset_view.rst new file mode 100644 index 0000000..66163d1 --- /dev/null +++ b/docs/examples/plots/simple_inset_view.rst @@ -0,0 +1,45 @@ +Create Inset Axes Without Plotting Twice +======================================== + +:meth:`matplotview.inset_zoom_axes` can be utilized to create inset axes where we +don't have to plot the parent axes data twice. + +.. plot:: + + from matplotlib import cbook + import matplotlib.pyplot as plt + import numpy as np + from matplotview import inset_zoom_axes + + def get_demo_image(): + z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) + # z is a numpy array of 15x15 + return z, (-3, 4, -4, 3) + + fig, ax = plt.subplots() + + # Make the data... + Z, extent = get_demo_image() + Z2 = np.zeros((150, 150)) + ny, nx = Z.shape + Z2[30:30+ny, 30:30+nx] = Z + + ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") + + # Creates an inset axes with automatic view of the parent axes... + axins = inset_zoom_axes(ax, [0.5, 0.5, 0.47, 0.47]) + # Set limits to sub region of the original image + x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 + axins.set_xlim(x1, x2) + axins.set_ylim(y1, y2) + + # Remove the tick labels from the inset axes + axins.set_xticklabels([]) + axins.set_yticklabels([]) + + # Draw the indicator or zoom lines. + ax.indicate_inset_zoom(axins, edgecolor="black") + + fig.show() + + diff --git a/docs/examples/plots/simplest_example.rst b/docs/examples/plots/simplest_example.rst new file mode 100644 index 0000000..9745dc6 --- /dev/null +++ b/docs/examples/plots/simplest_example.rst @@ -0,0 +1,25 @@ +The Simplest View +================= + +The simplest example: We make a view of a line! Views can be created quickly +using :meth:`matplotview.view` . + +.. plot:: + + from matplotview import view + import matplotlib.pyplot as plt + import numpy as np + + fig, (ax1, ax2) = plt.subplots(1, 2) + + # Plot a line in the first axes. + ax1.plot([i for i in range(10)], "-o") + + # Create a view! Turn axes 2 into a view of axes 1. + view(ax2, ax1) + # Modify the second axes data limits so we get a slightly zoomed out view + ax2.set_xlim(-5, 15) + ax2.set_ylim(-5, 15) + + fig.show() + diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..f6aab1c --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,23 @@ +.. matplotview documentation master file, created by + sphinx-quickstart on Sat Aug 13 19:55:28 2022. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Matplotview |release| Documentation +=================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + installation + examples/index + api/index + + +Additional Links +================ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..4051030 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,9 @@ +Installation +============ + +Matplotview can be installed using `pip `__: + +.. code-block:: bash + + pip install matplotview + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 4ac78cb..f848069 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -10,7 +10,7 @@ from matplotview._docs import dynamic_doc_string, get_interpolation_list_str -__all__ = ["view", "inset_zoom_axes", "ViewSpecification"] +__all__ = ["view", "stop_viewing", "inset_zoom_axes"] @dynamic_doc_string( @@ -27,7 +27,9 @@ def view( ) -> Axes: """ Convert an axes into a view of another axes, displaying the contents of - the second axes. + the second axes. If this axes is already viewing the passed axes (This + function is called twice with the same axes arguments) this function + will update the settings of the viewing instead of creating a new view. Parameters ---------- @@ -62,7 +64,12 @@ def view( Returns ------- axes - The modified `~.axes.Axes` instance which is now a view. + The modified `~.axes.Axes` instance which is now a view. The modification occurs in-place. + + See Also + -------- + matplotview.stop_viewing: Delete or stop an already constructed view. + matplotview.inset_zoom_axes: Convenience method for creating inset axes that are views of the parent axes. """ view_obj = view_wrapper(type(axes)).from_axes(axes, render_depth) view_obj.view_specifications[axes_to_view] = ViewSpecification( @@ -73,6 +80,37 @@ def view( return view_obj +def stop_viewing(view: Axes, axes_of_viewing: Axes) -> Axes: + """ + Terminate the viewing of a specified axes. + + Parameters + ---------- + view: Axes + The axes the is currently viewing the axes_of_viewing... + + axes_of_viewing: Axes + The axes that the view should stop viewing. + + Returns + ------- + view + The view, which has now been modified in-place. + + Raises + ------ + AttributeError + If the provided axes_of_viewing is not actually being viewed by the specified view. + + See Also + -------- + matplotview.view: To create views. + """ + view = view_wrapper(type(view)).from_axes(view) + del view.view_specifications[axes_of_viewing] + return view + + @dynamic_doc_string( render_depth=DEFAULT_RENDER_DEPTH, interp_list=get_interpolation_list_str() @@ -139,9 +177,9 @@ def inset_zoom_axes( ax The created `~.axes.Axes` instance. - Examples + See Also -------- - See `Axes.inset_axes` method for examples. + matplotview.view: For creating views in generalized cases. """ inset_ax = axes.inset_axes( bounds, transform=transform, zorder=zorder, **kwargs diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index d505e1a..91c5e4b 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -107,8 +107,8 @@ class ViewSpecification: A view specification, or a mutable dataclass containing configuration options for a view's "viewing" of a different axes. - Parameters: - ----------- + Attributes + ---------- image_interpolation: string Supported options are {interp_list}. The default value is '{image_interpolation}'. This determines the interpolation From e4ce905a32b0c100b312675333833d4127026247 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Mon, 15 Aug 2022 15:34:08 -0400 Subject: [PATCH 46/78] Update css for docs and increment version... --- .gitignore | 3 +- README.md | 111 +----------------- docs/_static/gallery_mods.css | 20 ++++ docs/conf.py | 11 ++ docs/examples/plots/multiple_artist_view.rst | 30 ----- docs/examples/plots/sierpinski_triangle.rst | 48 -------- docs/examples/plots/simple_inset_view.rst | 45 ------- docs/examples/plots/simplest_example.rst | 25 ---- docs/index.rst | 1 - .../examples/index.rst => examples/README.rst | 11 +- examples/plot_00_simplest_example.py | 23 ++++ examples/plot_01_multiple_artist_view.py | 29 +++++ examples/plot_02_simple_inset_view.py | 43 +++++++ examples/plot_03_view_with_annotations.py | 48 ++++++++ examples/plot_04_sierpinski_triangle.py | 49 ++++++++ examples/plot_05_3d_views.py | 30 +++++ examples/plot_06_polar_views.py | 30 +++++ examples/plot_07_geographic_viewing.py | 30 +++++ examples/plot_08_viewing_2_axes.py | 28 +++++ examples/plot_09_artist_filtering.py | 32 +++++ examples/plot_10_line_scaling.py | 29 +++++ examples/plot_11_image_interpolation.py | 52 ++++++++ examples/plot_12_editing_view_properties.py | 37 ++++++ matplotview/__init__.py | 4 +- setup.py | 2 +- 25 files changed, 499 insertions(+), 272 deletions(-) create mode 100644 docs/_static/gallery_mods.css delete mode 100644 docs/examples/plots/multiple_artist_view.rst delete mode 100644 docs/examples/plots/sierpinski_triangle.rst delete mode 100644 docs/examples/plots/simple_inset_view.rst delete mode 100644 docs/examples/plots/simplest_example.rst rename docs/examples/index.rst => examples/README.rst (57%) create mode 100644 examples/plot_00_simplest_example.py create mode 100644 examples/plot_01_multiple_artist_view.py create mode 100644 examples/plot_02_simple_inset_view.py create mode 100644 examples/plot_03_view_with_annotations.py create mode 100644 examples/plot_04_sierpinski_triangle.py create mode 100644 examples/plot_05_3d_views.py create mode 100644 examples/plot_06_polar_views.py create mode 100644 examples/plot_07_geographic_viewing.py create mode 100644 examples/plot_08_viewing_2_axes.py create mode 100644 examples/plot_09_artist_filtering.py create mode 100644 examples/plot_10_line_scaling.py create mode 100644 examples/plot_11_image_interpolation.py create mode 100644 examples/plot_12_editing_view_properties.py diff --git a/.gitignore b/.gitignore index 0584d03..05a3908 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ result_images dist *.egg-info docs/_build/ -docs/api/generated \ No newline at end of file +docs/api/generated +docs/examples/ \ No newline at end of file diff --git a/README.md b/README.md index fa5aa25..764386a 100644 --- a/README.md +++ b/README.md @@ -12,118 +12,11 @@ You can install matplotview using pip: pip install matplotview ``` -## Usage - -matplotview provides two methods, `view`, and `inset_zoom_axes`. The `view` -method accepts two `Axes`, and makes the first axes a view of the second. The -`inset_zoom_axes` method provides the same functionality as `Axes.inset_axes`, -but the returned inset axes is configured to be a view of the parent axes. - ## Examples -An example of two axes showing the same plot. -```python -from matplotview import view -import matplotlib.pyplot as plt -import numpy as np - -fig, (ax1, ax2) = plt.subplots(1, 2) - -# Plot a line, circle patch, some text, and an image... -ax1.plot([i for i in range(10)], "r") -ax1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) -ax1.text(10, 10, "Hello World!", size=20) -ax1.imshow(np.random.rand(30, 30), origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - -# Turn axes 2 into a view of axes 1. -view(ax2, ax1) -# Modify the second axes data limits to match the first axes... -ax2.set_aspect(ax1.get_aspect()) -ax2.set_xlim(ax1.get_xlim()) -ax2.set_ylim(ax1.get_ylim()) - -fig.tight_layout() -fig.show() -``` -![First example plot results, two views of the same plot.](https://user-images.githubusercontent.com/47544550/149814592-dd815f95-c3ef-406d-bd7e-504859c836bf.png) - -An inset axes example. -```python -from matplotlib import cbook -import matplotlib.pyplot as plt -import numpy as np -from matplotview import inset_zoom_axes - -def get_demo_image(): - z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) - # z is a numpy array of 15x15 - return z, (-3, 4, -4, 3) - -fig, ax = plt.subplots(figsize=[5, 4]) - -# Make the data... -Z, extent = get_demo_image() -Z2 = np.zeros((150, 150)) -ny, nx = Z.shape -Z2[30:30+ny, 30:30+nx] = Z +See the documentation -ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") +## Documentation and Usage -# Creates an inset axes with automatic view of the parent axes... -axins = inset_zoom_axes(ax, [0.5, 0.5, 0.47, 0.47]) -# Set limits to sub region of the original image -x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 -axins.set_xlim(x1, x2) -axins.set_ylim(y1, y2) -axins.set_xticklabels([]) -axins.set_yticklabels([]) -ax.indicate_inset_zoom(axins, edgecolor="black") -fig.show() -``` -![Second example plot results, an inset axes showing a zoom view of an image.](https://user-images.githubusercontent.com/47544550/149814558-c2b1228d-2e5d-41be-86c0-f5dd01d42884.png) - -Because views support recursive drawing, they can be used to create -fractals also. -```python -import matplotlib.pyplot as plt -import matplotview as mpv -from matplotlib.patches import PathPatch -from matplotlib.path import Path -from matplotlib.transforms import Affine2D - -outside_color = "black" -inner_color = "white" - -t = Affine2D().scale(-0.5) - -outer_triangle = Path.unit_regular_polygon(3) -inner_triangle = t.transform_path(outer_triangle) -b = outer_triangle.get_extents() - -fig, ax = plt.subplots(1) -ax.set_aspect(1) - -ax.add_patch(PathPatch(outer_triangle, fc=outside_color, ec=[0] * 4)) -ax.add_patch(PathPatch(inner_triangle, fc=inner_color, ec=[0] * 4)) -ax.set_xlim(b.x0, b.x1) -ax.set_ylim(b.y0, b.y1) - -ax_locs = [ - [0, 0, 0.5, 0.5], - [0.5, 0, 0.5, 0.5], - [0.25, 0.5, 0.5, 0.5] -] - -for loc in ax_locs: - inax = mpv.inset_zoom_axes(ax, loc, render_depth=6) - inax.set_xlim(b.x0, b.x1) - inax.set_ylim(b.y0, b.y1) - inax.axis("off") - inax.patch.set_visible(False) - -fig.show() -``` -![Third example plot results, a SierpiƄski triangle](https://user-images.githubusercontent.com/47544550/150047401-e9364f0f-becd-45c5-a6f4-062118ce713f.png) \ No newline at end of file diff --git a/docs/_static/gallery_mods.css b/docs/_static/gallery_mods.css new file mode 100644 index 0000000..7da4417 --- /dev/null +++ b/docs/_static/gallery_mods.css @@ -0,0 +1,20 @@ + +.sphx-glr-thumbcontainer[tooltip]:hover:after { + background: var(--sg-tooltip-background); + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + color: var(--sg-tooltip-foreground); + content: ""; + opacity: 0.35; + padding: 10px; + z-index: 98; + width: 100%; + height: 100%; + position: absolute; + pointer-events: none; + top: 0; + box-sizing: border-box; + overflow: hidden; + backdrop-filter: blur(3px); +} \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 10ab5a2..462b3ea 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,15 +22,26 @@ 'numpydoc', 'matplotlib.sphinxext.mathmpl', 'matplotlib.sphinxext.plot_directive', + 'sphinx_gallery.gen_gallery' ] templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +from sphinx_gallery.sorting import FileNameSortKey + +sphinx_gallery_conf = { + "examples_dirs": "../examples", + "gallery_dirs": "examples", + "line_numbers": True, + "within_subsection_order": FileNameSortKey +} # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = 'alabaster' html_static_path = ['_static'] +html_css_files = ['gallery_mods.css'] + plot_include_source = True diff --git a/docs/examples/plots/multiple_artist_view.rst b/docs/examples/plots/multiple_artist_view.rst deleted file mode 100644 index 0b819b0..0000000 --- a/docs/examples/plots/multiple_artist_view.rst +++ /dev/null @@ -1,30 +0,0 @@ -A View With Several Plot Elements -================================= - -A simple example with an assortment of plot elements. - -.. plot:: - - from matplotview import view - import matplotlib.pyplot as plt - import numpy as np - - fig, (ax1, ax2) = plt.subplots(1, 2) - - # Plot a line, circle patch, some text, and an image... - ax1.plot([i for i in range(10)], "r") - ax1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) - ax1.text(10, 10, "Hello World!", size=20) - ax1.imshow(np.random.rand(30, 30), origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - - # Turn axes 2 into a view of axes 1. - view(ax2, ax1) - # Modify the second axes data limits to match the first axes... - ax2.set_aspect(ax1.get_aspect()) - ax2.set_xlim(ax1.get_xlim()) - ax2.set_ylim(ax1.get_ylim()) - - fig.tight_layout() - fig.show() - diff --git a/docs/examples/plots/sierpinski_triangle.rst b/docs/examples/plots/sierpinski_triangle.rst deleted file mode 100644 index 5de9477..0000000 --- a/docs/examples/plots/sierpinski_triangle.rst +++ /dev/null @@ -1,48 +0,0 @@ -SierpiƄski Triangle With Recursive Views -======================================== - -Matplotview's views support recursive drawing of other views and themselves to a -configurable depth. This feature allows matplotview to be used to generate fractals, -such as a sierpiƄski triangle as shown in the following example. - -.. plot:: - - import matplotlib.pyplot as plt - import matplotview as mpv - from matplotlib.patches import PathPatch - from matplotlib.path import Path - from matplotlib.transforms import Affine2D - - # We'll plot a white upside down triangle inside of black one, and then use - # 3 views to draw all the rest of the recursions of the sierpiƄski triangle. - outside_color = "black" - inner_color = "white" - - t = Affine2D().scale(-0.5) - - outer_triangle = Path.unit_regular_polygon(3) - inner_triangle = t.transform_path(outer_triangle) - b = outer_triangle.get_extents() - - fig, ax = plt.subplots(1) - ax.set_aspect(1) - - ax.add_patch(PathPatch(outer_triangle, fc=outside_color, ec=[0] * 4)) - ax.add_patch(PathPatch(inner_triangle, fc=inner_color, ec=[0] * 4)) - ax.set_xlim(b.x0, b.x1) - ax.set_ylim(b.y0, b.y1) - - ax_locs = [ - [0, 0, 0.5, 0.5], - [0.5, 0, 0.5, 0.5], - [0.25, 0.5, 0.5, 0.5] - ] - - for loc in ax_locs: - inax = mpv.inset_zoom_axes(ax, loc, render_depth=6) - inax.set_xlim(b.x0, b.x1) - inax.set_ylim(b.y0, b.y1) - inax.axis("off") - inax.patch.set_visible(False) - - fig.show() \ No newline at end of file diff --git a/docs/examples/plots/simple_inset_view.rst b/docs/examples/plots/simple_inset_view.rst deleted file mode 100644 index 66163d1..0000000 --- a/docs/examples/plots/simple_inset_view.rst +++ /dev/null @@ -1,45 +0,0 @@ -Create Inset Axes Without Plotting Twice -======================================== - -:meth:`matplotview.inset_zoom_axes` can be utilized to create inset axes where we -don't have to plot the parent axes data twice. - -.. plot:: - - from matplotlib import cbook - import matplotlib.pyplot as plt - import numpy as np - from matplotview import inset_zoom_axes - - def get_demo_image(): - z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) - # z is a numpy array of 15x15 - return z, (-3, 4, -4, 3) - - fig, ax = plt.subplots() - - # Make the data... - Z, extent = get_demo_image() - Z2 = np.zeros((150, 150)) - ny, nx = Z.shape - Z2[30:30+ny, 30:30+nx] = Z - - ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") - - # Creates an inset axes with automatic view of the parent axes... - axins = inset_zoom_axes(ax, [0.5, 0.5, 0.47, 0.47]) - # Set limits to sub region of the original image - x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 - axins.set_xlim(x1, x2) - axins.set_ylim(y1, y2) - - # Remove the tick labels from the inset axes - axins.set_xticklabels([]) - axins.set_yticklabels([]) - - # Draw the indicator or zoom lines. - ax.indicate_inset_zoom(axins, edgecolor="black") - - fig.show() - - diff --git a/docs/examples/plots/simplest_example.rst b/docs/examples/plots/simplest_example.rst deleted file mode 100644 index 9745dc6..0000000 --- a/docs/examples/plots/simplest_example.rst +++ /dev/null @@ -1,25 +0,0 @@ -The Simplest View -================= - -The simplest example: We make a view of a line! Views can be created quickly -using :meth:`matplotview.view` . - -.. plot:: - - from matplotview import view - import matplotlib.pyplot as plt - import numpy as np - - fig, (ax1, ax2) = plt.subplots(1, 2) - - # Plot a line in the first axes. - ax1.plot([i for i in range(10)], "-o") - - # Create a view! Turn axes 2 into a view of axes 1. - view(ax2, ax1) - # Modify the second axes data limits so we get a slightly zoomed out view - ax2.set_xlim(-5, 15) - ax2.set_ylim(-5, 15) - - fig.show() - diff --git a/docs/index.rst b/docs/index.rst index f6aab1c..0d6a0e0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,5 +19,4 @@ Additional Links ================ * :ref:`genindex` -* :ref:`modindex` * :ref:`search` diff --git a/docs/examples/index.rst b/examples/README.rst similarity index 57% rename from docs/examples/index.rst rename to examples/README.rst index fca9ef7..43dcd19 100644 --- a/docs/examples/index.rst +++ b/examples/README.rst @@ -4,13 +4,4 @@ Examples Because of the way matplotview is designed, it can work with any Axes and projection types, and works with all the default projection modes included in matplotlib. The following examples showcase using matplotview in several different scenarios and -with different projections. - -.. toctree:: - :maxdepth: 2 - :caption: Examples: - - plots/simplest_example - plots/multiple_artist_view - plots/simple_inset_view - plots/sierpinski_triangle \ No newline at end of file +with different projections. \ No newline at end of file diff --git a/examples/plot_00_simplest_example.py b/examples/plot_00_simplest_example.py new file mode 100644 index 0000000..8075d2f --- /dev/null +++ b/examples/plot_00_simplest_example.py @@ -0,0 +1,23 @@ +""" +The Simplest View +================= + +The simplest example: We make a view of a line! Views can be created quickly +using :meth:`matplotview.view` . +""" + +from matplotview import view +import matplotlib.pyplot as plt + +fig, (ax1, ax2) = plt.subplots(1, 2) + +# Plot a line in the first axes. +ax1.plot([i for i in range(10)], "-o") + +# Create a view! Turn axes 2 into a view of axes 1. +view(ax2, ax1) +# Modify the second axes data limits so we get a slightly zoomed out view +ax2.set_xlim(-5, 15) +ax2.set_ylim(-5, 15) + +fig.show() \ No newline at end of file diff --git a/examples/plot_01_multiple_artist_view.py b/examples/plot_01_multiple_artist_view.py new file mode 100644 index 0000000..0f3a649 --- /dev/null +++ b/examples/plot_01_multiple_artist_view.py @@ -0,0 +1,29 @@ +""" +A View With Several Plot Elements +================================= + +A simple example with an assortment of plot elements. +""" + +from matplotview import view +import matplotlib.pyplot as plt +import numpy as np + +fig, (ax1, ax2) = plt.subplots(1, 2) + +# Plot a line, circle patch, some text, and an image... +ax1.plot([i for i in range(10)], "r") +ax1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) +ax1.text(10, 10, "Hello World!", size=20) +ax1.imshow(np.random.rand(30, 30), origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + +# Turn axes 2 into a view of axes 1. +view(ax2, ax1) +# Modify the second axes data limits to match the first axes... +ax2.set_aspect(ax1.get_aspect()) +ax2.set_xlim(ax1.get_xlim()) +ax2.set_ylim(ax1.get_ylim()) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_02_simple_inset_view.py b/examples/plot_02_simple_inset_view.py new file mode 100644 index 0000000..0995e07 --- /dev/null +++ b/examples/plot_02_simple_inset_view.py @@ -0,0 +1,43 @@ +""" +Create An Inset Axes Without Plotting Twice +=========================================== + +:meth:`matplotview.inset_zoom_axes` can be utilized to create inset axes where we +don't have to plot the parent axes data twice. +""" + +from matplotlib import cbook +import matplotlib.pyplot as plt +import numpy as np +from matplotview import inset_zoom_axes + +def get_demo_image(): + z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) + # z is a numpy array of 15x15 + return z, (-3, 4, -4, 3) + +fig, ax = plt.subplots() + +# Make the data... +Z, extent = get_demo_image() +Z2 = np.zeros((150, 150)) +ny, nx = Z.shape +Z2[30:30+ny, 30:30+nx] = Z + +ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") + +# Creates an inset axes with automatic view of the parent axes... +axins = inset_zoom_axes(ax, [0.5, 0.5, 0.47, 0.47]) +# Set limits to sub region of the original image +x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 +axins.set_xlim(x1, x2) +axins.set_ylim(y1, y2) + +# Remove the tick labels from the inset axes +axins.set_xticklabels([]) +axins.set_yticklabels([]) + +# Draw the indicator or zoom lines. +ax.indicate_inset_zoom(axins, edgecolor="black") + +fig.show() \ No newline at end of file diff --git a/examples/plot_03_view_with_annotations.py b/examples/plot_03_view_with_annotations.py new file mode 100644 index 0000000..c89c5c2 --- /dev/null +++ b/examples/plot_03_view_with_annotations.py @@ -0,0 +1,48 @@ +""" +View With Annotations +===================== + +Matplotview's views are also regular matplotlib `Axes `_, +meaning they support regular plotting on top of their viewing capabilities, allowing +for annotations, as shown below. +""" + +# All the same as from the prior inset axes example... +from matplotlib import cbook +import matplotlib.pyplot as plt +import numpy as np +from matplotview import inset_zoom_axes + + +def get_demo_image(): + z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) + return z, (-3, 4, -4, 3) + + +fig, ax = plt.subplots() + +Z, extent = get_demo_image() +Z2 = np.zeros((150, 150)) +ny, nx = Z.shape +Z2[30:30 + ny, 30:30 + nx] = Z + +ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") + +axins = inset_zoom_axes(ax, [0.5, 0.5, 0.47, 0.47]) + +x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 +axins.set_xlim(x1, x2) +axins.set_ylim(y1, y2) + +# We'll annotate the 'interesting' spot in the view.... +axins.annotate( + "Interesting Feature", (-1.3, -2.25), (0.1, 0.1), + textcoords="axes fraction", arrowprops=dict(arrowstyle="->") +) + +axins.set_xticklabels([]) +axins.set_yticklabels([]) + +ax.indicate_inset_zoom(axins, edgecolor="black") + +fig.show() \ No newline at end of file diff --git a/examples/plot_04_sierpinski_triangle.py b/examples/plot_04_sierpinski_triangle.py new file mode 100644 index 0000000..18dae70 --- /dev/null +++ b/examples/plot_04_sierpinski_triangle.py @@ -0,0 +1,49 @@ +""" +SierpiƄski Triangle With Recursive Views +======================================== + +Matplotview's views support recursive drawing of other views and themselves to a +configurable depth. This feature allows matplotview to be used to generate fractals, +such as a sierpiƄski triangle as shown in the following example. +""" + +import matplotlib.pyplot as plt +import matplotview as mpv +from matplotlib.patches import PathPatch +from matplotlib.path import Path +from matplotlib.transforms import Affine2D + +# We'll plot a white upside down triangle inside of black one, and then use +# 3 views to draw all the rest of the recursions of the sierpiƄski triangle. +outside_color = "black" +inner_color = "white" + +t = Affine2D().scale(-0.5) + +outer_triangle = Path.unit_regular_polygon(3) +inner_triangle = t.transform_path(outer_triangle) +b = outer_triangle.get_extents() + +fig, ax = plt.subplots(1) +ax.set_aspect(1) + +ax.add_patch(PathPatch(outer_triangle, fc=outside_color, ec=[0] * 4)) +ax.add_patch(PathPatch(inner_triangle, fc=inner_color, ec=[0] * 4)) +ax.set_xlim(b.x0, b.x1) +ax.set_ylim(b.y0, b.y1) + +ax_locs = [ + [0, 0, 0.5, 0.5], + [0.5, 0, 0.5, 0.5], + [0.25, 0.5, 0.5, 0.5] +] + +for loc in ax_locs: + # Here we limit the render depth to 6 levels in total for each zoom view.... + inax = mpv.inset_zoom_axes(ax, loc, render_depth=6) + inax.set_xlim(b.x0, b.x1) + inax.set_ylim(b.y0, b.y1) + inax.axis("off") + inax.patch.set_visible(False) + +fig.show() \ No newline at end of file diff --git a/examples/plot_05_3d_views.py b/examples/plot_05_3d_views.py new file mode 100644 index 0000000..d083998 --- /dev/null +++ b/examples/plot_05_3d_views.py @@ -0,0 +1,30 @@ +""" +Viewing 3D Axes +=============== + +Matplotview has built-in support for viewing 3D axes and plots. +""" +import matplotlib.pyplot as plt +import numpy as np +from matplotview import view + +X = Y = np.arange(-5, 5, 0.25) +X, Y = np.meshgrid(X, Y) +Z = np.sin(np.sqrt(X ** 2 + Y ** 2)) + +# Make some 3D plots... +fig, (ax1, ax2) = plt.subplots(1, 2, subplot_kw=dict(projection="3d")) + +# Plot our surface +ax1.plot_surface(X, Y, Z, cmap="plasma") + +# Axes 2 is now viewing axes 1. +view(ax2, ax1) + +# Update the limits, and set the elevation higher, so we get a better view of the inside of the surface. +ax2.view_init(elev=80) +ax2.set_xlim(-10, 10) +ax2.set_ylim(-10, 10) +ax2.set_zlim(-2, 2) + +fig.show() \ No newline at end of file diff --git a/examples/plot_06_polar_views.py b/examples/plot_06_polar_views.py new file mode 100644 index 0000000..1fcfe7b --- /dev/null +++ b/examples/plot_06_polar_views.py @@ -0,0 +1,30 @@ +""" +Viewing Polar Axes +================== + +Views also support viewing polar axes. +""" + +import numpy as np +import matplotlib.pyplot as plt +from matplotview import view + +# Create the data... +r = np.arange(0, 2, 0.01) +theta = 2 * np.pi * r + +fig, (ax, ax2) = plt.subplots(1, 2, subplot_kw=dict(projection='polar')) + +ax.plot(theta, r) +ax.set_rmax(2) +ax.set_rticks([0.5, 1, 1.5, 2]) # Less radial ticks +ax.set_rlabel_position(-22.5) # Move radial labels away from plotted line +# Include a grid +ax.grid(True) + +# ax2 is now zoomed in on ax. +view(ax2, ax) + +fig.tight_layout() + +fig.show() \ No newline at end of file diff --git a/examples/plot_07_geographic_viewing.py b/examples/plot_07_geographic_viewing.py new file mode 100644 index 0000000..1dbc310 --- /dev/null +++ b/examples/plot_07_geographic_viewing.py @@ -0,0 +1,30 @@ +""" +Viewing Geographic Projections +============================== + +Matplotview also works with matplotlib's built in geographic projections. +""" +import matplotlib.pyplot as plt +import numpy as np +from matplotview import view + +x = np.linspace(-2.5, 2.5, 20) +y = np.linspace(-1, 1, 20) +circ_gen = lambda: plt.Circle((1.5, 0.25), 0.7, ec="black", fc="blue") + +fig_test = plt.figure() + +# Plot in 2 seperate geographic projections... +ax_t1 = fig_test.add_subplot(1, 2, 1, projection="hammer") +ax_t2 = fig_test.add_subplot(1, 2, 2, projection="lambert") + +ax_t1.grid(True) +ax_t2.grid(True) + +ax_t1.plot(x, y) +ax_t1.add_patch(circ_gen()) + +view(ax_t2, ax_t1) + +fig_test.tight_layout() +fig_test.savefig("test7.png") diff --git a/examples/plot_08_viewing_2_axes.py b/examples/plot_08_viewing_2_axes.py new file mode 100644 index 0000000..16fdea6 --- /dev/null +++ b/examples/plot_08_viewing_2_axes.py @@ -0,0 +1,28 @@ +""" +Viewing Multiple Axes From A Single View +======================================== + +Views can view multiple axes at the same time, by simply calling :meth:`matplotview.view` multiple times. +""" +import matplotlib.pyplot as plt +from matplotview import view + +fig, (ax1, ax2, ax3) = plt.subplots(1, 3) + +# We'll plot 2 circles in axes 1 and 3. +ax1.add_patch(plt.Circle((1, 1), 1.5, ec="black", fc=(0, 0, 1, 0.5))) +ax3.add_patch(plt.Circle((3, 1), 1.5, ec="black", fc=(1, 0, 0, 0.5))) +for ax in (ax1, ax3): + ax.set_aspect(1) + ax.relim() + ax.autoscale_view() + +# Axes 2 is a view of 1 and 3 at the same time (view returns the axes it turns into a view...) +view(view(ax2, ax1), ax3) + +# Change data limits, so we can see the entire 'venn diagram' +ax2.set_aspect(1) +ax2.set_xlim(-0.5, 4.5) +ax2.set_ylim(-0.5, 2.5) + +fig.show() \ No newline at end of file diff --git a/examples/plot_09_artist_filtering.py b/examples/plot_09_artist_filtering.py new file mode 100644 index 0000000..e528fe4 --- /dev/null +++ b/examples/plot_09_artist_filtering.py @@ -0,0 +1,32 @@ +""" +Filtering Artists in a View +=========================== + +:meth:`matplotview.view` supports filtering out artist instances and types using the `filter_set` parameter, +which accepts an iterable of artists types and instances. +""" +import matplotlib.pyplot as plt +from matplotview import view + +fig, (ax1, ax2, ax3) = plt.subplots(3, 1) + +# Plot a line, circle patch, and some text in axes 1 +ax1.set_title("Original Plot") +ax1.plot([(i / 10) for i in range(10)], [(i / 10) for i in range(10)], "r") +ax1.add_patch(plt.Circle((0.5, 0.5), 0.25, ec="black", fc="blue")) +text = ax1.text(0.2, 0.2, "Hello World!", size=12) + +# Axes 2 is viewing axes 1, but filtering circles... +ax2.set_title("View Filtering Out Circles") +view(ax2, ax1, filter_set=[plt.Circle]) # We can pass artist types +ax2.set_xlim(ax1.get_xlim()) +ax2.set_ylim(ax1.get_ylim()) + +# Axes 3 is viewing axes 1, but filtering the text artist +ax3.set_title("View Filtering Out Just the Text Artist.") +view(ax3, ax1, filter_set=[text]) # We can also pass artist instances... +ax3.set_xlim(ax1.get_xlim()) +ax3.set_ylim(ax1.get_ylim()) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_10_line_scaling.py b/examples/plot_10_line_scaling.py new file mode 100644 index 0000000..9fe6179 --- /dev/null +++ b/examples/plot_10_line_scaling.py @@ -0,0 +1,29 @@ +""" +Disabling Line Scaling +====================== + +By default, matplotview scales the line thickness settings for lines and markers to match the zoom level. +This can be disabled via the `scale_lines` parameter of :meth:`matplotview.view`. +""" +import matplotlib.pyplot as plt +from matplotview import view + +fig, (ax1, ax2, ax3) = plt.subplots(3, 1) + +# Plot a line, and circle patch in axes 1 +ax1.set_title("Original Plot") +ax1.plot([(i / 10) for i in range(10)], [(i / 10) for i in range(10)], "r-") +ax1.add_patch(plt.Circle((0.5, 0.5), 0.1, ec="black", fc="blue")) + +ax2.set_title("Zoom View With Line Scaling") +view(ax2, ax1, scale_lines=True) # Default, line scaling is ON +ax2.set_xlim(0.33, 0.66) +ax2.set_ylim(0.33, 0.66) + +ax3.set_title("Zoom View Without Line Scaling") +view(ax3, ax1, scale_lines=False) # Line scaling is OFF +ax3.set_xlim(0.33, 0.66) +ax3.set_ylim(0.33, 0.66) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_11_image_interpolation.py b/examples/plot_11_image_interpolation.py new file mode 100644 index 0000000..06ecde5 --- /dev/null +++ b/examples/plot_11_image_interpolation.py @@ -0,0 +1,52 @@ +""" +Image Interpolation Methods +=========================== + +:meth:`matplotview.view` and :meth:`matplotview.inset_zoom_axes` support specifying an +image interpolation method via the `image_interpolation` parameter. This image interpolation +method is used to resize images when displaying them in the view. +""" +import matplotlib.pyplot as plt +from matplotview import view +import numpy as np + +fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) + +fig.suptitle("Different interpolations when zoomed in on the bottom left corner.") + +ax1.set_title("Original") +ax1.imshow(np.random.rand(100, 100), cmap="Blues", origin="lower") +ax1.add_patch(plt.Rectangle((0, 0), 10, 10, ec="red", fc=(0, 0, 0, 0))) + +for ax, interpolation, title in zip([ax2, ax3, ax4], ["nearest", "bilinear", "bicubic"], ["Nearest (Default)", "Bilinear", "Cubic"]): + ax.set_title(title) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.set_aspect("equal") + view(ax, ax1, image_interpolation=interpolation) + +fig.tight_layout() +fig.show() + +#%% +# If you want to avoid interpolation artifacts, you can use `pcolormesh` instead of `imshow`. + +import matplotlib.pyplot as plt +from matplotview import view +import numpy as np + +fig, (ax1, ax2) = plt.subplots(1, 2) + +ax1.set_title("Original") +ax1.pcolormesh(np.random.rand(100, 100), cmap="Blues") +ax1.add_patch(plt.Rectangle((0, 0), 10, 10, ec="red", fc=(0, 0, 0, 0))) +ax1.set_aspect("equal") + +ax2.set_title("Zoomed in View") +ax2.set_xlim(0, 10) +ax2.set_ylim(0, 10) +ax2.set_aspect("equal") +view(ax2, ax1) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_12_editing_view_properties.py b/examples/plot_12_editing_view_properties.py new file mode 100644 index 0000000..b5cc3cb --- /dev/null +++ b/examples/plot_12_editing_view_properties.py @@ -0,0 +1,37 @@ +""" +Editing View Properties +======================= + +A view's properties can be edited by simply calling :meth:`matplotview.view` with the same axes arguments. +To stop a viewing, :meth:`matplotview.stop_viewing` can be used. +""" +import matplotlib.pyplot as plt +from matplotview import view, stop_viewing + +fig, (ax1, ax2, ax3) = plt.subplots(3, 1) + +# Plot a line, and circle patch in axes 1 +ax1.set_title("Original Plot") +ax1.plot([(i / 10) for i in range(10)], [(i / 10) for i in range(10)], "r-") +ax1.add_patch(plt.Circle((0.5, 0.5), 0.1, ec="black", fc="blue")) + +ax2.set_title("An Edited View") +# Ask ax2 to view ax1. +view(ax2, ax1, filter_set=[plt.Circle]) +ax2.set_xlim(0.33, 0.66) +ax2.set_ylim(0.33, 0.66) + +# Does not create a new view as ax2 is already viewing ax1. +# Edit ax2's viewing of ax1, remove filtering and disable line scaling. +view(ax2, ax1, filter_set=None, scale_lines=False) + +ax3.set_title("A Stopped View") +view(ax3, ax1) # Ask ax3 to view ax1. +ax3.set_xlim(0.33, 0.66) +ax3.set_ylim(0.33, 0.66) + +# This makes ax3 stop viewing ax1. +stop_viewing(ax3, ax1) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/matplotview/__init__.py b/matplotview/__init__.py index f848069..65ddb1c 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -87,7 +87,7 @@ def stop_viewing(view: Axes, axes_of_viewing: Axes) -> Axes: Parameters ---------- view: Axes - The axes the is currently viewing the axes_of_viewing... + The axes the is currently viewing the `axes_of_viewing`... axes_of_viewing: Axes The axes that the view should stop viewing. @@ -100,7 +100,7 @@ def stop_viewing(view: Axes, axes_of_viewing: Axes) -> Axes: Raises ------ AttributeError - If the provided axes_of_viewing is not actually being viewed by the specified view. + If the provided `axes_of_viewing` is not actually being viewed by the specified view. See Also -------- diff --git a/setup.py b/setup.py index aa41aa2..b62ee79 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import setuptools -VERSION = "0.2.0" +VERSION = "1.0.0" with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() From 06d3b660633fb29488dc82d788d8f299bf9ce9ee Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Mon, 15 Aug 2022 15:37:53 -0400 Subject: [PATCH 47/78] Flake8 fixes. --- matplotview/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 65ddb1c..7f5bc07 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -64,12 +64,14 @@ def view( Returns ------- axes - The modified `~.axes.Axes` instance which is now a view. The modification occurs in-place. + The modified `~.axes.Axes` instance which is now a view. + The modification occurs in-place. See Also -------- matplotview.stop_viewing: Delete or stop an already constructed view. - matplotview.inset_zoom_axes: Convenience method for creating inset axes that are views of the parent axes. + matplotview.inset_zoom_axes: Convenience method for creating inset axes + that are views of the parent axes. """ view_obj = view_wrapper(type(axes)).from_axes(axes, render_depth) view_obj.view_specifications[axes_to_view] = ViewSpecification( @@ -100,7 +102,8 @@ def stop_viewing(view: Axes, axes_of_viewing: Axes) -> Axes: Raises ------ AttributeError - If the provided `axes_of_viewing` is not actually being viewed by the specified view. + If the provided `axes_of_viewing` is not actually being + viewed by the specified view. See Also -------- From 400e3ab54a6e4b5db4cad7e5693454b3e93b1d1c Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Mon, 15 Aug 2022 15:48:57 -0400 Subject: [PATCH 48/78] Fix README.md --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 764386a..a156e57 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,12 @@ pip install matplotview ## Examples -See the documentation +Examples can be found in the example gallery: + [https://matplotview.readthedocs.io/en/latest/examples/index.html](https://matplotview.readthedocs.io/en/latest/examples/index.html) -## Documentation and Usage +## Documentation +Additional documentation can be found at the link below: + [https://matplotview.readthedocs.io/en/latest/](https://matplotview.readthedocs.io/en/latest/) From 02b77c1dc8247a9bfefdf935abc8112a71f517e4 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Mon, 15 Aug 2022 15:49:38 -0400 Subject: [PATCH 49/78] Fix README.md --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a156e57..0a03f53 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,13 @@ pip install matplotview ## Examples Examples can be found in the example gallery: - [https://matplotview.readthedocs.io/en/latest/examples/index.html](https://matplotview.readthedocs.io/en/latest/examples/index.html) + +[https://matplotview.readthedocs.io/en/latest/examples/index.html](https://matplotview.readthedocs.io/en/latest/examples/index.html) ## Documentation Additional documentation can be found at the link below: - [https://matplotview.readthedocs.io/en/latest/](https://matplotview.readthedocs.io/en/latest/) + +[https://matplotview.readthedocs.io/en/latest/](https://matplotview.readthedocs.io/en/latest/) From ce1a7533e66a597afc765684c2df9c970c3c80f1 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Mon, 15 Aug 2022 16:06:36 -0400 Subject: [PATCH 50/78] Add docs requirements.txt --- docs/requirements.txt | 64 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/requirements.txt diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..6f330aa --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,64 @@ +alabaster==0.7.12 +attrs==21.4.0 +Babel==2.10.3 +bleach==4.1.0 +build==0.7.0 +certifi==2021.10.8 +cffi==1.15.0 +charset-normalizer==2.0.10 +colorama==0.4.4 +cryptography==36.0.1 +cycler==0.11.0 +docutils==0.18.1 +flake8==4.0.1 +fonttools==4.28.5 +idna==3.3 +imagesize==1.4.1 +importlib-metadata==4.10.1 +iniconfig==1.1.1 +jeepney==0.7.1 +Jinja2==3.1.2 +keyring==23.5.0 +kiwisolver==1.3.2 +MarkupSafe==2.1.1 +matplotlib==3.5.3 +matplotview==0.2.0 +mccabe==0.6.1 +numpy==1.22.1 +numpydoc==1.4.0 +packaging==21.3 +pep517==0.12.0 +Pillow==9.0.0 +pkginfo==1.8.2 +pluggy==1.0.0 +py==1.11.0 +pycodestyle==2.8.0 +pycparser==2.21 +pyflakes==2.4.0 +Pygments==2.11.2 +pyparsing==3.0.6 +pytest==6.2.5 +python-dateutil==2.8.2 +pytz==2022.2.1 +readme-renderer==32.0 +requests==2.27.1 +requests-toolbelt==0.9.1 +rfc3986==2.0.0 +SecretStorage==3.3.1 +six==1.16.0 +snowballstemmer==2.2.0 +Sphinx==5.1.1 +sphinx-gallery==0.11.0 +sphinxcontrib-applehelp==1.0.2 +sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-htmlhelp==2.0.0 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-serializinghtml==1.1.5 +toml==0.10.2 +tomli==2.0.0 +tqdm==4.62.3 +twine==3.7.1 +urllib3==1.26.8 +webencodings==0.5.1 +zipp==3.7.0 From 8a5532e410de15866efdb686def8b198c8affb46 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Mon, 15 Aug 2022 16:12:01 -0400 Subject: [PATCH 51/78] Fix requirements again... --- docs/requirements.txt | 77 ++++++++----------------------------------- 1 file changed, 13 insertions(+), 64 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 6f330aa..9fb3f41 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,64 +1,13 @@ -alabaster==0.7.12 -attrs==21.4.0 -Babel==2.10.3 -bleach==4.1.0 -build==0.7.0 -certifi==2021.10.8 -cffi==1.15.0 -charset-normalizer==2.0.10 -colorama==0.4.4 -cryptography==36.0.1 -cycler==0.11.0 -docutils==0.18.1 -flake8==4.0.1 -fonttools==4.28.5 -idna==3.3 -imagesize==1.4.1 -importlib-metadata==4.10.1 -iniconfig==1.1.1 -jeepney==0.7.1 -Jinja2==3.1.2 -keyring==23.5.0 -kiwisolver==1.3.2 -MarkupSafe==2.1.1 -matplotlib==3.5.3 -matplotview==0.2.0 -mccabe==0.6.1 -numpy==1.22.1 -numpydoc==1.4.0 -packaging==21.3 -pep517==0.12.0 -Pillow==9.0.0 -pkginfo==1.8.2 -pluggy==1.0.0 -py==1.11.0 -pycodestyle==2.8.0 -pycparser==2.21 -pyflakes==2.4.0 -Pygments==2.11.2 -pyparsing==3.0.6 -pytest==6.2.5 -python-dateutil==2.8.2 -pytz==2022.2.1 -readme-renderer==32.0 -requests==2.27.1 -requests-toolbelt==0.9.1 -rfc3986==2.0.0 -SecretStorage==3.3.1 -six==1.16.0 -snowballstemmer==2.2.0 -Sphinx==5.1.1 -sphinx-gallery==0.11.0 -sphinxcontrib-applehelp==1.0.2 -sphinxcontrib-devhelp==1.0.2 -sphinxcontrib-htmlhelp==2.0.0 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.3 -sphinxcontrib-serializinghtml==1.1.5 -toml==0.10.2 -tomli==2.0.0 -tqdm==4.62.3 -twine==3.7.1 -urllib3==1.26.8 -webencodings==0.5.1 -zipp==3.7.0 +matplotlib +numpy +sphinx +sphinx-gallery +sphinxcontrib-applehelp +sphinxcontrib-devhelp +sphinxcontrib-htmlhelp +sphinxcontrib-jsmath +sphinxcontrib-qthelp +sphinxcontrib-serializinghtml +numpy +numpydoc +alabaster \ No newline at end of file From 66e02137fea4e89cbc8c141294bb6958e1ad78a4 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 16 Aug 2022 08:48:32 -0400 Subject: [PATCH 52/78] Fix typo in docs. --- matplotview/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 7f5bc07..19c0c37 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -89,7 +89,7 @@ def stop_viewing(view: Axes, axes_of_viewing: Axes) -> Axes: Parameters ---------- view: Axes - The axes the is currently viewing the `axes_of_viewing`... + The axes that is currently viewing the `axes_of_viewing`... axes_of_viewing: Axes The axes that the view should stop viewing. From f7e1e587f27fe138a93406e4da542784d949a7df Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 16 Aug 2022 09:11:58 -0400 Subject: [PATCH 53/78] Add test to test stop_viewing... --- matplotview/_docs.py | 3 +-- matplotview/tests/test_view_rendering.py | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/matplotview/_docs.py b/matplotview/_docs.py index ec5a324..acea1f2 100644 --- a/matplotview/_docs.py +++ b/matplotview/_docs.py @@ -1,11 +1,10 @@ import inspect -from inspect import signature def dynamic_doc_string(**kwargs): def convert(func): default_vals = { - k: v.default for k, v in signature(func).parameters.items() + k: v.default for k, v in inspect.signature(func).parameters.items() if(v.default is not inspect.Parameter.empty) } default_vals.update(kwargs) diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index 0e3ea66..03b9547 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -1,7 +1,7 @@ import numpy as np import matplotlib.pyplot as plt from matplotlib.testing.decorators import check_figures_equal -from matplotview import view, inset_zoom_axes +from matplotview import view, inset_zoom_axes, stop_viewing @check_figures_equal(tol=6) @@ -219,3 +219,24 @@ def test_double_view(fig_test, fig_ref): ax.set_aspect(1) ax.relim() ax.autoscale_view() + + +@check_figures_equal() +def test_stop_viewing(fig_test, fig_ref): + np.random.seed(1) + data = np.random.randint(0, 10, 10) + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.plot(data) + ax1_test.text(0.5, 0.5, "Hello") + + view(ax2_test, ax1_test) + stop_viewing(ax2_test, ax1_test) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.plot(data) + ax1_ref.text(0.5, 0.5, "Hello") From 3b754b52a35e12e65e23f1344d75dd234e7661f7 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Fri, 2 Sep 2022 14:07:34 -0400 Subject: [PATCH 54/78] Remove depreciated doc decorator. --- matplotview/_view_axes.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 91c5e4b..c9162ea 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -3,7 +3,6 @@ from typing import Type, List, Optional, Any, Set, Dict, Union from matplotlib.axes import Axes from matplotlib.transforms import Bbox -import matplotlib.docstring as docstring from matplotview._transform_renderer import _TransformRenderer from matplotlib.artist import Artist from matplotlib.backend_bases import RendererBase @@ -166,7 +165,6 @@ def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: if(issubclass(axes_class, Axes) and issubclass(axes_class, __ViewType)): return axes_class - @docstring.interpd class View(axes_class, __ViewType): """ An axes which automatically displays elements of another axes. Does not @@ -197,9 +195,7 @@ def __init__( **kwargs Other optional keyword arguments supported by the Axes - constructor this ViewAxes wraps: - - %(Axes:kwdoc)s + constructor this ViewAxes wraps. Returns ------- From 2e590e776719dfd7ee7d71941b6b2ddb2bb6939e Mon Sep 17 00:00:00 2001 From: LGTM Migrator Date: Thu, 10 Nov 2022 10:49:50 +0000 Subject: [PATCH 55/78] Add CodeQL workflow for GitHub code scanning --- .github/workflows/codeql.yml | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6135e19 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "15 9 * * 3" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" From 5b7da5f8a169e10afa1f50a2d88bd3f61559a8e4 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Thu, 2 Mar 2023 10:06:50 -0500 Subject: [PATCH 56/78] Update and fix tests for 3.7.0. --- matplotview/tests/test_view_obj.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py index 6210e63..2562188 100644 --- a/matplotview/tests/test_view_obj.py +++ b/matplotview/tests/test_view_obj.py @@ -8,6 +8,9 @@ def test_obj_comparison(): from matplotlib.axes import Subplot, Axes + import matplotlib + + mpl_version = tuple(int(v) for v in matplotlib.__version__.split(".")) view_class1 = view_wrapper(Subplot) view_class2 = view_wrapper(Subplot) @@ -15,7 +18,11 @@ def test_obj_comparison(): assert view_class1 is view_class2 assert view_class1 == view_class2 - assert view_class2 != view_class3 + if(mpl_version >= (3, 7, 0)): + # As of 3.7.0, the subplot class no long exists, and is an alias to the Axes class... + assert view_class2 == view_class3 + else: + assert view_class2 != view_class3 @check_figures_equal(tol=5.6) From 471f3e8aa3037d86408180bdb7f4348bce929d7c Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Wed, 12 Jul 2023 09:01:53 -0400 Subject: [PATCH 57/78] Mitigate to readthedocs v2. --- .readthedocs.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..e88e6c7 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt From b6f03a1e5861d36d526882c5cdcb1127b370c90a Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 5 Sep 2023 22:20:09 -0400 Subject: [PATCH 58/78] Fix testing to properly support rc releases of matplotlib. Remove depreciated apis from tests. --- matplotview/tests/test_view_obj.py | 6 +++--- matplotview/tests/utils.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py index 2562188..5eb6067 100644 --- a/matplotview/tests/test_view_obj.py +++ b/matplotview/tests/test_view_obj.py @@ -10,7 +10,7 @@ def test_obj_comparison(): from matplotlib.axes import Subplot, Axes import matplotlib - mpl_version = tuple(int(v) for v in matplotlib.__version__.split(".")) + mpl_version = tuple(int(v) for v in matplotlib.__version__.split(".")[:2]) view_class1 = view_wrapper(Subplot) view_class2 = view_wrapper(Subplot) @@ -18,8 +18,8 @@ def test_obj_comparison(): assert view_class1 is view_class2 assert view_class1 == view_class2 - if(mpl_version >= (3, 7, 0)): - # As of 3.7.0, the subplot class no long exists, and is an alias to the Axes class... + if(mpl_version >= (3, 7)): + # As of 3.7.0, the subplot class no longer exists, and is an alias to the Axes class... assert view_class2 == view_class3 else: assert view_class2 != view_class3 diff --git a/matplotview/tests/utils.py b/matplotview/tests/utils.py index 812eb84..8ca3f56 100644 --- a/matplotview/tests/utils.py +++ b/matplotview/tests/utils.py @@ -4,8 +4,8 @@ def figure_to_image(figure): figure.canvas.draw() - img = np.frombuffer(figure.canvas.tostring_rgb(), dtype=np.uint8) - return img.reshape(figure.canvas.get_width_height()[::-1] + (3,)) + img = np.frombuffer(figure.canvas.buffer_rgba(), dtype=np.uint8) + return img.reshape(figure.canvas.get_width_height()[::-1] + (4,))[..., :3] def matches_post_pickle(figure): From dab9d62cbfdafaee1a02b8dd548e574b75f16394 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Tue, 5 Sep 2023 22:46:07 -0400 Subject: [PATCH 59/78] Update workflows to test against same python versions as matplotlib. --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index f4bda9b..01d8516 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - python-version: ["3.7", "3.8", "3.9"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 From 81a363558e9742213175fdddf588a580c948d815 Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Wed, 6 Sep 2023 08:43:14 -0400 Subject: [PATCH 60/78] Update workflows to test against same python versions as matplotlib. --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 01d8516..eed6181 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -61,7 +61,7 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: [ "3.7", "3.8", "3.9" ] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 From e23934cca6104344ce1238c094b27a65d87a613b Mon Sep 17 00:00:00 2001 From: Isaac Robinson Date: Wed, 6 Sep 2023 08:46:48 -0400 Subject: [PATCH 61/78] Update workflows to test against same python versions as matplotlib. --- .github/workflows/pytest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index eed6181..83075c8 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 @@ -61,7 +61,7 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 From 6e3bca8b1834f39bb3bfe2d4ebdc98f8373c45b3 Mon Sep 17 00:00:00 2001 From: isaacrobinson2000 Date: Fri, 15 Dec 2023 11:28:41 -0500 Subject: [PATCH 62/78] PEP-8 Compliance, support for latest matplotlib version (3.8) --- matplotview/_docs.py | 4 ++-- matplotview/_transform_renderer.py | 28 ++++++++++++++-------------- matplotview/_view_axes.py | 29 ++++++++++++++--------------- matplotview/tests/test_view_obj.py | 5 +++-- setup.py | 2 +- 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/matplotview/_docs.py b/matplotview/_docs.py index acea1f2..5753df5 100644 --- a/matplotview/_docs.py +++ b/matplotview/_docs.py @@ -5,7 +5,7 @@ def dynamic_doc_string(**kwargs): def convert(func): default_vals = { k: v.default for k, v in inspect.signature(func).parameters.items() - if(v.default is not inspect.Parameter.empty) + if (v.default is not inspect.Parameter.empty) } default_vals.update(kwargs) func.__doc__ = func.__doc__.format(**default_vals) @@ -18,6 +18,6 @@ def convert(func): def get_interpolation_list_str(): from matplotlib.image import _interpd_ return ", ".join([ - f"'{k}'" if(i != len(_interpd_) - 1) else f"or '{k}'" + f"'{k}'" if (i != len(_interpd_) - 1) else f"or '{k}'" for i, k in enumerate(_interpd_) ]) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index 8c6e747..a61d1ba 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -105,7 +105,7 @@ def _scale_gc(self, gc: GraphicsContextBase) -> GraphicsContextBase: unit_box = transfer_transform.transform_bbox(unit_box) mult_factor = np.sqrt(unit_box.width * unit_box.height) - if(mult_factor == 0 or (not np.isfinite(mult_factor))): + if (mult_factor == 0 or (not np.isfinite(mult_factor))): return new_gc new_gc.set_linewidth(gc.get_linewidth() * mult_factor) @@ -218,18 +218,18 @@ def draw_path( # We check if the path intersects the axes box at all, if not don't # waste time drawing it. - if(not path.intersects_bbox(bbox, True)): + if (not path.intersects_bbox(bbox, True)): return - if(self.__scale_widths): + if (self.__scale_widths): gc = self._scale_gc(gc) # Change the clip to the sub-axes box gc.set_clip_rectangle(bbox) - if(not isinstance(self.__bounding_axes.patch, Rectangle)): + if (not isinstance(self.__bounding_axes.patch, Rectangle)): gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) - rgbFace = tuple(rgbFace) if(rgbFace is not None) else None + rgbFace = tuple(rgbFace) if (rgbFace is not None) else None self.__renderer.draw_path(gc, path, IdentityTransform(), rgbFace) @@ -244,7 +244,7 @@ def _draw_text_as_path( ismath: bool ): # If the text field is empty, don't even try rendering it... - if((s is None) or (s.strip() == "")): + if ((s is None) or (s.strip() == "")): return # Call the super class instance, which works for all cases except one @@ -264,14 +264,14 @@ def draw_gouraud_triangle( path = Path(points, closed=True) bbox = self._get_axes_display_box() - if(not path.intersects_bbox(bbox, True)): + if (not path.intersects_bbox(bbox, True)): return - if(self.__scale_widths): + if (self.__scale_widths): gc = self._scale_gc(gc) gc.set_clip_rectangle(bbox) - if(not isinstance(self.__bounding_axes.patch, Rectangle)): + if (not isinstance(self.__bounding_axes.patch, Rectangle)): gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) self.__renderer.draw_gouraud_triangle(gc, path.vertices, colors, @@ -299,7 +299,7 @@ def draw_image( out_box = img_bbox_disp.transformed(shift_data_transform) clipped_out_box = Bbox.intersection(out_box, axes_bbox) - if(clipped_out_box is None): + if (clipped_out_box is None): return # We compute what the dimensions of the final output image within the @@ -307,7 +307,7 @@ def draw_image( x, y, out_w, out_h = clipped_out_box.bounds out_w, out_h = int(np.ceil(out_w * mag)), int(np.ceil(out_h * mag)) - if((out_w <= 0) or (out_h <= 0)): + if ((out_w <= 0) or (out_h <= 0)): return # We can now construct the transform which converts between the @@ -330,16 +330,16 @@ def draw_image( alpha=1) out_arr[:, :, 3] = trans_msk - if(self.__scale_widths): + if (self.__scale_widths): gc = self._scale_gc(gc) gc.set_clip_rectangle(clipped_out_box) - if(not isinstance(self.__bounding_axes.patch, Rectangle)): + if (not isinstance(self.__bounding_axes.patch, Rectangle)): gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) x, y = clipped_out_box.x0, clipped_out_box.y0 - if(self.option_scale_image()): + if (self.option_scale_image()): self.__renderer.draw_image(gc, x, y, out_arr, None) else: self.__renderer.draw_image(gc, x, y, out_arr) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index c9162ea..f0b47a9 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -53,14 +53,14 @@ def draw(self, renderer: RendererBase): # If we are working with a 3D object, swap out it's axes with # this zoom axes (swapping out the 3d transform) and reproject it. - if(hasattr(self._artist, "do_3d_projection")): + if (hasattr(self._artist, "do_3d_projection")): self.do_3d_projection() # Check and see if the passed limiting box and extents of the # artist intersect, if not don't bother drawing this artist. # First 2 checks are a special case where we received a bad clip box. # (those can happen when we try to get the bounds of a map projection) - if( + if ( self._clip_box.width == 0 or self._clip_box.height == 0 or Bbox.intersection(full_extents, self._clip_box) is not None ): @@ -128,7 +128,7 @@ class ViewSpecification: def __post_init__(self): self.image_interpolation = str(self.image_interpolation) - if(self.filter_set is not None): + if (self.filter_set is not None): self.filter_set = set(self.filter_set) self.scale_lines = bool(self.scale_lines) @@ -138,7 +138,6 @@ class __ViewType: PRIVATE: A simple identifier class for identifying view types, a view will inherit from the axes class it is wrapping and this type... """ - ... # Cache classes so grabbing the same type twice leads to actually getting the @@ -162,7 +161,7 @@ def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: another axes contents... """ # If the passed class is a view, simply return it. - if(issubclass(axes_class, Axes) and issubclass(axes_class, __ViewType)): + if (issubclass(axes_class, Axes) and issubclass(axes_class, __ViewType)): return axes_class class View(axes_class, __ViewType): @@ -228,14 +227,14 @@ def get_children(self) -> List[Artist]: child_list = super().get_children() def filter_check(artist, filter_set): - if(filter_set is None): + if (filter_set is None): return True return ( (artist not in filter_set) and (type(artist) not in filter_set) ) - if(self.__renderer is not None): + if (self.__renderer is not None): for ax, spec in self.view_specifications.items(): mock_renderer = _TransformRenderer( self.__renderer, ax.transData, self.transData, @@ -253,7 +252,7 @@ def filter_check(artist, filter_set): for a in itertools.chain( ax._children, ax.child_axes - ) if(filter_check(a, spec.filter_set)) + ) if (filter_check(a, spec.filter_set)) ]) return child_list @@ -262,7 +261,7 @@ def draw(self, renderer: RendererBase = None): # It is possible to have two axes which are views of each other # therefore we track the number of recursions and stop drawing # at a certain depth - if(self.figure._current_render_depth >= self.__max_render_depth): + if (self.figure._current_render_depth >= self.__max_render_depth): return self.figure._current_render_depth += 1 # Set the renderer, causing get_children to return the view's @@ -281,7 +280,7 @@ def __reduce__(self): cls = type(self) args = tuple( - arg if(arg != cls) else cls.__bases__[0] for arg in args + arg if (arg != cls) else cls.__bases__[0] for arg in args ) return ( @@ -318,7 +317,7 @@ def set_max_render_depth(self, val: int): allow. Zero and negative values are invalid, and will raise a ValueError. """ - if(val <= 0): + if (val <= 0): raise ValueError(f"Render depth must be positive, not {val}.") self.__max_render_depth = val @@ -376,12 +375,12 @@ def from_axes( If the provided axes to convert has an Axes type which does not match the axes class this view type wraps. """ - if(isinstance(axes, cls)): - if(render_depth is not None): + if (isinstance(axes, cls)): + if (render_depth is not None): axes.set_max_render_depth(render_depth) return axes - if(type(axes) != axes_class): + if (type(axes) is not axes_class): raise TypeError( f"Can't convert {type(axes).__name__} to {cls.__name__}" ) @@ -389,7 +388,7 @@ def from_axes( axes.__class__ = cls axes._init_vars( DEFAULT_RENDER_DEPTH - if(render_depth is None) + if (render_depth is None) else render_depth ) return axes diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py index 5eb6067..c356664 100644 --- a/matplotview/tests/test_view_obj.py +++ b/matplotview/tests/test_view_obj.py @@ -18,8 +18,9 @@ def test_obj_comparison(): assert view_class1 is view_class2 assert view_class1 == view_class2 - if(mpl_version >= (3, 7)): - # As of 3.7.0, the subplot class no longer exists, and is an alias to the Axes class... + if (mpl_version >= (3, 7)): + # As of 3.7.0, the subplot class no longer exists, and is an alias + # to the Axes class... assert view_class2 == view_class3 else: assert view_class2 != view_class3 diff --git a/setup.py b/setup.py index b62ee79..2f0e9c4 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import setuptools -VERSION = "1.0.0" +VERSION = "1.0.1" with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() From 011c9cea3a5f4dd9a014bda6794779c4fa590963 Mon Sep 17 00:00:00 2001 From: Isaac Robinson <47544550+isaacrobinson2000@users.noreply.github.com> Date: Fri, 15 Dec 2023 11:38:27 -0500 Subject: [PATCH 63/78] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0a03f53..788bca6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # matplotview -#### A library for creating lightweight views of matplotlib axes. +#### A small library for creating lightweight views of matplotlib axes. matplotview provides a simple interface for creating "views" of matplotlib axes, providing a simple way of displaying overviews and zoomed views of From 523f5c2d3e061fdcd97f923ff97e5c04e4e04078 Mon Sep 17 00:00:00 2001 From: isaacrobinson2000 Date: Sun, 22 Sep 2024 19:44:29 -0400 Subject: [PATCH 64/78] Working with latest matplotlib, scale_lines also now scales markers. --- matplotview/_transform_renderer.py | 70 +++++++++++++++++++++++ matplotview/tests/test_view_rendering.py | 73 ++++++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index a61d1ba..6732636 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -251,6 +251,76 @@ def _draw_text_as_path( # checked above... (Above case causes error) super()._draw_text_as_path(gc, x, y, s, prop, angle, ismath) + def draw_markers( + self, + gc, + marker_path, + marker_trans, + path, + trans, + rgbFace = None, + ): + # If the markers need to be scaled accurately (such as in log scale), just use the fallback as each will need + # to be scaled separately. + if(self.__scale_widths): + super().draw_markers(gc, marker_path, marker_trans, path, trans, rgbFace) + return + + # Otherwise we transform just the marker offsets (not the marker patch), so they stay the same size. + path = path.deepcopy() + path.vertices = self._get_transfer_transform(trans).transform(path.vertices) + bbox = self._get_axes_display_box() + + # Change the clip to the sub-axes box + gc.set_clip_rectangle(bbox) + if (not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) + + rgbFace = tuple(rgbFace) if (rgbFace is not None) else None + self.__renderer.draw_markers(gc, marker_path, marker_trans, path, IdentityTransform(), rgbFace) + + def draw_path_collection( + self, + gc, + master_transform, + paths, + all_transforms, + offsets, + offset_trans, + facecolors, + edgecolors, + linewidths, + linestyles, + antialiaseds, + urls, + offset_position, + ): + # If we want accurate scaling for each marker (such as in log scale), just use superclass implementation... + if(self.__scale_widths): + super().draw_path_collection( + gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, + edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position + ) + return + + # Otherwise we transform just the offsets, and pass them to the backend. + print(offsets) + if(np.any(np.isnan(offsets))): + raise ValueError("???") + offsets = self._get_transfer_transform(offset_trans).transform(offsets) + print(offsets) + bbox = self._get_axes_display_box() + + # Change the clip to the sub-axes box + gc.set_clip_rectangle(bbox) + if (not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) + + self.__renderer.draw_path_collection( + gc, master_transform, paths, all_transforms, offsets, IdentityTransform(), facecolors, + edgecolors, linewidths, linestyles, antialiaseds, urls, None + ) + def draw_gouraud_triangle( self, gc: GraphicsContextBase, diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index 03b9547..bb9b744 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -240,3 +240,76 @@ def test_stop_viewing(fig_test, fig_ref): ax1_ref.plot(data) ax1_ref.text(0.5, 0.5, "Hello") + + +@check_figures_equal() +def test_log_line(fig_test, fig_ref): + data = [i for i in range(10)] + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.set(xscale="log", yscale="log") + ax1_test.plot(data, "-o") + + view(ax2_test, ax1_test, scale_lines=False) + ax2_test.set_xlim(-1, 10) + ax2_test.set_ylim(-1, 10) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.set(xscale="log", yscale="log") + ax1_ref.plot(data, "-o") + ax2_ref.plot(data, "-o") + ax2_ref.set_xlim(-1, 10) + ax2_ref.set_ylim(-1, 10) + + +@check_figures_equal() +def test_log_scatter(fig_test, fig_ref): + data = [i for i in range(1, 11)] + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.set(xscale="log", yscale="log") + ax1_test.scatter(data, data) + + view(ax2_test, ax1_test, scale_lines=False) + ax2_test.set_xlim(-5, 15) + ax2_test.set_ylim(-5, 15) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.set(xscale="log", yscale="log") + ax1_ref.scatter(data, data) + ax2_ref.scatter(data, data) + ax2_ref.set_xlim(-5, 15) + ax2_ref.set_ylim(-5, 15) + + +@check_figures_equal() +def test_log_scatter_with_colors(fig_test, fig_ref): + data = [i for i in range(1, 11)] + colors = list("rgbrgbrgbr") + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.set(xscale="log", yscale="log") + ax1_test.scatter(data, data, color=colors) + + view(ax2_test, ax1_test, scale_lines=False) + ax2_test.set_xlim(-5, 15) + ax2_test.set_ylim(-5, 15) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.set(xscale="log", yscale="log") + ax1_ref.scatter(data, data, color=colors) + ax2_ref.scatter(data, data, color=colors) + ax2_ref.set_xlim(-5, 15) + ax2_ref.set_ylim(-5, 15) From 9c059cb2aa09c17dcc441da47a6132c912488fbe Mon Sep 17 00:00:00 2001 From: isaacrobinson2000 Date: Sun, 22 Sep 2024 19:54:47 -0400 Subject: [PATCH 65/78] Minor adjustment so we don't have a marker at 0 on the log scale. --- matplotview/tests/test_view_rendering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index bb9b744..79c3eea 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -244,7 +244,7 @@ def test_stop_viewing(fig_test, fig_ref): @check_figures_equal() def test_log_line(fig_test, fig_ref): - data = [i for i in range(10)] + data = [i for i in range(1, 10)] # Test case... Create a view and stop it... ax1_test, ax2_test = fig_test.subplots(1, 2) From c260e6d9ad55ae5614c56ccf04501c04ba1ff92c Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 17 Jul 2025 23:08:39 -0400 Subject: [PATCH 66/78] CI: auto-fix via zizmor May include: - Avoids risky string interpolation. - Prevents checkout premissions from leaking --- .github/workflows/codeql.yml | 2 ++ .github/workflows/pytest.yml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6135e19..f070eb6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,6 +25,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 + with: + persist-credentials: false - name: Initialize CodeQL uses: github/codeql-action/init@v2 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 83075c8..ffa8201 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -21,6 +21,8 @@ jobs: steps: - uses: actions/checkout@v3 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: @@ -65,6 +67,8 @@ jobs: steps: - uses: actions/checkout@v3 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: From 947dfa04b1bf9156a16b5e9beab309f078c913b6 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 17 Jul 2025 23:24:56 -0400 Subject: [PATCH 67/78] CI: Restrict default permissions Reduces risk of arbitrary code is run by attacker. --- .github/workflows/pytest.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index ffa8201..c211b71 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,5 +1,7 @@ name: Validate Python Code +permissions: + contents: read on: push: From 3a2fcc1ea1c1e381ad66f4d7dc01b4b242bebef9 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 18 Jul 2025 11:31:15 -0400 Subject: [PATCH 68/78] CI: add dependabot config file for GHA --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fc9f855 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Location of your workflow files + schedule: + interval: "weekly" # Options: daily, weekly, monthly From 426821d0cfa8054d4c30d365b91806ae3ec0e0cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:10:55 +0000 Subject: [PATCH 69/78] Bump github/codeql-action from 2 to 3 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v2...v3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f070eb6..5e6e053 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,15 +29,15 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{ matrix.language }}" From 806f86ea1813d5ab209bc6851545e92ba88a694a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:10:57 +0000 Subject: [PATCH 70/78] Bump actions/setup-python from 3 to 5 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 3 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v3...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/pytest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c211b71..3d10f55 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -26,7 +26,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -72,7 +72,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} From 2b02497ab60ed78ab801c785e09d02e57a5034ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:11:00 +0000 Subject: [PATCH 71/78] Bump actions/checkout from 3 to 4 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- .github/workflows/pytest.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f070eb6..91ae017 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: persist-credentials: false diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c211b71..2026133 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -22,7 +22,7 @@ jobs: python-version: ["3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} @@ -68,7 +68,7 @@ jobs: python-version: ["3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} From a8cf1b415b75204f58c636839aa4812c11889053 Mon Sep 17 00:00:00 2001 From: isaacr Date: Fri, 18 Jul 2025 12:52:39 -0600 Subject: [PATCH 72/78] Add artifact upload on failure. --- .github/workflows/pytest.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 685d7b0..e8f7712 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -57,9 +57,18 @@ jobs: flake8 matplotview --count --select=E9,F63,F7,F82 --show-source --statistics flake8 matplotview --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest + id: pytest run: | pytest + - name: Upload images on failure + uses: actions/upload-artifact@v4 + if: ${{ failure() && steps.pytest.conclusion == 'failure' }} + with: + name: test-result-images + retention-days: 1 + path: result_images/ + test-windows: runs-on: windows-latest @@ -82,5 +91,14 @@ jobs: pip install pytest pip install -r requirements.txt - name: Test with pytest + id: pytest run: | - pytest \ No newline at end of file + pytest + + - name: Upload images on failure + uses: actions/upload-artifact@v4 + if: ${{ failure() && steps.pytest.conclusion == 'failure' }} + with: + name: test-result-images + retention-days: 1 + path: result_images/ From 351342b346f12074b26364e403531e2f3e941493 Mon Sep 17 00:00:00 2001 From: isaacr Date: Fri, 18 Jul 2025 13:12:36 -0600 Subject: [PATCH 73/78] Add small tolerance for macos --- .github/workflows/pytest.yml | 4 ++-- matplotview/tests/test_view_rendering.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index e8f7712..f8c31fa 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -19,7 +19,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.11", "3.12", "3.13", "3.13t"] steps: - uses: actions/checkout@v4 @@ -74,7 +74,7 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.11", "3.12", "3.13", "3.13t"] steps: - uses: actions/checkout@v4 diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index 79c3eea..32dbb14 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -1,3 +1,5 @@ +import sys + import numpy as np import matplotlib.pyplot as plt from matplotlib.testing.decorators import check_figures_equal @@ -241,8 +243,8 @@ def test_stop_viewing(fig_test, fig_ref): ax1_ref.plot(data) ax1_ref.text(0.5, 0.5, "Hello") - -@check_figures_equal() +# On MacOS the results are off by an extremely tiny amount, can't even see in diff. It's close enough... +@check_figures_equal(tol=0 if sys.platform.startswith("darwin") else 0.2) def test_log_line(fig_test, fig_ref): data = [i for i in range(1, 10)] From ad031e681330c3a95592aae205e5b857e0ea1515 Mon Sep 17 00:00:00 2001 From: isaacr Date: Fri, 18 Jul 2025 13:15:23 -0600 Subject: [PATCH 74/78] Add small tolerance for macos --- matplotview/tests/test_view_rendering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index 32dbb14..b348ce8 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -244,7 +244,7 @@ def test_stop_viewing(fig_test, fig_ref): ax1_ref.text(0.5, 0.5, "Hello") # On MacOS the results are off by an extremely tiny amount, can't even see in diff. It's close enough... -@check_figures_equal(tol=0 if sys.platform.startswith("darwin") else 0.2) +@check_figures_equal(tol=0.02) def test_log_line(fig_test, fig_ref): data = [i for i in range(1, 10)] From 3e94fa2b6d970642b022cc16c552ea87c37d0df5 Mon Sep 17 00:00:00 2001 From: isaacr Date: Fri, 18 Jul 2025 13:15:33 -0600 Subject: [PATCH 75/78] Add small tolerance for macos --- matplotview/tests/test_view_rendering.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index b348ce8..6e3ab02 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -1,5 +1,3 @@ -import sys - import numpy as np import matplotlib.pyplot as plt from matplotlib.testing.decorators import check_figures_equal From 0e560d958d84f07c9172e499c893efce8a68ab56 Mon Sep 17 00:00:00 2001 From: isaacr Date: Fri, 18 Jul 2025 13:16:42 -0600 Subject: [PATCH 76/78] Add small tolerance for macos --- matplotview/tests/test_view_rendering.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index 6e3ab02..5cafa7c 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -1,3 +1,5 @@ +import sys + import numpy as np import matplotlib.pyplot as plt from matplotlib.testing.decorators import check_figures_equal @@ -242,7 +244,7 @@ def test_stop_viewing(fig_test, fig_ref): ax1_ref.text(0.5, 0.5, "Hello") # On MacOS the results are off by an extremely tiny amount, can't even see in diff. It's close enough... -@check_figures_equal(tol=0.02) +@check_figures_equal(tol=0.02 if sys.platform.startswith("darwin") else 0) def test_log_line(fig_test, fig_ref): data = [i for i in range(1, 10)] From 4ca387b637474f5677f0ab7162cbee9c39000e4f Mon Sep 17 00:00:00 2001 From: isaacr Date: Fri, 18 Jul 2025 13:21:36 -0600 Subject: [PATCH 77/78] Fix some linting issues. --- matplotview/_transform_renderer.py | 8 ++++---- matplotview/tests/test_view_rendering.py | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index 6732636..4bfd180 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -258,11 +258,11 @@ def draw_markers( marker_trans, path, trans, - rgbFace = None, + rgbFace=None, ): # If the markers need to be scaled accurately (such as in log scale), just use the fallback as each will need # to be scaled separately. - if(self.__scale_widths): + if (self.__scale_widths): super().draw_markers(gc, marker_path, marker_trans, path, trans, rgbFace) return @@ -296,7 +296,7 @@ def draw_path_collection( offset_position, ): # If we want accurate scaling for each marker (such as in log scale), just use superclass implementation... - if(self.__scale_widths): + if (self.__scale_widths): super().draw_path_collection( gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position @@ -305,7 +305,7 @@ def draw_path_collection( # Otherwise we transform just the offsets, and pass them to the backend. print(offsets) - if(np.any(np.isnan(offsets))): + if (np.any(np.isnan(offsets))): raise ValueError("???") offsets = self._get_transfer_transform(offset_trans).transform(offsets) print(offsets) diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index 5cafa7c..05d44e1 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -243,6 +243,7 @@ def test_stop_viewing(fig_test, fig_ref): ax1_ref.plot(data) ax1_ref.text(0.5, 0.5, "Hello") + # On MacOS the results are off by an extremely tiny amount, can't even see in diff. It's close enough... @check_figures_equal(tol=0.02 if sys.platform.startswith("darwin") else 0) def test_log_line(fig_test, fig_ref): From 2e833f4875abb2c12ee4a02fc9ff4bd46c71f8eb Mon Sep 17 00:00:00 2001 From: isaacr Date: Fri, 18 Jul 2025 13:48:21 -0600 Subject: [PATCH 78/78] Version bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2f0e9c4..9e53965 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import setuptools -VERSION = "1.0.1" +VERSION = "1.0.2" with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy