Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dd4b0a20c | |||
| 271ce73d09 | |||
| 1bd6c6b011 | |||
| d8bca535ac | |||
| 14257304f8 | |||
| 5738920c9c | |||
| 452862e0f3 | |||
| 999f47092d | |||
| 6441a92062 | |||
| 8c361c14ae | |||
| 5897a9e5df | |||
| f44b578d5b | |||
| c5be9a2bb4 | |||
| 17ea010f7e | |||
| 1d6d74b2e4 | |||
| 2f0a5aaf1e | |||
| e96fbb73c2 | |||
| f3bacc8238 | |||
| e743363a62 | |||
| d48a8a1076 | |||
| 8ef61a1285 | |||
| 84fc1a08f7 | |||
| c904800e94 | |||
| 20c0a47c00 | |||
| 269bbc03ca | |||
| 815d49d699 | |||
| 43ef22079d | |||
| b01bba536a | |||
| 17344aae13 | |||
| fd3384f08c | |||
| 08aefa9888 | |||
| 06f308be6f | |||
| 93afd6187c | |||
| 8217f46d47 | |||
| d4802fd1a3 | |||
| ea64f3c524 | |||
| 58baed19e2 | |||
| 25b0741003 | |||
| df67a32a82 | |||
| 5723f055c4 | |||
| 4485803122 | |||
| acd94a9d33 | |||
| 88b3f202b1 | |||
| 4a11d2595f | |||
| b46ccba1ee | |||
| 7a1df0bca4 | |||
| 198c6c63c4 | |||
| 04b81ad2f2 | |||
| 45c9e6c3f3 | |||
| ca6cd0cab1 | |||
| 34c251aa90 | |||
| c70d29d6d7 | |||
| 96aca975d0 | |||
| b3c56ef5b5 | |||
| 9111eb2741 | |||
| b46a70347f | |||
| 155abfd15c | |||
| 2c2d601495 | |||
| b2e3867370 | |||
| cab419bdc7 | |||
| 249999fa1e | |||
| a3e032d000 | |||
| 57376556d2 | |||
| 7cb877b11b | |||
| 08616aa1fa | |||
| 02f1481fe5 | |||
| 34e1b042f5 | |||
| bdef1f1cd8 | |||
| 39b9ad77eb | |||
| 12632323e8 | |||
| 839a61e479 | |||
| 9b8e729927 | |||
| 6080e6aebc | |||
| d5db5f3014 | |||
| 1a19bb6679 | |||
| 20f33234a8 | |||
| 711abc96df | |||
| 77419f9ebd | |||
| 4791c34205 | |||
| e582df7962 | |||
| a7b91b5508 | |||
| addf5a2598 | |||
| bdb1e8c8b9 | |||
| ebaafbe6e9 | |||
| 351153e78a | |||
| 842ceca007 | |||
| b78f2422d8 | |||
| 444dc0a6d1 | |||
| 27d7c1e832 | |||
| 4b1b23cfb6 | |||
| 4b57efc73c | |||
| 7c03f53000 | |||
| 67337fa232 | |||
| 661b782bad | |||
| 25f8b4d609 | |||
| b98d1f15f4 | |||
| 8f9731839a | |||
| 552f96bb47 | |||
| 71daab184f | |||
| 9beaf2ed67 | |||
| 800ec41254 | |||
| 9c45a51770 | |||
| 4a48af2ae3 | |||
| b879563738 | |||
| f504c86491 | |||
| 632fd51050 | |||
| 8eb80e20a8 | |||
| b2f82fe779 | |||
| 3c5c5f9bc5 | |||
| a5e8472e88 | |||
| d6cee498ad | |||
| e7d5489438 | |||
| 828471d27a | |||
| cf64a247be | |||
| 813b66d0c5 | |||
| 8c6d6c1630 | |||
| 1afaaa0bae | |||
| cb66371a5b | |||
| 390d6210d0 | |||
| 2760623b53 | |||
| 13eeec7119 | |||
| 0629f1b5ec | |||
| 9eb5942459 | |||
| 11c7d2e98b | |||
| 88bba213e2 | |||
| 8ce7b489d6 | |||
| bc90d327f1 | |||
| e46965cb51 | |||
| c554fcfda7 | |||
| 08e46e335b | |||
| e38e1a2eaf | |||
| 7fa5bacdfe | |||
| 896a30b376 | |||
| 13dbc8f13b | |||
| 8925968f34 | |||
| 2959b79fa2 | |||
| 990b5d0db8 | |||
| 1bfcfaa5f2 | |||
| ad6ce5d11d | |||
| 351739ec36 | |||
| 9f2d624b4f | |||
| 9fe82d7cef | |||
| ae2353f7f8 |
@@ -73,3 +73,9 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Subagent-driven development scratch: ledger, briefs, review packages
|
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
|
||||||
|
packaging/windows/_strumenti.py
|
||||||
|
|
||||||
|
# #109: scritto da packaging/windows/sviluppo.ps1 (sha del submodule), non va versionato
|
||||||
|
/vision-engine-version.txt
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
|
|||||||
|
|
||||||
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||||
|
|
||||||
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 2e73691.
|
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 9eb6f4c.
|
||||||
|
|
||||||
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
|
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
|
||||||
|
|
||||||
@@ -347,6 +347,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
|||||||
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
|
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
|
||||||
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
|
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
|
||||||
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
|
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
|
||||||
|
- ⚠️ Strumenti di visione (TieMeasureFlow#82): nel binario non ci sono i metadati di `vs-tools`, quindi `load_tools()` (entry point `visionsuite.task.tools`) non trova nulla e ogni ricetta fallisce con tipo sconosciuto. `build.ps1` genera `packaging/windows/_strumenti.py` (`MODULI`, gitignorato, letto dal venv del build), `avvio.py` `registra_strumenti()` lo importa prima dei server, e prima di Inno Setup `build.ps1` lancia l'exe con `--controlla-strumenti` (senza licenza né server; JSON in `...\log\controllo-strumenti.json`, exit 3 se il catalogo è vuoto o un modulo non si importa).
|
||||||
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
|
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
|
||||||
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
|
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
|
||||||
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
|
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radic
|
|||||||
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
|
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
|
||||||
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
|
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
|
||||||
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
|
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
|
||||||
|
`--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni
|
||||||
|
ne' database; registra gli strumenti, scrive su stdout e in
|
||||||
|
%PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON
|
||||||
|
{strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3.
|
||||||
|
La usa build.ps1 prima di Inno Setup.
|
||||||
|
|
||||||
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
|
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
|
||||||
|
|
||||||
@@ -25,11 +30,15 @@ Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, wai
|
|||||||
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
|
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
|
||||||
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
|
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
|
||||||
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
|
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
|
||||||
|
⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli
|
||||||
|
strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py`
|
||||||
|
(MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -264,6 +273,50 @@ def _prepara_log(dati: Path | None = None) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- strumenti di visione
|
||||||
|
|
||||||
|
def registra_strumenti() -> list[str]:
|
||||||
|
"""Registra gli strumenti di Core; torna i moduli che non si sono importati (#82).
|
||||||
|
|
||||||
|
Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e
|
||||||
|
l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente
|
||||||
|
`_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread
|
||||||
|
di questo processo: registrare una volta sola qui vale per tutti."""
|
||||||
|
from visionsuite.task.registry import load_tools
|
||||||
|
load_tools()
|
||||||
|
try:
|
||||||
|
from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py
|
||||||
|
except ImportError:
|
||||||
|
MODULI = () # da sorgente: gli entry point di load_tools() bastano
|
||||||
|
falliti: list[str] = []
|
||||||
|
for modulo in MODULI:
|
||||||
|
try:
|
||||||
|
importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool
|
||||||
|
except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie
|
||||||
|
log.error("strumenti %s non importati", modulo, exc_info=True)
|
||||||
|
falliti.append(modulo)
|
||||||
|
return falliti
|
||||||
|
|
||||||
|
|
||||||
|
def controlla_strumenti() -> int:
|
||||||
|
"""Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti."""
|
||||||
|
from visionsuite.task.graph import registry
|
||||||
|
from visionsuite.task.registry import unavailable
|
||||||
|
falliti = registra_strumenti()
|
||||||
|
kind = sorted(registry())
|
||||||
|
esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()}
|
||||||
|
riga = json.dumps(esito, ensure_ascii=False)
|
||||||
|
if sys.stdout is not None:
|
||||||
|
print(riga)
|
||||||
|
try:
|
||||||
|
f = cartella_dati() / "log" / "controllo-strumenti.json"
|
||||||
|
f.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("controllo-strumenti.json non scritto: %s", e)
|
||||||
|
return 0 if kind and not falliti else 3
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- rete
|
# ---------------------------------------------------------------- rete
|
||||||
|
|
||||||
def _porta_aperta(porta: int) -> bool:
|
def _porta_aperta(porta: int) -> bool:
|
||||||
@@ -295,6 +348,69 @@ def _nessun_utente(db: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def camere_da_scaldare(db: Path) -> list[tuple[str, str]]:
|
||||||
|
"""Coppie (camera_url, camera_source) configurate nel db (stazioni e task di ricetta), senza doppioni.
|
||||||
|
Solo sorgenti "camera:" che aprono hardware (non "camera:folder"). Errore sqlite -> lista vuota (#108)."""
|
||||||
|
coppie: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def aggiungi(url, src) -> None:
|
||||||
|
# #108: valida la coppia e scarta duplicati e sorgenti che non aprono hardware
|
||||||
|
if not (isinstance(url, str) and isinstance(src, str) and url.strip() and src.strip()):
|
||||||
|
return
|
||||||
|
if not src.startswith("camera:") or src == "camera:folder":
|
||||||
|
return
|
||||||
|
if (url, src) not in coppie:
|
||||||
|
coppie.append((url, src))
|
||||||
|
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
|
||||||
|
try:
|
||||||
|
for url, src in con.execute("SELECT camera_url, camera_source FROM stations"):
|
||||||
|
aggiungi(url, src)
|
||||||
|
for (blob,) in con.execute("SELECT acquisition_json FROM recipe_tasks WHERE acquisition_json IS NOT NULL"):
|
||||||
|
try:
|
||||||
|
cfg = json.loads(blob)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue # #108: JSON non valido, si salta
|
||||||
|
if isinstance(cfg, dict):
|
||||||
|
aggiungi(cfg.get("camera_url"), cfg.get("camera_source"))
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return coppie
|
||||||
|
|
||||||
|
|
||||||
|
def scalda_camere(db: Path) -> None:
|
||||||
|
"""Apre ogni camera configurata con un grab a vuoto, cosi' il primo "Acquisisci" non aspetta ~25 s (#108).
|
||||||
|
Gira in un thread daemon: nessuna eccezione esce."""
|
||||||
|
try:
|
||||||
|
camere = camere_da_scaldare(db)
|
||||||
|
if not camere:
|
||||||
|
return
|
||||||
|
for _ in range(300): # aspetta il servizio camera (max ~60 s), come _apri_browser_quando_pronto
|
||||||
|
if _porta_aperta(PORTA_CAMERA):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
else:
|
||||||
|
log.warning("scalda camere: servizio camera non raggiungibile, salto")
|
||||||
|
return
|
||||||
|
for url, src in camere:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
richiesta = urllib.request.Request(
|
||||||
|
f"{url.rstrip('/')}/camera/grab",
|
||||||
|
data=json.dumps({"source": src, "trigger": "continuo", "timeout_ms": 5000}).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(richiesta, timeout=90) as r:
|
||||||
|
esito = f"HTTP {r.status}"
|
||||||
|
log.info("scalda camera %s: %s in %.1f s", src, esito, time.monotonic() - t0)
|
||||||
|
except Exception as e: # #108: una camera che non risponde non ferma le altre
|
||||||
|
log.warning("scalda camera %s: %s dopo %.1f s", src, e, time.monotonic() - t0)
|
||||||
|
except Exception:
|
||||||
|
log.exception("scalda camere non riuscito")
|
||||||
|
|
||||||
|
|
||||||
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
|
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
|
||||||
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
|
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
|
||||||
for _ in range(300):
|
for _ in range(300):
|
||||||
@@ -402,6 +518,8 @@ def main(argv=None) -> int:
|
|||||||
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
||||||
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
||||||
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
||||||
|
p.add_argument("--controlla-strumenti", action="store_true",
|
||||||
|
help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)")
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
_prepara_log()
|
_prepara_log()
|
||||||
|
|
||||||
@@ -415,6 +533,9 @@ def main(argv=None) -> int:
|
|||||||
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.controlla_strumenti:
|
||||||
|
return controlla_strumenti() # prima della licenza: la build non ha una licenza
|
||||||
|
|
||||||
if not controlla_licenza(args.senza_finestre):
|
if not controlla_licenza(args.senza_finestre):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -449,7 +570,10 @@ def main(argv=None) -> int:
|
|||||||
_prepara_log(dati) # alembic ha riconfigurato il logging
|
_prepara_log(dati) # alembic ha riconfigurato il logging
|
||||||
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
|
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
|
||||||
|
|
||||||
|
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
|
||||||
thread = avvia_server(args.host, dati)
|
thread = avvia_server(args.host, dati)
|
||||||
|
# #108: apre le camere configurate con un grab a vuoto, con o senza browser
|
||||||
|
threading.Thread(target=scalda_camere, args=(dati / "tmf.db",), daemon=True).start()
|
||||||
if not args.senza_browser:
|
if not args.senza_browser:
|
||||||
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
||||||
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
|
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ in
|
|||||||
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
|
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
|
||||||
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
|
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
|
||||||
funziona ma e' piu' lento nel matching.
|
funziona ma e' piu' lento nel matching.
|
||||||
|
⚠️ Gli strumenti di vs-tools si registrano solo come entry point (visionsuite.task.tools) e i
|
||||||
|
metadati di vs-tools non stanno nel binario (TieMeasureFlow#82): il build genera
|
||||||
|
packaging\windows\_strumenti.py (MODULI, gitignorato) che avvio.py importa, e prima di
|
||||||
|
Inno Setup esegue l'exe con --controlla-strumenti: se il catalogo e' vuoto il build fallisce.
|
||||||
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
|
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
|
||||||
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
||||||
#>
|
#>
|
||||||
@@ -85,8 +89,9 @@ try {
|
|||||||
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
|
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
|
||||||
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
|
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
|
||||||
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
||||||
# Nessun --include-distribution-metadata per vs-*: nulla li legge e Nuitka rifiuta i metadati di una
|
# Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
|
||||||
# distribuzione il cui pacchetto (visionsuite.camera, namespace) non e' di primo livello.
|
# pacchetto (visionsuite.camera, namespace) non e' di primo livello. Gli entry point di vs-tools, che
|
||||||
|
# load_tools() cercherebbe nei metadati, si rimpiazzano con _strumenti.py (sotto, TieMeasureFlow#82).
|
||||||
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
|
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
|
||||||
|
|
||||||
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
|
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
|
||||||
@@ -103,6 +108,25 @@ if (Test-Path $leggimi) {
|
|||||||
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
||||||
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
|
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
|
||||||
|
|
||||||
|
# TieMeasureFlow#82: i moduli-strumento dagli entry point del venv del build (nel binario non ci sono i
|
||||||
|
# metadati di vs-tools, quindi load_tools() non li troverebbe). Si usa $py, non `uv run`.
|
||||||
|
$strumenti = Join-Path $radice 'packaging\windows\_strumenti.py'
|
||||||
|
$genera = @'
|
||||||
|
import sys
|
||||||
|
from importlib.metadata import entry_points
|
||||||
|
moduli = sorted({ep.value for ep in entry_points(group="visionsuite.task.tools")})
|
||||||
|
if not moduli:
|
||||||
|
sys.exit(1)
|
||||||
|
righe = ["# Generato da build.ps1 (TieMeasureFlow#82): non modificare, non committare.", "MODULI = ("]
|
||||||
|
righe += [f" {m!r}," for m in moduli] + [")"]
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8", newline="") as f:
|
||||||
|
print(*righe, sep=chr(10), file=f)
|
||||||
|
print(len(moduli))
|
||||||
|
'@
|
||||||
|
$nModuli = $genera | & $py - $strumenti
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Entry point visionsuite.task.tools non trovati nel venv: nessuno strumento da includere" }
|
||||||
|
Write-Host "Moduli-strumento ($nModuli) in $strumenti"
|
||||||
|
|
||||||
$out = Join-Path $radice 'build\nuitka'
|
$out = Join-Path $radice 'build\nuitka'
|
||||||
New-Item -ItemType Directory -Force $out | Out-Null
|
New-Item -ItemType Directory -Force $out | Out-Null
|
||||||
$nuitka = @(
|
$nuitka = @(
|
||||||
@@ -201,6 +225,17 @@ $sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
|
|||||||
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
||||||
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
|
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
|
||||||
|
|
||||||
|
# TieMeasureFlow#82: l'exe deve vedere gli strumenti (--controlla-strumenti non vuole licenza ne' apre server/DB)
|
||||||
|
$exe = Join-Path $sorgente 'TieMeasureFlow.exe'
|
||||||
|
$jsonControllo = Join-Path $env:PROGRAMDATA 'Tielogic\TieMeasureFlow\log\controllo-strumenti.json'
|
||||||
|
Remove-Item $jsonControllo -ErrorAction SilentlyContinue # un JSON vecchio non deve far passare il controllo
|
||||||
|
# l'exe e' GUI-subsystem: con & non si aspetta, serve Start-Process -Wait
|
||||||
|
$proc = Start-Process -FilePath $exe -ArgumentList '--controlla-strumenti' -Wait -PassThru
|
||||||
|
$esito = if (Test-Path $jsonControllo) { Get-Content $jsonControllo -Raw } else { '(controllo-strumenti.json non scritto)' }
|
||||||
|
if ($proc.ExitCode -ne 0) { throw "Controllo strumenti fallito (codice $($proc.ExitCode)): $esito" }
|
||||||
|
$nStrumenti = ($esito | ConvertFrom-Json).strumenti
|
||||||
|
Write-Host "Controllo strumenti: $nStrumenti tipi registrati nell'exe"
|
||||||
|
|
||||||
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
|
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
|
||||||
|
|
||||||
$dist = Join-Path $radice 'dist'
|
$dist = Join-Path $radice 'dist'
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<#
|
||||||
|
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). Scrive vision-engine-version.txt nella radice (sha del submodule
|
||||||
|
vendor/visionsuite) se git c'e'; senza git e senza il file si ferma con un errore chiaro (#109).
|
||||||
|
|
||||||
|
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), git (opzionale: senza, serve vision-engine-version.txt gia' nella radice), 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
|
||||||
|
|
||||||
|
# #109: il worker di visione rifiuta ogni job senza VISION_ENGINE_VERSION, che avvio.py legge da
|
||||||
|
# <radice>\vision-engine-version.txt (come nel pacchetto, build.ps1). Da sorgente lo scrive questo script.
|
||||||
|
$versione = Join-Path $radice 'vision-engine-version.txt'
|
||||||
|
$shaMotore = $null
|
||||||
|
# #109: solo se il submodule e' inizializzato (ha .git): altrimenti git risale al repo padre e darebbe lo sha di TMF
|
||||||
|
if ((Get-Command git -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $radice 'vendor\visionsuite\.git'))) {
|
||||||
|
$shaMotore = (git -C (Join-Path $radice 'vendor\visionsuite') rev-parse HEAD 2>$null)
|
||||||
|
if ($LASTEXITCODE -ne 0) { $shaMotore = $null }
|
||||||
|
}
|
||||||
|
if ($shaMotore) {
|
||||||
|
# 40 caratteri ASCII, UTF8 senza BOM e senza a capo (avvio.py fa .strip(), che non toglie il BOM)
|
||||||
|
[System.IO.File]::WriteAllText($versione, $shaMotore.Trim(), (New-Object System.Text.UTF8Encoding $false))
|
||||||
|
} elseif (Test-Path $versione) {
|
||||||
|
Write-Host "git non disponibile: uso $versione = $((Get-Content -Raw $versione).Trim())"
|
||||||
|
} else {
|
||||||
|
throw "Manca vision-engine-version.txt e git non puo' leggere vendor\visionsuite. Rimedio: crea l'archivio con 'uv run python scripts/archivio_sorgente.py --out tmf.zip' da un clone con git, oppure scrivi in $versione lo sha del submodule (40 caratteri, ASCII, senza BOM e senza a capo)."
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -13,6 +13,8 @@ import importlib.util
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -122,6 +124,16 @@ def test_variabili_del_contratto(avvio, tmp_path):
|
|||||||
assert len(e[k]) >= 32
|
assert len(e[k]) >= 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_versione_motore_visione_letta_dalla_radice_sorgente(avvio, tmp_path, monkeypatch):
|
||||||
|
# #109: da sorgente il file vision-engine-version.txt sta nella radice del repo e arriva al worker
|
||||||
|
prog = tmp_path / "prog"
|
||||||
|
prog.mkdir()
|
||||||
|
(prog / "vision-engine-version.txt").write_bytes(b"a" * 40)
|
||||||
|
monkeypatch.setattr(avvio, "cartella_programma", lambda: prog)
|
||||||
|
avvio.imposta_ambiente(tmp_path / "dati", "127.0.0.1", {k: "x" * 32 for k in avvio.SEGRETI})
|
||||||
|
assert os.environ["VISION_ENGINE_VERSION"] == "a" * 40
|
||||||
|
|
||||||
|
|
||||||
def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path):
|
def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path):
|
||||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
primo = avvio.carica_segreti(dati)
|
primo = avvio.carica_segreti(dati)
|
||||||
@@ -195,3 +207,151 @@ def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(av
|
|||||||
avvio.main(["--senza-finestre", "--senza-browser"])
|
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||||
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
||||||
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
|
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
|
||||||
|
|
||||||
|
|
||||||
|
# --- TieMeasureFlow#82: strumenti nel pacchetto (nel binario non ci sono gli entry point) ---
|
||||||
|
|
||||||
|
def _modulo_strumenti(monkeypatch, moduli):
|
||||||
|
# finto _strumenti.py generato da build.ps1
|
||||||
|
finto = types.ModuleType("_strumenti")
|
||||||
|
finto.MODULI = tuple(moduli)
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", finto)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_da_sorgente_senza_modulo_generato(avvio, monkeypatch):
|
||||||
|
# _strumenti.py esiste solo dopo build.ps1: se manca (sorgente) bastano gli entry point
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", None) # None in sys.modules = ImportError all'import
|
||||||
|
from visionsuite.task.graph import registry
|
||||||
|
assert avvio.registra_strumenti() == []
|
||||||
|
assert len(registry()) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_modulo_inesistente_non_solleva(avvio, monkeypatch):
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image", "visionsuite.tools.non_esiste"))
|
||||||
|
assert avvio.registra_strumenti() == ["visionsuite.tools.non_esiste"] # raccolto, non sollevato
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_importa_i_moduli_elencati(avvio, monkeypatch):
|
||||||
|
# senza entry point (come nell'exe) i moduli di MODULI sono l'unica via: li si importa per nome
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image",))
|
||||||
|
visti = []
|
||||||
|
monkeypatch.setattr(avvio.importlib, "import_module", lambda nome: visti.append(nome))
|
||||||
|
assert avvio.registra_strumenti() == [] and visti == ["visionsuite.tools.image"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_senza_licenza_scrive_json(avvio, monkeypatch, capsys, tmp_path):
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", None)
|
||||||
|
|
||||||
|
# la licenza non deve essere nemmeno chiesta, e server e migrazioni non devono partire
|
||||||
|
def mai(*a, **k):
|
||||||
|
raise AssertionError("licenza, server o migrazioni chiamati da --controlla-strumenti")
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio.lic, "verifica", mai)
|
||||||
|
monkeypatch.setattr(avvio.lic, "cerca_licenza", mai)
|
||||||
|
monkeypatch.setattr(avvio, "avvia_server", mai)
|
||||||
|
monkeypatch.setattr(avvio, "esegui_migrazioni", mai)
|
||||||
|
assert avvio.main(["--controlla-strumenti", "--senza-finestre"]) == 0
|
||||||
|
dati = json.loads(capsys.readouterr().out)
|
||||||
|
assert dati["strumenti"] > 0 and dati["strumenti"] == len(dati["kind"])
|
||||||
|
assert dati["falliti"] == []
|
||||||
|
scritto = tmp_path / "Tielogic" / "TieMeasureFlow" / "log" / "controllo-strumenti.json"
|
||||||
|
assert json.loads(scritto.read_text(encoding="utf-8")) == dati
|
||||||
|
assert avvio.aperti == [] and not (tmp_path / "Tielogic" / "TieMeasureFlow" / "tmf.db").exists() # nessun DB
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_exit_3_se_un_modulo_fallisce(avvio, monkeypatch, capsys):
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.non_esiste",))
|
||||||
|
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||||
|
assert json.loads(capsys.readouterr().out)["falliti"] == ["visionsuite.tools.non_esiste"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_exit_3_se_il_catalogo_e_vuoto(avvio, monkeypatch, capsys):
|
||||||
|
# niente strumenti registrati (es. entry point assenti e _strumenti vuoto): il controllo deve fallire
|
||||||
|
import visionsuite.task.graph as graph
|
||||||
|
monkeypatch.setattr(avvio, "registra_strumenti", lambda: [])
|
||||||
|
monkeypatch.setattr(graph, "registry", lambda: {})
|
||||||
|
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||||
|
assert json.loads(capsys.readouterr().out)["strumenti"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path, monkeypatch):
|
||||||
|
# main() chiama registra_strumenti() dopo licenza e migrazioni e prima di avvia_server
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
monkeypatch.setattr(avvio, "registra_strumenti", lambda: avvio.chiamate.append("strumenti") or [])
|
||||||
|
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- scalda camere (#108)
|
||||||
|
|
||||||
|
def _db_camere(percorso):
|
||||||
|
# #108: db minimo con le due tabelle lette da camere_da_scaldare
|
||||||
|
con = sqlite3.connect(percorso)
|
||||||
|
con.execute("CREATE TABLE stations (id INTEGER PRIMARY KEY, camera_url TEXT, camera_source TEXT)")
|
||||||
|
con.execute("CREATE TABLE recipe_tasks (id INTEGER PRIMARY KEY, acquisition_json TEXT)")
|
||||||
|
con.executemany("INSERT INTO stations (camera_url, camera_source) VALUES (?, ?)", [
|
||||||
|
("http://127.0.0.1:8765", "camera:601020"), # valida
|
||||||
|
(None, None), # stazione senza camera
|
||||||
|
("http://127.0.0.1:8765", "camera:folder"), # non apre hardware
|
||||||
|
])
|
||||||
|
con.executemany("INSERT INTO recipe_tasks (acquisition_json) VALUES (?)", [
|
||||||
|
(json.dumps({"camera_url": "http://127.0.0.1:8765", "camera_source": "camera:601020"}),), # duplicato
|
||||||
|
(json.dumps({"camera_url": "http://10.0.0.5:8765/", "camera_source": "camera:777"}),), # valida
|
||||||
|
("{rotto",), # JSON non valido
|
||||||
|
(None,),
|
||||||
|
])
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_camere_da_scaldare_filtra_e_deduplica(avvio, tmp_path):
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
_db_camere(db)
|
||||||
|
assert avvio.camere_da_scaldare(db) == [
|
||||||
|
("http://127.0.0.1:8765", "camera:601020"),
|
||||||
|
("http://10.0.0.5:8765/", "camera:777"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_camere_da_scaldare_db_inesistente(avvio, tmp_path):
|
||||||
|
assert avvio.camere_da_scaldare(tmp_path / "non_esiste.db") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_scalda_camere_una_chiamata_per_coppia(avvio, tmp_path, monkeypatch):
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
_db_camere(db)
|
||||||
|
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||||
|
chiamate = []
|
||||||
|
|
||||||
|
class Risposta:
|
||||||
|
status = 200
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *a): return False
|
||||||
|
|
||||||
|
def finto_urlopen(richiesta, timeout=None):
|
||||||
|
chiamate.append((richiesta.full_url, json.loads(richiesta.data), richiesta.get_method(), timeout))
|
||||||
|
return Risposta()
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio.urllib.request, "urlopen", finto_urlopen)
|
||||||
|
avvio.scalda_camere(db)
|
||||||
|
corpo = {"trigger": "continuo", "timeout_ms": 5000}
|
||||||
|
assert chiamate == [
|
||||||
|
("http://127.0.0.1:8765/camera/grab", {"source": "camera:601020", **corpo}, "POST", 90),
|
||||||
|
("http://10.0.0.5:8765/camera/grab", {"source": "camera:777", **corpo}, "POST", 90),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scalda_camere_eccezione_urlopen_non_esce(avvio, tmp_path, monkeypatch):
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
_db_camere(db)
|
||||||
|
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||||
|
tentativi = []
|
||||||
|
|
||||||
|
def urlopen_rotto(richiesta, timeout=None):
|
||||||
|
tentativi.append(richiesta.full_url)
|
||||||
|
raise OSError("camera irraggiungibile")
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio.urllib.request, "urlopen", urlopen_rotto)
|
||||||
|
avvio.scalda_camere(db) # non solleva e passa alla camera successiva
|
||||||
|
assert len(tentativi) == 2
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Archivio sorgente di TieMeasureFlow per un PC senza git (TieMeasureFlow#109).
|
||||||
|
|
||||||
|
Cosa fa
|
||||||
|
Crea uno zip del repo a un commit, con dentro anche il submodule vendor/visionsuite (che
|
||||||
|
`git archive` da solo NON include) e il file vision-engine-version.txt nella radice dello zip.
|
||||||
|
Quel file contiene lo sha del submodule REGISTRATO nel commit (non la HEAD del checkout del
|
||||||
|
submodule): packaging/windows/avvio.py lo legge e lo passa al worker di visione come
|
||||||
|
VISION_ENGINE_VERSION; senza, il worker rifiuta ogni job. Formato: 40 caratteri ASCII,
|
||||||
|
senza BOM e senza a capo (avvio.py usa .strip(), che non toglie un BOM).
|
||||||
|
|
||||||
|
Come si usa (dalla radice del repo, con il submodule inizializzato)
|
||||||
|
uv run python scripts/archivio_sorgente.py --out C:/tmp/tmf.zip [--ref HEAD]
|
||||||
|
Esce 0 e stampa lo sha; esce 1 con un messaggio e senza lasciare lo zip se il submodule
|
||||||
|
non e' inizializzato o non ha il commit registrato (git submodule update --init vendor/visionsuite).
|
||||||
|
|
||||||
|
Dipende da: git nel PATH, solo libreria standard.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SUBMODULE = "vendor/visionsuite"
|
||||||
|
FILE_VERSIONE = "vision-engine-version.txt"
|
||||||
|
|
||||||
|
|
||||||
|
class ErroreArchivio(RuntimeError):
|
||||||
|
"""Errore chiaro da mostrare all'operatore (niente traceback)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cartella: Path, *args: str, binario: bool = False):
|
||||||
|
"""Esegue git in `cartella`; solleva ErroreArchivio con lo stderr se fallisce."""
|
||||||
|
r = subprocess.run(["git", "-C", str(cartella), *args], capture_output=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise ErroreArchivio(f"git {' '.join(args)} fallito in {cartella}: {r.stderr.decode(errors='replace').strip()}")
|
||||||
|
return r.stdout if binario else r.stdout.decode().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def sha_registrato(repo: Path, ref: str) -> str:
|
||||||
|
"""Sha del submodule registrato nel commit `ref` (gitlink, modo 160000), non la HEAD del checkout."""
|
||||||
|
campi = _git(repo, "ls-tree", ref, SUBMODULE).split()
|
||||||
|
if len(campi) < 3 or campi[0] != "160000":
|
||||||
|
raise ErroreArchivio(f"{SUBMODULE} non e' un submodule registrato in {ref}")
|
||||||
|
return campi[2]
|
||||||
|
|
||||||
|
|
||||||
|
def crea_archivio(repo: Path, out: Path, ref: str = "HEAD") -> str:
|
||||||
|
"""Scrive lo zip in `out` e ritorna lo sha del submodule; in caso di errore non lascia nulla."""
|
||||||
|
repo, out = Path(repo), Path(out)
|
||||||
|
sha = sha_registrato(repo, ref)
|
||||||
|
sub = repo / SUBMODULE
|
||||||
|
# submodule non inizializzato o senza il commit registrato: errore prima di scrivere lo zip
|
||||||
|
if not (sub / ".git").exists():
|
||||||
|
raise ErroreArchivio(f"{SUBMODULE} non e' inizializzato: git submodule update --init {SUBMODULE}")
|
||||||
|
if subprocess.run(["git", "-C", str(sub), "cat-file", "-e", f"{sha}^{{commit}}"], capture_output=True).returncode != 0:
|
||||||
|
raise ErroreArchivio(f"{SUBMODULE} non ha il commit {sha}: git submodule update --init {SUBMODULE}")
|
||||||
|
|
||||||
|
zip_repo = _git(repo, "archive", "--format=zip", ref, binario=True)
|
||||||
|
zip_sub = _git(sub, "archive", "--format=zip", f"--prefix={SUBMODULE}/", sha, binario=True)
|
||||||
|
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(suffix=".zip.tmp", dir=out.parent) # scrittura atomica: o c'e' tutto o niente
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
viste: set[str] = set()
|
||||||
|
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for blob in (zip_repo, zip_sub):
|
||||||
|
with zipfile.ZipFile(io.BytesIO(blob)) as sorgente:
|
||||||
|
for info in sorgente.infolist():
|
||||||
|
if info.filename in viste:
|
||||||
|
continue # es. la cartella vendor/visionsuite/ del gitlink, gia' nel primo zip
|
||||||
|
viste.add(info.filename)
|
||||||
|
z.writestr(info, sorgente.read(info.filename), compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
if FILE_VERSIONE in viste:
|
||||||
|
raise ErroreArchivio(f"{FILE_VERSIONE} e' gia' nel repo a {ref}: va tolto dal versionamento")
|
||||||
|
# 40 caratteri ASCII, senza BOM e senza a capo (avvio.py fa .strip(), che non toglie un BOM)
|
||||||
|
z.writestr(FILE_VERSIONE, sha.encode("ascii"))
|
||||||
|
os.replace(tmp, out)
|
||||||
|
except BaseException:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise
|
||||||
|
return sha
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(description="Zip del sorgente con submodule e vision-engine-version.txt (#109)")
|
||||||
|
p.add_argument("--out", required=True, type=Path, help="file .zip da creare")
|
||||||
|
p.add_argument("--ref", default="HEAD", help="commit da archiviare (default HEAD)")
|
||||||
|
a = p.parse_args(argv)
|
||||||
|
radice = Path(__file__).resolve().parents[1]
|
||||||
|
try:
|
||||||
|
sha = crea_archivio(radice, a.out, a.ref)
|
||||||
|
except ErroreArchivio as e:
|
||||||
|
print(f"ERRORE: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"{a.out} creato; {FILE_VERSIONE} = {sha}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,781 @@
|
|||||||
|
"""Calibrazione a scacchiera dell'acquisitore (TieMeasureFlow, #83).
|
||||||
|
|
||||||
|
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
|
||||||
|
dell'immagine raddrizzata. L'Archivio salva la mappa accanto al JSON (.npz) e
|
||||||
|
`raddrizza()` la applica ai frame.
|
||||||
|
Uso: importato da scripts/camera_service.py (rotte /camera/calibrate e
|
||||||
|
/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e
|
||||||
|
`calibra_scacchiera_densa(gray, square_mm)` sono logica pura, senza FastAPI,
|
||||||
|
e si provano da sole.
|
||||||
|
Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro (no scipy).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
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
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from visionsuite.calibration import calibrate_planar, find_chessboard
|
||||||
|
from visionsuite.calibration.distortion_map import DistortionMap # #84 mappa densa
|
||||||
|
from visionsuite.core.image import Image
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrazioneError(ValueError):
|
||||||
|
"""Parametri invalidi o scacchiera non trovata: input sbagliato, non un guasto (422)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _gray_u8(pixels: np.ndarray) -> np.ndarray:
|
||||||
|
"""Immagine -> grigio uint8 2D."""
|
||||||
|
px = np.asarray(pixels)
|
||||||
|
if px.ndim == 3:
|
||||||
|
px = cv2.cvtColor(px, cv2.COLOR_BGRA2GRAY if px.shape[2] == 4 else cv2.COLOR_BGR2GRAY)
|
||||||
|
if px.dtype != np.uint8:
|
||||||
|
# float/uint16 -> scala sul massimo, come fa vs-core
|
||||||
|
m = float(px.max()) if px.size else 1.0
|
||||||
|
px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8)
|
||||||
|
return np.ascontiguousarray(px)
|
||||||
|
|
||||||
|
|
||||||
|
def calibra_scacchiera(pixels, cols: int, rows: int, square_mm: float) -> dict:
|
||||||
|
"""Scala (px/mm) da una foto di scacchiera piana. cols/rows = angoli INTERNI.
|
||||||
|
|
||||||
|
Ritorna {px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm,
|
||||||
|
image_width, image_height, scale_spread_pct, metodo, distorsione,
|
||||||
|
overlay_png (b64)}.
|
||||||
|
"""
|
||||||
|
if cols < 2 or rows < 2:
|
||||||
|
raise CalibrazioneError("cols e rows (angoli interni) devono essere almeno 2")
|
||||||
|
if not (square_mm > 0 and math.isfinite(square_mm)):
|
||||||
|
raise CalibrazioneError("square_mm deve essere > 0")
|
||||||
|
|
||||||
|
frame = _gray_u8(pixels)
|
||||||
|
image = Image(frame)
|
||||||
|
try:
|
||||||
|
image_pts, world_pts = find_chessboard(image, (cols, rows), square_mm)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# find_chessboard solleva RuntimeError quando la scacchiera non c'e'
|
||||||
|
raise CalibrazioneError(
|
||||||
|
f"scacchiera {cols}x{rows} non trovata nell'immagine: {exc}") from exc
|
||||||
|
model = calibrate_planar(image, image_pts, world_pts) # omografia pura
|
||||||
|
|
||||||
|
# Scala al centro della scacchiera (puo' non stare al centro immagine)
|
||||||
|
x0, y0 = image_pts.min(axis=0)
|
||||||
|
x1, y1 = image_pts.max(axis=0)
|
||||||
|
mm_per_px = model.pixel_size((x0 + x1) / 2.0, (y0 + y1) / 2.0)
|
||||||
|
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
|
||||||
|
raise CalibrazioneError("calibrazione degenere: scala non valida")
|
||||||
|
# Scarto massimo % della scala ai quattro angoli dell'area della scacchiera
|
||||||
|
# rispetto al centro: ottica telecentrica/piana ~ 0, prospettiva = grande.
|
||||||
|
spread = max(abs(model.pixel_size(x, y) - mm_per_px) / mm_per_px * 100.0
|
||||||
|
for x in (x0, x1) for y in (y0, y1))
|
||||||
|
|
||||||
|
# Overlay: angoli disegnati sull'immagine (PNG base64)
|
||||||
|
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||||
|
cv2.drawChessboardCorners(
|
||||||
|
bgr, (cols, rows), image_pts.reshape(-1, 1, 2).astype(np.float32), True)
|
||||||
|
ok, png = cv2.imencode(".png", bgr)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("PNG dell'overlay non codificabile")
|
||||||
|
|
||||||
|
h, w = frame.shape
|
||||||
|
return {
|
||||||
|
"px_per_mm": 1.0 / mm_per_px,
|
||||||
|
"mm_per_px": mm_per_px,
|
||||||
|
"rms_px": float(model.rms_reprojection),
|
||||||
|
"n_corners": int(len(image_pts)),
|
||||||
|
"cols": int(cols),
|
||||||
|
"rows": int(rows),
|
||||||
|
"square_mm": float(square_mm),
|
||||||
|
"image_width": int(w),
|
||||||
|
"image_height": int(h),
|
||||||
|
"scale_spread_pct": float(spread),
|
||||||
|
"metodo": "planare", # #84: il metodo con cols/rows resta quello di prima
|
||||||
|
"distorsione": False,
|
||||||
|
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- #84: calibrazione densa (scacchiera che riempie il campo, griglia non nota)
|
||||||
|
|
||||||
|
# #84: findChessboardCornersSBWithMeta (verificato su opencv 5.0.0) su una scacchiera
|
||||||
|
# grande quanto l'immagine trova solo frammenti (2064x1544 con quadrati da 40 px:
|
||||||
|
# 5x5, 28x6...). Per questo la griglia si cerca qui: sellini della Hessiana a piu'
|
||||||
|
# scale + crescita del reticolo; poi cornerSubPix.
|
||||||
|
_SIGME = (1.5, 2.2, 3.2, 4.5, 6.5, 9.0, 13.0) # scale dei sellini (passo ~ 5..120 px)
|
||||||
|
_MAX_CANDIDATI = 6000
|
||||||
|
|
||||||
|
|
||||||
|
def _candidati(gray: np.ndarray, sigma: float) -> np.ndarray:
|
||||||
|
"""Massimi locali del 'selliness' (-det Hessiana) = angoli di scacchiera, (n,2) x,y."""
|
||||||
|
b = cv2.GaussianBlur(gray.astype(np.float32), (0, 0), sigma)
|
||||||
|
ixx = cv2.Sobel(b, cv2.CV_32F, 2, 0, ksize=3)
|
||||||
|
iyy = cv2.Sobel(b, cv2.CV_32F, 0, 2, ksize=3)
|
||||||
|
ixy = cv2.Sobel(b, cv2.CV_32F, 1, 1, ksize=3)
|
||||||
|
r = ixy * ixy - ixx * iyy # >0 solo dove l'immagine ha un sellino
|
||||||
|
massimo = float(r.max())
|
||||||
|
if massimo <= 0:
|
||||||
|
return np.zeros((0, 2))
|
||||||
|
k = 2 * int(math.ceil(1.5 * sigma)) + 1
|
||||||
|
picchi = (r >= cv2.dilate(r, np.ones((k, k), np.uint8))) & (r > 0.1 * massimo)
|
||||||
|
ys, xs = np.nonzero(picchi)
|
||||||
|
if len(xs) > _MAX_CANDIDATI: # tiene i piu' forti
|
||||||
|
forti = np.argsort(r[ys, xs])[::-1][:_MAX_CANDIDATI]
|
||||||
|
ys, xs = ys[forti], xs[forti]
|
||||||
|
return np.stack([xs, ys], axis=1).astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def _e_angolo_x(lisc: np.ndarray, delta: float, p: np.ndarray, u: np.ndarray, v: np.ndarray) -> bool:
|
||||||
|
"""True se attorno a p i quattro quadranti alternano chiaro/scuro (vero angolo X).
|
||||||
|
|
||||||
|
Scarta le giunzioni a L/T al bordo della scacchiera (3 quadranti uguali), che
|
||||||
|
il selliness da solo accetta e che spostano la griglia.
|
||||||
|
"""
|
||||||
|
h, w = lisc.shape
|
||||||
|
campioni = []
|
||||||
|
for q in (0.25 * (u + v), -0.25 * (u + v), 0.25 * (u - v), -0.25 * (u - v)):
|
||||||
|
x, y = int(round(p[0] + q[0])), int(round(p[1] + q[1]))
|
||||||
|
if not (0 <= x < w and 0 <= y < h):
|
||||||
|
return False
|
||||||
|
campioni.append(float(lisc[y, x]))
|
||||||
|
s1, s2, t1, t2 = campioni
|
||||||
|
return (min(s1, s2) > max(t1, t2) + delta) or (max(s1, s2) < min(t1, t2) - delta)
|
||||||
|
|
||||||
|
|
||||||
|
def _cresci_reticolo(pts: np.ndarray, centro: tuple[float, float],
|
||||||
|
lisc: np.ndarray, delta: float) -> dict | None:
|
||||||
|
"""Assegna un indice (riga, colonna) ai candidati che stanno su un reticolo.
|
||||||
|
|
||||||
|
Parte dal candidato piu' vicino al centro, ricava i due vettori di passo dai
|
||||||
|
vicini e cresce a macchia d'olio: la base si aggiorna a ogni passo, quindi
|
||||||
|
segue una distorsione lenta. Ritorna {(i, j): indice candidato} o None.
|
||||||
|
"""
|
||||||
|
if len(pts) < 9:
|
||||||
|
return None
|
||||||
|
seme = int(np.argmin(np.hypot(pts[:, 0] - centro[0], pts[:, 1] - centro[1])))
|
||||||
|
d = np.hypot(pts[:, 0] - pts[seme, 0], pts[:, 1] - pts[seme, 1])
|
||||||
|
vicini = np.argsort(d)[1:9]
|
||||||
|
u = pts[vicini[0]] - pts[seme]
|
||||||
|
lu = float(np.hypot(*u))
|
||||||
|
v = None
|
||||||
|
for k in vicini[1:]:
|
||||||
|
w = pts[k] - pts[seme]
|
||||||
|
lw = float(np.hypot(*w))
|
||||||
|
# il secondo vettore: quasi ortogonale al primo e di lunghezza simile
|
||||||
|
if lu > 0 and lw > 0 and abs(float(u @ w)) / (lu * lw) < 0.4 and lw < 1.5 * lu:
|
||||||
|
v = w
|
||||||
|
break
|
||||||
|
if v is None or lu < 3:
|
||||||
|
return None
|
||||||
|
posto = {(0, 0): seme}
|
||||||
|
indice = {seme: (0, 0)}
|
||||||
|
basi = {seme: (u, v)}
|
||||||
|
coda = deque([seme])
|
||||||
|
while coda:
|
||||||
|
a = coda.popleft()
|
||||||
|
i, j = indice[a]
|
||||||
|
u, v = basi[a]
|
||||||
|
tol = 0.3 * min(float(np.hypot(*u)), float(np.hypot(*v)))
|
||||||
|
for di, dj, vec in ((0, 1, u), (0, -1, -u), (1, 0, v), (-1, 0, -v)):
|
||||||
|
if (i + di, j + dj) in posto:
|
||||||
|
continue
|
||||||
|
prev = pts[a] + vec
|
||||||
|
dist = np.hypot(pts[:, 0] - prev[0], pts[:, 1] - prev[1])
|
||||||
|
b = int(np.argmin(dist))
|
||||||
|
if dist[b] > tol or b in indice:
|
||||||
|
continue
|
||||||
|
if not _e_angolo_x(lisc, delta, pts[b], u, v): # niente L/T del bordo
|
||||||
|
continue
|
||||||
|
posto[(i + di, j + dj)] = b
|
||||||
|
indice[b] = (i + di, j + dj)
|
||||||
|
reale = pts[b] - pts[a]
|
||||||
|
basi[b] = (reale * dj, v) if dj else (u, reale * di) # base locale aggiornata
|
||||||
|
coda.append(b)
|
||||||
|
return posto
|
||||||
|
|
||||||
|
|
||||||
|
def _rettangolo_pieno(posto: dict) -> tuple[int, int, int, int] | None:
|
||||||
|
"""Il rettangolo di indici tutto occupato di area massima: (i0, j0, righe, colonne)."""
|
||||||
|
if not posto:
|
||||||
|
return None
|
||||||
|
i_min = min(i for i, _ in posto)
|
||||||
|
j_min = min(j for _, j in posto)
|
||||||
|
nr = max(i for i, _ in posto) - i_min + 1
|
||||||
|
nc = max(j for _, j in posto) - j_min + 1
|
||||||
|
m = np.zeros((nr, nc), bool)
|
||||||
|
for (i, j) in posto:
|
||||||
|
m[i - i_min, j - j_min] = True
|
||||||
|
migliore = (0, 0, 0, 0)
|
||||||
|
alt = np.zeros(nc, int)
|
||||||
|
for r in range(nr): # istogramma delle altezze + pila, O(righe*colonne)
|
||||||
|
alt = np.where(m[r], alt + 1, 0)
|
||||||
|
pila: list[int] = []
|
||||||
|
for c in range(nc + 1):
|
||||||
|
h = int(alt[c]) if c < nc else 0
|
||||||
|
while pila and alt[pila[-1]] >= h:
|
||||||
|
altezza = int(alt[pila.pop()])
|
||||||
|
sx = pila[-1] + 1 if pila else 0
|
||||||
|
larg = c - sx
|
||||||
|
if altezza * larg > migliore[2] * migliore[3]:
|
||||||
|
migliore = (r - altezza + 1 + i_min, sx + j_min, altezza, larg)
|
||||||
|
pila.append(c)
|
||||||
|
return migliore if migliore[2] * migliore[3] else None
|
||||||
|
|
||||||
|
|
||||||
|
def _orienta(g: np.ndarray) -> np.ndarray:
|
||||||
|
"""Riordina (nr,nc,2) con colonne verso destra e righe verso il basso."""
|
||||||
|
a = (g[:, -1] - g[:, 0]).mean(axis=0) # direzione dell'indice di colonna
|
||||||
|
b = (g[-1, :] - g[0, :]).mean(axis=0) # direzione dell'indice di riga
|
||||||
|
if abs(b[0]) > abs(a[0]): # assi scambiati (scacchiera ruotata di ~90 gradi)
|
||||||
|
g = g.transpose(1, 0, 2)
|
||||||
|
a, b = b, a
|
||||||
|
if a[0] < 0:
|
||||||
|
g = g[:, ::-1]
|
||||||
|
if b[1] < 0:
|
||||||
|
g = g[::-1]
|
||||||
|
return np.ascontiguousarray(g)
|
||||||
|
|
||||||
|
|
||||||
|
def _passo_mediano(g: np.ndarray) -> float:
|
||||||
|
"""Distanza mediana (px) fra corner adiacenti."""
|
||||||
|
passi = np.concatenate([np.diff(g, axis=1).reshape(-1, 2), np.diff(g, axis=0).reshape(-1, 2)])
|
||||||
|
return float(np.median(np.hypot(passi[:, 0], passi[:, 1])))
|
||||||
|
|
||||||
|
|
||||||
|
def _raffina(img: np.ndarray, pts: np.ndarray, passo: float) -> np.ndarray:
|
||||||
|
"""cornerSubPix su (n,2) con finestra ~0.3 passo, ristretta per chi sta vicino al bordo.
|
||||||
|
|
||||||
|
Una finestra che esce dall'immagine vede il bordo replicato e sposta il corner
|
||||||
|
di ~1 px (visto sull'ultima colonna di una scacchiera a 14 px dal bordo): per
|
||||||
|
questi punti si usa la finestra piu' grande che sta dentro; sotto 3 px resta
|
||||||
|
la posizione grezza.
|
||||||
|
"""
|
||||||
|
h, w = img.shape
|
||||||
|
out = pts.astype(np.float32).copy()
|
||||||
|
win = int(min(20, max(3, 0.3 * passo)))
|
||||||
|
dentro = np.minimum.reduce([out[:, 0], w - 1 - out[:, 0], out[:, 1], h - 1 - out[:, 1]])
|
||||||
|
per_punto = np.minimum(win, np.floor(dentro).astype(int) - 2)
|
||||||
|
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 40, 0.001)
|
||||||
|
for wi in np.unique(per_punto[per_punto >= 3]):
|
||||||
|
sel = per_punto == wi
|
||||||
|
out[sel] = cv2.cornerSubPix(img, out[sel].reshape(-1, 1, 2).copy(),
|
||||||
|
(int(wi), int(wi)), (-1, -1), crit).reshape(-1, 2)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _trova_griglia(frame: np.ndarray) -> np.ndarray:
|
||||||
|
"""Griglia di angoli (n_righe, n_colonne, 2) x,y, in ordine di riga dall'alto."""
|
||||||
|
h, w = frame.shape
|
||||||
|
migliore = None
|
||||||
|
lisc = cv2.GaussianBlur(frame.astype(np.float32), (0, 0), 1.0)
|
||||||
|
p5, p95 = np.percentile(frame, (5, 95))
|
||||||
|
delta = 0.25 * float(p95 - p5) # contrasto minimo fra quadranti opposti
|
||||||
|
for sigma in _SIGME:
|
||||||
|
pts = _candidati(frame, sigma)
|
||||||
|
posto = _cresci_reticolo(pts, (w / 2.0, h / 2.0), lisc, delta)
|
||||||
|
rett = _rettangolo_pieno(posto) if posto else None
|
||||||
|
if rett is None:
|
||||||
|
continue
|
||||||
|
i0, j0, nr, nc = rett
|
||||||
|
if nr < 3 or nc < 3:
|
||||||
|
continue
|
||||||
|
if migliore is not None and nr * nc < 0.5 * migliore[0] * migliore[1]:
|
||||||
|
break # oltre il picco: le scale piu' grosse fondono gli angoli
|
||||||
|
if migliore is None or nr * nc > migliore[0] * migliore[1]:
|
||||||
|
g = np.array([[pts[posto[(i0 + i, j0 + j)]] for j in range(nc)]
|
||||||
|
for i in range(nr)])
|
||||||
|
migliore = (nr, nc, g)
|
||||||
|
if migliore is None:
|
||||||
|
raise CalibrazioneError(
|
||||||
|
"griglia della scacchiera non trovata: serve una scacchiera nitida "
|
||||||
|
"che riempia il campo")
|
||||||
|
g = _orienta(migliore[2])
|
||||||
|
nr, nc, _ = g.shape
|
||||||
|
passo = _passo_mediano(g)
|
||||||
|
# sub-pixel: finestra proporzionale al passo, abbastanza piccola da non vedere i vicini
|
||||||
|
grezzi = g.reshape(-1, 2).astype(np.float32)
|
||||||
|
raff = _raffina(frame, grezzi, passo)
|
||||||
|
spostati = np.hypot(*(raff - grezzi).T) > 0.3 * passo
|
||||||
|
raff[spostati] = grezzi[spostati] # un raffinamento che scappa e' peggio del grezzo
|
||||||
|
return raff.reshape(nr, nc, 2).astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def _estrapola(g: np.ndarray, k: int, prima: bool) -> np.ndarray:
|
||||||
|
"""Aggiunge k colonne (asse 1) per estrapolazione lineare, prima o dopo."""
|
||||||
|
if k <= 0:
|
||||||
|
return g
|
||||||
|
if prima:
|
||||||
|
passo = g[:, 0] - g[:, 1]
|
||||||
|
extra = np.stack([g[:, 0] + m * passo for m in range(k, 0, -1)], axis=1)
|
||||||
|
return np.concatenate([extra, g], axis=1)
|
||||||
|
passo = g[:, -1] - g[:, -2]
|
||||||
|
extra = np.stack([g[:, -1] + m * passo for m in range(1, k + 1)], axis=1)
|
||||||
|
return np.concatenate([g, extra], axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _estendi_asse(g: np.ndarray, lungh: int, c: int, passo: float,
|
||||||
|
grid_step: int) -> tuple[np.ndarray, int]:
|
||||||
|
"""Estende la griglia (asse 1) fino a coprire l'immagine; ritorna (griglia, k_prima).
|
||||||
|
|
||||||
|
`c` e' la coordinata (0 = x, 1 = y) e `lungh` la dimensione dell'immagine su
|
||||||
|
quell'asse. `from_grid` (crop_domain=False) tiene costante la mappa fuori
|
||||||
|
dalla griglia: senza anelli in piu' i bordi dell'immagine raddrizzata
|
||||||
|
sarebbero sbavati.
|
||||||
|
"""
|
||||||
|
margine = max(0.0, float(g[:, 0, c].min()))
|
||||||
|
k_pre = int(math.ceil(margine / passo)) + 1
|
||||||
|
g = _estrapola(g, k_pre, True)
|
||||||
|
offset = int(g[0, 0, c]) # lo stesso int() che usa from_grid
|
||||||
|
# colonne totali perche' l'ultimo nodo, a passo grid_step, superi il bordo
|
||||||
|
servono = int(math.ceil((lungh - 1 - offset) / grid_step)) + 2
|
||||||
|
g = _estrapola(g, max(0, servono - g.shape[1]), False)
|
||||||
|
return g, k_pre
|
||||||
|
|
||||||
|
|
||||||
|
class MappaPronta(NamedTuple):
|
||||||
|
"""Mappa gia' convertita in virgola fissa per un cv2.remap veloce (#84)."""
|
||||||
|
map1: np.ndarray
|
||||||
|
map2: np.ndarray
|
||||||
|
pixel_size: float
|
||||||
|
shape: tuple # (altezza, larghezza) dei frame per cui vale
|
||||||
|
|
||||||
|
|
||||||
|
def _prepara_mappa(mappa: DistortionMap) -> MappaPronta:
|
||||||
|
# CV_16SC2: remap bilineare in virgola fissa, piu' veloce di float32 su 5 MP
|
||||||
|
m1, m2 = cv2.convertMaps(mappa.map_col, mappa.map_row, cv2.CV_16SC2)
|
||||||
|
return MappaPronta(m1, m2, float(mappa.pixel_size), mappa.shape)
|
||||||
|
|
||||||
|
|
||||||
|
def raddrizza(frame: np.ndarray, mappa: MappaPronta) -> np.ndarray | None:
|
||||||
|
"""Frame raddrizzato, o None se le dimensioni non sono quelle della calibrazione."""
|
||||||
|
if frame.shape[:2] != tuple(mappa.shape):
|
||||||
|
return None
|
||||||
|
return cv2.remap(frame, mappa.map1, mappa.map2, cv2.INTER_LINEAR,
|
||||||
|
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
||||||
|
|
||||||
|
|
||||||
|
def calibra_scacchiera_densa(pixels, square_mm: float) -> tuple[dict, DistortionMap]:
|
||||||
|
"""Calibra da UNA foto di scacchiera densa (riempie il campo). Input: solo square_mm.
|
||||||
|
|
||||||
|
Ritorna (risultato, mappa). Risultato: {px_per_mm, mm_per_px (dell'immagine
|
||||||
|
RADDRIZZATA), rms_px, residuo_rms_px, residuo_max_px, n_corners, cols, rows,
|
||||||
|
square_mm, image_width, image_height, scale_spread_pct (0: la mappa uniforma
|
||||||
|
la scala), copertura_pct, metodo, distorsione, overlay_png (b64)}.
|
||||||
|
"""
|
||||||
|
if not (square_mm > 0 and math.isfinite(square_mm)):
|
||||||
|
raise CalibrazioneError("square_mm deve essere > 0")
|
||||||
|
frame = _gray_u8(pixels)
|
||||||
|
h, w = frame.shape
|
||||||
|
g = _trova_griglia(frame)
|
||||||
|
nr, nc, _ = g.shape
|
||||||
|
passo = _passo_mediano(g)
|
||||||
|
grid_step = int(round(passo))
|
||||||
|
if grid_step < 2:
|
||||||
|
raise CalibrazioneError("calibrazione degenere: passo della griglia troppo piccolo")
|
||||||
|
|
||||||
|
# anelli in piu' ai bordi (x e poi y), poi la mappa dai punti estesi
|
||||||
|
e, kl = _estendi_asse(g, w, 0, passo, grid_step)
|
||||||
|
et, kt = _estendi_asse(e.transpose(1, 0, 2), h, 1, passo, grid_step)
|
||||||
|
e = et.transpose(1, 0, 2)
|
||||||
|
try:
|
||||||
|
mappa = DistortionMap.from_grid(
|
||||||
|
e[..., 0].ravel(), e[..., 1].ravel(), e.shape[1], e.shape[0],
|
||||||
|
image_size=(w, h), grid_step=grid_step, crop_domain=False,
|
||||||
|
grid_step_measure=square_mm)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CalibrazioneError(f"mappa di distorsione non costruibile: {exc}") from exc
|
||||||
|
mm_per_px = float(mappa.pixel_size)
|
||||||
|
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
|
||||||
|
raise CalibrazioneError("calibrazione degenere: scala non valida")
|
||||||
|
|
||||||
|
# qualita': si raddrizza la stessa foto e si rilegge ogni corner nel punto ideale
|
||||||
|
off_c, off_r = int(e[0, 0, 0]), int(e[0, 0, 1])
|
||||||
|
ideali = np.array([[(off_c + (kl + j) * grid_step, off_r + (kt + i) * grid_step)
|
||||||
|
for j in range(nc)] for i in range(nr)], np.float32).reshape(-1, 1, 2)
|
||||||
|
rett = cv2.remap(frame, mappa.map_col, mappa.map_row, cv2.INTER_LINEAR,
|
||||||
|
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
||||||
|
# solo i corner ideali dentro l'immagine raddrizzata (cornerSubPix rifiuta quelli fuori)
|
||||||
|
xy = ideali.reshape(-1, 2)
|
||||||
|
dentro = (xy[:, 0] >= 1) & (xy[:, 0] <= w - 2) & (xy[:, 1] >= 1) & (xy[:, 1] <= h - 2)
|
||||||
|
if int(dentro.sum()) < 4:
|
||||||
|
raise CalibrazioneError("griglia raddrizzata fuori dall'immagine: calibrazione non verificabile")
|
||||||
|
ideali = ideali[dentro].reshape(-1, 2)
|
||||||
|
letti = _raffina(rett, ideali, grid_step)
|
||||||
|
scarto = (letti - ideali).astype(np.float64)
|
||||||
|
scarto -= np.median(scarto, axis=0) # int() dell'offset sposta tutto di una traslazione
|
||||||
|
dist = np.hypot(scarto[:, 0], scarto[:, 1])
|
||||||
|
residuo_max = float(dist.max())
|
||||||
|
residuo_rms = float(math.sqrt(float((dist ** 2).mean())))
|
||||||
|
|
||||||
|
# copertura: area del quadrilatero dei quattro angoli esterni / area immagine
|
||||||
|
quad = np.array([g[0, 0], g[0, -1], g[-1, -1], g[-1, 0]], np.float32)
|
||||||
|
copertura = float(cv2.contourArea(quad)) / float(w * h) * 100.0
|
||||||
|
|
||||||
|
# overlay: corner trovati sull'originale (ridotto a max 1600 px: 5 MP in PNG pesano)
|
||||||
|
f = min(1.0, 1600.0 / w)
|
||||||
|
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||||
|
pts = g.reshape(-1, 1, 2).astype(np.float32)
|
||||||
|
if f < 1.0:
|
||||||
|
bgr = cv2.resize(bgr, None, fx=f, fy=f, interpolation=cv2.INTER_AREA)
|
||||||
|
pts = pts * np.float32(f)
|
||||||
|
cv2.drawChessboardCorners(bgr, (nc, nr), pts, True)
|
||||||
|
ok, png = cv2.imencode(".png", bgr)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("PNG dell'overlay non codificabile")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"px_per_mm": 1.0 / mm_per_px,
|
||||||
|
"mm_per_px": mm_per_px,
|
||||||
|
"rms_px": residuo_rms,
|
||||||
|
"residuo_rms_px": residuo_rms,
|
||||||
|
"residuo_max_px": residuo_max,
|
||||||
|
"n_corners": int(nr * nc),
|
||||||
|
"cols": int(nc),
|
||||||
|
"rows": int(nr),
|
||||||
|
"square_mm": float(square_mm),
|
||||||
|
"image_width": int(w),
|
||||||
|
"image_height": int(h),
|
||||||
|
"scale_spread_pct": 0.0,
|
||||||
|
"copertura_pct": copertura,
|
||||||
|
"metodo": "densa",
|
||||||
|
"distorsione": True,
|
||||||
|
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
|
||||||
|
}, mappa
|
||||||
|
|
||||||
|
|
||||||
|
def ora_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
class NomeNonValido(ValueError):
|
||||||
|
"""#91: nome di calibrazione vuoto, troppo lungo o con caratteri di controllo (400)."""
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _libreria(self) -> Path:
|
||||||
|
return self.cartella / "calibrazioni"
|
||||||
|
|
||||||
|
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 _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=destinazione.parent, suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(dati, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, destinazione)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
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)
|
||||||
|
os.replace(tmp, destinazione) # atomica come il JSON
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
with self._lock:
|
||||||
|
self._cache.pop(str(destinazione), None)
|
||||||
|
|
||||||
|
def _togli_mappa(self, nome: str) -> None:
|
||||||
|
destinazione = self._file_mappa_nome(nome)
|
||||||
|
try:
|
||||||
|
destinazione.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
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 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
|
||||||
|
except OSError:
|
||||||
|
with self._lock:
|
||||||
|
self._cache.pop(chiave, None)
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
voce = self._cache.get(chiave)
|
||||||
|
if voce is not None and voce[0] == mtime:
|
||||||
|
return voce[1]
|
||||||
|
try:
|
||||||
|
pronta = _prepara_mappa(DistortionMap.load(percorso))
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return None # illeggibile = nessuna correzione
|
||||||
|
with self._lock:
|
||||||
|
self._cache[chiave] = (mtime, pronta)
|
||||||
|
return pronta
|
||||||
File diff suppressed because it is too large
Load Diff
+702
-29
@@ -4,19 +4,93 @@ Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
|
|||||||
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
||||||
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
||||||
GET /camera/sources -> {"sources": [{name, kind, label}]}
|
GET /camera/sources -> {"sources": [{name, kind, label}]}
|
||||||
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG
|
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000,
|
||||||
|
grezzo=false} -> PNG
|
||||||
|
#84: se la sorgente ha una mappa di distorsione salvata il frame esce
|
||||||
|
raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è.
|
||||||
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||||
|
|
||||||
|
Calibrazione a scacchiera (#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; 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
|
||||||
|
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
|
||||||
|
GET /acquisizione -> stessa pagina (#87)
|
||||||
|
GET /visore/{image_viewer.js|save_image.js|vision_overlay.js} -> i JS del <tmf-viewer> del frontend (#122), altri nomi 404
|
||||||
|
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.
|
||||||
|
#110: specchiatura per sorgente, in software (cv2.flip) su OGNI frame letto dalla
|
||||||
|
sorgente, prima di raddrizzatura/calibrazione (anteprima, grab, calibrazione da camera,
|
||||||
|
copia bloccata); non tocca l'immagine caricata a mano in /camera/calibrate.
|
||||||
|
GET /camera/orientamento?source=S -> {source, orizzontale, verticale} (default false/false);
|
||||||
|
400 se manca source
|
||||||
|
PUT /camera/orientamento json {source, orizzontale: bool, verticale: bool} -> come GET +
|
||||||
|
"avviso" (stringa o null: la calibrazione attiva ha una mappa fatta con l'orientamento
|
||||||
|
di prima e va rifatta). 400 se source manca o i due campi non sono bool. Salva subito
|
||||||
|
su disco; scarta i frame tenuti (anteprima/blocca).
|
||||||
|
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json, l'orientamento
|
||||||
|
per sorgente in orientamento.json (#110) (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
|
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
|
||||||
raggiunge; questo servizio permette di scegliere l'host di ascolto.
|
raggiunge; questo servizio permette di scegliere l'host di ascolto.
|
||||||
|
|
||||||
Uso (dalla radice del repo):
|
Uso (dalla radice del repo):
|
||||||
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
|
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
|
||||||
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
|
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>] \
|
||||||
|
[--config-dir <cartella calibrazioni>]
|
||||||
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
--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).
|
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
||||||
|
|
||||||
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
||||||
vs-camera, fastapi, uvicorn, opencv.
|
vs-camera, fastapi, uvicorn, opencv, python-multipart (rotta /camera/calibrate),
|
||||||
|
visionsuite.calibration (vs-core).
|
||||||
|
|
||||||
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
|
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
|
||||||
camera a chiunque raggiunga la porta: solo su rete isolata.
|
camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||||
@@ -24,18 +98,48 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import json # #110: orientamento.json
|
||||||
|
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 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 contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
from fastapi import Body, FastAPI, HTTPException
|
import numpy as np
|
||||||
from fastapi.responses import Response
|
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.camera_interface import CameraError, CameraGrabError, CameraStateError
|
||||||
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
from visionsuite.camera.sources import _ESTENSIONI, SourceNotFound, list_sources, open_source # #92: _ESTENSIONI = tipi ammessi per le immagini caricate
|
||||||
|
|
||||||
|
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import calibrazione # noqa: E402
|
||||||
|
from calibrazione_pagina import PAGINA # noqa: E402
|
||||||
|
|
||||||
|
# #122: i tre file JS del frontend che la pagina carica su /visore/<nome> (solo questi nomi: nessun percorso arbitrario dall'URL).
|
||||||
|
# Cartelle cercate nell'ordine: pacchetto Nuitka (static/ accanto al modulo, copiata da build.ps1) e sorgenti (src/frontend/flask_app/static/js)
|
||||||
|
_FILE_VISORE = ("image_viewer.js", "save_image.js", "vision_overlay.js")
|
||||||
|
_QUI = Path(__file__).resolve().parent
|
||||||
|
_CARTELLE_VISORE = [_QUI / "static" / "js", _QUI.parent / "src" / "frontend" / "flask_app" / "static" / "js"]
|
||||||
|
|
||||||
_TRIGGER = ("continuo",)
|
_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
|
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
||||||
_MESSAGGI = {
|
_MESSAGGI = {
|
||||||
@@ -61,10 +165,56 @@ def _traduci(messaggio: str) -> str:
|
|||||||
return messaggio
|
return messaggio
|
||||||
|
|
||||||
|
|
||||||
def create_app(folder: Path | str | None = None) -> FastAPI:
|
def _config_dir_default() -> Path:
|
||||||
|
"""#83: cartella delle calibrazioni salvate."""
|
||||||
|
if os.name == "nt":
|
||||||
|
base = os.environ.get("ProgramData") or "C:/ProgramData"
|
||||||
|
return Path(base) / "Tielogic" / "TieMeasureFlow" / "acquisitore"
|
||||||
|
return Path("acquisitore")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(folder: Path | str | None = None,
|
||||||
|
config_dir: Path | str | None = None) -> FastAPI:
|
||||||
|
archivio = calibrazione.Archivio(config_dir if config_dir is not None
|
||||||
|
else _config_dir_default()) # #83
|
||||||
aperte: dict = {}
|
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 = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||||
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||||
|
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
|
||||||
|
lock_calcoli = threading.Lock()
|
||||||
|
file_orientamento = archivio.cartella / "orientamento.json" # #110: {sorgente: {orizzontale, verticale}}
|
||||||
|
|
||||||
|
def _carica_orientamenti() -> dict:
|
||||||
|
"""#110: orientamenti salvati; file assente o rotto -> {} (mai eccezione)."""
|
||||||
|
if not file_orientamento.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
dati = json.loads(file_orientamento.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(dati, dict):
|
||||||
|
raise ValueError("non e' un oggetto JSON")
|
||||||
|
return {n: {"orizzontale": v.get("orizzontale") is True, "verticale": v.get("verticale") is True}
|
||||||
|
for n, v in dati.items() if isinstance(n, str) and isinstance(v, dict)}
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
_log.warning("orientamento non caricato (%s): %s", file_orientamento, e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
orientamenti: dict = _carica_orientamenti() # #110: sorgente -> {"orizzontale": bool, "verticale": bool}; protetto da `lock`
|
||||||
|
|
||||||
|
def _specchia(nome: str, frame):
|
||||||
|
"""#110: applica l'orientamento della sorgente al frame letto; nessuno -> lo stesso oggetto, senza copia."""
|
||||||
|
with lock:
|
||||||
|
o = orientamenti.get(nome) or {}
|
||||||
|
orizz, vert = o.get("orizzontale", False), o.get("verticale", False)
|
||||||
|
if orizz and vert:
|
||||||
|
return cv2.flip(frame, -1) # entrambi = rotazione di 180 gradi
|
||||||
|
if orizz:
|
||||||
|
return cv2.flip(frame, 1)
|
||||||
|
if vert:
|
||||||
|
return cv2.flip(frame, 0)
|
||||||
|
return frame
|
||||||
|
|
||||||
def _config() -> dict:
|
def _config() -> dict:
|
||||||
if folder is None:
|
if folder is None:
|
||||||
@@ -75,8 +225,32 @@ def create_app(folder: Path | str | None = None) -> FastAPI:
|
|||||||
with lock:
|
with lock:
|
||||||
return lock_sorgenti.setdefault(nome, threading.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
|
@asynccontextmanager
|
||||||
async def _vita(_app):
|
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
|
yield
|
||||||
with lock: # alla chiusura rilascia le camere aperte
|
with lock: # alla chiusura rilascia le camere aperte
|
||||||
sorgenti = list(aperte.values())
|
sorgenti = list(aperte.values())
|
||||||
@@ -90,10 +264,108 @@ def create_app(folder: Path | str | None = None) -> FastAPI:
|
|||||||
app = FastAPI(title="TMF camera service", lifespan=_vita)
|
app = FastAPI(title="TMF camera service", lifespan=_vita)
|
||||||
|
|
||||||
@app.get("/camera/sources")
|
@app.get("/camera/sources")
|
||||||
def camera_sources():
|
def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
|
||||||
voci = list_sources(None, cameras=True, config=_config())
|
elenco = list(_camere(aggiorna))
|
||||||
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
|
||||||
for v in voci if v.kind in ("camera", "virtual")]}
|
for v in list_sources(cartella_immagini, cameras=False):
|
||||||
|
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
|
||||||
|
return {"sources": elenco}
|
||||||
|
|
||||||
|
def _chiudi(nome: str) -> None:
|
||||||
|
"""#92: chiude la sorgente aperta e scorda il frame tenuto (immagine sostituita o tolta)."""
|
||||||
|
with _lock_di(nome):
|
||||||
|
with lock:
|
||||||
|
s = aperte.pop(nome, None)
|
||||||
|
grezzi.pop(nome, None)
|
||||||
|
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
|
||||||
|
return fn(_apri(nome))
|
||||||
|
except SourceNotFound as e:
|
||||||
|
raise HTTPException(404, _traduci(str(e)))
|
||||||
|
except TimeoutError as e:
|
||||||
|
raise HTTPException(504, _traduci(str(e)) or "timeout")
|
||||||
|
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: _specchia(nome, s.grab(timeout))) # #87: corpo in _con_sorgente; #110: specchiato prima di tutto il resto
|
||||||
|
|
||||||
@app.post("/camera/grab")
|
@app.post("/camera/grab")
|
||||||
def camera_grab(corpo: dict = Body(default=None)):
|
def camera_grab(corpo: dict = Body(default=None)):
|
||||||
@@ -104,28 +376,427 @@ def create_app(folder: Path | str | None = None) -> FastAPI:
|
|||||||
trigger = corpo.get("trigger", "continuo")
|
trigger = corpo.get("trigger", "continuo")
|
||||||
if trigger not in _TRIGGER:
|
if trigger not in _TRIGGER:
|
||||||
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
||||||
|
grezzo = corpo.get("grezzo", False) # #84: true = frame non raddrizzato
|
||||||
|
if not isinstance(grezzo, bool):
|
||||||
|
raise HTTPException(400, "«grezzo» deve essere true o false")
|
||||||
timeout = corpo.get("timeout_ms", 5000)
|
timeout = corpo.get("timeout_ms", 5000)
|
||||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
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")
|
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||||
try:
|
frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
|
||||||
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
raddrizzato = False
|
||||||
with lock:
|
if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache (#91: quella attiva)
|
||||||
sorgente = aperte.get(nome)
|
mappa = archivio.leggi_mappa(nome)
|
||||||
if sorgente is None:
|
if mappa is not None:
|
||||||
sorgente = open_source(nome, None, config=_config())
|
corretto = calibrazione.raddrizza(frame, mappa)
|
||||||
with lock:
|
if corretto is not None: # None = dimensioni diverse da quelle calibrate
|
||||||
aperte[nome] = sorgente
|
frame, raddrizzato = corretto, True
|
||||||
frame = sorgente.grab(timeout)
|
|
||||||
except SourceNotFound as e:
|
|
||||||
raise HTTPException(404, _traduci(str(e)))
|
|
||||||
except TimeoutError as e:
|
|
||||||
raise HTTPException(504, _traduci(str(e)) or "timeout")
|
|
||||||
except (CameraGrabError, CameraError) as e:
|
|
||||||
raise HTTPException(503, _traduci(str(e)))
|
|
||||||
ok, buf = cv2.imencode(".png", frame)
|
ok, buf = cv2.imencode(".png", frame)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(500, "codifica PNG fallita")
|
raise HTTPException(500, "codifica PNG fallita")
|
||||||
return Response(buf.tobytes(), media_type="image/png")
|
return Response(buf.tobytes(), media_type="image/png",
|
||||||
|
headers={"X-Raddrizzato": "si" if raddrizzato else "no"})
|
||||||
|
|
||||||
|
# ---- #83: calibrazione a scacchiera per sorgente
|
||||||
|
|
||||||
|
def _sorgente(valore) -> str:
|
||||||
|
if not isinstance(valore, str) or not valore.strip():
|
||||||
|
raise HTTPException(400, "manca «source»")
|
||||||
|
return valore.strip()
|
||||||
|
|
||||||
|
@app.get("/camera/calibration")
|
||||||
|
def calibrazione_leggi(source: str = ""):
|
||||||
|
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.scollega(_sorgente(source)) # #91: scollega soltanto, la libreria resta
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@app.post("/camera/calibrate")
|
||||||
|
async def calibrazione_calcola(
|
||||||
|
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), # #91: ignorato, il calcolo non salva mai (si salva con nome)
|
||||||
|
bloccata: bool = Form(False), # #94: true = calcola sulla copia bloccata, senza grab
|
||||||
|
):
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
if cols is None: # #84: densa, nessuna griglia da dichiarare
|
||||||
|
risultato, mappa = await run_in_threadpool(
|
||||||
|
calibrazione.calibra_scacchiera_densa, frame, square_mm)
|
||||||
|
else: # metodo planare di #83, invariato
|
||||||
|
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
|
||||||
|
except calibrazione.CalibrazioneError as e:
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
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 = _specchia(nome, s.grab(timeout)) # #110: specchiato PRIMA di tenerlo, cosi' blocca/calibrata lo ricevono gia' specchiato
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ---- #110: specchiatura orizzontale/verticale per sorgente
|
||||||
|
|
||||||
|
def _orientamento_di(nome: str) -> dict:
|
||||||
|
with lock:
|
||||||
|
o = orientamenti.get(nome) or {}
|
||||||
|
return {"source": nome, "orizzontale": bool(o.get("orizzontale")), "verticale": bool(o.get("verticale"))}
|
||||||
|
|
||||||
|
@app.get("/camera/orientamento")
|
||||||
|
def orientamento_leggi(source: str = ""):
|
||||||
|
return _orientamento_di(_sorgente(source))
|
||||||
|
|
||||||
|
@app.put("/camera/orientamento")
|
||||||
|
def orientamento_scrivi(corpo: dict = Body(default=None)):
|
||||||
|
corpo = corpo if isinstance(corpo, dict) else {}
|
||||||
|
nome = _sorgente(corpo.get("source"))
|
||||||
|
orizz, vert = corpo.get("orizzontale"), corpo.get("verticale")
|
||||||
|
if not isinstance(orizz, bool) or not isinstance(vert, bool):
|
||||||
|
raise HTTPException(400, "«orizzontale» e «verticale» devono essere true o false")
|
||||||
|
with lock:
|
||||||
|
prima = orientamenti.get(nome) or {}
|
||||||
|
prima_voce = orientamenti.get(nome) # #110: voce di prima (None = non c'era), per il ripristino se il salvataggio fallisce
|
||||||
|
cambiato = (bool(prima.get("orizzontale")), bool(prima.get("verticale"))) != (orizz, vert)
|
||||||
|
if orizz or vert:
|
||||||
|
orientamenti[nome] = {"orizzontale": orizz, "verticale": vert}
|
||||||
|
else:
|
||||||
|
orientamenti.pop(nome, None) # tutti false: la sorgente esce dal file
|
||||||
|
# scrittura atomica di tutto il dizionario (tmp + replace)
|
||||||
|
try:
|
||||||
|
file_orientamento.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = file_orientamento.with_name(file_orientamento.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(orientamenti, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
os.replace(tmp, file_orientamento)
|
||||||
|
except OSError as e:
|
||||||
|
_log.warning("orientamento non salvato (%s): %s", file_orientamento, e)
|
||||||
|
if prima_voce is None: # #110: salvataggio fallito -> in memoria resta l'orientamento di prima
|
||||||
|
orientamenti.pop(nome, None)
|
||||||
|
else:
|
||||||
|
orientamenti[nome] = prima_voce
|
||||||
|
raise HTTPException(500, "salvataggio dell'orientamento non riuscito")
|
||||||
|
# i frame tenuti hanno l'orientamento di prima
|
||||||
|
grezzi.pop(nome, None)
|
||||||
|
bloccati.pop(nome, None)
|
||||||
|
avviso = None
|
||||||
|
if cambiato and archivio.leggi_mappa(nome) is not None:
|
||||||
|
avviso = (f"la calibrazione attiva «{archivio.attiva_di(nome)}» corregge la distorsione "
|
||||||
|
"con l'orientamento di prima: va rifatta")
|
||||||
|
return {**_orientamento_di(nome), "avviso": avviso}
|
||||||
|
|
||||||
|
@app.get("/calibrazione", response_class=HTMLResponse)
|
||||||
|
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
|
||||||
|
def calibrazione_pagina():
|
||||||
|
return HTMLResponse(PAGINA)
|
||||||
|
|
||||||
|
@app.get("/visore/{nome}")
|
||||||
|
def visore_file(nome: str):
|
||||||
|
"""#122: i file JS del <tmf-viewer> letti dal frontend Flask; la pagina resta offline."""
|
||||||
|
if nome not in _FILE_VISORE: # lista chiusa: niente percorsi dall'URL
|
||||||
|
raise HTTPException(404, "file non trovato")
|
||||||
|
for cartella in _CARTELLE_VISORE:
|
||||||
|
f = cartella / nome
|
||||||
|
if f.is_file():
|
||||||
|
return Response(f.read_bytes(), media_type="application/javascript", headers={"Cache-Control": "no-cache"})
|
||||||
|
raise HTTPException(404, "file non trovato")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -136,8 +807,10 @@ def main() -> None:
|
|||||||
p.add_argument("--host", default="127.0.0.1")
|
p.add_argument("--host", default="127.0.0.1")
|
||||||
p.add_argument("--port", type=int, default=8765)
|
p.add_argument("--port", type=int, default=8765)
|
||||||
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
|
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
|
||||||
|
p.add_argument("--config-dir", default=None,
|
||||||
|
help="cartella delle calibrazioni salvate (default: ProgramData su Windows)") # #83
|
||||||
a = p.parse_args()
|
a = p.parse_args()
|
||||||
uvicorn.run(create_app(a.folder), host=a.host, port=a.port)
|
uvicorn.run(create_app(a.folder, a.config_dir), host=a.host, port=a.port)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
"""Seed per il PC della fiera: utenti, stazione, tre ricette e quattro settimane di misure.
|
||||||
|
|
||||||
|
Cosa fa
|
||||||
|
Popola un database TieMeasureFlow (SQLite del pacchetto Windows) cosi' che al primo
|
||||||
|
avvio NON compaia la procedura di setup e la demo abbia gia' qualcosa da mostrare:
|
||||||
|
|
||||||
|
- utenti adriano (admin), maker, capoturno, operatore (password nella costante
|
||||||
|
UTENTI qui sotto, decise da Adriano per la fiera);
|
||||||
|
- la stazione ST-DEFAULT (quella del pacchetto, vedi packaging/windows/avvio.py)
|
||||||
|
con camera_url http://127.0.0.1:8765 e camera_source camera:601020 (Itala);
|
||||||
|
- FIERA-FLANGIA «Flangia Ø80» (5 quote) e FIERA-ALBERO «Albero rettificato»
|
||||||
|
(4 quote), con circa 4 settimane di storico fino a ieri (lun-ven 8-17, una
|
||||||
|
produzione chiusa per giorno, lotti e seriali coerenti, misure con Cpk diversi
|
||||||
|
per quota, una deriva lenta nell'ultima settimana, una quota con 1-3% fuori
|
||||||
|
tolleranza);
|
||||||
|
- FIERA-DXF «Profilo DXF (visione)»: un task dxf_compare senza ricetta di visione
|
||||||
|
(si crea in fiera dal pannello «Crea ricetta da DXF»).
|
||||||
|
|
||||||
|
«Setup fatto» per il backend vuol dire: esiste almeno un utente admin
|
||||||
|
(api/routers/setup.py::_admin_exists), quindi basta creare adriano.
|
||||||
|
|
||||||
|
Come si usa (da daitarn, TMF fermo sul PC, su una COPIA del file SQLite)
|
||||||
|
uv run python scripts/seed_fiera.py --db C:/percorso/tmf.db [--replace] [--dry-run]
|
||||||
|
[--fine AAAA-MM-GG]
|
||||||
|
|
||||||
|
Il file deve avere gia' lo schema (alembic upgrade head: lo fa l'exe al primo
|
||||||
|
avvio). --replace toglie solo i dati FIERA-* (ricette, misure, produzioni) e li
|
||||||
|
rifa'; senza, un secondo giro non duplica le ricette. Gli utenti e la stazione
|
||||||
|
sono idempotenti: se esistono si aggiornano password, ruoli e camera.
|
||||||
|
--dry-run fa tutto e annulla (stampa i numeri). Il seme casuale e' fisso: stesso
|
||||||
|
comando e stessa data di fine, stessi dati.
|
||||||
|
|
||||||
|
Da cosa dipende
|
||||||
|
src.backend (modelli ORM, auth_service, measurement_service), SQLAlchemy +
|
||||||
|
aiosqlite, tzdata su Windows (gia' in pyproject). Non usa MySQL.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy import delete, event, select
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession, async_sessionmaker, create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
# La radice del repo nel path, cosi' "src.backend" si importa lanciando lo script da ovunque
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from src.backend.database import async_session_factory # noqa: E402
|
||||||
|
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
||||||
|
from src.backend.models.orm.production import ( # noqa: E402
|
||||||
|
ProductionEvent, ProductionRun,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
|
||||||
|
from src.backend.models.orm.setting import RecipeVersionAudit # noqa: E402
|
||||||
|
from src.backend.models.orm.station import ( # noqa: E402
|
||||||
|
Station, StationRecipeAssignment,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
|
||||||
|
from src.backend.models.orm.task_result import TaskResult # noqa: E402
|
||||||
|
from src.backend.models.orm.user import User # noqa: E402
|
||||||
|
from src.backend.services import auth_service # noqa: E402
|
||||||
|
from src.backend.services.measurement_service import calculate_pass_fail # noqa: E402
|
||||||
|
|
||||||
|
CODES = ("FIERA-FLANGIA", "FIERA-ALBERO", "FIERA-DXF")
|
||||||
|
STATION_CODE = "ST-DEFAULT" # STATION_CODE del pacchetto Windows (avvio.py PREDEFINITI)
|
||||||
|
CAMERA_URL = "http://127.0.0.1:8765"
|
||||||
|
CAMERA_SOURCE = "camera:601020" # Itala sul PC fiera
|
||||||
|
SEED = 20261026 # seme fisso: dati ripetibili
|
||||||
|
FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale
|
||||||
|
GIORNI_STORICO = 28 # circa 4 settimane
|
||||||
|
|
||||||
|
# (username, password, nome, ruoli, admin). Password "123" per tutti, chiesta da Adriano per la fiera (#85); il login non controlla la lunghezza.
|
||||||
|
UTENTI = [
|
||||||
|
("adriano", "123", "Adriano Dal Pastro",
|
||||||
|
["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True),
|
||||||
|
("maker", "123", "Marco Ferrari (Maker)", ["Maker"], False),
|
||||||
|
("capoturno", "123", "Luca Bianchi (Capoturno)",
|
||||||
|
["Supervisor", "MeasurementTec"], False),
|
||||||
|
("operatore", "123", "Giulia Rossi (Operatore)", ["MeasurementTec"], False),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _quota(marker, descrizione, nominale, tol, unita="mm", dec=3):
|
||||||
|
"""Quota simmetrica: tolleranza = nominale +/- tol, warning al 70% della tolleranza."""
|
||||||
|
return dict(
|
||||||
|
marker_number=marker, description=descrizione, nominal=nominale,
|
||||||
|
ltl=round(nominale - tol, dec), lwl=round(nominale - 0.7 * tol, dec),
|
||||||
|
uwl=round(nominale + 0.7 * tol, dec), utl=round(nominale + tol, dec),
|
||||||
|
unit=unita,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Profilo statistico di una quota: scostamento medio (in frazioni di tol), sigma (in
|
||||||
|
# frazioni di tol) e deriva (frazione di tol aggiunta linearmente nell'ultima settimana).
|
||||||
|
# Cpk ~ (1 - |scostamento|) / (3 * sigma): sigma 0.2 -> 1,67; 0.33 -> 1,0.
|
||||||
|
FLANGIA = [
|
||||||
|
# (task, quota, scostamento, sigma, deriva)
|
||||||
|
(0, _quota(1, "Diametro esterno Ø80", 80.0, 0.05), 0.0, 0.20, 0.0), # Cpk ~1,67 capace
|
||||||
|
(0, _quota(2, "Diametro foro centrale Ø25", 25.0, 0.02), 0.0, 0.33, 0.0), # Cpk ~1,0 al limite
|
||||||
|
(1, _quota(3, "Spessore flangia", 12.0, 0.10), 0.0, 0.22, 0.55), # deriva ultima settimana
|
||||||
|
(1, _quota(4, "Interasse fori Ø60", 60.0, 0.10), 0.10, 0.25, 0.0), # Cpk ~1,2
|
||||||
|
(1, _quota(5, "Profondita' lamatura", 8.0, 0.15), 0.50, 0.244, 0.0), # ~2% fuori tolleranza
|
||||||
|
]
|
||||||
|
ALBERO = [
|
||||||
|
(0, _quota(1, "Diametro cuscinetto Ø20 h6", 20.0, 0.010), 0.0, 0.20, 0.0), # Cpk ~1,67
|
||||||
|
(0, _quota(2, "Diametro sede Ø25 j5", 25.0, 0.008), 0.0, 0.25, 0.0), # Cpk ~1,33
|
||||||
|
(1, _quota(3, "Lunghezza totale", 150.0, 0.10), 0.0, 0.33, 0.0), # Cpk ~1,0
|
||||||
|
(1, _quota(4, "Rugosita' Ra", 0.40, 0.20, unita="µm"), 0.20, 0.25, 0.0), # Cpk ~1,07
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ora_utc(giorno: date, minuti_da_mezzanotte: int) -> datetime:
|
||||||
|
"""Ora locale di officina -> datetime UTC naive, come le salva il backend."""
|
||||||
|
locale = datetime.combine(giorno, time(0, 0), tzinfo=FUSO) + timedelta(minutes=minuti_da_mezzanotte)
|
||||||
|
return locale.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _giorni_lavorativi(fine: date) -> list[date]:
|
||||||
|
"""Lun-ven degli ultimi GIORNI_STORICO giorni, fino a `fine` compreso."""
|
||||||
|
giorni = [fine - timedelta(days=i) for i in range(GIORNI_STORICO)]
|
||||||
|
return sorted(g for g in giorni if g.weekday() < 5)
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_giornalieri(rng: random.Random, n: int) -> list[int]:
|
||||||
|
"""n orari (minuti dalla mezzanotte) in 8-12 e 13-17, ogni ~30 min con un po' di scarto."""
|
||||||
|
mezz_ore = [8 * 60 + 30 * i for i in range(8)] + [13 * 60 + 30 * i for i in range(8)]
|
||||||
|
scelti = sorted(rng.sample(mezz_ore, n))
|
||||||
|
return [m + rng.randint(0, 9) for m in scelti]
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_utenti(session: AsyncSession) -> dict[str, User]:
|
||||||
|
"""Crea gli utenti o, se esistono, ne aggiorna password, nome, ruoli e flag admin."""
|
||||||
|
utenti = {}
|
||||||
|
for username, password, nome, ruoli, admin in UTENTI:
|
||||||
|
esistente = (await session.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if esistente is None:
|
||||||
|
utenti[username] = await auth_service.create_user(
|
||||||
|
session, username=username, password=password, display_name=nome,
|
||||||
|
roles=ruoli, is_admin=admin,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
esistente.password_hash = auth_service.hash_password(password)
|
||||||
|
esistente.display_name = nome
|
||||||
|
esistente.roles = ruoli
|
||||||
|
esistente.is_admin = admin
|
||||||
|
esistente.active = True
|
||||||
|
utenti[username] = esistente
|
||||||
|
await session.flush()
|
||||||
|
return utenti
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_stazione(session: AsyncSession, owner: User) -> Station:
|
||||||
|
"""La stazione del pacchetto, con la camera Itala; se c'e' gia' ne aggiorna la camera."""
|
||||||
|
stazione = (await session.execute(
|
||||||
|
select(Station).where(Station.code == STATION_CODE)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if stazione is None:
|
||||||
|
stazione = Station(
|
||||||
|
code=STATION_CODE, name="Postazione fiera", location="Stand",
|
||||||
|
created_by=owner.id,
|
||||||
|
)
|
||||||
|
session.add(stazione)
|
||||||
|
stazione.camera_url = CAMERA_URL
|
||||||
|
stazione.camera_source = CAMERA_SOURCE
|
||||||
|
await session.flush()
|
||||||
|
return stazione
|
||||||
|
|
||||||
|
|
||||||
|
async def _conta_e_cancella(session: AsyncSession, ricette: list[Recipe]) -> dict:
|
||||||
|
"""Toglie le ricette FIERA-* e tutto cio' che e' misurato contro di esse. Ritorna i conteggi."""
|
||||||
|
conti = {"misure": 0, "produzioni": 0, "eventi": 0}
|
||||||
|
for ricetta in ricette:
|
||||||
|
versioni = (await session.execute(
|
||||||
|
select(RecipeVersion.id).where(RecipeVersion.recipe_id == ricetta.id)
|
||||||
|
)).scalars().all()
|
||||||
|
runs = (await session.execute(
|
||||||
|
select(ProductionRun.id).where(ProductionRun.recipe_id == ricetta.id)
|
||||||
|
)).scalars().all()
|
||||||
|
if versioni:
|
||||||
|
conti["misure"] += (await session.execute(
|
||||||
|
delete(Measurement).where(Measurement.version_id.in_(versioni))
|
||||||
|
)).rowcount
|
||||||
|
await session.execute( # esiti dei task di queste versioni (FK sulle versioni)
|
||||||
|
delete(TaskResult).where(TaskResult.version_id.in_(versioni))
|
||||||
|
)
|
||||||
|
if runs:
|
||||||
|
conti["eventi"] += (await session.execute(
|
||||||
|
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
|
||||||
|
)).rowcount
|
||||||
|
conti["produzioni"] += (await session.execute(
|
||||||
|
delete(ProductionRun).where(ProductionRun.id.in_(runs))
|
||||||
|
)).rowcount
|
||||||
|
await session.execute(
|
||||||
|
delete(StationRecipeAssignment).where(StationRecipeAssignment.recipe_id == ricetta.id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(RecipeVersionAudit).where(RecipeVersionAudit.recipe_id == ricetta.id)
|
||||||
|
)
|
||||||
|
# Versioni, task e quote se ne vanno con la cascade ORM della ricetta.
|
||||||
|
await session.delete(ricetta)
|
||||||
|
await session.flush()
|
||||||
|
return conti
|
||||||
|
|
||||||
|
|
||||||
|
async def _nuova_ricetta(session, owner, stazione, *, code, name, description,
|
||||||
|
interval, requires_serial, note_versione):
|
||||||
|
"""Ricetta + versione 1 corrente + assegnazione alla stazione."""
|
||||||
|
ricetta = Recipe(
|
||||||
|
code=code, name=name, description=description, created_by=owner.id, active=True,
|
||||||
|
measurement_interval_minutes=interval, requires_lot=True,
|
||||||
|
requires_serial=requires_serial, allow_manual_input=True,
|
||||||
|
)
|
||||||
|
session.add(ricetta)
|
||||||
|
await session.flush()
|
||||||
|
versione = RecipeVersion(
|
||||||
|
recipe_id=ricetta.id, version_number=1, is_current=True,
|
||||||
|
created_by=owner.id, change_notes=note_versione,
|
||||||
|
)
|
||||||
|
session.add(versione)
|
||||||
|
session.add(StationRecipeAssignment(
|
||||||
|
station_id=stazione.id, recipe_id=ricetta.id, assigned_by=owner.id,
|
||||||
|
))
|
||||||
|
await session.flush()
|
||||||
|
return ricetta, versione
|
||||||
|
|
||||||
|
|
||||||
|
async def _task(session, versione, ordine, titolo, tipo, direttiva=None,
|
||||||
|
descrizione=None, quote=()):
|
||||||
|
task = RecipeTask(
|
||||||
|
version_id=versione.id, order_index=ordine, title=titolo, task_type=tipo,
|
||||||
|
directive=direttiva, description=descrizione,
|
||||||
|
)
|
||||||
|
session.add(task)
|
||||||
|
await session.flush()
|
||||||
|
sottotask = []
|
||||||
|
for q in quote:
|
||||||
|
st = RecipeSubtask(task_id=task.id, **q)
|
||||||
|
session.add(st)
|
||||||
|
sottotask.append(st)
|
||||||
|
await session.flush()
|
||||||
|
return task, sottotask
|
||||||
|
|
||||||
|
|
||||||
|
async def _ricetta_meccanica(session, owner, stazione, *, code, name, description,
|
||||||
|
interval, requires_serial, profilo, titoli_task):
|
||||||
|
"""Ricetta con una nota iniziale e un task di misura per ogni gruppo di quote del profilo."""
|
||||||
|
ricetta, versione = await _nuova_ricetta(
|
||||||
|
session, owner, stazione, code=code, name=name, description=description,
|
||||||
|
interval=interval, requires_serial=requires_serial,
|
||||||
|
note_versione="Ricetta dimostrativa per la fiera",
|
||||||
|
)
|
||||||
|
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||||
|
direttiva="Prima di misurare",
|
||||||
|
descrizione="Pulire il pezzo e controllare che sia a **temperatura ambiente**.")
|
||||||
|
sottotask_per_quota = {}
|
||||||
|
for indice, titolo in enumerate(titoli_task):
|
||||||
|
quote = [q for (t, q, *_r) in profilo if t == indice]
|
||||||
|
_t, sottotask = await _task(
|
||||||
|
session, versione, indice + 1, titolo, "measure",
|
||||||
|
direttiva="Misurare le quote in sequenza", quote=quote,
|
||||||
|
)
|
||||||
|
for st in sottotask:
|
||||||
|
sottotask_per_quota[st.marker_number] = st
|
||||||
|
return ricetta, versione, sottotask_per_quota
|
||||||
|
|
||||||
|
|
||||||
|
def _valore(rng, quota, scost, sigma, deriva, frazione_tempo):
|
||||||
|
"""Un valore: normale attorno a nominale + scostamento (+ deriva nell'ultima settimana)."""
|
||||||
|
tol = quota["utl"] - quota["nominal"]
|
||||||
|
media = quota["nominal"] + tol * (scost + deriva * max(0.0, (frazione_tempo - 0.75) / 0.25))
|
||||||
|
return round(rng.gauss(media, tol * sigma), 6)
|
||||||
|
|
||||||
|
|
||||||
|
async def _storico(session, rng, fine, owner_ricetta, stazione, utenti, ricetta, versione,
|
||||||
|
sottotask, profilo, pezzi_giorno, prefisso_lotto, prefisso_seriale):
|
||||||
|
"""Una produzione chiusa per giorno lavorativo, con i suoi pezzi, eventi e misure."""
|
||||||
|
giorni = _giorni_lavorativi(fine)
|
||||||
|
operatore, capoturno = utenti["operatore"], utenti["capoturno"]
|
||||||
|
task_ids = {} # marker -> id del task di misura
|
||||||
|
for marker, st in sottotask.items():
|
||||||
|
task_ids[marker] = st.task_id
|
||||||
|
ordine_task = sorted(set(task_ids.values()))
|
||||||
|
ultimo_task = ordine_task[-1]
|
||||||
|
contatore = 0
|
||||||
|
totale = 0
|
||||||
|
for indice_giorno, giorno in enumerate(giorni):
|
||||||
|
frazione = indice_giorno / max(1, len(giorni) - 1) # 0 = primo giorno, 1 = ieri
|
||||||
|
n = rng.randint(*pezzi_giorno)
|
||||||
|
slot = _slot_giornalieri(rng, n)
|
||||||
|
lotto = f"{prefisso_lotto}{giorno:%y%m%d}"
|
||||||
|
inizio, fine_run = _ora_utc(giorno, slot[0] - 5), _ora_utc(giorno, slot[-1] + 10)
|
||||||
|
run = ProductionRun(
|
||||||
|
station_id=stazione.id, recipe_id=ricetta.id, version_id=versione.id,
|
||||||
|
operator_id=operatore.id, lot_number=lotto, status="closed",
|
||||||
|
measurement_interval_minutes=ricetta.measurement_interval_minutes,
|
||||||
|
cycle_count=n, started_at=inizio, closed_at=fine_run, closed_by=capoturno.id,
|
||||||
|
active_station_id=None,
|
||||||
|
)
|
||||||
|
session.add(run)
|
||||||
|
await session.flush()
|
||||||
|
session.add(ProductionEvent(run_id=run.id, event_type="start", user_id=operatore.id,
|
||||||
|
created_at=inizio))
|
||||||
|
for numero, minuti in enumerate(slot, start=1):
|
||||||
|
contatore += 1
|
||||||
|
seriale = f"{prefisso_seriale}{contatore:05d}"
|
||||||
|
istante = _ora_utc(giorno, minuti)
|
||||||
|
for marker in sorted(sottotask):
|
||||||
|
st = sottotask[marker]
|
||||||
|
_t, quota, scost, sigma, deriva = next(p for p in profilo if p[1]["marker_number"] == marker)
|
||||||
|
valore = _valore(rng, quota, scost, sigma, deriva, frazione)
|
||||||
|
esito, scarto = calculate_pass_fail(valore, st)
|
||||||
|
m = Measurement(
|
||||||
|
subtask_id=st.id, version_id=versione.id, measured_by=operatore.id,
|
||||||
|
value=valore, pass_fail=esito, deviation=scarto, lot_number=lotto,
|
||||||
|
serial_number=seriale,
|
||||||
|
input_method="usb_caliper" if rng.random() < 0.75 else "manual",
|
||||||
|
input_duration_ms=rng.randint(2500, 9000),
|
||||||
|
production_run_id=run.id,
|
||||||
|
# un fuori tolleranza passa solo con l'ok del capoturno (come in produzione)
|
||||||
|
supervisor_id=capoturno.id if esito == "fail" else None,
|
||||||
|
authorised_at=istante + timedelta(minutes=1) if esito == "fail" else None,
|
||||||
|
measured_at=istante + timedelta(seconds=20 * marker),
|
||||||
|
)
|
||||||
|
session.add(m)
|
||||||
|
totale += 1
|
||||||
|
# un task_measured per ogni task di misura non finale, poi il ciclo completato
|
||||||
|
for tid in ordine_task:
|
||||||
|
tipo = "cycle_completed" if tid == ultimo_task else "task_measured"
|
||||||
|
session.add(ProductionEvent(
|
||||||
|
run_id=run.id, event_type=tipo, user_id=operatore.id, task_id=tid,
|
||||||
|
created_at=istante + timedelta(minutes=1),
|
||||||
|
))
|
||||||
|
session.add(ProductionEvent(run_id=run.id, event_type="close", user_id=capoturno.id,
|
||||||
|
supervisor_id=capoturno.id, created_at=fine_run))
|
||||||
|
await session.flush()
|
||||||
|
return contatore, totale
|
||||||
|
|
||||||
|
|
||||||
|
async def _ricetta_dxf(session, owner, stazione):
|
||||||
|
"""Demo visione: un task dxf_compare, senza grafo (si crea in fiera dal pannello DXF)."""
|
||||||
|
ricetta, versione = await _nuova_ricetta(
|
||||||
|
session, owner, stazione, code="FIERA-DXF", name="Profilo DXF (visione)",
|
||||||
|
description="Confronto di un profilo con il disegno DXF tramite la camera. "
|
||||||
|
"La ricetta di visione si crea in fiera dal pannello «Crea ricetta da DXF».",
|
||||||
|
interval=None, requires_serial=False, note_versione="Ricetta dimostrativa per la fiera",
|
||||||
|
)
|
||||||
|
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||||
|
direttiva="Prima di misurare",
|
||||||
|
descrizione="Appoggiare il pezzo sul piano della camera, **in piano**.")
|
||||||
|
await _task(
|
||||||
|
session, versione, 1, "Confronto profilo con DXF", "dxf_compare",
|
||||||
|
direttiva="Acquisire e confrontare",
|
||||||
|
descrizione="Lo scostamento massimo del profilo rispetto al DXF deve restare entro la tolleranza.",
|
||||||
|
quote=[_quota(1, "Scostamento massimo profilo", 0.0, 0.15)],
|
||||||
|
)
|
||||||
|
return ricetta
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(replace: bool, dry_run: bool, fine: date) -> int:
|
||||||
|
"""Esegue il seed in una sola transazione; con dry_run annulla tutto alla fine."""
|
||||||
|
rng = random.Random(SEED)
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
utenti = await _upsert_utenti(session)
|
||||||
|
owner = utenti["adriano"]
|
||||||
|
stazione = await _upsert_stazione(session, owner)
|
||||||
|
|
||||||
|
esistenti = list((await session.execute(
|
||||||
|
select(Recipe).where(Recipe.code.in_(CODES))
|
||||||
|
)).scalars().all())
|
||||||
|
if esistenti and not replace:
|
||||||
|
print(f"Ricette FIERA gia' presenti ({', '.join(r.code for r in esistenti)}): "
|
||||||
|
"non duplico. Usa --replace per rifarle.")
|
||||||
|
else:
|
||||||
|
if esistenti:
|
||||||
|
conti = await _conta_e_cancella(session, esistenti)
|
||||||
|
print(f"--replace: rimosse {', '.join(r.code for r in esistenti)} "
|
||||||
|
f"({conti['misure']} misure, {conti['produzioni']} produzioni, "
|
||||||
|
f"{conti['eventi']} eventi)")
|
||||||
|
|
||||||
|
r1, v1, sub1 = await _ricetta_meccanica(
|
||||||
|
session, owner, stazione, code="FIERA-FLANGIA", name="Flangia Ø80",
|
||||||
|
description="Flangia tornita in alluminio: diametri, spessore, interasse fori "
|
||||||
|
"e profondita' della lamatura. Lotto e seriale obbligatori.",
|
||||||
|
interval=30, requires_serial=True, profilo=FLANGIA,
|
||||||
|
titoli_task=["Misure diametrali", "Spessore e posizione fori"],
|
||||||
|
)
|
||||||
|
n1, m1 = await _storico(session, rng, fine, owner, stazione, utenti, r1, v1, sub1,
|
||||||
|
FLANGIA, (10, 14), "LF", "FL")
|
||||||
|
r2, v2, sub2 = await _ricetta_meccanica(
|
||||||
|
session, owner, stazione, code="FIERA-ALBERO", name="Albero rettificato",
|
||||||
|
description="Albero in acciaio rettificato: sedi dei cuscinetti, lunghezza e "
|
||||||
|
"rugosita'. Tolleranze strette.",
|
||||||
|
interval=30, requires_serial=False, profilo=ALBERO,
|
||||||
|
titoli_task=["Diametri rettificati", "Lunghezza e rugosita'"],
|
||||||
|
)
|
||||||
|
n2, m2 = await _storico(session, rng, fine, owner, stazione, utenti, r2, v2, sub2,
|
||||||
|
ALBERO, (8, 11), "LA", "AL")
|
||||||
|
await _ricetta_dxf(session, owner, stazione)
|
||||||
|
print(f"FIERA-FLANGIA: {n1} pezzi, {m1} misure; FIERA-ALBERO: {n2} pezzi, {m2} misure")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
await session.rollback()
|
||||||
|
print("--dry-run: nessuna modifica salvata.")
|
||||||
|
else:
|
||||||
|
await session.commit()
|
||||||
|
print(f"Seed fiera completato (stazione {stazione.code}, utenti: "
|
||||||
|
f"{', '.join(u[0] for u in UTENTI)}).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _factory_per_file(db: Path):
|
||||||
|
"""Sessioni async su un file SQLite qualsiasi, con le stesse PRAGMA dell'app (FK attive)."""
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{db.resolve().as_posix()}", echo=False)
|
||||||
|
|
||||||
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def _pragma(dbapi_connection, _record) -> None:
|
||||||
|
cur = dbapi_connection.cursor()
|
||||||
|
cur.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cur.execute("PRAGMA busy_timeout=5000")
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _main_async(args) -> int:
|
||||||
|
global async_session_factory
|
||||||
|
engine, async_session_factory = _factory_per_file(args.db)
|
||||||
|
try:
|
||||||
|
fine = date.fromisoformat(args.fine) if args.fine else date.today() - timedelta(days=1)
|
||||||
|
return await seed(args.replace, args.dry_run, fine)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--db", required=True, type=Path, help="file SQLite con lo schema gia' creato")
|
||||||
|
parser.add_argument("--replace", action="store_true",
|
||||||
|
help="rimuove i dati FIERA-* (ricette, misure, produzioni) e li rifa'")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="esegue tutto e annulla")
|
||||||
|
parser.add_argument("--fine", default=None, help="ultimo giorno dello storico AAAA-MM-GG (default: ieri)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.db.is_file():
|
||||||
|
print(f"File non trovato: {args.db}")
|
||||||
|
return 2
|
||||||
|
return asyncio.run(_main_async(args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Test di scripts/archivio_sorgente.py (TieMeasureFlow#109): repo temporaneo con un submodule finto.
|
||||||
|
|
||||||
|
Uso: uv run --no-project --python 3.13 --with pytest pytest -q scripts/tests/test_archivio_sorgente.py
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import subprocess
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"archivio_sorgente", Path(__file__).resolve().parents[1] / "archivio_sorgente.py")
|
||||||
|
archivio = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(archivio)
|
||||||
|
|
||||||
|
GIT = ["git", "-c", "core.autocrlf=false", "-c", "protocol.file.allow=always",
|
||||||
|
"-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]
|
||||||
|
|
||||||
|
|
||||||
|
def _g(cartella, *args):
|
||||||
|
r = subprocess.run([*GIT, "-C", str(cartella), *args], capture_output=True, text=True)
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _repo(tmp_path, nome):
|
||||||
|
d = tmp_path / nome
|
||||||
|
d.mkdir()
|
||||||
|
_g(d, "init", "-q", "-b", "main")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def padre(tmp_path):
|
||||||
|
"""Repo padre con vendor/visionsuite registrato (submodule finto da un repo locale)."""
|
||||||
|
origine = _repo(tmp_path, "origine")
|
||||||
|
(origine / "motore.py").write_text("x = 1\n")
|
||||||
|
_g(origine, "add", ".")
|
||||||
|
_g(origine, "commit", "-q", "-m", "motore")
|
||||||
|
p = _repo(tmp_path, "padre")
|
||||||
|
(p / "app.py").write_text("print('tmf')\n")
|
||||||
|
_g(p, "add", ".")
|
||||||
|
_g(p, "commit", "-q", "-m", "app")
|
||||||
|
_g(p, "submodule", "add", "-q", str(origine), "vendor/visionsuite")
|
||||||
|
_g(p, "commit", "-q", "-m", "submodule")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_zip_con_repo_submodule_e_versione(padre, tmp_path):
|
||||||
|
atteso = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
|
||||||
|
out = tmp_path / "o" / "tmf.zip"
|
||||||
|
assert archivio.crea_archivio(padre, out) == atteso
|
||||||
|
with zipfile.ZipFile(out) as z:
|
||||||
|
nomi = z.namelist()
|
||||||
|
assert "app.py" in nomi and "vendor/visionsuite/motore.py" in nomi
|
||||||
|
dati = z.read("vision-engine-version.txt")
|
||||||
|
assert dati == atteso.encode("ascii") and len(dati) == 40
|
||||||
|
assert not dati.startswith(b"\xef\xbb\xbf") and b"\n" not in dati # niente BOM, niente a capo
|
||||||
|
|
||||||
|
|
||||||
|
def test_sha_resta_quello_registrato_se_il_submodule_avanza(padre, tmp_path):
|
||||||
|
registrato = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
|
||||||
|
sub = padre / "vendor" / "visionsuite"
|
||||||
|
(sub / "nuovo.py").write_text("y = 2\n")
|
||||||
|
_g(sub, "add", ".")
|
||||||
|
_g(sub, "commit", "-q", "-m", "avanti non registrato")
|
||||||
|
assert _g(sub, "rev-parse", "HEAD") != registrato
|
||||||
|
out = tmp_path / "tmf.zip"
|
||||||
|
assert archivio.crea_archivio(padre, out) == registrato
|
||||||
|
with zipfile.ZipFile(out) as z:
|
||||||
|
assert z.read("vision-engine-version.txt").decode() == registrato
|
||||||
|
assert "vendor/visionsuite/nuovo.py" not in z.namelist() # contenuto del commit registrato
|
||||||
|
|
||||||
|
|
||||||
|
def test_submodule_non_inizializzato_errore_e_nessuno_zip(padre, tmp_path):
|
||||||
|
clone = tmp_path / "clone"
|
||||||
|
_g(tmp_path, "clone", "-q", str(padre), str(clone)) # senza --recurse-submodules
|
||||||
|
out = tmp_path / "tmf.zip"
|
||||||
|
with pytest.raises(archivio.ErroreArchivio, match="inizializzato"):
|
||||||
|
archivio.crea_archivio(clone, out)
|
||||||
|
assert not out.exists() and not list(tmp_path.glob("*.tmp")) # nessuno zip ne' temporaneo
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_esce_1_con_messaggio(padre, tmp_path, capsys, monkeypatch):
|
||||||
|
clone = tmp_path / "clone"
|
||||||
|
_g(tmp_path, "clone", "-q", str(padre), str(clone))
|
||||||
|
originale = archivio.crea_archivio
|
||||||
|
# la CLI lavora sul repo che contiene lo script: la si dirotta sul clone senza submodule
|
||||||
|
monkeypatch.setattr(archivio, "crea_archivio", lambda r, o, ref="HEAD": originale(clone, o, ref))
|
||||||
|
assert archivio.main(["--out", str(tmp_path / "x.zip")]) == 1
|
||||||
|
assert "ERRORE" in capsys.readouterr().err
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""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]
|
||||||
|
# #111: il lato dell'anteprima segue lo zoom (latoAnteprima(), 1280 senza zoom)
|
||||||
|
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(latoAnteprima())")
|
||||||
|
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)")
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""Test della calibrazione dell'acquisitore (#83): logica pura e rotte di camera_service.
|
||||||
|
|
||||||
|
Uso: dalla radice, `uv run --python 3.13 --extra vision-worker --extra dev
|
||||||
|
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q`
|
||||||
|
(o il venv di VisionSuite come per test_camera_service.py).
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||||
|
MODULO = SCRIPTS / "camera_service.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _carica():
|
||||||
|
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = 60) -> np.ndarray:
|
||||||
|
"""nx x ny quadrati => (nx-1) x (ny-1) angoli interni, su fondo bianco."""
|
||||||
|
img = np.full((ny * square_px + 2 * margin, nx * square_px + 2 * margin), 255, np.uint8)
|
||||||
|
for j in range(ny):
|
||||||
|
for i in range(nx):
|
||||||
|
if (i + j) % 2 == 0:
|
||||||
|
y, x = margin + j * square_px, margin + i * square_px
|
||||||
|
img[y:y + square_px, x:x + square_px] = 0
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def una_scacchiera_densa(w: int = 640, h: int = 480, square_px: int = 40, k: float = 1e-8) -> np.ndarray:
|
||||||
|
"""#84: scacchiera che riempie tutto il campo, con distorsione a barilotto (k sui px dal centro)."""
|
||||||
|
m = 3 * square_px # la scacchiera continua oltre il campo: nessun bordo finto nell'immagine
|
||||||
|
yy, xx = np.mgrid[0:h + 2 * m, 0:w + 2 * m]
|
||||||
|
piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8)
|
||||||
|
cx, cy = w / 2.0, h / 2.0
|
||||||
|
dx, dy = (xx[m:m + h, m:m + w] - m - cx).astype(np.float32), (yy[m:m + h, m:m + w] - m - cy).astype(np.float32)
|
||||||
|
f = 1 + k * (dx * dx + dy * dy)
|
||||||
|
img = cv2.remap(piana, cx + m + dx * f, cy + m + dy * f, cv2.INTER_LINEAR)
|
||||||
|
return cv2.GaussianBlur(img, (3, 3), 0.8) # bordi non perfettamente netti, come in camera
|
||||||
|
|
||||||
|
|
||||||
|
def residuo_reticolo(griglia: np.ndarray) -> float:
|
||||||
|
"""Residuo massimo (px) della griglia (nr,nc,2) da un reticolo affine regolare."""
|
||||||
|
nr, nc, _ = griglia.shape
|
||||||
|
ii, jj = np.mgrid[0:nr, 0:nc]
|
||||||
|
A = np.stack([np.ones(nr * nc), ii.ravel(), jj.ravel()], axis=1)
|
||||||
|
P = griglia.reshape(-1, 2)
|
||||||
|
coef, *_ = np.linalg.lstsq(A, P, rcond=None)
|
||||||
|
return float(np.hypot(*(A @ coef - P).T).max())
|
||||||
|
|
||||||
|
|
||||||
|
def _png(img: np.ndarray) -> bytes:
|
||||||
|
ok, buf = cv2.imencode(".png", img)
|
||||||
|
assert ok
|
||||||
|
return buf.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cartella(tmp_path):
|
||||||
|
# la sorgente camera:folder fa il loop su una scacchiera: il grab la trova
|
||||||
|
cartella = tmp_path / "immagini"
|
||||||
|
cartella.mkdir()
|
||||||
|
cv2.imwrite(str(cartella / "scacchiera.png"), una_scacchiera())
|
||||||
|
return cartella
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(cartella, tmp_path):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella, config_dir=tmp_path / "config")) as c:
|
||||||
|
c.config = tmp_path / "config"
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
|
||||||
|
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", "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
|
||||||
|
def cartella_densa(tmp_path):
|
||||||
|
# #84: la sorgente camera:folder fa il loop su una scacchiera densa distorta
|
||||||
|
cartella = tmp_path / "immagini_dense"
|
||||||
|
cartella.mkdir()
|
||||||
|
cv2.imwrite(str(cartella / "densa.png"), una_scacchiera_densa(k=1e-7))
|
||||||
|
return cartella
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_denso(cartella_densa, tmp_path):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella_densa, config_dir=tmp_path / "config_densa")) as c:
|
||||||
|
c.config = tmp_path / "config_densa"
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
NON_IMMAGINE = b"questo non e una immagine"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- logica pura
|
||||||
|
|
||||||
|
def test_scacchiera_sintetica_da_la_scala():
|
||||||
|
_carica() # mette scripts/ in sys.path
|
||||||
|
import calibrazione
|
||||||
|
r = calibrazione.calibra_scacchiera(una_scacchiera(), 9, 6, 2.0)
|
||||||
|
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||||
|
assert r["mm_per_px"] == pytest.approx(0.05, rel=0.005)
|
||||||
|
assert r["rms_px"] < 0.5
|
||||||
|
assert r["n_corners"] == 54
|
||||||
|
assert (r["image_width"], r["image_height"]) == (520, 400)
|
||||||
|
assert r["scale_spread_pct"] < 1.0
|
||||||
|
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_immagine_bianca_solleva():
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
|
||||||
|
calibrazione.calibra_scacchiera(np.full((300, 300), 255, np.uint8), 9, 6, 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cols,rows,quadrato", [(1, 6, 2.0), (9, 6, 0.0), (9, 6, -1.0)])
|
||||||
|
def test_parametri_invalidi_sollevano(cols, rows, quadrato):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
with pytest.raises(calibrazione.CalibrazioneError):
|
||||||
|
calibrazione.calibra_scacchiera(una_scacchiera(), cols, rows, quadrato)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- rotte
|
||||||
|
|
||||||
|
def test_c1_non_calibrata_404(client):
|
||||||
|
r = client.get("/camera/calibration", params={"source": "camera:folder"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json()["detail"] == "sorgente non calibrata"
|
||||||
|
|
||||||
|
|
||||||
|
def test_c1_senza_source_400(client):
|
||||||
|
assert client.get("/camera/calibration").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_immagine_caricata_senza_salvare(client):
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||||
|
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
corpo = r.json()
|
||||||
|
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["origine"] == "file:s.png"
|
||||||
|
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||||
|
# 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.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)
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
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 | 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" 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
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_scacchiera_non_trovata_422(client):
|
||||||
|
bianca = np.full((300, 300), 255, np.uint8)
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||||
|
files={"image": ("b.png", _png(bianca), "image/png")})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert "non trovata" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_parametri_invalidi_422(client):
|
||||||
|
r = client.post("/camera/calibrate",
|
||||||
|
data={"source": "camera:folder", "cols": "1", "rows": "6", "square_mm": "2"},
|
||||||
|
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_immagine_illeggibile_422(client):
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||||
|
files={"image": ("x.png", NON_IMMAGINE, "image/png")})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_errori_camera_come_grab(client):
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:non-esiste", **CAMPI})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
r = client.get("/calibrazione")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"].startswith("text/html")
|
||||||
|
assert "Acquisisci e calibra" in r.text
|
||||||
|
assert "Lato dello scacco" in r.text and 'id="cols"' not in r.text # #84: un solo campo
|
||||||
|
assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN
|
||||||
|
|
||||||
|
|
||||||
|
# ---- #84: calibrazione densa (logica pura)
|
||||||
|
|
||||||
|
def test_densa_trova_la_griglia_da_sola_e_la_scala():
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
r, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(k=1e-8), 2.0)
|
||||||
|
assert (r["cols"], r["rows"]) == (15, 11) # 640x480 con quadrati da 40 px
|
||||||
|
assert r["n_corners"] == 15 * 11
|
||||||
|
assert r["mm_per_px"] == pytest.approx(2.0 / 40, rel=0.005)
|
||||||
|
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||||
|
assert r["metodo"] == "densa" and r["distorsione"] is True
|
||||||
|
assert r["residuo_max_px"] < 0.3 and r["residuo_rms_px"] <= r["residuo_max_px"]
|
||||||
|
assert 60 < r["copertura_pct"] <= 100
|
||||||
|
assert (r["image_width"], r["image_height"]) == (640, 480)
|
||||||
|
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
assert mappa.shape == (480, 640) and mappa.pixel_size == pytest.approx(0.05, rel=0.005)
|
||||||
|
|
||||||
|
|
||||||
|
def test_densa_raddrizza_su_griglia_regolare():
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
img = una_scacchiera_densa(k=1e-7) # distorsione marcata: ai bordi ~5 %
|
||||||
|
r, mappa = calibrazione.calibra_scacchiera_densa(img, 2.0)
|
||||||
|
assert (r["cols"], r["rows"]) == (15, 11)
|
||||||
|
# prima la griglia e' curva, dopo e' un reticolo regolare
|
||||||
|
assert residuo_reticolo(calibrazione._trova_griglia(img)) > 1.0
|
||||||
|
pronta = calibrazione._prepara_mappa(mappa)
|
||||||
|
dritta = calibrazione.raddrizza(img, pronta)
|
||||||
|
assert dritta.shape == img.shape
|
||||||
|
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cosa", ["bianca", "scacchiera_piccola"])
|
||||||
|
def test_densa_senza_griglia_solleva(cosa):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
img = (np.full((300, 300), 255, np.uint8) if cosa == "bianca"
|
||||||
|
else una_scacchiera_densa(80, 60, 20)) # 3x2 angoli: meno di 3x3
|
||||||
|
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
|
||||||
|
calibrazione.calibra_scacchiera_densa(img, 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("quadrato", [0.0, -1.0, float("nan")])
|
||||||
|
def test_densa_quadrato_invalido_solleva(quadrato):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
with pytest.raises(calibrazione.CalibrazioneError):
|
||||||
|
calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), quadrato)
|
||||||
|
|
||||||
|
|
||||||
|
def test_archivio_mappa_cache_e_invalidazione(tmp_path):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
_, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)
|
||||||
|
arch = calibrazione.Archivio(tmp_path)
|
||||||
|
assert arch.leggi_mappa("s") is None
|
||||||
|
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.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_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
|
||||||
|
|
||||||
|
def _grigio(r) -> np.ndarray:
|
||||||
|
return cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
|
||||||
|
|
||||||
|
|
||||||
|
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", "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 not client_denso.config.exists() or not list(client_denso.config.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_solo_cols_o_solo_rows_422(client_denso):
|
||||||
|
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "cols": "9"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_raddrizzato_se_calibrata_e_grezzo_a_richiesta(client_denso, cartella_densa):
|
||||||
|
originale = cv2.imread(str(cartella_densa / "densa.png"), cv2.IMREAD_GRAYSCALE)
|
||||||
|
prima = client_denso.post("/camera/grab", json={"source": SORGENTE})
|
||||||
|
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"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
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 | 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"
|
||||||
|
import calibrazione
|
||||||
|
dritta = _grigio(dopo)
|
||||||
|
assert dritta.shape == originale.shape
|
||||||
|
assert not np.array_equal(dritta, originale)
|
||||||
|
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 # griglia regolare
|
||||||
|
|
||||||
|
grezzo = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": True})
|
||||||
|
assert grezzo.headers["x-raddrizzato"] == "no"
|
||||||
|
assert np.array_equal(_grigio(grezzo), originale)
|
||||||
|
|
||||||
|
# la calibrazione parte sempre dal frame grezzo: ricalibrare dalla sorgente da' lo stesso mm/px
|
||||||
|
di_nuovo = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||||
|
assert di_nuovo.json()["mm_per_px"] == pytest.approx(r.json()["mm_per_px"])
|
||||||
|
assert di_nuovo.json()["residuo_max_px"] < 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_grezzo_non_booleano_400(client_denso):
|
||||||
|
r = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": "si"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
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 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):
|
||||||
|
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 salva_con_nome(client_denso, planare.json(), "Cal").status_code == 409 # nome gia' preso
|
||||||
|
assert salva_con_nome(client_denso, planare.json(), "Cal", sovrascrivi=True).status_code == 201
|
||||||
|
assert not list(libreria.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto
|
||||||
|
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""Test dell'archivio unico di calibrazioni con nome (#91): scripts/calibrazione.py, classe Archivio.
|
||||||
|
|
||||||
|
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||||
|
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||||
|
Solo tmp_path: niente ProgramData, niente camera.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_calibrazione import _carica, una_scacchiera_densa
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def mappa_densa():
|
||||||
|
# #91: una mappa vera, calcolata una volta sola per tutti i test del modulo
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
return calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _dati(**extra):
|
||||||
|
base = {"px_per_mm": 20.0, "mm_per_px": 0.05, "image_width": 640, "image_height": 480,
|
||||||
|
"metodo": "planare", "square_mm": 2.0, "calibrated_at": "2026-10-24T10:00:00+00:00",
|
||||||
|
"origine": "file:s.png"}
|
||||||
|
return {**base, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cal():
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
return calibrazione
|
||||||
|
|
||||||
|
|
||||||
|
# ---- nomi
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x00b", "a\nb", "a\tb", None, 5])
|
||||||
|
def test_nome_non_valido(cal, tmp_path, nome):
|
||||||
|
with pytest.raises(cal.NomeNonValido):
|
||||||
|
cal.Archivio(tmp_path).salva_nome(nome, _dati())
|
||||||
|
|
||||||
|
|
||||||
|
def test_nome_valido_limiti_e_strip(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("x" * 60, _dati())
|
||||||
|
meta = arch.salva_nome(" Banco 1 ", _dati())
|
||||||
|
assert meta["nome"] == "Banco 1" # strip
|
||||||
|
assert arch.leggi_nome("Banco 1")["nome"] == "Banco 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nome_unico_senza_maiuscole(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
|
||||||
|
with pytest.raises(cal.NomeGiaUsato, match="Banco"):
|
||||||
|
arch.salva_nome("BANCO", _dati(px_per_mm=2.0))
|
||||||
|
assert arch.leggi_nome("banco")["px_per_mm"] == 1.0 # non toccata
|
||||||
|
assert len(arch.elenco()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sovrascrivi_sostituisce_e_prende_il_nuovo_nome(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
|
||||||
|
arch.salva_nome("BANCO", _dati(px_per_mm=2.0), sovrascrivi=True)
|
||||||
|
voci = arch.elenco()
|
||||||
|
assert len(voci) == 1 and voci[0]["nome"] == "BANCO" and voci[0]["px_per_mm"] == 2.0
|
||||||
|
assert len(list((tmp_path / "calibrazioni").glob("*.json"))) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_salva_non_scrive_source_ne_overlay(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("A", _dati(source="camera:x", overlay_png="abc", saved=True))
|
||||||
|
scritto = json.loads(next((tmp_path / "calibrazioni").glob("*.json")).read_text(encoding="utf-8"))
|
||||||
|
assert "source" not in scritto and "overlay_png" not in scritto and "saved" not in scritto
|
||||||
|
assert scritto["nome"] == "A" and scritto["origine"] == "file:s.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leggi_nome_inesistente_o_non_valido(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
assert arch.leggi_nome("nessuna") is None and arch.leggi_nome("") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- elenco e attive
|
||||||
|
|
||||||
|
def test_elenco_dalla_piu_recente_e_attiva_su(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("vecchia", _dati(calibrated_at="2026-01-01T00:00:00+00:00"))
|
||||||
|
arch.salva_nome("nuova", _dati(calibrated_at="2026-10-01T00:00:00+00:00"))
|
||||||
|
arch.attiva("camera:b", "nuova")
|
||||||
|
arch.attiva("camera:a", "nuova")
|
||||||
|
voci = arch.elenco()
|
||||||
|
assert [v["nome"] for v in voci] == ["nuova", "vecchia"]
|
||||||
|
assert voci[0]["attiva_su"] == ["camera:a", "camera:b"] and voci[1]["attiva_su"] == []
|
||||||
|
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm",
|
||||||
|
"mm_per_px", "metodo", "square_mm", "calibrated_at", "attiva_su"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_attiva_leggi_scollega(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("Banco", _dati())
|
||||||
|
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
|
||||||
|
assert arch.attiva("camera:a", "banco") == "Banco" # nome come in libreria
|
||||||
|
assert arch.attiva_di("camera:a") == "Banco"
|
||||||
|
letta = arch.leggi("camera:a")
|
||||||
|
assert letta["nome"] == "Banco" and letta["source"] == "camera:a" and letta["px_per_mm"] == 20.0
|
||||||
|
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:a": "Banco"}
|
||||||
|
arch.scollega("camera:a")
|
||||||
|
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
|
||||||
|
assert arch.leggi_nome("Banco") is not None # la libreria resta
|
||||||
|
arch.scollega("camera:a") # idempotente
|
||||||
|
|
||||||
|
|
||||||
|
def test_attiva_nome_sconosciuto(cal, tmp_path):
|
||||||
|
with pytest.raises(cal.NomeSconosciuto):
|
||||||
|
cal.Archivio(tmp_path).attiva("camera:a", "nessuna")
|
||||||
|
|
||||||
|
|
||||||
|
def test_due_sorgenti_stessa_calibrazione(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("Comune", _dati())
|
||||||
|
arch.attiva("camera:a", "Comune")
|
||||||
|
arch.attiva("camera:b", "Comune")
|
||||||
|
assert arch.leggi("camera:a")["source"] == "camera:a" and arch.leggi("camera:b")["source"] == "camera:b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_elimina_nome_toglie_da_attive_e_mappa(cal, tmp_path, mappa_densa):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
|
||||||
|
arch.salva_nome("Altra", _dati())
|
||||||
|
arch.attiva("camera:a", "Densa")
|
||||||
|
arch.attiva("camera:b", "Densa")
|
||||||
|
arch.attiva("camera:c", "Altra")
|
||||||
|
assert arch.leggi_mappa("camera:a") is not None
|
||||||
|
assert arch.elimina_nome("DENSA") is True
|
||||||
|
assert arch.leggi_nome("Densa") is None and not list((tmp_path / "calibrazioni").glob("*.npz"))
|
||||||
|
assert arch.attiva_di("camera:a") is None and arch.attiva_di("camera:b") is None
|
||||||
|
assert arch.leggi_mappa("camera:a") is None
|
||||||
|
assert arch.attiva_di("camera:c") == "Altra"
|
||||||
|
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:c": "Altra"}
|
||||||
|
assert arch.elimina_nome("Densa") is False # gia' tolta
|
||||||
|
|
||||||
|
|
||||||
|
# ---- mappa
|
||||||
|
|
||||||
|
def test_leggi_mappa_risolve_l_attiva_con_cache(cal, tmp_path, mappa_densa):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
assert arch.leggi_mappa("camera:a") is None
|
||||||
|
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
|
||||||
|
assert arch.leggi_mappa("camera:a") is None # non attiva su camera:a
|
||||||
|
arch.attiva("camera:a", "Densa")
|
||||||
|
prima = arch.leggi_mappa("camera:a")
|
||||||
|
assert prima is not None and arch.leggi_mappa("camera:a") is prima # cache in memoria
|
||||||
|
assert arch.leggi_mappa("camera:b") is None
|
||||||
|
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
|
||||||
|
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa, sovrascrivi=True) # file riscritto
|
||||||
|
assert arch.leggi_mappa("camera:a") is not prima # cache invalidata
|
||||||
|
arch.salva_nome("Densa", _dati(), sovrascrivi=True) # sovrascritta con una planare: la mappa sparisce
|
||||||
|
assert arch.leggi_mappa("camera:a") is None and not list(tmp_path.rglob("*.npz"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- migrazione dal formato vecchio
|
||||||
|
|
||||||
|
def _vecchia(cartella, cal, source, mappa=None, **extra):
|
||||||
|
"""Scrive una calibrazione nel formato vecchio (un JSON per sorgente, + .npz) come faceva Archivio.salva."""
|
||||||
|
dati = {"source": source, **_dati(**extra)}
|
||||||
|
dati.pop("origine")
|
||||||
|
nudo = cal.Archivio.__new__(cal.Archivio) # senza costruttore: niente migrazione mentre si prepara
|
||||||
|
nudo.cartella = cartella
|
||||||
|
percorso = nudo._file(source)
|
||||||
|
cartella.mkdir(parents=True, exist_ok=True)
|
||||||
|
percorso.write_text(json.dumps(dati), encoding="utf-8")
|
||||||
|
if mappa is not None:
|
||||||
|
mappa.save(str(percorso.with_suffix(".npz")))
|
||||||
|
return percorso
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrazione_formato_vecchio(cal, tmp_path, mappa_densa):
|
||||||
|
v1 = _vecchia(tmp_path, cal, "camera:601020", mappa_densa, metodo="densa", px_per_mm=7.0)
|
||||||
|
v2 = _vecchia(tmp_path, cal, "camera:folder", px_per_mm=9.0)
|
||||||
|
(tmp_path / "settaggi").mkdir()
|
||||||
|
(tmp_path / "settaggi" / v1.name).write_text("{}", encoding="utf-8") # non si tocca
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
assert not v1.exists() and not v1.with_suffix(".npz").exists() and not v2.exists()
|
||||||
|
assert {v["nome"] for v in arch.elenco()} == {"camera:601020", "camera:folder"}
|
||||||
|
assert arch.attiva_di("camera:601020") == "camera:601020" and arch.attiva_di("camera:folder") == "camera:folder"
|
||||||
|
letta = arch.leggi("camera:601020")
|
||||||
|
assert letta["px_per_mm"] == 7.0 and letta["source"] == "camera:601020"
|
||||||
|
assert arch.leggi_mappa("camera:601020") is not None # il .npz ha seguito il JSON
|
||||||
|
assert len(list((tmp_path / "calibrazioni").glob("*.npz"))) == 1
|
||||||
|
assert (tmp_path / "settaggi" / v1.name).read_text(encoding="utf-8") == "{}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrazione_idempotente(cal, tmp_path):
|
||||||
|
_vecchia(tmp_path, cal, "camera:a")
|
||||||
|
cal.Archivio(tmp_path)
|
||||||
|
prima = sorted(p.name for p in tmp_path.rglob("*") if p.is_file())
|
||||||
|
arch = cal.Archivio(tmp_path) # secondo avvio
|
||||||
|
arch.migra()
|
||||||
|
assert sorted(p.name for p in tmp_path.rglob("*") if p.is_file()) == prima
|
||||||
|
assert [v["nome"] for v in arch.elenco()] == ["camera:a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrazione_nome_gia_preso(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("camera:a", _dati(px_per_mm=1.0, calibrated_at="2020-01-01T00:00:00+00:00"))
|
||||||
|
_vecchia(tmp_path, cal, "camera:a", px_per_mm=5.0)
|
||||||
|
arch.migra()
|
||||||
|
nomi = {v["nome"]: v["px_per_mm"] for v in arch.elenco()}
|
||||||
|
assert nomi == {"camera:a": 1.0, "camera:a (2)": 5.0}
|
||||||
|
assert arch.attiva_di("camera:a") == "camera:a (2)" # la sorgente usa la sua, non quella gia' presente
|
||||||
|
# un altro file con lo stesso nome -> (3)
|
||||||
|
_vecchia(tmp_path, cal, "camera:A", px_per_mm=6.0) # nome uguale senza maiuscole
|
||||||
|
arch.migra()
|
||||||
|
assert "camera:A (3)" in {v["nome"] for v in arch.elenco()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrazione_ignora_json_senza_source_e_attive(cal, tmp_path):
|
||||||
|
tmp_path.mkdir(exist_ok=True)
|
||||||
|
(tmp_path / "altro.json").write_text(json.dumps({"x": 1}), encoding="utf-8")
|
||||||
|
(tmp_path / "rotto.json").write_text("{non json", encoding="utf-8")
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("A", _dati())
|
||||||
|
arch.attiva("source", "A") # una sorgente chiamata «source» non deve far scambiare attive.json per un vecchio file
|
||||||
|
arch.migra()
|
||||||
|
assert (tmp_path / "altro.json").exists() and (tmp_path / "rotto.json").exists()
|
||||||
|
assert arch.attiva_di("source") == "A" and [v["nome"] for v in arch.elenco()] == ["A"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cartella_assente_non_da_errore(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path / "non" / "esiste")
|
||||||
|
assert arch.elenco() == [] and arch.leggi("s") is None and arch.leggi_mappa("s") is None
|
||||||
|
arch.scollega("s")
|
||||||
|
assert arch.elimina_nome("x") is False
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""Test delle rotte dell'archivio unico di calibrazioni con nome (#91): scripts/camera_service.py.
|
||||||
|
|
||||||
|
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||||
|
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||||
|
Sorgenti: camera:folder su cartelle temporanee (520x400 scacchiera piana, 640x480 densa).
|
||||||
|
Solo tmp_path: niente ProgramData, niente camera vera.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
|
||||||
|
CAMPI, CHIAVI_ATTIVA, CHIAVI_DENSA, CHIAVI_PLANARE, SORGENTE, _carica, _png,
|
||||||
|
carica_su, cartella, cartella_densa, client, client_denso, salva_con_nome,
|
||||||
|
una_scacchiera, una_scacchiera_densa,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["cartella", "cartella_densa", "client", "client_denso"] # le fixture importate restano usate
|
||||||
|
|
||||||
|
|
||||||
|
def _calcola_file(c, **extra):
|
||||||
|
"""Calcolo planare da un'immagine (520x400): ritorna la risposta JSON."""
|
||||||
|
r = c.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI, **extra},
|
||||||
|
files={"image": ("scacco.png", _png(una_scacchiera()), "image/png")})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- POST /camera/calibrate
|
||||||
|
|
||||||
|
def test_calibrate_senza_source_con_immagine_ok(client):
|
||||||
|
r = client.post("/camera/calibrate", data=CAMPI,
|
||||||
|
files={"image": ("C:\\foto\\scacco.png", _png(una_scacchiera()), "image/png")})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["origine"] == "file:scacco.png" # solo il nome del file, non il percorso
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_senza_source_e_senza_immagine_400(client):
|
||||||
|
r = client.post("/camera/calibrate", data=CAMPI)
|
||||||
|
assert r.status_code == 400 and "source" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_da_sorgente_ha_origine_camera_e_id(client):
|
||||||
|
r = client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
corpo = r.json()
|
||||||
|
assert corpo["origine"] == SORGENTE and corpo["calibrated_at"]
|
||||||
|
assert len(corpo["calcolo_id"]) == 32 and int(corpo["calcolo_id"], 16) >= 0 # esadecimale
|
||||||
|
assert corpo["calcolo_id"] != client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI}).json()["calcolo_id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- POST /camera/calibrazioni
|
||||||
|
|
||||||
|
def test_salva_201_con_metadati(client):
|
||||||
|
calcolo = _calcola_file(client)
|
||||||
|
r = salva_con_nome(client, calcolo, " Banco 1 ")
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
meta = r.json()
|
||||||
|
assert meta["nome"] == "Banco 1" and meta["origine"] == "file:scacco.png"
|
||||||
|
assert (meta["image_width"], meta["image_height"]) == (520, 400)
|
||||||
|
assert meta["metodo"] == "planare" and meta["attiva_su"] == []
|
||||||
|
assert meta["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||||
|
# lo stesso calcolo si puo' salvare anche con un altro nome
|
||||||
|
assert salva_con_nome(client, calcolo, "Banco 2").status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_salva_calcolo_sconosciuto_404(client):
|
||||||
|
r = client.post("/camera/calibrazioni", json={"calcolo_id": "0" * 32, "nome": "X"})
|
||||||
|
assert r.status_code == 404 and r.json()["detail"] == "calcolo non più disponibile: ricalcolare"
|
||||||
|
|
||||||
|
|
||||||
|
def test_salva_calcolo_scaduto_dopo_otto_calcoli_404(client):
|
||||||
|
primo = _calcola_file(client)
|
||||||
|
ultimo = primo
|
||||||
|
for _ in range(8): # il nono calcolo spinge fuori il primo
|
||||||
|
ultimo = _calcola_file(client)
|
||||||
|
assert salva_con_nome(client, primo, "Vecchio").status_code == 404
|
||||||
|
assert salva_con_nome(client, ultimo, "Nuovo").status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x01b", None, 7])
|
||||||
|
def test_salva_nome_non_valido_400(client, nome):
|
||||||
|
r = client.post("/camera/calibrazioni", json={"calcolo_id": _calcola_file(client)["calcolo_id"], "nome": nome})
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_salva_corpo_incompleto_400(client):
|
||||||
|
assert client.post("/camera/calibrazioni", json={"nome": "X"}).status_code == 400
|
||||||
|
calcolo = _calcola_file(client)
|
||||||
|
r = client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": "X", "sovrascrivi": "si"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_salva_nome_gia_usato_409_e_sovrascrivi(client):
|
||||||
|
a = _calcola_file(client)
|
||||||
|
assert salva_con_nome(client, a, "Banco").status_code == 201
|
||||||
|
r = salva_con_nome(client, a, "BANCO") # senza distinguere maiuscole
|
||||||
|
assert r.status_code == 409 and r.json()["detail"] == "esiste già una calibrazione «Banco»"
|
||||||
|
assert len(client.get("/camera/calibrazioni").json()["calibrazioni"]) == 1
|
||||||
|
r = salva_con_nome(client, a, "BANCO", sovrascrivi=True)
|
||||||
|
assert r.status_code == 201 and r.json()["nome"] == "BANCO"
|
||||||
|
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
|
||||||
|
assert [v["nome"] for v in voci] == ["BANCO"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- GET /camera/calibrazioni
|
||||||
|
|
||||||
|
def test_elenco_vuoto_e_ordine(client):
|
||||||
|
assert client.get("/camera/calibrazioni").json() == {"calibrazioni": [], "attiva": None}
|
||||||
|
prima = _calcola_file(client)
|
||||||
|
salva_con_nome(client, prima, "prima")
|
||||||
|
seconda = _calcola_file(client)
|
||||||
|
seconda_ts = seconda["calibrated_at"]
|
||||||
|
salva_con_nome(client, seconda, "seconda")
|
||||||
|
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
|
||||||
|
assert {v["nome"] for v in voci} == {"prima", "seconda"}
|
||||||
|
assert [v["calibrated_at"] for v in voci] == sorted((v["calibrated_at"] for v in voci), reverse=True)
|
||||||
|
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm", "mm_per_px",
|
||||||
|
"metodo", "square_mm", "calibrated_at", "attiva_su"}
|
||||||
|
assert seconda_ts >= prima["calibrated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_elenco_con_source_dice_l_attiva(client):
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Altra")
|
||||||
|
assert carica_su(client, SORGENTE, "banco").status_code == 200
|
||||||
|
r = client.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
|
||||||
|
assert r["attiva"] == "Banco"
|
||||||
|
attiva_su = {v["nome"]: v["attiva_su"] for v in r["calibrazioni"]}
|
||||||
|
assert attiva_su == {"Banco": [SORGENTE], "Altra": []}
|
||||||
|
assert client.get("/camera/calibrazioni", params={"source": "camera:altra"}).json()["attiva"] is None
|
||||||
|
assert client.get("/camera/calibrazioni", params={"source": " "}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---- DELETE /camera/calibrazioni
|
||||||
|
|
||||||
|
def test_elimina_204_404_e_toglie_dalle_sorgenti(client):
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||||
|
carica_su(client, SORGENTE, "Banco")
|
||||||
|
assert client.delete("/camera/calibrazioni", params={"nome": "BANCO"}).status_code == 204
|
||||||
|
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
|
||||||
|
assert client.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # non piu' attiva
|
||||||
|
assert json.loads((client.config / "attive.json").read_text(encoding="utf-8")) == {}
|
||||||
|
assert client.delete("/camera/calibrazioni", params={"nome": "Banco"}).status_code == 404
|
||||||
|
assert client.delete("/camera/calibrazioni").status_code == 404 # senza nome
|
||||||
|
|
||||||
|
|
||||||
|
# ---- PUT /camera/calibrazione-attiva
|
||||||
|
|
||||||
|
def test_carica_risoluzione_uguale_200_come_get(client):
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Banco") # 520x400 = la scacchiera in cartella
|
||||||
|
r = carica_su(client, SORGENTE, "banco")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert set(r.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA
|
||||||
|
assert r.json()["nome"] == "Banco" and r.json()["source"] == SORGENTE
|
||||||
|
assert client.get("/camera/calibration", params={"source": SORGENTE}).json() == r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_risoluzione_diversa_409_e_anteprima_continua(client_denso):
|
||||||
|
# calibrazione da file 520x400, sorgente 640x480
|
||||||
|
salva_con_nome(client_denso, _calcola_file(client_denso), "Piccola")
|
||||||
|
r = carica_su(client_denso, SORGENTE, "Piccola")
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"] == ("la calibrazione «Piccola» è per immagini 520×400, "
|
||||||
|
"la sorgente dà 640×480: non si può caricare")
|
||||||
|
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # nulla attivato
|
||||||
|
# l'acquisizione continua: grab e anteprima rispondono, frame non raddrizzato
|
||||||
|
g = client_denso.post("/camera/grab", json={"source": SORGENTE})
|
||||||
|
assert g.status_code == 200 and g.headers["x-raddrizzato"] == "no"
|
||||||
|
a = client_denso.post("/camera/anteprima", json={"source": SORGENTE})
|
||||||
|
assert a.status_code == 200 and a.headers["x-raddrizzato"] == "no"
|
||||||
|
assert (a.headers["x-larghezza"], a.headers["x-altezza"]) == ("640", "480")
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_risoluzione_diversa_non_scalza_l_attiva_precedente(client_denso):
|
||||||
|
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
|
||||||
|
salva_con_nome(client_denso, densa, "Giusta")
|
||||||
|
salva_con_nome(client_denso, _calcola_file(client_denso), "Sbagliata")
|
||||||
|
assert carica_su(client_denso, SORGENTE, "Giusta").status_code == 200
|
||||||
|
assert carica_su(client_denso, SORGENTE, "Sbagliata").status_code == 409
|
||||||
|
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()["nome"] == "Giusta"
|
||||||
|
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_densa_su_sorgente_stessa_risoluzione_raddrizza(client_denso):
|
||||||
|
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
|
||||||
|
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
|
||||||
|
r = carica_su(client_denso, SORGENTE, "Densa")
|
||||||
|
assert r.status_code == 200 and set(r.json()) == CHIAVI_DENSA | CHIAVI_ATTIVA
|
||||||
|
assert client_denso.post("/camera/anteprima", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_nome_sconosciuto_404(client):
|
||||||
|
r = carica_su(client, SORGENTE, "nessuna")
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert client.put("/camera/calibrazione-attiva", json={"source": SORGENTE}).status_code == 404 # senza nome
|
||||||
|
assert client.put("/camera/calibrazione-attiva", json={"nome": "x"}).status_code == 400 # senza source
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_su_sorgente_inesistente_404(client):
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||||
|
assert carica_su(client, "camera:non-esiste", "Banco").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_senza_risoluzione_registrata_409(cartella, tmp_path):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
config = tmp_path / "config_senza"
|
||||||
|
calibrazione.Archivio(config).salva_nome("Senza", {"px_per_mm": 10.0, "calibrated_at": "2026-01-01T00:00:00+00:00"})
|
||||||
|
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
|
||||||
|
r = carica_su(c, SORGENTE, "Senza")
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"] == "la calibrazione «Senza» non ha la risoluzione registrata: non si può caricare"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- contratto verso il backend e migrazione all'avvio
|
||||||
|
|
||||||
|
def test_get_calibration_contratto_per_il_backend(client):
|
||||||
|
# src/backend/services/vision_service.py legge questi campi
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||||
|
carica_su(client, SORGENTE, "Banco")
|
||||||
|
dati = client.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||||
|
for campo in ("px_per_mm", "mm_per_px", "image_width", "image_height", "calibrated_at", "metodo"):
|
||||||
|
assert campo in dati
|
||||||
|
assert client.get("/camera/calibration", params={"source": "camera:altra"}).json() == {
|
||||||
|
"detail": "sorgente non calibrata"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_avvio_migra_il_formato_vecchio(cartella, tmp_path):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
config = tmp_path / "config_vecchia"
|
||||||
|
config.mkdir()
|
||||||
|
nudo = calibrazione.Archivio.__new__(calibrazione.Archivio) # per avere il nome file del formato vecchio
|
||||||
|
nudo.cartella = config
|
||||||
|
vecchio = nudo._file(SORGENTE)
|
||||||
|
vecchio.write_text(json.dumps({"source": SORGENTE, "px_per_mm": 20.0, "mm_per_px": 0.05,
|
||||||
|
"image_width": 520, "image_height": 400, "metodo": "planare",
|
||||||
|
"calibrated_at": "2026-10-01T10:00:00+00:00"}), encoding="utf-8")
|
||||||
|
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
|
||||||
|
dati = c.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||||
|
assert dati["nome"] == SORGENTE and dati["source"] == SORGENTE and dati["px_per_mm"] == 20.0
|
||||||
|
voci = c.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
|
||||||
|
assert [v["nome"] for v in voci["calibrazioni"]] == [SORGENTE] and voci["attiva"] == SORGENTE
|
||||||
|
assert voci["calibrazioni"][0]["attiva_su"] == [SORGENTE]
|
||||||
|
assert not vecchio.exists()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Test della specchiatura orizzontale/verticale per sorgente (#110): 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
|
||||||
|
Copre: GET/PUT /camera/orientamento, effetto su /camera/grab e /camera/anteprima,
|
||||||
|
persistenza al riavvio, file rotto, validazione, scarto dei frame tenuti, avviso
|
||||||
|
sulla calibrazione densa. Sorgente: camera:folder su un'immagine 4x3 asimmetrica.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from test_calibrazione import ( # calibrazione densa reale (#84), per l'avviso
|
||||||
|
SORGENTE as SORGENTE_DENSA, cartella_densa, carica_su, client_denso, salva_con_nome,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["cartella_densa", "client_denso"] # fixture importate, restano usate
|
||||||
|
|
||||||
|
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||||
|
SORGENTE = "camera:folder"
|
||||||
|
|
||||||
|
|
||||||
|
def _carica():
|
||||||
|
spec = importlib.util.spec_from_file_location("camera_service_orient", MODULO)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cartella(tmp_path):
|
||||||
|
# #110: 4x3 tutta nera con UN pixel acceso in alto a sinistra (riga 0, colonna 0)
|
||||||
|
img = tmp_path / "foto"
|
||||||
|
img.mkdir()
|
||||||
|
f = np.zeros((3, 4, 3), np.uint8)
|
||||||
|
f[0, 0] = 255
|
||||||
|
cv2.imwrite(str(img / "a.png"), f)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config(tmp_path):
|
||||||
|
return tmp_path / "cfg"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(cartella, config):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella, config)) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _acceso(png: bytes):
|
||||||
|
"""(riga, colonna) del pixel acceso nel PNG; controlla che sia uno solo."""
|
||||||
|
f = cv2.imdecode(np.frombuffer(png, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
assert f.shape[:2] == (3, 4)
|
||||||
|
r, c = np.nonzero(f.max(axis=2) > 128)
|
||||||
|
assert len(r) == 1
|
||||||
|
return int(r[0]), int(c[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _imposta(c, orizz, vert, source=SORGENTE):
|
||||||
|
return c.put("/camera/orientamento", json={"source": source, "orizzontale": orizz, "verticale": vert})
|
||||||
|
|
||||||
|
|
||||||
|
def _grab(c):
|
||||||
|
r = c.post("/camera/grab", json={"source": SORGENTE})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return _acceso(r.content)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_senza_file_false_false(client):
|
||||||
|
r = client.get("/camera/orientamento", params={"source": SORGENTE})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"source": SORGENTE, "orizzontale": False, "verticale": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_senza_source_400(client):
|
||||||
|
assert client.get("/camera/orientamento").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("orizz,vert,atteso", [
|
||||||
|
(False, False, (0, 0)), # originale: alto a sinistra
|
||||||
|
(True, False, (0, 3)), # orizzontale: alto a destra
|
||||||
|
(False, True, (2, 0)), # verticale: basso a sinistra
|
||||||
|
(True, True, (2, 3)), # entrambi: basso a destra (180 gradi)
|
||||||
|
])
|
||||||
|
def test_grab_specchiato(client, orizz, vert, atteso):
|
||||||
|
r = _imposta(client, orizz, vert)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert (r.json()["orizzontale"], r.json()["verticale"]) == (orizz, vert)
|
||||||
|
assert client.get("/camera/orientamento", params={"source": SORGENTE}).json()["orizzontale"] is orizz
|
||||||
|
assert _grab(client) == atteso
|
||||||
|
|
||||||
|
|
||||||
|
def test_tutti_false_toglie_la_sorgente_dal_file(client, config):
|
||||||
|
_imposta(client, True, False)
|
||||||
|
assert SORGENTE in (config / "orientamento.json").read_text(encoding="utf-8")
|
||||||
|
_imposta(client, False, False)
|
||||||
|
assert SORGENTE not in (config / "orientamento.json").read_text(encoding="utf-8")
|
||||||
|
assert _grab(client) == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anteprima_specchiata_dimensioni_invariate(client):
|
||||||
|
_imposta(client, True, True)
|
||||||
|
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 4096})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("4", "3")
|
||||||
|
f = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
|
||||||
|
assert f.shape == (3, 4)
|
||||||
|
# JPEG con perdite: il pixel acceso e' il piu' chiaro, in basso a destra
|
||||||
|
assert np.unravel_index(int(np.argmax(f)), f.shape) == (2, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_persistenza_al_riavvio(cartella, config):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella, config)) as c1:
|
||||||
|
assert _imposta(c1, True, False).status_code == 200
|
||||||
|
with TestClient(_carica().create_app(cartella, config)) as c2:
|
||||||
|
j = c2.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||||
|
assert (j["orizzontale"], j["verticale"]) == (True, False)
|
||||||
|
assert _grab(c2) == (0, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_rotto_non_blocca_l_avvio(cartella, config):
|
||||||
|
config.mkdir(parents=True)
|
||||||
|
(config / "orientamento.json").write_text("{non e json", encoding="utf-8")
|
||||||
|
with TestClient(_carica().create_app(cartella, config)) as c:
|
||||||
|
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||||
|
assert (j["orizzontale"], j["verticale"]) == (False, False)
|
||||||
|
assert _imposta(c, False, True).status_code == 200 # e si puo' riscrivere
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_campi_non_bool_400(client):
|
||||||
|
for corpo in ({"source": SORGENTE, "orizzontale": 1, "verticale": False},
|
||||||
|
{"source": SORGENTE, "orizzontale": True, "verticale": "si"},
|
||||||
|
{"source": SORGENTE, "orizzontale": True},
|
||||||
|
{"orizzontale": True, "verticale": True}):
|
||||||
|
assert client.put("/camera/orientamento", json=corpo).status_code == 400, corpo
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_scarta_i_frame_tenuti(client):
|
||||||
|
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 200
|
||||||
|
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 200
|
||||||
|
_imposta(client, True, False)
|
||||||
|
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 404
|
||||||
|
# col frame tenuto scartato anche l'ultimo grezzo dell'anteprima e' sparito
|
||||||
|
assert client.post("/camera/anteprima/calibrata", json={"source": SORGENTE}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_avviso_senza_mappa_e_null(client):
|
||||||
|
j = _imposta(client, True, False).json()
|
||||||
|
assert j["avviso"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_avviso_con_mappa_attiva(client_denso):
|
||||||
|
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE_DENSA, "square_mm": "2"}).json()
|
||||||
|
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
|
||||||
|
assert carica_su(client_denso, SORGENTE_DENSA, "Densa").status_code == 200
|
||||||
|
r = _imposta(client_denso, True, False, source=SORGENTE_DENSA)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert "«Densa»" in r.json()["avviso"] and "va rifatta" in r.json()["avviso"]
|
||||||
|
# stesso valore di nuovo: nulla e' cambiato, nessun avviso
|
||||||
|
assert _imposta(client_denso, True, False, source=SORGENTE_DENSA).json()["avviso"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrittura_fallita_500_e_orientamento_di_prima(cartella, config, monkeypatch):
|
||||||
|
# #110: se orientamento.json non si scrive, in memoria resta l'orientamento di prima
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella, config)) as c:
|
||||||
|
assert _imposta(c, True, False).status_code == 200 # stato di partenza: orizzontale
|
||||||
|
def rotto(*a, **k):
|
||||||
|
raise OSError("disco pieno")
|
||||||
|
monkeypatch.setattr(mod.os, "replace", rotto)
|
||||||
|
assert _imposta(c, False, True).status_code == 500
|
||||||
|
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||||
|
assert (j["orizzontale"], j["verticale"]) == (True, False)
|
||||||
|
assert _grab(c) == (0, 3) # ancora solo orizzontale
|
||||||
|
# sorgente che prima non c'era: la voce non resta
|
||||||
|
assert c.put("/camera/orientamento", json={"source": "altra", "orizzontale": True, "verticale": True}).status_code == 500
|
||||||
|
j = c.get("/camera/orientamento", params={"source": "altra"}).json()
|
||||||
|
assert (j["orizzontale"], j["verticale"]) == (False, False)
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""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()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_specchiatura_per_sorgente(): # #110
|
||||||
|
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||||
|
assert {"specchiaO", "specchiaV", "msgSpecchia"} <= ids
|
||||||
|
assert '"/camera/orientamento' in PAGINA
|
||||||
|
assert "Specchia orizzontale" in PAGINA and "Specchia verticale" in PAGINA
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_visori(): # #111, #122: i visori sono <tmf-viewer>, la barra e lo zoom vengono dal componente
|
||||||
|
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||||
|
for i in ("immagine", "immagineCal", "immagineCalTab", "overlay"): # grezza, calibrata, tab Calibrazione, angoli
|
||||||
|
assert re.search(r'<tmf-viewer id="%s"' % i, PAGINA), i
|
||||||
|
assert PAGINA.count("<tmf-viewer") == 4
|
||||||
|
for nome in ("grezza", "calibrata", "calibrazione", "angoli"):
|
||||||
|
assert 'save-name="%s"' % nome in PAGINA, nome
|
||||||
|
assert "zoomAdatta" not in PAGINA and "barraZoom" not in PAGINA and "creaVisore(" in PAGINA
|
||||||
|
assert not {"zoomMeno", "zoomPiu", "zoomPct"} & ids
|
||||||
|
assert "latoAnteprima()" in PAGINA and "unaAnteprima(1280)" not in PAGINA # lato_max segue lo zoom
|
||||||
|
assert '"viewer-zoom"' in PAGINA # lo zoom si legge dall'evento del componente
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_del_visore_in_ordine(): # #122: i JS arrivano da /visore/ (offline), prima dello script di pagina
|
||||||
|
src = re.findall(r'<script src="([^"]+)"', PAGINA)
|
||||||
|
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
|
||||||
|
assert PAGINA.index("/visore/image_viewer.js") < PAGINA.index("<script>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_nessuna_funzione_dichiarata_due_volte(): # #111: in JS vince l'ultima dichiarazione, in silenzio
|
||||||
|
script = re.search(r"<script>(.*?)</script>", PAGINA, re.S).group(1)
|
||||||
|
nomi = re.findall(r"function\s+([A-Za-z0-9_$]+)\s*\(", script)
|
||||||
|
doppi = sorted({n for n in nomi if nomi.count(n) > 1})
|
||||||
|
assert not doppi, "funzioni dichiarate piu' volte: " + ", ".join(doppi)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Test delle rotte /visore/*.js di camera_service e della pagina che le usa (#122).
|
||||||
|
|
||||||
|
Uso: python -m pytest scripts/tests/test_visore_rotte.py -q
|
||||||
|
Dipende da: venv con fastapi, httpx, opencv (come test_camera_service.py).
|
||||||
|
image_viewer.js lo scrive un altro piano (V1): se manca nei sorgenti il test della rotta usa una cartella temporanea con file finti.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||||
|
NOMI = ("image_viewer.js", "save_image.js", "vision_overlay.js")
|
||||||
|
|
||||||
|
|
||||||
|
def _carica():
|
||||||
|
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(tmp_path)) as c:
|
||||||
|
c.mod = mod
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cartella_finta(client, tmp_path):
|
||||||
|
# file finti in una cartella temporanea: la rotta si prova senza dipendere da image_viewer.js del frontend
|
||||||
|
d = tmp_path / "js"
|
||||||
|
d.mkdir()
|
||||||
|
for n in NOMI:
|
||||||
|
(d / n).write_text("/* finto " + n + " */\n", encoding="utf-8")
|
||||||
|
client.mod._CARTELLE_VISORE[:] = [d]
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nome", NOMI)
|
||||||
|
def test_rotta_serve_il_file(client, cartella_finta, nome):
|
||||||
|
r = client.get("/visore/" + nome)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"].startswith("application/javascript")
|
||||||
|
assert r.content == (cartella_finta / nome).read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nome", ["altro.js", "camera_service.py", "..%2Fcamera_service.py", "image_viewer.JS", "image_viewer.js.map"])
|
||||||
|
def test_altri_nomi_danno_404(client, cartella_finta, nome):
|
||||||
|
assert client.get("/visore/" + nome).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_mancante_nella_cartella_da_404(client, tmp_path):
|
||||||
|
client.mod._CARTELLE_VISORE[:] = [tmp_path / "vuota"]
|
||||||
|
assert client.get("/visore/save_image.js").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_cartelle_di_default_sono_static_js_del_frontend(client):
|
||||||
|
# sviluppo: sorgenti del frontend; pacchetto Nuitka: static/ accanto al modulo (build.ps1 la include)
|
||||||
|
cartelle = _carica()._CARTELLE_VISORE
|
||||||
|
assert any(c.parts[-5:] == ("src", "frontend", "flask_app", "static", "js") for c in cartelle)
|
||||||
|
assert any(c.parts[-2:] == ("static", "js") and c.parent.parent == MODULO.parent for c in cartelle)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nome", ("save_image.js", "vision_overlay.js", "image_viewer.js"))
|
||||||
|
def test_file_reali_del_frontend(client, nome):
|
||||||
|
reale = next((c / nome for c in client.mod._CARTELLE_VISORE if (c / nome).is_file()), None)
|
||||||
|
if reale is None:
|
||||||
|
pytest.skip(nome + " non e' ancora nei sorgenti del frontend (lo scrive il piano V1)")
|
||||||
|
r = client.get("/visore/" + nome)
|
||||||
|
assert r.status_code == 200 and r.content == reale.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rotta", ("/acquisizione", "/calibrazione"))
|
||||||
|
def test_pagina_usa_quattro_visori_e_i_tre_script(client, rotta):
|
||||||
|
html = client.get(rotta).text
|
||||||
|
assert html.count("<tmf-viewer") == 4
|
||||||
|
src = re.findall(r'<script src="(/visore/[^"]+)"', html)
|
||||||
|
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
|
||||||
|
assert "zoomAdatta" not in html
|
||||||
@@ -357,9 +357,13 @@ async def export_measurements_csv(
|
|||||||
# Rows
|
# Rows
|
||||||
for m in measurements:
|
for m in measurements:
|
||||||
# Format decimals with configured separator
|
# Format decimals with configured separator
|
||||||
value_str = str(m.value).replace(".", decimal_separator)
|
# #118: quota non misurata dalla visione -> NaN
|
||||||
|
value_str = "NaN" if m.value is None else str(m.value).replace(".", decimal_separator)
|
||||||
|
# #124: quota NaN (value None) -> deviazione NaN, non vuota
|
||||||
deviation_str = (
|
deviation_str = (
|
||||||
str(m.deviation).replace(".", decimal_separator)
|
"NaN"
|
||||||
|
if m.value is None
|
||||||
|
else str(m.deviation).replace(".", decimal_separator)
|
||||||
if m.deviation is not None
|
if m.deviation is not None
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ async def get_capability(
|
|||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
# #118: le quote NaN (value None) non entrano nei calcoli
|
||||||
|
values = [float(m.value) for m in measurements if m.value is not None]
|
||||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||||
return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"])
|
return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"])
|
||||||
|
|
||||||
@@ -171,6 +172,8 @@ async def get_control_chart(
|
|||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
|
# #118: le quote NaN (value None) si saltano, valori e istanti restano allineati
|
||||||
|
measurements = [m for m in measurements if m.value is not None]
|
||||||
values = [float(m.value) for m in measurements]
|
values = [float(m.value) for m in measurements]
|
||||||
timestamps = [m.measured_at for m in measurements]
|
timestamps = [m.measured_at for m in measurements]
|
||||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||||
@@ -201,7 +204,8 @@ async def get_histogram(
|
|||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
# #118: le quote NaN (value None) non entrano nell'istogramma
|
||||||
|
values = [float(m.value) for m in measurements if m.value is not None]
|
||||||
return compute_histogram(values, n_bins)
|
return compute_histogram(values, n_bins)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ async def create_task(
|
|||||||
file_type=data.file_type,
|
file_type=data.file_type,
|
||||||
annotations_json=data.annotations_json,
|
annotations_json=data.annotations_json,
|
||||||
vision_json=data.vision_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
|
# #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),
|
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
|
||||||
at_close=data.at_close,
|
at_close=data.at_close,
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ It does not know, and must not know, whether the server or the station computed
|
|||||||
the numbers: that is what lets a station's configuration change without touching
|
the numbers: that is what lets a station's configuration change without touching
|
||||||
the frontend or the recipes.
|
the frontend or the recipes.
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
@@ -20,13 +24,13 @@ from src.backend.api.routers.files import (
|
|||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.models.api.vision import (
|
from src.backend.models.api.vision import (
|
||||||
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
||||||
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
||||||
)
|
)
|
||||||
from src.backend.models.orm.vision import VisionResult
|
from src.backend.models.orm.vision import VisionResult
|
||||||
from src.backend.models.orm.task import RecipeTask
|
from src.backend.models.orm.task import RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.services import vision_service
|
from src.backend.services import station_service, vision_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||||
|
|
||||||
@@ -37,14 +41,22 @@ _IMAGE_EXTENSIONS = {
|
|||||||
"image/png": "png",
|
"image/png": "png",
|
||||||
"image/gif": "gif",
|
"image/gif": "gif",
|
||||||
"image/webp": "webp",
|
"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
|
# 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
|
# 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
|
# still be caught before it reaches the worker as a bare 500 (#15). TIFF is
|
||||||
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
|
# included even though the allow-list does not accept it (PIL, and so the
|
||||||
# because PIL - and so the worker - can decode both; this check only refuses
|
# worker, can decode it; browsers do not show it in the result); BMP is now
|
||||||
# what is not a real image at all, it does not narrow the allow-list above.
|
# 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, ...], ...] = (
|
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
|
||||||
(b"\x89PNG\r\n\x1a\n",), # PNG
|
(b"\x89PNG\r\n\x1a\n",), # PNG
|
||||||
(b"\xff\xd8\xff",), # JPEG
|
(b"\xff\xd8\xff",), # JPEG
|
||||||
@@ -80,7 +92,7 @@ async def _read_image(image: UploadFile) -> bytes:
|
|||||||
is in hand - the same two-step convention `files.py::upload_file` already
|
is in hand - the same two-step convention `files.py::upload_file` already
|
||||||
uses, reused rather than reinvented here.
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||||
@@ -150,7 +162,10 @@ async def execute(
|
|||||||
engine_version=result.engine_version,
|
engine_version=result.engine_version,
|
||||||
measurements=[
|
measurements=[
|
||||||
VisionMeasurementResult(
|
VisionMeasurementResult(
|
||||||
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
|
subtask_id=m.subtask_id,
|
||||||
|
# #118: None = quota non prodotta, il frontend la mostra NaN
|
||||||
|
value=float(m.value) if m.value is not None else None,
|
||||||
|
pass_fail=m.pass_fail,
|
||||||
)
|
)
|
||||||
for m in saved
|
for m in saved
|
||||||
],
|
],
|
||||||
@@ -161,6 +176,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")
|
@router.get("/results/{result_id}/image")
|
||||||
async def get_result_image(
|
async def get_result_image(
|
||||||
result_id: int,
|
result_id: int,
|
||||||
@@ -185,6 +216,30 @@ def _422(errors: list[str]) -> HTTPException:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _store_validate_assign(
|
||||||
|
task: RecipeTask, graph: dict, placed: dict[str, bytes],
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
"""Scrive la cartella ricetta, la fa validare dal worker, la assegna al task.
|
||||||
|
|
||||||
|
Condiviso da upload_recipe e dxf-compare con save (#83): stesso
|
||||||
|
comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check).
|
||||||
|
"""
|
||||||
|
# The folder is content-addressed and immutable; writing it before the
|
||||||
|
# validation is harmless (an orphan folder is never referenced) and the
|
||||||
|
# worker needs it on disk to validate meta files.
|
||||||
|
try:
|
||||||
|
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _422([str(exc)])
|
||||||
|
check = await vision_service.validate_recipe(graph, recipe_dir)
|
||||||
|
if not check["ok"]:
|
||||||
|
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
||||||
|
|
||||||
|
task.vision_json = graph
|
||||||
|
task.vision_recipe_dir = recipe_dir
|
||||||
|
return recipe_dir, check
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
||||||
async def upload_recipe(
|
async def upload_recipe(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
@@ -218,19 +273,7 @@ async def upload_recipe(
|
|||||||
if errors:
|
if errors:
|
||||||
raise _422(errors)
|
raise _422(errors)
|
||||||
|
|
||||||
# The folder is content-addressed and immutable; writing it before the
|
recipe_dir, check = await _store_validate_assign(task, graph, placed)
|
||||||
# validation is harmless (an orphan folder is never referenced) and the
|
|
||||||
# worker needs it on disk to validate meta files.
|
|
||||||
try:
|
|
||||||
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise _422([str(exc)])
|
|
||||||
check = await vision_service.validate_recipe(graph, recipe_dir)
|
|
||||||
if not check["ok"]:
|
|
||||||
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
|
||||||
|
|
||||||
task.vision_json = graph
|
|
||||||
task.vision_recipe_dir = recipe_dir
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return VisionRecipeUploadResponse(
|
return VisionRecipeUploadResponse(
|
||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
@@ -325,3 +368,133 @@ async def preview(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
task = await _task_or_404(db, task_id)
|
task = await _task_or_404(db, task_id)
|
||||||
return await vision_service.preview(db, task, await _read_image(image))
|
return await vision_service.preview(db, task, await _read_image(image))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dxf-preview")
|
||||||
|
async def dxf_preview(
|
||||||
|
dxf: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
"""#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni
|
||||||
|
in mm e dimensioni), letto dal worker con lo stesso lettore del confronto.
|
||||||
|
Non scrive nulla; stesso limite di dimensione della rotta dxf-compare."""
|
||||||
|
dxf_bytes = await dxf.read()
|
||||||
|
if not validate_file_size(len(dxf_bytes)):
|
||||||
|
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
|
||||||
|
return await vision_service.dxf_preview(dxf_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
||||||
|
async def create_dxf_compare_recipe(
|
||||||
|
task_id: int,
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
dxf: UploadFile = File(...),
|
||||||
|
station_id: int | None = Form(None, gt=0),
|
||||||
|
px_per_mm: float | None = Form(None, gt=0),
|
||||||
|
band_mm: float = Form(0.1, gt=0),
|
||||||
|
min_points_pct: float = Form(95, ge=0, le=100),
|
||||||
|
polarity: Literal["dark", "light"] = Form("dark"),
|
||||||
|
save: bool = Form(False),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
"""Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83).
|
||||||
|
|
||||||
|
La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore).
|
||||||
|
Con save=false si vede il risultato e non si scrive nulla; con save=true la
|
||||||
|
ricetta va sul task come con l'upload, e se il task non ha nessuna quota di
|
||||||
|
visione nasce la quota «Punti in tolleranza».
|
||||||
|
"""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
if task.task_type not in vision_service.VISION_TASK_TYPES:
|
||||||
|
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
|
||||||
|
|
||||||
|
station = None
|
||||||
|
if station_id is not None:
|
||||||
|
station = await station_service.get_station(db, station_id) # 404 se non esiste
|
||||||
|
if px_per_mm is None:
|
||||||
|
calibration, reason = await vision_service.fetch_station_calibration(station)
|
||||||
|
if calibration is None:
|
||||||
|
# irraggiungibile e non calibrata sono due guasti diversi per chi lo legge
|
||||||
|
raise _422([
|
||||||
|
"acquisitore della stazione non raggiungibile"
|
||||||
|
if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata"
|
||||||
|
])
|
||||||
|
px_per_mm = float(calibration["px_per_mm"])
|
||||||
|
|
||||||
|
image_bytes = await _read_image(image)
|
||||||
|
dxf_bytes = await dxf.read()
|
||||||
|
if not validate_file_size(len(dxf_bytes)):
|
||||||
|
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
|
||||||
|
|
||||||
|
body = await vision_service.dxf_compare_recipe(
|
||||||
|
image_bytes, dxf_bytes,
|
||||||
|
px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct,
|
||||||
|
polarity=polarity,
|
||||||
|
worker_url=(station.vision_worker_url or None) if station is not None else None,
|
||||||
|
)
|
||||||
|
# i file stanno nella ricetta salvata, non nella risposta al client
|
||||||
|
files = body.pop("files")
|
||||||
|
response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None}
|
||||||
|
if not save:
|
||||||
|
return response
|
||||||
|
|
||||||
|
uploads: list[tuple[str, bytes]] = []
|
||||||
|
for name, encoded in files.items():
|
||||||
|
try:
|
||||||
|
uploads.append((name, base64.b64decode(encoded, validate=True)))
|
||||||
|
except (binascii.Error, ValueError, TypeError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker's answer was malformed: file {name!r} is not base64",
|
||||||
|
)
|
||||||
|
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
|
||||||
|
if errors:
|
||||||
|
raise _422(errors)
|
||||||
|
# #105: l'immagine di riferimento resta nella cartella (non e' in meta, quindi
|
||||||
|
# /validate e l'esecuzione la ignorano) e il wizard puo' riaprirla; entra nel digest.
|
||||||
|
placed[f"riferimento.{_IMAGE_EXTENSIONS.get(image.content_type, 'png')}"] = image_bytes
|
||||||
|
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
|
||||||
|
await vision_service.ensure_points_quote(db, task, min_points_pct)
|
||||||
|
await db.commit()
|
||||||
|
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/recipe/dxf-compare/source")
|
||||||
|
async def get_dxf_compare_source(
|
||||||
|
task_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
"""#105: i parametri della ricetta fatta con «Crea ricetta da DXF», per modificarla.
|
||||||
|
|
||||||
|
404 se il task non ha una ricetta del wizard (nessuna, o caricata a mano).
|
||||||
|
"""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
source = vision_service.dxf_compare_source(task.vision_json, task.vision_recipe_dir)
|
||||||
|
if source is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="nessuna ricetta da DXF su questo task",
|
||||||
|
)
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/recipe/files/{path:path}")
|
||||||
|
async def get_recipe_file(
|
||||||
|
task_id: int,
|
||||||
|
path: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""#105: un file della cartella ricetta del task (DXF, immagine di riferimento).
|
||||||
|
|
||||||
|
Percorso non sicuro, file assente o task senza ricetta: sempre 404.
|
||||||
|
"""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
target = vision_service.recipe_file_path(task.vision_recipe_dir, path)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||||
|
media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||||
|
return FileResponse(target, media_type=media_type)
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""measurements.value nullable (#118)
|
||||||
|
|
||||||
|
measurements.value: NULL = quota che il grafo di visione non ha prodotto
|
||||||
|
(l'operatore la vede come NaN, esito sempre `fail`, deviazione NULL). Prima
|
||||||
|
execute rifiutava con 422 l'intera acquisizione. measurements.deviation era
|
||||||
|
gia' nullable.
|
||||||
|
|
||||||
|
Downgrade: torna NOT NULL; FALLISCE se esistono righe con value NULL (vanno
|
||||||
|
cancellate o valorizzate prima).
|
||||||
|
|
||||||
|
Revision ID: 022_measurement_value_nullable
|
||||||
|
Revises: 021_task_acquisition
|
||||||
|
Create Date: 2026-10-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '022_measurement_value_nullable'
|
||||||
|
down_revision: Union[str, None] = '021_task_acquisition'
|
||||||
|
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('measurements') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'value', existing_type=sa.DECIMAL(12, 6), nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'value', existing_type=sa.DECIMAL(12, 6), nullable=False,
|
||||||
|
)
|
||||||
@@ -43,7 +43,7 @@ class MeasurementResponse(BaseModel):
|
|||||||
subtask_id: int
|
subtask_id: int
|
||||||
version_id: int
|
version_id: int
|
||||||
measured_by: int
|
measured_by: int
|
||||||
value: float
|
value: Optional[float] = None # #118: null = quota non prodotta dalla visione (NaN)
|
||||||
pass_fail: str
|
pass_fail: str
|
||||||
deviation: Optional[float] = None
|
deviation: Optional[float] = None
|
||||||
lot_number: Optional[str] = None
|
lot_number: Optional[str] = None
|
||||||
@@ -108,7 +108,7 @@ class CycleMeasurementResponse(BaseModel):
|
|||||||
"""One quote already measured in the cycle under way."""
|
"""One quote already measured in the cycle under way."""
|
||||||
|
|
||||||
subtask_id: int
|
subtask_id: int
|
||||||
value: float
|
value: Optional[float] = None # #118: null = NaN
|
||||||
pass_fail: str
|
pass_fail: str
|
||||||
measured_at: UtcDateTime
|
measured_at: UtcDateTime
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class MonitorSubtask(BaseModel):
|
|||||||
description: str
|
description: str
|
||||||
unit: str
|
unit: str
|
||||||
count: int
|
count: int
|
||||||
mean: float
|
mean: Optional[float] = None # #118: None = solo quote NaN
|
||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
utl: Optional[float] = None
|
utl: Optional[float] = None
|
||||||
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Literal, Optional
|
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
|
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.
|
# 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
|
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):
|
class TaskCreate(BaseModel):
|
||||||
"""Schema for creating a task."""
|
"""Schema for creating a task."""
|
||||||
title: str = Field(..., min_length=1, max_length=255)
|
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)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||||
# #53: assente = default dal tipo (misure e check nel ciclo)
|
# #53: assente = default dal tipo (misure e check nel ciclo)
|
||||||
in_loop: Optional[bool] = None
|
in_loop: Optional[bool] = None
|
||||||
at_close: bool = False
|
at_close: bool = False
|
||||||
@@ -186,6 +212,7 @@ class TaskUpdate(BaseModel):
|
|||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||||
in_loop: Optional[bool] = None
|
in_loop: Optional[bool] = None
|
||||||
at_close: Optional[bool] = None
|
at_close: Optional[bool] = None
|
||||||
fields: Optional[list[dict[str, Any]]] = None
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
@@ -207,6 +234,7 @@ class TaskResponse(BaseModel):
|
|||||||
file_type: Optional[str] = None
|
file_type: Optional[str] = None
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||||
in_loop: bool = False
|
in_loop: bool = False
|
||||||
at_close: bool = False
|
at_close: bool = False
|
||||||
fields: Optional[list[dict[str, Any]]] = None
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
class VisionMeasurementResult(BaseModel):
|
class VisionMeasurementResult(BaseModel):
|
||||||
"""One quote's outcome, as much of it as the client needs to show."""
|
"""One quote's outcome, as much of it as the client needs to show."""
|
||||||
subtask_id: int
|
subtask_id: int
|
||||||
value: float
|
value: float | None # #118: null = quota non prodotta dal grafo (NaN), pass_fail "fail"
|
||||||
pass_fail: str
|
pass_fail: str
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel):
|
|||||||
failures: list[dict] = []
|
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):
|
class VisionRecipeOutput(BaseModel):
|
||||||
"""One numeric output a recipe offers to the quotes."""
|
"""One numeric output a recipe offers to the quotes."""
|
||||||
key: str
|
key: str
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class Measurement(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Measurement data
|
# Measurement data
|
||||||
value: Mapped[float] = mapped_column(DECIMAL(12, 6), nullable=False)
|
# #118: NULL = quota che il grafo di visione non ha prodotto (mostrata NaN, sempre fail)
|
||||||
|
value: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
|
||||||
pass_fail: Mapped[str] = mapped_column(
|
pass_fail: Mapped[str] = mapped_column(
|
||||||
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
|
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ class RecipeTask(Base):
|
|||||||
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||||
# meta della ricetta VisionSuite v2 (#46).
|
# meta della ricetta VisionSuite v2 (#46).
|
||||||
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
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
|
# #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).
|
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
||||||
in_loop: Mapped[bool] = mapped_column(
|
in_loop: Mapped[bool] = mapped_column(
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ async def current_cycle_measurements(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"subtask_id": m.subtask_id,
|
"subtask_id": m.subtask_id,
|
||||||
"value": float(m.value),
|
"value": float(m.value) if m.value is not None else None, # #118: NaN
|
||||||
"pass_fail": m.pass_fail,
|
"pass_fail": m.pass_fail,
|
||||||
"measured_at": m.measured_at,
|
"measured_at": m.measured_at,
|
||||||
}
|
}
|
||||||
@@ -355,7 +355,7 @@ async def save_measurement(
|
|||||||
subtask_id: int,
|
subtask_id: int,
|
||||||
version_id: int,
|
version_id: int,
|
||||||
measured_by: int,
|
measured_by: int,
|
||||||
value: float,
|
value: float | None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
input_method: str = "manual",
|
input_method: str = "manual",
|
||||||
@@ -372,8 +372,12 @@ async def save_measurement(
|
|||||||
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
||||||
the gate models "moving on" between measurements taken one after another,
|
the gate models "moving on" between measurements taken one after another,
|
||||||
which does not exist inside a single camera acquisition.
|
which does not exist inside a single camera acquisition.
|
||||||
|
|
||||||
|
#118: ``value=None`` is a quote the vision graph did not produce (shown as
|
||||||
|
NaN): saved as a ``fail`` with no deviation. Only the vision path passes it.
|
||||||
"""
|
"""
|
||||||
_validate_measured_value(value)
|
if value is not None: # #118: una quota non misurata non ha un valore da validare
|
||||||
|
_validate_measured_value(value)
|
||||||
|
|
||||||
# Get subtask for tolerance values
|
# Get subtask for tolerance values
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -421,8 +425,12 @@ async def save_measurement(
|
|||||||
# Rounded once, here, so the stored value is exactly the one the verdict
|
# Rounded once, here, so the stored value is exactly the one the verdict
|
||||||
# above was computed on - calculate_pass_fail rounds internally too, but
|
# above was computed on - calculate_pass_fail rounds internally too, but
|
||||||
# matching it here keeps the persisted row from disagreeing with itself.
|
# matching it here keeps the persisted row from disagreeing with itself.
|
||||||
value = round(value, 6)
|
if value is None:
|
||||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
# #118: quota non prodotta dal grafo -> NaN: fail, nessuna deviazione
|
||||||
|
pass_fail, deviation = "fail", None
|
||||||
|
else:
|
||||||
|
value = round(value, 6)
|
||||||
|
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||||
|
|
||||||
measurement = Measurement(
|
measurement = Measurement(
|
||||||
subtask_id=subtask_id,
|
subtask_id=subtask_id,
|
||||||
|
|||||||
@@ -96,14 +96,21 @@ async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
|
|||||||
per_sub[row.subtask_id].append(row)
|
per_sub[row.subtask_id].append(row)
|
||||||
subtasks = []
|
subtasks = []
|
||||||
for sid, group in sorted(per_sub.items()):
|
for sid, group in sorted(per_sub.items()):
|
||||||
mean = sum(float(g.value) for g in group) / len(group)
|
# #118: le quote NaN (value None) non entrano nella media; con soli
|
||||||
|
# NaN la quota resta in elenco con mean None ed e' fuori tolleranza
|
||||||
|
numeric = [float(g.value) for g in group if g.value is not None]
|
||||||
|
mean = sum(numeric) / len(numeric) if numeric else None
|
||||||
first = group[0]
|
first = group[0]
|
||||||
subtasks.append(MonitorSubtask(
|
subtasks.append(MonitorSubtask(
|
||||||
subtask_id=sid, description=first.description, unit=first.unit,
|
subtask_id=sid, description=first.description, unit=first.unit,
|
||||||
count=len(group), mean=round(mean, 6),
|
count=len(group),
|
||||||
|
mean=round(mean, 6) if mean is not None else None, # #118
|
||||||
ltl=float(first.ltl) if first.ltl is not None else None,
|
ltl=float(first.ltl) if first.ltl is not None else None,
|
||||||
utl=float(first.utl) if first.utl is not None else None,
|
utl=float(first.utl) if first.utl is not None else None,
|
||||||
in_tolerance=_in_tolerance(mean, first.ltl, first.utl),
|
in_tolerance=(
|
||||||
|
_in_tolerance(mean, first.ltl, first.utl)
|
||||||
|
if mean is not None else False # #118: tutte NaN = fail
|
||||||
|
),
|
||||||
))
|
))
|
||||||
timer = production_service.describe(run)
|
timer = production_service.describe(run)
|
||||||
ev = last_event.get(run.id)
|
ev = last_event.get(run.id)
|
||||||
|
|||||||
@@ -159,8 +159,8 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
|||||||
subtask.marker_number if subtask else "",
|
subtask.marker_number if subtask else "",
|
||||||
neutralise_csv_cell(subtask.description) if subtask else "",
|
neutralise_csv_cell(subtask.description) if subtask else "",
|
||||||
number(subtask.nominal) if subtask else "",
|
number(subtask.nominal) if subtask else "",
|
||||||
number(measurement.value),
|
number(measurement.value) if measurement.value is not None else "NaN", # #118
|
||||||
number(measurement.deviation),
|
"NaN" if measurement.value is None else number(measurement.deviation), # #124: quota NaN -> deviazione NaN
|
||||||
measurement.pass_fail,
|
measurement.pass_fail,
|
||||||
number(subtask.ltl) if subtask else "",
|
number(subtask.ltl) if subtask else "",
|
||||||
number(subtask.lwl) if subtask else "",
|
number(subtask.lwl) if subtask else "",
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ async def _copy_tasks_to_version(
|
|||||||
vision_json=task.vision_json,
|
vision_json=task.vision_json,
|
||||||
# Cartella immutabile: basta il riferimento (#46).
|
# Cartella immutabile: basta il riferimento (#46).
|
||||||
vision_recipe_dir=task.vision_recipe_dir,
|
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.
|
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
||||||
in_loop=task.in_loop,
|
in_loop=task.in_loop,
|
||||||
at_close=task.at_close,
|
at_close=task.at_close,
|
||||||
|
|||||||
@@ -318,8 +318,10 @@ async def generate_spc_report(
|
|||||||
|
|
||||||
# 2. Compute SPC data
|
# 2. Compute SPC data
|
||||||
pass_fail_values = [m.pass_fail for m in measurements]
|
pass_fail_values = [m.pass_fail for m in measurements]
|
||||||
values = [float(m.value) for m in measurements]
|
# #118: le quote NaN (value None) contano nell'esito ma non nei calcoli
|
||||||
timestamps = [m.measured_at for m in measurements]
|
numeric = [m for m in measurements if m.value is not None]
|
||||||
|
values = [float(m.value) for m in numeric]
|
||||||
|
timestamps = [m.measured_at for m in numeric]
|
||||||
|
|
||||||
summary = compute_summary(pass_fail_values)
|
summary = compute_summary(pass_fail_values)
|
||||||
|
|
||||||
@@ -426,7 +428,7 @@ async def generate_measurement_report(
|
|||||||
rows.append({
|
rows.append({
|
||||||
"num": i,
|
"num": i,
|
||||||
"subtask": f"#{st_info.get('marker_number', '?')} — {st_info.get('description', '?')}",
|
"subtask": f"#{st_info.get('marker_number', '?')} — {st_info.get('description', '?')}",
|
||||||
"value": f"{float(m.value):.4f}",
|
"value": f"{float(m.value):.4f}" if m.value is not None else "NaN", # #118
|
||||||
"unit": st_info.get("unit", "mm"),
|
"unit": st_info.get("unit", "mm"),
|
||||||
"pass_fail": m.pass_fail,
|
"pass_fail": m.pass_fail,
|
||||||
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -24,6 +25,8 @@ from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
|||||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
from src.backend.services import measurement_service
|
from src.backend.services import measurement_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Worker responses whose status and reason are forwarded as-is rather than
|
# Worker responses whose status and reason are forwarded as-is rather than
|
||||||
# flattened into a generic 502 - see `_call_worker`.
|
# flattened into a generic 502 - see `_call_worker`.
|
||||||
_FORWARDED_WORKER_STATUSES = frozenset({
|
_FORWARDED_WORKER_STATUSES = frozenset({
|
||||||
@@ -140,6 +143,183 @@ async def _call_worker(
|
|||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
async def call_worker_multipart(
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
files: dict,
|
||||||
|
data: dict,
|
||||||
|
worker_url: str | None = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> dict:
|
||||||
|
"""POST multipart generico al worker (#83): /recipes/dxf-compare.
|
||||||
|
|
||||||
|
Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul
|
||||||
|
trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si
|
||||||
|
inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale.
|
||||||
|
"""
|
||||||
|
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
response = await client.post(f"{base}{path}", files=files, data=data)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail=f"the vision worker did not answer in time: {exc} ({base})",
|
||||||
|
) from exc
|
||||||
|
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker is unreachable: {exc} ({base})",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
||||||
|
try:
|
||||||
|
detail = response.json().get("detail", response.text)
|
||||||
|
except ValueError:
|
||||||
|
detail = response.text
|
||||||
|
raise HTTPException(status_code=response.status_code, detail=detail)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker refused the job: {response.text}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||||
|
) from exc
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
async def dxf_compare_recipe(
|
||||||
|
image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float,
|
||||||
|
min_points_pct: float, polarity: str, worker_url: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test."""
|
||||||
|
body = await call_worker_multipart(
|
||||||
|
"/recipes/dxf-compare",
|
||||||
|
files={
|
||||||
|
"image": ("frame.png", image_bytes, "image/png"),
|
||||||
|
"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"),
|
||||||
|
},
|
||||||
|
data={
|
||||||
|
"px_per_mm": str(px_per_mm), "band_mm": str(band_mm),
|
||||||
|
"min_points_pct": str(min_points_pct), "polarity": polarity,
|
||||||
|
},
|
||||||
|
worker_url=worker_url,
|
||||||
|
)
|
||||||
|
# forma minima del contratto W2: senza graph e files non c'e' nulla da salvare
|
||||||
|
if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: missing graph or files",
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
async def dxf_preview(dxf_bytes: bytes, worker_url: str | None = None) -> dict:
|
||||||
|
"""#101: anteprima del DXF (contorni in mm + dimensioni) dal worker. Sostituita dai test."""
|
||||||
|
body = await call_worker_multipart(
|
||||||
|
"/dxf/preview",
|
||||||
|
files={"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream")},
|
||||||
|
data={},
|
||||||
|
worker_url=worker_url,
|
||||||
|
)
|
||||||
|
# forma minima del contratto: senza contorni e dimensioni non c'e' nulla da mostrare
|
||||||
|
required = {"contorni", "bbox", "larghezza_mm", "altezza_mm", "entita", "punti"}
|
||||||
|
if not required <= body.keys():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: missing "
|
||||||
|
+ ", ".join(sorted(required - body.keys())),
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
# Stati di fetch_station_calibration (#83)
|
||||||
|
CAL_OK = "ok"
|
||||||
|
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
||||||
|
CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile)
|
||||||
|
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
||||||
|
|
||||||
|
|
||||||
|
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. `label` serve
|
||||||
|
solo ai log (stazione o task).
|
||||||
|
"""
|
||||||
|
if not camera_url or not camera_source:
|
||||||
|
return None, CAL_NO_CAMERA
|
||||||
|
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": camera_source})
|
||||||
|
except httpx.HTTPError as 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 %s: HTTP %s", label, response.status_code)
|
||||||
|
return None, CAL_UNREACHABLE
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
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):
|
||||||
|
return None, CAL_NOT_CALIBRATED
|
||||||
|
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)
|
||||||
|
return calibration
|
||||||
|
|
||||||
|
|
||||||
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
||||||
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
||||||
|
|
||||||
@@ -305,6 +485,84 @@ def list_recipe_files(recipe_dir: str | None) -> list[str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# #105: nomi e id della ricetta costruita da «Crea ricetta da DXF» (src/vision/costruttori.py).
|
||||||
|
_WIZARD_DXF_FILE = "disegno.dxf"
|
||||||
|
_WIZARD_CALIBRATION_FILE = "calibrazione.json"
|
||||||
|
_WIZARD_REFERENCE_STEM = "riferimento"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_real_number(value: object) -> bool:
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||||
|
|
||||||
|
|
||||||
|
def dxf_compare_source(graph: dict | None, recipe_dir: str | None) -> dict | None:
|
||||||
|
"""#105: i parametri con cui il wizard ha costruito la ricetta, per riaprirlo.
|
||||||
|
|
||||||
|
Legge i valori dal grafo (tool `pezzo` LocateBlob, tool `dxf` CompareDxf) e i
|
||||||
|
file dalla cartella ricetta. None se il grafo non e' uno del wizard (il task
|
||||||
|
non ha ricetta, o l'ha caricata a mano): la rotta risponde 404. Pura: niente DB.
|
||||||
|
"""
|
||||||
|
if not isinstance(graph, dict) or not isinstance(graph.get("tools"), list):
|
||||||
|
return None
|
||||||
|
tools = {t.get("id"): t for t in graph["tools"] if isinstance(t, dict)}
|
||||||
|
pezzo, confronto = tools.get("pezzo"), tools.get("dxf")
|
||||||
|
if not pezzo or pezzo.get("kind") != "LocateBlob":
|
||||||
|
return None
|
||||||
|
if not confronto or confronto.get("kind") != "CompareDxf":
|
||||||
|
return None
|
||||||
|
p_values = pezzo.get("values") if isinstance(pezzo.get("values"), dict) else {}
|
||||||
|
c_values = confronto.get("values") if isinstance(confronto.get("values"), dict) else {}
|
||||||
|
band, min_pct = c_values.get("band_plus"), c_values.get("points_in_pct_nominal")
|
||||||
|
if not (_is_real_number(band) and _is_real_number(min_pct)):
|
||||||
|
return None
|
||||||
|
polarity = p_values.get("polarity")
|
||||||
|
if polarity not in ("dark", "light"):
|
||||||
|
polarity = "dark" # default di LocateBlob e della rotta di creazione
|
||||||
|
|
||||||
|
folder = None
|
||||||
|
if recipe_dir:
|
||||||
|
try:
|
||||||
|
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
folder = None
|
||||||
|
if folder is not None and not folder.is_dir():
|
||||||
|
folder = None
|
||||||
|
|
||||||
|
px_per_mm = None
|
||||||
|
dxf_name = None
|
||||||
|
image_name = None
|
||||||
|
if folder is not None:
|
||||||
|
try:
|
||||||
|
cal = json.loads((folder / _WIZARD_CALIBRATION_FILE).read_text(encoding="utf-8"))
|
||||||
|
value = cal.get("px_per_mm") if isinstance(cal, dict) else None
|
||||||
|
if _is_real_number(value) and value > 0:
|
||||||
|
px_per_mm = float(value)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
px_per_mm = None # manca o illeggibile: il wizard chiedera' la scala
|
||||||
|
if (folder / _WIZARD_DXF_FILE).is_file():
|
||||||
|
dxf_name = _WIZARD_DXF_FILE
|
||||||
|
for candidate in sorted(folder.glob(f"{_WIZARD_REFERENCE_STEM}.*")):
|
||||||
|
if candidate.is_file():
|
||||||
|
image_name = candidate.name
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"band_mm": float(band), "min_points_pct": float(min_pct), "polarity": polarity,
|
||||||
|
"px_per_mm": px_per_mm, "dxf": dxf_name, "image": image_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_file_path(recipe_dir: str | None, relative: str) -> Path | None:
|
||||||
|
"""#105: percorso di un file della cartella ricetta, o None se non sicuro o assente."""
|
||||||
|
if not recipe_dir:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||||
|
target = _safe_join(folder, relative)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return None
|
||||||
|
return target if target.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
def save_result_files(
|
def save_result_files(
|
||||||
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
@@ -365,11 +623,15 @@ async def execute_task(
|
|||||||
)
|
)
|
||||||
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||||
station_url = None
|
station_url = 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:
|
if run is not None:
|
||||||
station = (await db.execute(
|
station = (await db.execute(
|
||||||
select(Station).where(Station.id == run.station_id)
|
select(Station).where(Station.id == run.station_id)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||||
|
calibration, _ = await fetch_task_calibration(task, station)
|
||||||
|
|
||||||
if not task.vision_json:
|
if not task.vision_json:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -419,31 +681,25 @@ async def execute_task(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
|
# La calibrazione viene dall'acquisitore della stazione (#83); se non c'e',
|
||||||
# passa `calibration`, la ricetta porta la sua (#46).
|
# non si passa `calibration` e la ricetta porta la sua (#46).
|
||||||
|
worker_kwargs: dict = {}
|
||||||
|
if calibration is not None:
|
||||||
|
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
||||||
report = await _call_worker(
|
report = await _call_worker(
|
||||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
worker_url=station_url, # #45
|
worker_url=station_url, # #45
|
||||||
|
**worker_kwargs,
|
||||||
)
|
)
|
||||||
outputs = report["outputs"]
|
outputs = report["outputs"]
|
||||||
|
|
||||||
# Every quote or none. A half-measured piece read as a whole one is worse
|
# #118: una quota che il grafo non produce non blocca piu' l'esecuzione
|
||||||
# than a piece not measured at all. A quote counts as produced only if its
|
# (prima 422 "the graph did not produce"): viene salvata con value NULL,
|
||||||
# output is present *and* a finite, non-bool number: the in-process runner
|
# mostrata NaN, sempre `fail`; i motivi restano nei `failures` del risultato.
|
||||||
# already drops NaN/inf itself (see src/vision/runner.py), but the worker
|
# Una quota conta come prodotta solo se l'uscita c'e' *ed* e' un numero
|
||||||
# is reached over HTTP - a different implementation of it, or a malformed
|
# finito non-bool: il worker risponde via HTTP, un'altra implementazione o
|
||||||
# response, could still hand back None or a non-finite float for a mapped
|
# una risposta malformata potrebbe dare None o un non finito per una chiave
|
||||||
# key instead of omitting it outright.
|
# mappata invece di ometterla.
|
||||||
missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))]
|
|
||||||
if missing:
|
|
||||||
failed = ", ".join(f["tool_name"] for f in report["failures"])
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=(
|
|
||||||
f"the graph did not produce {', '.join(missing)}"
|
|
||||||
+ (f" (tools that failed: {failed})" if failed else "")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
overlay = report.get("overlay") or []
|
overlay = report.get("overlay") or []
|
||||||
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
|
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
|
||||||
@@ -457,6 +713,8 @@ async def execute_task(
|
|||||||
station_id=run.station_id if run is not None else None,
|
station_id=run.station_id if run is not None else None,
|
||||||
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||||
graph_snapshot=task.vision_json,
|
graph_snapshot=task.vision_json,
|
||||||
|
# copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato
|
||||||
|
calibration_snapshot=calibration,
|
||||||
duration_ms=report.get("duration_ms"),
|
duration_ms=report.get("duration_ms"),
|
||||||
)
|
)
|
||||||
db.add(result)
|
db.add(result)
|
||||||
@@ -469,7 +727,11 @@ async def execute_task(
|
|||||||
subtask_id=quote.id,
|
subtask_id=quote.id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
measured_by=measured_by,
|
measured_by=measured_by,
|
||||||
value=float(outputs[quote.vision_output]),
|
# #118: None = quota non prodotta (NaN), salvata come fail
|
||||||
|
value=(
|
||||||
|
float(outputs[quote.vision_output])
|
||||||
|
if _is_measurable(outputs.get(quote.vision_output)) else None
|
||||||
|
),
|
||||||
lot_number=lot_number,
|
lot_number=lot_number,
|
||||||
serial_number=serial_number,
|
serial_number=serial_number,
|
||||||
input_method="camera",
|
input_method="camera",
|
||||||
@@ -481,6 +743,89 @@ async def execute_task(
|
|||||||
return result, saved, report
|
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) entrano in `measurements` con `value` None (NaN) e
|
||||||
|
`pass_fail` "fail" (#118, come execute); 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"]
|
||||||
|
# #118: una voce per ogni quota, in ordine di marker_number; non prodotta -> NaN/fail
|
||||||
|
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],
|
||||||
|
} if _is_measurable(outputs.get(q.vision_output)) else {
|
||||||
|
"subtask_id": q.id, "value": None, "pass_fail": "fail",
|
||||||
|
}
|
||||||
|
for q in quotes
|
||||||
|
]
|
||||||
|
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:
|
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
||||||
return settings.upload_path / reference.path
|
return settings.upload_path / reference.path
|
||||||
|
|
||||||
@@ -601,3 +946,62 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
|
|||||||
return await _call_worker(
|
return await _call_worker(
|
||||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_points_quote(
|
||||||
|
db: AsyncSession, task: RecipeTask, min_points_pct: float,
|
||||||
|
) -> bool:
|
||||||
|
"""Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83).
|
||||||
|
|
||||||
|
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
|
||||||
|
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
|
||||||
|
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
|
||||||
|
#105: unica eccezione, la quota nata da qui (`dxf.points_in_pct`, «Punti in
|
||||||
|
tolleranza») senza misure segue la soglia nuova: ltl = min_points_pct, e
|
||||||
|
ritorna True anche in quel caso.
|
||||||
|
"""
|
||||||
|
from src.backend.services import recipe_service # import locale: evita cicli
|
||||||
|
|
||||||
|
# #105: ricetta ripresa e salvata con un'altra soglia -> la quota la segue
|
||||||
|
own = (await db.execute(
|
||||||
|
select(RecipeSubtask)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||||
|
.where(RecipeSubtask.description == "Punti in tolleranza")
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if own is not None:
|
||||||
|
# soglia invariata o versione con misure: la quota resta com'e'
|
||||||
|
same = own.ltl is not None and float(own.ltl) == float(min_points_pct)
|
||||||
|
if same or await recipe_service.version_has_measurements(db, task.version_id):
|
||||||
|
return False
|
||||||
|
own.ltl = min_points_pct
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
existing = (await db.execute(
|
||||||
|
select(RecipeSubtask.id)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return False
|
||||||
|
if await recipe_service.version_has_measurements(db, task.version_id):
|
||||||
|
return False
|
||||||
|
markers = (await db.execute(
|
||||||
|
select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().all()
|
||||||
|
db.add(RecipeSubtask(
|
||||||
|
task_id=task.id,
|
||||||
|
marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker)
|
||||||
|
description="Punti in tolleranza",
|
||||||
|
nominal=100,
|
||||||
|
# limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct
|
||||||
|
ltl=min_points_pct,
|
||||||
|
utl=100,
|
||||||
|
unit="%",
|
||||||
|
vision_output="dxf.points_in_pct",
|
||||||
|
))
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -341,3 +341,93 @@ class TestStationCameraMigration:
|
|||||||
assert "vision_worker_url" in cols("stations") # la 019 resta
|
assert "vision_worker_url" in cols("stations") # la 019 resta
|
||||||
command.upgrade(cfg, "head") # e di nuovo su
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
assert {"camera_url", "camera_source"} <= cols("stations")
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeasurementValueNullableMigration:
|
||||||
|
"""022_measurement_value_nullable: measurements.value accetta NULL, su e giu (#118)."""
|
||||||
|
|
||||||
|
def test_022_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def value_nullable():
|
||||||
|
cols = _inspect(db_path).get_columns("measurements")
|
||||||
|
return {c["name"]: c["nullable"] for c in cols}["value"]
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert value_nullable() is True
|
||||||
|
command.downgrade(cfg, "021_task_acquisition")
|
||||||
|
assert value_nullable() is False
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert value_nullable() is True
|
||||||
|
|
||||||
|
def test_022_keeps_rows(self, sqlite_database_url):
|
||||||
|
"""#125: la ricostruzione della tabella conserva righe, somma dei value e indici."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "021_task_acquisition")
|
||||||
|
|
||||||
|
def indexes(con):
|
||||||
|
return sorted(r[0] for r in con.execute(
|
||||||
|
"select name from sqlite_master where type='index' and tbl_name='measurements'"
|
||||||
|
))
|
||||||
|
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
# Colonne NOT NULL senza default riempite per tipo (stessa idea di mig_check.py)
|
||||||
|
vals = {}
|
||||||
|
for _cid, name, typ, notnull, dflt, pk in con.execute("pragma table_info(measurements)").fetchall():
|
||||||
|
if pk or not notnull or dflt is not None:
|
||||||
|
continue
|
||||||
|
t = typ.upper()
|
||||||
|
vals[name] = (
|
||||||
|
1 if "INT" in t
|
||||||
|
else 1.5 if ("DEC" in t or "FLOAT" in t or "NUM" in t)
|
||||||
|
else "2026-01-01 00:00:00" if ("DATE" in t or "TIME" in t)
|
||||||
|
else "x"
|
||||||
|
)
|
||||||
|
vals["pass_fail"] = "pass"
|
||||||
|
vals["value"] = 12.5
|
||||||
|
sql = f"insert into measurements ({','.join(vals)}) values ({','.join('?' * len(vals))})"
|
||||||
|
for _ in range(3):
|
||||||
|
con.execute(sql, list(vals.values()))
|
||||||
|
con.commit()
|
||||||
|
before_idx = indexes(con)
|
||||||
|
before = con.execute("select count(*), sum(value) from measurements").fetchone()
|
||||||
|
con.close()
|
||||||
|
assert before == (3, 37.5)
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
assert con.execute("select count(*), sum(value) from measurements").fetchone() == before
|
||||||
|
assert indexes(con) == before_idx
|
||||||
|
assert con.execute("pragma integrity_check").fetchone()[0] == "ok"
|
||||||
|
vals["value"] = None
|
||||||
|
vals["pass_fail"] = "fail"
|
||||||
|
con.execute(sql, list(vals.values())) # una riga NaN (value NULL) ora e' accettata
|
||||||
|
con.commit()
|
||||||
|
assert con.execute("select count(*) from measurements where value is null").fetchone()[0] == 1
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|||||||
@@ -81,3 +81,29 @@ async def test_open_lists_only_open_runs_with_means(client, db_session, admin_us
|
|||||||
async def test_open_empty_for_admin(client, admin_user):
|
async def test_open_empty_for_admin(client, admin_user):
|
||||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
|
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
|
||||||
assert resp.status_code == 200 and resp.json() == []
|
assert resp.status_code == 200 and resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nan_quotes_in_the_monitor(client, db_session, admin_user):
|
||||||
|
"""#118: solo NaN -> la quota resta, mean null e fuori tolleranza; mista -> media dei numeri."""
|
||||||
|
sup = await _create_user(db_session, "capo", roles=["Supervisor"])
|
||||||
|
r1, s1, v1 = await _run(db_session, admin_user, "N1")
|
||||||
|
r2, s2, v2 = await _run(db_session, admin_user, "N2")
|
||||||
|
for i in range(2):
|
||||||
|
db_session.add(_m(r1, s1, v1, admin_user, None, "fail", i))
|
||||||
|
for i, (val, pf) in enumerate([(None, "fail"), (10.0, "pass"), (10.2, "pass")]):
|
||||||
|
db_session.add(_m(r2, s2, v2, admin_user, val, pf, i))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/api/production-runs/open", headers=auth_headers(sup))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = {r["station_code"]: r for r in resp.json()}
|
||||||
|
only_nan = body["ST-N1"]["subtasks"]
|
||||||
|
assert len(only_nan) == 1
|
||||||
|
assert only_nan[0]["mean"] is None
|
||||||
|
assert only_nan[0]["count"] == 2
|
||||||
|
assert only_nan[0]["in_tolerance"] is False
|
||||||
|
assert body["ST-N1"]["fail_count"] == 2
|
||||||
|
mixed = body["ST-N2"]["subtasks"][0]
|
||||||
|
assert mixed["count"] == 3
|
||||||
|
assert round(mixed["mean"], 6) == 10.1
|
||||||
|
assert mixed["in_tolerance"] is True
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""#118: una quota non misurata dalla visione e' una misura con value NULL (NaN), sempre fail.
|
||||||
|
|
||||||
|
Il valore NULL non deve far cadere statistiche, report, CSV e liste, e non entra
|
||||||
|
nei calcoli numerici (medie, indici, istogramma); conta solo nell'esito.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import measurement_service
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
from src.backend.tests.test_statistics import _seed_measurements
|
||||||
|
|
||||||
|
|
||||||
|
async def _recipe_with_nan(client, db_session, tec_user):
|
||||||
|
"""Una ricetta con tre misure numeriche e una NaN (value NULL, fail)."""
|
||||||
|
recipe = await create_test_recipe(db_session, tec_user.id)
|
||||||
|
subtask_id, version_id = await _seed_measurements(
|
||||||
|
client, tec_user, recipe.id, [10.0, 10.1, 9.9],
|
||||||
|
)
|
||||||
|
nan = await measurement_service.save_measurement(
|
||||||
|
db_session, subtask_id=subtask_id, version_id=version_id,
|
||||||
|
measured_by=tec_user.id, value=None, input_method="camera",
|
||||||
|
enforce_tolerance_gate=False,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
return recipe, subtask_id, version_id, nan
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_measurement_none_is_fail_without_deviation(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||||
|
):
|
||||||
|
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
|
||||||
|
assert nan.value is None
|
||||||
|
assert nan.pass_fail == "fail"
|
||||||
|
assert nan.deviation is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_create_still_requires_a_value(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||||
|
):
|
||||||
|
"""Solo il percorso visione puo' produrre NaN: la creazione manuale resta float."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _seed_measurements(
|
||||||
|
client, measurement_tec_user, recipe.id, [],
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/", headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"subtask_id": subtask_id, "version_id": version_id, "value": None},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_statistics_skip_the_nan_measurement(
|
||||||
|
client: AsyncClient, db_session: AsyncSession,
|
||||||
|
measurement_tec_user: User, metrologist_user: User,
|
||||||
|
):
|
||||||
|
recipe, subtask_id, _, _ = await _recipe_with_nan(
|
||||||
|
client, db_session, measurement_tec_user,
|
||||||
|
)
|
||||||
|
params = {"recipe_id": recipe.id, "subtask_id": subtask_id}
|
||||||
|
headers = auth_headers(metrologist_user)
|
||||||
|
|
||||||
|
cap = await client.get("/api/statistics/capability", headers=headers, params=params)
|
||||||
|
assert cap.status_code == 200
|
||||||
|
assert cap.json()["n"] == 3
|
||||||
|
assert abs(cap.json()["mean"] - 10.0) < 1e-9
|
||||||
|
|
||||||
|
chart = await client.get("/api/statistics/control-chart", headers=headers, params=params)
|
||||||
|
assert chart.status_code == 200
|
||||||
|
assert len(chart.json()["values"]) == 3
|
||||||
|
|
||||||
|
hist = await client.get("/api/statistics/histogram", headers=headers, params=params)
|
||||||
|
assert hist.status_code == 200
|
||||||
|
|
||||||
|
summary = await client.get(
|
||||||
|
"/api/statistics/summary", headers=headers, params={"recipe_id": recipe.id},
|
||||||
|
)
|
||||||
|
assert summary.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_measurement_list_returns_null_value(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||||
|
):
|
||||||
|
_, _, version_id, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/measurements/", headers=auth_headers(measurement_tec_user),
|
||||||
|
params={"version_id": version_id, "per_page": 50},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
rows = {m["id"]: m for m in resp.json()["items"]}
|
||||||
|
assert rows[nan.id]["value"] is None
|
||||||
|
assert rows[nan.id]["pass_fail"] == "fail"
|
||||||
|
assert rows[nan.id]["deviation"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_csv_export_writes_nan(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||||
|
):
|
||||||
|
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/measurements/export/csv", headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
rows = list(csv.reader(resp.text.splitlines()))
|
||||||
|
header = rows[0]
|
||||||
|
by_id = {r[header.index("id")]: r for r in rows[1:]}
|
||||||
|
row = by_id[str(nan.id)]
|
||||||
|
assert row[header.index("value")] == "NaN"
|
||||||
|
assert row[header.index("pass_fail")] == "fail"
|
||||||
|
assert row[header.index("deviation")] == "NaN" # #124
|
||||||
@@ -836,3 +836,31 @@ async def test_close_without_measurements_writes_no_file(
|
|||||||
)).json()
|
)).json()
|
||||||
assert closed["statistics_path"] is None
|
assert closed["statistics_path"] is None
|
||||||
assert not (tmp_path / "statistics").exists()
|
assert not (tmp_path / "statistics").exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_statistics_csv_writes_nan_value_and_deviation(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""#124: una quota NaN (value NULL) esce NaN sia nel valore sia nella deviazione."""
|
||||||
|
from src.backend.services import measurement_service, production_export_service
|
||||||
|
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-NAN")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-NAN")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
subtask = await _first_subtask(db_session, recipe.id)
|
||||||
|
await measurement_service.save_measurement(
|
||||||
|
db_session, subtask_id=subtask.id, version_id=opened["version_id"],
|
||||||
|
measured_by=measurement_tec_user.id, value=None, input_method="camera",
|
||||||
|
production_run_id=opened["id"], enforce_tolerance_gate=False,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
run = (await db_session.execute(
|
||||||
|
select(ProductionRun).where(ProductionRun.id == opened["id"])
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
text = await production_export_service.build_statistics_csv(db_session, run)
|
||||||
|
lines = [line for line in text.lstrip("").splitlines() if line.strip()]
|
||||||
|
header = lines[0].split(",")
|
||||||
|
row = lines[1].split(",")
|
||||||
|
assert row[header.index("value")] == "NaN"
|
||||||
|
assert row[header.index("deviation")] == "NaN"
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Test di scripts/seed_fiera.py (issue #85): il seed del PC della fiera.
|
||||||
|
|
||||||
|
Lo script non e' un modulo del pacchetto: si carica da file con importlib, come
|
||||||
|
test_seed_collaudo. La parte "dato" gira sul DB in memoria dei test; l'ultimo test
|
||||||
|
percorre invece la strada vera (file SQLite con `alembic upgrade head` + --db).
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import sqlite3
|
||||||
|
from argparse import Namespace
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from src.backend.config import Settings, settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import Recipe
|
||||||
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services.auth_service import authenticate_user
|
||||||
|
from src.backend.tests.conftest import TestSessionFactory, test_engine
|
||||||
|
|
||||||
|
_SCRIPT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "seed_fiera.py"
|
||||||
|
_ALEMBIC_INI = Path(__file__).resolve().parents[1] / "migrations" / "alembic.ini"
|
||||||
|
FINE = date(2026, 10, 22) # data fissa: il test non dipende dal giorno in cui gira
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seed_fiera(monkeypatch):
|
||||||
|
spec = importlib.util.spec_from_file_location("seed_fiera", _SCRIPT_PATH)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
monkeypatch.setattr(module, "async_session_factory", TestSessionFactory)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
async def _utente(db_session, username) -> User:
|
||||||
|
return (await db_session.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSeedFiera:
|
||||||
|
async def test_utenti_ruoli_e_login(self, seed_fiera, db_session):
|
||||||
|
assert await seed_fiera.seed(False, False, FINE) == 0
|
||||||
|
|
||||||
|
attesi = {
|
||||||
|
"adriano": ("123", True),
|
||||||
|
"maker": ("123", False),
|
||||||
|
"capoturno": ("123", False),
|
||||||
|
"operatore": ("123", False),
|
||||||
|
}
|
||||||
|
for username, (password, admin) in attesi.items():
|
||||||
|
utente = await authenticate_user(db_session, username, password)
|
||||||
|
assert utente is not None, username
|
||||||
|
assert utente.is_admin is admin
|
||||||
|
assert (await _utente(db_session, "maker")).roles == ["Maker"]
|
||||||
|
assert "Supervisor" in (await _utente(db_session, "capoturno")).roles
|
||||||
|
assert (await _utente(db_session, "operatore")).roles == ["MeasurementTec"]
|
||||||
|
assert {"Maker", "Metrologist"} <= set((await _utente(db_session, "adriano")).roles)
|
||||||
|
|
||||||
|
async def test_setup_risulta_completato(self, seed_fiera, client, monkeypatch):
|
||||||
|
import src.backend.api.routers.setup as setup_mod
|
||||||
|
monkeypatch.setattr(settings, "setup_password", "test-setup-password")
|
||||||
|
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||||
|
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||||
|
|
||||||
|
# prima del seed il frontend vede il setup da fare, dopo no
|
||||||
|
assert (await client.get("/api/setup/status")).json() == {"initialised": False}
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
assert (await client.get("/api/setup/status")).json() == {"initialised": True}
|
||||||
|
|
||||||
|
async def test_stazione_e_ricette(self, seed_fiera, db_session):
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
|
||||||
|
stazione = (await db_session.execute(
|
||||||
|
select(Station).where(Station.code == "ST-DEFAULT")
|
||||||
|
)).scalar_one()
|
||||||
|
assert stazione.camera_url == "http://127.0.0.1:8765"
|
||||||
|
assert stazione.camera_source == "camera:601020"
|
||||||
|
|
||||||
|
assegnate = (await db_session.execute(
|
||||||
|
select(Recipe.code)
|
||||||
|
.join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id)
|
||||||
|
.where(StationRecipeAssignment.station_id == stazione.id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert sorted(assegnate) == ["FIERA-ALBERO", "FIERA-DXF", "FIERA-FLANGIA"]
|
||||||
|
|
||||||
|
# la ricetta DXF ha un task dxf_compare e nessun grafo di visione
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
dxf = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.task_type == "dxf_compare")
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(dxf) == 1 and dxf[0].vision_json is None
|
||||||
|
|
||||||
|
async def test_misure_nel_numero_atteso(self, seed_fiera, db_session):
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
|
||||||
|
# ogni pezzo ha una misura per quota: pezzi 150-300 per ricetta
|
||||||
|
for codice, quote in (("FIERA-FLANGIA", 5), ("FIERA-ALBERO", 4)):
|
||||||
|
misure = (await db_session.execute(
|
||||||
|
select(func.count(Measurement.id))
|
||||||
|
.where(Measurement.version_id.in_(
|
||||||
|
select(seed_fiera.RecipeVersion.id)
|
||||||
|
.join(Recipe, Recipe.id == seed_fiera.RecipeVersion.recipe_id)
|
||||||
|
.where(Recipe.code == codice)
|
||||||
|
))
|
||||||
|
)).scalar_one()
|
||||||
|
assert misure % quote == 0
|
||||||
|
assert 150 <= misure // quote <= 300, (codice, misure // quote)
|
||||||
|
|
||||||
|
# lun-ven soltanto, e le misure compaiono nelle produzioni
|
||||||
|
giorni = (await db_session.execute(
|
||||||
|
select(seed_fiera.ProductionRun.started_at)
|
||||||
|
)).scalars().all()
|
||||||
|
assert giorni and all(g.weekday() < 5 for g in giorni)
|
||||||
|
senza_run = (await db_session.execute(
|
||||||
|
select(func.count(Measurement.id)).where(Measurement.production_run_id.is_(None))
|
||||||
|
)).scalar_one()
|
||||||
|
assert senza_run == 0
|
||||||
|
|
||||||
|
async def test_statistiche_numeriche(self, seed_fiera, client, db_session):
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
adriano = await _utente(db_session, "adriano")
|
||||||
|
from src.backend.services.auth_service import issue_api_key
|
||||||
|
chiave = await issue_api_key(db_session, adriano)
|
||||||
|
intestazioni = {"X-API-Key": chiave}
|
||||||
|
|
||||||
|
flangia = (await db_session.execute(
|
||||||
|
select(Recipe).where(Recipe.code == "FIERA-FLANGIA")
|
||||||
|
)).scalar_one()
|
||||||
|
quote = (await client.get(
|
||||||
|
"/api/statistics/subtasks", headers=intestazioni,
|
||||||
|
params={"recipe_id": flangia.id},
|
||||||
|
)).json()
|
||||||
|
assert len(quote) == 5
|
||||||
|
cpk = []
|
||||||
|
for quota in quote:
|
||||||
|
risposta = await client.get(
|
||||||
|
"/api/statistics/capability", headers=intestazioni,
|
||||||
|
params={"recipe_id": flangia.id, "subtask_id": quota["id"]},
|
||||||
|
)
|
||||||
|
assert risposta.status_code == 200
|
||||||
|
d = risposta.json()
|
||||||
|
for campo in ("mean", "std_dev", "cp", "cpk"):
|
||||||
|
assert isinstance(d[campo], (int, float)), (quota["id"], campo)
|
||||||
|
cpk.append(d["cpk"])
|
||||||
|
# Cpk diversi per quota: una capace, una al limite o sotto
|
||||||
|
assert max(cpk) > 1.4
|
||||||
|
assert min(cpk) < 1.1
|
||||||
|
|
||||||
|
async def test_secondo_giro_non_duplica_e_replace_rifa(self, seed_fiera, db_session):
|
||||||
|
async def conta():
|
||||||
|
return [
|
||||||
|
(await db_session.execute(select(func.count(t.id)))).scalar_one()
|
||||||
|
for t in (Recipe, Measurement, User)
|
||||||
|
]
|
||||||
|
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
primo = await conta()
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
assert await conta() == primo # utenti aggiornati, ricette e misure non duplicate
|
||||||
|
|
||||||
|
await seed_fiera.seed(True, False, FINE)
|
||||||
|
assert await conta() == primo # stessi numeri: il seme e' fisso
|
||||||
|
|
||||||
|
async def test_dry_run_non_salva(self, seed_fiera, db_session):
|
||||||
|
await seed_fiera.seed(False, True, FINE)
|
||||||
|
assert (await db_session.execute(select(func.count(User.id)))).scalar_one() == 0
|
||||||
|
assert (await db_session.execute(select(func.count(Measurement.id)))).scalar_one() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_file_sqlite_con_alembic(seed_fiera, tmp_path, monkeypatch):
|
||||||
|
"""La strada vera: schema da `alembic upgrade head`, poi il seed con --db."""
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
url = f"sqlite+aiosqlite:///{db.as_posix()}"
|
||||||
|
monkeypatch.setattr(Settings, "database_url", property(lambda self: url))
|
||||||
|
# alembic fa asyncio.run(): fuori dal loop di pytest-asyncio, in un thread
|
||||||
|
import asyncio
|
||||||
|
await asyncio.to_thread(command.upgrade, Config(str(_ALEMBIC_INI)), "head")
|
||||||
|
|
||||||
|
argomenti = Namespace(db=db, replace=False, dry_run=False, fine=FINE.isoformat())
|
||||||
|
assert await seed_fiera._main_async(argomenti) == 0
|
||||||
|
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
try:
|
||||||
|
assert con.execute("SELECT COUNT(*) FROM users WHERE is_admin=1").fetchone()[0] == 1
|
||||||
|
assert con.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] == 3
|
||||||
|
assert con.execute("SELECT COUNT(*) FROM measurements").fetchone()[0] > 1000
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""#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_lists_quotes_the_graph_did_not_produce_as_nan(
|
||||||
|
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
|
||||||
|
# #118: la quota non prodotta c'e', value null (NaN) e fail
|
||||||
|
measurements = resp.json()["measurements"]
|
||||||
|
assert len(measurements) == 1
|
||||||
|
assert measurements[0]["value"] is None and measurements[0]["pass_fail"] == "fail"
|
||||||
|
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)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""#83: ricetta DXF compare da camera e calibrazione letta dall'acquisitore.
|
||||||
|
|
||||||
|
Il worker (W2) e l'acquisitore (C1) non girano nei test: si fingono, come
|
||||||
|
fanno gli altri test di visione. La calibrazione vive nell'acquisitore esterno,
|
||||||
|
TMF la legge via GET {camera_url}/camera/calibration.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
|
||||||
|
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
|
||||||
|
_camera_task, _execute, _station_with_run, worker_calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
CAMERA_URL = "http://10.0.0.9:8200"
|
||||||
|
CAL = {
|
||||||
|
"source": "camera:A1", "px_per_mm": 12.5, "mm_per_px": 0.08, "rms_px": 0.2,
|
||||||
|
"n_corners": 40, "cols": 8, "rows": 5, "square_mm": 5.0, "image_width": 640,
|
||||||
|
"image_height": 480, "scale_spread_pct": 0.1, "calibrated_at": "2026-10-23T10:00:00",
|
||||||
|
}
|
||||||
|
GRAPH = {
|
||||||
|
"schema_version": 2, "name": "dxf-compare", "tools": [],
|
||||||
|
"meta": {"dxf": {"disegno": {"path": "disegno.dxf"}}, "calibration": {"path": "calibrazione.json"}},
|
||||||
|
}
|
||||||
|
OUTPUTS = [{"key": "dxf.points_in_pct", "quantity": "ratio", "unit": "%"}]
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(data: bytes) -> str:
|
||||||
|
return base64.b64encode(data).decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def redirect_uploads(monkeypatch, tmp_path):
|
||||||
|
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def w2(monkeypatch):
|
||||||
|
"""Finto worker W2: ricorda le chiamate e risponde col corpo del contratto."""
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def fake(image_bytes, dxf_bytes, **kwargs):
|
||||||
|
calls.append({"dxf": dxf_bytes, **kwargs})
|
||||||
|
return {
|
||||||
|
"graph": GRAPH,
|
||||||
|
"files": {
|
||||||
|
"disegno.dxf": _b64(dxf_bytes),
|
||||||
|
"calibrazione.json": _b64(json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()),
|
||||||
|
},
|
||||||
|
"outputs": {"dxf.points_in_pct": 99.0},
|
||||||
|
"overlay": [], "outcome": "pass",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def validate_ok(monkeypatch):
|
||||||
|
async def fake(graph, recipe_dir):
|
||||||
|
return {"ok": True, "errors": [], "outputs": OUTPUTS}
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def acquisitore(monkeypatch):
|
||||||
|
"""Finto C1: `state['cal']` = dict (200), None (404), 'down' (irraggiungibile)."""
|
||||||
|
state: dict = {"cal": CAL, "urls": []}
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, code, body=None):
|
||||||
|
self.status_code, self._body, self.text = code, body, ""
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get(self, url, params=None):
|
||||||
|
state["urls"].append((url, params))
|
||||||
|
if state["cal"] == "down":
|
||||||
|
raise httpx.ConnectError("giu'")
|
||||||
|
if state["cal"] is None:
|
||||||
|
return _Resp(404)
|
||||||
|
return _Resp(200, state["cal"])
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", _Client)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
async def _station(db_session, user, *, camera=True, worker_url=None, code="ST-C"):
|
||||||
|
station = Station(
|
||||||
|
code=code, name="Stazione C", active=True, created_by=user.id,
|
||||||
|
vision_worker_url=worker_url,
|
||||||
|
camera_url=CAMERA_URL if camera else None,
|
||||||
|
camera_source="camera:A1" if camera else None,
|
||||||
|
)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(station)
|
||||||
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
def _post(client, user, task, *, save=False, **data):
|
||||||
|
return client.post(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={
|
||||||
|
"image": ("p.png", an_image(), "image/png"),
|
||||||
|
"dxf": ("d.dxf", b"0\nEOF", "application/octet-stream"),
|
||||||
|
},
|
||||||
|
data={"save": str(save).lower(), **{k: str(v) for k, v in data.items() if v is not None}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- lettura della calibrazione dall'acquisitore (C1) -------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_calibration_ok(db_session, maker_user, acquisitore):
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
cal, reason = await vision_service.fetch_station_calibration(station)
|
||||||
|
assert (cal, reason) == (CAL, "ok")
|
||||||
|
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_calibration_reasons(db_session, maker_user, acquisitore):
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
acquisitore["cal"] = None
|
||||||
|
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
|
||||||
|
acquisitore["cal"] = "down"
|
||||||
|
assert await vision_service.fetch_station_calibration(station) == (None, "unreachable")
|
||||||
|
assert await vision_service.get_station_calibration(station) is None
|
||||||
|
acquisitore["cal"] = {**CAL, "px_per_mm": 0} # valore inutilizzabile = non calibrata
|
||||||
|
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
|
||||||
|
no_camera = await _station(db_session, maker_user, camera=False, code="ST-N")
|
||||||
|
assert await vision_service.fetch_station_calibration(no_camera) == (None, "no_camera")
|
||||||
|
assert await vision_service.fetch_station_calibration(None) == (None, "no_camera")
|
||||||
|
|
||||||
|
|
||||||
|
# --- B3: POST /api/vision/tasks/{id}/recipe/dxf-compare -----------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_preview_writes_nothing(
|
||||||
|
client, db_session, maker_user, w2, acquisitore,
|
||||||
|
):
|
||||||
|
task, subtask = await make_task(db_session, maker_user)
|
||||||
|
station = await _station(db_session, maker_user, worker_url="http://10.0.0.7:8100")
|
||||||
|
|
||||||
|
resp = await _post(client, maker_user, task, station_id=station.id, band_mm=0.2,
|
||||||
|
min_points_pct=90, polarity="light")
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["saved"] is False and body["recipe_dir"] is None and body["outputs"] is None
|
||||||
|
assert "files" not in body["result"] and body["result"]["graph"] == GRAPH
|
||||||
|
# px_per_mm dall'acquisitore, parametri e worker della stazione
|
||||||
|
assert w2[0]["px_per_mm"] == 12.5 and w2[0]["band_mm"] == 0.2
|
||||||
|
assert w2[0]["min_points_pct"] == 90 and w2[0]["polarity"] == "light"
|
||||||
|
assert w2[0]["worker_url"] == "http://10.0.0.7:8100"
|
||||||
|
await db_session.refresh(task)
|
||||||
|
assert task.vision_json is None and task.vision_recipe_dir is None
|
||||||
|
quotes = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||||
|
assert [q.id for q in quotes] == [subtask.id] # nessuna quota nuova
|
||||||
|
assert not (settings.upload_path / "vision" / "recipes").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_save_stores_recipe_and_quote_once(
|
||||||
|
client, db_session, maker_user, w2, validate_ok,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
|
||||||
|
resp = await _post(client, maker_user, task, save=True, px_per_mm=10, min_points_pct=95)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["saved"] is True and body["outputs"] == OUTPUTS
|
||||||
|
assert "files" not in body["result"]
|
||||||
|
folder = settings.upload_path / body["recipe_dir"]
|
||||||
|
assert (folder / "disegno.dxf").read_bytes() == b"0\nEOF"
|
||||||
|
assert json.loads((folder / "calibrazione.json").read_text()) == {"px_per_mm": 10.0}
|
||||||
|
await db_session.refresh(task)
|
||||||
|
assert task.vision_json == GRAPH and task.vision_recipe_dir == body["recipe_dir"]
|
||||||
|
|
||||||
|
quotes = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||||
|
.where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||||
|
assert len(quotes) == 1
|
||||||
|
q = quotes[0]
|
||||||
|
assert q.description == "Punti in tolleranza"
|
||||||
|
assert (float(q.nominal), float(q.ltl), float(q.utl)) == (100.0, 95.0, 100.0)
|
||||||
|
|
||||||
|
# secondo salvataggio: la quota non si duplica
|
||||||
|
task_id = task.id # dopo expire_all l'attributo scaduto non si rilegge in async
|
||||||
|
again = await _post(client, maker_user, task, save=True, px_per_mm=10)
|
||||||
|
assert again.status_code == 200
|
||||||
|
db_session.expire_all()
|
||||||
|
count = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.where(RecipeSubtask.task_id == task_id))).scalars().all()
|
||||||
|
assert len(count) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_keeps_an_existing_vision_quote(
|
||||||
|
client, db_session, maker_user, w2, validate_ok,
|
||||||
|
):
|
||||||
|
task, subtask = await make_task(db_session, maker_user)
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await _post(client, maker_user, task, save=True, px_per_mm=10)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
quotes = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||||
|
assert len(quotes) == 1 # niente quota in piu'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_uncalibrated_station_is_422(
|
||||||
|
client, db_session, maker_user, w2, acquisitore,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
acquisitore["cal"] = None
|
||||||
|
resp = await _post(client, maker_user, task, station_id=station.id)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == ["stazione non calibrata"]
|
||||||
|
assert w2 == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_unreachable_acquirer_is_422_with_its_own_message(
|
||||||
|
client, db_session, maker_user, w2, acquisitore,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
acquisitore["cal"] = "down"
|
||||||
|
resp = await _post(client, maker_user, task, station_id=station.id)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == ["acquisitore della stazione non raggiungibile"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_without_station_nor_scale_is_422(client, db_session, maker_user, w2):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await _post(client, maker_user, task)
|
||||||
|
assert resp.status_code == 422 and resp.json()["detail"] == ["stazione non calibrata"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_needs_maker(client, db_session, maker_user, measurement_tec_user, w2):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await _post(client, measurement_tec_user, task, px_per_mm=10)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_worker_422_is_forwarded(
|
||||||
|
client, db_session, maker_user, monkeypatch,
|
||||||
|
):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
async def refuse(*a, **k):
|
||||||
|
raise HTTPException(status_code=422, detail="pezzo non trovato")
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", refuse)
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await _post(client, maker_user, task, px_per_mm=10)
|
||||||
|
assert resp.status_code == 422 and resp.json()["detail"] == "pezzo non trovato"
|
||||||
|
|
||||||
|
|
||||||
|
# --- execute: calibrazione della stazione -------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_passes_station_calibration_and_snapshots_it(
|
||||||
|
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, None)
|
||||||
|
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
|
||||||
|
assert worker_calls[0]["calibration"] == {"px_per_mm": 12.5}
|
||||||
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
assert result.calibration_snapshot == CAL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_without_calibration_is_unchanged(
|
||||||
|
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, None)
|
||||||
|
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||||
|
await db_session.commit()
|
||||||
|
acquisitore["cal"] = "down" # acquisitore giu': si misura come prima, senza calibrazione
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
|
||||||
|
assert "calibration" not in worker_calls[0]
|
||||||
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
assert result.calibration_snapshot is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_station_without_camera_never_asks(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
_, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
|
||||||
|
assert acquisitore["urls"] == [] and "calibration" not in worker_calls[0]
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""#101: POST /api/vision/dxf-preview, anteprima del DXF nel wizard.
|
||||||
|
|
||||||
|
Il worker non gira nei test: `vision_service.dxf_preview` si sostituisce.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
|
||||||
|
BODY = {
|
||||||
|
"contorni": [[[0.0, 0.0], [10.0, 0.0], [10.0, 5.0], [0.0, 5.0], [0.0, 0.0]]],
|
||||||
|
"bbox": {"xmin": 0.0, "ymin": 0.0, "xmax": 10.0, "ymax": 5.0},
|
||||||
|
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _post(client, user, content=b"0\nEOF"):
|
||||||
|
return client.post(
|
||||||
|
"/api/vision/dxf-preview",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={"dxf": ("d.dxf", content, "application/octet-stream")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_forwards_the_file_and_returns_the_worker_body(
|
||||||
|
client, maker_user, monkeypatch,
|
||||||
|
):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
calls.append(dxf_bytes)
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, maker_user, b"DXF-BYTES")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == BODY
|
||||||
|
assert calls == [b"DXF-BYTES"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_forwards_the_worker_422(client, maker_user, monkeypatch):
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
raise HTTPException(status_code=422, detail="DXF illeggibile: file vuoto")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, maker_user, b"")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == "DXF illeggibile: file vuoto"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_requires_maker(client, measurement_tec_user, monkeypatch):
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, measurement_tec_user)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_too_big_is_422(client, maker_user, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
|
||||||
|
resp = await _post(client, maker_user, b"x" * 10)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_service_posts_to_worker_and_checks_shape(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def fake_multipart(path, *, files, data, worker_url=None, timeout=120.0):
|
||||||
|
seen.update(path=path, files=files)
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr(vision_service, "call_worker_multipart", fake_multipart)
|
||||||
|
assert await vision_service.dxf_preview(b"abc") == BODY
|
||||||
|
assert seen["path"] == "/dxf/preview" and seen["files"]["dxf"][1] == b"abc"
|
||||||
|
|
||||||
|
async def malformed(path, *, files, data, worker_url=None, timeout=120.0):
|
||||||
|
return {"contorni": []}
|
||||||
|
|
||||||
|
monkeypatch.setattr(vision_service, "call_worker_multipart", malformed)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await vision_service.dxf_preview(b"abc")
|
||||||
|
assert exc.value.status_code == 502
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""#105: la ricetta «Crea ricetta da DXF» si puo' riaprire e modificare.
|
||||||
|
|
||||||
|
Il worker non gira nei test: si finge come in test_vision_dxf_compare.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture)
|
||||||
|
redirect_uploads, validate_ok,
|
||||||
|
)
|
||||||
|
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
|
||||||
|
|
||||||
|
DXF = b"0\nEOF"
|
||||||
|
|
||||||
|
|
||||||
|
def _wizard_graph(band=0.3, pct=90.0, polarity="light") -> dict:
|
||||||
|
"""Grafo come lo costruisce src/vision/costruttori.py::_graph."""
|
||||||
|
return {
|
||||||
|
"schema_version": 2, "name": "Confronto DXF",
|
||||||
|
"meta": {"dxf": {"disegno": {"path": "disegno.dxf", "sha256": "x"}},
|
||||||
|
"calibration": {"path": "calibrazione.json", "sha256": "y"}},
|
||||||
|
"tools": [
|
||||||
|
{"id": "pezzo", "name": "Pezzo", "kind": "LocateBlob",
|
||||||
|
"values": {"polarity": polarity}, "links": {}},
|
||||||
|
{"id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf",
|
||||||
|
"values": {"band_plus": band, "band_minus": band, "points_in_pct_nominal": pct},
|
||||||
|
"links": {}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def w2(monkeypatch):
|
||||||
|
async def fake(image_bytes, dxf_bytes, **kwargs):
|
||||||
|
graph = _wizard_graph(kwargs["band_mm"], kwargs["min_points_pct"], kwargs["polarity"])
|
||||||
|
return {
|
||||||
|
"graph": graph,
|
||||||
|
"files": {
|
||||||
|
"disegno.dxf": base64.b64encode(dxf_bytes).decode(),
|
||||||
|
"calibrazione.json": base64.b64encode(
|
||||||
|
json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()).decode(),
|
||||||
|
},
|
||||||
|
"outputs": {"dxf.points_in_pct": 99.0}, "overlay": [], "outcome": "pass",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
|
||||||
|
|
||||||
|
|
||||||
|
def _bmp() -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("L", (32, 32)).save(buf, format="BMP")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _save(client, user, task, *, image=None, ctype="image/png", **data):
|
||||||
|
return client.post(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={"image": ("p", image if image is not None else an_image(), ctype),
|
||||||
|
"dxf": ("d.dxf", DXF, "application/octet-stream")},
|
||||||
|
data={"save": "true", "px_per_mm": "10", **{k: str(v) for k, v in data.items()}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_keeps_the_reference_image_png(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
image = an_image()
|
||||||
|
|
||||||
|
resp = await _save(client, maker_user, task, image=image)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
folder = settings.upload_path / resp.json()["recipe_dir"]
|
||||||
|
assert (folder / "riferimento.png").read_bytes() == image
|
||||||
|
assert (folder / "disegno.dxf").read_bytes() == DXF # il resto della cartella non cambia
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_keeps_the_reference_image_bmp(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
image = _bmp()
|
||||||
|
|
||||||
|
resp = await _save(client, maker_user, task, image=image, ctype="image/bmp")
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
folder = settings.upload_path / resp.json()["recipe_dir"]
|
||||||
|
assert (folder / "riferimento.bmp").read_bytes() == image
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_returns_what_the_wizard_saved(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
await _save(client, maker_user, task, band_mm=0.3, min_points_pct=90, polarity="light")
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source", headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == {
|
||||||
|
"band_mm": 0.3, "min_points_pct": 90.0, "polarity": "light", "px_per_mm": 10.0,
|
||||||
|
"dxf": "disegno.dxf", "image": "riferimento.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_404_without_recipe_or_with_a_hand_made_graph(client, db_session, maker_user):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
url = f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source"
|
||||||
|
|
||||||
|
assert (await client.get(url, headers=auth_headers(maker_user))).status_code == 404
|
||||||
|
|
||||||
|
task.vision_json = {"schema_version": 2, "name": "a mano", "tools": [], "meta": {}}
|
||||||
|
await db_session.commit()
|
||||||
|
resp = await client.get(url, headers=auth_headers(maker_user))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.json()["detail"] == "nessuna ricetta da DXF su questo task"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_needs_maker(client, db_session, maker_user, measurement_tec_user):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_of_an_old_recipe_has_no_image_and_no_unreadable_scale(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
folder = tmp_path / "vision" / "recipes" / "1" / "abc"
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(folder / "disegno.dxf").write_bytes(DXF)
|
||||||
|
(folder / "calibrazione.json").write_text("{non json", encoding="utf-8")
|
||||||
|
|
||||||
|
source = vision_service.dxf_compare_source(_wizard_graph(), "vision/recipes/1/abc")
|
||||||
|
|
||||||
|
assert source["image"] is None and source["px_per_mm"] is None
|
||||||
|
assert source["dxf"] == "disegno.dxf"
|
||||||
|
assert vision_service.dxf_compare_source(None, None) is None
|
||||||
|
# senza cartella i valori del grafo restano
|
||||||
|
assert vision_service.dxf_compare_source(_wizard_graph(), None)["dxf"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_files_returns_the_bytes(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
image = _bmp()
|
||||||
|
await _save(client, maker_user, task, image=image, ctype="image/bmp")
|
||||||
|
|
||||||
|
img = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/files/riferimento.bmp", headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
dxf = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/files/disegno.dxf", headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert img.status_code == 200 and img.content == image
|
||||||
|
assert img.headers["content-type"].startswith("image/")
|
||||||
|
assert dxf.status_code == 200 and dxf.content == DXF
|
||||||
|
assert dxf.headers["content-type"] # dal tipo dell'estensione, di ripiego octet-stream
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_files_404_on_unsafe_missing_or_no_recipe(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
base = f"/api/vision/tasks/{task.id}/recipe/files/"
|
||||||
|
headers = auth_headers(maker_user)
|
||||||
|
|
||||||
|
assert (await client.get(base + "disegno.dxf", headers=headers)).status_code == 404 # nessuna ricetta
|
||||||
|
|
||||||
|
await _save(client, maker_user, task)
|
||||||
|
assert (await client.get(base + "manca.bmp", headers=headers)).status_code == 404
|
||||||
|
for unsafe in ("../x", "%2e%2e/x", "..%2f..%2fx", "sub/../../x"):
|
||||||
|
assert (await client.get(base + unsafe, headers=headers)).status_code == 404, unsafe
|
||||||
|
# la funzione non lascia uscire dalla cartella nemmeno con un assoluto
|
||||||
|
assert vision_service.recipe_file_path("vision/recipes/1/abc", "../x") is None
|
||||||
|
assert vision_service.recipe_file_path("vision/recipes/1/abc", "/etc/passwd") is None
|
||||||
|
assert vision_service.recipe_file_path("../fuori", "x") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resave_follows_the_new_threshold(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
task_id = task.id
|
||||||
|
await _save(client, maker_user, task, min_points_pct=95)
|
||||||
|
|
||||||
|
resp = await _save(client, maker_user, task, min_points_pct=80)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
db_session.expire_all()
|
||||||
|
quotes = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||||
|
.where(RecipeSubtask.task_id == task_id))).scalars().all()
|
||||||
|
assert len(quotes) == 1 and float(quotes[0].ltl) == 80.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_points_quote_leaves_quote_with_measurements(
|
||||||
|
client, db_session, maker_user, w2, validate_ok, monkeypatch,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
task_id = task.id
|
||||||
|
await _save(client, maker_user, task, min_points_pct=95)
|
||||||
|
|
||||||
|
async def has_measurements(db, version_id):
|
||||||
|
return True
|
||||||
|
monkeypatch.setattr("src.backend.services.recipe_service.version_has_measurements", has_measurements)
|
||||||
|
task = await db_session.get(RecipeTask, task_id)
|
||||||
|
|
||||||
|
changed = await vision_service.ensure_points_quote(db_session, task, 80)
|
||||||
|
|
||||||
|
assert changed is False
|
||||||
|
quote = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||||
|
.where(RecipeSubtask.task_id == task_id))).scalar_one()
|
||||||
|
assert float(quote.ltl) == 95.0
|
||||||
@@ -134,72 +134,134 @@ async def test_the_execution_records_which_engine_produced_it(
|
|||||||
assert result.graph_snapshot == task.vision_json
|
assert result.graph_snapshot == task.vision_json
|
||||||
|
|
||||||
|
|
||||||
|
# #118: una quota che il grafo non produce non e' piu' un 422: e' NaN (value
|
||||||
|
# NULL), `fail`, salvata con le altre.
|
||||||
|
async def _assert_nan_saved(client, db_session, user, task, first, second):
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert [m["subtask_id"] for m in body["measurements"]] == [first.id, second.id]
|
||||||
|
assert body["measurements"][0]["value"] is None
|
||||||
|
assert body["measurements"][0]["pass_fail"] == "fail"
|
||||||
|
assert body["measurements"][1]["value"] == GOOD
|
||||||
|
rows = (await db_session.execute(
|
||||||
|
select(Measurement).order_by(Measurement.subtask_id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0].value is None
|
||||||
|
assert rows[0].pass_fail == "fail"
|
||||||
|
assert rows[0].deviation is None
|
||||||
|
assert rows[0].vision_result_id is not None
|
||||||
|
assert rows[1].value is not None and rows[1].pass_fail == "pass"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_missing_output_names_itself(
|
async def test_a_missing_output_is_saved_as_nan(
|
||||||
client, db_session, measurement_tec_user, worker_says,
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
):
|
):
|
||||||
"""No half measurement: if an output is missing, the error names it."""
|
"""#118: output assente -> 200, misura NULL/fail, le altre quote salvate."""
|
||||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
task.task_type = "camera_measure"
|
worker_says({"t1.radius": GOOD}, failures=[{"tool_name": "arco_tr"}])
|
||||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||||
subtask.vision_output = "t1.diameter"
|
|
||||||
await db_session.commit()
|
|
||||||
worker_says({"t1.radius": 5.0})
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_outputs_missing_still_saves_every_quote(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""#118: anche senza nessuna uscita, una misura NaN per quota."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
worker_says({})
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/vision/execute",
|
"/api/vision/execute",
|
||||||
headers=auth_headers(measurement_tec_user),
|
headers=auth_headers(measurement_tec_user),
|
||||||
files={"image": ("p.png", an_image(), "image/png")},
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
data={"task_id": str(task.id), "lot_number": "L1"},
|
data={"task_id": str(task.id)},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 422
|
assert response.status_code == 200
|
||||||
assert "t1.diameter" in response.json()["detail"]
|
assert [m["value"] for m in response.json()["measurements"]] == [None, None]
|
||||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
rows = (await db_session.execute(select(Measurement))).scalars().all()
|
||||||
|
assert len(rows) == 2 and all(r.value is None and r.pass_fail == "fail" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_null_output_counts_as_not_produced(
|
async def test_a_null_output_is_saved_as_nan(
|
||||||
client, db_session, measurement_tec_user, worker_says,
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
):
|
):
|
||||||
"""A worker answering with the key present but null (not omitted) must not
|
"""Chiave presente ma null (non omessa): NaN, non TypeError (#16, #118)."""
|
||||||
reach float(None) as a bare TypeError - it is "not produced", same as a
|
|
||||||
missing key (#16)."""
|
|
||||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
worker_says({"t1.diameter": None, "t1.radius": GOOD})
|
worker_says({"t1.diameter": None, "t1.radius": GOOD})
|
||||||
|
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||||
response = await client.post(
|
|
||||||
"/api/vision/execute",
|
|
||||||
headers=auth_headers(measurement_tec_user),
|
|
||||||
files={"image": ("p.png", an_image(), "image/png")},
|
|
||||||
data={"task_id": str(task.id)},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert "t1.diameter" in response.json()["detail"]
|
|
||||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_non_finite_output_counts_as_not_produced(
|
async def test_a_non_finite_output_is_saved_as_nan(
|
||||||
client, db_session, measurement_tec_user, worker_says,
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
):
|
):
|
||||||
"""NaN/inf must not become a saved measurement (#16)."""
|
"""NaN/inf non diventano un valore salvato: diventano NaN (NULL) (#16, #118)."""
|
||||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD})
|
worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD})
|
||||||
|
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_nan_quote_needs_supervisor_authorisation(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""#125: la quota NaN e' un fail come gli altri e chiede il capoturno (pending_authorisation);
|
||||||
|
la prossima acquisizione non e' bloccata."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
worker_says({"t1.radius": GOOD})
|
||||||
|
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||||
|
|
||||||
|
blocking = await measurement_service.pending_authorisation(
|
||||||
|
db_session, version.id, measurement_tec_user.id,
|
||||||
|
)
|
||||||
|
assert blocking is not None and blocking.subtask_id == first.id and blocking.value is None
|
||||||
|
|
||||||
|
# #125: una seconda acquisizione sullo stesso task risponde 200, non e' bloccata
|
||||||
|
again = await client.post(
|
||||||
"/api/vision/execute",
|
"/api/vision/execute",
|
||||||
headers=auth_headers(measurement_tec_user),
|
headers=auth_headers(measurement_tec_user),
|
||||||
files={"image": ("p.png", an_image(), "image/png")},
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
data={"task_id": str(task.id)},
|
data={"task_id": str(task.id)},
|
||||||
)
|
)
|
||||||
|
assert again.status_code == 200, again.text
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert "t1.diameter" in response.json()["detail"]
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_lists_a_missing_quote_as_nan(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""#118: check -> una voce per quota, la non prodotta e' value null / fail."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
worker_says({"t1.radius": GOOD})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/check",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["measurements"] == [
|
||||||
|
{"subtask_id": first.id, "value": None, "pass_fail": "fail"},
|
||||||
|
{"subtask_id": second.id, "value": GOOD, "pass_fail": "pass"},
|
||||||
|
]
|
||||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Admin blueprint - user management."""
|
"""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 import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
@@ -170,6 +172,39 @@ def api_toggle_active(user_id: int):
|
|||||||
|
|
||||||
# --- Stations ---
|
# --- 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=15) # #106: era 5, la prima enumerazione dopo l'avvio ha preso 3,7 s
|
||||||
|
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"])
|
@admin_bp.route("/api/stations", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import math
|
|||||||
|
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
from functools import wraps
|
||||||
|
from urllib.parse import quote # #105: percorso del file ricetta nell'URL del backend
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from blueprints.auth import login_required, role_required
|
from blueprints.auth import login_required, role_required
|
||||||
@@ -14,6 +17,9 @@ from services.file_proxy import proxy_file
|
|||||||
|
|
||||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||||
|
|
||||||
|
# #106: la prima apertura GenTL dopo l'avvio supera 15 s, il grab ha bisogno di piu margine
|
||||||
|
CAMERA_GRAB_TIMEOUT_S = 60
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PAGINE (GET)
|
# PAGINE (GET)
|
||||||
@@ -483,6 +489,281 @@ def api_upload_vision_recipe(task_id: int):
|
|||||||
return jsonify(resp), 200
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# RICETTA DXF DA CAMERA (#83): stazioni, camera, calibrazione, creazione ricetta
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def maker_or_admin_required(f):
|
||||||
|
"""Maker dell'editor oppure admin della pagina stazioni (#83: stesse rotte camera)."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
user = session.get("user", {})
|
||||||
|
if not (user.get("is_admin") or "Maker" in user.get("roles", [])):
|
||||||
|
abort(403)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def _stations_for_maker() -> list[dict]:
|
||||||
|
"""Stazioni visibili al chiamante.
|
||||||
|
|
||||||
|
GET /api/stations e' solo admin: un Maker non admin ripiega sulla stazione
|
||||||
|
corrente (by-code, aperta a tutti), cosi' l'editor funziona comunque.
|
||||||
|
"""
|
||||||
|
resp = api_client.get("/api/stations")
|
||||||
|
if isinstance(resp, list):
|
||||||
|
return resp
|
||||||
|
if Config.STATION_CODE:
|
||||||
|
one = api_client.get_station_by_code(Config.STATION_CODE)
|
||||||
|
if isinstance(one, dict) and not one.get("error"):
|
||||||
|
return [one]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _camera_of_station(station_id: int) -> tuple[str, str] | None:
|
||||||
|
"""(camera_url, camera_source) della stazione con questo id, None se senza camera."""
|
||||||
|
for st in _stations_for_maker():
|
||||||
|
if st.get("id") == station_id:
|
||||||
|
if st.get("camera_url") and st.get("camera_source"):
|
||||||
|
return str(st["camera_url"]), str(st["camera_source"])
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@maker_bp.route("/api/stations", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@maker_or_admin_required
|
||||||
|
def api_list_stations_for_maker():
|
||||||
|
"""Elenco stazioni con camera per il pannello «Crea ricetta da DXF»."""
|
||||||
|
out = []
|
||||||
|
for st in _stations_for_maker():
|
||||||
|
if st.get("camera_url") and st.get("camera_source"):
|
||||||
|
out.append({
|
||||||
|
"id": st.get("id"), "code": st.get("code"), "name": st.get("name"),
|
||||||
|
"camera_url": st.get("camera_url"),
|
||||||
|
})
|
||||||
|
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
|
||||||
|
@maker_or_admin_required
|
||||||
|
def api_station_camera_grab(station_id: int):
|
||||||
|
"""Proxy: un frame dalla camera della stazione, risposta PNG."""
|
||||||
|
camera = _camera_of_station(station_id)
|
||||||
|
if camera is None:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Camera non configurata per questa stazione"),
|
||||||
|
}), 409
|
||||||
|
camera_url, camera_source = camera
|
||||||
|
try:
|
||||||
|
resp = http_requests.post(
|
||||||
|
f"{camera_url.rstrip('/')}/camera/grab",
|
||||||
|
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||||
|
timeout=CAMERA_GRAB_TIMEOUT_S, # #106: era 15, troppo corto alla prima apertura GenTL
|
||||||
|
)
|
||||||
|
except http_requests.RequestException as exc:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||||
|
}), 502
|
||||||
|
if resp.status_code != 200:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
detail = body.get("detail", "") if isinstance(body, dict) else ""
|
||||||
|
except ValueError:
|
||||||
|
detail = (resp.text or "")[:200]
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Acquisizione non riuscita (%(status)s): %(detail)s",
|
||||||
|
status=resp.status_code, detail=detail),
|
||||||
|
}), 502
|
||||||
|
return Response(resp.content, content_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@maker_bp.route("/api/stations/<int:station_id>/camera/calibration", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@maker_or_admin_required
|
||||||
|
def api_station_camera_calibration(station_id: int):
|
||||||
|
"""Proxy: calibrazione della camera letta dall'acquisitore (C1).
|
||||||
|
|
||||||
|
Risponde sempre 200 con `state`: calibrated | uncalibrated | unreachable,
|
||||||
|
cosi' la UI mostra lo stato senza trattare «non calibrata» come errore.
|
||||||
|
409 solo se la stazione non ha camera.
|
||||||
|
"""
|
||||||
|
camera = _camera_of_station(station_id)
|
||||||
|
if camera is None:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Camera non configurata per questa stazione"),
|
||||||
|
}), 409
|
||||||
|
camera_url, camera_source = camera
|
||||||
|
base = camera_url.rstrip("/")
|
||||||
|
link = f"{base}/acquisizione" # pagina di acquisizione dell'acquisitore (#88; era /calibrazione, C4)
|
||||||
|
try:
|
||||||
|
resp = http_requests.get(
|
||||||
|
f"{base}/camera/calibration",
|
||||||
|
params={"source": camera_source},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except http_requests.RequestException as exc:
|
||||||
|
return jsonify({"state": "unreachable", "detail": str(exc), "calibration_url": link}), 200
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return jsonify({"state": "uncalibrated", "calibration_url": link}), 200
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return jsonify({
|
||||||
|
"state": "unreachable", "calibration_url": link,
|
||||||
|
"detail": _("Risposta %(status)s dall'acquisitore", status=resp.status_code),
|
||||||
|
}), 200
|
||||||
|
try:
|
||||||
|
calibration = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"state": "unreachable", "calibration_url": link,
|
||||||
|
"detail": _("Risposta non valida dall'acquisitore")}), 200
|
||||||
|
return jsonify({"state": "calibrated", "calibration": calibration, "calibration_url": link}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_dxf_compare_recipe(task_id: int):
|
||||||
|
"""Proxy: crea (o prova, save=false) la ricetta DXF compare da camera (B3)."""
|
||||||
|
image = request.files.get("image")
|
||||||
|
dxf = request.files.get("dxf")
|
||||||
|
if image is None or not image.filename or dxf is None or not dxf.filename:
|
||||||
|
return jsonify({"error": True, "detail": _("Servono l'immagine e il file DXF")}), 400
|
||||||
|
|
||||||
|
# Solo i campi del contratto B3; i vuoti non si inoltrano (il backend ha i default)
|
||||||
|
data = {}
|
||||||
|
for key in ("station_id", "px_per_mm", "band_mm", "min_points_pct", "polarity", "save"):
|
||||||
|
value = request.form.get(key)
|
||||||
|
if value not in (None, ""):
|
||||||
|
data[key] = value
|
||||||
|
|
||||||
|
resp = api_client.post(
|
||||||
|
f"/api/vision/tasks/{task_id}/recipe/dxf-compare",
|
||||||
|
data=data,
|
||||||
|
files={
|
||||||
|
"image": (image.filename, image.read(), image.mimetype or "application/octet-stream"),
|
||||||
|
"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
# #105: parametri della ricetta DXF gia' salvata sul task, per riaprire il wizard compilato
|
||||||
|
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare/source", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_dxf_compare_source(task_id: int):
|
||||||
|
"""Proxy: parametri e file della ricetta DXF del task (404 se non ce n'e' una) (#105)."""
|
||||||
|
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe/dxf-compare/source")
|
||||||
|
# Codice e corpo del backend arrivano com'e': il 404 vuol dire «nessuna ricetta da DXF»
|
||||||
|
if resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
# #105: un file della cartella ricetta (DXF, immagine di riferimento) come byte, non JSON
|
||||||
|
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/files/<path:path>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_vision_recipe_file(task_id: int, path: str):
|
||||||
|
"""Proxy: byte di un file della cartella ricetta del task, con content-type del backend (#105)."""
|
||||||
|
# Niente segmenti '..' o backslash: urllib3 normalizzerebbe l'URL fuori da questa rotta del backend
|
||||||
|
if "\\" in path or ".." in path.split("/"):
|
||||||
|
return jsonify({"error": True, "detail": _("File non trovato")}), 404
|
||||||
|
try:
|
||||||
|
resp = http_requests.get(
|
||||||
|
f"{Config.API_SERVER_URL.rstrip('/')}/api/vision/tasks/{task_id}/recipe/files/{quote(path)}",
|
||||||
|
headers={"X-API-Key": session.get("api_key", "")},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except http_requests.RequestException as exc:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Servizio non raggiungibile: %(detail)s", detail=str(exc)),
|
||||||
|
}), 502
|
||||||
|
return Response(
|
||||||
|
resp.content,
|
||||||
|
status=resp.status_code,
|
||||||
|
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# #101: anteprima del DXF appena scelto nel wizard (contorni letti dal vision worker)
|
||||||
|
@maker_bp.route("/api/vision/dxf-preview", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_dxf_preview():
|
||||||
|
"""Proxy: anteprima del DXF (contorni, bbox, L x H) prima di creare la ricetta (#101)."""
|
||||||
|
dxf = request.files.get("dxf")
|
||||||
|
if dxf is None or not dxf.filename:
|
||||||
|
return jsonify({"error": True, "detail": _("Serve il file DXF")}), 400
|
||||||
|
|
||||||
|
resp = api_client.post(
|
||||||
|
"/api/vision/dxf-preview",
|
||||||
|
files={"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream")},
|
||||||
|
)
|
||||||
|
if resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||||
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
@@ -16,6 +17,9 @@ from services.file_proxy import proxy_file
|
|||||||
|
|
||||||
measure_bp = Blueprint("measure", __name__)
|
measure_bp = Blueprint("measure", __name__)
|
||||||
|
|
||||||
|
# #106: la prima apertura GenTL dopo l'avvio supera 15 s, il grab ha bisogno di piu margine
|
||||||
|
CAMERA_GRAB_TIMEOUT_S = 60
|
||||||
|
|
||||||
# Session key holding a station chosen at runtime, overriding Config.STATION_CODE.
|
# Session key holding a station chosen at runtime, overriding Config.STATION_CODE.
|
||||||
STATION_OVERRIDE_KEY = "station_override"
|
STATION_OVERRIDE_KEY = "station_override"
|
||||||
|
|
||||||
@@ -337,6 +341,14 @@ def task_execute(task_id: int):
|
|||||||
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
||||||
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
|
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(
|
return render_template(
|
||||||
"measure/task_execute.html",
|
"measure/task_execute.html",
|
||||||
task=task_resp,
|
task=task_resp,
|
||||||
@@ -345,7 +357,8 @@ def task_execute(task_id: int):
|
|||||||
all_task_ids=all_task_ids,
|
all_task_ids=all_task_ids,
|
||||||
measurement_interval_minutes=measurement_interval_minutes,
|
measurement_interval_minutes=measurement_interval_minutes,
|
||||||
allow_manual_input=allow_manual_input,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -427,7 +440,8 @@ def task_complete(recipe_id: int):
|
|||||||
m["subtask"] = st
|
m["subtask"] = st
|
||||||
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
|
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
|
||||||
# Compute deviation if not present
|
# Compute deviation if not present
|
||||||
if m.get("deviation") is None and st.get("nominal") is not None:
|
# #118: quota non misurata (value None): la deviazione resta None (il template mostra NaN)
|
||||||
|
if m.get("deviation") is None and st.get("nominal") is not None and m.get("value") is not None:
|
||||||
try:
|
try:
|
||||||
m["deviation"] = m["value"] - st["nominal"]
|
m["deviation"] = m["value"] - st["nominal"]
|
||||||
except (TypeError, KeyError):
|
except (TypeError, KeyError):
|
||||||
@@ -1001,6 +1015,42 @@ def api_vision_execute():
|
|||||||
return jsonify(resp), 200
|
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:
|
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||||
"""(camera_url, camera_source) of the station, None when it has no camera.
|
"""(camera_url, camera_source) of the station, None when it has no camera.
|
||||||
|
|
||||||
@@ -1019,6 +1069,31 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
|||||||
return str(url), str(source)
|
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
|
# #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.
|
# 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"])
|
@measure_bp.route("/api/vision/grab", methods=["POST"])
|
||||||
@@ -1026,7 +1101,16 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
|||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
def api_vision_grab():
|
def api_vision_grab():
|
||||||
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
|
"""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:
|
if camera is None:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"error": True,
|
"error": True,
|
||||||
@@ -1037,7 +1121,7 @@ def api_vision_grab():
|
|||||||
resp = http_requests.post(
|
resp = http_requests.post(
|
||||||
f"{camera_url.rstrip('/')}/camera/grab",
|
f"{camera_url.rstrip('/')}/camera/grab",
|
||||||
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||||
timeout=15,
|
timeout=CAMERA_GRAB_TIMEOUT_S, # #106: era 15, troppo corto alla prima apertura GenTL
|
||||||
)
|
)
|
||||||
except http_requests.RequestException as exc:
|
except http_requests.RequestException as exc:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|||||||
@@ -84,16 +84,18 @@ function csvExport() {
|
|||||||
const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl)
|
const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl)
|
||||||
? nominal - subtask.ltl : null;
|
? nominal - subtask.ltl : null;
|
||||||
|
|
||||||
|
// #118: quota non misurata (value null) -> NaN nel valore e nella deviazione; le tolleranze nulle restano vuote
|
||||||
|
const notMeasured = m.value === null || m.value === undefined;
|
||||||
const row = [
|
const row = [
|
||||||
m.id || '',
|
m.id || '',
|
||||||
m.subtask_id || '',
|
m.subtask_id || '',
|
||||||
this.escapeCsvValue(subtask.description || ''),
|
this.escapeCsvValue(subtask.description || ''),
|
||||||
this.formatNumber(m.value),
|
notMeasured ? 'NaN' : this.formatNumber(m.value),
|
||||||
this.escapeCsvValue(subtask.unit || 'mm'),
|
this.escapeCsvValue(subtask.unit || 'mm'),
|
||||||
this.formatNumber(nominal),
|
this.formatNumber(nominal),
|
||||||
this.formatNumber(tolerancePlus),
|
this.formatNumber(tolerancePlus),
|
||||||
this.formatNumber(toleranceMinus),
|
this.formatNumber(toleranceMinus),
|
||||||
this.formatNumber(m.deviation),
|
notMeasured ? 'NaN' : this.formatNumber(m.deviation),
|
||||||
m.pass_fail || '',
|
m.pass_fail || '',
|
||||||
this.escapeCsvValue(m.lot_number || ''),
|
this.escapeCsvValue(m.lot_number || ''),
|
||||||
this.escapeCsvValue(m.serial_number || ''),
|
this.escapeCsvValue(m.serial_number || ''),
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// «Crea ricetta da DXF» dell'editor task (#83): da un'immagine della camera e un DXF
|
||||||
|
// genera la ricetta dxf_compare (POST /maker/api/vision/tasks/<id>/recipe/dxf-compare).
|
||||||
|
// «Prova» (save=false) mostra overlay e quattro valori, «Salva ricetta» (save=true)
|
||||||
|
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
|
||||||
|
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
|
||||||
|
function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||||
|
// #121: i colori per ruolo stanno in VisionOverlay (vision_overlay.js)
|
||||||
|
const VALUE_UNITS = { 'dxf.points_in_pct': '%', 'dxf.distance_avg': 'mm', 'dxf.distance_min': 'mm', 'dxf.distance_max': 'mm' }; // #104
|
||||||
|
const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId: taskId,
|
||||||
|
msgs: msgs || {},
|
||||||
|
open: false,
|
||||||
|
stations: [],
|
||||||
|
stationId: '',
|
||||||
|
calState: '', // '' | calibrated | uncalibrated | unreachable
|
||||||
|
calLabel: '',
|
||||||
|
calUrl: '',
|
||||||
|
imageFile: null, // immagine caricata o acquisita
|
||||||
|
imageName: '',
|
||||||
|
imageSrc: '',
|
||||||
|
imagePx: null, // #102: {w, h} in pixel, letto dall'<img> dell'anteprima
|
||||||
|
imageBroken: false, // #102: il browser non sa mostrare il formato (il file resta valido per l'invio)
|
||||||
|
calPxMm: null, // #102: px/mm della calibrazione della stazione
|
||||||
|
dxfFile: null,
|
||||||
|
dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti}
|
||||||
|
dxfPreviewBusy: false,
|
||||||
|
dxfPreviewError: '',
|
||||||
|
_dxfPreviewSeq: 0, // #101: una risposta arrivata dopo un cambio di file si scarta
|
||||||
|
mmPx: '', // #98: mm/px a mano (immagine da file), stringa con virgola o punto
|
||||||
|
bandMm: 0.1,
|
||||||
|
minPointsPct: 95,
|
||||||
|
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
|
||||||
|
busy: false,
|
||||||
|
grabbing: false,
|
||||||
|
error: '',
|
||||||
|
notice: '',
|
||||||
|
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
|
||||||
|
imageSource: '', // #104: '' | 'file' | 'camera'; il campo mm/px vale solo per 'file'
|
||||||
|
tested: false, // #104: true dopo una Prova (anche fallita): mostra la sezione del risultato
|
||||||
|
// #122: zoom, Adatta e pan delle tre viste (immagine, DXF, Prova) ora stanno nel componente <tmf-viewer>
|
||||||
|
runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione)
|
||||||
|
runBandMm: null, // #104: banda usata nell'ultima Prova
|
||||||
|
runMinPointsPct: null, // #104: soglia usata nell'ultima Prova
|
||||||
|
existing: null, // #105: risposta di /source (ricetta DXF gia' salvata sul task); null = nessuna
|
||||||
|
existingInfo: false, // #105: «Ricetta esistente caricata...» dopo aver compilato il wizard
|
||||||
|
existingWarn: '', // #105: avviso (immagine non salvata, file illeggibili)
|
||||||
|
dxfFromRecipe: false, // #105: il DXF viene dalla ricetta (l'<input type=file> non puo' mostrarne il nome)
|
||||||
|
_existingApplied: false, // #105: il wizard e' gia' stato compilato dalla ricetta (non sovrascrivere le modifiche)
|
||||||
|
_existingReady: null, // #105: promessa della prima lettura di /source
|
||||||
|
_srcSeq: 0, // #105: una risposta di /source arrivata dopo una lettura piu' recente si scarta
|
||||||
|
_dxfPickSeq: 0, // #105: sale a ogni scelta manuale del DXF; i file della ricetta arrivati dopo si scartano
|
||||||
|
_imgPickSeq: 0, // #105: idem per l'immagine (_setImage)
|
||||||
|
|
||||||
|
// #105: all'avvio legge se il task ha gia' una ricetta da DXF (serve per l'etichetta del bottone)
|
||||||
|
init() {
|
||||||
|
if (this.taskId) this._existingReady = this.loadExisting();
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggle() {
|
||||||
|
this.open = !this.open;
|
||||||
|
if (this.open) {
|
||||||
|
this.openExisting(); // #105: compila il wizard dalla ricetta esistente, in parallelo alle stazioni
|
||||||
|
if (!this.stations.length) await this.loadStations();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// #105: GET /source -> this.existing (null se 404 o errore)
|
||||||
|
async loadExisting() {
|
||||||
|
const seq = ++this._srcSeq;
|
||||||
|
let found = null;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare/source`);
|
||||||
|
if (resp.ok) {
|
||||||
|
const body = await resp.json().catch(() => null);
|
||||||
|
if (body && typeof body === 'object' && !body.error) found = body;
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('dxfRecipeWizard source', e); }
|
||||||
|
if (seq !== this._srcSeq) return; // lettura piu' recente in corso
|
||||||
|
this.existing = found;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #105: all'apertura, una sola volta, riempie il wizard con la ricetta esistente
|
||||||
|
async openExisting() {
|
||||||
|
if (this._existingReady) await this._existingReady;
|
||||||
|
if (!this.existing || this._existingApplied) return;
|
||||||
|
await this.applyExisting();
|
||||||
|
},
|
||||||
|
|
||||||
|
// #105: mm/px da px/mm, fino a 6 cifre significative e virgola decimale; '' se non valido
|
||||||
|
_mmPxFrom(pxPerMm) {
|
||||||
|
const v = Number(pxPerMm);
|
||||||
|
if (!Number.isFinite(v) || v <= 0) return '';
|
||||||
|
return String(Number((1 / v).toPrecision(6))).replace('.', ',');
|
||||||
|
},
|
||||||
|
|
||||||
|
// #105: un file della cartella ricetta -> File; null se assente o illeggibile (in quel caso avvisa)
|
||||||
|
async _fetchRecipeFile(name) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/files/${encodeURIComponent(name)}`);
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
const blob = await resp.blob();
|
||||||
|
return new File([blob], name, { type: blob.type || '' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('dxfRecipeWizard file', name, e);
|
||||||
|
this.existingWarn = this.msgs.existingFilesFailed || 'Cannot read the recipe files';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// #105: valori salvati nei campi, poi DXF e immagine di riferimento dalla cartella ricetta
|
||||||
|
async applyExisting() {
|
||||||
|
const e = this.existing;
|
||||||
|
if (!e) return;
|
||||||
|
this._existingApplied = true;
|
||||||
|
this.error = '';
|
||||||
|
this.notice = '';
|
||||||
|
this.existingWarn = '';
|
||||||
|
const band = Number(e.band_mm), pct = Number(e.min_points_pct);
|
||||||
|
if (Number.isFinite(band) && band > 0) this.bandMm = band;
|
||||||
|
if (Number.isFinite(pct) && pct > 0) this.minPointsPct = pct;
|
||||||
|
this.dark = e.polarity === 'dark';
|
||||||
|
const mm = this._mmPxFrom(e.px_per_mm);
|
||||||
|
if (mm) this.mmPx = mm; // il campo compare quando l'immagine e' da file
|
||||||
|
this.existingInfo = true;
|
||||||
|
// Sequenze lette prima di scaricare: se l'utente sceglie un file a mano nel frattempo, vince lui
|
||||||
|
const dxfSeq = this._dxfPickSeq, imgSeq = this._imgPickSeq;
|
||||||
|
const [dxf, img] = await Promise.all([
|
||||||
|
e.dxf ? this._fetchRecipeFile(e.dxf) : null,
|
||||||
|
e.image ? this._fetchRecipeFile(e.image) : null,
|
||||||
|
]);
|
||||||
|
if (dxf && dxfSeq === this._dxfPickSeq) {
|
||||||
|
this.dxfFile = dxf;
|
||||||
|
this.dxfFromRecipe = true;
|
||||||
|
this.result = null;
|
||||||
|
this.tested = false;
|
||||||
|
this.loadDxfPreview();
|
||||||
|
}
|
||||||
|
if (img && imgSeq === this._imgPickSeq) this._setImage(img, 'file'); // 'file': il campo mm/px compare gia' compilato
|
||||||
|
if (!e.image && !this.existingWarn) this.existingWarn = this.msgs.noReferenceImage || 'Reference image not saved';
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadStations() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/maker/api/stations');
|
||||||
|
if (resp.ok) this.stations = await resp.json();
|
||||||
|
} catch (e) { console.error('dxfRecipeWizard stations', e); }
|
||||||
|
// Una sola stazione con camera: preselezionata
|
||||||
|
if (this.stations.length === 1) {
|
||||||
|
this.stationId = String(this.stations[0].id);
|
||||||
|
await this.onStation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
|
||||||
|
async onStation() {
|
||||||
|
this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calPxMm = null;
|
||||||
|
this.result = null;
|
||||||
|
this.tested = false; // #104
|
||||||
|
if (!this.stationId) return;
|
||||||
|
const r = await fetchStationCalibration(this.stationId);
|
||||||
|
this.calState = r.state || 'unreachable';
|
||||||
|
this.calUrl = r.calibration_url || '';
|
||||||
|
if (this.calState === 'calibrated') {
|
||||||
|
this.calLabel = formatCalibration(r.calibration || {});
|
||||||
|
const c = Number((r.calibration || {}).px_per_mm); // #102: scala per il campo in mm dell'anteprima
|
||||||
|
this.calPxMm = Number.isFinite(c) && c > 0 ? c : null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get calibrated() { return this.calState === 'calibrated'; },
|
||||||
|
// #98: mm/px inserito a mano -> px/mm; null se vuoto o fuori da 0.00001..100 (limiti di /camera/calibrazioni/manuale, #95)
|
||||||
|
get manualPxMm() {
|
||||||
|
const raw = String(this.mmPx || '').trim().replace(',', '.');
|
||||||
|
if (!raw) return null;
|
||||||
|
const v = Number(raw);
|
||||||
|
if (!Number.isFinite(v) || v < 0.00001 || v > 100) return null;
|
||||||
|
return 1 / v;
|
||||||
|
},
|
||||||
|
// #98: compilato ma non valido -> errore in pagina
|
||||||
|
get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && this.manualPxMm === null; },
|
||||||
|
// #104: la scala a mano vale solo per un'immagine da file; con la camera vale la calibrazione della stazione
|
||||||
|
get useManualScale() { return this.imageSource === 'file' && this.manualPxMm !== null; },
|
||||||
|
get canRun() {
|
||||||
|
// #98: la scala a mano sostituisce la calibrazione della stazione (#104: solo per immagini da file)
|
||||||
|
return !!(this.stationId && (this.calibrated || this.useManualScale) && this.imageFile && this.dxfFile) && !this.busy;
|
||||||
|
},
|
||||||
|
|
||||||
|
_setImage(file, source) {
|
||||||
|
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||||
|
// #102: con file null (immagine tolta) si azzera anche l'anteprima
|
||||||
|
this.imageFile = file || null;
|
||||||
|
this.imageName = (file && file.name) || '';
|
||||||
|
this.imageSrc = file ? URL.createObjectURL(file) : '';
|
||||||
|
this.imagePx = null;
|
||||||
|
this.imageBroken = false;
|
||||||
|
this.result = null;
|
||||||
|
this.tested = false; // #104
|
||||||
|
this.imageSource = file ? (source || '') : ''; // #104
|
||||||
|
this._imgPickSeq++; // #105: una scelta piu' recente batte i file della ricetta ancora in arrivo
|
||||||
|
},
|
||||||
|
|
||||||
|
// #102: il <tmf-viewer> ha caricato l'immagine (evento viewer-load): legge i pixel dal detail
|
||||||
|
// #122: lo zoom e' del componente; qui restano solo le dimensioni per il riepilogo
|
||||||
|
onImagePreviewLoad(d) {
|
||||||
|
this.imageBroken = false;
|
||||||
|
this.imagePx = d && d.width ? { w: d.width, h: d.height } : null;
|
||||||
|
},
|
||||||
|
// #102: se il formato non e' mostrabile (sonda <img hidden> del template) compare l'avviso
|
||||||
|
onImagePreviewError() {
|
||||||
|
this.imageBroken = !!this.imageSrc;
|
||||||
|
this.imagePx = null;
|
||||||
|
},
|
||||||
|
// #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati)
|
||||||
|
get imagePreviewUrl() { return this.imageSrc; },
|
||||||
|
// #102: «W × H px · campo ≈ L × H mm»; scala = mm/px a mano se valido, altrimenti calibrazione della stazione
|
||||||
|
get imagePreviewSummary() {
|
||||||
|
const p = this.imagePx;
|
||||||
|
if (!p) return '';
|
||||||
|
const lang = document.documentElement.lang || 'it';
|
||||||
|
const f = (v, d) => Number(v).toLocaleString(lang, { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
|
let text = `${f(p.w, 0)} × ${f(p.h, 0)} px`;
|
||||||
|
const pxMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null); // #104
|
||||||
|
if (pxMm) text += ` · ${this.msgs.imageField || 'field'} ≈ ${f(p.w / pxMm, 2)} × ${f(p.h / pxMm, 2)} mm`;
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
|
||||||
|
onImage(ev) {
|
||||||
|
const f = ev.target.files && ev.target.files[0];
|
||||||
|
if (f) this._setImage(f, 'file'); // #104
|
||||||
|
},
|
||||||
|
onDxf(ev) {
|
||||||
|
this.dxfFile = (ev.target.files && ev.target.files[0]) || null;
|
||||||
|
this._dxfPickSeq++; // #105: scelta a mano: il DXF della ricetta in arrivo si scarta
|
||||||
|
this.dxfFromRecipe = false; // #105
|
||||||
|
this.result = null;
|
||||||
|
this.tested = false; // #104
|
||||||
|
this.loadDxfPreview(); // #101: anteprima appena si sceglie il file
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: legge il DXF sul vision worker (via Flask e backend) e ne mostra i contorni
|
||||||
|
async loadDxfPreview() {
|
||||||
|
const seq = ++this._dxfPreviewSeq;
|
||||||
|
this.dxfPreview = null;
|
||||||
|
this.dxfPreviewError = '';
|
||||||
|
this.dxfPreviewBusy = false;
|
||||||
|
if (!this.dxfFile) return; // file tolto: tutto azzerato
|
||||||
|
this.dxfPreviewBusy = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||||||
|
const resp = await fetch('/maker/api/vision/dxf-preview', {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||||||
|
});
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (seq !== this._dxfPreviewSeq) return; // file cambiato nel frattempo
|
||||||
|
if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body);
|
||||||
|
else {
|
||||||
|
this.dxfPreview = body;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== this._dxfPreviewSeq) return;
|
||||||
|
this.dxfPreviewError = this.msgs.requestFailed || String(e);
|
||||||
|
}
|
||||||
|
if (seq === this._dxfPreviewSeq) this.dxfPreviewBusy = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: una stringa `points` per ogni contorno, per <polyline>
|
||||||
|
dxfPreviewPaths() {
|
||||||
|
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||||||
|
return contorni.filter(c => c && c.length > 1).map(c => c.map(p => p[0] + ',' + p[1]).join(' '));
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: tutti i contorni in un solo attributo `d` ("M x y L x y ..."): x-for non funziona dentro <svg>
|
||||||
|
dxfPreviewD() {
|
||||||
|
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||||||
|
return contorni.filter(c => c && c.length > 1)
|
||||||
|
.map(c => c.map((p, i) => (i ? 'L ' : 'M ') + p[0] + ' ' + p[1]).join(' ')).join(' ');
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: viewBox sul bbox con margine 5% (minimo 0.5 mm per lato)
|
||||||
|
dxfPreviewViewBox() {
|
||||||
|
const b = this.dxfPreview && this.dxfPreview.bbox;
|
||||||
|
if (!b) return '0 0 1 1';
|
||||||
|
const w = b.xmax - b.xmin, h = b.ymax - b.ymin;
|
||||||
|
const m = Math.max(0.5, 0.05 * Math.max(w, h));
|
||||||
|
return [b.xmin - m, b.ymin - m, w + 2 * m, h + 2 * m].map(v => +v.toFixed(3)).join(' ');
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: «L × H mm · N entità», decimale con virgola (2 decimali) secondo la lingua pagina
|
||||||
|
get dxfPreviewSummary() {
|
||||||
|
const d = this.dxfPreview;
|
||||||
|
if (!d) return '';
|
||||||
|
const f = v => Number(v).toLocaleString(document.documentElement.lang || 'it',
|
||||||
|
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
return `${f(d.larghezza_mm)} × ${f(d.altezza_mm)} mm · ${d.entita} ${this.msgs.dxfEntities || 'entities'}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Frame dalla camera della stazione scelta
|
||||||
|
async grab() {
|
||||||
|
if (!this.stationId || this.grabbing) return;
|
||||||
|
this.grabbing = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/maker/api/stations/${this.stationId}/camera/grab`, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': this._csrf() },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
this.error = body.detail || this.msgs.requestFailed || 'Error';
|
||||||
|
} else {
|
||||||
|
const blob = await resp.blob();
|
||||||
|
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }), 'camera'); // #104
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = this.msgs.requestFailed || String(e);
|
||||||
|
}
|
||||||
|
this.grabbing = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
_csrf() { return typeof csrf === 'function' ? csrf() : csrf; },
|
||||||
|
|
||||||
|
// Il 422 porta `detail` (stringa o lista) oppure `errors`
|
||||||
|
_errorText(body) {
|
||||||
|
if (Array.isArray(body.errors) && body.errors.length) return body.errors.map(String).join('; ');
|
||||||
|
const d = body.detail;
|
||||||
|
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))).join('; ');
|
||||||
|
if (d && typeof d === 'object') return JSON.stringify(d);
|
||||||
|
return d || this.msgs.requestFailed || 'Error';
|
||||||
|
},
|
||||||
|
|
||||||
|
async run(save) {
|
||||||
|
if (!this.canRun) return;
|
||||||
|
this.busy = true;
|
||||||
|
this.error = '';
|
||||||
|
this.notice = '';
|
||||||
|
// #104: parametri usati, mostrati accanto ai risultati
|
||||||
|
this.runPxPerMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null);
|
||||||
|
this.runBandMm = this.bandMm;
|
||||||
|
this.runMinPointsPct = this.minPointsPct;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
|
||||||
|
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||||||
|
fd.append('station_id', this.stationId);
|
||||||
|
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione (#104: solo con immagine da file)
|
||||||
|
if (this.useManualScale) fd.append('px_per_mm', String(this.manualPxMm));
|
||||||
|
fd.append('band_mm', String(this.bandMm));
|
||||||
|
fd.append('min_points_pct', String(this.minPointsPct));
|
||||||
|
fd.append('polarity', this.dark ? 'dark' : 'light');
|
||||||
|
fd.append('save', save ? 'true' : 'false');
|
||||||
|
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare`, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||||||
|
});
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok || body.error) {
|
||||||
|
this.error = this._errorText(body);
|
||||||
|
} else {
|
||||||
|
this.result = body;
|
||||||
|
if (save && body.saved) {
|
||||||
|
this.notice = this.msgs.saved || 'Saved';
|
||||||
|
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
|
||||||
|
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
|
||||||
|
// #105: il task ha ora una ricetta (etichetta «Modifica»); il wizard e' gia' allineato, non si ricompila
|
||||||
|
this._existingApplied = true;
|
||||||
|
this.existingInfo = false;
|
||||||
|
this.existingWarn = ''; // #105: l'immagine di riferimento ora e' salvata con la ricetta
|
||||||
|
await this.loadExisting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = this.msgs.requestFailed || String(e);
|
||||||
|
}
|
||||||
|
this.busy = false;
|
||||||
|
// #104: la sezione del risultato compare anche con Prova fallita (immagine a sinistra, errore a destra)
|
||||||
|
this.tested = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Le quattro uscite del confronto, nell'ordine fisso
|
||||||
|
get values() {
|
||||||
|
const out = (this.result && this.result.result && this.result.result.outputs) || {};
|
||||||
|
return VALUE_KEYS.map(k => ({ key: k, value: out[k], label: this._valueLabel(k), unit: VALUE_UNITS[k] })); // #104
|
||||||
|
},
|
||||||
|
|
||||||
|
// #104: etichette leggibili (testi passati da msgs), chiave grezza come ripiego
|
||||||
|
_valueLabel(k) {
|
||||||
|
const m = { 'dxf.points_in_pct': this.msgs.pointsInBand, 'dxf.distance_avg': this.msgs.distAvg,
|
||||||
|
'dxf.distance_min': this.msgs.distMin, 'dxf.distance_max': this.msgs.distMax };
|
||||||
|
return m[k] || k;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #104: esito della ricetta sul riferimento: pass / warning / fail (src/vision/runner.py); altro = valore grezzo
|
||||||
|
get outcome() { return (this.result && this.result.result && this.result.result.outcome) || ''; },
|
||||||
|
get outcomeLabel() {
|
||||||
|
const m = { pass: this.msgs.outcomePass, warning: this.msgs.outcomeWarning, fail: this.msgs.outcomeFail };
|
||||||
|
return m[this.outcome] || this.outcome;
|
||||||
|
},
|
||||||
|
get outcomeClass() {
|
||||||
|
return { pass: 'text-emerald-600', warning: 'text-amber-600', fail: 'text-red-500' }[this.outcome] || 'text-[var(--text-muted)]';
|
||||||
|
},
|
||||||
|
// #104: scala usata nell'ultima Prova, con la fonte
|
||||||
|
get scaleSummary() {
|
||||||
|
if (!this.runPxPerMm) return this.msgs.scaleStation || 'station calibration';
|
||||||
|
const lang = document.documentElement.lang || 'it';
|
||||||
|
const mm = (1 / this.runPxPerMm).toLocaleString(lang, { maximumFractionDigits: 6 });
|
||||||
|
return `${mm} mm/px (${this.imageSource === 'file' ? (this.msgs.scaleManual || 'manual') : (this.msgs.scaleStation || 'station calibration')})`;
|
||||||
|
},
|
||||||
|
|
||||||
|
fmt(v) {
|
||||||
|
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
|
||||||
|
{ maximumFractionDigits: 3 }) : '-';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* image_to_png.js - #107: converte nel browser le immagini che il backend non ammette (es. BMP) in PNG.
|
||||||
|
*
|
||||||
|
* Cosa fa: tmfNeedsPng(type) dice se il MIME e' un'immagine fuori da PNG/JPEG/GIF/WebP;
|
||||||
|
* tmfImageToPng(file) la ridisegna su canvas e ritorna un File PNG (null se illeggibile, es. TIFF).
|
||||||
|
* Come si usa: await tmfImageToPng(file) prima del ritaglio e del caricamento.
|
||||||
|
* Dipende da: createImageBitmap e canvas del browser (non servono nei test: il caso "nessuna conversione" e' puro).
|
||||||
|
*/
|
||||||
|
// #107: tipi che il backend ammette gia': non vanno convertiti
|
||||||
|
const TMF_PNG_PASSTHROUGH = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
// #107: true per un'immagine (image/*) non ammessa dal backend, es. image/bmp
|
||||||
|
function tmfNeedsPng(type) {
|
||||||
|
return typeof type === 'string' && type.indexOf('image/') === 0 && TMF_PNG_PASSTHROUGH.indexOf(type) === -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #107: converte in PNG con canvas; ritorna il file invariato se non serve, null se il browser non sa decodificarlo
|
||||||
|
async function tmfImageToPng(file) {
|
||||||
|
if (!file || !tmfNeedsPng(file.type)) return file;
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||||
|
if (bitmap.close) bitmap.close();
|
||||||
|
const blob = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||||
|
if (!blob) return null;
|
||||||
|
const base = (file.name || 'immagine').replace(/\.[^.]+$/, '');
|
||||||
|
return new File([blob], base + '.png', { type: 'image/png' });
|
||||||
|
} catch (err) {
|
||||||
|
// #107: formato non decodificabile dal browser (es. TIFF)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #107: export per il test node (nel browser module non esiste)
|
||||||
|
if (typeof module !== 'undefined') module.exports = { tmfNeedsPng, tmfImageToPng };
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
/**
|
||||||
|
* image_viewer.js - #122: <tmf-viewer>, il visualizzatore immagine standard di TieMeasureFlow.
|
||||||
|
*
|
||||||
|
* Cosa fa: Custom Element in JS puro (senza Alpine ne' Tailwind) con zoom -/+, Adatta, Salva, percentuale,
|
||||||
|
* rotella (e Ctrl+rotella) intorno al puntatore, pan col puntatore, overlay SVG a tratto costante ed etichette,
|
||||||
|
* menu dei livelli in alto a sinistra (#12).
|
||||||
|
* Sostituisce le quattro implementazioni di zoom di prima (pannello operatore #115, wizard DXF #113 e #104,
|
||||||
|
* pagina camera #111). Light DOM, stile iniettato una volta nel <head> (<style id="tmf-viewer-css">).
|
||||||
|
* Come si usa: <script> vision_overlay.js e save_image.js, poi image_viewer.js; poi
|
||||||
|
* <tmf-viewer src="..." width="1920" height="1080" save-name="task_14" fit-height></tmf-viewer>
|
||||||
|
* Attributi: src, width, height (px logici), save-name, save-src, no-save, fit-height,
|
||||||
|
* label-fit, label-save, title-in, title-out, title-save.
|
||||||
|
* Senza src, un figlio <svg> (DXF) va nello stage al 100 % (modo contenuto; Salva lo rasterizza in PNG).
|
||||||
|
* Proprieta': overlay (forme vs-tools -> VisionOverlay.svg), labels ([{key,x,y,text,cls}] in px logici),
|
||||||
|
* zoom (sola lettura); metodi fit(), zoomBy(f). Eventi (bubbling): viewer-load {width,height}, viewer-zoom {zoom,fit}.
|
||||||
|
* #12 livelli: la rotella senza Ctrl zooma sul punto del mouse. L'overlay sta in tre gruppi SVG
|
||||||
|
* (<g data-layer>): ricerca, costruzioni (opacity 0.5), quote; un menu a caselle (.tmfv-layers) accende
|
||||||
|
* Immagine, Costruzioni, Ricerca (spenta di default), Quote, Nomi. #126 attributo no-layers = viewer semplice, come
|
||||||
|
* prima dei livelli: niente menu, un solo disegno (VisionOverlay.svg di tutte le forme, poi le quote), nessun <g data-layer>
|
||||||
|
* ne' opacity, forme e immagine sempre visibili, etichette sempre accese (text) e non trascinabili (niente quota-spostata);
|
||||||
|
* zoom con rotella, pan, Adatta e Salva restano. Lo stato dei livelli non conta con no-layers. Proprieta'
|
||||||
|
* layers get/set {immagine,costruzioni,ricerca,quote,nomi}, evento viewer-layers {layers}; stato in localStorage
|
||||||
|
* (chiave tmf-viewer-layers[:save-name]). Etichette estese: {key,x,y,text,breve,cls,nome,camera,quota,testo};
|
||||||
|
* con quota stanno sul punto della scritta (VisionOverlay.quotaPunto) e si trascinano: evento quota-spostata
|
||||||
|
* {key,nome,camera,testo:[du,dv]|null} (doppio clic = testo null, posizione di default).
|
||||||
|
* In Alpine: :src="...", x-effect="$el.overlay = ...; $el.labels = ...".
|
||||||
|
* Dipende da: window.VisionOverlay (facoltativo, per l'overlay), tmfSaveImage/tmfSaveBlob/tmfSvgToPngBlob di save_image.js
|
||||||
|
* (per Salva). Il modulo si carica anche in node (require): esporta solo TmfViewerMath, funzioni pure.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var TAG = 'tmf-viewer';
|
||||||
|
var STEP = 1.25; // #122: passo dei pulsanti +/- e della rotella (come #111 e #115)
|
||||||
|
// #12: livelli del menu, titoli, valori di default e colore delle quote per esito (classi cls di stazione.js)
|
||||||
|
var LAYER_NAMES = ['immagine', 'costruzioni', 'ricerca', 'quote', 'nomi'];
|
||||||
|
var LAYER_TITLES = { immagine: 'Immagine', costruzioni: 'Costruzioni', ricerca: 'Ricerca', quote: 'Quote', nomi: 'Nomi' };
|
||||||
|
var LAYER_DEFAULT = { immagine: true, costruzioni: true, ricerca: false, quote: true, nomi: true };
|
||||||
|
var QUOTA_COLORS = { 'stz-ok': '#16a34a', 'stz-avviso': '#d97706', 'stz-ko': '#dc2626' };
|
||||||
|
var QUOTA_COLOR_DEFAULT = '#ef4444';
|
||||||
|
|
||||||
|
// #122: matematica pura dello zoom, testata in node senza browser
|
||||||
|
var TmfViewerMath = {
|
||||||
|
// zoom che fa stare tutta l'immagine (w x h) nel box (boxW x boxH); 0 se non calcolabile. boxH puo' essere Infinity
|
||||||
|
fitZoom: function (boxW, boxH, w, h) {
|
||||||
|
if (!(boxW > 0) || !(boxH > 0) || !(w > 0) || !(h > 0)) return 0;
|
||||||
|
var f = Math.min(boxW / w, boxH / h);
|
||||||
|
return isFinite(f) && f > 0 ? f : 0;
|
||||||
|
},
|
||||||
|
// zoom ristretto a [fit * 0.5, max(8, fit * 8)]: il massimo segue il fit, cosi' un DXF in mm (fit ~10) si ingrandisce ancora
|
||||||
|
clampZoom: function (z, fit) {
|
||||||
|
var lo = fit * 0.5, hi = Math.max(8, fit * 8);
|
||||||
|
return Math.min(hi, Math.max(lo, z));
|
||||||
|
},
|
||||||
|
// nuovo scroll che tiene fermo il punto (px, py) del viewport passando dallo zoom z0 a z1
|
||||||
|
zoomAround: function (z0, z1, scrollL, scrollT, px, py) {
|
||||||
|
var k = z1 / z0;
|
||||||
|
return { scrollLeft: (scrollL + px) * k - px, scrollTop: (scrollT + py) * k - py };
|
||||||
|
},
|
||||||
|
pct: function (z) { return Math.round(z * 100) + ' %'; },
|
||||||
|
};
|
||||||
|
|
||||||
|
var CSS = [
|
||||||
|
'tmf-viewer{display:block;position:relative;overflow:hidden;background:#1f2937;min-width:0}',
|
||||||
|
'tmf-viewer *{box-sizing:border-box}',
|
||||||
|
'tmf-viewer [hidden]{display:none!important}',
|
||||||
|
// #122: anche l'host con l'attributo hidden si nasconde (display:block sopra vincerebbe sul [hidden] del browser)
|
||||||
|
'tmf-viewer[hidden]{display:none!important}',
|
||||||
|
'tmf-viewer .tmfv-vp{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;display:flex}',
|
||||||
|
'tmf-viewer[fit-height] .tmfv-vp{position:relative;width:100%}',
|
||||||
|
'tmf-viewer .tmfv-stage{position:relative;flex:none;margin:auto;user-select:none;-webkit-user-select:none}',
|
||||||
|
'tmf-viewer .tmfv-img{display:block;width:100%;height:100%;max-width:none;-webkit-user-drag:none}',
|
||||||
|
'tmf-viewer .tmfv-stage>svg:not(.tmfv-ov){display:block;width:100%;height:100%}',
|
||||||
|
'tmf-viewer .tmfv-ov{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:visible}',
|
||||||
|
'tmf-viewer .tmfv-labels{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}',
|
||||||
|
// #122: aspetto completo dell'etichetta; le classi `cls` della pagina (es. bg-red-600 text-white) cambiano solo i colori
|
||||||
|
'tmf-viewer .tmfv-label{position:absolute;transform:translate(8px,-50%);white-space:nowrap;font:700 12px/1.3 system-ui,sans-serif;',
|
||||||
|
'padding:2px 6px;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.4);pointer-events:none}',
|
||||||
|
':where(tmf-viewer .tmfv-label){color:#fff;background:rgba(0,0,0,.6)}',
|
||||||
|
'tmf-viewer .tmfv-bar{position:absolute;top:6px;right:6px;z-index:3;display:flex;align-items:center;gap:2px;padding:2px;',
|
||||||
|
'background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
|
||||||
|
'tmf-viewer .tmfv-bar button{appearance:none;border:0;border-radius:4px;background:transparent;color:#fff;',
|
||||||
|
'padding:2px 8px;font:inherit;cursor:pointer;min-width:26px}',
|
||||||
|
'tmf-viewer .tmfv-bar button:hover{background:rgba(255,255,255,.22)}',
|
||||||
|
'tmf-viewer .tmfv-pct{min-width:46px;text-align:center;font-variant-numeric:tabular-nums}',
|
||||||
|
// #12: etichette con key trascinabili; menu dei livelli in alto a sinistra, stile della barra
|
||||||
|
'tmf-viewer .tmfv-label[data-key]{pointer-events:auto;cursor:move;touch-action:none}',
|
||||||
|
// #126: con no-layers le etichette non si trascinano (stessa specificita' della regola sopra, viene dopo)
|
||||||
|
'tmf-viewer[no-layers] .tmfv-label,tmf-viewer[no-layers] .tmfv-label[data-key]{pointer-events:none;cursor:default}',
|
||||||
|
'tmf-viewer .tmfv-layers{position:absolute;top:6px;left:6px;z-index:3;display:flex;flex-wrap:wrap;align-items:center;gap:2px 10px;',
|
||||||
|
'padding:3px 8px;max-width:calc(100% - 12px);background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
|
||||||
|
'tmf-viewer .tmfv-layers label{display:inline-flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}',
|
||||||
|
'tmf-viewer .tmfv-layers input{margin:0;cursor:pointer}',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// #122: stile iniettato una volta sola nel <head>
|
||||||
|
function injectCss() {
|
||||||
|
if (typeof document === 'undefined' || document.getElementById('tmf-viewer-css')) return;
|
||||||
|
var st = document.createElement('style');
|
||||||
|
st.id = 'tmf-viewer-css';
|
||||||
|
st.textContent = CSS;
|
||||||
|
(document.head || document.documentElement).appendChild(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v) { var n = parseFloat(v); return n > 0 && isFinite(n) ? n : 0; }
|
||||||
|
|
||||||
|
// #122: dimensioni logiche di un <svg> dal viewBox, altrimenti da width/height
|
||||||
|
function svgDims(svg) {
|
||||||
|
var vb = svg.viewBox && svg.viewBox.baseVal;
|
||||||
|
if (vb && vb.width > 0 && vb.height > 0) return { w: vb.width, h: vb.height };
|
||||||
|
var a = (svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
|
||||||
|
if (a.length === 4 && a[2] > 0 && a[3] > 0) return { w: a[2], h: a[3] };
|
||||||
|
return { w: num(svg.getAttribute('width')), h: num(svg.getAttribute('height')) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineViewer() {
|
||||||
|
var OBSERVED = ['src', 'width', 'height', 'fit-height', 'no-save', 'save-name',
|
||||||
|
'label-fit', 'label-save', 'title-in', 'title-out', 'title-save', 'no-layers'];
|
||||||
|
|
||||||
|
class TmfViewer extends HTMLElement {
|
||||||
|
static get observedAttributes() { return OBSERVED; }
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._built = false;
|
||||||
|
this._w = 0; this._h = 0; // dimensioni logiche correnti
|
||||||
|
this._nw = 0; this._nh = 0; // dimensioni naturali dell'immagine
|
||||||
|
this._z = 0; this._fit = 0; this._auto = true;
|
||||||
|
this._ov = []; this._lb = [];
|
||||||
|
this._pan = null;
|
||||||
|
this._content = null; // modo contenuto: l'<svg> adottato
|
||||||
|
this._lastEvt = '';
|
||||||
|
this._lay = Object.assign({}, LAYER_DEFAULT); // #12: livelli visibili
|
||||||
|
this._ldrag = null; // #12: trascinamento di un'etichetta in corso
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
injectCss();
|
||||||
|
if (this._built) return;
|
||||||
|
this._built = true;
|
||||||
|
// proprieta' impostate prima dell'upgrade dell'elemento: le riporto sugli accessor
|
||||||
|
var self = this;
|
||||||
|
this._layersLoad(); // #12: stato salvato, prima di eventuali proprieta' impostate dalla pagina
|
||||||
|
['overlay', 'labels', 'layers'].forEach(function (k) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(self, k)) { var v = self[k]; delete self[k]; self[k] = v; }
|
||||||
|
});
|
||||||
|
var kids = Array.prototype.slice.call(this.children);
|
||||||
|
|
||||||
|
var bar = document.createElement('div'); bar.className = 'tmfv-bar'; bar.hidden = true;
|
||||||
|
this._bOut = this._btn('−'); this._bIn = this._btn('+'); this._bFit = this._btn(''); this._bSave = this._btn('');
|
||||||
|
this._pctEl = document.createElement('span'); this._pctEl.className = 'tmfv-pct';
|
||||||
|
bar.appendChild(this._bOut); bar.appendChild(this._bIn); bar.appendChild(this._bFit);
|
||||||
|
bar.appendChild(this._bSave); bar.appendChild(this._pctEl);
|
||||||
|
// #12: menu dei livelli (caselle) in alto a sinistra
|
||||||
|
var lay = document.createElement('div'); lay.className = 'tmfv-layers'; lay.hidden = true;
|
||||||
|
this._layBoxes = {};
|
||||||
|
LAYER_NAMES.forEach(function (name) {
|
||||||
|
var lb = document.createElement('label'), cb = document.createElement('input'), tx = document.createElement('span');
|
||||||
|
cb.type = 'checkbox'; cb.setAttribute('data-layer', name); cb.checked = self._lay[name];
|
||||||
|
tx.textContent = LAYER_TITLES[name];
|
||||||
|
cb.addEventListener('change', function () { var o = {}; o[name] = cb.checked; self.layers = o; });
|
||||||
|
lb.appendChild(cb); lb.appendChild(tx); lay.appendChild(lb);
|
||||||
|
self._layBoxes[name] = cb;
|
||||||
|
});
|
||||||
|
var vp = document.createElement('div'); vp.className = 'tmfv-vp';
|
||||||
|
var stage = document.createElement('div'); stage.className = 'tmfv-stage';
|
||||||
|
var img = document.createElement('img'); img.className = 'tmfv-img'; img.draggable = false; img.alt = ''; img.hidden = true;
|
||||||
|
var ov = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
ov.setAttribute('class', 'tmfv-ov'); ov.setAttribute('preserveAspectRatio', 'none');
|
||||||
|
var labels = document.createElement('div'); labels.className = 'tmfv-labels';
|
||||||
|
stage.appendChild(img); stage.appendChild(ov); stage.appendChild(labels);
|
||||||
|
vp.appendChild(stage);
|
||||||
|
this._layBar = lay; this._bar = bar; this._vp = vp; this._stage = stage; this._img = img; this._ovEl = ov; this._lbEl = labels;
|
||||||
|
this.appendChild(bar); this.appendChild(lay); this.appendChild(vp);
|
||||||
|
|
||||||
|
this._bOut.onclick = function () { self.zoomBy(1 / STEP); };
|
||||||
|
this._bIn.onclick = function () { self.zoomBy(STEP); };
|
||||||
|
this._bFit.onclick = function () { self.fit(); };
|
||||||
|
this._bSave.onclick = function () { self._save(); };
|
||||||
|
img.addEventListener('load', function () { self._onLoad(); });
|
||||||
|
// #122: errore di caricamento -> evento viewer-error (bubbling) con l'URL che non si e' caricato
|
||||||
|
img.addEventListener('error', function () {
|
||||||
|
if (!self._hasSrc()) return; // l'errore di un src appena tolto non conta
|
||||||
|
self.dispatchEvent(new CustomEvent('viewer-error', { bubbles: true, detail: { src: self.getAttribute('src') } }));
|
||||||
|
});
|
||||||
|
// Ctrl+rotella = zoom intorno al puntatore; senza Ctrl la pagina scorre come sempre
|
||||||
|
vp.addEventListener('wheel', function (ev) { self._wheel(ev); }, { passive: false });
|
||||||
|
vp.addEventListener('pointerdown', function (ev) { self._panDown(ev); });
|
||||||
|
vp.addEventListener('pointermove', function (ev) { self._panMove(ev); });
|
||||||
|
vp.addEventListener('pointerup', function () { self._panEnd(); });
|
||||||
|
vp.addEventListener('pointercancel', function () { self._panEnd(); });
|
||||||
|
// resize del viewport: in stato «adatta» rifa Adatta; altrimenti aggiorna solo il fit
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
// #122: la callback cambia l'altezza del viewport (fit-height): rimando a un task dopo, senza «ResizeObserver loop»
|
||||||
|
this._ro = new ResizeObserver(function () {
|
||||||
|
if (self._raf) return;
|
||||||
|
var run = function () { self._raf = 0; self._onResize(); };
|
||||||
|
self._raf = setTimeout(run, 0);
|
||||||
|
});
|
||||||
|
this._ro.observe(vp);
|
||||||
|
}
|
||||||
|
// contenuto aggiunto dopo (x-html, template): lo adotto
|
||||||
|
if (typeof MutationObserver !== 'undefined') {
|
||||||
|
this._mo = new MutationObserver(function () { self._adopt(); });
|
||||||
|
this._mo.observe(this, { childList: true });
|
||||||
|
this._mo2 = new MutationObserver(function () { self._refresh(); });
|
||||||
|
}
|
||||||
|
kids.forEach(function (k) { self._take(k); });
|
||||||
|
this._texts();
|
||||||
|
this._loadSrc();
|
||||||
|
this._refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
// gli osservatori restano: se l'elemento viene ricollegato il viewport e' lo stesso
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name, oldV, newV) {
|
||||||
|
if (!this._built || oldV === newV) return;
|
||||||
|
if (name === 'src') this._loadSrc();
|
||||||
|
else if (name === 'width' || name === 'height') this._refresh();
|
||||||
|
else if (name === 'fit-height') { if (!this.hasAttribute('fit-height')) this._vp.style.height = ''; this._onResize(true); }
|
||||||
|
else if (name === 'no-layers') { this._syncLayers(); this._drawOverlay(); this._drawLabels(); } // #12 #126: ridisegna al cambio
|
||||||
|
else this._texts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #126: viewer senza livelli (attributo no-layers): disegno semplice, etichette fisse
|
||||||
|
_noLay() { return this.hasAttribute('no-layers'); }
|
||||||
|
|
||||||
|
_btn(txt) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.textContent = txt;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: testi e titoli dei pulsanti dagli attributi
|
||||||
|
_texts() {
|
||||||
|
var g = function (el, n, d) { var v = el.getAttribute(n); return v === null || v === '' ? d : v; };
|
||||||
|
this._bFit.textContent = g(this, 'label-fit', 'Adatta');
|
||||||
|
this._bSave.textContent = g(this, 'label-save', 'Salva');
|
||||||
|
this._bIn.title = g(this, 'title-in', 'Ingrandisci');
|
||||||
|
this._bOut.title = g(this, 'title-out', 'Riduci');
|
||||||
|
this._bSave.title = g(this, 'title-save', 'Salva immagine');
|
||||||
|
this._bSave.hidden = this.hasAttribute('no-save');
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: adotta un figlio <svg> come contenuto (modo contenuto, DXF)
|
||||||
|
_take(el) {
|
||||||
|
if (!el || el === this._bar || el === this._layBar || el === this._vp || el.parentNode !== this) return;
|
||||||
|
if (String(el.tagName).toLowerCase() !== 'svg') return;
|
||||||
|
if (this._content && this._content !== el && this._content.parentNode) this._content.parentNode.removeChild(this._content);
|
||||||
|
this._content = el;
|
||||||
|
this._stage.insertBefore(el, this._ovEl);
|
||||||
|
if (this._mo2) this._mo2.observe(el, { attributes: true, attributeFilter: ['viewBox', 'width', 'height'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
_adopt() {
|
||||||
|
var kids = Array.prototype.slice.call(this.children), self = this, found = false;
|
||||||
|
kids.forEach(function (k) { if (k !== self._bar && k !== self._layBar && k !== self._vp) { self._take(k); found = true; } });
|
||||||
|
if (found) this._refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: (ri)carica l'immagine dall'attributo src
|
||||||
|
_loadSrc() {
|
||||||
|
var src = this.getAttribute('src');
|
||||||
|
if (src) {
|
||||||
|
this._img.hidden = false;
|
||||||
|
this._img.src = src;
|
||||||
|
} else {
|
||||||
|
this._img.removeAttribute('src');
|
||||||
|
this._img.hidden = true;
|
||||||
|
this._nw = 0; this._nh = 0;
|
||||||
|
this._refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onLoad() {
|
||||||
|
var img = this._img;
|
||||||
|
this._nw = img.naturalWidth; this._nh = img.naturalHeight;
|
||||||
|
this._refresh();
|
||||||
|
this.dispatchEvent(new CustomEvent('viewer-load', { bubbles: true, detail: { width: this._w, height: this._h } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasSrc() { return !!this.getAttribute('src'); }
|
||||||
|
|
||||||
|
// #122: ricalcola le dimensioni logiche; Adatta se cambiano (o se la vista era ad Adatta), zoom mantenuto altrimenti
|
||||||
|
_refresh() {
|
||||||
|
if (!this._built) return;
|
||||||
|
var w = num(this.getAttribute('width')), h = num(this.getAttribute('height'));
|
||||||
|
if (this._hasSrc()) {
|
||||||
|
if (!w) w = this._nw;
|
||||||
|
if (!h) h = this._nh;
|
||||||
|
if (!this._nw || !this._nh) { if (!(w && h)) { w = 0; h = 0; } }
|
||||||
|
} else if (this._content) {
|
||||||
|
var d = svgDims(this._content);
|
||||||
|
if (!w) w = d.w;
|
||||||
|
if (!h) h = d.h;
|
||||||
|
} else { w = 0; h = 0; }
|
||||||
|
if (!(w > 0 && h > 0)) { w = 0; h = 0; }
|
||||||
|
var changed = w !== this._w || h !== this._h;
|
||||||
|
this._w = w; this._h = h;
|
||||||
|
this._bar.hidden = !w;
|
||||||
|
this._img.hidden = !this._hasSrc();
|
||||||
|
if (this._content) this._content.style.display = this._hasSrc() ? 'none' : '';
|
||||||
|
if (!w) { this._z = 0; this._fit = 0; this._auto = true; this._apply(); return; }
|
||||||
|
if (changed || this._auto || !this._z) this.fit(); else this._apply();
|
||||||
|
if (!this._z) this._apply(true); // riquadro nascosto: Adatta rimandata, ma overlay ed etichette (in %) si disegnano gia'
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: area utile del viewport: offset meno i bordi (client* escludono le barre che spariranno dopo l'Adatta)
|
||||||
|
_area() {
|
||||||
|
var vp = this._vp, cw = vp.clientWidth, ch = vp.clientHeight, bx = 0, by = 0;
|
||||||
|
if (vp.offsetWidth && typeof getComputedStyle === 'function') {
|
||||||
|
var cs = getComputedStyle(vp);
|
||||||
|
bx = (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth)) || 0;
|
||||||
|
by = (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)) || 0;
|
||||||
|
cw = vp.offsetWidth - bx;
|
||||||
|
ch = vp.offsetHeight - by;
|
||||||
|
}
|
||||||
|
return { cw: cw, ch: ch, by: by };
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoom di Adatta per l'area corrente; 0 se non calcolabile (riquadro nascosto o vuoto)
|
||||||
|
_calcFit() {
|
||||||
|
if (!this._w) return 0;
|
||||||
|
var a = this._area();
|
||||||
|
if (this.hasAttribute('fit-height')) return TmfViewerMath.fitZoom(a.cw, Infinity, this._w, this._h);
|
||||||
|
return TmfViewerMath.fitZoom(a.cw, a.ch, this._w, this._h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: altezza del viewport in fit-height: segue l'immagine adattata alla larghezza
|
||||||
|
_fitHeightBox(fit) {
|
||||||
|
if (!this.hasAttribute('fit-height')) return;
|
||||||
|
this._vp.style.height = fit > 0 ? (this._h * fit + this._area().by) + 'px' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
fit() {
|
||||||
|
if (!this._built || !this._w) return;
|
||||||
|
var f = this._calcFit();
|
||||||
|
if (!(f > 0)) return; // riquadro nascosto: ci pensa l'osservatore
|
||||||
|
this._fit = f; this._z = f; this._auto = true;
|
||||||
|
this._fitHeightBox(f);
|
||||||
|
this._vp.scrollLeft = 0; this._vp.scrollTop = 0;
|
||||||
|
this._apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
// resize del viewport: Adatta se la vista non e' stata zoomata, altrimenti fit aggiornato e zoom nei limiti
|
||||||
|
_onResize(force) {
|
||||||
|
if (!this._built || !this._w) return;
|
||||||
|
var f = this._calcFit();
|
||||||
|
if (!(f > 0)) return;
|
||||||
|
if (!force && Math.abs(f - this._fit) < 1e-9 && this._z) return;
|
||||||
|
if (this._auto) { this.fit(); return; }
|
||||||
|
this._fit = f;
|
||||||
|
this._z = TmfViewerMath.clampZoom(this._z, f);
|
||||||
|
this._fitHeightBox(f);
|
||||||
|
this._apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: zoom di un fattore f tenendo fermo il punto (px, py) del viewport (default: il centro)
|
||||||
|
zoomBy(f, px, py, ancora) { // `ancora` {ix, iy} (interna, #12): punto immagine da tenere sotto (px, py)
|
||||||
|
if (!this._built || !this._w || !this._z) return;
|
||||||
|
var vp = this._vp, z0 = this._z;
|
||||||
|
var z1 = TmfViewerMath.clampZoom(z0 * f, this._fit);
|
||||||
|
if (z1 === z0) return;
|
||||||
|
if (px == null) px = vp.clientWidth / 2;
|
||||||
|
if (py == null) py = vp.clientHeight / 2;
|
||||||
|
// #12: punto immagine sotto (px, py) misurato sullo stage reale, cosi' resta fermo anche quando lo stage e' centrato
|
||||||
|
// dal margin:auto (immagine piu' piccola del viewport prima dello zoom); zoomAround copre il caso senza geometria
|
||||||
|
var sr = this._stage.getBoundingClientRect(), vr = vp.getBoundingClientRect();
|
||||||
|
var ix = (px - (sr.left - vr.left - vp.clientLeft)) / z0, iy = (py - (sr.top - vr.top - vp.clientTop)) / z0;
|
||||||
|
// #12: con l'ancora di una sequenza di rotella si riporta sotto il mouse il punto di partenza, non quello corrente
|
||||||
|
if (ancora && isFinite(ancora.ix) && isFinite(ancora.iy)) { ix = ancora.ix; iy = ancora.iy; }
|
||||||
|
this._zpt = { ix: ix, iy: iy };
|
||||||
|
this._z = z1; this._auto = false;
|
||||||
|
this._apply(); // la dimensione dello stage cambia subito, poi si puo' scorrere
|
||||||
|
var sr2 = this._stage.getBoundingClientRect(), vr2 = vp.getBoundingClientRect();
|
||||||
|
if (sr.width > 0 && isFinite(ix) && isFinite(iy)) {
|
||||||
|
vp.scrollLeft += (sr2.left - vr2.left - vp.clientLeft + ix * z1) - px;
|
||||||
|
vp.scrollTop += (sr2.top - vr2.top - vp.clientTop + iy * z1) - py;
|
||||||
|
} else {
|
||||||
|
var s = TmfViewerMath.zoomAround(z0, z1, vp.scrollLeft, vp.scrollTop, px, py);
|
||||||
|
vp.scrollLeft = s.scrollLeft; vp.scrollTop = s.scrollTop;
|
||||||
|
}
|
||||||
|
this._apply(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get zoom() { return this._z; }
|
||||||
|
|
||||||
|
// #122: applica zoom allo stage, ridisegna overlay ed etichette, aggiorna barra e cursore, avvisa con viewer-zoom
|
||||||
|
_apply(quiet) {
|
||||||
|
if (!this._built) return;
|
||||||
|
var z = this._z;
|
||||||
|
this._stage.style.width = this._w && z ? (this._w * z) + 'px' : '0px';
|
||||||
|
this._stage.style.height = this._w && z ? (this._h * z) + 'px' : '0px';
|
||||||
|
// #122: modo contenuto (DXF in mm) = percentuale rispetto ad Adatta; immagine = 100 % un pixel per pixel
|
||||||
|
this._pctEl.textContent = TmfViewerMath.pct(!this._hasSrc() && this._content && this._fit ? z / this._fit : z);
|
||||||
|
this._drawOverlay();
|
||||||
|
this._drawLabels();
|
||||||
|
this._syncLayers(); // #12
|
||||||
|
this._cursor();
|
||||||
|
if (quiet) return;
|
||||||
|
var key = z + '|' + this._fit;
|
||||||
|
if (key !== this._lastEvt) {
|
||||||
|
this._lastEvt = key;
|
||||||
|
this.dispatchEvent(new CustomEvent('viewer-zoom', { bubbles: true, detail: { zoom: z, fit: this._fit } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_canPan() {
|
||||||
|
var vp = this._vp;
|
||||||
|
return this._w > 0 && (vp.scrollWidth > vp.clientWidth + 1 || vp.scrollHeight > vp.clientHeight + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cursor() {
|
||||||
|
var can = this._canPan();
|
||||||
|
this._vp.style.cursor = can ? (this._pan ? 'grabbing' : 'grab') : 'default';
|
||||||
|
this._vp.style.touchAction = can ? 'none' : 'auto'; // il dito trascina solo se c'e' qualcosa da scorrere
|
||||||
|
}
|
||||||
|
|
||||||
|
_wheel(ev) { // Ctrl+rotella (anche il pinch del trackpad) fa lo stesso
|
||||||
|
// #12: la rotella zooma sempre sul punto del mouse (prima solo con Ctrl); senza immagine la pagina scorre
|
||||||
|
if (!this._w) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
var r = this._vp.getBoundingClientRect(), now = Date.now(), wa = this._wa;
|
||||||
|
// #12: sequenza di scatti a mouse fermo (<= 2 px, < 1500 ms) = stessa ancora; altrimenti la nuova ancora si misura in zoomBy.
|
||||||
|
// Cosi' lo scarto accumulato mentre lo stage e' piu' stretto del viewport (centrato, scroll impossibile) si recupera dopo.
|
||||||
|
var riusa = wa && Math.abs(ev.clientX - wa.cx) <= 2 && Math.abs(ev.clientY - wa.cy) <= 2 && now - wa.t < 1500;
|
||||||
|
this._zpt = null;
|
||||||
|
this.zoomBy(ev.deltaY < 0 ? STEP : 1 / STEP, ev.clientX - r.left - this._vp.clientLeft, ev.clientY - r.top - this._vp.clientTop,
|
||||||
|
riusa ? wa : null);
|
||||||
|
var pt = this._zpt || (riusa ? wa : null);
|
||||||
|
this._wa = pt ? { cx: ev.clientX, cy: ev.clientY, t: now, ix: pt.ix, iy: pt.iy } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_panDown(ev) {
|
||||||
|
var vp = this._vp;
|
||||||
|
if (!this._canPan() || (ev.pointerType === 'mouse' && ev.button)) return;
|
||||||
|
ev.preventDefault(); // senza, Chromium manda pointercancel al primo movimento
|
||||||
|
this._wa = null; // #12: il pan cambia il punto sotto il mouse: niente ancora della rotella
|
||||||
|
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
|
||||||
|
this._cursor();
|
||||||
|
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
_panMove(ev) {
|
||||||
|
var p = this._pan;
|
||||||
|
if (!p) return;
|
||||||
|
this._vp.scrollLeft = p.left - (ev.clientX - p.x);
|
||||||
|
this._vp.scrollTop = p.top - (ev.clientY - p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
_panEnd() { this._pan = null; this._cursor(); }
|
||||||
|
|
||||||
|
// #122: overlay SVG a tratto costante; ridisegnato a ogni zoom perche' le croci dei points restano a dimensione fissa.
|
||||||
|
// #12: tre gruppi <g data-layer> (ricerca, costruzioni a opacity 0.5, quote); un livello spento ha display none.
|
||||||
|
_drawOverlay() {
|
||||||
|
var ov = this._ovEl, VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||||
|
if (!this._w) { ov.innerHTML = ''; return; }
|
||||||
|
ov.setAttribute('viewBox', '0 0 ' + this._w + ' ' + this._h);
|
||||||
|
if (!VO) { ov.innerHTML = ''; return; }
|
||||||
|
var z = this._z || 1, lay = this._lay, noLay = this._noLay(); // #126
|
||||||
|
var by = { ricerca: [], costruzioni: [], quote: [] };
|
||||||
|
(this._ov || []).forEach(function (o) {
|
||||||
|
var k = VO.layerOf ? VO.layerOf(o) : (o && o.role === 'found' ? 'costruzioni' : 'ricerca');
|
||||||
|
by[k].push(o);
|
||||||
|
});
|
||||||
|
var quote = noLay ? '' : VO.svg(by.quote, { zoom: z }); // #126: con no-layers le forme vanno tutte nel disegno unico sotto
|
||||||
|
(this._lb || []).forEach(function (l) {
|
||||||
|
if (!l) return;
|
||||||
|
var col = QUOTA_COLOR_DEFAULT;
|
||||||
|
String(l.cls || '').split(/\s+/).forEach(function (c) { if (QUOTA_COLORS[c]) col = QUOTA_COLORS[c]; });
|
||||||
|
if (l.quota && VO.quotaSvg) { quote += VO.quotaSvg(l.quota, { zoom: z, color: col }); return; }
|
||||||
|
var t = l.testo;
|
||||||
|
if (Array.isArray(t) && t.length === 2 && isFinite(t[0]) && isFinite(t[1]) && isFinite(l.x) && isFinite(l.y)) {
|
||||||
|
// senza quota ma spostata: linea sottile dal punto originale alla scritta
|
||||||
|
var r2 = function (v) { return String(Math.round(v * 100) / 100); };
|
||||||
|
quote += '<line x1="' + r2(l.x) + '" y1="' + r2(l.y) + '" x2="' + r2(l.x + t[0]) + '" y2="' + r2(l.y + t[1]) +
|
||||||
|
'" stroke="' + col + '" stroke-width="1" vector-effect="non-scaling-stroke"/>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// #126: no-layers = disegno di prima dei livelli: tutte le forme nell'ordine dato, poi le quote; niente <g>, niente opacity
|
||||||
|
if (noLay) { ov.innerHTML = VO.svg(this._ov || [], { zoom: z }) + quote; return; }
|
||||||
|
var g = function (name, body, extra) {
|
||||||
|
return '<g data-layer="' + name + '"' + (extra || '') + (lay[name] ? '' : ' display="none"') + '>' + body + '</g>';
|
||||||
|
};
|
||||||
|
ov.innerHTML = g('ricerca', VO.svg(by.ricerca, { zoom: z })) +
|
||||||
|
g('costruzioni', VO.svg(by.costruzioni, { zoom: z }), ' opacity="0.5"') + g('quote', quote);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: punto della scritta di un'etichetta in px logici: sulla quota, oppure (x,y) + testo [dx,dy], oppure (x,y)
|
||||||
|
_labelPos(l) {
|
||||||
|
var VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||||
|
if (l.quota && VO && VO.quotaPunto) { var q = VO.quotaPunto(l.quota); if (q) return { x: q.x, y: q.y, quota: true }; }
|
||||||
|
var t = l.testo;
|
||||||
|
if (Array.isArray(t) && t.length === 2 && isFinite(t[0]) && isFinite(t[1])) return { x: l.x + t[0], y: l.y + t[1], quota: false };
|
||||||
|
return { x: l.x, y: l.y, quota: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: etichette in px logici -> % dello stage, cosi' seguono lo zoom senza ingrandirsi
|
||||||
|
// #12: testo per «Nomi» (text) o solo valore (breve); il livello Quote nasconde tutte le etichette
|
||||||
|
_drawLabels() {
|
||||||
|
var box = this._lbEl;
|
||||||
|
while (box.firstChild) box.removeChild(box.firstChild);
|
||||||
|
var noLay = this._noLay(); // #126
|
||||||
|
box.style.display = noLay || this._lay.quote ? '' : 'none'; // #126: con no-layers le etichette sono sempre visibili
|
||||||
|
if (!this._w) return;
|
||||||
|
var self = this, nomi = noLay || this._lay.nomi; // #126: con no-layers testo = text
|
||||||
|
(this._lb || []).forEach(function (l) {
|
||||||
|
if (!l || !isFinite(l.x) || !isFinite(l.y)) return;
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.className = 'tmfv-label' + (l.cls ? ' ' + l.cls : '');
|
||||||
|
var p = self._labelPos(l);
|
||||||
|
if (l.key != null) d.setAttribute('data-key', l.key);
|
||||||
|
d.style.left = (p.x / self._w * 100) + '%';
|
||||||
|
d.style.top = (p.y / self._h * 100) + '%';
|
||||||
|
if (p.quota) d.style.transform = 'translate(-50%,-50%)'; // la scritta di una quota e' centrata sul suo punto
|
||||||
|
var txt = nomi ? l.text : (l.breve != null ? l.breve : l.text);
|
||||||
|
d.textContent = txt == null ? '' : String(txt);
|
||||||
|
if (l.key != null && !noLay) { // #126: con no-layers nessun trascinamento
|
||||||
|
d.addEventListener('pointerdown', function (ev) { self._labelDown(ev, l); });
|
||||||
|
d.addEventListener('dblclick', function (ev) { self._labelReset(ev, l); });
|
||||||
|
}
|
||||||
|
box.appendChild(d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: sposta la scritta dell'etichetta nel punto (x, y) in px logici (testo [du,dv] per le quote, [dx,dy] per le altre)
|
||||||
|
_labelSet(l, x, y) {
|
||||||
|
var VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||||
|
var t = null;
|
||||||
|
if (l.quota && VO && VO.quotaTesto) t = VO.quotaTesto(l.quota, x, y);
|
||||||
|
if (t) l.quota = Object.assign({}, l.quota, { testo: t });
|
||||||
|
else t = [x - l.x, y - l.y];
|
||||||
|
l.testo = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
_labelEmit(l, testo) {
|
||||||
|
this.dispatchEvent(new CustomEvent('quota-spostata', { bubbles: true,
|
||||||
|
detail: { key: l.key, nome: l.nome == null ? null : l.nome, camera: l.camera == null ? null : l.camera, testo: testo } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: trascinamento di un'etichetta: niente pan (la propagazione si ferma), movimento sul documento
|
||||||
|
_labelDown(ev, l) {
|
||||||
|
if (ev.pointerType === 'mouse' && ev.button) return;
|
||||||
|
ev.stopPropagation(); ev.preventDefault();
|
||||||
|
var self = this, z = this._z || 1, p0 = this._labelPos(l);
|
||||||
|
var st = { cx: ev.clientX, cy: ev.clientY, moved: false };
|
||||||
|
var move = function (e) {
|
||||||
|
if (!st.moved && Math.abs(e.clientX - st.cx) + Math.abs(e.clientY - st.cy) < 3) return; // soglia: un clic non sposta
|
||||||
|
st.moved = true;
|
||||||
|
self._labelSet(l, p0.x + (e.clientX - st.cx) / z, p0.y + (e.clientY - st.cy) / z);
|
||||||
|
self._drawOverlay(); self._drawLabels();
|
||||||
|
};
|
||||||
|
var up = function () {
|
||||||
|
document.removeEventListener('pointermove', move);
|
||||||
|
document.removeEventListener('pointerup', up);
|
||||||
|
document.removeEventListener('pointercancel', up);
|
||||||
|
self._ldrag = null;
|
||||||
|
if (!st.moved) return;
|
||||||
|
var t = [Math.round(l.testo[0] * 10) / 10, Math.round(l.testo[1] * 10) / 10]; // 0.1 px, come il contratto
|
||||||
|
if (l.quota) l.quota = Object.assign({}, l.quota, { testo: t });
|
||||||
|
l.testo = t;
|
||||||
|
self._drawOverlay(); self._drawLabels();
|
||||||
|
self._labelEmit(l, t);
|
||||||
|
};
|
||||||
|
this._ldrag = st;
|
||||||
|
document.addEventListener('pointermove', move);
|
||||||
|
document.addEventListener('pointerup', up);
|
||||||
|
document.addEventListener('pointercancel', up);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: doppio clic = la scritta torna alla posizione di default (testo null)
|
||||||
|
_labelReset(ev, l) {
|
||||||
|
ev.stopPropagation(); ev.preventDefault();
|
||||||
|
if (l.quota) l.quota = Object.assign({}, l.quota, { testo: null });
|
||||||
|
l.testo = null;
|
||||||
|
this._drawOverlay(); this._drawLabels();
|
||||||
|
this._labelEmit(l, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: livelli. Stato in localStorage (try/catch: puo' essere bloccato), chiave per save-name
|
||||||
|
_layKey() {
|
||||||
|
var n = this.getAttribute('save-name');
|
||||||
|
return 'tmf-viewer-layers' + (n ? ':' + n : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
_layersLoad() {
|
||||||
|
try {
|
||||||
|
var raw = window.localStorage.getItem(this._layKey()), o = raw ? JSON.parse(raw) : null, lay = this._lay;
|
||||||
|
if (o && typeof o === 'object') LAYER_NAMES.forEach(function (k) { if (typeof o[k] === 'boolean') lay[k] = o[k]; });
|
||||||
|
} catch (e) { /* storage non disponibile: restano i default */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
_layersSave() {
|
||||||
|
try { window.localStorage.setItem(this._layKey(), JSON.stringify(this._lay)); } catch (e) { /* storage non disponibile */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: menu visibile con immagine o contenuto e almeno una forma o un'etichetta; immagine nascosta ma stage invariato
|
||||||
|
_syncLayers() {
|
||||||
|
if (!this._built || !this._layBar) return;
|
||||||
|
var has = (this._ov && this._ov.length) || (this._lb && this._lb.length);
|
||||||
|
this._layBar.hidden = this.hasAttribute('no-layers') || !this._w || !has;
|
||||||
|
this._img.style.visibility = this._noLay() || this._lay.immagine ? '' : 'hidden'; // #126: con no-layers l'immagine resta visibile
|
||||||
|
var self = this;
|
||||||
|
LAYER_NAMES.forEach(function (k) { self._layBoxes[k].checked = !!self._lay[k]; });
|
||||||
|
}
|
||||||
|
|
||||||
|
get layers() { return Object.assign({}, this._lay); }
|
||||||
|
set layers(v) {
|
||||||
|
var changed = false, lay = this._lay;
|
||||||
|
if (v && typeof v === 'object') {
|
||||||
|
LAYER_NAMES.forEach(function (k) {
|
||||||
|
if (k in v && !!v[k] !== lay[k]) { lay[k] = !!v[k]; changed = true; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!changed || !this._layBar) return; // prima della costruzione del DOM resta solo lo stato
|
||||||
|
this._syncLayers(); this._drawOverlay(); this._drawLabels();
|
||||||
|
this._layersSave();
|
||||||
|
this.dispatchEvent(new CustomEvent('viewer-layers', { bubbles: true, detail: { layers: this.layers } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
get overlay() { return this._ov; }
|
||||||
|
set overlay(v) { this._ov = Array.isArray(v) ? v : []; if (this._built) { this._drawOverlay(); this._syncLayers(); } }
|
||||||
|
get labels() { return this._lb; }
|
||||||
|
set labels(v) { this._lb = Array.isArray(v) ? v : []; if (this._built) { this._drawOverlay(); this._drawLabels(); this._syncLayers(); } } // #12: le quote stanno nell'overlay
|
||||||
|
|
||||||
|
// #122: Salva = immagine grezza (src o save-src) o, in modo contenuto, l'SVG rasterizzato
|
||||||
|
_save() {
|
||||||
|
var name = this.getAttribute('save-name') || 'immagine';
|
||||||
|
if (this._hasSrc()) {
|
||||||
|
if (typeof tmfSaveImage === 'function') return tmfSaveImage(this.getAttribute('save-src') || this.getAttribute('src'), name);
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
if (!this._content || typeof tmfSvgToPngBlob !== 'function' || typeof tmfSaveBlob !== 'function') return Promise.resolve(false);
|
||||||
|
var self = this;
|
||||||
|
return tmfSvgToPngBlob(this._content, this._w, this._h).then(function (blob) {
|
||||||
|
return tmfSaveBlob(blob, name);
|
||||||
|
}).catch(function (err) {
|
||||||
|
if (typeof console !== 'undefined') console.error('tmf-viewer salva:', err);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TmfViewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// customElements.define una sola volta, anche se lo script e' caricato due volte
|
||||||
|
if (typeof customElements !== 'undefined' && typeof HTMLElement !== 'undefined' &&
|
||||||
|
!(typeof customElements.get === 'function' && customElements.get(TAG))) {
|
||||||
|
customElements.define(TAG, defineViewer());
|
||||||
|
if (typeof document !== 'undefined') injectCss();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') window.TmfViewerMath = TmfViewerMath;
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = { TmfViewerMath: TmfViewerMath };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* save_image.js - #119: salva il frame grezzo mostrato nel pannello visione come PNG, senza disegni.
|
||||||
|
*
|
||||||
|
* Cosa fa: tmfSaveImage(src, baseName) scarica l'immagine `src` (blob: o http), la converte in PNG se serve
|
||||||
|
* e la salva con nome <baseName>_<AAAAMMGG-hhmmss>.png: «Salva con nome» (showSaveFilePicker)
|
||||||
|
* dove c'e', altrimenti <a download>. Ritorna una Promise<boolean> (false se annullato o fallito).
|
||||||
|
* tmfSaveImageName(baseName, date) e' la funzione pura del nome, esportata per i test.
|
||||||
|
* Come si usa: <script src=".../save_image.js"></script> poi @click="tmfSaveImage(url, 'task_14')".
|
||||||
|
* Dipende da: fetch, canvas, createImageBitmap e window del browser, toccati solo dentro le funzioni
|
||||||
|
* (il modulo si carica anche in node, con require, senza questi globali).
|
||||||
|
* #122: tmfSaveBlob(blob, baseName) salva un blob PNG gia' pronto; tmfSvgToPngBlob(svgEl, w, h) rasterizza un <svg>
|
||||||
|
* (sfondo bianco, lato lungo 1600-4096 px). Entrambi usati da <tmf-viewer> (image_viewer.js).
|
||||||
|
* Idea di conversione da image_to_png.js (#107).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// #119: nome file proposto <baseName>_<AAAAMMGG-hhmmss>.png; baseName ripulito dai caratteri non validi
|
||||||
|
function tmfSaveImageName(baseName, date) {
|
||||||
|
var base = String(baseName === undefined || baseName === null ? '' : baseName)
|
||||||
|
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_') // caratteri vietati nei nomi file Windows
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/^[._]+|[._]+$/g, ''); // niente punti o underscore ai bordi
|
||||||
|
if (!base) base = 'immagine';
|
||||||
|
var d = date instanceof Date && !isNaN(date.getTime()) ? date : new Date();
|
||||||
|
var p = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||||
|
var stamp = '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' +
|
||||||
|
p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
|
||||||
|
return base + '_' + stamp + '.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
// #119: ridisegna su canvas un'immagine non PNG (es. JPEG, BMP) e ritorna il blob PNG
|
||||||
|
async function tmfBlobToPng(blob) {
|
||||||
|
var bitmap = await createImageBitmap(blob);
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||||
|
if (bitmap.close) bitmap.close();
|
||||||
|
var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||||
|
if (!png) throw new Error('conversione PNG fallita');
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: «Salva con nome» (showSaveFilePicker) dove c'e'; null se manca (si ripiega su <a download>). Puo' lanciare AbortError
|
||||||
|
async function tmfPickPng(name) {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function') {
|
||||||
|
return window.showSaveFilePicker({
|
||||||
|
suggestedName: name,
|
||||||
|
types: [{ description: 'PNG', accept: { 'image/png': ['.png'] } }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: scrive il blob sul file scelto (handle) oppure, senza handle, lo scarica con <a download>
|
||||||
|
async function tmfWritePng(handle, blob, name) {
|
||||||
|
if (handle) {
|
||||||
|
var w = await handle.createWritable();
|
||||||
|
await w.write(blob);
|
||||||
|
await w.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// #119: ripiego senza «Salva con nome» (browser non Chromium, contesto non sicuro): <a download>
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: gestione comune dell'errore di salvataggio: l'annullo dell'utente non e' un errore
|
||||||
|
function tmfSaveFailed(err, tag) {
|
||||||
|
if (err && err.name === 'AbortError') return false; // #119: annullato dall'utente, non e' un errore
|
||||||
|
if (typeof console !== 'undefined') console.error(tag + ':', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: salva un blob PNG gia' pronto (es. l'SVG del DXF rasterizzato) come <baseName>_<data>.png; true = salvato
|
||||||
|
async function tmfSaveBlob(blob, baseName) {
|
||||||
|
if (!blob) return false;
|
||||||
|
try {
|
||||||
|
var name = tmfSaveImageName(baseName, new Date());
|
||||||
|
var handle = await tmfPickPng(name);
|
||||||
|
await tmfWritePng(handle, blob, name);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return tmfSaveFailed(err, 'tmfSaveBlob');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: rasterizza un <svg> in PNG (sfondo bianco). w,h = dimensioni logiche (default dal viewBox o dagli attributi);
|
||||||
|
// lato lungo portato fra 1600 e 4096 px. Ritorna una Promise<Blob> o lancia se il browser non riesce a disegnarlo.
|
||||||
|
async function tmfSvgToPngBlob(svgEl, w, h) {
|
||||||
|
var vb = svgEl.getAttribute && svgEl.getAttribute('viewBox');
|
||||||
|
var vbp = vb ? vb.trim().split(/[\s,]+/).map(Number) : [];
|
||||||
|
if (!(w > 0)) w = vbp.length === 4 ? vbp[2] : parseFloat(svgEl.getAttribute('width'));
|
||||||
|
if (!(h > 0)) h = vbp.length === 4 ? vbp[3] : parseFloat(svgEl.getAttribute('height'));
|
||||||
|
if (!(w > 0) || !(h > 0)) throw new Error('dimensioni SVG non valide');
|
||||||
|
var scale = Math.min(4096, Math.max(1600, Math.max(w, h))) / Math.max(w, h);
|
||||||
|
var cw = Math.max(1, Math.round(w * scale));
|
||||||
|
var ch = Math.max(1, Math.round(h * scale));
|
||||||
|
var clone = svgEl.cloneNode(true);
|
||||||
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
if (!vb) clone.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||||
|
clone.setAttribute('width', cw); // dimensione in pixel del PNG: l'SVG isolato non ha il CSS della pagina
|
||||||
|
clone.setAttribute('height', ch);
|
||||||
|
if (clone.style) { clone.style.width = ''; clone.style.height = ''; }
|
||||||
|
var xml = new XMLSerializer().serializeToString(clone);
|
||||||
|
var url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
|
||||||
|
try {
|
||||||
|
var img = new Image();
|
||||||
|
await new Promise(function (resolve, reject) {
|
||||||
|
img.onload = resolve;
|
||||||
|
img.onerror = function () { reject(new Error('SVG non disegnabile')); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = cw;
|
||||||
|
canvas.height = ch;
|
||||||
|
var g = canvas.getContext('2d');
|
||||||
|
g.fillStyle = '#ffffff';
|
||||||
|
g.fillRect(0, 0, cw, ch);
|
||||||
|
g.drawImage(img, 0, 0, cw, ch);
|
||||||
|
var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||||
|
if (!png) throw new Error('conversione PNG fallita');
|
||||||
|
return png;
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #119: scarica `src`, lo porta a PNG e lo salva; true = salvato, false = annullato dall'utente o errore
|
||||||
|
async function tmfSaveImage(src, baseName) {
|
||||||
|
if (!src) return false; // #119: nessuna immagine mostrata
|
||||||
|
try {
|
||||||
|
var name = tmfSaveImageName(baseName, new Date());
|
||||||
|
// #119: «Salva con nome» per primo, finche' vale il gesto dell'utente (Chrome lo rifiuta dopo ~5 s di fetch e conversione)
|
||||||
|
var handle = await tmfPickPng(name);
|
||||||
|
var resp = await fetch(src);
|
||||||
|
if (!resp.ok) return false;
|
||||||
|
var blob = await resp.blob();
|
||||||
|
if (blob.type !== 'image/png') blob = await tmfBlobToPng(blob);
|
||||||
|
await tmfWritePng(handle, blob, name);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return tmfSaveFailed(err, 'tmfSaveImage');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #119: globale nel browser, export per il test node
|
||||||
|
// #122: esportati anche tmfSaveBlob e tmfSvgToPngBlob (usati da <tmf-viewer>)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.tmfSaveImage = tmfSaveImage; window.tmfSaveImageName = tmfSaveImageName;
|
||||||
|
window.tmfSaveBlob = tmfSaveBlob; window.tmfSvgToPngBlob = tmfSvgToPngBlob;
|
||||||
|
}
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Stato di calibrazione della camera di una stazione (#83).
|
||||||
|
// La calibrazione vive nell'acquisitore (camera_service): qui si legge solo lo stato
|
||||||
|
// tramite il proxy Flask e si offre il link alla sua pagina «Calibra».
|
||||||
|
// Uso: x-data="stationCalibrationCell(station, msgs)" (msgs: stringhe tradotte). Dipende da Alpine.js.
|
||||||
|
|
||||||
|
// Formatta px/mm e data nella lingua della pagina (es. «20,03 px/mm · 23/10/2026»)
|
||||||
|
function formatCalibration(cal) {
|
||||||
|
const lang = document.documentElement.lang || 'it';
|
||||||
|
let text = new Intl.NumberFormat(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
.format(Number(cal.px_per_mm)) + ' px/mm';
|
||||||
|
if (cal.calibrated_at) {
|
||||||
|
const d = new Date(cal.calibrated_at);
|
||||||
|
if (!isNaN(d)) text += ' · ' + d.toLocaleDateString(lang);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legge lo stato dal proxy: ritorna {state, calibration, calibration_url, detail}
|
||||||
|
async function fetchStationCalibration(stationId) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/maker/api/stations/${stationId}/camera/calibration`);
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) return { state: 'unreachable', detail: body.detail || '' };
|
||||||
|
return body;
|
||||||
|
} catch (e) {
|
||||||
|
return { state: 'unreachable', detail: String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationCalibrationCell(station, msgs) {
|
||||||
|
return {
|
||||||
|
hasCamera: !!(station.camera_url && station.camera_source),
|
||||||
|
cameraUrl: station.camera_url || '',
|
||||||
|
state: '', // '' = in lettura | calibrated | uncalibrated | unreachable
|
||||||
|
label: '',
|
||||||
|
calibrationUrl: '',
|
||||||
|
msgs: msgs || {},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (!this.hasCamera) return;
|
||||||
|
const r = await fetchStationCalibration(station.id);
|
||||||
|
this.state = r.state || 'unreachable';
|
||||||
|
// 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,11 +11,15 @@
|
|||||||
* taskType, subtasks, labels.
|
* taskType, subtasks, labels.
|
||||||
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
||||||
* which the template binds to the parent task component.
|
* 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() {
|
function visionExecute() {
|
||||||
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
|
||||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
busy: false,
|
busy: false,
|
||||||
grabbing: false, // frame requested from the station camera (#72)
|
grabbing: false, // frame requested from the station camera (#72)
|
||||||
@@ -24,6 +28,135 @@ function visionExecute() {
|
|||||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||||
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
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
|
||||||
|
recordedBlob: null, // #123: frame saved by the last successful «Registra»: not recordable twice
|
||||||
|
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() {
|
||||||
|
// #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera
|
||||||
|
window.addEventListener('task-result-reset', () => {
|
||||||
|
this.saved = false;
|
||||||
|
// #123: il frame gia' registrato non e' piu' un'anteprima ne' si registra due volte
|
||||||
|
this.preview = !!this.result && this.lastBlob !== this.recordedBlob;
|
||||||
|
// #123: camera impulsiva = frame nuovo subito (come all'apertura); in linea riparte il ciclo
|
||||||
|
if (this.hasCamera && !this.inLinea) this.grab(); else this.kick();
|
||||||
|
});
|
||||||
|
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.lastBlob !== this.recordedBlob && !this.saved && !this.registering; }, // #123: non sul frame gia' registrato
|
||||||
|
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
this.recordedBlob = blob; // #123: questo frame e' registrato, Registra resta spento finche' non ne arriva uno nuovo
|
||||||
|
this._emitRecorded(); // #120: la pagina conta le quote registrate
|
||||||
|
}
|
||||||
|
} 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.
|
// Entry point shared with the future agent camera.
|
||||||
async acquire(blob) {
|
async acquire(blob) {
|
||||||
if (!blob || this.busy) return;
|
if (!blob || this.busy) return;
|
||||||
@@ -53,8 +186,8 @@ function visionExecute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.result = body;
|
this.result = body;
|
||||||
// Draw once Alpine has rendered the canvas.
|
// #120: senza camera il file scelto e' salvato subito (nessun «Registra»): stesso evento
|
||||||
this.$nextTick(() => this.drawOverlay());
|
if (!this.hasCamera && body.vision_result_id) this._emitRecorded();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -62,39 +195,60 @@ function visionExecute() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #120: misure salvate (risposta di execute) alla pagina: conteggio quote e «Completato»
|
||||||
|
_emitRecorded() {
|
||||||
|
const ms = (this.result && this.result.measurements) || [];
|
||||||
|
window.dispatchEvent(new CustomEvent('vision-recorded', { detail: { measurements: ms } }));
|
||||||
|
},
|
||||||
|
|
||||||
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
||||||
// acquire() like a picked file. Errors show as the execute ones do.
|
// 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() {
|
async grab() {
|
||||||
const cfg = window.__visionCfg || {};
|
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.grabbing = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
let blob = null;
|
let blob = null;
|
||||||
try {
|
try {
|
||||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
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, {
|
const resp = await fetch(cfg.grabUrl, {
|
||||||
method: 'POST', headers: { 'X-CSRFToken': csrf },
|
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await resp.json().catch(() => ({}));
|
const body = await resp.json().catch(() => ({}));
|
||||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
blob = await resp.blob();
|
blob = await resp.blob();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
return;
|
return false;
|
||||||
} finally {
|
} 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.
|
// 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) {
|
onPick(ev) {
|
||||||
const f = ev.target.files && ev.target.files[0];
|
const f = ev.target.files && ev.target.files[0];
|
||||||
ev.target.value = '';
|
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.
|
// Image shown: the saved one from the server when available, else the local one.
|
||||||
@@ -113,14 +267,51 @@ function visionExecute() {
|
|||||||
return ((this.result && this.result.measurements) || []).map((m) => {
|
return ((this.result && this.result.measurements) || []).map((m) => {
|
||||||
const st = subs.find((s) => s.id === m.subtask_id) || {};
|
const st = subs.find((s) => s.id === m.subtask_id) || {};
|
||||||
return {
|
return {
|
||||||
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
|
// #116: chiave stabile della quota, nome senza numero di marker e uscita ricetta, per le etichette sull'immagine
|
||||||
|
key: m.subtask_id,
|
||||||
|
name: st.description || st.measurement_type || '#' + m.subtask_id,
|
||||||
|
vision_output: st.vision_output,
|
||||||
|
// #114: il nome della quota e' description (prima: measurement_type/name, che per le quote DXF e' vuoto -> '#15')
|
||||||
|
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.description || st.measurement_type || '#' + m.subtask_id),
|
||||||
value: m.value,
|
value: m.value,
|
||||||
unit: st.unit || 'mm',
|
unit: st.unit || 'mm',
|
||||||
status: m.pass_fail,
|
status: m.pass_fail,
|
||||||
|
// #117: nominale e tolleranze della quota (come la schermata dei calibri); null se mancano
|
||||||
|
nominal: st.nominal, ltl: st.ltl, lwl: st.lwl, uwl: st.uwl, utl: st.utl,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #122: zoom, pan, resize e Salva li gestisce <tmf-viewer> (static/js/image_viewer.js)
|
||||||
|
|
||||||
|
// #118: valore della cella: una quota non misurata (null) e' «NaN», mai Number(null) = 0
|
||||||
|
fmtValue(v, unit) {
|
||||||
|
return (v == null || !Number.isFinite(Number(v)) ? 'NaN' : Number(v).toFixed(3)) + ' ' + unit;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #117: tolleranza a 3 decimali, trattino se manca (come x-text="...?.toFixed(3) || '—'" dei calibri, ma 0 resta 0.000)
|
||||||
|
fmtTol(v) { return v == null ? '\u2014' : Number(v).toFixed(3); },
|
||||||
|
|
||||||
|
// #119 #122: nome proposto da «Salva» (titolo del task) per <tmf-viewer save-name>
|
||||||
|
get saveName() { return (window.__visionCfg || {}).taskName || 'immagine'; },
|
||||||
|
|
||||||
|
// #116 #122: etichette «nome valore unita» per <tmf-viewer>: x/y in px logici dell'immagine
|
||||||
|
// (il componente le posiziona in % e le tiene a dimensione costante a ogni zoom)
|
||||||
|
get viewerLabels() {
|
||||||
|
if (typeof VisionOverlay === 'undefined' || !this.result) return [];
|
||||||
|
return VisionOverlay.labels(this.rows, this.result.overlay || []).map((l) => ({
|
||||||
|
key: l.key, x: l.x, y: l.y, text: l.text, cls: this.labelClass(l.status),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// #116: colori dell'etichetta per esito
|
||||||
|
labelClass(s) {
|
||||||
|
if (s === 'pass') return 'bg-green-600 text-white';
|
||||||
|
if (s === 'warning') return 'bg-amber-400 text-black';
|
||||||
|
if (s === 'fail') return 'bg-red-600 text-white';
|
||||||
|
return 'bg-gray-700 text-white';
|
||||||
|
},
|
||||||
|
|
||||||
statusClass(s) {
|
statusClass(s) {
|
||||||
// Same palette as the measurement markers of the manual flow.
|
// Same palette as the measurement markers of the manual flow.
|
||||||
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
|
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
|
||||||
@@ -128,27 +319,5 @@ function visionExecute() {
|
|||||||
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
|
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
|
||||||
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
|
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
|
||||||
},
|
},
|
||||||
|
|
||||||
// Canvas is sized to the natural image, so overlay coordinates (image px)
|
|
||||||
// are used as they are and CSS scales the whole thing to the displayed size.
|
|
||||||
drawOverlay() {
|
|
||||||
const img = this.$refs.img, cv = this.$refs.overlay;
|
|
||||||
if (!img || !cv || !img.naturalWidth) return;
|
|
||||||
cv.width = img.naturalWidth;
|
|
||||||
cv.height = img.naturalHeight;
|
|
||||||
const g = cv.getContext('2d');
|
|
||||||
g.clearRect(0, 0, cv.width, cv.height);
|
|
||||||
g.lineWidth = Math.max(2, cv.width / 400);
|
|
||||||
g.lineJoin = 'round';
|
|
||||||
for (const item of ((this.result && this.result.overlay) || [])) {
|
|
||||||
const rows = item.rows || [], cols = item.cols || [];
|
|
||||||
if (!rows.length || rows.length !== cols.length) continue;
|
|
||||||
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
|
|
||||||
g.beginPath();
|
|
||||||
g.moveTo(cols[0], rows[0]);
|
|
||||||
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
|
|
||||||
g.stroke();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
/**
|
||||||
|
* #116: disegno dell'overlay del check visione sul canvas del pannello operatore e
|
||||||
|
* ancoraggio delle etichette delle quote alla geometria del loro strumento.
|
||||||
|
*
|
||||||
|
* Script classico (niente import/export): lo carica task_execute.html prima di
|
||||||
|
* vision_execute.js, che lo usa via window.VisionOverlay. In node (test) si carica
|
||||||
|
* con require.
|
||||||
|
*
|
||||||
|
* API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null;
|
||||||
|
* labels(rows, overlay) -> [{key, x, y, text, status}];
|
||||||
|
* svg(overlay, opts) -> stringa di figli SVG (#121, vedi sotto);
|
||||||
|
* layerOf(o), quotaPunto(q), quotaTesto(q, x, y), quotaSvg(q, opts): livelli e quote CAD (#12).
|
||||||
|
* Convenzioni (vs-tools): row = y, col = x; angoli in gradi positivi antiorari a video.
|
||||||
|
* Porting da TieVision-Studio frontend/src/comune/overlay.js (verticiRect2, forma)
|
||||||
|
* e frontend/src/editor/misure.js (ancoraOverlay).
|
||||||
|
*/
|
||||||
|
var VisionOverlay = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #116: colore per ruolo, uguale a quello dello Studio
|
||||||
|
var COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||||
|
var COLOR_OTHER = '#ef4444';
|
||||||
|
|
||||||
|
function rad(d) { return (d * Math.PI) / 180; }
|
||||||
|
function num(v) { return typeof v === 'number' && isFinite(v); }
|
||||||
|
function allNum(o) {
|
||||||
|
for (var i = 1; i < arguments.length; i++) {
|
||||||
|
if (!o || !num(o[arguments[i]])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// #116: serie rows/cols valida = entrambe array, stessa lunghezza, non vuote, tutti numeri finiti
|
||||||
|
function validSeries(o) {
|
||||||
|
if (!o || !Array.isArray(o.rows) || !Array.isArray(o.cols)) return false;
|
||||||
|
if (o.rows.length === 0 || o.rows.length !== o.cols.length) return false;
|
||||||
|
for (var i = 0; i < o.rows.length; i++) {
|
||||||
|
if (!num(o.rows[i]) || !num(o.cols[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #116: i 4 vertici del rect2 (x = col, y = row), come verticiRect2 dello Studio
|
||||||
|
function rect2Vertices(o) {
|
||||||
|
var a = rad(o.phi_deg);
|
||||||
|
var u = [Math.cos(a), -Math.sin(a)];
|
||||||
|
var v = [-Math.sin(a), -Math.cos(a)]; // u ruotato di +90 antiorario
|
||||||
|
return [[1, 1], [1, -1], [-1, -1], [-1, 1]].map(function (st) {
|
||||||
|
return [
|
||||||
|
o.col + st[0] * o.length1 * u[0] + st[1] * o.length2 * v[0],
|
||||||
|
o.row + st[0] * o.length1 * u[1] + st[1] * o.length2 * v[1],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// #116: disegna una forma; true se disegnata, false se saltata (tipo ignoto o dati malformati)
|
||||||
|
function drawShape(g, o, lw) {
|
||||||
|
var type = o && o.type;
|
||||||
|
var i;
|
||||||
|
switch (type) {
|
||||||
|
case 'rect2': {
|
||||||
|
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return false;
|
||||||
|
var vs = rect2Vertices(o);
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(vs[0][0], vs[0][1]);
|
||||||
|
for (i = 1; i < vs.length; i++) g.lineTo(vs[i][0], vs[i][1]);
|
||||||
|
g.closePath();
|
||||||
|
g.stroke();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'segment':
|
||||||
|
case 'line':
|
||||||
|
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return false;
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(o.col1, o.row1);
|
||||||
|
g.lineTo(o.col2, o.row2);
|
||||||
|
g.stroke();
|
||||||
|
return true;
|
||||||
|
case 'circle':
|
||||||
|
if (!allNum(o, 'row', 'col', 'radius')) return false;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(o.col, o.row, o.radius, 0, 2 * Math.PI);
|
||||||
|
g.stroke();
|
||||||
|
return true;
|
||||||
|
case 'arc':
|
||||||
|
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return false;
|
||||||
|
g.beginPath();
|
||||||
|
// #116: y canvas verso il basso, quindi angoli negati; extent > 0 = antiorario a video
|
||||||
|
g.arc(o.col, o.row, o.radius, -rad(o.start_deg), -rad(o.start_deg + o.extent_deg), o.extent_deg > 0);
|
||||||
|
g.stroke();
|
||||||
|
return true;
|
||||||
|
case 'points': {
|
||||||
|
if (!validSeries(o)) return false;
|
||||||
|
var m = Math.max(3, lw * 2); // semi-lato della croce
|
||||||
|
g.beginPath();
|
||||||
|
for (i = 0; i < o.rows.length; i++) {
|
||||||
|
g.moveTo(o.cols[i] - m, o.rows[i]);
|
||||||
|
g.lineTo(o.cols[i] + m, o.rows[i]);
|
||||||
|
g.moveTo(o.cols[i], o.rows[i] - m);
|
||||||
|
g.lineTo(o.cols[i], o.rows[i] + m);
|
||||||
|
}
|
||||||
|
g.stroke();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'contour':
|
||||||
|
if (!validSeries(o)) return false;
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(o.cols[0], o.rows[0]);
|
||||||
|
for (i = 1; i < o.rows.length; i++) g.lineTo(o.cols[i], o.rows[i]);
|
||||||
|
g.stroke(); // polilinea aperta, come nello Studio
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(g, overlay, lw) {
|
||||||
|
var res = { drawn: 0, skipped: 0 };
|
||||||
|
var items = Array.isArray(overlay) ? overlay : [];
|
||||||
|
for (var k = 0; k < items.length; k++) {
|
||||||
|
var o = items[k];
|
||||||
|
var role = o && o.role;
|
||||||
|
var isRoi = role === 'roi';
|
||||||
|
g.strokeStyle = COLORS[role] || COLOR_OTHER;
|
||||||
|
g.lineWidth = isRoi ? lw * 0.75 : lw;
|
||||||
|
g.setLineDash(isRoi ? [lw * 3, lw * 2] : []); // roi tratteggiata
|
||||||
|
var ok = false;
|
||||||
|
try { ok = drawShape(g, o, lw); } catch (e) { ok = false; } // un elemento rotto non ferma gli altri
|
||||||
|
if (ok) res.drawn++; else res.skipped++;
|
||||||
|
}
|
||||||
|
g.setLineDash([]);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #121: numero per il markup SVG, al piu' 2 decimali (niente stringhe dei dati nel markup)
|
||||||
|
function n2(v) { return String(Math.round(v * 100) / 100); }
|
||||||
|
function pos(v, dflt) { return num(v) && v > 0 ? v : dflt; }
|
||||||
|
|
||||||
|
// #121: path SVG di un arco, porting di percorsoArco dello Studio; extent > 0 = antiorario a video
|
||||||
|
function arcPath(o) {
|
||||||
|
var ext = Math.max(-359.99, Math.min(359.99, o.extent_deg)); // 360 pieni darebbero start == end
|
||||||
|
var a0 = rad(o.start_deg);
|
||||||
|
var a1 = rad(o.start_deg + ext);
|
||||||
|
var x0 = o.col + o.radius * Math.cos(a0), y0 = o.row - o.radius * Math.sin(a0);
|
||||||
|
var x1 = o.col + o.radius * Math.cos(a1), y1 = o.row - o.radius * Math.sin(a1);
|
||||||
|
var large = Math.abs(ext) > 180 ? 1 : 0;
|
||||||
|
var sweep = ext > 0 ? 0 : 1; // y verso il basso: antiorario a video = sweep-flag 0
|
||||||
|
return 'M ' + n2(x0) + ' ' + n2(y0) + ' A ' + n2(o.radius) + ' ' + n2(o.radius) +
|
||||||
|
' 0 ' + large + ' ' + sweep + ' ' + n2(x1) + ' ' + n2(y1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #121: [tag, attributi geometrici] di una forma, o null se tipo ignoto o dati malformati (stessi criteri di drawShape)
|
||||||
|
function shapeSvg(o, cross) {
|
||||||
|
var i, pts;
|
||||||
|
switch (o && o.type) {
|
||||||
|
case 'rect2':
|
||||||
|
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return null;
|
||||||
|
pts = rect2Vertices(o).map(function (v) { return n2(v[0]) + ',' + n2(v[1]); }).join(' ');
|
||||||
|
return ['polygon', 'points="' + pts + '"'];
|
||||||
|
case 'segment':
|
||||||
|
case 'line':
|
||||||
|
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return null;
|
||||||
|
return ['line', 'x1="' + n2(o.col1) + '" y1="' + n2(o.row1) + '" x2="' + n2(o.col2) + '" y2="' + n2(o.row2) + '"'];
|
||||||
|
case 'circle':
|
||||||
|
if (!allNum(o, 'row', 'col', 'radius')) return null;
|
||||||
|
return ['circle', 'cx="' + n2(o.col) + '" cy="' + n2(o.row) + '" r="' + n2(o.radius) + '"'];
|
||||||
|
case 'arc':
|
||||||
|
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
|
||||||
|
return ['path', 'd="' + arcPath(o) + '"'];
|
||||||
|
case 'points': {
|
||||||
|
if (!validSeries(o)) return null;
|
||||||
|
var d = [];
|
||||||
|
for (i = 0; i < o.rows.length; i++) {
|
||||||
|
var x = o.cols[i], y = o.rows[i];
|
||||||
|
d.push('M ' + n2(x - cross) + ' ' + n2(y) + ' L ' + n2(x + cross) + ' ' + n2(y) +
|
||||||
|
' M ' + n2(x) + ' ' + n2(y - cross) + ' L ' + n2(x) + ' ' + n2(y + cross));
|
||||||
|
}
|
||||||
|
return ['path', 'd="' + d.join(' ') + '"'];
|
||||||
|
}
|
||||||
|
case 'contour':
|
||||||
|
if (!validSeries(o)) return null;
|
||||||
|
pts = o.rows.map(function (r, k) { return n2(o.cols[k]) + ',' + n2(r); }).join(' ');
|
||||||
|
return ['polyline', 'points="' + pts + '"']; // aperta, come draw
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #121: overlay -> figli SVG (senza <svg> esterno) in px immagine. Tratto a spessore costante a video
|
||||||
|
// (vector-effect non-scaling-stroke), croci dei points a semi-braccio costante a video (cross / zoom).
|
||||||
|
function svg(overlay, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var zoom = pos(opts.zoom, 1), lw = pos(opts.lw, 2), cross = pos(opts.cross, 6);
|
||||||
|
var items = Array.isArray(overlay) ? overlay : [];
|
||||||
|
var out = [];
|
||||||
|
for (var k = 0; k < items.length; k++) {
|
||||||
|
var o = items[k];
|
||||||
|
var sh = null;
|
||||||
|
try { sh = shapeSvg(o, cross / zoom); } catch (e) { sh = null; } // un elemento rotto non ferma gli altri
|
||||||
|
if (!sh) continue;
|
||||||
|
var role = o.role;
|
||||||
|
var isRoi = role === 'roi';
|
||||||
|
// solo colori fissi e numeri: nessuna stringa dei dati finisce nel markup
|
||||||
|
out.push('<' + sh[0] + ' ' + sh[1] + ' fill="none" stroke="' + (COLORS[role] || COLOR_OTHER) +
|
||||||
|
'" stroke-width="' + n2(isRoi ? lw * 0.75 : lw) + '"' +
|
||||||
|
(isRoi ? ' stroke-dasharray="6 4"' : '') + ' vector-effect="non-scaling-stroke"/>');
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// #116: punto di ancoraggio dell'etichetta (px immagine), come ancoraOverlay dello Studio
|
||||||
|
function anchor(o) {
|
||||||
|
switch (o && o.type) {
|
||||||
|
case 'segment':
|
||||||
|
case 'line':
|
||||||
|
return allNum(o, 'row1', 'col1', 'row2', 'col2')
|
||||||
|
? { x: (o.col1 + o.col2) / 2, y: (o.row1 + o.row2) / 2 } : null;
|
||||||
|
case 'circle':
|
||||||
|
return allNum(o, 'row', 'col', 'radius') ? { x: o.col + o.radius, y: o.row } : null; // punto a destra
|
||||||
|
case 'arc': {
|
||||||
|
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
|
||||||
|
var a = rad(o.start_deg + o.extent_deg / 2); // punto medio dell'arco
|
||||||
|
return { x: o.col + o.radius * Math.cos(a), y: o.row - o.radius * Math.sin(a) };
|
||||||
|
}
|
||||||
|
case 'points':
|
||||||
|
return Array.isArray(o.rows) && Array.isArray(o.cols) && num(o.rows[0]) && num(o.cols[0])
|
||||||
|
? { x: o.cols[0], y: o.rows[0] } : null;
|
||||||
|
case 'rect2':
|
||||||
|
return allNum(o, 'row', 'col') ? { x: o.col, y: o.row } : null;
|
||||||
|
case 'contour': {
|
||||||
|
if (!Array.isArray(o.rows) || !Array.isArray(o.cols)) return null;
|
||||||
|
var i = Math.floor(Math.min(o.rows.length, o.cols.length) / 2);
|
||||||
|
return num(o.rows[i]) && num(o.cols[i]) ? { x: o.cols[i], y: o.rows[i] } : null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #116: un'etichetta per riga con vision_output 'tool_id.proprieta' che ha un overlay ancorabile
|
||||||
|
function labels(rows, overlay) {
|
||||||
|
var out = [];
|
||||||
|
var items = Array.isArray(overlay) ? overlay : [];
|
||||||
|
(rows || []).forEach(function (r) {
|
||||||
|
if (!r || typeof r.vision_output !== 'string' || r.vision_output.indexOf('.') < 0) return;
|
||||||
|
var toolId = r.vision_output.split('.')[0];
|
||||||
|
var mine = items.filter(function (o) { return o && o.tool_id === toolId; });
|
||||||
|
// prima gli elementi 'found', poi qualsiasi altro
|
||||||
|
var order = mine.filter(function (o) { return o.role === 'found'; })
|
||||||
|
.concat(mine.filter(function (o) { return o.role !== 'found'; }));
|
||||||
|
var pt = null;
|
||||||
|
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
|
||||||
|
if (!pt) return;
|
||||||
|
// #118: valore null/undefined/vuoto/non finito = quota non misurata (Number(null) e' 0): etichetta NaN, status della riga (fail)
|
||||||
|
var v = (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean')
|
||||||
|
? NaN : Number(r.value);
|
||||||
|
var txt = isFinite(v) ? v.toFixed(3) : 'NaN';
|
||||||
|
out.push({
|
||||||
|
key: r.key,
|
||||||
|
x: pt.x,
|
||||||
|
y: pt.y,
|
||||||
|
text: r.name + ' ' + txt + ' ' + (r.unit || ''),
|
||||||
|
status: r.status,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- #12: livelli e quote stile CAD ------------------------------------------------------------
|
||||||
|
// Coordinate in px immagine; angoli in gradi antiorari a video, versore u(a) = (cos a, -sin a),
|
||||||
|
// normale n = (-u.y, u.x). Contratto: gitea:Adriano/TieVision-Station#12.
|
||||||
|
|
||||||
|
// #12: colori ammessi per le quote (esiti del visore + rosso di default); mai una stringa dei dati nel markup
|
||||||
|
var QUOTA_COLORS = ['#16a34a', '#d97706', '#dc2626', '#ef4444'];
|
||||||
|
var QUOTA_ARROW = 9, QUOTA_GAP = 3, QUOTA_OVER = 6, QUOTA_LW = 1.5; // px a video
|
||||||
|
|
||||||
|
// #12: livello di una forma: campo "layer" se valido, altrimenti found = costruzioni, il resto = ricerca
|
||||||
|
function layerOf(o) {
|
||||||
|
var l = o && o.layer;
|
||||||
|
if (l === 'costruzioni' || l === 'ricerca' || l === 'quote') return l;
|
||||||
|
return o && o.role === 'found' ? 'costruzioni' : 'ricerca';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pt2(p) { return Array.isArray(p) && p.length === 2 && num(p[0]) && num(p[1]); }
|
||||||
|
function uVec(deg) { var a = rad(deg); return [Math.cos(a), -Math.sin(a)]; }
|
||||||
|
|
||||||
|
// #12: riferimento locale della quota {b, u, n, td, ...} o null se malformata
|
||||||
|
function quotaFrame(q) {
|
||||||
|
if (!q || typeof q !== 'object') return null;
|
||||||
|
var f = { tipo: q.tipo };
|
||||||
|
switch (q.tipo) {
|
||||||
|
case 'lineare': {
|
||||||
|
if (!pt2(q.p1) || !pt2(q.p2)) return null;
|
||||||
|
var dx = q.p2[0] - q.p1[0], dy = q.p2[1] - q.p1[1], L = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
f.u = L > 1e-12 ? [dx / L, dy / L] : [1, 0];
|
||||||
|
f.b = [(q.p1[0] + q.p2[0]) / 2, (q.p1[1] + q.p2[1]) / 2];
|
||||||
|
f.L = L; f.dflt = [0, 0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'raggio':
|
||||||
|
if (!pt2(q.centro) || !num(q.r) || !num(q.dir_deg)) return null;
|
||||||
|
f.u = uVec(q.dir_deg); f.b = [q.centro[0], q.centro[1]]; f.dflt = [2 * q.r, 0];
|
||||||
|
break;
|
||||||
|
case 'angolo':
|
||||||
|
if (!pt2(q.centro) || !num(q.r) || !num(q.start_deg) || !num(q.extent_deg)) return null;
|
||||||
|
f.u = uVec(q.start_deg + q.extent_deg / 2); f.b = [q.centro[0], q.centro[1]]; f.dflt = [1.5 * q.r, 0];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
f.n = [-f.u[1], f.u[0]];
|
||||||
|
f.td = pt2(q.testo) ? [q.testo[0], q.testo[1]] : f.dflt; // testo null o malformato = posizione di default
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: punto T della scritta (px immagine) = B + du*u + dv*n; null se la quota e' malformata
|
||||||
|
function quotaPunto(q) {
|
||||||
|
var f = quotaFrame(q);
|
||||||
|
if (!f) return null;
|
||||||
|
return { x: f.b[0] + f.td[0] * f.u[0] + f.td[1] * f.n[0], y: f.b[1] + f.td[0] * f.u[1] + f.td[1] * f.n[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: inverso di quotaPunto: [du, dv] che porta la scritta nel punto (x, y); serve al trascinamento
|
||||||
|
function quotaTesto(q, x, y) {
|
||||||
|
var f = quotaFrame(q);
|
||||||
|
if (!f || !num(x) || !num(y)) return null;
|
||||||
|
var rx = x - f.b[0], ry = y - f.b[1];
|
||||||
|
return [rx * f.u[0] + ry * f.u[1], rx * f.n[0] + ry * f.n[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function qLine(x1, y1, x2, y2, color) {
|
||||||
|
return '<line x1="' + n2(x1) + '" y1="' + n2(y1) + '" x2="' + n2(x2) + '" y2="' + n2(y2) + '" stroke="' + color +
|
||||||
|
'" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>';
|
||||||
|
}
|
||||||
|
// #12: freccia piena con la punta in (x, y) rivolta verso (dx, dy) (versore); lunghezza fissa a video
|
||||||
|
function qArrow(x, y, dx, dy, zoom, color) {
|
||||||
|
var len = QUOTA_ARROW / zoom, w = len / 3;
|
||||||
|
var bx = x - dx * len, by = y - dy * len;
|
||||||
|
return '<polygon points="' + n2(x) + ',' + n2(y) + ' ' + n2(bx - dy * w) + ',' + n2(by + dx * w) + ' ' +
|
||||||
|
n2(bx + dy * w) + ',' + n2(by - dx * w) + '" fill="' + color + '" stroke="none"/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaSvgLineare(q, f, zoom, color) {
|
||||||
|
var out = [], u = f.u, n = f.n, dv = f.td[1], du = f.td[0], i;
|
||||||
|
var sg = dv >= 0 ? 1 : -1, gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
|
||||||
|
var ps = [q.p1, q.p2];
|
||||||
|
if (Math.abs(dv) * zoom >= 3) { // linee di estensione: assenti se la linea di misura sta sul pezzo
|
||||||
|
for (i = 0; i < 2; i++) {
|
||||||
|
out.push(qLine(ps[i][0] + sg * gap * n[0], ps[i][1] + sg * gap * n[1],
|
||||||
|
ps[i][0] + (dv + sg * over) * n[0], ps[i][1] + (dv + sg * over) * n[1], color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var m0 = [f.b[0] + dv * n[0], f.b[1] + dv * n[1]]; // punto medio della linea di misura
|
||||||
|
var h = f.L / 2, inside = f.L * zoom >= 2.5 * QUOTA_ARROW;
|
||||||
|
var lo = Math.min(-h, du), hi = Math.max(h, du); // la linea arriva fino alla scritta se sta oltre A o B
|
||||||
|
if (!inside) { lo = Math.min(lo, -h - 12 / zoom); hi = Math.max(hi, h + 12 / zoom); }
|
||||||
|
out.push(qLine(m0[0] + lo * u[0], m0[1] + lo * u[1], m0[0] + hi * u[0], m0[1] + hi * u[1], color));
|
||||||
|
var k = inside ? 1 : -1; // frecce corte: fuori, rivolte verso l'interno
|
||||||
|
out.push(qArrow(m0[0] - h * u[0], m0[1] - h * u[1], -k * u[0], -k * u[1], zoom, color));
|
||||||
|
out.push(qArrow(m0[0] + h * u[0], m0[1] + h * u[1], k * u[0], k * u[1], zoom, color));
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaSvgRaggio(q, f, zoom, color) {
|
||||||
|
var out = [], c = q.centro, T = quotaPunto(q);
|
||||||
|
var vx = T.x - c[0], vy = T.y - c[1], d = Math.sqrt(vx * vx + vy * vy);
|
||||||
|
var w = d > 1e-9 ? [vx / d, vy / d] : uVec(q.dir_deg);
|
||||||
|
var px = c[0] + q.r * w[0], py = c[1] + q.r * w[1], cr = 4 / zoom;
|
||||||
|
out.push(qLine(c[0] - cr, c[1], c[0] + cr, c[1], color)); // crocetta sul centro
|
||||||
|
out.push(qLine(c[0], c[1] - cr, c[0], c[1] + cr, color));
|
||||||
|
if (d > q.r) { // scritta fuori dal cerchio: da P a T, freccia in P verso il centro
|
||||||
|
out.push(qLine(px, py, T.x, T.y, color));
|
||||||
|
out.push(qArrow(px, py, -w[0], -w[1], zoom, color));
|
||||||
|
} else { // scritta dentro: dal centro a P, freccia in P verso fuori
|
||||||
|
out.push(qLine(c[0], c[1], px, py, color));
|
||||||
|
out.push(qArrow(px, py, w[0], w[1], zoom, color));
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaSvgAngolo(q, f, zoom, color) {
|
||||||
|
var out = [], c = q.centro, T = quotaPunto(q), i;
|
||||||
|
var rho = Math.sqrt((T.x - c[0]) * (T.x - c[0]) + (T.y - c[1]) * (T.y - c[1]));
|
||||||
|
if (!(rho > 1e-9)) rho = q.r > 0 ? 1.5 * q.r : 1;
|
||||||
|
var gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
|
||||||
|
var ends = [q.start_deg, q.start_deg + q.extent_deg];
|
||||||
|
var sgn = q.extent_deg >= 0 ? 1 : -1;
|
||||||
|
var inside = rho * rad(Math.abs(q.extent_deg)) * zoom >= 2.5 * QUOTA_ARROW;
|
||||||
|
for (i = 0; i < 2; i++) {
|
||||||
|
var u = uVec(ends[i]);
|
||||||
|
var a = rho >= q.r ? q.r + gap : rho - over, b = rho >= q.r ? rho + over : q.r - gap;
|
||||||
|
out.push(qLine(c[0] + a * u[0], c[1] + a * u[1], c[0] + b * u[0], c[1] + b * u[1], color)); // estensione radiale
|
||||||
|
}
|
||||||
|
out.push('<path d="' + arcPath({ col: c[0], row: c[1], radius: rho, start_deg: q.start_deg, extent_deg: q.extent_deg }) +
|
||||||
|
'" fill="none" stroke="' + color + '" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>');
|
||||||
|
for (i = 0; i < 2; i++) {
|
||||||
|
var ang = rad(ends[i]), uu = uVec(ends[i]);
|
||||||
|
var tg = [-Math.sin(ang), -Math.cos(ang)]; // tangente nel verso degli angoli crescenti
|
||||||
|
var dir = (i === 0 ? -1 : 1) * sgn * (inside ? 1 : -1); // dentro: verso fuori dall'arco; corte: verso l'interno
|
||||||
|
out.push(qArrow(c[0] + rho * uu[0], c[1] + rho * uu[1], dir * tg[0], dir * tg[1], zoom, color));
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: quota -> figli SVG (px immagine). opts = {zoom, color}; misure fisse a video divise per lo zoom.
|
||||||
|
// Solo numeri e colori da lista fissa nel markup.
|
||||||
|
function quotaSvg(q, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var zoom = pos(opts.zoom, 1);
|
||||||
|
var color = QUOTA_COLORS.indexOf(opts.color) >= 0 ? opts.color : COLOR_OTHER;
|
||||||
|
try {
|
||||||
|
var f = quotaFrame(q);
|
||||||
|
if (!f) return '';
|
||||||
|
if (f.tipo === 'lineare') return quotaSvgLineare(q, f, zoom, color);
|
||||||
|
if (f.tipo === 'raggio') return quotaSvgRaggio(q, f, zoom, color);
|
||||||
|
return quotaSvgAngolo(q, f, zoom, color);
|
||||||
|
} catch (e) { return ''; } // una quota rotta non ferma le altre
|
||||||
|
}
|
||||||
|
|
||||||
|
return { draw: draw, anchor: anchor, labels: labels, svg: svg,
|
||||||
|
layerOf: layerOf, quotaPunto: quotaPunto, quotaTesto: quotaTesto, quotaSvg: quotaSvg };
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay;
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = VisionOverlay;
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
|
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
|
||||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
|
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
|
||||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
|
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
|
||||||
|
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Calibrazione') }}</th>
|
||||||
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
|
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
|
||||||
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
|
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -81,6 +82,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
|
{# Calibrazione (#83): stato letto dall'acquisitore della camera, link alla sua pagina «Calibra» #}
|
||||||
|
<td class="px-4 py-3 hidden md:table-cell" @click.stop
|
||||||
|
x-data="stationCalibrationCell(station, {{ {'uncalibrated': _('non calibrata'), 'unreachable': _('acquisitore non raggiungibile')}|tojson_attr }})">
|
||||||
|
<template x-if="!hasCamera">
|
||||||
|
<span class="text-xs italic text-[var(--text-secondary)]">{{ _('Nessuna camera') }}</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="hasCamera">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||||
|
<span :class="state === 'calibrated' ? 'text-emerald-700 dark:text-emerald-300'
|
||||||
|
: (state === 'uncalibrated' || state === 'unreachable') ? 'text-amber-700 dark:text-amber-300'
|
||||||
|
: 'text-[var(--text-secondary)]'">
|
||||||
|
<span x-show="state === 'calibrated'">{{ _('Calibrazione') }}: </span><span x-text="state ? label : '...'"></span>
|
||||||
|
</span>
|
||||||
|
<a :href="calibrationUrl" target="_blank" rel="noopener"
|
||||||
|
x-show="calibrationUrl"
|
||||||
|
class="font-medium text-primary hover:underline">{{ _('Acquisizione') }}</a> {# #88: era «Calibra» #}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 text-center">
|
<td class="px-4 py-3 text-center">
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
|
||||||
:class="station.active
|
:class="station.active
|
||||||
@@ -218,21 +238,42 @@
|
|||||||
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
|
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label>
|
<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)]
|
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"
|
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"
|
placeholder="host:8765"
|
||||||
maxlength="500">
|
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>
|
<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>
|
</div>
|
||||||
|
{# Sorgente camera (#88): testo libero con suggerimenti letti dall'acquisitore (datalist) #}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
|
||||||
<input type="text" x-model="form.camera_source"
|
<div class="flex gap-2">
|
||||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
<input type="text" x-model="form.camera_source" list="cameraSourcesList"
|
||||||
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
class="flex-1 min-w-0 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||||
placeholder="camera:folder"
|
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||||
maxlength="200">
|
placeholder="camera:folder"
|
||||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Es. camera:<seriale> oppure camera:folder') }}</p>
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -459,6 +500,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
function stationManagement(initialStations, initialRecipes) {
|
function stationManagement(initialStations, initialRecipes) {
|
||||||
return {
|
return {
|
||||||
@@ -488,6 +530,12 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
camera_source: '', // #72
|
camera_source: '', // #72
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
|
// #88: sorgenti lette dall'acquisitore per il datalist della sorgente camera
|
||||||
|
cameraSources: [],
|
||||||
|
cameraSourcesLoaded: false,
|
||||||
|
cameraSourcesLoading: false,
|
||||||
|
cameraSourcesStatus: '',
|
||||||
|
cameraSourcesError: false,
|
||||||
|
|
||||||
get csrfToken() {
|
get csrfToken() {
|
||||||
const meta = document.querySelector('meta[name=csrf-token]');
|
const meta = document.querySelector('meta[name=csrf-token]');
|
||||||
@@ -535,11 +583,46 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
return '{{ _("Nessuna ricetta assegnata") }}';
|
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() {
|
openCreateModal() {
|
||||||
this.isEditing = false;
|
this.isEditing = false;
|
||||||
this.editingId = null;
|
this.editingId = null;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true };
|
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;
|
this.showModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -557,6 +640,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
camera_source: station.camera_source || '', // #72
|
camera_source: station.camera_source || '', // #72
|
||||||
active: station.active,
|
active: station.active,
|
||||||
};
|
};
|
||||||
|
this.loadCameraSources(); // #88: se la camera e' valorizzata legge subito le sorgenti
|
||||||
this.showModal = true;
|
this.showModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -504,6 +504,7 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/image_to_png.js') }}"></script>{# #107 #}
|
||||||
<script>
|
<script>
|
||||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||||
ImageCrop.configure({
|
ImageCrop.configure({
|
||||||
@@ -686,6 +687,13 @@ function recipeEditor() {
|
|||||||
async uploadFile(file) {
|
async uploadFile(file) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
// #107: BMP e simili (leggibili dal browser, non dal backend) diventano PNG prima dei controlli
|
||||||
|
file = await tmfImageToPng(file);
|
||||||
|
if (!file) {
|
||||||
|
this.errorMessage = {{ _("Formato immagine non leggibile. Usa PNG, JPG o PDF.")|tojson }};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate file type
|
// Validate file type
|
||||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||||
if (!allowed.includes(file.type)) {
|
if (!allowed.includes(file.type)) {
|
||||||
|
|||||||
@@ -547,10 +547,76 @@
|
|||||||
|
|
||||||
<div class="tmf-card-body space-y-4">
|
<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 ========== -->
|
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
|
||||||
<template x-if="task.task_type === 'camera_measure' || task.task_type === '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)]"
|
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
|
||||||
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
|
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})"
|
||||||
|
@dxf-recipe-saved="init()">{# #83: dopo «Salva ricetta» da DXF si rilegge la ricetta #}
|
||||||
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||||
{{ _('Ricetta visione') }}
|
{{ _('Ricetta visione') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -593,6 +659,173 @@
|
|||||||
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
|
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
|
||||||
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
|
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
|
||||||
</ul>
|
</ul>
|
||||||
|
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
|
||||||
|
<template x-if="task.task_type === 'dxf_compare'">
|
||||||
|
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
|
||||||
|
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata'), 'dxfEntities': _('entità'), 'imageField': _('campo'), 'pointsInBand': _('Punti in banda'), 'distAvg': _('Distanza media'), 'distMin': _('Distanza minima'), 'distMax': _('Distanza massima'), 'outcomePass': _('Conforme'), 'outcomeWarning': _('Con avvisi'), 'outcomeFail': _('Non conforme'), 'scaleManual': _('mm/px a mano'), 'scaleStation': _('calibrazione stazione'), 'createLabel': _('Crea ricetta da DXF'), 'editLabel': _('Modifica ricetta da DXF'), 'noReferenceImage': _('Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala'), 'existingFilesFailed': _('Impossibile leggere i file della ricetta esistente')}|tojson_attr }})">{# #104: etichette del risultato della Prova; #105: etichette del bottone e avvisi della ricetta esistente #}
|
||||||
|
{# #105: «Modifica ricetta da DXF» se il task ha gia' una ricetta da DXF #}
|
||||||
|
<button type="button" class="btn btn-secondary text-xs" @click="toggle()"
|
||||||
|
x-text="existing ? msgs.editLabel : msgs.createLabel">
|
||||||
|
{{ _('Crea ricetta da DXF') }}
|
||||||
|
</button>
|
||||||
|
<div x-show="open" x-cloak class="mt-3 space-y-3">
|
||||||
|
<!-- #105: ricetta esistente caricata nel wizard -->
|
||||||
|
<p x-show="existingInfo" class="text-xs text-emerald-600">{{ _('Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla') }}</p>
|
||||||
|
<p x-show="existingWarn" class="text-xs text-amber-600" x-text="existingWarn"></p>
|
||||||
|
<!-- Stazione -->
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
|
||||||
|
<select class="tmf-input text-xs" x-model="stationId" @change="onStation()">
|
||||||
|
<option value="">{{ _('Scegli la stazione') }}</option>
|
||||||
|
<template x-for="st in stations" :key="st.id">
|
||||||
|
<option :value="String(st.id)" x-text="st.code + ' - ' + st.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<p x-show="!stations.length" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Nessuna stazione con camera configurata') }}</p>
|
||||||
|
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
|
||||||
|
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
|
||||||
|
</p>
|
||||||
|
<p x-show="stationId && calState && !calibrated && !useManualScale" class="mt-1 text-xs text-amber-600">{# #98: nascosto con mm/px valido (#104: solo per immagine da file) #}
|
||||||
|
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
|
||||||
|
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
|
||||||
|
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
|
||||||
|
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Immagine: camera o file. #104: prima le scelte, poi le anteprime affiancate -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button type="button" class="btn btn-secondary text-xs" @click="grab()"
|
||||||
|
:disabled="!stationId || grabbing">{{ _('Acquisisci dalla camera') }}</button>
|
||||||
|
<label class="btn btn-secondary text-xs cursor-pointer">
|
||||||
|
{{ _('Carica immagine') }}
|
||||||
|
<input type="file" accept="image/*" class="hidden" @change="onImage($event)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
|
||||||
|
<!-- #98: mm/px a mano, #104: solo per un'immagine da file; sostituisce la calibrazione della stazione -->
|
||||||
|
<div x-show="imageSource === 'file'" class="mt-2">
|
||||||
|
<label class="tmf-label text-xs">{{ _('mm/px (immagine da file)') }}</label>
|
||||||
|
<input type="text" inputmode="decimal" class="tmf-input text-xs" x-model="mmPx" placeholder="0,01406">
|
||||||
|
<p x-show="manualPxMm !== null" class="mt-1 text-xs text-emerald-600">
|
||||||
|
= <span x-text="fmt(manualPxMm)"></span> px/mm
|
||||||
|
</p>
|
||||||
|
<p x-show="mmPxInvalid" class="mt-1 text-xs text-amber-600">{{ _('mm/px non valido') }}</p>
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Se compilato sostituisce la calibrazione della stazione; la scala resta salvata nella ricetta.') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
|
||||||
|
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
|
||||||
|
<!-- #105: DXF preso dalla ricetta esistente (l'input file non puo' mostrarne il nome) -->
|
||||||
|
<p x-show="dxfFromRecipe && dxfFile" class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfFile ? dxfFile.name : ''"></p>
|
||||||
|
<!-- #101: lettura del disegno appena scelto il .dxf -->
|
||||||
|
<p x-show="dxfPreviewBusy" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Lettura DXF…') }}</p>
|
||||||
|
<p x-show="dxfPreviewError" class="mt-1 text-xs text-red-500" x-text="dxfPreviewError"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- #104: anteprime sotto le scelte, scatole alte uguali (240px) per stare allineate -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
|
||||||
|
<div x-show="imagePreviewUrl">
|
||||||
|
<!-- #102: anteprima dell'immagine -->
|
||||||
|
{# #122: <tmf-viewer> standard (zoom, Adatta, Salva, pan) al posto della barra e del viewport scritti a mano (#113); altezza fissa 240px #}
|
||||||
|
<div x-show="!imageBroken">{# #122: x-show sul contenitore, mai sul <tmf-viewer> #}
|
||||||
|
{# #122: formato non mostrabile -> viewer-error del componente -> avviso imageBroken (al posto della sonda <img hidden>) #}
|
||||||
|
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||||
|
<tmf-viewer :src="imagePreviewUrl" save-name="immagine" no-layers
|
||||||
|
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||||
|
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||||
|
@viewer-load="onImagePreviewLoad($event.detail)" @viewer-error="onImagePreviewError()"
|
||||||
|
class="rounded border border-[var(--border-color)]"
|
||||||
|
style="height: 240px; background: var(--bg-primary);"></tmf-viewer>
|
||||||
|
</div>
|
||||||
|
<p x-show="imageBroken" class="text-xs text-amber-600">{{ _('Anteprima non disponibile per questo formato') }}</p>
|
||||||
|
<p x-show="imagePreviewSummary" class="mt-1 text-xs text-[var(--text-muted)]" x-text="imagePreviewSummary"></p>
|
||||||
|
</div>
|
||||||
|
<div x-show="dxfPreview" class="sm:col-start-2">{# #104: resta a destra anche senza anteprima immagine #}
|
||||||
|
{# #122: <tmf-viewer> in modo contenuto: lo <svg> (viewBox in mm) va nello stage; width/height = misura del viewBox, ora anche Salva (PNG) #}
|
||||||
|
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||||
|
<tmf-viewer save-name="dxf" no-layers
|
||||||
|
:width="dxfPreviewViewBox().split(' ')[2]" :height="dxfPreviewViewBox().split(' ')[3]"
|
||||||
|
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||||
|
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||||
|
class="rounded border border-[var(--border-color)]"
|
||||||
|
style="height: 240px; background: var(--bg-primary);">
|
||||||
|
<svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
|
||||||
|
class="block rounded" style="width: 100%; height: 100%;">
|
||||||
|
<!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox -->
|
||||||
|
<path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
|
||||||
|
stroke-width="1.5" vector-effect="non-scaling-stroke"
|
||||||
|
stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
</tmf-viewer>
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Pochi parametri -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Banda ± mm') }}</label>
|
||||||
|
<input type="number" step="0.01" min="0.001" class="tmf-input text-xs" x-model.number="bandMm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Soglia punti in banda %%') }}</label>
|
||||||
|
<input type="number" step="1" min="1" max="100" class="tmf-input text-xs" x-model.number="minPointsPct">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-xs">
|
||||||
|
<input type="checkbox" x-model="dark">
|
||||||
|
{{ _('Pezzo scuro su fondo chiaro (controluce)') }}
|
||||||
|
</label>
|
||||||
|
<!-- Azioni -->
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" class="btn btn-secondary text-xs" @click="run(false)"
|
||||||
|
:disabled="!canRun">{{ _('Prova') }}</button>
|
||||||
|
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
|
||||||
|
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
|
||||||
|
</div>
|
||||||
|
<!-- #104: errore di acquisizione prima di una Prova (dopo, sta nella colonna dei dati) -->
|
||||||
|
<p x-show="error && !tested" class="text-xs text-red-500" x-text="error"></p>
|
||||||
|
<!-- #104: risultato della Prova su due colonne: immagine con zoom a sinistra, dati ed errori a destra -->
|
||||||
|
<div x-show="tested" class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">{# #104: risultati a destra gia' da 640px, come le altre griglie del wizard #}
|
||||||
|
<div>
|
||||||
|
{# #122: <tmf-viewer fit-height> al posto di barra, viewport, pan e overlay della Prova (#104 #121); overlay dalle forme del risultato #}
|
||||||
|
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||||
|
<tmf-viewer fit-height :src="imageSrc" save-name="prova" no-layers
|
||||||
|
x-effect="$el.overlay = (result && result.result && result.result.overlay) || []"
|
||||||
|
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||||
|
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||||
|
class="rounded border border-[var(--border-color)]"
|
||||||
|
style="background: var(--bg-primary);"></tmf-viewer>
|
||||||
|
</div>{# #122: chiude solo la colonna dell'immagine (una chiusura in piu' chiudeva la griglia) #}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
||||||
|
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
|
||||||
|
<div x-show="result" class="space-y-2">
|
||||||
|
<p class="text-xs">{{ _('Esito') }}: <span class="font-semibold" :class="outcomeClass" x-text="outcomeLabel"></span></p>
|
||||||
|
<table class="tmf-table text-xs">
|
||||||
|
<tbody>
|
||||||
|
<template x-for="v in values" :key="v.key">
|
||||||
|
<tr>
|
||||||
|
<td :title="v.key" x-text="v.label"></td>
|
||||||
|
<td class="text-right"><span x-text="fmt(v.value)"></span> <span class="text-[var(--text-muted)]" x-text="v.unit"></span></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="text-xs text-[var(--text-muted)] space-y-0.5">
|
||||||
|
<li>{{ _('Scala') }}: <span x-text="scaleSummary"></span></li>
|
||||||
|
<li>{{ _('Banda ± mm') }}: <span x-text="fmt(runBandMm)"></span></li>
|
||||||
|
<li>{{ _('Soglia punti in banda %%') }}: <span x-text="fmt(runMinPointsPct)"></span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -647,6 +880,8 @@
|
|||||||
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
|
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- #107: errore di caricamento anche dentro il riquadro, non solo nel banner in cima -->
|
||||||
|
<p x-show="uploadErrorTask === task.id && errorMessage" x-text="errorMessage" class="text-xs text-red-600 dark:text-red-400 mt-2"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Directive / Description (read-only display) -->
|
<!-- Task Directive / Description (read-only display) -->
|
||||||
@@ -1245,10 +1480,17 @@
|
|||||||
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
||||||
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
|
<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/vision_overlay.js') }}"></script>{# #121 #}
|
||||||
|
<script src="{{ url_for('static', filename='js/save_image.js') }}"></script>{# #119 #}
|
||||||
|
<script src="{{ url_for('static', filename='js/image_viewer.js') }}"></script>{# #122: <tmf-viewer>, dopo vision_overlay.js e save_image.js #}
|
||||||
|
<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 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>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>
|
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||||
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/image_to_png.js') }}"></script>{# #107 #}
|
||||||
<script>
|
<script>
|
||||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||||
ImageCrop.configure({
|
ImageCrop.configure({
|
||||||
@@ -1295,11 +1537,12 @@ function taskEditor() {
|
|||||||
showAddTask: false,
|
showAddTask: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
|
uploadErrorTask: null, // #107: id del task il cui upload e' fallito (errore mostrato nel riquadro Disegno Tecnico)
|
||||||
successMessage: '',
|
successMessage: '',
|
||||||
|
|
||||||
// ---- Task CRUD state ----
|
// ---- Task CRUD state ----
|
||||||
editingTask: null,
|
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: [] },
|
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)
|
MAX_NOTE_FIELDS: 20, // #55: limite campi per nota (stesso del backend)
|
||||||
_fieldUid: 0,
|
_fieldUid: 0,
|
||||||
@@ -1485,6 +1728,18 @@ function taskEditor() {
|
|||||||
this.newTask = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
|
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 #55: ciclo di misura e campi della nota ----
|
||||||
|
|
||||||
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
|
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
|
||||||
@@ -1695,7 +1950,13 @@ function taskEditor() {
|
|||||||
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
|
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
|
||||||
in_loop_touched: false,
|
in_loop_touched: false,
|
||||||
at_close: !!task.at_close,
|
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
|
// Ensure expanded
|
||||||
this.expandedTask = task.id;
|
this.expandedTask = task.id;
|
||||||
@@ -1703,7 +1964,7 @@ function taskEditor() {
|
|||||||
|
|
||||||
cancelEditTask() {
|
cancelEditTask() {
|
||||||
this.editingTask = null;
|
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) {
|
async updateTask(taskId) {
|
||||||
@@ -1711,6 +1972,8 @@ function taskEditor() {
|
|||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
const fp = this._fieldsPayload(this.editTaskData); // #55
|
const fp = this._fieldsPayload(this.editTaskData); // #55
|
||||||
if (!fp.ok) return;
|
if (!fp.ok) return;
|
||||||
|
const acq = this._acquisitionPayload(this.editTaskData); // #97
|
||||||
|
if (!acq.ok) return;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1728,7 +1991,8 @@ function taskEditor() {
|
|||||||
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
|
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
|
||||||
in_loop: !!this.editTaskData.in_loop,
|
in_loop: !!this.editTaskData.in_loop,
|
||||||
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
|
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
|
||||||
fields: fp.fields
|
fields: fp.fields,
|
||||||
|
acquisition_json: acq.value // #97
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2159,14 +2423,24 @@ function taskEditor() {
|
|||||||
async uploadTaskFile(taskId, file, task) {
|
async uploadTaskFile(taskId, file, task) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
// #107: BMP e simili (leggibili dal browser, non dal backend) diventano PNG prima dei controlli
|
||||||
|
file = await tmfImageToPng(file);
|
||||||
|
if (!file) {
|
||||||
|
this.errorMessage = {{ _("Formato immagine non leggibile. Usa PNG, JPG o PDF.")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'application/pdf'];
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'application/pdf'];
|
||||||
if (!allowedTypes.includes(file.type)) {
|
if (!allowedTypes.includes(file.type)) {
|
||||||
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG o PDF.")|tojson }};
|
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG o PDF.")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId; // #107
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > 20 * 1024 * 1024) {
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
this.errorMessage = {{ _("File troppo grande. Dimensione massima: 20MB.")|tojson }};
|
this.errorMessage = {{ _("File troppo grande. Dimensione massima: 20MB.")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId; // #107
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2176,6 +2450,7 @@ function taskEditor() {
|
|||||||
|
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
this.uploadErrorTask = null; // #107: nuovo tentativo, azzera l'errore del riquadro
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Upload file
|
// 1. Upload file
|
||||||
@@ -2192,6 +2467,7 @@ function taskEditor() {
|
|||||||
const uploadData = await uploadResp.json();
|
const uploadData = await uploadResp.json();
|
||||||
if (uploadData.error) {
|
if (uploadData.error) {
|
||||||
this.errorMessage = uploadData.detail || {{ _("Errore durante il caricamento del file")|tojson }};
|
this.errorMessage = uploadData.detail || {{ _("Errore durante il caricamento del file")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId; // #107
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2216,6 +2492,7 @@ function taskEditor() {
|
|||||||
const updateData = await updateResp.json();
|
const updateData = await updateResp.json();
|
||||||
if (updateData.error) {
|
if (updateData.error) {
|
||||||
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId; // #107
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2231,6 +2508,7 @@ function taskEditor() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('uploadTaskFile error:', err);
|
console.error('uploadTaskFile error:', err);
|
||||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||||
|
this.uploadErrorTask = taskId; // #107
|
||||||
}
|
}
|
||||||
|
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
|
|||||||
@@ -195,10 +195,12 @@
|
|||||||
{{ "%.3f"|format(m.subtask.nominal) }} {{ m.subtask.unit }}
|
{{ "%.3f"|format(m.subtask.nominal) }} {{ m.subtask.unit }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right measure-value font-semibold" style="color: var(--text-primary);">
|
<td class="text-right measure-value font-semibold" style="color: var(--text-primary);">
|
||||||
{{ "%.3f"|format(m.value) }} {{ m.subtask.unit }}
|
{# #118: quota non misurata (value None) -> NaN, senza unita' #}
|
||||||
|
{% if m.value is none %}NaN{% else %}{{ "%.3f"|format(m.value) }} {{ m.subtask.unit }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right measure-value {% if m.deviation > 0 %}text-measure-fail dark:text-red-400{% elif m.deviation < 0 %}text-measure-pass dark:text-green-400{% else %}{% endif %}">
|
<td class="text-right measure-value {% if m.value is not none and m.deviation is not none and m.deviation > 0 %}text-measure-fail dark:text-red-400{% elif m.value is not none and m.deviation is not none and m.deviation < 0 %}text-measure-pass dark:text-green-400{% else %}{% endif %}">
|
||||||
{{ "%+.3f"|format(m.deviation) }}
|
{# #118: deviazione None, o valore None (il blueprint la mette a 0.0) -> NaN #}
|
||||||
|
{% if m.value is none or m.deviation is none %}NaN{% else %}{{ "%+.3f"|format(m.deviation) }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{% if m.pass_fail == 'pass' %}
|
{% if m.pass_fail == 'pass' %}
|
||||||
|
|||||||
@@ -69,7 +69,8 @@
|
|||||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||||
@numpad-rejected.window="onNumpadRejected($event.detail)"
|
@numpad-rejected.window="onNumpadRejected($event.detail)"
|
||||||
@numpad-error.window="onNumpadError($event.detail)"
|
@numpad-error.window="onNumpadError($event.detail)"
|
||||||
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
|
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)"
|
||||||
|
@vision-recorded.window="onVisionRecorded($event.detail)">
|
||||||
|
|
||||||
{# ================================================================
|
{# ================================================================
|
||||||
HEADER — Compact task info bar
|
HEADER — Compact task info bar
|
||||||
@@ -471,7 +472,8 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{ _('Registrata') }}:</span>
|
<span>{{ _('Registrata') }}:</span>
|
||||||
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
|
{# #118: quota registrata senza valore (visione, NaN) #}
|
||||||
|
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) ?? 'NaN'"></span>
|
||||||
<span x-text="currentSubtask?.unit || 'mm'"></span>
|
<span x-text="currentSubtask?.unit || 'mm'"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -534,23 +536,42 @@
|
|||||||
<div x-show="isVisionTask" x-cloak
|
<div x-show="isVisionTask" x-cloak
|
||||||
x-data="visionExecute()"
|
x-data="visionExecute()"
|
||||||
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
|
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">
|
<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. #}
|
{% if not camera_enabled %}
|
||||||
|
{# #119: l'input file solo senza camera. 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' : ''">
|
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
||||||
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
|
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
|
||||||
<input type="file" accept="image/*" capture="environment" class="hidden"
|
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||||
@change="onPick($event)" :disabled="busy">
|
@change="onPick($event)" :disabled="busy">
|
||||||
</label>
|
</label>
|
||||||
{% if camera_enabled %}
|
{% else %}
|
||||||
{# #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()"
|
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
|
||||||
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
||||||
:disabled="busy || grabbing">
|
:disabled="busy || grabbing">
|
||||||
<span>{{ _('Acquisisci dalla camera') }}</span>
|
<span>{{ _('Acquisisci da camera') }}</span> {# #119 #}
|
||||||
</button>
|
</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>
|
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||||
@@ -558,27 +579,49 @@
|
|||||||
|
|
||||||
<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="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">
|
{# #96: riquadro flex-1 min-h-0; #122: dentro, <tmf-viewer> (zoom, pan, Salva, overlay SVG a tratto costante ed etichette quota) riempie il riquadro; overlay e labels arrivano da x-effect (px dell'immagine, #121 #116); x-show sul contenitore, mai sul viewer #}
|
||||||
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
|
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||||
|
<tmf-viewer class="block w-full h-full" no-layers :src="shownImage" :save-name="saveName"
|
||||||
|
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||||
|
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||||
|
x-effect="$el.overlay = (result && result.overlay) || []; $el.labels = viewerLabels"></tmf-viewer>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
||||||
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
||||||
</div>
|
</div>
|
||||||
</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)]">
|
<div class="text-sm font-bold text-[var(--text-primary)]">
|
||||||
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
|
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="w-full text-sm" x-show="rows.length">
|
<table class="w-full text-sm" x-show="rows.length">
|
||||||
|
{# #117: due righe per quota (esito, poi nominale e tolleranze come nella schermata dei calibri); un solo tbody per quota perche' x-for vuole un'unica radice #}
|
||||||
<template x-for="r in rows" :key="r.label">
|
<template x-for="r in rows" :key="r.label">
|
||||||
<tr>
|
<tbody>
|
||||||
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
|
<tr>
|
||||||
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
|
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
|
||||||
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
|
{# #118: quota non misurata (value null) = «NaN», esito fail #}
|
||||||
</tr>
|
<td class="py-1 pr-2 font-mono text-right" x-text="fmtValue(r.value, r.unit)"></td>
|
||||||
|
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b border-[var(--border-color)]">
|
||||||
|
<td colspan="3" class="pb-1.5 text-[11px] font-mono">
|
||||||
|
<span class="text-primary font-semibold">{{ _('Nom') }} <span x-text="fmtTol(r.nominal)"></span></span>
|
||||||
|
<span class="text-measure-fail ml-2">LTL <span x-text="fmtTol(r.ltl)"></span></span>
|
||||||
|
<span class="text-measure-fail ml-1">UTL <span x-text="fmtTol(r.utl)"></span></span>
|
||||||
|
<template x-if="r.lwl != null || r.uwl != null">
|
||||||
|
<span>
|
||||||
|
<span class="text-measure-warning ml-2">LWL <span x-text="fmtTol(r.lwl)"></span></span>
|
||||||
|
<span class="text-measure-warning ml-1">UWL <span x-text="fmtTol(r.uwl)"></span></span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
</template>
|
</template>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -1146,13 +1189,23 @@
|
|||||||
window.__visionCfg = {
|
window.__visionCfg = {
|
||||||
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
||||||
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
|
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) }}',
|
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||||
taskId: {{ task.id|tojson }},
|
taskId: {{ task.id|tojson }},
|
||||||
taskType: {{ (task.task_type or '')|tojson }},
|
taskType: {{ (task.task_type or '')|tojson }},
|
||||||
|
taskName: {{ (task.title or '')|tojson }}, {# #119: nome proposto per «Salva» #}
|
||||||
subtasks: window.__taskSubtasks,
|
subtasks: window.__taskSubtasks,
|
||||||
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
|
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
{# #116: disegno overlay ed etichette quote; prima di vision_execute.js, che lo usa se presente #}
|
||||||
|
<script src="{{ url_for('static', filename='js/vision_overlay.js') }}"></script>
|
||||||
|
{# #119: «Salva» immagine (frame grezzo PNG) #}
|
||||||
|
<script src="{{ url_for('static', filename='js/save_image.js') }}"></script>
|
||||||
|
{# #122: visualizzatore standard <tmf-viewer>, dopo vision_overlay.js e save_image.js #}
|
||||||
|
<script src="{{ url_for('static', filename='js/image_viewer.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
||||||
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
|
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
|
||||||
<script>
|
<script>
|
||||||
@@ -1194,6 +1247,7 @@ function taskExecute() {
|
|||||||
// ---- Measurement state ----
|
// ---- Measurement state ----
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
|
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
|
||||||
|
visionRecorded: false, // #120: task visione: il pezzo e' stato registrato («Registra»), le quote sono in measurements
|
||||||
saving: false,
|
saving: false,
|
||||||
// The single reentrancy guard for handleMeasurement (#13): true for the
|
// The single reentrancy guard for handleMeasurement (#13): true for the
|
||||||
// whole time a reading is being processed, including the 1s feedback
|
// whole time a reading is being processed, including the 1s feedback
|
||||||
@@ -1300,6 +1354,8 @@ function taskExecute() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
get missingQuotesLabel() {
|
get missingQuotesLabel() {
|
||||||
|
// #120: task visione non registrato: le quote arrivano tutte col «Registra», non una a una
|
||||||
|
if (this.isVisionTask && !this.visionRecorded) return {{ _('Registra il pezzo')|tojson }};
|
||||||
const n = this.missingQuotes;
|
const n = this.missingQuotes;
|
||||||
return n === 1
|
return n === 1
|
||||||
? {{ _('Manca 1 quota')|tojson }}
|
? {{ _('Manca 1 quota')|tojson }}
|
||||||
@@ -1313,6 +1369,8 @@ function taskExecute() {
|
|||||||
ciclo misura», and the quotes it exists to collect would be optional. */
|
ciclo misura», and the quotes it exists to collect would be optional. */
|
||||||
get canLeaveTask() {
|
get canLeaveTask() {
|
||||||
if (this.blockedByTolerance) return false;
|
if (this.blockedByTolerance) return false;
|
||||||
|
// #120: un task visione si lascia solo dopo «Registra» (anche dxf_compare, prima sempre attivo)
|
||||||
|
if (this.isVisionTask && !this.visionRecorded) return false;
|
||||||
if (this.isMeasureTask && this.subtasks.length > 0) {
|
if (this.isMeasureTask && this.subtasks.length > 0) {
|
||||||
return this.cycleConfirmed;
|
return this.cycleConfirmed;
|
||||||
}
|
}
|
||||||
@@ -1323,6 +1381,7 @@ function taskExecute() {
|
|||||||
if (this.blockedByTolerance) {
|
if (this.blockedByTolerance) {
|
||||||
return {{ _("Serve l'autorizzazione del capoturno")|tojson }};
|
return {{ _("Serve l'autorizzazione del capoturno")|tojson }};
|
||||||
}
|
}
|
||||||
|
if (this.isVisionTask && !this.visionRecorded) return this.missingQuotesLabel; // #120
|
||||||
if (!this.isComplete) return this.missingQuotesLabel;
|
if (!this.isComplete) return this.missingQuotesLabel;
|
||||||
return {{ _('Chiudi prima il ciclo di misura')|tojson }};
|
return {{ _('Chiudi prima il ciclo di misura')|tojson }};
|
||||||
},
|
},
|
||||||
@@ -1406,9 +1465,10 @@ function taskExecute() {
|
|||||||
subtask_id: m.subtask_id,
|
subtask_id: m.subtask_id,
|
||||||
value: m.value,
|
value: m.value,
|
||||||
pass_fail: m.pass_fail,
|
pass_fail: m.pass_fail,
|
||||||
deviation: m.value - nominal,
|
deviation: m.value == null ? null : m.value - nominal, // #118: quota non misurata = NaN, nessuna deviazione
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
if (this.isVisionTask) this.visionRecorded = true; // #120: misure ripristinate = pezzo gia' registrato
|
||||||
const next = this.subtasks.findIndex(
|
const next = this.subtasks.findIndex(
|
||||||
s => !this.measurements.some(m => m.subtask_id === s.id),
|
s => !this.measurements.some(m => m.subtask_id === s.id),
|
||||||
);
|
);
|
||||||
@@ -1889,6 +1949,23 @@ function taskExecute() {
|
|||||||
this.redirectIn = this.measurementTaskUrl ? 5 : 0;
|
this.redirectIn = this.measurementTaskUrl ? 5 : 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* #120: «Registra» del pannello visione: le misure salvate dal server diventano le misure del
|
||||||
|
ciclo (conteggio quote, «Fine ciclo misura»/«Completato»). value null = quota non misurata. */
|
||||||
|
onVisionRecorded(detail) {
|
||||||
|
const ms = (detail && detail.measurements) || [];
|
||||||
|
this.measurements = ms.map(m => {
|
||||||
|
const st = this.subtasks.find(s => s.id === m.subtask_id);
|
||||||
|
const nominal = st ? (st.nominal || 0) : 0;
|
||||||
|
return {
|
||||||
|
subtask_id: m.subtask_id,
|
||||||
|
value: m.value == null ? null : m.value,
|
||||||
|
pass_fail: m.pass_fail,
|
||||||
|
deviation: m.value == null ? null : m.value - nominal,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.visionRecorded = true;
|
||||||
|
},
|
||||||
|
|
||||||
/* Clear the screen for another pass over the same quotes.
|
/* Clear the screen for another pass over the same quotes.
|
||||||
Local only: everything already measured is on the server, and the next
|
Local only: everything already measured is on the server, and the next
|
||||||
reading is saved beside it rather than replacing it. */
|
reading is saved beside it rather than replacing it. */
|
||||||
@@ -1896,6 +1973,7 @@ function taskExecute() {
|
|||||||
this.cycleConfirmed = false;
|
this.cycleConfirmed = false;
|
||||||
this.showCompletionOverlay = false;
|
this.showCompletionOverlay = false;
|
||||||
this.measurements = [];
|
this.measurements = [];
|
||||||
|
this.visionRecorded = false; // #120: nuovo ciclo = pezzo da registrare di nuovo
|
||||||
this.currentIndex = 0;
|
this.currentIndex = 0;
|
||||||
this.currentValue = null;
|
this.currentValue = null;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<span class="truncate" x-text="s.description"></span>
|
<span class="truncate" x-text="s.description"></span>
|
||||||
<span class="font-mono whitespace-nowrap"
|
<span class="font-mono whitespace-nowrap"
|
||||||
:class="s.in_tolerance ? 'text-green-700' : 'text-red-700 font-bold'"
|
:class="s.in_tolerance ? 'text-green-700' : 'text-red-700 font-bold'"
|
||||||
x-text="s.mean.toFixed(3) + ' ' + s.unit"></span>
|
x-text="(s.mean == null ? 'NaN' : s.mean.toFixed(3)) + ' ' + s.unit"></span>{# #118: quota con soli valori NaN: media null #}
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -51,7 +51,9 @@
|
|||||||
<!-- Subtask -->
|
<!-- Subtask -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
||||||
|
<!-- #100: il cambio di punto ricarica subito, senza «Applica filtri» -->
|
||||||
<select x-model="selectedSubtaskId"
|
<select x-model="selectedSubtaskId"
|
||||||
|
@change="loadData()"
|
||||||
:disabled="subtasks.length === 0"
|
:disabled="subtasks.length === 0"
|
||||||
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
|
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
|
||||||
<option value="">{{ _("Tutti i punti") }}</option>
|
<option value="">{{ _("Tutti i punti") }}</option>
|
||||||
@@ -287,20 +289,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!capability || capability.cp == null">
|
<!-- #100: con «Tutti i punti» tabella con una riga per punto di misura -->
|
||||||
|
<template x-if="!loadedSubtaskId && capabilityAll.length > 0">
|
||||||
|
<div>
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs text-steel text-left">
|
||||||
|
<th class="py-1 font-medium">{{ _("Punto di misura") }}</th>
|
||||||
|
<th class="py-1 font-medium text-right">n</th>
|
||||||
|
<th class="py-1 font-medium text-right">Cp</th>
|
||||||
|
<th class="py-1 font-medium text-right">Cpk</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="row in capabilityAll" :key="row.subtask.id">
|
||||||
|
<tr class="cursor-pointer hover:bg-[var(--bg-primary)] border-t border-[var(--border-color)]"
|
||||||
|
@click="selectedSubtaskId = String(row.subtask.id); loadData()">
|
||||||
|
<td class="py-1.5 pr-2" x-text="'#' + row.subtask.marker_number + ' — ' + row.subtask.description"></td>
|
||||||
|
<td class="py-1.5 text-right font-mono" x-text="row.data && row.data.n != null ? row.data.n : '—'"></td>
|
||||||
|
<td class="py-1.5 text-right font-mono font-bold"
|
||||||
|
:class="row.data && row.data.cp != null ? capabilityColor(row.data.cp) : 'text-steel'"
|
||||||
|
x-text="row.data && row.data.cp != null ? row.data.cp : '—'"></td>
|
||||||
|
<td class="py-1.5 text-right font-mono font-bold"
|
||||||
|
:class="row.data && row.data.cpk != null ? capabilityColor(row.data.cpk) : 'text-steel'"
|
||||||
|
x-text="row.data && row.data.cpk != null ? row.data.cpk : '—'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="text-xs text-steel mt-3">{{ _("Scegli un punto per carta di controllo e istogramma") }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="(!capability || capability.cp == null) && (loadedSubtaskId || capabilityAll.length === 0)">
|
||||||
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
|
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 2: Carta di Controllo -->
|
<!-- Row 2: Carta di Controllo -->
|
||||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
<!-- #100: x-show (non x-if) perche' renderCharts() cerca gli id nel DOM; nascosta senza punto -->
|
||||||
|
<div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
||||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
|
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
|
||||||
<div id="control-chart" style="min-height: 350px;"></div>
|
<div id="control-chart" style="min-height: 350px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 3: Istogramma -->
|
<!-- Row 3: Istogramma -->
|
||||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
<div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
||||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
|
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
|
||||||
<div id="histogram-chart" style="min-height: 350px;"></div>
|
<div id="histogram-chart" style="min-height: 350px;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,6 +380,8 @@ function spcDashboard() {
|
|||||||
subtasks: [],
|
subtasks: [],
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
|
loadedSubtaskId: '', // #100 punto con cui sono stati caricati i dati (i pannelli dipendono da questo)
|
||||||
|
capabilityAll: [], // #100 con «Tutti i punti»: [{subtask, data}] una riga per punto
|
||||||
summary: null,
|
summary: null,
|
||||||
capability: null,
|
capability: null,
|
||||||
controlChart: null,
|
controlChart: null,
|
||||||
@@ -408,6 +444,8 @@ function spcDashboard() {
|
|||||||
|
|
||||||
async onRecipeChange() {
|
async onRecipeChange() {
|
||||||
this.selectedSubtaskId = '';
|
this.selectedSubtaskId = '';
|
||||||
|
this.loadedSubtaskId = ''; // #100
|
||||||
|
this.capabilityAll = []; // #100
|
||||||
this.selectedRunId = ''; // #58
|
this.selectedRunId = ''; // #58
|
||||||
this.runs = [];
|
this.runs = [];
|
||||||
this.subtasks = [];
|
this.subtasks = [];
|
||||||
@@ -459,10 +497,11 @@ function spcDashboard() {
|
|||||||
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
||||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||||
|
const requestedSubtaskId = this.selectedSubtaskId; // #100 punto richiesto da questa chiamata
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parallel fetch of all endpoints
|
// Parallel fetch of all endpoints
|
||||||
const [summaryResp, capabilityResp, controlResp, histogramResp] = await Promise.allSettled([
|
const [summaryResp, capabilityResp, controlResp, histogramResp, allResp] = await Promise.allSettled([
|
||||||
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
|
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
|
||||||
this.selectedSubtaskId
|
this.selectedSubtaskId
|
||||||
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
|
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
|
||||||
@@ -473,6 +512,17 @@ function spcDashboard() {
|
|||||||
this.selectedSubtaskId
|
this.selectedSubtaskId
|
||||||
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
|
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
|
// #100: senza punto scelto, capability di ogni punto della ricetta
|
||||||
|
requestedSubtaskId
|
||||||
|
? Promise.resolve([])
|
||||||
|
: Promise.all(this.subtasks.map(st => {
|
||||||
|
const p = new URLSearchParams(params.toString());
|
||||||
|
p.set('subtask_id', st.id);
|
||||||
|
return fetch('/statistics/api/capability?' + p.toString())
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => ({ subtask: st, data: (d && !d.error) ? d : null }))
|
||||||
|
.catch(() => ({ subtask: st, data: null }));
|
||||||
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Summary (always available)
|
// Summary (always available)
|
||||||
@@ -505,6 +555,9 @@ function spcDashboard() {
|
|||||||
this.histogram = null;
|
this.histogram = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #100: lista per-punto solo con «Tutti i punti»; con un punto resta il flusso di prima
|
||||||
|
this.capabilityAll = (allResp.status === 'fulfilled' && Array.isArray(allResp.value)) ? allResp.value : [];
|
||||||
|
this.loadedSubtaskId = requestedSubtaskId;
|
||||||
this.hasData = true;
|
this.hasData = true;
|
||||||
|
|
||||||
// Render charts after DOM update
|
// Render charts after DOM update
|
||||||
@@ -522,7 +575,7 @@ function spcDashboard() {
|
|||||||
|
|
||||||
renderCharts() {
|
renderCharts() {
|
||||||
// Control chart
|
// Control chart
|
||||||
if (this.controlChart && this.selectedSubtaskId) {
|
if (this.controlChart && this.loadedSubtaskId) { // #100
|
||||||
renderControlChart('control-chart', this.controlChart);
|
renderControlChart('control-chart', this.controlChart);
|
||||||
} else {
|
} else {
|
||||||
const el = document.getElementById('control-chart');
|
const el = document.getElementById('control-chart');
|
||||||
@@ -530,7 +583,7 @@ function spcDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Histogram
|
// Histogram
|
||||||
if (this.histogram && this.selectedSubtaskId) {
|
if (this.histogram && this.loadedSubtaskId) { // #100
|
||||||
const tol = this.capability ? {
|
const tol = this.capability ? {
|
||||||
utl: this.capability.utl,
|
utl: this.capability.utl,
|
||||||
ltl: this.capability.ltl,
|
ltl: this.capability.ltl,
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!-- #122: pagina di prova di <tmf-viewer> da aprire in Chrome (file:// va bene): immagine generata col canvas,
|
||||||
|
overlay vs-tools finto, etichette e modo contenuto (svg). Il riquadro #log riporta i controlli automatici. -->
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Prova tmf-viewer</title>
|
||||||
|
<style>
|
||||||
|
body { font: 14px system-ui, sans-serif; margin: 16px; background: #f3f4f6; }
|
||||||
|
.griglia { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.box { height: 320px; }
|
||||||
|
h2 { font-size: 13px; margin: 12px 0 4px; }
|
||||||
|
#log { white-space: pre; font: 12px ui-monospace, monospace; background: #fff; padding: 8px; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Prova di <tmf-viewer></h1>
|
||||||
|
<div class="griglia">
|
||||||
|
<div><h2>Immagine 800x500 + overlay + etichette (box fisso, Adatta nel box)</h2>
|
||||||
|
<tmf-viewer id="a" class="box" save-name="demo_immagine" width="800" height="500"></tmf-viewer></div>
|
||||||
|
<div><h2>fit-height, no-save: alto quanto l'immagine</h2>
|
||||||
|
<tmf-viewer id="b" fit-height no-save label-fit="Intera"></tmf-viewer></div>
|
||||||
|
<div><h2>Modo contenuto: svg (DXF) senza src</h2>
|
||||||
|
<tmf-viewer id="c" class="box" save-name="demo_dxf">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect x="5" y="5" width="190" height="90" fill="none" stroke="#fff" stroke-width="1"/><circle cx="100" cy="50" r="30" fill="none" stroke="#0ff" stroke-width="1"/></svg>
|
||||||
|
</tmf-viewer></div>
|
||||||
|
<div><h2>Vuoto: barra nascosta</h2><tmf-viewer id="d" class="box"></tmf-viewer></div>
|
||||||
|
<!-- #126: no-layers (overlay misto, etichette con key) e, per confronto, lo stesso senza no-layers -->
|
||||||
|
<div><h2>#126 no-layers: overlay misto, etichette fisse</h2>
|
||||||
|
<tmf-viewer id="f" class="box" no-layers width="400" height="250" save-name="demo_nolayers"></tmf-viewer></div>
|
||||||
|
<div><h2>#126 senza no-layers (confronto): livelli e trascinamento</h2>
|
||||||
|
<tmf-viewer id="g" class="box" width="400" height="250" save-name="demo_conlayers"></tmf-viewer></div>
|
||||||
|
</div>
|
||||||
|
<div id="log">in corso...</div>
|
||||||
|
<script>
|
||||||
|
// #126: stato dei livelli salvato prima del caricamento del visore (ricerca spenta): con no-layers non deve contare
|
||||||
|
try { localStorage.setItem('tmf-viewer-layers', JSON.stringify({ ricerca: false })); } catch (e) { /* storage bloccato */ }
|
||||||
|
</script>
|
||||||
|
<script src="../../../static/js/vision_overlay.js"></script>
|
||||||
|
<script src="../../../static/js/save_image.js"></script>
|
||||||
|
<script src="../../../static/js/image_viewer.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onerror = function (m, u, l) { document.getElementById('log').textContent += '\nERRORE ' + m + ' riga ' + l; }; // errori visibili nel riquadro
|
||||||
|
(function () {
|
||||||
|
var out = [], fails = 0;
|
||||||
|
function check(l, c, x) { if (!c) fails++; out.push((c ? 'ok - ' : 'FAIL - ') + l + (c || x === undefined ? '' : ' :: ' + x)); document.getElementById('log').textContent = out.join('\n'); } // log aggiornato a ogni controllo
|
||||||
|
window.tmfCheck = check; // #126: il blocco no-layers in fondo alla pagina scrive nello stesso #log
|
||||||
|
function img(w, h, col) {
|
||||||
|
var c = document.createElement('canvas'); c.width = w; c.height = h;
|
||||||
|
var g = c.getContext('2d'); g.fillStyle = col; g.fillRect(0, 0, w, h);
|
||||||
|
g.fillStyle = '#fff'; for (var x = 0; x < w; x += 100) g.fillRect(x, 0, 2, h);
|
||||||
|
for (var y = 0; y < h; y += 100) g.fillRect(0, y, w, 2);
|
||||||
|
return c.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
var a = document.getElementById('a'), b = document.getElementById('b'), c = document.getElementById('c'), d = document.getElementById('d');
|
||||||
|
var ev = { load: 0, zoom: 0 };
|
||||||
|
a.addEventListener('viewer-load', function (e) { ev.load++; ev.det = e.detail; });
|
||||||
|
a.addEventListener('viewer-zoom', function () { ev.zoom++; });
|
||||||
|
a.overlay = [{ type: 'segment', role: 'found', row1: 50, col1: 50, row2: 450, col2: 750 },
|
||||||
|
{ type: 'points', role: 'reference', rows: [100, 200], cols: [100, 300] }];
|
||||||
|
a.labels = [{ key: 'q1', x: 400, y: 250, text: 'Quota 1 12.345 mm', cls: 'ok' }];
|
||||||
|
a.setAttribute('src', img(800, 500, '#334155'));
|
||||||
|
b.setAttribute('src', img(1000, 400, '#475569'));
|
||||||
|
function btn(v, i) { return v.querySelectorAll('.tmfv-bar button')[i]; }
|
||||||
|
setTimeout(function () {
|
||||||
|
var vpa = a.querySelector('.tmfv-vp'), st = a.querySelector('.tmfv-stage');
|
||||||
|
var fitA = function () { return Math.min(vpa.clientWidth / 800, vpa.clientHeight / 500); };
|
||||||
|
check('a: viewer-load una volta con 800x500', ev.load === 1 && ev.det && ev.det.width === 800 && ev.det.height === 500, JSON.stringify(ev));
|
||||||
|
check('a: Adatta = min(box/img), stage entra nel box', Math.abs(a.zoom - fitA()) < 0.01 && st.offsetWidth <= vpa.clientWidth + 1 && st.offsetHeight <= vpa.clientHeight + 1,
|
||||||
|
a.zoom + ' ' + st.offsetWidth + 'x' + st.offsetHeight + ' vp ' + vpa.clientWidth + 'x' + vpa.clientHeight);
|
||||||
|
check('a: percentuale mostrata', /^\d+ %$/.test(a.querySelector('.tmfv-pct').textContent), a.querySelector('.tmfv-pct').textContent);
|
||||||
|
check('a: overlay con 2 forme e viewBox 0 0 800 500', a.querySelector('.tmfv-ov').children.length >= 2 && a.querySelector('.tmfv-ov').getAttribute('viewBox') === '0 0 800 500', a.querySelector('.tmfv-ov').innerHTML.slice(0, 80));
|
||||||
|
var lab = a.querySelector('.tmfv-label');
|
||||||
|
check('a: etichetta al 50 % / 50 %, testo, classe', lab && lab.style.left === '50%' && lab.style.top === '50%' && lab.textContent === 'Quota 1 12.345 mm' && lab.classList.contains('ok'));
|
||||||
|
check('a: barra visibile con -, +, Adatta, Salva', !a.querySelector('.tmfv-bar').hidden && a.querySelectorAll('.tmfv-bar button').length === 4 && btn(a, 3).textContent === 'Salva');
|
||||||
|
var z0 = a.zoom; btn(a, 1).click();
|
||||||
|
check('a: + ingrandisce di 1.25', Math.abs(a.zoom / z0 - 1.25) < 1e-9, a.zoom + '/' + z0);
|
||||||
|
for (var i = 0; i < 6; i++) a.zoomBy(1.25);
|
||||||
|
check('a: ingrandito -> scorrimento possibile e cursore grab', vpa.scrollWidth > vpa.clientWidth && vpa.style.cursor === 'grab', vpa.style.cursor);
|
||||||
|
var rect = vpa.getBoundingClientRect(), px = 120, py = 90;
|
||||||
|
var zb = a.zoom, lx = (vpa.scrollLeft + px) / zb, ly = (vpa.scrollTop + py) / zb;
|
||||||
|
vpa.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true, deltaY: -100, clientX: rect.left + vpa.clientLeft + px, clientY: rect.top + vpa.clientTop + py, bubbles: true, cancelable: true }));
|
||||||
|
var za = a.zoom, lx2 = (vpa.scrollLeft + px) / za, ly2 = (vpa.scrollTop + py) / za;
|
||||||
|
check('a: Ctrl+rotella zooma e tiene fermo il punto', za > zb && Math.abs(lx - lx2) < 1 && Math.abs(ly - ly2) < 1, [zb, za, lx, lx2, ly, ly2].join(' '));
|
||||||
|
for (i = 0; i < 40; i++) a.zoomBy(1.25);
|
||||||
|
check('a: zoom limitato a max(8, fit)', a.zoom <= 8.0000001, a.zoom);
|
||||||
|
var we = new WheelEvent('wheel', { deltaY: 100, bubbles: true, cancelable: true }); vpa.dispatchEvent(we);
|
||||||
|
check('a: rotella senza Ctrl zooma sul punto del mouse (intercettata, #12 #126)', we.defaultPrevented === true); // #126: controllo aggiornato al contratto #12
|
||||||
|
btn(a, 2).click();
|
||||||
|
check('a: Adatta torna al fit e scroll a 0', Math.abs(a.zoom - fitA()) < 0.01 && vpa.scrollLeft === 0, a.zoom);
|
||||||
|
check('a: viewer-zoom emesso a ogni cambio', ev.zoom > 3, ev.zoom);
|
||||||
|
a.zoomBy(1.25); var zz = a.zoom; ev.load = 0;
|
||||||
|
a.setAttribute('src', img(800, 500, '#7f1d1d'));
|
||||||
|
setTimeout(function () {
|
||||||
|
check('a: nuovo src, stesse dimensioni -> zoom mantenuto', ev.load === 1 && Math.abs(a.zoom - zz) < 1e-9, a.zoom + ' vs ' + zz);
|
||||||
|
var vpb = b.querySelector('.tmfv-vp');
|
||||||
|
check('b: fit-height: viewport alto quanto l\'immagine adattata alla larghezza', Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5 && Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01,
|
||||||
|
vpb.offsetHeight + ' vs ' + 400 * b.zoom + ' zoom ' + b.zoom);
|
||||||
|
check('b: no-save nasconde Salva, label-fit "Intera"', btn(b, 3).hidden && btn(b, 2).textContent === 'Intera');
|
||||||
|
b.style.width = '300px';
|
||||||
|
// il resize e' consegnato dal ResizeObserver al prossimo frame: in una scheda in background puo' tardare, quindi attendo (max 5 s)
|
||||||
|
var t0 = Date.now();
|
||||||
|
(function attendi() { if (b.zoom < 0.5 || Date.now() - t0 > 5000) dopo(); else setTimeout(attendi, 100); })();
|
||||||
|
function dopo() {
|
||||||
|
check('b: resize in stato adatta -> rifa Adatta (300 px)', Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01 && Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5, b.zoom + ' ' + vpb.offsetHeight);
|
||||||
|
var stc = c.querySelector('.tmfv-stage');
|
||||||
|
check('c: svg nello stage, dimensioni dal viewBox 200x100, Adatta', stc.querySelector('svg:not(.tmfv-ov)') && !c.querySelector(':scope > svg') && c.zoom > 0 && Math.abs(stc.offsetWidth / stc.offsetHeight - 2) < 0.05, c.zoom + ' ' + stc.offsetWidth + 'x' + stc.offsetHeight);
|
||||||
|
// #122: DXF in mm (fit ~ 10): si ingrandisce oltre Adatta; width/height cambiati dopo il connect rifanno Adatta
|
||||||
|
c.setAttribute('width', '50'); c.setAttribute('height', '30'); // contenuto 50 x 30 mm nel box ~ 560 x 320
|
||||||
|
var zc = c.zoom; c.zoomBy(1.25);
|
||||||
|
check('c: width/height 50x30 -> fit > 8 e zoom oltre Adatta', zc > 8 && c.zoom > zc * 1.2, zc + ' -> ' + c.zoom);
|
||||||
|
// #122: viewer-error su immagine non caricabile; src tolto -> componente vuoto senza errori
|
||||||
|
var e = document.createElement('tmf-viewer'); e.style.height = '100px'; document.body.appendChild(e);
|
||||||
|
var gotErr = null; e.addEventListener('viewer-error', function (x) { gotErr = x.detail; });
|
||||||
|
e.overlay = [{ type: 'segment', role: 'found', row1: 1, col1: 1, row2: 5, col2: 9 }]; e.labels = [{ x: 5, y: 5, text: 'x' }]; // prima del load
|
||||||
|
e.setAttribute('src', 'inesistente.png');
|
||||||
|
setTimeout(function () {
|
||||||
|
check('e: viewer-error con src', gotErr && gotErr.src === 'inesistente.png', JSON.stringify(gotErr));
|
||||||
|
e.setAttribute('src', img(100, 50, '#000'));
|
||||||
|
setTimeout(function () {
|
||||||
|
check('e: overlay ed etichette impostati prima del load disegnati dopo', e.querySelectorAll('.tmfv-ov g[data-layer="costruzioni"] > *').length === 1 && e.querySelectorAll('.tmfv-label').length === 1);
|
||||||
|
var lb = getComputedStyle(e.querySelector('.tmfv-label'));
|
||||||
|
check('e: etichetta con aspetto proprio (absolute, bold, padding)', lb.position === 'absolute' && lb.fontWeight === '700' && lb.paddingLeft === '6px' && lb.pointerEvents === 'none');
|
||||||
|
e.removeAttribute('src');
|
||||||
|
check('e: src tolto -> vuoto, barra nascosta', e.querySelector('.tmfv-bar').hidden === true && e.zoom === 0);
|
||||||
|
}, 300);
|
||||||
|
}, 300);
|
||||||
|
check('d: vuoto -> barra nascosta', d.querySelector('.tmfv-bar').hidden === true);
|
||||||
|
check('stile iniettato una sola volta', document.querySelectorAll('#tmf-viewer-css').length === 1);
|
||||||
|
tmfSvgToPngBlob(c.querySelector('.tmfv-stage > svg:not(.tmfv-ov)'), 200, 100).then(function (bl) {
|
||||||
|
check('c: tmfSvgToPngBlob produce un PNG', bl && bl.type === 'image/png' && bl.size > 100, bl && (bl.type + ' ' + bl.size));
|
||||||
|
return createImageBitmap(bl);
|
||||||
|
}).then(function (bm) {
|
||||||
|
check('c: PNG 1600x800', bm.width === 1600 && bm.height === 800, bm.width + 'x' + bm.height);
|
||||||
|
}).catch(function (er) { check('c: rasterizzazione', false, String(er)); }).then(function () { return new Promise(function (r) { setTimeout(r, 900); }); }).then(function () {
|
||||||
|
document.getElementById('log').textContent = out.join('\n') + '\n\n' + (out.length - fails) + '/' + out.length + ' controlli ok';
|
||||||
|
document.title = fails ? 'FALLITO' : 'TUTTO OK';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
}, 600);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// #126: controlli del viewer no-layers (f) contro lo stesso overlay senza no-layers (g)
|
||||||
|
(function () {
|
||||||
|
var check = window.tmfCheck;
|
||||||
|
function img(w, h, col) {
|
||||||
|
var c = document.createElement('canvas'); c.width = w; c.height = h;
|
||||||
|
var g = c.getContext('2d'); g.fillStyle = col; g.fillRect(0, 0, w, h);
|
||||||
|
return c.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
var f = document.getElementById('f'), g = document.getElementById('g');
|
||||||
|
function dati() { // overlay misto: found (costruzioni), non found (ricerca), layer 'ricerca' esplicito; due etichette con key
|
||||||
|
return {
|
||||||
|
ov: [{ type: 'segment', role: 'found', row1: 20, col1: 20, row2: 220, col2: 380 },
|
||||||
|
{ type: 'segment', role: 'reference', row1: 20, col1: 380, row2: 220, col2: 20 },
|
||||||
|
{ type: 'points', role: 'reference', layer: 'ricerca', rows: [60, 120], cols: [100, 300] }],
|
||||||
|
lb: [{ key: 'q1', nome: 'Larghezza', camera: 1, x: 200, y: 120, text: 'Larghezza 12.345', breve: '12.345', cls: 'stz-ok',
|
||||||
|
quota: { tipo: 'lineare', p1: [60, 200], p2: [340, 200], testo: [0, 20] } },
|
||||||
|
{ key: 'q2', nome: 'Punto', camera: 1, x: 100, y: 60, text: 'Punto', breve: 'P' }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var df = dati(), dg = dati(), movf = 0, movg = 0;
|
||||||
|
f.addEventListener('quota-spostata', function () { movf++; });
|
||||||
|
g.addEventListener('quota-spostata', function () { movg++; });
|
||||||
|
f.overlay = df.ov; f.labels = df.lb; g.overlay = dg.ov; g.labels = dg.lb;
|
||||||
|
f.setAttribute('src', img(400, 250, '#1e293b')); g.setAttribute('src', img(400, 250, '#1e293b'));
|
||||||
|
function drag(el) { // trascinamento simulato di un'etichetta: down sull'etichetta, move e up sul documento
|
||||||
|
var r = el.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
|
||||||
|
var o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1 };
|
||||||
|
el.dispatchEvent(new PointerEvent('pointerdown', Object.assign({ clientX: x, clientY: y }, o)));
|
||||||
|
document.dispatchEvent(new PointerEvent('pointermove', Object.assign({ clientX: x + 30, clientY: y + 20 }, o)));
|
||||||
|
document.dispatchEvent(new PointerEvent('pointerup', Object.assign({ clientX: x + 30, clientY: y + 20 }, o)));
|
||||||
|
}
|
||||||
|
setTimeout(function () {
|
||||||
|
var ovf = f.querySelector('.tmfv-ov'), ovg = g.querySelector('.tmfv-ov');
|
||||||
|
check('f no-layers: localStorage ricerca=false, ma nessun g[data-layer], nessun opacity, nessun display none', ovf.querySelectorAll('g[data-layer]').length === 0 && ovf.querySelectorAll('[opacity]').length === 0 && ovf.innerHTML.indexOf('display="none"') < 0, ovf.innerHTML.slice(0, 120));
|
||||||
|
check('f no-layers: forme found, non found e layer ricerca tutte nel disegno (>= 5 elementi)', ovf.children.length >= 5, ovf.children.length);
|
||||||
|
check('f no-layers: menu dei livelli nascosto', f.querySelector('.tmfv-layers').hidden === true);
|
||||||
|
check('f no-layers: immagine visibile, etichette visibili con text', f.querySelector('.tmfv-img').style.visibility === '' && f.querySelector('.tmfv-labels').style.display !== 'none' &&
|
||||||
|
f.querySelectorAll('.tmfv-label')[0].textContent === 'Larghezza 12.345' && f.querySelectorAll('.tmfv-label')[1].textContent === 'Punto');
|
||||||
|
check('g senza no-layers (confronto): tre gruppi g[data-layer] e menu visibile', ovg.querySelectorAll('g[data-layer]').length === 3 && g.querySelector('.tmfv-layers').hidden === false);
|
||||||
|
var lf = f.querySelector('.tmfv-label[data-key="q1"]');
|
||||||
|
var pf = lf.style.left + '|' + lf.style.top;
|
||||||
|
check('f no-layers: etichetta con pointer-events none e cursore default', getComputedStyle(lf).pointerEvents === 'none' && getComputedStyle(lf).cursor === 'default', getComputedStyle(lf).pointerEvents);
|
||||||
|
drag(lf); drag(g.querySelector('.tmfv-label[data-key="q1"]'));
|
||||||
|
var lf2 = f.querySelector('.tmfv-label[data-key="q1"]');
|
||||||
|
check('f no-layers: trascinamento simulato -> nessun quota-spostata, posizione invariata', movf === 0 && lf2.style.left + '|' + lf2.style.top === pf, movf + ' ' + lf2.style.left);
|
||||||
|
check('g senza no-layers (controllo del test): lo stesso trascinamento emette quota-spostata', movg === 1, movg);
|
||||||
|
var z0 = f.zoom, r = f.querySelector('.tmfv-vp').getBoundingClientRect();
|
||||||
|
var we = new WheelEvent('wheel', { deltaY: -100, clientX: r.left + 100, clientY: r.top + 100, bubbles: true, cancelable: true });
|
||||||
|
f.querySelector('.tmfv-vp').dispatchEvent(we);
|
||||||
|
check('f no-layers: rotella senza Ctrl -> zoom cambiato', f.zoom > z0 && we.defaultPrevented === true, z0 + ' -> ' + f.zoom);
|
||||||
|
f.removeAttribute('no-layers');
|
||||||
|
check('f: no-layers tolto a runtime -> gruppi g[data-layer], menu e trascinamento', ovf.querySelectorAll('g[data-layer]').length === 3 && f.querySelector('.tmfv-layers').hidden === false &&
|
||||||
|
getComputedStyle(f.querySelector('.tmfv-label[data-key="q1"]')).pointerEvents === 'auto');
|
||||||
|
f.setAttribute('no-layers', '');
|
||||||
|
check('f: no-layers rimesso a runtime -> di nuovo disegno unico, menu nascosto', ovf.querySelectorAll('g[data-layer]').length === 0 && f.querySelector('.tmfv-layers').hidden === true && ovf.children.length >= 5);
|
||||||
|
}, 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
{
|
||||||
|
"outcome": "pass",
|
||||||
|
"overlay": [
|
||||||
|
{
|
||||||
|
"type": "points",
|
||||||
|
"role": "found",
|
||||||
|
"rows": [
|
||||||
|
2218.926540676052
|
||||||
|
],
|
||||||
|
"cols": [
|
||||||
|
2371.08496101821
|
||||||
|
],
|
||||||
|
"tool_id": "blob"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "segment",
|
||||||
|
"role": "found",
|
||||||
|
"row1": 2218.926540676052,
|
||||||
|
"col1": 2371.08496101821,
|
||||||
|
"row2": 2268.925168990387,
|
||||||
|
"col2": 2371.4553212279457,
|
||||||
|
"tool_id": "blob"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "contour",
|
||||||
|
"role": "found",
|
||||||
|
"rows": [
|
||||||
|
2469.0093822012514,
|
||||||
|
2467.8907055854274,
|
||||||
|
1969.1995500932737,
|
||||||
|
1970.3182267090972,
|
||||||
|
2469.0093822012514
|
||||||
|
],
|
||||||
|
"cols": [
|
||||||
|
2297.7334028311207,
|
||||||
|
2448.7547459672996,
|
||||||
|
2445.0607374080982,
|
||||||
|
2294.0393942719193,
|
||||||
|
2297.7334028311207
|
||||||
|
],
|
||||||
|
"tool_id": "blob"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "rect2",
|
||||||
|
"role": "roi",
|
||||||
|
"row": 2250.0000476834334,
|
||||||
|
"col": 2296.999963957319,
|
||||||
|
"phi_deg": -89.99999458063853,
|
||||||
|
"length1": 159.9999999999998,
|
||||||
|
"length2": 20.0,
|
||||||
|
"tool_id": "lato_sx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "segment",
|
||||||
|
"role": "found",
|
||||||
|
"row1": 2409.997184964675,
|
||||||
|
"col1": 2297.344751969946,
|
||||||
|
"row2": 2089.9983738509072,
|
||||||
|
"col2": 2295.147617847124,
|
||||||
|
"tool_id": "lato_sx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "rect2",
|
||||||
|
"role": "roi",
|
||||||
|
"row": 2250.000033590164,
|
||||||
|
"col": 2445.999963957318,
|
||||||
|
"phi_deg": -89.99999458063853,
|
||||||
|
"length1": 159.9999999999998,
|
||||||
|
"length2": 20.0,
|
||||||
|
"tool_id": "lato_dx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "segment",
|
||||||
|
"role": "found",
|
||||||
|
"row1": 2410.000338325413,
|
||||||
|
"col1": 2446.9546542059475,
|
||||||
|
"row2": 2090.0001187672883,
|
||||||
|
"col2": 2444.590522414638,
|
||||||
|
"tool_id": "lato_dx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "rect2",
|
||||||
|
"role": "roi",
|
||||||
|
"row": 1970.0000406368001,
|
||||||
|
"col": 2371.4999374733225,
|
||||||
|
"phi_deg": 5.419361327409989e-06,
|
||||||
|
"length1": 36.499999999999936,
|
||||||
|
"length2": 15.0,
|
||||||
|
"tool_id": "lato_alto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "segment",
|
||||||
|
"role": "found",
|
||||||
|
"row1": 1969.7270815449738,
|
||||||
|
"col1": 2335.0012415021283,
|
||||||
|
"row2": 1969.9815342357629,
|
||||||
|
"col2": 2408.0075386065682,
|
||||||
|
"tool_id": "lato_alto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "rect2",
|
||||||
|
"role": "roi",
|
||||||
|
"row": 2466.000040636798,
|
||||||
|
"col": 2371.4999843878295,
|
||||||
|
"phi_deg": 5.419361327409989e-06,
|
||||||
|
"length1": 36.499999999999936,
|
||||||
|
"length2": 15.0,
|
||||||
|
"tool_id": "lato_basso"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "segment",
|
||||||
|
"role": "found",
|
||||||
|
"row1": 2466.9468485672455,
|
||||||
|
"col1": 2335.0088484836465,
|
||||||
|
"row2": 2465.293164536675,
|
||||||
|
"col2": 2407.9979613716796,
|
||||||
|
"tool_id": "lato_basso"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "arc",
|
||||||
|
"role": "roi",
|
||||||
|
"row": 2015.6000435878736,
|
||||||
|
"col": 2340.2999417864303,
|
||||||
|
"radius": 46.0,
|
||||||
|
"start_deg": 95.00000541936147,
|
||||||
|
"extent_deg": 80.0,
|
||||||
|
"tool_id": "arco_tl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "arc",
|
||||||
|
"role": "found",
|
||||||
|
"row": 2014.0729365101115,
|
||||||
|
"col": 2338.325339664551,
|
||||||
|
"radius": 43.69800114451398,
|
||||||
|
"start_deg": 92.61763130401864,
|
||||||
|
"extent_deg": 84.15103825789168,
|
||||||
|
"tool_id": "arco_tl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "arc",
|
||||||
|
"role": "roi",
|
||||||
|
"row": 2016.1000381586543,
|
||||||
|
"col": 2397.699941833723,
|
||||||
|
"radius": 46.0,
|
||||||
|
"start_deg": 5.000005419361472,
|
||||||
|
"extent_deg": 80.0,
|
||||||
|
"tool_id": "arco_tr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "arc",
|
||||||
|
"role": "found",
|
||||||
|
"row": 2014.7155277666257,
|
||||||
|
"col": 2400.767078642102,
|
||||||
|
"radius": 43.26606749223183,
|
||||||
|
"start_deg": 3.5348692687035217,
|
||||||
|
"extent_deg": 85.33536916618716,
|
||||||
|
"tool_id": "arco_tr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "arc",
|
||||||
|
"role": "roi",
|
||||||
|
"row": 2418.4000378086853,
|
||||||
|
"col": 2401.3999798855507,
|
||||||
|
"radius": 46.0,
|
||||||
|
"start_deg": 275.00000541936146,
|
||||||
|
"extent_deg": 80.0,
|
||||||
|
"tool_id": "arco_br"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "arc",
|
||||||
|
"role": "found",
|
||||||
|
"row": 2420.300713032017,
|
||||||
|
"col": 2404.4253040181898,
|
||||||
|
"radius": 42.604285715934104,
|
||||||
|
"start_deg": -76.0934883340429,
|
||||||
|
"extent_deg": 73.28101449433917,
|
||||||
|
"tool_id": "arco_br"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "arc",
|
||||||
|
"role": "roi",
|
||||||
|
"row": 2421.8000432662807,
|
||||||
|
"col": 2343.699980207142,
|
||||||
|
"radius": 46.0,
|
||||||
|
"start_deg": 185.00000541936146,
|
||||||
|
"extent_deg": 80.0,
|
||||||
|
"tool_id": "arco_bl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "arc",
|
||||||
|
"role": "found",
|
||||||
|
"row": 2423.3935764712314,
|
||||||
|
"col": 2338.8006257209418,
|
||||||
|
"radius": 41.362918221221754,
|
||||||
|
"start_deg": -176.60049573516702,
|
||||||
|
"extent_deg": 77.4552373110766,
|
||||||
|
"tool_id": "arco_bl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "segment",
|
||||||
|
"role": "found",
|
||||||
|
"row1": 2250.0002285463506,
|
||||||
|
"col1": 2445.772588310293,
|
||||||
|
"row2": 2251.0268387327387,
|
||||||
|
"col2": 2296.253250501527,
|
||||||
|
"tool_id": "larghezza"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "segment",
|
||||||
|
"role": "found",
|
||||||
|
"row1": 2466.12000655196,
|
||||||
|
"col1": 2371.503404927663,
|
||||||
|
"row2": 1969.8603328620893,
|
||||||
|
"col2": 2373.2330448944704,
|
||||||
|
"tool_id": "lunghezza"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "circle",
|
||||||
|
"role": "found",
|
||||||
|
"row": 2014.0729365101115,
|
||||||
|
"col": 2338.325339664551,
|
||||||
|
"radius": 43.69800114451398,
|
||||||
|
"tool_id": "R_tl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "circle",
|
||||||
|
"role": "found",
|
||||||
|
"row": 2014.7155277666257,
|
||||||
|
"col": 2400.767078642102,
|
||||||
|
"radius": 43.26606749223183,
|
||||||
|
"tool_id": "R_tr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "circle",
|
||||||
|
"role": "found",
|
||||||
|
"row": 2420.300713032017,
|
||||||
|
"col": 2404.4253040181898,
|
||||||
|
"radius": 42.604285715934104,
|
||||||
|
"tool_id": "R_br"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "circle",
|
||||||
|
"role": "found",
|
||||||
|
"row": 2423.3935764712314,
|
||||||
|
"col": 2338.8006257209418,
|
||||||
|
"radius": 41.362918221221754,
|
||||||
|
"tool_id": "R_bl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"failures": [],
|
||||||
|
"measurements": [
|
||||||
|
{
|
||||||
|
"subtask_id": 15,
|
||||||
|
"value": 1.6682265730716714,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subtask_id": 16,
|
||||||
|
"value": 5.536802808714362,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subtask_id": 17,
|
||||||
|
"value": 0.48753859876934247,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subtask_id": 18,
|
||||||
|
"value": 0.4827195150108305,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subtask_id": 19,
|
||||||
|
"value": 0.4753360157326768,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subtask_id": 20,
|
||||||
|
"value": 0.4614860785941711,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subtask_id": 21,
|
||||||
|
"value": 0.004819083758511997,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subtask_id": 22,
|
||||||
|
"value": 0.012202583036665687,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subtask_id": 23,
|
||||||
|
"value": 0.026052520175171373,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subtask_id": 24,
|
||||||
|
"value": 0.00738349927815369,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subtask_id": 25,
|
||||||
|
"value": 0.021233436416659375,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subtask_id": 26,
|
||||||
|
"value": 0.013849937138505686,
|
||||||
|
"pass_fail": "pass"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"calibration": "ok",
|
||||||
|
"_subtasks": [
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"marker_number": 1,
|
||||||
|
"description": "Larghezza",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "larghezza.value",
|
||||||
|
"nominal": 1.668,
|
||||||
|
"ltl": null,
|
||||||
|
"utl": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"marker_number": 2,
|
||||||
|
"description": "Lunghezza",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "lunghezza.value",
|
||||||
|
"nominal": 5.537,
|
||||||
|
"ltl": null,
|
||||||
|
"utl": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"marker_number": 3,
|
||||||
|
"description": "R alto-sinistra",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "R_tl.value",
|
||||||
|
"nominal": 0.488,
|
||||||
|
"ltl": null,
|
||||||
|
"utl": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"marker_number": 4,
|
||||||
|
"description": "R alto-destra",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "R_tr.value",
|
||||||
|
"nominal": 0.483,
|
||||||
|
"ltl": null,
|
||||||
|
"utl": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"marker_number": 5,
|
||||||
|
"description": "R basso-destra",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "R_br.value",
|
||||||
|
"nominal": 0.475,
|
||||||
|
"ltl": null,
|
||||||
|
"utl": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"marker_number": 6,
|
||||||
|
"description": "R basso-sinistra",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "R_bl.value",
|
||||||
|
"nominal": 0.464,
|
||||||
|
"ltl": null,
|
||||||
|
"utl": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"marker_number": 7,
|
||||||
|
"description": "Diff. R alto-sx / alto-dx",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "d_tl_tr.value",
|
||||||
|
"nominal": 0.0,
|
||||||
|
"ltl": -0.05,
|
||||||
|
"utl": 0.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"marker_number": 8,
|
||||||
|
"description": "Diff. R alto-sx / basso-dx",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "d_tl_br.value",
|
||||||
|
"nominal": 0.0,
|
||||||
|
"ltl": -0.05,
|
||||||
|
"utl": 0.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"marker_number": 9,
|
||||||
|
"description": "Diff. R alto-sx / basso-sx",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "d_tl_bl.value",
|
||||||
|
"nominal": 0.0,
|
||||||
|
"ltl": -0.05,
|
||||||
|
"utl": 0.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"marker_number": 10,
|
||||||
|
"description": "Diff. R alto-dx / basso-dx",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "d_tr_br.value",
|
||||||
|
"nominal": 0.0,
|
||||||
|
"ltl": -0.05,
|
||||||
|
"utl": 0.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 25,
|
||||||
|
"marker_number": 11,
|
||||||
|
"description": "Diff. R alto-dx / basso-sx",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "d_tr_bl.value",
|
||||||
|
"nominal": 0.0,
|
||||||
|
"ltl": -0.05,
|
||||||
|
"utl": 0.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 26,
|
||||||
|
"marker_number": 12,
|
||||||
|
"description": "Diff. R basso-dx / basso-sx",
|
||||||
|
"measurement_type": null,
|
||||||
|
"unit": "mm",
|
||||||
|
"vision_output": "d_br_bl.value",
|
||||||
|
"nominal": 0.0,
|
||||||
|
"ltl": -0.05,
|
||||||
|
"utl": 0.05
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -153,6 +153,20 @@ const MEASUREMENT = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #118: quota non misurata (value null) -> NaN nel valore e nella deviazione; tolleranze nulle restano vuote
|
||||||
|
{
|
||||||
|
const e = csvExport();
|
||||||
|
const nan = { ...MEASUREMENT, value: null, deviation: null, pass_fail: 'fail',
|
||||||
|
subtask: { ...MEASUREMENT.subtask, utl: null, ltl: null } };
|
||||||
|
const row = e.buildMeasurementCSV([nan]).split('\n')[1].split(';');
|
||||||
|
check('#118 value null -> NaN', row[3] === 'NaN', row.join('|'));
|
||||||
|
check('#118 deviazione null -> NaN', row[8] === 'NaN', row.join('|'));
|
||||||
|
check('#118 tolleranze nulle restano vuote', row[6] === '' && row[7] === '', row.join('|'));
|
||||||
|
check('#118 esito fail', row[9] === 'fail', row.join('|'));
|
||||||
|
const ok = e.buildMeasurementCSV([MEASUREMENT]).split('\n')[1].split(';');
|
||||||
|
check('#118 valore presente invariato', ok[3] !== 'NaN' && ok[8] !== 'NaN', ok.join('|'));
|
||||||
|
}
|
||||||
|
|
||||||
if (failures > 0) {
|
if (failures > 0) {
|
||||||
console.error(failures + ' test falliti su csv-export.js');
|
console.error(failures + ' test falliti su csv-export.js');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* #101: anteprima DXF del wizard «Crea ricetta da DXF», con plain node.
|
||||||
|
* Copre dxfPreviewPaths, dxfPreviewViewBox, dxfPreviewSummary e lo scarto
|
||||||
|
* di una risposta arrivata dopo un cambio di file.
|
||||||
|
*
|
||||||
|
* Run from src/frontend/flask_app: node tests/js/test_dxf_preview.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
global.document = { documentElement: { lang: 'it' } };
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'static', 'js', 'dxf_recipe_wizard.js'), 'utf8',
|
||||||
|
);
|
||||||
|
eval(src + ';global.dxfRecipeWizard = dxfRecipeWizard;');
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(label, cond, extra) {
|
||||||
|
if (cond) console.log('ok - ' + label);
|
||||||
|
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW = {
|
||||||
|
contorni: [[[0, -5], [12.3, -5], [12.3, 0], [0, 0], [0, -5]], [[1, -1]]],
|
||||||
|
bbox: { xmin: 0, ymin: -5.1, xmax: 12.3, ymax: 0 },
|
||||||
|
larghezza_mm: 12.3, altezza_mm: 5.1, entita: 2, punti: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const w = dxfRecipeWizard(1, () => 'tok', { dxfEntities: 'entità' });
|
||||||
|
|
||||||
|
check('senza anteprima: nessun path', w.dxfPreviewPaths().length === 0);
|
||||||
|
check('senza anteprima: summary vuoto', w.dxfPreviewSummary === '');
|
||||||
|
|
||||||
|
w.dxfPreview = PREVIEW;
|
||||||
|
const paths = w.dxfPreviewPaths();
|
||||||
|
check('un contorno con >1 punti diventa una polyline (quello da 1 punto no)', paths.length === 1);
|
||||||
|
check('formato points', paths[0] === '0,-5 12.3,-5 12.3,0 0,0 0,-5', paths[0]);
|
||||||
|
|
||||||
|
// #101: path unico per <path :d>
|
||||||
|
check('dxfPreviewD', w.dxfPreviewD() === 'M 0 -5 L 12.3 -5 L 12.3 0 L 0 0 L 0 -5', w.dxfPreviewD());
|
||||||
|
w.dxfPreview = null;
|
||||||
|
check('dxfPreviewD vuoto senza anteprima', w.dxfPreviewD() === '');
|
||||||
|
w.dxfPreview = PREVIEW;
|
||||||
|
|
||||||
|
const vb = w.dxfPreviewViewBox().split(' ').map(Number);
|
||||||
|
const m = Math.max(0.5, 0.05 * 12.3);
|
||||||
|
check('viewBox con margine 5%', Math.abs(vb[0] + m) < 1e-3 && Math.abs(vb[2] - (12.3 + 2 * m)) < 1e-3, vb.join(' '));
|
||||||
|
|
||||||
|
// Disegno minuscolo: margine minimo 0.5 mm
|
||||||
|
w.dxfPreview = { ...PREVIEW, bbox: { xmin: 0, ymin: 0, xmax: 1, ymax: 1 } };
|
||||||
|
check('margine minimo 0.5 mm', Math.abs(w.dxfPreviewViewBox().split(' ').map(Number)[0] + 0.5) < 1e-3);
|
||||||
|
|
||||||
|
w.dxfPreview = PREVIEW;
|
||||||
|
const s = w.dxfPreviewSummary;
|
||||||
|
check('summary L × H mm · N entità', s.includes('×') && s.includes('mm') && s.endsWith('2 entità')
|
||||||
|
&& /12[,.]30/.test(s) && /5[,.]10/.test(s), s);
|
||||||
|
|
||||||
|
// Risposta tardiva scartata: due scelte di file, la prima risponde per ultima
|
||||||
|
const resolvers = [];
|
||||||
|
global.fetch = () => new Promise(res => resolvers.push(res));
|
||||||
|
global.FormData = class { append() {} };
|
||||||
|
const w2 = dxfRecipeWizard(1, () => 'tok', {});
|
||||||
|
const reply = (body, ok = true) => ({ ok, json: async () => body });
|
||||||
|
w2.dxfFile = { name: 'a.dxf' };
|
||||||
|
const p1 = w2.loadDxfPreview();
|
||||||
|
w2.dxfFile = { name: 'b.dxf' };
|
||||||
|
const p2 = w2.loadDxfPreview();
|
||||||
|
resolvers[1](reply(PREVIEW));
|
||||||
|
await p2;
|
||||||
|
resolvers[0](reply({ ...PREVIEW, entita: 99 }));
|
||||||
|
await p1;
|
||||||
|
check('la risposta vecchia non sovrascrive la nuova', w2.dxfPreview && w2.dxfPreview.entita === 2);
|
||||||
|
check('busy spento', w2.dxfPreviewBusy === false);
|
||||||
|
|
||||||
|
// File tolto: tutto azzerato
|
||||||
|
w2.dxfFile = null;
|
||||||
|
await w2.loadDxfPreview();
|
||||||
|
check('file tolto azzera anteprima ed errore', w2.dxfPreview === null && w2.dxfPreviewError === '');
|
||||||
|
|
||||||
|
// Errore 422 con detail
|
||||||
|
global.fetch = async () => reply({ detail: 'DXF illeggibile: x' }, false);
|
||||||
|
w2.dxfFile = { name: 'c.dxf' };
|
||||||
|
await w2.loadDxfPreview();
|
||||||
|
check('errore col detail', w2.dxfPreviewError === 'DXF illeggibile: x' && w2.dxfPreview === null, w2.dxfPreviewError);
|
||||||
|
|
||||||
|
// #102: anteprima dell'immagine
|
||||||
|
const wi = dxfRecipeWizard(1, () => 'tok', { imageField: 'campo' });
|
||||||
|
check('#102 senza immagine: summary vuoto', wi.imagePreviewSummary === '');
|
||||||
|
global.URL.createObjectURL = () => 'blob:x';
|
||||||
|
global.URL.revokeObjectURL = () => {};
|
||||||
|
wi._setImage({ name: 'a.bmp' }, 'file'); // #104: la scala a mano vale solo per file
|
||||||
|
check('#102 _setImage imposta URL e nome', wi.imagePreviewUrl === 'blob:x' && wi.imageName === 'a.bmp');
|
||||||
|
wi.onImagePreviewLoad({ width: 2000, height: 1000 }); // #122: detail di viewer-load
|
||||||
|
check('#102 senza scala: solo pixel', /2[. ]?000 × 1[. ]?000 px$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
|
||||||
|
wi.mmPx = '0,01';
|
||||||
|
check('#102 con mm/px a mano: campo in mm', /px · campo ≈ 20,00 × 10,00 mm$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
|
||||||
|
wi.mmPx = ''; wi.calState = 'calibrated'; wi.calPxMm = 100;
|
||||||
|
check('#102 con calibrazione stazione: campo in mm', /campo ≈ 20,00 × 10,00 mm$/.test(wi.imagePreviewSummary), wi.imagePreviewSummary);
|
||||||
|
wi.onImagePreviewError();
|
||||||
|
check('#102 errore <img>: imageBroken e file ancora valido', wi.imageBroken === true && wi.imageFile && wi.imagePx === null);
|
||||||
|
wi._setImage(null);
|
||||||
|
check('#102 _setImage(null) azzera', wi.imagePreviewUrl === '' && wi.imageFile === null && wi.imageName === ''
|
||||||
|
&& wi.imagePx === null && wi.imageBroken === false && wi.imagePreviewSummary === '');
|
||||||
|
|
||||||
|
// #104: scala a mano solo per immagini da file
|
||||||
|
const w4 = dxfRecipeWizard(1, () => 'tok', {});
|
||||||
|
w4._setImage({ name: 'a.bmp' }, 'file');
|
||||||
|
w4.mmPx = '0,01406';
|
||||||
|
check('#104 useManualScale vero con file', w4.useManualScale === true);
|
||||||
|
w4._setImage({ name: 'camera.png' }, 'camera');
|
||||||
|
check('#104 useManualScale falso con camera anche se mmPx valido', w4.manualPxMm !== null && w4.useManualScale === false);
|
||||||
|
w4.stationId = '1'; w4.dxfFile = { name: 'd.dxf' };
|
||||||
|
check('#104 canRun con camera richiede la calibrazione', w4.canRun === false);
|
||||||
|
w4.calState = 'calibrated'; w4.calPxMm = 50;
|
||||||
|
check('#104 canRun con camera e stazione calibrata', w4.canRun === true);
|
||||||
|
|
||||||
|
// #104: run() con la camera non invia px_per_mm; con il file si
|
||||||
|
const sent = [];
|
||||||
|
global.FormData = class { append(k, v) { sent.push(k); } };
|
||||||
|
global.fetch = async () => ({ ok: true, json: async () => ({ result: { outputs: {}, overlay: [], outcome: 'pass' } }) });
|
||||||
|
const tick = [];
|
||||||
|
w4.$nextTick = f => tick.push(f);
|
||||||
|
w4.$dispatch = () => {};
|
||||||
|
w4.$refs = {};
|
||||||
|
await w4.run(false);
|
||||||
|
check('#104 run con camera: niente px_per_mm', !sent.includes('px_per_mm') && sent.includes('station_id'), sent.join(','));
|
||||||
|
check('#104 run: tested true e scala = stazione', w4.tested === true && w4.runPxPerMm === 50);
|
||||||
|
sent.length = 0;
|
||||||
|
w4._setImage({ name: 'a.bmp' }, 'file');
|
||||||
|
w4.mmPx = '0,02';
|
||||||
|
await w4.run(false);
|
||||||
|
check('#104 run con file: px_per_mm inviato', sent.includes('px_per_mm') && Math.abs(w4.runPxPerMm - 50) < 1e-9, sent.join(','));
|
||||||
|
check('#104 esito pass', w4.outcome === 'pass' && w4.outcomeClass.includes('emerald'));
|
||||||
|
|
||||||
|
// #104: tested torna false dopo _setImage / onDxf / onStation
|
||||||
|
w4.tested = true; w4._setImage(null);
|
||||||
|
check('#104 tested falso dopo _setImage', w4.tested === false && w4.imageSource === '');
|
||||||
|
w4.tested = true; w4.onDxf({ target: { files: [] } });
|
||||||
|
check('#104 tested falso dopo onDxf', w4.tested === false);
|
||||||
|
w4.tested = true; w4.stationId = ''; await w4.onStation();
|
||||||
|
check('#104 tested falso dopo onStation', w4.tested === false);
|
||||||
|
|
||||||
|
// #122: zoom, Adatta e pan della Prova stanno in <tmf-viewer> (tests/js/test_image_viewer.js), non piu' nel wizard
|
||||||
|
const w5 = dxfRecipeWizard(1, () => 'tok', {});
|
||||||
|
check('#122 zoom e Adatta tolti dal wizard', ['zoomBy', 'fit', 'onPanDown', 'onPanMove', 'onPanEnd'].every(k => typeof w5[k] !== 'function'));
|
||||||
|
|
||||||
|
// #105: ricetta esistente -> wizard compilato
|
||||||
|
global.File = class { constructor(parts, name, opts) { this.parts = parts; this.name = name; this.type = (opts && opts.type) || ''; } };
|
||||||
|
const SRC = { band_mm: 0.3, min_points_pct: 90, polarity: 'light', px_per_mm: 71.1237, dxf: 'disegno.dxf', image: 'riferimento.bmp' };
|
||||||
|
const blobOf = t => ({ type: t });
|
||||||
|
const mkFetch = (routes, calls) => async (url) => {
|
||||||
|
calls.push(url);
|
||||||
|
for (const k of Object.keys(routes)) if (url.endsWith(k)) return routes[k]();
|
||||||
|
return { ok: false, status: 404, json: async () => ({ error: true }) };
|
||||||
|
};
|
||||||
|
const okJson = body => () => ({ ok: true, json: async () => body });
|
||||||
|
const okBlob = t => () => ({ ok: true, blob: async () => blobOf(t) });
|
||||||
|
|
||||||
|
const w6 = dxfRecipeWizard(8, () => 'tok', { noReferenceImage: 'senza immagine', editLabel: 'Modifica', createLabel: 'Crea' });
|
||||||
|
w6.$nextTick = f => f();
|
||||||
|
const calls6 = [];
|
||||||
|
global.fetch = mkFetch({
|
||||||
|
'/recipe/dxf-compare/source': okJson(SRC),
|
||||||
|
'/recipe/files/disegno.dxf': okBlob('application/dxf'),
|
||||||
|
'/recipe/files/riferimento.bmp': okBlob('image/bmp'),
|
||||||
|
'/dxf-preview': okJson(PREVIEW),
|
||||||
|
}, calls6);
|
||||||
|
global.FormData = class { append() {} };
|
||||||
|
check('#105 senza ricetta: existing null', w6.existing === null);
|
||||||
|
w6.init();
|
||||||
|
await w6._existingReady;
|
||||||
|
check('#105 init legge /source', w6.existing && w6.existing.band_mm === 0.3 && calls6[0] === '/maker/api/vision/tasks/8/recipe/dxf-compare/source', calls6.join(','));
|
||||||
|
await w6.toggle();
|
||||||
|
await w6.openExisting();
|
||||||
|
check('#105 apertura: banda, soglia, polarita', w6.bandMm === 0.3 && w6.minPointsPct === 90 && w6.dark === false);
|
||||||
|
check('#105 mm/px = 1/px_per_mm con virgola', w6.mmPx === '0,01406', w6.mmPx);
|
||||||
|
check('#105 DXF caricato dalla cartella', w6.dxfFile && w6.dxfFile.name === 'disegno.dxf' && w6.dxfFromRecipe === true);
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // l'anteprima parte senza essere attesa
|
||||||
|
check('#105 anteprima DXF richiesta', calls6.some(u => u.endsWith('/dxf-preview')) && w6.dxfPreview !== null);
|
||||||
|
check('#105 immagine con source file: il campo mm/px vale', w6.imageFile && w6.imageFile.name === 'riferimento.bmp'
|
||||||
|
&& w6.imageSource === 'file' && w6.imageSrc === 'blob:x');
|
||||||
|
check('#105 info mostrata, nessun avviso', w6.existingInfo === true && w6.existingWarn === '');
|
||||||
|
const nCalls = calls6.length;
|
||||||
|
w6.bandMm = 0.2;
|
||||||
|
await w6.openExisting();
|
||||||
|
check('#105 seconda apertura non ricompila', w6.bandMm === 0.2 && calls6.length === nCalls);
|
||||||
|
|
||||||
|
// #105: ricetta senza immagine salvata -> avviso
|
||||||
|
const w7 = dxfRecipeWizard(8, () => 'tok', { noReferenceImage: 'senza immagine' });
|
||||||
|
global.fetch = mkFetch({
|
||||||
|
'/recipe/dxf-compare/source': okJson({ ...SRC, image: null, px_per_mm: null }),
|
||||||
|
'/recipe/files/disegno.dxf': okBlob('application/dxf'),
|
||||||
|
'/dxf-preview': okJson(PREVIEW),
|
||||||
|
}, []);
|
||||||
|
w7.init(); await w7._existingReady; await w7.openExisting();
|
||||||
|
check('#105 senza immagine: avviso e nessun file', w7.existingWarn === 'senza immagine' && w7.imageFile === null && w7.imageSource === '');
|
||||||
|
check('#105 px_per_mm null: mm/px resta vuoto', w7.mmPx === '');
|
||||||
|
|
||||||
|
// #105: 404 -> existing null, etichetta «Crea»
|
||||||
|
const w8 = dxfRecipeWizard(9, () => 'tok', {});
|
||||||
|
global.fetch = mkFetch({}, []);
|
||||||
|
w8.init(); await w8._existingReady; await w8.openExisting();
|
||||||
|
check('#105 404: existing null e nessuna compilazione', w8.existing === null && w8.existingInfo === false && w8.dxfFile === null);
|
||||||
|
|
||||||
|
// #105: file scelti a mano durante il download della ricetta non si sovrascrivono
|
||||||
|
const w9 = dxfRecipeWizard(8, () => 'tok', {});
|
||||||
|
const gates = {};
|
||||||
|
const gate = k => () => new Promise(res => { gates[k] = () => res({ ok: true, blob: async () => blobOf(k) }); });
|
||||||
|
global.fetch = mkFetch({
|
||||||
|
'/recipe/dxf-compare/source': okJson(SRC),
|
||||||
|
'/recipe/files/disegno.dxf': gate('dxf'),
|
||||||
|
'/recipe/files/riferimento.bmp': gate('bmp'),
|
||||||
|
'/dxf-preview': okJson(PREVIEW),
|
||||||
|
}, []);
|
||||||
|
w9.init(); await w9._existingReady;
|
||||||
|
const pa = w9.openExisting();
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
w9.onDxf({ target: { files: [{ name: 'mio.dxf' }] } });
|
||||||
|
w9._setImage({ name: 'mia.bmp' }, 'file');
|
||||||
|
gates.dxf(); gates.bmp();
|
||||||
|
await pa;
|
||||||
|
check('#105 DXF scelto a mano non sovrascritto', w9.dxfFile.name === 'mio.dxf' && w9.dxfFromRecipe === false);
|
||||||
|
check('#105 immagine scelta a mano non sovrascritta', w9.imageFile.name === 'mia.bmp');
|
||||||
|
|
||||||
|
// #105: file della ricetta illeggibile -> avviso
|
||||||
|
const w10 = dxfRecipeWizard(8, () => 'tok', { existingFilesFailed: 'illeggibili' });
|
||||||
|
global.fetch = mkFetch({
|
||||||
|
'/recipe/dxf-compare/source': okJson(SRC),
|
||||||
|
'/recipe/files/disegno.dxf': () => ({ ok: false, status: 404 }),
|
||||||
|
'/recipe/files/riferimento.bmp': okBlob('image/bmp'),
|
||||||
|
}, []);
|
||||||
|
w10.init(); await w10._existingReady; await w10.openExisting();
|
||||||
|
check('#105 DXF illeggibile: avviso, immagine comunque caricata', w10.existingWarn === 'illeggibili' && w10.dxfFile === null && w10.imageFile !== null);
|
||||||
|
|
||||||
|
// #105: dopo Salva si rilegge /source e il wizard non si ricompila
|
||||||
|
const w11 = dxfRecipeWizard(8, () => 'tok', {});
|
||||||
|
w11.$nextTick = f => f(); w11.$dispatch = () => {}; w11.$refs = {};
|
||||||
|
w11.stationId = '1'; w11.calState = 'calibrated'; w11.calPxMm = 50;
|
||||||
|
w11.imageFile = { name: 'a.bmp' }; w11.dxfFile = { name: 'd.dxf' };
|
||||||
|
global.FormData = class { append() {} };
|
||||||
|
global.fetch = async (url, opts) => url.endsWith('/source')
|
||||||
|
? { ok: true, json: async () => SRC }
|
||||||
|
: { ok: true, json: async () => ({ saved: true, result: { outputs: {}, overlay: [], outcome: 'pass' } }) };
|
||||||
|
await w11.run(true);
|
||||||
|
check('#105 dopo Salva: existing letto e non ricompilato', w11.existing && w11.existing.band_mm === 0.3 && w11._existingApplied === true);
|
||||||
|
|
||||||
|
if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
|
||||||
|
console.log('tutto ok');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* #122: lo zoom delle tre viste del wizard (immagine, DXF, Prova) e' nel componente <tmf-viewer>.
|
||||||
|
* Qui si prova cio' che resta nel wizard (dimensioni dal detail di viewer-load, anteprima DXF)
|
||||||
|
* e che le funzioni di zoom/pan/fit/overlay non ci siano piu'.
|
||||||
|
* (Il nome del file resta quello di #113 per non rompere i riferimenti.)
|
||||||
|
*
|
||||||
|
* Run from src/frontend/flask_app: node tests/js/test_dxf_zoom.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
global.document = { documentElement: { lang: 'it' } };
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'static', 'js', 'dxf_recipe_wizard.js'), 'utf8',
|
||||||
|
);
|
||||||
|
eval(src + ';global.dxfRecipeWizard = dxfRecipeWizard;');
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(label, cond, extra) {
|
||||||
|
if (cond) console.log('ok - ' + label);
|
||||||
|
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = dxfRecipeWizard(1, () => 'tok', {});
|
||||||
|
w.$nextTick = fn => fn();
|
||||||
|
|
||||||
|
// #122: via tutto lo zoom/pan/fit/overlay del wizard
|
||||||
|
const gone = ['views', 'viewPanKey', 'zoom', 'fitZoom', 'panning', 'resPx', 'vZoomBy', 'vFit', 'vWheel', 'vPanDown',
|
||||||
|
'vPanMove', 'vPanEnd', 'vCursor', 'vPct', 'vW', 'vH', 'vReset', 'vSetContent', 'zoomBy', 'fit', 'onWheel',
|
||||||
|
'onPanDown', 'onPanMove', 'onPanEnd', 'onResultImgLoad', 'drawOverlay', 'zoomPct', 'zoomW', 'zoomH', 'vpHeight'];
|
||||||
|
for (const k of gone) check('#122 tolto dal wizard: ' + k, !(k in w));
|
||||||
|
check('#122 sorgente senza $refs di zoom', !/\$refs\./.test(src));
|
||||||
|
|
||||||
|
// onImagePreviewLoad riceve il detail di viewer-load e alimenta il riepilogo
|
||||||
|
global.URL.createObjectURL = () => 'blob:x';
|
||||||
|
global.URL.revokeObjectURL = () => {};
|
||||||
|
w._setImage({ name: 'a.bmp' }, 'file');
|
||||||
|
w.onImagePreviewLoad({ width: 800, height: 240 });
|
||||||
|
check('#122 viewer-load: dimensioni nel riepilogo', w.imagePx.w === 800 && w.imagePx.h === 240 && w.imageBroken === false);
|
||||||
|
check('#122 riepilogo «W × H px»', /800.*240 px/.test(w.imagePreviewSummary), w.imagePreviewSummary);
|
||||||
|
w.onImagePreviewLoad({ width: 0, height: 0 });
|
||||||
|
check('#122 viewer-load senza dimensioni: imagePx null', w.imagePx === null);
|
||||||
|
w.onImagePreviewLoad({ width: 800, height: 240 });
|
||||||
|
w.onImagePreviewError();
|
||||||
|
check('#122 errore immagine: avviso e dimensioni azzerate', w.imageBroken === true && w.imagePx === null);
|
||||||
|
w._setImage({ name: 'b.bmp' }, 'file');
|
||||||
|
check('#122 nuova immagine: avviso tolto', w.imageBroken === false && w.imagePx === null);
|
||||||
|
w._setImage(null);
|
||||||
|
check('#122 immagine tolta: src vuoto', w.imageSrc === '' && w.imagePreviewUrl === '');
|
||||||
|
|
||||||
|
// Anteprima DXF: il viewBox resta quello per il <tmf-viewer> (width/height del template)
|
||||||
|
(async () => {
|
||||||
|
const PREVIEW = {
|
||||||
|
contorni: [[[0, 0], [20, 0], [20, 10]]],
|
||||||
|
bbox: { xmin: 0, ymin: 0, xmax: 20, ymax: 10 },
|
||||||
|
larghezza_mm: 20, altezza_mm: 10, entita: 1, punti: 3,
|
||||||
|
};
|
||||||
|
global.FormData = class { append() {} };
|
||||||
|
global.fetch = async () => ({ ok: true, json: async () => PREVIEW });
|
||||||
|
const wd = dxfRecipeWizard(1, () => 'tok', {});
|
||||||
|
wd.$nextTick = fn => fn();
|
||||||
|
wd.dxfFile = { name: 'a.dxf' };
|
||||||
|
await wd.loadDxfPreview();
|
||||||
|
const vb = wd.dxfPreviewViewBox().split(' ').map(Number);
|
||||||
|
check('#122 DXF: viewBox con margine 5 % (w 22 x h 12)', vb.length === 4 && Math.abs(vb[2] - 22) < 1e-6 && Math.abs(vb[3] - 12) < 1e-6, vb.join(' '));
|
||||||
|
check('#122 DXF: anteprima caricata', wd.dxfPreview && wd.dxfPreviewD().startsWith('M 0 0'));
|
||||||
|
wd.dxfFile = null;
|
||||||
|
await wd.loadDxfPreview();
|
||||||
|
check('#122 DXF tolto: anteprima azzerata', wd.dxfPreview === null && wd.dxfPreviewViewBox() === '0 0 1 1');
|
||||||
|
|
||||||
|
if (failures) { console.log(failures + ' test falliti'); process.exit(1); }
|
||||||
|
console.log('tutti i test #122 ok');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* image_to_png.js, run with plain node (#107): solo la parte pura (niente canvas qui).
|
||||||
|
* Run from src/frontend/flask_app: node tests/js/test_image_to_png.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const path = require('path');
|
||||||
|
const { tmfNeedsPng, tmfImageToPng } = require(
|
||||||
|
path.join(__dirname, '..', '..', 'static', 'js', 'image_to_png.js'),
|
||||||
|
);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(label, cond) {
|
||||||
|
if (cond) { console.log('ok - ' + label); } else { console.error('FAIL - ' + label); failures++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
check('bmp va convertito', tmfNeedsPng('image/bmp') === true);
|
||||||
|
check('tiff va convertito', tmfNeedsPng('image/tiff') === true);
|
||||||
|
check('png no', tmfNeedsPng('image/png') === false);
|
||||||
|
check('jpeg no', tmfNeedsPng('image/jpeg') === false);
|
||||||
|
check('gif no', tmfNeedsPng('image/gif') === false);
|
||||||
|
check('webp no', tmfNeedsPng('image/webp') === false);
|
||||||
|
check('pdf no', tmfNeedsPng('application/pdf') === false);
|
||||||
|
check('tipo vuoto no', tmfNeedsPng('') === false);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const fake = { name: 'a.png', type: 'image/png' };
|
||||||
|
check('png ritorna lo stesso oggetto', (await tmfImageToPng(fake)) === fake);
|
||||||
|
const pdf = { name: 'a.pdf', type: 'application/pdf' };
|
||||||
|
check('pdf ritorna lo stesso oggetto', (await tmfImageToPng(pdf)) === pdf);
|
||||||
|
if (failures) { console.error(failures + ' failure(s)'); process.exit(1); }
|
||||||
|
console.log('all ok');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* #122: static/js/image_viewer.js (<tmf-viewer>) con plain node: matematica pura (TmfViewerMath) e
|
||||||
|
* registrazione del Custom Element una sola volta. Il DOM vero si prova in Chrome (fixtures/image_viewer_demo.html).
|
||||||
|
*
|
||||||
|
* Il modulo si carica senza window/document/customElements.
|
||||||
|
* Run from src/frontend/flask_app: node tests/js/test_image_viewer.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const MOD = path.join(__dirname, '..', '..', 'static', 'js', 'image_viewer.js');
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
let total = 0;
|
||||||
|
function check(label, cond, extra) {
|
||||||
|
total++;
|
||||||
|
if (cond) console.log('ok - ' + label);
|
||||||
|
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||||
|
}
|
||||||
|
const near = (a, b) => Math.abs(a - b) < 1e-9;
|
||||||
|
|
||||||
|
// --- caricamento in node senza globali del browser ---
|
||||||
|
check('node senza window/document/customElements/HTMLElement',
|
||||||
|
typeof window === 'undefined' && typeof document === 'undefined' &&
|
||||||
|
typeof customElements === 'undefined' && typeof HTMLElement === 'undefined');
|
||||||
|
const { TmfViewerMath: M } = require(MOD);
|
||||||
|
check('esporta TmfViewerMath con le quattro funzioni',
|
||||||
|
M && ['fitZoom', 'clampZoom', 'zoomAround', 'pct'].every((k) => typeof M[k] === 'function'));
|
||||||
|
|
||||||
|
// --- fitZoom ---
|
||||||
|
check('fitZoom: box largo, immagine alta -> limita l\'altezza', near(M.fitZoom(800, 400, 1000, 1000), 0.4));
|
||||||
|
check('fitZoom: box alto, immagine larga -> limita la larghezza', near(M.fitZoom(400, 800, 1000, 500), 0.4));
|
||||||
|
check('fitZoom: immagine piu\' piccola del box -> zoom > 1', near(M.fitZoom(800, 600, 400, 300), 2));
|
||||||
|
check('fitZoom: box esatto -> 1', near(M.fitZoom(640, 480, 640, 480), 1));
|
||||||
|
check('fitZoom: boxH Infinity (fit-height) -> solo larghezza', near(M.fitZoom(500, Infinity, 1000, 4000), 0.5));
|
||||||
|
check('fitZoom: 0 se box nascosto o immagine senza dimensioni',
|
||||||
|
M.fitZoom(0, 400, 10, 10) === 0 && M.fitZoom(400, 0, 10, 10) === 0 &&
|
||||||
|
M.fitZoom(400, 400, 0, 10) === 0 && M.fitZoom(400, 400, 10, 0) === 0);
|
||||||
|
check('fitZoom: 0 con NaN, negativi, undefined',
|
||||||
|
M.fitZoom(NaN, 400, 10, 10) === 0 && M.fitZoom(-5, 400, 10, 10) === 0 && M.fitZoom(400, 400, undefined, 10) === 0 &&
|
||||||
|
M.fitZoom(400, 400, 10, null) === 0);
|
||||||
|
|
||||||
|
// --- clampZoom ---
|
||||||
|
check('clampZoom: dentro i limiti resta uguale', near(M.clampZoom(1, 0.5), 1));
|
||||||
|
check('clampZoom: sotto fit*0.5 -> fit*0.5', near(M.clampZoom(0.01, 0.5), 0.25));
|
||||||
|
check('clampZoom: sopra 8 -> 8', near(M.clampZoom(20, 0.5), 8));
|
||||||
|
check('clampZoom: fit sopra 1 alza il massimo a fit*8 (DXF in mm, fit ~10)', near(M.clampZoom(20, 12), 20) && near(M.clampZoom(200, 12), 96) && near(M.clampZoom(11, 12), 11));
|
||||||
|
check('clampZoom: con fit piccolo il massimo resta 8', near(M.clampZoom(20, 0.5), 8) && near(M.clampZoom(20, 1), 8) && near(M.clampZoom(100, 2), 16));
|
||||||
|
check('clampZoom: limiti esatti', near(M.clampZoom(0.25, 0.5), 0.25) && near(M.clampZoom(8, 0.5), 8));
|
||||||
|
|
||||||
|
// --- zoomAround ---
|
||||||
|
{
|
||||||
|
// il punto del contenuto sotto (px,py) prima e dopo lo zoom deve essere lo stesso
|
||||||
|
const z0 = 0.5, z1 = 1.25, sl = 120, st = 40, px = 300, py = 200;
|
||||||
|
const r = M.zoomAround(z0, z1, sl, st, px, py);
|
||||||
|
const before = { x: (sl + px) / z0, y: (st + py) / z0 }; // coordinate logiche sotto il puntatore
|
||||||
|
const after = { x: (r.scrollLeft + px) / z1, y: (r.scrollTop + py) / z1 };
|
||||||
|
check('zoomAround: il punto sotto il puntatore resta fermo', near(before.x, after.x) && near(before.y, after.y),
|
||||||
|
JSON.stringify([before, after]));
|
||||||
|
const same = M.zoomAround(2, 2, 50, 60, 10, 10);
|
||||||
|
check('zoomAround: z1 == z0 non sposta lo scroll', near(same.scrollLeft, 50) && near(same.scrollTop, 60));
|
||||||
|
const c = M.zoomAround(1, 2, 0, 0, 100, 50);
|
||||||
|
check('zoomAround: da scroll 0 raddoppiando (100,50) -> scroll (100,50)', near(c.scrollLeft, 100) && near(c.scrollTop, 50));
|
||||||
|
const o = M.zoomAround(2, 1, 100, 100, 0, 0);
|
||||||
|
check('zoomAround: puntatore all\'origine, riduzione -> scroll dimezzato', near(o.scrollLeft, 50) && near(o.scrollTop, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pct ---
|
||||||
|
check('pct: 1 -> "100 %"', M.pct(1) === '100 %', M.pct(1));
|
||||||
|
check('pct: arrotonda 0.4567 -> "46 %"', M.pct(0.4567) === '46 %', M.pct(0.4567));
|
||||||
|
check('pct: 8 -> "800 %", 0 -> "0 %"', M.pct(8) === '800 %' && M.pct(0) === '0 %');
|
||||||
|
|
||||||
|
// --- define una sola volta, anche caricando due volte ---
|
||||||
|
{
|
||||||
|
const defined = {};
|
||||||
|
let defineCalls = 0;
|
||||||
|
globalThis.HTMLElement = class {};
|
||||||
|
globalThis.customElements = {
|
||||||
|
get: (n) => defined[n],
|
||||||
|
define: (n, c) => { defineCalls++; defined[n] = c; },
|
||||||
|
};
|
||||||
|
delete require.cache[require.resolve(MOD)];
|
||||||
|
require(MOD);
|
||||||
|
delete require.cache[require.resolve(MOD)];
|
||||||
|
require(MOD);
|
||||||
|
check('customElements.define("tmf-viewer") una sola volta con due caricamenti',
|
||||||
|
defineCalls === 1 && typeof defined['tmf-viewer'] === 'function', 'chiamate: ' + defineCalls);
|
||||||
|
const C = defined['tmf-viewer'];
|
||||||
|
check('osserva src, width, height, fit-height, no-save, save-name',
|
||||||
|
['src', 'width', 'height', 'fit-height', 'no-save', 'save-name'].every((a) => C.observedAttributes.includes(a)));
|
||||||
|
check('espone fit, zoomBy, overlay, labels, zoom sul prototipo',
|
||||||
|
typeof C.prototype.fit === 'function' && typeof C.prototype.zoomBy === 'function' &&
|
||||||
|
['overlay', 'labels', 'zoom'].every((k) => Object.getOwnPropertyDescriptor(C.prototype, k)));
|
||||||
|
delete globalThis.customElements;
|
||||||
|
delete globalThis.HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* #119: helper static/js/save_image.js (salva il frame grezzo come PNG) con plain node.
|
||||||
|
*
|
||||||
|
* Fetch, window e document sono finti: il modulo li tocca solo dentro le funzioni.
|
||||||
|
* Run from src/frontend/flask_app: node tests/js/test_save_image.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const mod = require(path.join(__dirname, '..', '..', 'static', 'js', 'save_image.js'));
|
||||||
|
const { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob } = mod; // #122: + tmfSaveBlob, tmfSvgToPngBlob
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
let total = 0;
|
||||||
|
function check(label, cond, extra) {
|
||||||
|
total++;
|
||||||
|
if (cond) console.log('ok - ' + label);
|
||||||
|
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- nome proposto (funzione pura) ---
|
||||||
|
const d = new Date(2026, 9, 28, 9, 5, 7); // 28/10/2026 09:05:07 ora locale
|
||||||
|
check('nome: baseName + AAAAMMGG-hhmmss + .png', tmfSaveImageName('task_14', d) === 'task_14_20261028-090507.png',
|
||||||
|
tmfSaveImageName('task_14', d));
|
||||||
|
check('nome: caratteri non validi -> _', tmfSaveImageName('a/b\\c:d*e?f"g<h>i|j', d) === 'a_b_c_d_e_f_g_h_i_j_20261028-090507.png',
|
||||||
|
tmfSaveImageName('a/b\\c:d*e?f"g<h>i|j', d));
|
||||||
|
check('nome: spazi -> _, punti ai bordi tolti', tmfSaveImageName(' ..Ricetta test 1.. ', d) === 'Ricetta_test_1_20261028-090507.png',
|
||||||
|
tmfSaveImageName(' ..Ricetta test 1.. ', d));
|
||||||
|
check('nome: vuoto/null/undefined -> immagine',
|
||||||
|
tmfSaveImageName('', d).startsWith('immagine_') && tmfSaveImageName(null, d).startsWith('immagine_') &&
|
||||||
|
tmfSaveImageName(undefined, d).startsWith('immagine_'));
|
||||||
|
check('nome: solo caratteri vietati -> immagine', tmfSaveImageName('///', d) === 'immagine_20261028-090507.png',
|
||||||
|
tmfSaveImageName('///', d));
|
||||||
|
check('nome: senza data usa ora corrente e ha il formato giusto',
|
||||||
|
/^x_\d{8}-\d{6}\.png$/.test(tmfSaveImageName('x')), tmfSaveImageName('x'));
|
||||||
|
check('nome: data non valida -> ora corrente', /^x_\d{8}-\d{6}\.png$/.test(tmfSaveImageName('x', new Date('boh'))));
|
||||||
|
check('nome: non esiste doppia estensione', !/\.png\.png$/.test(tmfSaveImageName('foto.png', d)));
|
||||||
|
|
||||||
|
// --- finti del browser ---
|
||||||
|
const globals = ['fetch', 'window', 'document', 'URL', 'createImageBitmap', 'setTimeout', 'Image', 'XMLSerializer']; // #122: + Image, XMLSerializer
|
||||||
|
const saved = {};
|
||||||
|
globals.forEach((k) => { saved[k] = Object.getOwnPropertyDescriptor(globalThis, k); });
|
||||||
|
function restore() {
|
||||||
|
globals.forEach((k) => {
|
||||||
|
if (saved[k]) Object.defineProperty(globalThis, k, saved[k]); else delete globalThis[k];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setGlobal(k, v) { Object.defineProperty(globalThis, k, { value: v, configurable: true, writable: true }); }
|
||||||
|
|
||||||
|
function fakeEnv({ blobType = 'image/png', ok = true, picker, pickerErr, fetchErr } = {}) {
|
||||||
|
const log = { fetched: [], written: [], closed: 0, clicks: [], appended: 0, removed: 0, revoked: [], pickerOpts: null, canvases: [] };
|
||||||
|
const blob = { type: blobType, size: 3, tag: 'blob-originale' };
|
||||||
|
setGlobal('fetch', async (src) => {
|
||||||
|
log.fetched.push(src);
|
||||||
|
if (fetchErr) throw fetchErr;
|
||||||
|
return { ok, blob: async () => blob };
|
||||||
|
});
|
||||||
|
const win = {};
|
||||||
|
if (picker) {
|
||||||
|
win.showSaveFilePicker = async (opts) => {
|
||||||
|
log.pickerOpts = opts;
|
||||||
|
if (pickerErr) throw pickerErr;
|
||||||
|
return { createWritable: async () => ({ write: async (b) => { log.written.push(b); }, close: async () => { log.closed++; } }) };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setGlobal('window', win);
|
||||||
|
setGlobal('document', {
|
||||||
|
body: { appendChild() { log.appended++; }, removeChild() { log.removed++; } },
|
||||||
|
createElement(tag) {
|
||||||
|
if (tag === 'canvas') {
|
||||||
|
const c = {
|
||||||
|
width: 0, height: 0,
|
||||||
|
getContext() { return { drawImage() { c.drawn = true; } }; },
|
||||||
|
toBlob(cb, type) { cb({ type, size: 9, tag: 'blob-png' }); },
|
||||||
|
};
|
||||||
|
log.canvases.push(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
const a = { click() { log.clicks.push({ href: a.href, download: a.download }); } };
|
||||||
|
return a;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setGlobal('URL', { createObjectURL: (b) => 'blob:fake/' + b.tag, revokeObjectURL: (u) => { log.revoked.push(u); } });
|
||||||
|
setGlobal('createImageBitmap', async () => ({ width: 20, height: 10, close() {} }));
|
||||||
|
setGlobal('setTimeout', (fn) => { fn(); return 0; }); // il revoke differito scatta subito
|
||||||
|
return { log, blob };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const origErr = console.error;
|
||||||
|
try {
|
||||||
|
// --- ramo showSaveFilePicker, blob gia' PNG ---
|
||||||
|
{
|
||||||
|
const { log, blob } = fakeEnv({ picker: true });
|
||||||
|
const r = await tmfSaveImage('blob:http://x/abc', 'task 14');
|
||||||
|
check('picker: risolve a true', r === true);
|
||||||
|
check('picker: fetch dell\'src', log.fetched.length === 1 && log.fetched[0] === 'blob:http://x/abc');
|
||||||
|
check('picker: suggestedName del formato atteso',
|
||||||
|
log.pickerOpts && /^task_14_\d{8}-\d{6}\.png$/.test(log.pickerOpts.suggestedName), JSON.stringify(log.pickerOpts));
|
||||||
|
check('picker: tipo PNG', log.pickerOpts && log.pickerOpts.types[0].accept['image/png'][0] === '.png');
|
||||||
|
check('picker: scritto il blob originale (gia\' PNG, nessuna conversione) e chiuso',
|
||||||
|
log.written.length === 1 && log.written[0] === blob && log.closed === 1 && log.canvases.length === 0);
|
||||||
|
check('picker: nessun <a download>', log.clicks.length === 0);
|
||||||
|
}
|
||||||
|
// --- picker, blob JPEG -> convertito su canvas ---
|
||||||
|
{
|
||||||
|
const { log } = fakeEnv({ picker: true, blobType: 'image/jpeg' });
|
||||||
|
const r = await tmfSaveImage('http://x/frame.jpg', 'f');
|
||||||
|
check('picker + JPEG: risolve a true', r === true);
|
||||||
|
check('picker + JPEG: ridisegnato su canvas 20x10 e scritto il PNG',
|
||||||
|
log.canvases.length === 1 && log.canvases[0].width === 20 && log.canvases[0].height === 10 && log.canvases[0].drawn &&
|
||||||
|
log.written.length === 1 && log.written[0].tag === 'blob-png' && log.written[0].type === 'image/png');
|
||||||
|
}
|
||||||
|
// --- ramo <a download> (nessun showSaveFilePicker) ---
|
||||||
|
{
|
||||||
|
const { log, blob } = fakeEnv({ picker: false });
|
||||||
|
const r = await tmfSaveImage('blob:http://x/abc', 'task_14');
|
||||||
|
check('a download: risolve a true', r === true);
|
||||||
|
check('a download: un click con href del blob e nome atteso',
|
||||||
|
log.clicks.length === 1 && log.clicks[0].href === 'blob:fake/' + blob.tag &&
|
||||||
|
/^task_14_\d{8}-\d{6}\.png$/.test(log.clicks[0].download), JSON.stringify(log.clicks));
|
||||||
|
check('a download: elemento aggiunto, rimosso e URL liberato',
|
||||||
|
log.appended === 1 && log.removed === 1 && log.revoked.length === 1);
|
||||||
|
}
|
||||||
|
// --- AbortError: annullo dell'utente, nessun errore ---
|
||||||
|
{
|
||||||
|
const abort = Object.assign(new Error('The user aborted a request.'), { name: 'AbortError' });
|
||||||
|
const errs = [];
|
||||||
|
console.error = (...a) => errs.push(a);
|
||||||
|
const { log } = fakeEnv({ picker: true, pickerErr: abort });
|
||||||
|
let thrown = null, r = null;
|
||||||
|
try { r = await tmfSaveImage('blob:x', 'a'); } catch (e) { thrown = e; }
|
||||||
|
check('AbortError: nessuna eccezione, risolve a false', thrown === null && r === false, String(thrown));
|
||||||
|
check('AbortError: nessun errore in console, niente scritto, niente <a download>',
|
||||||
|
errs.length === 0 && log.written.length === 0 && log.clicks.length === 0);
|
||||||
|
}
|
||||||
|
// --- errori veri: fetch fallisce, risposta non ok, picker con errore diverso ---
|
||||||
|
{
|
||||||
|
console.error = () => {};
|
||||||
|
let thrown = null, r1 = null, r2 = null, r3 = null;
|
||||||
|
try {
|
||||||
|
fakeEnv({ picker: true, fetchErr: new TypeError('rete') });
|
||||||
|
r1 = await tmfSaveImage('blob:x', 'a');
|
||||||
|
fakeEnv({ picker: true, ok: false });
|
||||||
|
r2 = await tmfSaveImage('blob:x', 'a');
|
||||||
|
fakeEnv({ picker: true, pickerErr: Object.assign(new Error('disco pieno'), { name: 'NotAllowedError' }) });
|
||||||
|
r3 = await tmfSaveImage('blob:x', 'a');
|
||||||
|
} catch (e) { thrown = e; }
|
||||||
|
check('errori: fetch rotto, risposta non ok, picker che fallisce -> false senza eccezioni',
|
||||||
|
thrown === null && r1 === false && r2 === false && r3 === false, String(thrown) + [r1, r2, r3]);
|
||||||
|
}
|
||||||
|
// --- #122: tmfSaveBlob con picker ---
|
||||||
|
{
|
||||||
|
const { log } = fakeEnv({ picker: true });
|
||||||
|
const png = { type: 'image/png', size: 5, tag: 'blob-dxf' };
|
||||||
|
const r = await tmfSaveBlob(png, 'dxf ricetta');
|
||||||
|
check('saveBlob picker: true, scrive il blob dato senza fetch ne conversioni',
|
||||||
|
r === true && log.written.length === 1 && log.written[0] === png && log.closed === 1 &&
|
||||||
|
log.fetched.length === 0 && log.canvases.length === 0);
|
||||||
|
check('saveBlob picker: nome e tipo PNG proposti',
|
||||||
|
/^dxf_ricetta_\d{8}-\d{6}\.png$/.test(log.pickerOpts.suggestedName) && log.pickerOpts.types[0].accept['image/png'][0] === '.png');
|
||||||
|
}
|
||||||
|
// --- #122: tmfSaveBlob senza picker -> <a download> ---
|
||||||
|
{
|
||||||
|
const { log } = fakeEnv({ picker: false });
|
||||||
|
const png = { type: 'image/png', size: 5, tag: 'blob-dxf' };
|
||||||
|
const r = await tmfSaveBlob(png, 'dxf');
|
||||||
|
check('saveBlob a download: true, un click con href del blob e nome atteso, URL liberato',
|
||||||
|
r === true && log.clicks.length === 1 && log.clicks[0].href === 'blob:fake/blob-dxf' &&
|
||||||
|
/^dxf_\d{8}-\d{6}\.png$/.test(log.clicks[0].download) && log.revoked.length === 1);
|
||||||
|
}
|
||||||
|
// --- #122: tmfSaveBlob: blob mancante, annullo, errore ---
|
||||||
|
{
|
||||||
|
const errs = [];
|
||||||
|
console.error = (...a) => errs.push(a);
|
||||||
|
fakeEnv({ picker: true });
|
||||||
|
const rNull = await tmfSaveBlob(null, 'x');
|
||||||
|
const abort = Object.assign(new Error('annullato'), { name: 'AbortError' });
|
||||||
|
fakeEnv({ picker: true, pickerErr: abort });
|
||||||
|
const rAbort = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x');
|
||||||
|
const nAbort = errs.length;
|
||||||
|
fakeEnv({ picker: true, pickerErr: Object.assign(new Error('disco pieno'), { name: 'NotAllowedError' }) });
|
||||||
|
const rErr = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x');
|
||||||
|
check('saveBlob: null -> false; AbortError -> false senza log; altro errore -> false con log',
|
||||||
|
rNull === false && rAbort === false && nAbort === 0 && rErr === false && errs.length === 1);
|
||||||
|
console.error = () => {};
|
||||||
|
}
|
||||||
|
// --- #122: tmfSvgToPngBlob ---
|
||||||
|
{
|
||||||
|
const { log } = fakeEnv({ picker: false });
|
||||||
|
const svgs = [];
|
||||||
|
setGlobal('XMLSerializer', class { serializeToString(el) { svgs.push(el); return '<svg/>'; } });
|
||||||
|
setGlobal('Image', class { set src(v) { this._src = v; setImmediate(() => this.onload()); } });
|
||||||
|
const fills = [];
|
||||||
|
const cv = document.createElement;
|
||||||
|
document.createElement = (tag) => {
|
||||||
|
const e = cv(tag);
|
||||||
|
if (tag === 'canvas') e.getContext = () => ({ set fillStyle(v) { fills.push(v); }, fillRect() { fills.push('rect'); }, drawImage() { e.drawn = true; } });
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
const made = [];
|
||||||
|
const svgEl = {
|
||||||
|
attrs: { viewBox: '0 0 300 200' }, style: { width: '100%', height: '100%' },
|
||||||
|
getAttribute(k) { return this.attrs[k] === undefined ? null : this.attrs[k]; },
|
||||||
|
setAttribute(k, v) { this.attrs[k] = String(v); },
|
||||||
|
cloneNode() { const c = Object.assign({}, this, { attrs: Object.assign({}, this.attrs), style: Object.assign({}, this.style) }); made.push(c); return c; },
|
||||||
|
};
|
||||||
|
const png = await tmfSvgToPngBlob(svgEl, 300, 200);
|
||||||
|
const c = log.canvases[0];
|
||||||
|
check('svgToPng: ritorna il PNG del canvas, sfondo bianco, lato lungo portato a 1600 (300x200 -> 1600x1067)',
|
||||||
|
png && png.tag === 'blob-png' && c.width === 1600 && c.height === 1067 && fills[0] === '#ffffff' && fills[1] === 'rect' && c.drawn,
|
||||||
|
JSON.stringify([c.width, c.height, fills]));
|
||||||
|
check('svgToPng: clone con xmlns, width/height in pixel del PNG e stile 100% tolto; originale intatto',
|
||||||
|
made.length === 1 && made[0].attrs.xmlns === 'http://www.w3.org/2000/svg' && made[0].attrs.width === '1600' &&
|
||||||
|
made[0].attrs.height === '1067' && made[0].style.width === '' && svgEl.attrs.width === undefined && svgs[0] === made[0]);
|
||||||
|
check('svgToPng: URL del blob SVG liberato', log.revoked.length === 1);
|
||||||
|
// lato lungo oltre 4096 -> ridotto
|
||||||
|
const big = await tmfSvgToPngBlob(svgEl, 10000, 5000);
|
||||||
|
check('svgToPng: 10000x5000 -> 4096x2048', log.canvases[1].width === 4096 && log.canvases[1].height === 2048 && big);
|
||||||
|
// senza w/h usa il viewBox; senza dimensioni lancia
|
||||||
|
await tmfSvgToPngBlob(svgEl);
|
||||||
|
check('svgToPng: senza w/h usa il viewBox', log.canvases[2].width === 1600 && log.canvases[2].height === 1067);
|
||||||
|
let thrown = null;
|
||||||
|
try { await tmfSvgToPngBlob({ getAttribute: () => null }); } catch (e) { thrown = e; }
|
||||||
|
check('svgToPng: senza dimensioni lancia', thrown !== null);
|
||||||
|
document.createElement = cv;
|
||||||
|
}
|
||||||
|
// --- esposizione ---
|
||||||
|
{
|
||||||
|
fakeEnv({ picker: false });
|
||||||
|
check('esposto come funzione e export', typeof tmfSaveImage === 'function' && typeof tmfSaveImageName === 'function' &&
|
||||||
|
typeof tmfSaveBlob === 'function' && typeof tmfSvgToPngBlob === 'function');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.error = origErr;
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* #116: overlay del check visione (static/js/vision_overlay.js) con plain node.
|
||||||
|
*
|
||||||
|
* Run from src/frontend/flask_app: node tests/js/test_vision_overlay.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const VisionOverlay = require(path.join(__dirname, '..', '..', 'static', 'js', 'vision_overlay.js'));
|
||||||
|
const fixture = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(__dirname, 'fixtures', 'vision_check_task14.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
let total = 0;
|
||||||
|
function check(label, cond, extra) {
|
||||||
|
total++;
|
||||||
|
if (cond) console.log('ok - ' + label);
|
||||||
|
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||||
|
}
|
||||||
|
const near = (a, b) => Math.abs(a - b) < 1e-9;
|
||||||
|
|
||||||
|
// Canvas finto: registra le chiamate e le proprieta' al momento dello stroke
|
||||||
|
function fakeCanvas() {
|
||||||
|
const calls = [];
|
||||||
|
const g = {
|
||||||
|
calls,
|
||||||
|
strokes: [],
|
||||||
|
dash: [],
|
||||||
|
strokeStyle: '',
|
||||||
|
lineWidth: 0,
|
||||||
|
beginPath() { calls.push(['beginPath']); },
|
||||||
|
moveTo(x, y) { calls.push(['moveTo', x, y]); },
|
||||||
|
lineTo(x, y) { calls.push(['lineTo', x, y]); },
|
||||||
|
arc(...a) { calls.push(['arc', ...a]); },
|
||||||
|
closePath() { calls.push(['closePath']); },
|
||||||
|
stroke() {
|
||||||
|
calls.push(['stroke']);
|
||||||
|
g.strokes.push({ style: g.strokeStyle, width: g.lineWidth, dash: g.dash.slice() });
|
||||||
|
},
|
||||||
|
setLineDash(d) { g.dash = d.slice(); calls.push(['setLineDash', d.slice()]); },
|
||||||
|
};
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
const onlyCalls = (g, name) => g.calls.filter((c) => c[0] === name);
|
||||||
|
|
||||||
|
// --- fixture vero: task 14 TEST1 ---
|
||||||
|
const overlay = fixture.overlay;
|
||||||
|
check('fixture: overlay con 25 elementi', overlay.length === 25, String(overlay.length));
|
||||||
|
{
|
||||||
|
const g = fakeCanvas();
|
||||||
|
const res = VisionOverlay.draw(g, overlay, 2);
|
||||||
|
check('draw sul fixture: drawn 25, skipped 0', res.drawn === 25 && res.skipped === 0, JSON.stringify(res));
|
||||||
|
const lastDash = onlyCalls(g, 'setLineDash').pop();
|
||||||
|
check('draw: ultimo setLineDash e\' []', lastDash && lastDash[1].length === 0);
|
||||||
|
check('draw: uno stroke per elemento', g.strokes.length === 25, String(g.strokes.length));
|
||||||
|
const roiIdx = overlay.findIndex((o) => o.role === 'roi');
|
||||||
|
const foundIdx = overlay.findIndex((o) => o.role === 'found');
|
||||||
|
check('roi: arancione, tratteggiata, spessore lw*0.75',
|
||||||
|
g.strokes[roiIdx].style === '#f59e0b' && g.strokes[roiIdx].width === 1.5 &&
|
||||||
|
JSON.stringify(g.strokes[roiIdx].dash) === '[6,4]', JSON.stringify(g.strokes[roiIdx]));
|
||||||
|
check('found: verde, piena, spessore lw',
|
||||||
|
g.strokes[foundIdx].style === '#16a34a' && g.strokes[foundIdx].width === 2 &&
|
||||||
|
g.strokes[foundIdx].dash.length === 0, JSON.stringify(g.strokes[foundIdx]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- colori: reference e ruolo ignoto ---
|
||||||
|
{
|
||||||
|
const g = fakeCanvas();
|
||||||
|
VisionOverlay.draw(g, [
|
||||||
|
{ type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 },
|
||||||
|
{ type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 },
|
||||||
|
], 1);
|
||||||
|
check('reference blu', g.strokes[0].style === '#2563eb');
|
||||||
|
check('ruolo ignoto rosso', g.strokes[1].style === '#ef4444');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rect2 ---
|
||||||
|
function xs(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[1]); }
|
||||||
|
function ys(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[2]); }
|
||||||
|
{
|
||||||
|
const g = fakeCanvas();
|
||||||
|
const r = VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }], 1);
|
||||||
|
const X = xs(g), Y = ys(g);
|
||||||
|
check('rect2 phi 0: 4 vertici chiusi', r.drawn === 1 && X.length === 4 && onlyCalls(g, 'closePath').length === 1);
|
||||||
|
check('rect2 phi 0: col 90/110', X.every((x) => near(x, 90) || near(x, 110)) && X.filter((x) => near(x, 90)).length === 2, String(X));
|
||||||
|
check('rect2 phi 0: row 46/54', Y.every((y) => near(y, 46) || near(y, 54)) && Y.filter((y) => near(y, 46)).length === 2, String(Y));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const g = fakeCanvas();
|
||||||
|
VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 90, length1: 10, length2: 4 }], 1);
|
||||||
|
const X = xs(g), Y = ys(g);
|
||||||
|
check('rect2 phi 90: lati scambiati, col 96/104', X.every((x) => near(x, 96) || near(x, 104)), String(X));
|
||||||
|
check('rect2 phi 90: lati scambiati, row 40/60', Y.every((y) => near(y, 40) || near(y, 60)), String(Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- arc: argomenti come da contratto ---
|
||||||
|
{
|
||||||
|
const g = fakeCanvas();
|
||||||
|
const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 };
|
||||||
|
VisionOverlay.draw(g, [a], 1);
|
||||||
|
const c = onlyCalls(g, 'arc')[0];
|
||||||
|
check('arc: g.arc(col, row, r, -rad(start), -rad(start+extent), extent>0)',
|
||||||
|
c && near(c[1], 40) && near(c[2], 30) && near(c[3], 12) &&
|
||||||
|
near(c[4], -Math.PI / 2) && near(c[5], -Math.PI) && c[6] === true, JSON.stringify(c));
|
||||||
|
const g2 = fakeCanvas();
|
||||||
|
VisionOverlay.draw(g2, [{ ...a, start_deg: 0, extent_deg: -45 }], 1);
|
||||||
|
const c2 = onlyCalls(g2, 'arc')[0];
|
||||||
|
check('arc: extent negativo = orario (anticlockwise false)',
|
||||||
|
c2 && near(c2[4], 0) && near(c2[5], Math.PI / 4) && c2[6] === false, JSON.stringify(c2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- circle, points, contour ---
|
||||||
|
{
|
||||||
|
const g = fakeCanvas();
|
||||||
|
VisionOverlay.draw(g, [{ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 }], 1);
|
||||||
|
const c = onlyCalls(g, 'arc')[0];
|
||||||
|
check('circle: cerchio pieno', c && c[1] === 7 && c[2] === 5 && c[3] === 3 && c[4] === 0 && near(c[5], 2 * Math.PI));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const g = fakeCanvas();
|
||||||
|
VisionOverlay.draw(g, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 1);
|
||||||
|
const m = onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo'));
|
||||||
|
// lw 1 -> semi-lato max(3, 2) = 3
|
||||||
|
check('points: croce di semi-lato 3', m.length === 4 && m.some((c) => c[1] === 17 && c[2] === 10) &&
|
||||||
|
m.some((c) => c[1] === 23 && c[2] === 10) && m.some((c) => c[1] === 20 && c[2] === 7) &&
|
||||||
|
m.some((c) => c[1] === 20 && c[2] === 13), JSON.stringify(m));
|
||||||
|
const g2 = fakeCanvas();
|
||||||
|
VisionOverlay.draw(g2, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 5);
|
||||||
|
check('points: semi-lato lw*2 per lw grande', onlyCalls(g2, 'moveTo').some((c) => c[1] === 10 && c[2] === 10));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const g = fakeCanvas();
|
||||||
|
VisionOverlay.draw(g, [{ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] }], 1);
|
||||||
|
check('contour: polilinea aperta di 3 punti',
|
||||||
|
onlyCalls(g, 'moveTo').length === 1 && onlyCalls(g, 'lineTo').length === 2 && onlyCalls(g, 'closePath').length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- elementi malformati: saltati, nessuna eccezione ---
|
||||||
|
{
|
||||||
|
const g = fakeCanvas();
|
||||||
|
let thrown = null;
|
||||||
|
let res = null;
|
||||||
|
try {
|
||||||
|
res = VisionOverlay.draw(g, [
|
||||||
|
{ type: 'circle', role: 'found', row: 1, col: 1 }, // manca radius
|
||||||
|
{ type: 'points', role: 'found', rows: [1, 2], cols: [1] }, // lunghezze diverse
|
||||||
|
{ type: 'contour', role: 'found', rows: [], cols: [] }, // vuoto
|
||||||
|
{ type: 'boh', role: 'found' }, // tipo ignoto
|
||||||
|
{ type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 }, // non finito
|
||||||
|
null, // elemento nullo
|
||||||
|
{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, // valido
|
||||||
|
], 1);
|
||||||
|
} catch (e) { thrown = e; }
|
||||||
|
check('malformati: nessuna eccezione', thrown === null, String(thrown));
|
||||||
|
check('malformati: 6 saltati, 1 disegnato', res && res.drawn === 1 && res.skipped === 6, JSON.stringify(res));
|
||||||
|
check('malformati: draw con overlay non array', (() => {
|
||||||
|
try { const r = VisionOverlay.draw(fakeCanvas(), null, 1); return r.drawn === 0 && r.skipped === 0; } catch (e) { return false; }
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- anchor, valori calcolati a mano ---
|
||||||
|
{
|
||||||
|
const A = VisionOverlay.anchor;
|
||||||
|
const seg = A({ type: 'segment', row1: 10, col1: 0, row2: 20, col2: 40 });
|
||||||
|
check('anchor segment: punto medio', seg && seg.x === 20 && seg.y === 15);
|
||||||
|
const lin = A({ type: 'line', row1: 0, col1: 2, row2: 0, col2: 4 });
|
||||||
|
check('anchor line: punto medio', lin && lin.x === 3 && lin.y === 0);
|
||||||
|
const cir = A({ type: 'circle', row: 50, col: 100, radius: 8 });
|
||||||
|
check('anchor circle: centro + raggio a destra', cir && cir.x === 108 && cir.y === 50);
|
||||||
|
const arc = A({ type: 'arc', row: 50, col: 100, radius: 10, start_deg: 0, extent_deg: 180 });
|
||||||
|
check('anchor arc: punto medio (90 gradi = in alto a video)', arc && near(arc.x, 100) && near(arc.y, 40), JSON.stringify(arc));
|
||||||
|
const pts = A({ type: 'points', rows: [7, 8], cols: [3, 4] });
|
||||||
|
check('anchor points: primo punto', pts && pts.x === 3 && pts.y === 7);
|
||||||
|
const r2 = A({ type: 'rect2', row: 9, col: 11, phi_deg: 30, length1: 5, length2: 2 });
|
||||||
|
check('anchor rect2: centro', r2 && r2.x === 11 && r2.y === 9);
|
||||||
|
const con = A({ type: 'contour', rows: [0, 1, 2, 3], cols: [10, 11, 12, 13] });
|
||||||
|
check('anchor contour: punto a meta\' (indice 2)', con && con.x === 12 && con.y === 2);
|
||||||
|
check('anchor: tipo ignoto -> null', A({ type: 'boh' }) === null);
|
||||||
|
check('anchor: dati mancanti -> null', A({ type: 'circle', row: 1, col: 1 }) === null && A(null) === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- labels sul fixture, righe costruite come fara' vision_execute.js ---
|
||||||
|
const subById = {};
|
||||||
|
fixture._subtasks.forEach((s) => { subById[s.id] = s; });
|
||||||
|
const rows = fixture.measurements.map((m) => {
|
||||||
|
const s = subById[m.subtask_id];
|
||||||
|
return {
|
||||||
|
key: m.subtask_id, name: s.description, value: m.value, unit: s.unit,
|
||||||
|
status: m.pass_fail, vision_output: s.vision_output,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
{
|
||||||
|
const L = VisionOverlay.labels(rows, overlay);
|
||||||
|
check('labels fixture: 6 etichette', L.length === 6, String(L.length));
|
||||||
|
const names = L.map((l) => l.text.split(' ')[0]).join(',');
|
||||||
|
check('labels fixture: larghezza, lunghezza e 4 raggi',
|
||||||
|
L[0].text.startsWith('Larghezza') && L[1].text.startsWith('Lunghezza') &&
|
||||||
|
L.slice(2).every((l) => l.text.startsWith('R ')), names);
|
||||||
|
check('labels fixture: nessuna per le differenze', L.every((l) => !/^Diff/.test(l.text)));
|
||||||
|
const l15 = L.find((l) => l.key === 15);
|
||||||
|
check('quota 15: testo con Larghezza e 1.668', l15 && l15.text.includes('Larghezza') && l15.text.includes('1.668'), l15 && l15.text);
|
||||||
|
check('quota 15: status riportato', l15 && l15.status === fixture.measurements[0].pass_fail);
|
||||||
|
const seg = overlay.find((o) => o.tool_id === 'larghezza' && o.role === 'found');
|
||||||
|
check('quota 15: ancorata al punto medio del segmento', l15 && near(l15.x, (seg.col1 + seg.col2) / 2) && near(l15.y, (seg.row1 + seg.row2) / 2));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const nuda = rows.map((r) => ({ ...r, vision_output: undefined }));
|
||||||
|
check('labels: righe senza vision_output -> nessuna etichetta', VisionOverlay.labels(nuda, overlay).length === 0);
|
||||||
|
const sola = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass' }], overlay);
|
||||||
|
check('labels: riga senza vision_output (assente) -> nessuna', sola.length === 0);
|
||||||
|
const noTool = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass', vision_output: 'nonesiste.value' }], overlay);
|
||||||
|
check('labels: tool senza overlay -> nessuna', noTool.length === 0);
|
||||||
|
// found preferito: la roi (per prima) ha un'altra ancora, ma vince il found
|
||||||
|
const ov = [
|
||||||
|
{ type: 'rect2', role: 'roi', tool_id: 't', row: 1, col: 1, phi_deg: 0, length1: 1, length2: 1 },
|
||||||
|
{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 },
|
||||||
|
];
|
||||||
|
const f = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'warning', vision_output: 't.value' }], ov);
|
||||||
|
check('labels: preferisce l\'ancora del found', f.length === 1 && f[0].x === 15 && f[0].y === 10 && f[0].status === 'warning', JSON.stringify(f));
|
||||||
|
const solo = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'fail', vision_output: 't.value' }], [ov[0]]);
|
||||||
|
check('labels: senza found ripiega sul primo elemento', solo.length === 1 && solo[0].x === 1 && solo[0].y === 1);
|
||||||
|
check('labels: testo name + valore a 3 decimali + unit', f[0].text === 'T 1.500 mm', f[0].text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- #118: labels, valore null/undefined/non finito -> etichetta NaN con status della riga ---
|
||||||
|
{
|
||||||
|
const ov = [{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 }];
|
||||||
|
const mk = (value) => ({ key: 1, name: 'T', value, unit: 'mm', status: 'fail', vision_output: 't.value' });
|
||||||
|
const nan = (v) => { const l = VisionOverlay.labels([mk(v)], ov); return l.length === 1 && l[0].text === 'T NaN mm' && l[0].status === 'fail'; };
|
||||||
|
check('labels: value null -> etichetta NaN, status fail (Number(null) e 0)', nan(null));
|
||||||
|
check('labels: value undefined / stringa vuota -> etichetta NaN', nan(undefined) && nan(''));
|
||||||
|
check('labels: value NaN/Infinity -> etichetta NaN', nan(NaN) && nan(Infinity) && nan(-Infinity));
|
||||||
|
check('labels: value 0 resta un valore valido', VisionOverlay.labels([mk(0)], ov)[0].text === 'T 0.000 mm');
|
||||||
|
const misto = VisionOverlay.labels([{ ...mk(null), key: 1 }, { ...mk(2.5), key: 2 }], ov);
|
||||||
|
check('labels: null e valida entrambe presenti', misto.length === 2 && misto[0].text === 'T NaN mm' && misto[1].key === 2);
|
||||||
|
check('labels: riga NaN senza ancoraggio -> nessuna etichetta', VisionOverlay.labels([mk(null)], []).length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- #121: svg(overlay, opts) ---
|
||||||
|
const count = (str, re) => (str.match(re) || []).length;
|
||||||
|
const SHAPE_RE = /<(polygon|line|circle|path|polyline) /g;
|
||||||
|
{
|
||||||
|
const out = VisionOverlay.svg(overlay);
|
||||||
|
// il fixture vero ha 25 forme: 7 segment, 1 contour, 1 points, 4 rect2 roi, 4 arc roi, 4 arc found, 4 circle
|
||||||
|
check('svg fixture: 25 forme', count(out, SHAPE_RE) === 25, String(count(out, SHAPE_RE)));
|
||||||
|
check('svg fixture: ogni forma con vector-effect non-scaling-stroke',
|
||||||
|
count(out, /vector-effect="non-scaling-stroke"/g) === 25);
|
||||||
|
check('svg fixture: ogni forma fill none', count(out, /fill="none"/g) === 25);
|
||||||
|
check('svg fixture: 8 roi (4 rect2 + 4 arc) con stroke-dasharray 6 4', count(out, /stroke-dasharray="6 4"/g) === 8,
|
||||||
|
String(count(out, /stroke-dasharray="6 4"/g)));
|
||||||
|
check('svg fixture: nessun <svg> esterno', !/<svg/.test(out));
|
||||||
|
check('svg fixture: nessun NaN/undefined/null nel markup', !/NaN|undefined|null|Infinity/.test(out));
|
||||||
|
check('svg fixture: numeri con al piu\' 2 decimali', !/\d\.\d{3,}/.test(out));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const one = (o, opts) => VisionOverlay.svg([o], opts);
|
||||||
|
const roi = one({ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 });
|
||||||
|
check('svg roi: polygon 4 vertici, colore roi, dash 6 4, spessore lw*0.75',
|
||||||
|
roi.startsWith('<polygon ') && roi.includes('points="110,46 110,54 90,54 90,46"') && roi.includes('stroke="#f59e0b"') &&
|
||||||
|
roi.includes('stroke-dasharray="6 4"') && roi.includes('stroke-width="1.5"'), roi);
|
||||||
|
const roi4 = one({ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }, { lw: 4 });
|
||||||
|
check('svg roi: lw 4 -> stroke-width 3', roi4.includes('stroke-width="3"'), roi4);
|
||||||
|
const fnd = one({ type: 'segment', role: 'found', row1: 1, col1: 2, row2: 3, col2: 4 });
|
||||||
|
check('svg found: line verde piena, spessore lw 2 predefinito',
|
||||||
|
fnd === '<line x1="2" y1="1" x2="4" y2="3" fill="none" stroke="#16a34a" stroke-width="2" vector-effect="non-scaling-stroke"/>', fnd);
|
||||||
|
check('svg reference blu, ruolo ignoto rosso',
|
||||||
|
one({ type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 }).includes('stroke="#2563eb"') &&
|
||||||
|
one({ type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 }).includes('stroke="#ef4444"'));
|
||||||
|
check('svg: spessore costante, non dipende dallo zoom',
|
||||||
|
one({ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, { zoom: 4 }).includes('stroke-width="2"'));
|
||||||
|
const cir = one({ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 });
|
||||||
|
check('svg circle: cx cy r', cir.startsWith('<circle cx="7" cy="5" r="3" '), cir);
|
||||||
|
const con = one({ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] });
|
||||||
|
check('svg contour: polyline aperta', con.startsWith('<polyline points="4,1 5,2 6,3" ') && !/ Z/.test(con), con);
|
||||||
|
|
||||||
|
// points: croce di semi-braccio cross/zoom
|
||||||
|
const p1 = one({ type: 'points', role: 'found', rows: [10], cols: [20] }, { zoom: 1 });
|
||||||
|
check('svg points zoom 1: semi-braccio 6', p1.includes('d="M 14 10 L 26 10 M 20 4 L 20 16"'), p1);
|
||||||
|
const p2 = one({ type: 'points', role: 'found', rows: [10], cols: [20] }, { zoom: 2 });
|
||||||
|
check('svg points zoom 2: semi-braccio 3', p2.includes('d="M 17 10 L 23 10 M 20 7 L 20 13"'), p2);
|
||||||
|
const p3 = one({ type: 'points', role: 'found', rows: [10, 30], cols: [20, 40] }, { zoom: 1, cross: 2 });
|
||||||
|
check('svg points: cross personalizzato, 2 croci in un solo path',
|
||||||
|
count(p3, /<path /g) === 1 && count(p3, /M /g) === 4 && p3.includes('M 18 10 L 22 10'), p3);
|
||||||
|
|
||||||
|
// arc: estremi calcolati (y verso il basso, angoli antiorari)
|
||||||
|
const arcD = (o) => /d="([^"]+)"/.exec(one({ type: 'arc', role: 'found', ...o }))[1];
|
||||||
|
const parse = (d) => d.match(/-?\d+(\.\d+)?/g).map(Number); // [x0, y0, rx, ry, rot, large, sweep, x1, y1]
|
||||||
|
{
|
||||||
|
// start 90, extent 90 su centro (40,30) r 12: da (40,18) a (28,30), antiorario (sweep 0), arco piccolo
|
||||||
|
const d = arcD({ row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 });
|
||||||
|
const v = parse(d);
|
||||||
|
check('svg arc 90+90: M 40 18 A 12 12 0 0 0 28 30',
|
||||||
|
near(v[0], 40) && near(v[1], 18) && v[2] === 12 && v[3] === 12 && v[4] === 0 && v[5] === 0 && v[6] === 0 &&
|
||||||
|
near(v[7], 28) && near(v[8], 30), d);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// start 0, extent -90 (orario): da (10,0) a (0,10) a video, sweep 1
|
||||||
|
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: -90 }));
|
||||||
|
check('svg arc extent negativo: sweep 1, fine in basso a video',
|
||||||
|
near(v[0], 10) && near(v[1], 0) && v[5] === 0 && v[6] === 1 && near(v[7], 0) && near(v[8], 10), JSON.stringify(v));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// 270 gradi antiorari da 0: fine a 270 = in basso a video (0, +10)
|
||||||
|
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: 270 }));
|
||||||
|
check('svg arc 270: large-arc 1, sweep 0, fine (0,10) a video',
|
||||||
|
v[5] === 1 && v[6] === 0 && near(v[7], 0) && near(v[8], 10), JSON.stringify(v));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const v = parse(arcD({ row: 0, col: 0, radius: 10, start_deg: 0, extent_deg: 360 }));
|
||||||
|
check('svg arc 360: estensione limitata a 359.99 (large-arc, fine quasi sullo start)',
|
||||||
|
v[5] === 1 && Math.abs(v[8]) < 0.01 && v[7] > 9.99, JSON.stringify(v));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// coincide con draw: punto finale del g.arc del canvas (angolo -rad(start+extent))
|
||||||
|
const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 30, extent_deg: 100 };
|
||||||
|
const g = fakeCanvas();
|
||||||
|
VisionOverlay.draw(g, [a], 1);
|
||||||
|
const c = onlyCalls(g, 'arc')[0];
|
||||||
|
const ex = c[1] + c[3] * Math.cos(c[5]), ey = c[2] + c[3] * Math.sin(c[5]);
|
||||||
|
const sx = c[1] + c[3] * Math.cos(c[4]), sy = c[2] + c[3] * Math.sin(c[4]);
|
||||||
|
const v = parse(arcD(a));
|
||||||
|
check('svg arc: estremi uguali a quelli di draw su canvas',
|
||||||
|
Math.abs(v[7] - ex) < 0.01 && Math.abs(v[8] - ey) < 0.01 && Math.abs(v[0] - sx) < 0.01 && Math.abs(v[1] - sy) < 0.01,
|
||||||
|
JSON.stringify([v, ex, ey]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// malformati e ignoti: saltati, nessuna eccezione, la forma valida resta
|
||||||
|
let thrown = null, out = '';
|
||||||
|
try {
|
||||||
|
out = VisionOverlay.svg([
|
||||||
|
{ type: 'circle', role: 'found', row: 1, col: 1 },
|
||||||
|
{ type: 'points', role: 'found', rows: [1, 2], cols: [1] },
|
||||||
|
{ type: 'contour', role: 'found', rows: [], cols: [] },
|
||||||
|
{ type: 'boh', role: 'found' },
|
||||||
|
{ type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 },
|
||||||
|
{ type: 'arc', role: 'found', row: 0, col: 0, radius: 1, start_deg: 0 },
|
||||||
|
{ type: 'rect2', role: 'roi', row: 0, col: 0, phi_deg: 0, length1: Infinity, length2: 1 },
|
||||||
|
null,
|
||||||
|
{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 },
|
||||||
|
], { zoom: 1 });
|
||||||
|
} catch (e) { thrown = e; }
|
||||||
|
check('svg malformati: nessuna eccezione, una sola forma valida',
|
||||||
|
thrown === null && count(out, /<line /g) === 1 && count(out, /vector-effect/g) === 1, String(thrown) + out);
|
||||||
|
check('svg: overlay non array o vuoto -> stringa vuota',
|
||||||
|
VisionOverlay.svg(null) === '' && VisionOverlay.svg(undefined) === '' && VisionOverlay.svg([]) === '');
|
||||||
|
check('svg: opts non validi -> predefiniti',
|
||||||
|
VisionOverlay.svg([{ type: 'points', role: 'found', rows: [10], cols: [20] }], { zoom: 0, lw: -1, cross: NaN })
|
||||||
|
.includes('d="M 14 10 L 26 10 M 20 4 L 20 16"'));
|
||||||
|
|
||||||
|
// nessuna stringa dei dati nel markup
|
||||||
|
const evil = '<script>alert(1)</script>"onload="x';
|
||||||
|
const sicuro = VisionOverlay.svg([
|
||||||
|
{ type: 'segment', role: evil, tool_id: evil, name: evil, row1: 0, col1: 0, row2: 1, col2: 1 },
|
||||||
|
{ type: 'circle', role: 'found', tool_id: evil, row: 1, col: 1, radius: 2 },
|
||||||
|
]);
|
||||||
|
check('svg: tool_id e role dei dati non compaiono nel markup',
|
||||||
|
!/script|onload|alert/.test(sicuro) && count(sicuro, /vector-effect/g) === 2, sicuro);
|
||||||
|
const strNum = VisionOverlay.svg([{ type: 'segment', role: 'found', row1: '<b>', col1: 0, row2: 1, col2: 1 }]);
|
||||||
|
check('svg: coordinata stringa -> forma saltata', strNum === '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- #12: livelli e quote CAD ---
|
||||||
|
{
|
||||||
|
const VO = VisionOverlay;
|
||||||
|
check('layerOf: layer esplicito vince sul ruolo',
|
||||||
|
VO.layerOf({ role: 'found', layer: 'quote' }) === 'quote' && VO.layerOf({ role: 'roi', layer: 'costruzioni' }) === 'costruzioni');
|
||||||
|
check('layerOf: senza campo found = costruzioni, altro = ricerca',
|
||||||
|
VO.layerOf({ role: 'found' }) === 'costruzioni' && VO.layerOf({ role: 'roi' }) === 'ricerca' &&
|
||||||
|
VO.layerOf({ role: 'reference' }) === 'ricerca' && VO.layerOf({}) === 'ricerca' && VO.layerOf(null) === 'ricerca');
|
||||||
|
check('layerOf: layer ignoto -> secondo il ruolo', VO.layerOf({ role: 'found', layer: 'boh' }) === 'costruzioni');
|
||||||
|
|
||||||
|
const lin = { tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: null };
|
||||||
|
const rag = { tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 90, testo: null };
|
||||||
|
const ang = { tipo: 'angolo', centro: [50, 50], r: 20, start_deg: 0, extent_deg: 90, testo: null };
|
||||||
|
// default
|
||||||
|
let p = VO.quotaPunto(lin);
|
||||||
|
check('quotaPunto lineare default = punto medio', near(p.x, 50) && near(p.y, 0), JSON.stringify(p));
|
||||||
|
p = VO.quotaPunto(rag);
|
||||||
|
check('quotaPunto raggio default = centro + 2r lungo dir (90 gradi = verso l\'alto a video)', near(p.x, 50) && near(p.y, 30), JSON.stringify(p));
|
||||||
|
p = VO.quotaPunto(ang);
|
||||||
|
{ // u a 45 gradi = (cos45, -sin45); T = centro + 1.5r u
|
||||||
|
const e = 30 * Math.SQRT1_2;
|
||||||
|
check('quotaPunto angolo default = centro + 1.5r a meta arco', near(p.x, 50 + e) && near(p.y, 50 - e), JSON.stringify(p));
|
||||||
|
}
|
||||||
|
// con testo: lineare p1->p2 lungo +x, n = (0, 1): dv positivo = verso il basso a video
|
||||||
|
p = VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [10, 20] });
|
||||||
|
check('quotaPunto lineare con testo [du,dv]', near(p.x, 60) && near(p.y, 20), JSON.stringify(p));
|
||||||
|
// inverse
|
||||||
|
[lin, rag, ang].forEach((q) => {
|
||||||
|
[[3, -7], [-12.5, 40], [0, 0]].forEach((t) => {
|
||||||
|
const q2 = Object.assign({}, q, { testo: t });
|
||||||
|
const pt = VO.quotaPunto(q2);
|
||||||
|
const back = VO.quotaTesto(q2, pt.x, pt.y);
|
||||||
|
check('quotaTesto inverso di quotaPunto (' + q.tipo + ' ' + t + ')', near(back[0], t[0]) && near(back[1], t[1]), JSON.stringify(back));
|
||||||
|
});
|
||||||
|
// dal punto di default si torna a testo di default
|
||||||
|
const d = VO.quotaPunto(q), back = VO.quotaTesto(q, d.x, d.y), p2 = VO.quotaPunto(Object.assign({}, q, { testo: back }));
|
||||||
|
check('quotaTesto dal punto di default riproduce il default (' + q.tipo + ')', near(p2.x, d.x) && near(p2.y, d.y));
|
||||||
|
});
|
||||||
|
check('quota malformata: quotaPunto/quotaTesto null, quotaSvg stringa vuota',
|
||||||
|
VO.quotaPunto(null) === null && VO.quotaPunto({ tipo: 'boh' }) === null &&
|
||||||
|
VO.quotaPunto({ tipo: 'lineare', p1: [0, 'a'], p2: [1, 1] }) === null &&
|
||||||
|
VO.quotaTesto({ tipo: 'raggio' }, 1, 1) === null &&
|
||||||
|
VO.quotaSvg({ tipo: 'angolo', centro: [0, 0] }) === '' && VO.quotaSvg(undefined) === '');
|
||||||
|
check('quotaPunto: testo malformato = default', near(VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: ['x', 1] }).x, 5));
|
||||||
|
|
||||||
|
// quotaSvg lineare: linee di estensione solo con dv != 0
|
||||||
|
const conDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 1, color: '#16a34a' });
|
||||||
|
const senzaDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 0] }, { zoom: 1, color: '#16a34a' });
|
||||||
|
check('quotaSvg lineare dv=30: 2 estensioni + 1 linea di misura, 2 frecce', count(conDv, /<line /g) === 3 && count(conDv, /<polygon /g) === 2, conDv);
|
||||||
|
check('quotaSvg lineare dv=0: nessuna estensione (solo linea di misura), 2 frecce', count(senzaDv, /<line /g) === 1 && count(senzaDv, /<polygon /g) === 2, senzaDv);
|
||||||
|
check('quotaSvg: tratto non-scaling 1.5 e colore passato', /stroke-width="1.5" vector-effect="non-scaling-stroke"/.test(conDv) && conDv.includes('#16a34a'));
|
||||||
|
// frecce dentro/fuori: lineare lunga 200 a zoom 1 -> la punta sta in A (0, 30): primo vertice del poligono
|
||||||
|
{ // dentro: punta in A, coda verso +u (x crescente) -> il secondo vertice ha x > 0? no: dentro la freccia punta verso fuori, coda verso l'interno (x>0)
|
||||||
|
const m = conDv.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||||
|
check('freccia dentro: punta in A, coda verso l\'interno della misura', near(m[0][0], 0) && near(m[0][1], 30) && m[1][0] > 0, m.join('|'));
|
||||||
|
const corta = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: [0, 30] }, { zoom: 1 });
|
||||||
|
const mc = corta.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||||
|
check('freccia fuori (misura corta): punta in A, coda fuori (x < 0)', near(mc[0][0], 0) && mc[1][0] < 0, mc.join('|'));
|
||||||
|
const zz = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 4 });
|
||||||
|
const mz = zz.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||||
|
check('freccia: lunghezza fissa a video (9 px / zoom)', near(mz[1][0], 9 / 4) && count(zz, /<line /g) === 3, mz.join('|'));
|
||||||
|
}
|
||||||
|
// T oltre B: la linea di misura si allunga
|
||||||
|
const oltre = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [150, 30] }, { zoom: 1 });
|
||||||
|
check('quotaSvg lineare: scritta oltre B allunga la linea di misura fino a lei', oltre.includes('x2="200"'), oltre);
|
||||||
|
// raggio
|
||||||
|
const rFuori = VO.quotaSvg({ tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 0, testo: [30, 0] }, { zoom: 1 });
|
||||||
|
const rDentro = VO.quotaSvg({ tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 0, testo: [5, 0] }, { zoom: 1 });
|
||||||
|
check('quotaSvg raggio: crocetta + linea + freccia (fuori)', count(rFuori, /<line /g) === 3 && count(rFuori, /<polygon /g) === 1, rFuori);
|
||||||
|
{ // fuori: P = (60, 50), freccia verso il centro: coda a x > 60
|
||||||
|
const m = rFuori.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||||
|
check('raggio fuori: punta in P sul cerchio, coda verso fuori', near(m[0][0], 60) && near(m[0][1], 50) && m[1][0] > 60, m.join('|'));
|
||||||
|
const d = rDentro.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||||
|
check('raggio dentro: punta in P, coda verso il centro (x < 60)', near(d[0][0], 60) && d[1][0] < 60, d.join('|'));
|
||||||
|
}
|
||||||
|
// angolo
|
||||||
|
const a1 = VO.quotaSvg(ang, { zoom: 1 });
|
||||||
|
check('quotaSvg angolo: 2 estensioni radiali + arco + 2 frecce', count(a1, /<line /g) === 2 && count(a1, /<path /g) === 1 && count(a1, /<polygon /g) === 2, a1);
|
||||||
|
// markup senza stringhe dei dati
|
||||||
|
const evil = '<script>alert(1)</script>"onload="x';
|
||||||
|
const s1 = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [0, 20], nome: evil, extra: evil }, { zoom: 1, color: evil });
|
||||||
|
const s2 = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [0, evil] }, { zoom: 1 });
|
||||||
|
check('quotaSvg: nessuna stringa dei dati nel markup, colore non in lista -> rosso',
|
||||||
|
!/script|onload|alert/.test(s1) && s1.includes('#ef4444') && !/script|onload|alert/.test(s2), s1);
|
||||||
|
check('quotaSvg: colori ammessi (esiti)', ['#16a34a', '#d97706', '#dc2626'].every((c) => VO.quotaSvg(lin, { color: c }).includes(c)));
|
||||||
|
check('svg() invariato: uscita di un segmento', VO.svg([{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }]).startsWith('<line x1="0"'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* #114: etichette della tabella quote nel pannello visione (getter `rows`), con plain node.
|
||||||
|
*
|
||||||
|
* Run from src/frontend/flask_app: node tests/js/test_vision_rows.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'static', 'js', 'vision_execute.js'), 'utf8',
|
||||||
|
);
|
||||||
|
// Contesto vm con window e document finti; il file definisce visionExecute() globale
|
||||||
|
const win = {};
|
||||||
|
// #120: CustomEvent finto (la pagina emette vision-recorded con window.dispatchEvent)
|
||||||
|
const ctx = vm.createContext({
|
||||||
|
window: win,
|
||||||
|
document: { addEventListener() {}, querySelector() { return null; } },
|
||||||
|
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
|
||||||
|
});
|
||||||
|
vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(label, cond, extra) {
|
||||||
|
if (cond) console.log('ok - ' + label);
|
||||||
|
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
win.__visionCfg = {
|
||||||
|
subtasks: [
|
||||||
|
{ id: 15, marker_number: 1, description: 'Larghezza', measurement_type: null, unit: 'mm' },
|
||||||
|
{ id: 16, marker_number: 2, description: '', measurement_type: 'Diametro', unit: 'mm' },
|
||||||
|
{ id: 17, marker_number: 3, description: null, measurement_type: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const comp = ctx.visionExecute();
|
||||||
|
comp.result = {
|
||||||
|
measurements: [
|
||||||
|
{ subtask_id: 15, value: 1, pass_fail: 'pass' },
|
||||||
|
{ subtask_id: 16, value: 2, pass_fail: 'pass' },
|
||||||
|
{ subtask_id: 17, value: 3, pass_fail: 'fail' },
|
||||||
|
{ subtask_id: 99, value: 4, pass_fail: 'pass' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const rows = comp.rows;
|
||||||
|
check('#114 riga 15: description', rows[0].label === '1. Larghezza', rows[0].label);
|
||||||
|
check('#114 riga 16: ripiego su measurement_type', rows[1].label === '2. Diametro', rows[1].label);
|
||||||
|
check('#114 riga 17: ripiego su #id', rows[2].label === '3. #17', rows[2].label);
|
||||||
|
check('#114 riga 99: subtask sconosciuta', rows[3].label === '#99', rows[3].label);
|
||||||
|
check('#114 unit riga 17 = mm', rows[2].unit === 'mm', rows[2].unit);
|
||||||
|
check('#114 unit riga 99 = mm', rows[3].unit === 'mm', rows[3].unit);
|
||||||
|
|
||||||
|
// #117: nominale e tolleranze portati dalla quota; fmtTol
|
||||||
|
win.__visionCfg = {
|
||||||
|
subtasks: [
|
||||||
|
{ id: 20, description: 'Largh', unit: 'mm', nominal: 12.5, ltl: 12.4, utl: 12.6, lwl: 12.45, uwl: 12.55 },
|
||||||
|
{ id: 21, description: 'Diam', unit: 'mm', nominal: 0, ltl: null, utl: 0.05 },
|
||||||
|
{ id: 22, description: 'Nuda', unit: 'mm' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const c2 = ctx.visionExecute();
|
||||||
|
c2.result = { measurements: [
|
||||||
|
{ subtask_id: 20, value: 12.5, pass_fail: 'pass' },
|
||||||
|
{ subtask_id: 21, value: 0, pass_fail: 'pass' },
|
||||||
|
{ subtask_id: 22, value: 1, pass_fail: 'pass' },
|
||||||
|
] };
|
||||||
|
const r2 = c2.rows;
|
||||||
|
check('#117 riga 20: nominale e tolleranze',
|
||||||
|
r2[0].nominal === 12.5 && r2[0].ltl === 12.4 && r2[0].utl === 12.6 && r2[0].lwl === 12.45 && r2[0].uwl === 12.55);
|
||||||
|
check('#117 riga 21: nominale 0 resta 0, ltl null', r2[1].nominal === 0 && r2[1].ltl == null && r2[1].utl === 0.05);
|
||||||
|
check('#117 riga 22: senza tolleranze sono undefined/null', r2[2].nominal == null && r2[2].utl == null && r2[2].lwl == null);
|
||||||
|
check('#117 label invariato', r2[0].label === 'Largh', r2[0].label);
|
||||||
|
check('#117 fmtTol(null) = trattino', c2.fmtTol(null) === '\u2014', c2.fmtTol(null));
|
||||||
|
check('#117 fmtTol(undefined) = trattino', c2.fmtTol(undefined) === '\u2014');
|
||||||
|
check('#117 fmtTol(0.05) = 0.050', c2.fmtTol(0.05) === '0.050', c2.fmtTol(0.05));
|
||||||
|
check('#117 fmtTol(0) = 0.000', c2.fmtTol(0) === '0.000', c2.fmtTol(0));
|
||||||
|
|
||||||
|
// #116: righe con key/name/vision_output, etichette in % con un VisionOverlay finto, labelClass
|
||||||
|
win.__visionCfg = {
|
||||||
|
subtasks: [
|
||||||
|
{ id: 30, marker_number: 1, description: 'Larghezza', unit: 'mm', vision_output: 'larg.value' },
|
||||||
|
{ id: 31, description: '', measurement_type: 'Diametro', unit: 'mm' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const c3 = ctx.visionExecute();
|
||||||
|
c3.result = { overlay: [{ type: 'circle', tool_id: 'larg' }], measurements: [
|
||||||
|
{ subtask_id: 30, value: 1.5, pass_fail: 'pass' },
|
||||||
|
{ subtask_id: 31, value: 2, pass_fail: 'fail' },
|
||||||
|
] };
|
||||||
|
const r3 = c3.rows;
|
||||||
|
check('#116 riga: key = subtask_id', r3[0].key === 30 && r3[1].key === 31);
|
||||||
|
check('#116 riga: name senza marker', r3[0].name === 'Larghezza' && r3[1].name === 'Diametro', r3[0].name + '/' + r3[1].name);
|
||||||
|
check('#116 riga: vision_output', r3[0].vision_output === 'larg.value' && r3[1].vision_output == null);
|
||||||
|
check('#116 label invariato (#114)', r3[0].label === '1. Larghezza', r3[0].label);
|
||||||
|
check('#116 senza VisionOverlay: nessuna etichetta', c3.viewerLabels.length === 0);
|
||||||
|
|
||||||
|
let seen = null;
|
||||||
|
ctx.VisionOverlay = {
|
||||||
|
labels(rows, overlay) { seen = { rows, overlay }; return [{ key: 30, x: 500, y: 250, text: 'Larghezza 1.500 mm', status: 'pass' }]; },
|
||||||
|
};
|
||||||
|
const ls = c3.viewerLabels;
|
||||||
|
check('#116 una etichetta', ls.length === 1 && ls[0].key === 30 && ls[0].text === 'Larghezza 1.500 mm', JSON.stringify(ls));
|
||||||
|
// #122: posizione in px logici dell'immagine (la percentuale la calcola <tmf-viewer>), niente style
|
||||||
|
check('#122 posizione in px logici', ls[0].x === 500 && ls[0].y === 250 && ls[0].style === undefined, JSON.stringify(ls[0]));
|
||||||
|
check('#116 classe da esito', ls[0].cls === 'bg-green-600 text-white', ls[0].cls);
|
||||||
|
check('#116 labels riceve rows e overlay', seen.rows.length === 2 && seen.overlay.length === 1);
|
||||||
|
c3.result = null;
|
||||||
|
check('#116 senza risultato: nessuna etichetta', c3.viewerLabels.length === 0);
|
||||||
|
check('#116 labelClass pass', c3.labelClass('pass') === 'bg-green-600 text-white');
|
||||||
|
check('#116 labelClass warning', c3.labelClass('warning') === 'bg-amber-400 text-black');
|
||||||
|
check('#116 labelClass fail', c3.labelClass('fail') === 'bg-red-600 text-white');
|
||||||
|
check('#116 labelClass altro', c3.labelClass('boh') === 'bg-gray-700 text-white' && c3.labelClass(undefined) === 'bg-gray-700 text-white');
|
||||||
|
|
||||||
|
// #122: zoom, overlay SVG e Salva sono di <tmf-viewer>: il componente non li ha piu'
|
||||||
|
check('#122 niente drawOverlay/saveImage/overlayLabels/z', c3.drawOverlay === undefined && c3.saveImage === undefined && c3.overlayLabels === undefined && c3.z === undefined);
|
||||||
|
check('#122 niente codice canvas', !/getContext|clearRect/.test(src));
|
||||||
|
|
||||||
|
// #118: quota non misurata (value null) = NaN, mai Number(null) = 0
|
||||||
|
check('#118 fmtValue(null) = NaN', c3.fmtValue(null, 'mm') === 'NaN mm', c3.fmtValue(null, 'mm'));
|
||||||
|
check('#118 fmtValue(undefined) = NaN', c3.fmtValue(undefined, 'mm') === 'NaN mm');
|
||||||
|
check('#118 fmtValue(0) = 0.000', c3.fmtValue(0, 'mm') === '0.000 mm', c3.fmtValue(0, 'mm'));
|
||||||
|
check('#118 fmtValue(1.5) = 1.500', c3.fmtValue(1.5, 'mm') === '1.500 mm');
|
||||||
|
win.__visionCfg = { subtasks: [{ id: 40, description: 'Persa', unit: 'mm', nominal: 5, ltl: 4, utl: 6 }] };
|
||||||
|
const c4 = ctx.visionExecute();
|
||||||
|
c4.result = { measurements: [{ subtask_id: 40, value: null, pass_fail: 'fail' }] };
|
||||||
|
check('#118 riga con value null: valore null, esito fail, tolleranze come prima',
|
||||||
|
c4.rows[0].value === null && c4.rows[0].status === 'fail' && c4.rows[0].nominal === 5 && c4.rows[0].utl === 6);
|
||||||
|
|
||||||
|
// #120: evento vision-recorded dopo un record riuscito, non dopo uno fallito
|
||||||
|
const events = [];
|
||||||
|
win.dispatchEvent = (e) => { events.push(e); return true; };
|
||||||
|
const listeners = {};
|
||||||
|
win.addEventListener = (t, f) => { (listeners[t] = listeners[t] || []).push(f); };
|
||||||
|
win.__visionCfg = { cameraAvailable: true, modo: 'impulsiva', subtasks: [{ id: 40, description: 'Persa', unit: 'mm' }] };
|
||||||
|
const c5 = ctx.visionExecute();
|
||||||
|
c5.lastBlob = { name: 'x' };
|
||||||
|
const okMs = [{ subtask_id: 40, value: null, pass_fail: 'fail' }, { subtask_id: 41, value: 2, pass_fail: 'pass' }];
|
||||||
|
c5.acquire = async function () { this.result = { vision_result_id: 7, measurements: okMs }; };
|
||||||
|
(async () => {
|
||||||
|
await c5.record();
|
||||||
|
const ev = events.filter((e) => e.type === 'vision-recorded');
|
||||||
|
check('#120 record riuscito: un evento vision-recorded', ev.length === 1, String(ev.length));
|
||||||
|
check('#120 evento: misure di execute', ev[0].detail.measurements === okMs);
|
||||||
|
check('#120 record riuscito: saved', c5.saved === true && c5.preview === false);
|
||||||
|
|
||||||
|
events.length = 0;
|
||||||
|
const c6 = ctx.visionExecute();
|
||||||
|
c6.lastBlob = { name: 'x' };
|
||||||
|
c6.acquire = async function () { this.error = 'boom'; this.result = null; };
|
||||||
|
await c6.record();
|
||||||
|
check('#120 record fallito (errore): nessun evento', events.length === 0 && c6.saved === false);
|
||||||
|
const c7 = ctx.visionExecute();
|
||||||
|
c7.lastBlob = { name: 'x' };
|
||||||
|
c7.acquire = async function () { this.result = { measurements: okMs }; }; // niente vision_result_id
|
||||||
|
await c7.record();
|
||||||
|
check('#120 record senza vision_result_id: nessun evento', events.length === 0 && c7.saved === false);
|
||||||
|
|
||||||
|
// senza camera il file e' salvato subito da acquire(): stesso evento
|
||||||
|
events.length = 0;
|
||||||
|
win.__visionCfg = { cameraAvailable: false, executeUrl: '/e', taskId: 1, subtasks: [] };
|
||||||
|
const c8 = ctx.visionExecute();
|
||||||
|
c8.$nextTick = (fn) => fn();
|
||||||
|
ctx.URL = { createObjectURL: () => 'blob:x', revokeObjectURL() {} };
|
||||||
|
ctx.FormData = class { append() {} };
|
||||||
|
ctx.fetch = async () => ({ ok: true, json: async () => ({ vision_result_id: 9, measurements: okMs }) });
|
||||||
|
await c8.acquire({ name: 'a.png' });
|
||||||
|
check('#120 senza camera: acquire riuscito emette vision-recorded', events.length === 1 && events[0].type === 'vision-recorded', String(events.length));
|
||||||
|
|
||||||
|
// task-result-reset: saved torna false (anche senza camera, init registra l'ascolto)
|
||||||
|
c5.init();
|
||||||
|
const h = (listeners['task-result-reset'] || [])[0];
|
||||||
|
check('#120 init ascolta task-result-reset', typeof h === 'function');
|
||||||
|
c5.saved = true;
|
||||||
|
h();
|
||||||
|
check('#120 task-result-reset: saved = false', c5.saved === false);
|
||||||
|
|
||||||
|
// #123: dopo Registra + task-result-reset Registra e' spento sul frame registrato, anteprima spenta, impulsiva = grab
|
||||||
|
win.__visionCfg = { cameraAvailable: true, modo: 'impulsiva', subtasks: [] };
|
||||||
|
const c9 = ctx.visionExecute();
|
||||||
|
let grabs = 0;
|
||||||
|
c9.grab = async function () { grabs++; };
|
||||||
|
c9.lastBlob = { name: 'f1' };
|
||||||
|
c9.acquire = async function () { this.result = { vision_result_id: 11, measurements: okMs }; };
|
||||||
|
check('#123 prima di Registra: canRecord', c9.canRecord === true);
|
||||||
|
await c9.record();
|
||||||
|
check('#123 dopo Registra: canRecord falso (saved)', c9.canRecord === false && c9.recordedBlob === c9.lastBlob);
|
||||||
|
c9.$nextTick = () => {}; // #123: nel test nessun grab di apertura
|
||||||
|
c9.init(); // registra l'ascolto di task-result-reset
|
||||||
|
grabs = 0;
|
||||||
|
(listeners['task-result-reset'] || []).slice(-1)[0]();
|
||||||
|
check('#123 reset sul frame registrato: canRecord falso', c9.canRecord === false && c9.saved === false);
|
||||||
|
check('#123 reset sul frame registrato: niente badge anteprima', c9.preview === false);
|
||||||
|
check('#123 reset in impulsiva: un solo grab', grabs === 1, String(grabs));
|
||||||
|
c9.lastBlob = { name: 'f2' };
|
||||||
|
check('#123 frame nuovo: canRecord vero', c9.canRecord === true);
|
||||||
|
|
||||||
|
// #119 #122: saveName = titolo del task (nome proposto da <tmf-viewer save-name>), 'immagine' se manca
|
||||||
|
win.__visionCfg = { taskName: 'Roto3', subtasks: [] };
|
||||||
|
check('#122 saveName = nome del task', ctx.visionExecute().saveName === 'Roto3');
|
||||||
|
win.__visionCfg = { subtasks: [] };
|
||||||
|
check('#122 saveName di riserva', ctx.visionExecute().saveName === 'immagine');
|
||||||
|
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* #122: lo zoom del pannello visione non e' piu' in vision_execute.js ma in <tmf-viewer> (image_viewer.js).
|
||||||
|
* Qui: il componente passa al viewer solo le etichette in px logici (viewerLabels) e non ha piu' codice di zoom.
|
||||||
|
*
|
||||||
|
* Run from src/frontend/flask_app: node tests/js/test_vision_zoom.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'static', 'js', 'vision_execute.js'), 'utf8',
|
||||||
|
);
|
||||||
|
const win = { __visionCfg: { subtasks: [{ id: 1, description: 'Larg', unit: 'mm' }] } };
|
||||||
|
const ctx = vm.createContext({ window: win, document: { addEventListener() {}, querySelector() { return null; } } });
|
||||||
|
vm.runInContext(src + ';this.visionExecute = visionExecute;', ctx);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(label, cond, extra) {
|
||||||
|
if (cond) console.log('ok - ' + label);
|
||||||
|
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Niente piu' stato ne' funzioni di zoom/pan/resize/Salva/overlay nel componente
|
||||||
|
const c = ctx.visionExecute();
|
||||||
|
for (const k of ['z', 'zoomBy', 'zFit', 'zWheel', 'zCanPan', 'zCursor', 'zPct', 'zSize', 'zPanDown', 'zPanMove', 'zPanEnd',
|
||||||
|
'onImgLoad', 'drawOverlay', 'saveImage', 'overlayLabels']) {
|
||||||
|
check('#122 componente senza ' + k, c[k] === undefined);
|
||||||
|
}
|
||||||
|
check('#122 sorgente senza $watch di zoom ne ResizeObserver', !/z\.zoom|ResizeObserver|tmfSaveImage/.test(src));
|
||||||
|
|
||||||
|
// viewerLabels: {key, x, y, text, cls} in px logici, classe dall'esito; vuote senza risultato o senza VisionOverlay
|
||||||
|
check('#122 senza VisionOverlay: nessuna etichetta', (c.result = { measurements: [] }, c.viewerLabels.length === 0));
|
||||||
|
win.VisionOverlay = ctx.VisionOverlay = {
|
||||||
|
labels() { return [{ key: 1, x: 500, y: 250, text: 'Larg 1.500 mm', status: 'fail' }]; },
|
||||||
|
};
|
||||||
|
const ls = c.viewerLabels;
|
||||||
|
check('#122 viewerLabels in px logici', ls.length === 1 && ls[0].x === 500 && ls[0].y === 250 && ls[0].key === 1 && ls[0].text === 'Larg 1.500 mm', JSON.stringify(ls));
|
||||||
|
check('#122 viewerLabels: classe da esito', ls[0].cls === 'bg-red-600 text-white', ls[0].cls);
|
||||||
|
c.result = null;
|
||||||
|
check('#122 senza risultato: nessuna etichetta', c.viewerLabels.length === 0);
|
||||||
|
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
@@ -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)
|
resp = logged_in_client.post("/admin/api/stations", json=body)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
mock_admin_api.post.assert_called_once_with("/api/stations", data=body)
|
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"] == 15 # #106
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Runs the plain-node DXF preview behaviour test (tests/js/test_dxf_preview.js).
|
||||||
|
|
||||||
|
Same thin pytest wrapper pattern as test_numpad_js.py: shells out to ``node``
|
||||||
|
and asserts the script's own exit code. Skips (does not fail) when node is
|
||||||
|
not installed.
|
||||||
|
|
||||||
|
Covers gitea:Adriano/TieMeasureFlow#101.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
JS_TEST = Path(__file__).resolve().parent / "js" / "test_dxf_preview.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_js_behaviour():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS di dxf_recipe_wizard.js saltato")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(JS_TEST)],
|
||||||
|
cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"tests/js/test_dxf_preview.js fallito (exit {result.returncode})\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"""gitea:Adriano/TieMeasureFlow#83 - ricetta DXF compare da camera: rotte Flask nuove.
|
||||||
|
|
||||||
|
api_client e requests sono finti: backend (B3) e acquisitore (C1) rispondono
|
||||||
|
come da contratto del piano.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
STATION = {
|
||||||
|
"id": 4, "code": "ST-4", "name": "Banco",
|
||||||
|
"camera_url": "http://acq:8765/", "camera_source": "camera:folder",
|
||||||
|
}
|
||||||
|
NO_CAMERA = {"id": 5, "code": "ST-5", "name": "Senza", "camera_url": None, "camera_source": None}
|
||||||
|
PNG = b"\x89PNG\r\n\x1a\n fake"
|
||||||
|
CAL = {
|
||||||
|
"source": "camera:folder", "px_per_mm": 20.03, "mm_per_px": 0.0499, "rms_px": 0.2,
|
||||||
|
"n_corners": 54, "cols": 9, "rows": 6, "square_mm": 5.0,
|
||||||
|
"image_width": 2448, "image_height": 2048, "scale_spread_pct": 0.3,
|
||||||
|
"calibrated_at": "2026-10-23T10:00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake(status=200, content=PNG, json_body=None):
|
||||||
|
resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1"))
|
||||||
|
if json_body is None:
|
||||||
|
resp.json.side_effect = ValueError("no json")
|
||||||
|
else:
|
||||||
|
resp.json.return_value = json_body
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _stations(mock_api_client, stations=(STATION, NO_CAMERA)):
|
||||||
|
mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)})
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationList:
|
||||||
|
def test_only_stations_with_camera(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
resp = logged_in_client.get("/maker/api/stations")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert [s["id"] for s in resp.get_json()] == [4]
|
||||||
|
|
||||||
|
def test_non_admin_list_falls_back_to_current_station(
|
||||||
|
self, logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
# /api/stations e' solo admin: il Maker vede la stazione corrente (by-code)
|
||||||
|
monkeypatch.setattr("blueprints.maker.Config.STATION_CODE", "ST-4")
|
||||||
|
mock_api_client.get.side_effect = api_get_router(
|
||||||
|
{"/api/stations": {"error": True, "status_code": 403, "detail": "no"}})
|
||||||
|
mock_api_client.get_station_by_code.return_value = STATION
|
||||||
|
resp = logged_in_client.get("/maker/api/stations")
|
||||||
|
assert [s["id"] for s in resp.get_json()] == [4]
|
||||||
|
mock_api_client.get_station_by_code.assert_called_once_with("ST-4")
|
||||||
|
|
||||||
|
def test_requires_maker_or_admin(self, logged_in_client, mock_api_client):
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"], "is_admin": False}
|
||||||
|
assert logged_in_client.get("/maker/api/stations").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationCameraGrab:
|
||||||
|
def test_png_pass_through(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
with patch("blueprints.maker.http_requests.post", return_value=_fake()) as post:
|
||||||
|
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.mimetype == "image/png"
|
||||||
|
assert resp.data == PNG
|
||||||
|
args, kwargs = post.call_args
|
||||||
|
assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto
|
||||||
|
assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000}
|
||||||
|
assert kwargs["timeout"] == 60 # #106: margine per la prima apertura GenTL
|
||||||
|
|
||||||
|
def test_get_also_works(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
with patch("blueprints.maker.http_requests.post", return_value=_fake()):
|
||||||
|
assert logged_in_client.get("/maker/api/stations/4/camera/grab").status_code == 200
|
||||||
|
|
||||||
|
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
with patch("blueprints.maker.http_requests.post") as post:
|
||||||
|
resp = logged_in_client.post("/maker/api/stations/5/camera/grab")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
def test_unknown_station_is_409(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
assert logged_in_client.post("/maker/api/stations/99/camera/grab").status_code == 409
|
||||||
|
|
||||||
|
def test_service_down_is_502(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
with patch("blueprints.maker.http_requests.post",
|
||||||
|
side_effect=requests.ConnectionError("refused")):
|
||||||
|
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert resp.get_json()["error"]
|
||||||
|
|
||||||
|
def test_service_503_is_502_with_detail(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
fake = _fake(503, b"{}", {"detail": "camera occupata"})
|
||||||
|
with patch("blueprints.maker.http_requests.post", return_value=fake):
|
||||||
|
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert "camera occupata" in resp.get_json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationCameraCalibration:
|
||||||
|
def _get(self, client, fake=None, exc=None):
|
||||||
|
with patch("blueprints.maker.http_requests.get", return_value=fake,
|
||||||
|
side_effect=exc) as get:
|
||||||
|
resp = client.get("/maker/api/stations/4/camera/calibration")
|
||||||
|
return resp, get
|
||||||
|
|
||||||
|
def test_calibrated(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
resp, get = self._get(logged_in_client, _fake(200, b"{}", CAL))
|
||||||
|
body = resp.get_json()
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert body["state"] == "calibrated"
|
||||||
|
assert body["calibration"]["px_per_mm"] == 20.03
|
||||||
|
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"}
|
||||||
|
|
||||||
|
def test_404_is_uncalibrated(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
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/acquisizione"
|
||||||
|
|
||||||
|
def test_unreachable(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
resp, _ = self._get(logged_in_client, exc=requests.ConnectionError("down"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["state"] == "unreachable"
|
||||||
|
|
||||||
|
def test_server_error_is_unreachable(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
resp, _ = self._get(logged_in_client, _fake(500, b"boom"))
|
||||||
|
assert resp.get_json()["state"] == "unreachable"
|
||||||
|
|
||||||
|
def test_station_without_camera_is_409(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/stations/5/camera/calibration")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDxfCompareProxy:
|
||||||
|
URL = "/maker/api/vision/tasks/7/recipe/dxf-compare"
|
||||||
|
OK = {
|
||||||
|
"result": {"outputs": {"dxf.points_in_pct": 99.1}, "overlay": [], "outcome": "pass"},
|
||||||
|
"saved": False, "recipe_dir": None, "outputs": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _post(self, client, **form):
|
||||||
|
data = {"image": (io.BytesIO(PNG), "p.png"), "dxf": (io.BytesIO(b"0\nEOF"), "d.dxf"), **form}
|
||||||
|
return client.post(self.URL, data=data, content_type="multipart/form-data")
|
||||||
|
|
||||||
|
def test_forwards_files_and_fields(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = self.OK
|
||||||
|
resp = self._post(logged_in_client, station_id="4", band_mm="0.1",
|
||||||
|
min_points_pct="95", polarity="dark", save="false", px_per_mm="")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["result"]["outputs"]["dxf.points_in_pct"] == 99.1
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/vision/tasks/7/recipe/dxf-compare"
|
||||||
|
# campi del contratto B3; px_per_mm vuoto non si inoltra
|
||||||
|
assert kwargs["data"] == {
|
||||||
|
"station_id": "4", "band_mm": "0.1", "min_points_pct": "95",
|
||||||
|
"polarity": "dark", "save": "false",
|
||||||
|
}
|
||||||
|
assert set(kwargs["files"]) == {"image", "dxf"}
|
||||||
|
assert kwargs["files"]["image"][1] == PNG
|
||||||
|
|
||||||
|
def test_manual_px_per_mm_forwarded(self, logged_in_client, mock_api_client):
|
||||||
|
# #98: scala a mano dal wizard (immagine da file): inoltrata al backend
|
||||||
|
mock_api_client.post.return_value = self.OK
|
||||||
|
self._post(logged_in_client, station_id="4", px_per_mm="71.1237")
|
||||||
|
assert mock_api_client.post.call_args.kwargs["data"]["px_per_mm"] == "71.1237"
|
||||||
|
|
||||||
|
def test_save_true_forwarded(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
|
||||||
|
resp = self._post(logged_in_client, save="true")
|
||||||
|
assert resp.get_json()["saved"] is True
|
||||||
|
assert mock_api_client.post.call_args.kwargs["data"]["save"] == "true"
|
||||||
|
|
||||||
|
def test_missing_files_is_400(self, logged_in_client, mock_api_client):
|
||||||
|
resp = logged_in_client.post(self.URL, data={"station_id": "4"},
|
||||||
|
content_type="multipart/form-data")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 422, "detail": "stazione non calibrata"}
|
||||||
|
resp = self._post(logged_in_client)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "stazione non calibrata"
|
||||||
|
|
||||||
|
def test_requires_maker_role(self, logged_in_client, mock_api_client):
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
|
||||||
|
assert self._post(logged_in_client).status_code == 403
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDxfPreviewProxy:
|
||||||
|
"""#101: anteprima del DXF scelto nel wizard (proxy verso il backend)."""
|
||||||
|
URL = "/maker/api/vision/dxf-preview"
|
||||||
|
OK = {
|
||||||
|
"contorni": [[[0.0, -5.0], [10.0, -5.0], [10.0, 0.0], [0.0, 0.0]]],
|
||||||
|
"bbox": {"xmin": 0.0, "ymin": -5.0, "xmax": 10.0, "ymax": 0.0},
|
||||||
|
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _post(self, client, with_file=True):
|
||||||
|
data = {"dxf": (io.BytesIO(b"0\nEOF"), "d.dxf")} if with_file else {}
|
||||||
|
return client.post(self.URL, data=data, content_type="multipart/form-data")
|
||||||
|
|
||||||
|
def test_forwards_file(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = self.OK
|
||||||
|
resp = self._post(logged_in_client)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["larghezza_mm"] == 10.0
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/vision/dxf-preview"
|
||||||
|
assert set(kwargs["files"]) == {"dxf"}
|
||||||
|
assert kwargs["files"]["dxf"][0] == "d.dxf"
|
||||||
|
assert kwargs["files"]["dxf"][1] == b"0\nEOF"
|
||||||
|
|
||||||
|
def test_missing_file_is_400(self, logged_in_client, mock_api_client):
|
||||||
|
resp = self._post(logged_in_client, with_file=False)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 422, "detail": "DXF illeggibile: x"}
|
||||||
|
resp = self._post(logged_in_client)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "DXF illeggibile: x"
|
||||||
|
|
||||||
|
def test_requires_maker_role(self, logged_in_client, mock_api_client):
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
|
||||||
|
assert self._post(logged_in_client).status_code == 403
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDxfSourceProxy:
|
||||||
|
"""#105: parametri della ricetta DXF gia' salvata (proxy verso il backend)."""
|
||||||
|
URL = "/maker/api/vision/tasks/7/recipe/dxf-compare/source"
|
||||||
|
OK = {"band_mm": 0.3, "min_points_pct": 95.0, "polarity": "dark", "px_per_mm": 71.12,
|
||||||
|
"dxf": "disegno.dxf", "image": "riferimento.bmp"}
|
||||||
|
|
||||||
|
def test_forwards_body(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = self.OK
|
||||||
|
resp = logged_in_client.get(self.URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == self.OK
|
||||||
|
assert mock_api_client.get.call_args.args[0] == "/api/vision/tasks/7/recipe/dxf-compare/source"
|
||||||
|
|
||||||
|
def test_404_propagates(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = {
|
||||||
|
"error": True, "status_code": 404, "detail": "nessuna ricetta da DXF su questo task"}
|
||||||
|
resp = logged_in_client.get(self.URL)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.get_json()["detail"] == "nessuna ricetta da DXF su questo task"
|
||||||
|
|
||||||
|
def test_requires_maker_role(self, logged_in_client, mock_api_client):
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
|
||||||
|
assert logged_in_client.get(self.URL).status_code == 403
|
||||||
|
mock_api_client.get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecipeFileProxy:
|
||||||
|
"""#105: file della cartella ricetta (DXF, immagine di riferimento) passati come byte."""
|
||||||
|
URL = "/maker/api/vision/tasks/7/recipe/files/riferimento.bmp"
|
||||||
|
BMP = b"BM 1 fake bmp"
|
||||||
|
|
||||||
|
def _fake_file(self, status=200, content=BMP, ctype="image/bmp"):
|
||||||
|
resp = MagicMock(status_code=status, content=content)
|
||||||
|
resp.headers = {"content-type": ctype}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def test_bytes_and_content_type_pass_through(self, logged_in_client, mock_api_client):
|
||||||
|
with patch("blueprints.maker.http_requests.get", return_value=self._fake_file()) as get:
|
||||||
|
resp = logged_in_client.get(self.URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.data == self.BMP
|
||||||
|
assert resp.mimetype == "image/bmp"
|
||||||
|
args, kwargs = get.call_args
|
||||||
|
assert args[0].endswith("/api/vision/tasks/7/recipe/files/riferimento.bmp")
|
||||||
|
assert kwargs["headers"]["X-API-Key"] == "test-api-key-12345"
|
||||||
|
|
||||||
|
def test_backend_404_propagates(self, logged_in_client, mock_api_client):
|
||||||
|
body = b'{"detail":"file non trovato"}'
|
||||||
|
with patch("blueprints.maker.http_requests.get",
|
||||||
|
return_value=self._fake_file(404, body, "application/json")):
|
||||||
|
resp = logged_in_client.get(self.URL)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.get_json()["detail"] == "file non trovato"
|
||||||
|
|
||||||
|
def test_unsafe_path_is_404_without_calling_backend(self, logged_in_client, mock_api_client):
|
||||||
|
with patch("blueprints.maker.http_requests.get") as get:
|
||||||
|
for path in ("a/../../x", "..%5Cx", "%2e%2e/x"):
|
||||||
|
assert logged_in_client.get(f"/maker/api/vision/tasks/7/recipe/files/{path}").status_code == 404
|
||||||
|
get.assert_not_called()
|
||||||
|
|
||||||
|
def test_service_down_is_502(self, logged_in_client, mock_api_client):
|
||||||
|
with patch("blueprints.maker.http_requests.get", side_effect=requests.ConnectionError("giu")):
|
||||||
|
assert logged_in_client.get(self.URL).status_code == 502
|
||||||
|
|
||||||
|
def test_requires_maker_role(self, logged_in_client, mock_api_client):
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
|
||||||
|
with patch("blueprints.maker.http_requests.get") as get:
|
||||||
|
assert logged_in_client.get(self.URL).status_code == 403
|
||||||
|
get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPages:
|
||||||
|
def test_stations_page_has_calibration_cell(self, logged_in_client):
|
||||||
|
with patch("blueprints.admin.api_client") as admin_api:
|
||||||
|
admin_api.get.side_effect = api_get_router({"/api/stations": [STATION], "/api/recipes": []})
|
||||||
|
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||||
|
assert "js/station_calibration.js" in html
|
||||||
|
assert "stationCalibrationCell(" in html
|
||||||
|
# niente piu' pannello di calibrazione nella pagina: solo il link all'acquisitore
|
||||||
|
assert "calibration-panel" not in html
|
||||||
|
|
||||||
|
def _editor(self, client, mock_api_client, task_type):
|
||||||
|
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,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
return client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
||||||
|
|
||||||
|
def test_editor_has_dxf_wizard(self, logged_in_client, mock_api_client):
|
||||||
|
html = self._editor(logged_in_client, mock_api_client, "dxf_compare")
|
||||||
|
assert "js/dxf_recipe_wizard.js" in html
|
||||||
|
assert "dxfRecipeWizard(" in html
|
||||||
|
assert "@dxf-recipe-saved" in html
|
||||||
|
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano
|
||||||
|
assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF
|
||||||
|
assert "imageSource === 'file'" in html # #104: mm/px solo da file
|
||||||
|
assert "imagePreviewUrl" in html and "imagePreviewSummary" in html # #102: anteprima immagine
|
||||||
|
# #122: le tre viste (immagine, DXF, Prova) sono <tmf-viewer>; zoom, Adatta, pan e Salva stanno nel componente
|
||||||
|
assert html.count("<tmf-viewer") == 3 and "js/image_viewer.js" in html
|
||||||
|
assert '@viewer-load="onImagePreviewLoad($event.detail)"' in html # #122: dimensioni per il riepilogo
|
||||||
|
assert "fit-height" in html and 'save-name="prova"' in html # #122: la Prova segue l'immagine
|
||||||
|
for old in ('x-ref="vpImg"', 'x-ref="vpDxf"', "vZoomBy(", "vFit(", "vPanDown(", "onPanDown(", "zoomBy(", 'x-ref="viewport"'):
|
||||||
|
assert old not in html, old # #122: zoom a mano tolto dal wizard
|
||||||
|
assert "existing ? msgs.editLabel : msgs.createLabel" in html and "Modifica ricetta da DXF" in html # #105: etichetta dinamica
|
||||||
|
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Runs the plain-node DXF wizard zoom test (tests/js/test_dxf_zoom.js).
|
||||||
|
|
||||||
|
Same thin pytest wrapper pattern as test_dxf_preview_js.py: shells out to
|
||||||
|
``node`` and asserts the script's own exit code. Skips (does not fail) when
|
||||||
|
node is not installed.
|
||||||
|
|
||||||
|
Covers gitea:Adriano/TieMeasureFlow#113.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
JS_TEST = Path(__file__).resolve().parent / "js" / "test_dxf_zoom.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_zoom_js_behaviour():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS dello zoom del wizard saltato")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(JS_TEST)],
|
||||||
|
cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"tests/js/test_dxf_zoom.js fallito (exit {result.returncode})\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Runs the plain-node image_to_png.js test (tests/js/test_image_to_png.js).
|
||||||
|
|
||||||
|
Covers gitea:Adriano/TieMeasureFlow#107.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
JS_TEST = Path(__file__).resolve().parent / "js" / "test_image_to_png.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_to_png_js_pure_behaviour():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS di image_to_png.js saltato")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(JS_TEST)],
|
||||||
|
cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"tests/js/test_image_to_png.js fallito (exit {result.returncode})\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Runs the plain-node image viewer (TmfViewerMath) test (tests/js/test_image_viewer.js).
|
||||||
|
|
||||||
|
Same thin pytest wrapper pattern as test_vision_overlay_js.py: shells out to
|
||||||
|
``node`` and asserts the script's own exit code. Skips (does not fail) when
|
||||||
|
node is not installed.
|
||||||
|
|
||||||
|
Covers gitea:Adriano/TieMeasureFlow#122.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
JS_TEST = Path(__file__).resolve().parent / "js" / "test_image_viewer.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_viewer_js_behaviour():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS del visualizzatore immagine saltato")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(JS_TEST)],
|
||||||
|
cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"tests/js/test_image_viewer.js fallito (exit {result.returncode})\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
@@ -313,6 +313,10 @@ class TestImageCropInclusion:
|
|||||||
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
||||||
assert "js/image-crop.js" in html
|
assert "js/image-crop.js" in html
|
||||||
assert "tmfCropBeforeUpload(file)" in html
|
assert "tmfCropBeforeUpload(file)" in html
|
||||||
|
# #107: conversione BMP->PNG e errore upload nel riquadro Disegno Tecnico
|
||||||
|
assert "js/image_to_png.js" in html
|
||||||
|
assert "tmfImageToPng(file)" in html
|
||||||
|
assert "uploadErrorTask" in html
|
||||||
|
|
||||||
def test_recipe_editor_includes_crop(self, logged_in_client, mock_api_client):
|
def test_recipe_editor_includes_crop(self, logged_in_client, mock_api_client):
|
||||||
mock_api_client.get.side_effect = [
|
mock_api_client.get.side_effect = [
|
||||||
@@ -322,3 +326,6 @@ class TestImageCropInclusion:
|
|||||||
html = logged_in_client.get("/maker/recipes/1/edit").get_data(as_text=True)
|
html = logged_in_client.get("/maker/recipes/1/edit").get_data(as_text=True)
|
||||||
assert "js/image-crop.js" in html
|
assert "js/image-crop.js" in html
|
||||||
assert "tmfCropBeforeUpload(file)" in html
|
assert "tmfCropBeforeUpload(file)" in html
|
||||||
|
# #107: conversione BMP->PNG prima dei controlli di formato
|
||||||
|
assert "js/image_to_png.js" in html
|
||||||
|
assert "tmfImageToPng(file)" in html
|
||||||
|
|||||||
@@ -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'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
|
||||||
@@ -68,7 +68,7 @@ def test_grab_passes_png_through(logged_in_client, mock_api_client, monkeypatch)
|
|||||||
assert kwargs["json"] == {
|
assert kwargs["json"] == {
|
||||||
"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000,
|
"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000,
|
||||||
}
|
}
|
||||||
assert kwargs["timeout"] == 15
|
assert kwargs["timeout"] == 60 # #106
|
||||||
|
|
||||||
|
|
||||||
def test_grab_service_503_is_502(logged_in_client, mock_api_client, monkeypatch):
|
def test_grab_service_503_is_502(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
|||||||
@@ -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,35 @@
|
|||||||
|
"""Runs the plain-node save-image helper test (tests/js/test_save_image.js).
|
||||||
|
|
||||||
|
Same thin pytest wrapper pattern as test_vision_overlay_js.py: shells out to
|
||||||
|
``node`` and asserts the script's own exit code. Skips (does not fail) when
|
||||||
|
node is not installed.
|
||||||
|
|
||||||
|
Covers gitea:Adriano/TieMeasureFlow#119.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
JS_TEST = Path(__file__).resolve().parent / "js" / "test_save_image.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_image_js_behaviour():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS del salvataggio immagine saltato")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(JS_TEST)],
|
||||||
|
cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"tests/js/test_save_image.js fallito (exit {result.returncode})\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
@@ -167,3 +167,11 @@ class TestRunTaskResultsProxy:
|
|||||||
assert 'id="run-task-results"' in html
|
assert 'id="run-task-results"' in html
|
||||||
assert "loadTaskResults" in html
|
assert "loadTaskResults" in html
|
||||||
assert "/statistics/api/runs/" in html
|
assert "/statistics/api/runs/" in html
|
||||||
|
|
||||||
|
def test_dashboard_all_points_overview(self, logged_in_client, mock_api_client):
|
||||||
|
# #100: con «Tutti i punti» la pagina ha tabella per-punto e select che ricarica da solo
|
||||||
|
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
|
||||||
|
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
|
||||||
|
assert "capabilityAll" in html
|
||||||
|
assert "loadedSubtaskId" in html
|
||||||
|
assert '@change="loadData()"' in html
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""#118: il riepilogo task mostra NaN per una quota non misurata (value None)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _station(monkeypatch):
|
||||||
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_complete_renders_nan_for_null_value(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.side_effect = [
|
||||||
|
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
||||||
|
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
|
||||||
|
{"id": 1, "marker_number": 1, "description": "quota mancante", "nominal": 1.0,
|
||||||
|
"unit": "mm"},
|
||||||
|
{"id": 2, "marker_number": 2, "description": "quota ok", "nominal": 2.0,
|
||||||
|
"unit": "mm"}]}],
|
||||||
|
None,
|
||||||
|
{"items": [
|
||||||
|
{"id": 1, "subtask_id": 1, "value": None, "deviation": None, "pass_fail": "fail",
|
||||||
|
"measured_at": "2026-10-06T08:00:00+00:00"},
|
||||||
|
{"id": 2, "subtask_id": 2, "value": 2.5, "pass_fail": "pass",
|
||||||
|
"measured_at": "2026-10-06T08:00:00+00:00"},
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
assert "NaN" in html
|
||||||
|
assert "2.500" in html # la quota misurata resta formattata
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""gitea:Adriano/TieMeasureFlow#118 #119 #120 #121 - pannello visione dell'operatore (task_execute.html).
|
||||||
|
|
||||||
|
Controlli sul testo del template: barra in alto con/senza camera, pulsante Salva,
|
||||||
|
conteggio quote e «Completato» dopo «Registra», cella NaN, svg al posto del canvas.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TEMPLATE = Path(__file__).resolve().parents[1] / "templates" / "measure" / "task_execute.html"
|
||||||
|
SRC = TEMPLATE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _panel():
|
||||||
|
start = SRC.rindex("<div", 0, SRC.index('x-data="visionExecute()"'))
|
||||||
|
return SRC[start:SRC.index("MEASUREMENT TIMER BANNER")]
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(panel):
|
||||||
|
# barra in alto: dall'apertura fino al blocco di errore
|
||||||
|
return panel[:panel.index('<div x-show="error"')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_input_only_without_camera():
|
||||||
|
# #119: l'input file «Acquisisci immagine/Riprova» sta nel ramo `not camera_enabled`
|
||||||
|
bar = _bar(_panel())
|
||||||
|
no_cam = bar[bar.index("{% if not camera_enabled %}"):bar.index("{% else %}")]
|
||||||
|
assert 'type="file"' in no_cam
|
||||||
|
cam = bar[bar.index("{% else %}"):]
|
||||||
|
assert 'type="file"' not in cam
|
||||||
|
assert bar.count('type="file"') == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_bar_has_grab_and_record_only():
|
||||||
|
# #119: con camera «Acquisisci da camera» (grab) e «Registra»; in_linea resta Pausa/Riprendi
|
||||||
|
cam = _bar(_panel())
|
||||||
|
cam = cam[cam.index("{% else %}"):]
|
||||||
|
assert "{{ _('Acquisisci da camera') }}" in cam and '@click="grab()"' in cam
|
||||||
|
assert '@click="record()"' in cam and "{{ _('Registra') }}" in cam
|
||||||
|
assert "togglePause()" in cam and "{{ _('Pausa') }}" in cam and "{{ _('Riprendi') }}" in cam
|
||||||
|
assert "onPick(" not in cam
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_button_is_tmf_viewer_with_texts():
|
||||||
|
# #119 #122: «Salva» e' del componente <tmf-viewer> (barra: Riduci, Ingrandisci, Adatta, Salva, %); il template gli passa nome e testi
|
||||||
|
panel = _panel()
|
||||||
|
assert "<tmf-viewer" in panel and ':src="shownImage"' in panel and ':save-name="saveName"' in panel
|
||||||
|
for t in ("Adatta", "Salva", "Ingrandisci", "Riduci", "Salva immagine"):
|
||||||
|
assert "{{ _('%s') }}" % t in panel, t
|
||||||
|
assert "saveImage()" not in panel and "zPct()" not in panel
|
||||||
|
assert "taskName:" in SRC
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewer_scripts_order():
|
||||||
|
# #122: image_viewer.js dopo vision_overlay.js e save_image.js (e prima di vision_execute.js non serve: e' un Custom Element)
|
||||||
|
assert SRC.index("js/vision_overlay.js") < SRC.index("js/image_viewer.js")
|
||||||
|
assert SRC.index("js/save_image.js") < SRC.index("js/image_viewer.js")
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_and_labels_passed_to_viewer():
|
||||||
|
# #121 #122: overlay e etichette arrivano al viewer con x-effect (proprieta'), niente canvas
|
||||||
|
panel = _panel()
|
||||||
|
assert "<canvas" not in panel
|
||||||
|
assert "$el.overlay = (result && result.overlay) || []" in panel and "$el.labels = viewerLabels" in panel
|
||||||
|
assert "overlayLabels" not in panel
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_cell_shows_nan_for_null():
|
||||||
|
# #118: la cella passa da fmtValue (null -> NaN), non da Number(r.value).toFixed
|
||||||
|
panel = _panel()
|
||||||
|
assert 'fmtValue(r.value, r.unit)' in panel
|
||||||
|
assert "Number(r.value)" not in panel
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_leave_task_depends_on_vision_recorded():
|
||||||
|
# #120: «Completato» e «Fine ciclo misura» richiedono visionRecorded per i task visione
|
||||||
|
m = re.search(r"get canLeaveTask\(\) \{(.*?)\n \},", SRC, re.S)
|
||||||
|
assert m and "isVisionTask && !this.visionRecorded" in m.group(1)
|
||||||
|
assert m.group(1).index("visionRecorded") < m.group(1).index("cycleConfirmed")
|
||||||
|
assert "visionRecorded: false" in SRC
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_label_says_record_the_piece():
|
||||||
|
# #120: non registrato -> «Registra il pezzo», non «Mancano N quote»
|
||||||
|
m = re.search(r"get missingQuotesLabel\(\) \{(.*?)\n \},", SRC, re.S)
|
||||||
|
assert m and "Registra il pezzo" in m.group(1)
|
||||||
|
assert m.group(1).index("Registra il pezzo") < m.group(1).index("Mancano")
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_listens_to_vision_recorded_and_resets():
|
||||||
|
# #120: la radice ascolta l'evento; resetForNewCycle azzera visionRecorded; il ripristino mette deviation null
|
||||||
|
assert '@vision-recorded.window="onVisionRecorded($event.detail)"' in SRC
|
||||||
|
m = re.search(r"resetForNewCycle\(\) \{(.*?)\n \},", SRC, re.S)
|
||||||
|
assert m and "visionRecorded = false" in m.group(1)
|
||||||
|
m = re.search(r"onVisionRecorded\(detail\) \{(.*?)\n \},", SRC, re.S)
|
||||||
|
assert m and "visionRecorded = true" in m.group(1) and "this.measurements = " in m.group(1)
|
||||||
|
assert "deviation: m.value == null ? null" in SRC
|
||||||
|
assert "if (this.isVisionTask) this.visionRecorded = true" in SRC
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Runs the plain-node vision overlay draw/anchor/labels test (tests/js/test_vision_overlay.js).
|
||||||
|
|
||||||
|
Same thin pytest wrapper pattern as test_vision_rows_js.py: shells out to
|
||||||
|
``node`` and asserts the script's own exit code. Skips (does not fail) when
|
||||||
|
node is not installed.
|
||||||
|
|
||||||
|
Covers gitea:Adriano/TieMeasureFlow#116.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
JS_TEST = Path(__file__).resolve().parent / "js" / "test_vision_overlay.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_overlay_js_behaviour():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS dell'overlay saltato")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(JS_TEST)],
|
||||||
|
cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"tests/js/test_vision_overlay.js fallito (exit {result.returncode})\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""gitea:Adriano/TieMeasureFlow#96 #122 - l'immagine del task visione sta in fit, senza scorrimento.
|
||||||
|
|
||||||
|
Controllo sulle classi del template: pannello overflow-hidden, riquadro flex-1 min-h-0 che contiene
|
||||||
|
<tmf-viewer> a tutta altezza (#122: zoom, img e overlay stanno dentro il componente).
|
||||||
|
"""
|
||||||
|
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_inside_tmf_viewer():
|
||||||
|
# #122: img, svg overlay, barra zoom e pan sono di <tmf-viewer>: il template non ha piu' img/svg/viewport propri
|
||||||
|
panel = _panel()
|
||||||
|
viewer = _tag_with(panel, "tmf-viewer class")
|
||||||
|
assert "block" in viewer and "w-full" in viewer and "h-full" in viewer
|
||||||
|
assert ':src="shownImage"' in viewer
|
||||||
|
assert "x-show" not in viewer # mai x-show e :style stringa sullo stesso elemento: x-show sta sul riquadro
|
||||||
|
assert 'x-ref="img"' not in panel and 'x-ref="overlay"' not in panel and "<canvas" not in panel
|
||||||
|
assert "<img" not in panel and "<svg" not in panel
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_own_zoom_viewport_in_template():
|
||||||
|
# #122: niente piu' viewport/wrapper di zoom del pannello (#115): li fa il componente
|
||||||
|
panel = _panel()
|
||||||
|
assert 'x-ref="vp"' not in panel
|
||||||
|
assert "zSize()" not in panel and "zoomBy(" not in panel and "zFit()" not in panel
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_quote_table_shows_nominal_and_tolerances():
|
||||||
|
# #117: la tabella quote del pannello mostra nominale e tolleranze come la schermata dei calibri
|
||||||
|
panel = _panel()
|
||||||
|
for marker in ("fmtTol(r.nominal)", "fmtTol(r.ltl)", "fmtTol(r.utl)", "fmtTol(r.lwl)", "fmtTol(r.uwl)"):
|
||||||
|
assert marker in panel, marker
|
||||||
|
assert "text-measure-fail" in panel and "text-measure-warning" in panel
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_script_loaded_before_vision_execute_and_viewer():
|
||||||
|
# #116 #122: vision_overlay.js prima di vision_execute.js; le etichette le disegna <tmf-viewer> (viewerLabels via x-effect)
|
||||||
|
src = TEMPLATE.read_text(encoding="utf-8")
|
||||||
|
assert src.index("js/vision_overlay.js") < src.index("js/vision_execute.js")
|
||||||
|
assert src.index("js/vision_overlay.js") < src.index("js/image_viewer.js")
|
||||||
|
panel = _panel()
|
||||||
|
assert "$el.labels = viewerLabels" in panel
|
||||||
|
assert 'x-for="l in overlayLabels"' not in panel
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Runs the plain-node vision panel quote rows label test (tests/js/test_vision_rows.js).
|
||||||
|
|
||||||
|
Same thin pytest wrapper pattern as test_dxf_zoom_js.py: shells out to
|
||||||
|
``node`` and asserts the script's own exit code. Skips (does not fail) when
|
||||||
|
node is not installed.
|
||||||
|
|
||||||
|
Covers gitea:Adriano/TieMeasureFlow#114.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
JS_TEST = Path(__file__).resolve().parent / "js" / "test_vision_rows.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_rows_js_behaviour():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS delle etichette quote saltato")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(JS_TEST)],
|
||||||
|
cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"tests/js/test_vision_rows.js fallito (exit {result.returncode})\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Runs the plain-node vision panel zoom test (tests/js/test_vision_zoom.js).
|
||||||
|
|
||||||
|
Same thin pytest wrapper pattern as test_vision_rows_js.py: shells out to
|
||||||
|
``node`` and asserts the script's own exit code. Skips (does not fail) when
|
||||||
|
node is not installed.
|
||||||
|
|
||||||
|
Covers gitea:Adriano/TieMeasureFlow#115 #122 (dal #122 il test JS verifica che lo zoom non sia piu' nel componente).
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
JS_TEST = Path(__file__).resolve().parent / "js" / "test_vision_zoom.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_zoom_js_behaviour():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS dello zoom del pannello saltato")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(JS_TEST)],
|
||||||
|
cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"tests/js/test_vision_zoom.js fallito (exit {result.returncode})\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user