merge #91 servizio: calibrazioni con nome, archivio unico

This commit is contained in:
2026-10-24 16:10:25 +02:00
5 changed files with 922 additions and 98 deletions
+249 -34
View File
@@ -3,6 +3,9 @@
Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con
TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente
camera in un JSON (scrittura atomica). camera in un JSON (scrittura atomica).
#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 #84: `calibra_scacchiera_densa(pixels, square_mm)` calibra da UNA foto di
scacchiera che riempie tutto il campo (ottica telecentrica): trova da sola la scacchiera che riempie tutto il campo (ottica telecentrica): trova da sola la
griglia, costruisce la mappa di distorsione (DistortionMap) e il mm/px griglia, costruisce la mappa di distorsione (DistortionMap) e il mm/px
@@ -24,6 +27,7 @@ import os
import re import re
import tempfile import tempfile
import threading import threading
import unicodedata # #91 caratteri di controllo nei nomi
from collections import deque from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -463,43 +467,87 @@ def ora_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds") return datetime.now(timezone.utc).isoformat(timespec="seconds")
class Archivio: class NomeNonValido(ValueError):
"""Un JSON per sorgente in `cartella`; nome file dalla sorgente ripulito. """#91: nome di calibrazione vuoto, troppo lungo o con caratteri di controllo (400)."""
#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. 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): def __init__(self, cartella: Path | str):
self.cartella = Path(cartella) self.cartella = Path(cartella)
self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta) self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta)
self._lock = threading.Lock() 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: def _file(self, sorgente: str) -> Path:
# caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano # caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano
# (#91: oggi serve ai settaggi camera e al formato vecchio da migrare)
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", sorgente).strip("._") or "sorgente" pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", sorgente).strip("._") or "sorgente"
sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8] sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8]
return self.cartella / f"{pulito[:60]}-{sigla}.json" return self.cartella / f"{pulito[:60]}-{sigla}.json"
def _file_mappa(self, sorgente: str) -> Path: @property
return self._file(sorgente).with_suffix(".npz") # #84 stesso nome del JSON def _libreria(self) -> Path:
return self.cartella / "calibrazioni"
def leggi(self, sorgente: str) -> dict | None: def _file_nome(self, nome: str) -> Path:
try: # #91: stesso schema di _file, ma l'hash e' sul nome senza maiuscole/minuscole
return json.loads(self._file(sorgente).read_text(encoding="utf-8")) pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", nome).strip("._") or "calibrazione"
except (OSError, ValueError): sigla = hashlib.sha1(nome.casefold().encode("utf-8")).hexdigest()[:8]
return None # assente o illeggibile = non calibrata return self._libreria / f"{pulito[:60]}-{sigla}.json"
def salva(self, sorgente: str, dati: dict, mappa: DistortionMap | None = None) -> None: def _file_mappa_nome(self, nome: str) -> Path:
destinazione = self._file(sorgente) return self._file_nome(nome).with_suffix(".npz") # #84 stesso nome del JSON
self.cartella.mkdir(parents=True, exist_ok=True)
# #84: prima la mappa (o la rimozione di una vecchia), poi il JSON che la dichiara @property
if mappa is not None: def _file_attive(self) -> Path:
self._salva_mappa(sorgente, mappa) return self.cartella / "attive.json"
else:
self._togli_mappa(sorgente) # ricalibrazione planare: la vecchia mappa non vale piu' # ---- 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 # scrittura atomica: file temporaneo nella stessa cartella, poi replace
fd, tmp = tempfile.mkstemp(dir=self.cartella, suffix=".tmp") fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp")
try: try:
with os.fdopen(fd, "w", encoding="utf-8") as f: with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(dati, f, ensure_ascii=False, indent=2) json.dump(dati, f, ensure_ascii=False, indent=2)
@@ -511,9 +559,128 @@ class Archivio:
pass pass
raise raise
def _salva_mappa(self, sorgente: str, mappa: DistortionMap) -> None: def _leggi_json(self, percorso: Path) -> dict | None:
destinazione = self._file_mappa(sorgente) try:
fd, tmp = tempfile.mkstemp(dir=self.cartella, suffix=".tmp.npz") # .npz: numpy non aggiunge nulla 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) os.close(fd)
try: try:
mappa.save(tmp) mappa.save(tmp)
@@ -527,8 +694,8 @@ class Archivio:
with self._lock: with self._lock:
self._cache.pop(str(destinazione), None) self._cache.pop(str(destinazione), None)
def _togli_mappa(self, sorgente: str) -> None: def _togli_mappa(self, nome: str) -> None:
destinazione = self._file_mappa(sorgente) destinazione = self._file_mappa_nome(nome)
try: try:
destinazione.unlink() destinazione.unlink()
except FileNotFoundError: except FileNotFoundError:
@@ -536,9 +703,64 @@ class Archivio:
with self._lock: with self._lock:
self._cache.pop(str(destinazione), None) 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: def leggi_mappa(self, sorgente: str) -> MappaPronta | None:
"""La mappa salvata, pronta per `raddrizza`; None se la sorgente non ne ha una.""" """La mappa della calibrazione attiva, pronta per `raddrizza`; None se non ne ha una."""
percorso = self._file_mappa(sorgente) 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) chiave = str(percorso)
try: try:
mtime = percorso.stat().st_mtime_ns mtime = percorso.stat().st_mtime_ns
@@ -557,10 +779,3 @@ class Archivio:
with self._lock: with self._lock:
self._cache[chiave] = (mtime, pronta) self._cache[chiave] = (mtime, pronta)
return 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
+105 -25
View File
@@ -10,21 +10,35 @@ OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è. raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è.
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout. Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py): Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
GET /camera/calibration?source=<src> -> 200 {source, px_per_mm, mm_per_px, #91: archivio UNICO di calibrazioni con nome; una sorgente ne ha una attiva:
rms_px, n_corners, cols, rows, square_mm, image_width, image_height, POST /camera/calibrate multipart: source (obbligatorio solo senza image),
scale_spread_pct, calibrated_at} | 404 "sorgente non calibrata" square_mm, image (file, opzionale: senza, grab GREZZO dalla sorgente),
POST /camera/calibrate multipart: source, square_mm, image (file, opzionale: cols/rows (opzionali). NON salva mai (il vecchio campo save e' ignorato).
senza, grab GREZZO dalla sorgente), save=false, cols/rows (opzionali).
#84: SENZA cols/rows = calibrazione densa (default): una foto di #84: SENZA cols/rows = calibrazione densa (default): una foto di
scacchiera che riempie il campo, griglia trovata da sola, mappa 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 distorsione; mm/px e px/mm sono quelli dell'immagine RADDRIZZATA. CON
dell'immagine RADDRIZZATA. CON cols/rows (angoli interni) = metodo cols/rows (angoli interni) = metodo planare di prima. Risposta: i campi
planare di prima. Campi: quelli di C1 + overlay_png (b64) + saved + del calcolo + overlay_png (b64), calibrated_at, origine ("camera:<src>" |
metodo ("densa"|"planare"), distorsione (bool) e, per la densa, "file:<nome>"), calcolo_id (il servizio tiene in memoria gli ultimi 8
residuo_rms_px, residuo_max_px, copertura_pct; calcoli) e metodo ("densa"|"planare"), distorsione (bool) e, per la
densa, residuo_rms_px, residuo_max_px, copertura_pct;
422 scacchiera non trovata o parametri invalidi 422 scacchiera non trovata o parametri invalidi
DELETE /camera/calibration?source=<src> -> 204 (toglie JSON e mappa) GET /camera/calibrazioni[?source=S] -> {calibrazioni: [{nome, origine,
image_width, image_height, px_per_mm, mm_per_px, metodo, square_mm,
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
da ogni sorgente) | 404
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
sorgente e carica la calibrazione solo se la risoluzione (larghezza x
altezza) coincide: 409 altrimenti; 404 nome ignoto; 200 come GET sotto
GET /camera/calibration?source=<src> -> 200 la calibrazione ATTIVA {source,
nome, px_per_mm, mm_per_px, ..., image_width, image_height,
calibrated_at} | 404 "sorgente non calibrata" (lo legge il backend)
DELETE /camera/calibration?source=<src> -> 204 (SCOLLEGA la calibrazione attiva,
la libreria resta)
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
GET /acquisizione -> stessa pagina (#87) GET /acquisizione -> stessa pagina (#87)
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default: Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
@@ -43,7 +57,7 @@ Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
POST /camera/parametri/salva json {source} -> {source, salvati, file} POST /camera/parametri/salva json {source} -> {source, salvati, file}
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
della camera nel servizio il file, se c'e', viene ricaricato. della camera nel servizio il file, se c'e', viene ricaricato.
Il risultato salvato sta in un JSON per sorgente in --config-dir (default Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json (default
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove). %ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
@@ -70,6 +84,8 @@ import logging
import os import os
import sys import sys
import threading import threading
import uuid # #91 id dei calcoli in attesa di nome
from collections import OrderedDict
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -88,6 +104,7 @@ import calibrazione # noqa: E402
from calibrazione_pagina import PAGINA # noqa: E402 from calibrazione_pagina import PAGINA # noqa: E402
_TRIGGER = ("continuo",) _TRIGGER = ("continuo",)
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi) _log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
# #87: i quattro parametri regolabili, nell'ordine di presentazione # #87: i quattro parametri regolabili, nell'ordine di presentazione
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto") _PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
@@ -133,6 +150,8 @@ def create_app(folder: Path | str | None = None,
aperte: dict = {} aperte: dict = {}
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
lock_calcoli = threading.Lock()
def _config() -> dict: def _config() -> dict:
if folder is None: if folder is None:
@@ -226,7 +245,7 @@ def create_app(folder: Path | str | None = None,
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000") raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
raddrizzato = False raddrizzato = False
if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache (#91: quella attiva)
mappa = archivio.leggi_mappa(nome) mappa = archivio.leggi_mappa(nome)
if mappa is not None: if mappa is not None:
corretto = calibrazione.raddrizza(frame, mappa) corretto = calibrazione.raddrizza(frame, mappa)
@@ -247,33 +266,35 @@ def create_app(folder: Path | str | None = None,
@app.get("/camera/calibration") @app.get("/camera/calibration")
def calibrazione_leggi(source: str = ""): def calibrazione_leggi(source: str = ""):
dati = archivio.leggi(_sorgente(source)) dati = archivio.leggi(_sorgente(source)) # #91: la calibrazione ATTIVA della sorgente
if dati is None: if dati is None:
raise HTTPException(404, "sorgente non calibrata") raise HTTPException(404, "sorgente non calibrata")
return dati return dati
@app.delete("/camera/calibration", status_code=204) @app.delete("/camera/calibration", status_code=204)
def calibrazione_elimina(source: str = ""): def calibrazione_elimina(source: str = ""):
archivio.elimina(_sorgente(source)) archivio.scollega(_sorgente(source)) # #91: scollega soltanto, la libreria resta
return Response(status_code=204) return Response(status_code=204)
@app.post("/camera/calibrate") @app.post("/camera/calibrate")
async def calibrazione_calcola( async def calibrazione_calcola(
source: str = Form(...), source: str | None = Form(None), # #91: serve solo se manca l'immagine
square_mm: float = Form(...), square_mm: float = Form(...),
cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa
rows: int | None = Form(None), rows: int | None = Form(None),
image: UploadFile | None = File(None), image: UploadFile | None = File(None),
save: bool = Form(False), save: bool = Form(False), # #91: ignorato, il calcolo non salva mai (si salva con nome)
): ):
nome = _sorgente(source)
contenuto = await image.read() if image is not None else b"" contenuto = await image.read() if image is not None else b""
if contenuto: if contenuto:
frame = cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) frame = cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED)
if frame is None: if frame is None:
raise HTTPException(422, "l'immagine caricata non è leggibile") raise HTTPException(422, "l'immagine caricata non è leggibile")
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine")
else: else:
nome = _sorgente(source)
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504) frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
origine = nome
if (cols is None) != (rows is None): if (cols is None) != (rows is None):
raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)") raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)")
mappa = None mappa = None
@@ -285,12 +306,71 @@ def create_app(folder: Path | str | None = None,
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm) risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
except calibrazione.CalibrazioneError as e: except calibrazione.CalibrazioneError as e:
raise HTTPException(422, str(e)) raise HTTPException(422, str(e))
corpo = {"source": nome, corpo = {**{k: v for k, v in risultato.items() if k != "overlay_png"},
**{k: v for k, v in risultato.items() if k != "overlay_png"}, "calibrated_at": calibrazione.ora_iso(), "origine": origine}
"calibrated_at": calibrazione.ora_iso()} calcolo_id = uuid.uuid4().hex
if save: with lock_calcoli: # #91: il calcolo resta in memoria finche' non gli si da' un nome
archivio.salva(nome, corpo, mappa) # #84: la mappa (se densa) va in .npz accanto calcoli[calcolo_id] = (corpo, mappa)
return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)} while len(calcoli) > _MAX_CALCOLI:
calcoli.popitem(last=False)
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"]}
# ---- #91: archivio unico di calibrazioni con nome
@app.get("/camera/calibrazioni")
def calibrazioni_elenco(source: str | None = None):
attiva = archivio.attiva_di(_sorgente(source)) if source is not None else None
return {"calibrazioni": archivio.elenco(), "attiva": attiva}
@app.post("/camera/calibrazioni", status_code=201)
def calibrazioni_salva(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
calcolo_id = corpo.get("calcolo_id")
if not isinstance(calcolo_id, str) or not calcolo_id:
raise HTTPException(400, "manca «calcolo_id»")
sovrascrivi = corpo.get("sovrascrivi", False)
if not isinstance(sovrascrivi, bool):
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
try:
nome = calibrazione.pulisci_nome(corpo.get("nome"))
except calibrazione.NomeNonValido as e:
raise HTTPException(400, str(e))
with lock_calcoli:
voce = calcoli.get(calcolo_id)
if voce is None:
raise HTTPException(404, "calcolo non più disponibile: ricalcolare")
dati, mappa = voce
try:
return archivio.salva_nome(nome, dati, mappa, sovrascrivi)
except calibrazione.NomeGiaUsato as e:
raise HTTPException(409, str(e))
@app.delete("/camera/calibrazioni", status_code=204)
def calibrazioni_elimina(nome: str = ""):
if not archivio.elimina_nome(nome):
raise HTTPException(404, "calibrazione non trovata")
return Response(status_code=204)
@app.put("/camera/calibrazione-attiva")
def calibrazione_attiva(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
sorgente = _sorgente(corpo.get("source"))
richiesto = corpo.get("nome")
dati = archivio.leggi_nome(richiesto) if isinstance(richiesto, str) else None
if dati is None:
raise HTTPException(404, "calibrazione non trovata")
nome = dati["nome"]
frame = _grab(sorgente, 5000) # stesso lock e stessi errori di /camera/grab (404/503/504)
alt, larg = frame.shape[:2]
cw, ch = dati.get("image_width"), dati.get("image_height")
if not isinstance(cw, int) or not isinstance(ch, int):
raise HTTPException(409, f"la calibrazione «{nome}» non ha la risoluzione registrata: "
"non si può caricare")
if (cw, ch) != (larg, alt): # la mappa vale solo per frame della stessa risoluzione
raise HTTPException(409, f"la calibrazione «{nome}» è per immagini {cw}×{ch}, "
f"la sorgente dà {larg}×{alt}: non si può caricare")
archivio.attiva(sorgente, nome)
return archivio.leggi(sorgente)
# ---- #87: acquisizione (anteprima, parametri, settaggi) # ---- #87: acquisizione (anteprima, parametri, settaggi)
+83 -37
View File
@@ -82,12 +82,24 @@ def client(cartella, tmp_path):
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"} CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
CHIAVI_C1 = {"source", "px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows", CHIAVI_C1 = {"px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows",
"square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"} "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 # #84: campi in piu' per metodo planare (C2 con cols/rows) e per la densa
CHIAVI_PLANARE = CHIAVI_C1 | {"metodo", "distorsione"} CHIAVI_PLANARE = CHIAVI_C1 | {"metodo", "distorsione", "origine"} # #91: + origine ("camera:X" | "file:Y")
CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"} CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"}
SORGENTE = "camera:folder" SORGENTE = "camera:folder"
# #91: GET /camera/calibration = voce di libreria + "nome" + "source" (la sorgente che la usa)
CHIAVI_ATTIVA = {"source", "nome"}
def salva_con_nome(client, calcolo: dict, nome: str, **extra):
"""#91: POST /camera/calibrazioni sul calcolo_id di una risposta di /camera/calibrate."""
return client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": nome, **extra})
def carica_su(client, sorgente: str, nome: str):
"""#91: PUT /camera/calibrazione-attiva."""
return client.put("/camera/calibrazione-attiva", json={"source": sorgente, "nome": nome})
@pytest.fixture @pytest.fixture
@@ -157,34 +169,49 @@ def test_c2_immagine_caricata_senza_salvare(client):
files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
corpo = r.json() corpo = r.json()
assert CHIAVI_PLANARE | {"overlay_png", "saved"} == set(corpo) assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id"} == set(corpo) # #91: niente "saved", niente "source"
assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
assert corpo["saved"] is False assert corpo["origine"] == "file:s.png"
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005) assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
# senza save non si scrive nulla # il calcolo non salva mai
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404 assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert not client.config.exists() or not list(client.config.glob("*.json")) assert not client.config.exists() or not list(client.config.iterdir())
def test_c2_save_true_viene_ignorato(client):
# #91: il vecchio campo save non salva piu' nulla
r = client.post("/camera/calibrate", data={"source": "camera:folder", "save": "true", **CAMPI})
assert r.status_code == 200, r.text
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
assert not client.config.exists() or not list(client.config.iterdir())
def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client): def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client):
# nessun file: grab da camera:folder (la scacchiera in cartella), con save # nessun file: grab da camera:folder (la scacchiera in cartella)
r = client.post("/camera/calibrate", r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
data={"source": "camera:folder", "save": "true", **CAMPI})
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["saved"] is True assert r.json()["origine"] == "camera:folder"
assert salva_con_nome(client, r.json(), "Banco").status_code == 201 # #91: si salva con nome, dopo
# salvata ma non ancora caricata sulla sorgente
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert carica_su(client, "camera:folder", "Banco").status_code == 200
r1 = client.get("/camera/calibration", params={"source": "camera:folder"}) r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
assert r1.status_code == 200 assert r1.status_code == 200
assert set(r1.json()) == CHIAVI_PLANARE # nessun overlay nel file salvato assert set(r1.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA # nessun overlay nel file salvato
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005) assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r1.json()["source"] == "camera:folder" assert r1.json()["source"] == "camera:folder" and r1.json()["nome"] == "Banco"
file_salvati = list(client.config.glob("*.json")) file_salvati = list((client.config / "calibrazioni").glob("*.json"))
assert len(file_salvati) == 1 assert len(file_salvati) == 1
assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9 assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9
# DELETE /camera/calibration scollega: la libreria resta
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204 assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404 assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
# cancellare di nuovo e' idempotente assert len(list((client.config / "calibrazioni").glob("*.json"))) == 1
# scollegare di nuovo e' idempotente
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204 assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
@@ -214,12 +241,14 @@ def test_c2_errori_camera_come_grab(client):
assert r.status_code == 404 assert r.status_code == 404
def test_due_sorgenti_hanno_file_diversi(client): def test_due_nomi_simili_hanno_file_diversi(client):
for nome in ("camera:a b", "camera:a_b"): # #91: i nomi si ripuliscono come prima (hash sul nome originale): due nomi diversi non si pestano
r = client.post("/camera/calibrate", data={"source": nome, "save": "true", **CAMPI}, r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200 assert r.status_code == 200
assert len(list(client.config.glob("*.json"))) == 2 for nome in ("banco a b", "banco a_b"):
assert salva_con_nome(client, r.json(), nome).status_code == 201
assert len(list((client.config / "calibrazioni").glob("*.json"))) == 2
def test_c4_pagina_html(client): def test_c4_pagina_html(client):
@@ -287,14 +316,15 @@ def test_archivio_mappa_cache_e_invalidazione(tmp_path):
_, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0) _, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)
arch = calibrazione.Archivio(tmp_path) arch = calibrazione.Archivio(tmp_path)
assert arch.leggi_mappa("s") is None assert arch.leggi_mappa("s") is None
arch.salva("s", {"a": 1}, mappa) arch.salva_nome("cal", {"a": 1}, mappa) # #91: salvataggio con nome + attivazione sulla sorgente
arch.attiva("s", "cal")
prima = arch.leggi_mappa("s") prima = arch.leggi_mappa("s")
assert prima is not None and arch.leggi_mappa("s") is prima # cache in memoria 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 assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
arch.salva("s", {"a": 2}, mappa) # file riscritto -> cache invalidata arch.salva_nome("cal", {"a": 2}, mappa, sovrascrivi=True) # file riscritto -> cache invalidata
assert arch.leggi_mappa("s") is not prima assert arch.leggi_mappa("s") is not prima
arch.salva("s", {"a": 3}) # salvataggio senza mappa (planare): la vecchia sparisce arch.salva_nome("cal", {"a": 3}, sovrascrivi=True) # salvataggio senza mappa (planare): la vecchia sparisce
assert arch.leggi_mappa("s") is None and not list(tmp_path.glob("*.npz")) assert arch.leggi_mappa("s") is None and not list(tmp_path.rglob("*.npz"))
# ---- #84: rotte con la calibrazione densa # ---- #84: rotte con la calibrazione densa
@@ -307,11 +337,10 @@ def test_c2_senza_cols_rows_e_densa(client_denso):
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}) r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
corpo = r.json() corpo = r.json()
assert CHIAVI_DENSA | {"overlay_png", "saved"} == set(corpo) assert CHIAVI_DENSA | {"overlay_png", "calcolo_id"} == set(corpo)
assert corpo["metodo"] == "densa" and corpo["distorsione"] is True assert corpo["metodo"] == "densa" and corpo["distorsione"] is True
assert (corpo["cols"], corpo["rows"]) == (15, 11) assert (corpo["cols"], corpo["rows"]) == (15, 11)
assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"]) 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()) assert not client_denso.config.exists() or not list(client_denso.config.iterdir())
@@ -326,14 +355,17 @@ def test_grab_raddrizzato_se_calibrata_e_grezzo_a_richiesta(client_denso, cartel
assert prima.headers["x-raddrizzato"] == "no" # non calibrata: frame com'e' assert prima.headers["x-raddrizzato"] == "no" # non calibrata: frame com'e'
assert np.array_equal(_grigio(prima), originale) assert np.array_equal(_grigio(prima), originale)
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"}) r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["saved"] is True assert salva_con_nome(client_denso, r.json(), "Densa").status_code == 201 # #91: salvata con nome
json_salvati, mappe = list(client_denso.config.glob("*.json")), list(client_denso.config.glob("*.npz")) libreria = client_denso.config / "calibrazioni"
json_salvati, mappe = list(libreria.glob("*.json")), list(libreria.glob("*.npz"))
assert len(json_salvati) == 1 and len(mappe) == 1 assert len(json_salvati) == 1 and len(mappe) == 1
assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" # salvata != attiva
assert carica_su(client_denso, SORGENTE, "Densa").status_code == 200
saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json() saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()
assert set(saved) == CHIAVI_DENSA and saved["metodo"] == "densa" assert set(saved) == CHIAVI_DENSA | CHIAVI_ATTIVA and saved["metodo"] == "densa"
dopo = client_denso.post("/camera/grab", json={"source": SORGENTE}) dopo = client_denso.post("/camera/grab", json={"source": SORGENTE})
assert dopo.status_code == 200 and dopo.headers["x-raddrizzato"] == "si" assert dopo.status_code == 200 and dopo.headers["x-raddrizzato"] == "si"
@@ -358,19 +390,33 @@ def test_grab_grezzo_non_booleano_400(client_denso):
assert r.status_code == 400 assert r.status_code == 400
def test_delete_toglie_anche_la_mappa(client_denso): def test_scollega_lascia_la_libreria_ed_elimina_toglie_la_mappa(client_denso):
client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"}) r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert list(client_denso.config.glob("*.npz")) salva_con_nome(client_denso, r.json(), "Densa")
carica_su(client_denso, SORGENTE, "Densa")
libreria = client_denso.config / "calibrazioni"
assert list(libreria.glob("*.npz"))
# scollegare: il frame torna com'e', ma la voce (e la mappa) restano in libreria
assert client_denso.delete("/camera/calibration", params={"source": SORGENTE}).status_code == 204 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"
assert list(libreria.glob("*.npz")) and list(libreria.glob("*.json"))
# eliminare la voce toglie JSON e mappa
carica_su(client_denso, SORGENTE, "Densa")
assert client_denso.delete("/camera/calibrazioni", params={"nome": "Densa"}).status_code == 204
assert not list(libreria.glob("*.npz")) and not list(libreria.glob("*.json"))
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso): def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso):
client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"}) densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert list(client_denso.config.glob("*.npz")) salva_con_nome(client_denso, densa.json(), "Cal")
planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "save": "true", **CAMPI}, carica_su(client_denso, SORGENTE, "Cal")
libreria = client_denso.config / "calibrazioni"
assert list(libreria.glob("*.npz"))
planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert planare.status_code == 200 and planare.json()["metodo"] == "planare" 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 salva_con_nome(client_denso, planare.json(), "Cal").status_code == 409 # nome gia' preso
assert salva_con_nome(client_denso, planare.json(), "Cal", sovrascrivi=True).status_code == 201
assert not list(libreria.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
+233
View File
@@ -0,0 +1,233 @@
"""Test dell'archivio unico di calibrazioni con nome (#91): scripts/calibrazione.py, classe Archivio.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Solo tmp_path: niente ProgramData, niente camera.
"""
import json
import pytest
from test_calibrazione import _carica, una_scacchiera_densa
@pytest.fixture(scope="module")
def mappa_densa():
# #91: una mappa vera, calcolata una volta sola per tutti i test del modulo
_carica()
import calibrazione
return calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)[1]
def _dati(**extra):
base = {"px_per_mm": 20.0, "mm_per_px": 0.05, "image_width": 640, "image_height": 480,
"metodo": "planare", "square_mm": 2.0, "calibrated_at": "2026-10-24T10:00:00+00:00",
"origine": "file:s.png"}
return {**base, **extra}
@pytest.fixture
def cal():
_carica()
import calibrazione
return calibrazione
# ---- nomi
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x00b", "a\nb", "a\tb", None, 5])
def test_nome_non_valido(cal, tmp_path, nome):
with pytest.raises(cal.NomeNonValido):
cal.Archivio(tmp_path).salva_nome(nome, _dati())
def test_nome_valido_limiti_e_strip(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("x" * 60, _dati())
meta = arch.salva_nome(" Banco 1 ", _dati())
assert meta["nome"] == "Banco 1" # strip
assert arch.leggi_nome("Banco 1")["nome"] == "Banco 1"
def test_nome_unico_senza_maiuscole(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
with pytest.raises(cal.NomeGiaUsato, match="Banco"):
arch.salva_nome("BANCO", _dati(px_per_mm=2.0))
assert arch.leggi_nome("banco")["px_per_mm"] == 1.0 # non toccata
assert len(arch.elenco()) == 1
def test_sovrascrivi_sostituisce_e_prende_il_nuovo_nome(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
arch.salva_nome("BANCO", _dati(px_per_mm=2.0), sovrascrivi=True)
voci = arch.elenco()
assert len(voci) == 1 and voci[0]["nome"] == "BANCO" and voci[0]["px_per_mm"] == 2.0
assert len(list((tmp_path / "calibrazioni").glob("*.json"))) == 1
def test_salva_non_scrive_source_ne_overlay(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("A", _dati(source="camera:x", overlay_png="abc", saved=True))
scritto = json.loads(next((tmp_path / "calibrazioni").glob("*.json")).read_text(encoding="utf-8"))
assert "source" not in scritto and "overlay_png" not in scritto and "saved" not in scritto
assert scritto["nome"] == "A" and scritto["origine"] == "file:s.png"
def test_leggi_nome_inesistente_o_non_valido(cal, tmp_path):
arch = cal.Archivio(tmp_path)
assert arch.leggi_nome("nessuna") is None and arch.leggi_nome("") is None
# ---- elenco e attive
def test_elenco_dalla_piu_recente_e_attiva_su(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("vecchia", _dati(calibrated_at="2026-01-01T00:00:00+00:00"))
arch.salva_nome("nuova", _dati(calibrated_at="2026-10-01T00:00:00+00:00"))
arch.attiva("camera:b", "nuova")
arch.attiva("camera:a", "nuova")
voci = arch.elenco()
assert [v["nome"] for v in voci] == ["nuova", "vecchia"]
assert voci[0]["attiva_su"] == ["camera:a", "camera:b"] and voci[1]["attiva_su"] == []
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm",
"mm_per_px", "metodo", "square_mm", "calibrated_at", "attiva_su"}
def test_attiva_leggi_scollega(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati())
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
assert arch.attiva("camera:a", "banco") == "Banco" # nome come in libreria
assert arch.attiva_di("camera:a") == "Banco"
letta = arch.leggi("camera:a")
assert letta["nome"] == "Banco" and letta["source"] == "camera:a" and letta["px_per_mm"] == 20.0
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:a": "Banco"}
arch.scollega("camera:a")
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
assert arch.leggi_nome("Banco") is not None # la libreria resta
arch.scollega("camera:a") # idempotente
def test_attiva_nome_sconosciuto(cal, tmp_path):
with pytest.raises(cal.NomeSconosciuto):
cal.Archivio(tmp_path).attiva("camera:a", "nessuna")
def test_due_sorgenti_stessa_calibrazione(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Comune", _dati())
arch.attiva("camera:a", "Comune")
arch.attiva("camera:b", "Comune")
assert arch.leggi("camera:a")["source"] == "camera:a" and arch.leggi("camera:b")["source"] == "camera:b"
def test_elimina_nome_toglie_da_attive_e_mappa(cal, tmp_path, mappa_densa):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
arch.salva_nome("Altra", _dati())
arch.attiva("camera:a", "Densa")
arch.attiva("camera:b", "Densa")
arch.attiva("camera:c", "Altra")
assert arch.leggi_mappa("camera:a") is not None
assert arch.elimina_nome("DENSA") is True
assert arch.leggi_nome("Densa") is None and not list((tmp_path / "calibrazioni").glob("*.npz"))
assert arch.attiva_di("camera:a") is None and arch.attiva_di("camera:b") is None
assert arch.leggi_mappa("camera:a") is None
assert arch.attiva_di("camera:c") == "Altra"
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:c": "Altra"}
assert arch.elimina_nome("Densa") is False # gia' tolta
# ---- mappa
def test_leggi_mappa_risolve_l_attiva_con_cache(cal, tmp_path, mappa_densa):
arch = cal.Archivio(tmp_path)
assert arch.leggi_mappa("camera:a") is None
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
assert arch.leggi_mappa("camera:a") is None # non attiva su camera:a
arch.attiva("camera:a", "Densa")
prima = arch.leggi_mappa("camera:a")
assert prima is not None and arch.leggi_mappa("camera:a") is prima # cache in memoria
assert arch.leggi_mappa("camera:b") is None
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa, sovrascrivi=True) # file riscritto
assert arch.leggi_mappa("camera:a") is not prima # cache invalidata
arch.salva_nome("Densa", _dati(), sovrascrivi=True) # sovrascritta con una planare: la mappa sparisce
assert arch.leggi_mappa("camera:a") is None and not list(tmp_path.rglob("*.npz"))
# ---- migrazione dal formato vecchio
def _vecchia(cartella, cal, source, mappa=None, **extra):
"""Scrive una calibrazione nel formato vecchio (un JSON per sorgente, + .npz) come faceva Archivio.salva."""
dati = {"source": source, **_dati(**extra)}
dati.pop("origine")
nudo = cal.Archivio.__new__(cal.Archivio) # senza costruttore: niente migrazione mentre si prepara
nudo.cartella = cartella
percorso = nudo._file(source)
cartella.mkdir(parents=True, exist_ok=True)
percorso.write_text(json.dumps(dati), encoding="utf-8")
if mappa is not None:
mappa.save(str(percorso.with_suffix(".npz")))
return percorso
def test_migrazione_formato_vecchio(cal, tmp_path, mappa_densa):
v1 = _vecchia(tmp_path, cal, "camera:601020", mappa_densa, metodo="densa", px_per_mm=7.0)
v2 = _vecchia(tmp_path, cal, "camera:folder", px_per_mm=9.0)
(tmp_path / "settaggi").mkdir()
(tmp_path / "settaggi" / v1.name).write_text("{}", encoding="utf-8") # non si tocca
arch = cal.Archivio(tmp_path)
assert not v1.exists() and not v1.with_suffix(".npz").exists() and not v2.exists()
assert {v["nome"] for v in arch.elenco()} == {"camera:601020", "camera:folder"}
assert arch.attiva_di("camera:601020") == "camera:601020" and arch.attiva_di("camera:folder") == "camera:folder"
letta = arch.leggi("camera:601020")
assert letta["px_per_mm"] == 7.0 and letta["source"] == "camera:601020"
assert arch.leggi_mappa("camera:601020") is not None # il .npz ha seguito il JSON
assert len(list((tmp_path / "calibrazioni").glob("*.npz"))) == 1
assert (tmp_path / "settaggi" / v1.name).read_text(encoding="utf-8") == "{}"
def test_migrazione_idempotente(cal, tmp_path):
_vecchia(tmp_path, cal, "camera:a")
cal.Archivio(tmp_path)
prima = sorted(p.name for p in tmp_path.rglob("*") if p.is_file())
arch = cal.Archivio(tmp_path) # secondo avvio
arch.migra()
assert sorted(p.name for p in tmp_path.rglob("*") if p.is_file()) == prima
assert [v["nome"] for v in arch.elenco()] == ["camera:a"]
def test_migrazione_nome_gia_preso(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("camera:a", _dati(px_per_mm=1.0, calibrated_at="2020-01-01T00:00:00+00:00"))
_vecchia(tmp_path, cal, "camera:a", px_per_mm=5.0)
arch.migra()
nomi = {v["nome"]: v["px_per_mm"] for v in arch.elenco()}
assert nomi == {"camera:a": 1.0, "camera:a (2)": 5.0}
assert arch.attiva_di("camera:a") == "camera:a (2)" # la sorgente usa la sua, non quella gia' presente
# un altro file con lo stesso nome -> (3)
_vecchia(tmp_path, cal, "camera:A", px_per_mm=6.0) # nome uguale senza maiuscole
arch.migra()
assert "camera:A (3)" in {v["nome"] for v in arch.elenco()}
def test_migrazione_ignora_json_senza_source_e_attive(cal, tmp_path):
tmp_path.mkdir(exist_ok=True)
(tmp_path / "altro.json").write_text(json.dumps({"x": 1}), encoding="utf-8")
(tmp_path / "rotto.json").write_text("{non json", encoding="utf-8")
arch = cal.Archivio(tmp_path)
arch.salva_nome("A", _dati())
arch.attiva("source", "A") # una sorgente chiamata «source» non deve far scambiare attive.json per un vecchio file
arch.migra()
assert (tmp_path / "altro.json").exists() and (tmp_path / "rotto.json").exists()
assert arch.attiva_di("source") == "A" and [v["nome"] for v in arch.elenco()] == ["A"]
def test_cartella_assente_non_da_errore(cal, tmp_path):
arch = cal.Archivio(tmp_path / "non" / "esiste")
assert arch.elenco() == [] and arch.leggi("s") is None and arch.leggi_mappa("s") is None
arch.scollega("s")
assert arch.elimina_nome("x") is False
+250
View File
@@ -0,0 +1,250 @@
"""Test delle rotte dell'archivio unico di calibrazioni con nome (#91): scripts/camera_service.py.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgenti: camera:folder su cartelle temporanee (520x400 scacchiera piana, 640x480 densa).
Solo tmp_path: niente ProgramData, niente camera vera.
"""
import json
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
CAMPI, CHIAVI_ATTIVA, CHIAVI_DENSA, CHIAVI_PLANARE, SORGENTE, _carica, _png,
carica_su, cartella, cartella_densa, client, client_denso, salva_con_nome,
una_scacchiera, una_scacchiera_densa,
)
__all__ = ["cartella", "cartella_densa", "client", "client_denso"] # le fixture importate restano usate
def _calcola_file(c, **extra):
"""Calcolo planare da un'immagine (520x400): ritorna la risposta JSON."""
r = c.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI, **extra},
files={"image": ("scacco.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
return r.json()
# ---- POST /camera/calibrate
def test_calibrate_senza_source_con_immagine_ok(client):
r = client.post("/camera/calibrate", data=CAMPI,
files={"image": ("C:\\foto\\scacco.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
assert r.json()["origine"] == "file:scacco.png" # solo il nome del file, non il percorso
def test_calibrate_senza_source_e_senza_immagine_400(client):
r = client.post("/camera/calibrate", data=CAMPI)
assert r.status_code == 400 and "source" in r.json()["detail"]
def test_calibrate_da_sorgente_ha_origine_camera_e_id(client):
r = client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI})
assert r.status_code == 200, r.text
corpo = r.json()
assert corpo["origine"] == SORGENTE and corpo["calibrated_at"]
assert len(corpo["calcolo_id"]) == 32 and int(corpo["calcolo_id"], 16) >= 0 # esadecimale
assert corpo["calcolo_id"] != client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI}).json()["calcolo_id"]
# ---- POST /camera/calibrazioni
def test_salva_201_con_metadati(client):
calcolo = _calcola_file(client)
r = salva_con_nome(client, calcolo, " Banco 1 ")
assert r.status_code == 201, r.text
meta = r.json()
assert meta["nome"] == "Banco 1" and meta["origine"] == "file:scacco.png"
assert (meta["image_width"], meta["image_height"]) == (520, 400)
assert meta["metodo"] == "planare" and meta["attiva_su"] == []
assert meta["px_per_mm"] == pytest.approx(20.0, rel=0.005)
# lo stesso calcolo si puo' salvare anche con un altro nome
assert salva_con_nome(client, calcolo, "Banco 2").status_code == 201
def test_salva_calcolo_sconosciuto_404(client):
r = client.post("/camera/calibrazioni", json={"calcolo_id": "0" * 32, "nome": "X"})
assert r.status_code == 404 and r.json()["detail"] == "calcolo non più disponibile: ricalcolare"
def test_salva_calcolo_scaduto_dopo_otto_calcoli_404(client):
primo = _calcola_file(client)
ultimo = primo
for _ in range(8): # il nono calcolo spinge fuori il primo
ultimo = _calcola_file(client)
assert salva_con_nome(client, primo, "Vecchio").status_code == 404
assert salva_con_nome(client, ultimo, "Nuovo").status_code == 201
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x01b", None, 7])
def test_salva_nome_non_valido_400(client, nome):
r = client.post("/camera/calibrazioni", json={"calcolo_id": _calcola_file(client)["calcolo_id"], "nome": nome})
assert r.status_code == 400, r.text
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
def test_salva_corpo_incompleto_400(client):
assert client.post("/camera/calibrazioni", json={"nome": "X"}).status_code == 400
calcolo = _calcola_file(client)
r = client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": "X", "sovrascrivi": "si"})
assert r.status_code == 400
def test_salva_nome_gia_usato_409_e_sovrascrivi(client):
a = _calcola_file(client)
assert salva_con_nome(client, a, "Banco").status_code == 201
r = salva_con_nome(client, a, "BANCO") # senza distinguere maiuscole
assert r.status_code == 409 and r.json()["detail"] == "esiste già una calibrazione «Banco»"
assert len(client.get("/camera/calibrazioni").json()["calibrazioni"]) == 1
r = salva_con_nome(client, a, "BANCO", sovrascrivi=True)
assert r.status_code == 201 and r.json()["nome"] == "BANCO"
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
assert [v["nome"] for v in voci] == ["BANCO"]
# ---- GET /camera/calibrazioni
def test_elenco_vuoto_e_ordine(client):
assert client.get("/camera/calibrazioni").json() == {"calibrazioni": [], "attiva": None}
prima = _calcola_file(client)
salva_con_nome(client, prima, "prima")
seconda = _calcola_file(client)
seconda_ts = seconda["calibrated_at"]
salva_con_nome(client, seconda, "seconda")
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
assert {v["nome"] for v in voci} == {"prima", "seconda"}
assert [v["calibrated_at"] for v in voci] == sorted((v["calibrated_at"] for v in voci), reverse=True)
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm", "mm_per_px",
"metodo", "square_mm", "calibrated_at", "attiva_su"}
assert seconda_ts >= prima["calibrated_at"]
def test_elenco_con_source_dice_l_attiva(client):
salva_con_nome(client, _calcola_file(client), "Banco")
salva_con_nome(client, _calcola_file(client), "Altra")
assert carica_su(client, SORGENTE, "banco").status_code == 200
r = client.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
assert r["attiva"] == "Banco"
attiva_su = {v["nome"]: v["attiva_su"] for v in r["calibrazioni"]}
assert attiva_su == {"Banco": [SORGENTE], "Altra": []}
assert client.get("/camera/calibrazioni", params={"source": "camera:altra"}).json()["attiva"] is None
assert client.get("/camera/calibrazioni", params={"source": " "}).status_code == 400
# ---- DELETE /camera/calibrazioni
def test_elimina_204_404_e_toglie_dalle_sorgenti(client):
salva_con_nome(client, _calcola_file(client), "Banco")
carica_su(client, SORGENTE, "Banco")
assert client.delete("/camera/calibrazioni", params={"nome": "BANCO"}).status_code == 204
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
assert client.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # non piu' attiva
assert json.loads((client.config / "attive.json").read_text(encoding="utf-8")) == {}
assert client.delete("/camera/calibrazioni", params={"nome": "Banco"}).status_code == 404
assert client.delete("/camera/calibrazioni").status_code == 404 # senza nome
# ---- PUT /camera/calibrazione-attiva
def test_carica_risoluzione_uguale_200_come_get(client):
salva_con_nome(client, _calcola_file(client), "Banco") # 520x400 = la scacchiera in cartella
r = carica_su(client, SORGENTE, "banco")
assert r.status_code == 200, r.text
assert set(r.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA
assert r.json()["nome"] == "Banco" and r.json()["source"] == SORGENTE
assert client.get("/camera/calibration", params={"source": SORGENTE}).json() == r.json()
def test_carica_risoluzione_diversa_409_e_anteprima_continua(client_denso):
# calibrazione da file 520x400, sorgente 640x480
salva_con_nome(client_denso, _calcola_file(client_denso), "Piccola")
r = carica_su(client_denso, SORGENTE, "Piccola")
assert r.status_code == 409
assert r.json()["detail"] == ("la calibrazione «Piccola» è per immagini 520×400, "
"la sorgente dà 640×480: non si può caricare")
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # nulla attivato
# l'acquisizione continua: grab e anteprima rispondono, frame non raddrizzato
g = client_denso.post("/camera/grab", json={"source": SORGENTE})
assert g.status_code == 200 and g.headers["x-raddrizzato"] == "no"
a = client_denso.post("/camera/anteprima", json={"source": SORGENTE})
assert a.status_code == 200 and a.headers["x-raddrizzato"] == "no"
assert (a.headers["x-larghezza"], a.headers["x-altezza"]) == ("640", "480")
def test_carica_risoluzione_diversa_non_scalza_l_attiva_precedente(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
salva_con_nome(client_denso, densa, "Giusta")
salva_con_nome(client_denso, _calcola_file(client_denso), "Sbagliata")
assert carica_su(client_denso, SORGENTE, "Giusta").status_code == 200
assert carica_su(client_denso, SORGENTE, "Sbagliata").status_code == 409
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()["nome"] == "Giusta"
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
def test_carica_densa_su_sorgente_stessa_risoluzione_raddrizza(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
r = carica_su(client_denso, SORGENTE, "Densa")
assert r.status_code == 200 and set(r.json()) == CHIAVI_DENSA | CHIAVI_ATTIVA
assert client_denso.post("/camera/anteprima", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
def test_carica_nome_sconosciuto_404(client):
r = carica_su(client, SORGENTE, "nessuna")
assert r.status_code == 404
assert client.put("/camera/calibrazione-attiva", json={"source": SORGENTE}).status_code == 404 # senza nome
assert client.put("/camera/calibrazione-attiva", json={"nome": "x"}).status_code == 400 # senza source
def test_carica_su_sorgente_inesistente_404(client):
salva_con_nome(client, _calcola_file(client), "Banco")
assert carica_su(client, "camera:non-esiste", "Banco").status_code == 404
def test_carica_senza_risoluzione_registrata_409(cartella, tmp_path):
_carica()
import calibrazione
config = tmp_path / "config_senza"
calibrazione.Archivio(config).salva_nome("Senza", {"px_per_mm": 10.0, "calibrated_at": "2026-01-01T00:00:00+00:00"})
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
r = carica_su(c, SORGENTE, "Senza")
assert r.status_code == 409
assert r.json()["detail"] == "la calibrazione «Senza» non ha la risoluzione registrata: non si può caricare"
# ---- contratto verso il backend e migrazione all'avvio
def test_get_calibration_contratto_per_il_backend(client):
# src/backend/services/vision_service.py legge questi campi
salva_con_nome(client, _calcola_file(client), "Banco")
carica_su(client, SORGENTE, "Banco")
dati = client.get("/camera/calibration", params={"source": SORGENTE}).json()
for campo in ("px_per_mm", "mm_per_px", "image_width", "image_height", "calibrated_at", "metodo"):
assert campo in dati
assert client.get("/camera/calibration", params={"source": "camera:altra"}).json() == {
"detail": "sorgente non calibrata"}
def test_avvio_migra_il_formato_vecchio(cartella, tmp_path):
_carica()
import calibrazione
config = tmp_path / "config_vecchia"
config.mkdir()
nudo = calibrazione.Archivio.__new__(calibrazione.Archivio) # per avere il nome file del formato vecchio
nudo.cartella = config
vecchio = nudo._file(SORGENTE)
vecchio.write_text(json.dumps({"source": SORGENTE, "px_per_mm": 20.0, "mm_per_px": 0.05,
"image_width": 520, "image_height": 400, "metodo": "planare",
"calibrated_at": "2026-10-01T10:00:00+00:00"}), encoding="utf-8")
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
dati = c.get("/camera/calibration", params={"source": SORGENTE}).json()
assert dati["nome"] == SORGENTE and dati["source"] == SORGENTE and dati["px_per_mm"] == 20.0
voci = c.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
assert [v["nome"] for v in voci["calibrazioni"]] == [SORGENTE] and voci["attiva"] == SORGENTE
assert voci["calibrazioni"][0]["attiva_su"] == [SORGENTE]
assert not vecchio.exists()