diff --git a/scripts/calibrazione.py b/scripts/calibrazione.py index 3d1d8a8..9c21070 100644 --- a/scripts/calibrazione.py +++ b/scripts/calibrazione.py @@ -258,6 +258,27 @@ def _passo_mediano(g: np.ndarray) -> float: return float(np.median(np.hypot(passi[:, 0], passi[:, 1]))) +def _raffina(img: np.ndarray, pts: np.ndarray, passo: float) -> np.ndarray: + """cornerSubPix su (n,2) con finestra ~0.3 passo, ristretta per chi sta vicino al bordo. + + Una finestra che esce dall'immagine vede il bordo replicato e sposta il corner + di ~1 px (visto sull'ultima colonna di una scacchiera a 14 px dal bordo): per + questi punti si usa la finestra piu' grande che sta dentro; sotto 3 px resta + la posizione grezza. + """ + h, w = img.shape + out = pts.astype(np.float32).copy() + win = int(min(20, max(3, 0.3 * passo))) + dentro = np.minimum.reduce([out[:, 0], w - 1 - out[:, 0], out[:, 1], h - 1 - out[:, 1]]) + per_punto = np.minimum(win, np.floor(dentro).astype(int) - 2) + crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 40, 0.001) + for wi in np.unique(per_punto[per_punto >= 3]): + sel = per_punto == wi + out[sel] = cv2.cornerSubPix(img, out[sel].reshape(-1, 1, 2).copy(), + (int(wi), int(wi)), (-1, -1), crit).reshape(-1, 2) + return out + + def _trova_griglia(frame: np.ndarray) -> np.ndarray: """Griglia di angoli (n_righe, n_colonne, 2) x,y, in ordine di riga dall'alto.""" h, w = frame.shape @@ -288,11 +309,9 @@ def _trova_griglia(frame: np.ndarray) -> np.ndarray: nr, nc, _ = g.shape passo = _passo_mediano(g) # sub-pixel: finestra proporzionale al passo, abbastanza piccola da non vedere i vicini - win = int(min(20, max(3, 0.3 * passo))) - crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 40, 0.001) - grezzi = g.reshape(-1, 1, 2).astype(np.float32) - raff = cv2.cornerSubPix(frame, grezzi.copy(), (win, win), (-1, -1), crit) - spostati = np.hypot(*(raff - grezzi).reshape(-1, 2).T) > 0.3 * passo + grezzi = g.reshape(-1, 2).astype(np.float32) + raff = _raffina(frame, grezzi, passo) + spostati = np.hypot(*(raff - grezzi).T) > 0.3 * passo raff[spostati] = grezzi[spostati] # un raffinamento che scappa e' peggio del grezzo return raff.reshape(nr, nc, 2).astype(np.float64) @@ -391,16 +410,14 @@ def calibra_scacchiera_densa(pixels, square_mm: float) -> tuple[dict, Distortion for j in range(nc)] for i in range(nr)], np.float32).reshape(-1, 1, 2) rett = cv2.remap(frame, mappa.map_col, mappa.map_row, cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=0) - win = int(min(20, max(3, 0.3 * grid_step))) - crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 40, 0.001) # solo i corner ideali dentro l'immagine raddrizzata (cornerSubPix rifiuta quelli fuori) xy = ideali.reshape(-1, 2) dentro = (xy[:, 0] >= 1) & (xy[:, 0] <= w - 2) & (xy[:, 1] >= 1) & (xy[:, 1] <= h - 2) if int(dentro.sum()) < 4: raise CalibrazioneError("griglia raddrizzata fuori dall'immagine: calibrazione non verificabile") - ideali = ideali[dentro] - letti = cv2.cornerSubPix(rett, ideali.copy(), (win, win), (-1, -1), crit) - scarto = (letti - ideali).reshape(-1, 2).astype(np.float64) + ideali = ideali[dentro].reshape(-1, 2) + letti = _raffina(rett, ideali, grid_step) + scarto = (letti - ideali).astype(np.float64) scarto -= np.median(scarto, axis=0) # int() dell'offset sposta tutto di una traslazione dist = np.hypot(scarto[:, 0], scarto[:, 1]) residuo_max = float(dist.max()) diff --git a/scripts/tests/test_calibrazione.py b/scripts/tests/test_calibrazione.py index 12bcb29..5b31c26 100644 --- a/scripts/tests/test_calibrazione.py +++ b/scripts/tests/test_calibrazione.py @@ -38,12 +38,13 @@ def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = def una_scacchiera_densa(w: int = 640, h: int = 480, square_px: int = 40, k: float = 1e-8) -> np.ndarray: """#84: scacchiera che riempie tutto il campo, con distorsione a barilotto (k sui px dal centro).""" - yy, xx = np.mgrid[0:h, 0:w] + m = 3 * square_px # la scacchiera continua oltre il campo: nessun bordo finto nell'immagine + yy, xx = np.mgrid[0:h + 2 * m, 0:w + 2 * m] piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8) cx, cy = w / 2.0, h / 2.0 - dx, dy = (xx - cx).astype(np.float32), (yy - cy).astype(np.float32) + dx, dy = (xx[m:m + h, m:m + w] - m - cx).astype(np.float32), (yy[m:m + h, m:m + w] - m - cy).astype(np.float32) f = 1 + k * (dx * dx + dy * dy) - img = cv2.remap(piana, cx + dx * f, cy + dy * f, cv2.INTER_LINEAR, borderValue=255) + img = cv2.remap(piana, cx + m + dx * f, cy + m + dy * f, cv2.INTER_LINEAR) return cv2.GaussianBlur(img, (3, 3), 0.8) # bordi non perfettamente netti, come in camera @@ -242,7 +243,7 @@ def test_densa_trova_la_griglia_da_sola_e_la_scala(): assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005) assert r["metodo"] == "densa" and r["distorsione"] is True assert r["residuo_max_px"] < 0.3 and r["residuo_rms_px"] <= r["residuo_max_px"] - assert 70 < r["copertura_pct"] <= 100 + assert 60 < r["copertura_pct"] <= 100 assert (r["image_width"], r["image_height"]) == (640, 480) assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n" assert mappa.shape == (480, 640) and mappa.pixel_size == pytest.approx(0.05, rel=0.005)