"""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). #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 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 Archivio: """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 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" 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, 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: 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 _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