AsianPlayer/TriDrive / code /tests /test_wheel_ellipse.py
AsianPlayer's picture
download
raw
8.6 kB
"""Unit tests for data/wheel_ellipse.py (label v4 geometry): exact signed distance, direct / RANSAC ellipse fit, arc-coverage gate. Synthetic, fast."""
import sys
from pathlib import Path
import numpy as np, pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "data"))
import wheel_ellipse as WE # noqa: E402
rng = np.random.default_rng(0)
G = (1.1, 0.7, 0.25, 0.15, 0.4) # centre, semi-axes, rotation (normalised units of build_kin_causal)
# ------------------------------------------------------------------------------------------------------------ conversions
def test_geom_conic_roundtrip_and_sign_convention():
K = WE.geom_to_conic(*G); g = WE.conic_to_geom(K)
assert np.allclose(g, G, atol=1e-9)
assert WE.sampson_distance(K, [G[0]], [G[1]])[0] < 0, "Q < 0 inside"
x, y = WE.ellipse_points(G, [0.0, 90.0, 200.0]); assert np.allclose(WE.sampson_distance(K, x, y), 0, atol=1e-9)
# a hyperbola / degenerate conic is rejected
assert WE.conic_to_geom([1.0, 0.0, -1.0, 0.0, 0.0, -1.0]) is None and WE.conic_to_geom([1.0, 0.0, 1.0, 0.0, 0.0, 1.0]) is None
# theta wrap: the same ellipse with theta + pi maps to the same geometry
g2 = WE.conic_to_geom(WE.geom_to_conic(G[0], G[1], G[2], G[3], G[4] + np.pi)); assert np.allclose(g2, G, atol=1e-9)
# swapped axes are re-ordered (a >= b) with theta rotated by 90 deg
g3 = WE.conic_to_geom(WE.geom_to_conic(G[0], G[1], G[3], G[2], G[4])); assert np.isclose(g3[2], G[2]) and np.isclose(g3[3], G[3]) and np.isclose(abs(g3[4] - G[4]), np.pi / 2, atol=1e-9)
# --------------------------------------------------------------------------------------------------------- signed distance
def test_signed_distance_exact_rim_inside_outside():
phi = np.linspace(0, 360, 721); x, y = WE.ellipse_points(G, phi)
assert np.abs(WE.signed_distance(G, x, y)).max() < 1e-12, "points on the rim have distance 0"
assert WE.signed_distance(G, [G[0]], [G[1]])[0] == pytest.approx(-G[3]), "centre: -b (nearest rim point is the minor vertex)"
c, s = np.cos(G[4]), np.sin(G[4]); xv, yv = WE.ellipse_points(G, [0.0])
assert WE.signed_distance(G, [xv[0] + 0.1 * c], [yv[0] + 0.1 * s])[0] == pytest.approx(0.1), "0.1 outside along the major axis"
xm, ym = WE.ellipse_points(G, [90.0]); assert WE.signed_distance(G, [xm[0] - 0.05 * (-s)], [ym[0] - 0.05 * c])[0] == pytest.approx(-0.05), "0.05 inside along the minor axis"
# every interior point negative, every exterior point positive (implicit test)
xs = rng.uniform(0.5, 1.7, 2000); ys = rng.uniform(0.2, 1.2, 2000); d = WE.signed_distance(G, xs, ys)
u = (xs - G[0]) * c + (ys - G[1]) * s; v = -(xs - G[0]) * s + (ys - G[1]) * c; inside = (u / G[2]) ** 2 + (v / G[3]) ** 2 < 1
assert (d[inside] < 0).all() and (d[~inside] > 0).all()
# |d| equals the brute-force minimum distance to a densely sampled rim
phi = np.linspace(0, 360, 400001); rx, ry = WE.ellipse_points(G, phi)
for xx, yy, dd in list(zip(xs, ys, d))[:60]:
assert abs(abs(dd) - np.hypot(rx - xx, ry - yy).min()) < 5e-9
# circle: d = r - R exactly; NaN propagation; per-point geometry arrays
r = rng.uniform(0, 0.6, 500); ang = rng.uniform(0, 2 * np.pi, 500)
assert np.allclose(WE.signed_distance((0.5, 0.5, 0.2, 0.2, 0.0), 0.5 + r * np.cos(ang), 0.5 + r * np.sin(ang)), r - 0.2, atol=1e-12)
d2 = WE.signed_distance(G, [np.nan, 1.0], [0.7, 0.7]); assert np.isnan(d2[0]) and np.isfinite(d2[1])
per = tuple(np.array([G[j], np.nan]) for j in range(5)); d3 = WE.signed_distance(per, [xv[0] + 0.1 * c] * 2, [yv[0] + 0.1 * s] * 2)
assert d3[0] == pytest.approx(0.1) and np.isnan(d3[1])
def test_signed_distance_axis_points_are_well_conditioned():
# a point on the major axis inside the evolute: the nearest rim point is off-axis (Eberly's case y1 == 0); tiny y1 must give the same
e0, e1, y0 = 0.25, 0.15, 0.15
ref = WE._dist_canonical(e0, e1, y0, 0.0)
xde = e0 * y0 / (e0 * e0 - e1 * e1); expect = np.hypot(e0 * xde - y0, e1 * np.sqrt(1 - xde * xde))
assert ref == pytest.approx(expect, abs=1e-12) and expect < 0.1
assert WE._dist_canonical(e0, e1, y0, 1e-17) == pytest.approx(expect, abs=1e-9)
assert WE._dist_canonical(e0, e1, 0.0, 0.05) == pytest.approx(0.10, abs=1e-12)
def test_sampson_close_to_exact_near_rim():
K = WE.geom_to_conic(*G); x0, y0 = WE.ellipse_points(G, rng.uniform(0, 360, 400)); nrm = rng.normal(0, 0.01, 400); ang = rng.uniform(0, 2 * np.pi, 400)
x = x0 + nrm * np.cos(ang); y = y0 + nrm * np.sin(ang)
ds, de = WE.sampson_distance(K, x, y), WE.signed_distance(G, x, y)
assert np.sign(ds[np.abs(de) > 1e-6]).tolist() == np.sign(de[np.abs(de) > 1e-6]).tolist() and np.abs(ds - de).max() < 0.01
assert np.allclose(WE.sampson_distance(np.stack([K, K]), x, y)[1], ds)
# ------------------------------------------------------------------------------------------------------------------- fits
def test_direct_fit_recovers_clean_ellipse():
x, y = WE.ellipse_points(G, rng.uniform(0, 360, 300)); g = WE.fit_ellipse_direct(x, y)
assert np.allclose(g, G, atol=1e-6)
assert WE.fit_ellipse_direct(x[:4], y[:4]) is None and WE.fit_ellipse_direct(np.linspace(0, 1, 50), np.linspace(0, 1, 50)) is None
def test_ransac_recovers_ellipse_from_noisy_arc_with_30pct_outliers():
n = 1000; phi = rng.uniform(0, 360, n); x, y = WE.ellipse_points(G, phi); x += rng.normal(0, 0.008, n); y += rng.normal(0, 0.008, n)
no = int(round(0.3 * n / 0.7)); P = np.c_[np.r_[x, rng.uniform(0.6, 1.6, no)], np.r_[y, rng.uniform(0.3, 1.1, no)]]
r = WE.ransac_ellipse(P, np.random.default_rng(1))
assert r["passed"] and r["n_inlier"] >= 0.9 * n and r["coverage_deg"] >= 300
g = r["geom"]; assert abs(g[0] - G[0]) < 0.01 and abs(g[1] - G[1]) < 0.01 and abs(g[2] - G[2]) < 0.015 and abs(g[3] - G[3]) < 0.015
assert abs(((g[4] - G[4]) + np.pi / 2) % np.pi - np.pi / 2) < np.radians(5)
# the plain direct fit on the contaminated set is biased by the outliers (the RANSAC is needed)
gd = WE.fit_ellipse_direct(P[:, 0], P[:, 1]); assert abs(gd[2] - G[2]) > 0.03 or abs(gd[3] - G[3]) > 0.03
# determinism: same rng seed -> identical result
r2 = WE.ransac_ellipse(P, np.random.default_rng(1)); assert r2["geom"] == r["geom"] and np.array_equal(r2["inliers"], r["inliers"])
def test_arc_coverage_gate():
def pts(phis, n): p = np.concatenate([rng.uniform(a, b, n) for a, b in phis]); x, y = WE.ellipse_points(G, p); return x + rng.normal(0, 0.003, len(x)), y + rng.normal(0, 0.003, len(y))
x, y = pts([(0, 120)], 600); cp, cc = WE.arc_coverage_deg(G, x, y, both=True); assert cp >= 110 and cc >= 110 and WE.arc_coverage_deg(G, x, y) >= WE.COV_MIN_DEG
x, y = pts([(0, 60)], 600); assert WE.arc_coverage_deg(G, x, y) < WE.COV_MIN_DEG
x, y = pts([(0, 20), (180, 200)], 300); assert WE.arc_coverage_deg(G, x, y) < WE.COV_MIN_DEG, "two opposite grip clusters do not span 90 deg"
x, y = pts([(0, 70), (180, 250)], 300); assert WE.arc_coverage_deg(G, x, y) >= WE.COV_MIN_DEG, "two opposite 70 deg arcs add up (parametric 140, central >= 110 on this 1.67:1 ellipse)"
x, y = pts([(0, 50), (180, 230)], 300); cp, cc = WE.arc_coverage_deg(G, x, y, both=True); assert cp >= 90 and cc < cp, "the central angle compresses near the major vertices (min rule is the conservative one)"
# a few scattered points do not occupy a bin (>= max(5, 1 %) rule)
x, y = pts([(0, 60)], 1000); x2, y2 = WE.ellipse_points(G, [200.0, 250.0, 300.0]); assert WE.arc_coverage_deg(G, np.r_[x, x2], np.r_[y, y2]) == WE.arc_coverage_deg(G, x, y)
# gate through ransac: 60 deg arc -> not passed; two 20 deg clusters -> not passed (ratio floor + coverage); 200 pts needed
x, y = pts([(0, 20), (180, 200)], 300); r = WE.ransac_ellipse(np.c_[x, y], np.random.default_rng(3)); assert not r["passed"]
x, y = pts([(0, 360)], 90); r = WE.ransac_ellipse(np.c_[x, y], np.random.default_rng(3)); assert not r["passed"] and r["geom"] is None, "< 200 points: not fitted"
x, y = pts([(0, 360)], 250); r = WE.ransac_ellipse(np.c_[x, y], np.random.default_rng(3)); assert r["passed"] and r["n_inlier"] >= 200
def test_candidate_sanity_range():
g_thin = np.array([[1.0, 0.7, 0.3, 0.05, 0.0]]); g_ok = np.array([[1.0, 0.7, 0.25, 0.15, 0.0]]); g_big = np.array([[1.0, 0.7, 0.7, 0.4, 0.0]]); g_small = np.array([[1.0, 0.7, 0.04, 0.03, 0.0]])
assert WE._sane(g_ok)[0] and not WE._sane(g_thin)[0] and not WE._sane(g_big)[0] and not WE._sane(g_small)[0]
assert not WE._sane(np.array([[1.0, 0.7, np.nan, 0.1, 0.0]]))[0]

Xet Storage Details

Size:
8.6 kB
·
Xet hash:
dd1eae4f4def922837601fb321fb604d52142cb6a956107990f688fb5556213d

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.