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

Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 131 additions & 10 deletions lib/matplotlib/bezier.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,123 @@
import numpy as np

from matplotlib import _api
from numpy.polynomial.polynomial import polyval as _polyval


def _bisect_root_finder(f, a, b, tol=1e-12, max_iter=64):
"""Find root of f in [a, b] using bisection. Assumes sign change exists."""
fa = f(a)
for _ in range(max_iter):
mid = (a + b) * 0.5
fm = f(mid)
if abs(fm) < tol or (b - a) < tol:
return mid
if fa * fm < 0:
b = mid
else:
a, fa = mid, fm
return (a + b) * 0.5


def _bisected_roots_in_01(coeffs):
"""
Find real roots of polynomial in [0, 1] using sampling and bisection.
coeffs in ascending order: c0 + c1*x + c2*x**2 + ...
"""
deg = len(coeffs) - 1
n_samples = max(8, deg * 2)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this guarantees you will find all roots, does it? (your initial sieve could be too wide and leave two roots in the same bin, leading to no sign change seen.)
I would think you need something like https://en.wikipedia.org/wiki/Sturm%27s_theorem#Root_isolation

In practice you could also say that we don't care about the degree>=4 case (because Paths can only represent up to cubic beziers), error out for those, and implement the explicit formula for cubics, which is long but reasonably doable (the trig formula https://en.wikipedia.org/wiki/Cubic_equation#Trigonometric_and_hyperbolic_solutions is easier to handle in my experience).

Copy link
Copy Markdown
Contributor Author

@scottshambaugh scottshambaugh Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a really good point which I overlooked.

I didn't realize that Paths only go up to cubic though, which really simplifies things. axis_aligned_extrema does root finding on the derivative of the curve's coefficients, so we are ever actually only root finding for degree <= 2. I took out the bisection altogether and swapped in np.roots as a fallback for higher orders. This makes the code a good bit simpler with no speed impact in practice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image Profiling results still look good

ts = np.linspace(0, 1, n_samples)
vals = _polyval(ts, coeffs)

signs = np.sign(vals)
sign_changes = np.where((signs[:-1] != signs[1:]) & (signs[:-1] != 0))[0]

roots = []

def f(t):
return _polyval(t, coeffs)
Comment thread
scottshambaugh marked this conversation as resolved.
Outdated

max_iter = 53 # float64 fractional precision for [0, 1] interval
for i in sign_changes:
roots.append(_bisect_root_finder(f, ts[i], ts[i + 1], max_iter=max_iter))

# Check endpoints
if abs(vals[0]) < 1e-12:
roots.insert(0, 0.0)
if abs(vals[-1]) < 1e-12 and (not roots or abs(roots[-1] - 1.0) > 1e-10):
roots.append(1.0)

return np.asarray(roots)


def _quadratic_roots_in_01(c0, c1, c2):
"""Real roots of c0 + c1*x + c2*x**2 in [0, 1]."""
if abs(c2) < 1e-12: # Linear
if abs(c1) < 1e-12:
return np.array([])
root = -c0 / c1
return np.array([root]) if 0 <= root <= 1 else np.array([])

disc = c1 * c1 - 4 * c2 * c0
if disc < 0:
return np.array([])

sqrt_disc = np.sqrt(disc)
# Numerically stable quadratic formula
if c1 >= 0:
q = -0.5 * (c1 + sqrt_disc)
else:
q = -0.5 * (c1 - sqrt_disc)

roots = []
if abs(q) > 1e-12:
roots.append(c0 / q)
if abs(c2) > 1e-12:
roots.append(q / c2)

roots = np.asarray(roots)
return roots[(roots >= 0) & (roots <= 1)]


def _real_roots_in_01(coeffs):
"""
Find real roots of a polynomial in the interval [0, 1].

This is optimized for finding roots only in [0, 1], which is faster than
computing all roots with `numpy.roots` and filtering. For polynomials of
degree <= 2, closed-form solutions are used. For higher degrees, sampling
and bisection are used.

Parameters
----------
coeffs : array-like
Polynomial coefficients in ascending order:
``c[0] + c[1]*x + c[2]*x**2 + ...``
Note this is the opposite convention from `numpy.roots`.

Returns
-------
roots : ndarray
Sorted array of real roots in [0, 1].
"""
coeffs = np.asarray(coeffs, dtype=float)

# Trim trailing near-zeros to get actual degree
deg = len(coeffs) - 1
while deg > 0 and abs(coeffs[deg]) < 1e-12:
deg -= 1

if deg <= 0:
return np.array([])
elif deg == 1:
root = -coeffs[0] / coeffs[1]
return np.array([root]) if 0 <= root <= 1 else np.array([])
elif deg == 2:
roots = _quadratic_roots_in_01(coeffs[0], coeffs[1], coeffs[2])
else:
roots = _bisected_roots_in_01(coeffs[:deg + 1])

return np.sort(roots)


@lru_cache(maxsize=16)
Expand Down Expand Up @@ -309,17 +426,21 @@ def axis_aligned_extrema(self):
if n <= 1:
return np.array([]), np.array([])
Cj = self.polynomial_coefficients
dCj = np.arange(1, n+1)[:, None] * Cj[1:]
dims = []
roots = []
dCj = np.arange(1, n + 1)[:, None] * Cj[1:]

all_dims = []
all_roots = []
Comment thread
timhoffm marked this conversation as resolved.

for i, pi in enumerate(dCj.T):
r = np.roots(pi[::-1])
roots.append(r)
dims.append(np.full_like(r, i))
roots = np.concatenate(roots)
dims = np.concatenate(dims)
in_range = np.isreal(roots) & (roots >= 0) & (roots <= 1)
return dims[in_range], np.real(roots)[in_range]
r = _real_roots_in_01(pi)
if len(r) > 0:
all_roots.append(r)
all_dims.append(np.full(len(r), i))

if not all_roots:
return np.array([]), np.array([])

return np.concatenate(all_dims), np.concatenate(all_roots)


def split_bezier_intersecting_with_closedpath(
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/bezier.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def get_normal_points(
cx: float, cy: float, cos_t: float, sin_t: float, length: float
) -> tuple[float, float, float, float]: ...
def split_de_casteljau(beta: ArrayLike, t: float) -> tuple[np.ndarray, np.ndarray]: ...
def _real_roots_in_01(coeffs: ArrayLike) -> np.ndarray: ...
def find_bezier_t_intersecting_with_closedpath(
bezier_point_at_t: Callable[[float], tuple[float, float]],
inside_closedpath: Callable[[tuple[float, float]], bool],
Expand Down
52 changes: 51 additions & 1 deletion lib/matplotlib/tests/test_bezier.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,57 @@
Tests specific to the bezier module.
"""

from matplotlib.bezier import inside_circle, split_bezier_intersecting_with_closedpath
import pytest
import numpy as np
from numpy.testing import assert_allclose

from matplotlib.bezier import (
_real_roots_in_01, inside_circle, split_bezier_intersecting_with_closedpath
)


def _np_real_roots_in_01(coeffs):
"""Reference implementation using np.roots for comparison."""
coeffs = np.asarray(coeffs)
# np.roots expects descending order (highest power first)
all_roots = np.roots(coeffs[::-1])
# Filter to real roots in [0, 1]
real_mask = np.abs(all_roots.imag) < 1e-10
real_roots = all_roots[real_mask].real
in_range = (real_roots >= -1e-10) & (real_roots <= 1 + 1e-10)
return np.sort(np.clip(real_roots[in_range], 0, 1))


@pytest.mark.parametrize("coeffs, expected", [
([-0.5, 1], [0.5]),
([-2, 1], []), # roots: [2.0], not in [0, 1]
([0.1875, -1, 1], [0.25, 0.75]),
([1, -2.5, 1], [0.5]), # roots: [0.5, 2.0], only one in [0, 1]
([1, 0, 1], []), # roots: [+-i], not real
([-0.08, 0.66, -1.5, 1], [0.2, 0.5, 0.8]),
([5], []),
([0, 0, 0], []),
([0, -0.5, 1], [0.0, 0.5]),
([0.5, -1.5, 1], [0.5, 1.0]),
])
def test_real_roots_in_01_known_cases(coeffs, expected):
Comment thread
scottshambaugh marked this conversation as resolved.
Outdated
"""Test _real_roots_in_01 against known values and np.roots reference."""
result = _real_roots_in_01(coeffs)
np_expected = _np_real_roots_in_01(coeffs)
assert_allclose(result, expected, atol=1e-10)
assert_allclose(result, np_expected, atol=1e-10)


@pytest.mark.parametrize("degree", range(1, 11))
def test_real_roots_in_01_random(degree):
"""Test random polynomials against np.roots."""
rng = np.random.default_rng(seed=0)
coeffs = rng.uniform(-10, 10, size=degree + 1)
result = _real_roots_in_01(coeffs)
expected = _np_real_roots_in_01(coeffs)
assert len(result) == len(expected)
if len(result) > 0:
assert_allclose(result, expected, atol=1e-8)


def test_split_bezier_with_large_values():
Expand Down
Loading