painting-vision-robotics-kit / test_robot_planner.py
constructelligence's picture
Upload test_robot_planner.py with huggingface_hub
2d1925c verified
Raw History Blame Contribute Delete
7.94 kB
#!/usr/bin/env python3
"""Fast geometry regression tests for robot_planner (no torch/cv2 needed).
Run with ``python3 test_robot_planner.py``. These pin the pure-numpy/scipy
behaviour: contour tracing, polygon simplification, homography round-trips, and
stroke planning/coverage, including the failure path when the roller cannot fit.
"""
import numpy as np
import robot_planner as rp
def rectangle(height, width, top, left, bottom, right):
mask = np.zeros((height, width), dtype=bool)
mask[top:bottom, left:right] = True
return mask
def test_trace_follows_concavity():
# L-shape: a convex hull would have 4 corners and bridge the notch.
mask = rectangle(60, 60, 5, 5, 55, 20)
mask |= rectangle(60, 60, 40, 5, 55, 55)
traced = [(x, y) for y, x in rp.trace_boundary(mask)]
simplified = rp.simplify_closed(traced, tolerance=2.0)
assert 5 <= len(simplified) <= 8, f"expected ~6 corners for an L-shape, got {len(simplified)}"
assert len(traced) > 40
def test_rectangle_simplifies_to_four_corners():
traced = [(x, y) for y, x in rp.trace_boundary(rectangle(40, 70, 5, 6, 33, 61))]
simplified = rp.simplify_closed(traced, tolerance=1.5)
assert len(simplified) == 4, simplified
def test_homography_round_trip():
corners = np.array([[10, 12], [410, 8], [420, 308], [5, 300]], float)
matrix = rp.homography(corners, [(0, 0), (400, 0), (400, 300), (0, 300)])
mapped = rp.apply_homography(matrix, corners)
assert np.allclose(mapped, [[0, 0], [400, 0], [400, 300], [0, 300]], atol=1e-6)
back = rp.apply_homography(np.linalg.inv(matrix), mapped)
assert np.allclose(back, corners, atol=1e-6)
def test_plan_covers_open_wall():
paintable = rectangle(400, 600, 20, 20, 380, 580)
keepout = np.zeros_like(paintable)
plan, debug = rp.build_plan(paintable, keepout, stroke_width=80, overlap=0.2, margin=20,
orientation="horizontal", corners=None, mm_per_unit=2.5,
boundary_tolerance=6.0)
assert plan["warnings"] == [], plan["warnings"]
assert plan["wall"]["corners"] == 4
assert plan["plan"]["segments"] > 0
assert plan["coverage"]["planned_fraction"] >= 0.8, plan["coverage"]
assert plan["plan"]["path_length_mm"] > plan["plan"]["path_length_units"]
assert debug["allowed"].any()
def test_plan_reports_infeasible_roller():
paintable = rectangle(200, 200, 0, 0, 200, 200)
keepout = np.zeros_like(paintable)
plan, _ = rp.build_plan(paintable, keepout, stroke_width=900, overlap=0.2, margin=100,
orientation="horizontal", corners=None, mm_per_unit=None,
boundary_tolerance=4.0)
assert plan["plan"]["segments"] == 0
assert plan["warnings"] and "centre-line" in plan["warnings"][0]
def test_keepout_blocks_strokes():
paintable = rectangle(300, 300, 10, 10, 290, 290)
keepout = rectangle(300, 300, 0, 140, 300, 160) # vertical stripe: splits every pass
plan, _ = rp.build_plan(paintable, keepout, stroke_width=40, overlap=0.2, margin=10,
orientation="horizontal", corners=None, mm_per_unit=None,
boundary_tolerance=4.0)
assert plan["plan"]["segments"] > plan["plan"]["passes"], "keep-out stripe should split passes"
assert 0.0 < plan["coverage"]["planned_fraction"] < 1.0
def test_trim_tool_reaches_edges_and_fixtures():
paintable = rectangle(400, 600, 20, 20, 380, 580)
keepout = rectangle(400, 600, 0, 290, 400, 310) # fixture stripe the roller keeps clear of
roller_only, _ = rp.build_plan(paintable, keepout, stroke_width=90, overlap=0.2, margin=25,
orientation="horizontal", corners=None, mm_per_unit=None,
boundary_tolerance=4.0)
combined, _ = rp.build_plan(paintable, keepout, stroke_width=90, overlap=0.2, margin=25,
orientation="horizontal", corners=None, mm_per_unit=None,
boundary_tolerance=4.0, trim_width=20, trim_overlap=0.3)
assert roller_only["tools"] == ["roller"]
assert combined["tools"] == ["roller", "trim"]
assert combined["plan"]["trim"]["segments"] > 0
assert combined["coverage"]["planned_fraction"] > roller_only["coverage"]["planned_fraction"]
assert combined["coverage"]["remaining_fraction"] < roller_only["coverage"]["remaining_fraction"]
assert combined["coverage"]["remaining_fraction"] < 0.05
def test_waypoints_carry_paint_flags():
paintable = rectangle(200, 200, 10, 10, 190, 190)
plan, _ = rp.build_plan(paintable, np.zeros_like(paintable), stroke_width=40, overlap=0.2, margin=10,
orientation="horizontal", corners=None, mm_per_unit=None, boundary_tolerance=4.0)
waypoints = plan["plan"]["waypoints"]
segments = plan["plan"]["segments"]
assert waypoints[0]["paint"] is True
assert sum(1 for w in waypoints if not w["paint"]) == max(0, segments - 1)
assert all(set(("x", "y", "paint", "tool")) <= set(w) for w in waypoints)
def test_progress_state_round_trip_and_replan():
import tempfile
from pathlib import Path
painted = rectangle(120, 160, 10, 10, 90, 150)
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "state.npz"
assert rp.load_state(path) is None
rp.save_state(path, painted, (160, 120))
loaded, size = rp.load_state(path)
assert size == (160, 120) and np.array_equal(loaded, painted)
# A fully painted target is recognised and yields no new strokes.
plan, _ = rp.build_plan(painted, np.zeros_like(painted), stroke_width=30, overlap=0.2, margin=5,
orientation="horizontal", corners=None, mm_per_unit=None,
boundary_tolerance=3.0, already_painted=painted)
assert plan["plan"]["segments"] == 0
assert plan["coverage"]["already_painted_fraction"] == 1.0
assert any("already fully painted" in w for w in plan["warnings"])
def test_progress_state_rejects_size_mismatch():
paintable = rectangle(120, 160, 10, 10, 90, 150)
try:
rp.build_plan(paintable, np.zeros_like(paintable), stroke_width=30, overlap=0.2, margin=5,
orientation="horizontal", corners=None, mm_per_unit=None, boundary_tolerance=3.0,
already_painted=rectangle(100, 100, 0, 0, 100, 100))
except ValueError as exc:
assert "does not match plane" in str(exc)
else:
raise AssertionError("expected a size-mismatch ValueError")
def test_measured_plane_size_sets_true_aspect():
# A foreshortened wall in the image: without the measured size the plane takes the
# image's longest edges; with it, the plane has the wall's true proportions.
paintable = rectangle(400, 600, 0, 0, 400, 600)
corners = [(100, 80), (500, 120), (500, 300), (100, 340)]
_, guessed = rp.build_plan(paintable, np.zeros_like(paintable), stroke_width=20, overlap=0.2, margin=5,
orientation="horizontal", corners=corners, mm_per_unit=None, boundary_tolerance=4.0)
plan, measured = rp.build_plan(paintable, np.zeros_like(paintable), stroke_width=20, overlap=0.2, margin=5,
orientation="horizontal", corners=corners, mm_per_unit=10.0,
boundary_tolerance=4.0, plane_size=(366, 229))
assert tuple(guessed["size"]) == (402, 260) and tuple(measured["size"]) == (366, 229)
assert plan["plane_size"]["width"] == 366 and plan["plane_size"]["mm_per_unit"] == 10.0
def main():
tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")]
for test in tests:
test()
print(f"ok {test.__name__}")
print(f"{len(tests)} tests passed")
if __name__ == "__main__":
main()