Skip to content

[Bug]: matplotlib.path.Path.to_polygons fails with TriContourSet paths #25114

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

Closed
cvr opened this issue Jan 31, 2023 · 3 comments · Fixed by #26226
Closed

[Bug]: matplotlib.path.Path.to_polygons fails with TriContourSet paths #25114

cvr opened this issue Jan 31, 2023 · 3 comments · Fixed by #26226
Milestone

Comments

@cvr
Copy link

cvr commented Jan 31, 2023

Bug summary

The Path objects produced by tricontourf to draw its contours lead to spurious polygons when running the respective to_polygons method.

Code for reproduction

import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
import matplotlib.tri

# Example for TriContour based from:
# https://matplotlib.org/2.0.2/examples/pylab_examples/tricontour_smooth_delaunay.html


def experiment_res(x, y):
    """ An analytic function representing experiment results """
    x = 2.*x
    r1 = np.sqrt((0.5 - x)**2 + (0.5 - y)**2)
    theta1 = np.arctan2(0.5 - x, 0.5 - y)
    r2 = np.sqrt((-x - 0.2)**2 + (-y - 0.2)**2)
    theta2 = np.arctan2(-x - 0.2, -y - 0.2)
    z = (4*(np.exp((r1/10)**2) - 1)*30. * np.cos(3*theta1) +
         (np.exp((r2/10)**2) - 1)*30. * np.cos(5*theta2) +
         2*(x**2 + y**2))
    return (np.max(z) - z)/(np.max(z) - np.min(z))

n = 200
random_gen = np.random.mtrand.RandomState(seed=127260)
x = random_gen.uniform(-1., 1., size=n)
y = random_gen.uniform(-1., 1., size=n)
v = experiment_res(x, y)

## triangulate
tri = mpl.tri.Triangulation(x, y)
mask = mpl.tri.TriAnalyzer(tri).get_flat_tri_mask(min_circle_ratio=.01)
tri.set_mask(mask)

## refine triangular mesh
refiner = mpl.tri.UniformTriRefiner(tri)
triFiner, vFiner = refiner.refine_field(v, subdiv=2)  # uses CubicTriInterpolator

## plot the mesh
plt.close('all')
plt.triplot(tri, color='k', lw=.8)
plt.triplot(triFiner, color='k', alpha=.3, lw=.5)
plt.show()

## plot and create TriCounterSet object
levels = [.7, .9, 1]
cmap = mpl.cm.plasma
plt.close('all')
cl = plt.tricontour(triFiner, vFiner, levels=levels, colors='k', linewidths=2, linestyles='--')
cf = plt.tricontourf(triFiner, vFiner, levels=levels, cmap=cmap, extend='both', alpha=.8)
_ = [collection.set_edgecolor("face") for collection in cf.collections]  # https://stackoverflow.com/a/73805556/921580
plt.triplot(tri, color='k', lw=1.5, alpha=.1)  # refined mesh
plt.triplot(triFiner, color='k', lw=.5, alpha=.1)  # original mesh
cb = plt.colorbar(cf)
plt.show()

## retrieve contours from TriContourSet (paths) and convert to polygons
for i in range(len(cf.collections)):
    plt.gca().set_xlim(-1.1, 1.1)
    plt.gca().set_ylim(-1.1, 1.1)
    [plt.gca().add_patch(
            mpl.patches.PathPatch(
                cf.collections[i].get_paths()[j],
                facecolor=cf.collections[i].get_facecolor()
            )
        ) for j in range(len(cf.collections[i].get_paths()))]
    [plt.plot(*list(zip(*p)), c='red', ls='--') 
            for path in cf.collections[i].get_paths()
            for p in path.to_polygons()
        ]
    if i == (len(cf.collections) - 2):
        plt.savefig('bug_actual_outcome.png')
    plt.show()

## compare path vertices with output from to_polygons method
path = cf.collections[-2].get_paths()[0]
print("\npath vertices and codes:\n", path)
print("\npath.to_polygons() = \n", path.to_polygons())

Actual outcome

bug_actual_outcome

Expected outcome

bug_expected_outcome

Additional information

The issue seems to be related to the Path object and its to_polygons method. More specifically the problem may happen when a Path contains multiple polylines but is defined without the CLOSEPOLY codes ending each polyline. When producing contour plots the code can cope with this data without problems, but the to_polygons method cannot.

I have tested in both 3.4.1 and 3.0.2 versions and both have the same problem.

I have a fix where I defined a function path2polygons:

def path2polygons(path):
    assert isinstance(path, mpl.path.Path), f"Type of input is {type(path)} instead of Path."
    codesOld = path.codes[path.codes != mpl.path.Path.CLOSEPOLY]
    vertsOld = path.vertices[path.codes != mpl.path.Path.CLOSEPOLY]
    jj = np.append(np.flatnonzero(codesOld == path.MOVETO), [len(codesOld)])
    verts, codes = [], []
    for ji, je in zip(jj[:-1], jj[1:]):
        codes.extend(   [mpl.path.Path.MOVETO]
                      + [mpl.path.Path.LINETO]*(je - ji - 1)
                      + [mpl.path.Path.CLOSEPOLY]
                    )
        verts.extend( list(vertsOld[ji:je]) + [vertsOld[ji]] )
    return mpl.path.Path(verts, codes).to_polygons()

but is not tested with other Path codes such as CURVE3 and CURVE4.

Operating system

Debian 10

Matplotlib Version

3.4.1

Matplotlib Backend

TkAgg

Python version

3.7.3

Jupyter version

No response

Installation

pip

@ianthomas23
Copy link
Member

I'd recommended fixing this by setting the CLOSEPOLY codes in the C++ tricontour code somewhere near

*codes_ptr++ = (point == line->begin() ? MOVETO : LINETO);

@cvr
Copy link
Author

cvr commented Feb 1, 2023

Made some further tests with a simpler case, just generating a Path with one hole and performing path.to_polygons(). Same condition as paths returned by TriContourf: paths consist of polylines whithout the CLOSEPOLY code.

Apparently if the total number of vertexes defining the path is below 128 the to_polygons method works, otherwise it will fail unless the CLOSEPOLY codes are introduced.

import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt

def path2polygons(path):
    """My solution to convert the matplotlib Path to a set of polygons."""
    assert isinstance(path, mpl.path.Path), f"Type of input is {type(path)} instead of Path"
    codesOld = path.codes[path.codes != mpl.path.Path.CLOSEPOLY]
    vertsOld = path.vertices[path.codes != mpl.path.Path.CLOSEPOLY]
    jj = np.append(np.flatnonzero(codesOld == path.MOVETO), [len(codesOld)])
    verts, codes = [], []
    for ji, je in zip(jj[:-1], jj[1:]):
        codes.extend( list(codesOld[ji:je]) + [mpl.path.Path.CLOSEPOLY] )
        verts.extend( list(vertsOld[ji:je]) + [vertsOld[ji]] )
    return mpl.path.Path(verts, codes).to_polygons()

## WORKS
nout = 100
nint = 27
verts = list(zip(np.cos(2*np.pi*np.linspace(0, 1-1./nout, nout)), np.sin(2*np.pi*np.linspace(0, 1-1./nout, nout))))
codes = [mpl.path.Path.MOVETO] + [mpl.path.Path.LINETO]*(nout-1)
verts += list(zip(.2*np.cos(2*np.pi*np.linspace(0, 1-1./nint, nint))[::-1], .4*np.sin(2*np.pi*np.linspace(0, 1-1./nint, nint))[::-1]))
codes += [mpl.path.Path.MOVETO] + [mpl.path.Path.LINETO]*(nint-1)

print(len(verts))
path = mpl.path.Path(verts, codes)
patch = mpl.patches.PathPatch(path, facecolor='turquoise', alpha=.3)
plt.close('all')
plt.gca().add_patch(patch)
[plt.plot(*list(zip(*p)), c='orange', ls='-') for p in path.to_polygons()]
[plt.plot(*list(zip(*p)), c='red', ls='--') for p in path2polygons(path)]
plt.gca().set_title(f"vertices in path: {len(verts)}, verdict: OK")
plt.savefig("bug_path2polygons_127vertsOk.png")
plt.show()

## DOES NOT WORK
nout = 100
nint = 28
verts = list(zip(np.cos(2*np.pi*np.linspace(0, 1-1./nout, nout)), np.sin(2*np.pi*np.linspace(0, 1-1./nout, nout))))
codes = [mpl.path.Path.MOVETO] + [mpl.path.Path.LINETO]*(nout-1)
verts += list(zip(.2*np.cos(2*np.pi*np.linspace(0, 1-1./nint, nint))[::-1], .4*np.sin(2*np.pi*np.linspace(0, 1-1./nint, nint))[::-1]))
codes += [mpl.path.Path.MOVETO] + [mpl.path.Path.LINETO]*(nint-1)

print(len(verts))
path = mpl.path.Path(verts, codes)
patch = mpl.patches.PathPatch(path, facecolor='turquoise', alpha=.3)
plt.close('all')
plt.gca().add_patch(patch)
[plt.plot(*list(zip(*p)), c='orange', ls='-') for p in path.to_polygons()]
[plt.plot(*list(zip(*p)), c='red', ls='--') for p in path2polygons(path)]
plt.gca().set_title(f"vertices in path: {len(verts)}, verdict: FAILURE")
plt.savefig("bug_path2polygons_128vertsFail.png")
plt.show()

## WORKS
nout = 200
nint = 100
verts = list(zip(np.cos(2*np.pi*np.linspace(0, 1, nout)), np.sin(2*np.pi*np.linspace(0, 1, nout))))
codes = [mpl.path.Path.MOVETO] + [mpl.path.Path.LINETO]*(nout-2) + [mpl.path.Path.CLOSEPOLY]
verts += list(zip(.2*np.cos(2*np.pi*np.linspace(0, 1, nint))[::-1], .4*np.sin(2*np.pi*np.linspace(0, 1, nint))[::-1]))
codes += [mpl.path.Path.MOVETO] + [mpl.path.Path.LINETO]*(nint-2) + [mpl.path.Path.CLOSEPOLY]

print(len(verts))
path = mpl.path.Path(verts, codes)
patch = mpl.patches.PathPatch(path, facecolor='turquoise', alpha=.3)
plt.close('all')
plt.gca().add_patch(patch)
[plt.plot(*list(zip(*p)), c='orange', ls='-') for p in path.to_polygons()]
[plt.plot(*list(zip(*p)), c='red', ls='--') for p in path2polygons(path)]
plt.gca().set_title(f"ensuring CLOSEPOLY, vertices in path: {len(verts)}, verdict: OK")
plt.savefig("bug_path2polygons_300vertsOk.png")
plt.show()

bug_path2polygons_127vertsOk
bug_path2polygons_128vertsFail
bug_path2polygons_300vertsOk

@ccoulet
Copy link

ccoulet commented Apr 12, 2023

Thanks @cvr
I was stucked for a long time with a similar problem of polygon limit which was different locally different between 3 neighbors triangles leading to inconsistant polygon without finding the origin of the problem.
Switching to path.to_polygons to your path2polygons function solve my problem.

@ianthomas23 ianthomas23 changed the title [Bug]: matplotlib.path.Path.to_polygons fails with TriCountourSet paths [Bug]: matplotlib.path.Path.to_polygons fails with TriContourSet paths Jun 30, 2023
@QuLogic QuLogic added this to the v3.8.0 milestone Jul 4, 2023
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.

4 participants