0420c4a863
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>
100 lines
3.1 KiB
Python
100 lines
3.1 KiB
Python
"""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)
|