"""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)