Calibrazione densa: finestra sub-pixel ristretta al bordo, test su scacchiera continua (#84)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
2026-10-24 00:00:36 +02:00
parent 7fa5bacdfe
commit e38e1a2eaf
2 changed files with 32 additions and 14 deletions
+27 -10
View File
@@ -258,6 +258,27 @@ def _passo_mediano(g: np.ndarray) -> float:
return float(np.median(np.hypot(passi[:, 0], passi[:, 1]))) 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: def _trova_griglia(frame: np.ndarray) -> np.ndarray:
"""Griglia di angoli (n_righe, n_colonne, 2) x,y, in ordine di riga dall'alto.""" """Griglia di angoli (n_righe, n_colonne, 2) x,y, in ordine di riga dall'alto."""
h, w = frame.shape h, w = frame.shape
@@ -288,11 +309,9 @@ def _trova_griglia(frame: np.ndarray) -> np.ndarray:
nr, nc, _ = g.shape nr, nc, _ = g.shape
passo = _passo_mediano(g) passo = _passo_mediano(g)
# sub-pixel: finestra proporzionale al passo, abbastanza piccola da non vedere i vicini # sub-pixel: finestra proporzionale al passo, abbastanza piccola da non vedere i vicini
win = int(min(20, max(3, 0.3 * passo))) grezzi = g.reshape(-1, 2).astype(np.float32)
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 40, 0.001) raff = _raffina(frame, grezzi, passo)
grezzi = g.reshape(-1, 1, 2).astype(np.float32) spostati = np.hypot(*(raff - grezzi).T) > 0.3 * passo
raff = cv2.cornerSubPix(frame, grezzi.copy(), (win, win), (-1, -1), crit)
spostati = np.hypot(*(raff - grezzi).reshape(-1, 2).T) > 0.3 * passo
raff[spostati] = grezzi[spostati] # un raffinamento che scappa e' peggio del grezzo raff[spostati] = grezzi[spostati] # un raffinamento che scappa e' peggio del grezzo
return raff.reshape(nr, nc, 2).astype(np.float64) 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) 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, rett = cv2.remap(frame, mappa.map_col, mappa.map_row, cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=0) 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) # solo i corner ideali dentro l'immagine raddrizzata (cornerSubPix rifiuta quelli fuori)
xy = ideali.reshape(-1, 2) xy = ideali.reshape(-1, 2)
dentro = (xy[:, 0] >= 1) & (xy[:, 0] <= w - 2) & (xy[:, 1] >= 1) & (xy[:, 1] <= h - 2) dentro = (xy[:, 0] >= 1) & (xy[:, 0] <= w - 2) & (xy[:, 1] >= 1) & (xy[:, 1] <= h - 2)
if int(dentro.sum()) < 4: if int(dentro.sum()) < 4:
raise CalibrazioneError("griglia raddrizzata fuori dall'immagine: calibrazione non verificabile") raise CalibrazioneError("griglia raddrizzata fuori dall'immagine: calibrazione non verificabile")
ideali = ideali[dentro] ideali = ideali[dentro].reshape(-1, 2)
letti = cv2.cornerSubPix(rett, ideali.copy(), (win, win), (-1, -1), crit) letti = _raffina(rett, ideali, grid_step)
scarto = (letti - ideali).reshape(-1, 2).astype(np.float64) scarto = (letti - ideali).astype(np.float64)
scarto -= np.median(scarto, axis=0) # int() dell'offset sposta tutto di una traslazione scarto -= np.median(scarto, axis=0) # int() dell'offset sposta tutto di una traslazione
dist = np.hypot(scarto[:, 0], scarto[:, 1]) dist = np.hypot(scarto[:, 0], scarto[:, 1])
residuo_max = float(dist.max()) residuo_max = float(dist.max())
+5 -4
View File
@@ -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: 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).""" """#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) piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8)
cx, cy = w / 2.0, h / 2.0 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) 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 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["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r["metodo"] == "densa" and r["distorsione"] is True 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 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 (r["image_width"], r["image_height"]) == (640, 480)
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n" 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) assert mappa.shape == (480, 640) and mappa.pixel_size == pytest.approx(0.05, rel=0.005)