From 896a30b3762ef27834307eb00b6e6d0b8130800a Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Fri, 23 Oct 2026 23:53:48 +0200 Subject: [PATCH 1/3] Calibrazione densa da una foto: griglia, mappa di distorsione, archivio .npz (#84) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB --- scripts/calibrazione.py | 417 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 411 insertions(+), 6 deletions(-) diff --git a/scripts/calibrazione.py b/scripts/calibrazione.py index 76abd81..3d1d8a8 100644 --- a/scripts/calibrazione.py +++ b/scripts/calibrazione.py @@ -3,10 +3,16 @@ Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente 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 -/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e' -logica pura, senza FastAPI, e si prova da sola. -Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro. +/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e +`calibra_scacchiera_densa(gray, square_mm)` sono logica pura, senza FastAPI, +e si provano da sole. +Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro (no scipy). """ from __future__ import annotations @@ -17,13 +23,17 @@ import math import os import re import tempfile +import threading +from collections import deque from datetime import datetime, timezone from pathlib import Path +from typing import NamedTuple import cv2 import numpy as np from visionsuite.calibration import calibrate_planar, find_chessboard +from visionsuite.calibration.distortion_map import DistortionMap # #84 mappa densa 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. 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: 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_height": int(h), "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"), } +# ---- #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: return datetime.now(timezone.utc).isoformat(timespec="seconds") 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): self.cartella = Path(cartella) + self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta) + self._lock = threading.Lock() def _file(self, sorgente: str) -> Path: # 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] 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: try: return json.loads(self._file(sorgente).read_text(encoding="utf-8")) except (OSError, ValueError): 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) 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 fd, tmp = tempfile.mkstemp(dir=self.cartella, suffix=".tmp") try: @@ -137,8 +494,56 @@ class Archivio: pass 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: try: self._file(sorgente).unlink() except FileNotFoundError: pass + self._togli_mappa(sorgente) # #84 toglie anche la mappa From 7fa5bacdfe6e5b39fca4971c16b7e76cd641e1b4 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Fri, 23 Oct 2026 23:56:15 +0200 Subject: [PATCH 2/3] Acquisitore: calibrazione densa su C2/grab/pagina, test (#84) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB --- scripts/calibrazione_pagina.py | 55 +++++--- scripts/camera_service.py | 47 +++++-- scripts/tests/test_calibrazione.py | 197 ++++++++++++++++++++++++++++- 3 files changed, 265 insertions(+), 34 deletions(-) diff --git a/scripts/calibrazione_pagina.py b/scripts/calibrazione_pagina.py index 33a9409..500ddb2 100644 --- a/scripts/calibrazione_pagina.py +++ b/scripts/calibrazione_pagina.py @@ -3,6 +3,8 @@ Cosa fa: una pagina sola, autonoma e offline (niente CDN, JS e CSS inline), in italiano. Parla con le rotte dello stesso servizio (/camera/sources, /camera/calibration, /camera/calibrate). +#84: calibrazione densa, un solo dato dell'operatore (lato dello scacco in mm): +scacchiera che riempie il campo, griglia trovata da sola, mappa di distorsione. Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati. """ @@ -41,20 +43,18 @@ PAGINA = """
-
-
-
+

- Gli angoli interni sono quelli dove si toccano quattro quadrati: una scacchiera di 10 x 7 quadrati ha 9 x 6 angoli. - Tenere la scacchiera piana e ferma, ben illuminata.

+ Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco. + La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.

- +
- +
@@ -64,7 +64,7 @@ PAGINA = """
- + Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.
Angoli trovati sulla scacchiera @@ -95,12 +95,19 @@ function numero(v, cifre) { return Number(v).toFixed(cifre); } function righe(d) { return [ ["Scala", numero(d.px_per_mm, 4) + " px/mm (" + numero(d.mm_per_px, 5) + " mm/px)"], + ["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"], + ["Griglia trovata", d.cols + " x " + d.rows + " angoli (" + d.n_corners + ")"] + ].concat(d.metodo !== "densa" ? [ ["Errore di riproiezione (RMS)", numero(d.rms_px, 3) + " px"], - ["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"], - ["Angoli trovati", d.n_corners + " (" + d.cols + " x " + d.rows + ")"], - ["Lato del quadrato", d.square_mm + " mm"], + ["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"] + ] : [ + ["Residuo massimo", numero(d.residuo_max_px, 3) + " px"], + ["Residuo RMS", numero(d.residuo_rms_px, 3) + " px"], + ["Copertura del campo", numero(d.copertura_pct, 1) + " %"] + ]).concat([ + ["Lato dello scacco", d.square_mm + " mm"], ["Immagine", d.image_width + " x " + d.image_height + " px"] - ]; + ]); } function tabella(el, d) { @@ -157,9 +164,19 @@ async function aggiornaStato() { } function parametri() { - return { - cols: $("cols").value, rows: $("rows").value, quadrato: $("quadrato").value - }; + return { quadrato: $("quadrato").value }; +} + +// avvisi sulla qualita' (solo calibrazione densa): copertura bassa o residuo alto +function avvisi(d) { + var a = []; + if (d.metodo !== "densa") { + if (d.scale_spread_pct > 1) a.push("Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica."); + } else { + if (d.copertura_pct < 70) a.push("Attenzione: la scacchiera copre solo il " + numero(d.copertura_pct, 1) + " % del campo (meno del 70 %): la correzione ai bordi non e' misurata."); + if (d.residuo_max_px > 0.5) a.push("Attenzione: residuo massimo " + numero(d.residuo_max_px, 3) + " px (oltre 0,5 px): scacchiera non piana, sfocata o con scacchi irregolari."); + } + return a.join(" "); } async function calcola(conFile, salva) { @@ -168,8 +185,6 @@ async function calcola(conFile, salva) { var p = parametri(); var f = new FormData(); f.append("source", sorgente); - f.append("cols", p.cols); - f.append("rows", p.rows); f.append("square_mm", p.quadrato); f.append("save", salva ? "true" : "false"); if (conFile) { @@ -186,11 +201,9 @@ async function calcola(conFile, salva) { ultimo = { conFile: conFile, dati: d }; tabella($("tabella"), d); $("overlay").src = "data:image/png;base64," + d.overlay_png; - $("avviso").textContent = d.scale_spread_pct > 1 - ? "Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica." - : ""; + $("avviso").textContent = avvisi(d); $("risultato").hidden = false; - messaggio(d.saved ? "Calibrazione salvata." : "Calcolo riuscito: controllare e salvare.", "ok"); + messaggio(d.saved ? "Calibrazione salvata." : "Calibrazione riuscita: controllare e salvare.", "ok"); if (d.saved) aggiornaStato(); } catch (e) { messaggio("Servizio non raggiungibile.", "errore"); diff --git a/scripts/camera_service.py b/scripts/camera_service.py index ad4e894..3f221e9 100644 --- a/scripts/camera_service.py +++ b/scripts/camera_service.py @@ -4,18 +4,27 @@ Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL, OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo `grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py): GET /camera/sources -> {"sources": [{name, kind, label}]} - POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG + POST /camera/grab json {source, trigger="continuo", timeout_ms=5000, + grezzo=false} -> PNG + #84: se la sorgente ha una mappa di distorsione salvata il frame esce + raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è. Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout. Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py): GET /camera/calibration?source= -> 200 {source, px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm, image_width, image_height, scale_spread_pct, calibrated_at} | 404 "sorgente non calibrata" - POST /camera/calibrate multipart: source, cols, rows (angoli interni), - square_mm, image (file, opzionale: senza, grab dalla sorgente come - /camera/grab), save=false -> campi di sopra + overlay_png (b64) + saved; + POST /camera/calibrate multipart: source, square_mm, image (file, opzionale: + senza, grab GREZZO dalla sorgente), save=false, cols/rows (opzionali). + #84: SENZA cols/rows = calibrazione densa (default): una foto di + scacchiera che riempie il campo, griglia trovata da sola, mappa di + distorsione salvata accanto al JSON (.npz); mm/px e px/mm sono quelli + dell'immagine RADDRIZZATA. CON cols/rows (angoli interni) = metodo + planare di prima. Campi: quelli di C1 + overlay_png (b64) + saved + + metodo ("densa"|"planare"), distorsione (bool) e, per la densa, + residuo_rms_px, residuo_max_px, copertura_pct; 422 scacchiera non trovata o parametri invalidi - DELETE /camera/calibration?source= -> 204 + DELETE /camera/calibration?source= -> 204 (toglie JSON e mappa) GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano Il risultato salvato sta in un JSON per sorgente in --config-dir (default %ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove). @@ -158,14 +167,25 @@ def create_app(folder: Path | str | None = None, trigger = corpo.get("trigger", "continuo") if trigger not in _TRIGGER: raise HTTPException(400, "«trigger» supportato: solo continuo") + grezzo = corpo.get("grezzo", False) # #84: true = frame non raddrizzato + if not isinstance(grezzo, bool): + raise HTTPException(400, "«grezzo» deve essere true o false") timeout = corpo.get("timeout_ms", 5000) if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000: raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000") frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato + raddrizzato = False + if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache + mappa = archivio.leggi_mappa(nome) + if mappa is not None: + corretto = calibrazione.raddrizza(frame, mappa) + if corretto is not None: # None = dimensioni diverse da quelle calibrate + frame, raddrizzato = corretto, True ok, buf = cv2.imencode(".png", frame) if not ok: raise HTTPException(500, "codifica PNG fallita") - return Response(buf.tobytes(), media_type="image/png") + return Response(buf.tobytes(), media_type="image/png", + headers={"X-Raddrizzato": "si" if raddrizzato else "no"}) # ---- #83: calibrazione a scacchiera per sorgente @@ -189,9 +209,9 @@ def create_app(folder: Path | str | None = None, @app.post("/camera/calibrate") async def calibrazione_calcola( source: str = Form(...), - cols: int = Form(...), - rows: int = Form(...), square_mm: float = Form(...), + cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa + rows: int | None = Form(None), image: UploadFile | None = File(None), save: bool = Form(False), ): @@ -203,15 +223,22 @@ def create_app(folder: Path | str | None = None, raise HTTPException(422, "l'immagine caricata non è leggibile") else: frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504) + if (cols is None) != (rows is None): + raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)") + mappa = None try: - risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm) + if cols is None: # #84: densa, nessuna griglia da dichiarare + risultato, mappa = await run_in_threadpool( + calibrazione.calibra_scacchiera_densa, frame, square_mm) + else: # metodo planare di #83, invariato + risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm) except calibrazione.CalibrazioneError as e: raise HTTPException(422, str(e)) corpo = {"source": nome, **{k: v for k, v in risultato.items() if k != "overlay_png"}, "calibrated_at": calibrazione.ora_iso()} if save: - archivio.salva(nome, corpo) + archivio.salva(nome, corpo, mappa) # #84: la mappa (se densa) va in .npz accanto return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)} @app.get("/calibrazione", response_class=HTMLResponse) diff --git a/scripts/tests/test_calibrazione.py b/scripts/tests/test_calibrazione.py index 726ec1a..12bcb29 100644 --- a/scripts/tests/test_calibrazione.py +++ b/scripts/tests/test_calibrazione.py @@ -36,6 +36,27 @@ def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = return img +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] + 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) + f = 1 + k * (dx * dx + dy * dy) + img = cv2.remap(piana, cx + dx * f, cy + dy * f, cv2.INTER_LINEAR, borderValue=255) + return cv2.GaussianBlur(img, (3, 3), 0.8) # bordi non perfettamente netti, come in camera + + +def residuo_reticolo(griglia: np.ndarray) -> float: + """Residuo massimo (px) della griglia (nr,nc,2) da un reticolo affine regolare.""" + nr, nc, _ = griglia.shape + ii, jj = np.mgrid[0:nr, 0:nc] + A = np.stack([np.ones(nr * nc), ii.ravel(), jj.ravel()], axis=1) + P = griglia.reshape(-1, 2) + coef, *_ = np.linalg.lstsq(A, P, rcond=None) + return float(np.hypot(*(A @ coef - P).T).max()) + + def _png(img: np.ndarray) -> bytes: ok, buf = cv2.imencode(".png", img) assert ok @@ -62,6 +83,29 @@ def client(cartella, tmp_path): CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"} CHIAVI_C1 = {"source", "px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows", "square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"} +# #84: campi in piu' per metodo planare (C2 con cols/rows) e per la densa +CHIAVI_PLANARE = CHIAVI_C1 | {"metodo", "distorsione"} +CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"} +SORGENTE = "camera:folder" + + +@pytest.fixture +def cartella_densa(tmp_path): + # #84: la sorgente camera:folder fa il loop su una scacchiera densa distorta + cartella = tmp_path / "immagini_dense" + cartella.mkdir() + cv2.imwrite(str(cartella / "densa.png"), una_scacchiera_densa(k=1e-7)) + return cartella + + +@pytest.fixture +def client_denso(cartella_densa, tmp_path): + mod = _carica() + with TestClient(mod.create_app(cartella_densa, config_dir=tmp_path / "config_densa")) as c: + c.config = tmp_path / "config_densa" + yield c + + NON_IMMAGINE = b"questo non e una immagine" @@ -112,7 +156,8 @@ def test_c2_immagine_caricata_senza_salvare(client): files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) assert r.status_code == 200, r.text corpo = r.json() - assert CHIAVI_C1 | {"overlay_png", "saved"} == set(corpo) + assert CHIAVI_PLANARE | {"overlay_png", "saved"} == set(corpo) + assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia assert corpo["saved"] is False assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005) # senza save non si scrive nulla @@ -129,7 +174,7 @@ def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client): r1 = client.get("/camera/calibration", params={"source": "camera:folder"}) assert r1.status_code == 200 - assert set(r1.json()) == CHIAVI_C1 # nessun overlay nel file salvato + assert set(r1.json()) == CHIAVI_PLANARE # nessun overlay nel file salvato assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005) assert r1.json()["source"] == "camera:folder" file_salvati = list(client.config.glob("*.json")) @@ -180,5 +225,151 @@ def test_c4_pagina_html(client): r = client.get("/calibrazione") assert r.status_code == 200 assert r.headers["content-type"].startswith("text/html") - assert "Acquisisci e calcola" in r.text + assert "Acquisisci e calibra" in r.text + assert "Lato dello scacco" in r.text and 'id="cols"' not in r.text # #84: un solo campo assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN + + +# ---- #84: calibrazione densa (logica pura) + +def test_densa_trova_la_griglia_da_sola_e_la_scala(): + _carica() + import calibrazione + r, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(k=1e-8), 2.0) + assert (r["cols"], r["rows"]) == (15, 11) # 640x480 con quadrati da 40 px + assert r["n_corners"] == 15 * 11 + assert r["mm_per_px"] == pytest.approx(2.0 / 40, 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["residuo_max_px"] < 0.3 and r["residuo_rms_px"] <= r["residuo_max_px"] + assert 70 < 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) + + +def test_densa_raddrizza_su_griglia_regolare(): + _carica() + import calibrazione + img = una_scacchiera_densa(k=1e-7) # distorsione marcata: ai bordi ~5 % + r, mappa = calibrazione.calibra_scacchiera_densa(img, 2.0) + assert (r["cols"], r["rows"]) == (15, 11) + # prima la griglia e' curva, dopo e' un reticolo regolare + assert residuo_reticolo(calibrazione._trova_griglia(img)) > 1.0 + pronta = calibrazione._prepara_mappa(mappa) + dritta = calibrazione.raddrizza(img, pronta) + assert dritta.shape == img.shape + assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 + + +@pytest.mark.parametrize("cosa", ["bianca", "scacchiera_piccola"]) +def test_densa_senza_griglia_solleva(cosa): + _carica() + import calibrazione + img = (np.full((300, 300), 255, np.uint8) if cosa == "bianca" + else una_scacchiera_densa(80, 60, 20)) # 3x2 angoli: meno di 3x3 + with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"): + calibrazione.calibra_scacchiera_densa(img, 2.0) + + +@pytest.mark.parametrize("quadrato", [0.0, -1.0, float("nan")]) +def test_densa_quadrato_invalido_solleva(quadrato): + _carica() + import calibrazione + with pytest.raises(calibrazione.CalibrazioneError): + calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), quadrato) + + +def test_archivio_mappa_cache_e_invalidazione(tmp_path): + _carica() + import calibrazione + _, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0) + arch = calibrazione.Archivio(tmp_path) + assert arch.leggi_mappa("s") is None + arch.salva("s", {"a": 1}, mappa) + prima = arch.leggi_mappa("s") + assert prima is not None and arch.leggi_mappa("s") is prima # cache in memoria + assert not list(tmp_path.glob("*.tmp*")) # niente temporanei rimasti + arch.salva("s", {"a": 2}, mappa) # file riscritto -> cache invalidata + assert arch.leggi_mappa("s") is not prima + arch.salva("s", {"a": 3}) # salvataggio senza mappa (planare): la vecchia sparisce + assert arch.leggi_mappa("s") is None and not list(tmp_path.glob("*.npz")) + + +# ---- #84: rotte con la calibrazione densa + +def _grigio(r) -> np.ndarray: + return cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE) + + +def test_c2_senza_cols_rows_e_densa(client_denso): + r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}) + assert r.status_code == 200, r.text + corpo = r.json() + assert CHIAVI_DENSA | {"overlay_png", "saved"} == set(corpo) + assert corpo["metodo"] == "densa" and corpo["distorsione"] is True + assert (corpo["cols"], corpo["rows"]) == (15, 11) + assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"]) + assert corpo["saved"] is False + assert not client_denso.config.exists() or not list(client_denso.config.iterdir()) + + +def test_c2_solo_cols_o_solo_rows_422(client_denso): + r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "cols": "9"}) + assert r.status_code == 422 + + +def test_grab_raddrizzato_se_calibrata_e_grezzo_a_richiesta(client_denso, cartella_densa): + originale = cv2.imread(str(cartella_densa / "densa.png"), cv2.IMREAD_GRAYSCALE) + prima = client_denso.post("/camera/grab", json={"source": SORGENTE}) + assert prima.headers["x-raddrizzato"] == "no" # non calibrata: frame com'e' + assert np.array_equal(_grigio(prima), originale) + + r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"}) + assert r.status_code == 200, r.text + assert r.json()["saved"] is True + json_salvati, mappe = list(client_denso.config.glob("*.json")), list(client_denso.config.glob("*.npz")) + assert len(json_salvati) == 1 and len(mappe) == 1 + assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON + saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json() + assert set(saved) == CHIAVI_DENSA and saved["metodo"] == "densa" + + dopo = client_denso.post("/camera/grab", json={"source": SORGENTE}) + assert dopo.status_code == 200 and dopo.headers["x-raddrizzato"] == "si" + import calibrazione + dritta = _grigio(dopo) + assert dritta.shape == originale.shape + assert not np.array_equal(dritta, originale) + assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 # griglia regolare + + grezzo = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": True}) + assert grezzo.headers["x-raddrizzato"] == "no" + assert np.array_equal(_grigio(grezzo), originale) + + # la calibrazione parte sempre dal frame grezzo: ricalibrare dalla sorgente da' lo stesso mm/px + di_nuovo = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}) + assert di_nuovo.json()["mm_per_px"] == pytest.approx(r.json()["mm_per_px"]) + assert di_nuovo.json()["residuo_max_px"] < 0.3 + + +def test_grab_grezzo_non_booleano_400(client_denso): + r = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": "si"}) + assert r.status_code == 400 + + +def test_delete_toglie_anche_la_mappa(client_denso): + client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"}) + assert list(client_denso.config.glob("*.npz")) + assert client_denso.delete("/camera/calibration", params={"source": SORGENTE}).status_code == 204 + assert not list(client_denso.config.glob("*.npz")) and not list(client_denso.config.glob("*.json")) + assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" + + +def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso): + client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"}) + assert list(client_denso.config.glob("*.npz")) + planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "save": "true", **CAMPI}, + files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) + assert planare.status_code == 200 and planare.json()["metodo"] == "planare" + assert not list(client_denso.config.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto + assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" From e38e1a2eafcd924b57ebe6978acdfe4bdade3bcb Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 24 Oct 2026 00:00:36 +0200 Subject: [PATCH 3/3] Calibrazione densa: finestra sub-pixel ristretta al bordo, test su scacchiera continua (#84) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB --- scripts/calibrazione.py | 37 ++++++++++++++++++++++-------- scripts/tests/test_calibrazione.py | 9 ++++---- 2 files changed, 32 insertions(+), 14 deletions(-) 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)