Reputation: 114548
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
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:
ax.transData
Affine2D
ScalesTranslation
to place the object at the correct data locationHere 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
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.
aspect_ratio = fig.get_figheight() / fig.get_figwidth()
fig.get_figheight()
: This function returns the height of the figure in inches.fig.get_figwidth()
: This function returns the width of the figure in inches.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.
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
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()
# 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()
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
Interactive Plot Zoomed
Interactive Plot
Interactive Plot Zoomed
Interactive Plot
Interactive Plot Zoomed
Upvotes: 0