Coverage for src / min_circle / welzl.py: 100%
37 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 19:42 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 19:42 +0000
1"""Welzl's algorithm implementation for finding the minimum enclosing circle.
3This module provides an implementation of Welzl's randomized algorithm
4for finding the minimum enclosing circle of a set of points in 2D space.
5"""
7import secrets
9import numpy as np
11from .cvx import min_circle_cvx
12from .utils.circle import Circle
15def perpendicular_slope(p1: np.ndarray, p2: np.ndarray) -> float:
16 """Calculate the slope of the perpendicular bisector between two points.
18 Args:
19 p1: First point as a numpy array [x, y]
20 p2: Second point as a numpy array [x, y]
22 Returns:
23 The slope of the perpendicular bisector line
25 Examples:
26 >>> import numpy as np
27 >>> perpendicular_slope(np.array([0.0, 0.0]), np.array([2.0, 2.0]))
28 -1.0
29 >>> perpendicular_slope(np.array([0.0, 0.0]), np.array([1.0, 0.0]))
30 inf
31 >>> perpendicular_slope(np.array([0.0, 0.0]), np.array([2.0, 1.0]))
32 -2.0
33 """
34 if p2[1] == p1[1]: # horizontal line
35 return float(np.inf) # Perpendicular bisector is vertical
36 return float(-(p2[0] - p1[0]) / (p2[1] - p1[1]))
39def make_circle_n_points(matrix: list[np.ndarray]) -> Circle:
40 """Construct a circle from 0 to 3 points.
42 Args:
43 matrix: List of points (up to 3) as numpy arrays
45 Returns:
46 Circle object containing the center and radius of the circle
47 """
48 num_points = len(matrix)
50 if num_points == 0:
51 return Circle(np.array([0.0, 0.0]), -np.inf)
53 if num_points == 1:
54 return Circle(matrix[0], 0) # Single point, radius 0
56 if num_points == 2 or num_points == 3:
57 return min_circle_cvx(np.array(matrix), solver="CLARABEL")
59 msg = f"Expected 0-3 points, got {num_points}"
60 raise ValueError(msg)
62 # if num_points == 2:
63 # # Two points: the center is the midpoint, radius is half the distance
64 # center = (matrix[0] + matrix[1]) / 2
65 # radius = np.linalg.norm(matrix[0] - matrix[1]) / 2
66 #
67 # return Circle(center=center, radius=radius)
69 # if num_points == 3:
70 # # For 3 points: use the circumcenter and circumradius formula
71 # # p1, p2, p3 = matrix
72 # p = np.array(matrix)
73 #
74 # # Midpoints of the sides
75 # mid12 = (p[0] + p[1]) * 0.5
76 # mid23 = (p[1] + p[2]) * 0.5
77 #
78 # # Slopes of the perpendicular bisectors
79 # # Perpendicular slope to line 1-2: m1
80 # m1 = perpendicular_slope(p[1], p[0])
81 #
82 # # Perpendicular slope to line 2-3: m2
83 # m2 = perpendicular_slope(p[1], p[2])
84 #
85 # # Use line equations to solve for the intersection (circumcenter)
86 # if m1 == np.inf: # Line 1-2 is vertical, so we solve for x = mid12[0]
87 # center_x = mid12[0]
88 # center_y = m2 * (center_x - mid23[0]) + mid23[1]
89 # elif m2 == np.inf: # Line 2-3 is vertical, so we solve for x = mid23[0]
90 # center_x = mid23[0]
91 # center_y = m1 * (center_x - mid12[0]) + mid12[1]
92 # else:
93 # # Calculate the intersection of the two perpendicular bisectors
94 # A, B = m1, -1
95 # C, D = m2, -1
96 # E = m1 * mid12[0] - mid12[1]
97 # F = m2 * mid23[0] - mid23[1]
98 #
99 # # Construct the coefficient matrix and the right-hand side vector
100 # coeff_matrix = np.array([[A, B], [C, D]])
101 # rhs = np.array([E, F])
103 # try:
104 # center_x, center_y = np.linalg.solve(coeff_matrix, rhs)
105 # except np.linalg.LinAlgError:
106 # max_x = p[:, 0].max()
107 # min_x = p[:, 0].min()
108 #
109 # max_y = p[:, 1].max()
110 # min_y = p[:, 1].min()
112 # center_x = (max_x + min_x) / 2
113 # center_y = (max_y + min_y) / 2
115 # center = np.array([center_x, center_y])
116 # radius = np.linalg.norm(p - center, axis=1).max()
118 # return Circle(center=center, radius=radius)
121def welzl_helper(points: list[np.ndarray], r: list[np.ndarray], n: int) -> Circle:
122 """Recursive helper function for Welzl's algorithm."""
123 if n == 0 or len(r) == 3:
124 return make_circle_n_points(r)
126 # Remove a random point by shuffling it to the end
127 # we know at this stage that n > 0
128 idx = secrets.SystemRandom().randrange(n)
129 p = points[idx]
130 points[idx], points[n - 1] = points[n - 1], points[idx]
132 # Recursively compute the minimum circle without p
133 # This is drilling down and open welzl_helper for each individual p!
134 # R remains empty for now
135 circle = welzl_helper(points, r, n - 1)
136 # finally it will arrive at n == 0 and R = []
137 # It now calls make_circle_n_points with R = []
138 # It returns a circle with radius -inf
139 # and lands back here where
140 # n = 1
141 # p is the final surviving point
142 # and the circle has radius -inf
143 # obviously p is not(!) contained in the circle!
145 # If p is inside the circle, we're done
146 if circle.contains(p):
147 return circle
149 # Otherwise, p must be on the boundary of the minimum enclosing circle
150 # now we add this final point, points is still empty
151 r.append(p)
152 circle = welzl_helper(points, r, n - 1)
153 r.pop()
155 return circle
158def min_circle_welzl(points: np.ndarray | list[np.ndarray]) -> Circle:
159 """Find the minimum enclosing circle using Welzl's algorithm.
161 This is the main entry point for Welzl's randomized algorithm. It takes a set of points
162 and returns the smallest circle that contains all the points.
164 Args:
165 points: Array or list of 2D points
167 Returns:
168 Circle object containing the center and radius of the minimum enclosing circle
169 """
170 if isinstance(points, np.ndarray):
171 points = list(points)
173 # Make a copy of points to avoid modifying the input
174 points = points.copy()
175 # Shuffle the points randomly
176 secrets.SystemRandom().shuffle(points)
178 return welzl_helper(points, [], len(points))