3

What I mean is this: given the instance of a generic matplotlib Artist, is there a generic way to get the positional argument values of that artist to instantiate another artist of the same type? I know there is the properties-method, but in my experience, this only returns the keyword arguments, but not the positional ones.

I have learned of the inspect module, and I managed to use it to at least get the names of the positional arguments for a given artist type, but I didn't get any further than this, because the corresponding attributes of the artists usually have different names.

import inspect
from matplotlib.lines import Line2D


artist = Line2D([0, 1], [2, 3])
sig = inspect.signature(type(artist))
args = {}
for param in sig.parameters.values():
    if (
        param.default == param.empty
        and param.kind != inspect.Parameter.VAR_KEYWORD
    ):
        args[param.name] = getattr(artist, param.name)

new_artist = type(artist)(**args)
mapf
  • 1,906
  • 1
  • 14
  • 40
  • 1
    This looks a bit like an XY problem. Are you just trying to _copy_ the object? If so, I'd recommend using the standard library's `copy` module. – bnaecker Jun 16 '20 at 21:43
  • You're right it is basically an xy problem. You guessed right that I would actually like to copy to artist. But because I've read that it is not recommended to copy artists from one figure to the next, I was thinking to create a new artist from scratch with the same attributes. I mean if I use `copy`, won't matplotlib complain that an artist was created in another figure and shouldn't be moved? – mapf Jun 16 '20 at 21:51
  • Hmm. I've never tried it, but I would agree that copying is problematic. It might be possible to "move" a copy, using the `Artist.set_figure` method, but that may only work if it's not already on a figure. Are you creating the artists exactly as you've shown here? As in, you're getting them by construction, rather than from something like `plt.plot()`? – bnaecker Jun 16 '20 at 21:56
  • 1
    This looks relevant: https://stackoverflow.com/questions/39975898/copy-matplotlib-artist. You'll basically have to `setattr(new_artist, name, getattr(old_artist, name))` for each key in `properties()`. – bnaecker Jun 16 '20 at 21:59
  • Thanks for your suggestion! This can be done much simpler though, as there are a few convenience functions that take care of this, such as `artist.update_from()` e.g., as documented [here](https://matplotlib.org/3.2.1/api/artist_api.html#bulk-properties). For most artists though, this mostly only takes care of `kwargs`, but not `args`. For some reason, there is no convenient way to have the `args` returned for an artist. – mapf Jun 18 '20 at 07:54

1 Answers1

0

Ok, so after countless hours of research, getting stuck in all sorts of rabbit holes and circling through the same ideas (some of them documented here), I have finally given up for now. My last approach which I post here, was actually to manually hardcode functions that could retrieve the relevant information, depending on the type of artist. But even this approach ultimately failed, because:

  1. using the convenience functions that are supposed to copy the properties from one artist to the next, such as artist.update_from() unfortunately:

    • either copy incompatible properties as well, i.e. if you run the method after you added the new artist to the other axes, it will raise errors
    • or seemingly not copy any properties at all, as is the case with AxesImages e.g.

    this means you would have to come up with your own way of individually copying this information as well. This would again be extremely cumbersome, but more importantly:

  2. For some artist types, it is simply impossible to retrieve all the necessary args and kwargs at all from an instance. This is for example the case with FancyArrows. Literally none of the kwargs and only two of the args that are used for the initialization of this class can be accessed again in any way from the artist instance itself. This is so incredibly frustrating.

In any case, here is my attempt at hardcoding the duplication methods, maybe they will be of use to somebody else.

duplicate.py

from matplotlib.patches import Ellipse, Rectangle, FancyArrow
from matplotlib.text import Annotation, Text
from matplotlib.lines import Line2D
from matplotlib.image import AxesImage
from matplotlib.artist import Artist
from matplotlib.axes import Axes
from matplotlib_scalebar.scalebar import ScaleBar
from typing import Callable


def _get_ellipse_args(ellipse: Ellipse) -> list:
    return [ellipse.center, ellipse.width, ellipse.height]


def _get_rectangle_args(rectangle: Rectangle) -> list:
    return [rectangle.get_xy(), rectangle.get_width(), rectangle.get_height()]


def _get_fancy_arroy_args(arrow: FancyArrow) -> list:
    return [*arrow.xy, arrow.dx, arrow.dy]


def _get_scalebar_args(scalebar: ScaleBar) -> list:
    return [scalebar.dx]


def _get_line2d_args(line: Line2D) -> list:
    return line.get_data()


def _get_text_args(text: Text) -> list:
    return []


def _get_annotation_args(text: Text) -> list:
    return [text.get_text(), text.xy]


class Duplicator:
    _arg_fetchers = {
        Line2D: _get_line2d_args,
        # Ellipse: _get_ellipse_args,
        Rectangle: _get_rectangle_args,
        Text: _get_text_args,
        Annotation: _get_annotation_args,
        ScaleBar: _get_scalebar_args,
        AxesImage: lambda: None,
    }

    def args(self, artist):
        return self._arg_fetchers[type(artist)](artist)

    @classmethod
    def duplicate(
            cls, artist: Artist, other_ax: Axes,
            duplication_method: Callable = None
    ) -> Artist:
        if duplication_method is not None:
            cls._arg_fetchers[type(artist)] = duplication_method
        if type(artist) in cls._arg_fetchers:
            if isinstance(artist, AxesImage):
                duplicate = other_ax.imshow(artist.get_array())
                # duplicate.update_from(artist) has no effect on AxesImage
                # instances for some reason.
                # duplicate.update(artist.properties()) causes an
                # AttributeError for some other reason.
                # Thus it seems kwargs need to be set individually for
                # AxesImages.
                duplicate.set_cmap(artist.get_cmap())
                duplicate.set_clim(artist.get_clim())
            else:
                duplicate = type(artist)(*cls.args(cls, artist))
                # this unfortunately copies properties that should not be
                # copied, resulting in the artist being absent in the new axes
                # duplicate.update_from(artist)
                other_ax.add_artist(duplicate)

            return duplicate
        else:
            raise TypeError(
                'There is no duplication method for this type of artist',
                type(artist)
            )

    @classmethod
    def can_duplicate(cls, artist: Artist) -> bool:
        return type(artist) in cls._arg_fetchers

duplicate_test.py

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse
from matplotlib.text import Annotation
from matplotlib.lines import Line2D

from duplicate import Duplicator, _get_ellipse_args


fig, (ax1, ax2, ax3) = plt.subplots(1, 3)

# determine artists that were there before we manually added some
default_artists1 = set(ax1.get_children())
default_artists2 = set(ax2.get_children())

# add several artists to ax1
ax1.add_line(Line2D([0, 1], [2, 3], lw=4, color='red'))
ax1.add_patch(Ellipse((1, 1), 1, 1))
ax1.imshow(np.random.uniform(0, 1, (10, 10)))
ax2.add_patch(Ellipse((3, 5), 2, 4, fc='purple'))
ax2.add_artist(Annotation('text', (1, 1), fontsize=20))

# set axes limits, optional, but usually necessary
for ax in [ax1, ax2]:
    ax.relim()
    ax.autoscale_view()

ax2.axis('square')
for ax in [ax2, ax3]:
    ax.set_xlim(ax1.get_xlim())
    ax.set_ylim(ax1.get_ylim())

# determine artists that were added manually
new_artists1 = set(ax1.get_children()) - default_artists1
new_artists2 = set(ax2.get_children()) - default_artists2
new_artists = new_artists1 | new_artists2

# declare our own arg fetchers for artists types that may not be
# covered by the Duplicator class
arg_fetchers = {Ellipse: _get_ellipse_args}

# duplicate artists to ax3
for artist in new_artists:
    if Duplicator.can_duplicate(artist):
        Duplicator.duplicate(artist, ax3)
    else:
        Duplicator.duplicate(artist, ax3, arg_fetchers[type(artist)])

fig.show()

enter image description here

I really don't understand why, while matplotlib is adament about not re-using the same artist in different figures / axes (for which there is probably a good technical reason), they at the same time make it impossible, if not at least very awkward / hacky to move or copy artists.

Matplotlib gods, please, for all that is good, introduce a standard way of copying artists

mapf
  • 1,906
  • 1
  • 14
  • 40