Compare commits

...

35 Commits

Author SHA1 Message Date
Adriano 444dc0a6d1 backend: immagini BMP ammesse nelle rotte visione (Refs #99)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:46:58 +01:00
Adriano 4b1b23cfb6 Merge branch 'lavoro/daitarn-tmf-97c' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:04 +02:00
Adriano 4b57efc73c Merge branch 'lavoro/daitarn-tmf-97b' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:03 +02:00
Adriano 7c03f53000 Merge branch 'lavoro/daitarn-tmf-97a' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:03 +02:00
Adriano 67337fa232 operatore: immagine gia' analizzata all'ingresso, analisi impulsiva o in linea, Registra (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:30:31 +02:00
Adriano 661b782bad backend: test camera e modo di analisi nel task, /vision/check (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:26:26 +02:00
Adriano 25f8b4d609 maker: camera e modo di analisi nel task visione (Refs #97)
Rotte /maker/api/camera/servizi e /maker/api/camera/sorgenti (url ammesso solo
se e' un servizio delle stazioni), sezione «Acquisizione» nell'editor task
(servizio, sorgente, analisi impulsiva/in linea) salvata come acquisition_json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 18:26:00 +02:00
Adriano b98d1f15f4 operatore: immagine del task visione in fit, pagina senza scorrimento (Refs #96)
Pannello visione overflow-hidden/min-h-0, immagine e overlay object-contain
sovrapposti nel riquadro flex-1, colonna esito con scorrimento proprio.
Test sulle classi del template.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:20:15 +02:00
Adriano 8f9731839a backend: camera e modo di analisi nel task, /vision/check senza salvataggio (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:20:14 +02:00
Adriano 552f96bb47 acquisitore: calibrazione manuale con mm/px impostato a mano (Refs #95)
POST /camera/calibrazioni/manuale salva una voce senza mappa (metodo manuale);
anteprima/calibrata senza mappa risponde il frame grezzo (X-Raddrizzato: no);
scheda «Calibrazione manuale» nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:36:45 +02:00
Adriano 71daab184f acquisitore: il frame bloccato e' quello mostrato (Refs #94)
Con «Blocca immagine» la pagina aspetta l'anteprima in volo (max 3 s) e sospende
ciclo e «Scatta» prima di POST /camera/blocca, cosi' la copia del servizio e' l'ultimo
frame visibile nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:31:38 +02:00
Adriano 9beaf2ed67 acquisitore: immagine viva in Calibrazione e «Blocca immagine» per il calcolo (Refs #94)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:25:28 +02:00
Adriano 800ec41254 acquisitore: «Attiva calibrazione» con scelta e CONFERMA sotto l'immagine calibrata (Refs #93)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:03:16 +02:00
Adriano 9c45a51770 acquisitore: elenco sorgenti in cache, calibrata chiesta solo se serve (Refs #92)
Servizio: la ricerca camere di /camera/sources resta 60 s in cache (lock,
riscaldamento all'avvio, ?aggiorna=true la forza); le immagini caricate si
leggono sempre fresche. Pagina: l'immagine caricata entra nella select senza
rileggere l'elenco, pulsante Aggiorna, nessuna richiesta calibrata se la
sorgente non ha calibrazione attiva o dopo un 409.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:41:18 +02:00
Adriano 4a48af2ae3 merge #92 pagina: sorgente in cima, grezza e calibrata affiancate 2026-10-24 16:28:00 +02:00
Adriano b879563738 merge #92 servizio: immagini come sorgenti, anteprima calibrata 2026-10-24 16:28:00 +02:00
Adriano f504c86491 Acquisizione: immagine da file come sorgente, riquadri grezza e calibrata affiancati (Refs #92)
Pagina: Sorgente in cima alla tab Acquisizione con carica/rimuovi immagine,
riquadro Calibrata con casella, calibrazione sempre dal frame grezzo della
sorgente, ciclo anteprima sospeso nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:27:20 +02:00
Adriano 632fd51050 acquisitore: immagini caricate come sorgenti e anteprima calibrata (Refs #92)
POST/DELETE /camera/immagini (cartella <config>/immagini), elenco sorgenti con le immagini e senza camera:file, apertura per nome di file con loop, frame grezzo tenuto dall'anteprima e POST /camera/anteprima/calibrata che raddrizza senza leggere un frame nuovo. 22 test nuovi.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:26:30 +02:00
Adriano 8eb80e20a8 Acquisizione: elenco calibrazioni a schede, parametri in lettura (Refs #91)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:14:37 +02:00
Adriano b2f82fe779 merge #91 pagina: tab Acquisizione / Calibrazione 2026-10-24 16:10:25 +02:00
Adriano 3c5c5f9bc5 merge #91 servizio: calibrazioni con nome, archivio unico 2026-10-24 16:10:25 +02:00
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 d6cee498ad Acquisizione: pagina a due tab, calibrazioni salvate con nome e carica per sorgente (Refs #91)
Tab Acquisizione | Calibrazione con anteprima sempre visibile; il calcolo non salva piu',
Salva usa calcolo_id e un nome (conferma di sovrascrittura al secondo clic);
elenco unico con Carica / Scollega / Elimina e segno di risoluzione diversa.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:08:02 +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
Adriano 9eb5942459 Acquisitore: anteprima, parametri camera e settaggi di default (#87, piano A)
Rotte nuove nel servizio camera: POST /camera/anteprima (JPEG ridotto con
nitidezza, saturati e media sul frame pieno), GET/PUT /camera/parametri
(esposizione e guadagno con i relativi auto), POST /camera/parametri/salva.
I settaggi salvati vengono ricaricati alla prima apertura della camera.
GET /acquisizione serve la stessa pagina di /calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:36:18 +02:00
41 changed files with 5557 additions and 326 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
File diff suppressed because it is too large Load Diff
+486 -40
View File
@@ -10,23 +10,58 @@ 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
POST /camera/calibrazioni/manuale json {source, nome, mm_per_px, sovrascrivi=false}
-> 201 metadati (#95: mm/px impostato a mano, metodo "manuale", nessuna
mappa; non la attiva). 400 mm/px fuori 0.00001..100 o nome non valido,
409 nome gia' usato; la risoluzione e' quella del frame tenuto o di un grab
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
Il risultato salvato sta in un JSON per sorgente in --config-dir (default
GET /acquisizione -> stessa pagina (#87)
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito).
Header: X-Raddrizzato si|no, X-Larghezza/X-Altezza (frame PIENO),
X-Nitidezza (varianza del Laplaciano), X-Saturati-Pct (% pixel >= 250),
X-Media (livello medio 0-255), tutti sul frame pieno in grigio.
GET /camera/parametri?source=<src> -> {source, regolabile, parametri:
{esposizione, esposizione_auto, guadagno, guadagno_auto: dominio}};
dominio = GenicamSource.dominio (valore, tipo, min/max/passo o scelte).
Esposizione in us, guadagno in dB. Sorgente senza capacita': regolabile=false.
PUT /camera/parametri json {source, valori: {nome: valore}} -> come GET.
Scrive prima gli *_auto, poi esposizione/guadagno (assesta=3).
400 nome ignoto o corpo errato, 409 non regolabile, 422 camera rifiuta
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.
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
@@ -37,6 +72,9 @@ Uso (dalla radice del repo):
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>] \
[--config-dir <cartella calibrazioni>]
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
Immagini caricate (#92): POST/DELETE /camera/immagini, in <config-dir>/immagini; sono sorgenti
a pieno titolo (/camera/sources, grab, anteprima, calibrate). POST /camera/anteprima/calibrata
raddrizza l'ultimo frame grezzo dell'anteprima con la calibrazione attiva, senza leggerne uno nuovo.
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
@@ -49,9 +87,16 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
from __future__ import annotations
import argparse
import logging
import math # #95: mm/px finito nella calibrazione manuale
import os
import re # #92: nome sicuro per le immagini caricate
import sys
import threading
import time # #92: scadenza della cache delle camere
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
import uuid # #91 id dei calcoli in attesa di nome
from collections import OrderedDict
from contextlib import asynccontextmanager
from pathlib import Path
@@ -61,8 +106,8 @@ from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile
from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuori dall'event loop
from fastapi.responses import HTMLResponse, Response
from visionsuite.camera.camera_interface import CameraError, CameraGrabError
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
from visionsuite.camera.sources import _ESTENSIONI, SourceNotFound, list_sources, open_source # #92: _ESTENSIONI = tipi ammessi per le immagini caricate
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -70,6 +115,13 @@ 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
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
_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")
# #87: in scrittura prima gli automatismi (un valore manuale con auto acceso viene riscritto)
_ORDINE_SCRITTURA = ("esposizione_auto", "guadagno_auto", "esposizione", "guadagno")
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
_MESSAGGI = {
@@ -108,8 +160,13 @@ def create_app(folder: Path | str | None = None,
archivio = calibrazione.Archivio(config_dir if config_dir is not None
else _config_dir_default()) # #83
aperte: dict = {}
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
bloccati: dict = {} # #94: sorgente -> copia del frame grezzo pieno «bloccata» per il calcolo (stesso lock di grezzi)
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
lock_calcoli = threading.Lock()
def _config() -> dict:
if folder is None:
@@ -120,8 +177,32 @@ def create_app(folder: Path | str | None = None,
with lock:
return lock_sorgenti.setdefault(nome, threading.Lock())
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
cache_camere: dict = {"voci": None, "t": 0.0}
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
def _camere(aggiorna: bool = False) -> list:
with lock_ricerca:
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
if fresca and not aggiorna:
return cache_camere["voci"]
voci = list_sources(None, cameras=True, config=_config())
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
cache_camere["t"] = time.monotonic()
return cache_camere["voci"]
def _prima_ricerca() -> None:
try:
_camere()
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
_log.exception("prima ricerca camere fallita")
@asynccontextmanager
async def _vita(_app):
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
yield
with lock: # alla chiusura rilascia le camere aperte
sorgenti = list(aperte.values())
@@ -135,22 +216,98 @@ def create_app(folder: Path | str | None = None,
app = FastAPI(title="TMF camera service", lifespan=_vita)
@app.get("/camera/sources")
def camera_sources():
voci = list_sources(None, cameras=True, config=_config())
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual")]}
def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
elenco = list(_camere(aggiorna))
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
for v in list_sources(cartella_immagini, cameras=False):
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
return {"sources": elenco}
def _grab(nome: str, timeout: int):
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
def _chiudi(nome: str) -> None:
"""#92: chiude la sorgente aperta e scorda il frame tenuto (immagine sostituita o tolta)."""
with _lock_di(nome):
with lock:
s = aperte.pop(nome, None)
grezzi.pop(nome, None)
bloccati.pop(nome, None) # #94: tolta l'immagine/chiusa la sorgente, anche la copia bloccata
if s is not None:
try:
s.close()
except Exception: # noqa: BLE001 - la chiusura non deve far fallire la rotta
pass
def _nome_immagine(grezzo: str) -> str:
"""#92: solo il nome del file, coi caratteri sicuri di Archivio._file; "" se non ammesso."""
base = Path((grezzo or "").replace("\\", "/")).name
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._")
radice, punto, estensione = pulito.rpartition(".")
if not punto or not radice or f".{estensione.lower()}" not in _ESTENSIONI:
return ""
return f"{radice[:100]}.{estensione}"
@app.post("/camera/immagini", status_code=201)
async def immagini_carica(image: UploadFile | None = File(None)):
if image is None:
raise HTTPException(400, "manca il file «image»")
nome = _nome_immagine(image.filename or "")
if not nome:
raise HTTPException(400, "estensione non ammessa: " + ", ".join(_ESTENSIONI))
contenuto = await image.read()
# #92: 422 se non e' un'immagine; la verifica sta PRIMA della scrittura, cosi' non resta nulla su disco
if not contenuto or cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) is None:
raise HTTPException(422, "il file caricato non è leggibile come immagine")
cartella_immagini.mkdir(parents=True, exist_ok=True)
(cartella_immagini / nome).write_bytes(contenuto) # stesso nome = sostituisce
await run_in_threadpool(_chiudi, nome) # se era aperta, si riapre sul file nuovo
return {"source": nome, "label": f"immagine — {nome}", "kind": "image"}
@app.delete("/camera/immagini", status_code=204)
def immagini_elimina(nome: str = ""):
percorso = cartella_immagini / nome
# #92: solo il nome di un file dentro la cartella, niente percorsi
if not nome or Path(nome).name != nome or not percorso.is_file():
raise HTTPException(404, "immagine non trovata")
_chiudi(nome)
percorso.unlink()
return Response(status_code=204)
def _apri(nome: str):
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
with lock:
sorgente = aperte.get(nome)
if sorgente is None:
sorgente = open_source(nome, cartella_immagini, config=_config()) # #92: un nome di file = immagine caricata
if hasattr(sorgente, "loop"): # #92: l'immagine singola non deve "finire" al secondo grab (anteprima continua)
sorgente.loop = True
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
if hasattr(sorgente, "solo_ultimo_frame"):
try:
sorgente.solo_ultimo_frame()
except Exception as e: # noqa: BLE001 - producer senza il nodo: si apre lo stesso
_log.warning("frame piu' recente non impostabile su %s: %s", nome, e)
_carica_settaggi(nome, sorgente) # #87: predefiniti salvati, se ci sono
with lock:
aperte[nome] = sorgente
return sorgente
def _file_settaggi(nome: str) -> Path:
# #87: stesso schema di nome della calibrazione, in una sottocartella
return archivio.cartella / "settaggi" / archivio._file(nome).name
def _carica_settaggi(nome: str, sorgente) -> None:
percorso = _file_settaggi(nome)
if not percorso.is_file() or not hasattr(sorgente, "carica_settaggi"):
return
try:
sorgente.carica_settaggi(percorso)
except Exception as e: # noqa: BLE001 - un file rotto non blocca l'apertura
_log.warning("settaggi di %s non caricati (%s): %s", nome, percorso, e)
def _con_sorgente(nome: str, fn):
"""#87: fn(sorgente) sotto il lock della sorgente; errori camera -> HTTP."""
try:
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
with lock:
sorgente = aperte.get(nome)
if sorgente is None:
sorgente = open_source(nome, None, config=_config())
with lock:
aperte[nome] = sorgente
return sorgente.grab(timeout)
return fn(_apri(nome))
except SourceNotFound as e:
raise HTTPException(404, _traduci(str(e)))
except TimeoutError as e:
@@ -158,6 +315,10 @@ def create_app(folder: Path | str | None = None,
except (CameraGrabError, CameraError) as e:
raise HTTPException(503, _traduci(str(e)))
def _grab(nome: str, timeout: int):
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente
@app.post("/camera/grab")
def camera_grab(corpo: dict = Body(default=None)):
nome = corpo.get("source") if isinstance(corpo, dict) else None
@@ -175,7 +336,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)
@@ -196,33 +357,43 @@ 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)
bloccata: bool = Form(False), # #94: true = calcola sulla copia bloccata, senza grab
):
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")
elif bloccata: # #94: copia bloccata, nessun grab e nessun lock camera
nome = _sorgente(source)
with lock:
frame = bloccati.get(nome)
if frame is None:
raise HTTPException(409, "nessuna immagine bloccata su questa sorgente")
origine = nome
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
@@ -234,14 +405,289 @@ 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"],
"bloccata": bool(bloccata and not contenuto)} # #94: solo nella risposta (non nel salvato): true se il frame e' la copia bloccata
# ---- #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.post("/camera/calibrazioni/manuale", status_code=201)
def calibrazioni_manuale(corpo: dict = Body(default=None)):
"""#95: calibrazione con mm/px impostato a mano (nessuna scacchiera, nessuna mappa); NON la attiva."""
corpo = corpo if isinstance(corpo, dict) else {}
sorgente = _sorgente(corpo.get("source"))
mm_px = corpo.get("mm_per_px")
if (isinstance(mm_px, bool) or not isinstance(mm_px, (int, float))
or not math.isfinite(mm_px) or not 0.00001 <= mm_px <= 100):
raise HTTPException(400, "mm/px deve essere un numero positivo")
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:
frame = grezzi.get(sorgente)
if frame is None: # nessun frame tenuto: un grab come /camera/grab (404/503/504)
frame = _grab(sorgente, 5000)
alt, larg = frame.shape[:2] # serve al controllo W×H di PUT /camera/calibrazione-attiva
dati = {"px_per_mm": 1.0 / mm_px, "mm_per_px": float(mm_px), "metodo": "manuale",
"image_width": larg, "image_height": alt, "origine": sorgente,
"calibrated_at": calibrazione.ora_iso(), "rms_px": None, "square_mm": None}
try:
return archivio.salva_nome(nome, dati, None, 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)
def _regolabile(s) -> bool:
# #87: ha il vocabolario delle capacita' e ne supporta almeno una delle quattro
if not (hasattr(s, "supporta") and hasattr(s, "dominio")):
return False
return any(s.supporta(n) for n in _PARAMETRI)
def _parametri(nome: str, s) -> dict:
"""#87: forma di GET /camera/parametri; da chiamare sotto lock."""
parametri: dict = {}
if _regolabile(s):
for n in _PARAMETRI:
if not s.supporta(n):
continue
try:
parametri[n] = s.dominio(n)
except CameraStateError as e: # capacita' dichiarata ma non leggibile: si salta
_log.warning("dominio di %s su %s non letto: %s", n, nome, e)
return {"source": nome, "regolabile": bool(parametri), "parametri": parametri}
def _grigio8(frame):
g = frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if g.dtype == np.uint16: # #87: 16 bit -> 8 bit, per soglia e media coerenti
g = (g >> 8).astype(np.uint8)
elif g.dtype != np.uint8:
g = cv2.convertScaleAbs(g, alpha=255.0 / max(float(g.max()), 1.0))
return g
@app.post("/camera/anteprima")
def camera_anteprima(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
grezzo = corpo.get("grezzo", False)
if not isinstance(grezzo, bool):
raise HTTPException(400, "«grezzo» deve essere true o false")
lato = corpo.get("lato_max", 1280)
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
timeout = corpo.get("timeout_ms", 5000)
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
f = s.grab(timeout)
with lock:
grezzi[nome] = f.copy()
return f
frame = _con_sorgente(nome, leggi)
raddrizzato = False
if not grezzo: # come /camera/grab
mappa = archivio.leggi_mappa(nome)
if mappa is not None:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is not None:
frame, raddrizzato = corretto, True
alt, larg = frame.shape[:2]
grigio = _grigio8(frame) # misure sul frame PIENO, non sul ridotto
nitidezza = float(cv2.Laplacian(grigio, cv2.CV_64F).var())
saturati = float((grigio >= 250).mean() * 100.0)
media = float(grigio.mean())
if frame.dtype != np.uint8: # il JPEG vuole 8 bit
frame = grigio
scala = lato / max(alt, larg)
if scala < 1.0: # mai ingrandita
frame = cv2.resize(frame, (max(1, round(larg * scala)), max(1, round(alt * scala))),
interpolation=cv2.INTER_AREA)
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
if not ok:
raise HTTPException(500, "codifica JPEG fallita")
return Response(buf.tobytes(), media_type="image/jpeg", headers={
"X-Raddrizzato": "si" if raddrizzato else "no",
"X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
"X-Media": f"{media:.1f}"})
@app.post("/camera/blocca")
def camera_blocca(corpo: dict = Body(default=None)):
"""#94: copia l'ultimo frame grezzo della sorgente; /camera/calibrate con bloccata=true calcola su quella."""
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
with lock:
frame = grezzi.get(nome)
if frame is None:
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
bloccati[nome] = frame.copy()
alt, larg = frame.shape[:2]
return {"source": nome, "larghezza": larg, "altezza": alt}
@app.delete("/camera/blocca", status_code=204)
def camera_sblocca(source: str = ""):
with lock: # #94: idempotente, 204 anche se non c'era nulla
bloccati.pop(source.strip(), None)
return Response(status_code=204)
@app.post("/camera/anteprima/calibrata")
def camera_anteprima_calibrata(corpo: dict = Body(default=None)):
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo. #95: senza mappa esce grezzo (X-Raddrizzato: no)."""
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
lato = corpo.get("lato_max", 1280)
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
with lock:
frame = grezzi.get(nome)
if frame is None:
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
attiva = archivio.attiva_di(nome)
if attiva is None:
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
mappa = archivio.leggi_mappa(nome)
if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
dati = archivio.leggi(nome) or {}
cw, ch = dati.get("image_width"), dati.get("image_height")
alt, larg = frame.shape[:2]
if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
corretto, raddrizzato = frame, "no"
else:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is None: # risoluzione diversa da quella calibrata
ch, cw = mappa.shape[:2]
alt, larg = frame.shape[:2]
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
raddrizzato = "si"
alt, larg = corretto.shape[:2]
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
corretto = _grigio8(corretto)
scala = lato / max(alt, larg)
if scala < 1.0: # mai ingrandita
corretto = cv2.resize(corretto, (max(1, round(larg * scala)), max(1, round(alt * scala))),
interpolation=cv2.INTER_AREA)
ok, buf = cv2.imencode(".jpg", corretto, [cv2.IMWRITE_JPEG_QUALITY, 85])
if not ok:
raise HTTPException(500, "codifica JPEG fallita")
return Response(buf.tobytes(), media_type="image/jpeg", headers={
"X-Raddrizzato": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
@app.get("/camera/parametri")
def parametri_leggi(source: str = ""):
nome = _sorgente(source)
return _con_sorgente(nome, lambda s: _parametri(nome, s))
@app.put("/camera/parametri")
def parametri_scrivi(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
valori = corpo.get("valori")
if not isinstance(valori, dict) or not valori:
raise HTTPException(400, "manca «valori»")
for n, v in valori.items():
if n not in _PARAMETRI:
raise HTTPException(400, f"parametro non riconosciuto: «{n}»")
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
raise HTTPException(400, f"valore non valido per «{n}»")
def scrivi(s):
if not _regolabile(s):
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
for n in _ORDINE_SCRITTURA: # prima gli *_auto
if n in valori:
try:
s.scrivi(n, valori[n], assesta=3)
except CameraStateError as e:
raise HTTPException(422, _traduci(str(e)))
return _parametri(nome, s) # valori riletti dalla camera
return _con_sorgente(nome, scrivi)
@app.post("/camera/parametri/salva")
def parametri_salva(corpo: dict = Body(default=None)):
nome = _sorgente(corpo.get("source") if isinstance(corpo, dict) else None)
def salva(s):
if not _regolabile(s) or not hasattr(s, "salva_settaggi"):
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
percorso = _file_settaggi(nome)
percorso.parent.mkdir(parents=True, exist_ok=True)
s.salva_settaggi(percorso)
return {"source": nome, "salvati": True, "file": percorso.name}
return _con_sorgente(nome, salva)
@app.get("/calibrazione", response_class=HTMLResponse)
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
def calibrazione_pagina():
return HTMLResponse(PAGINA)
+156
View File
@@ -0,0 +1,156 @@
"""Test di #94: «Blocca immagine» (scripts/camera_service.py) e riquadro vivo nella tab Calibrazione (PAGINA).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgente finta che conta i grab: niente camera vera, niente ProgramData.
"""
import re
import numpy as np
import pytest
from test_calibrazione import _png, cartella, client, una_scacchiera # fixture e helper della calibrazione
from test_immagini_affiancate import _anteprima, _carica, _modulo
from test_pagina_acquisizione import PAGINA
__all__ = ["cartella", "client"] # le fixture importate restano usate
FINTA = "finta"
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
@pytest.fixture
def finta(client, monkeypatch):
"""Sorgente «finta»: il primo grab da' la scacchiera, i successivi un frame bianco (non calibrabile)."""
letture = []
scacchiera = una_scacchiera()
bianco = np.full(scacchiera.shape, 255, np.uint8)
class Finta:
def grab(self, timeout=1000):
letture.append(1)
return scacchiera.copy() if len(letture) == 1 else bianco.copy()
def close(self):
pass
monkeypatch.setitem(_modulo(client), "open_source", lambda *a, **k: Finta())
client.letture = letture
return client
def _blocca(c, nome=FINTA):
return c.post("/camera/blocca", json={"source": nome})
def _calcola(c, **extra):
return c.post("/camera/calibrate", data={"source": FINTA, **CAMPI, **extra})
# ---- servizio
def test_blocca_senza_frame_404(finta):
r = _blocca(finta)
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
def test_blocca_senza_source_400(finta):
assert finta.post("/camera/blocca", json={}).status_code == 400
assert finta.post("/camera/blocca").status_code == 400
def test_anteprima_poi_blocca_200_con_misure(finta):
assert _anteprima(finta, FINTA).status_code == 200
r = _blocca(finta)
alt, larg = una_scacchiera().shape[:2]
assert r.status_code == 200 and r.json() == {"source": FINTA, "larghezza": larg, "altezza": alt}
def test_calibrate_bloccata_usa_la_copia_senza_grab(finta):
assert _anteprima(finta, FINTA).status_code == 200 # frame 1: la scacchiera
assert _blocca(finta).status_code == 200
assert _anteprima(finta, FINTA).status_code == 200 # frame 2: bianco, il grezzo cambia ma la copia no
prima = len(finta.letture)
r = _calcola(finta, bloccata="true")
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is True and r.json()["origine"] == FINTA
assert len(finta.letture) == prima # nessun grab per il calcolo
# senza il blocco il calcolo legge un frame nuovo (bianco): non calibrabile
assert _calcola(finta).status_code == 422
assert len(finta.letture) == prima + 1
def test_calibrate_normale_dice_bloccata_false(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is False
def test_calibrate_bloccata_senza_copia_409(finta):
assert _anteprima(finta, FINTA).status_code == 200 # il grezzo c'e', ma non e' bloccato
r = _calcola(finta, bloccata="true")
assert r.status_code == 409 and r.json()["detail"] == "nessuna immagine bloccata su questa sorgente"
def test_sblocca_204_idempotente_e_toglie_la_copia(finta):
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204 # niente da togliere
assert _anteprima(finta, FINTA).status_code == 200
assert _blocca(finta).status_code == 200
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
assert _calcola(finta, bloccata="true").status_code == 409
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
def test_rimuovere_l_immagine_toglie_la_copia_bloccata(client):
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
assert _anteprima(client, "foto.png").status_code == 200
assert _blocca(client, "foto.png").status_code == 200
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
r = client.post("/camera/calibrate", data={"source": "foto.png", "bloccata": "true", **CAMPI})
assert r.status_code == 409
# ---- pagina (contenuto statico)
def test_pagina_riquadro_e_casella_nella_tab_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"immagineCalTab", "vuotoCalTab", "scattaCal", "bloccaImg", "msgBlocco"} <= ids
assert "Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione" in PAGINA
assert "Blocca immagine" in PAGINA
tab = PAGINA.split('id="pannelloCal"')[1].split('id="sezioneSalvate"')[0]
assert "immagineCalTab" in tab and "bloccaImg" in tab # nella sezione Calibrazione, non altrove
def test_pagina_scegli_tab_non_ferma_piu_il_ciclo():
assert "A.riprendi" not in PAGINA
scegli = PAGINA.split("function scegliTab")[1].split("$(\"tabAcq\").onclick")[0]
assert "fermaAnteprima" not in scegli and "avviaAnteprima" not in scegli
def test_pagina_calcola_manda_bloccata_e_non_ferma_il_ciclo():
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert 'f.append("bloccata", "true")' in calcola
assert "if (bloccata)" in calcola and "else fermaAnteprima();" in calcola
assert "immagine bloccata" in calcola
assert "l'anteprima resta sospesa" not in PAGINA and "si può bloccare l'immagine" in PAGINA
def test_pagina_blocco_rotte_e_cambio_sorgente():
assert '"/camera/blocca"' in PAGINA and '"/camera/blocca?source="' in PAGINA
assert "method: \"DELETE\"" in PAGINA
cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0]
assert "rilasciaBlocco()" in cambio
assert cambio.index("rilasciaBlocco()") < cambio.index("svuotaRiquadri()")
def test_pagina_blocco_attende_l_anteprima_in_volo():
# #94: il frame bloccato e' quello mostrato: flag A.bloccando, attesa di A.inVolo (20 ms, max 3 s), poi POST, flag tolto in finally
blocco = PAGINA.split('$("bloccaImg").onchange')[1].split('$("avvia").onclick')[0]
assert "A.bloccando++" in blocco and "A.bloccando--" in blocco and "finally" in blocco
assert "k < 150" in blocco and "pausa(20)" in blocco
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(1280)")
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
+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", "bloccata"} == set(corpo) # #94: + bloccata; #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", "bloccata"} == 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"
+148
View File
@@ -0,0 +1,148 @@
"""Test di #95: calibrazione manuale (mm/px impostato a mano) in scripts/camera_service.py e nella PAGINA.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgenti = immagini caricate in tmp_path: niente camera vera, niente ProgramData.
"""
import re
import numpy as np
import pytest
from test_calibrazione import _png, cartella, client # fixture della calibrazione
from test_immagini_affiancate import _anteprima, _calibrata, _carica
from test_pagina_acquisizione import PAGINA
__all__ = ["cartella", "client"] # le fixture importate restano usate
SRC = "banco.png" # 64x48
ALTRA = "grande.png" # 80x60, risoluzione diversa
@pytest.fixture
def sorgenti(client):
assert _carica(client, SRC, np.full((48, 64), 120, np.uint8)).status_code == 201
assert _carica(client, ALTRA, np.full((60, 80), 120, np.uint8)).status_code == 201
return client
def _manuale(c, nome="Manuale", mm=0.05, source=SRC, **extra):
return c.post("/camera/calibrazioni/manuale",
json={"source": source, "nome": nome, "mm_per_px": mm, **extra})
# ---- servizio
def test_manuale_201_con_scala_e_risoluzione_del_frame(sorgenti):
r = _manuale(sorgenti, mm=0.05)
assert r.status_code == 201, r.text
m = r.json()
assert m["nome"] == "Manuale" and m["metodo"] == "manuale"
assert m["px_per_mm"] == pytest.approx(20.0) and m["mm_per_px"] == pytest.approx(0.05)
assert (m["image_width"], m["image_height"]) == (64, 48)
assert m["origine"] == SRC and m["square_mm"] is None and m["attiva_su"] == [] # non la attiva
voci = sorgenti.get("/camera/calibrazioni").json()["calibrazioni"]
assert [v["nome"] for v in voci] == ["Manuale"]
def test_manuale_usa_il_frame_tenuto_se_c_e(sorgenti):
assert _anteprima(sorgenti, SRC).status_code == 200 # tiene il grezzo 64x48
r = _manuale(sorgenti)
assert r.status_code == 201 and (r.json()["image_width"], r.json()["image_height"]) == (64, 48)
@pytest.mark.parametrize("valore", [0, -1, 0.000001, 100.5, "abc", None, True])
def test_manuale_mm_non_valido_400(sorgenti, valore):
r = _manuale(sorgenti, mm=valore)
assert r.status_code == 400
assert "mm/px deve essere un numero positivo" in r.json()["detail"]
def test_manuale_senza_source_o_nome_400_e_sorgente_ignota_404(sorgenti):
assert sorgenti.post("/camera/calibrazioni/manuale", json={"nome": "x", "mm_per_px": 0.1}).status_code == 400
assert _manuale(sorgenti, nome=" ").status_code == 400
assert _manuale(sorgenti, source="nonesiste.png").status_code == 404
def test_manuale_409_nome_doppio_e_sovrascrivi(sorgenti):
assert _manuale(sorgenti, mm=0.05).status_code == 201
r = _manuale(sorgenti, mm=0.1)
assert r.status_code == 409 and "esiste già una calibrazione" in r.json()["detail"]
r = _manuale(sorgenti, mm=0.1, sovrascrivi=True)
assert r.status_code == 201 and r.json()["px_per_mm"] == pytest.approx(10.0)
assert _manuale(sorgenti, mm=0.1, sovrascrivi="si").status_code == 400
def test_attiva_manuale_ok_su_stessa_risoluzione_e_legge_px_per_mm(sorgenti):
assert _manuale(sorgenti, mm=0.05).status_code == 201
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"})
assert r.status_code == 200, r.text
g = sorgenti.get("/camera/calibration", params={"source": SRC})
assert g.status_code == 200 and g.json()["px_per_mm"] == pytest.approx(20.0)
assert g.json()["nome"] == "Manuale" and g.json()["metodo"] == "manuale"
def test_attiva_manuale_409_su_risoluzione_diversa(sorgenti):
assert _manuale(sorgenti).status_code == 201
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": ALTRA, "nome": "Manuale"})
assert r.status_code == 409 and "64×48" in r.json()["detail"]
def test_anteprima_calibrata_con_manuale_200_grezza(sorgenti):
assert _manuale(sorgenti).status_code == 201
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
assert _anteprima(sorgenti, SRC).status_code == 200
r = _calibrata(sorgenti, SRC)
assert r.status_code == 200, r.text
assert r.headers["X-Raddrizzato"] == "no" and r.headers["X-Calibrazione"] == "Manuale"
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("64", "48")
assert r.headers["content-type"] == "image/jpeg"
def test_anteprima_calibrata_manuale_409_se_il_frame_ha_altra_risoluzione(sorgenti):
assert _manuale(sorgenti).status_code == 201
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
assert _anteprima(sorgenti, ALTRA).status_code == 200
# stessa sorgente attiva, ma il frame tenuto cambia (immagine sostituita con altra misura)
assert _carica(sorgenti, SRC, np.full((60, 80), 9, np.uint8)).status_code == 201
assert _anteprima(sorgenti, SRC).status_code == 200
r = _calibrata(sorgenti, SRC)
assert r.status_code == 409 and "64×48" in r.json()["detail"]
# ---- pagina
def test_pagina_scheda_manuale_id_e_testi():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"mmPx", "pxMmCalc", "nomeManuale", "salvaManuale", "msgManuale"} <= ids
tab = PAGINA.split('id="pannelloCal"')[1]
assert tab.index('id="sezioneCalibrazione"') < tab.index('id="sezioneManuale"') < tab.index('id="sezioneSalvate"')
assert "Calibrazione manuale" in PAGINA and "mm per pixel" in PAGINA
assert "salvata: per usarla «Carica» o CONFERMA" in PAGINA
assert "Sovrascrivi «" in PAGINA.split("async function salvaManuale")[1] # stessa conferma del Salva scacchiera
assert "/camera/calibrazioni/manuale" in PAGINA and "alert(" not in PAGINA and "confirm(" not in PAGINA
def test_pagina_manuale_senza_sorgente_pulsante_spento_con_nota():
stato = PAGINA.split("function statoManuale")[1].split("function aggiornaManuale")[0]
assert 'salvaManuale").disabled = !s' in stato and "Scegliere una sorgente" in stato
assert "statoManuale()" in PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
def test_pagina_px_mm_calcolato_a_ogni_tasto():
assert '$("mmPx").oninput = aggiornaManuale' in PAGINA
assert '"= " + fmt(1 / v, 4) + " px/mm"' in PAGINA
def test_pagina_metodo_manuale_e_campi_null():
assert 'c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare"' in PAGINA
righe = PAGINA.split("function righe")[1].split("function tabella")[0]
assert 'd.metodo === "manuale"' in righe and "Lato dello scacco" in righe # la manuale ha il suo elenco, le altre il solito
assert 'v === null || v === undefined' in PAGINA.split("function fmt")[1].split("function fmtCasella")[0]
stato = PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
assert "Number(d.square_mm) > 0" in stato
def test_pagina_calibrata_senza_raddrizzamento_dice_solo_scala():
assert "solo scala, nessuna correzione ottica" in PAGINA
assert 'r.headers.get("X-Raddrizzato") === "no"' in PAGINA
+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()
+320
View File
@@ -0,0 +1,320 @@
"""Test delle rotte di acquisizione di scripts/camera_service.py (#87).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Copre: POST /camera/anteprima, GET/PUT /camera/parametri, POST /camera/parametri/salva,
caricamento dei settaggi alla riapertura, alias GET /acquisizione.
Sorgente regolabile finta (iniettata su open_source) e FolderCamera su PNG temporanei.
"""
import importlib.util
import json
from pathlib import Path
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
from visionsuite.camera.camera_interface import CameraStateError
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
SORGENTE = "camera:finta"
def _carica():
spec = importlib.util.spec_from_file_location("camera_service_acq", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class Finta:
"""Camera regolabile finta: stesso vocabolario di GenicamSource."""
DOMINI = {
"esposizione": {"tipo": "numero", "min": 10.0, "max": 100000.0, "passo": 1.0},
"esposizione_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
"guadagno": {"tipo": "numero", "min": 0.0, "max": 24.0},
"guadagno_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
}
def __init__(self, supportati=None, rifiuta=()):
self.valori = {"esposizione": 5000.0, "esposizione_auto": "Continuous",
"guadagno": 1.0, "guadagno_auto": "Off"}
self.supportati = set(supportati if supportati is not None else self.DOMINI)
self.rifiuta = set(rifiuta)
self.scritture = []
self.caricato = None
self.chiusa = False
# metà bianca (255) e metà a 100: saturati 50%, media 177,5
frame = np.full((1000, 2000, 3), 100, np.uint8)
frame[:, :1000] = 255
self.frame = frame
def supporta(self, nome):
return nome in self.supportati
def dominio(self, nome):
return {"capacita": nome, "valore": self.valori[nome], **self.DOMINI[nome]}
def leggi(self, nome):
return self.valori[nome]
def scrivi(self, nome, valore, assesta=0):
if nome in self.rifiuta:
raise CameraStateError(f"non posso impostare {nome}: nodo in sola lettura")
self.scritture.append((nome, valore, assesta))
self.valori[nome] = valore
def salva_settaggi(self, percorso):
Path(percorso).write_text(json.dumps(self.valori), encoding="utf-8")
return dict(self.valori)
def carica_settaggi(self, percorso, forza=False):
self.caricato = Path(percorso)
self.valori.update(json.loads(self.caricato.read_text(encoding="utf-8")))
return []
def grab(self, timeout=0):
return self.frame.copy()
def close(self):
self.chiusa = True
@pytest.fixture
def cartella(tmp_path):
img = tmp_path / "foto"
img.mkdir()
for nome in ("a.png", "b.png"):
cv2.imwrite(str(img / nome), np.full((48, 64, 3), 128, np.uint8))
return img
def _client(cartella, config, monkeypatch, finta):
mod = _carica()
monkeypatch.setattr(mod, "open_source", lambda *a, **k: finta)
c = TestClient(mod.create_app(cartella, config))
c.mod = mod
return c
@pytest.fixture
def finta():
return Finta()
@pytest.fixture
def client(cartella, tmp_path, monkeypatch, finta):
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
c.finta = finta
c.config = tmp_path / "cfg"
yield c
@pytest.fixture
def client_cartella(cartella, tmp_path):
mod = _carica()
with TestClient(mod.create_app(cartella, tmp_path / "cfg")) as c:
yield c
# ---- anteprima
def test_anteprima_jpeg_ridotta_e_header(client):
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 500})
assert r.status_code == 200
assert r.headers["content-type"] == "image/jpeg"
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
assert img.shape[:2] == (250, 500) # lato lungo 2000 -> 500
assert r.headers["x-raddrizzato"] == "no"
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("2000", "1000") # frame pieno
assert r.headers["x-saturati-pct"] == "50.00"
assert r.headers["x-media"] == "177.5"
assert float(r.headers["x-nitidezza"]) > 0 # bordo netto fra le due metà
def test_anteprima_mai_ingrandita(client_cartella):
r = client_cartella.post("/camera/anteprima", json={"source": "camera:folder"})
assert r.status_code == 200
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
assert img.shape[:2] == (48, 64) # lato_max 1280 > 64: resta com'è
assert r.headers["x-nitidezza"] == "0.0" # grigio uniforme
assert r.headers["x-saturati-pct"] == "0.00"
assert r.headers["x-media"] == "128.0"
def test_anteprima_parametri_errati_400(client):
for corpo in ({}, {"source": SORGENTE, "lato_max": 100}, {"source": SORGENTE, "lato_max": 5000},
{"source": SORGENTE, "timeout_ms": 0}, {"source": SORGENTE, "grezzo": "si"}):
assert client.post("/camera/anteprima", json=corpo).status_code == 400, corpo
def test_anteprima_sorgente_inesistente_404(client_cartella):
assert client_cartella.post("/camera/anteprima",
json={"source": "camera:non-esiste"}).status_code == 404
def test_anteprima_errore_camera_503_e_timeout_504(client):
from visionsuite.camera.camera_interface import CameraGrabError
def rotta(timeout=0):
raise CameraGrabError("Camera is not open")
client.finta.grab = rotta
r = client.post("/camera/anteprima", json={"source": SORGENTE})
assert r.status_code == 503 and r.json()["detail"] == "La camera non è aperta"
def lenta(timeout=0):
raise TimeoutError("scaduto")
client.finta.grab = lenta
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 504
# ---- parametri
def test_parametri_regolabile(client):
r = client.get("/camera/parametri", params={"source": SORGENTE})
assert r.status_code == 200
j = r.json()
assert j["source"] == SORGENTE and j["regolabile"] is True
assert list(j["parametri"]) == ["esposizione", "esposizione_auto", "guadagno", "guadagno_auto"]
assert j["parametri"]["esposizione"]["valore"] == 5000.0
assert j["parametri"]["esposizione_auto"]["scelte"] == ["Off", "Once", "Continuous"]
def test_parametri_solo_quelli_supportati(cartella, tmp_path, monkeypatch):
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(supportati=["esposizione"])) as c:
j = c.get("/camera/parametri", params={"source": SORGENTE}).json()
assert j["regolabile"] is True and list(j["parametri"]) == ["esposizione"]
def test_parametri_sorgente_non_regolabile(client_cartella):
j = client_cartella.get("/camera/parametri", params={"source": "camera:folder"}).json()
assert j == {"source": "camera:folder", "regolabile": False, "parametri": {}}
def test_parametri_senza_source_400(client):
assert client.get("/camera/parametri").status_code == 400
def test_put_scrive_prima_gli_auto(client):
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {
"esposizione": 8000, "guadagno": 3.5, "esposizione_auto": "Off", "guadagno_auto": "Off"}})
assert r.status_code == 200
assert client.finta.scritture == [
("esposizione_auto", "Off", 3), ("guadagno_auto", "Off", 3),
("esposizione", 8000, 3), ("guadagno", 3.5, 3)]
j = r.json() # valori riletti dalla camera
assert j["parametri"]["esposizione"]["valore"] == 8000
assert j["parametri"]["esposizione_auto"]["valore"] == "Off"
def test_put_nome_ignoto_400(client):
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {"frame_rate": 10}})
assert r.status_code == 400
assert client.finta.scritture == []
for corpo in ({"source": SORGENTE}, {"source": SORGENTE, "valori": {}},
{"source": SORGENTE, "valori": {"guadagno": True}}):
assert client.put("/camera/parametri", json=corpo).status_code == 400, corpo
def test_put_non_regolabile_409(client_cartella):
r = client_cartella.put("/camera/parametri",
json={"source": "camera:folder", "valori": {"esposizione": 1000}})
assert r.status_code == 409
assert r.json()["detail"] == "questa sorgente non ha parametri regolabili"
def test_put_rifiuto_della_camera_422(cartella, tmp_path, monkeypatch):
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(rifiuta=["guadagno"])) as c:
r = c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"guadagno": 5}})
assert r.status_code == 422
assert "guadagno" in r.json()["detail"]
# ---- salvataggio e riapertura
def test_salva_crea_il_file(client):
r = client.post("/camera/parametri/salva", json={"source": SORGENTE})
assert r.status_code == 200
j = r.json()
assert j["source"] == SORGENTE and j["salvati"] is True
atteso = client.config / "settaggi" / j["file"]
assert atteso.is_file()
assert j["file"].startswith("camera_finta-") and j["file"].endswith(".json")
assert json.loads(atteso.read_text(encoding="utf-8"))["esposizione"] == 5000.0
def test_salva_non_regolabile_409(client_cartella):
r = client_cartella.post("/camera/parametri/salva", json={"source": "camera:folder"})
assert r.status_code == 409
def test_riapertura_carica_i_settaggi(cartella, tmp_path, monkeypatch):
config = tmp_path / "cfg"
prima = Finta()
with _client(cartella, config, monkeypatch, prima) as c:
c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"esposizione": 7777}})
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
seconda = Finta() # servizio riavviato: camera nuova, valori di fabbrica
with _client(cartella, config, monkeypatch, seconda) as c:
assert seconda.caricato is None # niente apertura finché nessuno la chiede
c.get("/camera/parametri", params={"source": SORGENTE})
assert seconda.caricato == config / "settaggi" / file
assert seconda.valori["esposizione"] == 7777
def test_settaggi_rotti_non_bloccano_l_apertura(cartella, tmp_path, monkeypatch):
class Rotta(Finta):
def carica_settaggi(self, percorso, forza=False):
raise ValueError("file illeggibile")
config = tmp_path / "cfg"
with _client(cartella, config, monkeypatch, Finta()) as c:
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
assert (config / "settaggi" / file).is_file()
with _client(cartella, config, monkeypatch, Rotta()) as c:
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):
a = client.get("/acquisizione")
c = client.get("/calibrazione")
assert a.status_code == 200 and a.text == c.text
+292
View File
@@ -0,0 +1,292 @@
"""Test di #92: immagini caricate come sorgenti e anteprima calibrata (scripts/camera_service.py).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Solo tmp_path: niente ProgramData, niente camera vera. Le immagini le scrive il test.
"""
from urllib.parse import quote, unquote
import cv2
import numpy as np
import pytest
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
CAMPI, _png, carica_su, cartella, client, salva_con_nome, una_scacchiera,
una_scacchiera_densa,
)
__all__ = ["cartella", "client"] # le fixture importate restano usate
DENSA = "densa.png"
PIANA = "piana.png"
def _modulo(c) -> dict:
"""Il dict globale di camera_service (per sostituire open_source con una spia)."""
return next(r.endpoint for r in c.app.routes if r.path == "/camera/grab").__globals__
def _carica(c, nome: str, img: np.ndarray, **extra):
"""POST /camera/immagini con un'immagine codificata PNG (o il contenuto dato)."""
return c.post("/camera/immagini", files={"image": (nome, _png(img), "image/png")}, **extra)
def _densa(**kw) -> np.ndarray:
return una_scacchiera_densa(k=1e-7, **kw)
def _calibra_e_attiva(c, nome_immagine: str, nome_calibrazione: str, **campi):
"""Calcola dalla sorgente, salva con nome e la rende attiva su quella sorgente."""
r = c.post("/camera/calibrate", data={"source": nome_immagine, "square_mm": "2", **campi})
assert r.status_code == 200, r.text
assert salva_con_nome(c, r.json(), nome_calibrazione).status_code == 201
assert carica_su(c, nome_immagine, nome_calibrazione).status_code == 200
def _anteprima(c, nome: str, **extra):
return c.post("/camera/anteprima", json={"source": nome, "grezzo": True, **extra})
def _calibrata(c, nome: str, **extra):
return c.post("/camera/anteprima/calibrata", json={"source": nome, **extra})
# ---- POST /camera/immagini
def test_carica_201_salva_in_cartella_immagini(client):
r = _carica(client, "calibration_135_1.bmp", una_scacchiera())
assert r.status_code == 201, r.text
assert r.json() == {"source": "calibration_135_1.bmp",
"label": "immagine — calibration_135_1.bmp", "kind": "image"}
assert (client.config / "immagini" / "calibration_135_1.bmp").is_file()
def test_carica_ripulisce_il_nome_e_toglie_il_percorso(client):
r = _carica(client, "C:\\foto\\ban co è1.PNG", una_scacchiera())
assert r.status_code == 201, r.text
assert r.json()["source"] == "ban_co_1.PNG" # niente percorso, caratteri sicuri, estensione intatta
assert (client.config / "immagini" / "ban_co_1.PNG").is_file()
def test_carica_stesso_nome_sostituisce(client):
_carica(client, "a.png", np.full((48, 64), 10, np.uint8))
assert _carica(client, "a.png", np.full((30, 40), 200, np.uint8)).status_code == 201
assert [f.name for f in (client.config / "immagini").iterdir()] == ["a.png"]
img = cv2.imread(str(client.config / "immagini" / "a.png"), cv2.IMREAD_UNCHANGED)
assert img.shape[:2] == (30, 40)
def test_carica_senza_file_400(client):
assert client.post("/camera/immagini").status_code == 400
def test_carica_estensione_non_ammessa_400(client):
r = client.post("/camera/immagini", files={"image": ("note.txt", b"ciao", "text/plain")})
assert r.status_code == 400
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
def test_carica_file_illeggibile_422_e_niente_su_disco(client):
r = client.post("/camera/immagini", files={"image": ("rotta.png", b"non e una immagine", "image/png")})
assert r.status_code == 422
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
# ---- GET /camera/sources
def test_sources_con_immagini_e_senza_camera_file(client):
_carica(client, "b.png", una_scacchiera())
_carica(client, "a.png", una_scacchiera())
voci = client.get("/camera/sources").json()["sources"]
nomi = [v["name"] for v in voci]
assert "camera:file" not in nomi
assert "camera:folder" in nomi
assert nomi[-2:] == ["a.png", "b.png"] # le immagini stanno in coda
assert voci[-1] == {"name": "b.png", "kind": "image", "label": "immagine — b.png"}
assert nomi.index("camera:folder") < nomi.index("a.png")
def test_sources_camere_in_cache_e_aggiorna(client):
# #92: la ricerca camere (~10 s) si fa una volta e resta 60 s; aggiorna=true la rilancia; le immagini sono sempre fresche
g = _modulo(client)
originale = g["list_sources"]
chiamate = []
def finto(cartella, cameras=True, **kw):
if cameras:
chiamate.append(1)
return originale(cartella, cameras=cameras, **kw)
g["list_sources"] = finto
try:
client.get("/camera/sources") # se la ricerca d'avvio e' ancora in corso aspetta il suo lock
base = len(chiamate)
_carica(client, "nuova.png", una_scacchiera())
voci = client.get("/camera/sources").json()["sources"]
client.get("/camera/sources")
assert len(chiamate) == base # seconda e terza chiamata: nessuna nuova ricerca
assert voci[-1]["name"] == "nuova.png" # ma l'immagine appena caricata c'e'
client.get("/camera/sources", params={"aggiorna": "true"})
assert len(chiamate) == base + 1 # aggiorna=true la rilancia
client.get("/camera/sources")
assert len(chiamate) == base + 1 # e il risultato nuovo torna in cache
finally:
g["list_sources"] = originale
def test_sources_senza_immagini_non_crea_la_cartella(client):
voci = client.get("/camera/sources").json()["sources"]
assert all(v["kind"] != "image" for v in voci)
# ---- grab / anteprima / calibrate su un'immagine caricata
def test_grab_anteprima_e_grab_ripetuti_su_immagine(client):
_carica(client, "foto.png", una_scacchiera())
for _ in range(3): # l'anteprima continua non deve esaurire un'immagine sola
r = client.post("/camera/grab", json={"source": "foto.png"})
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
for _ in range(3):
r = _anteprima(client, "foto.png")
assert r.status_code == 200 and r.headers["content-type"] == "image/jpeg"
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("520", "400")
def test_grab_immagine_inesistente_404(client):
assert client.post("/camera/grab", json={"source": "non_c_e.png"}).status_code == 404
def test_calibrate_su_immagine_origine_e_il_nome(client):
_carica(client, "calibration_135_1.bmp", una_scacchiera())
r = client.post("/camera/calibrate", data={"source": "calibration_135_1.bmp", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["origine"] == "calibration_135_1.bmp"
# ---- DELETE /camera/immagini
def test_elimina_204_poi_404_e_sparisce_dalle_sorgenti(client):
_carica(client, "foto.png", una_scacchiera())
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert not (client.config / "immagini" / "foto.png").exists()
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 404
assert "foto.png" not in [v["name"] for v in client.get("/camera/sources").json()["sources"]]
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 404
def test_elimina_nome_con_percorso_404(client):
_carica(client, "foto.png", una_scacchiera())
assert client.delete("/camera/immagini", params={"nome": "../immagini/foto.png"}).status_code == 404
assert client.delete("/camera/immagini").status_code == 404
assert (client.config / "immagini" / "foto.png").is_file()
def test_elimina_chiude_la_sorgente_aperta(client, monkeypatch):
chiuse = []
modulo = _modulo(client)
reale = modulo["open_source"]
def spia(*a, **k):
s = reale(*a, **k)
chiusura = s.close
s.close = lambda: (chiuse.append(a[0]), chiusura())[1]
return s
monkeypatch.setitem(modulo, "open_source", spia)
_carica(client, "foto.png", una_scacchiera())
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 200
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert chiuse == ["foto.png"]
# ---- POST /camera/anteprima/calibrata
def test_calibrata_senza_frame_404(client):
_carica(client, DENSA, _densa())
r = _calibrata(client, DENSA)
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
def test_calibrata_senza_attiva_409(client):
_carica(client, DENSA, _densa())
assert _anteprima(client, DENSA).status_code == 200
r = _calibrata(client, DENSA)
assert r.status_code == 409 and r.json()["detail"] == "nessuna calibrazione attiva su questa sorgente"
def test_calibrata_validazione_ingresso(client):
assert client.post("/camera/anteprima/calibrata", json={}).status_code == 400
assert _calibrata(client, DENSA, lato_max=100).status_code == 400
assert _calibrata(client, DENSA, lato_max=True).status_code == 400
def test_calibrata_200_densa_senza_nuova_lettura(client, monkeypatch):
modulo = _modulo(client)
reale = modulo["open_source"]
letture = []
def conta(*a, **k):
s = reale(*a, **k)
originale = s.grab
s.grab = lambda t=1000: (letture.append(1), originale(t))[1]
return s
monkeypatch.setitem(modulo, "open_source", conta)
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco àè 1")
assert _anteprima(client, DENSA).status_code == 200
prima = len(letture)
for _ in range(3):
r = _calibrata(client, DENSA)
assert r.status_code == 200, r.text
assert r.headers["content-type"] == "image/jpeg"
assert r.headers["X-Raddrizzato"] == "si"
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480")
assert unquote(r.headers["X-Calibrazione"]) == "Banco àè 1"
assert r.headers["X-Calibrazione"] == quote("Banco àè 1", safe="") # percent-encoded UTF-8
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
assert img.shape[:2] == (480, 640)
assert len(letture) == prima # la calibrata non legge mai un frame nuovo
def test_calibrata_ridotta_a_lato_max(client):
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco")
assert _anteprima(client, DENSA).status_code == 200
r = _calibrata(client, DENSA, lato_max=320)
assert r.status_code == 200
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
assert max(img.shape[:2]) == 320
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480") # misure del frame pieno
def test_anteprima_grezza_e_calibrata_usano_lo_stesso_frame_grezzo(client):
# anche con anteprima NON grezza la copia tenuta e' quella prima di raddrizzare
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco")
r = client.post("/camera/anteprima", json={"source": DENSA}) # calibrata: raddrizzata
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si"
r = _calibrata(client, DENSA)
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si" # raddrizzata una volta sola
def test_calibrata_risoluzione_diversa_409(client):
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco")
# stessa sorgente, immagine sostituita con un'altra risoluzione: la calibrazione resta attiva
assert _carica(client, DENSA, _densa(w=320, h=240)).status_code == 201
assert _calibrata(client, DENSA).status_code == 404 # sostituire l'immagine scorda il frame vecchio
assert _anteprima(client, DENSA).status_code == 200
r = _calibrata(client, DENSA)
assert r.status_code == 409
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
def test_calibrata_planare_senza_mappa_200_grezza(client):
# #95: senza mappa (planare) non e' piu' 409: esce il frame grezzo, X-Raddrizzato: no
_carica(client, PIANA, una_scacchiera())
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
assert _anteprima(client, PIANA).status_code == 200
r = _calibrata(client, PIANA)
assert r.status_code == 200
assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
+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", "mirinoCasella", "immagine",
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
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", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
"Attiva calibrazione"): # #92 #93
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()]
+143
View File
@@ -0,0 +1,143 @@
"""Test della pagina a due tab (#91, #92): contenuto statico di PAGINA, senza servizio."""
import importlib.util
import re
from pathlib import Path
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
_spec = importlib.util.spec_from_file_location("calibrazione_pagina_tab", _FILE)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
PAGINA = _mod.PAGINA
def test_due_tab_con_ruolo_e_selezione():
assert re.search(r'<button id="tabAcq"[^>]*role="tab"[^>]*aria-selected="true"', PAGINA)
assert re.search(r'<button id="tabCal"[^>]*role="tab"[^>]*aria-selected="false"', PAGINA)
assert ">Acquisizione</button>" in PAGINA and ">Calibrazione</button>" in PAGINA
assert 'role="tablist"' in PAGINA and 'role="tabpanel"' in PAGINA
assert 'aria-selected", cal ?' in PAGINA # la selezione cambia da JS
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
def test_sorgente_in_cima_poi_riquadri():
# #92: Sorgente, anteprima e parametri stanno nel pannello Acquisizione; Sorgente prima dei riquadri
i_acq = PAGINA.index('id="pannelloAcq"')
i_cal = PAGINA.index('id="pannelloCal"')
i_sorg = PAGINA.index('id="sezioneSorgente"')
i_ant = PAGINA.index('id="sezioneAnteprima"')
i_riq = PAGINA.index('id="riquadri"')
assert i_acq < i_sorg < i_ant < i_riq < PAGINA.index('id="sezioneParametri"') < i_cal
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
# barra: casella calibrata e mirino; la vecchia casella «Immagine grezza» non c'e' piu'
assert 'id="mostraCal"' in PAGINA and "Attiva calibrazione" in PAGINA
assert 'id="grezzo"' not in PAGINA and "Immagine grezza (senza correzione)" not in PAGINA
# riquadri: «Immagine» sempre visibile, «Calibrata» nascosta finche' la casella e' spenta
assert re.search(r'<div class="riquadro" id="riquadroCal" hidden>', PAGINA)
assert "grezzo: true" in PAGINA
def test_id_del_pannello_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"calSorgente", "quadrato", "acquisisci", "nomeCal", "salva", "msgSalva", "calibrazioni",
"msgElenco", "stato", "risultato", "overlay",
"caricaImmagine", "fileImmagine", "rimuoviImmagine", "riquadroCal", "immagineCal", "mirinoCal"}
assert attesi <= ids, attesi - ids
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
def test_calibrazione_senza_file_ne_radio():
# #92: l'immagine da file e' una sorgente di acquisizione, non un'opzione della calibrazione
for sparito in ("origFile", "origSorgente", "scegliFile", "nomeFile", 'type="radio"', "Immagine da file", "Sorgente selezionata"):
assert sparito not in PAGINA, sparito
# l'unico input file e' nella sezione Sorgente, in cima alla tab Acquisizione
assert PAGINA.count('type="file"') == 1
assert PAGINA.index('type="file"') < PAGINA.index('id="pannelloCal"')
assert "immagine grezza dalla tab Acquisizione" in PAGINA
# il calcolo manda solo la sorgente (e il lato), mai il file
assert 'f.append("source", sorgente)' in PAGINA and 'f.append("image"' not in PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
def test_testi_del_pannello():
for testo in ("Camera o immagine", "Carica immagine da file...", "Rimuovi immagine", "Calcola",
"Calibrazioni salvate", "risoluzione diversa", "Calibrata",
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome",
"calibrata: ", "non calibrata"):
assert testo in PAGINA, testo
def test_rotte_del_contratto():
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
"/camera/calibration?source=", "/camera/immagini", "/camera/immagini?nome=",
"/camera/anteprima/calibrata"): # #92
assert '"' + rotta in PAGINA, rotta
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
assert parola in PAGINA, parola
# il calcolo non salva piu': il campo save sparisce
assert 'append("save"' not in PAGINA
def test_nessuna_finestra_del_browser_e_offline():
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
assert vietato not in PAGINA, vietato
def test_acquisizione_di_87_90_conservata():
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
for parola in ("Parametri camera", "Mirino centrale",
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
assert parola in PAGINA, parola
def test_pagina_stretta():
assert "@media (max-width: 500px)" in PAGINA
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
assert "Lettura dei parametri..." in PAGINA
def test_ciclo_non_sospeso_nella_tab_calibrazione():
# #94: il ciclo non si ferma piu' entrando in Calibrazione (prima #92: A.riprendi); Calcola senza blocco aspetta le richieste in volo
assert "A.riprendi" not in PAGINA
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert "fermaAnteprima();" in calcola and "A.inVolo) await pausa(50)" in calcola
def test_calibrata_dopo_anteprima_e_dopo_carica():
# #92: la calibrata segue ogni anteprima grezza riuscita e si aggiorna dopo Carica/Scollega; 404/409 al posto dell'immagine
assert "await aggiornaCalibrata(lato);" in PAGINA
assert PAGINA.count("aggiornaCalibrata();") >= 3
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA
def test_elenco_sorgenti_senza_attese_e_calibrata_solo_se_serve():
# #92: la voce dell'immagine caricata si aggiunge in pagina (niente rilettura dell'elenco); «Aggiorna» forza la ricerca camere
assert "aggiungiSorgente(j);" in PAGINA and "caricaSorgenti(j.source)" not in PAGINA
assert 'id="aggiornaSorgenti"' in PAGINA and ">Aggiorna</button>" in PAGINA
assert '"?aggiorna=true"' in PAGINA
# nessuna calibrazione attiva: il riquadro lo dice senza chiamare il servizio; dopo un 409 non si richiede piu'
assert "if (A.calAttiva === false)" in PAGINA
assert "nessuna calibrazione attiva su questa sorgente" in PAGINA
assert "if (r.status === 409) A.bloccoCal = true;" in PAGINA and "if (A.bloccoCal) return;" in PAGINA
assert PAGINA.count("A.bloccoCal = false;") >= 3 # cambio sorgente, Carica, Scollega
def test_attiva_calibrazione_con_scelta_e_conferma():
# #93: sotto la calibrata una combo e CONFERMA; la casella cambia nome
for i in ('id="sceltaCal"', 'id="confermaCal"', 'id="msgSceltaCal"'):
assert i in PAGINA, i
assert "Attiva calibrazione" in PAGINA and ">CONFERMA</button>" in PAGINA
assert "Mostra immagine calibrata" not in PAGINA
assert PAGINA.index('id="riquadroCal"') < PAGINA.index('id="sceltaCal"') < PAGINA.index('id="confermaCal"') < PAGINA.index('id="msgSceltaCal"')
# il gestore di CONFERMA usa PUT calibrazione-attiva (via caricaNome) o DELETE calibration (via scollega), messaggio sotto la calibrata
gestore = PAGINA[PAGINA.index('$("confermaCal").onclick'):]
gestore = gestore[:gestore.index("};")]
assert 'caricaNome(nome, "msgSceltaCal")' in gestore and 'scollega("msgSceltaCal")' in gestore
assert 'method: "PUT"' in PAGINA and "/camera/calibrazione-attiva" in PAGINA
assert 'method: "DELETE"' in PAGINA and '"/camera/calibration?source="' in PAGINA
# voce vuota «nessuna», suffisso di risoluzione, combo ridisegnata con l'elenco e spenta senza sorgente
assert "— nessuna (non calibrata) —" in PAGINA and "— risoluzione diversa" in PAGINA
assert "disegnaSceltaCal();" in PAGINA and "= !$(\"sorgente\").value" in PAGINA
+2
View File
@@ -250,6 +250,8 @@ async def create_task(
file_type=data.file_type,
annotations_json=data.annotations_json,
vision_json=data.vision_json,
# #97: camera e modo di analisi (dict gia' validato dal modello)
acquisition_json=data.acquisition_json.model_dump() if data.acquisition_json else None,
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
at_close=data.at_close,
+30 -6
View File
@@ -23,7 +23,7 @@ from src.backend.api.routers.files import (
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.models.api.vision import (
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionRecipeUploadResponse, VisionReferenceImageResponse,
)
from src.backend.models.orm.vision import VisionResult
@@ -40,14 +40,22 @@ _IMAGE_EXTENSIONS = {
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp", # #99
"image/x-ms-bmp": "bmp", # #99
}
# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the
# vision routes accept it on top of files.py's list. files.py is untouched: its
# attachments are served by extension and BMP is not meant to be shown there.
_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"}
# Sniffed from the first bytes, independent of the declared Content-Type: a
# text file uploaded as "image/png" passes the header check above and must
# still be caught before it reaches the worker as a bare 500 (#15). BMP and
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
# because PIL - and so the worker - can decode both; this check only refuses
# what is not a real image at all, it does not narrow the allow-list above.
# still be caught before it reaches the worker as a bare 500 (#15). TIFF is
# included even though the allow-list does not accept it (PIL, and so the
# worker, can decode it; browsers do not show it in the result); BMP is now
# also allowed (#99). This check only refuses what is not a real image at all,
# it does not narrow the allow-list above.
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
(b"\x89PNG\r\n\x1a\n",), # PNG
(b"\xff\xd8\xff",), # JPEG
@@ -83,7 +91,7 @@ async def _read_image(image: UploadFile) -> bytes:
is in hand - the same two-step convention `files.py::upload_file` already
uses, reused rather than reinvented here.
"""
if image.content_type not in ALLOWED_IMAGE_TYPES:
if image.content_type not in _VISION_IMAGE_TYPES: # #99
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {image.content_type} not allowed. Must be an image.",
@@ -164,6 +172,22 @@ async def execute(
)
@router.post("/check", response_model=VisionCheckResponse)
async def check(
task_id: int = Form(...),
image: UploadFile = File(...),
production_run_id: int | None = Form(None, gt=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> VisionCheckResponse:
"""#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure)."""
task = await _task_or_404(db, task_id)
report = await vision_service.check_task(
db, task, await _read_image(image), production_run_id=production_run_id,
)
return VisionCheckResponse(**report)
@router.get("/results/{result_id}/image")
async def get_result_image(
result_id: int,
@@ -0,0 +1,31 @@
"""recipe_tasks.acquisition_json (#97)
recipe_tasks.acquisition_json: camera e modo di analisi scelti nel task,
{"camera_url", "camera_source", "modo": "impulsiva" | "in_linea"}; NULL = la
camera della stazione dell'operatore, analisi impulsiva (come prima).
Revision ID: 021_task_acquisition
Revises: 020_station_camera
Create Date: 2026-10-24
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '021_task_acquisition'
down_revision: Union[str, None] = '020_station_camera'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('acquisition_json', sa.JSON(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.drop_column('acquisition_json')
+29 -1
View File
@@ -2,8 +2,9 @@
import re
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from src.backend.models.api.station import _normalize_http_url
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
# Kept in step with the ORM enum so the two cannot drift apart.
@@ -159,6 +160,30 @@ class SubtaskResponse(BaseModel):
vision_output: Optional[str] = None
class AcquisitionConfig(BaseModel):
"""#97: camera (servizio + sorgente) e modo di analisi scelti nel task."""
camera_url: str = Field(..., max_length=500)
camera_source: str = Field(..., min_length=1, max_length=200)
modo: Literal["impulsiva", "in_linea"] = "impulsiva"
@field_validator("camera_url")
@classmethod
def _check_camera_url(cls, value: str) -> str:
# stessa normalizzazione delle stazioni; qui il vuoto non e' ammesso
normalized = _normalize_http_url(value, "camera_url")
if normalized is None:
raise ValueError("camera_url must not be empty")
return normalized
@field_validator("camera_source")
@classmethod
def _check_camera_source(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("camera_source must not be empty")
return value
class TaskCreate(BaseModel):
"""Schema for creating a task."""
title: str = Field(..., min_length=1, max_length=255)
@@ -169,6 +194,7 @@ class TaskCreate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
# #53: assente = default dal tipo (misure e check nel ciclo)
in_loop: Optional[bool] = None
at_close: bool = False
@@ -186,6 +212,7 @@ class TaskUpdate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
in_loop: Optional[bool] = None
at_close: Optional[bool] = None
fields: Optional[list[dict[str, Any]]] = None
@@ -207,6 +234,7 @@ class TaskResponse(BaseModel):
file_type: Optional[str] = None
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
in_loop: bool = False
at_close: bool = False
fields: Optional[list[dict[str, Any]]] = None
+10
View File
@@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel):
failures: list[dict] = []
class VisionCheckResponse(BaseModel):
"""What `POST /api/vision/check` returns (#97): the analysis, nothing saved."""
outcome: str | None = None
overlay: list[dict] = []
failures: list[dict] = []
measurements: list[VisionMeasurementResult] = []
# esito della lettura della calibrazione: ok | no_camera | not_calibrated | unreachable
calibration: str
class VisionRecipeOutput(BaseModel):
"""One numeric output a recipe offers to the quotes."""
key: str
+3
View File
@@ -65,6 +65,9 @@ class RecipeTask(Base):
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
# meta della ricetta VisionSuite v2 (#46).
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# #97: camera e modo di analisi del task, {camera_url, camera_source, modo};
# NULL = camera della stazione corrente, analisi impulsiva.
acquisition_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
in_loop: Mapped[bool] = mapped_column(
+2
View File
@@ -117,6 +117,8 @@ async def _copy_tasks_to_version(
vision_json=task.vision_json,
# Cartella immutabile: basta il riferimento (#46).
vision_recipe_dir=task.vision_recipe_dir,
# #97: camera e modo di analisi seguono il task nella nuova versione.
acquisition_json=task.acquisition_json,
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
in_loop=task.in_loop,
at_close=task.at_close,
+122 -14
View File
@@ -231,35 +231,36 @@ CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
async def fetch_calibration(
camera_url: str | None, camera_source: str | None, label: str = "",
) -> tuple[dict | None, str]:
"""Legge la calibrazione da un acquisitore (C1, #83; generalizzata in #97).
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
Un px_per_mm non finito o <= 0 conta come non calibrata.
Un px_per_mm non finito o <= 0 conta come non calibrata. `label` serve
solo ai log (stazione o task).
"""
if station is None or not station.camera_url or not station.camera_source:
if not camera_url or not camera_source:
return None, CAL_NO_CAMERA
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
url = f"{camera_url.rstrip('/')}/camera/calibration"
try:
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url, params={"source": station.camera_source})
response = await client.get(url, params={"source": camera_source})
except httpx.HTTPError as exc:
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
logger.warning("acquisitore %s non raggiungibile: %s", label, exc)
return None, CAL_UNREACHABLE
if response.status_code == status.HTTP_404_NOT_FOUND:
return None, CAL_NOT_CALIBRATED
if response.status_code != 200:
logger.warning(
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
)
logger.warning("acquisitore %s: HTTP %s", label, response.status_code)
return None, CAL_UNREACHABLE
try:
body = response.json()
except ValueError:
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
logger.warning("acquisitore %s: risposta non JSON", label)
return None, CAL_UNREACHABLE
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
@@ -267,6 +268,33 @@ async def fetch_station_calibration(station: Station | None) -> tuple[dict | Non
return body, CAL_OK
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
"""Calibrazione dall'acquisitore della stazione (C1, #83): vedi fetch_calibration."""
if station is None:
return None, CAL_NO_CAMERA
return await fetch_calibration(
station.camera_url, station.camera_source, f"della stazione {station.code}",
)
def task_camera(task: RecipeTask, station: Station | None) -> tuple[str | None, str | None]:
"""Camera di un task (#97): acquisition_json, altrimenti quella della stazione."""
acquisition = task.acquisition_json
if isinstance(acquisition, dict) and acquisition.get("camera_url") and acquisition.get("camera_source"):
return acquisition["camera_url"], acquisition["camera_source"]
if station is not None:
return station.camera_url, station.camera_source
return None, None
async def fetch_task_calibration(
task: RecipeTask, station: Station | None,
) -> tuple[dict | None, str]:
"""Calibrazione della camera del task (#97), stessi esiti CAL_* di fetch_calibration."""
camera_url, camera_source = task_camera(task, station)
return await fetch_calibration(camera_url, camera_source, f"del task {task.id}")
async def get_station_calibration(station: Station | None) -> dict | None:
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
calibration, _ = await fetch_station_calibration(station)
@@ -498,14 +526,15 @@ async def execute_task(
)
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
station_url = None
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
calibration = None
# Calibrazione letta dall'acquisitore (#83); None = non c'e'. #97: la camera e'
# quella del task se la ricetta ne sceglie una, altrimenti quella della stazione.
station = None
if run is not None:
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
calibration = await get_station_calibration(station)
calibration, _ = await fetch_task_calibration(task, station)
if not task.vision_json:
raise HTTPException(
@@ -623,6 +652,85 @@ async def execute_task(
return result, saved, report
async def check_task(
db: AsyncSession,
task: RecipeTask,
image_bytes: bytes,
*,
production_run_id: int | None = None,
) -> dict:
"""Esegue il grafo come execute_task ma NON salva nulla (#97).
Anteprima per l'operatore (ingresso nel task, ciclo «in linea»): niente
VisionResult, niente file, niente misure. L'esito per quota usa
calculate_pass_fail sulle quote con vision_output, nell'ordine di
marker_number come execute. Le quote che il grafo non produce (pezzo non
ancora in posizione) restano fuori da `measurements` e il perche' sta in
`failures`: un'anteprima non deve dare 422 a ogni frame.
"""
if task.task_type not in VISION_TASK_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"task type {task.task_type!r} is not a camera measurement",
)
station = None
station_url = None
if production_run_id is not None:
# stesse regole di execute: 404 / 422 / 409 sulla sessione di produzione
run = await measurement_service.validate_run_for_version(
db, production_run_id, task.version_id,
)
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
if not task.vision_json:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph",
)
quotes = (await db.execute(
select(RecipeSubtask)
.where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output.is_not(None))
.order_by(RecipeSubtask.marker_number)
)).scalars().all()
if not quotes:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="no quote on this task names a vision output",
)
# stessa calibrazione di execute: camera del task, altrimenti della stazione
calibration, cal_reason = await fetch_task_calibration(task, station)
worker_kwargs: dict = {}
if calibration is not None:
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
worker_url=station_url,
**worker_kwargs,
)
outputs = report["outputs"]
measurements = [
{
"subtask_id": q.id,
"value": float(outputs[q.vision_output]),
"pass_fail": measurement_service.calculate_pass_fail(
float(outputs[q.vision_output]), q,
)[0],
}
for q in quotes if _is_measurable(outputs.get(q.vision_output))
]
return {
"outcome": report.get("outcome"),
"overlay": report.get("overlay") or [],
"failures": report.get("failures") or [],
"measurements": measurements,
"calibration": cal_reason,
}
def _reference_file_path(reference: VisionReferenceImage) -> Path:
return settings.upload_path / reference.path
+19
View File
@@ -341,3 +341,22 @@ class TestStationCameraMigration:
assert "vision_worker_url" in cols("stations") # la 019 resta
command.upgrade(cfg, "head") # e di nuovo su
assert {"camera_url", "camera_source"} <= cols("stations")
class TestTaskAcquisitionMigration:
"""021_task_acquisition: recipe_tasks.acquisition_json, su e giu (#97)."""
def test_021_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def cols(table):
return {c["name"] for c in _inspect(db_path).get_columns(table)}
command.upgrade(cfg, "head")
assert "acquisition_json" in cols("recipe_tasks")
command.downgrade(cfg, "020_station_camera")
assert "acquisition_json" not in cols("recipe_tasks")
assert "camera_url" in cols("stations") # la 020 resta
command.upgrade(cfg, "head") # e di nuovo su
assert "acquisition_json" in cols("recipe_tasks")
+308
View File
@@ -0,0 +1,308 @@
"""#97: camera e modo di analisi nel task, POST /api/vision/check senza salvataggio.
Il worker e l'acquisitore non girano nei test: si fingono (fixture di
test_vision_dxf_compare e test_vision_station_worker).
"""
import pytest
from sqlalchemy import func, select
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionResult
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_copy_on_write import (
_current_task_and_subtask, _seed_measurement,
)
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture acquisitore)
CAL, CAMERA_URL, acquisitore, redirect_uploads,
)
from src.backend.tests.test_vision_execute import an_image
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
_camera_task, _execute, _station_with_run, worker_calls,
)
TASK_CAMERA = "http://10.0.0.50:8300"
ACQ = {"camera_url": TASK_CAMERA, "camera_source": "camera:601020", "modo": "in_linea"}
async def _task_with_acquisition(db_session, user_id, acquisition=ACQ):
recipe, task = await _camera_task(db_session, user_id)
task.acquisition_json = acquisition
await db_session.commit()
return recipe, task
# --- modello e API del task ---------------------------------------------------
@pytest.mark.asyncio
async def test_create_task_stores_and_normalizes_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={
"title": "Camera", "task_type": "camera_measure",
"acquisition_json": {"camera_url": " http://h:1/ ", "camera_source": " camera:1 "},
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
# url normalizzato come nelle stazioni, sorgente senza spazi, modo di default
expected = {"camera_url": "http://h:1", "camera_source": "camera:1", "modo": "impulsiva"}
assert body["acquisition_json"] == expected
db_session.expunge_all()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.id == body["id"])
)).scalar_one()
assert task.acquisition_json == expected # sul DB, non solo nella risposta
got = await client.get(f"/api/tasks/{body['id']}", headers=auth_headers(maker_user))
assert got.json()["acquisition_json"] == expected
@pytest.mark.asyncio
async def test_task_without_acquisition_is_null(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.get(f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user))
assert resp.json()[0]["acquisition_json"] is None
@pytest.mark.asyncio
@pytest.mark.parametrize("bad", [
{"camera_url": "ftp://h", "camera_source": "camera:1"},
{"camera_url": " ", "camera_source": "camera:1"},
{"camera_url": "http://h", "camera_source": " "},
{"camera_url": "http://h", "camera_source": "x" * 201},
{"camera_url": "http://h", "camera_source": "camera:1", "modo": "continua"},
{"camera_source": "camera:1"},
])
async def test_invalid_acquisition_is_422(client, db_session, maker_user, bad):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Camera", "task_type": "camera_measure", "acquisition_json": bad},
)
assert resp.status_code == 422, resp.text
@pytest.mark.asyncio
async def test_update_sets_and_clears_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
resp = await client.put(
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": ACQ},
)
assert resp.status_code == 200, resp.text
assert resp.json()["acquisition_json"] == ACQ
resp = await client.put(
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": None},
)
assert resp.status_code == 200
assert resp.json()["acquisition_json"] is None # null = camera della stazione
@pytest.mark.asyncio
async def test_copy_on_write_preserves_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
task.acquisition_json = ACQ
await db_session.flush()
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
resp = await client.put(
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
json={"change_notes": "forza il copy-on-write"},
)
assert resp.status_code == 200
db_session.expunge_all()
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
assert version.version_number == 2
assert new_task.acquisition_json == ACQ
# --- calibrazione dalla camera del task ----------------------------------------
@pytest.mark.asyncio
async def test_execute_reads_calibration_from_the_task_camera(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
# stazione con camera propria: la camera del task prevale
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
await _execute(client, measurement_tec_user, task, run)
assert acquisitore["urls"] == [(f"{TASK_CAMERA}/camera/calibration", {"source": "camera:601020"})]
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.calibration_snapshot["px_per_mm"] == CAL["px_per_mm"]
@pytest.mark.asyncio
async def test_execute_task_camera_works_without_a_run(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
await _execute(client, measurement_tec_user, task) # niente sessione di produzione
assert acquisitore["urls"][0][0] == f"{TASK_CAMERA}/camera/calibration"
assert "calibration" in worker_calls[0]
@pytest.mark.asyncio
async def test_execute_falls_back_to_the_station_camera(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id) # acquisition_json null
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
await _execute(client, measurement_tec_user, task, run)
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
# --- POST /api/vision/check ----------------------------------------------------
async def _check(client, user, task, run=None):
data = {"task_id": str(task.id)}
if run is not None:
data["production_run_id"] = str(run.id)
return await client.post(
"/api/vision/check", headers=auth_headers(user),
files={"image": ("p.png", an_image(), "image/png")}, data=data,
)
async def _counts(db_session):
return tuple([
(await db_session.execute(select(func.count()).select_from(model))).scalar_one()
for model in (Measurement, VisionResult)
])
@pytest.mark.asyncio
async def test_check_saves_nothing_and_reports_pass_fail(
client, db_session, measurement_tec_user, monkeypatch, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
seen = {}
async def fake_call(image_bytes, graph, **kwargs):
seen.update(kwargs)
return {"outputs": {"t1.diameter": 11.0}, "failures": [{"tool_name": "t"}],
"overlay": [{"kind": "line"}], "outcome": "fail",
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
before = await _counts(db_session)
resp = await _check(client, measurement_tec_user, task)
assert resp.status_code == 200, resp.text
body = resp.json()
quote = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().one()
assert body == {
"outcome": "fail", "overlay": [{"kind": "line"}], "failures": [{"tool_name": "t"}],
"measurements": [{"subtask_id": quote.id, "value": 11.0, "pass_fail": "fail"}],
"calibration": "ok",
}
assert seen["calibration"] == {"px_per_mm": CAL["px_per_mm"]} # stessa calibrazione di execute
assert await _counts(db_session) == before # niente misure ne' VisionResult
assert not list(settings.upload_path.rglob("*.overlay.json")) # ne' file
@pytest.mark.asyncio
@pytest.mark.parametrize("value,expected", [(10.0, "pass"), (10.4, "warning"), (9.0, "fail")])
async def test_check_pass_fail_levels(
client, db_session, measurement_tec_user, monkeypatch, value, expected,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
async def fake_call(image_bytes, graph, **kwargs):
return {"outputs": {"t1.diameter": value}, "failures": [], "engine_version": "e" * 40}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await _check(client, measurement_tec_user, task)
assert resp.json()["measurements"][0]["pass_fail"] == expected
assert resp.json()["calibration"] == "no_camera" # senza camera ne' stazione
@pytest.mark.asyncio
async def test_check_calibration_reasons(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
acquisitore["cal"] = None
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "not_calibrated"
acquisitore["cal"] = "down"
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "unreachable"
assert "calibration" not in worker_calls[-1] # senza calibrazione la ricetta usa la sua
@pytest.mark.asyncio
async def test_check_omits_quotes_the_graph_did_not_produce(
client, db_session, measurement_tec_user, monkeypatch,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
async def fake_call(image_bytes, graph, **kwargs):
return {"outputs": {}, "failures": [{"tool_name": "blob"}], "engine_version": "e" * 40}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await _check(client, measurement_tec_user, task)
assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame
assert resp.json()["measurements"] == []
assert resp.json()["failures"] == [{"tool_name": "blob"}]
@pytest.mark.asyncio
async def test_check_422_without_graph_or_quotes_or_vision_type(
client, db_session, measurement_tec_user, worker_calls,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
task.vision_json = None
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
quote = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().one()
quote.vision_output = None
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
task.task_type = "measure"
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
@pytest.mark.asyncio
async def test_check_with_run_uses_station_worker_and_station_camera(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(
db_session, measurement_tec_user, recipe, "http://10.0.0.7:8100")
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
before = await _counts(db_session)
resp = await _check(client, measurement_tec_user, task, run)
assert resp.status_code == 200, resp.text
assert worker_calls[0]["worker_url"] == "http://10.0.0.7:8100"
assert acquisitore["urls"][0][0] == f"{CAMERA_URL}/camera/calibration"
assert await _counts(db_session) == before
@pytest.mark.asyncio
async def test_check_requires_login(client, db_session, measurement_tec_user, worker_calls):
_, task = await _camera_task(db_session, measurement_tec_user.id)
resp = await client.post(
"/api/vision/check", files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert resp.status_code in (401, 403)
+109
View File
@@ -0,0 +1,109 @@
"""#99: the vision routes accept BMP (the Opto bench cameras save it); TIFF and
non-images stay refused. files.py (attachments) is not touched by this."""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_upload_limits import ( # noqa: F401 - fixtures
recipe_parts, redirect_uploads, worker_says,
)
def a_bmp() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="BMP")
return buffer.getvalue()
def a_tiff() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="TIFF")
return buffer.getvalue()
async def a_camera_task(db_session, user):
recipe = await create_test_recipe(db_session, user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
await db_session.commit()
return task
@pytest.mark.asyncio
@pytest.mark.parametrize("content_type", ["image/bmp", "image/x-ms-bmp"])
async def test_preview_accepts_a_bmp(
client, db_session, maker_user, worker_says, content_type,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", a_bmp(), content_type)},
data={"task_id": str(task.id)},
)
assert response.status_code != 400, response.text
assert response.status_code != 422, response.text
@pytest.mark.asyncio
async def test_preview_still_rejects_a_tiff(
client, db_session, maker_user, worker_says,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.tif", a_tiff(), "image/tiff")},
data={"task_id": str(task.id)},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_a_text_file_declared_bmp_is_refused_on_its_bytes(
client, db_session, maker_user, worker_says,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", b"i am not an image", "image/bmp")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_reference_image_bmp_is_saved_with_the_bmp_extension(
client, db_session, maker_user,
):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
await db_session.commit()
response = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", a_bmp(), "image/x-ms-bmp")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200, response.text
saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
assert saved.path.endswith(".bmp")
assert (settings.upload_path / saved.path).exists()
@@ -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
+50 -1
View File
@@ -541,6 +541,55 @@ def api_list_stations_for_maker():
return jsonify(out), 200
def _camera_services() -> list[dict]:
"""#97: servizi di acquisizione distinti delle stazioni che hanno una camera_url."""
services: dict[str, list[str]] = {}
for st in _stations_for_maker():
url = (st.get("camera_url") or "").strip().rstrip("/")
if not url:
continue
label = f"{st.get('code') or ''} - {st.get('name') or ''}"
services.setdefault(url, []).append(label)
return [{"camera_url": u, "stazioni": s} for u, s in services.items()]
@maker_bp.route("/api/camera/servizi", methods=["GET"])
@login_required
@maker_or_admin_required
def api_camera_services():
"""#97: servizi di acquisizione fra cui scegliere la camera di un task."""
return jsonify({"servizi": _camera_services()}), 200
@maker_bp.route("/api/camera/sorgenti", methods=["GET"])
@login_required
@maker_or_admin_required
def api_camera_sources():
"""#97: proxy di GET {url}/camera/sources; solo per i servizi delle stazioni."""
url = (request.args.get("url") or "").strip().rstrip("/")
# Niente richieste verso indirizzi arbitrari: url ammesso solo se e' un servizio noto
if not url or url not in {s["camera_url"] for s in _camera_services()}:
return jsonify({
"error": True,
"detail": _("Servizio di acquisizione non ammesso"),
}), 400
try:
resp = http_requests.get(f"{url}/camera/sources", timeout=15)
body = resp.json() if resp.status_code == 200 else None
except (http_requests.RequestException, ValueError) as exc:
return jsonify({
"error": True,
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
sources = body.get("sources") if isinstance(body, dict) else None
if not isinstance(sources, list):
return jsonify({
"error": True,
"detail": _("Risposta del servizio camera non valida (%(status)s)", status=resp.status_code),
}), 502
return jsonify({"sources": sources}), 200
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
@login_required
@@ -598,7 +647,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",
+82 -2
View File
@@ -1,4 +1,5 @@
"""MeasurementTec blueprint - recipe selection and measurement execution."""
import json
import urllib.parse
import requests as http_requests
@@ -337,6 +338,14 @@ def task_execute(task_id: int):
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
# #97: camera del task (acquisition_json) o della stazione; il modo di analisi
# (impulsiva / in_linea) viene dal task. Solo i task visione ne hanno bisogno.
camera = None
camera_mode = "impulsiva"
if task_resp.get("task_type") in ("camera_measure", "dxf_compare"):
camera = _task_camera(task_resp, _current_station()[0])
camera_mode = _task_mode(task_resp)
return render_template(
"measure/task_execute.html",
task=task_resp,
@@ -345,7 +354,8 @@ def task_execute(task_id: int):
all_task_ids=all_task_ids,
measurement_interval_minutes=measurement_interval_minutes,
allow_manual_input=allow_manual_input,
camera_enabled=_station_camera(_current_station()[0]) is not None,
camera_enabled=camera is not None,
camera_mode=camera_mode,
)
@@ -1001,6 +1011,42 @@ def api_vision_execute():
return jsonify(resp), 200
# #97: analysis of a frame without saving anything (preview before «Registra»).
@measure_bp.route("/api/vision/check", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_vision_check():
"""Proxy: run the task's VisionSuite recipe on the image, nothing is saved."""
image = request.files.get("image")
task_id = request.form.get("task_id")
if image is None or not task_id:
return jsonify({
"error": True,
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
}), 400
form = {"task_id": task_id}
if request.form.get("production_run_id"):
form["production_run_id"] = request.form["production_run_id"]
resp = api_client.post(
"/api/vision/check",
data=form,
files={"image": (
image.filename or "capture.jpg",
image.read(),
image.mimetype or "application/octet-stream",
)},
)
if resp.get("error"):
status_code = resp.get("status_code", 500)
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
if resp.get("session_expired"):
body["session_expired"] = True
return jsonify(body), status_code if status_code >= 400 else 500
return jsonify(resp), 200
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
"""(camera_url, camera_source) of the station, None when it has no camera.
@@ -1019,6 +1065,31 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
return str(url), str(source)
def _task_acquisition(task: dict | None) -> dict | None:
"""#97: acquisition_json of a task as a dict, None when absent or unreadable."""
acq = task.get("acquisition_json") if isinstance(task, dict) else None
if isinstance(acq, str):
try:
acq = json.loads(acq)
except ValueError:
return None
return acq if isinstance(acq, dict) else None
def _task_camera(task: dict | None, station_code: str | None) -> tuple[str, str] | None:
"""#97: camera of a task = its acquisition_json (url + source), else the station's."""
acq = _task_acquisition(task)
if acq and acq.get("camera_url") and acq.get("camera_source"):
return str(acq["camera_url"]), str(acq["camera_source"])
return _station_camera(station_code)
def _task_mode(task: dict | None) -> str:
"""#97: analysis mode of a task, «impulsiva» unless acquisition_json says «in_linea»."""
acq = _task_acquisition(task)
return "in_linea" if acq and acq.get("modo") == "in_linea" else "impulsiva"
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
@measure_bp.route("/api/vision/grab", methods=["POST"])
@@ -1026,7 +1097,16 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
@role_required("MeasurementTec")
def api_vision_grab():
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
camera = _station_camera(_current_station()[0])
# #97: con task_id (form o json) la camera e' quella del task, altrimenti della stazione
payload = request.get_json(silent=True) or {}
task_id = request.form.get("task_id") or payload.get("task_id")
task = None
if task_id:
try:
task = api_client.get(f"/api/tasks/{int(task_id)}")
except (TypeError, ValueError):
task = None
camera = _task_camera(task, _current_station()[0])
if camera is None:
return jsonify({
"error": True,
@@ -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';
@@ -0,0 +1,70 @@
// «Acquisizione» dell'editor task (#97): camera (servizio + sorgente) e modo di analisi
// («impulsiva» / «in linea») dei task camera_measure e dxf_compare.
// Legge /maker/api/camera/servizi e /maker/api/camera/sorgenti?url=. Lo stato scelto sta
// nel componente padre (editTaskData.acquisition): questo componente carica solo gli elenchi.
// Uso: x-data="taskAcquisition(() => editTaskData.acquisition, msgs)". Dipende da Alpine.js.
function taskAcquisition(getAcq, msgs) {
return {
msgs: msgs || {},
servizi: [],
sources: [],
loading: false,
error: '',
async init() {
await this.loadServizi();
// Task con camera gia' scelta: elenco sorgenti del suo servizio
const acq = getAcq();
if (acq && acq.camera_url) await this.loadSources(acq.camera_url);
},
async loadServizi() {
try {
const resp = await fetch('/maker/api/camera/servizi');
if (resp.ok) this.servizi = (await resp.json()).servizi || [];
} catch (e) { console.error('taskAcquisition servizi', e); }
},
// Servizio salvato nel task ma non piu' fra quelli delle stazioni: resta selezionabile
serviziConCorrente() {
const acq = getAcq();
const out = this.servizi.slice();
if (acq && acq.camera_url && !out.some(s => s.camera_url === acq.camera_url)) {
out.push({ camera_url: acq.camera_url, stazioni: [] });
}
return out;
},
// Cambio servizio: la sorgente scelta non vale piu'
async onServizio() {
const acq = getAcq();
acq.camera_source = '';
this.sources = [];
this.error = '';
if (acq.camera_url) await this.loadSources(acq.camera_url);
},
async loadSources(url) {
this.loading = true;
this.error = '';
try {
const resp = await fetch('/maker/api/camera/sorgenti?url=' + encodeURIComponent(url));
const data = await resp.json().catch(() => ({}));
if (resp.ok) this.sources = data.sources || [];
else this.error = data.detail || this.msgs.genericError || '';
} catch (e) {
this.error = this.msgs.genericError || String(e);
} finally { this.loading = false; }
},
// Sorgente salvata ma non piu' elencata dal servizio: resta visibile
sourcesConCorrente() {
const acq = getAcq();
const out = this.sources.slice();
if (acq && acq.camera_source && !out.some(s => s.name === acq.camera_source)) {
out.push({ name: acq.camera_source, label: acq.camera_source });
}
return out;
}
};
}
@@ -11,6 +11,13 @@
* taskType, subtasks, labels.
* Run context (lot, serial, production run) is read lazily through `ctx()`,
* which the template binds to the parent task component.
*
* #97: with a camera (cameraAvailable, the task's or the station's) the task opens
* on a frame already grabbed and analysed (/vision/check, nothing saved: «preview»).
* modo «impulsiva»: «Acquisisci» redoes grab+check. modo «in_linea»: continuous
* loop grab+check, one request at a time, standing still while paused / page hidden
* / leaving / saving. «Registra» saves the LAST frame shown through /vision/execute.
* Extra config: cameraAvailable, modo, checkUrl.
*/
function visionExecute() {
// Overlay colour per role (VisionSuite roles: reference, roi, found).
@@ -24,6 +31,125 @@ function visionExecute() {
imageSrc: '', // object URL of the acquired image (shown under the overlay)
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
// #97: preview / loop state
preview: false, // result on screen is an analysis only, not saved
saved: false, // «Registra» done: the saved result replaces the preview
lastBlob: null, // last frame shown, the one «Registra» saves
paused: false, // operator paused the in-line loop
registering: false, // «Registra» in progress: the loop stands still
leaving: false, // page is being left: no more requests
looping: false, // an in-line loop is running
gen: 0, // bumped by «Registra»: answers of older requests are dropped
// #97: start on a frame already acquired and analysed; hook visibility and exit.
init() {
if (!this.hasCamera) return;
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
const leave = () => { this.leaving = true; };
window.addEventListener('pagehide', leave);
window.addEventListener('beforeunload', leave);
// After x-init has bound ctx(): in-line starts the loop, impulsive grabs once.
this.$nextTick(() => { if (this.inLinea) this.loop(); else this.grab(); });
},
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
// «Registra» needs a frame on screen that is not saved yet
get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; },
shouldRun() {
return this.inLinea && !this.paused && !this.saved && !this.registering
&& !this.leaving && !document.hidden;
},
// Page visible again: restart the loop unless the operator paused it.
kick() { if (this.shouldRun() && !this.looping) this.loop(); },
// In-line loop: one request at a time, at least 300 ms between two of them
// (longer after an error, so a dead camera is not hammered).
async loop() {
if (this.looping) return;
this.looping = true;
try {
while (this.shouldRun()) {
const ok = await this.grab();
await new Promise((r) => setTimeout(r, ok ? 300 : 2000));
}
} finally {
this.looping = false;
}
},
// «Pausa» / «Riprendi»; after «Registra» only «Riprendi» restarts the loop.
togglePause() {
if (this.paused || this.saved) {
this.paused = false;
this.saved = false;
this.loop();
} else {
this.paused = true;
}
},
// Analysis only (/vision/check): the frame becomes the preview, nothing is saved.
async analyze(blob) {
const cfg = window.__visionCfg || {};
if (!blob || !cfg.checkUrl) return false;
const gen = this.gen;
this.busy = true;
this.error = '';
try {
const c = this.ctx() || {};
const fd = new FormData();
fd.append('task_id', cfg.taskId);
fd.append('image', blob, blob.name || 'capture.jpg');
if (c.runId) fd.append('production_run_id', c.runId);
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch(cfg.checkUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (gen !== this.gen) return false; // «Registra» took over
if (!resp.ok || body.error) {
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
}
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageSrc = URL.createObjectURL(blob);
this.lastBlob = blob;
this.result = body;
this.preview = true;
this.saved = false;
this.$nextTick(() => this.drawOverlay());
return true;
} catch (e) {
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
} finally {
if (gen === this.gen) this.busy = false;
}
},
// «Registra»: save the LAST frame shown with the usual /vision/execute.
async record() {
if (!this.canRecord) return;
const blob = this.lastBlob;
this.registering = true;
this.gen++; // drop whatever request is still in flight
this.busy = false;
this.grabbing = false;
try {
await this.acquire(blob);
if (!this.error && this.result && this.result.vision_result_id) {
this.preview = false;
this.saved = true;
}
} finally {
this.registering = false;
this.kick(); // in-line: stays still while saved, restarts if the save failed
}
},
// Entry point shared with the future agent camera.
async acquire(blob) {
if (!blob || this.busy) return;
@@ -64,37 +190,52 @@ function visionExecute() {
// Station camera (#72): the Flask proxy answers a PNG, which goes through
// acquire() like a picked file. Errors show as the execute ones do.
// #97: sends task_id (the task's camera wins over the station's); with a camera
// the frame goes to the preview analysis. Returns true when the frame was analysed.
async grab() {
const cfg = window.__visionCfg || {};
if (this.busy || this.grabbing || !cfg.grabUrl) return;
if (this.busy || this.grabbing || this.registering || !cfg.grabUrl) return false;
const gen = this.gen;
this.grabbing = true;
this.error = '';
let blob = null;
try {
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const fd = new FormData();
fd.append('task_id', cfg.taskId);
const resp = await fetch(cfg.grabUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf },
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return;
if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
}
blob = await resp.blob();
} catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return;
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
} finally {
this.grabbing = false;
if (gen === this.gen) this.grabbing = false;
}
if (blob) await this.acquire(blob);
if (!blob || gen !== this.gen) return false;
if (this.hasCamera) return await this.analyze(blob);
await this.acquire(blob);
return !this.error;
},
// Input change: take the file and allow picking the same one again.
// #97: with a camera the file is a preview too (the loop pauses), «Registra» saves it.
onPick(ev) {
const f = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (f) this.acquire(f);
if (!f) return;
if (this.hasCamera) {
if (this.inLinea) this.paused = true;
this.analyze(f);
} else {
this.acquire(f);
}
},
// Image shown: the saved one from the server when available, else the local one.
@@ -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;
},
@@ -547,6 +547,71 @@
<div class="tmf-card-body space-y-4">
<!-- ========== Acquisizione (#97): camera e modo di analisi, solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Acquisizione') }}
</p>
<!-- Lettura: camera e modo salvati nel task -->
<template x-if="editingTask !== task.id">
<div class="text-xs space-y-1">
<template x-if="task.acquisition_json && task.acquisition_json.camera_url">
<div class="space-y-1">
<p><span class="text-[var(--text-secondary)]">{{ _('Servizio di acquisizione') }}:</span>
<span class="font-mono" x-text="task.acquisition_json.camera_url"></span></p>
<p><span class="text-[var(--text-secondary)]">{{ _('Sorgente') }}:</span>
<span class="font-mono" x-text="task.acquisition_json.camera_source"></span></p>
<p><span class="text-[var(--text-secondary)]">{{ _('Modo di analisi') }}:</span>
<span x-text="task.acquisition_json.modo === 'in_linea' ? {{ _('Analisi in linea')|tojson_attr }} : {{ _('Analisi impulsiva')|tojson_attr }}"></span></p>
</div>
</template>
<p x-show="!(task.acquisition_json && task.acquisition_json.camera_url)" class="text-[var(--text-muted)]">
{{ _("Camera della stazione dell'operatore") }}
</p>
</div>
</template>
<!-- Modifica: servizio, sorgente, modo (salvati con «Salva» del task) -->
<template x-if="editingTask === task.id">
<div class="space-y-3"
x-data="taskAcquisition(() => editTaskData.acquisition, {{ {'genericError': _('Errore durante il caricamento delle sorgenti')}|tojson_attr }})">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Servizio di acquisizione') }}</label>
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_url" @change="onServizio()">
<option value="">{{ _("Camera della stazione dell'operatore") }}</option>
<template x-for="sv in serviziConCorrente()" :key="sv.camera_url">
<option :value="sv.camera_url"
x-text="sv.camera_url + (sv.stazioni.length ? ' (' + sv.stazioni.join(', ') + ')' : '')"></option>
</template>
</select>
</div>
<div x-show="editTaskData.acquisition.camera_url">
<label class="tmf-label text-xs">{{ _('Sorgente') }}</label>
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_source" :disabled="loading">
<option value="">{{ _('Scegli la sorgente') }}</option>
<template x-for="src in sourcesConCorrente()" :key="src.name">
<option :value="src.name" x-text="src.label || src.name"></option>
</template>
</select>
</div>
</div>
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
<div x-show="editTaskData.acquisition.camera_url" class="flex flex-wrap items-center gap-4 text-xs">
<label class="flex items-center gap-2">
<input type="radio" :name="'acq-modo-' + task.id" value="impulsiva" x-model="editTaskData.acquisition.modo">
{{ _('Analisi impulsiva') }}
</label>
<label class="flex items-center gap-2">
<input type="radio" :name="'acq-modo-' + task.id" value="in_linea" x-model="editTaskData.acquisition.modo">
{{ _('Analisi in linea') }}
</label>
</div>
</div>
</template>
</div>
</template>
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
@@ -1335,6 +1400,7 @@
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
<script src="{{ url_for('static', filename='js/task_acquisition.js') }}"></script>{# #97 #}
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
@@ -1389,7 +1455,7 @@ function taskEditor() {
// ---- Task CRUD state ----
editingTask: null,
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }, // #97
newTask: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
MAX_NOTE_FIELDS: 20, // #55: limite campi per nota (stesso del backend)
_fieldUid: 0,
@@ -1575,6 +1641,18 @@ function taskEditor() {
this.newTask = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
},
// #97: acquisition_json del task; null = camera della stazione (voce vuota o tipo senza camera)
_acquisitionPayload(obj) {
const a = obj.acquisition || {};
const usesCamera = obj.task_type === 'camera_measure' || obj.task_type === 'dxf_compare';
if (!usesCamera || !a.camera_url) return { ok: true, value: null };
if (!a.camera_source) {
this.errorMessage = {{ _("Scegli la sorgente della camera")|tojson }};
return { ok: false };
}
return { ok: true, value: { camera_url: a.camera_url, camera_source: a.camera_source, modo: a.modo === 'in_linea' ? 'in_linea' : 'impulsiva' } };
},
// ---- #53 #55: ciclo di misura e campi della nota ----
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
@@ -1785,7 +1863,13 @@ function taskEditor() {
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
in_loop_touched: false,
at_close: !!task.at_close,
fields: this._fieldsToState(task.fields)
fields: this._fieldsToState(task.fields),
// #97: camera e modo di analisi del task (acquisition_json), vuoti = camera della stazione
acquisition: {
camera_url: (task.acquisition_json && task.acquisition_json.camera_url) || '',
camera_source: (task.acquisition_json && task.acquisition_json.camera_source) || '',
modo: (task.acquisition_json && task.acquisition_json.modo) || 'impulsiva'
}
};
// Ensure expanded
this.expandedTask = task.id;
@@ -1793,7 +1877,7 @@ function taskEditor() {
cancelEditTask() {
this.editingTask = null;
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }; // #97
},
async updateTask(taskId) {
@@ -1801,6 +1885,8 @@ function taskEditor() {
this.errorMessage = '';
const fp = this._fieldsPayload(this.editTaskData); // #55
if (!fp.ok) return;
const acq = this._acquisitionPayload(this.editTaskData); // #97
if (!acq.ok) return;
this.saving = true;
try {
@@ -1818,7 +1904,8 @@ function taskEditor() {
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
in_loop: !!this.editTaskData.in_loop,
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
fields: fp.fields
fields: fp.fields,
acquisition_json: acq.value // #97
})
});
@@ -534,9 +534,10 @@
<div x-show="isVisionTask" x-cloak
x-data="visionExecute()"
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
class="flex-1 min-h-0 overflow-hidden p-3 flex flex-col lg:flex-row gap-3">
{# #96: il pannello non scorre mai (overflow-hidden, min-h-0): l'immagine sta in fit nello spazio che resta #}
<div class="flex-1 min-w-0 flex flex-col gap-2">
<div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
@@ -545,12 +546,29 @@
@change="onPick($event)" :disabled="busy">
</label>
{% if camera_enabled %}
{# #72: frame dalla camera della stazione, stessa strada di acquire() #}
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
{% if camera_mode == 'in_linea' %}
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
:class="registering ? 'opacity-50 pointer-events-none' : ''"
:disabled="registering">
<span x-text="(paused || saved) ? '{{ _('Riprendi') }}' : '{{ _('Pausa') }}'"></span>
</button>
{% else %}
{# #72: frame dalla camera, stessa strada di acquire(); #97: ora grab + analisi senza salvare #}
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
:disabled="busy || grabbing">
<span>{{ _('Acquisisci dalla camera') }}</span>
<span>{{ _('Acquisisci') }}</span>
</button>
{% endif %}
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
<button type="button" class="btn btn-primary text-sm gap-1.5" @click="record()"
:class="canRecord ? '' : 'opacity-50 pointer-events-none'"
:disabled="!canRecord">
<span>{{ _('Registra') }}</span>
</button>
<span x-show="preview && !saved" x-cloak
class="inline-block px-2 py-0.5 rounded text-xs font-bold bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700">{{ _('Anteprima, non registrata') }}</span>
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
{% endif %}
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
@@ -558,16 +576,18 @@
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
{# #96: riquadro flex-1 min-h-0; img e canvas object-contain sovrapposti (il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato) #}
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="absolute inset-0 w-full h-full object-contain">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
</div>
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
</div>
</div>
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
{# #96: colonna esito con scorrimento proprio (solo con molte quote); sotto lg max 40% dell'altezza #}
<div class="lg:w-80 shrink-0 min-h-0 overflow-y-auto max-h-[40%] lg:max-h-none flex flex-col gap-2" x-show="result">
<div class="text-sm font-bold text-[var(--text-primary)]">
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
</div>
@@ -1146,6 +1166,9 @@
window.__visionCfg = {
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
checkUrl: '{{ url_for("measure.api_vision_check") }}', {# #97: analisi senza salvare #}
cameraAvailable: {{ (camera_enabled|default(false))|tojson }},
modo: {{ (camera_mode|default('impulsiva'))|tojson }},
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|tojson }},
@@ -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)
@@ -0,0 +1,140 @@
"""gitea:Adriano/TieMeasureFlow#97 - camera e modo di analisi nel task visione (lato Maker).
api_client e requests sono finti: nessun servizio camera vero.
"""
import json
import re
from unittest.mock import MagicMock, patch
import requests
from tests.conftest import api_get_router
ST1 = {"id": 4, "code": "ST-4", "name": "Banco", "camera_url": "http://acq:8765/", "camera_source": "camera:1"}
ST2 = {"id": 5, "code": "ST-5", "name": "Linea", "camera_url": "http://acq:8765", "camera_source": "camera:2"}
ST3 = {"id": 6, "code": "ST-6", "name": "Altra", "camera_url": "http://altro:9000", "camera_source": "x"}
NO_CAMERA = {"id": 7, "code": "ST-7", "name": "Senza", "camera_url": None, "camera_source": None}
SOURCES = {"sources": [
{"name": "camera:601020", "kind": "camera", "label": "Basler 601020"},
{"name": "pezzo.png", "kind": "image", "label": "immagine - pezzo.png"},
]}
def _stations(mock_api_client, stations=(ST1, ST2, ST3, NO_CAMERA)):
mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)})
def _fake(status=200, body=None):
resp = MagicMock(status_code=status, text="")
if body is None:
resp.json.side_effect = ValueError("no json")
else:
resp.json.return_value = body
return resp
class TestCameraServizi:
def test_distinct_urls_with_stations(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp = logged_in_client.get("/maker/api/camera/servizi")
assert resp.status_code == 200
assert resp.get_json() == {"servizi": [
{"camera_url": "http://acq:8765", "stazioni": ["ST-4 - Banco", "ST-5 - Linea"]},
{"camera_url": "http://altro:9000", "stazioni": ["ST-6 - Altra"]},
]}
def test_no_stations_with_camera(self, logged_in_client, mock_api_client):
_stations(mock_api_client, [NO_CAMERA])
assert logged_in_client.get("/maker/api/camera/servizi").get_json() == {"servizi": []}
class TestCameraSorgenti:
def test_lists_sources(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get", return_value=_fake(200, SOURCES)) as get:
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765/")
assert resp.status_code == 200
assert resp.get_json() == SOURCES
args, kwargs = get.call_args
assert args[0] == "http://acq:8765/camera/sources"
assert kwargs["timeout"] == 15
def test_url_not_a_station_service_is_400(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get") as get:
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://evil:1")
assert resp.status_code == 400
get.assert_not_called()
def test_missing_url_is_400(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get") as get:
assert logged_in_client.get("/maker/api/camera/sorgenti").status_code == 400
get.assert_not_called()
def test_service_down_is_502(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get",
side_effect=requests.ConnectionError("refused")):
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765")
assert resp.status_code == 502
assert "refused" in resp.get_json()["detail"]
def test_service_error_status_is_502(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get", return_value=_fake(500, {"detail": "x"})):
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765")
assert resp.status_code == 502
class TestTaskProxyForwardsAcquisition:
def test_put_forwards_acquisition_json(self, logged_in_client, mock_api_client):
acq = {"camera_url": "http://acq:8765", "camera_source": "camera:1", "modo": "in_linea"}
mock_api_client.put.return_value = {"id": 11, "title": "T", "acquisition_json": acq}
resp = logged_in_client.put("/maker/api/tasks/11", json={"title": "T", "acquisition_json": acq})
assert resp.status_code == 200
assert mock_api_client.put.call_args.kwargs["data"]["acquisition_json"] == acq
def test_put_forwards_null(self, logged_in_client, mock_api_client):
mock_api_client.put.return_value = {"id": 11, "title": "T", "acquisition_json": None}
logged_in_client.put("/maker/api/tasks/11", json={"title": "T", "acquisition_json": None})
assert mock_api_client.put.call_args.kwargs["data"]["acquisition_json"] is None
class TestTaskEditorAcquisitionUi:
def _html(self, client, mock_api_client, task_type="camera_measure"):
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": [{
"id": 11, "title": "T", "task_type": task_type, "subtasks": [],
"file_path": None, "order_index": 1,
"acquisition_json": {"camera_url": "http://acq:8765", "camera_source": "camera:1",
"modo": "in_linea"},
}],
})
return client.get("/maker/recipes/3/tasks").get_data(as_text=True)
def test_section_and_fields(self, logged_in_client, mock_api_client):
html = self._html(logged_in_client, mock_api_client)
assert "taskAcquisition(" in html
assert "js/task_acquisition.js" in html
assert "Servizio di acquisizione" in html
assert "Sorgente" in html
assert "Analisi impulsiva" in html and "Analisi in linea" in html
assert 'value="impulsiva"' in html and 'value="in_linea"' in html
assert "Camera della stazione dell&#39;operatore" in html or \
"Camera della stazione dell'operatore" in html
def test_payload_carries_acquisition_json(self, logged_in_client, mock_api_client):
html = self._html(logged_in_client, mock_api_client)
assert "acquisition_json: acq.value" in html
assert "_acquisitionPayload(this.editTaskData)" in html
# il task salvato riempie lo stato di modifica
assert "task.acquisition_json.camera_url" in html
def test_static_js_endpoints(self):
import os
path = os.path.join(os.path.dirname(__file__), "..", "static", "js", "task_acquisition.js")
src = open(path, encoding="utf-8").read()
assert "/maker/api/camera/servizi" in src
assert "/maker/api/camera/sorgenti?url=" in src
@@ -0,0 +1,182 @@
"""gitea:Adriano/TieMeasureFlow#97 - operator: image already analysed on entry.
The backend (POST /api/vision/check, task.acquisition_json) is built elsewhere:
api_client and requests are mocked here with the contract's shapes.
"""
import io
import json
from unittest.mock import MagicMock, patch
from tests.conftest import api_get_router
from tests.test_measure_camera_grab import (
PNG, RECIPE, STATION, TASK, _fake, _station,
)
ACQ = {"camera_url": "http://acq-host:9000", "camera_source": "camera:601020", "modo": "in_linea"}
def _task_with(acq):
return {**TASK, "acquisition_json": acq}
# --- grab: the camera of the task, else the station's -------------------------
def test_grab_with_task_id_uses_task_camera(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200 and resp.data == PNG
args, kwargs = post.call_args
assert args[0] == "http://acq-host:9000/camera/grab"
assert kwargs["json"]["source"] == "camera:601020"
mock_api_client.get_station_by_code.assert_not_called()
def test_grab_task_id_in_json_works_too(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", json={"task_id": 12})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
def test_grab_task_without_acquisition_uses_station(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(None)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
args, kwargs = post.call_args
assert args[0] == "http://host.docker.internal:8765/camera/grab"
assert kwargs["json"]["source"] == "camera:folder"
def test_grab_task_camera_wins_even_if_station_has_none(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
def test_grab_task_lookup_error_falls_back_to_station(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router(
{"/api/tasks/12": {"error": True, "status_code": 404, "detail": "x"}})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://host.docker.internal:8765/camera/grab"
def test_grab_acquisition_as_json_string(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
mock_api_client.get.side_effect = api_get_router(
{"/api/tasks/12": _task_with(json.dumps(ACQ))})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
# --- check proxy ---------------------------------------------------------------
CHECK_OK = {
"outcome": "ok", "overlay": [], "failures": [], "calibration": "ok",
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
}
def test_check_proxy_forwards_multipart(logged_in_client, mock_api_client):
mock_api_client.post.return_value = CHECK_OK
resp = logged_in_client.post(
"/measure/api/vision/check",
data={
"task_id": "12", "production_run_id": "4",
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
},
content_type="multipart/form-data",
)
assert resp.status_code == 200
assert resp.get_json()["measurements"][0]["pass_fail"] == "pass"
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/check"
assert kwargs["data"] == {"task_id": "12", "production_run_id": "4"}
name, content, mime = kwargs["files"]["image"]
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
def test_check_proxy_without_run_leaves_it_out(logged_in_client, mock_api_client):
mock_api_client.post.return_value = CHECK_OK
logged_in_client.post(
"/measure/api/vision/check",
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
content_type="multipart/form-data",
)
assert mock_api_client.post.call_args[1]["data"] == {"task_id": "12"}
def test_check_proxy_requires_image(logged_in_client, mock_api_client):
resp = logged_in_client.post("/measure/api/vision/check", data={"task_id": "12"})
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_check_proxy_relays_backend_error(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "nessun grafo"}
resp = logged_in_client.post(
"/measure/api/vision/check",
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
content_type="multipart/form-data",
)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "nessun grafo"
def test_check_requires_login(client):
resp = client.post("/measure/api/vision/check")
assert resp.status_code in (302, 401)
# --- execute page: buttons and config -------------------------------------------
def _page(logged_in_client, mock_api_client, monkeypatch, station, task):
_station(mock_api_client, monkeypatch, station)
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/12": task,
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
"/api/recipes/3": RECIPE,
})
return logged_in_client.get("/measure/execute/12").get_data(as_text=True)
def test_page_impulsive_has_acquire_and_record(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, TASK)
assert 'cameraAvailable: true' in html
assert 'modo: "impulsiva"' in html
assert "/measure/api/vision/check" in html
assert "grab()" in html and "record()" in html
assert "togglePause()" not in html
assert "Anteprima, non registrata" in html
def test_page_in_line_has_pause_and_record(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, _task_with(ACQ))
assert 'modo: "in_linea"' in html
assert "togglePause()" in html and "record()" in html
assert "grab()" not in html # the loop grabs by itself
def test_page_task_camera_enables_it_without_station_camera(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch,
{**STATION, "camera_url": None}, _task_with({**ACQ, "modo": "impulsiva"}))
assert 'cameraAvailable: true' in html
assert "grab()" in html
def test_page_without_camera_is_as_before(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch,
{**STATION, "camera_url": None}, TASK)
assert 'cameraAvailable: false' in html
assert "record()" not in html and "togglePause()" not in html and "grab()" not in html
@@ -0,0 +1,54 @@
"""gitea:Adriano/TieMeasureFlow#96 - l'immagine del task visione sta in fit, senza scorrimento.
Controllo sulle classi del template: pannello overflow-hidden, img/canvas
object-contain sovrapposti, nessun h-auto sull'immagine (era la causa dello scorrimento).
"""
from pathlib import Path
TEMPLATE = Path(__file__).resolve().parents[1] / "templates" / "measure" / "task_execute.html"
def _panel():
src = TEMPLATE.read_text(encoding="utf-8")
start = src.rindex("<div", 0, src.index('x-data="visionExecute()"'))
end = src.index("MEASUREMENT TIMER BANNER")
return src[start:end]
def _tag_with(panel, marker):
# Il tag (aperto) che contiene il marker; i '>' dentro le virgolette (es. "=>") non lo chiudono.
pos = panel.index(marker)
i = panel.rindex("<", 0, pos)
quote = None
for j in range(i, len(panel)):
c = panel[j]
if quote:
quote = None if c == quote else quote
elif c in "\"'":
quote = c
elif c == ">":
return panel[i:j + 1]
raise AssertionError(marker)
def test_panel_does_not_scroll():
tag = _tag_with(_panel(), 'x-data="visionExecute()"')
assert "overflow-hidden" in tag and "min-h-0" in tag
assert "overflow-auto" not in tag
def test_image_is_object_contain_without_h_auto():
panel = _panel()
img = _tag_with(panel, 'x-ref="img"')
assert "object-contain" in img
assert "h-auto" not in img
canvas = _tag_with(panel, 'x-ref="overlay"')
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato
def test_image_box_and_outcome_column_can_shrink():
panel = _panel()
box = _tag_with(panel, 'x-show="shownImage"')
assert "flex-1" in box and "min-h-0" in box
outcome = _tag_with(panel, 'x-show="result">')
assert "overflow-y-auto" in outcome and "min-h-0" in outcome
@@ -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"