Files
TieMeasureFlow/scripts/calibrazione.py
T
Adriano e7d5489438 acquisitore: archivio unico di calibrazioni con nome, migrazione dal formato per sorgente (Refs #91)
Archivio: libreria calibrazioni/<nome>-<sha>.json (+npz), attive.json per sorgente,
salva_nome/leggi_nome/elimina_nome/attiva/scollega/attiva_di/elenco; leggi e
leggi_mappa risolvono la calibrazione attiva. Le rotte seguono nel commit successivo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 16:06:05 +02:00

782 lines
33 KiB
Python

"""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: <cartella>/calibrazioni/<nome ripulito>-<sha1 del nome>.json (+ .npz se
densa, stesso nome). Attive: <cartella>/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 <cartella>) 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 <cartella>/*.json con "source" (una per sorgente) entra in libreria.
Nome = valore di "source" (gia' preso: "<source> (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