Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 9d83ca6

Browse files
PERF: Bezier root finding speedup (matplotlib#31005)
* Faster bezier root finding on [0, 1] * Break out bezier root finding and test it * Fix stub * Simplify bezier root finding * Update bezier tests --------- Co-authored-by: Scott Shambaugh <[email protected]>
1 parent 829a9cc commit 9d83ca6

3 files changed

Lines changed: 123 additions & 11 deletions

File tree

lib/matplotlib/bezier.py

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,82 @@
1111
from matplotlib import _api
1212

1313

14+
def _quadratic_roots_in_01(c0, c1, c2):
15+
"""Real roots of c0 + c1*x + c2*x**2 in [0, 1]."""
16+
if abs(c2) < 1e-12: # Linear
17+
if abs(c1) < 1e-12:
18+
return np.array([])
19+
root = -c0 / c1
20+
return np.array([root]) if 0 <= root <= 1 else np.array([])
21+
22+
disc = c1 * c1 - 4 * c2 * c0
23+
if disc < 0:
24+
return np.array([])
25+
26+
sqrt_disc = np.sqrt(disc)
27+
# Numerically stable quadratic formula
28+
if c1 >= 0:
29+
q = -0.5 * (c1 + sqrt_disc)
30+
else:
31+
q = -0.5 * (c1 - sqrt_disc)
32+
33+
roots = []
34+
if abs(q) > 1e-12:
35+
roots.append(c0 / q)
36+
if abs(c2) > 1e-12:
37+
roots.append(q / c2)
38+
39+
roots = np.asarray(roots)
40+
return roots[(roots >= 0) & (roots <= 1)]
41+
42+
43+
def _real_roots_in_01(coeffs):
44+
"""
45+
Find real roots of a polynomial in the interval [0, 1].
46+
47+
For polynomials of degree <= 2, closed-form solutions are used.
48+
For higher degrees, `numpy.roots` is used as a fallback. In practice,
49+
matplotlib only ever uses cubic bezier curves and axis_aligned_extrema()
50+
differentiates, so we only ever find roots for degree <= 2.
51+
52+
Parameters
53+
----------
54+
coeffs : array-like
55+
Polynomial coefficients in ascending order:
56+
``c[0] + c[1]*x + c[2]*x**2 + ...``
57+
Note this is the opposite convention from `numpy.roots`.
58+
59+
Returns
60+
-------
61+
roots : ndarray
62+
Sorted array of real roots in [0, 1].
63+
"""
64+
coeffs = np.asarray(coeffs, dtype=float)
65+
66+
# Trim trailing near-zeros to get actual degree
67+
deg = len(coeffs) - 1
68+
while deg > 0 and abs(coeffs[deg]) < 1e-12:
69+
deg -= 1
70+
71+
if deg <= 0:
72+
return np.array([])
73+
elif deg == 1:
74+
root = -coeffs[0] / coeffs[1]
75+
return np.array([root]) if 0 <= root <= 1 else np.array([])
76+
elif deg == 2:
77+
roots = _quadratic_roots_in_01(coeffs[0], coeffs[1], coeffs[2])
78+
else:
79+
# np.roots expects descending order (highest power first)
80+
eps = 1e-10
81+
all_roots = np.roots(coeffs[deg::-1])
82+
real_mask = np.abs(all_roots.imag) < eps
83+
real_roots = all_roots[real_mask].real
84+
in_range = (real_roots >= -eps) & (real_roots <= 1 + eps)
85+
roots = np.clip(real_roots[in_range], 0, 1)
86+
87+
return np.sort(roots)
88+
89+
1490
@lru_cache(maxsize=16)
1591
def _get_coeff_matrix(n):
1692
"""
@@ -309,17 +385,21 @@ def axis_aligned_extrema(self):
309385
if n <= 1:
310386
return np.array([]), np.array([])
311387
Cj = self.polynomial_coefficients
312-
dCj = np.arange(1, n+1)[:, None] * Cj[1:]
313-
dims = []
314-
roots = []
388+
dCj = np.arange(1, n + 1)[:, None] * Cj[1:]
389+
390+
all_dims = []
391+
all_roots = []
392+
315393
for i, pi in enumerate(dCj.T):
316-
r = np.roots(pi[::-1])
317-
roots.append(r)
318-
dims.append(np.full_like(r, i))
319-
roots = np.concatenate(roots)
320-
dims = np.concatenate(dims)
321-
in_range = np.isreal(roots) & (roots >= 0) & (roots <= 1)
322-
return dims[in_range], np.real(roots)[in_range]
394+
r = _real_roots_in_01(pi)
395+
if len(r) > 0:
396+
all_roots.append(r)
397+
all_dims.append(np.full(len(r), i))
398+
399+
if not all_roots:
400+
return np.array([]), np.array([])
401+
402+
return np.concatenate(all_dims), np.concatenate(all_roots)
323403

324404

325405
def split_bezier_intersecting_with_closedpath(

lib/matplotlib/bezier.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def get_normal_points(
2222
cx: float, cy: float, cos_t: float, sin_t: float, length: float
2323
) -> tuple[float, float, float, float]: ...
2424
def split_de_casteljau(beta: ArrayLike, t: float) -> tuple[np.ndarray, np.ndarray]: ...
25+
def _real_roots_in_01(coeffs: ArrayLike) -> np.ndarray: ...
2526
def find_bezier_t_intersecting_with_closedpath(
2627
bezier_point_at_t: Callable[[float], tuple[float, float]],
2728
inside_closedpath: Callable[[tuple[float, float]], bool],

lib/matplotlib/tests/test_bezier.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,38 @@
22
Tests specific to the bezier module.
33
"""
44

5-
from matplotlib.bezier import inside_circle, split_bezier_intersecting_with_closedpath
5+
import pytest
6+
import numpy as np
7+
from numpy.testing import assert_allclose
8+
9+
from matplotlib.bezier import (
10+
_real_roots_in_01, inside_circle, split_bezier_intersecting_with_closedpath
11+
)
12+
13+
14+
@pytest.mark.parametrize("roots, expected_in_01", [
15+
([0.5], [0.5]),
16+
([0.25, 0.75], [0.25, 0.75]),
17+
([0.2, 0.5, 0.8], [0.2, 0.5, 0.8]),
18+
([0.1, 0.2, 0.3, 0.4], [0.1, 0.2, 0.3, 0.4]),
19+
([0.0, 0.5], [0.0, 0.5]),
20+
([0.5, 1.0], [0.5, 1.0]),
21+
([2.0], []), # outside [0, 1]
22+
([0.5, 2.0], [0.5]), # one in, one out
23+
([-1j, 1j], []), # complex roots
24+
([0.5, -1j, 1j], [0.5]), # mix of real and complex
25+
([0.3, 0.3], [0.3, 0.3]), # repeated root
26+
])
27+
def test_real_roots_in_01(roots, expected_in_01):
28+
roots = np.array(roots)
29+
coeffs = np.poly(roots)[::-1] # np.poly gives descending, we need ascending
30+
result = _real_roots_in_01(coeffs.real)
31+
assert_allclose(result, expected_in_01, atol=1e-10)
32+
33+
34+
@pytest.mark.parametrize("coeffs", [[5], [0, 0, 0]])
35+
def test_real_roots_in_01_no_roots(coeffs):
36+
assert len(_real_roots_in_01(coeffs)) == 0
637

738

839
def test_split_bezier_with_large_values():

0 commit comments

Comments
 (0)