Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 632fd51050 | |||
| 8eb80e20a8 | |||
| b2f82fe779 | |||
| 3c5c5f9bc5 | |||
| a5e8472e88 | |||
| d6cee498ad | |||
| e7d5489438 | |||
| 828471d27a | |||
| cf64a247be | |||
| 813b66d0c5 | |||
| 8c6d6c1630 | |||
| 1afaaa0bae |
@@ -0,0 +1,53 @@
|
||||
<#
|
||||
Cosa fa: avvia TieMeasureFlow DA SORGENTE su una macchina di sviluppo Windows, senza la build
|
||||
Nuitka (che richiede ~20 minuti): stessi quattro server e stesse porte dell'exe (8780 web,
|
||||
8000 backend, 8100 visione, 8765 camera), stessa cartella dati %PROGRAMDATA%\Tielogic\TieMeasureFlow.
|
||||
Prima dell'avvio: ambiente Python 3.13 in build\venv313 (uv sync, solo con -Sincronizza o se manca),
|
||||
tailwind.css e traduzioni .mo ricompilati, DLL di WeasyPrint in build\gtk (scaricate con
|
||||
pdf\prendi-gtk.ps1 se mancano).
|
||||
|
||||
Come si usa (dalla radice del repo):
|
||||
powershell -ExecutionPolicy Bypass -File packaging\windows\sviluppo.ps1 [-Sincronizza] [-Ascolto 0.0.0.0] [-EscludiCamere OECS]
|
||||
Dopo una modifica: Ctrl+C e rilancio (pochi secondi). Template e JS non richiedono altro;
|
||||
classi Tailwind nuove e msgid nuovi vengono ricompilati a ogni avvio.
|
||||
|
||||
Dipende da: uv, npx (Node), la licenza tiemeasureflow di QUESTA macchina in
|
||||
%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json (impronta: avvio.py --impronta).
|
||||
#>
|
||||
param(
|
||||
[switch]$Sincronizza, # forza uv sync (dipendenze cambiate)
|
||||
[string]$Ascolto = '127.0.0.1', # indirizzo di ascolto della UI web ($Host e' riservato in PowerShell)
|
||||
[string]$EscludiCamere = '' # backend nativi da saltare (TieVision-Core#339): OECS se c'e' il runtime OECS ma in fiera si usa GenTL
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313' # stesso ambiente della build
|
||||
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
|
||||
|
||||
if ($Sincronizza -or -not (Test-Path $py)) {
|
||||
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
|
||||
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
|
||||
}
|
||||
|
||||
# CSS e traduzioni: non sono nel repo, come in build.ps1
|
||||
$flask = Join-Path $radice 'src\frontend\flask_app'
|
||||
Push-Location $flask
|
||||
try {
|
||||
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
|
||||
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') -q compile -d translations
|
||||
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
|
||||
} finally { Pop-Location }
|
||||
|
||||
# DLL di WeasyPrint fuori dalla radice (gtk\ nella radice non e' ignorata da git): build\gtk
|
||||
$gtk = Join-Path $radice 'build\gtk'
|
||||
if (-not (Test-Path (Join-Path $gtk '*.dll'))) {
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1') -Destinazione $gtk
|
||||
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
|
||||
}
|
||||
$env:WEASYPRINT_DLL_DIRECTORIES = $gtk
|
||||
|
||||
if ($EscludiCamere) { $env:VS_CAMERA_ESCLUDI = $EscludiCamere } # #339: stesso percorso camera del PC fiera
|
||||
Set-Location $radice
|
||||
& $py (Join-Path $PSScriptRoot 'avvio.py') --senza-browser --senza-finestre --host $Ascolto # errori in console, niente MessageBox
|
||||
exit $LASTEXITCODE
|
||||
+249
-34
@@ -3,6 +3,9 @@
|
||||
Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con
|
||||
TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente
|
||||
camera in un JSON (scrittura atomica).
|
||||
#91: le calibrazioni stanno in un archivio UNICO, salvate con nome (classe Archivio);
|
||||
una sorgente ne ha una attiva (attive.json) e il formato vecchio (una per sorgente)
|
||||
si migra da solo.
|
||||
#84: `calibra_scacchiera_densa(pixels, square_mm)` calibra da UNA foto di
|
||||
scacchiera che riempie tutto il campo (ottica telecentrica): trova da sola la
|
||||
griglia, costruisce la mappa di distorsione (DistortionMap) e il mm/px
|
||||
@@ -24,6 +27,7 @@ import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import unicodedata # #91 caratteri di controllo nei nomi
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -463,43 +467,87 @@ def ora_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class Archivio:
|
||||
"""Un JSON per sorgente in `cartella`; nome file dalla sorgente ripulito.
|
||||
class NomeNonValido(ValueError):
|
||||
"""#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):
|
||||
self.cartella = Path(cartella)
|
||||
self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta)
|
||||
self._lock = threading.Lock()
|
||||
self._lock_dati = threading.RLock() # #91: libreria e attive.json (leggi-modifica-scrivi)
|
||||
self.migra()
|
||||
|
||||
# ---- percorsi
|
||||
|
||||
def _file(self, sorgente: str) -> Path:
|
||||
# caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano
|
||||
# (#91: oggi serve ai settaggi camera e al formato vecchio da migrare)
|
||||
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", sorgente).strip("._") or "sorgente"
|
||||
sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8]
|
||||
return self.cartella / f"{pulito[:60]}-{sigla}.json"
|
||||
|
||||
def _file_mappa(self, sorgente: str) -> Path:
|
||||
return self._file(sorgente).with_suffix(".npz") # #84 stesso nome del JSON
|
||||
@property
|
||||
def _libreria(self) -> Path:
|
||||
return self.cartella / "calibrazioni"
|
||||
|
||||
def leggi(self, sorgente: str) -> dict | None:
|
||||
try:
|
||||
return json.loads(self._file(sorgente).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None # assente o illeggibile = non calibrata
|
||||
def _file_nome(self, nome: str) -> Path:
|
||||
# #91: stesso schema di _file, ma l'hash e' sul nome senza maiuscole/minuscole
|
||||
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", nome).strip("._") or "calibrazione"
|
||||
sigla = hashlib.sha1(nome.casefold().encode("utf-8")).hexdigest()[:8]
|
||||
return self._libreria / f"{pulito[:60]}-{sigla}.json"
|
||||
|
||||
def salva(self, sorgente: str, dati: dict, mappa: DistortionMap | None = None) -> None:
|
||||
destinazione = self._file(sorgente)
|
||||
self.cartella.mkdir(parents=True, exist_ok=True)
|
||||
# #84: prima la mappa (o la rimozione di una vecchia), poi il JSON che la dichiara
|
||||
if mappa is not None:
|
||||
self._salva_mappa(sorgente, mappa)
|
||||
else:
|
||||
self._togli_mappa(sorgente) # ricalibrazione planare: la vecchia mappa non vale piu'
|
||||
def _file_mappa_nome(self, nome: str) -> Path:
|
||||
return self._file_nome(nome).with_suffix(".npz") # #84 stesso nome del JSON
|
||||
|
||||
@property
|
||||
def _file_attive(self) -> Path:
|
||||
return self.cartella / "attive.json"
|
||||
|
||||
# ---- scrittura atomica
|
||||
|
||||
def _scrivi_json(self, destinazione: Path, dati: dict) -> None:
|
||||
destinazione.parent.mkdir(parents=True, exist_ok=True)
|
||||
# scrittura atomica: file temporaneo nella stessa cartella, poi replace
|
||||
fd, tmp = tempfile.mkstemp(dir=self.cartella, suffix=".tmp")
|
||||
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(dati, f, ensure_ascii=False, indent=2)
|
||||
@@ -511,9 +559,128 @@ class Archivio:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _salva_mappa(self, sorgente: str, mappa: DistortionMap) -> None:
|
||||
destinazione = self._file_mappa(sorgente)
|
||||
fd, tmp = tempfile.mkstemp(dir=self.cartella, suffix=".tmp.npz") # .npz: numpy non aggiunge nulla
|
||||
def _leggi_json(self, percorso: Path) -> dict | None:
|
||||
try:
|
||||
dati = json.loads(percorso.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None # assente o illeggibile
|
||||
return dati if isinstance(dati, dict) else None
|
||||
|
||||
# ---- attive
|
||||
|
||||
def _leggi_attive(self) -> dict:
|
||||
dati = self._leggi_json(self._file_attive) or {}
|
||||
return {k: v for k, v in dati.items() if isinstance(k, str) and isinstance(v, str)}
|
||||
|
||||
def _scrivi_attive(self, attive: dict) -> None:
|
||||
self._scrivi_json(self._file_attive, attive)
|
||||
|
||||
# ---- migrazione dal formato vecchio
|
||||
|
||||
def migra(self) -> None:
|
||||
"""#91: ogni <cartella>/*.json con "source" (una per sorgente) entra in libreria.
|
||||
|
||||
Nome = valore di "source" (gia' preso: "<source> (2)", "(3)"...); il .npz
|
||||
accanto segue; la sorgente resta calibrata con quella voce (attive.json).
|
||||
Idempotente: il vecchio file sparisce, un secondo giro non trova nulla.
|
||||
"""
|
||||
if not self.cartella.is_dir():
|
||||
return
|
||||
with self._lock_dati:
|
||||
for vecchio in sorted(self.cartella.glob("*.json")):
|
||||
if vecchio.name == self._file_attive.name:
|
||||
continue
|
||||
dati = self._leggi_json(vecchio)
|
||||
sorgente = dati.get("source") if dati else None
|
||||
if not isinstance(sorgente, str) or not sorgente.strip():
|
||||
continue
|
||||
try:
|
||||
self._migra_uno(vecchio, dati, sorgente)
|
||||
except OSError:
|
||||
continue # un file bloccato non ferma il servizio: riprova al prossimo avvio
|
||||
|
||||
def _migra_uno(self, vecchio: Path, dati: dict, sorgente: str) -> None:
|
||||
base = "".join(c for c in sorgente if unicodedata.category(c) != "Cc").strip()[:60].strip() or "calibrazione"
|
||||
nome, n = base, 1
|
||||
while True:
|
||||
esistente = self.leggi_nome(nome)
|
||||
if esistente is None:
|
||||
break
|
||||
if (esistente.get("calibrated_at") == dati.get("calibrated_at")
|
||||
and esistente.get("px_per_mm") == dati.get("px_per_mm")):
|
||||
break # gia' copiata da un giro interrotto: si completa e basta
|
||||
n += 1
|
||||
suffisso = f" ({n})"
|
||||
nome = base[:60 - len(suffisso)].rstrip() + suffisso
|
||||
nuovo = {k: v for k, v in dati.items() if k != "source"}
|
||||
nuovo["nome"] = nome
|
||||
nuovo.setdefault("origine", sorgente) # l'origine vera non era registrata
|
||||
vecchia_mappa = vecchio.with_suffix(".npz")
|
||||
if vecchia_mappa.is_file(): # prima la mappa, poi il JSON che la dichiara
|
||||
self._libreria.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(vecchia_mappa, self._file_mappa_nome(nome))
|
||||
self._scrivi_json(self._file_nome(nome), nuovo)
|
||||
attive = self._leggi_attive()
|
||||
attive[sorgente.strip()] = nome
|
||||
self._scrivi_attive(attive)
|
||||
vecchio.unlink()
|
||||
|
||||
# ---- libreria
|
||||
|
||||
def leggi_nome(self, nome: str) -> dict | None:
|
||||
"""#91: la calibrazione con questo nome (senza distinguere maiuscole), o None."""
|
||||
try:
|
||||
nome = pulisci_nome(nome)
|
||||
except NomeNonValido:
|
||||
return None
|
||||
return self._leggi_json(self._file_nome(nome))
|
||||
|
||||
def _meta(self, dati: dict, attive: dict) -> dict:
|
||||
meta = {k: dati.get(k) for k in _CAMPI_ELENCO}
|
||||
chiave = str(dati.get("nome", "")).casefold()
|
||||
meta["attiva_su"] = sorted(s for s, n in attive.items() if n.casefold() == chiave)
|
||||
return meta
|
||||
|
||||
def elenco(self) -> list[dict]:
|
||||
"""#91: metadati di tutte le calibrazioni, dalla piu' recente."""
|
||||
with self._lock_dati:
|
||||
attive = self._leggi_attive()
|
||||
voci = []
|
||||
for percorso in self._libreria.glob("*.json") if self._libreria.is_dir() else []:
|
||||
dati = self._leggi_json(percorso)
|
||||
if dati and isinstance(dati.get("nome"), str):
|
||||
voci.append(self._meta(dati, attive))
|
||||
voci.sort(key=lambda m: str(m.get("calibrated_at") or ""), reverse=True)
|
||||
return voci
|
||||
|
||||
def metadati(self, nome: str) -> dict | None:
|
||||
"""#91: i metadati di una voce (come in `elenco`), o None."""
|
||||
dati = self.leggi_nome(nome)
|
||||
return self._meta(dati, self._leggi_attive()) if dati else None
|
||||
|
||||
def salva_nome(self, nome: str, dati: dict, mappa: DistortionMap | None = None,
|
||||
sovrascrivi: bool = False) -> dict:
|
||||
"""#91: salva `dati` (e la mappa, se densa) con questo nome; ritorna i metadati."""
|
||||
nome = pulisci_nome(nome)
|
||||
destinazione = self._file_nome(nome)
|
||||
with self._lock_dati:
|
||||
if destinazione.exists() and not sovrascrivi:
|
||||
esistente = self._leggi_json(destinazione) or {}
|
||||
raise NomeGiaUsato(f"esiste già una calibrazione «{esistente.get('nome', nome)}»")
|
||||
corpo = {k: v for k, v in dati.items() if k not in ("source", "overlay_png", "saved")}
|
||||
corpo["nome"] = nome
|
||||
self._libreria.mkdir(parents=True, exist_ok=True)
|
||||
# #84: prima la mappa (o la rimozione di una vecchia), poi il JSON che la dichiara
|
||||
if mappa is not None:
|
||||
self._salva_mappa(nome, mappa)
|
||||
else:
|
||||
self._togli_mappa(nome) # voce planare: una vecchia mappa non vale piu'
|
||||
self._scrivi_json(destinazione, corpo)
|
||||
return self._meta(corpo, self._leggi_attive())
|
||||
|
||||
def _salva_mappa(self, nome: str, mappa: DistortionMap) -> None:
|
||||
destinazione = self._file_mappa_nome(nome)
|
||||
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp.npz") # .npz: numpy non aggiunge nulla
|
||||
os.close(fd)
|
||||
try:
|
||||
mappa.save(tmp)
|
||||
@@ -527,8 +694,8 @@ class Archivio:
|
||||
with self._lock:
|
||||
self._cache.pop(str(destinazione), None)
|
||||
|
||||
def _togli_mappa(self, sorgente: str) -> None:
|
||||
destinazione = self._file_mappa(sorgente)
|
||||
def _togli_mappa(self, nome: str) -> None:
|
||||
destinazione = self._file_mappa_nome(nome)
|
||||
try:
|
||||
destinazione.unlink()
|
||||
except FileNotFoundError:
|
||||
@@ -536,9 +703,64 @@ class Archivio:
|
||||
with self._lock:
|
||||
self._cache.pop(str(destinazione), None)
|
||||
|
||||
def elimina_nome(self, nome: str) -> bool:
|
||||
"""#91: toglie la voce (JSON e mappa) e la scollega da ogni sorgente. False se non c'era."""
|
||||
dati = self.leggi_nome(nome)
|
||||
if dati is None:
|
||||
return False
|
||||
canonico = str(dati.get("nome", nome))
|
||||
with self._lock_dati:
|
||||
try:
|
||||
self._file_nome(canonico).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self._togli_mappa(canonico)
|
||||
attive = self._leggi_attive()
|
||||
restanti = {s: n for s, n in attive.items() if n.casefold() != canonico.casefold()}
|
||||
if restanti != attive:
|
||||
self._scrivi_attive(restanti)
|
||||
return True
|
||||
|
||||
# ---- sorgenti
|
||||
|
||||
def attiva(self, sorgente: str, nome: str) -> str:
|
||||
"""#91: la sorgente usa questa calibrazione; ritorna il nome com'e' in libreria."""
|
||||
dati = self.leggi_nome(nome)
|
||||
if dati is None:
|
||||
raise NomeSconosciuto(nome)
|
||||
canonico = str(dati["nome"])
|
||||
with self._lock_dati:
|
||||
attive = self._leggi_attive()
|
||||
attive[sorgente] = canonico
|
||||
self._scrivi_attive(attive)
|
||||
return canonico
|
||||
|
||||
def scollega(self, sorgente: str) -> None:
|
||||
"""#91: la sorgente non ha piu' una calibrazione attiva; la libreria resta."""
|
||||
with self._lock_dati:
|
||||
attive = self._leggi_attive()
|
||||
if sorgente in attive:
|
||||
del attive[sorgente]
|
||||
self._scrivi_attive(attive)
|
||||
|
||||
def attiva_di(self, sorgente: str) -> str | None:
|
||||
"""#91: il nome della calibrazione attiva della sorgente (se esiste ancora)."""
|
||||
nome = self._leggi_attive().get(sorgente)
|
||||
dati = self.leggi_nome(nome) if nome else None
|
||||
return str(dati["nome"]) if dati else None
|
||||
|
||||
def leggi(self, sorgente: str) -> dict | None:
|
||||
"""#91: la calibrazione ATTIVA della sorgente, con "nome" e "source"; None se nessuna."""
|
||||
nome = self.attiva_di(sorgente)
|
||||
dati = self.leggi_nome(nome) if nome else None
|
||||
return {**dati, "source": sorgente} if dati else None
|
||||
|
||||
def leggi_mappa(self, sorgente: str) -> MappaPronta | None:
|
||||
"""La mappa salvata, pronta per `raddrizza`; None se la sorgente non ne ha una."""
|
||||
percorso = self._file_mappa(sorgente)
|
||||
"""La mappa della calibrazione attiva, pronta per `raddrizza`; None se non ne ha una."""
|
||||
nome = self.attiva_di(sorgente) # #91: la mappa e' della voce attiva, non della sorgente
|
||||
if nome is None:
|
||||
return None
|
||||
percorso = self._file_mappa_nome(nome)
|
||||
chiave = str(percorso)
|
||||
try:
|
||||
mtime = percorso.stat().st_mtime_ns
|
||||
@@ -557,10 +779,3 @@ class Archivio:
|
||||
with self._lock:
|
||||
self._cache[chiave] = (mtime, pronta)
|
||||
return pronta
|
||||
|
||||
def elimina(self, sorgente: str) -> None:
|
||||
try:
|
||||
self._file(sorgente).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self._togli_mappa(sorgente) # #84 toglie anche la mappa
|
||||
|
||||
+356
-98
@@ -8,6 +8,10 @@ Parla con le rotte dello stesso servizio: /camera/sources, /camera/anteprima,
|
||||
/camera/parametri, /camera/parametri/salva, /camera/calibration, /camera/calibrate.
|
||||
#84: calibrazione densa, un solo dato dell'operatore (lato dello scacco in mm).
|
||||
#87: anteprima, parametri camera, stile TMF.
|
||||
#91: due tab, «Acquisizione» e «Calibrazione», nella stessa pagina; l'anteprima resta
|
||||
visibile in entrambe. Calibrazioni salvate con nome in un archivio unico
|
||||
(/camera/calibrazioni, /camera/calibrazione-attiva); il calcolo non salva piu':
|
||||
si salva dal calcolo_id con un nome.
|
||||
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
|
||||
"""
|
||||
|
||||
@@ -75,16 +79,45 @@ PAGINA = r"""<!doctype html>
|
||||
.parametro .campo input[type=text] { width: 110px; text-align: right; flex: none; }
|
||||
.parametro .unita { font-size: .82rem; color: var(--secondario); min-width: 2.2em; }
|
||||
.parametro .limiti { font-size: .75rem; color: var(--secondario); }
|
||||
/* #91: tab Acquisizione | Calibrazione */
|
||||
.tabs { display: flex; gap: 6px; margin: 0 0 14px; border-bottom: 1px solid var(--bordo); }
|
||||
.tabs button { background: transparent; color: var(--secondario); border-radius: 6px 6px 0 0; border-bottom: 3px solid transparent;
|
||||
flex: 0 1 auto; padding: 8px 18px; }
|
||||
.tabs button[aria-selected=true] { color: var(--testo); background: var(--pannello); border-bottom-color: var(--accento); font-weight: 600; }
|
||||
/* #91: calibrazione con nome, elenco delle salvate */
|
||||
/* #91: elenco a schede, una per calibrazione; nome e risoluzione sempre visibili, niente scorrimento orizzontale */
|
||||
.elenco { margin-top: 8px; }
|
||||
.scheda-cal { border-top: 1px solid var(--bordo); padding: 8px 0; }
|
||||
.scheda-cal .nome-cal { font-weight: 700; overflow-wrap: anywhere; }
|
||||
.scheda-cal .dettagli { font-size: .8rem; color: var(--secondario); margin-top: 2px; overflow-wrap: anywhere; }
|
||||
.scheda-cal .azioni { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||
.scheda-cal .azioni button { padding: 3px 10px; font-size: .82rem; }
|
||||
.segno { display: inline-block; font-size: .72rem; padding: 0 6px; border-radius: 9px; background: var(--ok); color: #052e16; margin-left: 6px; }
|
||||
.segno.diversa { background: var(--avviso); color: #422006; }
|
||||
.origine label { display: inline-block; margin: 0 14px 4px 0; color: var(--testo); font-size: .92rem; }
|
||||
input[type=radio] { accent-color: var(--accento); }
|
||||
@media (max-width: 900px) {
|
||||
.griglia { grid-template-columns: minmax(0, 1fr); }
|
||||
.cornice img { max-height: 60vh; }
|
||||
}
|
||||
@media (max-width: 500px) { /* #91: utilizzabile anche stretta (400 px) */
|
||||
main { padding: 8px; }
|
||||
.scheda { padding: 10px; }
|
||||
.tabs button { flex: 1 1 0; padding: 8px 6px; }
|
||||
.parametro .campo input[type=text] { width: 88px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Acquisizione camera</h1>
|
||||
|
||||
<!-- #91: due tab; la colonna dell'anteprima resta visibile in entrambe -->
|
||||
<div class="tabs" role="tablist" aria-label="Sezioni della pagina">
|
||||
<button id="tabAcq" type="button" role="tab" aria-selected="true" aria-controls="pannelloAcq">Acquisizione</button>
|
||||
<button id="tabCal" type="button" role="tab" aria-selected="false" aria-controls="pannelloCal" tabindex="-1">Calibrazione</button>
|
||||
</div>
|
||||
|
||||
<div class="griglia">
|
||||
<div class="colonna">
|
||||
<!-- #87: sezione Anteprima -->
|
||||
@@ -132,7 +165,7 @@ PAGINA = r"""<!doctype html>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="colonna">
|
||||
<div class="colonna" id="pannelloAcq" role="tabpanel" aria-labelledby="tabAcq">
|
||||
<!-- #87: sezione Sorgente -->
|
||||
<section class="scheda" id="sezioneSorgente">
|
||||
<h2>Sorgente</h2>
|
||||
@@ -151,43 +184,51 @@ PAGINA = r"""<!doctype html>
|
||||
</div>
|
||||
<div id="msgParametri" class="messaggio"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- #91: tab Calibrazione (colonna destra al posto di Sorgente e Parametri) -->
|
||||
<div class="colonna" id="pannelloCal" role="tabpanel" aria-labelledby="tabCal" hidden>
|
||||
<section class="scheda" id="sezioneCalibrazione">
|
||||
<h2>Calibrazione</h2>
|
||||
<div class="riga">
|
||||
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> (si cambia nella tab Acquisizione)</div>
|
||||
<div class="origine">
|
||||
<label for="origSorgente"><input id="origSorgente" name="origine" type="radio" value="sorgente" checked> Sorgente selezionata</label>
|
||||
<label for="origFile"><input id="origFile" name="origine" type="radio" value="file"> Immagine da file</label>
|
||||
</div>
|
||||
<div class="riga" style="margin-top:4px">
|
||||
<button id="scegliFile" class="secondario">Scegli immagine...</button>
|
||||
<input id="file" type="file" accept="image/*" hidden>
|
||||
<span id="nomeFile" class="nota" style="margin:0 0 6px">nessuna immagine scelta</span>
|
||||
</div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<div><label for="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="text" inputmode="decimal" value="2" size="8"></div>
|
||||
<button id="acquisisci" title="Acquisisci e calibra">Calcola</button>
|
||||
</div>
|
||||
<p class="nota">
|
||||
Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco.
|
||||
La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.
|
||||
Durante il calcolo l'anteprima si sospende.</p>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<button id="acquisisci">Acquisisci e calibra</button>
|
||||
<button id="scegliFile" class="secondario">Scegli immagine...</button>
|
||||
<input id="file" type="file" accept="image/*" hidden>
|
||||
<span id="nomeFile" class="nota" style="margin:0 0 6px">nessuna immagine scelta</span>
|
||||
<button id="carica" class="secondario">Carica immagine e calibra</button>
|
||||
</div>
|
||||
Calcolando dalla sorgente l'anteprima si sospende e poi riprende.</p>
|
||||
<div id="messaggio" class="messaggio"></div>
|
||||
|
||||
<div id="risultato" hidden style="margin-top:10px">
|
||||
<strong>Risultato del calcolo</strong>
|
||||
<div id="origineCalcolo" class="nota" style="margin:2px 0 6px"></div>
|
||||
<table id="tabella"></table>
|
||||
<div id="avviso" class="avviso"></div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<div><label for="nomeCal">Nome</label><input id="nomeCal" type="text" maxlength="60" size="22" autocomplete="off"></div>
|
||||
<button id="salva">Salva</button>
|
||||
<span class="nota" style="margin:0 0 6px">Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.</span>
|
||||
</div>
|
||||
<div id="msgSalva" class="messaggio"></div>
|
||||
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="margin-top:14px">
|
||||
<strong>Calibrazione salvata</strong>
|
||||
<div id="stato" class="stato">-</div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<button id="elimina" class="pericolo" disabled>Elimina calibrazione</button>
|
||||
</div>
|
||||
</div>
|
||||
<section class="scheda" id="sezioneSalvate">
|
||||
<h2>Calibrazioni salvate</h2>
|
||||
<div id="stato" class="stato">-</div>
|
||||
<div id="calibrazioni" class="elenco"></div> <!-- #91: una scheda per calibrazione, niente tabella larga -->
|
||||
<div id="msgElenco" class="messaggio"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,7 +249,8 @@ function fmt(v, cifre) {
|
||||
return n.toLocaleString("it-IT", o);
|
||||
}
|
||||
// numero senza separatore delle migliaia, per le caselle di input
|
||||
function fmtCasella(v) { return Number(v).toLocaleString("it-IT", { maximumFractionDigits: 6, useGrouping: false }); }
|
||||
// #87: cifre = decimali massimi; i parametri camera ne usano 1 (la camera rilegge 11999,993164 per 12000)
|
||||
function fmtCasella(v, cifre) { return Number(v).toLocaleString("it-IT", { maximumFractionDigits: cifre === undefined ? 6 : cifre, useGrouping: false }); }
|
||||
// legge un numero scritto con virgola o con punto
|
||||
function leggiNumero(testo) {
|
||||
var n = Number(String(testo).split(" ").join("").replace(",", "."));
|
||||
@@ -274,6 +316,7 @@ function cambioSorgente(salva) {
|
||||
fermaAnteprima();
|
||||
azzeraIndicatori();
|
||||
dire("msgAnteprima", "");
|
||||
dire("msgElenco", ""); // #91
|
||||
aggiornaStato();
|
||||
caricaParametri();
|
||||
if (era) avviaAnteprima();
|
||||
@@ -281,7 +324,7 @@ function cambioSorgente(salva) {
|
||||
|
||||
// ---- Anteprima (#87)
|
||||
|
||||
var A = { attiva: false, gen: 0, inVolo: false, url: null, nitMax: 0 };
|
||||
var A = { attiva: false, gen: 0, inVolo: false, url: null, nitMax: 0, ris: null }; // #91: ris = {w, h} dell'ultimo frame
|
||||
|
||||
function azzeraIndicatori() {
|
||||
A.nitMax = 0;
|
||||
@@ -289,6 +332,8 @@ function azzeraIndicatori() {
|
||||
$("saturati").className = "valore";
|
||||
$("barraNitidezza").style.width = "0";
|
||||
$("raddrizzato").textContent = " ";
|
||||
A.ris = null; // #91: risoluzione del frame non piu' nota
|
||||
segnaRisoluzioni();
|
||||
}
|
||||
|
||||
function pulsantiAnteprima() {
|
||||
@@ -321,7 +366,12 @@ function aggiornaIndicatori(h) {
|
||||
}
|
||||
if (isFinite(media)) $("media").textContent = fmt(media, 1);
|
||||
var w = h.get("X-Larghezza"), a = h.get("X-Altezza");
|
||||
if (w && a) $("dimensioni").textContent = fmt(w, 0) + " x " + fmt(a, 0);
|
||||
if (w && a) {
|
||||
$("dimensioni").textContent = fmt(w, 0) + " x " + fmt(a, 0);
|
||||
// #91: risoluzione nota del frame, per segnare le calibrazioni che non combaciano
|
||||
var nw = parseInt(w, 10), na = parseInt(a, 10);
|
||||
if (!A.ris || A.ris.w !== nw || A.ris.h !== na) { A.ris = { w: nw, h: na }; segnaRisoluzioni(); }
|
||||
}
|
||||
var r = h.get("X-Raddrizzato");
|
||||
$("raddrizzato").textContent = r === "si" ? "raddrizzata dalla calibrazione" : r === "no" ? "non raddrizzata" : " ";
|
||||
}
|
||||
@@ -357,7 +407,7 @@ async function unaAnteprima(lato) {
|
||||
}
|
||||
}
|
||||
|
||||
// ciclo sequenziale: la richiesta dopo parte solo a risposta arrivata, almeno 300 ms dopo la precedente
|
||||
// ciclo sequenziale: la richiesta dopo parte solo a risposta arrivata, almeno 100 ms dopo la precedente (#90: ~10 fps)
|
||||
async function ciclo(g) {
|
||||
while (A.inVolo) await pausa(50);
|
||||
while (A.attiva && g === A.gen) {
|
||||
@@ -365,7 +415,7 @@ async function ciclo(g) {
|
||||
var esito = await unaAnteprima(1280);
|
||||
if (g !== A.gen) return;
|
||||
if (esito === "fatale") { fermaAnteprima(); return; }
|
||||
await pausa(esito === "errore" ? 1500 : Math.max(300 - (Date.now() - t0), 0));
|
||||
await pausa(esito === "errore" ? 1500 : Math.max(100 - (Date.now() - t0), 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,6 +488,12 @@ async function caricaParametri() {
|
||||
$("salvaParametriRiga").hidden = true;
|
||||
dire("msgParametri", "");
|
||||
if (!sorgente) { $("parametri").textContent = "-"; return; }
|
||||
// #91: subito via il testo della sorgente precedente; la prima apertura della camera puo' durare ~20 s
|
||||
$("parametri").textContent = "";
|
||||
var attesa = document.createElement("div");
|
||||
attesa.className = "nota";
|
||||
attesa.textContent = "Lettura dei parametri...";
|
||||
$("parametri").appendChild(attesa);
|
||||
try {
|
||||
var r = await fetch("/camera/parametri?source=" + encodeURIComponent(sorgente));
|
||||
if (g !== P.gen) return;
|
||||
@@ -504,20 +560,20 @@ function creaParametro(nome, d) {
|
||||
cur.disabled = bloccato;
|
||||
cur.oninput = function () { // il cursore aggiorna la casella subito, la scrittura arriva dopo 300 ms
|
||||
var v = adatta(d, daPosizione(d, Number(cur.value)));
|
||||
casella.value = fmtCasella(v);
|
||||
casella.value = fmtCasella(v, 1);
|
||||
modifica(nome, v);
|
||||
};
|
||||
campo.appendChild(cur);
|
||||
}
|
||||
casella.type = "text";
|
||||
casella.inputMode = "decimal";
|
||||
casella.value = fmtCasella(d.valore);
|
||||
casella.value = fmtCasella(d.valore, 1);
|
||||
casella.disabled = bloccato;
|
||||
casella.onchange = function () {
|
||||
var v = leggiNumero(casella.value);
|
||||
if (isNaN(v)) { dire("msgParametri", "Valore non valido per " + ETICHETTE[nome].toLowerCase() + ".", "errore"); casella.value = fmtCasella(P.dati[nome].valore); return; }
|
||||
if (isNaN(v)) { dire("msgParametri", "Valore non valido per " + ETICHETTE[nome].toLowerCase() + ".", "errore"); casella.value = fmtCasella(P.dati[nome].valore, 1); return; }
|
||||
v = adatta(d, v);
|
||||
casella.value = fmtCasella(v);
|
||||
casella.value = fmtCasella(v, 1);
|
||||
if (cur) cur.value = aPosizione(d, v);
|
||||
modifica(nome, v);
|
||||
};
|
||||
@@ -588,7 +644,7 @@ function aggiornaValori(j) {
|
||||
if (!el || P.pendenti.hasOwnProperty(nome)) return;
|
||||
P.dati[nome].valore = d.valore;
|
||||
if (el.select) el.select.value = d.valore;
|
||||
if (el.casella && document.activeElement !== el.casella) el.casella.value = fmtCasella(d.valore);
|
||||
if (el.casella && document.activeElement !== el.casella) el.casella.value = fmtCasella(d.valore, 1);
|
||||
if (el.cursore && document.activeElement !== el.cursore) el.cursore.value = aPosizione(P.dati[nome], d.valore);
|
||||
});
|
||||
}
|
||||
@@ -610,6 +666,29 @@ $("salvaParametri").onclick = async function () {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Tab (#91): «Acquisizione» | «Calibrazione»; l'anteprima sta fuori dai pannelli e resta sempre visibile
|
||||
|
||||
function scegliTab(nome, salva) {
|
||||
var cal = nome === "cal";
|
||||
$("tabAcq").setAttribute("aria-selected", cal ? "false" : "true");
|
||||
$("tabCal").setAttribute("aria-selected", cal ? "true" : "false");
|
||||
$("tabAcq").tabIndex = cal ? -1 : 0;
|
||||
$("tabCal").tabIndex = cal ? 0 : -1;
|
||||
$("pannelloAcq").hidden = cal;
|
||||
$("pannelloCal").hidden = !cal;
|
||||
if (salva) { memoria("tmf.acq.tab", nome); if (cal) caricaCalibrazioni(); } // l'elenco si rilegge entrando
|
||||
}
|
||||
$("tabAcq").onclick = function () { scegliTab("acq", true); };
|
||||
$("tabCal").onclick = function () { scegliTab("cal", true); };
|
||||
[$("tabAcq"), $("tabCal")].forEach(function (b) { // frecce sinistra/destra fra le tab
|
||||
b.onkeydown = function (e) {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
var cal = b.id === "tabAcq";
|
||||
scegliTab(cal ? "cal" : "acq", true);
|
||||
$(cal ? "tabCal" : "tabAcq").focus();
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Calibrazione (logica di #83 e #84; #87: lato precompilato, data italiana, conferma in pagina)
|
||||
|
||||
function righe(d) {
|
||||
@@ -643,37 +722,6 @@ function tabella(el, d) {
|
||||
});
|
||||
}
|
||||
|
||||
async function aggiornaStato() {
|
||||
var sorgente = $("sorgente").value;
|
||||
var stato = $("stato");
|
||||
annullaConferma();
|
||||
$("elimina").disabled = true;
|
||||
if (!sorgente) { stato.textContent = "-"; $("statoSorgente").textContent = "-"; return; }
|
||||
try {
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
|
||||
if (sorgente !== $("sorgente").value) return; // nel frattempo si e' cambiata sorgente
|
||||
if (r.status === 404) {
|
||||
stato.textContent = "Sorgente non calibrata.";
|
||||
$("statoSorgente").textContent = "non calibrata";
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { stato.textContent = await dettaglio(r); return; }
|
||||
var d = await r.json();
|
||||
stato.textContent = "";
|
||||
var t = document.createElement("table");
|
||||
tabella(t, d);
|
||||
var quando = document.createElement("div");
|
||||
quando.textContent = "Calibrata il " + dataItaliana(d.calibrated_at);
|
||||
stato.appendChild(t);
|
||||
stato.appendChild(quando);
|
||||
$("elimina").disabled = false;
|
||||
$("statoSorgente").textContent = "calibrata: " + fmt(d.px_per_mm, 2) + " px/mm";
|
||||
if (d.square_mm) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato
|
||||
} catch (e) {
|
||||
stato.textContent = "Servizio non raggiungibile.";
|
||||
}
|
||||
}
|
||||
|
||||
// avvisi sulla qualita' (solo calibrazione densa): copertura bassa o residuo alto
|
||||
function avvisi(d) {
|
||||
var a = [];
|
||||
@@ -686,78 +734,288 @@ function avvisi(d) {
|
||||
return a.join(" ");
|
||||
}
|
||||
|
||||
async function calcola(conFile, salva) {
|
||||
// ---- Calibrazioni con nome (#91): archivio unico, una attiva per sorgente
|
||||
|
||||
var C = { elenco: [], attiva: null, celle: [], gen: 0, confermaNome: null, confermaBtn: null, confermaTimer: null };
|
||||
var S = { nome: null, timer: null }; // secondo clic su «Sovrascrivi» entro 4 s
|
||||
var JSON_H = { "Content-Type": "application/json" };
|
||||
|
||||
function descriviOrigine(o) { return String(o || "-").replace(":", " "); }
|
||||
|
||||
// stato della calibrazione attiva sulla sorgente selezionata (come prima di #91, piu' il nome)
|
||||
async function aggiornaStato() {
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente) { messaggio("Scegliere una sorgente.", "errore"); return; }
|
||||
var stato = $("stato");
|
||||
annullaConferma();
|
||||
$("calSorgente").textContent = sorgente ? $("sorgente").options[$("sorgente").selectedIndex].textContent : "-"; // #91
|
||||
if (!sorgente) { stato.textContent = "-"; $("statoSorgente").textContent = "-"; caricaCalibrazioni(); return; }
|
||||
try {
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
|
||||
if (sorgente !== $("sorgente").value) return; // nel frattempo si e' cambiata sorgente
|
||||
if (r.status === 404) {
|
||||
stato.textContent = "Sorgente non calibrata.";
|
||||
$("statoSorgente").textContent = "non calibrata";
|
||||
} else if (!r.ok) {
|
||||
stato.textContent = await dettaglio(r);
|
||||
} else {
|
||||
var d = await r.json();
|
||||
stato.textContent = "";
|
||||
var titolo = document.createElement("div");
|
||||
titolo.textContent = "Calibrazione attiva: " + (d.nome ? "«" + d.nome + "»" : "(senza nome)");
|
||||
titolo.style.fontWeight = "600";
|
||||
var t = document.createElement("table");
|
||||
tabella(t, d);
|
||||
var quando = document.createElement("div");
|
||||
quando.textContent = "Calibrata il " + dataItaliana(d.calibrated_at);
|
||||
stato.appendChild(titolo);
|
||||
stato.appendChild(t);
|
||||
stato.appendChild(quando);
|
||||
$("statoSorgente").textContent = "calibrata: " + fmt(d.px_per_mm, 2) + " px/mm";
|
||||
if (d.square_mm) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato
|
||||
}
|
||||
} catch (e) {
|
||||
stato.textContent = "Servizio non raggiungibile.";
|
||||
}
|
||||
caricaCalibrazioni();
|
||||
}
|
||||
|
||||
// #91: elenco unico delle calibrazioni salvate; "attiva" e' quella della sorgente selezionata
|
||||
async function caricaCalibrazioni() {
|
||||
var sorgente = $("sorgente").value;
|
||||
var g = ++C.gen;
|
||||
try {
|
||||
var r = await fetch("/camera/calibrazioni" + (sorgente ? "?source=" + encodeURIComponent(sorgente) : ""));
|
||||
if (g !== C.gen) return;
|
||||
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; }
|
||||
var j = await r.json();
|
||||
if (g !== C.gen) return;
|
||||
C.elenco = j.calibrazioni || [];
|
||||
C.attiva = j.attiva || null;
|
||||
disegnaElenco();
|
||||
} catch (e) {
|
||||
if (g === C.gen) dire("msgElenco", "Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
}
|
||||
|
||||
function bottone(testo, classe, fn) {
|
||||
var b = document.createElement("button");
|
||||
b.textContent = testo;
|
||||
b.className = classe || "";
|
||||
b.onclick = fn;
|
||||
return b;
|
||||
}
|
||||
|
||||
function disegnaElenco() {
|
||||
var t = $("calibrazioni");
|
||||
annullaConferma();
|
||||
t.textContent = "";
|
||||
C.celle = [];
|
||||
if (!C.elenco.length) {
|
||||
var vuota = document.createElement("div");
|
||||
vuota.className = "nota";
|
||||
vuota.textContent = "Nessuna calibrazione salvata.";
|
||||
t.appendChild(vuota);
|
||||
return;
|
||||
}
|
||||
C.elenco.forEach(function (c) {
|
||||
var sc = document.createElement("div"); // #91: scheda della calibrazione
|
||||
sc.className = "scheda-cal";
|
||||
var attiva = C.attiva !== null && c.nome === C.attiva;
|
||||
var riga1 = document.createElement("div");
|
||||
riga1.className = "nome-cal";
|
||||
riga1.appendChild(document.createTextNode(c.nome));
|
||||
if (attiva) {
|
||||
var s1 = document.createElement("span");
|
||||
s1.className = "segno";
|
||||
s1.textContent = "attiva";
|
||||
riga1.appendChild(s1);
|
||||
}
|
||||
var s2 = document.createElement("span"); // «risoluzione diversa» dal frame della sorgente (si accende in segnaRisoluzioni)
|
||||
s2.className = "segno diversa";
|
||||
s2.textContent = "risoluzione diversa";
|
||||
s2.hidden = true;
|
||||
riga1.appendChild(s2);
|
||||
C.celle.push({ w: Number(c.image_width), h: Number(c.image_height), span: s2 });
|
||||
var riga2 = document.createElement("div");
|
||||
riga2.className = "dettagli";
|
||||
riga2.textContent = [
|
||||
c.image_width && c.image_height ? fmt(c.image_width, 0) + "×" + fmt(c.image_height, 0) : "-",
|
||||
fmt(c.px_per_mm, 3) + " px/mm",
|
||||
c.metodo === "densa" ? "densa" : "planare",
|
||||
c.calibrated_at ? dataItaliana(c.calibrated_at) : "-",
|
||||
descriviOrigine(c.origine)
|
||||
].join(" · ");
|
||||
var az = document.createElement("div");
|
||||
az.className = "azioni";
|
||||
var bc = bottone("Carica", "", function () { caricaNome(c.nome); });
|
||||
bc.disabled = !$("sorgente").value;
|
||||
az.appendChild(bc);
|
||||
if (attiva) az.appendChild(bottone("Scollega", "secondario", scollega));
|
||||
az.appendChild(bottone("Elimina", "secondario", function (e) { eliminaNome(c.nome, e.currentTarget); }));
|
||||
sc.appendChild(riga1);
|
||||
sc.appendChild(riga2);
|
||||
sc.appendChild(az);
|
||||
t.appendChild(sc);
|
||||
});
|
||||
segnaRisoluzioni();
|
||||
}
|
||||
|
||||
// #91: «risoluzione diversa» se il frame noto della sorgente non combacia con quello della calibrazione
|
||||
function segnaRisoluzioni() {
|
||||
(C.celle || []).forEach(function (c) {
|
||||
c.span.hidden = !(A.ris && c.w > 0 && c.h > 0 && (A.ris.w !== c.w || A.ris.h !== c.h));
|
||||
});
|
||||
}
|
||||
|
||||
// «Carica»: la calibrazione diventa quella attiva della sorgente. Un errore (es. risoluzione diversa) non ferma l'anteprima.
|
||||
async function caricaNome(nome) {
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente) { dire("msgElenco", "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
|
||||
dire("msgElenco", "Caricamento di «" + nome + "»...", "");
|
||||
try {
|
||||
var r = await fetch("/camera/calibrazione-attiva", { method: "PUT", headers: JSON_H, body: JSON.stringify({ source: sorgente, nome: nome }) });
|
||||
if (sorgente !== $("sorgente").value) return;
|
||||
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; }
|
||||
dire("msgElenco", "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
|
||||
$("raddrizzato").textContent = $("grezzo").checked ? "non raddrizzata" : "raddrizzata dalla calibrazione"; // il prossimo frame conferma
|
||||
aggiornaStato();
|
||||
} catch (e) {
|
||||
dire("msgElenco", "Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
}
|
||||
|
||||
// «Scollega»: la sorgente torna senza calibrazione, la libreria resta
|
||||
async function scollega() {
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente) return;
|
||||
try {
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
|
||||
dire("msgElenco", r.ok ? "Calibrazione scollegata dalla sorgente (resta fra le salvate)." : await dettaglio(r), r.ok ? "ok" : "errore");
|
||||
if (r.ok) $("raddrizzato").textContent = "non raddrizzata";
|
||||
} catch (e) {
|
||||
dire("msgElenco", "Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
aggiornaStato();
|
||||
}
|
||||
|
||||
// «Elimina» di una riga: conferma in pagina con un secondo clic entro 4 s (niente finestre del browser)
|
||||
function annullaConferma() {
|
||||
clearTimeout(C.confermaTimer);
|
||||
C.confermaTimer = null;
|
||||
if (C.confermaBtn) C.confermaBtn.textContent = "Elimina";
|
||||
C.confermaBtn = null;
|
||||
C.confermaNome = null;
|
||||
}
|
||||
async function eliminaNome(nome, btn) {
|
||||
if (C.confermaNome !== nome) {
|
||||
annullaConferma();
|
||||
C.confermaNome = nome;
|
||||
C.confermaBtn = btn;
|
||||
btn.textContent = "Confermi? Clic ancora";
|
||||
C.confermaTimer = setTimeout(annullaConferma, 4000);
|
||||
return;
|
||||
}
|
||||
annullaConferma();
|
||||
try {
|
||||
var r = await fetch("/camera/calibrazioni?nome=" + encodeURIComponent(nome), { method: "DELETE" });
|
||||
dire("msgElenco", r.ok ? "Calibrazione «" + nome + "» eliminata." : await dettaglio(r), r.ok ? "ok" : "errore");
|
||||
} catch (e) {
|
||||
dire("msgElenco", "Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
aggiornaStato();
|
||||
}
|
||||
|
||||
// #91: il calcolo non salva mai; il servizio tiene il risultato e ne da' il calcolo_id per «Salva»
|
||||
async function calcola() {
|
||||
var daFile = $("origFile").checked;
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!daFile && !sorgente) { messaggio("Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
|
||||
var lato = leggiNumero($("quadrato").value);
|
||||
if (!(lato > 0)) { messaggio("Il lato dello scacco deve essere un numero maggiore di zero.", "errore"); return; }
|
||||
var f = new FormData();
|
||||
f.append("source", sorgente);
|
||||
f.append("square_mm", String(lato)); // al servizio sempre con il punto
|
||||
f.append("save", salva ? "true" : "false");
|
||||
if (conFile) {
|
||||
if (daFile) {
|
||||
var file = $("file").files[0];
|
||||
if (!file) { messaggio("Scegliere un file immagine.", "errore"); return; }
|
||||
f.append("image", file);
|
||||
} else {
|
||||
f.append("source", sorgente);
|
||||
}
|
||||
var eraAttiva = A.attiva; // #87: l'anteprima si sospende e poi riprende
|
||||
var eraAttiva = !daFile && A.attiva; // #87: da sorgente l'anteprima si sospende e poi riprende
|
||||
if (eraAttiva) fermaAnteprima();
|
||||
messaggio("Calcolo in corso...", "");
|
||||
["acquisisci", "carica", "salva"].forEach(function (i) { $(i).disabled = true; });
|
||||
["acquisisci", "salva"].forEach(function (i) { $(i).disabled = true; });
|
||||
try {
|
||||
var r = await fetch("/camera/calibrate", { method: "POST", body: f });
|
||||
if (!r.ok) { messaggio(await dettaglio(r), "errore"); return; }
|
||||
var d = await r.json();
|
||||
ultimo = { conFile: conFile, dati: d };
|
||||
ultimo = { dati: d };
|
||||
tabella($("tabella"), d);
|
||||
$("overlay").src = "data:image/png;base64," + d.overlay_png;
|
||||
$("avviso").textContent = avvisi(d);
|
||||
$("origineCalcolo").textContent = "Origine: " + descriviOrigine(d.origine);
|
||||
$("risultato").hidden = false;
|
||||
messaggio(d.saved ? "Calibrazione salvata." : "Calibrazione riuscita: controllare e salvare.", "ok");
|
||||
if (d.saved) aggiornaStato();
|
||||
annullaSovrascrivi();
|
||||
dire("msgSalva", "");
|
||||
messaggio("Calcolo riuscito: dare un nome e salvare.", "ok");
|
||||
} catch (e) {
|
||||
messaggio("Servizio non raggiungibile.", "errore");
|
||||
} finally {
|
||||
["acquisisci", "carica", "salva"].forEach(function (i) { $(i).disabled = false; });
|
||||
$("acquisisci").disabled = false;
|
||||
$("salva").disabled = false;
|
||||
if (eraAttiva) avviaAnteprima();
|
||||
}
|
||||
}
|
||||
|
||||
$("acquisisci").onclick = function () { calcola(false, false); };
|
||||
$("carica").onclick = function () { calcola(true, false); };
|
||||
// «Salva» rifa il calcolo con save=true sulla stessa origine (altra acquisizione se da camera)
|
||||
$("salva").onclick = function () { calcola(ultimo ? ultimo.conFile : false, true); };
|
||||
// «Salva» con nome; se il nome c'e' gia' il pulsante chiede un secondo clic per sovrascrivere
|
||||
function annullaSovrascrivi() {
|
||||
clearTimeout(S.timer);
|
||||
S.timer = null;
|
||||
S.nome = null;
|
||||
$("salva").textContent = "Salva";
|
||||
}
|
||||
async function salvaNome() {
|
||||
if (!ultimo || !ultimo.dati.calcolo_id) { dire("msgSalva", "Nessun calcolo da salvare: calcolare prima.", "errore"); return; }
|
||||
var nome = $("nomeCal").value.trim();
|
||||
if (!nome) { dire("msgSalva", "Scrivere un nome per la calibrazione.", "errore"); return; }
|
||||
if (nome.length > 60) { dire("msgSalva", "Il nome può avere al massimo 60 caratteri.", "errore"); return; }
|
||||
var sovrascrivi = S.timer !== null && S.nome === nome.toLowerCase(); // secondo clic sullo stesso nome
|
||||
annullaSovrascrivi();
|
||||
$("salva").disabled = true;
|
||||
try {
|
||||
var r = await fetch("/camera/calibrazioni", {
|
||||
method: "POST", headers: JSON_H,
|
||||
body: JSON.stringify({ calcolo_id: ultimo.dati.calcolo_id, nome: nome, sovrascrivi: sovrascrivi })
|
||||
});
|
||||
if (r.status === 409) {
|
||||
S.nome = nome.toLowerCase();
|
||||
$("salva").textContent = "Sovrascrivi «" + nome + "»? Clic ancora";
|
||||
S.timer = setTimeout(annullaSovrascrivi, 4000);
|
||||
dire("msgSalva", await dettaglio(r), "errore");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { dire("msgSalva", await dettaglio(r), "errore"); return; }
|
||||
dire("msgSalva", "Calibrazione «" + nome + "» salvata. Per usarla su una sorgente: «Carica» nell'elenco.", "ok");
|
||||
caricaCalibrazioni();
|
||||
} catch (e) {
|
||||
dire("msgSalva", "Servizio non raggiungibile.", "errore");
|
||||
} finally {
|
||||
$("salva").disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
$("acquisisci").onclick = calcola;
|
||||
$("salva").onclick = salvaNome;
|
||||
$("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); };
|
||||
// #87: il pulsante del file e' in italiano, l'input nativo resta nascosto
|
||||
$("scegliFile").onclick = function () { $("file").click(); };
|
||||
$("file").onchange = function () {
|
||||
$("nomeFile").textContent = $("file").files[0] ? $("file").files[0].name : "nessuna immagine scelta";
|
||||
};
|
||||
|
||||
// #87: «Elimina calibrazione» chiede conferma in pagina con un secondo clic (niente finestre del browser)
|
||||
var confermaTimer = null;
|
||||
function annullaConferma() {
|
||||
clearTimeout(confermaTimer);
|
||||
confermaTimer = null;
|
||||
$("elimina").textContent = "Elimina calibrazione";
|
||||
}
|
||||
$("elimina").onclick = async function () {
|
||||
if (!confermaTimer) {
|
||||
$("elimina").textContent = "Confermi? Clic ancora per eliminare";
|
||||
confermaTimer = setTimeout(annullaConferma, 4000);
|
||||
return;
|
||||
}
|
||||
annullaConferma();
|
||||
var sorgente = $("sorgente").value;
|
||||
try {
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
|
||||
messaggio(r.ok ? "Calibrazione eliminata." : await dettaglio(r), r.ok ? "ok" : "errore");
|
||||
} catch (e) {
|
||||
messaggio("Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
aggiornaStato();
|
||||
if ($("file").files[0]) $("origFile").checked = true; // #91: scelto un file, l'origine diventa il file
|
||||
};
|
||||
|
||||
$("sorgente").onchange = function () { cambioSorgente(true); };
|
||||
scegliTab(memoria("tmf.acq.tab") === "cal" ? "cal" : "acq", false); // #91: tab ricordata
|
||||
caricaSorgenti();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+220
-30
@@ -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'è.
|
||||
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||
|
||||
Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py):
|
||||
GET /camera/calibration?source=<src> -> 200 {source, px_per_mm, mm_per_px,
|
||||
rms_px, n_corners, cols, rows, square_mm, image_width, image_height,
|
||||
scale_spread_pct, calibrated_at} | 404 "sorgente non calibrata"
|
||||
POST /camera/calibrate multipart: source, square_mm, image (file, opzionale:
|
||||
senza, grab GREZZO dalla sorgente), save=false, cols/rows (opzionali).
|
||||
Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
|
||||
#91: archivio UNICO di calibrazioni con nome; una sorgente ne ha una attiva:
|
||||
POST /camera/calibrate multipart: source (obbligatorio solo senza image),
|
||||
square_mm, image (file, opzionale: senza, grab GREZZO dalla sorgente),
|
||||
cols/rows (opzionali). NON salva mai (il vecchio campo save e' ignorato).
|
||||
#84: SENZA cols/rows = calibrazione densa (default): una foto 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
|
||||
dell'immagine RADDRIZZATA. CON cols/rows (angoli interni) = metodo
|
||||
planare di prima. Campi: quelli di C1 + overlay_png (b64) + saved +
|
||||
metodo ("densa"|"planare"), distorsione (bool) e, per la densa,
|
||||
residuo_rms_px, residuo_max_px, copertura_pct;
|
||||
distorsione; mm/px e px/mm sono quelli dell'immagine RADDRIZZATA. CON
|
||||
cols/rows (angoli interni) = metodo planare di prima. Risposta: i campi
|
||||
del calcolo + overlay_png (b64), calibrated_at, origine ("camera:<src>" |
|
||||
"file:<nome>"), calcolo_id (il servizio tiene in memoria gli ultimi 8
|
||||
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
|
||||
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 /acquisizione -> stessa pagina (#87)
|
||||
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}
|
||||
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
|
||||
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).
|
||||
|
||||
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
|
||||
@@ -54,6 +68,9 @@ Uso (dalla radice del repo):
|
||||
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>] \
|
||||
[--config-dir <cartella calibrazioni>]
|
||||
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
||||
Immagini caricate (#92): POST/DELETE /camera/immagini, in <config-dir>/immagini; sono sorgenti
|
||||
a pieno titolo (/camera/sources, grab, anteprima, calibrate). POST /camera/anteprima/calibrata
|
||||
raddrizza l'ultimo frame grezzo dell'anteprima con la calibrazione attiva, senza leggerne uno nuovo.
|
||||
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
||||
|
||||
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
||||
@@ -68,8 +85,12 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re # #92: nome sicuro per le immagini caricate
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
|
||||
import uuid # #91 id dei calcoli in attesa di nome
|
||||
from collections import OrderedDict
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
@@ -80,7 +101,7 @@ from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuor
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
|
||||
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
||||
from visionsuite.camera.sources import _ESTENSIONI, SourceNotFound, list_sources, open_source # #92: _ESTENSIONI = tipi ammessi per le immagini caricate
|
||||
|
||||
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
@@ -88,6 +109,7 @@ import calibrazione # noqa: E402
|
||||
from calibrazione_pagina import PAGINA # noqa: E402
|
||||
|
||||
_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)
|
||||
# #87: i quattro parametri regolabili, nell'ordine di presentazione
|
||||
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
|
||||
@@ -131,8 +153,12 @@ def create_app(folder: Path | str | None = None,
|
||||
archivio = calibrazione.Archivio(config_dir if config_dir is not None
|
||||
else _config_dir_default()) # #83
|
||||
aperte: dict = {}
|
||||
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
|
||||
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
|
||||
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||
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:
|
||||
if folder is None:
|
||||
@@ -160,15 +186,75 @@ def create_app(folder: Path | str | None = None,
|
||||
@app.get("/camera/sources")
|
||||
def camera_sources():
|
||||
voci = list_sources(None, cameras=True, config=_config())
|
||||
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual")]}
|
||||
# #92: camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
|
||||
elenco = [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
|
||||
# #92: in coda le immagini caricate, che sono sorgenti a pieno titolo
|
||||
for v in list_sources(cartella_immagini, cameras=False):
|
||||
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
|
||||
return {"sources": elenco}
|
||||
|
||||
def _chiudi(nome: str) -> None:
|
||||
"""#92: chiude la sorgente aperta e scorda il frame tenuto (immagine sostituita o tolta)."""
|
||||
with _lock_di(nome):
|
||||
with lock:
|
||||
s = aperte.pop(nome, None)
|
||||
grezzi.pop(nome, None)
|
||||
if s is not None:
|
||||
try:
|
||||
s.close()
|
||||
except Exception: # noqa: BLE001 - la chiusura non deve far fallire la rotta
|
||||
pass
|
||||
|
||||
def _nome_immagine(grezzo: str) -> str:
|
||||
"""#92: solo il nome del file, coi caratteri sicuri di Archivio._file; "" se non ammesso."""
|
||||
base = Path((grezzo or "").replace("\\", "/")).name
|
||||
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._")
|
||||
radice, punto, estensione = pulito.rpartition(".")
|
||||
if not punto or not radice or f".{estensione.lower()}" not in _ESTENSIONI:
|
||||
return ""
|
||||
return f"{radice[:100]}.{estensione}"
|
||||
|
||||
@app.post("/camera/immagini", status_code=201)
|
||||
async def immagini_carica(image: UploadFile | None = File(None)):
|
||||
if image is None:
|
||||
raise HTTPException(400, "manca il file «image»")
|
||||
nome = _nome_immagine(image.filename or "")
|
||||
if not nome:
|
||||
raise HTTPException(400, "estensione non ammessa: " + ", ".join(_ESTENSIONI))
|
||||
contenuto = await image.read()
|
||||
# #92: 422 se non e' un'immagine; la verifica sta PRIMA della scrittura, cosi' non resta nulla su disco
|
||||
if not contenuto or cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) is None:
|
||||
raise HTTPException(422, "il file caricato non è leggibile come immagine")
|
||||
cartella_immagini.mkdir(parents=True, exist_ok=True)
|
||||
(cartella_immagini / nome).write_bytes(contenuto) # stesso nome = sostituisce
|
||||
await run_in_threadpool(_chiudi, nome) # se era aperta, si riapre sul file nuovo
|
||||
return {"source": nome, "label": f"immagine — {nome}", "kind": "image"}
|
||||
|
||||
@app.delete("/camera/immagini", status_code=204)
|
||||
def immagini_elimina(nome: str = ""):
|
||||
percorso = cartella_immagini / nome
|
||||
# #92: solo il nome di un file dentro la cartella, niente percorsi
|
||||
if not nome or Path(nome).name != nome or not percorso.is_file():
|
||||
raise HTTPException(404, "immagine non trovata")
|
||||
_chiudi(nome)
|
||||
percorso.unlink()
|
||||
return Response(status_code=204)
|
||||
|
||||
def _apri(nome: str):
|
||||
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, None, config=_config())
|
||||
sorgente = open_source(nome, cartella_immagini, config=_config()) # #92: un nome di file = immagine caricata
|
||||
if hasattr(sorgente, "loop"): # #92: l'immagine singola non deve "finire" al secondo grab (anteprima continua)
|
||||
sorgente.loop = True
|
||||
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
|
||||
if hasattr(sorgente, "solo_ultimo_frame"):
|
||||
try:
|
||||
sorgente.solo_ultimo_frame()
|
||||
except Exception as e: # noqa: BLE001 - producer senza il nodo: si apre lo stesso
|
||||
_log.warning("frame piu' recente non impostabile su %s: %s", nome, e)
|
||||
_carica_settaggi(nome, sorgente) # #87: predefiniti salvati, se ci sono
|
||||
with lock:
|
||||
aperte[nome] = sorgente
|
||||
@@ -220,7 +306,7 @@ def create_app(folder: Path | str | None = None,
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
|
||||
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)
|
||||
if mappa is not None:
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
@@ -241,33 +327,35 @@ def create_app(folder: Path | str | None = None,
|
||||
|
||||
@app.get("/camera/calibration")
|
||||
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:
|
||||
raise HTTPException(404, "sorgente non calibrata")
|
||||
return dati
|
||||
|
||||
@app.delete("/camera/calibration", status_code=204)
|
||||
def calibrazione_elimina(source: str = ""):
|
||||
archivio.elimina(_sorgente(source))
|
||||
archivio.scollega(_sorgente(source)) # #91: scollega soltanto, la libreria resta
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.post("/camera/calibrate")
|
||||
async def calibrazione_calcola(
|
||||
source: str = Form(...),
|
||||
source: str | None = Form(None), # #91: serve solo se manca l'immagine
|
||||
square_mm: float = Form(...),
|
||||
cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa
|
||||
rows: int | None = Form(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""
|
||||
if contenuto:
|
||||
frame = cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
if frame is None:
|
||||
raise HTTPException(422, "l'immagine caricata non è leggibile")
|
||||
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine")
|
||||
else:
|
||||
nome = _sorgente(source)
|
||||
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
|
||||
origine = nome
|
||||
if (cols is None) != (rows is None):
|
||||
raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)")
|
||||
mappa = None
|
||||
@@ -279,12 +367,71 @@ def create_app(folder: Path | str | None = None,
|
||||
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
|
||||
except calibrazione.CalibrazioneError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
corpo = {"source": nome,
|
||||
**{k: v for k, v in risultato.items() if k != "overlay_png"},
|
||||
"calibrated_at": calibrazione.ora_iso()}
|
||||
if save:
|
||||
archivio.salva(nome, corpo, mappa) # #84: la mappa (se densa) va in .npz accanto
|
||||
return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)}
|
||||
corpo = {**{k: v for k, v in risultato.items() if k != "overlay_png"},
|
||||
"calibrated_at": calibrazione.ora_iso(), "origine": origine}
|
||||
calcolo_id = uuid.uuid4().hex
|
||||
with lock_calcoli: # #91: il calcolo resta in memoria finche' non gli si da' un nome
|
||||
calcoli[calcolo_id] = (corpo, mappa)
|
||||
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)
|
||||
|
||||
@@ -328,7 +475,12 @@ def create_app(folder: Path | str | None = None,
|
||||
timeout = corpo.get("timeout_ms", 5000)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
frame = _grab(nome, timeout)
|
||||
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
|
||||
f = s.grab(timeout)
|
||||
with lock:
|
||||
grezzi[nome] = f.copy()
|
||||
return f
|
||||
frame = _con_sorgente(nome, leggi)
|
||||
raddrizzato = False
|
||||
if not grezzo: # come /camera/grab
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
@@ -356,6 +508,44 @@ def create_app(folder: Path | str | None = None,
|
||||
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
|
||||
"X-Media": f"{media:.1f}"})
|
||||
|
||||
@app.post("/camera/anteprima/calibrata")
|
||||
def camera_anteprima_calibrata(corpo: dict = Body(default=None)):
|
||||
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo."""
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
lato = corpo.get("lato_max", 1280)
|
||||
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
|
||||
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
|
||||
with lock:
|
||||
frame = grezzi.get(nome)
|
||||
if frame is None:
|
||||
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
|
||||
attiva = archivio.attiva_di(nome)
|
||||
if attiva is None:
|
||||
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is None: # planare: ha solo scala, nessuna mappa da applicare
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» non ha una mappa di correzione (planare)")
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is None: # risoluzione diversa da quella calibrata
|
||||
ch, cw = mappa.shape[:2]
|
||||
alt, larg = frame.shape[:2]
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||
f"il frame è {larg}×{alt}")
|
||||
alt, larg = corretto.shape[:2]
|
||||
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
|
||||
corretto = _grigio8(corretto)
|
||||
scala = lato / max(alt, larg)
|
||||
if scala < 1.0: # mai ingrandita
|
||||
corretto = cv2.resize(corretto, (max(1, round(larg * scala)), max(1, round(alt * scala))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
ok, buf = cv2.imencode(".jpg", corretto, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica JPEG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/jpeg", headers={
|
||||
"X-Raddrizzato": "si", "X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
|
||||
|
||||
@app.get("/camera/parametri")
|
||||
def parametri_leggi(source: str = ""):
|
||||
nome = _sorgente(source)
|
||||
|
||||
@@ -82,12 +82,24 @@ def client(cartella, tmp_path):
|
||||
|
||||
|
||||
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"}
|
||||
# #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"}
|
||||
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
|
||||
@@ -157,34 +169,49 @@ def test_c2_immagine_caricata_senza_salvare(client):
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
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["saved"] is False
|
||||
assert corpo["origine"] == "file:s.png"
|
||||
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 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):
|
||||
# nessun file: grab da camera:folder (la scacchiera in cartella), con save
|
||||
r = client.post("/camera/calibrate",
|
||||
data={"source": "camera:folder", "save": "true", **CAMPI})
|
||||
# nessun file: grab da camera:folder (la scacchiera in cartella)
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
|
||||
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"})
|
||||
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()["source"] == "camera:folder"
|
||||
file_salvati = list(client.config.glob("*.json"))
|
||||
assert r1.json()["source"] == "camera:folder" and r1.json()["nome"] == "Banco"
|
||||
file_salvati = list((client.config / "calibrazioni").glob("*.json"))
|
||||
assert len(file_salvati) == 1
|
||||
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.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
|
||||
|
||||
|
||||
@@ -214,12 +241,14 @@ def test_c2_errori_camera_come_grab(client):
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_due_sorgenti_hanno_file_diversi(client):
|
||||
for nome in ("camera:a b", "camera:a_b"):
|
||||
r = client.post("/camera/calibrate", data={"source": nome, "save": "true", **CAMPI},
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200
|
||||
assert len(list(client.config.glob("*.json"))) == 2
|
||||
def test_due_nomi_simili_hanno_file_diversi(client):
|
||||
# #91: i nomi si ripuliscono come prima (hash sul nome originale): due nomi diversi non si pestano
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200
|
||||
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):
|
||||
@@ -287,14 +316,15 @@ def test_archivio_mappa_cache_e_invalidazione(tmp_path):
|
||||
_, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)
|
||||
arch = calibrazione.Archivio(tmp_path)
|
||||
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")
|
||||
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
|
||||
arch.salva("s", {"a": 2}, mappa) # file riscritto -> cache invalidata
|
||||
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
|
||||
arch.salva_nome("cal", {"a": 2}, mappa, sovrascrivi=True) # file riscritto -> cache invalidata
|
||||
assert arch.leggi_mappa("s") is not prima
|
||||
arch.salva("s", {"a": 3}) # salvataggio senza mappa (planare): la vecchia sparisce
|
||||
assert arch.leggi_mappa("s") is None and not list(tmp_path.glob("*.npz"))
|
||||
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.rglob("*.npz"))
|
||||
|
||||
|
||||
# ---- #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"})
|
||||
assert r.status_code == 200, r.text
|
||||
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["cols"], corpo["rows"]) == (15, 11)
|
||||
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())
|
||||
|
||||
|
||||
@@ -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 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.json()["saved"] is True
|
||||
json_salvati, mappe = list(client_denso.config.glob("*.json")), list(client_denso.config.glob("*.npz"))
|
||||
assert salva_con_nome(client_denso, r.json(), "Densa").status_code == 201 # #91: salvata con nome
|
||||
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 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()
|
||||
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})
|
||||
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
|
||||
|
||||
|
||||
def test_delete_toglie_anche_la_mappa(client_denso):
|
||||
client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"})
|
||||
assert list(client_denso.config.glob("*.npz"))
|
||||
def test_scollega_lascia_la_libreria_ed_elimina_toglie_la_mappa(client_denso):
|
||||
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
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 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"
|
||||
|
||||
|
||||
def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso):
|
||||
client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"})
|
||||
assert list(client_denso.config.glob("*.npz"))
|
||||
planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "save": "true", **CAMPI},
|
||||
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
salva_con_nome(client_denso, densa.json(), "Cal")
|
||||
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")})
|
||||
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"
|
||||
|
||||
@@ -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()
|
||||
@@ -278,6 +278,40 @@ def test_settaggi_rotti_non_bloccano_l_apertura(cartella, tmp_path, monkeypatch)
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
|
||||
|
||||
def test_apertura_chiede_il_frame_piu_recente(cartella, tmp_path, monkeypatch):
|
||||
# #89: solo_ultimo_frame() una volta sola all'apertura, non a ogni richiesta
|
||||
class ConUltimoFrame(Finta):
|
||||
chiamate = 0
|
||||
|
||||
def solo_ultimo_frame(self, attivo=True):
|
||||
self.chiamate += 1
|
||||
|
||||
finta = ConUltimoFrame()
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
|
||||
for _ in range(3):
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
assert finta.chiamate == 1
|
||||
|
||||
|
||||
def test_frame_piu_recente_non_impostabile_non_blocca_l_apertura(cartella, tmp_path, monkeypatch, caplog):
|
||||
# #89: producer senza il nodo -> avviso nel log, apertura riuscita
|
||||
class SenzaNodo(Finta):
|
||||
def solo_ultimo_frame(self, attivo=True):
|
||||
raise CameraStateError("questo producer non espone StreamBufferHandlingMode")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, SenzaNodo()) as c:
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
assert "frame piu' recente non impostabile" in caplog.text
|
||||
|
||||
|
||||
def test_sorgente_senza_solo_ultimo_frame_si_apre(cartella, tmp_path, monkeypatch):
|
||||
# #89: FolderCamera e simili non hanno il metodo: nessun errore
|
||||
assert not hasattr(Finta(), "solo_ultimo_frame")
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta()) as c:
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
|
||||
|
||||
# ---- pagina
|
||||
|
||||
def test_acquisizione_alias_della_pagina(client):
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Test di #92: immagini caricate come sorgenti e anteprima calibrata (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
|
||||
Solo tmp_path: niente ProgramData, niente camera vera. Le immagini le scrive il test.
|
||||
"""
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
|
||||
CAMPI, _png, carica_su, cartella, client, salva_con_nome, una_scacchiera,
|
||||
una_scacchiera_densa,
|
||||
)
|
||||
|
||||
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||
|
||||
DENSA = "densa.png"
|
||||
PIANA = "piana.png"
|
||||
|
||||
|
||||
def _modulo(c) -> dict:
|
||||
"""Il dict globale di camera_service (per sostituire open_source con una spia)."""
|
||||
return next(r.endpoint for r in c.app.routes if r.path == "/camera/grab").__globals__
|
||||
|
||||
|
||||
def _carica(c, nome: str, img: np.ndarray, **extra):
|
||||
"""POST /camera/immagini con un'immagine codificata PNG (o il contenuto dato)."""
|
||||
return c.post("/camera/immagini", files={"image": (nome, _png(img), "image/png")}, **extra)
|
||||
|
||||
|
||||
def _densa(**kw) -> np.ndarray:
|
||||
return una_scacchiera_densa(k=1e-7, **kw)
|
||||
|
||||
|
||||
def _calibra_e_attiva(c, nome_immagine: str, nome_calibrazione: str, **campi):
|
||||
"""Calcola dalla sorgente, salva con nome e la rende attiva su quella sorgente."""
|
||||
r = c.post("/camera/calibrate", data={"source": nome_immagine, "square_mm": "2", **campi})
|
||||
assert r.status_code == 200, r.text
|
||||
assert salva_con_nome(c, r.json(), nome_calibrazione).status_code == 201
|
||||
assert carica_su(c, nome_immagine, nome_calibrazione).status_code == 200
|
||||
|
||||
|
||||
def _anteprima(c, nome: str, **extra):
|
||||
return c.post("/camera/anteprima", json={"source": nome, "grezzo": True, **extra})
|
||||
|
||||
|
||||
def _calibrata(c, nome: str, **extra):
|
||||
return c.post("/camera/anteprima/calibrata", json={"source": nome, **extra})
|
||||
|
||||
|
||||
# ---- POST /camera/immagini
|
||||
|
||||
def test_carica_201_salva_in_cartella_immagini(client):
|
||||
r = _carica(client, "calibration_135_1.bmp", una_scacchiera())
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json() == {"source": "calibration_135_1.bmp",
|
||||
"label": "immagine — calibration_135_1.bmp", "kind": "image"}
|
||||
assert (client.config / "immagini" / "calibration_135_1.bmp").is_file()
|
||||
|
||||
|
||||
def test_carica_ripulisce_il_nome_e_toglie_il_percorso(client):
|
||||
r = _carica(client, "C:\\foto\\ban co è1.PNG", una_scacchiera())
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["source"] == "ban_co_1.PNG" # niente percorso, caratteri sicuri, estensione intatta
|
||||
assert (client.config / "immagini" / "ban_co_1.PNG").is_file()
|
||||
|
||||
|
||||
def test_carica_stesso_nome_sostituisce(client):
|
||||
_carica(client, "a.png", np.full((48, 64), 10, np.uint8))
|
||||
assert _carica(client, "a.png", np.full((30, 40), 200, np.uint8)).status_code == 201
|
||||
assert [f.name for f in (client.config / "immagini").iterdir()] == ["a.png"]
|
||||
img = cv2.imread(str(client.config / "immagini" / "a.png"), cv2.IMREAD_UNCHANGED)
|
||||
assert img.shape[:2] == (30, 40)
|
||||
|
||||
|
||||
def test_carica_senza_file_400(client):
|
||||
assert client.post("/camera/immagini").status_code == 400
|
||||
|
||||
|
||||
def test_carica_estensione_non_ammessa_400(client):
|
||||
r = client.post("/camera/immagini", files={"image": ("note.txt", b"ciao", "text/plain")})
|
||||
assert r.status_code == 400
|
||||
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
|
||||
|
||||
|
||||
def test_carica_file_illeggibile_422_e_niente_su_disco(client):
|
||||
r = client.post("/camera/immagini", files={"image": ("rotta.png", b"non e una immagine", "image/png")})
|
||||
assert r.status_code == 422
|
||||
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
|
||||
|
||||
|
||||
# ---- GET /camera/sources
|
||||
|
||||
def test_sources_con_immagini_e_senza_camera_file(client):
|
||||
_carica(client, "b.png", una_scacchiera())
|
||||
_carica(client, "a.png", una_scacchiera())
|
||||
voci = client.get("/camera/sources").json()["sources"]
|
||||
nomi = [v["name"] for v in voci]
|
||||
assert "camera:file" not in nomi
|
||||
assert "camera:folder" in nomi
|
||||
assert nomi[-2:] == ["a.png", "b.png"] # le immagini stanno in coda
|
||||
assert voci[-1] == {"name": "b.png", "kind": "image", "label": "immagine — b.png"}
|
||||
assert nomi.index("camera:folder") < nomi.index("a.png")
|
||||
|
||||
|
||||
def test_sources_senza_immagini_non_crea_la_cartella(client):
|
||||
voci = client.get("/camera/sources").json()["sources"]
|
||||
assert all(v["kind"] != "image" for v in voci)
|
||||
|
||||
|
||||
# ---- grab / anteprima / calibrate su un'immagine caricata
|
||||
|
||||
def test_grab_anteprima_e_grab_ripetuti_su_immagine(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
for _ in range(3): # l'anteprima continua non deve esaurire un'immagine sola
|
||||
r = client.post("/camera/grab", json={"source": "foto.png"})
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
|
||||
for _ in range(3):
|
||||
r = _anteprima(client, "foto.png")
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "image/jpeg"
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("520", "400")
|
||||
|
||||
|
||||
def test_grab_immagine_inesistente_404(client):
|
||||
assert client.post("/camera/grab", json={"source": "non_c_e.png"}).status_code == 404
|
||||
|
||||
|
||||
def test_calibrate_su_immagine_origine_e_il_nome(client):
|
||||
_carica(client, "calibration_135_1.bmp", una_scacchiera())
|
||||
r = client.post("/camera/calibrate", data={"source": "calibration_135_1.bmp", **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["origine"] == "calibration_135_1.bmp"
|
||||
|
||||
|
||||
# ---- DELETE /camera/immagini
|
||||
|
||||
def test_elimina_204_poi_404_e_sparisce_dalle_sorgenti(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||
assert not (client.config / "immagini" / "foto.png").exists()
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 404
|
||||
assert "foto.png" not in [v["name"] for v in client.get("/camera/sources").json()["sources"]]
|
||||
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 404
|
||||
|
||||
|
||||
def test_elimina_nome_con_percorso_404(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.delete("/camera/immagini", params={"nome": "../immagini/foto.png"}).status_code == 404
|
||||
assert client.delete("/camera/immagini").status_code == 404
|
||||
assert (client.config / "immagini" / "foto.png").is_file()
|
||||
|
||||
|
||||
def test_elimina_chiude_la_sorgente_aperta(client, monkeypatch):
|
||||
chiuse = []
|
||||
modulo = _modulo(client)
|
||||
reale = modulo["open_source"]
|
||||
|
||||
def spia(*a, **k):
|
||||
s = reale(*a, **k)
|
||||
chiusura = s.close
|
||||
s.close = lambda: (chiuse.append(a[0]), chiusura())[1]
|
||||
return s
|
||||
|
||||
monkeypatch.setitem(modulo, "open_source", spia)
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 200
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||
assert chiuse == ["foto.png"]
|
||||
|
||||
|
||||
# ---- POST /camera/anteprima/calibrata
|
||||
|
||||
def test_calibrata_senza_frame_404(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
|
||||
|
||||
|
||||
def test_calibrata_senza_attiva_409(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 409 and r.json()["detail"] == "nessuna calibrazione attiva su questa sorgente"
|
||||
|
||||
|
||||
def test_calibrata_validazione_ingresso(client):
|
||||
assert client.post("/camera/anteprima/calibrata", json={}).status_code == 400
|
||||
assert _calibrata(client, DENSA, lato_max=100).status_code == 400
|
||||
assert _calibrata(client, DENSA, lato_max=True).status_code == 400
|
||||
|
||||
|
||||
def test_calibrata_200_densa_senza_nuova_lettura(client, monkeypatch):
|
||||
modulo = _modulo(client)
|
||||
reale = modulo["open_source"]
|
||||
letture = []
|
||||
|
||||
def conta(*a, **k):
|
||||
s = reale(*a, **k)
|
||||
originale = s.grab
|
||||
s.grab = lambda t=1000: (letture.append(1), originale(t))[1]
|
||||
return s
|
||||
|
||||
monkeypatch.setitem(modulo, "open_source", conta)
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco àè 1")
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
prima = len(letture)
|
||||
for _ in range(3):
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
assert r.headers["X-Raddrizzato"] == "si"
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480")
|
||||
assert unquote(r.headers["X-Calibrazione"]) == "Banco àè 1"
|
||||
assert r.headers["X-Calibrazione"] == quote("Banco àè 1", safe="") # percent-encoded UTF-8
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
assert img.shape[:2] == (480, 640)
|
||||
assert len(letture) == prima # la calibrata non legge mai un frame nuovo
|
||||
|
||||
|
||||
def test_calibrata_ridotta_a_lato_max(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA, lato_max=320)
|
||||
assert r.status_code == 200
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
assert max(img.shape[:2]) == 320
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480") # misure del frame pieno
|
||||
|
||||
|
||||
def test_anteprima_grezza_e_calibrata_usano_lo_stesso_frame_grezzo(client):
|
||||
# anche con anteprima NON grezza la copia tenuta e' quella prima di raddrizzare
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
r = client.post("/camera/anteprima", json={"source": DENSA}) # calibrata: raddrizzata
|
||||
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si"
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si" # raddrizzata una volta sola
|
||||
|
||||
|
||||
def test_calibrata_risoluzione_diversa_409(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
# stessa sorgente, immagine sostituita con un'altra risoluzione: la calibrazione resta attiva
|
||||
assert _carica(client, DENSA, _densa(w=320, h=240)).status_code == 201
|
||||
assert _calibrata(client, DENSA).status_code == 404 # sostituire l'immagine scorda il frame vecchio
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
|
||||
|
||||
|
||||
def test_calibrata_planare_senza_mappa_409(client):
|
||||
_carica(client, PIANA, una_scacchiera())
|
||||
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
|
||||
assert _anteprima(client, PIANA).status_code == 200
|
||||
r = _calibrata(client, PIANA)
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "la calibrazione «Piana» non ha una mappa di correzione (planare)"
|
||||
@@ -32,7 +32,7 @@ def test_id_principali():
|
||||
attesi = {"sorgente", "avvia", "ferma", "scatta", "grezzo", "mirinoCasella", "immagine",
|
||||
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
|
||||
"parametri", "salvaParametri", "quadrato", "acquisisci", "scegliFile", "file",
|
||||
"carica", "salva", "elimina", "stato", "statoSorgente"}
|
||||
"salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga, nell'elenco
|
||||
assert attesi <= ids, attesi - ids
|
||||
# ogni id usato dal JS con $("...") esiste nel markup
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Test della pagina a due tab (#91): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||
_spec = importlib.util.spec_from_file_location("calibrazione_pagina_tab", _FILE)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
PAGINA = _mod.PAGINA
|
||||
|
||||
|
||||
def test_due_tab_con_ruolo_e_selezione():
|
||||
assert re.search(r'<button id="tabAcq"[^>]*role="tab"[^>]*aria-selected="true"', PAGINA)
|
||||
assert re.search(r'<button id="tabCal"[^>]*role="tab"[^>]*aria-selected="false"', PAGINA)
|
||||
assert ">Acquisizione</button>" in PAGINA and ">Calibrazione</button>" in PAGINA
|
||||
assert 'role="tablist"' in PAGINA and 'role="tabpanel"' in PAGINA
|
||||
assert 'aria-selected", cal ?' in PAGINA # la selezione cambia da JS
|
||||
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
|
||||
|
||||
|
||||
def test_anteprima_fuori_dai_pannelli():
|
||||
# l'anteprima sta nella colonna sinistra, prima dei due pannelli: visibile in entrambe le tab
|
||||
i_ant = PAGINA.index('id="sezioneAnteprima"')
|
||||
i_acq = PAGINA.index('id="pannelloAcq"')
|
||||
i_cal = PAGINA.index('id="pannelloCal"')
|
||||
assert i_ant < i_acq < i_cal
|
||||
# Sorgente e Parametri nel pannello Acquisizione, la calibrazione in quello Calibrazione
|
||||
assert i_acq < PAGINA.index('id="sezioneSorgente"') < PAGINA.index('id="sezioneParametri"') < i_cal
|
||||
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
|
||||
|
||||
|
||||
def test_id_del_pannello_calibrazione():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
attesi = {"calSorgente", "origSorgente", "origFile", "scegliFile", "file", "quadrato", "acquisisci",
|
||||
"nomeCal", "salva", "msgSalva", "calibrazioni", "msgElenco", "stato", "risultato", "overlay"}
|
||||
assert attesi <= ids, attesi - ids
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
assert usati <= ids, usati - ids
|
||||
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
|
||||
|
||||
|
||||
def test_testi_del_pannello():
|
||||
for testo in ("Sorgente selezionata", "Immagine da file", "Scegli immagine...", "Calcola",
|
||||
"Calibrazioni salvate", "si cambia nella tab Acquisizione", "risoluzione diversa",
|
||||
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome"):
|
||||
assert testo in PAGINA, testo
|
||||
|
||||
|
||||
def test_rotte_del_contratto():
|
||||
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
|
||||
"/camera/calibration?source="):
|
||||
assert '"' + rotta in PAGINA, rotta
|
||||
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
|
||||
assert parola in PAGINA, parola
|
||||
# il calcolo non salva piu': il campo save sparisce
|
||||
assert 'append("save"' not in PAGINA
|
||||
|
||||
|
||||
def test_nessuna_finestra_del_browser_e_offline():
|
||||
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||
assert vietato not in PAGINA, vietato
|
||||
|
||||
|
||||
def test_acquisizione_di_87_90_conservata():
|
||||
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
|
||||
for parola in ("Parametri camera", "Immagine grezza (senza correzione)", "Mirino centrale",
|
||||
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
|
||||
assert parola in PAGINA, parola
|
||||
|
||||
|
||||
def test_pagina_stretta():
|
||||
assert "@media (max-width: 500px)" in PAGINA
|
||||
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
|
||||
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
|
||||
assert "Lettura dei parametri..." in PAGINA
|
||||
Vendored
+1
-1
Submodule vendor/visionsuite updated: e0d19dee7e...06b549a25d
Reference in New Issue
Block a user