Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bug]: contains_point() does not appear to work? #23178

Closed
stallam-unb opened this issue Jun 1, 2022 · 8 comments · Fixed by #27681
Closed

[Bug]: contains_point() does not appear to work? #23178

stallam-unb opened this issue Jun 1, 2022 · 8 comments · Fixed by #27681
Milestone

Comments

@stallam-unb
Copy link

Bug summary

The method contains_point() of a circular patch does not appear to return the correct answer. Note that I have also tested solution proposed in #14207, but it did not help.

Code for reproduction

import numpy
from matplotlib.animation import FuncAnimation
import matplotlib.pyplot as plt
from matplotlib.patches import Circle


class SimpleDemo:
    def __init__(self):
        fig, ax = plt.subplots()
        self.figure = fig
        self.axes = ax
        self.axes.set_aspect("equal")
        self.patches = [
            Circle((0.0, 1.5), 1, color="red"),
            Circle((0.0, -1.5), 1, color="red")
        ]

        self.cursor, = ax.plot(
            [],
            [],
            linestyle="None",
            marker="d",
            markersize=10,
            markerfacecolor="blue",
        )

        for patch in self.patches:
            self.axes.add_patch(patch)

        self.sample_num = 1
        self.rate = 0.01

    def update(self, frame_num):
        new_coordinates = numpy.exp(1j*2*numpy.pi*self.rate*self.sample_num)
        self.sample_num += 1
        cart_coords = [new_coordinates.real, new_coordinates.imag]

        self.cursor.set_xdata(cart_coords[0])
        self.cursor.set_ydata(cart_coords[1])

        for patch in self.patches:

            # Case 1: Manually test if point is inside circle.
            # centre = numpy.array(patch.get_center())
            # diff_val = cart_coords - centre
            #
            # if numpy.hypot(diff_val[0], diff_val[1]) <= 1:
            #     patch.set_color("green")
            # else:
            #     patch.set_color("red")

            # Case 2: Use contains_point(). Does not work.
            # if patch.contains_point(tuple(cart_coords)):
            #     patch.set_color("green")
            # else:
            #     patch.set_color("red")

            # Case 3: Use contains_point() with transform(). Also does not work.
            if patch.contains_point(patch.get_transform().transform(tuple(cart_coords))):
                patch.set_color("green")
            else:
                patch.set_color("red")

        artists = []
        artists.extend(self.patches)
        artists.append(self.cursor)

        return artists


def main():
    demo = SimpleDemo()
    fig = demo.figure

    ani = FuncAnimation(fig,
                        demo.update,
                        frames=100,
                        interval=100,
                        repeat=False,
                        blit=True)
    # plt.show()
    ani.save('Case_3.mp4', fps=10, extra_args=['-vcodec', 'libx264'])


if __name__ == '__main__':
    main()

Actual outcome

Case 2 (calling contains_point() without transform):

Case_2.mp4

Case 3 (calling contains_point() with transform):

Case_3.mp4

Expected outcome

Case 1 (manually checking point):

Case_1.mp4

Additional information

No response

Operating system

Windows

Matplotlib Version

3.5.2

Matplotlib Backend

Qt5Agg

Python version

3.10.4

Jupyter version

NA

Installation

conda

@stallam-unb
Copy link
Author

On further investigation, it appears that the docs might not be fully clear. It is not patch.get_transform().transform() that should be used, but axes.transData.transform(). Reading the docs however, self.get_transform() implies the former which doesn't appear to work. Perhaps the docs can clarify that the axes transform should be used?

@jklymak
Copy link
Member

jklymak commented Jun 2, 2022

Can you simplify this so that it is not an animation? Its not trivial to debug in this form. Thanks....

@stallam-unb
Copy link
Author

stallam-unb commented Jun 2, 2022

@jklymak Ok here is a simpler code without any animation:

import matplotlib.pyplot as plt
from matplotlib.patches import Circle
import matplotlib


def main():

    print(f"matplotlib version: {matplotlib.__version__}")
    print(f"matplotlib backend: {matplotlib.get_backend()}")

    fig, ax = plt.subplots()
    ax.set_aspect("equal")

    fig: plt.Figure
    ax: plt.Axes

    # Create two non-overlapping circles whose colour changes depending on certain conditions.
    circle_1_centre = (0, 5)
    patch_1 = Circle(circle_1_centre, 1, color="red")

    circle_2_centre = (0, -5)
    patch_2 = Circle(circle_2_centre, 1, color="red")

    patches = [patch_1, patch_2]

    for patch in patches:
        ax.add_patch(patch)

    # Create a test point.
    test_point_coords = (0, 4.5)
    _ = ax.plot(test_point_coords[0], test_point_coords[1], linestyle="None", marker="d")

    # Logic to test if the test point lies inside a patch. If it does, change the colour.
    for patch in patches:

        # Case 1 without transform (doesn't work):
        # if patch.contains_point(test_point_coords):
        #     patch.set_color("green")

        # Case 2 with patch transform (also doesn't work):
        # if patch.contains_point(patch.get_transform().transform(test_point_coords)):
        #     patch.set_color("green")

        # Case 3 with axes transform (this one works):
        if patch.contains_point(ax.transData.transform(test_point_coords)):
            patch.set_color("green")

    plt.show()


if __name__ == '__main__':
    main()

I tested three cases: without any transform, with transform that the patch returns, and with the transform the axes returns. Only the transform returned by the axes appears to work as intended. Look the the figures:

Case 1 (no transformation):
case_1

Case 2 (patch transformation):
case_2

Case 3: (axes transformation):
case_3

@jklymak
Copy link
Member

jklymak commented Jun 2, 2022

I actually have no idea why the transform changes, but it definitely is not the same as ax.transData. I suspect its so that the circle stays a circle regardless of the aspect ratio of the axes, but I'm not sure

@WeatherGod
Copy link
Member

WeatherGod commented Oct 11, 2022 via email

@hassec
Copy link
Contributor

hassec commented Nov 10, 2022

Stumbled over a similar problem myself.

@stallam-unb there is also:

def get_data_transform(self):
"""
Return the `~.transforms.Transform` mapping data coordinates to
physical coordinates.
"""

Which I think is what you need for your use case. (Same as transData but easier to get to if you have a patch)

contains_point does correctly document itself, that the passed point has to be in display coordinates.
So if you have a point in data coordinates, applying get_data_transform().transform(point) will get you the display coordinates.

If I understand the Circle patch correctly, there are 3 different coordinate systems that come into play, because when creating a Circle((1,1), radius=.5), what actually happens is that the patch is a unit circle at (0,0) plus a 2D affine transformation.
See the patch's get_patch_transform which returns the transform needed to turn the unit circle into the circle in data coordinates.

And then there is the above mentioned get_data_transform() to get from data -> display coordinates.
A Circle's get_transform simply returns the joint transformation of both such that it goes from unit circle -> display coords.

So the documentation of contains_point:

The point (x, y) to check, in target coordinates of self.get_transform()

is technically correct because we need (x,y) in the target coordinate system of self.get_transform() which is the display coordinates. But it is easy to read that and think one should use get_transform which in this case isn't correct as for a point that is in data coordinates one needs -> get_data_transform() to get to display coordinates.

Note that the example that is given is also confusing:

>>> center = 0, 0
>>> c = Circle(center, radius=1)
>>> plt.gca().add_patch(c)
>>> transformed_center = c.get_transform().transform(center)
>>> c.contains_point(transformed_center)
True

The code does return true, but change it a little:

import matplotlib.pyplot as plt
from matplotlib.patches import Circle


f = plt.figure()
ax = f.subplots(1, 1)

c = Circle((0, 0), radius=0.5)

ax.add_patch(c)

point_outside = (0, .75)
wrong = c.get_transform().transform(point_outside)
correct = c.get_data_transform().transform(point_outside)

print(c.contains_point(wrong))  # prints true
print(c.contains_point(correct))  # prints false 

And now it shows that one needs get_data_transform() and not get_transform()

So the docs of Patch should maybe be changed to clarify and fix the example? I'd be happy to do that if the maintainer team is open to the proposed changed :) (WDYT @jklymak?)

@stallam-unb
Copy link
Author

stallam-unb commented Nov 10, 2022

@hassec Huh, interesting! I am not a huge fan of the transformation step though as it does make it a bit confusing. After all, if I am specifying a circle (or any polygon) Patch with some radius and diameter in data co-ordinates, why must I concern myself with how that is affected by the render/display? Shouldn't matplotlib implicitly transform data as needed? Am I missing some context here?

@hassec
Copy link
Contributor

hassec commented Nov 10, 2022

@stallam-unb I totally see your point of view. I actually also think that having contains_point per default expect the point to be in data coordinates and doing whatever transform it needs internally would be a reasonable default from a user perspective.

Not sure if that's an easy to make change though? 🤔

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants