Search code examples
pythonmatplotlibtransformaffinetransform

Creating blended transform with identical data-dependent scaling


I am trying to create a circle that displays a circle regardless of axis scaling, but placed in data coordinates and whose radius is dependent on the scaling of the y-axis. Based on the transforms tutorial, and more specifically the bit about plotting in physical coordinates, I need a pipeline that looks like this:

from matplotlib import pyplot as plt, patches as mpatch, transforms as mtrans

fig, ax = plt.subplots()
x, y = 5, 10
r = 3
transform = fig.dpi_scale_trans + fig_to_data_scaler + mtrans.ScaledTranslation(x, y, ax.transData)
ax.add_patch(mpatch.Circle((0, 0), r, edgecolor='k', linewidth=2, facecolor='w', transform=t))

The goal is to create a circle that's scaled correctly at the figure level, scale it to the correct height, and then move it in data coordinates. fig.dpi_scale_trans and mtrans.ScaledTranslation(x, y, ax.transData) work as expected. However, I am unable to come up with an adequate definition for fig_to_data_scaler.

It is pretty clear that I need a blended transformation that takes the y-scale from ax.transData combined with fig.dpi_scale_trans (inverted?) and then uses the same values for x, regardless of data transforms. How do I do that?

Another reference that I looked at: https://stackoverflow.com/a/56079290/2988730.


Here's a transform graph I've attempted to construct unsuccessfully:

vertical_scale_transform = mtrans.blended_transform_factory(mtrans.IdentityTransform(), fig.dpi_scale_trans.inverted() + mtrans.AffineDeltaTransform(ax.transData))
reflection = mtrans.Affine2D.from_values(0, 1, 1, 0, 0, 0)
fig_to_data_scaler = vertical_scale_transform + reflection + vertical_scale_transform # + reflection, though it's optional

It looks like the previous attempt was a bit over-complicated. It does not matter what the figure aspect ratio is. The axes data transform literally handles all of that out-of-the box. The following attempt almost works. The only thing it does not handle is pixel aspect ratio:

vertical_scale_transform = mtrans.AffineDeltaTransform(ax.transData)
reflection = mtrans.Affine2D.from_values(0, 1, 1, 0, 0, 0)
uniform_scale_transform = mtrans.blended_transform_factory(reflection + vertical_scale_transform + reflection, vertical_scale_transform)
t = uniform_scale_transform + mtrans.ScaledTranslation(x, y, ax.transData)
ax.add_patch(mpatch.Circle((0, 0), r, edgecolor='k', linewidth=2, facecolor='w', transform=t))

This places perfect circles at the correct locations. Panning works as expected. The only issue is that the size of the circles does not update when I zoom. Given mtrans.AffineDeltaTransform(ax.transData) on the y-axis, I find that to be surprising.

I guess the updated question is then, why is the scaling part of the transform graph not updating fully when I zoom the axes?


Solution

  • It appears that the approach I proposed in the question is supposed to work. To create a transform that has data scaling in the y-direction and the same scaling regardless of data in the x-direction, we can do the following:

    1. Create a transform that scales vertically with ax.transData
    2. Create a simple reflection transform using Affine2D
    3. By reflecting, applying the transform in step 1 and reflecting back, we can make a transform that scales the x-axis the same as y.
    4. Finally, we add a ScalesTranslation to place the object at the correct data location

    Here is the full solution:

    from matplotlib import pyplot as plt, patches as mpatch, transforms as mtrans
    
    fig, ax = plt.subplots()
    x, y = 5, 10
    r = 3
    
    # AffineDeltaTransform returns just the scaling portion
    vertical_scale_transform = mtrans.AffineDeltaTransform(ax.transData)
    reflection = mtrans.Affine2D.from_values(0, 1, 1, 0, 0, 0)
    # The first argument relies on the fact that `reflection` is its own inverse 
    uniform_scale_transform = mtrans.blended_transform_factory(reflection + vertical_scale_transform + reflection, vertical_scale_transform)
    t = uniform_scale_transform + mtrans.ScaledTranslation(x, y, ax.transData)
    # Create a circle at origin, and move it with the transform
    ax.add_patch(mpatch.Circle((0, 0), r, edgecolor='k', linewidth=2, facecolor='w', transform=t))
    

    This answer is encapsulated in a proposed gallery example: https://github.com/matplotlib/matplotlib/pull/28364


    The issue with this solution at time of writing is that AffineDeltaTransform is not updating correctly when axes are zoomed or resized. The issue has been filed in matplotlib#28372, and resolved in matplotlib#28375. Future versions of matplotlib will be able to run the code above interactively.