Skip to content

Commit 38be0a6

Browse files
Clear20-22cclauss
andauthored
Add Rotating Calipers algorithm for convex polygon diameter (#15275)
* Add Rotating Calipers algorithm for convex polygon diameter * refactor: define Point as NamedTuple class to adhere to naming conventions --------- Co-authored-by: Christian Clauss <cclauss@me.com>
1 parent 1eb3c71 commit 38be0a6

1 file changed

Lines changed: 207 additions & 0 deletions

File tree

geometry/rotating_calipers.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
"""
2+
Rotating Calipers Algorithm for Convex Polygon Diameter.
3+
4+
References:
5+
- https://en.wikipedia.org/wiki/Rotating_calipers
6+
- https://cp-algorithms.com/geometry/convex-hull-kernel.html
7+
- Toussaint, G. T. (1983). "Solving geometric problems with the rotating calipers".
8+
Proceedings of IEEE MELECON '83, Athens, Greece.
9+
10+
The rotating calipers paradigm allows computing the diameter (the maximum Euclidean
11+
distance between any pair of points) of a set of 2D points in O(n log n) time
12+
(O(n log n) for the convex hull and O(n) for the calipers sweep).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import math
18+
from typing import NamedTuple
19+
20+
21+
class Point(NamedTuple):
22+
"""
23+
A 2D point with real-valued coordinates.
24+
25+
>>> Point(0.0, 0.0)
26+
Point(x=0.0, y=0.0)
27+
>>> Point(1.5, -2.0)
28+
Point(x=1.5, y=-2.0)
29+
"""
30+
31+
x: float
32+
y: float
33+
34+
35+
def cross_product(origin: Point, point_a: Point, point_b: Point) -> float:
36+
"""
37+
Compute the 2D cross product of vectors (origin -> point_a) and (origin -> point_b).
38+
39+
The return value represents twice the signed area of triangle
40+
(origin, point_a, point_b):
41+
> 0 : Counter-clockwise turn (left turn)
42+
< 0 : Clockwise turn (right turn)
43+
= 0 : Collinear points
44+
45+
>>> cross_product(Point(0.0, 0.0), Point(1.0, 0.0), Point(1.0, 1.0))
46+
1.0
47+
>>> cross_product(Point(0.0, 0.0), Point(1.0, 1.0), Point(1.0, 0.0))
48+
-1.0
49+
>>> cross_product(Point(0.0, 0.0), Point(1.0, 1.0), Point(2.0, 2.0))
50+
0.0
51+
"""
52+
return (point_a.x - origin.x) * (point_b.y - origin.y) - (point_a.y - origin.y) * (
53+
point_b.x - origin.x
54+
)
55+
56+
57+
def distance_squared(point_a: Point, point_b: Point) -> float:
58+
"""
59+
Compute the squared Euclidean distance between point_a and point_b.
60+
61+
>>> distance_squared(Point(0.0, 0.0), Point(3.0, 4.0))
62+
25.0
63+
>>> distance_squared(Point(1.0, 1.0), Point(1.0, 1.0))
64+
0.0
65+
>>> distance_squared(Point(-1.0, -1.0), Point(2.0, 3.0))
66+
25.0
67+
"""
68+
return (point_a.x - point_b.x) ** 2 + (point_a.y - point_b.y) ** 2
69+
70+
71+
def convex_hull(points: list[Point]) -> list[Point]:
72+
"""
73+
Compute the convex hull of a set of 2D points in counter-clockwise order
74+
using Andrew's monotone chain algorithm.
75+
76+
Time Complexity: O(n log n) where n is the number of points.
77+
Space Complexity: O(n)
78+
79+
>>> convex_hull([Point(0.0, 0.0), Point(1.0, 1.0)])
80+
[Point(x=0.0, y=0.0), Point(x=1.0, y=1.0)]
81+
>>> convex_hull([
82+
... Point(0.0, 0.0),
83+
... Point(3.0, 0.0),
84+
... Point(3.0, 3.0),
85+
... Point(0.0, 3.0),
86+
... Point(1.0, 1.0),
87+
... ])
88+
[Point(x=0.0, y=0.0), Point(x=3.0, y=0.0), Point(x=3.0, y=3.0), Point(x=0.0, y=3.0)]
89+
>>> convex_hull([Point(0.0, 0.0), Point(1.0, 1.0), Point(2.0, 2.0)])
90+
[Point(x=0.0, y=0.0), Point(x=2.0, y=2.0)]
91+
>>> convex_hull([Point(1.0, 1.0)])
92+
[Point(x=1.0, y=1.0)]
93+
"""
94+
unique_points = sorted(set(points))
95+
if len(unique_points) <= 1:
96+
return unique_points
97+
98+
lower_hull: list[Point] = []
99+
for candidate_point in unique_points:
100+
while (
101+
len(lower_hull) >= 2
102+
and cross_product(lower_hull[-2], lower_hull[-1], candidate_point) <= 0.0
103+
):
104+
lower_hull.pop()
105+
lower_hull.append(candidate_point)
106+
107+
upper_hull: list[Point] = []
108+
for candidate_point in reversed(unique_points):
109+
while (
110+
len(upper_hull) >= 2
111+
and cross_product(upper_hull[-2], upper_hull[-1], candidate_point) <= 0.0
112+
):
113+
upper_hull.pop()
114+
upper_hull.append(candidate_point)
115+
116+
return lower_hull[:-1] + upper_hull[:-1]
117+
118+
119+
def rotating_calipers(points: list[Point]) -> tuple[float, tuple[Point, Point]]:
120+
"""
121+
Find the maximum Euclidean distance (polygon diameter) and an antipodal pair
122+
of points for a given set of 2D points using the rotating calipers algorithm.
123+
124+
Time Complexity: O(n log n) for convex hull construction
125+
+ O(n) for the calipers sweep.
126+
Space Complexity: O(n) for the convex hull.
127+
128+
Raises:
129+
ValueError: If fewer than 2 points are provided.
130+
131+
>>> points = [
132+
... Point(0.0, 0.0),
133+
... Point(3.0, 0.0),
134+
... Point(3.0, 4.0),
135+
... Point(0.0, 4.0),
136+
... ]
137+
>>> max_dist, pair = rotating_calipers(points)
138+
>>> max_dist
139+
5.0
140+
>>> pair in [
141+
... (Point(0.0, 0.0), Point(3.0, 4.0)),
142+
... (Point(3.0, 4.0), Point(0.0, 0.0)),
143+
... (Point(3.0, 0.0), Point(0.0, 4.0)),
144+
... (Point(0.0, 4.0), Point(3.0, 0.0)),
145+
... ]
146+
True
147+
>>> rotating_calipers([Point(0.0, 0.0), Point(0.0, 5.0)])
148+
(5.0, (Point(x=0.0, y=0.0), Point(x=0.0, y=5.0)))
149+
>>> rotating_calipers([Point(1.0, 1.0), Point(1.0, 1.0)])
150+
(0.0, (Point(x=1.0, y=1.0), Point(x=1.0, y=1.0)))
151+
>>> rotating_calipers([
152+
... Point(0.0, 0.0),
153+
... Point(1.0, 1.0),
154+
... Point(2.0, 2.0),
155+
... Point(3.0, 3.0),
156+
... ])[0]
157+
4.242640687119285
158+
>>> rotating_calipers([Point(1.0, 1.0)])
159+
Traceback (most recent call last):
160+
...
161+
ValueError: At least 2 points are required to compute polygon diameter.
162+
"""
163+
if len(points) < 2:
164+
raise ValueError("At least 2 points are required to compute polygon diameter.")
165+
166+
hull = convex_hull(points)
167+
hull_size = len(hull)
168+
169+
if hull_size == 1:
170+
return 0.0, (hull[0], hull[0])
171+
if hull_size == 2:
172+
return math.hypot(hull[0].x - hull[1].x, hull[0].y - hull[1].y), (
173+
hull[0],
174+
hull[1],
175+
)
176+
177+
max_dist_squared = 0.0
178+
best_pair = (hull[0], hull[1])
179+
180+
# Find initial antipodal point furthest from edge hull[0]-hull[1]
181+
antipodal_idx = 1
182+
while cross_product(
183+
hull[0], hull[1], hull[(antipodal_idx + 1) % hull_size]
184+
) > cross_product(hull[0], hull[1], hull[antipodal_idx]):
185+
antipodal_idx = (antipodal_idx + 1) % hull_size
186+
187+
for current_idx in range(hull_size):
188+
next_idx = (current_idx + 1) % hull_size
189+
while cross_product(
190+
hull[current_idx], hull[next_idx], hull[(antipodal_idx + 1) % hull_size]
191+
) > cross_product(hull[current_idx], hull[next_idx], hull[antipodal_idx]):
192+
antipodal_idx = (antipodal_idx + 1) % hull_size
193+
194+
for p in (hull[current_idx], hull[next_idx]):
195+
for candidate_idx in (antipodal_idx, (antipodal_idx + 1) % hull_size):
196+
dist_sq = distance_squared(p, hull[candidate_idx])
197+
if dist_sq > max_dist_squared:
198+
max_dist_squared = dist_sq
199+
best_pair = (p, hull[candidate_idx])
200+
201+
return math.sqrt(max_dist_squared), best_pair
202+
203+
204+
if __name__ == "__main__":
205+
import doctest
206+
207+
doctest.testmod()

0 commit comments

Comments
 (0)