Mad Physicist
Mad Physicist

Reputation: 114548

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?

Upvotes: 2

Views: 186

Answers (2)

Mad Physicist
Mad Physicist

Reputation: 114548

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.

Upvotes: 1

Trenton McKinney
Trenton McKinney

Reputation: 62523

Calculating the aspect ratio of the figure and applying it to the transformation ensures the circles are displayed with their correct aspect ratios regardless of the figure dimensions. The circles should maintain their shape and size consistently across different plotting configurations.

Calculate the aspect ratio of the figure

aspect_ratio = fig.get_figheight() / fig.get_figwidth()

The aspect ratio is calculated as the height divided by the width. This ratio is used to ensure the scaling transformation maintains the correct proportions when applied to the circle.

Create a transform to correctly scale the radius in data coordinates

transform = mtrans.Affine2D().scale(aspect_ratio, 1.0) + ax.transData
  • mtrans.Affine2D(): This creates a new affine transformation, which is a combination of translation, scaling, rotation, and shear transformations.
  • scale(aspect_ratio, 1.0): This method scales the transformation by the specified factors along the x and y axes. Scale the x-axis by the aspect ratio and the y-axis by 1.0 to ensure the circle's radius is scaled correctly according to the aspect ratio of the figure.
  • ax.transData: This transformation maps data coordinates to display coordinates. This combines the custom scaling with the existing transformation from data coordinates to display coordinates.

The transformation adjusts the radius of the circle in data coordinates, so it appears correctly scaled when rendered on the figure, which is important because the aspect ratio of the figure can distort objects if not accounted for, leading to circles appearing as ellipses.

Tested in python v3.12.3, matplotlib v3.8.4.

import matplotlib.pyplot as plt
import matplotlib.patches as mpatch
import matplotlib.transforms as mtrans
from typing import Tuple

def create_scaled_circle(ax: plt.Axes, center: Tuple[float, float], radius: float) -> mpatch.Circle:
    fig = ax.figure
    x, y = center

    # Calculate aspect ratio of the figure
    aspect_ratio = fig.get_figheight() / fig.get_figwidth()

    # Create a transform that scales the radius correctly in data coordinates
    transform = mtrans.Affine2D().scale(aspect_ratio, 1.0) + ax.transData

    circle = mpatch.Circle(center, radius, transform=transform, facecolor='none', edgecolor='red')
    return circle

Single Test Case

fig, ax = plt.subplots()
circle1 = create_scaled_circle(ax, (5, 10), 3)
circle2 = create_scaled_circle(ax, (7, 15), 5)
ax.add_patch(circle1)
ax.add_patch(circle2)
ax.set_xlim(0, 20)
ax.set_ylim(0, 20)
plt.show()

enter image description here

Multiple Test Cases

# Define 10 test cases with different centers, radii, axis limits, and quadrants
test_cases = [
    ((5, 10), 3, (-20, 20), (-20, 20)),  # Quadrant I
    ((-7, 15), 5, (-20, 20), (-20, 20)),  # Quadrant II
    ((-10, -5), 2, (-20, 20), (-20, 20)),  # Quadrant III
    ((15, -10), 4, (-20, 20), (-20, 20)),  # Quadrant IV
    ((0.5, 0.5), 7, (-30, 30), (-30, 30)),  # Near origin, all quadrants
    ((-12, -12), 6, (-20, 20), (-20, 20)),  # Quadrant III
    ((3, -18), 1.5, (-20, 20), (-20, 20)),  # Quadrant IV
    ((-6, 6), 2.5, (-15, 15), (-15, 15)),  # Quadrant II
    ((9, -9), 4.5, (-25, 25), (-25, 25)),  # Quadrant IV
    ((-11, 7), 3.5, (-20, 20), (-20, 20)),  # Quadrant II
]


for center, radius, xlim, ylim in test_cases:
    fig, ax = plt.subplots()
    circle = create_scaled_circle(ax, center, radius)
    ax.add_patch(circle)
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    plt.show()

enter image description here

enter image description here

enter image description here

enter image description here

enter image description here


def create_scaled_circle does not correctly set the center or aspect of the circle if the height and width of figsize are not equal.

The following function correctly positions and sizes circles even if height and width are not the same. Additionally, circles are resized when zoomed.

def create_scaled_circle(ax: plt.Axes, center: Tuple[float, float], radius: float) -> mpatch.Circle:
    fig = ax.figure
    x, y = center

    # Calculate the aspect ratio
    aspect_ratio = fig.get_figwidth() / fig.get_figheight()

    # Adjust the radius according to the aspect ratio
    adjusted_radius = radius / aspect_ratio

    # Create the circle with the adjusted radius
    circle = mpatch.Circle(center, adjusted_radius, transform=ax.transData, facecolor='none', edgecolor='violet')
    return circle


# Define test cases with different figsize
test_cases = [
    ((5, 10), 3, (-20, 20), (-20, 20), (5, 5)),  # square figure
    ((7, 15), 5, (-20, 20), (0, 25), (3, 5)),  # tall figure
    ((-10, -5), 7, (-20, 20), (-20, 20), (5, 3)),  # wide figure
]

for center, radius, xlim, ylim, figsize in test_cases:
    fig = plt.figure(figsize=figsize)
    ax = fig.add_subplot(111)
    circle = create_scaled_circle(ax, center, radius)
    ax.add_patch(circle)
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    ax.grid()
    ax.set_aspect('equal')
    plt.show()

Interactive Plot

enter image description here

Interactive Plot Zoomed

enter image description here

Interactive Plot

enter image description here

Interactive Plot Zoomed

enter image description here

Interactive Plot

enter image description here

Interactive Plot Zoomed

enter image description here

Upvotes: 0

Related Questions