140 lines
5.2 KiB
Python
140 lines
5.2 KiB
Python
"""Unit tests for src/server/target_point.py"""
|
|
import pytest
|
|
from src.server.target_point import (
|
|
_poly_to_abs,
|
|
_shape_center,
|
|
_pin_left_midpoint,
|
|
_pick_best,
|
|
compute_target_point,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _poly_to_abs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPolyToAbs:
|
|
def test_none_poly_returns_none(self):
|
|
assert _poly_to_abs(None, 10, 20) is None
|
|
|
|
def test_translates_relative_to_absolute(self):
|
|
poly = [[1, 2], [3, 4], [5, 6]]
|
|
result = _poly_to_abs(poly, 10.0, 20.0)
|
|
assert result is not None
|
|
assert result[0, 0] == pytest.approx(11.0)
|
|
assert result[0, 1] == pytest.approx(22.0)
|
|
assert result[2, 0] == pytest.approx(15.0)
|
|
|
|
def test_rejects_fewer_than_3_points(self):
|
|
poly = [[1, 2], [3, 4]]
|
|
assert _poly_to_abs(poly, 0, 0) is None
|
|
|
|
def test_rejects_wrong_shape(self):
|
|
poly = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
|
|
assert _poly_to_abs(poly, 0, 0) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _shape_center
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestShapeCenter:
|
|
def test_falls_back_to_bbox_center_with_no_poly(self):
|
|
det = {"x1": 0.0, "y1": 0.0, "x2": 100.0, "y2": 100.0}
|
|
cx, cy = _shape_center(det)
|
|
assert cx == pytest.approx(50.0)
|
|
assert cy == pytest.approx(50.0)
|
|
|
|
def test_uses_polygon_centroid_when_poly_present(self):
|
|
# Unit square at origin: centroid == (0.5, 0.5) + offset (10, 20) = (10.5, 20.5)
|
|
poly = [[0, 0], [1, 0], [1, 1], [0, 1]]
|
|
det = {"x1": 10.0, "y1": 20.0, "x2": 11.0, "y2": 21.0, "poly": poly}
|
|
cx, cy = _shape_center(det)
|
|
assert cx == pytest.approx(10.5, abs=0.1)
|
|
assert cy == pytest.approx(20.5, abs=0.1)
|
|
|
|
def test_returns_none_on_missing_coords(self):
|
|
assert _shape_center({}) == (0.0, 0.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _pin_left_midpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPinLeftMidpoint:
|
|
def test_returns_x1_and_vertical_midpoint(self):
|
|
det = {"x1": 5.0, "y1": 10.0, "x2": 50.0, "y2": 30.0}
|
|
x, y = _pin_left_midpoint(det)
|
|
assert x == pytest.approx(5.0)
|
|
assert y == pytest.approx(20.0)
|
|
|
|
def test_returns_none_on_bad_coords(self):
|
|
assert _pin_left_midpoint({"x1": "bad", "y1": 0, "y2": 10}) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _pick_best
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPickBest:
|
|
def test_returns_none_when_no_matching_label(self):
|
|
dets = [{"label": "pin", "conf": 0.9}]
|
|
assert _pick_best(dets, "crystal") is None
|
|
|
|
def test_returns_highest_confidence_detection(self):
|
|
dets = [
|
|
{"label": "crystal", "conf": 0.5},
|
|
{"label": "crystal", "conf": 0.9},
|
|
{"label": "crystal", "conf": 0.3},
|
|
]
|
|
best = _pick_best(dets, "crystal")
|
|
assert best["conf"] == pytest.approx(0.9)
|
|
|
|
def test_returns_none_for_empty_list(self):
|
|
assert _pick_best([], "crystal") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# compute_target_point — priority logic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestComputeTargetPoint:
|
|
def test_returns_none_for_empty_dets(self):
|
|
assert compute_target_point([]) is None
|
|
|
|
def test_crystal_takes_priority_over_loop_face(self):
|
|
dets = [
|
|
{"label": "loop_face", "conf": 0.99, "x1": 0, "y1": 0, "x2": 200, "y2": 200},
|
|
{"label": "crystal", "conf": 0.5, "x1": 10, "y1": 10, "x2": 50, "y2": 50},
|
|
]
|
|
result = compute_target_point(dets)
|
|
assert result is not None
|
|
assert result["source"] == "crystal_center"
|
|
|
|
def test_loop_face_takes_priority_over_loop_all(self):
|
|
dets = [
|
|
{"label": "loop_all", "conf": 0.99, "x1": 0, "y1": 0, "x2": 200, "y2": 200},
|
|
{"label": "loop_face", "conf": 0.5, "x1": 10, "y1": 10, "x2": 50, "y2": 50},
|
|
]
|
|
result = compute_target_point(dets)
|
|
assert result is not None
|
|
assert result["source"] == "loop_face_center"
|
|
|
|
def test_pin_used_as_last_resort(self):
|
|
dets = [{"label": "pin", "conf": 0.8, "x1": 20.0, "y1": 10.0, "x2": 80.0, "y2": 30.0}]
|
|
result = compute_target_point(dets)
|
|
assert result is not None
|
|
assert result["source"] == "pin_left_mid"
|
|
assert result["x"] == 20
|
|
assert result["y"] == 20
|
|
|
|
def test_output_contains_integer_coordinates(self):
|
|
dets = [{"label": "pin", "conf": 0.8, "x1": 10.7, "y1": 5.3, "x2": 60.0, "y2": 25.1}]
|
|
result = compute_target_point(dets)
|
|
assert isinstance(result["x"], int)
|
|
assert isinstance(result["y"], int)
|
|
|
|
def test_unknown_labels_return_none(self):
|
|
dets = [{"label": "unknown_object", "conf": 0.99, "x1": 0, "y1": 0, "x2": 100, "y2": 100}]
|
|
assert compute_target_point(dets) is None
|