Compare commits

..

12 Commits

Author SHA1 Message Date
Adriano a5e8472e88 acquisitore: rotte per calibrazioni con nome, caricamento con controllo risoluzione (Refs #91)
/camera/calibrate non salva piu' (calcolo_id in memoria, ultimi 8); nuove rotte
GET/POST/DELETE /camera/calibrazioni e PUT /camera/calibrazione-attiva (409 se la
risoluzione non combacia col frame della sorgente); DELETE /camera/calibration ora
scollega. Test: rotte nuove e test esistenti aggiornati (save=true -> salva con nome).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 16:10:10 +02:00
Adriano e7d5489438 acquisitore: archivio unico di calibrazioni con nome, migrazione dal formato per sorgente (Refs #91)
Archivio: libreria calibrazioni/<nome>-<sha>.json (+npz), attive.json per sorgente,
salva_nome/leggi_nome/elimina_nome/attiva/scollega/attiva_di/elenco; leggi e
leggi_mappa risolvono la calibrazione attiva. Le rotte seguono nel commit successivo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 16:06:05 +02:00
Adriano 828471d27a Acquisizione: anteprima a pausa minima 100 ms (Refs #90)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:49:17 +02:00
Adriano cf64a247be acquisitore: frame sempre piu' recente (Refs #89)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:44:38 +02:00
Adriano 813b66d0c5 Acquisizione: parametri camera con un decimale nelle caselle (#87)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:28:59 +02:00
Adriano 8c6d6c1630 sviluppo.ps1: -EscludiCamere; bump visionsuite VS_CAMERA_ESCLUDI (TieVision-Core#339)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:25:13 +02:00
Adriano 1afaaa0bae packaging: sviluppo.ps1, TMF da sorgente senza build Nuitka (#87 #88)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:09:33 +02:00
Adriano cb66371a5b Stazioni: x-cloak rimesso sulle modali, tolto per sbaglio (#88)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:58:23 +02:00
Adriano 390d6210d0 Stazioni: sorgenti camera lette dall'acquisitore, link Acquisizione (#88)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:51:22 +02:00
Adriano 2760623b53 Merge remote-tracking branch 'origin/lavoro/daitarn-tmf-acq-pagina' into lavoro/daitarn-tmf-dxfcal 2026-10-24 14:41:55 +02:00
Adriano 13eeec7119 Acquisizione: pagina, attributo hidden rispettato, indicatori su una riga, mirino a 2 px (#87)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:41:25 +02:00
Adriano 0629f1b5ec Acquisizione: pagina nuova con anteprima, parametri camera e stile TMF (#87)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:38:39 +02:00
18 changed files with 1971 additions and 230 deletions
+53
View File
@@ -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
View File
@@ -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
+653 -118
View File
@@ -1,80 +1,194 @@
"""Pagina HTML della calibrazione a scacchiera (servita da camera_service su /calibrazione, #83).
"""Pagina HTML dell'acquisizione camera (servita da camera_service su /calibrazione e /acquisizione, #83 #87).
Cosa fa: una pagina sola, autonoma e offline (niente CDN, JS e CSS inline), in
italiano. Parla con le rotte dello stesso servizio (/camera/sources,
/camera/calibration, /camera/calibrate).
#84: calibrazione densa, un solo dato dell'operatore (lato dello scacco in mm):
scacchiera che riempie il campo, griglia trovata da sola, mappa di distorsione.
Cosa fa: una pagina sola, autonoma e offline (niente CDN, niente font esterni,
JS e CSS inline), in italiano, stile scuro TMF. Quattro sezioni: Sorgente,
Anteprima (dal vivo, con nitidezza/saturazione/livello medio), Parametri camera
(esposizione e guadagno), Calibrazione a scacchiera.
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.
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
"""
PAGINA = """<!doctype html>
PAGINA = r"""<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calibrazione camera</title>
<title>Acquisizione camera</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; background: #f3f4f6; color: #1f2937; }
main { max-width: 960px; margin: 0 auto; padding: 16px; }
h1 { font-size: 1.4rem; margin: 0 0 12px; }
.scheda { background: #fff; border: 1px solid #d1d5db; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
label { display: block; font-size: .85rem; margin-bottom: 4px; color: #4b5563; }
select, input[type=number] { padding: 6px 8px; font-size: 1rem; border: 1px solid #9ca3af; border-radius: 6px; }
.riga { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
button { padding: 8px 14px; font-size: 1rem; border: 0; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
button.secondario { background: #6b7280; }
/* #87: stile scuro TMF (slate/blu), font di sistema */
:root { --sfondo: #0f172a; --pannello: #1e293b; --bordo: #334155; --accento: #3b82f6;
--testo: #e2e8f0; --secondario: #94a3b8; --errore: #f87171; --ok: #4ade80; --avviso: #fbbf24; }
* { box-sizing: border-box; }
[hidden] { display: none !important; } /* #87: vale anche dove il CSS imposta display */
body { font-family: "Segoe UI", system-ui, sans-serif; margin: 0; background: var(--sfondo); color: var(--testo); }
main { max-width: 1500px; margin: 0 auto; padding: 14px; }
h1 { font-size: 1.35rem; margin: 0 0 12px; }
h2 { font-size: 1.02rem; margin: 0 0 10px; }
.griglia { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(320px, 1fr); gap: 14px; align-items: start; }
.colonna { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.scheda { background: var(--pannello); border: 1px solid var(--bordo); border-radius: 8px; padding: 14px; }
label { display: block; font-size: .82rem; margin-bottom: 4px; color: var(--secondario); }
select, input[type=text] { padding: 6px 8px; font-size: .95rem; color: var(--testo); background: var(--sfondo);
border: 1px solid var(--bordo); border-radius: 6px; font-family: inherit; }
select { max-width: 100%; }
select:focus, input:focus, button:focus-visible { outline: 2px solid var(--accento); outline-offset: 1px; }
input[type=range] { width: 100%; accent-color: var(--accento); }
input[type=checkbox] { accent-color: var(--accento); }
.riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.nota { font-size: .82rem; color: var(--secondario); margin: 8px 0 0; }
button { padding: 7px 13px; font-size: .95rem; border: 0; border-radius: 6px; background: var(--accento); color: #fff;
cursor: pointer; font-family: inherit; }
button.secondario { background: var(--bordo); color: var(--testo); }
button.pericolo { background: #b91c1c; }
button:disabled { opacity: .5; cursor: default; }
.messaggio { margin-top: 10px; font-weight: 600; }
.errore { color: #b91c1c; }
.ok { color: #15803d; }
table { border-collapse: collapse; }
td { padding: 3px 14px 3px 0; }
td:first-child { color: #4b5563; }
img { max-width: 100%; border: 1px solid #d1d5db; border-radius: 6px; margin-top: 8px; }
.avviso { color: #92400e; }
button:disabled { opacity: .45; cursor: default; }
.messaggio { min-height: 1.2em; margin-top: 8px; font-size: .9rem; font-weight: 600; color: var(--secondario); }
.errore { color: var(--errore); }
.ok { color: var(--ok); }
.avviso { color: var(--avviso); font-size: .88rem; }
table { border-collapse: collapse; font-size: .88rem; }
td { padding: 2px 14px 2px 0; }
td:first-child { color: var(--secondario); }
.stato { font-size: .9rem; color: var(--secondario); margin-top: 8px; }
/* anteprima */
.palco { background: #020617; border: 1px solid var(--bordo); border-radius: 6px; min-height: 340px;
display: flex; align-items: center; justify-content: center; margin-top: 10px; overflow: hidden; }
.cornice { position: relative; display: inline-block; line-height: 0; max-width: 100%; }
.cornice img { max-width: 100%; max-height: calc(100vh - 330px); min-height: 200px; border: 0; margin: 0; }
#mirino { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat,
linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; }
#vuoto { padding: 30px 16px; color: var(--secondario); text-align: center; }
.indicatori { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-top: 10px; }
.indicatore { background: var(--sfondo); border: 1px solid var(--bordo); border-radius: 6px; padding: 8px 10px; }
.indicatore .nome { font-size: .78rem; color: var(--secondario); }
.indicatore .valore { font-size: 1.35rem; font-weight: 600; font-variant-numeric: tabular-nums; }
.indicatore .piccolo { font-size: .76rem; color: var(--secondario); }
.barra { height: 6px; background: var(--bordo); border-radius: 3px; margin-top: 4px; overflow: hidden; }
.barra > div { height: 100%; width: 0; background: var(--accento); transition: width .15s; }
.rosso { color: var(--errore); }
/* parametri */
.parametro { margin-bottom: 12px; }
.parametro .campo { display: flex; align-items: center; gap: 6px; }
.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); }
@media (max-width: 900px) {
.griglia { grid-template-columns: minmax(0, 1fr); }
.cornice img { max-height: 60vh; }
}
</style>
</head>
<body>
<main>
<h1>Calibrazione camera con scacchiera</h1>
<h1>Acquisizione camera</h1>
<div class="scheda">
<div class="riga">
<div><label for="sorgente">Sorgente</label><select id="sorgente"></select></div>
<div><label for="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="number" min="0.001" step="any" value="2"></div>
<div class="griglia">
<div class="colonna">
<!-- #87: sezione Anteprima -->
<section class="scheda" id="sezioneAnteprima">
<h2>Anteprima</h2>
<div class="riga">
<button id="avvia">Avvia anteprima</button>
<button id="ferma" class="secondario" disabled>Ferma anteprima</button>
<button id="scatta" class="secondario">Scatta</button>
<label style="margin:0 0 6px"><input id="grezzo" type="checkbox"> Immagine grezza (senza correzione)</label>
<label style="margin:0 0 6px"><input id="mirinoCasella" type="checkbox"> Mirino centrale</label>
</div>
<div class="palco">
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="cornice" hidden>
<img id="immagine" alt="Anteprima della camera">
<div id="mirino" hidden></div>
</div>
</div>
<div class="indicatori">
<div class="indicatore">
<div class="nome">Nitidezza</div>
<div class="valore" id="nitidezza">-</div>
<div class="barra"><div id="barraNitidezza"></div></div>
<div class="piccolo">massimo visto: <span id="nitidezzaMax">-</span>
<button id="azzeraMax" class="secondario" style="padding:1px 7px;font-size:.75rem;margin-left:6px">Azzera</button></div>
</div>
<div class="indicatore">
<div class="nome">Saturati</div>
<div class="valore" id="saturati">-</div>
<div class="piccolo">pixel a 250 o più; da tenere sotto l'1 %</div>
</div>
<div class="indicatore">
<div class="nome">Livello medio</div>
<div class="valore" id="media">-</div>
<div class="piccolo">scala 0-255</div>
</div>
<div class="indicatore">
<div class="nome">Frame pieno</div>
<div class="valore" id="dimensioni">-</div>
<div class="piccolo" id="raddrizzato">&nbsp;</div>
</div>
</div>
<div id="msgAnteprima" class="messaggio"></div>
</section>
</div>
<p style="font-size:.85rem;color:#4b5563;margin:10px 0 0">
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.</p>
</div>
<div class="scheda">
<div class="riga">
<button id="acquisisci">Acquisisci e calibra</button>
<div><label for="file">oppure un file immagine</label><input id="file" type="file" accept="image/*"></div>
<button id="carica" class="secondario">Carica immagine e calibra</button>
</div>
<div id="messaggio" class="messaggio"></div>
</div>
<div class="colonna">
<!-- #87: sezione Sorgente -->
<section class="scheda" id="sezioneSorgente">
<h2>Sorgente</h2>
<label for="sorgente">Camera o cartella</label>
<select id="sorgente"></select>
<div class="stato" id="statoSorgente">-</div>
<div id="msgSorgente" class="messaggio"></div>
</section>
<div class="scheda" id="risultato" hidden>
<strong>Risultato del calcolo</strong>
<table id="tabella"></table>
<div id="avviso" class="avviso"></div>
<div class="riga" style="margin-top:10px">
<button id="salva">Salva</button>
<span style="font-size:.85rem;color:#4b5563">Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.</span>
</div>
<img id="overlay" alt="Angoli trovati sulla scacchiera">
</div>
<!-- #87: sezione Parametri camera -->
<section class="scheda" id="sezioneParametri">
<h2>Parametri camera</h2>
<div id="parametri"><div class="nota">-</div></div>
<div class="riga" id="salvaParametriRiga" hidden>
<button id="salvaParametri" class="secondario">Salva come predefiniti</button>
</div>
<div id="msgParametri" class="messaggio"></div>
</section>
<div class="scheda">
<strong>Calibrazione salvata</strong>
<div id="stato">-</div>
<div class="riga" style="margin-top:10px">
<button id="elimina" class="pericolo" disabled>Elimina</button>
<section class="scheda" id="sezioneCalibrazione">
<h2>Calibrazione</h2>
<div class="riga">
<div><label for="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="text" inputmode="decimal" value="2" size="8"></div>
</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>
<div id="messaggio" class="messaggio"></div>
<div id="risultato" hidden style="margin-top:10px">
<strong>Risultato del calcolo</strong>
<table id="tabella"></table>
<div id="avviso" class="avviso"></div>
<div class="riga" style="margin-top:10px">
<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>
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px">
</div>
<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>
</div>
</div>
</main>
@@ -82,31 +196,438 @@ PAGINA = """<!doctype html>
<script>
"use strict";
var $ = function (id) { return document.getElementById(id); };
var ultimo = null; // ultimo calcolo, per «Salva»
var ultimo = null; // ultimo calcolo di calibrazione, per «Salva»
function messaggio(testo, classe) {
var m = $("messaggio");
// ---- utilita'
// numero all'italiana (virgola decimale); cifre = decimali fissi, omesso = fino a 4
function fmt(v, cifre) {
var n = Number(v);
if (!isFinite(n)) return "-";
var o = cifre === undefined ? { maximumFractionDigits: 4 } : { minimumFractionDigits: cifre, maximumFractionDigits: cifre };
return n.toLocaleString("it-IT", o);
}
// numero senza separatore delle migliaia, per le caselle di input
// #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(",", "."));
return isFinite(n) && String(testo).trim() !== "" ? n : NaN;
}
function dataItaliana(iso) {
var d = new Date(iso);
if (isNaN(d.getTime())) return String(iso);
return d.toLocaleString("it-IT", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }).replace(",", "");
}
function dire(id, testo, classe) {
var m = $(id);
m.textContent = testo || "";
m.className = "messaggio " + (classe || "");
}
function messaggio(testo, classe) { dire("messaggio", testo, classe); }
function pausa(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
function memoria(chiave, valore) { // localStorage sempre protetto: puo' essere bloccato
try {
if (valore === undefined) return localStorage.getItem(chiave);
localStorage.setItem(chiave, valore);
} catch (e) { /* niente memoria, la pagina funziona lo stesso */ }
return null;
}
function numero(v, cifre) { return Number(v).toFixed(cifre); }
// testo d'errore da una risposta; #87: rotte nuove assenti in un servizio vecchio -> messaggio chiaro
async function dettaglio(risposta) {
var testo;
try { var j = await risposta.json(); testo = j.detail || JSON.stringify(j); }
catch (e) { testo = "errore " + risposta.status; }
if (testo === "Not Found" || testo === "Method Not Allowed") {
return "Funzione non disponibile: il servizio della camera è una versione più vecchia (errore " + risposta.status + ").";
}
return typeof testo === "string" ? testo : JSON.stringify(testo);
}
// ---- Sorgente
async function caricaSorgenti() {
var sel = $("sorgente");
try {
var r = await fetch("/camera/sources");
var j = await r.json();
(j.sources || []).forEach(function (s) {
var o = document.createElement("option");
o.value = s.name;
o.textContent = s.label || s.name;
sel.appendChild(o);
});
} catch (e) { dire("msgSorgente", "Elenco delle sorgenti non disponibile.", "errore"); }
if (!sel.options.length) dire("msgSorgente", "Nessuna sorgente camera trovata.", "errore");
var ricordata = memoria("tmf.acq.sorgente"); // #87: ultima scelta ricordata
if (ricordata) {
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === ricordata) sel.value = ricordata;
}
cambioSorgente(false);
}
// #87: cambiando sorgente si azzerano gli indicatori e si rileggono parametri e calibrazione
function cambioSorgente(salva) {
if (salva) memoria("tmf.acq.sorgente", $("sorgente").value);
var era = A.attiva;
fermaAnteprima();
azzeraIndicatori();
dire("msgAnteprima", "");
aggiornaStato();
caricaParametri();
if (era) avviaAnteprima();
}
// ---- Anteprima (#87)
var A = { attiva: false, gen: 0, inVolo: false, url: null, nitMax: 0 };
function azzeraIndicatori() {
A.nitMax = 0;
["nitidezza", "saturati", "media", "dimensioni", "nitidezzaMax"].forEach(function (i) { $(i).textContent = "-"; });
$("saturati").className = "valore";
$("barraNitidezza").style.width = "0";
$("raddrizzato").textContent = " ";
}
function pulsantiAnteprima() {
$("avvia").disabled = A.attiva;
$("ferma").disabled = !A.attiva;
}
function mostraImmagine(blob) {
var vecchio = A.url;
A.url = URL.createObjectURL(blob);
$("immagine").src = A.url;
if (vecchio) URL.revokeObjectURL(vecchio); // niente memoria che cresce a ogni frame
$("cornice").hidden = false;
$("vuoto").hidden = true;
}
function aggiornaIndicatori(h) {
var nit = parseFloat(h.get("X-Nitidezza"));
var sat = parseFloat(h.get("X-Saturati-Pct"));
var media = parseFloat(h.get("X-Media"));
if (isFinite(nit)) {
if (nit > A.nitMax) A.nitMax = nit;
$("nitidezza").textContent = fmt(nit, 1);
$("nitidezzaMax").textContent = fmt(A.nitMax, 1);
$("barraNitidezza").style.width = (A.nitMax > 0 ? Math.round(100 * nit / A.nitMax) : 0) + "%";
}
if (isFinite(sat)) {
$("saturati").textContent = fmt(sat, 2) + " %";
$("saturati").className = "valore" + (sat > 1 ? " rosso" : ""); // oltre l'1 % si perde dettaglio
}
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);
var r = h.get("X-Raddrizzato");
$("raddrizzato").textContent = r === "si" ? "raddrizzata dalla calibrazione" : r === "no" ? "non raddrizzata" : " ";
}
// una richiesta di anteprima; ritorna "ok" | "errore" | "fatale" (errore che non passa riprovando)
async function unaAnteprima(lato) {
var sorgente = $("sorgente").value;
if (!sorgente) { dire("msgAnteprima", "Scegliere una sorgente.", "errore"); return "fatale"; }
var g = A.gen;
A.inVolo = true;
try {
var r = await fetch("/camera/anteprima", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: sorgente, grezzo: $("grezzo").checked, lato_max: lato, timeout_ms: 5000 })
});
if (!r.ok) {
var d = await dettaglio(r);
if (g === A.gen) dire("msgAnteprima", d, "errore");
return r.status >= 500 ? "errore" : "fatale"; // 503/504: la camera puo' tornare
}
var blob = await r.blob();
if (g !== A.gen) return "ok"; // frame di una sorgente o di un ciclo ormai chiuso: si scarta
mostraImmagine(blob);
aggiornaIndicatori(r.headers);
dire("msgAnteprima", "");
return "ok";
} catch (e) {
if (g === A.gen) dire("msgAnteprima", "Servizio non raggiungibile.", "errore");
return "errore";
} finally {
A.inVolo = false;
}
}
// 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) {
var t0 = Date.now();
var esito = await unaAnteprima(1280);
if (g !== A.gen) return;
if (esito === "fatale") { fermaAnteprima(); return; }
await pausa(esito === "errore" ? 1500 : Math.max(100 - (Date.now() - t0), 0));
}
}
function avviaAnteprima() {
if (A.attiva) return;
A.attiva = true;
A.gen++;
pulsantiAnteprima();
ciclo(A.gen);
}
function fermaAnteprima() {
A.attiva = false;
A.gen++;
pulsantiAnteprima();
}
// «Scatta»: ferma il ciclo e mostra un frame solo, alla risoluzione piena (fino a 4096 px)
async function scatta() {
fermaAnteprima();
$("scatta").disabled = true;
dire("msgAnteprima", "Scatto in corso...", "");
while (A.inVolo) await pausa(50);
await unaAnteprima(4096);
$("scatta").disabled = false;
}
$("avvia").onclick = avviaAnteprima;
$("ferma").onclick = fermaAnteprima;
$("scatta").onclick = scatta;
$("azzeraMax").onclick = function () { A.nitMax = 0; $("nitidezzaMax").textContent = "-"; $("barraNitidezza").style.width = "0"; };
$("mirinoCasella").onchange = function () { $("mirino").hidden = !$("mirinoCasella").checked; };
// ---- Parametri camera (#87)
var ORDINE = ["esposizione", "esposizione_auto", "guadagno", "guadagno_auto"];
var ETICHETTE = { esposizione: "Esposizione", esposizione_auto: "Esposizione automatica",
guadagno: "Guadagno", guadagno_auto: "Guadagno automatico" };
var UNITA = { esposizione: "µs", guadagno: "dB" };
var ETICHETTE_SCELTE = { Off: "Spenta", Once: "Una volta", Continuous: "Continua" };
var P = { el: {}, dati: {}, pendenti: {}, timer: null, inScrittura: false, gen: 0 };
function usaLog(d) { return typeof d.min === "number" && typeof d.max === "number" && d.min > 0 && d.max / d.min >= 100; }
function haCursore(d) { return typeof d.min === "number" && typeof d.max === "number" && d.max > d.min; }
function aPosizione(d, v) {
var t = usaLog(d) ? Math.log(Math.max(v, d.min) / d.min) / Math.log(d.max / d.min) : (v - d.min) / (d.max - d.min);
return Math.round(1000 * Math.min(Math.max(t, 0), 1));
}
function daPosizione(d, p) {
var t = p / 1000;
return usaLog(d) ? d.min * Math.pow(d.max / d.min, t) : d.min + t * (d.max - d.min);
}
// porta il valore nei limiti e sul passo della camera
function adatta(d, v) {
if (typeof d.min === "number") v = Math.max(v, d.min);
if (typeof d.max === "number") v = Math.min(v, d.max);
if (typeof d.passo === "number" && d.passo > 0) {
var base = typeof d.min === "number" ? d.min : 0;
v = base + Math.round((v - base) / d.passo) * d.passo;
if (typeof d.max === "number") v = Math.min(v, d.max);
}
return Number(v.toFixed(6));
}
async function caricaParametri() {
var sorgente = $("sorgente").value;
var g = ++P.gen;
P.pendenti = {};
clearTimeout(P.timer);
$("salvaParametriRiga").hidden = true;
dire("msgParametri", "");
if (!sorgente) { $("parametri").textContent = "-"; return; }
try {
var r = await fetch("/camera/parametri?source=" + encodeURIComponent(sorgente));
if (g !== P.gen) return;
if (!r.ok) {
$("parametri").textContent = "";
dire("msgParametri", await dettaglio(r), "errore");
return;
}
costruisciParametri(await r.json());
} catch (e) {
if (g === P.gen) dire("msgParametri", "Servizio non raggiungibile.", "errore");
}
}
function costruisciParametri(j) {
var box = $("parametri");
box.textContent = "";
P.el = {};
P.dati = j.parametri || {};
var nomi = ORDINE.filter(function (n) { return P.dati[n]; });
if (!j.regolabile || !nomi.length) {
var n = document.createElement("div");
n.className = "nota";
n.textContent = "Questa sorgente non ha parametri regolabili.";
box.appendChild(n);
$("salvaParametriRiga").hidden = true;
return;
}
nomi.forEach(function (nome) { box.appendChild(creaParametro(nome, P.dati[nome])); });
$("salvaParametriRiga").hidden = false;
}
function creaParametro(nome, d) {
var riga = document.createElement("div");
riga.className = "parametro";
var et = document.createElement("label");
et.textContent = ETICHETTE[nome] + (UNITA[nome] ? " (" + UNITA[nome] + ")" : "");
riga.appendChild(et);
var el = { tipo: d.tipo };
var bloccato = d.scrivibile === false;
if (d.tipo === "scelta") {
var sel = document.createElement("select");
(d.scelte || []).forEach(function (s) {
var o = document.createElement("option");
o.value = s;
o.textContent = ETICHETTE_SCELTE[s] || s;
sel.appendChild(o);
});
sel.value = d.valore;
sel.disabled = bloccato;
sel.onchange = function () { modifica(nome, sel.value); };
riga.appendChild(sel);
el.select = sel;
} else {
var campo = document.createElement("div");
campo.className = "campo";
var cur = null;
var casella = document.createElement("input");
if (haCursore(d)) {
cur = document.createElement("input");
cur.type = "range";
cur.min = 0; cur.max = 1000; cur.step = 1;
cur.value = aPosizione(d, d.valore);
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, 1);
modifica(nome, v);
};
campo.appendChild(cur);
}
casella.type = "text";
casella.inputMode = "decimal";
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, 1); return; }
v = adatta(d, v);
casella.value = fmtCasella(v, 1);
if (cur) cur.value = aPosizione(d, v);
modifica(nome, v);
};
campo.appendChild(casella);
var u = document.createElement("span");
u.className = "unita";
u.textContent = UNITA[nome] || "";
campo.appendChild(u);
riga.appendChild(campo);
if (typeof d.min === "number" || typeof d.max === "number") {
var lim = document.createElement("div");
lim.className = "limiti";
lim.textContent = "da " + (typeof d.min === "number" ? fmt(d.min) : "?") + " a " + (typeof d.max === "number" ? fmt(d.max) : "?") +
(typeof d.passo === "number" ? ", passo " + fmt(d.passo) : "") + (usaLog(d) ? " (cursore logaritmico)" : "");
riga.appendChild(lim);
}
el.cursore = cur;
el.casella = casella;
}
P.el[nome] = el;
return riga;
}
// una modifica dell'operatore: si accoda e parte dopo 300 ms dall'ultima
function modifica(nome, valore) {
P.pendenti[nome] = valore;
var aut = nome + "_auto";
// impostare un valore manuale spegne prima l'automatico corrispondente
if ((nome === "esposizione" || nome === "guadagno") && P.el[aut] && P.el[aut].select && P.el[aut].select.value !== "Off") {
P.pendenti[aut] = "Off";
P.el[aut].select.value = "Off";
}
clearTimeout(P.timer);
P.timer = setTimeout(scriviParametri, 300);
}
async function scriviParametri() {
if (P.inScrittura) { P.timer = setTimeout(scriviParametri, 100); return; }
var valori = P.pendenti;
P.pendenti = {};
if (!Object.keys(valori).length) return;
P.inScrittura = true;
try {
var r = await fetch("/camera/parametri", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: $("sorgente").value, valori: valori })
});
if (!r.ok) {
dire("msgParametri", await dettaglio(r), "errore");
if (r.status !== 404 && r.status !== 405) caricaParametri(); // si rilegge cosa ha la camera
return;
}
aggiornaValori(await r.json());
dire("msgParametri", "Parametri applicati.", "ok");
} catch (e) {
dire("msgParametri", "Servizio non raggiungibile.", "errore");
} finally {
P.inScrittura = false;
}
}
// rilegge i valori dalla risposta, lasciando stare quelli che l'operatore sta ancora toccando
function aggiornaValori(j) {
var dati = j.parametri || {};
Object.keys(dati).forEach(function (nome) {
var el = P.el[nome], d = dati[nome];
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, 1);
if (el.cursore && document.activeElement !== el.cursore) el.cursore.value = aPosizione(P.dati[nome], d.valore);
});
}
$("salvaParametri").onclick = async function () {
$("salvaParametri").disabled = true;
try {
var r = await fetch("/camera/parametri/salva", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: $("sorgente").value })
});
if (!r.ok) dire("msgParametri", await dettaglio(r), "errore");
else dire("msgParametri", "Predefiniti salvati: si ricaricano all'apertura della camera.", "ok");
} catch (e) {
dire("msgParametri", "Servizio non raggiungibile.", "errore");
} finally {
$("salvaParametri").disabled = false;
}
};
// ---- Calibrazione (logica di #83 e #84; #87: lato precompilato, data italiana, conferma in pagina)
function righe(d) {
return [
["Scala", numero(d.px_per_mm, 4) + " px/mm (" + numero(d.mm_per_px, 5) + " mm/px)"],
["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"],
["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"],
["Griglia trovata", d.cols + " x " + d.rows + " angoli (" + d.n_corners + ")"]
["Griglia trovata", d.cols + " x " + d.rows + " angoli (" + fmt(d.n_corners, 0) + ")"]
].concat(d.metodo !== "densa" ? [
["Errore di riproiezione (RMS)", numero(d.rms_px, 3) + " px"],
["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"]
["Errore di riproiezione (RMS)", fmt(d.rms_px, 3) + " px"],
["Variazione di scala nell'area", fmt(d.scale_spread_pct, 2) + " %"]
] : [
["Residuo massimo", numero(d.residuo_max_px, 3) + " px"],
["Residuo RMS", numero(d.residuo_rms_px, 3) + " px"],
["Copertura del campo", numero(d.copertura_pct, 1) + " %"]
["Residuo massimo", fmt(d.residuo_max_px, 3) + " px"],
["Residuo RMS", fmt(d.residuo_rms_px, 3) + " px"],
["Copertura del campo", fmt(d.copertura_pct, 1) + " %"]
]).concat([
["Lato dello scacco", d.square_mm + " mm"],
["Immagine", d.image_width + " x " + d.image_height + " px"]
["Lato dello scacco", fmt(d.square_mm) + " mm"],
["Immagine", fmt(d.image_width, 0) + " x " + fmt(d.image_height, 0) + " px"]
]);
}
@@ -123,58 +644,45 @@ function tabella(el, d) {
});
}
async function dettaglio(risposta) {
try { var j = await risposta.json(); return j.detail || JSON.stringify(j); }
catch (e) { return "errore " + risposta.status; }
}
async function caricaSorgenti() {
var sel = $("sorgente");
try {
var r = await fetch("/camera/sources");
var j = await r.json();
(j.sources || []).forEach(function (s) {
var o = document.createElement("option");
o.value = s.name;
o.textContent = s.label || s.name;
sel.appendChild(o);
});
} catch (e) { messaggio("Elenco delle sorgenti non disponibile.", "errore"); }
if (!sel.options.length) messaggio("Nessuna sorgente camera trovata.", "errore");
aggiornaStato();
}
async function aggiornaStato() {
var sorgente = $("sorgente").value;
var stato = $("stato");
annullaConferma();
$("elimina").disabled = true;
if (!sorgente) { stato.textContent = "-"; return; }
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
if (r.status === 404) { stato.textContent = "Sorgente 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 " + d.calibrated_at;
stato.appendChild(t);
stato.appendChild(quando);
$("elimina").disabled = false;
}
function parametri() {
return { quadrato: $("quadrato").value };
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 = [];
if (d.metodo !== "densa") {
if (d.scale_spread_pct > 1) a.push("Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica.");
if (d.scale_spread_pct > 1) a.push("Attenzione: la scala varia di " + fmt(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica.");
} else {
if (d.copertura_pct < 70) a.push("Attenzione: la scacchiera copre solo il " + numero(d.copertura_pct, 1) + " % del campo (meno del 70 %): la correzione ai bordi non e' misurata.");
if (d.residuo_max_px > 0.5) a.push("Attenzione: residuo massimo " + numero(d.residuo_max_px, 3) + " px (oltre 0,5 px): scacchiera non piana, sfocata o con scacchi irregolari.");
if (d.copertura_pct < 70) a.push("Attenzione: la scacchiera copre solo il " + fmt(d.copertura_pct, 1) + " % del campo (meno del 70 %): la correzione ai bordi non è misurata.");
if (d.residuo_max_px > 0.5) a.push("Attenzione: residuo massimo " + fmt(d.residuo_max_px, 3) + " px (oltre 0,5 px): scacchiera non piana, sfocata o con scacchi irregolari.");
}
return a.join(" ");
}
@@ -182,16 +690,19 @@ function avvisi(d) {
async function calcola(conFile, salva) {
var sorgente = $("sorgente").value;
if (!sorgente) { messaggio("Scegliere una sorgente.", "errore"); return; }
var p = parametri();
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", p.quadrato);
f.append("square_mm", String(lato)); // al servizio sempre con il punto
f.append("save", salva ? "true" : "false");
if (conFile) {
var file = $("file").files[0];
if (!file) { messaggio("Scegliere un file immagine.", "errore"); return; }
f.append("image", file);
}
var eraAttiva = A.attiva; // #87: l'anteprima si sospende e poi riprende
if (eraAttiva) fermaAnteprima();
messaggio("Calcolo in corso...", "");
["acquisisci", "carica", "salva"].forEach(function (i) { $(i).disabled = true; });
try {
@@ -209,6 +720,7 @@ async function calcola(conFile, salva) {
messaggio("Servizio non raggiungibile.", "errore");
} finally {
["acquisisci", "carica", "salva"].forEach(function (i) { $(i).disabled = false; });
if (eraAttiva) avviaAnteprima();
}
}
@@ -216,14 +728,37 @@ $("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); };
// #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;
if (!confirm("Eliminare la calibrazione salvata di " + sorgente + "?")) return;
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
messaggio(r.ok ? "Calibrazione eliminata." : await dettaglio(r), r.ok ? "ok" : "errore");
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();
};
$("sorgente").onchange = aggiornaStato;
$("sorgente").onchange = function () { cambioSorgente(true); };
caricaSorgenti();
</script>
</body>
+111 -25
View File
@@ -10,21 +10,35 @@ OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è.
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
@@ -70,6 +84,8 @@ import logging
import os
import sys
import threading
import uuid # #91 id dei calcoli in attesa di nome
from collections import OrderedDict
from contextlib import asynccontextmanager
from pathlib import Path
@@ -88,6 +104,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")
@@ -133,6 +150,8 @@ def create_app(folder: Path | str | None = None,
aperte: dict = {}
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:
@@ -169,6 +188,12 @@ def create_app(folder: Path | str | None = None,
sorgente = aperte.get(nome)
if sorgente is None:
sorgente = open_source(nome, None, config=_config())
# 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 +245,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 +266,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 +306,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)
+85 -39
View File
@@ -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"
+233
View File
@@ -0,0 +1,233 @@
"""Test dell'archivio unico di calibrazioni con nome (#91): scripts/calibrazione.py, classe Archivio.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Solo tmp_path: niente ProgramData, niente camera.
"""
import json
import pytest
from test_calibrazione import _carica, una_scacchiera_densa
@pytest.fixture(scope="module")
def mappa_densa():
# #91: una mappa vera, calcolata una volta sola per tutti i test del modulo
_carica()
import calibrazione
return calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)[1]
def _dati(**extra):
base = {"px_per_mm": 20.0, "mm_per_px": 0.05, "image_width": 640, "image_height": 480,
"metodo": "planare", "square_mm": 2.0, "calibrated_at": "2026-10-24T10:00:00+00:00",
"origine": "file:s.png"}
return {**base, **extra}
@pytest.fixture
def cal():
_carica()
import calibrazione
return calibrazione
# ---- nomi
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x00b", "a\nb", "a\tb", None, 5])
def test_nome_non_valido(cal, tmp_path, nome):
with pytest.raises(cal.NomeNonValido):
cal.Archivio(tmp_path).salva_nome(nome, _dati())
def test_nome_valido_limiti_e_strip(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("x" * 60, _dati())
meta = arch.salva_nome(" Banco 1 ", _dati())
assert meta["nome"] == "Banco 1" # strip
assert arch.leggi_nome("Banco 1")["nome"] == "Banco 1"
def test_nome_unico_senza_maiuscole(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
with pytest.raises(cal.NomeGiaUsato, match="Banco"):
arch.salva_nome("BANCO", _dati(px_per_mm=2.0))
assert arch.leggi_nome("banco")["px_per_mm"] == 1.0 # non toccata
assert len(arch.elenco()) == 1
def test_sovrascrivi_sostituisce_e_prende_il_nuovo_nome(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
arch.salva_nome("BANCO", _dati(px_per_mm=2.0), sovrascrivi=True)
voci = arch.elenco()
assert len(voci) == 1 and voci[0]["nome"] == "BANCO" and voci[0]["px_per_mm"] == 2.0
assert len(list((tmp_path / "calibrazioni").glob("*.json"))) == 1
def test_salva_non_scrive_source_ne_overlay(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("A", _dati(source="camera:x", overlay_png="abc", saved=True))
scritto = json.loads(next((tmp_path / "calibrazioni").glob("*.json")).read_text(encoding="utf-8"))
assert "source" not in scritto and "overlay_png" not in scritto and "saved" not in scritto
assert scritto["nome"] == "A" and scritto["origine"] == "file:s.png"
def test_leggi_nome_inesistente_o_non_valido(cal, tmp_path):
arch = cal.Archivio(tmp_path)
assert arch.leggi_nome("nessuna") is None and arch.leggi_nome("") is None
# ---- elenco e attive
def test_elenco_dalla_piu_recente_e_attiva_su(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("vecchia", _dati(calibrated_at="2026-01-01T00:00:00+00:00"))
arch.salva_nome("nuova", _dati(calibrated_at="2026-10-01T00:00:00+00:00"))
arch.attiva("camera:b", "nuova")
arch.attiva("camera:a", "nuova")
voci = arch.elenco()
assert [v["nome"] for v in voci] == ["nuova", "vecchia"]
assert voci[0]["attiva_su"] == ["camera:a", "camera:b"] and voci[1]["attiva_su"] == []
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm",
"mm_per_px", "metodo", "square_mm", "calibrated_at", "attiva_su"}
def test_attiva_leggi_scollega(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati())
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
assert arch.attiva("camera:a", "banco") == "Banco" # nome come in libreria
assert arch.attiva_di("camera:a") == "Banco"
letta = arch.leggi("camera:a")
assert letta["nome"] == "Banco" and letta["source"] == "camera:a" and letta["px_per_mm"] == 20.0
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:a": "Banco"}
arch.scollega("camera:a")
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
assert arch.leggi_nome("Banco") is not None # la libreria resta
arch.scollega("camera:a") # idempotente
def test_attiva_nome_sconosciuto(cal, tmp_path):
with pytest.raises(cal.NomeSconosciuto):
cal.Archivio(tmp_path).attiva("camera:a", "nessuna")
def test_due_sorgenti_stessa_calibrazione(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Comune", _dati())
arch.attiva("camera:a", "Comune")
arch.attiva("camera:b", "Comune")
assert arch.leggi("camera:a")["source"] == "camera:a" and arch.leggi("camera:b")["source"] == "camera:b"
def test_elimina_nome_toglie_da_attive_e_mappa(cal, tmp_path, mappa_densa):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
arch.salva_nome("Altra", _dati())
arch.attiva("camera:a", "Densa")
arch.attiva("camera:b", "Densa")
arch.attiva("camera:c", "Altra")
assert arch.leggi_mappa("camera:a") is not None
assert arch.elimina_nome("DENSA") is True
assert arch.leggi_nome("Densa") is None and not list((tmp_path / "calibrazioni").glob("*.npz"))
assert arch.attiva_di("camera:a") is None and arch.attiva_di("camera:b") is None
assert arch.leggi_mappa("camera:a") is None
assert arch.attiva_di("camera:c") == "Altra"
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:c": "Altra"}
assert arch.elimina_nome("Densa") is False # gia' tolta
# ---- mappa
def test_leggi_mappa_risolve_l_attiva_con_cache(cal, tmp_path, mappa_densa):
arch = cal.Archivio(tmp_path)
assert arch.leggi_mappa("camera:a") is None
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
assert arch.leggi_mappa("camera:a") is None # non attiva su camera:a
arch.attiva("camera:a", "Densa")
prima = arch.leggi_mappa("camera:a")
assert prima is not None and arch.leggi_mappa("camera:a") is prima # cache in memoria
assert arch.leggi_mappa("camera:b") is None
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa, sovrascrivi=True) # file riscritto
assert arch.leggi_mappa("camera:a") is not prima # cache invalidata
arch.salva_nome("Densa", _dati(), sovrascrivi=True) # sovrascritta con una planare: la mappa sparisce
assert arch.leggi_mappa("camera:a") is None and not list(tmp_path.rglob("*.npz"))
# ---- migrazione dal formato vecchio
def _vecchia(cartella, cal, source, mappa=None, **extra):
"""Scrive una calibrazione nel formato vecchio (un JSON per sorgente, + .npz) come faceva Archivio.salva."""
dati = {"source": source, **_dati(**extra)}
dati.pop("origine")
nudo = cal.Archivio.__new__(cal.Archivio) # senza costruttore: niente migrazione mentre si prepara
nudo.cartella = cartella
percorso = nudo._file(source)
cartella.mkdir(parents=True, exist_ok=True)
percorso.write_text(json.dumps(dati), encoding="utf-8")
if mappa is not None:
mappa.save(str(percorso.with_suffix(".npz")))
return percorso
def test_migrazione_formato_vecchio(cal, tmp_path, mappa_densa):
v1 = _vecchia(tmp_path, cal, "camera:601020", mappa_densa, metodo="densa", px_per_mm=7.0)
v2 = _vecchia(tmp_path, cal, "camera:folder", px_per_mm=9.0)
(tmp_path / "settaggi").mkdir()
(tmp_path / "settaggi" / v1.name).write_text("{}", encoding="utf-8") # non si tocca
arch = cal.Archivio(tmp_path)
assert not v1.exists() and not v1.with_suffix(".npz").exists() and not v2.exists()
assert {v["nome"] for v in arch.elenco()} == {"camera:601020", "camera:folder"}
assert arch.attiva_di("camera:601020") == "camera:601020" and arch.attiva_di("camera:folder") == "camera:folder"
letta = arch.leggi("camera:601020")
assert letta["px_per_mm"] == 7.0 and letta["source"] == "camera:601020"
assert arch.leggi_mappa("camera:601020") is not None # il .npz ha seguito il JSON
assert len(list((tmp_path / "calibrazioni").glob("*.npz"))) == 1
assert (tmp_path / "settaggi" / v1.name).read_text(encoding="utf-8") == "{}"
def test_migrazione_idempotente(cal, tmp_path):
_vecchia(tmp_path, cal, "camera:a")
cal.Archivio(tmp_path)
prima = sorted(p.name for p in tmp_path.rglob("*") if p.is_file())
arch = cal.Archivio(tmp_path) # secondo avvio
arch.migra()
assert sorted(p.name for p in tmp_path.rglob("*") if p.is_file()) == prima
assert [v["nome"] for v in arch.elenco()] == ["camera:a"]
def test_migrazione_nome_gia_preso(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("camera:a", _dati(px_per_mm=1.0, calibrated_at="2020-01-01T00:00:00+00:00"))
_vecchia(tmp_path, cal, "camera:a", px_per_mm=5.0)
arch.migra()
nomi = {v["nome"]: v["px_per_mm"] for v in arch.elenco()}
assert nomi == {"camera:a": 1.0, "camera:a (2)": 5.0}
assert arch.attiva_di("camera:a") == "camera:a (2)" # la sorgente usa la sua, non quella gia' presente
# un altro file con lo stesso nome -> (3)
_vecchia(tmp_path, cal, "camera:A", px_per_mm=6.0) # nome uguale senza maiuscole
arch.migra()
assert "camera:A (3)" in {v["nome"] for v in arch.elenco()}
def test_migrazione_ignora_json_senza_source_e_attive(cal, tmp_path):
tmp_path.mkdir(exist_ok=True)
(tmp_path / "altro.json").write_text(json.dumps({"x": 1}), encoding="utf-8")
(tmp_path / "rotto.json").write_text("{non json", encoding="utf-8")
arch = cal.Archivio(tmp_path)
arch.salva_nome("A", _dati())
arch.attiva("source", "A") # una sorgente chiamata «source» non deve far scambiare attive.json per un vecchio file
arch.migra()
assert (tmp_path / "altro.json").exists() and (tmp_path / "rotto.json").exists()
assert arch.attiva_di("source") == "A" and [v["nome"] for v in arch.elenco()] == ["A"]
def test_cartella_assente_non_da_errore(cal, tmp_path):
arch = cal.Archivio(tmp_path / "non" / "esiste")
assert arch.elenco() == [] and arch.leggi("s") is None and arch.leggi_mappa("s") is None
arch.scollega("s")
assert arch.elimina_nome("x") is False
+250
View File
@@ -0,0 +1,250 @@
"""Test delle rotte dell'archivio unico di calibrazioni con nome (#91): scripts/camera_service.py.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgenti: camera:folder su cartelle temporanee (520x400 scacchiera piana, 640x480 densa).
Solo tmp_path: niente ProgramData, niente camera vera.
"""
import json
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
CAMPI, CHIAVI_ATTIVA, CHIAVI_DENSA, CHIAVI_PLANARE, SORGENTE, _carica, _png,
carica_su, cartella, cartella_densa, client, client_denso, salva_con_nome,
una_scacchiera, una_scacchiera_densa,
)
__all__ = ["cartella", "cartella_densa", "client", "client_denso"] # le fixture importate restano usate
def _calcola_file(c, **extra):
"""Calcolo planare da un'immagine (520x400): ritorna la risposta JSON."""
r = c.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI, **extra},
files={"image": ("scacco.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
return r.json()
# ---- POST /camera/calibrate
def test_calibrate_senza_source_con_immagine_ok(client):
r = client.post("/camera/calibrate", data=CAMPI,
files={"image": ("C:\\foto\\scacco.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
assert r.json()["origine"] == "file:scacco.png" # solo il nome del file, non il percorso
def test_calibrate_senza_source_e_senza_immagine_400(client):
r = client.post("/camera/calibrate", data=CAMPI)
assert r.status_code == 400 and "source" in r.json()["detail"]
def test_calibrate_da_sorgente_ha_origine_camera_e_id(client):
r = client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI})
assert r.status_code == 200, r.text
corpo = r.json()
assert corpo["origine"] == SORGENTE and corpo["calibrated_at"]
assert len(corpo["calcolo_id"]) == 32 and int(corpo["calcolo_id"], 16) >= 0 # esadecimale
assert corpo["calcolo_id"] != client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI}).json()["calcolo_id"]
# ---- POST /camera/calibrazioni
def test_salva_201_con_metadati(client):
calcolo = _calcola_file(client)
r = salva_con_nome(client, calcolo, " Banco 1 ")
assert r.status_code == 201, r.text
meta = r.json()
assert meta["nome"] == "Banco 1" and meta["origine"] == "file:scacco.png"
assert (meta["image_width"], meta["image_height"]) == (520, 400)
assert meta["metodo"] == "planare" and meta["attiva_su"] == []
assert meta["px_per_mm"] == pytest.approx(20.0, rel=0.005)
# lo stesso calcolo si puo' salvare anche con un altro nome
assert salva_con_nome(client, calcolo, "Banco 2").status_code == 201
def test_salva_calcolo_sconosciuto_404(client):
r = client.post("/camera/calibrazioni", json={"calcolo_id": "0" * 32, "nome": "X"})
assert r.status_code == 404 and r.json()["detail"] == "calcolo non più disponibile: ricalcolare"
def test_salva_calcolo_scaduto_dopo_otto_calcoli_404(client):
primo = _calcola_file(client)
ultimo = primo
for _ in range(8): # il nono calcolo spinge fuori il primo
ultimo = _calcola_file(client)
assert salva_con_nome(client, primo, "Vecchio").status_code == 404
assert salva_con_nome(client, ultimo, "Nuovo").status_code == 201
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x01b", None, 7])
def test_salva_nome_non_valido_400(client, nome):
r = client.post("/camera/calibrazioni", json={"calcolo_id": _calcola_file(client)["calcolo_id"], "nome": nome})
assert r.status_code == 400, r.text
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
def test_salva_corpo_incompleto_400(client):
assert client.post("/camera/calibrazioni", json={"nome": "X"}).status_code == 400
calcolo = _calcola_file(client)
r = client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": "X", "sovrascrivi": "si"})
assert r.status_code == 400
def test_salva_nome_gia_usato_409_e_sovrascrivi(client):
a = _calcola_file(client)
assert salva_con_nome(client, a, "Banco").status_code == 201
r = salva_con_nome(client, a, "BANCO") # senza distinguere maiuscole
assert r.status_code == 409 and r.json()["detail"] == "esiste già una calibrazione «Banco»"
assert len(client.get("/camera/calibrazioni").json()["calibrazioni"]) == 1
r = salva_con_nome(client, a, "BANCO", sovrascrivi=True)
assert r.status_code == 201 and r.json()["nome"] == "BANCO"
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
assert [v["nome"] for v in voci] == ["BANCO"]
# ---- GET /camera/calibrazioni
def test_elenco_vuoto_e_ordine(client):
assert client.get("/camera/calibrazioni").json() == {"calibrazioni": [], "attiva": None}
prima = _calcola_file(client)
salva_con_nome(client, prima, "prima")
seconda = _calcola_file(client)
seconda_ts = seconda["calibrated_at"]
salva_con_nome(client, seconda, "seconda")
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
assert {v["nome"] for v in voci} == {"prima", "seconda"}
assert [v["calibrated_at"] for v in voci] == sorted((v["calibrated_at"] for v in voci), reverse=True)
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm", "mm_per_px",
"metodo", "square_mm", "calibrated_at", "attiva_su"}
assert seconda_ts >= prima["calibrated_at"]
def test_elenco_con_source_dice_l_attiva(client):
salva_con_nome(client, _calcola_file(client), "Banco")
salva_con_nome(client, _calcola_file(client), "Altra")
assert carica_su(client, SORGENTE, "banco").status_code == 200
r = client.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
assert r["attiva"] == "Banco"
attiva_su = {v["nome"]: v["attiva_su"] for v in r["calibrazioni"]}
assert attiva_su == {"Banco": [SORGENTE], "Altra": []}
assert client.get("/camera/calibrazioni", params={"source": "camera:altra"}).json()["attiva"] is None
assert client.get("/camera/calibrazioni", params={"source": " "}).status_code == 400
# ---- DELETE /camera/calibrazioni
def test_elimina_204_404_e_toglie_dalle_sorgenti(client):
salva_con_nome(client, _calcola_file(client), "Banco")
carica_su(client, SORGENTE, "Banco")
assert client.delete("/camera/calibrazioni", params={"nome": "BANCO"}).status_code == 204
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
assert client.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # non piu' attiva
assert json.loads((client.config / "attive.json").read_text(encoding="utf-8")) == {}
assert client.delete("/camera/calibrazioni", params={"nome": "Banco"}).status_code == 404
assert client.delete("/camera/calibrazioni").status_code == 404 # senza nome
# ---- PUT /camera/calibrazione-attiva
def test_carica_risoluzione_uguale_200_come_get(client):
salva_con_nome(client, _calcola_file(client), "Banco") # 520x400 = la scacchiera in cartella
r = carica_su(client, SORGENTE, "banco")
assert r.status_code == 200, r.text
assert set(r.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA
assert r.json()["nome"] == "Banco" and r.json()["source"] == SORGENTE
assert client.get("/camera/calibration", params={"source": SORGENTE}).json() == r.json()
def test_carica_risoluzione_diversa_409_e_anteprima_continua(client_denso):
# calibrazione da file 520x400, sorgente 640x480
salva_con_nome(client_denso, _calcola_file(client_denso), "Piccola")
r = carica_su(client_denso, SORGENTE, "Piccola")
assert r.status_code == 409
assert r.json()["detail"] == ("la calibrazione «Piccola» è per immagini 520×400, "
"la sorgente dà 640×480: non si può caricare")
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # nulla attivato
# l'acquisizione continua: grab e anteprima rispondono, frame non raddrizzato
g = client_denso.post("/camera/grab", json={"source": SORGENTE})
assert g.status_code == 200 and g.headers["x-raddrizzato"] == "no"
a = client_denso.post("/camera/anteprima", json={"source": SORGENTE})
assert a.status_code == 200 and a.headers["x-raddrizzato"] == "no"
assert (a.headers["x-larghezza"], a.headers["x-altezza"]) == ("640", "480")
def test_carica_risoluzione_diversa_non_scalza_l_attiva_precedente(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
salva_con_nome(client_denso, densa, "Giusta")
salva_con_nome(client_denso, _calcola_file(client_denso), "Sbagliata")
assert carica_su(client_denso, SORGENTE, "Giusta").status_code == 200
assert carica_su(client_denso, SORGENTE, "Sbagliata").status_code == 409
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()["nome"] == "Giusta"
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
def test_carica_densa_su_sorgente_stessa_risoluzione_raddrizza(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
r = carica_su(client_denso, SORGENTE, "Densa")
assert r.status_code == 200 and set(r.json()) == CHIAVI_DENSA | CHIAVI_ATTIVA
assert client_denso.post("/camera/anteprima", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
def test_carica_nome_sconosciuto_404(client):
r = carica_su(client, SORGENTE, "nessuna")
assert r.status_code == 404
assert client.put("/camera/calibrazione-attiva", json={"source": SORGENTE}).status_code == 404 # senza nome
assert client.put("/camera/calibrazione-attiva", json={"nome": "x"}).status_code == 400 # senza source
def test_carica_su_sorgente_inesistente_404(client):
salva_con_nome(client, _calcola_file(client), "Banco")
assert carica_su(client, "camera:non-esiste", "Banco").status_code == 404
def test_carica_senza_risoluzione_registrata_409(cartella, tmp_path):
_carica()
import calibrazione
config = tmp_path / "config_senza"
calibrazione.Archivio(config).salva_nome("Senza", {"px_per_mm": 10.0, "calibrated_at": "2026-01-01T00:00:00+00:00"})
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
r = carica_su(c, SORGENTE, "Senza")
assert r.status_code == 409
assert r.json()["detail"] == "la calibrazione «Senza» non ha la risoluzione registrata: non si può caricare"
# ---- contratto verso il backend e migrazione all'avvio
def test_get_calibration_contratto_per_il_backend(client):
# src/backend/services/vision_service.py legge questi campi
salva_con_nome(client, _calcola_file(client), "Banco")
carica_su(client, SORGENTE, "Banco")
dati = client.get("/camera/calibration", params={"source": SORGENTE}).json()
for campo in ("px_per_mm", "mm_per_px", "image_width", "image_height", "calibrated_at", "metodo"):
assert campo in dati
assert client.get("/camera/calibration", params={"source": "camera:altra"}).json() == {
"detail": "sorgente non calibrata"}
def test_avvio_migra_il_formato_vecchio(cartella, tmp_path):
_carica()
import calibrazione
config = tmp_path / "config_vecchia"
config.mkdir()
nudo = calibrazione.Archivio.__new__(calibrazione.Archivio) # per avere il nome file del formato vecchio
nudo.cartella = config
vecchio = nudo._file(SORGENTE)
vecchio.write_text(json.dumps({"source": SORGENTE, "px_per_mm": 20.0, "mm_per_px": 0.05,
"image_width": 520, "image_height": 400, "metodo": "planare",
"calibrated_at": "2026-10-01T10:00:00+00:00"}), encoding="utf-8")
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
dati = c.get("/camera/calibration", params={"source": SORGENTE}).json()
assert dati["nome"] == SORGENTE and dati["source"] == SORGENTE and dati["px_per_mm"] == 20.0
voci = c.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
assert [v["nome"] for v in voci["calibrazioni"]] == [SORGENTE] and voci["attiva"] == SORGENTE
assert voci["calibrazioni"][0]["attiva_su"] == [SORGENTE]
assert not vecchio.exists()
+34
View File
@@ -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):
+54
View File
@@ -0,0 +1,54 @@
"""Test della pagina Acquisizione camera (#87): 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", _FILE)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
PAGINA = _mod.PAGINA
def test_titolo_e_stile_scuro():
assert "<title>Acquisizione camera</title>" in PAGINA
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
def test_rotte_usate():
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
assert '"' + rotta in PAGINA, rotta
def test_offline_e_senza_finestre_del_browser():
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
assert vietato not in PAGINA, vietato
def test_id_principali():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"sorgente", "avvia", "ferma", "scatta", "grezzo", "mirinoCasella", "immagine",
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
"parametri", "salvaParametri", "quadrato", "acquisisci", "scegliFile", "file",
"carica", "salva", "elimina", "stato", "statoSorgente"}
assert attesi <= ids, attesi - ids
# ogni id usato dal JS con $("...") esiste nel markup
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
def test_sezioni_e_testi_italiani():
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
"Salva come predefiniti", "Scegli immagine...", "Questa sorgente non ha parametri regolabili",
"Immagine grezza (senza correzione)"):
assert testo in PAGINA, testo
assert "it-IT" in PAGINA # numeri con la virgola
assert "Choose File" not in PAGINA
def test_localstorage_protetto():
# ogni uso di localStorage sta dentro un try
for m in re.finditer(r"localStorage[.]", PAGINA):
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
@@ -1,4 +1,6 @@
"""Admin blueprint - user management."""
from urllib.parse import urlparse # #88: validazione url camera
import requests as http_requests # #88: lettura sorgenti dall'acquisitore (come maker.py)
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
@@ -170,6 +172,39 @@ def api_toggle_active(user_id: int):
# --- Stations ---
@admin_bp.route("/api/camera/sources", methods=["GET"])
@login_required
@admin_required
def api_camera_sources():
"""Proxy (#88): elenco sorgenti di un acquisitore, GET <url>/camera/sources."""
url = (request.args.get("url") or "").strip()
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return jsonify({"error": True, "detail": _("Indirizzo della camera non valido")}), 400
try:
resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=5)
except http_requests.RequestException as exc:
return jsonify({
"error": True,
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
invalid = (jsonify({"error": True, "detail": _("Risposta non valida dal servizio camera")}), 502)
if resp.status_code != 200:
return invalid
try:
sources = resp.json().get("sources")
except (ValueError, AttributeError):
return invalid
if not isinstance(sources, list):
return invalid
# solo i tre campi che la UI usa, sempre stringhe
clean = [
{k: str(s.get(k, "")) for k in ("name", "label", "kind")}
for s in sources if isinstance(s, dict)
]
return jsonify({"sources": clean}), 200
@admin_bp.route("/api/stations", methods=["POST"])
@login_required
@admin_required
+1 -1
View File
@@ -598,7 +598,7 @@ def api_station_camera_calibration(station_id: int):
}), 409
camera_url, camera_source = camera
base = camera_url.rstrip("/")
link = f"{base}/calibrazione" # pagina di calibrazione dell'acquisitore (C4)
link = f"{base}/acquisizione" # pagina di acquisizione dell'acquisitore (#88; era /calibrazione, C4)
try:
resp = http_requests.get(
f"{base}/camera/calibration",
@@ -40,8 +40,8 @@ function stationCalibrationCell(station, msgs) {
if (!this.hasCamera) return;
const r = await fetchStationCalibration(station.id);
this.state = r.state || 'unreachable';
// Link «Calibra»: dal proxy, altrimenti costruito dall'indirizzo della camera
this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/calibrazione');
// Link «Acquisizione» (#88, era «Calibra»): dal proxy, altrimenti costruito dall'indirizzo della camera
this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/acquisizione');
if (this.state === 'calibrated') this.label = formatCalibration(r.calibration || {});
else if (this.state === 'uncalibrated') this.label = this.msgs.uncalibrated || 'not calibrated';
else this.label = this.msgs.unreachable || 'camera service unreachable';
@@ -97,7 +97,7 @@
</span>
<a :href="calibrationUrl" target="_blank" rel="noopener"
x-show="calibrationUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
class="font-medium text-primary hover:underline">{{ _('Acquisizione') }}</a> {# #88: era «Calibra» #}
</div>
</template>
</td>
@@ -238,21 +238,42 @@
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label>
<input type="text" x-model="form.camera_url"
<input type="text" x-model="form.camera_url" @change="loadCameraSources()"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="host:8765"
maxlength="500">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale') }}</p>
</div>
{# Sorgente camera (#88): testo libero con suggerimenti letti dall'acquisitore (datalist) #}
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
<input type="text" x-model="form.camera_source"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="camera:folder"
maxlength="200">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Es. camera:<seriale> oppure camera:folder') }}</p>
<div class="flex gap-2">
<input type="text" x-model="form.camera_source" list="cameraSourcesList"
class="flex-1 min-w-0 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="camera:folder"
maxlength="200">
<datalist id="cameraSourcesList">
<template x-for="s in cameraSources" :key="s.name">
<option :value="s.name" :label="s.label"></option>
</template>
</datalist>
<button type="button" @click="loadCameraSources()"
:disabled="!(form.camera_url || '').trim() || cameraSourcesLoading"
class="px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-medium
text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap">
{{ _('Leggi sorgenti') }}
</button>
</div>
<p class="mt-1 text-xs"
:class="cameraSourcesError ? 'text-red-600 dark:text-red-400' : 'text-[var(--text-secondary)]'"
x-text="cameraSourcesStatus || {{ _('Es. camera:<seriale> oppure camera:folder')|tojson_attr }}"></p>
<p x-show="cameraSourceMissing" class="mt-1 text-xs text-amber-700 dark:text-amber-300">{{ _("Sorgente non presente nell'acquisitore") }}</p>
<a x-show="(form.camera_url || '').trim()"
:href="(form.camera_url || '').trim().replace(/\/+$/, '') + '/acquisizione'"
target="_blank" rel="noopener"
class="mt-1 inline-block text-xs font-medium text-primary hover:underline">{{ _('Apri pagina acquisizione') }}</a>
</div>
<div>
@@ -509,6 +530,12 @@ function stationManagement(initialStations, initialRecipes) {
camera_source: '', // #72
active: true,
},
// #88: sorgenti lette dall'acquisitore per il datalist della sorgente camera
cameraSources: [],
cameraSourcesLoaded: false,
cameraSourcesLoading: false,
cameraSourcesStatus: '',
cameraSourcesError: false,
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
@@ -556,11 +583,46 @@ function stationManagement(initialStations, initialRecipes) {
return '{{ _("Nessuna ricetta assegnata") }}';
},
// #88: legge le sorgenti dall'acquisitore (proxy admin) e riempie il datalist
async loadCameraSources() {
const url = (this.form.camera_url || '').trim();
this.cameraSources = [];
this.cameraSourcesLoaded = false;
this.cameraSourcesError = false;
if (!url) { this.cameraSourcesStatus = ''; return; }
this.cameraSourcesLoading = true;
this.cameraSourcesStatus = '{{ _("Lettura...") }}';
try {
const resp = await fetch('/admin/api/camera/sources?url=' + encodeURIComponent(url));
const result = await resp.json();
if (!resp.ok) {
this.cameraSourcesError = true;
this.cameraSourcesStatus = result.detail || '{{ _("Risposta non valida dal servizio camera") }}';
return;
}
this.cameraSources = result.sources || [];
this.cameraSourcesLoaded = true;
this.cameraSourcesStatus = '{{ _("%(n)s sorgenti trovate", n="__N__") }}'.replace('__N__', this.cameraSources.length);
} catch (e) {
this.cameraSourcesError = true;
this.cameraSourcesStatus = '{{ _("Risposta non valida dal servizio camera") }}';
} finally {
this.cameraSourcesLoading = false;
}
},
// #88: avviso se la sorgente scritta non e' fra quelle lette
get cameraSourceMissing() {
const src = (this.form.camera_source || '').trim();
return this.cameraSourcesLoaded && src !== '' && !this.cameraSources.some(s => s.name === src);
},
openCreateModal() {
this.isEditing = false;
this.editingId = null;
this.errorMsg = '';
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true };
this.loadCameraSources(); // #88: svuota lo stato delle sorgenti
this.showModal = true;
},
@@ -578,6 +640,7 @@ function stationManagement(initialStations, initialRecipes) {
camera_source: station.camera_source || '', // #72
active: station.active,
};
this.loadCameraSources(); // #88: se la camera e' valorizzata legge subito le sorgenti
this.showModal = true;
},
@@ -254,3 +254,74 @@ def test_create_station_proxy_forwards_the_camera_fields(logged_in_client, mock_
resp = logged_in_client.post("/admin/api/stations", json=body)
assert resp.status_code == 201
mock_admin_api.post.assert_called_once_with("/api/stations", data=body)
# --- Sorgenti camera lette dall'acquisitore (#88) ---
def _fake_sources_response(status=200, payload=None):
resp = MagicMock()
resp.status_code = status
resp.json.return_value = payload
return resp
def test_camera_sources_proxy_keeps_only_the_three_fields(logged_in_client, mock_admin_api):
payload = {"sources": [
{"name": "camera:folder", "label": "Cartella", "kind": "folder", "extra": 1},
{"name": "camera:123", "label": "Basler", "kind": "gige"},
]}
with patch("blueprints.admin.http_requests.get",
return_value=_fake_sources_response(200, payload)) as get:
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765/")
assert resp.status_code == 200
assert resp.get_json()["sources"][0] == {
"name": "camera:folder", "label": "Cartella", "kind": "folder"}
assert len(resp.get_json()["sources"]) == 2
assert get.call_args.args[0] == "http://acq:8765/camera/sources"
assert get.call_args.kwargs["timeout"] == 5
@pytest.mark.parametrize("url", ["ftp://x", "", "http://", "acq:8765"])
def test_camera_sources_proxy_rejects_invalid_url(logged_in_client, mock_admin_api, url):
with patch("blueprints.admin.http_requests.get") as get:
resp = logged_in_client.get("/admin/api/camera/sources", query_string={"url": url})
assert resp.status_code == 400
assert resp.get_json()["error"] is True
get.assert_not_called()
def test_camera_sources_proxy_unreachable_is_502(logged_in_client, mock_admin_api):
import requests
with patch("blueprints.admin.http_requests.get",
side_effect=requests.ConnectionError("rifiutata")):
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765")
assert resp.status_code == 502
assert "rifiutata" in resp.get_json()["detail"]
@pytest.mark.parametrize("status,payload", [(200, {"altro": 1}), (200, {"sources": "x"}), (500, {"sources": []})])
def test_camera_sources_proxy_invalid_response_is_502(logged_in_client, mock_admin_api, status, payload):
with patch("blueprints.admin.http_requests.get",
return_value=_fake_sources_response(status, payload)):
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765")
assert resp.status_code == 502
def test_camera_sources_proxy_non_admin_is_redirected(client, mock_admin_api):
with client.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {"id": 2, "username": "operator", "roles": ["MeasurementTec"],
"is_admin": False, "active": True}
sess["user_id"] = 2
with patch("blueprints.admin.http_requests.get") as get:
resp = client.get("/admin/api/camera/sources?url=http://acq:8765")
assert resp.status_code in (301, 302)
get.assert_not_called()
def test_station_form_has_the_camera_sources_picker(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = []
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
assert "cameraSourcesList" in html
assert "loadCameraSources" in html
assert "/acquisizione" in html
@@ -120,7 +120,7 @@ class TestStationCameraCalibration:
assert resp.status_code == 200
assert body["state"] == "calibrated"
assert body["calibration"]["px_per_mm"] == 20.03
assert body["calibration_url"] == "http://acq:8765/calibrazione"
assert body["calibration_url"] == "http://acq:8765/acquisizione"
args, kwargs = get.call_args
assert args[0] == "http://acq:8765/camera/calibration"
assert kwargs["params"] == {"source": "camera:folder"}
@@ -130,7 +130,7 @@ class TestStationCameraCalibration:
resp, _ = self._get(logged_in_client, _fake(404, b"{}", {"detail": "no"}))
assert resp.status_code == 200
assert resp.get_json()["state"] == "uncalibrated"
assert resp.get_json()["calibration_url"] == "http://acq:8765/calibrazione"
assert resp.get_json()["calibration_url"] == "http://acq:8765/acquisizione"
def test_unreachable(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
@@ -3452,3 +3452,36 @@ msgstr "Try"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta"
msgstr "Save recipe"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Acquisizione"
msgstr "Acquisition"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Leggi sorgenti"
msgstr "Read sources"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Lettura..."
msgstr "Reading..."
#: templates/admin/stations.html blueprints/admin.py (#88)
#, python-format
msgid "%(n)s sorgenti trovate"
msgstr "%(n)s sources found"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Sorgente non presente nell'acquisitore"
msgstr "Source not present in the acquisition service"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Apri pagina acquisizione"
msgstr "Open acquisition page"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Indirizzo della camera non valido"
msgstr "Invalid camera address"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera"
msgstr "Invalid response from the camera service"
@@ -3440,3 +3440,36 @@ msgstr "Prova"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta"
msgstr "Salva ricetta"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Acquisizione"
msgstr "Acquisizione"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Leggi sorgenti"
msgstr "Leggi sorgenti"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Lettura..."
msgstr "Lettura..."
#: templates/admin/stations.html blueprints/admin.py (#88)
#, python-format
msgid "%(n)s sorgenti trovate"
msgstr "%(n)s sorgenti trovate"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Sorgente non presente nell'acquisitore"
msgstr "Sorgente non presente nell'acquisitore"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Apri pagina acquisizione"
msgstr "Apri pagina acquisizione"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Indirizzo della camera non valido"
msgstr "Indirizzo della camera non valido"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera"
msgstr "Risposta non valida dal servizio camera"