Buckets:
| """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.