diff --git a/doc/api/axes_api.rst b/doc/api/axes_api.rst index 2e94fa5f9d65..59e4b87e9f69 100644 --- a/doc/api/axes_api.rst +++ b/doc/api/axes_api.rst @@ -194,6 +194,7 @@ Text and annotations Axes.table Axes.arrow Axes.inset_axes + Axes.inset_zoom_axes Axes.indicate_inset Axes.indicate_inset_zoom Axes.secondary_xaxis diff --git a/doc/users/next_whats_new/autoplot_inset_axes.rst b/doc/users/next_whats_new/autoplot_inset_axes.rst new file mode 100644 index 000000000000..433b85e9366b --- /dev/null +++ b/doc/users/next_whats_new/autoplot_inset_axes.rst @@ -0,0 +1,25 @@ +Addition of an inset Axes with automatic zoom plotting +------------------------------------------------------ + +It is now possible to create an inset axes that is a zoom-in on a region in +the parent axes without needing to replot all items a second time, using the +`~matplotlib.axes.Axes.inset_zoom_axes` method of the +`~matplotlib.axes.Axes` class. Arguments for this method are backwards +compatible with the `~matplotlib.axes.Axes.inset_axes` method. + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + import numpy as np + np.random.seed(1) + fig = plt.figure() + ax = fig.gca() + ax.plot([i for i in range(10)], "r-o") + ax.text(3, 2.5, "Hello World!", ha="center") + ax.imshow(np.random.rand(30, 30), origin="lower", cmap="Blues", alpha=0.5) + axins = ax.inset_zoom_axes([0.5, 0.5, 0.48, 0.48]) + axins.set_xlim(1, 5) + axins.set_ylim(1, 5) + ax.indicate_inset_zoom(axins, edgecolor="black") + plt.show() diff --git a/examples/subplots_axes_and_figures/zoom_inset_axes.py b/examples/subplots_axes_and_figures/zoom_inset_axes.py index 85f3f78ec6b4..6f0eccb714ed 100644 --- a/examples/subplots_axes_and_figures/zoom_inset_axes.py +++ b/examples/subplots_axes_and_figures/zoom_inset_axes.py @@ -24,11 +24,10 @@ def get_demo_image(): ny, nx = Z.shape Z2[30:30+ny, 30:30+nx] = Z -ax.imshow(Z2, extent=extent, origin="lower") +ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") # inset axes.... -axins = ax.inset_axes([0.5, 0.5, 0.47, 0.47]) -axins.imshow(Z2, extent=extent, origin="lower") +axins = ax.inset_zoom_axes([0.5, 0.5, 0.47, 0.47]) # sub region of the original image x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 axins.set_xlim(x1, x2) @@ -47,6 +46,6 @@ def get_demo_image(): # The use of the following functions, methods, classes and modules is shown # in this example: # -# - `matplotlib.axes.Axes.inset_axes` +# - `matplotlib.axes.Axes.inset_zoom_axes` # - `matplotlib.axes.Axes.indicate_inset_zoom` # - `matplotlib.axes.Axes.imshow` diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index b8a7c3b2c6bb..1ef19888bda9 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -367,6 +367,59 @@ def inset_axes(self, bounds, *, transform=None, zorder=5, **kwargs): return inset_ax + def inset_zoom_axes(self, bounds, *, transform=None, zorder=5, + image_interpolation="nearest", **kwargs): + """ + Add a child inset Axes to this existing Axes, which automatically plots + artists contained within the parent Axes. + + Parameters + ---------- + bounds : [x0, y0, width, height] + Lower-left corner of inset Axes, and its width and height. + + transform : `.Transform` + Defaults to `ax.transAxes`, i.e. the units of *rect* are in + Axes-relative coordinates. + + zorder : number + Defaults to 5 (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 + determines the interpolation used when attempting to render a + zoomed version of an image. + + **kwargs + Other keyword arguments are passed on to the child `.Axes`. + + Returns + ------- + ax + The created `~.axes.Axes` instance. + + Examples + -------- + See `Axes.inset_axes` method for examples. + """ + if(transform is None): + transform = self.transAxes + + inset_loc = _TransformedBoundsLocator(bounds, transform) + bb = inset_loc(self, None) + + from ._zoom_axes import ViewAxes + axin = ViewAxes(self, bb.bounds, zorder, image_interpolation, **kwargs) + axin.set_axes_locator(inset_loc) + self.add_child_axes(axin) + + return axin + @docstring.dedent_interpd def indicate_inset(self, bounds, inset_ax=None, *, transform=None, facecolor='none', edgecolor='0.5', alpha=0.5, diff --git a/lib/matplotlib/axes/_zoom_axes.py b/lib/matplotlib/axes/_zoom_axes.py new file mode 100644 index 000000000000..a654ac452705 --- /dev/null +++ b/lib/matplotlib/axes/_zoom_axes.py @@ -0,0 +1,426 @@ +from matplotlib.path import Path +from matplotlib.axes import Axes +from matplotlib.transforms import Bbox, IdentityTransform, Affine2D +from matplotlib.backend_bases import RendererBase +import matplotlib._image as _image +import matplotlib.docstring as docstring +import numpy as np +from matplotlib.image import _interpd_ + + +class _TransformRenderer(RendererBase): + """ + A matplotlib renderer which performs transforms to change the final + location of plotted elements, and then defers drawing work to the + original renderer. + """ + + def __init__( + self, + base_renderer, + mock_transform, + transform, + bounding_axes, + image_interpolation="nearest", + scale_linewidths=True + ): + """ + Constructs a new TransformRender. + + Parameters + ---------- + base_renderer: `~matplotlib.backend_bases.RenderBase` + The renderer to use for drawing objects after applying transforms. + + mock_transform: `~matplotlib.transforms.Transform` + The transform or coordinate space which all passed + paths/triangles/images will be converted to before being placed + back into display coordinates by the main transform. For example + if the parent axes transData is passed, all objects will be + converted to the parent axes data coordinate space before being + transformed via the main transform back into coordinate space. + + transform: `~matplotlib.transforms.Transform` + The main transform to be used for plotting all objects once + converted into the mock_transform coordinate space. Typically this + is the child axes data coordinate space (transData). + + bounding_axes: `~matplotlib.axes.Axes` + The axes to plot everything within. Everything outside of this + 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 + determines the interpolation used when attempting to render a + zoomed version of an image. + + scale_linewidths: bool, default is True + Specifies if line widths should be scaled, in addition to the + paths themselves. + + Returns + ------- + `~._zoom_axes._TransformRenderer` + The new transform renderer. + """ + super().__init__() + self.__renderer = base_renderer + self.__mock_trans = mock_transform + self.__core_trans = transform + self.__bounding_axes = bounding_axes + self.__scale_widths = scale_linewidths + + try: + self.__img_inter = _interpd_[image_interpolation.lower()] + except KeyError: + raise ValueError( + f"Invalid Interpolation Mode: {image_interpolation}" + ) + + def _scale_gc(self, gc): + 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) + + new_gc.set_linewidth(gc.get_linewidth() * mult_factor) + new_gc._hatch_linewidth = gc.get_hatch_linewidth() * mult_factor + + return new_gc + + 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 + ) + + def _get_transfer_transform(self, orig_transform): + """ + Private method, returns the transform which translates and scales + coordinates as if they were originally plotted on the child axes + instead of the parent axes. + + Parameters + ---------- + orig_transform: `~matplotlib.transforms.Transform` + The transform that was going to be originally used by the + object/path/text/image. + + Returns + ------- + `~matplotlib.transforms.Transform` + A matplotlib transform which goes from original point data -> + display coordinates if the data was originally plotted on the + child axes instead of the parent axes. + """ + # We apply the original transform to go to display coordinates, then + # apply the parent data transform inverted to go to the parent axes + # coordinate space (data space), then apply the child axes data + # transform to go back into display space, but as if we originally + # plotted the artist on the child axes.... + return ( + orig_transform + self.__mock_trans.inverted() + self.__core_trans + ) + + # 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): + return self.__renderer.get_canvas_width_height()[1] + + @property + def width(self): + return self.__renderer.get_canvas_width_height()[0] + + def get_text_width_height_descent(self, s, prop, ismath): + return self.__renderer.get_text_width_height_descent(s, prop, ismath) + + def get_canvas_width_height(self): + return self.__renderer.get_canvas_width_height() + + def get_texmanager(self): + return self.__renderer.get_texmanager() + + def get_image_magnification(self): + 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 option_scale_image(self): + return False + + def points_to_pixels(self, points): + return self.__renderer.points_to_pixels(points) + + def flipy(self): + return self.__renderer.flipy() + + def new_gc(self): + return self.__renderer.new_gc() + + # Actual drawing methods below: + def draw_path(self, gc, path, transform, rgbFace=None): + # Convert the path to display coordinates, but if it was originally + # drawn on the child axes. + path = path.deepcopy() + path.vertices = self._get_transfer_transform(transform).transform( + path.vertices + ) + bbox = self._get_axes_display_box() + + # 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)): + return + + if(self.__scale_widths): + gc = self._scale_gc(gc) + + # Change the clip to the sub-axes box + gc.set_clip_rectangle(bbox) + + self.__renderer.draw_path(gc, path, IdentityTransform(), rgbFace) + + def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath): + # If the text field is empty, don't even try rendering it... + if((s is None) or (s.strip() == "")): + return + + # Call the super class instance, which works for all cases except one + # 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): + # 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) + path = Path(points, closed=True) + bbox = self._get_axes_display_box() + + if(not path.intersects_bbox(bbox, True)): + return + + if(self.__scale_widths): + gc = self._scale_gc(gc) + + gc.set_clip_rectangle(bbox) + + 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): + mag = self.get_image_magnification() + shift_data_transform = self._get_transfer_transform( + IdentityTransform() + ) + axes_bbox = self._get_axes_display_box() + # Compute the image bounding box in display coordinates.... + # Image arrives pre-magnified. + img_bbox_disp = Bbox.from_bounds(x, y, im.shape[1], im.shape[0]) + # Now compute the output location, clipping it with the final axes + # patch. + out_box = img_bbox_disp.transformed(shift_data_transform) + clipped_out_box = Bbox.intersection(out_box, axes_bbox) + + if(clipped_out_box is None): + return + + # We compute what the dimensions of the final output image within the + # sub-axes are going to be. + 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)): + return + + # We can now construct the transform which converts between the + # original image (a 2D numpy array which starts at the origin) to the + # final zoomed image. + img_trans = ( + Affine2D().scale(1/mag, 1/mag) + .translate(img_bbox_disp.x0, img_bbox_disp.y0) + + shift_data_transform + + Affine2D().translate(-clipped_out_box.x0, -clipped_out_box.y0) + .scale(mag, mag) + ) + + # We resize and zoom the original image onto the out_arr. + out_arr = np.zeros((out_h, out_w, im.shape[2]), dtype=im.dtype) + trans_msk = np.zeros((out_h, out_w), dtype=im.dtype) + + _image.resample(im, out_arr, img_trans, self.__img_inter, alpha=1) + _image.resample(im[:, :, 3], trans_msk, img_trans, self.__img_inter, + alpha=1) + out_arr[:, :, 3] = trans_msk + + if(self.__scale_widths): + gc = self._scale_gc(gc) + + gc.set_clip_rectangle(clipped_out_box) + + x, y = clipped_out_box.x0, clipped_out_box.y0 + + 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) + + +@docstring.interpd +class ViewAxes(Axes): + """ + An axes which automatically displays elements of another axes. Does not + require Artists to be plotted twice. + """ + MAX_RENDER_DEPTH = 1 # The number of allowed recursions in the draw method + + def __init__( + self, + axes_to_view, + rect, + zorder=5, + image_interpolation="nearest", + **kwargs + ): + """ + Construct a new view axes. + + Parameters + ---------- + axes_to_view: `~.axes.Axes` + The axes to zoom in on which this axes will be nested inside. + + rect: [left, bottom, width, height] + The Axes is built in the rectangle *rect*. + + zorder: int + An integer, the z-order of the axes. Defaults to 5. + + 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. + + **kwargs + Other optional keyword arguments: + + %(Axes:kwdoc)s + + Returns + ------- + `~.axes.ZoomViewAxes` + The new zoom view axes instance... + """ + super().__init__(axes_to_view.figure, rect, zorder=zorder, + **kwargs) + + self.__view_axes = axes_to_view + self.__image_interpolation = image_interpolation + self._render_depth = 0 + self.__scale_lines = True + + def draw(self, renderer=None): + if(self._render_depth >= self.MAX_RENDER_DEPTH): + return + self._render_depth += 1 + + super().draw(renderer) + + if(not self.get_visible()): + return + + axes_children = [ + *self.__view_axes.collections, + *self.__view_axes.patches, + *self.__view_axes.lines, + *self.__view_axes.texts, + *self.__view_axes.artists, + *self.__view_axes.images, + *self.__view_axes.child_axes + ] + + # Sort all rendered items by their z-order so they render in layers + # correctly... + axes_children.sort(key=lambda obj: obj.get_zorder()) + + artist_boxes = [] + # We need to temporarily disable the clip boxes of all of the artists, + # in order to allow us to continue rendering them it even if it is + # outside of the parent axes (they might still be visible in this + # zoom axes). + for a in axes_children: + artist_boxes.append(a.get_clip_box()) + a.set_clip_box(a.get_window_extent(renderer)) + + # Construct mock renderer and draw all artists to it. + mock_renderer = _TransformRenderer( + 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 + ) + + for artist in axes_children: + if( + (artist is not self) + and ( + Bbox.intersection( + artist.get_window_extent(renderer), axes_box + ) is not None + ) + ): + artist.draw(mock_renderer) + + # Reset all of the artist clip boxes... + for a, box in zip(axes_children, artist_boxes): + a.set_clip_box(box) + + # We need to redraw the splines if enabled, as we have finally drawn + # everything... This avoids other objects being drawn over the splines. + if(self.axison and self._frameon): + for spine in self.spines.values(): + spine.draw(renderer) + + self._render_depth -= 1 + + def get_linescaling(self): + """ + 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): + """ + 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 diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 5da2df1455db..425912185f97 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -6415,6 +6415,41 @@ def test_zoom_inset(): axin1.get_position().get_points(), xx, rtol=1e-4) +# Tolerance needed because the way the auto-zoom axes handles images is +# entirely different, leading to a slightly different result. +@check_figures_equal(tol=3) +def test_auto_zoom_inset(fig_test, fig_ref): + np.random.seed(1) + im_data = np.random.rand(30, 30) + + # Test Case... + 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 = ax_test.inset_zoom_axes([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") + + # Reference + ax_ref = fig_ref.gca() + ax_ref.plot([i for i in range(10)], "r") + ax_ref.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + axins_ref = ax_ref.inset_axes([0.5, 0.5, 0.48, 0.48]) + axins_ref.set_xlim(1, 5) + axins_ref.set_ylim(1, 5) + axins_ref.plot([i for i in range(10)], "r") + axins_ref.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + axins_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + ax_ref.indicate_inset_zoom(axins_ref, edgecolor="black") + + @pytest.mark.parametrize('x_inverted', [False, True]) @pytest.mark.parametrize('y_inverted', [False, True]) def test_indicate_inset_inverted(x_inverted, y_inverted):