test: suite pytest sintetica (GT pose note) + deps dev pytest/ruff
11 test senza dipendenza dalle immagini Test/ (non versionate): - precisione/recall su 7 pose GT (soglie 0.2-0.5 deg, 0.3-1.0 px, margine 3-4x sulle misure Fase 2) - unit: angle_list con estremi, clamp piramide, save/load roundtrip, no collisione cache scena, mask poligonale, find non addestrato Config ruff in pyproject (E702/E402 idiomi del codebase esclusi). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""Fixture condivise: template e scene sintetiche con ground-truth nota.
|
||||
|
||||
Tutti i test sono sintetici (nessuna dipendenza dalle immagini Test/,
|
||||
non versionate): generano scene con pose note e verificano recall e
|
||||
precisione del matcher. Runtime totale atteso: ~2-4 min su 2 core.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
def make_template(tw: int = 160, th: int = 120) -> np.ndarray:
|
||||
"""Forma a L asimmetrica con foro circolare, contrasto netto.
|
||||
|
||||
Asimmetrica per evitare ambiguita' rotazionali nei confronti GT.
|
||||
"""
|
||||
img = np.full((th, tw), 60, np.uint8)
|
||||
cv2.rectangle(img, (20, 20), (60, th - 20), 200, -1)
|
||||
cv2.rectangle(img, (20, th - 55), (tw - 25, th - 20), 200, -1)
|
||||
cv2.circle(img, (tw - 45, 40), 16, 200, -1)
|
||||
return cv2.GaussianBlur(img, (3, 3), 0)
|
||||
|
||||
|
||||
# Pose ground-truth: (cx, cy, angle_deg) - angoli volutamente lontani
|
||||
# dalla griglia di step 5/2 gradi per misurare il refine.
|
||||
GT_POSES: list[tuple[float, float, float]] = [
|
||||
(150.0, 150.0, 0.0),
|
||||
(450.0, 140.0, 7.3),
|
||||
(740.0, 170.0, 33.7),
|
||||
(160.0, 420.0, 91.2),
|
||||
(460.0, 430.0, 158.4),
|
||||
(750.0, 480.0, 246.9),
|
||||
(300.0, 590.0, 312.6),
|
||||
]
|
||||
|
||||
|
||||
def make_scene(
|
||||
template: np.ndarray,
|
||||
poses: list[tuple[float, float, float]],
|
||||
W: int = 900, H: int = 700,
|
||||
noise: float = 4.0, seed: int = 7,
|
||||
) -> np.ndarray:
|
||||
"""Incolla il template warpato alle pose date su sfondo rumoroso.
|
||||
|
||||
Convenzione di rotazione identica al matcher (cv2.getRotationMatrix2D
|
||||
attorno al centro template, poi traslazione del centro su (cx, cy)).
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
scene = np.full((H, W), 60, np.float32)
|
||||
th, tw = template.shape
|
||||
for (cx, cy, ang) in poses:
|
||||
M = cv2.getRotationMatrix2D((tw / 2.0, th / 2.0), ang, 1.0)
|
||||
M[0, 2] += cx - tw / 2.0
|
||||
M[1, 2] += cy - th / 2.0
|
||||
warped = cv2.warpAffine(template.astype(np.float32), M, (W, H),
|
||||
flags=cv2.INTER_LINEAR, borderValue=-1)
|
||||
scene = np.where(warped >= 0, warped, scene)
|
||||
scene += rng.normal(0, noise, scene.shape)
|
||||
return np.clip(scene, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def ang_diff(a: float, b: float) -> float:
|
||||
"""Differenza angolare firmata in (-180, 180]."""
|
||||
d = (a - b) % 360.0
|
||||
return d - 360.0 if d > 180.0 else d
|
||||
|
||||
|
||||
def match_errors(matches, poses, radius: float = 20.0):
|
||||
"""Associa match a pose GT per distanza; ritorna (err_ang, err_pos, n_miss)."""
|
||||
errs_a: list[float] = []
|
||||
errs_p: list[float] = []
|
||||
miss = 0
|
||||
for (cx, cy, ang) in poses:
|
||||
cands = [
|
||||
(math.hypot(m.cx - cx, m.cy - cy), m)
|
||||
for m in matches
|
||||
if math.hypot(m.cx - cx, m.cy - cy) < radius
|
||||
]
|
||||
if not cands:
|
||||
miss += 1
|
||||
continue
|
||||
d, m = min(cands, key=lambda t: t[0])
|
||||
errs_a.append(abs(ang_diff(m.angle_deg, ang)))
|
||||
errs_p.append(d)
|
||||
return errs_a, errs_p, miss
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def template() -> np.ndarray:
|
||||
return make_template()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def scene(template) -> np.ndarray:
|
||||
return make_scene(template, GT_POSES)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Unit test rapidi su componenti del matcher (no matching pesante)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import cv2
|
||||
import pytest
|
||||
|
||||
from pm2d import LineShapeMatcher
|
||||
from tests.conftest import GT_POSES, make_scene, match_errors
|
||||
|
||||
|
||||
def test_angle_list_includes_range_end():
|
||||
# Range parziale ±15: l'estremo +15 deve essere testato (era escluso).
|
||||
m = LineShapeMatcher(angle_range_deg=(-15.0, 15.0), angle_step_deg=5.0)
|
||||
angles = m._angle_list()
|
||||
assert -15.0 in angles and 15.0 in angles
|
||||
assert len(angles) == 7
|
||||
|
||||
|
||||
def test_angle_list_full_circle_no_duplicate():
|
||||
# (0, 360): 360 coincide con 0 → escluso, niente variante duplicata.
|
||||
m = LineShapeMatcher(angle_range_deg=(0.0, 360.0), angle_step_deg=5.0)
|
||||
angles = m._angle_list()
|
||||
assert len(angles) == 72
|
||||
assert 360.0 not in angles
|
||||
|
||||
|
||||
def test_pyramid_clamp_small_template():
|
||||
# Template 40px di lato minimo: al top /4 le feature collassano →
|
||||
# i livelli vengono clampati (40/2=20 >= 12, 40/4=10 < 12 → 2 livelli).
|
||||
m = LineShapeMatcher(pyramid_levels=4, angle_range_deg=(0.0, 10.0),
|
||||
angle_step_deg=5.0)
|
||||
tpl = np.full((40, 200), 60, np.uint8)
|
||||
cv2.rectangle(tpl, (30, 8), (170, 32), 200, -1)
|
||||
m.train(tpl)
|
||||
assert m.pyramid_levels == 2
|
||||
|
||||
|
||||
def test_save_load_roundtrip(tmp_path, template, scene):
|
||||
m = LineShapeMatcher(angle_step_deg=10.0)
|
||||
m.train(template)
|
||||
path = str(tmp_path / "model.npz")
|
||||
m.save_model(path)
|
||||
m2 = LineShapeMatcher.load_model(path)
|
||||
assert len(m2.variants) == len(m.variants)
|
||||
matches = m2.find(scene, min_score=0.5, max_matches=10)
|
||||
_, _, miss = match_errors(matches, GT_POSES)
|
||||
assert miss == 0
|
||||
|
||||
|
||||
def test_scene_cache_no_collision(template):
|
||||
# Due scene IDENTICHE nella banda superiore ma diverse sotto: la cache
|
||||
# (che prima hashava solo i primi 64KB) non deve restituire i risultati
|
||||
# della scena sbagliata.
|
||||
poses_a = [GT_POSES[0], (450.0, 560.0, 33.7)]
|
||||
poses_b = [GT_POSES[0], (700.0, 560.0, 91.2)]
|
||||
scene_a = make_scene(template, poses_a)
|
||||
scene_b = make_scene(template, poses_b)
|
||||
# Stessa banda superiore (le pose extra sono in basso, y >= 430)
|
||||
assert np.array_equal(scene_a[:80], scene_b[:80])
|
||||
m = LineShapeMatcher(angle_step_deg=10.0)
|
||||
m.train(template)
|
||||
ma = m.find(scene_a, min_score=0.5, max_matches=5)
|
||||
mb = m.find(scene_b, min_score=0.5, max_matches=5)
|
||||
_, _, miss_a = match_errors(ma, poses_a)
|
||||
_, _, miss_b = match_errors(mb, poses_b)
|
||||
assert miss_a == 0 and miss_b == 0
|
||||
|
||||
|
||||
def test_train_mask_polygonal(template, scene):
|
||||
# ROI poligonale: mask che copre solo la L verticale del template.
|
||||
mask = np.zeros_like(template)
|
||||
cv2.rectangle(mask, (10, 10), (70, template.shape[0] - 10), 255, -1)
|
||||
m = LineShapeMatcher(angle_step_deg=10.0)
|
||||
n = m.train(template, mask=mask)
|
||||
assert n > 0
|
||||
matches = m.find(scene, min_score=0.5, max_matches=10)
|
||||
assert len(matches) >= 1
|
||||
|
||||
|
||||
def test_untrained_find_raises():
|
||||
m = LineShapeMatcher()
|
||||
with pytest.raises(RuntimeError):
|
||||
m.find(np.zeros((100, 100), np.uint8))
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Test di non-regressione su precisione e recall (GT sintetica).
|
||||
|
||||
Soglie derivate dalle misure di Fase 2 (errore mediano ~0.05 deg /
|
||||
~0.08 px) con margine 3-4x per assorbire rumore tra run/macchine.
|
||||
Una regressione del refine (es. score saturo, minMaxLoc sul plateau)
|
||||
riporterebbe gli errori a 2-4 deg / 4 px e fa fallire i test con
|
||||
margine enorme.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from pm2d import LineShapeMatcher
|
||||
from tests.conftest import GT_POSES, match_errors
|
||||
|
||||
|
||||
def _find(template, scene, step, **kw):
|
||||
m = LineShapeMatcher(angle_step_deg=step, num_features=96)
|
||||
m.train(template)
|
||||
return m.find(scene, min_score=0.5, max_matches=10, **kw)
|
||||
|
||||
|
||||
def test_recall_and_precision_step5(template, scene):
|
||||
matches = _find(template, scene, 5.0)
|
||||
errs_a, errs_p, miss = match_errors(matches, GT_POSES)
|
||||
assert miss == 0, f"{miss} pose GT non trovate"
|
||||
assert float(np.median(errs_a)) < 0.2, f"err angolo mediano {np.median(errs_a):.3f} deg"
|
||||
assert float(np.max(errs_a)) < 0.5, f"err angolo max {np.max(errs_a):.3f} deg"
|
||||
assert float(np.median(errs_p)) < 0.3, f"err posizione mediano {np.median(errs_p):.3f} px"
|
||||
assert float(np.max(errs_p)) < 1.0, f"err posizione max {np.max(errs_p):.3f} px"
|
||||
|
||||
|
||||
def test_recall_and_precision_step2(template, scene):
|
||||
# Step fine: storicamente il caso peggiore (plateau con piu' varianti
|
||||
# dentro la tolleranza spread → scelta variante arbitraria).
|
||||
matches = _find(template, scene, 2.0)
|
||||
errs_a, errs_p, miss = match_errors(matches, GT_POSES)
|
||||
assert miss == 0, f"{miss} pose GT non trovate"
|
||||
assert float(np.median(errs_a)) < 0.2
|
||||
assert float(np.max(errs_a)) < 0.5
|
||||
assert float(np.median(errs_p)) < 0.3
|
||||
|
||||
|
||||
def test_no_false_positives(template, scene):
|
||||
# max_matches alto: non devono comparire match spuri oltre le 7 pose.
|
||||
matches = _find(template, scene, 5.0)
|
||||
assert len(matches) <= len(GT_POSES) + 1, (
|
||||
f"{len(matches)} match per {len(GT_POSES)} oggetti reali"
|
||||
)
|
||||
|
||||
|
||||
def test_full_scan_path_equivalent(template, scene):
|
||||
# Il path full-scan (propagate off) deve trovare le stesse pose.
|
||||
matches = _find(template, scene, 5.0, pyramid_propagate=False)
|
||||
_, _, miss = match_errors(matches, GT_POSES)
|
||||
assert miss == 0
|
||||
Reference in New Issue
Block a user