Paul Brodersen
Paul Brodersen

Reputation: 13031

`matplotlib.patches.PathPatch` with the linewidth given in data units (not points)

I would like to create a matplotlib.patches.PathPatch like patch, with the linewidth given in data units (not points).

I am aware that for patches with a regular shape, one can mimic such an object by drawing two patches of different sizes on top of each other. However, this approach becomes a lot more computationally involved for arbitrary shapes (need to compute a parallel line for an arbitrary shape). Furthermore, I would like to retain the PathPath object methods, so ultimately I am looking for a class derived from PathPatch or Patch.

import matplotlib.pyplot as plt
from matplotlib.patches import PathPatch, Rectangle

fig, (ax1, ax2) = plt.subplots(1, 2, sharex=True, sharey=True)

origin = (0, 0)
width = 1
height = 2
lw = 0.25

outer = Rectangle((origin[0],    origin[1]),    width,      height,      facecolor='darkblue',  zorder=1)
inner = Rectangle((origin[0]+lw, origin[1]+lw), width-2*lw, height-2*lw, facecolor='lightblue', zorder=2)

ax1.add_patch(outer)
ax1.add_patch(inner)
ax1.axis([-0.5, 1.5, -0.5, 2.5])
ax1.set_title('Desired')

path = outer.get_path().transformed(outer.get_patch_transform())
pathpatch = PathPatch(path, facecolor='lightblue', edgecolor='darkblue', linewidth=lw)
ax2.add_patch(pathpatch)
ax2.set_title('Actual')

plt.show()

enter image description here

There is a different SO post that derives from Line2D to create a line with widths in data units.

class LineDataUnits(Line2D):
    # https://stackoverflow.com/a/42972469/2912349
    def __init__(self, *args, **kwargs):
        _lw_data = kwargs.pop("linewidth", 1)
        super().__init__(*args, **kwargs)
        self._lw_data = _lw_data

    def _get_lw(self):
        if self.axes is not None:
            ppd = 72./self.axes.figure.dpi
            trans = self.axes.transData.transform
            return ((trans((1, self._lw_data))-trans((0, 0)))*ppd)[1]
        else:
            return 1

    def _set_lw(self, lw):
        self._lw_data = lw

    _linewidth = property(_get_lw, _set_lw)

I don't quite understand how this implementation works (for example, no methods of Line2D seem to be overwritten) Nevertheless, I have tried copying this approach (class LineDataUnits(Line2D) -> class PathPatchDataUnits(PathPatch)), but -- unsurprisingly -- cannot get it to work (AttributeError: 'NoneType' object has no attribute 'set_figure').

Upvotes: 1

Views: 1172

Answers (1)

theFrok
theFrok

Reputation: 355

EDIT:

I made it work and got some insights on the way. First of all, here is the code:

import matplotlib.pyplot as plt
from matplotlib.patches import PathPatch, Rectangle

# The class from the solution you found, I just changed inheritance 
class PatchDataUnits(PathPatch):
    # https://stackoverflow.com/a/42972469/2912349
    def __init__(self, *args, **kwargs):
        _lw_data = kwargs.pop("linewidth", 1)
        super().__init__(*args, **kwargs)
        self._lw_data = _lw_data

    def _get_lw(self):
        if self.axes is not None:
            ppd = 72./self.axes.figure.dpi
            trans = self.axes.transData.transform
            # the line mentioned below
            return ((trans((self._lw_data, self._lw_data))-trans((0, 0)))*ppd)[1]
        else:
            return 1

    def _set_lw(self, lw):
        self._lw_data = lw

    _linewidth = property(_get_lw, _set_lw)

# Your code example
fig, (ax1, ax2) = plt.subplots(1, 2, sharex=True, sharey=True)

origin = (0, 0)
width = 1
height = 2
lw = 0.25

outer = Rectangle((origin[0],    origin[1]),    width,      height,      facecolor='darkblue',  zorder=1)
inner = Rectangle((origin[0]+lw, origin[1]+lw), width-2*lw, height-2*lw, facecolor='lightblue', zorder=2)

ax1.add_patch(outer)
ax1.add_patch(inner)
ax1.axis([-0.5, 1.5, -0.5, 2.5])
ax1.set_title('Desired')

# change lw because it overlaps some of the fig
mlw = lw /2 
# create new patch with the adusted size
mid = Rectangle((origin[0]+mlw, origin[1]+mlw), width-2*mlw, height-2*mlw, facecolor='lightblue',  zorder=1)
path = mid.get_path().transformed(mid.get_patch_transform())
# Use the data unit class to create the path patch with the original lw
pathpatch = PatchDataUnits(path, facecolor='lightblue', edgecolor='darkblue', linewidth=lw)
ax2.add_patch(pathpatch)
ax2.set_title('Actual')

plt.show()

The result: The results I got on my machine

Now for my thoughts:

The axes.transData transform really is a composition of two transforms, one for x-coordinates, the other for y-coordinates. However, a linewidth is a single scalar, therefor we need to make a choice which of the two transforms we will use. In my solution, I use the y-transform; however, by changing the line below the comment to index [0], you can select to use the x-transform. If the aspect of the axis is equal (i.e. a horizontal line of length 1 in data units has the same length in display units as a vertical line of length 1 in data units), this is not an issue. However, if the aspect is not equal, this may yield to unexpected behaviour as shown in the figure below.

enter image description here

The aspect of an axis can be forced to be equal using the command ax.set_aspect('equal').

Upvotes: 1

Related Questions