merge #91 servizio: calibrazioni con nome, archivio unico
This commit is contained in:
+249
-34
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user