Skip to content

Commit 54379fb

Browse files
committed
add Cython implementation of is_point_in_polygon_2d()
1 parent dfe2eef commit 54379fb

File tree

6 files changed

+232
-59
lines changed

6 files changed

+232
-59
lines changed

profiling/is_point_in_polygon.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Copyright (c) 2024, Manfred Moitzi
2+
# License: MIT License
3+
import time
4+
5+
from ezdxf.math import Vec2
6+
from ezdxf.math._construct import is_point_in_polygon_2d
7+
from ezdxf.acc.construct import is_point_in_polygon_2d as is_point_in_polygon_cy
8+
9+
VERTICES = [(1, 1), (5, 1), (5, 3), (3, 3), (3, 5), (5, 5), (5, 7), (1, 7)]
10+
SHAPE_PY = Vec2.list(VERTICES)
11+
12+
POINTS_INSIDE = Vec2.list(
13+
[(2, 2), (3, 2), (4, 2), (2, 3), (2, 4), (2, 5), (2, 6), (3, 6), (4, 6)]
14+
)
15+
16+
POINTS_OUTSIDE = Vec2.list(
17+
[
18+
(0, 0), # 0
19+
(2, 0), # 1
20+
(4, 0), # 2
21+
(6, 0), # 3
22+
(0, 2), # 4
23+
(6, 2), # 5
24+
(0, 4), # 6
25+
(4, 4), # 7
26+
(6, 2), # 8
27+
(0, 6), # 9
28+
(6, 6), # a
29+
(0, 8), # b
30+
(2, 8), # c
31+
(4, 8), # d
32+
(6, 8), # e
33+
]
34+
)
35+
36+
37+
ROUNDS = 2000
38+
39+
40+
def python_version():
41+
for point in POINTS_INSIDE + POINTS_OUTSIDE:
42+
is_point_in_polygon_2d(point, SHAPE_PY)
43+
44+
45+
def cython_version():
46+
for point in POINTS_INSIDE + POINTS_OUTSIDE:
47+
is_point_in_polygon_cy(point, SHAPE_PY)
48+
49+
50+
def profile(func) -> float:
51+
t0 = time.perf_counter()
52+
for _ in range(ROUNDS):
53+
func()
54+
t1 = time.perf_counter()
55+
return t1 - t0
56+
57+
58+
def main():
59+
py_t = profile(python_version)
60+
print(f"python version: {py_t:.3f}s")
61+
62+
# Numpy/Cython version was just 10x faster, this Cython version is 23x faster!
63+
cy_t = profile(cython_version)
64+
print(f"cython version: {cy_t:.3f}s")
65+
print(f"ratio python/cython: {py_t/cy_t:.3f}")
66+
67+
68+
if __name__ == "__main__":
69+
main()

src/ezdxf/acc/construct.pyx

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# cython: language_level=3
22
# distutils: language = c++
3-
# Copyright (c) 2020-2023, Manfred Moitzi
3+
# Copyright (c) 2020-2024, Manfred Moitzi
44
# License: MIT License
55
# type: ignore -- pylance sucks at type-checking cython files
66
from typing import Iterable, TYPE_CHECKING, Sequence, Optional, Tuple
@@ -199,3 +199,80 @@ def arc_angle_span_rad(double start, double end) -> float:
199199
if end < start:
200200
end += M_TAU
201201
return end - start
202+
203+
204+
def is_point_in_polygon_2d(
205+
point: Vec2, polygon: list[Vec2], double abs_tol=TOLERANCE
206+
) -> int:
207+
"""
208+
Test if `point` is inside `polygon`. Returns +1 for inside, 0 for on the
209+
boundary and -1 for outside.
210+
211+
Supports convex and concave polygons with clockwise or counter-clockwise oriented
212+
polygon vertices. Does not raise an exception for degenerated polygons.
213+
214+
215+
Args:
216+
point: 2D point to test as :class:`Vec2`
217+
polygon: list of 2D points as :class:`Vec2`
218+
abs_tol: tolerance for distance check
219+
220+
Returns:
221+
+1 for inside, 0 for on the boundary, -1 for outside
222+
223+
"""
224+
# Source: http://www.faqs.org/faqs/graphics/algorithms-faq/
225+
# Subject 2.03: How do I find if a point lies within a polygon?
226+
# Numpy version was just 10x faster, this version is 23x faster than the Python
227+
# version!
228+
cdef double a, b, c, d, x, y, x1, y1, x2, y2
229+
cdef list vertices = polygon
230+
cdef Vec2 p1, p2
231+
cdef int size, last, i
232+
cdef bint inside = 0
233+
234+
size = len(vertices)
235+
if size < 3: # empty polygon
236+
return -1
237+
last = size - 1
238+
p1 = <Vec2> vertices[0]
239+
p2 = <Vec2> vertices[last]
240+
241+
if v2_isclose(p1, p2, REL_TOL, ABS_TOL): # open polygon
242+
size -= 1
243+
last -= 1
244+
if size < 3:
245+
return -1
246+
247+
x = point.x
248+
y = point.y
249+
p1 = <Vec2> vertices[last]
250+
x1 = p1.x
251+
y1 = p1.y
252+
253+
for i in range(size):
254+
p2 = <Vec2> vertices[i]
255+
x2 = p2.x
256+
y2 = p2.y
257+
258+
# is point on polygon boundary line:
259+
# is point in x-range of line
260+
a, b = (x2, x1) if x2 < x1 else (x1, x2)
261+
if a <= x <= b:
262+
# is point in y-range of line
263+
c, d = (y2, y1) if y2 < y1 else (y1, y2)
264+
if (c <= y <= d) and fabs(
265+
(y2 - y1) * x - (x2 - x1) * y + (x2 * y1 - y2 * x1)
266+
) <= abs_tol:
267+
return 0 # on boundary line
268+
if ((y1 <= y < y2) or (y2 <= y < y1)) and (
269+
x < (x2 - x1) * (y - y1) / (y2 - y1) + x1
270+
):
271+
inside = not inside
272+
x1 = x2
273+
y1 = y2
274+
if inside:
275+
return 1 # inside polygon
276+
else:
277+
return -1 # outside polygon
278+

src/ezdxf/math/_construct.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2011-2022, Manfred Moitzi
1+
# Copyright (c) 2011-2024, Manfred Moitzi
22
# License: MIT License
33
# These are the pure Python implementations of the Cython accelerated
44
# construction tools: ezdxf/acc/construct.pyx
@@ -216,3 +216,61 @@ def arc_angle_span_rad(start: float, end: float) -> float:
216216
if end < start:
217217
end += tau
218218
return end - start
219+
220+
221+
def is_point_in_polygon_2d(
222+
point: Vec2, polygon: list[Vec2], abs_tol=TOLERANCE
223+
) -> int:
224+
"""
225+
Test if `point` is inside `polygon`. Returns +1 for inside, 0 for on the
226+
boundary and -1 for outside.
227+
228+
Supports convex and concave polygons with clockwise or counter-clockwise oriented
229+
polygon vertices. Does not raise an exception for degenerated polygons.
230+
231+
232+
Args:
233+
point: 2D point to test as :class:`Vec2`
234+
polygon: list of 2D points as :class:`Vec2`
235+
abs_tol: tolerance for distance check
236+
237+
Returns:
238+
+1 for inside, 0 for on the boundary, -1 for outside
239+
240+
"""
241+
# Source: http://www.faqs.org/faqs/graphics/algorithms-faq/
242+
# Subject 2.03: How do I find if a point lies within a polygon?
243+
# polygon: the Cython implementation needs a list as input to be fast!
244+
assert isinstance(polygon, list)
245+
if len(polygon) < 3: # empty polygon
246+
return -1
247+
248+
if polygon[0].isclose(polygon[-1]): # open polygon is required
249+
polygon = polygon[:-1]
250+
if len(polygon) < 3:
251+
return -1
252+
x = point.x
253+
y = point.y
254+
inside = False
255+
x1, y1 = polygon[-1]
256+
for x2, y2 in polygon:
257+
# is point on polygon boundary line:
258+
# is point in x-range of line
259+
a, b = (x2, x1) if x2 < x1 else (x1, x2)
260+
if a <= x <= b:
261+
# is point in y-range of line
262+
c, d = (y2, y1) if y2 < y1 else (y1, y2)
263+
if (c <= y <= d) and abs(
264+
(y2 - y1) * x - (x2 - x1) * y + (x2 * y1 - y2 * x1)
265+
) <= abs_tol:
266+
return 0 # on boundary line
267+
if ((y1 <= y < y2) or (y2 <= y < y1)) and (
268+
x < (x2 - x1) * (y - y1) / (y2 - y1) + x1
269+
):
270+
inside = not inside
271+
x1 = x2
272+
y1 = y2
273+
if inside:
274+
return 1 # inside polygon
275+
else:
276+
return -1 # outside polygon

src/ezdxf/math/_ctypes.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"intersection_ray_ray_3d",
3030
"arc_angle_span_deg",
3131
"arc_angle_span_rad",
32+
"is_point_in_polygon_2d",
3233
]
3334
# Import of Python or Cython implementations:
3435
if USE_C_EXT:
@@ -57,6 +58,7 @@
5758
intersection_ray_ray_3d,
5859
arc_angle_span_deg,
5960
arc_angle_span_rad,
61+
is_point_in_polygon_2d,
6062
)
6163
else:
6264
from ._vector import (
@@ -84,6 +86,7 @@
8486
intersection_ray_ray_3d,
8587
arc_angle_span_deg,
8688
arc_angle_span_rad,
89+
is_point_in_polygon_2d,
8790
)
8891

8992
# Early required type aliases

src/ezdxf/math/construct2d.py

Lines changed: 1 addition & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
"is_convex_polygon_2d",
3232
"is_axes_aligned_rectangle_2d",
3333
"is_point_on_line_2d",
34-
"is_point_in_polygon_2d",
3534
"is_point_left_of_line",
3635
"point_to_line_relation",
3736
"enclosing_angles",
@@ -241,62 +240,8 @@ def distance_point_line_2d(point: Vec2, start: Vec2, end: Vec2) -> float:
241240
return math.fabs((start - point).det(end - point)) / (end - start).magnitude
242241

243242

244-
# Candidate for a faster Cython implementation:
245-
# is also used for testing 3D ray and line intersection with polygon
246-
def is_point_in_polygon_2d(
247-
point: Vec2, polygon: Sequence[Vec2], abs_tol=TOLERANCE
248-
) -> int:
249-
"""Test if `point` is inside `polygon`. Returns ``-1`` (for outside) if the
250-
polygon is degenerated, no exception will be raised.
251-
252-
Supports convex and concave polygons with clockwise or counter-clockwise oriented
253-
polygon vertices.
254-
255-
Args:
256-
point: 2D point to test as :class:`Vec2`
257-
polygon: sequence of 2D points as :class:`Vec2`
258-
abs_tol: tolerance for distance check
259-
260-
Returns:
261-
``+1`` for inside, ``0`` for on boundary line, ``-1`` for outside
262-
263-
"""
264-
# Source: http://www.faqs.org/faqs/graphics/algorithms-faq/
265-
# Subject 2.03: How do I find if a point lies within a polygon?
266-
# TODO: Cython implementation
267-
if not polygon: # empty polygon
268-
return -1
269-
270-
if polygon[0].isclose(polygon[-1]): # open polygon is required
271-
polygon = polygon[:-1]
272-
if len(polygon) < 3:
273-
return -1
274-
x = point.x
275-
y = point.y
276-
inside = False
277-
x1, y1 = polygon[-1]
278-
for x2, y2 in polygon:
279-
# is point on polygon boundary line:
280-
# is point in x-range of line
281-
a, b = (x2, x1) if x2 < x1 else (x1, x2)
282-
if a <= x <= b:
283-
# is point in y-range of line
284-
c, d = (y2, y1) if y2 < y1 else (y1, y2)
285-
if (c <= y <= d) and math.fabs(
286-
(y2 - y1) * x - (x2 - x1) * y + (x2 * y1 - y2 * x1)
287-
) <= abs_tol:
288-
return 0 # on boundary line
289-
if ((y1 <= y < y2) or (y2 <= y < y1)) and (
290-
x < (x2 - x1) * (y - y1) / (y2 - y1) + x1
291-
):
292-
inside = not inside
293-
x1 = x2
294-
y1 = y2
295-
if inside:
296-
return 1 # inside polygon
297-
else:
298-
return -1 # outside polygon
299243

244+
300245

301246
def circle_radius_3p(a: Vec3, b: Vec3, c: Vec3) -> float:
302247
ba = b - a

tests/test_06_math/test_613_is_point_in_polygon_2d.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
# Copyright (c) 2020-2024, Manfred Moitzi
22
# License: MIT License
33
import pytest
4-
from ezdxf.math import is_point_in_polygon_2d, Vec2, is_convex_polygon_2d
4+
from ezdxf.math import Vec2, is_convex_polygon_2d
5+
6+
from ezdxf.math._construct import is_point_in_polygon_2d
7+
is_point_in_polygon_cy = is_point_in_polygon_2d
8+
try:
9+
from ezdxf.acc.construct import is_point_in_polygon_2d as is_point_in_polygon_cy
10+
except ImportError:
11+
pass
512

613

714
def test_inside_horizontal_box():
@@ -157,5 +164,19 @@ def test_is_outside_cw_polygon(point: Vec2):
157164
assert is_point_in_polygon_2d(point, SHAPE_C_CW) == -1
158165

159166

167+
@pytest.mark.skipif(
168+
is_point_in_polygon_cy is is_point_in_polygon_2d,
169+
reason="no Cython implementation available",
170+
)
171+
class TestCytonImplementation:
172+
@pytest.mark.parametrize("point", POINTS_INSIDE)
173+
def test_is_inside_ccw_polygon(self, point: Vec2):
174+
assert is_point_in_polygon_cy(point, SHAPE_C_CCW) >= 0
175+
176+
@pytest.mark.parametrize("point", POINTS_OUTSIDE)
177+
def test_is_outside_ccw_polygon(self, point: Vec2):
178+
assert is_point_in_polygon_cy(point, SHAPE_C_CCW) == -1
179+
180+
160181
if __name__ == "__main__":
161182
pytest.main([__file__])

0 commit comments

Comments
 (0)