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:
+27
-10
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user