"""Calibrazione a scacchiera dell'acquisitore (TieMeasureFlow, #83). 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). #91: le calibrazioni stanno in un archivio UNICO, salvate con nome (classe Archivio); una sorgente ne ha una attiva (attive.json) e il formato vecchio (una per sorgente) si migra da solo. #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 `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 import base64 import hashlib import json import math import os import re import tempfile import threading import unicodedata # #91 caratteri di controllo nei nomi 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 class CalibrazioneError(ValueError): """Parametri invalidi o scacchiera non trovata: input sbagliato, non un guasto (422).""" def _gray_u8(pixels: np.ndarray) -> np.ndarray: """Immagine -> grigio uint8 2D.""" px = np.asarray(pixels) if px.ndim == 3: px = cv2.cvtColor(px, cv2.COLOR_BGRA2GRAY if px.shape[2] == 4 else cv2.COLOR_BGR2GRAY) if px.dtype != np.uint8: # float/uint16 -> scala sul massimo, come fa vs-core m = float(px.max()) if px.size else 1.0 px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8) return np.ascontiguousarray(px) 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, metodo, distorsione, overlay_png (b64)}. """ if cols < 2 or rows < 2: raise CalibrazioneError("cols e rows (angoli interni) devono essere almeno 2") if not (square_mm > 0 and math.isfinite(square_mm)): raise CalibrazioneError("square_mm deve essere > 0") frame = _gray_u8(pixels) image = Image(frame) try: image_pts, world_pts = find_chessboard(image, (cols, rows), square_mm) except RuntimeError as exc: # find_chessboard solleva RuntimeError quando la scacchiera non c'e' raise CalibrazioneError( f"scacchiera {cols}x{rows} non trovata nell'immagine: {exc}") from exc model = calibrate_planar(image, image_pts, world_pts) # omografia pura # Scala al centro della scacchiera (puo' non stare al centro immagine) x0, y0 = image_pts.min(axis=0) x1, y1 = image_pts.max(axis=0) mm_per_px = model.pixel_size((x0 + x1) / 2.0, (y0 + y1) / 2.0) if not (mm_per_px > 0 and math.isfinite(mm_per_px)): raise CalibrazioneError("calibrazione degenere: scala non valida") # Scarto massimo % della scala ai quattro angoli dell'area della scacchiera # rispetto al centro: ottica telecentrica/piana ~ 0, prospettiva = grande. spread = max(abs(model.pixel_size(x, y) - mm_per_px) / mm_per_px * 100.0 for x in (x0, x1) for y in (y0, y1)) # Overlay: angoli disegnati sull'immagine (PNG base64) bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) cv2.drawChessboardCorners( bgr, (cols, rows), image_pts.reshape(-1, 1, 2).astype(np.float32), True) ok, png = cv2.imencode(".png", bgr) if not ok: raise RuntimeError("PNG dell'overlay non codificabile") h, w = frame.shape return { "px_per_mm": 1.0 / mm_per_px, "mm_per_px": mm_per_px, "rms_px": float(model.rms_reprojection), "n_corners": int(len(image_pts)), "cols": int(cols), "rows": int(rows), "square_mm": float(square_mm), "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 _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 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 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) 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) # 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].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()) 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 NomeNonValido(ValueError): """#91: nome di calibrazione vuoto, troppo lungo o con caratteri di controllo (400).""" class NomeGiaUsato(ValueError): """#91: esiste gia' una calibrazione con questo nome (senza distinguere maiuscole) (409).""" class NomeSconosciuto(KeyError): """#91: nessuna calibrazione con questo nome (404).""" def pulisci_nome(nome) -> str: """#91: nome valido = strip, 1-60 caratteri, niente caratteri di controllo.""" if not isinstance(nome, str): raise NomeNonValido("il nome della calibrazione deve essere un testo") nome = nome.strip() if not nome: raise NomeNonValido("il nome della calibrazione è vuoto") if len(nome) > 60: raise NomeNonValido("il nome della calibrazione supera i 60 caratteri") if any(unicodedata.category(c) == "Cc" for c in nome): raise NomeNonValido("il nome della calibrazione contiene caratteri di controllo") return nome # #91: campi che l'elenco espone per ogni calibrazione (il resto resta nel JSON) _CAMPI_ELENCO = ("nome", "origine", "image_width", "image_height", "px_per_mm", "mm_per_px", "metodo", "square_mm", "calibrated_at") class Archivio: """#91: archivio UNICO di calibrazioni con nome, in `cartella`. Libreria: /calibrazioni/-.json (+ .npz se densa, stesso nome). Attive: /attive.json = {sorgente: nome}. Una calibrazione non appartiene a una sorgente: la sorgente ne sceglie una (`attiva`). #84: `leggi_mappa` tiene la mappa in memoria finche' il file non cambia. Il formato di prima (un JSON per sorgente, campo "source", in ) si migra da solo al costruttore (`migra`, idempotente). """ def __init__(self, cartella: Path | str): self.cartella = Path(cartella) self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta) self._lock = threading.Lock() self._lock_dati = threading.RLock() # #91: libreria e attive.json (leggi-modifica-scrivi) self.migra() # ---- percorsi def _file(self, sorgente: str) -> Path: # caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano # (#91: oggi serve ai settaggi camera e al formato vecchio da migrare) pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", sorgente).strip("._") or "sorgente" sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8] return self.cartella / f"{pulito[:60]}-{sigla}.json" @property def _libreria(self) -> Path: return self.cartella / "calibrazioni" def _file_nome(self, nome: str) -> Path: # #91: stesso schema di _file, ma l'hash e' sul nome senza maiuscole/minuscole pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", nome).strip("._") or "calibrazione" sigla = hashlib.sha1(nome.casefold().encode("utf-8")).hexdigest()[:8] return self._libreria / f"{pulito[:60]}-{sigla}.json" def _file_mappa_nome(self, nome: str) -> Path: return self._file_nome(nome).with_suffix(".npz") # #84 stesso nome del JSON @property def _file_attive(self) -> Path: return self.cartella / "attive.json" # ---- scrittura atomica def _scrivi_json(self, destinazione: Path, dati: dict) -> None: destinazione.parent.mkdir(parents=True, exist_ok=True) # scrittura atomica: file temporaneo nella stessa cartella, poi replace fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(dati, f, ensure_ascii=False, indent=2) os.replace(tmp, destinazione) except BaseException: try: os.unlink(tmp) except OSError: pass raise def _leggi_json(self, percorso: Path) -> dict | None: try: dati = json.loads(percorso.read_text(encoding="utf-8")) except (OSError, ValueError): return None # assente o illeggibile return dati if isinstance(dati, dict) else None # ---- attive def _leggi_attive(self) -> dict: dati = self._leggi_json(self._file_attive) or {} return {k: v for k, v in dati.items() if isinstance(k, str) and isinstance(v, str)} def _scrivi_attive(self, attive: dict) -> None: self._scrivi_json(self._file_attive, attive) # ---- migrazione dal formato vecchio def migra(self) -> None: """#91: ogni /*.json con "source" (una per sorgente) entra in libreria. Nome = valore di "source" (gia' preso: " (2)", "(3)"...); il .npz accanto segue; la sorgente resta calibrata con quella voce (attive.json). Idempotente: il vecchio file sparisce, un secondo giro non trova nulla. """ if not self.cartella.is_dir(): return with self._lock_dati: for vecchio in sorted(self.cartella.glob("*.json")): if vecchio.name == self._file_attive.name: continue dati = self._leggi_json(vecchio) sorgente = dati.get("source") if dati else None if not isinstance(sorgente, str) or not sorgente.strip(): continue try: self._migra_uno(vecchio, dati, sorgente) except OSError: continue # un file bloccato non ferma il servizio: riprova al prossimo avvio def _migra_uno(self, vecchio: Path, dati: dict, sorgente: str) -> None: base = "".join(c for c in sorgente if unicodedata.category(c) != "Cc").strip()[:60].strip() or "calibrazione" nome, n = base, 1 while True: esistente = self.leggi_nome(nome) if esistente is None: break if (esistente.get("calibrated_at") == dati.get("calibrated_at") and esistente.get("px_per_mm") == dati.get("px_per_mm")): break # gia' copiata da un giro interrotto: si completa e basta n += 1 suffisso = f" ({n})" nome = base[:60 - len(suffisso)].rstrip() + suffisso nuovo = {k: v for k, v in dati.items() if k != "source"} nuovo["nome"] = nome nuovo.setdefault("origine", sorgente) # l'origine vera non era registrata vecchia_mappa = vecchio.with_suffix(".npz") if vecchia_mappa.is_file(): # prima la mappa, poi il JSON che la dichiara self._libreria.mkdir(parents=True, exist_ok=True) os.replace(vecchia_mappa, self._file_mappa_nome(nome)) self._scrivi_json(self._file_nome(nome), nuovo) attive = self._leggi_attive() attive[sorgente.strip()] = nome self._scrivi_attive(attive) vecchio.unlink() # ---- libreria def leggi_nome(self, nome: str) -> dict | None: """#91: la calibrazione con questo nome (senza distinguere maiuscole), o None.""" try: nome = pulisci_nome(nome) except NomeNonValido: return None return self._leggi_json(self._file_nome(nome)) def _meta(self, dati: dict, attive: dict) -> dict: meta = {k: dati.get(k) for k in _CAMPI_ELENCO} chiave = str(dati.get("nome", "")).casefold() meta["attiva_su"] = sorted(s for s, n in attive.items() if n.casefold() == chiave) return meta def elenco(self) -> list[dict]: """#91: metadati di tutte le calibrazioni, dalla piu' recente.""" with self._lock_dati: attive = self._leggi_attive() voci = [] for percorso in self._libreria.glob("*.json") if self._libreria.is_dir() else []: dati = self._leggi_json(percorso) if dati and isinstance(dati.get("nome"), str): voci.append(self._meta(dati, attive)) voci.sort(key=lambda m: str(m.get("calibrated_at") or ""), reverse=True) return voci def metadati(self, nome: str) -> dict | None: """#91: i metadati di una voce (come in `elenco`), o None.""" dati = self.leggi_nome(nome) return self._meta(dati, self._leggi_attive()) if dati else None def salva_nome(self, nome: str, dati: dict, mappa: DistortionMap | None = None, sovrascrivi: bool = False) -> dict: """#91: salva `dati` (e la mappa, se densa) con questo nome; ritorna i metadati.""" nome = pulisci_nome(nome) destinazione = self._file_nome(nome) with self._lock_dati: if destinazione.exists() and not sovrascrivi: esistente = self._leggi_json(destinazione) or {} raise NomeGiaUsato(f"esiste già una calibrazione «{esistente.get('nome', nome)}»") corpo = {k: v for k, v in dati.items() if k not in ("source", "overlay_png", "saved")} corpo["nome"] = nome self._libreria.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(nome, mappa) else: self._togli_mappa(nome) # voce planare: una vecchia mappa non vale piu' self._scrivi_json(destinazione, corpo) return self._meta(corpo, self._leggi_attive()) def _salva_mappa(self, nome: str, mappa: DistortionMap) -> None: destinazione = self._file_mappa_nome(nome) fd, tmp = tempfile.mkstemp(dir=destinazione.parent, 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, nome: str) -> None: destinazione = self._file_mappa_nome(nome) try: destinazione.unlink() except FileNotFoundError: pass with self._lock: self._cache.pop(str(destinazione), None) def elimina_nome(self, nome: str) -> bool: """#91: toglie la voce (JSON e mappa) e la scollega da ogni sorgente. False se non c'era.""" dati = self.leggi_nome(nome) if dati is None: return False canonico = str(dati.get("nome", nome)) with self._lock_dati: try: self._file_nome(canonico).unlink() except FileNotFoundError: pass self._togli_mappa(canonico) attive = self._leggi_attive() restanti = {s: n for s, n in attive.items() if n.casefold() != canonico.casefold()} if restanti != attive: self._scrivi_attive(restanti) return True # ---- sorgenti def attiva(self, sorgente: str, nome: str) -> str: """#91: la sorgente usa questa calibrazione; ritorna il nome com'e' in libreria.""" dati = self.leggi_nome(nome) if dati is None: raise NomeSconosciuto(nome) canonico = str(dati["nome"]) with self._lock_dati: attive = self._leggi_attive() attive[sorgente] = canonico self._scrivi_attive(attive) return canonico def scollega(self, sorgente: str) -> None: """#91: la sorgente non ha piu' una calibrazione attiva; la libreria resta.""" with self._lock_dati: attive = self._leggi_attive() if sorgente in attive: del attive[sorgente] self._scrivi_attive(attive) def attiva_di(self, sorgente: str) -> str | None: """#91: il nome della calibrazione attiva della sorgente (se esiste ancora).""" nome = self._leggi_attive().get(sorgente) dati = self.leggi_nome(nome) if nome else None return str(dati["nome"]) if dati else None def leggi(self, sorgente: str) -> dict | None: """#91: la calibrazione ATTIVA della sorgente, con "nome" e "source"; None se nessuna.""" nome = self.attiva_di(sorgente) dati = self.leggi_nome(nome) if nome else None return {**dati, "source": sorgente} if dati else None def leggi_mappa(self, sorgente: str) -> MappaPronta | None: """La mappa della calibrazione attiva, pronta per `raddrizza`; None se non ne ha una.""" nome = self.attiva_di(sorgente) # #91: la mappa e' della voce attiva, non della sorgente if nome is None: return None percorso = self._file_mappa_nome(nome) 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