matplotlib.Path.contains_points : "radius" parameter defined inconsistently
Asked Answered
F

1

4

Problem:

The radius parameter in the function contains_point in matplotlib.path is defined inconsistently. This function checks if a specified point is inside or outside of a closed path. The radius parameter is used to make the path a little bit smaller/larger (dependent on the sign of radius). In this way, points can be taken into/out of account, which are close to the path. The problem is, that the sign of radius depends on the orientation of the path (clockwise or counterclockwise). The inconsistency (in my opinion) is there, because the orientation of path is ignored when checking if a point is inside or outside the path. In a mathematical strict sense one says: everything which is left along the path is included.

In short:

If the path is orientated counterclockwise, a positive radius takes more points into account. If the path is orientated clockwise, a positive radius takes less points into account.

Example:

In the following example there are 3 cases checked - each for a clockwise and a counterclockwise path:

  1. Is a point (close to path) contained with positive radius
  2. Is a point (close to path) contained with negative radius
  3. Is the origin contained (which is in the middle of both paths)

Code:

import matplotlib.path as path
import numpy as np


verts=np.array([[-11.5,  16. ],[-11.5, -16. ],[ 11.5, -16. ],[ 11.5,  16. ],[-11.5,  16. ]])

ccwPath=path.Path(verts, closed=True) 
cwPath=path.Path(verts[::-1,:], closed=True) 

testPoint=[12,0]


print('contains:         ','|\t', '[12,0], radius=3','|\t', '[12,0], radius=-3','|\t', '[0,0]|')

print('counterclockwise: ','|\t'
,'{0:>16s}'.format(str(ccwPath.contains_point(testPoint,radius=3) )),'|\t'
,'{0:>17s}'.format(str(ccwPath.contains_point(testPoint,radius=-3) )),'|\t'
,ccwPath.contains_point([0,0],radius=0) ,'|\t'
,'=> radius increases tolerance \t'
)

print('clockwise:        ','|\t'
,'{0:>16s}'.format(str(cwPath.contains_point(testPoint,radius=3) )),'|\t'
,'{0:>17s}'.format(str(cwPath.contains_point(testPoint,radius=-3) )),'|\t'
,cwPath.contains_point([0,0],radius=0) ,'|\t'
,'=> radius decreases tolerance \t'
)

Output:

contains:          |     [12,0], radius=3 |      [12,0], radius=-3 |     [0,0]|
counterclockwise:  |                 True |                  False |     True |  => radius increases tolerance 
clockwise:         |                False |                   True |     True |  => radius decreases tolerance 

Solution for convex paths:

The only idea I came up with, is to force the path into a counter-clockwise orientation and use radius according to this.

import matplotlib.path as path
import numpy as np


verts=np.array([[-11.5,  16. ],[-11.5, -16. ],[ 11.5, -16. ],[ 11.5,  16. ],[-11.5,  16. ]])

#comment following line out to make isCounterClockWise crash
#verts=np.array([[-11.5,  16. ],[-10,0],[-11.5, -16. ],[ 11.5, -16. ],[ 11.5,  16. ],[-11.5,  16. ]])

ccwPath=path.Path(verts, closed=True) 
cwPath=path.Path(verts[::-1,:], closed=True) 

testPoint=[12,0]

def isCounterClockWise(myPath):

        #directions from on vertex to the other
        dirs=myPath.vertices[1:]-myPath.vertices[0:-1]
        #rot: array of rotations at ech edge
        rot=np.cross(dirs[:-1],dirs[1:]) 
        if len(rot[rot>0])==len(rot):
            #counterclockwise
            return True
        elif len(rot[rot<0])==len(rot):
            #clockwise
            return False
        else:
            assert False, 'no yet implemented: This case applies if myPath is concave'

def forceCounterClockWise(myPath):
    if not isCounterClockWise(myPath):
        myPath.vertices=myPath.vertices[::-1]


forceCounterClockWise(cwPath)
print('contains:         ','|\t', '[12,0], radius=3','|\t', '[12,0], radius=-3','|\t', '[0,0]|')

print('counterclockwise: ','|\t'
,'{0:>16s}'.format(str(ccwPath.contains_point(testPoint,radius=3) )),'|\t'
,'{0:>17s}'.format(str(ccwPath.contains_point(testPoint,radius=-3) )),'|\t'
,ccwPath.contains_point([0,0],radius=0) ,'|\t'
,'=> radius increases tolerance \t'
)

print('forced ccw:      ','|\t'
,'{0:>16s}'.format(str(cwPath.contains_point(testPoint,radius=3) )),'|\t'
,'{0:>17s}'.format(str(cwPath.contains_point(testPoint,radius=-3) )),'|\t'
,cwPath.contains_point([0,0],radius=0) ,'|\t'
,'=> radius increases tolerance \t'
)

giving the following output:

contains:          |     [12,0], radius=3 |      [12,0], radius=-3 |     [0,0]|
counterclockwise:  |                 True |                  False |     True |  => radius increases tolerance 
forced ccw:       |                  True |                  False |     True |  => radius increases tolerance 

An example, where this solution fails (for a concave path) is given in the comment of the code.

My questions:

  1. Does anyone know, why this inconsistency is there?
  2. Is there a more elegant way to circumvent this issue? Examples might be: using an other library for contains_point, using the radius parameter in a smarter/proper way or finding the orientation of a path with a predefined function.
Faustinafaustine answered 30/8, 2017 at 9:53 Comment(3)
Before looking deeper into this: Are you sure you interprete the .contains_point correctly. The documentation states radius allows the path to be made slightly larger or smaller. So it would be the path that is offset by radius not the point.Leftwards
exactly, this is how I see it as well! Sorry for any misunderstanding! If there is something misleading in my question, please let me knowFaustinafaustine
I do not see any inconsistency in the problem from the question as ccw + +r -> path expanded, cw + -r -> path expanded, ccw + -r -> path shrunk, cw + +r -> path shrunk are working as expected. However, I do see a problem in the case where you chose a radius of +0.9. In this case, the point at (12,0) should be inside the (ccw) path; as 11.5+0.9=12.4 > 12), but contains_point returns False. So while something is indeed strange, the example from the question is not well suited to see this problem.Leftwards
L
5

I think the only wrong assumption here is "everything which is left along the path is included.". Instead, contains_point literally means whether or not a closed path includes a point.

The radius is then defined to

  • expand the path when the path goes counterclockwise and to
  • shrink the path when the path goes clockwise

This is shown in the following example, where for a (counter)clockwise path the points included in the expanded/shunk area are plotted. (red = not contains_point, blue = contains_point)

enter image description here

import matplotlib.pyplot as plt
import matplotlib.path as path
import matplotlib.patches as patches
import numpy as np

verts=np.array([[-1,  1 ],[-1, -1 ],[ 1, -1 ],[ 1, 0 ],[ 1,  1],[-1,  1 ]])

ccwPath=path.Path(verts, closed=True) 
cwPath=path.Path(verts[::-1,:], closed=True) 

paths = [ccwPath, cwPath]
pathstitle = ["ccwPath", "cwPath"]
radii = [1,-1]

testPoint=(np.random.rand(400,2)-.5)*4

c = lambda p,x,r: p.contains_point(x,radius=r)

fig, axes = plt.subplots(nrows=len(paths),ncols=len(radii))

for j  in range(len(paths)):
    for i in range(len(radii)):
        ax = axes[i,j]
        r = radii[i]
        patch = patches.PathPatch(paths[j], fill=False, lw=2)
        ax.add_patch(patch)
        col = [c(paths[j], point[0], r) for point in zip(testPoint)]
        ax.scatter(testPoint[:,0], testPoint[:,1], c=col, s=8, vmin=0,vmax=1, cmap="bwr_r")
        ax.set_title("{}, r={}".format(pathstitle[j],radii[i]) )

plt.tight_layout()
plt.show()

A particularity, which does not seem to be documented at all is that radius actually expands or shrinks the path by radius/2.. This is seen above as with a radius of 1, points between -1.5 and 1.5 are included instead of points between -2 and 2.

Concerning the orientation of a path, there may not be one fix orientation. If you have 3 points, orientation can be unambiguously determined to be clockwise, counterclockwise (or colinear). Once you have more points, the concept of orientation is not well defined.

An option may be to check if the path is "mostly counterclockwise".

def is_ccw(p):
    v = p.vertices-p.vertices[0,:]
    a = np.arctan2(v[1:,1],v[1:,0])
    return (a[1:] >= a[:-1]).astype(int).mean() >= 0.5

This would then allow to adjust the radius in case of "mostly clockwise" paths,

r = r*is_ccw(p) - r*(1-is_ccw(p))

such that a positive radius always expands the path and a negative radius always shrinks it.

Complete example:

import matplotlib.pyplot as plt
import matplotlib.path as path
import matplotlib.patches as patches
import numpy as np

verts=np.array([[-1,  1 ],[-1, -1 ],[ 1, -1 ],[ 1, 0 ],[ 1,  1],[-1,  1 ]])

ccwPath=path.Path(verts, closed=True) 
cwPath=path.Path(verts[::-1,:], closed=True) 

paths = [ccwPath, cwPath]
pathstitle = ["ccwPath", "cwPath"]
radii = [1,-1]

testPoint=(np.random.rand(400,2)-.5)*4

c = lambda p,x,r: p.contains_point(x,radius=r)

def is_ccw(p):
    v = p.vertices-p.vertices[0,:]
    a = np.arctan2(v[1:,1],v[1:,0])
    return (a[1:] >= a[:-1]).astype(int).mean() >= 0.5

fig, axes = plt.subplots(nrows=len(radii),ncols=len(paths))

for j  in range(len(paths)):
    for i in range(len(radii)):
        ax = axes[i,j]
        r = radii[i]
        isccw = is_ccw(paths[j]) 
        r = r*isccw - r*(1-isccw)
        patch = patches.PathPatch(paths[j], fill=False, lw=2)
        ax.add_patch(patch)
        col = [c(paths[j], point[0], r) for point in zip(testPoint)]
        ax.scatter(testPoint[:,0], testPoint[:,1], c=col, s=8, vmin=0,vmax=1, cmap="bwr_r")
        ax.set_title("{}, r={} (isccw={})".format(pathstitle[j],radii[i], isccw) )

plt.tight_layout()
plt.show()

enter image description here

Leftwards answered 31/8, 2017 at 17:5 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.