Calibrazione densa da una foto: griglia, mappa di distorsione, archivio .npz (#84)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
+411
-6
@@ -3,10 +3,16 @@
|
|||||||
Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con
|
Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con
|
||||||
TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente
|
TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente
|
||||||
camera in un JSON (scrittura atomica).
|
camera in un JSON (scrittura atomica).
|
||||||
|
#84: `calibra_scacchiera_densa(pixels, square_mm)` calibra da UNA foto di
|
||||||
|
scacchiera che riempie tutto il campo (ottica telecentrica): trova da sola la
|
||||||
|
griglia, costruisce la mappa di distorsione (DistortionMap) e il mm/px
|
||||||
|
dell'immagine raddrizzata. L'Archivio salva la mappa accanto al JSON (.npz) e
|
||||||
|
`raddrizza()` la applica ai frame.
|
||||||
Uso: importato da scripts/camera_service.py (rotte /camera/calibrate e
|
Uso: importato da scripts/camera_service.py (rotte /camera/calibrate e
|
||||||
/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e'
|
/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e
|
||||||
logica pura, senza FastAPI, e si prova da sola.
|
`calibra_scacchiera_densa(gray, square_mm)` sono logica pura, senza FastAPI,
|
||||||
Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro.
|
e si provano da sole.
|
||||||
|
Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro (no scipy).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -17,13 +23,17 @@ import math
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from collections import deque
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from visionsuite.calibration import calibrate_planar, find_chessboard
|
from visionsuite.calibration import calibrate_planar, find_chessboard
|
||||||
|
from visionsuite.calibration.distortion_map import DistortionMap # #84 mappa densa
|
||||||
from visionsuite.core.image import Image
|
from visionsuite.core.image import Image
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +57,8 @@ def calibra_scacchiera(pixels, cols: int, rows: int, square_mm: float) -> dict:
|
|||||||
"""Scala (px/mm) da una foto di scacchiera piana. cols/rows = angoli INTERNI.
|
"""Scala (px/mm) da una foto di scacchiera piana. cols/rows = angoli INTERNI.
|
||||||
|
|
||||||
Ritorna {px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm,
|
Ritorna {px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm,
|
||||||
image_width, image_height, scale_spread_pct, overlay_png (b64)}.
|
image_width, image_height, scale_spread_pct, metodo, distorsione,
|
||||||
|
overlay_png (b64)}.
|
||||||
"""
|
"""
|
||||||
if cols < 2 or rows < 2:
|
if cols < 2 or rows < 2:
|
||||||
raise CalibrazioneError("cols e rows (angoli interni) devono essere almeno 2")
|
raise CalibrazioneError("cols e rows (angoli interni) devono essere almeno 2")
|
||||||
@@ -95,19 +106,357 @@ def calibra_scacchiera(pixels, cols: int, rows: int, square_mm: float) -> dict:
|
|||||||
"image_width": int(w),
|
"image_width": int(w),
|
||||||
"image_height": int(h),
|
"image_height": int(h),
|
||||||
"scale_spread_pct": float(spread),
|
"scale_spread_pct": float(spread),
|
||||||
|
"metodo": "planare", # #84: il metodo con cols/rows resta quello di prima
|
||||||
|
"distorsione": False,
|
||||||
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
|
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- #84: calibrazione densa (scacchiera che riempie il campo, griglia non nota)
|
||||||
|
|
||||||
|
# #84: findChessboardCornersSBWithMeta (verificato su opencv 5.0.0) su una scacchiera
|
||||||
|
# grande quanto l'immagine trova solo frammenti (2064x1544 con quadrati da 40 px:
|
||||||
|
# 5x5, 28x6...). Per questo la griglia si cerca qui: sellini della Hessiana a piu'
|
||||||
|
# scale + crescita del reticolo; poi cornerSubPix.
|
||||||
|
_SIGME = (1.5, 2.2, 3.2, 4.5, 6.5, 9.0, 13.0) # scale dei sellini (passo ~ 5..120 px)
|
||||||
|
_MAX_CANDIDATI = 6000
|
||||||
|
|
||||||
|
|
||||||
|
def _candidati(gray: np.ndarray, sigma: float) -> np.ndarray:
|
||||||
|
"""Massimi locali del 'selliness' (-det Hessiana) = angoli di scacchiera, (n,2) x,y."""
|
||||||
|
b = cv2.GaussianBlur(gray.astype(np.float32), (0, 0), sigma)
|
||||||
|
ixx = cv2.Sobel(b, cv2.CV_32F, 2, 0, ksize=3)
|
||||||
|
iyy = cv2.Sobel(b, cv2.CV_32F, 0, 2, ksize=3)
|
||||||
|
ixy = cv2.Sobel(b, cv2.CV_32F, 1, 1, ksize=3)
|
||||||
|
r = ixy * ixy - ixx * iyy # >0 solo dove l'immagine ha un sellino
|
||||||
|
massimo = float(r.max())
|
||||||
|
if massimo <= 0:
|
||||||
|
return np.zeros((0, 2))
|
||||||
|
k = 2 * int(math.ceil(1.5 * sigma)) + 1
|
||||||
|
picchi = (r >= cv2.dilate(r, np.ones((k, k), np.uint8))) & (r > 0.1 * massimo)
|
||||||
|
ys, xs = np.nonzero(picchi)
|
||||||
|
if len(xs) > _MAX_CANDIDATI: # tiene i piu' forti
|
||||||
|
forti = np.argsort(r[ys, xs])[::-1][:_MAX_CANDIDATI]
|
||||||
|
ys, xs = ys[forti], xs[forti]
|
||||||
|
return np.stack([xs, ys], axis=1).astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def _e_angolo_x(lisc: np.ndarray, delta: float, p: np.ndarray, u: np.ndarray, v: np.ndarray) -> bool:
|
||||||
|
"""True se attorno a p i quattro quadranti alternano chiaro/scuro (vero angolo X).
|
||||||
|
|
||||||
|
Scarta le giunzioni a L/T al bordo della scacchiera (3 quadranti uguali), che
|
||||||
|
il selliness da solo accetta e che spostano la griglia.
|
||||||
|
"""
|
||||||
|
h, w = lisc.shape
|
||||||
|
campioni = []
|
||||||
|
for q in (0.25 * (u + v), -0.25 * (u + v), 0.25 * (u - v), -0.25 * (u - v)):
|
||||||
|
x, y = int(round(p[0] + q[0])), int(round(p[1] + q[1]))
|
||||||
|
if not (0 <= x < w and 0 <= y < h):
|
||||||
|
return False
|
||||||
|
campioni.append(float(lisc[y, x]))
|
||||||
|
s1, s2, t1, t2 = campioni
|
||||||
|
return (min(s1, s2) > max(t1, t2) + delta) or (max(s1, s2) < min(t1, t2) - delta)
|
||||||
|
|
||||||
|
|
||||||
|
def _cresci_reticolo(pts: np.ndarray, centro: tuple[float, float],
|
||||||
|
lisc: np.ndarray, delta: float) -> dict | None:
|
||||||
|
"""Assegna un indice (riga, colonna) ai candidati che stanno su un reticolo.
|
||||||
|
|
||||||
|
Parte dal candidato piu' vicino al centro, ricava i due vettori di passo dai
|
||||||
|
vicini e cresce a macchia d'olio: la base si aggiorna a ogni passo, quindi
|
||||||
|
segue una distorsione lenta. Ritorna {(i, j): indice candidato} o None.
|
||||||
|
"""
|
||||||
|
if len(pts) < 9:
|
||||||
|
return None
|
||||||
|
seme = int(np.argmin(np.hypot(pts[:, 0] - centro[0], pts[:, 1] - centro[1])))
|
||||||
|
d = np.hypot(pts[:, 0] - pts[seme, 0], pts[:, 1] - pts[seme, 1])
|
||||||
|
vicini = np.argsort(d)[1:9]
|
||||||
|
u = pts[vicini[0]] - pts[seme]
|
||||||
|
lu = float(np.hypot(*u))
|
||||||
|
v = None
|
||||||
|
for k in vicini[1:]:
|
||||||
|
w = pts[k] - pts[seme]
|
||||||
|
lw = float(np.hypot(*w))
|
||||||
|
# il secondo vettore: quasi ortogonale al primo e di lunghezza simile
|
||||||
|
if lu > 0 and lw > 0 and abs(float(u @ w)) / (lu * lw) < 0.4 and lw < 1.5 * lu:
|
||||||
|
v = w
|
||||||
|
break
|
||||||
|
if v is None or lu < 3:
|
||||||
|
return None
|
||||||
|
posto = {(0, 0): seme}
|
||||||
|
indice = {seme: (0, 0)}
|
||||||
|
basi = {seme: (u, v)}
|
||||||
|
coda = deque([seme])
|
||||||
|
while coda:
|
||||||
|
a = coda.popleft()
|
||||||
|
i, j = indice[a]
|
||||||
|
u, v = basi[a]
|
||||||
|
tol = 0.3 * min(float(np.hypot(*u)), float(np.hypot(*v)))
|
||||||
|
for di, dj, vec in ((0, 1, u), (0, -1, -u), (1, 0, v), (-1, 0, -v)):
|
||||||
|
if (i + di, j + dj) in posto:
|
||||||
|
continue
|
||||||
|
prev = pts[a] + vec
|
||||||
|
dist = np.hypot(pts[:, 0] - prev[0], pts[:, 1] - prev[1])
|
||||||
|
b = int(np.argmin(dist))
|
||||||
|
if dist[b] > tol or b in indice:
|
||||||
|
continue
|
||||||
|
if not _e_angolo_x(lisc, delta, pts[b], u, v): # niente L/T del bordo
|
||||||
|
continue
|
||||||
|
posto[(i + di, j + dj)] = b
|
||||||
|
indice[b] = (i + di, j + dj)
|
||||||
|
reale = pts[b] - pts[a]
|
||||||
|
basi[b] = (reale * dj, v) if dj else (u, reale * di) # base locale aggiornata
|
||||||
|
coda.append(b)
|
||||||
|
return posto
|
||||||
|
|
||||||
|
|
||||||
|
def _rettangolo_pieno(posto: dict) -> tuple[int, int, int, int] | None:
|
||||||
|
"""Il rettangolo di indici tutto occupato di area massima: (i0, j0, righe, colonne)."""
|
||||||
|
if not posto:
|
||||||
|
return None
|
||||||
|
i_min = min(i for i, _ in posto)
|
||||||
|
j_min = min(j for _, j in posto)
|
||||||
|
nr = max(i for i, _ in posto) - i_min + 1
|
||||||
|
nc = max(j for _, j in posto) - j_min + 1
|
||||||
|
m = np.zeros((nr, nc), bool)
|
||||||
|
for (i, j) in posto:
|
||||||
|
m[i - i_min, j - j_min] = True
|
||||||
|
migliore = (0, 0, 0, 0)
|
||||||
|
alt = np.zeros(nc, int)
|
||||||
|
for r in range(nr): # istogramma delle altezze + pila, O(righe*colonne)
|
||||||
|
alt = np.where(m[r], alt + 1, 0)
|
||||||
|
pila: list[int] = []
|
||||||
|
for c in range(nc + 1):
|
||||||
|
h = int(alt[c]) if c < nc else 0
|
||||||
|
while pila and alt[pila[-1]] >= h:
|
||||||
|
altezza = int(alt[pila.pop()])
|
||||||
|
sx = pila[-1] + 1 if pila else 0
|
||||||
|
larg = c - sx
|
||||||
|
if altezza * larg > migliore[2] * migliore[3]:
|
||||||
|
migliore = (r - altezza + 1 + i_min, sx + j_min, altezza, larg)
|
||||||
|
pila.append(c)
|
||||||
|
return migliore if migliore[2] * migliore[3] else None
|
||||||
|
|
||||||
|
|
||||||
|
def _orienta(g: np.ndarray) -> np.ndarray:
|
||||||
|
"""Riordina (nr,nc,2) con colonne verso destra e righe verso il basso."""
|
||||||
|
a = (g[:, -1] - g[:, 0]).mean(axis=0) # direzione dell'indice di colonna
|
||||||
|
b = (g[-1, :] - g[0, :]).mean(axis=0) # direzione dell'indice di riga
|
||||||
|
if abs(b[0]) > abs(a[0]): # assi scambiati (scacchiera ruotata di ~90 gradi)
|
||||||
|
g = g.transpose(1, 0, 2)
|
||||||
|
a, b = b, a
|
||||||
|
if a[0] < 0:
|
||||||
|
g = g[:, ::-1]
|
||||||
|
if b[1] < 0:
|
||||||
|
g = g[::-1]
|
||||||
|
return np.ascontiguousarray(g)
|
||||||
|
|
||||||
|
|
||||||
|
def _passo_mediano(g: np.ndarray) -> float:
|
||||||
|
"""Distanza mediana (px) fra corner adiacenti."""
|
||||||
|
passi = np.concatenate([np.diff(g, axis=1).reshape(-1, 2), np.diff(g, axis=0).reshape(-1, 2)])
|
||||||
|
return float(np.median(np.hypot(passi[:, 0], passi[:, 1])))
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
migliore = None
|
||||||
|
lisc = cv2.GaussianBlur(frame.astype(np.float32), (0, 0), 1.0)
|
||||||
|
p5, p95 = np.percentile(frame, (5, 95))
|
||||||
|
delta = 0.25 * float(p95 - p5) # contrasto minimo fra quadranti opposti
|
||||||
|
for sigma in _SIGME:
|
||||||
|
pts = _candidati(frame, sigma)
|
||||||
|
posto = _cresci_reticolo(pts, (w / 2.0, h / 2.0), lisc, delta)
|
||||||
|
rett = _rettangolo_pieno(posto) if posto else None
|
||||||
|
if rett is None:
|
||||||
|
continue
|
||||||
|
i0, j0, nr, nc = rett
|
||||||
|
if nr < 3 or nc < 3:
|
||||||
|
continue
|
||||||
|
if migliore is not None and nr * nc < 0.5 * migliore[0] * migliore[1]:
|
||||||
|
break # oltre il picco: le scale piu' grosse fondono gli angoli
|
||||||
|
if migliore is None or nr * nc > migliore[0] * migliore[1]:
|
||||||
|
g = np.array([[pts[posto[(i0 + i, j0 + j)]] for j in range(nc)]
|
||||||
|
for i in range(nr)])
|
||||||
|
migliore = (nr, nc, g)
|
||||||
|
if migliore is None:
|
||||||
|
raise CalibrazioneError(
|
||||||
|
"griglia della scacchiera non trovata: serve una scacchiera nitida "
|
||||||
|
"che riempia il campo")
|
||||||
|
g = _orienta(migliore[2])
|
||||||
|
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
|
||||||
|
raff[spostati] = grezzi[spostati] # un raffinamento che scappa e' peggio del grezzo
|
||||||
|
return raff.reshape(nr, nc, 2).astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def _estrapola(g: np.ndarray, k: int, prima: bool) -> np.ndarray:
|
||||||
|
"""Aggiunge k colonne (asse 1) per estrapolazione lineare, prima o dopo."""
|
||||||
|
if k <= 0:
|
||||||
|
return g
|
||||||
|
if prima:
|
||||||
|
passo = g[:, 0] - g[:, 1]
|
||||||
|
extra = np.stack([g[:, 0] + m * passo for m in range(k, 0, -1)], axis=1)
|
||||||
|
return np.concatenate([extra, g], axis=1)
|
||||||
|
passo = g[:, -1] - g[:, -2]
|
||||||
|
extra = np.stack([g[:, -1] + m * passo for m in range(1, k + 1)], axis=1)
|
||||||
|
return np.concatenate([g, extra], axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _estendi_asse(g: np.ndarray, lungh: int, c: int, passo: float,
|
||||||
|
grid_step: int) -> tuple[np.ndarray, int]:
|
||||||
|
"""Estende la griglia (asse 1) fino a coprire l'immagine; ritorna (griglia, k_prima).
|
||||||
|
|
||||||
|
`c` e' la coordinata (0 = x, 1 = y) e `lungh` la dimensione dell'immagine su
|
||||||
|
quell'asse. `from_grid` (crop_domain=False) tiene costante la mappa fuori
|
||||||
|
dalla griglia: senza anelli in piu' i bordi dell'immagine raddrizzata
|
||||||
|
sarebbero sbavati.
|
||||||
|
"""
|
||||||
|
margine = max(0.0, float(g[:, 0, c].min()))
|
||||||
|
k_pre = int(math.ceil(margine / passo)) + 1
|
||||||
|
g = _estrapola(g, k_pre, True)
|
||||||
|
offset = int(g[0, 0, c]) # lo stesso int() che usa from_grid
|
||||||
|
# colonne totali perche' l'ultimo nodo, a passo grid_step, superi il bordo
|
||||||
|
servono = int(math.ceil((lungh - 1 - offset) / grid_step)) + 2
|
||||||
|
g = _estrapola(g, max(0, servono - g.shape[1]), False)
|
||||||
|
return g, k_pre
|
||||||
|
|
||||||
|
|
||||||
|
class MappaPronta(NamedTuple):
|
||||||
|
"""Mappa gia' convertita in virgola fissa per un cv2.remap veloce (#84)."""
|
||||||
|
map1: np.ndarray
|
||||||
|
map2: np.ndarray
|
||||||
|
pixel_size: float
|
||||||
|
shape: tuple # (altezza, larghezza) dei frame per cui vale
|
||||||
|
|
||||||
|
|
||||||
|
def _prepara_mappa(mappa: DistortionMap) -> MappaPronta:
|
||||||
|
# CV_16SC2: remap bilineare in virgola fissa, piu' veloce di float32 su 5 MP
|
||||||
|
m1, m2 = cv2.convertMaps(mappa.map_col, mappa.map_row, cv2.CV_16SC2)
|
||||||
|
return MappaPronta(m1, m2, float(mappa.pixel_size), mappa.shape)
|
||||||
|
|
||||||
|
|
||||||
|
def raddrizza(frame: np.ndarray, mappa: MappaPronta) -> np.ndarray | None:
|
||||||
|
"""Frame raddrizzato, o None se le dimensioni non sono quelle della calibrazione."""
|
||||||
|
if frame.shape[:2] != tuple(mappa.shape):
|
||||||
|
return None
|
||||||
|
return cv2.remap(frame, mappa.map1, mappa.map2, cv2.INTER_LINEAR,
|
||||||
|
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
||||||
|
|
||||||
|
|
||||||
|
def calibra_scacchiera_densa(pixels, square_mm: float) -> tuple[dict, DistortionMap]:
|
||||||
|
"""Calibra da UNA foto di scacchiera densa (riempie il campo). Input: solo square_mm.
|
||||||
|
|
||||||
|
Ritorna (risultato, mappa). Risultato: {px_per_mm, mm_per_px (dell'immagine
|
||||||
|
RADDRIZZATA), rms_px, residuo_rms_px, residuo_max_px, n_corners, cols, rows,
|
||||||
|
square_mm, image_width, image_height, scale_spread_pct (0: la mappa uniforma
|
||||||
|
la scala), copertura_pct, metodo, distorsione, overlay_png (b64)}.
|
||||||
|
"""
|
||||||
|
if not (square_mm > 0 and math.isfinite(square_mm)):
|
||||||
|
raise CalibrazioneError("square_mm deve essere > 0")
|
||||||
|
frame = _gray_u8(pixels)
|
||||||
|
h, w = frame.shape
|
||||||
|
g = _trova_griglia(frame)
|
||||||
|
nr, nc, _ = g.shape
|
||||||
|
passo = _passo_mediano(g)
|
||||||
|
grid_step = int(round(passo))
|
||||||
|
if grid_step < 2:
|
||||||
|
raise CalibrazioneError("calibrazione degenere: passo della griglia troppo piccolo")
|
||||||
|
|
||||||
|
# anelli in piu' ai bordi (x e poi y), poi la mappa dai punti estesi
|
||||||
|
e, kl = _estendi_asse(g, w, 0, passo, grid_step)
|
||||||
|
et, kt = _estendi_asse(e.transpose(1, 0, 2), h, 1, passo, grid_step)
|
||||||
|
e = et.transpose(1, 0, 2)
|
||||||
|
try:
|
||||||
|
mappa = DistortionMap.from_grid(
|
||||||
|
e[..., 0].ravel(), e[..., 1].ravel(), e.shape[1], e.shape[0],
|
||||||
|
image_size=(w, h), grid_step=grid_step, crop_domain=False,
|
||||||
|
grid_step_measure=square_mm)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CalibrazioneError(f"mappa di distorsione non costruibile: {exc}") from exc
|
||||||
|
mm_per_px = float(mappa.pixel_size)
|
||||||
|
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
|
||||||
|
raise CalibrazioneError("calibrazione degenere: scala non valida")
|
||||||
|
|
||||||
|
# qualita': si raddrizza la stessa foto e si rilegge ogni corner nel punto ideale
|
||||||
|
off_c, off_r = int(e[0, 0, 0]), int(e[0, 0, 1])
|
||||||
|
ideali = np.array([[(off_c + (kl + j) * grid_step, off_r + (kt + i) * grid_step)
|
||||||
|
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)
|
||||||
|
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())
|
||||||
|
residuo_rms = float(math.sqrt(float((dist ** 2).mean())))
|
||||||
|
|
||||||
|
# copertura: area del quadrilatero dei quattro angoli esterni / area immagine
|
||||||
|
quad = np.array([g[0, 0], g[0, -1], g[-1, -1], g[-1, 0]], np.float32)
|
||||||
|
copertura = float(cv2.contourArea(quad)) / float(w * h) * 100.0
|
||||||
|
|
||||||
|
# overlay: corner trovati sull'originale (ridotto a max 1600 px: 5 MP in PNG pesano)
|
||||||
|
f = min(1.0, 1600.0 / w)
|
||||||
|
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||||
|
pts = g.reshape(-1, 1, 2).astype(np.float32)
|
||||||
|
if f < 1.0:
|
||||||
|
bgr = cv2.resize(bgr, None, fx=f, fy=f, interpolation=cv2.INTER_AREA)
|
||||||
|
pts = pts * np.float32(f)
|
||||||
|
cv2.drawChessboardCorners(bgr, (nc, nr), pts, True)
|
||||||
|
ok, png = cv2.imencode(".png", bgr)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("PNG dell'overlay non codificabile")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"px_per_mm": 1.0 / mm_per_px,
|
||||||
|
"mm_per_px": mm_per_px,
|
||||||
|
"rms_px": residuo_rms,
|
||||||
|
"residuo_rms_px": residuo_rms,
|
||||||
|
"residuo_max_px": residuo_max,
|
||||||
|
"n_corners": int(nr * nc),
|
||||||
|
"cols": int(nc),
|
||||||
|
"rows": int(nr),
|
||||||
|
"square_mm": float(square_mm),
|
||||||
|
"image_width": int(w),
|
||||||
|
"image_height": int(h),
|
||||||
|
"scale_spread_pct": 0.0,
|
||||||
|
"copertura_pct": copertura,
|
||||||
|
"metodo": "densa",
|
||||||
|
"distorsione": True,
|
||||||
|
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
|
||||||
|
}, mappa
|
||||||
|
|
||||||
|
|
||||||
def ora_iso() -> str:
|
def ora_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
class Archivio:
|
class Archivio:
|
||||||
"""Un JSON per sorgente in `cartella`; nome file dalla sorgente ripulito."""
|
"""Un JSON per sorgente in `cartella`; nome file dalla sorgente ripulito.
|
||||||
|
|
||||||
|
#84: se la calibrazione e' densa, accanto al JSON sta la mappa (stesso nome,
|
||||||
|
.npz); `leggi_mappa` la tiene in memoria finche' il file non cambia.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, cartella: Path | str):
|
def __init__(self, cartella: Path | str):
|
||||||
self.cartella = Path(cartella)
|
self.cartella = Path(cartella)
|
||||||
|
self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def _file(self, sorgente: str) -> Path:
|
def _file(self, sorgente: str) -> Path:
|
||||||
# caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano
|
# caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano
|
||||||
@@ -115,15 +464,23 @@ class Archivio:
|
|||||||
sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8]
|
sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8]
|
||||||
return self.cartella / f"{pulito[:60]}-{sigla}.json"
|
return self.cartella / f"{pulito[:60]}-{sigla}.json"
|
||||||
|
|
||||||
|
def _file_mappa(self, sorgente: str) -> Path:
|
||||||
|
return self._file(sorgente).with_suffix(".npz") # #84 stesso nome del JSON
|
||||||
|
|
||||||
def leggi(self, sorgente: str) -> dict | None:
|
def leggi(self, sorgente: str) -> dict | None:
|
||||||
try:
|
try:
|
||||||
return json.loads(self._file(sorgente).read_text(encoding="utf-8"))
|
return json.loads(self._file(sorgente).read_text(encoding="utf-8"))
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return None # assente o illeggibile = non calibrata
|
return None # assente o illeggibile = non calibrata
|
||||||
|
|
||||||
def salva(self, sorgente: str, dati: dict) -> None:
|
def salva(self, sorgente: str, dati: dict, mappa: DistortionMap | None = None) -> None:
|
||||||
destinazione = self._file(sorgente)
|
destinazione = self._file(sorgente)
|
||||||
self.cartella.mkdir(parents=True, exist_ok=True)
|
self.cartella.mkdir(parents=True, exist_ok=True)
|
||||||
|
# #84: prima la mappa (o la rimozione di una vecchia), poi il JSON che la dichiara
|
||||||
|
if mappa is not None:
|
||||||
|
self._salva_mappa(sorgente, mappa)
|
||||||
|
else:
|
||||||
|
self._togli_mappa(sorgente) # ricalibrazione planare: la vecchia mappa non vale piu'
|
||||||
# scrittura atomica: file temporaneo nella stessa cartella, poi replace
|
# scrittura atomica: file temporaneo nella stessa cartella, poi replace
|
||||||
fd, tmp = tempfile.mkstemp(dir=self.cartella, suffix=".tmp")
|
fd, tmp = tempfile.mkstemp(dir=self.cartella, suffix=".tmp")
|
||||||
try:
|
try:
|
||||||
@@ -137,8 +494,56 @@ class Archivio:
|
|||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _salva_mappa(self, sorgente: str, mappa: DistortionMap) -> None:
|
||||||
|
destinazione = self._file_mappa(sorgente)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=self.cartella, suffix=".tmp.npz") # .npz: numpy non aggiunge nulla
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
mappa.save(tmp)
|
||||||
|
os.replace(tmp, destinazione) # atomica come il JSON
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
with self._lock:
|
||||||
|
self._cache.pop(str(destinazione), None)
|
||||||
|
|
||||||
|
def _togli_mappa(self, sorgente: str) -> None:
|
||||||
|
destinazione = self._file_mappa(sorgente)
|
||||||
|
try:
|
||||||
|
destinazione.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
with self._lock:
|
||||||
|
self._cache.pop(str(destinazione), None)
|
||||||
|
|
||||||
|
def leggi_mappa(self, sorgente: str) -> MappaPronta | None:
|
||||||
|
"""La mappa salvata, pronta per `raddrizza`; None se la sorgente non ne ha una."""
|
||||||
|
percorso = self._file_mappa(sorgente)
|
||||||
|
chiave = str(percorso)
|
||||||
|
try:
|
||||||
|
mtime = percorso.stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
with self._lock:
|
||||||
|
self._cache.pop(chiave, None)
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
voce = self._cache.get(chiave)
|
||||||
|
if voce is not None and voce[0] == mtime:
|
||||||
|
return voce[1]
|
||||||
|
try:
|
||||||
|
pronta = _prepara_mappa(DistortionMap.load(percorso))
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return None # illeggibile = nessuna correzione
|
||||||
|
with self._lock:
|
||||||
|
self._cache[chiave] = (mtime, pronta)
|
||||||
|
return pronta
|
||||||
|
|
||||||
def elimina(self, sorgente: str) -> None:
|
def elimina(self, sorgente: str) -> None:
|
||||||
try:
|
try:
|
||||||
self._file(sorgente).unlink()
|
self._file(sorgente).unlink()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
self._togli_mappa(sorgente) # #84 toglie anche la mappa
|
||||||
|
|||||||
Reference in New Issue
Block a user